A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit a715db2f28

a715db2f2843215b5324bf4fec93c67bb4b1ccac

parent: 3467efe33e

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-28T01:18:54Z

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

The web reached past the command registry in two places, which is how
blame and file editing ended up web-only — unreachable from the CLI,
the JSON API, and any native client.

repo blame attributes lines to commits, paging with --from/--to and
capped at BlameSpan, which is also the web's page size. Binary and
empty files are refusals that say why. The web's blame handler now
dispatches it and renders the result; the signature badge stays
presentation, where it belongs.

repo commit-file writes one file and commits it, carrying the rules
the web enforced privately: write access, not archived, a verified
primary email, and a refusal when the repository requires signed
commits — the server authors this commit, so it cannot sign it. The
web's editor dispatches it and renders the refusal.

repo refs lists branches and tags so clients present the same choices
the web does instead of asking people to type a branch name, and
mr show carries its milestone, which was previously a blind write.

runControlInto decodes a command's data into a typed value, so read
handlers can go through the registry without giving up their types.

Closes #42
Closes #43
Closes #45
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 {