memstead_git_branch/mem_repo_config.rs
1//! Read mem configs from `mem-repo-git:__MEMSTEAD:mems/<path>/<leaf>/config.json`.
2//!
3//! Mem-repo-backed mems have no working directory — the canonical
4//! config lives at `mem-repo-git:__MEMSTEAD:mems/<path>/<leaf>/config.json`
5//! on the unified `__MEMSTEAD` ref, where `<path>/<leaf>` mirrors the
6//! per-mem content branch `refs/heads/<path>/<leaf>` (the branch
7//! ref name and the `__MEMSTEAD` tree path under `mems/` are kept
8//! byte-identical so enumeration and config IO can share one source
9//! of truth). `main` is reserved for operator-facing docs (README,
10//! etc.); the engine never reads `main` for mem data.
11//!
12//! Flat (single-segment) layouts — `refs/heads/<leaf>` ↔
13//! `__MEMSTEAD:mems/<leaf>/config.json` — are still supported as the
14//! degenerate case where the organizational path is empty.
15//!
16//! The parser pipeline (`check_config` + `parse_mem_config`) is
17//! identical to the disk path; only the byte source differs (blob in
18//! the gix object database vs. file on disk).
19use std::path::Path;
20
21use memstead_schema::{ConfigError, MemConfig, SchemaRef};
22
23use crate::MemInit;
24use crate::vcs::CommitContext;
25
26/// Errors raised while reading a mem config from `mem-repo-git:__MEMSTEAD`.
27#[derive(Debug, thiserror::Error)]
28pub enum MemRepoConfigError {
29 /// The workspace is not a real mem-repo workspace — `mem-repo/.git/`
30 /// is missing. Caller decides whether to treat as fatal (post-cutover
31 /// invariant) or fall back to legacy disk shape.
32 #[error("mem-repo gitdir not found at {0}")]
33 GitdirNotFound(String),
34 /// `mem-repo/.git/` exists but cannot be opened (corrupt repo, IO
35 /// failure under the object database). The wrapped message names the
36 /// underlying gix error.
37 #[error("could not open mem-repo gitdir: {0}")]
38 GixOpen(String),
39 /// `__MEMSTEAD` registry-class branch ref is missing — empty-bare-repo
40 /// stub state, or a workspace that was never initialised. Variant
41 /// name retained as `NoMainBranch` for backward-compat with existing
42 /// matchers; semantically it now means "no `__MEMSTEAD` ref".
43 #[error("mem-repo has no `__MEMSTEAD` branch")]
44 NoMainBranch,
45 /// `__MEMSTEAD:mems/<mem>/config.json` does not exist in the tree.
46 /// Either the mem was never registered or the config blob was
47 /// deleted.
48 #[error("config not found in mem-repo: __MEMSTEAD:mems/{0}/config.json")]
49 ConfigNotFound(String),
50 /// Generic gix-tree read failure (object missing, corrupt tree, IO
51 /// underneath the object database).
52 #[error("git tree read error: {0}")]
53 GitTree(String),
54 /// Blob bytes are not valid UTF-8.
55 #[error("config blob is not valid UTF-8: {0}")]
56 NotUtf8(String),
57 /// Parse / validation failure surfaced from the shared schema
58 /// pipeline. Wrapped so callers can branch on the inner kind if
59 /// needed.
60 #[error("{0}")]
61 Schema(#[from] ConfigError),
62}
63
64/// Resolve a leaf mem name to its full branch path inside the
65/// mem-repo by scanning local branches and matching the leaf against
66/// the last `/`-separated segment of every branch shortname.
67///
68/// Returns `Ok(Some(full_path))` on a unique match (e.g. leaf `engine`
69/// → `demo/engine`, or flat `engine` → `engine`), `Ok(None)` when
70/// no branch ends in `leaf`, and `Ok(Some(_))` on the FIRST match if
71/// multiple branches share a leaf — leaf collision is a mem-create
72/// invariant violation that should not occur in practice; the caller
73/// surfacing `Some` here lets the read path proceed and the create
74/// path's collision check is the line of defense.
75///
76/// `main` and `__*`-prefix branches are filtered out; only writable
77/// per-mem content branches are considered.
78///
79/// Gitdir-rooted: caller supplies the mount's gitdir directly. The
80/// workspace-rooted callers in this module compose
81/// `<workspace_root>/mem-repo/.git/` via [`default_gitdir`] and
82/// delegate here.
83pub fn resolve_full_path_at_gitdir(
84 gitdir: &Path,
85 leaf: &str,
86) -> Result<Option<String>, MemRepoConfigError> {
87 if !gitdir.is_dir() {
88 return Err(MemRepoConfigError::GitdirNotFound(
89 gitdir.display().to_string(),
90 ));
91 }
92 let repo = gix::open(gitdir).map_err(|e| MemRepoConfigError::GixOpen(e.to_string()))?;
93 let refs = repo
94 .references()
95 .map_err(|e| MemRepoConfigError::GitTree(e.to_string()))?;
96 let iter = refs
97 .local_branches()
98 .map_err(|e| MemRepoConfigError::GitTree(e.to_string()))?;
99 for r in iter {
100 let reference = match r {
101 Ok(reference) => reference,
102 Err(_) => continue,
103 };
104 let short = reference.name().shorten();
105 let name = match std::str::from_utf8(short) {
106 Ok(s) => s,
107 Err(_) => continue,
108 };
109 if name == "main" {
110 continue;
111 }
112 // Filter `__*` only on the leading segment — a real mem under
113 // `foo/__weird-but-legal-leaf` would be filtered by the
114 // create-path validator before it lands; here we skip
115 // registry-class refs whose top-level segment starts with `__`
116 // (e.g. `__MEMSTEAD`).
117 if name.starts_with("__") {
118 continue;
119 }
120 let last = name.rsplit('/').next().unwrap_or(name);
121 if last == leaf {
122 return Ok(Some(name.to_string()));
123 }
124 }
125 Ok(None)
126}
127
128/// Walk the mem-repo's local branches and return every full path
129/// whose final `/`-separated segment equals `leaf`.
130///
131/// The mem-create orchestrator pre-flights this to surface
132/// tree-walk leaf collisions even when discovery's first-wins drop
133/// has hidden them from the engine snapshot. Returns `Ok(vec![])` for a
134/// mem-repo without any matching branches, `Ok(_)` of length 1 for
135/// the typical "leaf already exists at one path" case, and the rare
136/// `Ok(_)` of length ≥2 for a corrupt repo that has the same leaf
137/// sealed at two distinct organizational paths (e.g. manual git ref
138/// surgery, or a mid-create crash that landed both `demo/engine`
139/// and `planning/engine`).
140///
141/// Filters identical to [`resolve_full_path_at_gitdir`]: skips `main`
142/// and any branch whose leading segment starts with `__` (registry-
143/// class refs like `__MEMSTEAD`).
144pub fn find_branches_by_leaf_at_gitdir(
145 gitdir: &Path,
146 leaf: &str,
147) -> Result<Vec<String>, MemRepoConfigError> {
148 if !gitdir.is_dir() {
149 return Err(MemRepoConfigError::GitdirNotFound(
150 gitdir.display().to_string(),
151 ));
152 }
153 let repo = gix::open(gitdir).map_err(|e| MemRepoConfigError::GixOpen(e.to_string()))?;
154 let refs = repo
155 .references()
156 .map_err(|e| MemRepoConfigError::GitTree(e.to_string()))?;
157 let iter = refs
158 .local_branches()
159 .map_err(|e| MemRepoConfigError::GitTree(e.to_string()))?;
160 let mut matches: Vec<String> = Vec::new();
161 for r in iter {
162 let reference = match r {
163 Ok(reference) => reference,
164 Err(_) => continue,
165 };
166 let short = reference.name().shorten();
167 let name = match std::str::from_utf8(short) {
168 Ok(s) => s,
169 Err(_) => continue,
170 };
171 if name == "main" {
172 continue;
173 }
174 if name.starts_with("__") {
175 continue;
176 }
177 let last = name.rsplit('/').next().unwrap_or(name);
178 if last == leaf {
179 matches.push(name.to_string());
180 }
181 }
182 matches.sort();
183 Ok(matches)
184}
185
186/// Compute the fully-qualified branch ref name for `mem_name` by
187/// resolving its leaf to the matching hierarchical full path on the
188/// mem-repo (e.g. `refs/heads/demo/engine` for leaf `engine`,
189/// `refs/heads/alpha` for flat `alpha`). Falls back to
190/// `refs/heads/<mem_name>` (flat) when the leaf does not yet
191/// resolve to any branch — used by the create path before the branch
192/// is sealed, and by callers operating on a workspace whose mem-repo
193/// is not yet present.
194///
195/// Errors only on hard gix failures (gitdir missing, ref iteration
196/// failure); a clean "no match" returns the flat fallback so reads
197/// against a stub mem-repo do not surface a different error code
198/// just because the resolver runs first.
199///
200/// Workspace-rooted convenience wrapper around
201/// [`branch_ref_for_mem_at_gitdir`].
202pub fn branch_ref_for_mem(workspace_root: &Path, mem_name: &str) -> String {
203 branch_ref_for_mem_at_gitdir(&gitdir_for_leaf(workspace_root, mem_name), mem_name)
204}
205
206/// Gitdir-rooted variant of [`branch_ref_for_mem`].
207pub fn branch_ref_for_mem_at_gitdir(gitdir: &Path, mem_name: &str) -> String {
208 match resolve_full_path_at_gitdir(gitdir, mem_name) {
209 Ok(Some(full_path)) => format!("refs/heads/{full_path}"),
210 _ => format!("refs/heads/{mem_name}"),
211 }
212}
213
214/// Compose the workspace's gitdir path
215/// (`<workspace_root>/mem-repo/.git/`). The single canonical mount
216/// for git-branch-backed mems.
217fn default_gitdir(workspace_root: &Path) -> std::path::PathBuf {
218 workspace_root.join("mem-repo").join(".git")
219}
220
221/// Resolve the gitdir to use for a leaf's read/write operations. The
222/// post-rebuild architecture has exactly one git-branch mount per
223/// workspace, rooted at `<workspace_root>/mem-repo/.git/`.
224fn gitdir_for_leaf(workspace_root: &Path, _mem_name: &str) -> std::path::PathBuf {
225 default_gitdir(workspace_root)
226}
227
228/// Read and validate a mem config from `mem-repo-git:__MEMSTEAD:mems/<full_path>/config.json`.
229///
230/// `workspace_root` is the directory holding `mem-repo/.git/` (i.e. the
231/// directory `memstead` lives in). Returns `Ok(MemConfig)` on a clean
232/// read; the typed error variants discriminate the failure modes a
233/// caller may want to branch on (missing gitdir vs. missing `__MEMSTEAD`
234/// vs. missing config blob vs. parse error).
235///
236/// Workspace-rooted convenience wrapper around [`read_config_at_gitdir`].
237pub fn read_config(workspace_root: &Path, mem_name: &str) -> Result<MemConfig, MemRepoConfigError> {
238 read_config_at_gitdir(&gitdir_for_leaf(workspace_root, mem_name), mem_name)
239}
240
241/// Gitdir-rooted variant of [`read_config`]. Multi-mount callers pass
242/// the mount's gitdir directly so the resolver and the per-mem
243/// config lookup target the same mem-repo.
244///
245/// Reads from the unified `__MEMSTEAD` ref. `MemsteadRefError` is mapped to
246/// the legacy `MemRepoConfigError` envelope so callers' branch
247/// shapes stay stable.
248pub fn read_config_at_gitdir(
249 gitdir: &Path,
250 mem_name: &str,
251) -> Result<MemConfig, MemRepoConfigError> {
252 if !gitdir.is_dir() {
253 return Err(MemRepoConfigError::GitdirNotFound(
254 gitdir.display().to_string(),
255 ));
256 }
257 crate::storage_memstead::read_mem_config_from_memstead_ref(gitdir, mem_name).map_err(|e| {
258 match e {
259 crate::storage_memstead::MemsteadRefError::GixOpen(msg) => {
260 MemRepoConfigError::GixOpen(msg)
261 }
262 crate::storage_memstead::MemsteadRefError::GitTree(msg) => {
263 MemRepoConfigError::GitTree(msg)
264 }
265 crate::storage_memstead::MemsteadRefError::Config { path, message } => {
266 // Distinguish "ref not found" (workspace never had
267 // __MEMSTEAD — pre-bootstrap stub) from "config blob
268 // absent for this mem" (mem not registered).
269 // Mirrors the legacy reader's NoMainBranch /
270 // ConfigNotFound split that callers branch on.
271 if path == "refs/heads/__MEMSTEAD" {
272 MemRepoConfigError::NoMainBranch
273 } else if message.contains("config not found") {
274 MemRepoConfigError::ConfigNotFound(mem_name.to_string())
275 } else if message.contains("not utf-8") {
276 MemRepoConfigError::NotUtf8(message)
277 } else {
278 MemRepoConfigError::Schema(ConfigError::InvalidJson(message))
279 }
280 }
281 crate::storage_memstead::MemsteadRefError::GitCommit(msg) => {
282 MemRepoConfigError::GitTree(msg)
283 }
284 crate::storage_memstead::MemsteadRefError::NotUtf8(_, msg) => {
285 MemRepoConfigError::NotUtf8(msg)
286 }
287 crate::storage_memstead::MemsteadRefError::Schema { source, .. } => {
288 MemRepoConfigError::Schema(ConfigError::Other(source.to_string()))
289 }
290 }
291 })
292}
293
294/// Build a `MemInit { dir: None, .. }` for a mem-repo-backed branch.
295///
296/// Reads the per-mem config from `mem-repo-git:__MEMSTEAD:mems/<name>/config.json`
297/// and resolves its `schema` pin into a `SchemaRef`. The placeholder
298/// pin used when the config is missing or carries no `schema` field
299/// is `default@1.0.0`, mirroring `memstead-git-branch::discover`'s legacy
300/// fallback so disk-shaped and mem-repo-backed paths produce the same
301/// MemInit shape.
302///
303/// Used by `memstead-swift`'s `discover_mems` to build the macOS app's
304/// mem list from `enumerate_mem_repo_branches` output without
305/// re-implementing the schema-pin → SchemaRef plumbing.
306///
307/// Workspace-rooted convenience wrapper around
308/// [`mem_init_from_branch_at_gitdir`].
309pub fn mem_init_from_branch(
310 workspace_root: &Path,
311 mem_name: &str,
312) -> Result<MemInit, MemRepoConfigError> {
313 mem_init_from_branch_at_gitdir(&gitdir_for_leaf(workspace_root, mem_name), mem_name)
314}
315
316/// Gitdir-rooted variant of [`mem_init_from_branch`].
317pub fn mem_init_from_branch_at_gitdir(
318 gitdir: &Path,
319 mem_name: &str,
320) -> Result<MemInit, MemRepoConfigError> {
321 let config = read_config_at_gitdir(gitdir, mem_name)?;
322 let schema_ref = config
323 .schema
324 .clone()
325 .unwrap_or_else(|| SchemaRef::new("default", semver::Version::new(1, 0, 0)));
326 Ok(MemInit {
327 name: mem_name.to_string(),
328 dir: None,
329 schema_ref,
330 })
331}
332
333/// "Real mem-repo" gate: returns `true` if `<workspace_root>/mem-repo/.git/`
334/// carries `refs/heads/__MEMSTEAD` (the unified registry ref). Empty bare
335/// repos (the `init_mem_repo_stub` shape) return `false`.
336pub fn has_real_mem_repo_main(workspace_root: &Path) -> bool {
337 has_real_mem_repo_main_at_gitdir(&default_gitdir(workspace_root))
338}
339
340/// Gitdir-rooted variant of [`has_real_mem_repo_main`]. Multi-mount
341/// callers ask the question per mount.
342pub fn has_real_mem_repo_main_at_gitdir(gitdir: &Path) -> bool {
343 let Ok(repo) = gix::open(gitdir) else {
344 return false;
345 };
346 matches!(
347 repo.try_find_reference("refs/heads/__MEMSTEAD"),
348 Ok(Some(_))
349 )
350}
351
352/// Errors raised while writing a mem config to `mem-repo-git:__MEMSTEAD`.
353#[derive(Debug, thiserror::Error)]
354pub enum MemRepoWriteError {
355 /// Could not open `<workspace_root>/mem-repo/.git/`. Wraps the gix
356 /// error message; pre-flight via `has_real_mem_repo_main` to avoid
357 /// surfacing this from a workspace that is not mem-repo-backed.
358 #[error("could not open mem-repo gitdir at {path}: {message}")]
359 GixOpen { path: String, message: String },
360 /// `refs/heads/__MEMSTEAD` is missing — workspace is not initialised.
361 /// Variant name retained for backward compatibility.
362 #[error("mem-repo has no refs/heads/__MEMSTEAD: {0}")]
363 NoMainBranch(String),
364 /// Generic git-tree write failure (object database error, ref edit
365 /// rejected, etc.). The wrapped string surfaces the underlying
366 /// gix error for operator log lines.
367 #[error("git tree write error: {0}")]
368 GitTree(String),
369 /// A `commit_refs` batch was rejected by gix-ref's transaction
370 /// `prepare` phase — typically a `MustExistAndMatch` precondition
371 /// mismatch (the observed `main` tip moved between snapshot and
372 /// commit) or a `MustNotExist` violation on a branch the caller
373 /// thought it was creating fresh. The wrapped string surfaces the
374 /// underlying gix message — the full `source()` chain, not just its
375 /// headline (see [`error_chain`]).
376 #[error("ref transaction rejected: {0}")]
377 RefTransaction(String),
378}
379
380/// Render an error and every `source()` beneath it as `outer: inner: root`.
381///
382/// gix's transaction errors are headlines over a cause: the top-level
383/// Display of a reflog failure is the constant sentence "The reflog could
384/// not be created or updated", and the `io::Error` saying *why* — a
385/// permission, a missing directory, a bad signature — hangs off `source()`.
386/// `e.to_string()` renders the headline alone, so an operator (and CI) sees
387/// a symptom with the diagnosis deleted. Every conversion of a foreign
388/// error into [`MemRepoWriteError::RefTransaction`] goes through here.
389/// Identity the engine signs its own ref-log entries with.
390///
391/// Ref transactions request `RefLog::AndReference`, and gix refuses to write
392/// a reflog entry without a committer ("reflog messages need a committer
393/// which isn't set"). `edit_references` sources that committer from the
394/// ambient git config, so a machine with no `user.name` / `user.email` —
395/// a fresh laptop, a CI runner, a container — could not create a mem at all,
396/// while a developer's machine worked by accident of their global config.
397///
398/// The engine already signs its *commit* objects as `engine
399/// <noreply@memstead.io>` rather than borrowing the user's identity
400/// (`storage_memstead.rs`'s `COMMITTER_NAME` / `COMMITTER_EMAIL`); the reflog
401/// now does the same. Mem-repo history is engine-authored bookkeeping, not
402/// the user's authorship, so it should not depend on ambient config in either
403/// place.
404pub(crate) fn reflog_committer() -> gix::actor::Signature {
405 gix::actor::Signature {
406 name: "engine".into(),
407 email: "noreply@memstead.io".into(),
408 time: gix::date::Time::now_local_or_utc(),
409 }
410}
411
412/// Render an error and every `source()` beneath it as `outer: inner: root`.
413///
414/// gix's transaction errors are headlines over a cause: the top-level
415/// Display of a reflog failure is the constant sentence "The reflog could
416/// not be created or updated", and the error saying *why* hangs off
417/// `source()`. `e.to_string()` renders the headline alone, so an operator
418/// (and CI) sees a symptom with the diagnosis deleted — this cost a full
419/// debugging session on 2026-08-11. Every conversion of a foreign error into
420/// [`MemRepoWriteError::RefTransaction`] goes through here.
421pub(crate) fn error_chain(e: &dyn std::error::Error) -> String {
422 let mut out = e.to_string();
423 let mut cur = e.source();
424 while let Some(inner) = cur {
425 use std::fmt::Write as _;
426 let _ = write!(out, ": {inner}");
427 cur = inner.source();
428 }
429 out
430}
431
432/// One ref edit inside a [`commit_refs`] batch. The caller has already
433/// written the new commit object via `repo.write_object`; `RefSpec`
434/// names the ref to point at it and the precondition that gates the
435/// update.
436///
437/// The fields are deliberately the minimum needed to build a
438/// `gix_ref::transaction::RefEdit` without leaking the raw type into
439/// the call sites — call sites stay in plain `String`/`ObjectId`
440/// territory.
441pub struct RefSpec {
442 /// Fully qualified ref name (e.g. `"refs/heads/main"`,
443 /// `"refs/heads/<mem>"`).
444 pub ref_name: String,
445 /// Object id the ref will point at after the batch lands.
446 pub new_oid: gix::ObjectId,
447 /// Precondition. `MustNotExist` for a brand-new branch;
448 /// `MustExistAndMatch(observed_tip)` for a read-modify-write update
449 /// against a known previous tip.
450 pub expected: gix::refs::transaction::PreviousValue,
451 /// Reflog message for this ref edit. Carried into the per-ref
452 /// reflog when reflogs are enabled.
453 pub log_message: String,
454}
455
456/// Run a batch of ref edits as a single `edit_references` transaction
457/// against `<workspace_root>/mem-repo/.git/`.
458///
459/// **Atomicity scope (closes [D8] in-process registry-corruption from
460/// concurrent writers; commit-phase IO failure remains best-effort).**
461/// gix-ref's transaction `prepare` validates every spec's precondition
462/// and acquires per-ref locks; if any spec fails the precondition the
463/// entire batch is rejected — no partial state. Once `prepare`
464/// succeeds, `commit` writes ref values under lock; a commit-phase IO
465/// failure mid-batch can leave the ref store inconsistent (per
466/// `gix-ref-0.61.0/src/transaction/mod.rs:11-14`), which surfaces as a
467/// `RefTransaction` error and is the operator-recoverable case
468/// described in the plan's What does NOT land section.
469///
470/// **Cross-thread RMW caveat.** gix-ref reads `existing_ref` *before*
471/// acquiring the per-ref file lock (`gix-ref-0.61.0/src/store/file/transaction/prepare.rs:31`,
472/// lock at `:120`); the precondition is then checked against the
473/// pre-lock snapshot at `:142`. Two parallel `commit_refs` calls on
474/// separate `Repository` instances against the same observed tip can
475/// both pass `MustExistAndMatch(T0)` and serialise under the lock —
476/// the second silently overwrites the first. The engine is
477/// `&mut self`-disciplined today (`lib.rs:259-263`) so no in-process
478/// parallelism can occur; the gap is documented for the future
479/// multi-thread/multi-process plan.
480///
481/// Used by the two existing single-ref writers (`commit_config` calls
482/// from `memstead_install` and `mem_cache::register_read_mem_in_mem_repo`)
483/// and by `mem_management/create.rs` for the two-ref atomic
484/// mem-create batch.
485///
486/// Workspace-rooted convenience wrapper around [`commit_refs_at_gitdir`].
487pub fn commit_refs(workspace_root: &Path, specs: &[RefSpec]) -> Result<(), MemRepoWriteError> {
488 commit_refs_at_gitdir(&default_gitdir(workspace_root), specs)
489}
490
491/// Gitdir-rooted variant of [`commit_refs`]. Multi-mount callers route
492/// each batch to the target mount's gitdir directly.
493pub fn commit_refs_at_gitdir(gitdir: &Path, specs: &[RefSpec]) -> Result<(), MemRepoWriteError> {
494 use gix::refs::transaction::{Change, LogChange, RefEdit, RefLog};
495 use gix::refs::{FullName, Target};
496
497 let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
498 path: gitdir.display().to_string(),
499 message: e.to_string(),
500 })?;
501
502 let mut edits: Vec<RefEdit> = Vec::with_capacity(specs.len());
503 for spec in specs {
504 let name: FullName = spec.ref_name.as_str().try_into().map_err(|e| {
505 MemRepoWriteError::RefTransaction(format!("invalid ref name {:?}: {e}", spec.ref_name))
506 })?;
507 edits.push(RefEdit {
508 change: Change::Update {
509 log: LogChange {
510 mode: RefLog::AndReference,
511 force_create_reflog: false,
512 message: spec.log_message.as_str().into(),
513 },
514 expected: spec.expected.clone(),
515 new: Target::Object(spec.new_oid),
516 },
517 name,
518 deref: false,
519 });
520 }
521
522 repo.edit_references_as(
523 edits,
524 Some(reflog_committer().to_ref(&mut Default::default())),
525 )
526 .map_err(|e| MemRepoWriteError::RefTransaction(error_chain(&e)))?;
527
528 Ok(())
529}
530
531/// Commit `<mem_name>/config.json` to `mem-repo-git:refs/heads/__MEMSTEAD`.
532///
533/// Read-modify-write: snapshot the current `__SYSTEM` tip, build a tree
534/// that upserts the per-mem config blob, write a commit object that
535/// chains onto the snapshot, then atomically advance `__SYSTEM` via
536/// [`commit_refs`] with `MustExistAndMatch(observed_system_tip)`.
537///
538/// **Closes the in-process RMW race for the single-ref path** (D8 in
539/// the plan): if a sequential second writer observes `main = T0` but
540/// `main` has advanced to `T1` since the snapshot, the precondition
541/// rejects the batch with [`MemRepoWriteError::RefTransaction`] rather
542/// than silently advancing past the first writer's commit. The
543/// cross-thread RMW caveat documented on [`commit_refs`] applies here
544/// unchanged — the engine's `&mut self` discipline keeps the gap
545/// academic in current single-process usage.
546///
547/// Used by:
548/// - `memstead_install` to update an existing mem's `readMems` field
549/// (RMW the same blob).
550/// - `mem_cache::register_read_mem_in_mem_repo` (same RMW shape).
551///
552/// Workspace-rooted convenience wrapper around [`commit_config_at_gitdir`].
553pub fn commit_config(
554 workspace_root: &Path,
555 mem_name: &str,
556 config_bytes: &[u8],
557 ctx: &CommitContext<'_>,
558 message: &str,
559) -> Result<(), MemRepoWriteError> {
560 commit_config_at_gitdir(
561 &gitdir_for_leaf(workspace_root, mem_name),
562 mem_name,
563 config_bytes,
564 ctx,
565 message,
566 )
567}
568
569/// Gitdir-rooted variant of [`commit_config`]. Multi-mount callers
570/// route the RMW to the target mount's gitdir directly so the leaf
571/// resolver and the ref advance target the same mem-repo.
572///
573/// Writes only to `__MEMSTEAD` — the engine reader's sole source of truth
574/// for per-mem configs post-rebuild. `commit_config_to_memstead_at_gitdir`
575/// is self-creating: it advances `__MEMSTEAD` if present (MustExistAndMatch)
576/// or seeds it (MustNotExist) when absent, so callers do not preflight
577/// the ref.
578pub fn commit_config_at_gitdir(
579 gitdir: &Path,
580 mem_name: &str,
581 config_bytes: &[u8],
582 ctx: &CommitContext<'_>,
583 message: &str,
584) -> Result<(), MemRepoWriteError> {
585 crate::storage_memstead::commit_config_to_memstead_at_gitdir(
586 gitdir,
587 mem_name,
588 config_bytes,
589 ctx,
590 message,
591 )?;
592
593 Ok(())
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use tempfile::TempDir;
600
601 /// Build a minimal `mem-repo/.git/` carrying `__SYSTEM` with one
602 /// config blob. Returns the workspace root.
603 fn init_mem_repo_with_config(mem_name: &str, config_json: &str) -> TempDir {
604 let tmp = TempDir::new().unwrap();
605 let gitdir = tmp.path().join("mem-repo").join(".git");
606 std::fs::create_dir_all(&gitdir).unwrap();
607 let repo = gix::init_bare(&gitdir).unwrap();
608
609 let blob = repo.write_blob(config_json.as_bytes()).unwrap().detach();
610 let mut editor = repo.empty_tree().edit().unwrap();
611 editor
612 .upsert(
613 format!("{mem_name}/config.json"),
614 gix::objs::tree::EntryKind::Blob,
615 blob,
616 )
617 .unwrap();
618 let tree_id = editor.write().unwrap().detach();
619
620 let actor = gix::actor::Signature {
621 name: "test".into(),
622 email: "test@example.com".into(),
623 time: gix::date::Time {
624 seconds: 0,
625 offset: 0,
626 },
627 };
628 let mut buf = gix::date::parse::TimeBuf::default();
629 let actor_ref = actor.to_ref(&mut buf);
630 repo.commit_as(
631 actor_ref,
632 actor_ref,
633 "refs/heads/__SYSTEM",
634 "seed",
635 tree_id,
636 Vec::<gix::ObjectId>::new(),
637 )
638 .unwrap();
639
640 // Project the just-written __SYSTEM content onto the unified
641 // `__MEMSTEAD` ref so post-s140 reads (which target `__MEMSTEAD` only)
642 // see the seeded config.
643 crate::storage_memstead::migrate_to_memstead_ref(&gitdir).unwrap();
644
645 tmp
646 }
647
648 #[test]
649 fn reads_config_from_system_ref() {
650 let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
651 let config = read_config(tmp.path(), "alpha").unwrap();
652 // Configs no longer carry an in-config `name` field; the leaf folder on
653 // `__SYSTEM` is authoritative.
654 assert!(config.name.is_none());
655 assert_eq!(
656 config.schema.as_ref().map(|s| s.name.as_str()),
657 Some("default")
658 );
659 }
660
661 #[test]
662 fn errors_when_gitdir_missing() {
663 let tmp = TempDir::new().unwrap();
664 let err = read_config(tmp.path(), "alpha").unwrap_err();
665 assert!(matches!(err, MemRepoConfigError::GitdirNotFound(_)));
666 }
667
668 #[test]
669 fn errors_when_config_missing_in_tree() {
670 let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
671 let err = read_config(tmp.path(), "beta").unwrap_err();
672 assert!(matches!(err, MemRepoConfigError::ConfigNotFound(name) if name == "beta"));
673 }
674
675 /// Build a `mem-repo/.git/` carrying a hierarchical branch
676 /// `refs/heads/<full_path>` plus the matching `__SYSTEM` tree path.
677 /// The resolver should map `<leaf>` → `<full_path>` regardless of
678 /// the depth of the path prefix.
679 fn init_mem_repo_with_hierarchical_branch(full_path: &str) -> TempDir {
680 let tmp = TempDir::new().unwrap();
681 let gitdir = tmp.path().join("mem-repo").join(".git");
682 std::fs::create_dir_all(&gitdir).unwrap();
683 let repo = gix::init_bare(&gitdir).unwrap();
684 let actor = gix::actor::Signature {
685 name: "test".into(),
686 email: "test@example.com".into(),
687 time: gix::date::Time {
688 seconds: 0,
689 offset: 0,
690 },
691 };
692 let mut buf = gix::date::parse::TimeBuf::default();
693 let actor_ref = actor.to_ref(&mut buf);
694
695 // Seal the per-mem content branch with an empty tree.
696 let empty_tree = repo.empty_tree().id().detach();
697 repo.commit_as(
698 actor_ref,
699 actor_ref,
700 format!("refs/heads/{full_path}"),
701 "seal hierarchical",
702 empty_tree,
703 Vec::<gix::ObjectId>::new(),
704 )
705 .unwrap();
706
707 // Mirror `<full_path>/config.json` on `__SYSTEM`.
708 let blob = repo
709 .write_blob(br#"{"schema":"default@1.0.0"}"#)
710 .unwrap()
711 .detach();
712 let mut editor = repo.empty_tree().edit().unwrap();
713 editor
714 .upsert(
715 format!("{full_path}/config.json"),
716 gix::objs::tree::EntryKind::Blob,
717 blob,
718 )
719 .unwrap();
720 let tree_id = editor.write().unwrap().detach();
721 let mut buf = gix::date::parse::TimeBuf::default();
722 let actor_ref = actor.to_ref(&mut buf);
723 repo.commit_as(
724 actor_ref,
725 actor_ref,
726 "refs/heads/__SYSTEM",
727 "seed system",
728 tree_id,
729 Vec::<gix::ObjectId>::new(),
730 )
731 .unwrap();
732
733 // Project __SYSTEM onto __MEMSTEAD so post-s140 reads land.
734 crate::storage_memstead::migrate_to_memstead_ref(&gitdir).unwrap();
735
736 tmp
737 }
738
739 #[test]
740 fn resolve_full_path_returns_flat_branch_name() {
741 let tmp = init_mem_repo_with_hierarchical_branch("alpha");
742 let gitdir = tmp.path().join("mem-repo").join(".git");
743 let resolved = super::resolve_full_path_at_gitdir(&gitdir, "alpha").unwrap();
744 assert_eq!(resolved, Some("alpha".to_string()));
745 }
746
747 #[test]
748 fn resolve_full_path_returns_full_branch_for_hierarchical() {
749 let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
750 let gitdir = tmp.path().join("mem-repo").join(".git");
751 let resolved = super::resolve_full_path_at_gitdir(&gitdir, "engine").unwrap();
752 assert_eq!(resolved, Some("demo/engine".to_string()));
753 }
754
755 #[test]
756 fn resolve_full_path_returns_none_for_unknown_leaf() {
757 let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
758 let gitdir = tmp.path().join("mem-repo").join(".git");
759 let resolved = super::resolve_full_path_at_gitdir(&gitdir, "ghost").unwrap();
760 assert!(resolved.is_none());
761 }
762
763 /// Seal an arbitrary list of per-mem content branches at the
764 /// given full paths on top of an existing fixture repo.
765 fn seal_branches(gitdir: &std::path::Path, full_paths: &[&str]) {
766 let repo = gix::open(gitdir).unwrap();
767 let actor = gix::actor::Signature {
768 name: "test".into(),
769 email: "test@example.com".into(),
770 time: gix::date::Time {
771 seconds: 0,
772 offset: 0,
773 },
774 };
775 let empty_tree = repo.empty_tree().id().detach();
776 for full_path in full_paths {
777 let mut buf = gix::date::parse::TimeBuf::default();
778 let actor_ref = actor.to_ref(&mut buf);
779 repo.commit_as(
780 actor_ref,
781 actor_ref,
782 format!("refs/heads/{full_path}"),
783 "seal",
784 empty_tree,
785 Vec::<gix::ObjectId>::new(),
786 )
787 .unwrap();
788 }
789 }
790
791 #[test]
792 fn find_branches_by_leaf_returns_empty_for_unknown_leaf() {
793 let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
794 let gitdir = tmp.path().join("mem-repo").join(".git");
795 let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "ghost").unwrap();
796 assert!(matches.is_empty());
797 }
798
799 #[test]
800 fn find_branches_by_leaf_returns_single_full_path() {
801 let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
802 let gitdir = tmp.path().join("mem-repo").join(".git");
803 let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "engine").unwrap();
804 assert_eq!(matches, vec!["demo/engine".to_string()]);
805 }
806
807 #[test]
808 fn find_branches_by_leaf_returns_all_colliding_paths_sorted() {
809 // Two branches sharing leaf `engine` at distinct paths — the
810 // exact corruption scenario Goal 11 surfaces explicitly.
811 let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
812 let gitdir = tmp.path().join("mem-repo").join(".git");
813 seal_branches(&gitdir, &["planning/engine"]);
814 let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "engine").unwrap();
815 assert_eq!(
816 matches,
817 vec!["demo/engine".to_string(), "planning/engine".to_string()]
818 );
819 }
820
821 #[test]
822 fn find_branches_by_leaf_skips_main_and_registry_refs() {
823 // `__SYSTEM` ref carries the same `engine` leaf if a corrupt
824 // operator created `refs/heads/__weird`. The walker filters
825 // both `main` and any `__*`-leading-segment ref so registry
826 // refs cannot mask as content branches.
827 let tmp = init_mem_repo_with_hierarchical_branch("alpha");
828 let gitdir = tmp.path().join("mem-repo").join(".git");
829 // No content-branch with leaf `__SYSTEM` should be reported.
830 let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "__SYSTEM").unwrap();
831 assert!(matches.is_empty());
832 // And a flat `alpha` branch shows under leaf `alpha`.
833 let alpha = super::find_branches_by_leaf_at_gitdir(&gitdir, "alpha").unwrap();
834 assert_eq!(alpha, vec!["alpha".to_string()]);
835 }
836
837 #[test]
838 fn find_branches_by_leaf_errors_when_gitdir_missing() {
839 let tmp = TempDir::new().unwrap();
840 let gitdir = tmp.path().join("nonexistent").join(".git");
841 let err = super::find_branches_by_leaf_at_gitdir(&gitdir, "alpha")
842 .expect_err("missing gitdir must surface as GitdirNotFound");
843 assert!(matches!(err, super::MemRepoConfigError::GitdirNotFound(_)));
844 }
845
846 /// `_at_gitdir` siblings target an arbitrary gitdir, not the
847 /// workspace-default `<workspace_root>/mem-repo/.git/`. This
848 /// pins that the gitdir is the only thing that matters: a mem
849 /// living in a non-default mount path is readable as long as its
850 /// gitdir is supplied directly.
851 #[test]
852 fn at_gitdir_apis_target_arbitrary_gitdir() {
853 // Workspace tmp has no `mem-repo/.git/` at all — only a
854 // sibling mount at `external/.git/`. The workspace-rooted
855 // wrappers would all surface `GitdirNotFound`; the
856 // `_at_gitdir` siblings must work because we hand them the
857 // explicit gitdir.
858 let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
859 // Move the gitdir from the workspace-default location to a
860 // sibling so the workspace-rooted wrappers can no longer find
861 // it. (Equivalent to a `[[mem_repos]] path = "external"`
862 // declaration.)
863 let default_path = tmp.path().join("mem-repo");
864 let mount_path = tmp.path().join("external");
865 std::fs::rename(&default_path, &mount_path).unwrap();
866 let gitdir = mount_path.join(".git");
867
868 // `_at_gitdir` siblings see the mount.
869 assert!(super::has_real_mem_repo_main_at_gitdir(&gitdir));
870 let cfg = super::read_config_at_gitdir(&gitdir, "alpha").unwrap();
871 // Configs no longer carry an in-config `name` field.
872 assert!(cfg.name.is_none());
873 // The fixture seeds only `__SYSTEM:alpha/config.json` and no
874 // per-mem content branch — `resolve_full_path` returns `None`
875 // and `branch_ref_for_mem` falls back to the flat form. Pins
876 // both behaviours against the explicit gitdir.
877 assert_eq!(
878 super::resolve_full_path_at_gitdir(&gitdir, "alpha").unwrap(),
879 None
880 );
881 assert_eq!(
882 super::branch_ref_for_mem_at_gitdir(&gitdir, "alpha"),
883 "refs/heads/alpha"
884 );
885
886 // Workspace-rooted wrappers DON'T see the mount because the
887 // synthesised default path no longer exists.
888 assert!(!super::has_real_mem_repo_main(tmp.path()));
889 let err = super::read_config(tmp.path(), "alpha").unwrap_err();
890 assert!(matches!(err, MemRepoConfigError::GitdirNotFound(_)));
891 }
892
893 /// `commit_config_at_gitdir` routes the RMW to the supplied
894 /// gitdir's `__MEMSTEAD` ref. Pins that a non-default-mount commit
895 /// path advances exactly that mount's `__MEMSTEAD` and leaves any
896 /// default mount untouched.
897 #[test]
898 fn commit_config_at_gitdir_targets_arbitrary_mount() {
899 let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
900 let default_path = tmp.path().join("mem-repo");
901 let mount_path = tmp.path().join("external");
902 std::fs::rename(&default_path, &mount_path).unwrap();
903 let gitdir = mount_path.join(".git");
904
905 let pre_tip = {
906 let repo = gix::open(&gitdir).unwrap();
907 repo.find_reference("refs/heads/__MEMSTEAD")
908 .unwrap()
909 .into_fully_peeled_id()
910 .unwrap()
911 .detach()
912 };
913
914 let ctx = crate::vcs::CommitContext::internal();
915 super::commit_config_at_gitdir(
916 &gitdir,
917 "alpha",
918 br#"{"schema":"default@1.0.0","note":"v1"}"#,
919 &ctx,
920 "external mount commit",
921 )
922 .expect("commit_config_at_gitdir against external mount");
923
924 let post_tip = {
925 let repo = gix::open(&gitdir).unwrap();
926 repo.find_reference("refs/heads/__MEMSTEAD")
927 .unwrap()
928 .into_fully_peeled_id()
929 .unwrap()
930 .detach()
931 };
932 assert_ne!(pre_tip, post_tip, "external mount __MEMSTEAD must advance");
933
934 // Workspace-rooted commit_config still surfaces GixOpen
935 // because the default mount has no gitdir.
936 let result = super::commit_config(tmp.path(), "alpha", br#"{}"#, &ctx, "should fail");
937 assert!(matches!(result, Err(MemRepoWriteError::GixOpen { .. })));
938 }
939
940 #[test]
941 fn read_config_resolves_hierarchical_layout() {
942 let tmp = init_mem_repo_with_hierarchical_branch("planning/exec-foo");
943 // The lookup happens by leaf only and must walk via
944 // `resolve_full_path` to reach `planning/exec-foo/config.json`.
945 // Configs no longer carry an in-config `name` field — successful read of
946 // the schema field proves the resolver landed on the right
947 // tree path.
948 let cfg = super::read_config(tmp.path(), "exec-foo").unwrap();
949 assert!(cfg.name.is_none());
950 assert_eq!(
951 cfg.schema.as_ref().map(|s| s.name.as_str()),
952 Some("default")
953 );
954 }
955
956 #[test]
957 fn errors_when_system_ref_missing() {
958 // Empty bare repo — the stub shape used by `init_mem_repo_stub`.
959 let tmp = TempDir::new().unwrap();
960 let gitdir = tmp.path().join("mem-repo").join(".git");
961 std::fs::create_dir_all(&gitdir).unwrap();
962 gix::init_bare(&gitdir).unwrap();
963 let err = read_config(tmp.path(), "alpha").unwrap_err();
964 assert!(matches!(err, MemRepoConfigError::NoMainBranch));
965 }
966
967 /// Sequential read-modify-write: a `commit_refs` batch whose
968 /// `MustExistAndMatch(<observed_T0>)` precondition disagrees with
969 /// the current `__MEMSTEAD` tip (`T1`, after a prior commit landed) is
970 /// rejected with a typed `RefTransaction` error rather than
971 /// silently advancing past `T1` to a new `T2`.
972 ///
973 /// This is the sequential-RMW shape — cross-thread parallelism
974 /// (two threads racing the *same* observed `T0`) is NOT covered;
975 /// gix-ref's `prepare` reads `existing_ref` before lock acquisition,
976 /// so the cross-thread closure of the same race requires an outer
977 /// mutex (out of scope).
978 #[test]
979 fn commit_config_rejects_stale_main_tip() {
980 let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
981 let workspace_root = tmp.path();
982 let gitdir = workspace_root.join("mem-repo").join(".git");
983
984 let observed_t0 = {
985 let repo = gix::open(&gitdir).unwrap();
986 repo.find_reference("refs/heads/__MEMSTEAD")
987 .unwrap()
988 .into_fully_peeled_id()
989 .unwrap()
990 .detach()
991 };
992
993 let ctx = crate::vcs::CommitContext::internal();
994 commit_config(
995 workspace_root,
996 "alpha",
997 br#"{"schema":"default@1.0.0","note":"v1"}"#,
998 &ctx,
999 "first commit",
1000 )
1001 .expect("first commit_config should succeed");
1002
1003 let observed_t1 = {
1004 let repo = gix::open(&gitdir).unwrap();
1005 repo.find_reference("refs/heads/__MEMSTEAD")
1006 .unwrap()
1007 .into_fully_peeled_id()
1008 .unwrap()
1009 .detach()
1010 };
1011 assert_ne!(
1012 observed_t0, observed_t1,
1013 "__MEMSTEAD must have advanced after first commit_config"
1014 );
1015
1016 let stale_result = {
1017 let repo = gix::open(&gitdir).unwrap();
1018 let memstead_tree = repo
1019 .find_object(observed_t1)
1020 .unwrap()
1021 .into_commit()
1022 .tree()
1023 .unwrap()
1024 .id()
1025 .detach();
1026 let sig = gix::actor::Signature {
1027 name: "test".into(),
1028 email: "test@example.com".into(),
1029 time: gix::date::Time {
1030 seconds: 0,
1031 offset: 0,
1032 },
1033 };
1034 let new_commit = gix::objs::Commit {
1035 message: "stale".into(),
1036 tree: memstead_tree,
1037 author: sig.clone(),
1038 committer: sig,
1039 encoding: None,
1040 parents: std::iter::once(observed_t1).collect(),
1041 extra_headers: Default::default(),
1042 };
1043 let new_oid = repo.write_object(&new_commit).unwrap().detach();
1044
1045 commit_refs(
1046 workspace_root,
1047 &[RefSpec {
1048 ref_name: "refs/heads/__MEMSTEAD".to_string(),
1049 new_oid,
1050 expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
1051 gix::refs::Target::Object(observed_t0),
1052 ),
1053 log_message: "memstead: stale RMW".to_string(),
1054 }],
1055 )
1056 };
1057
1058 assert!(
1059 matches!(stale_result, Err(MemRepoWriteError::RefTransaction(_))),
1060 "expected RefTransaction precondition mismatch, got {:?}",
1061 stale_result
1062 );
1063
1064 let observed_after = {
1065 let repo = gix::open(&gitdir).unwrap();
1066 repo.find_reference("refs/heads/__MEMSTEAD")
1067 .unwrap()
1068 .into_fully_peeled_id()
1069 .unwrap()
1070 .detach()
1071 };
1072 assert_eq!(
1073 observed_after, observed_t1,
1074 "__MEMSTEAD must remain at T1 after rejected stale RMW"
1075 );
1076 }
1077}