CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb942b5..72ce6a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version.
+## 1.2.18
+
+- Branch dropdown on the repo page: the branch name button now opens a list of every branch (a native `<details>` disclosure, no client JS needed), and picking one browses that branch's file tree instead of the default. New `ListRepoTreeAtRef`/`GetRepoFileAtRef` on `admin.Ops`, threaded through `adminrpc`. `?branch=` is validated against the repo's real branch list server-side — an unknown value 404s instead of silently falling back, same treatment as an unknown path. The README/license preview stays tied to the default branch on purpose (no ref-aware variant of those two) rather than risk showing content from the wrong branch.
+
## 1.2.17
- Heuristic license-type detection: the repo page now shows "MIT (LICENSE)", "Apache-2.0 (LICENSE)", etc. instead of a generic "License (LICENSE)", by matching a handful of distinctive strings per common license (MIT, Apache-2.0, GPL-2/3, LGPL, AGPL, MPL-2.0, BSD-2/3-Clause, ISC, Unlicense, CC0-1.0) — not a full SPDX/licensee-style matcher, falls back to the generic label for anything unrecognized.
ROADMAP.md
diff --git a/ROADMAP.md b/ROADMAP.md
index a60372b..644b6d7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -70,10 +70,6 @@ celui-là la prochaine fois qu'on rouvre ce document.
### Interface du dépôt
-- **Dropdown de branches** sur la page repo pour parcourir l'arborescence
- d'une autre branche que celle par défaut — aujourd'hui le nom de
- branche n'est qu'un texte statique, aucune navigation possible.
- *(effort moyen)*.
- **Pagination de la liste des commits** — plafonnée en dur à 200
aujourd'hui (`maxCommitsShown`), rien de visible au-delà. *(effort
moyen)*.
cmd/gitfed-web/branch_options_test.go
diff --git a/cmd/gitfed-web/branch_options_test.go b/cmd/gitfed-web/branch_options_test.go
new file mode 100644
index 0000000..0ab3b7d
--- /dev/null
+++ b/cmd/gitfed-web/branch_options_test.go
@@ -0,0 +1,47 @@
+package main
+
+import "testing"
+
+func TestBranchOptions(t *testing.T) {
+ got := branchOptions("alice/demo", []string{"main", "feature/x"}, "cmd/tool.go", "feature/x")
+ if len(got) != 2 {
+ t.Fatalf("expected 2 options, got %d: %+v", len(got), got)
+ }
+
+ main, feature := got[0], got[1]
+ if main.Name != "main" || main.Active {
+ t.Errorf("main option = %+v, want Name=main Active=false", main)
+ }
+ if feature.Name != "feature/x" || !feature.Active {
+ t.Errorf("feature option = %+v, want Name=feature/x Active=true", feature)
+ }
+
+ wantMainURL := "/r/alice%2Fdemo?branch=main&path=cmd%2Ftool.go"
+ if main.URL != wantMainURL {
+ t.Errorf("main.URL = %q, want %q", main.URL, wantMainURL)
+ }
+ wantFeatureURL := "/r/alice%2Fdemo?branch=feature%2Fx&path=cmd%2Ftool.go"
+ if feature.URL != wantFeatureURL {
+ t.Errorf("feature.URL = %q, want %q", feature.URL, wantFeatureURL)
+ }
+}
+
+func TestBranchOptionsNoPath(t *testing.T) {
+ got := branchOptions("demo", []string{"main"}, "", "main")
+ if len(got) != 1 {
+ t.Fatalf("expected 1 option, got %d", len(got))
+ }
+ if got[0].URL != "/r/demo?branch=main" {
+ t.Errorf("URL = %q, want %q (no &path= when path is empty)", got[0].URL, "/r/demo?branch=main")
+ }
+}
+
+func TestContainsBranch(t *testing.T) {
+ branches := []string{"main", "feature/x"}
+ if !containsBranch(branches, "main") {
+ t.Error("expected containsBranch to find an existing branch")
+ }
+ if containsBranch(branches, "nonexistent") {
+ t.Error("expected containsBranch to reject an unknown branch")
+ }
+}
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go
index dede1a0..e4dbe2c 100644
--- a/cmd/gitfed-web/handlers_repo.go
+++ b/cmd/gitfed-web/handlers_repo.go
@@ -84,6 +84,28 @@ type treeEntryView struct {
FullPath string
}
+type branchOptionView struct {
+ Name string
+ URL string
+ Active bool
+}
+
+// branchOptions builds the repo page's branch dropdown entries — each
+// links back to the same repo/path with ?branch= set, so switching branch
+// while browsing a subdirectory tries to keep you at the same relative
+// path (a 404 if it doesn't exist there, same as any other bad path).
+func branchOptions(name string, branches []string, path, active string) []branchOptionView {
+ views := make([]branchOptionView, len(branches))
+ for i, b := range branches {
+ u := "/r/" + url.PathEscape(name) + "?branch=" + url.QueryEscape(b)
+ if path != "" {
+ u += "&path=" + url.QueryEscape(path)
+ }
+ views[i] = branchOptionView{Name: b, URL: u, Active: b == active}
+ }
+ return views
+}
+
// collaboratorView adds display-only context to a store.Collaborator: only
// the settings page needs to distinguish local from federated principals,
// so it's computed there rather than carried on the ACL model itself.
@@ -107,7 +129,14 @@ var repoTpl = newTpl("repo", `
<p class="muted">{{t .Lang "repo.owner"}}: {{if localUser .Repo.Owner .Domain}}<a href="/u/{{localUser .Repo.Owner .Domain}}">{{.Repo.Owner}}</a>{{else}}{{.Repo.Owner}}{{end}}</p>
<div class="gf-actions-row">
- {{if .Branch}}<span class="gf-btn">{{icon "branch"}} {{.Branch}}</span>{{end}}
+ {{if .Branch}}
+ <details class="gf-branch-dropdown">
+ <summary class="gf-btn">{{icon "branch"}} {{.Branch}}</summary>
+ <div class="gf-branch-menu">
+ {{range .Branches}}<a href="{{.URL}}"{{if .Active}} class="active"{{end}}>{{.Name}}</a>{{end}}
+ </div>
+ </details>
+ {{end}}
{{if not .Empty}}<a href="/repo-commits/{{.Repo.Name}}" class="gf-btn">{{icon "history"}} {{t .Lang "repo.commits_title"}}{{if .CommitCount}} ({{.CommitCount}}){{end}}</a>{{end}}
{{if not .Empty}}<a href="/repo-mrs/{{.Repo.Name}}" class="gf-btn">{{icon "branch"}} {{t .Lang "mr.list_title"}}</a>{{end}}
{{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">{{t .Lang "nav.settings"}}</a>{{end}}
@@ -141,7 +170,7 @@ var repoTpl = newTpl("repo", `
{{if .Crumbs}}
<div class="gf-crumbs">
- <a href="/r/{{.Repo.Name}}">{{.Repo.Name}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo.Name}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}}
+ <a href="/r/{{.Repo.Name}}?branch={{.Branch}}">{{.Repo.Name}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo.Name}}?path={{.Path}}&branch={{$.Branch}}">{{.Name}}</a>{{end}}{{end}}
</div>
{{end}}
@@ -153,12 +182,12 @@ var repoTpl = newTpl("repo", `
{{else}}
<div class="gf-card">
<table class="gf-file-table">
- {{if .ShowUp}}<tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{.Repo.Name}}?path={{.ParentPath}}">..</a></td><td class="meta"></td></tr>{{end}}
+ {{if .ShowUp}}<tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{.Repo.Name}}?path={{.ParentPath}}&branch={{.Branch}}">..</a></td><td class="meta"></td></tr>{{end}}
{{range .Entries}}
{{if eq .Type "tree"}}
- <tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.directory"}}</td></tr>
+ <tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{$.Repo.Name}}?path={{.FullPath}}&branch={{$.Branch}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.directory"}}</td></tr>
{{else}}
- <tr><td class="icon">{{icon (fileIcon .Name)}}</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.file"}}</td></tr>
+ <tr><td class="icon">{{icon (fileIcon .Name)}}</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}&branch={{$.Branch}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.file"}}</td></tr>
{{end}}
{{else}}
{{if not .ShowUp}}<tr><td colspan="3" class="muted" style="padding:0.9rem;">{{t .Lang "repo.nothing_here"}}</td></tr>{{end}}
@@ -198,7 +227,40 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
}
_, canAdminister := s.canAdminister(r, name)
- entries, found, err := s.ops.ListRepoTree(name, path)
+ branch, _, err := s.ops.GetRepoBranch(name)
+ if err != nil {
+ s.serverError(w, r, err)
+ return
+ }
+
+ branches, err := s.ops.ListBranches(name)
+ if err != nil {
+ s.serverError(w, r, err)
+ return
+ }
+
+ // A ?branch= naming a real branch switches the tree/blob views to it;
+ // anything else (including a stale link to a since-deleted branch) is
+ // a 404, same treatment as an unknown path — silently falling back to
+ // the default branch would look like it worked while quietly showing
+ // the wrong content.
+ onDefaultBranch := true
+ if requested := r.URL.Query().Get("branch"); requested != "" {
+ if !containsBranch(branches, requested) {
+ http.NotFound(w, r)
+ return
+ }
+ onDefaultBranch = requested == branch
+ branch = requested
+ }
+
+ var entries []gitexec.TreeEntry
+ var found bool
+ if onDefaultBranch {
+ entries, found, err = s.ops.ListRepoTree(name, path)
+ } else {
+ entries, found, err = s.ops.ListRepoTreeAtRef(name, branch, path)
+ }
if err != nil {
s.serverError(w, r, err)
return
@@ -220,12 +282,6 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
views[i] = treeEntryView{TreeEntry: e, FullPath: full}
}
- branch, _, err := s.ops.GetRepoBranch(name)
- if err != nil {
- s.serverError(w, r, err)
- return
- }
-
// Decorative (shown as a "(N)" suffix on the Commits button) — a
// failure here shouldn't take down the whole repo page, so it's
// deliberately not treated the same as the errors above.
@@ -234,7 +290,12 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
var readmeHTML template.HTML
var licenseFile, licenseType string
var tags []string
- if path == "" {
+ if path == "" && onDefaultBranch {
+ // README/license previews deliberately stay tied to the default
+ // branch even while browsing another one's tree — there's no
+ // ref-aware variant of these two, and showing (say) main's README
+ // while the file list below is some other branch's would be more
+ // confusing than just not showing a README at all here.
readmeContent, readmeFound, err := s.ops.GetRepoReadme(name)
if err != nil {
s.serverError(w, r, err)
@@ -272,6 +333,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
CloneURL string
HTTPSCloneURL string
Branch string
+ Branches []branchOptionView
CommitCount int
Lang string
Crumbs []crumb
@@ -286,7 +348,8 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
CanAdminister bool
Flash template.HTML
}{
- repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", "https://" + s.domain + "/" + name + ".git", branch, commitCount, string(lang),
+ repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", "https://" + s.domain + "/" + name + ".git", branch,
+ branchOptions(name, branches, path, branch), commitCount, string(lang),
breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found,
tags, readmeHTML, licenseFile, licenseType, canAdminister, flash(r),
})
@@ -301,7 +364,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
var blobTpl = newTpl("blob", `
{{.Flash}}
<div class="gf-crumbs">
- <a href="/r/{{.Repo}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}}
+ <a href="/r/{{.Repo}}?branch={{.Branch}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo}}?path={{.Path}}&branch={{$.Branch}}">{{.Name}}</a>{{end}}{{end}}
</div>
<div class="gf-card">
<div class="gf-readme-body markdown-body">
@@ -325,7 +388,33 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
return
}
- content, found, err := s.ops.GetRepoFile(name, path)
+ branch, _, err := s.ops.GetRepoBranch(name)
+ if err != nil {
+ s.serverError(w, r, err)
+ return
+ }
+ onDefaultBranch := true
+ if requested := r.URL.Query().Get("branch"); requested != "" {
+ branches, err := s.ops.ListBranches(name)
+ if err != nil {
+ s.serverError(w, r, err)
+ return
+ }
+ if !containsBranch(branches, requested) {
+ http.NotFound(w, r)
+ return
+ }
+ onDefaultBranch = requested == branch
+ branch = requested
+ }
+
+ var content string
+ var found bool
+ if onDefaultBranch {
+ content, found, err = s.ops.GetRepoFile(name, path)
+ } else {
+ content, found, err = s.ops.GetRepoFileAtRef(name, branch, path)
+ }
if err != nil {
s.serverError(w, r, err)
return
@@ -353,10 +442,11 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
_ = blobTpl.Execute(&buf, struct {
Repo string
+ Branch string
Crumbs []crumb
Content template.HTML
Flash template.HTML
- }{name, breadcrumbs(path), rendered, flash(r)})
+ }{name, branch, breadcrumbs(path), rendered, flash(r)})
s.render(w, r, path+" — "+name, "home", template.HTML(buf.String()))
}
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go
index 9639c9e..ebd9bb2 100644
--- a/cmd/gitfed-web/render.go
+++ b/cmd/gitfed-web/render.go
@@ -421,6 +421,21 @@ const shellHeadSrc = `<!doctype html>
.gf-repo-head h1 { font-size: 1.3rem; margin: 0; font-weight: 700; }
.gf-actions-row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin: 1rem 0 0.7rem; }
.gf-actions-row .gf-btn { margin-top: 0; }
+ .gf-branch-dropdown { position: relative; }
+ .gf-branch-dropdown > summary { list-style: none; }
+ .gf-branch-dropdown > summary::-webkit-details-marker { display: none; }
+ .gf-branch-dropdown[open] > summary { background: var(--surface-3); }
+ .gf-branch-menu {
+ position: absolute; top: calc(100% + 6px); left: 0; min-width: 180px; max-height: 280px; overflow-y: auto;
+ background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: 9px; padding: 0.4rem;
+ box-shadow: 0 10px 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; gap: 0.1rem; z-index: 20;
+ }
+ .gf-branch-menu a {
+ display: block; padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 0.84rem; font-family: var(--mono);
+ color: var(--text-dim); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+ .gf-branch-menu a:hover { background: var(--surface-3); color: var(--text); }
+ .gf-branch-menu a.active { color: var(--accent); font-weight: 600; }
.gf-clone-block { background: var(--surface-2); border: 1px solid var(--border); border-radius: 9px; overflow: hidden; margin-bottom: 1.25rem; }
.gf-clone-block .gf-tabs.gf-clone-toggle { display: flex; gap: 0.2rem; padding: 0.3rem; border-bottom: 1px solid var(--border); margin-bottom: 0; }
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go
index 9e2a93a..b6d4665 100644
--- a/internal/admin/admin.go
+++ b/internal/admin/admin.go
@@ -56,7 +56,9 @@ type Ops interface {
GetRepoLicense(name string) (content, filename string, found bool, err error)
ListRepoTags(name string) ([]string, error)
ListRepoTree(name, path string) (entries []gitexec.TreeEntry, found bool, err error)
+ ListRepoTreeAtRef(name, ref, path string) (entries []gitexec.TreeEntry, found bool, err error)
GetRepoFile(name, path string) (content string, found bool, err error)
+ GetRepoFileAtRef(name, ref, path string) (content string, found bool, err error)
GetRepoBranch(name string) (branch string, found bool, err error)
ListCommits(name string, limit int) (commits []gitexec.Commit, found bool, err error)
CountCommits(name string) (count int, found bool, err error)
@@ -415,6 +417,18 @@ func (a *Admin) ListRepoTree(name, path string) ([]gitexec.TreeEntry, bool, erro
return gitexec.ListTree(repo.Path, path)
}
+// ListRepoTreeAtRef is ListRepoTree against an explicit branch (from the
+// repo page's branch dropdown) instead of the repo's default branch. The
+// caller must have already validated ref against ListBranches — this
+// doesn't re-check it (see gitexec.ListTreeAtRef).
+func (a *Admin) ListRepoTreeAtRef(name, ref, path string) ([]gitexec.TreeEntry, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return nil, false, err
+ }
+ return gitexec.ListTreeAtRef(repo.Path, ref, path)
+}
+
// GetRepoFile returns the content of an exact path at HEAD — unlike
// GetRepoReadme/GetRepoLicense, it doesn't try alternate filenames.
func (a *Admin) GetRepoFile(name, path string) (string, bool, error) {
@@ -425,6 +439,17 @@ func (a *Admin) GetRepoFile(name, path string) (string, bool, error) {
return gitexec.ReadFileAtHEAD(repo.Path, path)
}
+// GetRepoFileAtRef is GetRepoFile against an explicit branch instead of the
+// repo's default branch — same caller-must-validate-ref contract as
+// ListRepoTreeAtRef.
+func (a *Admin) GetRepoFileAtRef(name, ref, path string) (string, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return "", false, err
+ }
+ return gitexec.ReadFileAtRef(repo.Path, ref, path)
+}
+
func (a *Admin) GetRepoBranch(name string) (string, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go
index 2d80a51..6aa0b47 100644
--- a/internal/adminrpc/client.go
+++ b/internal/adminrpc/client.go
@@ -202,12 +202,24 @@ func (c *Client) ListRepoTree(name, path string) ([]gitexec.TreeEntry, bool, err
return out.Entries, out.Found, err
}
+func (c *Client) ListRepoTreeAtRef(name, ref, path string) ([]gitexec.TreeEntry, bool, error) {
+ var out listTreeResult
+ err := c.call(methodListRepoTreeAtRef, pathRefArgs{Name: name, Ref: ref, Path: path}, &out)
+ return out.Entries, out.Found, err
+}
+
func (c *Client) GetRepoFile(name, path string) (string, bool, error) {
var out fileResult
err := c.call(methodGetRepoFile, pathArgs{Name: name, Path: path}, &out)
return out.Content, out.Found, err
}
+func (c *Client) GetRepoFileAtRef(name, ref, path string) (string, bool, error) {
+ var out fileResult
+ err := c.call(methodGetRepoFileAtRef, pathRefArgs{Name: name, Ref: ref, Path: path}, &out)
+ return out.Content, out.Found, err
+}
+
func (c *Client) GetRepoBranch(name string) (string, bool, error) {
var out branchResult
err := c.call(methodGetRepoBranch, nameArgs{Name: name}, &out)
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go
index e9e6bce..9c42a72 100644
--- a/internal/adminrpc/protocol.go
+++ b/internal/adminrpc/protocol.go
@@ -37,7 +37,9 @@ const (
methodGetRepoLicense = "GetRepoLicense"
methodListRepoTags = "ListRepoTags"
methodListRepoTree = "ListRepoTree"
+ methodListRepoTreeAtRef = "ListRepoTreeAtRef"
methodGetRepoFile = "GetRepoFile"
+ methodGetRepoFileAtRef = "GetRepoFileAtRef"
methodGetRepoBranch = "GetRepoBranch"
methodGetUser = "GetUser"
methodSetUserAdmin = "SetUserAdmin"
@@ -166,6 +168,12 @@ type pathArgs struct {
Path string `json:"path"`
}
+type pathRefArgs struct {
+ Name string `json:"name"`
+ Ref string `json:"ref"`
+ Path string `json:"path"`
+}
+
type listTreeResult struct {
Entries []gitexec.TreeEntry `json:"entries"`
Found bool `json:"found"`
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go
index b1ed21a..a9fe3dc 100644
--- a/internal/adminrpc/server.go
+++ b/internal/adminrpc/server.go
@@ -220,6 +220,14 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
entries, found, err := s.ops.ListRepoTree(a.Name, a.Path)
return listTreeResult{Entries: entries, Found: found}, err
+ case methodListRepoTreeAtRef:
+ var a pathRefArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ entries, found, err := s.ops.ListRepoTreeAtRef(a.Name, a.Ref, a.Path)
+ return listTreeResult{Entries: entries, Found: found}, err
+
case methodGetRepoFile:
var a pathArgs
if err := json.Unmarshal(req.Args, &a); err != nil {
@@ -228,6 +236,14 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
content, found, err := s.ops.GetRepoFile(a.Name, a.Path)
return fileResult{Content: content, Found: found}, err
+ case methodGetRepoFileAtRef:
+ var a pathRefArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ content, found, err := s.ops.GetRepoFileAtRef(a.Name, a.Ref, a.Path)
+ return fileResult{Content: content, Found: found}, err
+
case methodGetRepoBranch:
var a nameArgs
if err := json.Unmarshal(req.Args, &a); err != nil {
internal/gitexec/gitexec.go
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go
index 89a3814..9b9c731 100644
--- a/internal/gitexec/gitexec.go
+++ b/internal/gitexec/gitexec.go
@@ -233,8 +233,18 @@ func ReadFileAtHEAD(repoPath, filename string) (content string, found bool, err
if !ok {
return "", false, nil
}
+ return ReadFileAtRef(repoPath, ref, filename)
+}
+
+// ReadFileAtRef is ReadFileAtHEAD against an explicit ref (e.g. a non-default
+// branch someone picked from the repo page's branch dropdown) instead of the
+// repo's default branch. The caller is responsible for ref being a real,
+// known ref (e.g. checked against ListBranches) — --end-of-options is only
+// defense in depth against a dash-prefixed value being reinterpreted as a
+// git flag, not a substitute for that check.
+func ReadFileAtRef(repoPath, ref, filename string) (content string, found bool, err error) {
filename = strings.TrimPrefix(filename, "/")
- cmd := exec.Command("git", "--git-dir="+repoPath, "show", ref+":"+filename)
+ cmd := exec.Command("git", "--git-dir="+repoPath, "show", "--end-of-options", ref+":"+filename)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
@@ -274,13 +284,19 @@ func ListTree(repoPath, path string) (entries []TreeEntry, found bool, err error
if !ok {
return nil, false, nil
}
+ return ListTreeAtRef(repoPath, ref, path)
+}
+
+// ListTreeAtRef is ListTree against an explicit ref instead of the repo's
+// default branch — same caller-must-validate-ref contract as ReadFileAtRef.
+func ListTreeAtRef(repoPath, ref, path string) (entries []TreeEntry, found bool, err error) {
path = strings.Trim(path, "/")
treeish := ref
if path != "" {
treeish = ref + ":" + path
}
- cmd := exec.Command("git", "--git-dir="+repoPath, "ls-tree", treeish)
+ cmd := exec.Command("git", "--git-dir="+repoPath, "ls-tree", "--end-of-options", treeish)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
internal/gitexec/gitexec_test.go
diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go
index 1756367..5498311 100644
--- a/internal/gitexec/gitexec_test.go
+++ b/internal/gitexec/gitexec_test.go
@@ -279,6 +279,89 @@ func TestCountCommits(t *testing.T) {
}
}
+// TestListTreeAtRefAndReadFileAtRef reproduces the actual use case: a repo
+// with two branches whose content genuinely differs, confirming
+// ListTreeAtRef/ReadFileAtRef read the requested branch — not just
+// whatever the default branch happens to be (the bug it'd be easy to
+// introduce by accidentally calling resolveDefaultRef internally again).
+func TestListTreeAtRefAndReadFileAtRef(t *testing.T) {
+ tmp := t.TempDir()
+ barePath := filepath.Join(tmp, "repo.git")
+ if err := InitBareRepo(barePath); err != nil {
+ t.Fatalf("init bare repo: %v", err)
+ }
+
+ work := filepath.Join(tmp, "work")
+ if err := os.Mkdir(work, 0755); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "init", "-q", "-b", "main")
+ if err := os.WriteFile(filepath.Join(work, "shared.txt"), []byte("on main\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "add", "shared.txt")
+ run(t, work, "git", "commit", "-q", "-m", "main commit")
+ run(t, work, "git", "remote", "add", "origin", barePath)
+ run(t, work, "git", "push", "-q", "origin", "main")
+
+ run(t, work, "git", "checkout", "-q", "-b", "feature")
+ if err := os.WriteFile(filepath.Join(work, "shared.txt"), []byte("on feature\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(work, "only-on-feature.txt"), []byte("x"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "add", "shared.txt", "only-on-feature.txt")
+ run(t, work, "git", "commit", "-q", "-m", "feature commit")
+ run(t, work, "git", "push", "-q", "origin", "feature")
+
+ // ListTreeAtRef on "feature" sees the file that only exists there.
+ entries, found, err := ListTreeAtRef(barePath, "feature", "")
+ if err != nil {
+ t.Fatalf("ListTreeAtRef(feature): %v", err)
+ }
+ if !found {
+ t.Fatal("expected found=true for feature branch root")
+ }
+ var sawFeatureFile bool
+ for _, e := range entries {
+ if e.Name == "only-on-feature.txt" {
+ sawFeatureFile = true
+ }
+ }
+ if !sawFeatureFile {
+ t.Fatalf("ListTreeAtRef(feature) = %+v, expected only-on-feature.txt", entries)
+ }
+
+ // ListTree (default branch, "main") must NOT see it.
+ entries, _, err = ListTree(barePath, "")
+ if err != nil {
+ t.Fatalf("ListTree: %v", err)
+ }
+ for _, e := range entries {
+ if e.Name == "only-on-feature.txt" {
+ t.Fatalf("ListTree (default branch) unexpectedly saw feature-only file: %+v", entries)
+ }
+ }
+
+ // ReadFileAtRef reads the version of shared.txt specific to each branch.
+ content, found, err := ReadFileAtRef(barePath, "feature", "shared.txt")
+ if err != nil || !found {
+ t.Fatalf("ReadFileAtRef(feature): found=%v err=%v", found, err)
+ }
+ if content != "on feature\n" {
+ t.Fatalf("ReadFileAtRef(feature) = %q, want %q", content, "on feature\n")
+ }
+
+ content, found, err = ReadFileAtRef(barePath, "main", "shared.txt")
+ if err != nil || !found {
+ t.Fatalf("ReadFileAtRef(main): found=%v err=%v", found, err)
+ }
+ if content != "on main\n" {
+ t.Fatalf("ReadFileAtRef(main) = %q, want %q", content, "on main\n")
+ }
+}
+
func TestDefaultBranchNameViaHEAD(t *testing.T) {
tmp := t.TempDir()
barePath := filepath.Join(tmp, "repo.git")