socle

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
 -----------