Skip to main content

max / makenotwork

git: render personal annotations on the commit view The reader's own annotations are shown on any commit they can browse, and every one of them is reachable from /git/my-annotations, including an annotation whose commit nobody serves any more. Orphans are marked and never collected. The repository an annotation was written against is recorded in the ref name, refs/notes/annotations/<owner>/<repo>, so a clone of the annotation repository carries the context along with the writing.
Author: Max Johnson <me@maxj.phd> · 2026-08-31 13:47 UTC
Signed with PGP, not checked
Commit: d120512a8810a4f7423e8aaa5aa07d43352055cf
Parent: 8b95d99
16 files changed, +986 insertions, -89 deletions
@@ -375,6 +375,9 @@
375 375 /// Annotations in the per-repository notes feed. A feed is a recent-news
376 376 /// surface rather than an archive; the tab pages through the rest.
377 377 pub const GIT_NOTES_FEED_ITEMS: i64 = 50;
378 +
379 + /// Annotations per page on a reader's own annotations listing.
380 + pub const GIT_ANNOTATIONS_PER_PAGE: usize = 30;
378 381 pub const GIT_DIFF_MAX_FILES: usize = 20; // Inline diff hunks for first N files
379 382 pub const GIT_DIFF_MAX_LINES: usize = 500; // Per-file line cap for diff display
380 383 pub const GIT_REPOS_PER_PAGE: usize = 30;
@@ -706,4 +706,23 @@
706 706 "private, no-cache"
707 707 );
708 708 }
709 +
710 + #[test]
711 + fn a_git_page_is_never_shared_cacheable() {
712 + // A commit page carries the signed-in reader's own annotations, which
713 + // nobody else may see. `/git` is not in `is_public_page`, so it falls
714 + // through to `private` already; this pins that, and adding `/git` there
715 + // fails here rather than serving one reader's private writing to
716 + // another through a shared CDN.
717 + assert!(!is_public_page("/git/o/r/commit/abc"));
718 + assert_eq!(
719 + cache_control_value("/git/o/r/commit/abc", false, false, false),
720 + "private, no-cache"
721 + );
722 + assert!(!is_public_page("/git/my-annotations"));
723 + assert_eq!(
724 + cache_control_value("/git/my-annotations", false, false, true),
725 + "private, no-cache"
726 + );
727 + }
709 728 }
@@ -7218,6 +7218,34 @@
7218 7218 margin-bottom: var(--gap-pane);
7219 7219 }
7220 7220 .git-annotation textarea { width: 100%; font-family: var(--font-mono); font-size: var(--text-note); }
7221 + .git-annotations {
7222 + border-top: 1px solid var(--border);
7223 + padding-top: var(--gap-section);
7224 + margin-bottom: var(--gap-pane);
7225 + }
7226 + .git-annotations-heading { font-size: var(--text-note); margin: 0 0 var(--gap-peer); }
7227 + .git-annotations-privacy,
7228 + .git-annotations-empty,
7229 + .git-annotations-count { font-size: var(--text-note); color: var(--content-muted); }
7230 + .git-annotations-list { list-style: none; margin: 0; padding: 0; }
7231 + .git-annotation-entry {
7232 + border: 1px solid var(--border);
7233 + padding: var(--gap-section);
7234 + margin-bottom: var(--gap-section);
7235 + }
7236 + .git-annotation-header {
7237 + display: flex;
7238 + gap: var(--gap-peer);
7239 + flex-wrap: wrap;
7240 + align-items: baseline;
7241 + font-size: var(--text-note);
7242 + margin-bottom: var(--gap-peer);
7243 + }
7244 + .git-annotation-target,
7245 + .git-annotation-namespace,
7246 + .git-annotation-when { font-family: var(--font-mono); color: var(--content-muted); }
7247 + .git-annotation-orphan { font-size: var(--text-note); color: var(--content-muted); }
7248 + .git-explore-mine { font-size: var(--text-note); }
7221 7249 .git-diff-stats {
7222 7250 font-size: var(--text-note);
7223 7251 padding: var(--gap-section) 0;
@@ -286,6 +286,7 @@
286 286 GitCommitsTemplate,
287 287 GitCommitDetailTemplate,
288 288 GitNotesTemplate,
289 + GitAnnotationsTemplate,
289 290 GitTagsTemplate,
290 291 GitReplaceTemplate,
291 292 GitBlameTemplate,
@@ -582,7 +582,10 @@
582 582 "one annotation, keyed by the viewer and the hash"
583 583 );
584 584 assert_eq!(found[0].content, "this is the commit that broke it\n");
585 - assert_eq!(found[0].namespace, "commits");
585 + assert_eq!(
586 + found[0].namespace, "annotations/testowner/testrepo",
587 + "the namespace records the repository the annotation was written against"
588 + );
586 589 assert_eq!(found[0].target_oid, sha);
587 590 assert_eq!(
588 591 found[0].repo_name,
@@ -62,6 +62,7 @@
62 62 mod fingerprinting;
63 63 mod follows;
64 64 mod gallery;
65 + mod git_annotations;
65 66 mod git_browser;
66 67 mod git_issues;
67 68 mod git_notes;
@@ -12,6 +12,12 @@
12 12 //!
13 13 //! The repository is private permanently. Publishing a set of annotations is a
14 14 //! moderation and consent decision, not a toggle.
15 + //!
16 + //! The repository the annotation was written against is recorded in the ref
17 + //! name, `refs/notes/annotations/<owner>/<repo>`, and nowhere else. It has to
18 + //! be in the repository for the same reason the annotation is: a clone that
19 + //! carried the writing without the context it was written in would have lost
20 + //! half of it.
15 21
16 22 use axum::{
17 23 Form,
@@ -25,14 +31,53 @@
25 31 auth::AuthUser,
26 32 config::Config,
27 33 constants::ANNOTATION_REPO_NAME,
28 - db::{self, UserId},
34 + db,
29 35 error::{AppError, Result},
30 - git::notes::{self, GixEngine},
31 36 validation,
32 37 };
33 38
34 39 use super::{ResolvedRepo, notes_write::write_note_across};
35 40
41 + /// The first component of every namespace makenot.work writes into an
42 + /// annotation repository.
43 + ///
44 + /// The origin lives in the ref name because it has to live in the repository.
45 + /// A clone of the annotation repository carries `refs/notes/annotations/o/r`
46 + /// and so carries the fact that the note was written against `o/r`; a column in
47 + /// Postgres saying the same thing would be lost the moment the repository is
48 + /// exported, which is the promise this feature is built on.
49 + pub(crate) const ANNOTATION_NS_PREFIX: &str = "annotations";
50 +
51 + /// The namespace an annotation on a commit in `owner/repo` is written to.
52 + ///
53 + /// Namespaced per origin rather than written to `commits`, and both halves of
54 + /// that matter. Per origin, because the same hash can be served by a fork and
55 + /// by its parent and the reader wants to know which page they were reading.
56 + /// Not under `commits`, because git stores loose refs as files and
57 + /// `refs/notes/commits` as a file cannot coexist with `refs/notes/commits/o/r`
58 + /// as a directory: a reader who pushes an ordinary `commits` note into their
59 + /// own annotation repository would break every annotation in it.
60 + pub(crate) fn annotation_namespace(owner: &str, repo: &str) -> String {
61 + format!("{ANNOTATION_NS_PREFIX}/{owner}/{repo}")
62 + }
63 +
64 + /// The repository an annotation namespace was written against, or `None`.
65 + ///
66 + /// `None` for anything the reader pushed into their own annotation repository
67 + /// by hand. That is readable and shown, and it is not editable through the
68 + /// browser: the edit form lives on the origin's commit page and there is no
69 + /// origin to put it on.
70 + pub(crate) fn origin_of_namespace(namespace: &str) -> Option<(&str, &str)> {
71 + let rest = namespace
72 + .strip_prefix(ANNOTATION_NS_PREFIX)?
73 + .strip_prefix('/')?;
74 + let (owner, repo) = rest.split_once('/')?;
75 + if owner.is_empty() || repo.is_empty() || repo.contains('/') {
76 + return None;
77 + }
78 + Some((owner, repo))
79 + }
80 +
36 81 /// The account's annotation repository, creating the row and the bare
37 82 /// repository the first time it is needed.
38 83 ///
@@ -93,65 +138,6 @@
93 138 super::resolve_repo(db, config, &username, ANNOTATION_REPO_NAME, Some(user.id)).await
94 139 }
95 140
96 - /// The reader's own annotation on `oid`, as written, or `None`.
97 - ///
98 - /// A direct git read of one repository, not an index query: finding every
99 - /// annotation on a commit across the browse surface is P7b's job. Every error
100 - /// path yields `None` and logs, because an annotation is decoration on a commit
101 - /// page and must never take the page down with it.
102 - pub(crate) async fn annotation_source(
103 - db: &PgPool,
104 - config: &Config,
105 - user_id: UserId,
106 - username: &str,
107 - oid: &str,
108 - ) -> Option<String> {
109 - let target = notes::Oid::from_hex(oid.as_bytes()).ok()?;
110 -
111 - let row = match db::git_repos::get_annotation_repo(db, user_id).await {
112 - Ok(Some(repo)) => repo,
113 - Ok(None) => return None,
114 - Err(error) => {
115 - tracing::warn!(error = ?error, "annotation repo lookup failed");
116 - return None;
117 - }
118 - };
119 - if !db::git_repos::is_annotation_repo(&row) {
120 - return None;
121 - }
122 -
123 - let resolved = match super::resolve_repo(db, config, username, &row.name, Some(user_id)).await {
124 - Ok(resolved) => resolved,
125 - Err(error) => {
126 - tracing::warn!(error = ?error, "annotation repo did not resolve");
127 - return None;
128 - }
129 - };
130 -
131 - let read = resolved
132 - .with_repo(move |gix_repo| {
133 - let engine = GixEngine::new(gix_repo);
134 - let Some(ns) = notes::resolve_namespace(&engine, notes::DEFAULT_NAMESPACE)
135 - .map_err(crate::git::GitError::from)?
136 - else {
137 - return Ok(None);
138 - };
139 - let note = notes::note_for(&engine, ns.tip, target)
140 - .map_err(crate::git::GitError::from)?
141 - .map(|n| n.content_lossy().into_owned());
142 - Ok(note)
143 - })
144 - .await;
145 -
146 - match read {
147 - Ok(note) => note,
148 - Err(error) => {
149 - tracing::warn!(error = ?error, "reading a personal annotation failed");
150 - None
151 - }
152 - }
153 - }
154 -
155 141 #[derive(Deserialize)]
156 142 pub(super) struct AnnotationForm {
157 143 content: String,
@@ -225,18 +211,14 @@
225 211 // account rather than of which repository the note landed in.
226 212 let who = super::notes_write::identity(user.display_name.as_deref(), user.username.as_str());
227 213
228 - // No namespace picker: the surface is "my note on this commit", and the
229 - // default is what stock `git notes` reads. Pushing any other namespace into
230 - // their own repository stays open to them.
231 - let merged = write_note_across(
232 - &source,
233 - &dest,
234 - oid_str,
235 - notes::DEFAULT_NAMESPACE.to_string(),
236 - content,
237 - who,
238 - )
239 - .await?;
214 + // No namespace picker: the surface is "my note on this commit", and which
215 + // repository it was read in is the only thing that varies. Taken from the
216 + // resolved row rather than from the URL, so a clone URL spelt
217 + // `testrepo.git` cannot open a second namespace for the same repository.
218 + let namespace = annotation_namespace(source.db_user.username.as_str(), &source.db_repo.name);
219 +
220 + let merged =
221 + write_note_across(&source, &dest, oid_str, namespace.clone(), content, who).await?;
240 222
241 223 super::notes_index::reindex_after_write(
242 224 db,
@@ -244,13 +226,13 @@
244 226 dest.db_repo.id,
245 227 user.username.as_str(),
246 228 ANNOTATION_REPO_NAME,
247 - notes::DEFAULT_NAMESPACE,
229 + &namespace,
248 230 )
249 231 .await;
250 232
251 233 let query = if merged { "?annotation=merged" } else { "" };
252 234 Ok(Redirect::to(&format!(
253 - "/git/{owner}/{repo_name}/commit/{oid_str}{query}#annotation"
235 + "/git/{owner}/{repo_name}/commit/{oid_str}{query}#annotations"
254 236 )))
255 237 }
256 238