A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit d122636fc9

d122636fc915dc95728bc74a3990da1630aa36d3

parent: 92a519ae8a

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26T04:37:29Z

web: a real diff view, and commit references name their author

Diffs render one foldable section per file with line-number gutters,
per-file stats, rename and binary handling, and syntax highlighting run
per hunk per side so multi-line constructs lex as real code. Commit and
merge request pages share the partial.

Issue references from commit messages now read "referenced in commit
<sha> by <author>", linking the author when their email is verified
here.

Ref #35
e2e/commitrefs_test.go +17 −1
@@ -34,8 +34,10 @@ func TestCommitMessageIssueActions(t *testing.T) {
3434 // A closing keyword on the default branch closes the issue with a
3535 // comment; a bare reference (and a nonexistent #99) only comments.
3636 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
37 aliceEnv := append(append([]string{}, env...),
38 "GIT_AUTHOR_NAME=Alice", "GIT_AUTHOR_EMAIL=alice@example.test")
3739 mustGit(t, dir, env, "add", ".")
38 mustGit(t, dir, env, "commit", "-q", "-m", "repair the widget\n\nFixes #1. Related to #2 but not #99.")
40 mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "repair the widget\n\nFixes #1. Related to #2 but not #99.")
3941 mustGit(t, dir, env, "push", "-q", "origin", "main")
4042
4143 out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
@@ -55,6 +57,15 @@ func TestCommitMessageIssueActions(t *testing.T) {
5557 !strings.Contains(out, "repair the widget") {
5658 t.Fatalf("issue 2 not referenced: %s", out)
5759 }
60 // The reference names who wrote the commit, and links them when the
61 // author email is verified on an account here.
62 if !strings.Contains(out, "by [alice](/alice)") {
63 t.Fatalf("reference does not attribute the author: %s", out)
64 }
65 if status, body := inst.get(t, "/alice/app/issues/2"); status != 200 ||
66 !strings.Contains(body, `href="/alice"`) {
67 t.Fatalf("web reference does not link the author: %d\n%s", status, body)
68 }
5869
5970 // Commits on a feature branch do nothing until they land on the
6071 // default branch via a merge — then the merge path acts exactly once.
@@ -78,6 +89,11 @@ func TestCommitMessageIssueActions(t *testing.T) {
7889 if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") {
7990 t.Fatalf("merge did not close issue 3: %s", out)
8091 }
92 // This one was authored by an address nobody has verified, so it names
93 // git's author without inventing a profile link for them.
94 if !strings.Contains(out, "by t:") || strings.Contains(out, "by [t]") {
95 t.Fatalf("unresolved author should stay plain text: %s", out)
96 }
8197 if strings.Count(out, "closed by commit") != 1 {
8298 t.Fatalf("duplicate close comments: %s", out)
8399 }
e2e/dashboard_test.go +2 −2
@@ -110,11 +110,11 @@ func TestDashboard(t *testing.T) {
110110 if !strings.Contains(body, `href="/alice/app/mrs/1?view=diff"`) {
111111 t.Fatal("merge request missing the files-changed view")
112112 }
113 if strings.Contains(body, `class="diff"`) {
113 if strings.Contains(body, `class="difftable"`) {
114114 t.Fatal("diff rendered on the conversation view")
115115 }
116116 _, body = inst.get(t, "/alice/app/mrs/1?view=diff")
117 if !strings.Contains(body, "1 file changed") || !strings.Contains(body, `class="diff"`) {
117 if !strings.Contains(body, "1 file changed") || !strings.Contains(body, `class="difftable"`) {
118118 t.Fatalf("diff view missing stat or patch:\n%s", body)
119119 }
120120 }
e2e/diffweb_test.go added +104
@@ -0,0 +1,104 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// TestDiffRendering covers the shared diff view: per-file folds with stats,
11// line-number gutters, syntax highlighting, and binary files declared
12// rather than dumped.
13func TestDiffRendering(t *testing.T) {
14 inst := startInstance(t)
15 aliceKey := inst.newKey(t, "alice")
16 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
17 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
18 t.Fatalf("repo create: %s", errOut)
19 }
20
21 work := t.TempDir()
22 env := inst.gitEnv(aliceKey)
23 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
24 dir := filepath.Join(work, "w")
25 write := func(name, body string) {
26 os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644)
27 }
28 write("main.go", "package main\n\nfunc greet() string {\n\treturn \"hello\"\n}\n")
29 write("notes.txt", "old title\n")
30 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
31 mustGit(t, dir, env, "add", ".")
32 mustGit(t, dir, env, "commit", "-q", "-m", "base")
33 mustGit(t, dir, env, "push", "-q", "origin", "main")
34
35 // One commit touching three files in three different ways.
36 write("main.go", "package main\n\nfunc greet() string {\n\t// now with feeling\n\treturn \"HELLO\"\n}\n")
37 os.Rename(filepath.Join(dir, "notes.txt"), filepath.Join(dir, "README.md"))
38 os.WriteFile(filepath.Join(dir, "logo.png"), []byte("\x89PNG\r\n\x1a\n\x00\x00binary"), 0o644)
39 mustGit(t, dir, env, "add", "-A")
40 mustGit(t, dir, env, "commit", "-q", "-m", "rework")
41 mustGit(t, dir, env, "push", "-q", "origin", "main")
42
43 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/app", "--json")
44 sha := jsonField(out, "sha")
45 if sha == "" {
46 t.Fatalf("no sha in log: %s", out)
47 }
48
49 status, body := inst.get(t, "/alice/app/commit/"+sha)
50 if status != 200 {
51 t.Fatalf("commit page: %d", status)
52 }
53
54 // A fold per file, each naming its path.
55 for _, path := range []string{"main.go", "logo.png"} {
56 if !strings.Contains(body, ">"+path+"<") {
57 t.Errorf("no section for %s", path)
58 }
59 }
60 if strings.Count(body, `<details class="difffold"`) != 3 {
61 t.Errorf("want 3 file sections, got %d", strings.Count(body, `<details class="difffold"`))
62 }
63 // The rename is shown as one, not as an add plus a delete.
64 if !strings.Contains(body, "notes.txt") || !strings.Contains(body, "renamed") {
65 t.Error("rename not shown as a rename")
66 }
67 // Binary content is declared, never dumped into the page.
68 if !strings.Contains(body, "Binary file not shown") {
69 t.Error("binary file not declared")
70 }
71 if strings.Contains(body, "\x89PNG") {
72 t.Error("binary content leaked into the diff")
73 }
74 // Line-number gutters and per-file stats.
75 if !strings.Contains(body, `<td class="ln">`) {
76 t.Error("no line-number gutter")
77 }
78 if !strings.Contains(body, `<span class="add">+`) || !strings.Contains(body, `<span class="del">−`) {
79 t.Error("no per-file stat")
80 }
81 // Go is a type chroma knows, so the added line carries token markup.
82 if !strings.Contains(body, "class=\"k\"") && !strings.Contains(body, "class=\"kd\"") {
83 t.Error("diff content is not syntax highlighted")
84 }
85 // The +/- markers are CSS, so a copied selection is real source.
86 if strings.Contains(body, `<td class="code">+`) {
87 t.Error("diff markers are in the markup, not the stylesheet")
88 }
89}
90
91// jsonField pulls the first "name":"value" string out of a JSON blob.
92func jsonField(blob, name string) string {
93 key := `"` + name + `":"`
94 i := strings.Index(blob, key)
95 if i < 0 {
96 return ""
97 }
98 rest := blob[i+len(key):]
99 j := strings.IndexByte(rest, '"')
100 if j < 0 {
101 return ""
102 }
103 return rest[:j]
104}
internal/control/commitrefs.go +19 −5
@@ -47,11 +47,12 @@ func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID
4747 }
4848 }
4949 subject, _, _ := strings.Cut(m.Message, "\n")
50 author := authorLink(st, m.AuthorName, m.AuthorEmail)
5051 for n := range closes {
51 actOnIssue(st, repo, actorID, m.SHA, n, true, subject)
52 actOnIssue(st, repo, actorID, m.SHA, n, true, subject, author)
5253 }
5354 for n := range refs {
54 actOnIssue(st, repo, actorID, m.SHA, n, false, subject)
55 actOnIssue(st, repo, actorID, m.SHA, n, false, subject, author)
5556 }
5657 }
5758 }
@@ -72,7 +73,20 @@ func RecordLandedCommits(st *store.Store, dir string, repo store.Repo, old, new
7273 }
7374 }
7475
75func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject string) {
76// authorLink renders the commit's author for a system comment: a link to
77// their profile when the author email is verified on an account here, and
78// the name git recorded otherwise.
79func authorLink(st *store.Store, name, email string) string {
80 if user, ok := st.UsernameByVerifiedEmail(email); ok {
81 return fmt.Sprintf("[%s](/%s)", user, user)
82 }
83 if name == "" {
84 return email
85 }
86 return name
87}
88
89func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject, author string) {
7690 issue, err := st.IssueByNumber(repo.ID, number)
7791 if err != nil {
7892 return // no such issue: the reference is just text
@@ -93,9 +107,9 @@ func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, num
93107 slog.Error("commit refs: closing issue", "issue", number, "err", err)
94108 return
95109 }
96 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s: %s", link, subject))
110 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s by %s: %s", link, author, subject))
97111 st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha))
98112 return
99113 }
100 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s: %s", link, subject))
114 st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s by %s: %s", link, author, subject))
101115 }
internal/gitutil/messages.go +19 −4
@@ -62,15 +62,17 @@ func HasCommit(dir, sha string) bool {
6262 }
6363
6464 type CommitMsg struct {
65 SHA string
66 Message string
65 SHA string
66 Message string
67 AuthorName string
68 AuthorEmail string
6769 }
6870
6971 // RevListMessages returns sha and full message for commits reachable from
7072 // new but not old, newest first, capped at max. An empty or zero old (new
7173 // branch) lists from new alone, still capped.
7274 func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) {
73 args := []string{"-C", dir, "rev-list", fmt.Sprintf("-n%d", max), "--format=%B%x00", new}
75 args := []string{"-C", dir, "rev-list", fmt.Sprintf("-n%d", max), "--format=%an%x01%ae%x01%B%x00", new}
7476 if old != "" && old != zeroSHA {
7577 args = append(args, "^"+old)
7678 }
@@ -88,7 +90,20 @@ func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) {
8890 if !ok || !strings.HasPrefix(header, "commit ") {
8991 continue
9092 }
91 msgs = append(msgs, CommitMsg{SHA: strings.TrimPrefix(header, "commit "), Message: strings.TrimSpace(body)})
93 name, rest, ok := strings.Cut(body, "\x01")
94 if !ok {
95 continue
96 }
97 email, message, ok := strings.Cut(rest, "\x01")
98 if !ok {
99 continue
100 }
101 msgs = append(msgs, CommitMsg{
102 SHA: strings.TrimPrefix(header, "commit "),
103 Message: strings.TrimSpace(message),
104 AuthorName: name,
105 AuthorEmail: email,
106 })
92107 }
93108 return msgs, nil
94109 }
internal/httpd/diff.go added +305
@@ -0,0 +1,305 @@
1package httpd
2
3import (
4 "bytes"
5 "html/template"
6 "regexp"
7 "strconv"
8 "strings"
9
10 "github.com/alecthomas/chroma/v2"
11 "github.com/alecthomas/chroma/v2/formatters/html"
12 "github.com/alecthomas/chroma/v2/lexers"
13 "github.com/alecthomas/chroma/v2/styles"
14)
15
16type diffLine struct {
17 Class string // meta | hunk | add | del | ctx
18 Text string // the raw diff line, marker included
19 Content string // the line without its +/- marker
20 Code template.HTML // Content highlighted; empty when the type is unknown
21 Path string // file this line belongs to
22 NewLine int64 // line number in the new file (0 when absent)
23 OldLine int64 // line number in the old file (0 when absent)
24 Threads []diffThread
25}
26
27// diffFile is one file's worth of a unified diff: the header lines are
28// consumed into the fields here, so the template renders a section rather
29// than replaying "diff --git" at the reader.
30type diffFile struct {
31 Path string // new path; the old one for a delete
32 OldPath string // set only on a rename
33 Status string // added | deleted | renamed | modified
34 Adds int
35 Dels int
36 Binary bool
37 Lines []diffLine
38 Threads int // threads anchored in this file, so it can stay unfolded
39 Open bool // rendered unfolded: small files, and anything under review
40}
41
42type diffStat struct{ Files, Adds, Dels int }
43
44var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
45
46// parseDiff splits a unified diff into per-file sections, tracking old and
47// new line numbers so review threads can anchor inline.
48func parseDiff(patch string) []diffFile {
49 var files []diffFile
50 var cur *diffFile
51 var oldN, newN int64
52
53 // Paths arrive both in "diff --git a/x b/y" and in the ---/+++ pair.
54 // The latter is authoritative (it survives quoting oddities), so the
55 // git line only opens the section.
56 start := func() *diffFile {
57 files = append(files, diffFile{Status: "modified"})
58 return &files[len(files)-1]
59 }
60
61 for _, l := range strings.Split(patch, "\n") {
62 switch {
63 case strings.HasPrefix(l, "diff --git "):
64 cur = start()
65 if a, b, ok := gitHeaderPaths(l); ok {
66 cur.OldPath, cur.Path = a, b
67 }
68 continue
69 case cur == nil:
70 continue // preamble before the first file
71 case strings.HasPrefix(l, "new file mode"):
72 cur.Status = "added"
73 continue
74 case strings.HasPrefix(l, "deleted file mode"):
75 cur.Status = "deleted"
76 continue
77 case strings.HasPrefix(l, "rename from "):
78 cur.Status, cur.OldPath = "renamed", strings.TrimPrefix(l, "rename from ")
79 continue
80 case strings.HasPrefix(l, "rename to "):
81 cur.Status, cur.Path = "renamed", strings.TrimPrefix(l, "rename to ")
82 continue
83 case strings.HasPrefix(l, "Binary files "), strings.HasPrefix(l, "GIT binary patch"):
84 cur.Binary = true
85 continue
86 case strings.HasPrefix(l, "--- "):
87 if p := strings.TrimPrefix(l, "--- "); p != "/dev/null" {
88 cur.OldPath = strings.TrimPrefix(p, "a/")
89 }
90 continue
91 case strings.HasPrefix(l, "+++ "):
92 if p := strings.TrimPrefix(l, "+++ "); p != "/dev/null" {
93 cur.Path = strings.TrimPrefix(p, "b/")
94 }
95 continue
96 case strings.HasPrefix(l, "index "), strings.HasPrefix(l, "old mode "),
97 strings.HasPrefix(l, "new mode "), strings.HasPrefix(l, "similarity index "),
98 strings.HasPrefix(l, "dissimilarity index "):
99 continue
100 }
101
102 d := diffLine{Text: l, Content: l, Path: cur.Path}
103 switch {
104 case strings.HasPrefix(l, "@@"):
105 d.Class, d.Path = "hunk", ""
106 if m := hunkPat.FindStringSubmatch(l); m != nil {
107 oldN, _ = strconv.ParseInt(m[1], 10, 64)
108 newN, _ = strconv.ParseInt(m[2], 10, 64)
109 }
110 case strings.HasPrefix(l, "+"):
111 d.Class, d.Content, d.NewLine = "add", l[1:], newN
112 newN++
113 cur.Adds++
114 case strings.HasPrefix(l, "-"):
115 d.Class, d.Content, d.OldLine = "del", l[1:], oldN
116 oldN++
117 cur.Dels++
118 case l == `\ No newline at end of file`:
119 d.Class, d.Path = "meta", ""
120 case l == "":
121 continue // trailing newline from the split
122 default:
123 d.Class, d.Content, d.OldLine, d.NewLine = "ctx", l[1:], oldN, newN
124 oldN++
125 newN++
126 }
127 cur.Lines = append(cur.Lines, d)
128 }
129
130 for i := range files {
131 if files[i].Path == "" {
132 files[i].Path = files[i].OldPath
133 }
134 if files[i].Status == "renamed" && files[i].OldPath == files[i].Path {
135 files[i].Status = "modified"
136 }
137 highlightFile(&files[i])
138 // Big files fold shut so a large diff is navigable; anything
139 // carrying review threads stays open regardless.
140 files[i].Open = len(files[i].Lines) <= 300
141 }
142 return files
143}
144
145// gitHeaderPaths pulls both paths out of a "diff --git a/x b/y" line. Paths
146// with spaces make this ambiguous in general; git quotes those, and the
147// ---/+++ lines correct us either way.
148func gitHeaderPaths(l string) (string, string, bool) {
149 rest := strings.TrimPrefix(l, "diff --git ")
150 i := strings.Index(rest, " b/")
151 if !strings.HasPrefix(rest, "a/") || i < 0 {
152 return "", "", false
153 }
154 return rest[2:i], rest[i+3:], true
155}
156
157// diffFormatter is the blob formatter without line numbers: the diff
158// supplies its own gutters.
159var diffFormatter = html.New(html.WithClasses(true))
160
161// highlightFile syntax-highlights a file's diff content one hunk at a time,
162// each side separately. A hunk's context+deletions are contiguous lines of
163// the old file and its context+additions are contiguous lines of the new
164// one, so each side lexes as real code — highlighting line by line instead
165// would break every multi-line string and block comment.
166func highlightFile(f *diffFile) {
167 if f.Binary || len(f.Lines) == 0 {
168 return
169 }
170 lexer := lexers.Match(f.Path)
171 if lexer == nil {
172 return // unknown type: plain text reads fine, and guessing is worse
173 }
174 for start := 0; start < len(f.Lines); {
175 if f.Lines[start].Class == "hunk" || f.Lines[start].Class == "meta" {
176 start++
177 continue
178 }
179 end := start
180 for end < len(f.Lines) && f.Lines[end].Class != "hunk" && f.Lines[end].Class != "meta" {
181 end++
182 }
183 hunk := f.Lines[start:end]
184 assign(hunk, "del", highlightLines(lexer, sideText(hunk, "del")))
185 assign(hunk, "add", highlightLines(lexer, sideText(hunk, "add")))
186 start = end
187 }
188}
189
190// sideText joins one side of a hunk: context plus the given change class.
191func sideText(hunk []diffLine, class string) string {
192 var b strings.Builder
193 for _, l := range hunk {
194 if l.Class == "ctx" || l.Class == class {
195 b.WriteString(strings.TrimPrefix(strings.TrimPrefix(l.Text, "+"), "-"))
196 b.WriteByte('\n')
197 }
198 }
199 return b.String()
200}
201
202// assign hands highlighted lines back to the diff lines they came from.
203// Context lines take whichever side ran last; both sides hold identical
204// text there, so the result is the same either way.
205func assign(hunk []diffLine, class string, out []template.HTML) {
206 i := 0
207 for j := range hunk {
208 if hunk[j].Class != "ctx" && hunk[j].Class != class {
209 continue
210 }
211 if i < len(out) {
212 hunk[j].Code = out[i]
213 }
214 i++
215 }
216}
217
218// highlightLines formats source and splits the result back into lines.
219// chroma emits tokens that may span newlines, so the split happens on the
220// rendered HTML with tags reopened per line.
221func highlightLines(lexer chroma.Lexer, src string) []template.HTML {
222 if src == "" {
223 return nil
224 }
225 it, err := lexer.Tokenise(nil, src)
226 if err != nil {
227 return nil
228 }
229 var buf bytes.Buffer
230 if err := diffFormatter.Format(&buf, styles.Get("friendly"), it); err != nil {
231 return nil
232 }
233 body := buf.String()
234 // Strip the wrapper chroma puts around the whole block.
235 if i := strings.Index(body, "<code"); i >= 0 {
236 if j := strings.IndexByte(body[i:], '>'); j >= 0 {
237 body = body[i+j+1:]
238 }
239 }
240 body = strings.TrimSuffix(strings.TrimSuffix(body, "</pre>"), "</code>")
241 body = strings.TrimSuffix(body, "\n")
242
243 var out []template.HTML
244 for _, line := range splitHighlighted(body) {
245 out = append(out, template.HTML(line))
246 }
247 return out
248}
249
250// splitHighlighted breaks formatted HTML on newlines that sit outside a
251// tag, closing and reopening the spans that straddle the break so every
252// line is balanced markup on its own.
253func splitHighlighted(body string) []string {
254 var lines []string
255 var open []string
256 var cur strings.Builder
257 for i := 0; i < len(body); {
258 switch body[i] {
259 case '<':
260 j := strings.IndexByte(body[i:], '>')
261 if j < 0 {
262 cur.WriteString(body[i:])
263 i = len(body)
264 continue
265 }
266 tag := body[i : i+j+1]
267 if strings.HasPrefix(tag, "</") {
268 if len(open) > 0 {
269 open = open[:len(open)-1]
270 }
271 } else if !strings.HasSuffix(tag, "/>") {
272 open = append(open, tag)
273 }
274 cur.WriteString(tag)
275 i += j + 1
276 case '\n':
277 for range open {
278 cur.WriteString("</span>")
279 }
280 lines = append(lines, cur.String())
281 cur.Reset()
282 for _, t := range open {
283 cur.WriteString(t)
284 }
285 i++
286 default:
287 cur.WriteByte(body[i])
288 i++
289 }
290 }
291 if cur.Len() > 0 {
292 lines = append(lines, cur.String())
293 }
294 return lines
295}
296
297// statOf totals a parsed diff for the summary line.
298func statOf(files []diffFile) diffStat {
299 st := diffStat{Files: len(files)}
300 for _, f := range files {
301 st.Adds += f.Adds
302 st.Dels += f.Dels
303 }
304 return st
305}
internal/httpd/diff_test.go added +174
@@ -0,0 +1,174 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6)
7
8const samplePatch = `diff --git a/main.go b/main.go
9index 1234567..89abcde 100644
10-- a/main.go
11+++ b/main.go
12@@ -1,6 +1,7 @@
13 package main
14
15func old() string {
16 return "a"
17+func replaced() string {
18+ // a comment
19+ return "b"
20 }
21diff --git a/notes.txt b/README.md
22similarity index 60%
23rename from notes.txt
24rename to README.md
25-- a/notes.txt
26+++ b/README.md
27@@ -1 +1 @@
28old title
29+new title
30diff --git a/logo.png b/logo.png
31new file mode 100644
32index 0000000..1111111
33Binary files /dev/null and b/logo.png differ
34`
35
36func TestParseDiffFiles(t *testing.T) {
37 files := parseDiff(samplePatch)
38 if len(files) != 3 {
39 t.Fatalf("got %d files, want 3", len(files))
40 }
41
42 if got := files[0].Path; got != "main.go" {
43 t.Errorf("file 0 path = %q", got)
44 }
45 if files[0].Adds != 3 || files[0].Dels != 2 {
46 t.Errorf("main.go stat = +%d -%d, want +3 -2", files[0].Adds, files[0].Dels)
47 }
48 if files[0].Status != "modified" {
49 t.Errorf("main.go status = %q", files[0].Status)
50 }
51
52 if files[1].Status != "renamed" || files[1].OldPath != "notes.txt" || files[1].Path != "README.md" {
53 t.Errorf("rename = %q %q -> %q", files[1].Status, files[1].OldPath, files[1].Path)
54 }
55
56 if !files[2].Binary || files[2].Status != "added" || files[2].Path != "logo.png" {
57 t.Errorf("binary add = %+v", files[2])
58 }
59 if len(files[2].Lines) != 0 {
60 t.Errorf("binary file has %d lines, want none", len(files[2].Lines))
61 }
62
63 if st := statOf(files); st.Files != 3 || st.Adds != 4 || st.Dels != 3 {
64 t.Errorf("stat = %+v, want 3 files +4 -3", st)
65 }
66}
67
68// Line numbers anchor review threads, so an off-by-one here silently moves
69// every comment on a merge request.
70func TestParseDiffLineNumbers(t *testing.T) {
71 lines := parseDiff(samplePatch)[0].Lines
72 type want struct {
73 class string
74 old, new int64
75 content string
76 }
77 wants := []want{
78 {"hunk", 0, 0, ""},
79 {"ctx", 1, 1, "package main"},
80 {"ctx", 2, 2, ""},
81 {"del", 3, 0, "func old() string {"},
82 {"del", 4, 0, "\treturn \"a\""},
83 {"add", 0, 3, "func replaced() string {"},
84 {"add", 0, 4, "\t// a comment"},
85 {"add", 0, 5, "\treturn \"b\""},
86 {"ctx", 5, 6, "}"},
87 }
88 if len(lines) != len(wants) {
89 t.Fatalf("got %d lines, want %d: %+v", len(lines), len(wants), lines)
90 }
91 for i, w := range wants {
92 got := lines[i]
93 if got.Class != w.class || got.OldLine != w.old || got.NewLine != w.new {
94 t.Errorf("line %d = %s old=%d new=%d, want %s old=%d new=%d",
95 i, got.Class, got.OldLine, got.NewLine, w.class, w.old, w.new)
96 }
97 if w.class != "hunk" && got.Content != w.content {
98 t.Errorf("line %d content = %q, want %q", i, got.Content, w.content)
99 }
100 }
101}
102
103// Highlighting runs per hunk side and is mapped back line by line; the
104// mapping is what breaks, so check that every code line got markup and that
105// it still says what the source said.
106func TestParseDiffHighlighting(t *testing.T) {
107 for _, l := range parseDiff(samplePatch)[0].Lines {
108 if l.Class == "hunk" || l.Content == "" {
109 continue
110 }
111 if l.Code == "" {
112 t.Errorf("%s line %q got no highlighted markup", l.Class, l.Content)
113 continue
114 }
115 if text := strings.TrimSpace(stripTags(string(l.Code))); text != strings.TrimSpace(l.Content) {
116 t.Errorf("highlighted %q reads as %q", l.Content, text)
117 }
118 }
119}
120
121// A file whose type chroma does not know renders as plain text rather than
122// being guessed at.
123func TestParseDiffUnknownType(t *testing.T) {
124 files := parseDiff(`diff --git a/x.zzz b/x.zzz
125-- a/x.zzz
126+++ b/x.zzz
127@@ -1 +1 @@
128before
129+after
130`)
131 if len(files) != 1 {
132 t.Fatalf("got %d files", len(files))
133 }
134 for _, l := range files[0].Lines {
135 if l.Class == "hunk" {
136 continue
137 }
138 if l.Code != "" {
139 t.Errorf("unknown type got markup: %q", l.Code)
140 }
141 }
142}
143
144// splitHighlighted has to close and reopen spans that straddle a newline,
145// or one unterminated tag swallows the rest of the file.
146func TestSplitHighlightedBalancesTags(t *testing.T) {
147 got := splitHighlighted(`<span class="c">line one
148line two</span>plain`)
149 want := []string{`<span class="c">line one</span>`, `<span class="c">line two</span>plain`}
150 if len(got) != len(want) {
151 t.Fatalf("got %d lines: %q", len(got), got)
152 }
153 for i := range want {
154 if got[i] != want[i] {
155 t.Errorf("line %d = %q, want %q", i, got[i], want[i])
156 }
157 }
158}
159
160func stripTags(s string) string {
161 var b strings.Builder
162 depth := 0
163 for _, r := range s {
164 switch {
165 case r == '<':
166 depth++
167 case r == '>':
168 depth--
169 case depth == 0:
170 b.WriteRune(r)
171 }
172 }
173 return strings.ReplaceAll(b.String(), "&#34;", `"`)
174}
internal/httpd/web.go +29 −85
@@ -1060,53 +1060,6 @@ func renderReadme(name string, raw []byte) template.HTML {
10601060 }
10611061 }
10621062
1063type diffLine struct {
1064 Class string
1065 Text string
1066 Path string // file this line belongs to
1067 NewLine int64 // line number in the new file (0 when absent)
1068 OldLine int64 // line number in the old file (0 when absent)
1069 Threads []diffThread
1070}
1071
1072var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
1073
1074// classifyDiff parses a unified diff into rendered lines, tracking the
1075// file and old/new line numbers so review threads can anchor inline.
1076func classifyDiff(patch string) []diffLine {
1077 var lines []diffLine
1078 path := ""
1079 var oldN, newN int64
1080 for _, l := range strings.Split(patch, "\n") {
1081 d := diffLine{Text: l}
1082 switch {
1083 case strings.HasPrefix(l, "+++ "):
1084 d.Class = "meta"
1085 path = strings.TrimPrefix(strings.TrimPrefix(l, "+++ "), "b/")
1086 case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
1087 d.Class = "meta"
1088 case strings.HasPrefix(l, "@@"):
1089 d.Class = "hunk"
1090 if m := hunkPat.FindStringSubmatch(l); m != nil {
1091 oldN, _ = strconv.ParseInt(m[1], 10, 64)
1092 newN, _ = strconv.ParseInt(m[2], 10, 64)
1093 }
1094 case strings.HasPrefix(l, "+"):
1095 d.Class, d.Path, d.NewLine = "add", path, newN
1096 newN++
1097 case strings.HasPrefix(l, "-"):
1098 d.Class, d.Path, d.OldLine = "del", path, oldN
1099 oldN++
1100 default:
1101 d.Path, d.OldLine, d.NewLine = path, oldN, newN
1102 oldN++
1103 newN++
1104 }
1105 lines = append(lines, d)
1106 }
1107 return lines
1108}
1109
11101063 type diffThread struct {
11111064 ID int64
11121065 Resolved string
@@ -1117,7 +1070,7 @@ type diffThread struct {
11171070 // attachThreads injects review threads under their anchored diff lines;
11181071 // threads whose anchor no longer appears (stale after force-push, or on a
11191072 // context line outside the current diff) are returned separately.
1120func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) {
1073func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffFile, []diffThread) {
11211074 type anchor struct {
11221075 path string
11231076 side string
@@ -1137,19 +1090,24 @@ func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA strin
11371090 }
11381091 }
11391092 placed := map[int64]bool{}
1140 for i := range lines {
1141 for _, id := range order {
1142 if placed[id] || threads[id].Stale {
1143 continue
1144 }
1145 a := anchors[id]
1146 if lines[i].Path != a.path {
1147 continue
1148 }
1149 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1150 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1151 lines[i].Threads = append(lines[i].Threads, *threads[id])
1152 placed[id] = true
1093 for f := range files {
1094 lines := files[f].Lines
1095 for i := range lines {
1096 for _, id := range order {
1097 if placed[id] || threads[id].Stale {
1098 continue
1099 }
1100 a := anchors[id]
1101 if lines[i].Path != a.path {
1102 continue
1103 }
1104 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1105 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1106 lines[i].Threads = append(lines[i].Threads, *threads[id])
1107 files[f].Threads++
1108 files[f].Open = true
1109 placed[id] = true
1110 }
11531111 }
11541112 }
11551113 }
@@ -1159,7 +1117,7 @@ func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA strin
11591117 unplaced = append(unplaced, *threads[id])
11601118 }
11611119 }
1162 return lines, unplaced
1120 return files, unplaced
11631121 }
11641122
11651123 type sigView struct {
@@ -1263,7 +1221,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
12631221 return
12641222 }
12651223 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1266 lines := classifyDiff(patch)
1224 files := parseDiff(patch)
12671225 committerEmail := ""
12681226 if parsed.CommitterEmail != parsed.AuthorEmail {
12691227 committerEmail = parsed.CommitterEmail
@@ -1281,10 +1239,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
12811239 Parents []string
12821240 Sig sigView
12831241 Checks []store.CommitStatus
1284 DiffLines []diffLine
1242 DiffFiles []diffFile
12851243 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
12861244 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1287 gitutil.Parents(p.Dir, full), v, checks, lines})
1245 gitutil.Parents(p.Dir, full), v, checks, files})
12881246 }
12891247
12901248 // labelPalette provides default label chip colors: mid-tone hues that stay
@@ -1472,7 +1430,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
14721430 diffComments, _ := s.st.ListDiffComments(m.ID)
14731431
14741432 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1475 var lines []diffLine
1433 var files []diffFile
14761434 base := m.MergedBase
14771435 if base == "" {
14781436 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
@@ -1481,27 +1439,13 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
14811439 }
14821440 if base != "" {
14831441 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1484 lines = classifyDiff(patch)
1442 files = parseDiff(patch)
14851443 }
14861444 }
14871445 md := s.ugcFor(r, p.Repo)
14881446 var detachedThreads []diffThread
1489 lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
1490 type diffStat struct{ Files, Adds, Dels int }
1491 var stat diffStat
1492 seenFiles := map[string]bool{}
1493 for _, l := range lines {
1494 switch l.Class {
1495 case "add":
1496 stat.Adds++
1497 case "del":
1498 stat.Dels++
1499 }
1500 if l.Path != "" && !seenFiles[l.Path] {
1501 seenFiles[l.Path] = true
1502 stat.Files++
1503 }
1504 }
1447 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md)
1448 stat := statOf(files)
15051449 // The commits this MR carries: base..head, the same range as the diff.
15061450 type commitRow struct {
15071451 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
@@ -1544,7 +1488,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
15441488 Combined string
15451489 Comments []renderedComment
15461490 Reviews []store.MRReview
1547 DiffLines []diffLine
1491 DiffFiles []diffFile
15481492 Stat diffStat
15491493 Commits []commitRow
15501494 CanEdit bool
@@ -1553,7 +1497,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
15531497 Notice string
15541498 DetachedThreads []diffThread
15551499 }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1556 reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1500 reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
15571501 s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
15581502 }
15591503
internal/web/static/style.css +71 −17
@@ -42,6 +42,10 @@
4242 --surface: #ffffff;
4343 --hover: #f5f5f5;
4444 --code-bg: #f5f5f5;
45 --fill-subtle: #ececec; /* hunk headers, summary hover */
46 --diff-add: #e4f6ea; /* row grounds: tinted enough to scan, light
47 enough that syntax colors stay legible */
48 --diff-del: #fdeaea;
4549
4650 /* blue: what you can do */
4751 --accent: #0000f0; /* links, focus — 9.30:1 on white */
@@ -104,6 +108,9 @@
104108 --surface: #0f0f0f;
105109 --hover: #161616;
106110 --code-bg: #050505;
111 --fill-subtle: #161616;
112 --diff-add: #0d2a18;
113 --diff-del: #2c1113;
107114 --accent: #5b6bff; /* 4.71:1 — pure #0000f0 is 2.13:1 here */
108115 --accent-fg: #ffffff;
109116 --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */
@@ -787,34 +794,81 @@ pre.message {
787794 overflow-wrap: anywhere;
788795 }
789796
790/* diffs */
797/* diffs: one foldable section per file, a table so the line-number
798 gutters stay put while the code scrolls */
799details.difffold {
800 border: 1px solid var(--line);
801 border-radius: var(--r-md);
802 margin: 0 0 var(--sp-3);
803 background: var(--code-bg);
804}
791805 details.difffold summary {
792806 cursor: pointer;
793807 display: flex;
794808 align-items: baseline;
795809 gap: var(--sp-3);
796 margin: var(--sp-5) 0 var(--sp-2);
810 padding: var(--sp-2) var(--sp-3);
797811 list-style: none;
812 font-size: var(--fs-2);
798813 }
814details.difffold[open] summary { border-bottom: 1px solid var(--line); }
799815 details.difffold summary::-webkit-details-marker { display: none; }
800816 /* flex summaries lose the native disclosure marker; draw our own */
801details.difffold summary::before { content: "▸"; color: var(--muted); }
802details.difffold[open] summary::before { content: "▾"; }
803details.difffold summary h3 { display: inline; margin: 0; }
804details.difffold summary .add { color: var(--ok); }
805details.difffold summary .del { color: var(--bad); }
806pre.diff {
807 background: var(--code-bg);
808 border: 1px solid var(--line);
809 padding: var(--sp-3) var(--sp-4);
810 border-radius: var(--r-md);
811 overflow-x: auto;
817details.difffold summary::before { content: "\25b8"; color: var(--muted); }
818details.difffold[open] summary::before { content: "\25be"; }
819details.difffold summary:hover { background: var(--fill-subtle); }
820summary .fpath { font-family: var(--mono); overflow-wrap: anywhere; }
821summary .fpath .was { color: var(--muted); }
822summary .fstat { margin-left: auto; font-family: var(--mono); white-space: nowrap; }
823details.difffold .add { color: var(--ok); }
824details.difffold .del { color: var(--bad); }
825details.difffold > .none { padding: var(--sp-3); margin: 0; }
826details.difffold .tablewrap { border-radius: 0 0 var(--r-md) var(--r-md); }
827
828table.difftable {
829 border-collapse: collapse;
830 width: 100%;
831 font-family: var(--mono);
832 font-size: var(--fs-1);
812833 line-height: 1.5;
813834 }
814pre.diff .add { color: var(--ok); }
815pre.diff .del { color: var(--bad); }
816pre.diff .hunk { color: var(--accent); }
817pre.diff .meta { color: var(--muted); }
835table.difftable td {
836 border: 0;
837 padding: 0;
838 vertical-align: top;
839 font-size: inherit;
840}
841table.difftable td.ln {
842 width: 1%;
843 min-width: 2.5rem;
844 padding: 0 var(--sp-2);
845 text-align: right;
846 color: var(--muted);
847 user-select: none;
848 border-right: 1px solid var(--line);
849 white-space: nowrap;
850}
851table.difftable td.code {
852 padding: 0 var(--sp-3);
853 white-space: pre-wrap;
854 overflow-wrap: anywhere;
855}
856/* the marker is decoration, so it lives in CSS and stays out of a copied
857 selection */
858table.difftable tr.add td.code::before { content: "+"; color: var(--ok); }
859table.difftable tr.del td.code::before { content: "\2212"; color: var(--bad); }
860table.difftable tr.ctx td.code::before { content: " "; }
861table.difftable tr.add { background: var(--diff-add); }
862table.difftable tr.del { background: var(--diff-del); }
863table.difftable tr.hunk td, table.difftable tr.dmeta td {
864 color: var(--muted);
865 padding: var(--sp-1) var(--sp-3);
866 background: var(--fill-subtle);
867 border-top: 1px solid var(--line);
868 border-bottom: 1px solid var(--line);
869}
870table.difftable tr.hunk td.code { color: var(--accent); }
871table.difftable tr.threadrow td { padding: var(--sp-2) var(--sp-3); background: var(--bg); }
818872
819873 /* badges: signature and check states. Semantic colors stay distinct:
820874 green verified/success, amber pending/stale, red bad/failure, muted
internal/web/templates/commit.html +1 −2
@@ -9,6 +9,5 @@
99 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
1010 </div>
1111 <pre class="message">{{.Message}}</pre>
12<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
13{{end}}</pre>
12{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Can" false}}
1413 {{end}}
internal/web/templates/layout.html +24
@@ -109,3 +109,27 @@
109109 {{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}}
110110
111111 {{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}}
112
113{{/* difffiles renders a parsed diff: one foldable section per file, with
114 line-number gutters and review threads inline. Base and Can are the
115 MR's comment endpoint and the viewer's write permission; the commit
116 page passes neither and gets the same diff without thread controls. */}}
117{{define "difffiles"}}{{$base := .Base}}{{$can := .Can}}
118{{range .Files}}<details class="difffold"{{if .Open}} open{{end}}>
119 <summary>
120 <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span>
121 {{if ne .Status "modified"}}<span class="chip">{{.Status}}</span>{{end}}
122 <span class="fstat">{{if .Adds}}<span class="add">+{{.Adds}}</span>{{end}}{{if .Dels}} <span class="del">−{{.Dels}}</span>{{end}}</span>
123 </summary>
124 {{if .Binary}}<p class="none">Binary file not shown.</p>
125 {{else}}<div class="tablewrap"><table class="difftable">
126 {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="code">{{.Text}}</td></tr>
127 {{else if eq .Class "meta"}}<tr class="dmeta"><td class="ln" colspan="2"></td><td class="code">{{.Text}}</td></tr>
128 {{else}}<tr class="{{.Class}}"><td class="ln">{{if .OldLine}}{{.OldLine}}{{end}}</td><td class="ln">{{if .NewLine}}{{.NewLine}}{{end}}</td><td class="code">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td></tr>
129 {{end}}{{range .Threads}}<tr class="threadrow"><td colspan="3"><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}{{template "threadact" dict "ID" .ID "Resolved" .Resolved "Base" $base "Can" $can}}</div></td></tr>
130 {{end}}{{end}}
131 </table></div>{{end}}
132</details>
133{{end}}{{end}}
134
135{{define "threadact"}}{{if .Can}}<form method="post" action="{{.Base}}/thread" class="threadact"><input type="hidden" name="thread" value="{{.ID}}"><button type="submit" name="action" value="{{if .Resolved}}unresolve{{else}}resolve{{end}}" class="linklike">{{if .Resolved}}Reopen thread{{else}}Resolve thread{{end}}</button></form>{{end}}{{end}}
internal/web/templates/mr.html +1 −3
@@ -61,8 +61,7 @@
6161
6262 {{else}}
6363 <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></p>
64<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
65{{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}{{template "threadact" dict "ID" .ID "Resolved" .Resolved "Base" $base "Can" $.CanWrite}}</div><pre class="diff">{{end}}{{end}}</pre>
64{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Can" .CanWrite}}
6665 {{end}}
6766
6867 </div>
@@ -119,4 +118,3 @@
119118 </div>
120119 {{end}}
121120
122{{define "threadact"}}{{if .Can}}<form method="post" action="{{.Base}}/thread" class="threadact"><input type="hidden" name="thread" value="{{.ID}}"><button type="submit" name="action" value="{{if .Resolved}}unresolve{{else}}resolve{{end}}" class="linklike">{{if .Resolved}}Reopen thread{{else}}Resolve thread{{end}}</button></form>{{end}}{{end}}