Repositories / gitweb2.git

gitweb2.git

Clone (read-only): git clone http://git.guha-anderson.com/git/gitweb2.git

Branch

Render commit diffs on the History tab

Clicking a commit in the History list now opens a page showing the
commit metadata (subject, body, author, date, full SHA) and a
syntax-styled unified diff: green additions, red deletions, blue hunk
headers, muted file metadata, one panel per file.

The diff is produced via libgit2 (new Ocaml_git.commit_lookup and
Ocaml_git.commit_diff). Avoiding the git CLI matters because libgit2
doesn't enforce safe.directory, so this works for bare repos served by
a process running as a user other than the repo owner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Arjun Guha <a.guha@northeastern.edu>
Date
2026-05-04 12:02:30 -0400
Commit
110d127ad4c5ba94faf2c43e6e397b6cbdb9677d
src/gitweb2.ml
index 4e02b86..df65a2a 100644
--- a/src/gitweb2.ml
+++ b/src/gitweb2.ml
@@ -138,7 +138,8 @@ let page (title : string) (body : string) : string =
      h2 { margin: 0 0 10px; font-size: 20px; line-height: 1.25; overflow-wrap: anywhere; }\n\
      .repo-list, .commits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }\n\
      .repo-list li, .commits li { border-top: 1px solid var(--line); } .repo-list li:first-child, .commits li:first-child { border-top: 0; }\n\
-     .repo-list a, .commits li { display: grid; gap: 3px; padding: 14px 16px; }\n\
+     .repo-list a, .commits a { display: grid; gap: 3px; padding: 14px 16px; color: var(--ink); }\n\
+     .commits li:hover { background: var(--wash); }\n\
      .repo-list strong, .commits strong { font-size: 16px; overflow-wrap: anywhere; } .repo-list small, .commits span { display: block; overflow-wrap: anywhere; }\n\
      .panel { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }\n\
      table { width: 100%; border-collapse: collapse; } td { padding: 10px 12px; border-top: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }\n\
@@ -152,6 +153,20 @@ let page (title : string) (body : string) : string =
      .readme { margin-top: 24px; } .file-meta { padding: 10px 12px; border-bottom: 1px solid var(--line); background: #fff; }\n\
      .notice { padding: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--wash); }\n\
      .clone { margin: 6px 0 12px; color: var(--muted); } .clone code { background: var(--wash); border: 1px solid var(--line); border-radius: 6px; padding: 2px 8px; font: 13px ui-monospace, SFMono-Regular, Consolas, monospace; color: var(--ink); user-select: all; }\n\
