commit 8ce3088849bd8ed3ade94e599409b61ea5a9ecc0
Author: tri <tri@thac.loan>
Date:   Sat Oct 4 12:28:32 2025 +0700

    generate commit pages

diff --git a/Makefile b/Makefile
index fe38807..a87886d 100644
--- a/Makefile
+++ b/Makefile
@@ -18,3 +18,6 @@ deploy: build
 
 regen: deploy
 	ssh khoe "cd pub/khoe; ~/global-hooks/post-update"
+
+clean:
+	rm -rf zig-out .zig-cache
diff --git a/src/assets/style.css b/src/assets/style.css
index 525e2ef..0920a99 100644
--- a/src/assets/style.css
+++ b/src/assets/style.css
@@ -214,11 +214,18 @@ div.readme-content {
   border: 1px solid var(--table-border-color);
 }
 
+.commit-content {
+  width: fit-content;
+  max-width: 100%;
+  overflow-x: auto;
+  border: 1px solid var(--fg);
+  padding: 1rem;
+}
+
 @media screen and (max-width: 600px) {
-  .repo-content {
-    padding: 0.5rem;
-  }
-  .blob-content {
+  .repo-content,
+  .blob-content,
+  .commit-content {
     padding: 0.5rem;
   }
 }
diff --git a/src/git.zig b/src/git.zig
index c8cac10..cb66c0f 100644
--- a/src/git.zig
+++ b/src/git.zig
@@ -1,6 +1,7 @@
 const std = @import("std");
 const mem = std.mem;
 const fs = std.fs;
+const Io = std.Io;
 const t = std.testing;
 
 pub fn findGitDir(arena: mem.Allocator, dir: fs.Dir) ![]const u8 {
@@ -282,3 +283,15 @@ pub fn catFile(arena: mem.Allocator, dir: fs.Dir, object_hash: []const u8) ![]co
     std.debug.assert(proc.term.Exited == 0);
     return proc.stdout;
 }
+
+pub fn show(arena: mem.Allocator, dir: fs.Dir, commit_hash: []const u8) ![]const u8 {
+    // First get git's terminal output with color codes intact
+    var git_proc = try std.process.Child.run(.{
+        .allocator = arena,
+        .cwd_dir = dir,
+        .max_output_bytes = 1024 * 1024 * 64,
+        .argv = &.{ "git", "show", commit_hash },
+    });
+    std.debug.assert(git_proc.term.Exited == 0);
+    return git_proc.stdout;
+}
diff --git a/src/main.zig b/src/main.zig
index 2e7285c..6e8eb32 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -2,6 +2,7 @@ const std = @import("std");
 const fs = std.fs;
 const mem = std.mem;
 const ascii = std.ascii;
+const fmt = std.fmt;
 const utils = @import("utils.zig");
 const println = utils.println;
 const git = @import("git.zig");
@@ -474,9 +475,14 @@ pub fn writeCommitsPage(
         );
         const escaped_author = try html.escapeAlloc(arena, cmt.author_name);
 
+        const cmt_web_path = try fmt.allocPrint(arena, "../{s}/{s}/", .{
+            constants.web_objects_path,
+            cmt.hash,
+        });
+
         try writer.print(
             \\<tr>
-            \\  <td class="monospace" title="commit diff coming Soon™">{0s}</td>
+            \\  <td class="monospace"><a href="{5s}">{0s}</a></td>
             \\  <td>{1s}{2s}</td>
             \\  <td>{4s}</td>
             \\  <td><time class="relative" datetime="{3s}" title="{3s}">{3s}</time></td>
@@ -490,8 +496,11 @@ pub fn writeCommitsPage(
                 if (cmt.subject.len > 80) "…" else "",
                 cmt.time,
                 escaped_author,
+                cmt_web_path,
             },
         );
+
+        try writeCommitPage(arena, args, out_repo_dir, cmt);
     }
 
     try writer.writeAll(
@@ -503,6 +512,78 @@ pub fn writeCommitsPage(
     try writer.flush();
 }
 
+pub fn writeCommitPage(
+    arena: mem.Allocator,
+    args: *const RepoArgs,
+    out_repo_dir: fs.Dir,
+    commit: git.Commit,
+) !void {
+    var commit_dir = try out_repo_dir.makeOpenPath(
+        try fmt.allocPrint(
+            arena,
+            "{s}/{s}",
+            .{ constants.web_objects_path, commit.hash },
+        ),
+        .{},
+    );
+    defer commit_dir.close();
+
+    const index_html = "index.html";
+
+    const file_exists = blk: {
+        _ = commit_dir.statFile(index_html) catch |err| switch (err) {
+            error.FileNotFound => break :blk false,
+            else => return err,
+        };
+        break :blk true;
+    };
+    if (file_exists) return;
+
+    println("   writing {s} commit {s}", .{ args.repo_name, commit.hash[0..10] });
+
+    var commit_file = try commit_dir.createFile(index_html, .{});
+    errdefer commit_dir.deleteFile(index_html) catch {};
+    defer commit_file.close();
+
+    var buf: [4096]u8 = undefined;
+    var file_writer = commit_file.writer(&buf);
+    const writer = &file_writer.interface;
+
+    const commit_label = try fmt.allocPrint(arena, "{s} (commit)", .{commit.hash[0..10]});
+
+    try templates.base_start(arena, writer, .{
+        .title = commit_label,
+        .breadcrumbs = &.{
+            .{ .href = "/", .text = "repos" },
+            .{
+                .text = args.repo_name,
+                .href = try fmt.allocPrint(arena, "/{s}/{s}/{s}/", .{
+                    constants.web_path,
+                    args.repo_name,
+                    constants.web_commits_path,
+                }),
+            },
+            .{ .text = commit_label },
+        },
+    });
+
+    try writer.writeAll(
+        \\<pre class="commit-content">
+        \\
+    );
+
+    const commit_text = try git.show(arena, args.in_repo_dir, commit.hash);
+    try html.escape(writer, commit_text);
+
+    try writer.writeAll(
+        \\</pre>
+        \\
+    );
+
+    try templates.base_end(writer);
+    try writer.flush();
+}
+
 pub fn writeFilePage(
     args: *const RepoArgs,
     out_dir: fs.Dir,