Gitfed
bastien-mrq/gitfed/ Commits/ 14c83b5

Add branch dropdown to the repo page

The branch name button opens a list of every branch (native <details> disclosure, no client JS) and picking one browses that branch's tree instead of the default. New gitexec.ListTreeAtRef/ReadFileAtRef, admin.Ops ListRepoTreeAtRef/GetRepoFileAtRef, threaded through adminrpc. ?branch= is validated against the repo's real branch list server-side — an unknown value 404s rather than silently falling back to the default, same treatment as an unknown path. Also hardened with --end-of-options on the underlying git calls, matching CloneMirror's existing convention, so a branch name starting with "-" can't be reinterpreted as a git flag. README/license previews stay tied to the default branch on purpose (no ref-aware variant of those two) rather than risk showing the wrong branch's content next to a different branch's file list.

bastien-mrq 2026-07-30 12:52 commit 14c83b5cf6bc25dd54f19106120476480ffd916a parent a38ca6fb3c419633654e9a6c95f6a07db1dcafff
11 files changed +335 −23
M CHANGELOG.md +4 −0
M ROADMAP.md +0 −4
A cmd/gitfed-web/branch_options_test.go +47 −0
M cmd/gitfed-web/handlers_repo.go +107 −17
M cmd/gitfed-web/render.go +15 −0
M internal/admin/admin.go +25 −0
M internal/adminrpc/client.go +12 −0
M internal/adminrpc/protocol.go +8 −0
M internal/adminrpc/server.go +16 −0
M internal/gitexec/gitexec.go +18 −2
M internal/gitexec/gitexec_test.go +83 −0
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")