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 for operator log lines.
375 #[error("ref transaction rejected: {0}")]
376 RefTransaction(String),
377}
378
379/// One ref edit inside a [`commit_refs`] batch. The caller has already
380/// written the new commit object via `repo.write_object`; `RefSpec`
381/// names the ref to point at it and the precondition that gates the
382/// update.
383///
384/// The fields are deliberately the minimum needed to build a
385/// `gix_ref::transaction::RefEdit` without leaking the raw type into
386/// the call sites — call sites stay in plain `String`/`ObjectId`
387/// territory.
388pub struct RefSpec {
389 /// Fully qualified ref name (e.g. `"refs/heads/main"`,
390 /// `"refs/heads/<mem>"`).
391 pub ref_name: String,
392 /// Object id the ref will point at after the batch lands.
393 pub new_oid: gix::ObjectId,
394 /// Precondition. `MustNotExist` for a brand-new branch;
395 /// `MustExistAndMatch(observed_tip)` for a read-modify-write update
396 /// against a known previous tip.
397 pub expected: gix::refs::transaction::PreviousValue,
398 /// Reflog message for this ref edit. Carried into the per-ref
399 /// reflog when reflogs are enabled.
400 pub log_message: String,
401}
402
403/// Run a batch of ref edits as a single `edit_references` transaction
404/// against `<workspace_root>/mem-repo/.git/`.
405///
406/// **Atomicity scope (closes [D8] in-process registry-corruption from
407/// concurrent writers; commit-phase IO failure remains best-effort).**
408/// gix-ref's transaction `prepare` validates every spec's precondition
409/// and acquires per-ref locks; if any spec fails the precondition the
410/// entire batch is rejected — no partial state. Once `prepare`
411/// succeeds, `commit` writes ref values under lock; a commit-phase IO
412/// failure mid-batch can leave the ref store inconsistent (per
413/// `gix-ref-0.61.0/src/transaction/mod.rs:11-14`), which surfaces as a
414/// `RefTransaction` error and is the operator-recoverable case
415/// described in the plan's What does NOT land section.
416///
417/// **Cross-thread RMW caveat.** gix-ref reads `existing_ref` *before*
418/// acquiring the per-ref file lock (`gix-ref-0.61.0/src/store/file/transaction/prepare.rs:31`,
419/// lock at `:120`); the precondition is then checked against the
420/// pre-lock snapshot at `:142`. Two parallel `commit_refs` calls on
421/// separate `Repository` instances against the same observed tip can
422/// both pass `MustExistAndMatch(T0)` and serialise under the lock —
423/// the second silently overwrites the first. The engine is
424/// `&mut self`-disciplined today (`lib.rs:259-263`) so no in-process
425/// parallelism can occur; the gap is documented for the future
426/// multi-thread/multi-process plan.
427///
428/// Used by the two existing single-ref writers (`commit_config` calls
429/// from `memstead_install` and `mem_cache::register_read_mem_in_mem_repo`)
430/// and by `mem_management/create.rs` for the two-ref atomic
431/// mem-create batch.
432///
433/// Workspace-rooted convenience wrapper around [`commit_refs_at_gitdir`].
434pub fn commit_refs(workspace_root: &Path, specs: &[RefSpec]) -> Result<(), MemRepoWriteError> {
435 commit_refs_at_gitdir(&default_gitdir(workspace_root), specs)
436}
437
438/// Gitdir-rooted variant of [`commit_refs`]. Multi-mount callers route
439/// each batch to the target mount's gitdir directly.
440pub fn commit_refs_at_gitdir(gitdir: &Path, specs: &[RefSpec]) -> Result<(), MemRepoWriteError> {
441 use gix::refs::transaction::{Change, LogChange, RefEdit, RefLog};
442 use gix::refs::{FullName, Target};
443
444 let repo = gix::open(gitdir).map_err(|e| MemRepoWriteError::GixOpen {
445 path: gitdir.display().to_string(),
446 message: e.to_string(),
447 })?;
448
449 let mut edits: Vec<RefEdit> = Vec::with_capacity(specs.len());
450 for spec in specs {
451 let name: FullName = spec.ref_name.as_str().try_into().map_err(|e| {
452 MemRepoWriteError::RefTransaction(format!("invalid ref name {:?}: {e}", spec.ref_name))
453 })?;
454 edits.push(RefEdit {
455 change: Change::Update {
456 log: LogChange {
457 mode: RefLog::AndReference,
458 force_create_reflog: false,
459 message: spec.log_message.as_str().into(),
460 },
461 expected: spec.expected.clone(),
462 new: Target::Object(spec.new_oid),
463 },
464 name,
465 deref: false,
466 });
467 }
468
469 repo.edit_references(edits)
470 .map_err(|e| MemRepoWriteError::RefTransaction(e.to_string()))?;
471
472 Ok(())
473}
474
475/// Commit `<mem_name>/config.json` to `mem-repo-git:refs/heads/__MEMSTEAD`.
476///
477/// Read-modify-write: snapshot the current `__SYSTEM` tip, build a tree
478/// that upserts the per-mem config blob, write a commit object that
479/// chains onto the snapshot, then atomically advance `__SYSTEM` via
480/// [`commit_refs`] with `MustExistAndMatch(observed_system_tip)`.
481///
482/// **Closes the in-process RMW race for the single-ref path** (D8 in
483/// the plan): if a sequential second writer observes `main = T0` but
484/// `main` has advanced to `T1` since the snapshot, the precondition
485/// rejects the batch with [`MemRepoWriteError::RefTransaction`] rather
486/// than silently advancing past the first writer's commit. The
487/// cross-thread RMW caveat documented on [`commit_refs`] applies here
488/// unchanged — the engine's `&mut self` discipline keeps the gap
489/// academic in current single-process usage.
490///
491/// Used by:
492/// - `memstead_install` to update an existing mem's `readMems` field
493/// (RMW the same blob).
494/// - `mem_cache::register_read_mem_in_mem_repo` (same RMW shape).
495///
496/// Workspace-rooted convenience wrapper around [`commit_config_at_gitdir`].
497pub fn commit_config(
498 workspace_root: &Path,
499 mem_name: &str,
500 config_bytes: &[u8],
501 ctx: &CommitContext<'_>,
502 message: &str,
503) -> Result<(), MemRepoWriteError> {
504 commit_config_at_gitdir(
505 &gitdir_for_leaf(workspace_root, mem_name),
506 mem_name,
507 config_bytes,
508 ctx,
509 message,
510 )
511}
512
513/// Gitdir-rooted variant of [`commit_config`]. Multi-mount callers
514/// route the RMW to the target mount's gitdir directly so the leaf
515/// resolver and the ref advance target the same mem-repo.
516///
517/// Writes only to `__MEMSTEAD` — the engine reader's sole source of truth
518/// for per-mem configs post-rebuild. `commit_config_to_memstead_at_gitdir`
519/// is self-creating: it advances `__MEMSTEAD` if present (MustExistAndMatch)
520/// or seeds it (MustNotExist) when absent, so callers do not preflight
521/// the ref.
522pub fn commit_config_at_gitdir(
523 gitdir: &Path,
524 mem_name: &str,
525 config_bytes: &[u8],
526 ctx: &CommitContext<'_>,
527 message: &str,
528) -> Result<(), MemRepoWriteError> {
529 crate::storage_memstead::commit_config_to_memstead_at_gitdir(
530 gitdir,
531 mem_name,
532 config_bytes,
533 ctx,
534 message,
535 )?;
536
537 Ok(())
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use tempfile::TempDir;
544
545 /// Build a minimal `mem-repo/.git/` carrying `__SYSTEM` with one
546 /// config blob. Returns the workspace root.
547 fn init_mem_repo_with_config(mem_name: &str, config_json: &str) -> TempDir {
548 let tmp = TempDir::new().unwrap();
549 let gitdir = tmp.path().join("mem-repo").join(".git");
550 std::fs::create_dir_all(&gitdir).unwrap();
551 let repo = gix::init_bare(&gitdir).unwrap();
552
553 let blob = repo.write_blob(config_json.as_bytes()).unwrap().detach();
554 let mut editor = repo.empty_tree().edit().unwrap();
555 editor
556 .upsert(
557 format!("{mem_name}/config.json"),
558 gix::objs::tree::EntryKind::Blob,
559 blob,
560 )
561 .unwrap();
562 let tree_id = editor.write().unwrap().detach();
563
564 let actor = gix::actor::Signature {
565 name: "test".into(),
566 email: "test@example.com".into(),
567 time: gix::date::Time {
568 seconds: 0,
569 offset: 0,
570 },
571 };
572 let mut buf = gix::date::parse::TimeBuf::default();
573 let actor_ref = actor.to_ref(&mut buf);
574 repo.commit_as(
575 actor_ref,
576 actor_ref,
577 "refs/heads/__SYSTEM",
578 "seed",
579 tree_id,
580 Vec::<gix::ObjectId>::new(),
581 )
582 .unwrap();
583
584 // Project the just-written __SYSTEM content onto the unified
585 // `__MEMSTEAD` ref so post-s140 reads (which target `__MEMSTEAD` only)
586 // see the seeded config.
587 crate::storage_memstead::migrate_to_memstead_ref(&gitdir).unwrap();
588
589 tmp
590 }
591
592 #[test]
593 fn reads_config_from_system_ref() {
594 let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
595 let config = read_config(tmp.path(), "alpha").unwrap();
596 // Configs no longer carry an in-config `name` field; the leaf folder on
597 // `__SYSTEM` is authoritative.
598 assert!(config.name.is_none());
599 assert_eq!(
600 config.schema.as_ref().map(|s| s.name.as_str()),
601 Some("default")
602 );
603 }
604
605 #[test]
606 fn errors_when_gitdir_missing() {
607 let tmp = TempDir::new().unwrap();
608 let err = read_config(tmp.path(), "alpha").unwrap_err();
609 assert!(matches!(err, MemRepoConfigError::GitdirNotFound(_)));
610 }
611
612 #[test]
613 fn errors_when_config_missing_in_tree() {
614 let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
615 let err = read_config(tmp.path(), "beta").unwrap_err();
616 assert!(matches!(err, MemRepoConfigError::ConfigNotFound(name) if name == "beta"));
617 }
618
619 /// Build a `mem-repo/.git/` carrying a hierarchical branch
620 /// `refs/heads/<full_path>` plus the matching `__SYSTEM` tree path.
621 /// The resolver should map `<leaf>` → `<full_path>` regardless of
622 /// the depth of the path prefix.
623 fn init_mem_repo_with_hierarchical_branch(full_path: &str) -> TempDir {
624 let tmp = TempDir::new().unwrap();
625 let gitdir = tmp.path().join("mem-repo").join(".git");
626 std::fs::create_dir_all(&gitdir).unwrap();
627 let repo = gix::init_bare(&gitdir).unwrap();
628 let actor = gix::actor::Signature {
629 name: "test".into(),
630 email: "test@example.com".into(),
631 time: gix::date::Time {
632 seconds: 0,
633 offset: 0,
634 },
635 };
636 let mut buf = gix::date::parse::TimeBuf::default();
637 let actor_ref = actor.to_ref(&mut buf);
638
639 // Seal the per-mem content branch with an empty tree.
640 let empty_tree = repo.empty_tree().id().detach();
641 repo.commit_as(
642 actor_ref,
643 actor_ref,
644 format!("refs/heads/{full_path}"),
645 "seal hierarchical",
646 empty_tree,
647 Vec::<gix::ObjectId>::new(),
648 )
649 .unwrap();
650
651 // Mirror `<full_path>/config.json` on `__SYSTEM`.
652 let blob = repo
653 .write_blob(br#"{"schema":"default@1.0.0"}"#)
654 .unwrap()
655 .detach();
656 let mut editor = repo.empty_tree().edit().unwrap();
657 editor
658 .upsert(
659 format!("{full_path}/config.json"),
660 gix::objs::tree::EntryKind::Blob,
661 blob,
662 )
663 .unwrap();
664 let tree_id = editor.write().unwrap().detach();
665 let mut buf = gix::date::parse::TimeBuf::default();
666 let actor_ref = actor.to_ref(&mut buf);
667 repo.commit_as(
668 actor_ref,
669 actor_ref,
670 "refs/heads/__SYSTEM",
671 "seed system",
672 tree_id,
673 Vec::<gix::ObjectId>::new(),
674 )
675 .unwrap();
676
677 // Project __SYSTEM onto __MEMSTEAD so post-s140 reads land.
678 crate::storage_memstead::migrate_to_memstead_ref(&gitdir).unwrap();
679
680 tmp
681 }
682
683 #[test]
684 fn resolve_full_path_returns_flat_branch_name() {
685 let tmp = init_mem_repo_with_hierarchical_branch("alpha");
686 let gitdir = tmp.path().join("mem-repo").join(".git");
687 let resolved = super::resolve_full_path_at_gitdir(&gitdir, "alpha").unwrap();
688 assert_eq!(resolved, Some("alpha".to_string()));
689 }
690
691 #[test]
692 fn resolve_full_path_returns_full_branch_for_hierarchical() {
693 let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
694 let gitdir = tmp.path().join("mem-repo").join(".git");
695 let resolved = super::resolve_full_path_at_gitdir(&gitdir, "engine").unwrap();
696 assert_eq!(resolved, Some("demo/engine".to_string()));
697 }
698
699 #[test]
700 fn resolve_full_path_returns_none_for_unknown_leaf() {
701 let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
702 let gitdir = tmp.path().join("mem-repo").join(".git");
703 let resolved = super::resolve_full_path_at_gitdir(&gitdir, "ghost").unwrap();
704 assert!(resolved.is_none());
705 }
706
707 /// Seal an arbitrary list of per-mem content branches at the
708 /// given full paths on top of an existing fixture repo.
709 fn seal_branches(gitdir: &std::path::Path, full_paths: &[&str]) {
710 let repo = gix::open(gitdir).unwrap();
711 let actor = gix::actor::Signature {
712 name: "test".into(),
713 email: "test@example.com".into(),
714 time: gix::date::Time {
715 seconds: 0,
716 offset: 0,
717 },
718 };
719 let empty_tree = repo.empty_tree().id().detach();
720 for full_path in full_paths {
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 format!("refs/heads/{full_path}"),
727 "seal",
728 empty_tree,
729 Vec::<gix::ObjectId>::new(),
730 )
731 .unwrap();
732 }
733 }
734
735 #[test]
736 fn find_branches_by_leaf_returns_empty_for_unknown_leaf() {
737 let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
738 let gitdir = tmp.path().join("mem-repo").join(".git");
739 let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "ghost").unwrap();
740 assert!(matches.is_empty());
741 }
742
743 #[test]
744 fn find_branches_by_leaf_returns_single_full_path() {
745 let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
746 let gitdir = tmp.path().join("mem-repo").join(".git");
747 let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "engine").unwrap();
748 assert_eq!(matches, vec!["demo/engine".to_string()]);
749 }
750
751 #[test]
752 fn find_branches_by_leaf_returns_all_colliding_paths_sorted() {
753 // Two branches sharing leaf `engine` at distinct paths — the
754 // exact corruption scenario Goal 11 surfaces explicitly.
755 let tmp = init_mem_repo_with_hierarchical_branch("demo/engine");
756 let gitdir = tmp.path().join("mem-repo").join(".git");
757 seal_branches(&gitdir, &["planning/engine"]);
758 let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "engine").unwrap();
759 assert_eq!(
760 matches,
761 vec!["demo/engine".to_string(), "planning/engine".to_string()]
762 );
763 }
764
765 #[test]
766 fn find_branches_by_leaf_skips_main_and_registry_refs() {
767 // `__SYSTEM` ref carries the same `engine` leaf if a corrupt
768 // operator created `refs/heads/__weird`. The walker filters
769 // both `main` and any `__*`-leading-segment ref so registry
770 // refs cannot mask as content branches.
771 let tmp = init_mem_repo_with_hierarchical_branch("alpha");
772 let gitdir = tmp.path().join("mem-repo").join(".git");
773 // No content-branch with leaf `__SYSTEM` should be reported.
774 let matches = super::find_branches_by_leaf_at_gitdir(&gitdir, "__SYSTEM").unwrap();
775 assert!(matches.is_empty());
776 // And a flat `alpha` branch shows under leaf `alpha`.
777 let alpha = super::find_branches_by_leaf_at_gitdir(&gitdir, "alpha").unwrap();
778 assert_eq!(alpha, vec!["alpha".to_string()]);
779 }
780
781 #[test]
782 fn find_branches_by_leaf_errors_when_gitdir_missing() {
783 let tmp = TempDir::new().unwrap();
784 let gitdir = tmp.path().join("nonexistent").join(".git");
785 let err = super::find_branches_by_leaf_at_gitdir(&gitdir, "alpha")
786 .expect_err("missing gitdir must surface as GitdirNotFound");
787 assert!(matches!(err, super::MemRepoConfigError::GitdirNotFound(_)));
788 }
789
790 /// `_at_gitdir` siblings target an arbitrary gitdir, not the
791 /// workspace-default `<workspace_root>/mem-repo/.git/`. This
792 /// pins that the gitdir is the only thing that matters: a mem
793 /// living in a non-default mount path is readable as long as its
794 /// gitdir is supplied directly.
795 #[test]
796 fn at_gitdir_apis_target_arbitrary_gitdir() {
797 // Workspace tmp has no `mem-repo/.git/` at all — only a
798 // sibling mount at `external/.git/`. The workspace-rooted
799 // wrappers would all surface `GitdirNotFound`; the
800 // `_at_gitdir` siblings must work because we hand them the
801 // explicit gitdir.
802 let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
803 // Move the gitdir from the workspace-default location to a
804 // sibling so the workspace-rooted wrappers can no longer find
805 // it. (Equivalent to a `[[mem_repos]] path = "external"`
806 // declaration.)
807 let default_path = tmp.path().join("mem-repo");
808 let mount_path = tmp.path().join("external");
809 std::fs::rename(&default_path, &mount_path).unwrap();
810 let gitdir = mount_path.join(".git");
811
812 // `_at_gitdir` siblings see the mount.
813 assert!(super::has_real_mem_repo_main_at_gitdir(&gitdir));
814 let cfg = super::read_config_at_gitdir(&gitdir, "alpha").unwrap();
815 // Configs no longer carry an in-config `name` field.
816 assert!(cfg.name.is_none());
817 // The fixture seeds only `__SYSTEM:alpha/config.json` and no
818 // per-mem content branch — `resolve_full_path` returns `None`
819 // and `branch_ref_for_mem` falls back to the flat form. Pins
820 // both behaviours against the explicit gitdir.
821 assert_eq!(
822 super::resolve_full_path_at_gitdir(&gitdir, "alpha").unwrap(),
823 None
824 );
825 assert_eq!(
826 super::branch_ref_for_mem_at_gitdir(&gitdir, "alpha"),
827 "refs/heads/alpha"
828 );
829
830 // Workspace-rooted wrappers DON'T see the mount because the
831 // synthesised default path no longer exists.
832 assert!(!super::has_real_mem_repo_main(tmp.path()));
833 let err = super::read_config(tmp.path(), "alpha").unwrap_err();
834 assert!(matches!(err, MemRepoConfigError::GitdirNotFound(_)));
835 }
836
837 /// `commit_config_at_gitdir` routes the RMW to the supplied
838 /// gitdir's `__MEMSTEAD` ref. Pins that a non-default-mount commit
839 /// path advances exactly that mount's `__MEMSTEAD` and leaves any
840 /// default mount untouched.
841 #[test]
842 fn commit_config_at_gitdir_targets_arbitrary_mount() {
843 let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
844 let default_path = tmp.path().join("mem-repo");
845 let mount_path = tmp.path().join("external");
846 std::fs::rename(&default_path, &mount_path).unwrap();
847 let gitdir = mount_path.join(".git");
848
849 let pre_tip = {
850 let repo = gix::open(&gitdir).unwrap();
851 repo.find_reference("refs/heads/__MEMSTEAD")
852 .unwrap()
853 .into_fully_peeled_id()
854 .unwrap()
855 .detach()
856 };
857
858 let ctx = crate::vcs::CommitContext::internal();
859 super::commit_config_at_gitdir(
860 &gitdir,
861 "alpha",
862 br#"{"schema":"default@1.0.0","note":"v1"}"#,
863 &ctx,
864 "external mount commit",
865 )
866 .expect("commit_config_at_gitdir against external mount");
867
868 let post_tip = {
869 let repo = gix::open(&gitdir).unwrap();
870 repo.find_reference("refs/heads/__MEMSTEAD")
871 .unwrap()
872 .into_fully_peeled_id()
873 .unwrap()
874 .detach()
875 };
876 assert_ne!(pre_tip, post_tip, "external mount __MEMSTEAD must advance");
877
878 // Workspace-rooted commit_config still surfaces GixOpen
879 // because the default mount has no gitdir.
880 let result = super::commit_config(tmp.path(), "alpha", br#"{}"#, &ctx, "should fail");
881 assert!(matches!(result, Err(MemRepoWriteError::GixOpen { .. })));
882 }
883
884 #[test]
885 fn read_config_resolves_hierarchical_layout() {
886 let tmp = init_mem_repo_with_hierarchical_branch("planning/exec-foo");
887 // The lookup happens by leaf only and must walk via
888 // `resolve_full_path` to reach `planning/exec-foo/config.json`.
889 // Configs no longer carry an in-config `name` field — successful read of
890 // the schema field proves the resolver landed on the right
891 // tree path.
892 let cfg = super::read_config(tmp.path(), "exec-foo").unwrap();
893 assert!(cfg.name.is_none());
894 assert_eq!(
895 cfg.schema.as_ref().map(|s| s.name.as_str()),
896 Some("default")
897 );
898 }
899
900 #[test]
901 fn errors_when_system_ref_missing() {
902 // Empty bare repo — the stub shape used by `init_mem_repo_stub`.
903 let tmp = TempDir::new().unwrap();
904 let gitdir = tmp.path().join("mem-repo").join(".git");
905 std::fs::create_dir_all(&gitdir).unwrap();
906 gix::init_bare(&gitdir).unwrap();
907 let err = read_config(tmp.path(), "alpha").unwrap_err();
908 assert!(matches!(err, MemRepoConfigError::NoMainBranch));
909 }
910
911 /// Sequential read-modify-write: a `commit_refs` batch whose
912 /// `MustExistAndMatch(<observed_T0>)` precondition disagrees with
913 /// the current `__MEMSTEAD` tip (`T1`, after a prior commit landed) is
914 /// rejected with a typed `RefTransaction` error rather than
915 /// silently advancing past `T1` to a new `T2`.
916 ///
917 /// This is the sequential-RMW shape — cross-thread parallelism
918 /// (two threads racing the *same* observed `T0`) is NOT covered;
919 /// gix-ref's `prepare` reads `existing_ref` before lock acquisition,
920 /// so the cross-thread closure of the same race requires an outer
921 /// mutex (out of scope).
922 #[test]
923 fn commit_config_rejects_stale_main_tip() {
924 let tmp = init_mem_repo_with_config("alpha", r#"{"schema": "default@1.0.0"}"#);
925 let workspace_root = tmp.path();
926 let gitdir = workspace_root.join("mem-repo").join(".git");
927
928 let observed_t0 = {
929 let repo = gix::open(&gitdir).unwrap();
930 repo.find_reference("refs/heads/__MEMSTEAD")
931 .unwrap()
932 .into_fully_peeled_id()
933 .unwrap()
934 .detach()
935 };
936
937 let ctx = crate::vcs::CommitContext::internal();
938 commit_config(
939 workspace_root,
940 "alpha",
941 br#"{"schema":"default@1.0.0","note":"v1"}"#,
942 &ctx,
943 "first commit",
944 )
945 .expect("first commit_config should succeed");
946
947 let observed_t1 = {
948 let repo = gix::open(&gitdir).unwrap();
949 repo.find_reference("refs/heads/__MEMSTEAD")
950 .unwrap()
951 .into_fully_peeled_id()
952 .unwrap()
953 .detach()
954 };
955 assert_ne!(
956 observed_t0, observed_t1,
957 "__MEMSTEAD must have advanced after first commit_config"
958 );
959
960 let stale_result = {
961 let repo = gix::open(&gitdir).unwrap();
962 let memstead_tree = repo
963 .find_object(observed_t1)
964 .unwrap()
965 .into_commit()
966 .tree()
967 .unwrap()
968 .id()
969 .detach();
970 let sig = gix::actor::Signature {
971 name: "test".into(),
972 email: "test@example.com".into(),
973 time: gix::date::Time {
974 seconds: 0,
975 offset: 0,
976 },
977 };
978 let new_commit = gix::objs::Commit {
979 message: "stale".into(),
980 tree: memstead_tree,
981 author: sig.clone(),
982 committer: sig,
983 encoding: None,
984 parents: std::iter::once(observed_t1).collect(),
985 extra_headers: Default::default(),
986 };
987 let new_oid = repo.write_object(&new_commit).unwrap().detach();
988
989 commit_refs(
990 workspace_root,
991 &[RefSpec {
992 ref_name: "refs/heads/__MEMSTEAD".to_string(),
993 new_oid,
994 expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
995 gix::refs::Target::Object(observed_t0),
996 ),
997 log_message: "memstead: stale RMW".to_string(),
998 }],
999 )
1000 };
1001
1002 assert!(
1003 matches!(stale_result, Err(MemRepoWriteError::RefTransaction(_))),
1004 "expected RefTransaction precondition mismatch, got {:?}",
1005 stale_result
1006 );
1007
1008 let observed_after = {
1009 let repo = gix::open(&gitdir).unwrap();
1010 repo.find_reference("refs/heads/__MEMSTEAD")
1011 .unwrap()
1012 .into_fully_peeled_id()
1013 .unwrap()
1014 .detach()
1015 };
1016 assert_eq!(
1017 observed_after, observed_t1,
1018 "__MEMSTEAD must remain at T1 after rejected stale RMW"
1019 );
1020 }
1021}