A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 7ddcd8b4a9

7ddcd8b4a9f2f579a1550160d2447746fc2f22c8

parent: c29ec1b885

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-25T19:25:56Z

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

The web log filters to commits touching one file or directory
(rev-list ref -- path; the -- keeps paths from reading as options),
with a header naming the file, a filtered pager, and a history link in
the blob actions bar. repo log gains --path for CLI parity.
e2e/design_test.go +20
@@ -59,6 +59,26 @@ func TestReadmeRelativeLinks(t *testing.T) {
5959 if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">all refs") {
6060 t.Error("branch dropdown missing")
6161 }
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 }
6282 // Raw serves images with their real type (nosniff otherwise blocks
6383 // <img>); everything else stays inert text/plain.
6484 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() {
2323 register(Command{Path: []string{"pgp", "remove"},
2424 Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove})
2525 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})
2727 }
2828
2929 func runPGPAdd(c *Ctx, args []string) int {
@@ -119,7 +119,7 @@ func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sh
119119
120120 func runRepoLog(c *Ctx, args []string) int {
121121 limit := 30
122 var path string
122 var path, filePath string
123123 for i := 0; i < len(args); i++ {
124124 switch args[i] {
125125 case "--limit":
@@ -132,22 +132,34 @@ func runRepoLog(c *Ctx, args []string) int {
132132 }
133133 limit = n
134134 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++
135141 default:
136142 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>]")
138144 }
139145 path = args[i]
140146 }
141147 }
142148 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>]")
144150 }
145151 repo, code := resolveRepo(c, path, policy.CanRead)
146152 if code >= 0 {
147153 return code
148154 }
149155 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 }
151163 if err != nil {
152164 return c.fail(protocol.ExitFailure, "reading log: %v", err)
153165 }
internal/gitutil/gitutil.go +19
@@ -100,6 +100,25 @@ func RevList(dir, ref string, limit int) ([]string, error) {
100100 return shas, nil
101101 }
102102
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
103122 // PeelToCommit resolves a ref or object to its commit — annotated tags
104123 // peel to the commit they point at.
105124 func 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) {
10761076 }
10771077 p.Tab = "log"
10781078 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 }
10801091 if err != nil {
10811092 s.notFound(w, r)
10821093 return
@@ -1104,9 +1115,10 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
11041115 }
11051116 s.render(w, "log.html", struct {
11061117 repoPage
1107 Commits []row
1108 NextSHA string
1109 }{p, rows, next})
1118 Commits []row
1119 NextSHA string
1120 FilePath string
1121 }{p, rows, next, filePath})
11101122 }
11111123
11121124 func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
internal/web/templates/blob.html +1 −1
@@ -5,7 +5,7 @@
55 {{template "refmenu" .}}
66 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
77 <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>
99 </div>
1010 {{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>
1111 {{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 @@
11 {{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22 {{define "content"}}
33 {{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}}
45 <ul class="loglist">
56 {{range .Commits}}<li>
67 <div class="commitmain">
@@ -12,7 +13,7 @@
1213 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code>
1314 </div>
1415 </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}}
1617 </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}}
1819 {{end}}