A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 3cc47d6248

3cc47d624819525ab996bc2f98c3ed27b2dbcf05

parent: 61a3d5dea2

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T17:50:26Z

Add blame view (#24)

GET /{owner}/{repo}/blame/{ref}/{path} renders git blame --porcelain as
hunks: consecutive same-commit lines grouped, each with linked subject,
short sha, author, date, and the commit's signature badge. Files
paginate at 1000 lines via -L ranges (?page=N). Blob pages link to
blame; binary files get a note. New add/sub template funcs for the
pager.
e2e/blame_test.go added +87
@@ -0,0 +1,87 @@
1package e2e
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11func TestBlameView(t *testing.T) {
12 inst := startInstance(t)
13 aliceKey := inst.newKey(t, "alice")
14 inst.admin(t, "admin", "user", "create", "alice",
15 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
16
17 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
18 t.Fatalf("repo create: %s", errOut)
19 }
20 work := t.TempDir()
21 env := inst.gitEnv(aliceKey)
22 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
23 dir := filepath.Join(work, "w")
24
25 // Two commits attributing different lines of the same file.
26 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\ntwo\nthree\n"), 0o644)
27 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
28 mustGit(t, dir, env, "add", ".")
29 mustGit(t, dir, env, "commit", "-q", "-m", "first lines")
30 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\nTWO CHANGED\nthree\n"), 0o644)
31 mustGit(t, dir, env, "add", ".")
32 mustGit(t, dir, env, "commit", "-q", "-m", "change line two")
33 mustGit(t, dir, env, "push", "-q", "origin", "main")
34
35 status, body := inst.get(t, "/alice/app/blame/main/f.txt")
36 if status != 200 {
37 t.Fatalf("blame page: %d", status)
38 }
39 // Three hunks (first commit / second commit / first commit), each with
40 // its commit subject linked and a signature badge.
41 if got := strings.Count(body, `class="blamehunk"`); got != 3 {
42 t.Fatalf("expected 3 hunks, got %d", got)
43 }
44 for _, want := range []string{
45 ">first lines</a>", ">change line two</a>",
46 `<span class="lineno">2</span>TWO CHANGED`,
47 `<span class="lineno">3</span>three`,
48 `badge badge-unsigned`,
49 "/alice/app/commit/",
50 } {
51 if !strings.Contains(body, want) {
52 t.Errorf("missing %q on blame page", want)
53 }
54 }
55
56 // The blob page links to blame.
57 _, blob := inst.get(t, "/alice/app/blob/main/f.txt")
58 if !strings.Contains(blob, `/alice/app/blame/main/f.txt">blame</a>`) {
59 t.Error("blob page missing blame link")
60 }
61
62 // Pagination: 1001 lines means two pages; page 2 holds only the last line.
63 var big strings.Builder
64 for i := 1; i <= 1001; i++ {
65 fmt.Fprintf(&big, "line %d\n", i)
66 }
67 os.WriteFile(filepath.Join(dir, "big.txt"), []byte(big.String()), 0o644)
68 mustGit(t, dir, env, "add", ".")
69 mustGit(t, dir, env, "commit", "-q", "-m", "big file")
70 mustGit(t, dir, env, "push", "-q", "origin", "main")
71
72 _, body = inst.get(t, "/alice/app/blame/main/big.txt")
73 if !strings.Contains(body, "page 1 of 2") || !strings.Contains(body, `<span class="lineno">1000</span>line 1000`) ||
74 strings.Contains(body, ">line 1001") {
75 t.Fatal("page 1 wrong")
76 }
77 _, body = inst.get(t, "/alice/app/blame/main/big.txt?page=2")
78 if !strings.Contains(body, `<span class="lineno">1001</span>line 1001`) ||
79 strings.Contains(body, ">line 1000<") || !strings.Contains(body, "earlier lines") {
80 t.Fatal("page 2 wrong")
81 }
82
83 // Nonexistent path 404s.
84 if status, _ := inst.get(t, "/alice/app/blame/main/nope.txt"); status != 404 {
85 t.Fatalf("missing file blame: %d", status)
86 }
87}
internal/gitutil/blame.go added +96
@@ -0,0 +1,96 @@
1package gitutil
2
3import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "os/exec"
8 "strconv"
9 "strings"
10)
11
12// BlameHunk is a run of consecutive lines attributed to one commit.
13type BlameHunk struct {
14 SHA string
15 AuthorName string
16 AuthorEmail string
17 AuthorUnix int64
18 Summary string
19 StartLine int // file line number of Lines[0]
20 Lines []string
21}
22
23// Blame attributes lines start..end (1-based, inclusive) of path at ref,
24// merging consecutive same-commit lines into hunks.
25func Blame(dir, ref, path string, start, end int) ([]BlameHunk, error) {
26 cmd := exec.Command("git", "-C", dir, "blame", "--porcelain",
27 fmt.Sprintf("-L%d,%d", start, end), ref, "--", path)
28 out, err := cmd.Output()
29 if err != nil {
30 return nil, fmt.Errorf("git blame %s at %s: %w", path, ref, err)
31 }
32 type meta struct {
33 name, email, summary string
34 unix int64
35 }
36 metas := map[string]*meta{}
37 var hunks []BlameHunk
38 sc := bufio.NewScanner(bytes.NewReader(out))
39 sc.Buffer(make([]byte, 1<<20), 1<<20)
40 var cur string
41 var curLine int
42 for sc.Scan() {
43 line := sc.Text()
44 if strings.HasPrefix(line, "\t") {
45 // Content line; metadata for cur (if any) has already been seen.
46 content := line[1:]
47 if n := len(hunks) - 1; n >= 0 && hunks[n].SHA == cur &&
48 hunks[n].StartLine+len(hunks[n].Lines) == curLine {
49 hunks[n].Lines = append(hunks[n].Lines, content)
50 } else {
51 h := BlameHunk{SHA: cur, StartLine: curLine, Lines: []string{content}}
52 if m := metas[cur]; m != nil {
53 h.AuthorName, h.AuthorEmail, h.AuthorUnix, h.Summary = m.name, m.email, m.unix, m.summary
54 }
55 hunks = append(hunks, h)
56 }
57 continue
58 }
59 if f := strings.Fields(line); len(f) >= 3 && isSHA(f[0]) {
60 cur = f[0]
61 curLine, _ = strconv.Atoi(f[2])
62 if metas[cur] == nil {
63 metas[cur] = &meta{}
64 }
65 continue
66 }
67 m := metas[cur]
68 if m == nil {
69 continue
70 }
71 switch {
72 case strings.HasPrefix(line, "author "):
73 m.name = line[len("author "):]
74 case strings.HasPrefix(line, "author-mail "):
75 m.email = strings.Trim(line[len("author-mail "):], "<>")
76 case strings.HasPrefix(line, "author-time "):
77 m.unix, _ = strconv.ParseInt(line[len("author-time "):], 10, 64)
78 case strings.HasPrefix(line, "summary "):
79 m.summary = line[len("summary "):]
80 }
81 }
82 return hunks, sc.Err()
83}
84
85func isSHA(s string) bool {
86 if len(s) != 40 {
87 return false
88 }
89 for i := 0; i < len(s); i++ {
90 c := s[i]
91 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
92 return false
93 }
94 }
95 return true
96}
internal/httpd/routes.go +1
@@ -33,6 +33,7 @@ func (s *Server) Routes() []Route {
3333 Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome},
3434 Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree},
3535 Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob},
36 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame},
3637 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},
3738 Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log},
3839 Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log},
internal/httpd/web.go +82
@@ -329,6 +329,88 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
329329 }{p, cs, base, filePath, binary, len(data), codeHTML})
330330 }
331331
332// blamePageSize caps how many lines one blame page renders; blame is a
333// per-line subprocess cost, so large files paginate.
334const blamePageSize = 1000
335
336func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
337 p, ok := s.repoFor(w, r, r.PathValue("ref"))
338 if !ok {
339 return
340 }
341 p.Tab = "files"
342 filePath := strings.Trim(r.PathValue("path"), "/")
343 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
344 if err != nil {
345 s.notFound(w, r)
346 return
347 }
348 total := bytes.Count(data, []byte("\n"))
349 if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
350 total++
351 }
352 binary := gitutil.IsBinary(data)
353
354 type hunkView struct {
355 gitutil.BlameHunk
356 ShortSHA string
357 Date string
358 Sig sigView
359 Numbered []numberedLine
360 }
361 var hunks []hunkView
362 page, pages := 1, (total+blamePageSize-1)/blamePageSize
363 if pages == 0 {
364 pages = 1
365 }
366 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
367 page = n
368 }
369 if !binary && total > 0 {
370 start := (page-1)*blamePageSize + 1
371 end := min(total, page*blamePageSize)
372 raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
373 if err != nil {
374 s.notFound(w, r)
375 return
376 }
377 sigs := map[string]sigView{}
378 for _, h := range raw {
379 v, ok := sigs[h.SHA]
380 if !ok {
381 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
382 sigs[h.SHA] = v
383 }
384 hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
385 Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
386 for i, l := range h.Lines {
387 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
388 }
389 hunks = append(hunks, hv)
390 }
391 }
392 cs := crumbs(p, "blame", filePath)
393 base := ""
394 if len(cs) > 0 {
395 base = cs[len(cs)-1].Name
396 cs = cs[:len(cs)-1]
397 }
398 s.render(w, "blame.html", struct {
399 repoPage
400 Crumbs []crumb
401 Base string
402 Path string
403 Binary bool
404 Hunks []hunkView
405 Page, Pages int
406 }{p, cs, base, filePath, binary, hunks, page, pages})
407}
408
409type numberedLine struct {
410 N int
411 Text string
412}
413
332414 func highlight(filePath string, data []byte) template.HTML {
333415 lexer := lexers.Match(filePath)
334416 if lexer == nil {
internal/web/static/style.css +47
@@ -495,6 +495,50 @@ footer p { margin: 0; }
495495 footer a { color: var(--muted); text-decoration: underline; }
496496 footer a:hover { color: var(--accent); }
497497
498/* blame */
499.blame {
500 border: 1px solid var(--line);
501 border-radius: var(--rad-2, 6px);
502 overflow: hidden;
503}
504.blamehunk {
505 display: flex;
506 gap: var(--sp-3);
507 border-top: 1px solid var(--line);
508 align-items: flex-start;
509}
510.blamehunk:first-child { border-top: none; }
511.blameinfo {
512 flex: none;
513 width: 17rem;
514 padding: var(--sp-2) var(--sp-3);
515 min-width: 0;
516}
517.blameinfo .subject {
518 margin: 0;
519 font-size: var(--fs-1);
520 white-space: nowrap;
521 overflow: hidden;
522 text-overflow: ellipsis;
523}
524.blameinfo .meta { margin: 0; font-size: var(--fs-1); color: var(--muted); }
525.blamecode {
526 flex: 1;
527 margin: 0;
528 padding: var(--sp-2) var(--sp-3) var(--sp-2) 0;
529 overflow-x: auto;
530 border-left: 1px solid var(--line);
531 align-self: stretch;
532}
533.lineno {
534 display: inline-block;
535 min-width: 3.5em;
536 padding-right: 1em;
537 text-align: right;
538 color: var(--muted);
539 user-select: none;
540}
541
498542 /* mobile */
499543 @media (max-width: 40rem) {
500544 header { padding: var(--sp-3) var(--sp-4); }
@@ -502,6 +546,9 @@ footer a:hover { color: var(--accent); }
502546 .thread { margin-left: var(--sp-3); }
503547 table.tree td.mode { display: none; }
504548 .readme, .code { padding: var(--sp-3); }
549 .blamehunk { flex-direction: column; gap: 0; }
550 .blameinfo { width: auto; }
551 .blamecode { border-left: none; padding-left: var(--sp-3); width: 100%; }
505552 .cardhead {
506553 margin: calc(-1 * var(--sp-3)) calc(-1 * var(--sp-3)) var(--sp-3);
507554 padding: var(--sp-2) var(--sp-3);
internal/web/templates/blame.html added +25
@@ -0,0 +1,25 @@
1{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{template "repoheader" .}}
4<div class="pathbar">
5 <span class="refchip">{{template "branchicon"}} {{.Ref}}</span>
6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
7 <span class="spacer"></span>
8 <span class="actions"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.Path}}">view</a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a></span>
9</div>
10{{if .Binary}}<p class="empty-note">binary file — blame unavailable</p>
11{{else}}
12<div class="blame">
13{{range .Hunks}}<div class="blamehunk">
14 <div class="blameinfo">
15 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Summary}}</a></p>
16 <p class="meta"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> <span title="{{.AuthorEmail}}">{{.AuthorName}}</span> · {{.Date}} {{template "sigbadge" .Sig}}</p>
17 </div>
18 <pre class="blamecode">{{range .Numbered}}<span class="lineno">{{.N}}</span>{{.Text}}
19{{end}}</pre>
20</div>
21{{else}}<p class="empty-note">empty file</p>{{end}}
22</div>
23{{if gt .Pages 1}}<p class="pager">page {{.Page}} of {{.Pages}}{{if gt .Page 1}} · <a href="?page={{sub .Page 1}}">← earlier lines</a>{{end}}{{if lt .Page .Pages}} · <a href="?page={{add .Page 1}}">later lines →</a>{{end}}</p>{{end}}
24{{end}}
25{{end}}
internal/web/templates/blob.html +1 −1
@@ -5,7 +5,7 @@
55 <span class="refchip">{{template "branchicon"}} {{.Ref}}</span>
66 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
77 <span class="spacer"></span>
8 <span class="actions"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span>
8 <span class="actions">{{if not .Binary}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blame/{{.Ref}}/{{.Path}}">blame</a> · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span>
99 </div>
1010 {{if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
1111 {{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
internal/web/web.go +2
@@ -44,6 +44,8 @@ var funcs = template.FuncMap{
4444 }
4545 return s
4646 },
47 "add": func(a, b int) int { return a + b },
48 "sub": func(a, b int) int { return a - b },
4749 // when formats a stored RFC3339 timestamp for display; unparseable
4850 // values pass through unchanged.
4951 "when": func(s string) string {