Skip to main content

max / makenotwork

add per-user annotation repos and the cross-repo note write path
Author: Max Johnson <me@maxj.phd> · 2026-08-31 13:07 UTC
Signed with PGP, not checked
Commit: 1a8ca059a2148af4075ef2d2ab09a427899279d6
Parent: 89a6374
17 files changed, +690 insertions, -70 deletions
@@ -363,6 +363,13 @@
363 363
364 364 // Git source browser
365 365 pub const GIT_MAX_FILE_SIZE_BYTES: usize = 1_024_000; // 1MB display limit
366 +
367 + /// Repository names ending in this suffix, and the bare stem itself, are
368 + /// makenot.work's to create. Reserving a suffix rather than a word keeps a
369 + /// creator from ever losing a name they would plausibly have chosen.
370 + pub const RESERVED_REPO_SUFFIX: &str = ".mnw";
371 + /// The one repository per account that holds personal annotations.
372 + pub const ANNOTATION_REPO_NAME: &str = "annotations.mnw";
366 373 pub const GIT_COMMITS_PER_PAGE: usize = 30;
367 374
368 375 /// Annotations in the per-repository notes feed. A feed is a recent-news
@@ -7211,6 +7211,13 @@
7211 7211 margin-bottom: var(--gap-section);
7212 7212 font-size: var(--text-note);
7213 7213 }
7214 + /* A reader's own annotation, which lives in their repository rather than this one. */
7215 + .git-annotation {
7216 + border-top: 1px solid var(--border);
7217 + padding-top: var(--gap-section);
7218 + margin-bottom: var(--gap-pane);
7219 + }
7220 + .git-annotation textarea { width: 100%; font-family: var(--font-mono); font-size: var(--text-note); }
7214 7221 .git-diff-stats {
7215 7222 font-size: var(--text-note);
7216 7223 padding: var(--gap-section) 0;
@@ -333,6 +333,25 @@
333 333 Private => "private",
334 334 });
335 335
336 + // --- Git repository kind ---
337 +
338 + /// What a repository is for.
339 + ///
340 + /// `Source` is every repository a creator makes. `Annotations` is the one
341 + /// per-account repository holding nothing but `refs/notes/*`, which is private
342 + /// permanently.
343 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344 + #[serde(rename_all = "lowercase")]
345 + pub enum GitRepoKind {
346 + Source,
347 + Annotations,
348 + }
349 +
350 + impl_str_enum!(GitRepoKind {
351 + Source => "source",
352 + Annotations => "annotations",
353 + });
354 +
336 355 // --- Project member roles ---
337 356
338 357 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -2197,6 +2216,7 @@
2197 2216 SyncBillingStatus,
2198 2217 SyncEnforcementMode,
2199 2218 Visibility,
2219 + GitRepoKind,
2200 2220 ProjectRole,
2201 2221 SyncOperation,
2202 2222 SyncPlatform,
@@ -4,7 +4,7 @@
4 4 use sqlx::{FromRow, PgPool};
5 5
6 6 use super::models::DbGitRepo;
7 - use super::{GitRepoId, ProjectId, UserId, Visibility};
7 + use super::{GitRepoId, GitRepoKind, ProjectId, UserId, Visibility};
8 8 use crate::error::Result;
9 9
10 10 /// A public repo joined with its owner's username, for the explore page.
@@ -23,7 +23,7 @@
23 23 /// this with names supplied by untrusted remote git clients.
24 24 #[tracing::instrument(skip_all)]
25 25 pub async fn create_repo(pool: &PgPool, user_id: UserId, name: &str) -> Result<DbGitRepo> {
26 - crate::validation::validate_git_repo_name(name)?;
26 + crate::validation::validate_creatable_repo_name(name)?;
27 27 let repo = sqlx::query_as::<_, DbGitRepo>(
28 28 r"
29 29 INSERT INTO git_repos (user_id, name)
@@ -47,7 +47,7 @@
47 47 name: &str,
48 48 visibility: Visibility,
49 49 ) -> Result<DbGitRepo> {
50 - crate::validation::validate_git_repo_name(name)?;
50 + crate::validation::validate_creatable_repo_name(name)?;
51 51 let repo = sqlx::query_as::<_, DbGitRepo>(
52 52 r"
53 53 INSERT INTO git_repos (user_id, name, visibility)
@@ -64,6 +64,71 @@
64 64 Ok(repo)
65 65 }
66 66
67 + /// The account's annotation repository, if it has one yet.
68 + #[tracing::instrument(skip_all)]
69 + pub async fn get_annotation_repo(pool: &PgPool, user_id: UserId) -> Result<Option<DbGitRepo>> {
70 + let repo = sqlx::query_as::<_, DbGitRepo>(
71 + "SELECT * FROM git_repos WHERE user_id = $1 AND kind = 'annotations'",
72 + )
73 + .bind(user_id)
74 + .fetch_optional(pool)
75 + .await?;
76 +
77 + Ok(repo)
78 + }
79 +
80 + /// Register the account's annotation repository.
81 + ///
82 + /// Private and `kind = 'annotations'` in one statement rather than an insert
83 + /// plus two updates: a row that is public for the width of a transaction is a
84 + /// row that can be read. `ON CONFLICT DO NOTHING` plus a re-select, so two
85 + /// first-annotations racing each other both end up with the row the winner
86 + /// made.
87 + ///
88 + /// Deliberately does not call `validate_creatable_repo_name`: the name it
89 + /// writes is the reserved one, and this is the code the reservation exists for.
90 + #[tracing::instrument(skip_all)]
91 + pub async fn create_annotation_repo(pool: &PgPool, user_id: UserId) -> Result<DbGitRepo> {
92 + let inserted = sqlx::query_as::<_, DbGitRepo>(
93 + r"
94 + INSERT INTO git_repos (user_id, name, visibility, kind, description)
95 + VALUES ($1, $2, 'private', 'annotations', $3)
96 + ON CONFLICT (user_id, name) DO NOTHING
97 + RETURNING *
98 + ",
99 + )
100 + .bind(user_id)
101 + .bind(crate::constants::ANNOTATION_REPO_NAME)
102 + .bind(ANNOTATION_REPO_DESCRIPTION)
103 + .fetch_optional(pool)
104 + .await?;
105 +
106 + if let Some(repo) = inserted {
107 + return Ok(repo);
108 + }
109 +
110 + // Somebody else inserted it, or the account already owns a repository of
111 + // that name. Either way the row that is there is the answer.
112 + get_repo_by_user_and_name(pool, user_id, crate::constants::ANNOTATION_REPO_NAME)
113 + .await?
114 + .ok_or_else(|| {
115 + crate::error::AppError::Internal(anyhow::anyhow!(
116 + "annotation repo insert conflicted but no row is there"
117 + ))
118 + })
119 + }
120 +
121 + /// What the annotation repository says on its own page.
122 + pub const ANNOTATION_REPO_DESCRIPTION: &str =
123 + "Personal annotations. Notes on commits across makenot.work, private to this account.";
124 +
125 + /// Whether this repository is the account's annotation store, which is private
126 + /// permanently: publishing a set of annotations is a moderation and consent
127 + /// decision, not a toggle.
128 + pub fn is_annotation_repo(repo: &DbGitRepo) -> bool {
129 + repo.kind == GitRepoKind::Annotations
130 + }
131 +
67 132 /// Look up a repo by its primary key. Returns `None` if not found.
68 133 #[tracing::instrument(skip_all)]
69 134 pub async fn get_repo_by_id(pool: &PgPool, repo_id: GitRepoId) -> Result<Option<DbGitRepo>> {
@@ -259,3 +324,14 @@
259 324
260 325 Ok(rows)
261 326 }
327 +
328 + #[cfg(test)]
329 + mod tests {
330 + #[test]
331 + fn annotation_repo_name_is_a_legal_repo_name_and_reserved() {
332 + let name = crate::constants::ANNOTATION_REPO_NAME;
333 + assert!(crate::validation::validate_git_repo_name(name).is_ok());
334 + assert!(crate::validation::is_reserved_repo_name(name));
335 + assert!(crate::validation::validate_creatable_repo_name(name).is_err());
336 + }
337 + }