Skip to main content

max / makenotwork

server: feed the notes index from all three ref writers The reindex itself, its hook endpoint, and a rebuild command. The plan named one call site, the post-receive hook. That covers pushed notes and nothing else, because the P3 inbox merge and the P2 browser write both move refs/notes/* from inside the server, where no hook fires. All three go through reindex_namespace now, and none of them passes the tip it is replacing: the previously indexed tip is read from git_notes_index_state, so a hook that runs late or a merge that raced a push still diffs against what the index actually holds. An in-process write logs a failed reindex rather than failing the write. The note is in the repository by then, and a projection of it must not be able to veto it. mnw-admin reindex-notes rebuilds the table from the repositories, per repo or platform-wide. Walking a namespace the index already holds costs one ref read, so a full run is the answer to "is the index right" rather than something to schedule carefully. Deploy: existing repos keep the hook they were created with, so `mnw-admin install-hooks` has to be re-run or a notes push reindexes nothing. Same caveat P3 carries for the inbox arm.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 01:07 UTC
Signed with PGP, not checked
Commit: a88b14877061ec5f472467fda017447c7a8e79a4
Parent: 30e0299
8 files changed, +565 insertions, -17 deletions
@@ -126,6 +126,21 @@
126 126 || echo "[$(date -u +%FT%TZ)] FAILED issues/process-push exit=$?"
127 127 ) &
128 128 ;;
129 + refs/notes/*)
130 + # A note that arrived by push. Backgrounded like the builds and
131 + # issues arms: the index is a projection of what the push already
132 + # landed, so there is nothing to tell the pusher and nothing they
133 + # would do about it. The inbox arm below is the one that answers.
134 + ( exec >>"$LOG" 2>&1
135 + echo "[$(date -u +%FT%TZ)] notes-index $OWNER/$REPO_NAME ref=$refname"
136 + curl -sf -X POST \
137 + -H "Authorization: Bearer __HMAC__" \
138 + -H "Content-Type: application/json" \
139 + -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$refname\"}" \
140 + "http://localhost:3000/api/internal/notes/reindex" \
141 + || echo "[$(date -u +%FT%TZ)] FAILED notes/reindex exit=$?"
142 + ) &
143 + ;;
129 144 refs/mnw/notes-inbox/*)
130 145 # The one arm that is NOT backgrounded. Builds and issue links are
131 146 # things the pusher learns about later; a notes merge is the answer
@@ -1160,6 +1175,22 @@
1160 1175 assert!(hook.contains("/api/internal/builds/trigger"));
1161 1176 }
1162 1177
1178 + /// The two notes arms answer different refs and must not be confused for
1179 + /// each other: an inbox push is merged and answered synchronously, a notes
1180 + /// push is only indexed. A `case` pattern that caught both would either
1181 + /// merge a ref that is already the namespace or leave a push unindexed.
1182 + #[test]
1183 + fn the_hook_indexes_a_notes_push_and_merges_an_inbox_push() {
1184 + let hook = post_receive_hook("secret-token-123", "alice", "myrepo");
1185 + assert!(hook.contains("/api/internal/notes/reindex"));
1186 + assert!(hook.contains("/api/internal/notes/merge-inbox"));
1187 + assert!(hook.contains("refs/notes/*)"));
1188 + assert!(hook.contains("refs/mnw/notes-inbox/*)"));
1189 + // The inbox lives under refs/mnw/, so nothing an inbox push does can
1190 + // fall into the indexing arm. `notes_inbox` pins that prefix itself.
1191 + assert!(!"refs/mnw/notes-inbox/commits".starts_with("refs/notes/"));
1192 + }
1193 +
1163 1194 /// Fixed vector, duplicated in mnw-cli's `repo_hmac` test. mnw-cli installs
1164 1195 /// hooks for repos it auto-creates over SSH, and this endpoint verifies
1165 1196 /// them; if either side's derivation moves, both tests have to move
@@ -19,6 +19,7 @@
19 19 //! mnw-admin rebuild-keys Rebuild authorized_keys from DB
20 20 //! mnw-admin git-auth <key_id> Authenticate SSH git/management operations
21 21 //! mnw-admin setup-git Set up SSH directories, permissions, sudoers
22 + //! mnw-admin reindex-notes [user] Rebuild the git-notes index from the repos
22 23 //!
23 24 //! SSH management commands (via git-auth dispatcher):
24 25 //! repo list List your repositories
@@ -133,6 +134,11 @@
133 134 },
134 135 /// Install post-receive hooks on all git repos for build triggers
135 136 InstallHooks,
137 + /// Rebuild the git-notes index from the repositories on disk
138 + ReindexNotes {
139 + /// Only this owner's repositories. Omit for every repository.
140 + username: Option<String>,
141 + },
136 142 /// Backfill resource limits (receive.maxInputSize) into all existing bare repos
137 143 BackfillGitConfig,
138 144 /// Set up SSH infrastructure for git access (directories, permissions, sudoers)
@@ -172,6 +178,7 @@
172 178 Command::RebuildKeys => cmd_rebuild_keys(&pool).await?,
173 179 Command::GitAuth { key_id } => cmd_git_auth(&pool, &key_id).await?,
174 180 Command::InstallHooks => cmd_install_hooks()?,
181 + Command::ReindexNotes { username } => cmd_reindex_notes(&pool, username.as_deref()).await?,
175 182 Command::BackfillGitConfig => cmd_backfill_git_config()?,
176 183 Command::SetupGit => cmd_setup_git()?,
177 184 }
@@ -732,6 +739,52 @@
732 739 Ok(())
733 740 }
734 741
742 + // ── Notes index command ──
743 +
744 + /// Rebuild the `git_notes` index from what the repositories hold.
745 + ///
746 + /// The index is a projection and the repositories are truth, so this is always
747 + /// safe to run: it walks every namespace of every repository and makes the table
748 + /// agree with the refs. Reindexing a namespace the index already holds costs one
749 + /// ref read, so a full run over an up-to-date platform is cheap enough to be the
750 + /// answer to "is the index right" rather than a thing to be careful about.
751 + async fn cmd_reindex_notes(pool: &PgPool, username: Option<&str>) -> anyhow::Result<()> {
752 + let config = Config::from_env().map_err(|e| anyhow::anyhow!("failed to load config: {e}"))?;
753 +
754 + let repos = db::git_repos::all_repos_with_owner(pool).await?;
755 + let repos: Vec<_> = repos
756 + .into_iter()
757 + .filter(|(_, owner, _)| username.is_none_or(|wanted| owner == wanted))
758 + .collect();
759 + if repos.is_empty() {
760 + println!("No repositories to reindex.");
761 + return Ok(());
762 + }
763 +
764 + let mut done = 0u32;
765 + let mut failed = 0u32;
766 + for (repo_id, owner, name) in &repos {
767 + match makenotwork::routes::git::notes_index::reindex_repo(
768 + pool, &config, *repo_id, owner, name,
769 + )
770 + .await
771 + {
772 + Ok(()) => done += 1,
773 + // One unreadable repository must not stop the rest: the whole point
774 + // of the command is to make the index agree with the disk, and
775 + // stopping at the first problem leaves it disagreeing everywhere
776 + // after it.
777 + Err(e) => {
778 + failed += 1;
779 + eprintln!("{owner}/{name}: {e}");
780 + }
781 + }
782 + }
783 +
784 + println!("Reindexed notes for {done} repo(s), {failed} failed.");
785 + Ok(())
786 + }
787 +
735 788 /// One-time backfill: apply the standard bare-repo resource limits to every
736 789 /// existing repo on disk. New repos get these at creation via
737 790 /// `git::init_bare_repo`; this brings repos created before that landed up to par.
@@ -122,8 +122,12 @@
122 122 pub upserts: &'a [NoteUpsert],
123 123 /// Target ids whose note is gone, hex.
124 124 pub removals: &'a [String],
125 - /// The notes-ref tip this diff brings the index up to.
126 - pub tip: &'a str,
125 + /// The notes-ref tip this diff brings the index up to, or `None` for a
126 + /// batch that is part of a larger diff. A batch that claimed the tip before
127 + /// the rest of the diff was written would make the next reindex skip the
128 + /// remainder; leaving the old tip in place makes it recompute and redo work
129 + /// that is idempotent anyway.
130 + pub tip: Option<&'a str>,
127 131 /// When the annotations were written, and by whom: the notes commit's own
128 132 /// committer, not the person who triggered the reindex.
129 133 pub updated_at: DateTime<Utc>,
@@ -208,18 +212,20 @@
208 212 .await?;
209 213 }
210 214
211 - sqlx::query(
212 - "INSERT INTO git_notes_index_state (repo_id, namespace, indexed_tip, indexed_at)
213 - VALUES ($1, $2, $3, NOW())
214 - ON CONFLICT (repo_id, namespace) DO UPDATE SET
215 - indexed_tip = EXCLUDED.indexed_tip,
216 - indexed_at = EXCLUDED.indexed_at",
217 - )
218 - .bind(repo_id)
219 - .bind(namespace)
220 - .bind(tip)
221 - .execute(&mut *tx)
222 - .await?;
215 + if let Some(tip) = tip {
216 + sqlx::query(
217 + "INSERT INTO git_notes_index_state (repo_id, namespace, indexed_tip, indexed_at)
218 + VALUES ($1, $2, $3, NOW())
219 + ON CONFLICT (repo_id, namespace) DO UPDATE SET
220 + indexed_tip = EXCLUDED.indexed_tip,
221 + indexed_at = EXCLUDED.indexed_at",
222 + )
223 + .bind(repo_id)
224 + .bind(namespace)
225 + .bind(tip)
226 + .execute(&mut *tx)
227 + .await?;
228 + }
223 229
224 230 tx.commit().await?;
225 231 Ok(())
@@ -238,3 +238,24 @@
238 238
239 239 Ok(count.0)
240 240 }
241 +
242 + /// Every repository on the platform, with its owner's username.
243 + ///
244 + /// Visibility-blind on purpose: the caller is an operator rebuilding a
245 + /// server-side projection of what is on disk, not a page deciding what to show.
246 + /// Nothing user-facing should reach for this.
247 + #[tracing::instrument(skip_all)]
248 + pub async fn all_repos_with_owner(pool: &PgPool) -> Result<Vec<(GitRepoId, String, String)>> {
249 + let rows = sqlx::query_as::<_, (GitRepoId, String, String)>(
250 + r"
251 + SELECT g.id, u.username, g.name
252 + FROM git_repos g
253 + JOIN users u ON u.id = g.user_id
254 + ORDER BY u.username, g.name
255 + ",
256 + )
257 + .fetch_all(pool)
258 + .await?;
259 +
260 + Ok(rows)
261 + }
@@ -2,6 +2,7 @@
2 2
3 3 mod browsing;
4 4 mod notes_inbox;
5 + pub mod notes_index;
5 6 pub mod notes_view;
6 7 mod notes_write;
7 8 mod raw;
@@ -112,6 +113,10 @@
112 113 notes_inbox::merge_inbox,
113 114 ),
114 115 )
116 + .route(
117 + "/api/internal/notes/reindex",
118 + post_csrf_skip("internal git push hook, HMAC bearer", notes_index::reindex),
119 + )
115 120 }
116 121
117 122 // Helpers
@@ -103,7 +103,7 @@
103 103 db::users::get_user_by_username(&db, &db::Username::from_trusted(req.repo_owner.clone()))
104 104 .await?
105 105 .ok_or(AppError::NotFound)?;
106 - db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
106 + let db_repo = db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
107 107 .await?
108 108 .ok_or(AppError::NotFound)?;
109 109
@@ -111,6 +111,7 @@
111 111 let inbox_ref = req.ref_name.clone();
112 112 let notes_ref = format!("{}{namespace}", notes::NOTES_REF_PREFIX);
113 113 let namespace_out = namespace.clone();
114 + let (repo_owner, repo_name) = (req.repo_owner.clone(), req.repo_name.clone());
114 115
115 116 let (tip, merged) = tokio::task::spawn_blocking(move || -> Result<(Option<String>, bool)> {
116 117 let repo = crate::git::open_repo(&root, &req.repo_owner, &req.repo_name)?;
@@ -147,6 +148,20 @@
147 148 .await
148 149 .map_err(|e| AppError::Internal(anyhow::anyhow!("notes merge task failed: {e}")))??;
149 150
151 + // The merge moved `refs/notes/<ns>` from inside this process, so no hook
152 + // fired for it and nothing else will update the index. Reported to the log
153 + // and not to the pusher: their notes are merged either way, and the push
154 + // must not fail over a projection of them.
155 + super::notes_index::reindex_after_write(
156 + &db,
157 + &config,
158 + db_repo.id,
159 + &repo_owner,
160 + &repo_name,
161 + &namespace_out,
162 + )
163 + .await;
164 +
150 165 Ok(Json(MergeInboxResponse {
151 166 namespace: namespace_out,
152 167 tip,
@@ -167,8 +182,9 @@
167 182 }
168 183
169 184 /// The per-repo HMAC, derived from `BUILD_TRIGGER_TOKEN` so the global token
170 - /// never reaches a hook script on disk. Same check `process_push` makes.
171 - fn verify_repo_hmac(
185 + /// never reaches a hook script on disk. Same check `process_push` makes, and
186 + /// the same one the reindex endpoint next door makes.
187 + pub(super) fn verify_repo_hmac(
172 188 config: &Config,
173 189 headers: &HeaderMap,
174 190 owner: &str,
@@ -126,6 +126,7 @@
126 126 let target = notes::Oid::from_hex(oid_str.as_bytes()).map_err(|_| AppError::NotFound)?;
127 127 let gix_target = gix::ObjectId::from_hex(oid_str.as_bytes()).map_err(|_| AppError::NotFound)?;
128 128 let who = identity(user);
129 + let written_namespace = namespace.clone();
129 130
130 131 let merged = resolved
131 132 .with_repo(move |gix_repo| {
@@ -158,6 +159,20 @@
158 159 })
159 160 .await?;
160 161
162 + // A browser write moves `refs/notes/<ns>` in-process, so no hook fires and
163 + // the index would otherwise hold only what was pushed. Logged rather than
164 + // surfaced: the note is written, and a failed projection is not the
165 + // writer's problem to see.
166 + super::notes_index::reindex_after_write(
167 + db,
168 + config,
169 + resolved.db_repo.id,
170 + owner,
171 + repo_name,
172 + &written_namespace,
173 + )
174 + .await;
175 +
161 176 // Back to the commit, at the panel. `merged` is the one thing the page
162 177 // cannot work out for itself: the note on screen is not what was typed.
163 178 let query = if merged { "?notes=merged" } else { "" };
@@ -1,0 +1,401 @@
1 + //! Keeping the `git_notes` index in step with `refs/notes/*`.
2 + //!
3 + //! <!-- wiki: mnw-server-git-notes -->
4 + //!
5 + //! The repository is truth and this is a projection of it, so everything here
6 + //! is written to be safely repeatable: a reindex that runs twice does nothing
7 + //! the second time, a reindex that dies halfway leaves rows that the next one
8 + //! recomputes, and a reindex that never runs at all costs correctness nothing
9 + //! because every read path can still walk the repository.
10 + //!
11 + //! **Three call sites, not one.** The plan named the post-receive hook. That
12 + //! covers a push and nothing else: the P3 inbox merge and the P2 browser write
13 + //! both move `refs/notes/*` from inside this process, where no hook fires. A
14 + //! hook-only index would silently hold only the notes that arrived by push,
15 + //! which is the minority of them.
16 + //!
17 + //! None of the three tells this module which tip it is replacing. They do not
18 + //! all know it, and the one that does could be wrong about it (a hook that runs
19 + //! late, a push that landed while a merge was running). The previously indexed
20 + //! tip is read from `git_notes_index_state` instead, so all three are the same
21 + //! call and the diff is always against what the index actually holds.
22 +
23 + use axum::{Json, extract::State, http::HeaderMap};
24 + use chrono::{DateTime, Utc};
25 + use gix::bstr::ByteSlice;
26 + use serde::{Deserialize, Serialize};
27 + use sqlx::PgPool;
28 +
29 + use crate::{
30 + config::Config,
31 + db::{self, GitRepoId},
32 + error::{AppError, Result},
33 + git::notes::{self, GixEngine, NoteObjects, Oid},
34 + };
35 +
36 + use super::repos_root;
37 +
38 + /// How many notes are written per transaction.
39 + ///
40 + /// A cold index of a heavily annotated repository is one diff and can be large;
41 + /// the batch keeps a single statement's parameter arrays and the memory behind
42 + /// them bounded. The index-state row is written only with the last batch, so a
43 + /// run that dies partway through leaves the namespace claiming the tip it had
44 + /// before and the next run recomputes the whole diff. Both halves of applying a
45 + /// change are idempotent, which is what makes redoing it free.
46 + const BATCH: usize = 500;
47 +
48 + /// How much of a commit subject the feed keeps.
49 + const SUMMARY_MAX_CHARS: usize = 200;
50 +
51 + /// What one namespace's reindex turned out to need.
52 + enum Outcome {
53 + /// The ref is gone. The index forgets the namespace rather than emptying
54 + /// it, so a namespace that comes back is walked in full.
55 + Gone,
56 + /// The index already holds this tip.
57 + UpToDate,
58 + Apply(Plan),
59 + }
60 +
61 + struct Plan {
62 + tip: String,
63 + /// The notes commit's own committer, which is who wrote the annotations in
64 + /// this diff and when. Not the person who triggered the reindex: a rebuild
65 + /// run in 2027 must not restamp a note written in 2026 as written today.
66 + updated_at: DateTime<Utc>,
67 + updated_by: String,
68 + upserts: Vec<db::git_notes::NoteUpsert>,
69 + removals: Vec<String>,
70 + }
71 +
72 + /// Bring one namespace's rows up to date with the ref.
73 + ///
74 + /// Errors are the caller's to swallow or report. A write path should log and
75 + /// carry on: the note is in the repository either way, and failing somebody's
76 + /// annotation because a projection of it could not be updated would be the
77 + /// index deciding what the repository is allowed to hold.
78 + #[tracing::instrument(skip(db_pool, config), fields(%owner, %repo_name, %namespace))]
79 + pub async fn reindex_namespace(
80 + db_pool: &PgPool,
81 + config: &Config,
82 + repo_id: GitRepoId,
83 + owner: &str,
84 + repo_name: &str,
85 + namespace: &str,
86 + ) -> Result<()> {
87 + let previous = db::git_notes::indexed_tip(db_pool, repo_id, namespace).await?;
88 + let root = repos_root(config)?;
89 +
90 + let owned = (
91 + owner.to_string(),
92 + repo_name.to_string(),
93 + namespace.to_string(),
94 + );
95 + let outcome = tokio::task::spawn_blocking(move || {
96 + let (owner, repo_name, namespace) = owned;
97 + plan(&root, &owner, &repo_name, &namespace, previous.as_deref())
98 + })
99 + .await
100 + .map_err(|e| AppError::Internal(anyhow::anyhow!("notes reindex task failed: {e}")))??;
101 +
102 + match outcome {
103 + Outcome::UpToDate => Ok(()),
104 + Outcome::Gone => db::git_notes::forget_namespace(db_pool, repo_id, namespace).await,
105 + Outcome::Apply(plan) => apply(db_pool, repo_id, namespace, plan).await,
106 + }
107 + }
108 +
109 + /// Bring every namespace up to date, and forget the ones the repository no
110 + /// longer has.
111 + ///
112 + /// The full-rebuild entry point. Reindexing a namespace the index has never
113 + /// seen walks it whole, so dropping the rows and calling this reconstructs the
114 + /// table from the repositories, which is what the load-bearing rule promises.
115 + #[tracing::instrument(skip(db_pool, config), fields(%owner, %repo_name))]
116 + pub async fn reindex_repo(
117 + db_pool: &PgPool,
118 + config: &Config,
119 + repo_id: GitRepoId,
120 + owner: &str,
121 + repo_name: &str,
122 + ) -> Result<()> {
123 + let root = repos_root(config)?;
124 + let owned = (owner.to_string(), repo_name.to_string());
125 + let present = tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
126 + let (owner, repo_name) = owned;
127 + let repo = crate::git::open_repo(&root, &owner, &repo_name)?;
128 + let engine = GixEngine::new(&repo);
129 + Ok(notes::list_namespaces(&engine)
130 + .map_err(to_app)?
131 + .into_iter()
132 + .map(|ns| ns.name)
133 + .collect())
134 + })
135 + .await
136 + .map_err(|e| AppError::Internal(anyhow::anyhow!("notes reindex task failed: {e}")))??;
137 +
138 + for namespace in &present {
139 + reindex_namespace(db_pool, config, repo_id, owner, repo_name, namespace).await?;
140 + }
141 +
142 + // Namespaces the index knows and the repository does not. A deleted notes
143 + // ref fires a hook for the delete, but a rebuild has to catch the ones that
144 + // went away while nothing was watching.
145 + for namespace in db::git_notes::indexed_namespaces(db_pool, repo_id).await? {
146 + if !present.contains(&namespace) {
147 + db::git_notes::forget_namespace(db_pool, repo_id, &namespace).await?;
148 + }
149 + }
150 + Ok(())
151 + }
152 +
153 + /// Read the repository and work out what the index is missing.
154 + ///
155 + /// Blocking: this opens a repository and walks trees. Everything it returns is
156 + /// owned, so the async half touches no git object.
157 + fn plan(
158 + root: &std::path::Path,
159 + owner: &str,
160 + repo_name: &str,
161 + namespace: &str,
162 + previous: Option<&str>,
163 + ) -> Result<Outcome> {
164 + let repo = crate::git::open_repo(root, owner, repo_name)?;
165 + let engine = GixEngine::new(&repo);
166 +
167 + let full_ref = format!("{}{namespace}", notes::NOTES_REF_PREFIX);
168 + let Some(tip) = engine.resolve_ref(&full_ref).map_err(to_app)? else {
169 + return Ok(Outcome::Gone);
170 + };
171 +
172 + // A stored tip the repository cannot read is treated as no tip at all,
173 + // which walks the namespace from cold. It happens for real: a notes ref can
174 + // be force-pushed and the commit it used to name collected, and refusing to
175 + // index a repository because of a commit that no longer exists would leave
176 + // it stale forever with nothing to fix it.
177 + let previous = previous
178 + .and_then(|hex| Oid::from_hex(hex.as_bytes()).ok())
179 + .filter(|oid| engine.read_commit(*oid).is_ok());
180 + if previous == Some(tip) {
181 + return Ok(Outcome::UpToDate);
182 + }
183 +
184 + let changes = notes::diff_notes(&engine, previous, Some(tip)).map_err(to_app)?;
185 + let meta = engine.read_commit(tip).map_err(to_app)?;
186 +
187 + let mut upserts = Vec::new();
188 + let mut removals = Vec::new();
189 + let mut buffer = Vec::new();
190 + for change in changes {
191 + match change {
192 + notes::NoteChange::Removed { target } => removals.push(target.to_hex()),
193 + notes::NoteChange::Set { target, blob } => {
194 + buffer.clear();
195 + engine.read_blob_into(blob, &mut buffer).map_err(to_app)?;
196 + let (target_is_commit, target_summary, target_time) = target_facts(&repo, target);
197 + upserts.push(db::git_notes::NoteUpsert {
198 + target_oid: target.to_hex(),
199 + blob_oid: blob.to_hex(),
200 + content: String::from_utf8_lossy(&buffer).into_owned(),
201 + target_is_commit,
202 + target_summary,
203 + target_time,
204 + });
205 + }
206 + }
207 + }
208 +
209 + Ok(Outcome::Apply(Plan {
210 + tip: tip.to_hex(),
211 + updated_at: meta.committer.time,
212 + updated_by: meta.committer.name,
213 + upserts,
214 + removals,
215 + }))
216 + }
217 +
218 + /// What the annotated object is, for the feed and the annotated-commits filter.
219 + ///
220 + /// A target that is not a commit is the ordinary case for a note on a blob, and
221 + /// one that resolves to nothing at all is ordinary too: a notes tree can outlive
222 + /// the object it annotates.
223 + fn target_facts(repo: &gix::Repository, target: Oid) -> (bool, String, Option<DateTime<Utc>>) {
224 + let commit = gix::ObjectId::from_hex(target.to_hex().as_bytes())
225 + .ok()
226 + .and_then(|oid| repo.find_commit(oid).ok());
227 + let Some(commit) = commit else {
228 + return (false, String::new(), None);
229 + };
230 +
231 + let summary: String = commit
232 + .message_raw()
233 + .map(|m| m.to_str_lossy().into_owned())
234 + .unwrap_or_default()
235 + .lines()
236 + .next()
237 + .unwrap_or_default()
238 + .chars()
239 + .take(SUMMARY_MAX_CHARS)
240 + .collect();
241 + let when = commit
242 + .committer()
243 + .ok()
244 + .and_then(|c| c.time().ok())
245 + .and_then(|t| DateTime::from_timestamp(t.seconds, 0));
246 + (true, summary, when)
247 + }
248 +
249 + /// Write a plan out, in batches, claiming the tip only once all of it landed.
250 + async fn apply(db_pool: &PgPool, repo_id: GitRepoId, namespace: &str, plan: Plan) -> Result<()> {
251 + let mut removals = plan.removals.as_slice();
252 + let mut chunks = plan.upserts.chunks(BATCH).peekable();
253 +
254 + // An empty diff still has to record the tip: the namespace's history moved
255 + // without changing any note, and leaving the old tip stored would make every
256 + // later reindex rewalk from it.
257 + if chunks.peek().is_none() {
258 + return db::git_notes::apply_changes(
259 + db_pool,
260 + repo_id,
261 + &db::git_notes::NamespaceUpdate {
262 + namespace,
263 + upserts: &[],
264 + removals,
265 + tip: Some(&plan.tip),
266 + updated_at: plan.updated_at,
267 + updated_by: &plan.updated_by,
268 + },
269 + )
270 + .await;
271 + }
272 +
273 + while let Some(batch) = chunks.next() {
274 + let last = chunks.peek().is_none();
275 + db::git_notes::apply_changes(
276 + db_pool,
277 + repo_id,
278 + &db::git_notes::NamespaceUpdate {
279 + namespace,
280 + upserts: batch,
281 + // Removals go with the first batch; they are one statement and
282 + // repeating them per batch would be pure cost.
283 + removals,
284 + tip: last.then_some(plan.tip.as_str()),
285 + updated_at: plan.updated_at,
286 + updated_by: &plan.updated_by,
287 + },
288 + )
289 + .await?;
290 + removals = &[];
291 + }
292 + Ok(())
293 + }
294 +
295 + fn to_app(e: notes::NotesError) -> AppError {
296 + crate::git::GitError::from(e).into()
297 + }
298 +
299 + // ── The hook endpoint ──
300 +
301 + #[derive(Deserialize)]
302 + pub(super) struct ReindexRequest {
303 + repo_owner: String,
304 + repo_name: String,
305 + /// The full notes ref the push landed on.
306 + ref_name: String,
307 + }
308 +
309 + #[derive(Serialize)]
310 + pub(super) struct ReindexResponse {
311 + namespace: String,
312 + }
313 +
314 + /// `POST /api/internal/notes/reindex`
315 + ///
316 + /// Called by the post-receive hook for a push to `refs/notes/*`. Auth is the
317 + /// per-repo HMAC the other hook arms carry.
318 + ///
319 + /// The namespace is not validated against the rules the write paths apply. A
320 + /// push can land a namespace the browser would refuse, including `mnw/*`, and
321 + /// when it does the repository holds it and the index has to say so. Deciding
322 + /// what may be written is the write path's job; this one reports what is there.
323 + #[tracing::instrument(skip_all, name = "git::reindex_notes")]
324 + pub(super) async fn reindex(
325 + State(db_pool): State<PgPool>,
326 + State(config): State<Config>,
327 + headers: HeaderMap,
328 + Json(req): Json<ReindexRequest>,
329 + ) -> Result<Json<ReindexResponse>> {
330 + super::notes_inbox::verify_repo_hmac(&config, &headers, &req.repo_owner, &req.repo_name)?;
331 +
332 + let namespace = req
333 + .ref_name
334 + .strip_prefix(notes::NOTES_REF_PREFIX)
335 + .filter(|name| !name.is_empty())
336 + .ok_or_else(|| AppError::BadRequest(format!("{} is not a notes ref", req.ref_name)))?
337 + .to_string();
338 +
339 + let owner_user = db::users::get_user_by_username(
340 + &db_pool,
341 + &db::Username::from_trusted(req.repo_owner.clone()),
342 + )
343 + .await?
344 + .ok_or(AppError::NotFound)?;
345 + let repo = db::git_repos::get_repo_by_user_and_name(&db_pool, owner_user.id, &req.repo_name)
346 + .await?
347 + .ok_or(AppError::NotFound)?;
348 +
349 + reindex_namespace(
350 + &db_pool,
351 + &config,
352 + repo.id,
353 + &req.repo_owner,
354 + &req.repo_name,
355 + &namespace,
356 + )
357 + .await?;
358 +
359 + Ok(Json(ReindexResponse { namespace }))
360 + }
361 +
362 + /// Reindex after an in-process write, logging rather than failing.
363 + ///
364 + /// The note is already in the repository by the time this runs. An index that
365 + /// could not be updated is a stale projection, which the read paths tolerate;
366 + /// turning it into an error the writer sees would be the projection vetoing the
367 + /// thing it is a projection of.
368 + pub(super) async fn reindex_after_write(
369 + db_pool: &PgPool,
370 + config: &Config,
371 + repo_id: GitRepoId,
372 + owner: &str,
373 + repo_name: &str,
374 + namespace: &str,
375 + ) {
376 + if let Err(e) = reindex_namespace(db_pool, config, repo_id, owner, repo_name, namespace).await {
377 + tracing::warn!(
378 + owner, repo_name, namespace,
379 + error = %e,
380 + "notes index update failed; the repository is unaffected"
381 + );
382 + }
383 + }
384 +
385 + #[cfg(test)]
386 + mod tests {
387 + use super::*;
388 +
389 + #[test]
390 + fn a_notes_ref_yields_the_namespace_a_person_says() {
391 + fn namespace(r: &str) -> Option<&str> {
392 + r.strip_prefix(notes::NOTES_REF_PREFIX)
393 + .filter(|n| !n.is_empty())
394 + }
395 + assert_eq!(namespace("refs/notes/commits"), Some("commits"));
396 + assert_eq!(namespace("refs/notes/mnw/builds"), Some("mnw/builds"));
397 + // The inbox is not a namespace, and neither is the bare prefix.
398 + assert_eq!(namespace("refs/mnw/notes-inbox/commits"), None);
399 + assert_eq!(namespace("refs/notes/"), None);
400 + }
401 + }