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 — 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}