Commit 3cc47d6248
Verified · cmc
e2e/blame_test.go added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | func 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 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 13 | type 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. | |
| 25 | func 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 | ||
| 85 | func 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 { | ||
| 33 | 33 | Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome}, |
| 34 | 34 | Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree}, |
| 35 | 35 | 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}, | |
| 36 | 37 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, |
| 37 | 38 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, |
| 38 | 39 | 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) { | ||
| 329 | 329 | }{p, cs, base, filePath, binary, len(data), codeHTML}) |
| 330 | 330 | } |
| 331 | 331 | |
| 332 | // blamePageSize caps how many lines one blame page renders; blame is a | |
| 333 | // per-line subprocess cost, so large files paginate. | |
| 334 | const blamePageSize = 1000 | |
| 335 | ||
| 336 | func (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 | ||
| 409 | type numberedLine struct { | |
| 410 | N int | |
| 411 | Text string | |
| 412 | } | |
| 413 | ||
| 332 | 414 | func highlight(filePath string, data []byte) template.HTML { |
| 333 | 415 | lexer := lexers.Match(filePath) |
| 334 | 416 | if lexer == nil { |
internal/web/static/style.css +47
| @@ -495,6 +495,50 @@ footer p { margin: 0; } | ||
| 495 | 495 | footer a { color: var(--muted); text-decoration: underline; } |
| 496 | 496 | footer a:hover { color: var(--accent); } |
| 497 | 497 | |
| 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 | ||
| 498 | 542 | /* mobile */ |
| 499 | 543 | @media (max-width: 40rem) { |
| 500 | 544 | header { padding: var(--sp-3) var(--sp-4); } |
| @@ -502,6 +546,9 @@ footer a:hover { color: var(--accent); } | ||
| 502 | 546 | .thread { margin-left: var(--sp-3); } |
| 503 | 547 | table.tree td.mode { display: none; } |
| 504 | 548 | .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%; } | |
| 505 | 552 | .cardhead { |
| 506 | 553 | margin: calc(-1 * var(--sp-3)) calc(-1 * var(--sp-3)) var(--sp-3); |
| 507 | 554 | 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 @@ | ||
| 5 | 5 | <span class="refchip">{{template "branchicon"}} {{.Ref}}</span> |
| 6 | 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 | 7 | <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> | |
| 9 | 9 | </div> |
| 10 | 10 | {{if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> |
| 11 | 11 | {{else}}<div class="code">{{.CodeHTML}}</div>{{end}} |
internal/web/web.go +2
| @@ -44,6 +44,8 @@ var funcs = template.FuncMap{ | ||
| 44 | 44 | } |
| 45 | 45 | return s |
| 46 | 46 | }, |
| 47 | "add": func(a, b int) int { return a + b }, | |
| 48 | "sub": func(a, b int) int { return a - b }, | |
| 47 | 49 | // when formats a stored RFC3339 timestamp for display; unparseable |
| 48 | 50 | // values pass through unchanged. |
| 49 | 51 | "when": func(s string) string { |