+     .commit-meta { padding: 16px 18px; margin-bottom: 16px; background: #fff; }\n\
+     .commit-meta h2 { margin: 0 0 8px; }\n\
+     .commit-body { white-space: pre-wrap; background: var(--wash); border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; margin: 10px 0 14px; font: 13px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; overflow: auto; }\n\
+     .commit-fields { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 0; font-size: 13px; }\n\
+     .commit-fields dt { color: var(--muted); } .commit-fields dd { margin: 0; overflow-wrap: anywhere; }\n\
+     .commit-fields code { font: 13px ui-monospace, SFMono-Regular, Consolas, monospace; }\n\
+     .diff-file { margin-top: 14px; }\n\
+     .diff-file .file-meta { font: 13px ui-monospace, SFMono-Regular, Consolas, monospace; }\n\
+     pre.diff { margin: 0; padding: 8px 0; background: var(--wash); overflow: auto; font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; }\n\
+     pre.diff span { display: block; padding: 0 14px; min-height: 1.45em; }\n\
+     pre.diff .diff-add { background: #e6ffec; color: #1a7f37; }\n\
+     pre.diff .diff-del { background: #ffebe9; color: #cf222e; }\n\
+     pre.diff .diff-hunk { background: #ddf4ff; color: #0550ae; }\n\
+     pre.diff .diff-meta { color: var(--muted); }\n\
      @media (max-width: 640px) { main { padding: 20px 12px 40px; } h1 { font-size: 23px; } .kind { width: 78px; } }\n\
      </style>\n</head>\n<body><main>";
   Buffer.add_string buffer body;
@@ -206,6 +221,9 @@ let repo_url (repo : repo_info) (branch : string) (path : string) : string =
   let prefix = "/repo/" ^ url_encode repo.key ^ "/" ^ url_encode branch in
   if path = "" then prefix else prefix ^ "/" ^ (split_path path |> List.map url_encode |> String.concat "/")
 
+let commit_url (repo : repo_info) (branch : string) (id : string) : string =
+  "/repo/" ^ url_encode repo.key ^ "/" ^ url_encode branch ^ "/-/commit/" ^ url_encode id
+
 let repo_summary (repo : repo_info) : repo_summary =
   try
     Ocaml_git.with_repo repo.path @@ fun git ->
@@ -304,6 +322,88 @@ let blob_view ?(clone_base : string option) ~(pygments_command : string) (repo :
   Buffer.add_string buffer "</div>";
   Buffer.contents buffer
 
+let starts_with (prefix : string) (s : string) : bool =
+  let lp = String.length prefix and ls = String.length s in
+  ls >= lp && String.sub s 0 lp = prefix
+
+(* Best-effort path extraction from "diff --git a/foo b/foo".  Falls back to the
+   full line if the format is unexpected (e.g. paths containing spaces). *)
+let diff_header_path (line : string) : string =
+  match String.split_on_char ' ' line with
+  | _ :: _ :: _ :: b :: _ when starts_with "b/" b -> String.sub b 2 (String.length b - 2)
+  | _ :: _ :: a :: _ :: _ when starts_with "a/" a -> String.sub a 2 (String.length a - 2)
+  | _ -> line
+
+let render_diff (text : string) : string =
+  let buffer = Buffer.create (String.length text + 1024) in
+  let in_file = ref false in
+  let close_file () = if !in_file then (Buffer.add_string buffer "</pre></div>"; in_file := false) in
+  let line_class line =
+    if line = "" then "diff-ctx"
+    else if starts_with "+++ " line || starts_with "--- " line then "diff-meta"
+    else if starts_with "index " line || starts_with "new file" line || starts_with "deleted file" line
+            || starts_with "similarity " line || starts_with "rename " line || starts_with "copy " line
+            || starts_with "old mode" line || starts_with "new mode" line
+            || starts_with "Binary files" line then "diff-meta"
+    else if starts_with "@@" line then "diff-hunk"
+    else if line.[0] = '+' then "diff-add"
+    else if line.[0] = '-' then "diff-del"
+    else "diff-ctx"
+  in
+  List.iter
+    (fun line ->
+      if starts_with "diff --git " line then (
+        close_file ();
+        let path = diff_header_path line in
+        Printf.bprintf buffer
+          [%i {|<div class="diff-file panel"><div class="file-meta">{%s html path}</div><pre class="diff">|} ];
+        in_file := true)
+      else if !in_file then
+        let cls = line_class line in
+        Printf.bprintf buffer [%i {|<span class="{%s cls}">{%s html line}</span>
+|} ])
+    (String.split_on_char '\n' text);
+  close_file ();
+  Buffer.contents buffer
+
+let format_signature_date (sig_ : Ocaml_git.signature) : string =
+  let offset = sig_.timezone_offset_minutes in
+  let local_seconds = sig_.epoch_seconds + (offset * 60) in
+  let tm = Unix.gmtime (float_of_int local_seconds) in
+  let sign = if offset >= 0 then "+" else "-" in
+  let abs_offset = abs offset in
+  Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d %s%02d%02d" (tm.Unix.tm_year + 1900) (tm.tm_mon + 1) tm.tm_mday
+    tm.tm_hour tm.tm_min tm.tm_sec sign (abs_offset / 60) (abs_offset mod 60)
+
+let commit_view ?(clone_base : string option) (repo : repo_info) (branch : string) (commit_id : string) : string =
+  Ocaml_git.with_repo repo.path @@ fun git ->
+  let branches = local_branches git in
+  let buffer = Buffer.create 8192 in
+  Buffer.add_string buffer (repo_header ?clone_base repo branch "" branches "history");
+  (match
+     try Some (Ocaml_git.commit_lookup git commit_id) with Ocaml_git.Git_error _ -> None
+   with
+  | None -> Printf.bprintf buffer [%i {|<p class="notice">Unknown commit {%s html commit_id}</p>|} ]
+  | Some commit ->
+      let subject = commit.summary in
+      (* The full message starts with the subject; strip it to get just the body. *)
+      let body =
+        let msg = String.trim commit.message in
+        if subject <> "" && starts_with subject msg then
+          String.trim (String.sub msg (String.length subject) (String.length msg - String.length subject))
+        else msg
+      in
+      let author = commit.author.name in
+      let author_email = commit.author.email in
+      let date = format_signature_date commit.author in
+      Printf.bprintf buffer [%i {|<section class="commit-meta panel"><h2>{%s html subject}</h2>|} ];
+      if body <> "" then Printf.bprintf buffer [%i {|<pre class="commit-body">{%s html body}</pre>|} ];
+      Printf.bprintf buffer
+        [%i
+          {|<dl class="commit-fields"><dt>Author</dt><dd>{%s html author} &lt;{%s html author_email}&gt;</dd><dt>Date</dt><dd>{%s html date}</dd><dt>Commit</dt><dd><code>{%s html commit.id}</code></dd></dl></section>|} ];
+      Buffer.add_string buffer (render_diff (Ocaml_git.commit_diff git commit_id)));
+  Buffer.contents buffer
+
 let commits_page ?(clone_base : string option) (repo : repo_info) (branch : string) : string =
   Ocaml_git.with_repo repo.path @@ fun git ->
   let buffer = Buffer.create 4096 in
@@ -311,7 +411,9 @@ let commits_page ?(clone_base : string option) (repo : repo_info) (branch : stri
   Printf.bprintf buffer [%i {|<ol class="commits">|} ];
   List.iter
     (fun (commit : Ocaml_git.commit) ->
-      Printf.bprintf buffer [%i {|<li><strong>{%s html commit.summary}</strong><span>{%s html (String.sub commit.id 0 (min 12 (String.length commit.id)))} &middot; {%s html commit.author.name}</span></li>|} ])
+      let short = String.sub commit.id 0 (min 12 (String.length commit.id)) in
+      let href = commit_url repo branch commit.id in
+      Printf.bprintf buffer [%i {|<li><a href="{%s href}"><strong>{%s html commit.summary}</strong><span>{%s html short} &middot; {%s html commit.author.name}</span></a></li>|} ])
     (Ocaml_git.commits ~limit:100 git branch);
   Printf.bprintf buffer [%i {|</ol>|} ];
   page (branch ^ " history") (Buffer.contents buffer)
@@ -376,6 +478,11 @@ let route ?(pygments_command : string = default_pygments_command) ?(clone_base :
                 html_response 200 (repo_page ?clone_base ~pygments_command repo branch "")
             | branch :: "-" :: "commits" :: [] ->
                 html_response 200 (commits_page ?clone_base repo (url_decode branch))
+            | branch :: "-" :: "commit" :: id :: [] ->
+                let branch = url_decode branch in
+                let id = url_decode id in
+                let title = "commit " ^ String.sub id 0 (min 12 (String.length id)) in
+                html_response 200 (page title (commit_view ?clone_base repo branch id))
             | branch :: path_parts ->
                 let path = path_parts |> List.map url_decode |> String.concat "/" in
                 html_response 200 (repo_page ?clone_base ~pygments_command repo (url_decode branch) path)))