Per-file history: ?path= on the log, --path on repo log !54

merged merged by cmc on 2026-08-25 19:26 UTC · krz/gitbay:file-history into main

6 files changed, +76 −12

Layout: unified · split

e2e/design_test.go +20
@@ -59,6 +59,26 @@ func TestReadmeRelativeLinks(t *testing.T) {
59 if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">all refs") { 59 if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">all refs") {
60 t.Error("branch dropdown missing") 60 t.Error("branch dropdown missing")
61 } 61 }
62 // Per-file history: ?path= filters the log; blob pages link to it.
63 os.WriteFile(filepath.Join(dir, "docs", "notes.txt"), []byte("n\n"), 0o644)
64 mustGit(t, dir, env, "add", ".")
65 mustGit(t, dir, env, "commit", "-q", "-m", "touch only the notes")
66 mustGit(t, dir, env, "push", "-q", "origin", "main")
67 if _, body := inst.get(t, "/alice/site/log?path=docs/notes.txt"); !strings.Contains(body, "touch only the notes") ||
68 strings.Contains(body, ">base<") || !strings.Contains(body, "history of") {
69 t.Fatalf("per-file log wrong:\n%s", body)
70 }
71 if _, body := inst.get(t, "/alice/site/log?path=no/such/file"); !strings.Contains(body, "nothing touches") {
72 t.Fatalf("empty per-file log:\n%s", body)
73 }
74 if _, body := inst.get(t, "/alice/site/blob/main/docs/guide.md"); !strings.Contains(body, `log/main?path=docs%2fguide.md">history</a>`) {
75 t.Fatalf("blob history link missing:\n%s", body)
76 }
77 // CLI parity: repo log --path.
78 logOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/site", "--path", "docs/notes.txt", "--json")
79 if !strings.Contains(logOut, "touch only the notes") || strings.Contains(logOut, `"subject":"base"`) {
80 t.Fatalf("repo log --path wrong:\n%s", logOut)
81 }
62 // Raw serves images with their real type (nosniff otherwise blocks 82 // Raw serves images with their real type (nosniff otherwise blocks
63 // <img>); everything else stays inert text/plain. 83 // <img>); everything else stays inert text/plain.
64 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/raw/main/img/logo.png", inst.httpPort)) 84 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/raw/main/img/logo.png", inst.httpPort))
internal/control/sig.go +17 −5
@@ -23,7 +23,7 @@ func init() {
23 register(Command{Path: []string{"pgp", "remove"}, 23 register(Command{Path: []string{"pgp", "remove"},
24 Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove}) 24 Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove})
25 register(Command{Path: []string{"repo", "log"}, 25 register(Command{Path: []string{"repo", "log"},
26 Summary: "commit log with signature states: repo log <owner/name> [--limit n]", ReadOnly: true, Run: runRepoLog}) 26 Summary: "commit log with signature states: repo log <owner/name> [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog})
27} 27}
28 28
29func runPGPAdd(c *Ctx, args []string) int { 29func runPGPAdd(c *Ctx, args []string) int {
@@ -119,7 +119,7 @@ func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sh
119 119
120func runRepoLog(c *Ctx, args []string) int { 120func runRepoLog(c *Ctx, args []string) int {
121 limit := 30 121 limit := 30
122 var path string 122 var path, filePath string
123 for i := 0; i < len(args); i++ { 123 for i := 0; i < len(args); i++ {
124 switch args[i] { 124 switch args[i] {
125 case "--limit": 125 case "--limit":
@@ -132,22 +132,34 @@ func runRepoLog(c *Ctx, args []string) int {
132 } 132 }
133 limit = n 133 limit = n
134 i++ 134 i++
135 case "--path":
136 if i+1 >= len(args) {
137 return c.fail(protocol.ExitUsage, "--path requires a value")
138 }
139 filePath = args[i+1]
140 i++
135 default: 141 default:
136 if path != "" { 142 if path != "" {
137 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n]") 143 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n] [--path <file>]")
138 } 144 }
139 path = args[i] 145 path = args[i]
140 } 146 }
141 } 147 }
142 if path == "" { 148 if path == "" {
143 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n]") 149 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n] [--path <file>]")
144 } 150 }
145 repo, code := resolveRepo(c, path, policy.CanRead) 151 repo, code := resolveRepo(c, path, policy.CanRead)
146 if code >= 0 { 152 if code >= 0 {
147 return code 153 return code
148 } 154 }
149 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) 155 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
150 shas, err := gitutil.RevList(dir, repo.DefaultBranch, limit) 156 var shas []string
157 var err error
158 if filePath != "" {
159 shas, err = gitutil.RevListPath(dir, repo.DefaultBranch, filePath, limit)
160 } else {
161 shas, err = gitutil.RevList(dir, repo.DefaultBranch, limit)
162 }
151 if err != nil { 163 if err != nil {
152 return c.fail(protocol.ExitFailure, "reading log: %v", err) 164 return c.fail(protocol.ExitFailure, "reading log: %v", err)
153 } 165 }
internal/gitutil/gitutil.go +19
@@ -100,6 +100,25 @@ func RevList(dir, ref string, limit int) ([]string, error) {
100 return shas, nil 100 return shas, nil
101} 101}
102 102
103// RevListPath returns up to limit commit SHAs reachable from ref that
104// touch filePath, newest first. The "--" keeps the path from ever being
105// read as an option or ref.
106func RevListPath(dir, ref, filePath string, limit int) ([]string, error) {
107 cmd := exec.Command("git", "-C", dir, "rev-list",
108 fmt.Sprintf("--max-count=%d", limit), ref, "--", filePath)
109 out, err := cmd.Output()
110 if err != nil {
111 return nil, fmt.Errorf("rev-list %s -- %s: %w", ref, filePath, err)
112 }
113 var shas []string
114 for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
115 if l != "" {
116 shas = append(shas, l)
117 }
118 }
119 return shas, nil
120}
121
103// PeelToCommit resolves a ref or object to its commit — annotated tags 122// PeelToCommit resolves a ref or object to its commit — annotated tags
104// peel to the commit they point at. 123// peel to the commit they point at.
105func PeelToCommit(dir, ref string) (string, error) { 124func PeelToCommit(dir, ref string) (string, error) {
internal/httpd/web.go +16 −4
@@ -1076,7 +1076,18 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1076 } 1076 }
1077 p.Tab = "log" 1077 p.Tab = "log"
1078 const pageSize = 50 1078 const pageSize = 50
1079 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1) 1079 // ?path= filters to commits touching one file or directory.
1080 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1081 if filePath == "." {
1082 filePath = ""
1083 }
1084 var shas []string
1085 var err error
1086 if filePath != "" {
1087 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1088 } else {
1089 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1090 }
1080 if err != nil { 1091 if err != nil {
1081 s.notFound(w, r) 1092 s.notFound(w, r)
1082 return 1093 return
@@ -1104,9 +1115,10 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1104 } 1115 }
1105 s.render(w, "log.html", struct { 1116 s.render(w, "log.html", struct {
1106 repoPage 1117 repoPage
1107 Commits []row 1118 Commits []row
1108 NextSHA string 1119 NextSHA string
1109 }{p, rows, next}) 1120 FilePath string
1121 }{p, rows, next, filePath})
1110} 1122}
1111 1123
1112func (s *Server) commit(w http.ResponseWriter, r *http.Request) { 1124func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
internal/web/templates/blob.html +1 −1
@@ -5,7 +5,7 @@
5 {{template "refmenu" .}} 5 {{template "refmenu" .}}
6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span> 6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
7 <span class="spacer"></span> 7 <span class="spacer"></span>
8 <span class="actions">{{if not .Binary}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blame/{{.Ref}}/{{.Path}}">blame</a> · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span> 8 <span class="actions"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}?path={{.Path}}">history</a> · {{if not .Binary}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blame/{{.Ref}}/{{.Path}}">blame</a> · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span>
9</div> 9</div>
10{{if .Image}}<div class="blobimage"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}"><img src="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}" alt="{{.Base}}"></a><p class="meta">{{.Size}} bytes</p></div> 10{{if .Image}}<div class="blobimage"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}"><img src="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}" alt="{{.Base}}"></a><p class="meta">{{.Size}} bytes</p></div>
11{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> 11{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
internal/web/templates/log.html +3 −2
@@ -1,6 +1,7 @@
1{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3{{template "repoheader" .}}
4{{if .FilePath}}<p class="meta">history of <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.FilePath}}"><code>{{.FilePath}}</code></a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">full log</a></p>{{end}}
4<ul class="loglist"> 5<ul class="loglist">
5{{range .Commits}}<li> 6{{range .Commits}}<li>
6 <div class="commitmain"> 7 <div class="commitmain">
@@ -12,7 +13,7 @@
12 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> 13 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code>
13 </div> 14 </div>
14</li> 15</li>
15{{else}}<li class="empty">no commits on {{.Ref}} yet</li>{{end}} 16{{else}}<li class="empty">{{if .FilePath}}nothing touches <code>{{.FilePath}}</code> on {{.Ref}}{{else}}no commits on {{.Ref}} yet{{end}}</li>{{end}}
16</ul> 17</ul>
17{{if .NextSHA}}<p class="pager"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.NextSHA}}">older →</a></p>{{end}} 18{{if .NextSHA}}<p class="pager"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.NextSHA}}{{if .FilePath}}?path={{.FilePath}}{{end}}">older →</a></p>{{end}}
18{{end}} 19{{end}}