Commit 2dba2b3994
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +1
| @@ -360,6 +360,7 @@ func repoCmd() *cobra.Command { | ||
| 360 | 360 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 361 | 361 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), |
| 362 | 362 | 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}), | |
| 363 | 364 | pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}), |
| 364 | 365 | pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}), |
| 365 | 366 | pass("blame", "attribute lines to commits: <path> [--ref <ref>] [--from <n>] [--to <n>]", |
e2e/compare_test.go added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 12 | func 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) { | ||
| 117 | 117 | "repo cat": {"alice/app", "f.go"}, |
| 118 | 118 | "repo blame": {"alice/app", "f.go"}, |
| 119 | 119 | "repo grep": {"alice/app", "hello"}, |
| 120 | "repo diff": {"alice/app", "main", "feat"}, | |
| 120 | 121 | "repo commit": {"alice/app", sha}, |
| 121 | 122 | "repo download": {"alice/app"}, |
| 122 | 123 | "repo deploy-key list": {"alice/app"}, |
internal/control/repo.go +42
| @@ -90,6 +90,9 @@ func init() { | ||
| 90 | 90 | register(Command{Path: []string{"repo", "grep"}, |
| 91 | 91 | Summary: "search file contents", |
| 92 | 92 | 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}) | |
| 93 | 96 | register(Command{Path: []string{"repo", "pin"}, |
| 94 | 97 | Summary: "pin a repository to your dashboard", |
| 95 | 98 | Usage: "repo pin <owner/name>", Run: runRepoPin}) |
| @@ -908,3 +911,42 @@ func setProtect(c *Ctx, args []string, protect bool) int { | ||
| 908 | 911 | } |
| 909 | 912 | return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) }) |
| 910 | 913 | } |
| 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). | |
| 918 | func 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 15 | func (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 { | ||
| 74 | 74 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, |
| 75 | 75 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, |
| 76 | 76 | 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}, | |
| 77 | 79 | Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs}, |
| 78 | 80 | Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, |
| 79 | 81 | 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}}&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 @@ | ||
| 1 | 1 | {{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <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> | |
| 4 | 9 | <h2>Branches</h2> |
| 5 | 10 | <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> |
| 6 | 11 | <h2>Tags</h2> |