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) { |
| 59 | 59 | if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">all refs") { |
| 60 | 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 | 82 | // Raw serves images with their real type (nosniff otherwise blocks |
| 63 | 83 | // <img>); everything else stays inert text/plain. |
| 64 | 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 | 23 | register(Command{Path: []string{"pgp", "remove"}, |
| 24 | 24 | Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove}) |
| 25 | 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 | |
| 29 | 29 | func runPGPAdd(c *Ctx, args []string) int { |
| @@ -119,7 +119,7 @@ func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sh |
| 119 | 119 | |
| 120 | 120 | func runRepoLog(c *Ctx, args []string) int { |
| 121 | 121 | limit := 30 |
| 122 | | var path string |
| 122 | var path, filePath string |
| 123 | 123 | for i := 0; i < len(args); i++ { |
| 124 | 124 | switch args[i] { |
| 125 | 125 | case "--limit": |
| @@ -132,22 +132,34 @@ func runRepoLog(c *Ctx, args []string) int { |
| 132 | 132 | } |
| 133 | 133 | limit = n |
| 134 | 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 | 141 | default: |
| 136 | 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 | 145 | path = args[i] |
| 140 | 146 | } |
| 141 | 147 | } |
| 142 | 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 | 151 | repo, code := resolveRepo(c, path, policy.CanRead) |
| 146 | 152 | if code >= 0 { |
| 147 | 153 | return code |
| 148 | 154 | } |
| 149 | 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 | 163 | if err != nil { |
| 152 | 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 | 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. |
| 106 | func 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 | 122 | // PeelToCommit resolves a ref or object to its commit — annotated tags |
| 104 | 123 | // peel to the commit they point at. |
| 105 | 124 | 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) { |
| 1076 | 1076 | } |
| 1077 | 1077 | p.Tab = "log" |
| 1078 | 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 | 1091 | if err != nil { |
| 1081 | 1092 | s.notFound(w, r) |
| 1082 | 1093 | return |
| @@ -1104,9 +1115,10 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) { |
| 1104 | 1115 | } |
| 1105 | 1116 | s.render(w, "log.html", struct { |
| 1106 | 1117 | 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}) |
| 1110 | 1122 | } |
| 1111 | 1123 | |
| 1112 | 1124 | func (s *Server) commit(w http.ResponseWriter, r *http.Request) { |
internal/web/templates/blob.html
+1 −1
| @@ -5,7 +5,7 @@ |
| 5 | 5 | {{template "refmenu" .}} |
| 6 | 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 | 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 | 9 | </div> |
| 10 | 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 | 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 | 1 | {{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 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 | 5 | <ul class="loglist"> |
| 5 | 6 | {{range .Commits}}<li> |
| 6 | 7 | <div class="commitmain"> |
| @@ -12,7 +13,7 @@ |
| 12 | 13 | <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> |
| 13 | 14 | </div> |
| 14 | 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 | 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 | 19 | {{end}} |