Skip to main content

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}