Skip to main content

max / makenotwork

server: show git notes on the commit page P1 of the git notes program, first slice. The commit detail page now renders every note on the commit it shows, one block per refs/notes/* namespace, with the default `commits` namespace first. The view layer sits in routes/git/notes_view.rs rather than in the notes core, so the core stays free of HTML rendering and liftable into its own crate later. It is also the only place the notes::Oid boundary is crossed on this path: hex in, hex out, no gix type reaching a notes call. A note is decoration on a commit view, so nothing here can fail the page. An unparseable target, an unreadable notes ref, and a namespace whose tree is broken all yield no panel and a warning in the log rather than a 500. Visibility comes from resolve_repo, which has already run: notes live in the repo, so a private repo's notes are as private as its code. Attribution is rendered honestly. The walk that names who last changed a note is bounded at 50 commits, and when it runs out it reports the oldest commit it examined rather than the one it proved responsible. That case renders as "edited since <commit>" and never names a person, because the tree holds content rather than history and crediting an author it has not proven would be a quiet lie.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 18:59 UTC
Signed with PGP, not checked
Commit: 28bc1eee7584b1984d5f968e46c9b07122429976
Parent: 4e788e4
6 files changed, +237 insertions, -3 deletions
@@ -7165,6 +7165,27 @@
7165 7165 .git-commit-detail-meta .git-commit-oid { font-family: var(--font-mono); font-size: var(--text-fine); word-break: break-all; }
7166 7166 .git-commit-parents a { font-family: var(--font-mono); color: var(--content); text-decoration: none; }
7167 7167 .git-commit-parents a:hover { text-decoration: underline; }
7168 + /* Git notes (refs/notes/*) shown against the object they annotate */
7169 + .git-notes { margin-bottom: var(--gap-pane); }
7170 + .git-note {
7171 + border: 1px solid var(--border);
7172 + border-left: 2px solid var(--content);
7173 + margin-bottom: var(--gap-section);
7174 + }
7175 + .git-note-header {
7176 + display: flex;
7177 + align-items: baseline;
7178 + justify-content: space-between;
7179 + gap: var(--gap-group);
7180 + padding: var(--gap-peer) var(--gap-section);
7181 + border-bottom: 1px solid var(--border);
7182 + font-size: var(--text-fine);
7183 + }
7184 + .git-note-namespace { font-family: var(--font-mono); }
7185 + .git-note-attribution { opacity: 0.6; }
7186 + .git-note-body { padding: var(--gap-section); font-size: var(--text-note); }
7187 + .git-note-body > :first-child { margin-top: 0; }
7188 + .git-note-body > :last-child { margin-bottom: 0; }
7168 7189 .git-diff-stats {
7169 7190 font-size: var(--text-note);
7170 7191 padding: var(--gap-section) 0;
@@ -27,7 +27,8 @@
27 27 };
28 28
29 29 use super::{
30 - build_breadcrumbs, escape_html, fetch_linked_releases, format_size, parent_of, resolve_repo,
30 + build_breadcrumbs, escape_html, fetch_linked_releases, format_size, notes_view, parent_of,
31 + resolve_repo,
31 32 };
32 33
33 34 /// `GET /git/{owner}/{repo}`: repo overview (tree at HEAD + README).
@@ -384,7 +385,7 @@
384 385
385 386 let oid = gix::ObjectId::from_hex(oid_str.as_bytes()).map_err(|_| AppError::NotFound)?;
386 387 let repo_name_c = repo_name.clone();
387 - let (detail, diff_files, refs, info) = resolved
388 + let (detail, diff_files, refs, info, notes) = resolved
388 389 .with_repo(move |gix_repo| {
389 390 gix_repo.find_commit(oid).map_err(|_| AppError::NotFound)?;
390 391 let detail = git::commit_detail(gix_repo, oid)?;
@@ -396,7 +397,10 @@
396 397 )?;
397 398 let refs = git::list_refs(gix_repo);
398 399 let info = git::repo_info(gix_repo, &repo_name_c);
399 - Ok((detail, diff_files, refs, info))
400 + // Notes live in the repo, so this inherits the visibility check
401 + // `resolve_repo` already made; nothing extra is needed here.
402 + let notes = notes_view::notes_on_object(gix_repo, &detail.oid);
403 + Ok((detail, diff_files, refs, info, notes))
400 404 })
401 405 .await?;
402 406
@@ -418,6 +422,7 @@
418 422 current_ref: info.default_branch,
419 423 refs,
420 424 detail,
425 + notes,
421 426 diff_files,
422 427 total_files,
423 428 total_additions,
@@ -1,6 +1,7 @@
1 1 //! Git source browser routes; public browsing of bare repos on disk.
2 2
3 3 mod browsing;
4 + pub mod notes_view;
4 5 mod raw;
5 6
6 7 use std::path::PathBuf;
@@ -6,6 +6,7 @@
6 6
7 7 use crate::auth::SessionUser;
8 8 use crate::git;
9 + use crate::routes::git::notes_view::CommitNote;
9 10 use crate::types::{Item, Project, Version};
10 11
11 12 use super::super::CsrfTokenOption;
@@ -122,6 +123,9 @@
122 123 pub current_ref: String,
123 124 pub refs: Vec<git::RefInfo>,
124 125 pub detail: git::CommitDetail,
126 + /// Notes on this commit, one entry per `refs/notes/*` namespace that has
127 + /// one. Empty for the ordinary unannotated commit.
128 + pub notes: Vec<CommitNote>,
125 129 pub diff_files: Vec<git::DiffFile>,
126 130 pub total_files: usize,
127 131 pub total_additions: usize,
@@ -34,6 +34,28 @@
34 34 </div>
35 35 </div>
36 36
37 + {% if !notes.is_empty() %}
38 + <div class="git-notes">
39 + {% for note in notes %}
40 + <div class="git-note">
41 + <div class="git-note-header">
42 + <span class="git-note-namespace">{{ note.namespace }}</span>
43 + {% if let Some(a) = note.attribution %}
44 + <span class="git-note-attribution">
45 + {% if a.exact %}
46 + {{ a.by }} &middot; {{ a.when }}
47 + {% else %}
48 + edited since {{ a.short_commit }} &middot; {{ a.when }}
49 + {% endif %}
50 + </span>
51 + {% endif %}
52 + </div>
53 + <div class="git-note-body git-readme-body">{{ note.html|safe }}</div>
54 + </div>
55 + {% endfor %}
56 + </div>
57 + {% endif %}
58 +
37 59 <div class="git-diff-stats">
38 60 {{ total_files }} file{% if total_files != 1 %}s{% endif %} changed,
39 61 <span class="additions">+{{ total_additions }}</span> insertion{% if total_additions != 1 %}s{% endif %},
@@ -1,0 +1,181 @@
1 + //! Turning `refs/notes/*` into what the browse templates render.
2 + //!
3 + //! <!-- wiki: mnw-server-git-notes -->
4 + //!
5 + //! The read core is `crate::git::notes`, which is generic over an engine and
6 + //! names no gix type. This module is where the two meet: it opens a
7 + //! [`GixEngine`] over an already-resolved repository, converts hex ids across
8 + //! the `notes::Oid` boundary, and renders note content to HTML.
9 + //!
10 + //! A repository nobody has annotated is the ordinary case, so everything here
11 + //! returns empty rather than erroring, and a namespace whose tree cannot be
12 + //! read is skipped with a warning instead of failing the page it appears on. A
13 + //! note is decoration on a commit view; it must never be the reason the commit
14 + //! view 500s.
15 +
16 + use crate::git::notes::{self, Attribution, GixEngine, Oid};
17 +
18 + /// How far back the attribution walk may look on a detail view. Bounded because
19 + /// a busy notes ref is long; see `notes::attribution`, which reports
20 + /// `exact: false` when it runs out rather than guessing.
21 + const ATTRIBUTION_MAX_COMMITS: usize = 50;
22 +
23 + /// How much of an object id the templates show.
24 + const SHORT_OID_LEN: usize = 8;
25 +
26 + /// One namespace's note on the object being viewed, rendered.
27 + pub struct CommitNote {
28 + /// Namespace as a person says it: `commits`, `mnw/builds`.
29 + pub namespace: String,
30 + /// Note content rendered through docengine, sanitized.
31 + pub html: String,
32 + /// Who last changed it, when the walk could say.
33 + pub attribution: Option<NoteAttribution>,
34 + }
35 +
36 + /// Who last changed a note, in a shape the templates can print directly.
37 + ///
38 + /// `exact` is the load-bearing field: when it is false the walk hit its budget
39 + /// and `commit` is the oldest commit it examined rather than the one it proved
40 + /// responsible, so the template says "edited since" and never names a person.
41 + pub struct NoteAttribution {
42 + pub short_commit: String,
43 + pub by: String,
44 + pub when: String,
45 + pub exact: bool,
46 + }
47 +
48 + /// Every note on `target`, across every namespace, rendered for display.
49 + ///
50 + /// `target` is a hex object id; anything unparseable yields no notes rather
51 + /// than an error, because the caller has already resolved it against the
52 + /// repository and a second opinion here would only be a worse error message.
53 + pub fn notes_on_object(repo: &gix::Repository, target: &str) -> Vec<CommitNote> {
54 + let Ok(target) = Oid::from_hex(target.as_bytes()) else {
55 + return Vec::new();
56 + };
57 + let engine = GixEngine::new(repo);
58 +
59 + let namespaces = match notes::list_namespaces(&engine) {
60 + Ok(ns) => ns,
61 + Err(e) => {
62 + tracing::warn!(error = %e, "listing notes namespaces failed");
63 + return Vec::new();
64 + }
65 + };
66 +
67 + let mut out = Vec::new();
68 + for ns in namespaces {
69 + let note = match notes::note_for(&engine, ns.tip, target) {
70 + Ok(Some(note)) => note,
71 + Ok(None) => continue,
72 + Err(e) => {
73 + tracing::warn!(namespace = %ns.name, error = %e, "reading note failed");
74 + continue;
75 + }
76 + };
77 +
78 + // Attribution is a second walk over the notes ref, so it only runs for
79 + // a namespace that actually has something to attribute.
80 + let attribution = match notes::attribution(&engine, ns.tip, target, ATTRIBUTION_MAX_COMMITS)
81 + {
82 + Ok(a) => a.map(render_attribution),
83 + Err(e) => {
84 + tracing::warn!(namespace = %ns.name, error = %e, "note attribution failed");
85 + None
86 + }
87 + };
88 +
89 + out.push(CommitNote {
90 + namespace: ns.name,
91 + html: docengine::render_permissive(&note.content_lossy()),
92 + attribution,
93 + });
94 + }
95 + out
96 + }
97 +
98 + fn render_attribution(a: Attribution) -> NoteAttribution {
99 + NoteAttribution {
100 + short_commit: a.note_commit.to_short_hex(SHORT_OID_LEN),
101 + by: a.by.name,
102 + when: a.by.time.format("%Y-%m-%d %H:%M UTC").to_string(),
103 + exact: a.exact,
104 + }
105 + }
106 +
107 + #[cfg(test)]
108 + mod tests {
109 + use gix::objs::tree::EntryKind;
110 +
111 + use super::*;
112 +
113 + /// Well-formed hex; nothing dereferences it, since a notes tree is keyed by
114 + /// id and the reader never looks the target up as an object.
115 + const TARGET: &str = "aabbccddeeff00112233445566778899aabbccdd";
116 +
117 + /// A bare repo carrying one note on `TARGET` in each of two namespaces, so
118 + /// the ordering and the per-namespace split are both exercised.
119 + fn annotated_repo() -> (tempfile::TempDir, gix::Repository) {
120 + let tmp = tempfile::TempDir::new().unwrap();
121 + let path = tmp.path().join("owner").join("view-test.git");
122 + std::fs::create_dir_all(&path).unwrap();
123 + let repo = gix::init_bare(&path).unwrap();
124 +
125 + for (ns, body) in [
126 + ("commits", "reviewed by *hand*"),
127 + ("mnw/builds", "build ok"),
128 + ] {
129 + let blob = repo.write_blob(body.as_bytes()).unwrap().detach();
130 + let mut tree = gix::objs::Tree::empty();
131 + tree.entries.push(gix::objs::tree::Entry {
132 + mode: EntryKind::Blob.into(),
133 + filename: TARGET.into(),
134 + oid: blob,
135 + });
136 + let tree = repo.write_object(&tree).unwrap().detach();
137 + let who = gix::actor::SignatureRef {
138 + name: "Fixture".into(),
139 + email: "notes@example.com".into(),
140 + time: "1700000000 +0000",
141 + };
142 + repo.commit_as(
143 + who,
144 + who,
145 + format!("refs/notes/{ns}"),
146 + "notes: fixture",
147 + tree,
148 + Vec::<gix::ObjectId>::new(),
149 + )
150 + .unwrap();
151 + }
152 + (tmp, repo)
153 + }
154 +
155 + #[test]
156 + fn renders_one_block_per_namespace_with_default_first() {
157 + let (_tmp, repo) = annotated_repo();
158 + let notes = notes_on_object(&repo, TARGET);
159 +
160 + let names: Vec<&str> = notes.iter().map(|n| n.namespace.as_str()).collect();
161 + assert_eq!(names, ["commits", "mnw/builds"]);
162 + assert!(notes[0].html.contains("<em>hand</em>"), "{}", notes[0].html);
163 + }
164 +
165 + #[test]
166 + fn attribution_of_a_root_notes_commit_is_exact() {
167 + let (_tmp, repo) = annotated_repo();
168 + let notes = notes_on_object(&repo, TARGET);
169 +
170 + let a = notes[0].attribution.as_ref().expect("attributed");
171 + assert!(a.exact, "a root commit introduced the note by definition");
172 + assert_eq!(a.by, "Fixture");
173 + }
174 +
175 + #[test]
176 + fn an_unannotated_target_and_a_malformed_one_both_render_nothing() {
177 + let (_tmp, repo) = annotated_repo();
178 + assert!(notes_on_object(&repo, &"0".repeat(40)).is_empty());
179 + assert!(notes_on_object(&repo, "not-an-object-id").is_empty());
180 + }
181 + }