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 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), | 360 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 361 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), | 361 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), |
| 362 | pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), | 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 | pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}), | 364 | pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}), |
| 364 | pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}), | 365 | pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}), |
| 365 | pass("blame", "attribute lines to commits: <path> [--ref <ref>] [--from <n>] [--to <n>]", | 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 | "repo cat": {"alice/app", "f.go"}, | 117 | "repo cat": {"alice/app", "f.go"}, |
| 118 | "repo blame": {"alice/app", "f.go"}, | 118 | "repo blame": {"alice/app", "f.go"}, |
| 119 | "repo grep": {"alice/app", "hello"}, | 119 | "repo grep": {"alice/app", "hello"}, |
| 120 | "repo diff": {"alice/app", "main", "feat"}, | ||
| 120 | "repo commit": {"alice/app", sha}, | 121 | "repo commit": {"alice/app", sha}, |
| 121 | "repo download": {"alice/app"}, | 122 | "repo download": {"alice/app"}, |
| 122 | "repo deploy-key list": {"alice/app"}, | 123 | "repo deploy-key list": {"alice/app"}, |
internal/control/repo.go +42
| @@ -90,6 +90,9 @@ func init() { | |||
| 90 | register(Command{Path: []string{"repo", "grep"}, | 90 | register(Command{Path: []string{"repo", "grep"}, |
| 91 | Summary: "search file contents", | 91 | Summary: "search file contents", |
| 92 | Usage: "repo grep <owner/name> <query> [--ref <ref>]", ReadOnly: true, Run: runRepoGrep}) | 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 | register(Command{Path: []string{"repo", "pin"}, | 96 | register(Command{Path: []string{"repo", "pin"}, |
| 94 | Summary: "pin a repository to your dashboard", | 97 | Summary: "pin a repository to your dashboard", |
| 95 | Usage: "repo pin <owner/name>", Run: runRepoPin}) | 98 | Usage: "repo pin <owner/name>", Run: runRepoPin}) |
| @@ -908,3 +911,42 @@ func setProtect(c *Ctx, args []string, protect bool) int { | |||
| 908 | } | 911 | } |
| 909 | return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) }) | 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 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, | 74 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, |
| 75 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, | 75 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, |
| 76 | Route{Method: "GET", Pattern: "/{owner}/{repo}/commit/{sha}", Handler: s.commit}, | 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 | Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs}, | 79 | Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs}, |
| 78 | Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, | 80 | Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive}, |
| 79 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues", Handler: s.issues}, | 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 | {{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 1 | {{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 2 | {{define "content"}} |
| 3 | <h1>Refs</h1> | 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 | <h2>Branches</h2> | 9 | <h2>Branches</h2> |
| 5 | <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> | 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 | <h2>Tags</h2> | 11 | <h2>Tags</h2> |