A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

control: repo blame, repo commit-file, repo refs; milestone on mr show !99

merged cmc wants to merge krz/gitbay:ios-parity-contract into main

10 files changed, +521 −66

e2e/accounts_test.go +24
@@ -155,6 +155,30 @@ func TestWebAccounts(t *testing.T) {
155155 t.Fatalf("edited content not served: %d %q", status, body)
156156 }
157157
158 // Editing is a command, so it works from the CLI too — the web is one
159 // rendering of it. This is the capability that used to be web-only.
160 if _, errOut, code := inst.ssh(t, aliceKey, "edited from ssh\n",
161 "repo", "commit-file", "alice/site", "notes.txt",
162 "--ref", "main", "--message", "'ssh edit'", "--file", "-"); code != 0 {
163 t.Fatalf("repo commit-file: %s", errOut)
164 }
165 if status, body = browserGet(t, browser, inst.base()+"/alice/site/raw/main/notes.txt"); !strings.Contains(body, "edited from ssh") {
166 t.Fatalf("ssh edit not served: %d %q", status, body)
167 }
168 // A path cannot climb out of the repository.
169 if _, _, code := inst.ssh(t, aliceKey, "x", "repo", "commit-file", "alice/site",
170 "../../etc/passwd", "--ref", "main", "--file", "-"); code == 0 {
171 t.Error("commit-file escaped the repository")
172 }
173 // A stranger with no write access cannot commit.
174 strangerKey := inst.newKey(t, "mallory")
175 inst.admin(t, "admin", "user", "create", "mallory",
176 "--key", strangerKey+".pub", "--email", "mallory@example.test", "--verified")
177 if _, _, code := inst.ssh(t, strangerKey, "x", "repo", "commit-file", "alice/site",
178 "notes.txt", "--ref", "main", "--file", "-"); code == 0 {
179 t.Error("a stranger committed to a repository they cannot write")
180 }
181
158182 // A require-signed repo refuses web edits instead of violating itself.
159183 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/site", "on"); code != 0 {
160184 t.Fatal("require-signed failed")
e2e/apiread_test.go +20 −1
@@ -35,9 +35,25 @@ func TestRepoTreeAndCat(t *testing.T) {
3535 mustGit(t, dir, env, "add", ".")
3636 mustGit(t, dir, env, "commit", "-q", "-m", "base")
3737 mustGit(t, dir, env, "push", "-q", "origin", "main")
38 mustGit(t, dir, env, "checkout", "-q", "-b", "feature")
39 mustGit(t, dir, env, "push", "-q", "origin", "feature")
40 mustGit(t, dir, env, "tag", "v1.0.0")
41 mustGit(t, dir, env, "push", "-q", "origin", "v1.0.0")
42
43 // Refs are a control-plane read so native clients can present the same
44 // branch and tag choices as the web without synthesizing them.
45 out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "refs", "alice/app", "--json")
46 if code != 0 {
47 t.Fatalf("repo refs: %s", errOut)
48 }
49 if !strings.Contains(out, `"name":"main"`) ||
50 !strings.Contains(out, `"name":"feature"`) ||
51 !strings.Contains(out, `"name":"v1.0.0"`) {
52 t.Fatalf("repo refs output: %s", out)
53 }
3854
3955 // Tree at the root: directories and files, with sizes and object ids.
40 out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "--json")
56 out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "--json")
4157 if code != 0 {
4258 t.Fatalf("repo tree: %s", errOut)
4359 }
@@ -134,6 +150,9 @@ func TestRepoTreeAndCat(t *testing.T) {
134150 if _, _, code := inst.ssh(t, bobKey, "", "repo", "cat", "alice/app", "README.md"); code == 0 {
135151 t.Error("a stranger read a private repository's file")
136152 }
153 if _, _, code := inst.ssh(t, bobKey, "", "repo", "refs", "alice/app"); code == 0 {
154 t.Error("a stranger listed a private repository's refs")
155 }
137156
138157 // Paths cannot climb out of the repository.
139158 for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "src/../../.."} {
e2e/blame_test.go +50
@@ -32,6 +32,56 @@ func TestBlameView(t *testing.T) {
3232 mustGit(t, dir, env, "commit", "-q", "-m", "change line two")
3333 mustGit(t, dir, env, "push", "-q", "origin", "main")
3434
35 // Blame is a control command, so the CLI and the JSON API attribute
36 // lines too — the web is one rendering of it, not the only one.
37 out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "blame", "alice/app", "f.txt", "--json")
38 if code != 0 {
39 t.Fatalf("repo blame: %s", errOut)
40 }
41 for _, want := range []string{
42 `"total_lines":3`, `"start_line":1`,
43 `"summary":"first lines"`, `"summary":"change line two"`,
44 `"TWO CHANGED"`, `"author_name":"t"`, // the fixture's git author
45 } {
46 if !strings.Contains(out, want) {
47 t.Errorf("repo blame output missing %q: %s", want, out)
48 }
49 }
50
51 // A line range is a window on the same attribution.
52 out, _, code = inst.ssh(t, aliceKey, "", "repo", "blame", "alice/app", "f.txt",
53 "--from", "2", "--to", "2", "--json")
54 if code != 0 || !strings.Contains(out, `"from":2`) || !strings.Contains(out, `"to":2`) {
55 t.Fatalf("ranged blame: %s", out)
56 }
57 if strings.Contains(out, `"one"`) || strings.Contains(out, `"three"`) {
58 t.Errorf("ranged blame leaked lines outside the window: %s", out)
59 }
60
61 // Binary files have nothing to attribute, and say so rather than 500.
62 os.WriteFile(filepath.Join(dir, "logo.bin"), []byte{0, 1, 2, 0, 3}, 0o644)
63 mustGit(t, dir, env, "add", ".")
64 mustGit(t, dir, env, "commit", "-q", "-m", "binary")
65 mustGit(t, dir, env, "push", "-q", "origin", "main")
66 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "blame", "alice/app", "logo.bin"); code == 0 ||
67 !strings.Contains(errOut, "binary") {
68 t.Errorf("binary blame: exit %d, %s", code, errOut)
69 }
70
71 // A stranger cannot attribute a private repository's lines.
72 bobKey := inst.newKey(t, "bob")
73 inst.admin(t, "admin", "user", "create", "bob",
74 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
75 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "visibility", "alice/app", "private"); code != 0 {
76 t.Fatal("visibility private")
77 }
78 if _, _, code := inst.ssh(t, bobKey, "", "repo", "blame", "alice/app", "f.txt"); code == 0 {
79 t.Error("a stranger blamed a private repository's file")
80 }
81 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "visibility", "alice/app", "public"); code != 0 {
82 t.Fatal("visibility public")
83 }
84
3585 status, body := inst.get(t, "/alice/app/blame/main/f.txt")
3686 if status != 200 {
3787 t.Fatalf("blame page: %d", status)
e2e/milestone_test.go +4
@@ -77,6 +77,10 @@ func TestMilestonesAndTemplates(t *testing.T) {
7777 if !strings.Contains(out, `"milestone":"v1.0"`) {
7878 t.Fatalf("issue show milestone: %s", out)
7979 }
80 out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json")
81 if !strings.Contains(out, `"milestone":"v1.0"`) {
82 t.Fatalf("mr show milestone: %s", out)
83 }
8084
8185 // Progress: 2 open (issue 1 + MR 1); closing the issue moves it.
8286 out, _, _ = inst.ssh(t, aliceKey, "", "milestone", "list", "alice/app", "--json")
internal/control/commitfile.go added +122
@@ -0,0 +1,122 @@
1package control
2
3import (
4 "fmt"
5 "io"
6 "strings"
7
8 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/policy"
10 "gitbay.org/gitbay/internal/protocol"
11)
12
13func init() {
14 register(Command{
15 Path: []string{"repo", "commit-file"},
16 Summary: "write a file and commit it: repo commit-file <owner/name> <path> " +
17 "--ref <branch> [--message <m>] [--file -]",
18 ReadsStdin: true,
19 Run: runCommitFile,
20 })
21}
22
23// maxCommitFileBytes bounds one edit. Large content belongs in a push,
24// not a single-file commit over the control plane.
25const maxCommitFileBytes = 1 << 20
26
27// runCommitFile commits one file's contents to a branch. It exists so the
28// capability is reachable from every surface: the web's editor dispatches
29// this rather than calling git itself, which is what kept editing off the
30// CLI and the API.
31//
32// Commits made here are unsigned, because the server is authoring them.
33// A repository that requires verified signatures therefore refuses the
34// command rather than writing a commit its own policy would reject.
35func runCommitFile(c *Ctx, args []string) int {
36 const usage = "repo commit-file <owner/name> <path> --ref <branch> [--message <m>] [--file -]"
37 var rest []string
38 var ref, message, file string
39 for i := 0; i < len(args); i++ {
40 switch args[i] {
41 case "--ref", "--message", "--file":
42 if i+1 >= len(args) {
43 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
44 }
45 switch args[i] {
46 case "--ref":
47 ref = args[i+1]
48 case "--message":
49 message = args[i+1]
50 case "--file":
51 file = args[i+1]
52 }
53 i++
54 default:
55 if strings.HasPrefix(args[i], "--") {
56 return c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage)
57 }
58 rest = append(rest, args[i])
59 }
60 }
61 if len(rest) != 2 || ref == "" {
62 return c.fail(protocol.ExitUsage, "usage: %s", usage)
63 }
64 repo, code := resolveRepo(c, rest[0], policy.CanWrite)
65 if code >= 0 {
66 return code
67 }
68 if code := refuseArchived(c, repo); code >= 0 {
69 return code
70 }
71 filePath, ok := cleanRepoPath(rest[1])
72 if !ok || filePath == "" {
73 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
74 }
75 // The server authors this commit, so it cannot sign it.
76 if repo.Settings.RequireSignedCommits {
77 return c.fail(protocol.ExitDenied,
78 "%s requires signed commits; this writes an unsigned one — push a signed commit instead",
79 repo.Path())
80 }
81 // A commit carries an identity, and an unverified address is not one.
82 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
83 if err != nil {
84 return c.fail(protocol.ExitFailure, "%v", err)
85 }
86 if email == "" {
87 return c.fail(protocol.ExitDenied,
88 "commits carry your identity: your account needs a verified primary email")
89 }
90
91 var content []byte
92 if file != "" {
93 if file != "-" {
94 return c.fail(protocol.ExitUsage, "--file only supports - (stdin)")
95 }
96 content, err = io.ReadAll(io.LimitReader(c.Stdin, maxCommitFileBytes))
97 if err != nil {
98 return c.fail(protocol.ExitFailure, "reading content: %v", err)
99 }
100 }
101 if message = strings.TrimSpace(message); message == "" {
102 message = "edit " + filePath
103 }
104
105 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
106 sha, err := gitutil.CommitFileChange(dir, ref, filePath, content,
107 c.User.Username, email, message)
108 if err != nil {
109 return c.fail(protocol.ExitFailure, "%v", err)
110 }
111 c.Store.MarkMirrorsDirty(repo.ID, "push")
112
113 d := struct {
114 Path string `json:"path"`
115 Ref string `json:"ref"`
116 File string `json:"file"`
117 SHA string `json:"sha"`
118 }{repo.Path(), ref, filePath, sha}
119 return c.emit(d, func(w io.Writer) {
120 fmt.Fprintf(w, "committed %s on %s: %.10s\n", filePath, ref, sha)
121 })
122}
internal/control/mr.go +3 −1
@@ -303,6 +303,7 @@ type mrOut struct {
303303 TargetRef string `json:"target_ref"`
304304 HeadSHA string `json:"head_sha"`
305305 Body string `json:"body,omitempty"`
306 Milestone string `json:"milestone,omitempty"`
306307 CreatedAt string `json:"created_at"`
307308 }
308309
@@ -316,7 +317,8 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
316317 }
317318 }
318319 o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
319 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, CreatedAt: m.CreatedAt}
320 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
321 CreatedAt: m.CreatedAt}
320322 if withBody {
321323 o.Body = m.Body
322324 }
internal/control/read.go +184
@@ -1,11 +1,14 @@
11 package control
22
33 import (
4 "bytes"
45 "encoding/base64"
56 "fmt"
67 "io"
78 "path"
9 "strconv"
810 "strings"
11 "time"
912
1013 "gitbay.org/gitbay/internal/gitutil"
1114 "gitbay.org/gitbay/internal/policy"
@@ -25,6 +28,187 @@ func init() {
2528 ReadOnly: true,
2629 Run: runRepoCat,
2730 })
31 register(Command{
32 Path: []string{"repo", "blame"},
33 Summary: "attribute lines to commits: repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]",
34 ReadOnly: true,
35 Run: runRepoBlame,
36 })
37 register(Command{
38 Path: []string{"repo", "refs"},
39 Summary: "list branches and tags: repo refs <owner/name>",
40 ReadOnly: true,
41 Run: runRepoRefs,
42 })
43}
44
45func runRepoRefs(c *Ctx, args []string) int {
46 if len(args) != 1 {
47 return c.fail(protocol.ExitUsage, "usage: repo refs <owner/name>")
48 }
49 repo, code := resolveRepo(c, args[0], policy.CanRead)
50 if code >= 0 {
51 return code
52 }
53 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
54 branches, err := gitutil.Refs(dir, "heads")
55 if err != nil {
56 return c.fail(protocol.ExitFailure, "listing branches: %v", err)
57 }
58 tags, err := gitutil.Refs(dir, "tags")
59 if err != nil {
60 return c.fail(protocol.ExitFailure, "listing tags: %v", err)
61 }
62 type refOut struct {
63 Name string `json:"name"`
64 SHA string `json:"sha"`
65 }
66 type out struct {
67 Branches []refOut `json:"branches"`
68 Tags []refOut `json:"tags"`
69 }
70 d := out{Branches: []refOut{}, Tags: []refOut{}}
71 for _, ref := range branches {
72 d.Branches = append(d.Branches, refOut{Name: ref.Name, SHA: ref.SHA})
73 }
74 for _, ref := range tags {
75 d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA})
76 }
77 return c.emit(d, func(w io.Writer) {
78 for _, ref := range d.Branches {
79 fmt.Fprintf(w, "branch\t%s\t%.10s\n", ref.Name, ref.SHA)
80 }
81 for _, ref := range d.Tags {
82 fmt.Fprintf(w, "tag\t%s\t%.10s\n", ref.Name, ref.SHA)
83 }
84 })
85}
86
87// BlameSpan caps one blame request, and is the page size the web renders.
88// An unbounded blame on a large file is a slow query for every surface.
89const BlameSpan = 1000
90
91func runRepoBlame(c *Ctx, args []string) int {
92 const usage = "repo blame <owner/name> <path> [--ref <ref>] [--from <n>] [--to <n>]"
93 var rest []string
94 var ref string
95 from, to := 0, 0
96 for i := 0; i < len(args); i++ {
97 switch args[i] {
98 case "--ref", "--from", "--to":
99 if i+1 >= len(args) {
100 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
101 }
102 v := args[i+1]
103 if args[i] == "--ref" {
104 ref = v
105 } else {
106 n, err := strconv.Atoi(v)
107 if err != nil || n < 1 {
108 return c.fail(protocol.ExitUsage, "%s must be a positive line number", args[i])
109 }
110 if args[i] == "--from" {
111 from = n
112 } else {
113 to = n
114 }
115 }
116 i++
117 default:
118 if strings.HasPrefix(args[i], "--") {
119 return c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage)
120 }
121 rest = append(rest, args[i])
122 }
123 }
124 if len(rest) != 2 {
125 return c.fail(protocol.ExitUsage, "usage: %s", usage)
126 }
127 repo, code := resolveRepo(c, rest[0], policy.CanRead)
128 if code >= 0 {
129 return code
130 }
131 filePath, ok := cleanRepoPath(rest[1])
132 if !ok || filePath == "" {
133 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
134 }
135 if ref == "" {
136 ref = repo.DefaultBranch
137 }
138 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
139 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
140 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
141 }
142 data, err := gitutil.ReadBlob(dir, ref, filePath, c.Cfg.Limits.MaxBlobBytes)
143 if err != nil {
144 return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
145 }
146 if gitutil.IsBinary(data) {
147 return c.fail(protocol.ExitUsage, "%s is binary; there is nothing to attribute", filePath)
148 }
149 total := bytes.Count(data, []byte("\n"))
150 if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
151 total++
152 }
153 if total == 0 {
154 return c.fail(protocol.ExitNotFound, "%s is empty at %s", filePath, ref)
155 }
156 if from == 0 {
157 from = 1
158 }
159 if from > total {
160 return c.fail(protocol.ExitUsage, "--from %d is past the end of %s (%d lines)", from, filePath, total)
161 }
162 if to == 0 || to > total {
163 to = total
164 }
165 if to < from {
166 return c.fail(protocol.ExitUsage, "--to must not precede --from")
167 }
168 // One span per call; a client pages with --from/--to.
169 if to-from+1 > BlameSpan {
170 to = from + BlameSpan - 1
171 }
172 raw, err := gitutil.Blame(dir, ref, filePath, from, to)
173 if err != nil {
174 return c.fail(protocol.ExitFailure, "%v", err)
175 }
176
177 type hunkOut struct {
178 SHA string `json:"sha"`
179 AuthorName string `json:"author_name"`
180 AuthorEmail string `json:"author_email"`
181 Date string `json:"date"`
182 Summary string `json:"summary"`
183 StartLine int `json:"start_line"`
184 Lines []string `json:"lines"`
185 }
186 type out struct {
187 Path string `json:"path"`
188 Ref string `json:"ref"`
189 File string `json:"file"`
190 From int `json:"from"`
191 To int `json:"to"`
192 TotalLines int `json:"total_lines"`
193 Hunks []hunkOut `json:"hunks"`
194 }
195 d := out{Path: repo.Path(), Ref: ref, File: filePath, From: from, To: to,
196 TotalLines: total, Hunks: []hunkOut{}}
197 for _, h := range raw {
198 d.Hunks = append(d.Hunks, hunkOut{
199 SHA: h.SHA, AuthorName: h.AuthorName, AuthorEmail: h.AuthorEmail,
200 Date: time.Unix(h.AuthorUnix, 0).UTC().Format(time.RFC3339),
201 Summary: h.Summary,
202 StartLine: h.StartLine, Lines: h.Lines,
203 })
204 }
205 return c.emit(d, func(w io.Writer) {
206 for _, h := range d.Hunks {
207 for i, line := range h.Lines {
208 fmt.Fprintf(w, "%.10s\t%s\t%d\t%s\n", h.SHA, h.AuthorName, h.StartLine+i, line)
209 }
210 }
211 })
28212 }
29213
30214 // readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape
internal/httpd/accounts.go +9 −26
@@ -474,37 +474,20 @@ func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User
474474 }
475475 ref := r.PathValue("ref")
476476 filePath := strings.Trim(r.PathValue("path"), "/")
477 fail := func(msg string) {
477
478 // Editing is a control command; the web supplies the form and lets
479 // the registry enforce the rules — signed-commit policy, verified
480 // identity, archived repositories — so every surface agrees on them.
481 argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
482 if message := strings.TrimSpace(r.FormValue("message")); message != "" {
483 argv = append(argv, "--message", message)
484 }
485 if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
478486 s.render(w, "edit.html", editPage{
479487 basePage: s.baseFor(u), Repo: repo,
480488 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
481489 })
482 }
483 // Web edits produce unsigned commits; a repo that requires signed
484 // commits must refuse them rather than violate its own policy.
485 if repo.Settings.RequireSignedCommits {
486 fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
487 return
488 }
489 email, err := s.st.PrimaryVerifiedEmail(u.ID)
490 if err != nil {
491 fail("internal error")
492 return
493 }
494 if email == "" {
495 fail("commits carry your identity: your account needs a verified primary email")
496 return
497 }
498 message := strings.TrimSpace(r.FormValue("message"))
499 if message == "" {
500 message = "edit " + filePath
501 }
502 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
503 if _, err := gitutil.CommitFileChange(dir, ref, filePath,
504 []byte(r.FormValue("content")), u.Username, email, message); err != nil {
505 fail(err.Error())
506490 return
507491 }
508 s.st.MarkMirrorsDirty(repo.ID, "push")
509492 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
510493 }
internal/httpd/control.go +38
@@ -65,6 +65,44 @@ func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg
6565 return m, code == protocol.ExitOK
6666 }
6767
68// runControlInto runs a command in JSON mode and decodes its data into
69// target. Read handlers use it so the web renders exactly what the CLI
70// and the API return, rather than reaching past the registry into git.
71func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) {
72 var stdout, stderr bytes.Buffer
73 ctx := &control.Ctx{
74 User: u,
75 Source: "web",
76 Scope: "full",
77 Store: s.st,
78 Cfg: s.cfg,
79 Stdin: strings.NewReader(""),
80 Stdout: &stdout,
81 Stderr: &stderr,
82 JSON: true,
83 ViaAPI: true,
84 }
85 code := control.Dispatch(ctx, argv)
86 var env struct {
87 Data json.RawMessage `json:"data"`
88 Error string `json:"error"`
89 }
90 json.Unmarshal(stdout.Bytes(), &env)
91 if code != protocol.ExitOK {
92 m := env.Error
93 if m == "" {
94 m = strings.TrimSpace(stderr.String())
95 }
96 return m, false
97 }
98 if len(env.Data) > 0 {
99 if err := json.Unmarshal(env.Data, target); err != nil {
100 return "unreadable response", false
101 }
102 }
103 return "", true
104}
105
68106 // runControlJSON runs a command in JSON mode and returns its data object.
69107 // In JSON mode a failure is an envelope carrying the message rather than
70108 // stderr text, so both paths are read from the same envelope.
internal/httpd/web.go +67 −38
@@ -725,10 +725,6 @@ func markMatch(text, q string) template.HTML {
725725 return template.HTML(b.String())
726726 }
727727
728// blamePageSize caps how many lines one blame page renders; blame is a
729// per-line subprocess cost, so large files paginate.
730const blamePageSize = 1000
731
732728 func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
733729 p, ok := s.repoFor(w, r, r.PathValue("ref"))
734730 if !ok {
@@ -736,16 +732,48 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
736732 }
737733 p.Tab = "files"
738734 filePath := strings.Trim(r.PathValue("path"), "/")
739 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
740 if err != nil {
741 s.notFound(w, r)
742 return
735
736 // Blame is a control command; the web renders what it returns rather
737 // than shelling out to git itself, so all three surfaces agree.
738 page := 1
739 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
740 page = n
741 }
742 from := (page-1)*control.BlameSpan + 1
743
744 var out struct {
745 From int `json:"from"`
746 To int `json:"to"`
747 TotalLines int `json:"total_lines"`
748 Hunks []struct {
749 SHA string `json:"sha"`
750 AuthorName string `json:"author_name"`
751 AuthorEmail string `json:"author_email"`
752 Date string `json:"date"`
753 Summary string `json:"summary"`
754 StartLine int `json:"start_line"`
755 Lines []string `json:"lines"`
756 } `json:"hunks"`
757 }
758 argv := []string{"repo", "blame", p.Repo.Path(), filePath,
759 "--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
760 var viewer store.User
761 if s.cfg.Web.Mode == "accounts" {
762 viewer = s.viewer(r)
743763 }
744 total := bytes.Count(data, []byte("\n"))
745 if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
746 total++
764 msg, ok := s.runControlInto(viewer, argv, &out)
765
766 // A binary or empty file is a refusal, not a 404: the page still
767 // renders and says why there is nothing to attribute.
768 binary := false
769 if !ok {
770 if strings.Contains(msg, "is binary") {
771 binary = true
772 } else {
773 s.notFound(w, r)
774 return
775 }
747776 }
748 binary := gitutil.IsBinary(data)
749777
750778 type hunkView struct {
751779 gitutil.BlameHunk
@@ -755,36 +783,37 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
755783 Numbered []numberedLine
756784 }
757785 var hunks []hunkView
758 page, pages := 1, (total+blamePageSize-1)/blamePageSize
786 sigs := map[string]sigView{}
787 for _, h := range out.Hunks {
788 v, seen := sigs[h.SHA]
789 if !seen {
790 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
791 sigs[h.SHA] = v
792 }
793 date := h.Date
794 if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
795 date = t.Format("2006-01-02")
796 }
797 hv := hunkView{
798 BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
799 AuthorEmail: h.AuthorEmail, Summary: h.Summary,
800 StartLine: h.StartLine, Lines: h.Lines},
801 ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
802 }
803 for i, l := range h.Lines {
804 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
805 }
806 hunks = append(hunks, hv)
807 }
808
809 pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
759810 if pages == 0 {
760811 pages = 1
761812 }
762 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
763 page = n
764 }
765 if !binary && total > 0 {
766 start := (page-1)*blamePageSize + 1
767 end := min(total, page*blamePageSize)
768 raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
769 if err != nil {
770 s.notFound(w, r)
771 return
772 }
773 sigs := map[string]sigView{}
774 for _, h := range raw {
775 v, ok := sigs[h.SHA]
776 if !ok {
777 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
778 sigs[h.SHA] = v
779 }
780 hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
781 Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
782 for i, l := range h.Lines {
783 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
784 }
785 hunks = append(hunks, hv)
786 }
813 if page > pages {
814 page = pages
787815 }
816
788817 cs := crumbs(p, "blame", filePath)
789818 base := ""
790819 if len(cs) > 0 {