Commit 2dba2b3994

2dba2b39940ea21cab70f03fb593cb805c3089a2

parent: dbec077010

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 03:09 UTC

compare view: what one ref adds on top of another

/{owner}/{repo}/compare/{base}...{head} shows the commits in base..head
and the diff from their merge base, the range a merge request would
show before it exists; the refs page carries a form for it, and a
writer gets a link to open the merge request. repo diff <base> <head>
is the same range over ssh, so the capability lands there first and
the readonly test covers it.

Ref #118

Layout: unified · split

cmd/gitbay/main.go +1
@@ -360,6 +360,7 @@ func repoCmd() *cobra.Command {
360360 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
361361 pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}),
362362 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
363 pass("diff", "the patch between two refs: <base> <head>", passOpts{server: []string{"repo", "diff"}, needsRepo: true}),
363364 pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
364365 pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
365366 pass("blame", "attribute lines to commits: <path> [--ref <ref>] [--from <n>] [--to <n>]",
e2e/compare_test.go added +52
@@ -0,0 +1,52 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// The compare view shows what a branch adds on top of another from their
11// merge base, and repo diff is the same range over ssh (#118).
12func TestCompareView(t *testing.T) {
13 inst := startInstance(t)
14 aliceKey := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
16 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
17 t.Fatalf("repo create: %s", errOut)
18 }
19 work := t.TempDir()
20 env := inst.gitEnv(aliceKey)
21 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
22 dir := filepath.Join(work, "w")
23 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\n"), 0o644)
24 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
25 mustGit(t, dir, env, "add", ".")
26 mustGit(t, dir, env, "commit", "-q", "-m", "base")
27 mustGit(t, dir, env, "push", "-q", "origin", "main")
28 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
29 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\ntwo\n"), 0o644)
30 mustGit(t, dir, env, "add", ".")
31 mustGit(t, dir, env, "commit", "-q", "-m", "add two")
32 mustGit(t, dir, env, "push", "-q", "origin", "feat")
33 // main moves on: a compare from the merge base must not show this
34 // as a removal on feat.
35 mustGit(t, dir, env, "checkout", "-q", "main")
36 os.WriteFile(filepath.Join(dir, "g.txt"), []byte("g\n"), 0o644)
37 mustGit(t, dir, env, "add", ".")
38 mustGit(t, dir, env, "commit", "-q", "-m", "main moves")
39 mustGit(t, dir, env, "push", "-q", "origin", "main")
40
41 status, body := inst.get(t, "/alice/app/compare/main...feat")
42 if status != 200 || !strings.Contains(body, "add two") || !strings.Contains(body, `<tr class="add" id="f0-n2">`) || !strings.Contains(body, ">two</td>") || strings.Contains(body, "g.txt") {
43 t.Fatalf("compare page: %d\n%s", status, body)
44 }
45 if status, _ := inst.get(t, "/alice/app/compare/main...nope"); status != 404 {
46 t.Fatalf("compare with a missing ref: %d", status)
47 }
48 out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "diff", "alice/app", "main", "feat")
49 if code != 0 || !strings.Contains(out, "+two") || strings.Contains(out, "g.txt") {
50 t.Fatalf("repo diff: exit %d\n%s%s", code, out, errOut)
51 }
52}
e2e/readonly_test.go +1
@@ -117,6 +117,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
117117 "repo cat": {"alice/app", "f.go"},
118118 "repo blame": {"alice/app", "f.go"},
119119 "repo grep": {"alice/app", "hello"},
120 "repo diff": {"alice/app", "main", "feat"},
120121 "repo commit": {"alice/app", sha},
121122 "repo download": {"alice/app"},
122123 "repo deploy-key list": {"alice/app"},
internal/control/repo.go +42
@@ -90,6 +90,9 @@ func init() {
9090 register(Command{Path: []string{"repo", "grep"},
9191 Summary: "search file contents",
9292 Usage: "repo grep <owner/name> <query> [--ref <ref>]", ReadOnly: true, Run: runRepoGrep})
93 register(Command{Path: []string{"repo", "diff"},
94 Summary: "the patch between two refs, from their merge base",
95 Usage: "repo diff <owner/name> <base> <head>", ReadOnly: true, Run: runRepoDiff})
9396 register(Command{Path: []string{"repo", "pin"},
9497 Summary: "pin a repository to your dashboard",
9598 Usage: "repo pin <owner/name>", Run: runRepoPin})
@@ -908,3 +911,42 @@ func setProtect(c *Ctx, args []string, protect bool) int {
908911 }
909912 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
910913}
914
915// runRepoDiff is the compare view's command: what head adds on top of
916// base, measured from their merge base the way a merge request diff is,
917// so a base that moved on does not show up as removals (#118).
918func runRepoDiff(c *Ctx, args []string) int {
919 f, err := parseFlags(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"})
920 if err != nil || len(f.Pos) != 3 {
921 return c.fail(protocol.ExitUsage, "usage: repo diff <owner/name> <base> <head>")
922 }
923 repo, code := resolveRepo(c, f.pos(0), policy.CanRead)
924 if code >= 0 {
925 return code
926 }
927 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
928 base, err := gitutil.ResolveRef(dir, f.pos(1))
929 if err != nil {
930 return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(1), repo.Path())
931 }
932 head, err := gitutil.ResolveRef(dir, f.pos(2))
933 if err != nil {
934 return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(2), repo.Path())
935 }
936 mergeBase, err := gitutil.MergeBase(dir, base, head)
937 if err != nil {
938 return c.fail(protocol.ExitUsage, "%v", err)
939 }
940 patch, truncated, err := gitutil.Diff(dir, mergeBase, head, 4<<20)
941 if err != nil {
942 return c.fail(protocol.ExitFailure, "%v", err)
943 }
944 if c.JSON {
945 return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil)
946 }
947 fmt.Fprint(c.Stdout, patch)
948 if truncated {
949 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB")
950 }
951 return protocol.ExitOK
952}
internal/httpd/compare.go added +86
@@ -0,0 +1,86 @@
1package httpd
2
3import (
4 "net/http"
5 "strings"
6 "time"
7
8 "gitbay.org/gitbay/internal/gitutil"
9)
10
11// compare renders what head adds on top of base: the commits in
12// base..head and the diff from their merge base, the same range a merge
13// request shows before it exists (#118). /compare/{base}...{head} is the
14// link form; /compare?base=&head= is the form on the refs page.
15func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
16 p, ok := s.repoFor(w, r, "")
17 if !ok {
18 return
19 }
20 p.Tab = "refs"
21 base, head := r.URL.Query().Get("base"), r.URL.Query().Get("head")
22 if rng := r.PathValue("range"); rng != "" {
23 if b, h, ok := strings.Cut(rng, "..."); ok {
24 base, head = b, h
25 } else if b, h, ok := strings.Cut(rng, ".."); ok {
26 base, head = b, h
27 }
28 }
29 if base == "" {
30 base = p.Repo.DefaultBranch
31 }
32 if head == "" {
33 http.Redirect(w, r, "/"+p.Repo.Path()+"/refs", http.StatusSeeOther)
34 return
35 }
36 baseSHA, err := gitutil.ResolveRef(p.Dir, base)
37 if err != nil {
38 s.notFound(w, r)
39 return
40 }
41 headSHA, err := gitutil.ResolveRef(p.Dir, head)
42 if err != nil {
43 s.notFound(w, r)
44 return
45 }
46 mergeBase, err := gitutil.MergeBase(p.Dir, baseSHA, headSHA)
47 if err != nil {
48 s.notFound(w, r)
49 return
50 }
51 patch, truncated, _ := gitutil.Diff(p.Dir, mergeBase, headSHA, 4<<20)
52 files := parseDiff(patch)
53 type row struct {
54 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
55 Sig sigView
56 }
57 names := s.authorNames()
58 var commits []row
59 shas, _ := gitutil.RevListRange(p.Dir, mergeBase, headSHA)
60 const maxCommits = 100
61 total := len(shas)
62 if len(shas) > maxCommits {
63 shas = shas[:maxCommits]
64 }
65 for _, sha := range shas {
66 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
67 cr := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
68 if parsed != nil {
69 cr.Subject = parsed.Subject
70 cr.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
71 cr.AuthorUser, _ = names.account(parsed.AuthorEmail)
72 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
73 }
74 commits = append(commits, cr)
75 }
76 s.render(w, "compare.html", struct {
77 repoPage
78 Base, Head, BaseSHA, HeadSHA, MergeBase string
79 Commits []row
80 CommitsTotal int
81 DiffFiles []diffFile
82 DiffTruncated bool
83 Stat diffStat
84 CanWrite bool
85 }{p, base, head, baseSHA, headSHA, mergeBase, commits, total, files, truncated, statOf(files), s.canWriteRepo(r, p.Repo)})
86}
internal/httpd/routes.go +2
@@ -74,6 +74,8 @@ func (s *Server) Routes() []Route {
7474 Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log},
7575 Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log},
7676 Route{Method: "GET", Pattern: "/{owner}/{repo}/commit/{sha}", Handler: s.commit},
77 Route{Method: "GET", Pattern: "/{owner}/{repo}/compare", Handler: s.compare},
78 Route{Method: "GET", Pattern: "/{owner}/{repo}/compare/{range}", Handler: s.compare},
7779 Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs},
7880 Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive},
7981 Route{Method: "GET", Pattern: "/{owner}/{repo}/issues", Handler: s.issues},
internal/web/templates/compare.html added +17
@@ -0,0 +1,17 @@
1{{define "title"}}compare {{.Base}}...{{.Head}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3<h1>Compare <code>{{.Base}}</code> … <code>{{.Head}}</code></h1>
4<p class="meta">{{len .Commits}}{{if gt .CommitsTotal (len .Commits)}} of {{.CommitsTotal}}{{end}} commit{{if ne .CommitsTotal 1}}s{{end}} on <code>{{.Head}}</code> since the merge base <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.MergeBase}}"><code>{{short .MergeBase}}</code></a>{{if .CanWrite}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new?source={{.Head}}&amp;target={{.Base}}">open a merge request</a>{{end}}</p>
5{{if .Commits}}<ul class="loglist">
6{{range .Commits}}<li>
7 <div class="commitmain">
8 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p>
9 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" ""}} · {{.Date}}</p>
10 </div>
11 <span class="commitside">{{template "sigbadge" .Sig}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code></span>
12</li>{{end}}
13</ul>{{else}}<p class="empty-note"><code>{{.Head}}</code> has nothing that <code>{{.Base}}</code> does not.</p>{{end}}
14{{if .DiffFiles}}<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p>
15{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below.</p>{{end}}
16{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}}{{end}}
17{{end}}
internal/web/templates/refs.html +5
@@ -1,6 +1,11 @@
11{{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
33<h1>Refs</h1>
4<form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/compare" class="compareform">
5 <label>Compare <input name="base" value="{{.Repo.DefaultBranch}}" size="14" aria-label="base ref"></label>
6 <label>with <input name="head" size="14" placeholder="branch, tag or sha" aria-label="head ref"></label>
7 <button type="submit" class="btn">Compare</button>
8</form>
49<h2>Branches</h2>
510<table class="refs"><thead><tr><th scope="col">Branch</th><th scope="col">Commit</th></tr></thead>{{range .Branches}}<tr><td class="name">{{template "branchicon"}} <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td class="sha"><code>{{short .SHA}}</code></td></tr>{{else}}<tr><td class="empty">none</td></tr>{{end}}</table>
611<h2>Tags</h2>