gitmote

gitmote  ·  history  ·  branches

feat: compare two refs — commits and diff of base...head

b796f1f80abe5369aabf138293946bc73715a40b
Atanas Minev <atmin@protonmail.com> · 2026-07-18 15:54 · CI passed

diff --git a/docs/architecture/urls.md b/docs/architecture/urls.md
index 7e34645..823643c 100644
--- a/docs/architecture/urls.md
+++ b/docs/architecture/urls.md
@@ -30,6 +30,7 @@ verb under the repo or a top-level global route:
| File (explicit) | `/<repo>/blob/<ref>/<path…>` | read |
| Raw bytes | `/<repo>/raw/<ref>/<path…>` | read |
| History / one commit | `/<repo>/commits/<ref>/<path…>`, `/<repo>/commit/<sha>` | read |
+| Compare two refs | `/<repo>/compare/<base>...<head>` | read |
| Branches / tags | `/<repo>/refs` | read |
| CI runs / one run | `/<repo>/runs`, `/<repo>/runs/<id>` | read |
| Settings (default branch, visibility, rename, delete) | `/<repo>/settings` | admin |
@@ -173,7 +174,10 @@ Two non-obvious constraints the flat scheme creates:
from coexisting (a name can't be both a file and a directory under
`refs/heads`). The only residual case is a branch and a *tag* sharing a prefix,
resolved by a fixed precedence (branch wins). Pretty URLs, no `%2F` encoding
- and its proxy/`net/http` footguns.
+ and its proxy/`net/http` footguns. **Compare** carries *two* slashy refs in one
+ segment (`compare/<base>...<head>`) and splits on the literal `...`; git's ref
+ store forbids `..` in a ref name, so the separator is unambiguous even when
+ either side contains slashes.
- **Unify + tolerant `blob`.** One content verb means a relative link resolves
correctly whether the target is a file or a directory, without the link author
knowing which; `blob`/`tree` tolerance means an imprecise link still lands
diff --git a/internal/repo/browse.go b/internal/repo/browse.go
index 9229e61..34afc1f 100644
--- a/internal/repo/browse.go
+++ b/internal/repo/browse.go
@@ -256,6 +256,29 @@ func Show(ctx context.Context, dir, sha string) (Commit, string, error) {
return commits[0], strings.TrimLeft(string(diff), "\n"), nil
}
+// Compare returns the commits head adds over base and the unified diff between
+// them. The commit list is the two-dot range base..head (commits reachable from
+// head but not base); the diff is the three-dot base...head (head against the
+// merge base), matching a GitHub compare view. base and head are commit SHAs
+// (or any rev safeRev accepts); an unknown rev surfaces as ErrNotFound.
+func Compare(ctx context.Context, dir, base, head string) (commits []Commit, diff string, err error) {
+ if err := safeRev(base); err != nil {
+ return nil, "", err
+ }
+ if err := safeRev(head); err != nil {
+ return nil, "", err
+ }
+ log, err := runGitOut(ctx, dir, "log", "--format="+commitFormat, "--end-of-options", base+".."+head)
+ if err != nil {
+ return nil, "", err
+ }
+ patch, err := runGitOut(ctx, dir, "diff", "--end-of-options", base+"..."+head)
+ if err != nil {
+ return nil, "", err
+ }
+ return parseCommits(log), strings.TrimLeft(string(patch), "\n"), nil
+}
+
// parseCommits splits git output framed by commitFormat into commits.
func parseCommits(out []byte) []Commit {
var commits []Commit
diff --git a/internal/repo/browse_test.go b/internal/repo/browse_test.go
index 3524087..6217acd 100644
--- a/internal/repo/browse_test.go
+++ b/internal/repo/browse_test.go
@@ -207,3 +207,34 @@ func TestShow(t *testing.T) {
t.Fatalf("Show(first) diff missing initial content:\n%s", diff)
}
}
+
+func TestCompare(t *testing.T) {
+ ctx := context.Background()
+ dir, first, head := browseFixture(t)
+
+ // first..head: the second commit is the one head adds, and its diff adds "world".
+ commits, diff, err := Compare(ctx, dir, first, head)
+ if err != nil {
+ t.Fatalf("Compare(first, head): %v", err)
+ }
+ if len(commits) != 1 || commits[0].SHA != head || commits[0].Subject != "second" {
+ t.Fatalf("Compare(first, head) commits = %+v, want just the second commit", commits)
+ }
+ if !strings.Contains(diff, "+world") {
+ t.Fatalf("Compare(first, head) diff missing added line:\n%s", diff)
+ }
+
+ // Identical revs: no commits, empty diff (not an error).
+ commits, diff, err = Compare(ctx, dir, head, head)
+ if err != nil {
+ t.Fatalf("Compare(head, head): %v", err)
+ }
+ if len(commits) != 0 || diff != "" {
+ t.Fatalf("Compare(head, head) = %d commits, diff %q; want empty", len(commits), diff)
+ }
+
+ // An unknown rev is ErrNotFound, not a launch error.
+ if _, _, err := Compare(ctx, dir, first, "deadbeefdeadbeef"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("Compare(unknown head) err = %v, want ErrNotFound", err)
+ }
+}
diff --git a/internal/webui/browse.go b/internal/webui/browse.go
index 98f4bda..936885a 100644
--- a/internal/webui/browse.go
+++ b/internal/webui/browse.go
@@ -156,6 +156,90 @@ func (h *Handler) browseRefsRoute(w http.ResponseWriter, r *http.Request) {
})
}
+// browseCompareRoute serves /{repo}/compare/{base}...{head}: the commits head
+// adds over base and their combined diff. The two refs live in one path segment
+// split on the literal "..." — git forbids ".." in a ref name, so the separator
+// is unambiguous even when a ref itself contains slashes. Each side resolves as
+// a branch, tag, or raw SHA.
+func (h *Handler) browseCompareRoute(w http.ResponseWriter, r *http.Request) {
+ repoName := r.PathValue("repo")
+ if !h.authorizeRead(w, r, repoName) {
+ return
+ }
+ base, head, ok := strings.Cut(r.PathValue("rest"), "...")
+ if !ok || base == "" || head == "" {
+ http.NotFound(w, r) // not a "base...head" spec
+ return
+ }
+ ctx := r.Context()
+ rp, err := h.md.GetRepo(ctx, repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return
+ }
+ refs, err := h.md.ListRefs(ctx, rp.ID)
+ if err != nil {
+ h.serverError(w, "list refs", err)
+ return
+ }
+ dir, err := h.mz.Materialize(ctx, repoName)
+ if err != nil {
+ h.serverError(w, "materialize repo", err)
+ return
+ }
+ baseSHA, ok := h.resolveSide(w, r, dir, refs, base)
+ if !ok {
+ return
+ }
+ headSHA, ok := h.resolveSide(w, r, dir, refs, head)
+ if !ok {
+ return
+ }
+ commits, diff, err := repo.Compare(ctx, dir, baseSHA, headSHA)
+ if errors.Is(err, repo.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ h.serverError(w, "compare", err)
+ return
+ }
+ c := browseCtx{repo: rp, dir: dir, ref: head}
+ h.render(w, "browse_compare.html", compareData{
+ browseBase: h.browseHeader(r, c),
+ Base: base,
+ Head: head,
+ Commits: commits,
+ Diff: render.Diff(diff),
+ })
+}
+
+// resolveSide resolves one side of a compare — a whole branch or tag name, or a
+// raw SHA — to a commit SHA against the materialized dir. A named ref is
+// qualified so a branch beats a same-named tag; anything else is handed to git
+// as a rev. It writes a 404 and returns ok=false when the side resolves to
+// nothing.
+func (h *Handler) resolveSide(w http.ResponseWriter, r *http.Request, dir string, refs []meta.Ref, side string) (string, bool) {
+ rev := side
+ if name, tail, branch, ok := greedyRef(refs, side, ""); ok && name == side && tail == "" {
+ rev = qualify(name, branch)
+ }
+ sha, err := repo.ResolveRef(r.Context(), dir, rev)
+ if errors.Is(err, repo.ErrNotFound) {
+ http.NotFound(w, r)
+ return "", false
+ }
+ if err != nil {
+ h.serverError(w, "resolve ref", err)
+ return "", false
+ }
+ return sha, true
+}
+
// ciRunsRoute serves /{repo}/runs (the list) and /{repo}/runs/<id>[/job/…] (one
// run or a job log), sharing the repo-read gate with the other browse verbs.
func (h *Handler) ciRunsRoute(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go
index 3476764..5a6ad94 100644
--- a/internal/webui/browse_test.go
+++ b/internal/webui/browse_test.go
@@ -336,6 +336,56 @@ func TestBrowseCommitsAndCommit(t *testing.T) {
}
}
+// TestBrowseCompare: /{repo}/compare/{base}...{head} lists the commits head adds
+// over base and renders their combined diff, and the refs page links to it.
+func TestBrowseCompare(t *testing.T) {
+ x := newHarness(t)
+ head, first := x.seedBrowseRepo("app", "main")
+ session := x.login(x.mintTokenFor(x.admin.ID))
+ ctx := context.Background()
+ r, _ := x.md.GetRepo(ctx, "app")
+
+ // A second branch at the first commit, so "old...main" is a real ref-to-ref
+ // compare (main is ahead by the "second" commit).
+ if err := x.md.CASRef(ctx, r.ID, "refs/heads/old", meta.ZeroSHA, first); err != nil {
+ t.Fatalf("CASRef old: %v", err)
+ }
+
+ rec := x.do(http.MethodGet, "/app/compare/old...main", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("compare = %d (%s)", rec.Code, rec.Body)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "second") || !strings.Contains(body, "/app/commit/"+head) {
+ t.Fatalf("compare body missing the added commit:\n%s", body)
+ }
+ // The diff renders as a colored diff (the second commit edits hello.go).
+ for _, want := range []string{`class="diff"`, "dl-add", "dl-del"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("compare diff not rendered as a colored diff (missing %q):\n%s", want, body)
+ }
+ }
+
+ // Identical refs: 200 with no commits, no error.
+ rec = x.do(http.MethodGet, "/app/compare/main...main", nil, session)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "no commits") {
+ t.Fatalf("compare main...main = %d, want 200 with an empty range:\n%s", rec.Code, rec.Body)
+ }
+
+ // An unknown ref on either side is a 404, and a spec without "..." is too.
+ for _, target := range []string{"/app/compare/old...ghost", "/app/compare/main"} {
+ if rec := x.do(http.MethodGet, target, nil, session); rec.Code != http.StatusNotFound {
+ t.Errorf("%s = %d, want 404", target, rec.Code)
+ }
+ }
+
+ // The refs page offers a compare link for a non-default ref.
+ rec = x.do(http.MethodGet, "/app/refs", nil, session)
+ if !strings.Contains(rec.Body.String(), "/app/compare/main...old") {
+ t.Errorf("refs page missing compare link:\n%s", rec.Body)
+ }
+}
+
func TestBrowseHighlightAndMarkdown(t *testing.T) {
x := newHarness(t)
x.seedBrowseRepo("app", "main")
diff --git a/internal/webui/data.go b/internal/webui/data.go
index efd007b..6aac97d 100644
--- a/internal/webui/data.go
+++ b/internal/webui/data.go
@@ -131,6 +131,16 @@ type commitData struct {
Run *meta.Run // the latest CI run for this commit's SHA, if any (badge)
}
+type compareData struct {
+ browseBase
+ Path string // always empty; present so the shared browse_head renders
+ Crumbs []crumb // always nil; present so the shared browse_head renders
+ Base string // base ref/sha, as given in the URL (display + links)
+ Head string // head ref/sha, as given in the URL
+ Commits []repo.Commit
+ Diff template.HTML // the three-dot diff rendered as a colored diff (render.Diff)
+}
+
// --- ci status ---
type ciRunsData struct {
diff --git a/internal/webui/templates/browse_compare.html b/internal/webui/templates/browse_compare.html
new file mode 100644
index 0000000..cf639de
--- /dev/null
+++ b/internal/webui/templates/browse_compare.html
@@ -0,0 +1,21 @@
+{{define "browse_compare.html"}}{{template "top" .}}
+{{template "browse_head" .}}
+<h1>Comparing <code>{{.Base}}</code> … <code>{{.Head}}</code></h1>
+<p>{{len .Commits}} commit(s) on <code>{{.Head}}</code> since <code>{{.Base}}</code>.</p>
+<table>
+ <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>When</th></tr></thead>
+ <tbody>
+ {{range .Commits}}
+ <tr>
+ <td><a href="/{{$.Repo}}/commit/{{.SHA}}"><code>{{slice .SHA 0 10}}</code></a></td>
+ <td>{{.Subject}}</td>
+ <td>{{.Author}}</td>
+ <td>{{.When.Format "2006-01-02 15:04"}}</td>
+ </tr>
+ {{else}}
+ <tr><td colspan="4">no commits — these refs match, or the base is ahead</td></tr>
+ {{end}}
+ </tbody>
+</table>
+{{.Diff}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/browse_refs.html b/internal/webui/templates/browse_refs.html
index c62870e..ba3ad3d 100644
--- a/internal/webui/templates/browse_refs.html
+++ b/internal/webui/templates/browse_refs.html
@@ -3,10 +3,13 @@
<h1>Branches &amp; tags</h1>
{{if .Refs}}
<table>
- <thead><tr><th>Ref</th></tr></thead>
+ <thead><tr><th>Ref</th><th></th></tr></thead>
<tbody>
{{range .Refs}}
- <tr><td><a href="/{{$.Repo}}/tree/{{.Ref}}">{{.Name}}</a></td></tr>
+ <tr>
+ <td><a href="/{{$.Repo}}/tree/{{.Ref}}">{{.Name}}</a></td>
+ <td>{{if ne .Ref $.Ref}}<a href="/{{$.Repo}}/compare/{{$.Ref}}...{{.Ref}}">compare</a>{{else}}default{{end}}</td>
+ </tr>
{{end}}
</tbody>
</table>
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index b02a2e2..8b7c9c0 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -197,6 +197,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /{repo}/commits", h.browseCommitsRoute)
mux.HandleFunc("GET /{repo}/commits/{rest...}", h.browseCommitsRoute)
mux.HandleFunc("GET /{repo}/commit/{sha}", h.browseCommitRoute)
+ mux.HandleFunc("GET /{repo}/compare/{rest...}", h.browseCompareRoute)
mux.HandleFunc("GET /{repo}/refs", h.browseRefsRoute)
mux.HandleFunc("GET /{repo}/runs", h.ciRunsRoute)
mux.HandleFunc("GET /{repo}/runs/{rest...}", h.ciRunsRoute)