commit pages: metadata, message, and a colored unified diff
commit 18b9fa40a6f525815a6dff4ae0ae4dcf654fce6b · parent 30b113ea56
· browse this tree
Ximianth, 2026-10-26 20:28:44 UTC
/<name>/commit/<ref> renders the commit's subject, sha, parents (linked), author and date, the message body, and the diff against the first parent (--root for a root commit; first-parent is the reading a human wants for merges). Log shas now link here instead of to the tree; the commit page links onward to its tree. Diff coloring is by text color via the palette's success/danger vars, one classed span per notable line, with everything HTML-escaped; oversized diffs cut at a line boundary with a visible note. Full-sha commit URLs get the immutable cache header like every other sha-addressed page.
diff --git a/git.go b/git.go index 3b64a04..dd03435 100644 --- a/git.go +++ b/git.go @@ -312,6 +312,60 @@ func (r *repo) commits(ref string, limit int) ([]commitInfo, error) { return cs, nil } +// commitDetail is the full description of one commit, as the commit page wants it. +type commitDetail struct { + hash string + author string + time time.Time + parents []string // full shas; empty for a root commit + message string // the full commit message, subject line included +} + +// commit reads one commit's metadata and full message. sha must be a full commit sha +// (resolveCommit's output), like every other operation here. +func (r *repo) commit(sha string) (commitDetail, error) { + out, err := r.git("show", "-s", "--format=%H%x00%an%x00%at%x00%P%x00%B", "--end-of-options", sha, "--") + if err != nil { + return commitDetail{}, notFoundOnExit(err, fmt.Sprintf("commit %q", sha)) + } + parts := strings.SplitN(string(out), "\x00", 5) + if len(parts) != 5 { + return commitDetail{}, fmt.Errorf("unexpected show output for %q", sha) + } + unix, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return commitDetail{}, fmt.Errorf("unexpected author time in show output %q", parts[2]) + } + c := commitDetail{ + hash: parts[0], + author: parts[1], + time: time.Unix(unix, 0).UTC(), + message: strings.TrimRight(parts[4], "\n"), + } + if parts[3] != "" { + c.parents = strings.Split(parts[3], " ") + } + return c, nil +} + +// diff returns the commit's patch: against its first parent, or from scratch for a root +// commit. (For a merge commit the first parent is the branch merged *into*, which is the +// diff a reader almost always means.) Rename detection is on, matching what humans expect +// to read; the output is git's ordinary unified diff, uncolored. +func (r *repo) diff(c commitDetail) ([]byte, error) { + var out []byte + var err error + if len(c.parents) == 0 { + out, err = r.git("diff-tree", "--root", "-p", "-M", "--no-commit-id", "--end-of-options", c.hash, "--") + } else { + out, err = r.git("diff-tree", "-p", "-M", "--no-commit-id", "--end-of-options", c.parents[0], c.hash, "--") + } + if err != nil { + return nil, notFoundOnExit(err, fmt.Sprintf("commit %q", c.hash)) + } + return out, nil +} + // uploadPackAdvertise writes the ref advertisement for fetch, as produced by // git upload-pack --advertise-refs. The smart-HTTP "# service=" pkt-line preamble is the // HTTP layer's framing concern and is not written here. diff --git a/pages.go b/pages.go index 51e822d..f0770a6 100644 --- a/pages.go +++ b/pages.go @@ -45,6 +45,13 @@ article td.hash a { font-family: monospace; } article p.clone code { user-select: all; } article img.file { max-width: 100%; } article pre.file { tab-size: 4; } +/* Unified diffs color by text, not background: safe in both schemes, and the + hues are the palette's existing success/danger pair. */ +article pre.diff { tab-size: 4; } +article pre.diff .d-add { color: var(--success); } +article pre.diff .d-del { color: var(--danger); } +article pre.diff .d-hunk { color: var(--accent); } +article pre.diff .d-file { font-weight: bold; } ` // renderPage fills in the site-wide theme and writes the full page. @@ -494,7 +501,7 @@ func (s *server) renderLog(w http.ResponseWriter, rp *repo, ref string, cs []com for _, c := range cs { d.Rows = append(d.Rows, logRow{ Sha: shortSha(c.hash), - Href: treeHref(rp.name, c.hash, "") + "/", + Href: "/" + rp.name + "/commit/" + c.hash, Date: c.time.Format("2006-01-02"), Author: c.author, Subject: c.subject, @@ -505,3 +512,89 @@ func (s *server) renderLog(w http.ResponseWriter, rp *repo, ref string, cs []com Crumbs: repoCrumbs(rp.name, crumbLink{Name: "log"}, crumbLink{Name: shortSha(ref)}), }) } + +// ---- commit ---- + +var commitTmpl = template.Must(template.New("commit").Parse(`<h1>{{.Subject}}</h1> +<p>commit <code>{{.Sha}}</code>{{range .Parents}} · parent <a href="{{.Href}}"><code>{{.Name}}</code></a>{{end}} +· <a href="{{.TreeHref}}">browse this tree</a><br> +{{.Author}}, {{.Date}}</p> +{{if .Rest}}<pre class="file">{{.Rest}}</pre> +{{end}}{{if .Diff}}<pre class="diff">{{.Diff}}</pre> +{{else}}<p>No changes against the first parent.</p> +{{end}}{{if .Truncated}}<p>The diff is larger than shown; it was cut off here.</p> +{{end}}`)) + +type commitData struct { + Subject string + Rest string // the message past its subject line, if any + Sha string + Author string + Date string + Parents []refLink + TreeHref string + Diff template.HTML + Truncated bool +} + +// renderCommit writes the commit page: subject, metadata, the rest of the message, the diff. +func (s *server) renderCommit(w http.ResponseWriter, rp *repo, c commitDetail, diff []byte) { + subject, rest, _ := strings.Cut(c.message, "\n") + d := commitData{ + Subject: subject, + Rest: strings.TrimSpace(rest), + Sha: c.hash, + Author: c.author, + Date: c.time.Format("2006-01-02 15:04:05 UTC"), + TreeHref: treeHref(rp.name, c.hash, "") + "/", + } + for _, p := range c.parents { + d.Parents = append(d.Parents, refLink{shortSha(p), "/" + rp.name + "/commit/" + p}) + } + d.Diff, d.Truncated = diffHTML(diff) + s.render(w, commitTmpl, d, display.PageData{ + Title: rp.name + " commit " + shortSha(c.hash), + Crumbs: repoCrumbs(rp.name, crumbLink{Name: "commit"}, crumbLink{Name: shortSha(c.hash)}), + }) +} + +// diffHTML escapes a unified diff and wraps its notable lines in classed spans for color: +// added, removed, hunk headers, and everything else that isn't patch body (per-file header +// material -- in a unified diff, body lines only ever start with '+', '-', ' ', or '\\'). +// A diff beyond MaxRenderSize is cut at a line boundary and flagged truncated. +func diffHTML(diff []byte) (html template.HTML, truncated bool) { + if len(diff) > display.MaxRenderSize { + cut := bytes.LastIndexByte(diff[:display.MaxRenderSize], '\n') + if cut < 0 { + cut = 0 + } + diff, truncated = diff[:cut], true + } + var sb strings.Builder + for _, line := range strings.Split(strings.TrimSuffix(string(diff), "\n"), "\n") { + class := "" + switch { + case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"): + class = "d-file" + case strings.HasPrefix(line, "@@"): + class = "d-hunk" + case strings.HasPrefix(line, "+"): + class = "d-add" + case strings.HasPrefix(line, "-"): + class = "d-del" + case line == "" || line[0] == ' ' || line[0] == '\\': + // patch body context: no class + default: + class = "d-file" + } + if class != "" { + sb.WriteString(`<span class="` + class + `">`) + sb.WriteString(template.HTMLEscapeString(line)) + sb.WriteString("</span>\n") + } else { + sb.WriteString(template.HTMLEscapeString(line)) + sb.WriteString("\n") + } + } + return template.HTML(strings.TrimSuffix(sb.String(), "\n")), truncated +} diff --git a/serve.go b/serve.go index 7ff16ec..a343b69 100644 --- a/serve.go +++ b/serve.go @@ -84,6 +84,8 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.serveRawBlob(w, r, rp, arg) case verb == "log": s.serveLog(w, r, rp, arg) + case verb == "commit": + s.serveCommit(w, r, rp, arg) default: s.redirectIntoTree(w, r, rp, rest) } @@ -201,6 +203,30 @@ func (s *server) serveLog(w http.ResponseWriter, r *http.Request, rp *repo, arg s.renderLog(w, rp, ref, cs) } +// serveCommit handles /<name>/commit/<sha>: the commit's metadata, message, and diff. +// Any ref is accepted where <sha> is written, but links always use full shas, +// so the pages that matter get the immutable cache header. +func (s *server) serveCommit(w http.ResponseWriter, r *http.Request, rp *repo, arg string) { + ref := strings.TrimSuffix(arg, "/") + sha, err := rp.resolveCommit(ref) + if err != nil { + httpError(w, err) + return + } + c, err := rp.commit(sha) + if err != nil { + httpError(w, err) + return + } + diff, err := rp.diff(c) + if err != nil { + httpError(w, err) + return + } + setCacheHeader(w, ref, sha) + s.renderCommit(w, rp, c, diff) +} + // redirectIntoTree sends /<name>/<path> (any path that isn't a verb) into the tree at the // default branch, so relative links in a rendered README land somewhere sensible. // Image-ish extensions redirect to the raw view instead, so a README's relative diff --git a/serve_test.go b/serve_test.go index 1d2ef95..852927d 100644 --- a/serve_test.go +++ b/serve_test.go @@ -103,6 +103,8 @@ func TestCacheHeaders(t *testing.T) { {"/fixturama/tree/" + fixtureShaMain + "/README.md", immutable}, {"/fixturama/raw/" + fixtureShaMain + "/README.md", immutable}, {"/fixturama/log/" + fixtureShaMain, immutable}, + {"/fixturama/commit/" + fixtureShaMain, immutable}, + {"/fixturama/commit/main", ""}, // same page by branch name: the name moves {"/fixturama/tree/main/README.md", ""}, // a branch moves; never immutable {"/fixturama/raw/v1.0/README.md", ""}, // a tag names a commit but is not one {"/fixturama/info/refs?service=git-upload-pack", "no-cache"}, @@ -224,3 +226,49 @@ func TestFileViewModes(t *testing.T) { } } } + +// TestCommitPageShapes covers the commit-page shapes the standard fixture doesn't carry: +// a merge commit (two parent links; the diff is taken against the first parent, so the +// merged-in edit shows with both its removed and added lines) and a multi-line message +// (the part past the subject renders in its own block). +// These shas are deliberately not pinned; nothing here snapshots output. +func TestCommitPageShapes(t *testing.T) { + t.Parallel() + dir := t.TempDir() + gitFixture(t, dir, "init", "-q", "-b", "main", "--object-format=sha1", ".") + writeFixtureFile(t, dir, "a.txt", "original line\n") + gitFixture(t, dir, "add", "-A") + gitFixture(t, dir, "commit", "-q", "-m", "base") + gitFixture(t, dir, "checkout", "-q", "-b", "side") + writeFixtureFile(t, dir, "a.txt", "revised line\n") + gitFixture(t, dir, "add", "-A") + gitFixture(t, dir, "commit", "-q", "-m", "revise a") + gitFixture(t, dir, "checkout", "-q", "main") + writeFixtureFile(t, dir, "b.txt", "unrelated\n") + gitFixture(t, dir, "add", "-A") + gitFixture(t, dir, "commit", "-q", "-m", "grow b") + gitFixture(t, dir, "merge", "-q", "--no-ff", "-m", "merge the side branch\n\nBecause the revision was worth having.", "side") + rp, err := openRepo("merger", repoConfig{Path: dir}) + if err != nil { + t.Fatal(err) + } + s := newServer(map[string]*repo{"merger": rp}, display.NewTheme("test")) + rec := do(s, "/merger/commit/main", nil, nil) + if rec.Code != 200 { + t.Fatalf("status = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + "<h1>merge the side branch</h1>", + "Because the revision was worth having.", + `<span class="d-del">-original line</span>`, + `<span class="d-add">+revised line</span>`, + } { + if !strings.Contains(body, want) { + t.Errorf("commit page lacks %q", want) + } + } + if got := strings.Count(body, "parent <a href="); got != 2 { + t.Errorf("merge commit shows %d parent links, want 2", got) + } +} diff --git a/testdata/pages.md b/testdata/pages.md index 2407dc7..7c23fb3 100644 --- a/testdata/pages.md +++ b/testdata/pages.md @@ -312,8 +312,8 @@ Content-Type: text/html; charset=utf-8 <table class="log"> <thead><tr><th>commit</th><th>date</th><th>author</th><th>subject</th></tr></thead> <tbody> -<tr><td class="hash"><a href="/fixturama/tree/9db23702e9ae3e8317b5dba562b0f3bd1cb25e7f/">9db23702e9</a></td><td class="date">2026-01-02</td><td>Ada Fixture</td><td>add docs and src</td></tr> -<tr><td class="hash"><a href="/fixturama/tree/64ac67a35d36079e167cb8f929fd57f859c8b5d1/">64ac67a35d</a></td><td class="date">2026-01-02</td><td>Ada Fixture</td><td>inaugurate with a readme</td></tr> +<tr><td class="hash"><a href="/fixturama/commit/9db23702e9ae3e8317b5dba562b0f3bd1cb25e7f">9db23702e9</a></td><td class="date">2026-01-02</td><td>Ada Fixture</td><td>add docs and src</td></tr> +<tr><td class="hash"><a href="/fixturama/commit/64ac67a35d36079e167cb8f929fd57f859c8b5d1">64ac67a35d</a></td><td class="date">2026-01-02</td><td>Ada Fixture</td><td>inaugurate with a readme</td></tr> </tbody> </table> </article> @@ -323,6 +323,114 @@ Content-Type: text/html; charset=utf-8 ``` +commit +------ + +A commit with a parent: metadata, message, and a multi-file diff. + +[testmark]:# (commit-main/url) +```text +/fixturama/commit/9db23702e9ae3e8317b5dba562b0f3bd1cb25e7f +``` + +[testmark]:# (commit-main/response) +```text +status: 200 +Cache-Control: public, max-age=31536000, immutable +Content-Type: text/html; charset=utf-8 + +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>fixturama commit 9db23702e9</title> +<link rel="stylesheet" href="/-/style.css"> +</head> +<body> +<nav> +<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / commit / 9db23702e9 + +</nav> + +<article><h1>add docs and src</h1> +<p>commit <code>9db23702e9ae3e8317b5dba562b0f3bd1cb25e7f</code> · parent <a href="/fixturama/commit/64ac67a35d36079e167cb8f929fd57f859c8b5d1"><code>64ac67a35d</code></a> +· <a href="/fixturama/tree/9db23702e9ae3e8317b5dba562b0f3bd1cb25e7f/">browse this tree</a><br> +Ada Fixture, 2026-01-02 03:04:05 UTC</p> +<pre class="diff"><span class="d-file">diff --git a/docs/notes.md b/docs/notes.md</span> +<span class="d-file">new file mode 100644</span> +<span class="d-file">index 0000000..639f33e</span> +<span class="d-file">--- /dev/null</span> +<span class="d-file">+++ b/docs/notes.md</span> +<span class="d-hunk">@@ -0,0 +1 @@</span> +<span class="d-add">+Notes live here.</span> +<span class="d-file">diff --git a/docs/with space.txt b/docs/with space.txt</span> +<span class="d-file">new file mode 100644</span> +<span class="d-file">index 0000000..9c8e1ee</span> +<span class="d-file">--- /dev/null</span> +<span class="d-file">+++ b/docs/with space.txt </span> +<span class="d-hunk">@@ -0,0 +1 @@</span> +<span class="d-add">+even this filename works</span> +<span class="d-file">diff --git a/src/hello.txt b/src/hello.txt</span> +<span class="d-file">new file mode 100644</span> +<span class="d-file">index 0000000..b0760d0</span> +<span class="d-file">--- /dev/null</span> +<span class="d-file">+++ b/src/hello.txt</span> +<span class="d-hunk">@@ -0,0 +1 @@</span> +<span class="d-add">+hello from the fixture</span></pre> +</article> + +</body> +</html> +``` + +The root commit: no parent, so its diff is from scratch. + +[testmark]:# (commit-root/url) +```text +/fixturama/commit/64ac67a35d36079e167cb8f929fd57f859c8b5d1 +``` + +[testmark]:# (commit-root/response) +```text +status: 200 +Cache-Control: public, max-age=31536000, immutable +Content-Type: text/html; charset=utf-8 + +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>fixturama commit 64ac67a35d</title> +<link rel="stylesheet" href="/-/style.css"> +</head> +<body> +<nav> +<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / commit / 64ac67a35d + +</nav> + +<article><h1>inaugurate with a readme</h1> +<p>commit <code>64ac67a35d36079e167cb8f929fd57f859c8b5d1</code> +· <a href="/fixturama/tree/64ac67a35d36079e167cb8f929fd57f859c8b5d1/">browse this tree</a><br> +Ada Fixture, 2026-01-02 03:04:05 UTC</p> +<pre class="diff"><span class="d-file">diff --git a/README.md b/README.md</span> +<span class="d-file">new file mode 100644</span> +<span class="d-file">index 0000000..5a52002</span> +<span class="d-file">--- /dev/null</span> +<span class="d-file">+++ b/README.md</span> +<span class="d-hunk">@@ -0,0 +1,3 @@</span> +<span class="d-add">+# Fixturama</span> +<span class="d-add">+</span> +<span class="d-add">+A repo that exists to be looked at.</span></pre> +</article> + +</body> +</html> +``` + + error pages -----------