Skip to main content

memstead_git_branch/vcs/
mod.rs

1//! Per-mem version control via `gix`. Each mem owns a gix repository
2//! whose gitdir and worktree are resolved from the mem's config at
3//! `Engine::init` time — isolated from any outer project repo and from
4//! the developer's `~/.gitconfig`.
5//!
6//! Gitdir defaults to `<mem>/.git/` and worktree to the mem root; the
7//! optional `vcs` block in `.memstead/config.json` overrides either (notably
8//! the shared-gitdir idiom `{ "../.git", ".." }`). On first init the
9//! repo is bootstrapped and its per-repo config is patched with
10//! `core.logallrefupdates = true` + `commit.gpgsign = false`;
11//! `core.worktree` is written only when the declared worktree disagrees
12//! with the gitdir's natural parent (shared idiom).
13//!
14//! On every init we re-apply `commit.gpgsign = false` so a developer
15//! with global signing enabled does not hang every mutation waiting for
16//! a passphrase.
17//!
18//! The *committer* (`engine <noreply@memstead.io>`) is set explicitly per
19//! commit via `commit_as` and is therefore independent of any
20//! `user.name`/`user.email` config — global or per-repo. The *author* is
21//! derived per commit from a [`CommitContext`] so provenance (agent / cli /
22//! external drift) is visible in `git log` without storing PII.
23//!
24//! Trailer contract: callers pass prose only. The engine appends trailers
25//! (`Tool:`, `Actor:`, `Client:`) after a single `\n\n` separator. Callers
26//! MUST NOT write those keys themselves — duplicates would confuse
27//! `git interpret-trailers` consumers.
28//!
29//! ## In-process serialization: per-branch mutex
30//!
31//! A mem's commits race on a single git ref (today: HEAD's symref
32//! target on the disk adapter, an explicit branch name on the git-tree
33//! adapter). Without serialization two concurrent commits against the
34//! same ref would either produce an orphan parent chain or fail gix's
35//! reference-transaction check. The mutex's job is to keep the
36//! tree-build + commit + ref-advance window atomic for one ref.
37//!
38//! A process-wide registry (see [`acquire_branch_mutex`]) maps
39//! canonical ref-name strings (e.g. `refs/heads/main`) to
40//! `Arc<Mutex<()>>`. Both adapters acquire the mutex for the ref they
41//! are about to advance: the disk adapter resolves HEAD's symref to a
42//! concrete `refs/heads/<name>` first so a future git-tree writer
43//! committing onto the same branch shares the same key. Different ref
44//! names hold different mutexes and proceed in parallel.
45//!
46//! Cross-process contention is **out of scope** for this layer. A
47//! second process committing against the same ref hits gix's own
48//! lockfile discipline (`<gitdir>/index.lock`, `<gitdir>/HEAD.lock`,
49//! …), which this module surfaces as [`VcsError::Git`] →
50//! `VCS_ERROR`-coded envelopes. The human-readable message includes
51//! retry guidance; the industry norm (libgit2, GitHub Desktop) is to
52//! propagate lockfile errors back to the caller rather than introduce
53//! a custom `flock` layer.
54//!
55//! **Lock-order rule.** When a code path holds more than one per-branch
56//! mutex simultaneously (e.g. a cross-mem move that touches two
57//! branches under one shared multi-root gitdir), the mutexes MUST be
58//! acquired in lexicographic ref-name order to prevent deadlock.
59//! [`acquire_branch_mutexes_in_order`] enforces this by sorting before
60//! acquisition; ad-hoc multi-mutex code in debug builds is caught by
61//! the assertion inside [`acquire_branch_mutex`].
62
63use std::collections::HashMap;
64use std::ffi::OsStr;
65use std::path::{Path, PathBuf};
66use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
67
68use gix::objs::tree::EntryKind;
69
70/// Process-wide map from canonical ref-name string (e.g.
71/// `refs/heads/main`) to the mutex that serializes commits against that
72/// ref. Lazy-initialized; entries are created on first use and never
73/// removed for the process lifetime — ref names are stable and the
74/// memory footprint is O(distinct refs ever written), bounded by the
75/// user's workspace size.
76static BRANCH_MUTEXES: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
77
78// Debug-build-only stack of currently-held ref-name keys, per thread.
79// The lock-order rule says callers must acquire branch mutexes in
80// lexicographic order; a thread that already holds `b` and then
81// requests `a` (where `a < b`) is a bug, not just a stylistic issue —
82// in a multi-mem future where two threads each hold one of `(a, b)`
83// and request the other, the program deadlocks. The debug assertion
84// inside `acquire_branch_mutex` surfaces the bug at the offending
85// acquisition site instead of in a hung process. Release builds skip
86// the bookkeeping entirely.
87#[cfg(debug_assertions)]
88thread_local! {
89    static HELD_BRANCH_KEYS: std::cell::RefCell<Vec<String>> =
90        const { std::cell::RefCell::new(Vec::new()) };
91}
92
93/// Return the process-wide mutex for `ref_name`, creating it on first use.
94///
95/// `ref_name` is the full ref path (e.g. `refs/heads/main`). The disk
96/// adapter resolves HEAD's symref target before calling; the git-tree
97/// adapter passes its target ref directly. Two adapters writing to the
98/// same ref under the same gitdir resolve to the same `Arc<Mutex<()>>`
99/// and serialize on it; writes to different refs proceed in parallel.
100///
101/// The outer registry lock is held only for the HashMap lookup /
102/// insertion; the returned `Arc` is cloned out before release. Callers
103/// then acquire the inner mutex — held across the whole commit
104/// operation — without blocking other refs.
105///
106/// Poisoning: if a prior commit panicked while holding the inner
107/// mutex, subsequent acquisitions return `Err(PoisonError)`. Callers
108/// surface this as [`VcsError::Git`] with a message identifying the
109/// offending ref name so the operator can inspect state before
110/// retrying.
111///
112/// Debug-only: panics if the calling thread already holds a mutex with
113/// a lexicographically greater-or-equal key, enforcing the lock-order
114/// rule documented at the top of this module.
115pub(crate) fn acquire_branch_mutex(ref_name: &str) -> Arc<Mutex<()>> {
116    #[cfg(debug_assertions)]
117    {
118        HELD_BRANCH_KEYS.with(|held| {
119            let held = held.borrow();
120            if let Some(top) = held.last() {
121                assert!(
122                    top.as_str() < ref_name,
123                    "out-of-order branch-mutex acquisition: \
124                     thread already holds '{top}', cannot now acquire '{ref_name}' \
125                     (lexicographic order required)"
126                );
127            }
128        });
129    }
130    let registry = BRANCH_MUTEXES.get_or_init(|| Mutex::new(HashMap::new()));
131    // The registry lock is a brief critical section — HashMap lookup
132    // plus at most one insertion — so a poisoned registry is a bug we
133    // cannot recover from. `expect` here is load-bearing: it turns
134    // registry-level corruption into a clear panic rather than silent
135    // commit divergence.
136    let mut map = registry
137        .lock()
138        .expect("branch mutex registry poisoned — previous commit panicked inside the registry critical section");
139    map.entry(ref_name.to_string())
140        .or_insert_with(|| Arc::new(Mutex::new(())))
141        .clone()
142}
143
144/// RAII guard returned by [`acquire_branch_mutexes_in_order`]. Holds
145/// an `Arc<Mutex<()>>` and a `MutexGuard` rooted in the `Arc`'s
146/// storage; in debug builds it also pops the held-keys bookkeeping on
147/// drop. The 'static `MutexGuard` lifetime is sound because the `Arc`
148/// keeps the inner `Mutex` alive for the guard's full lifetime.
149#[cfg_attr(not(test), allow(dead_code))]
150pub(crate) struct BranchMutexGuard {
151    // SAFETY-via-construction: `_arc` outlives `_guard`; the guard's
152    // `Mutex<()>` is reachable via the Arc, and the Arc is held by the
153    // same struct.
154    _guard: MutexGuard<'static, ()>,
155    _arc: Arc<Mutex<()>>,
156    #[cfg(debug_assertions)]
157    key: String,
158}
159
160#[cfg(debug_assertions)]
161impl Drop for BranchMutexGuard {
162    fn drop(&mut self) {
163        HELD_BRANCH_KEYS.with(|held| {
164            let mut held = held.borrow_mut();
165            if let Some(pos) = held.iter().rposition(|k| k == &self.key) {
166                held.remove(pos);
167            }
168        });
169    }
170}
171
172/// Acquire branch mutexes for every ref in `refs`, after sorting them
173/// in lexicographic order. Returns the guards in acquisition order
174/// (lex-sorted). Used by code paths that need to serialize against
175/// more than one branch at once — e.g. a cross-mem move under a
176/// shared multi-root gitdir.
177///
178/// Holding the returned `Vec` keeps every branch locked; dropping it
179/// releases every guard. Order is guaranteed deterministic regardless
180/// of input order.
181#[cfg_attr(not(test), allow(dead_code))]
182pub(crate) fn acquire_branch_mutexes_in_order(refs: &[&str]) -> Vec<BranchMutexGuard> {
183    let mut sorted: Vec<&str> = refs.to_vec();
184    sorted.sort_unstable();
185    sorted.dedup();
186    let mut guards: Vec<BranchMutexGuard> = Vec::with_capacity(sorted.len());
187    for r in sorted {
188        let arc = acquire_branch_mutex(r);
189        // SAFETY: extend the guard's lifetime to 'static. The `_arc`
190        // field below holds the same `Arc` so the underlying `Mutex<()>`
191        // is kept alive for as long as the `BranchMutexGuard` exists.
192        let raw_guard: MutexGuard<'_, ()> = arc
193            .lock()
194            .expect("branch mutex poisoned during ordered acquisition");
195        let guard: MutexGuard<'static, ()> = unsafe {
196            std::mem::transmute::<MutexGuard<'_, ()>, MutexGuard<'static, ()>>(raw_guard)
197        };
198        #[cfg(debug_assertions)]
199        HELD_BRANCH_KEYS.with(|held| {
200            held.borrow_mut().push(r.to_string());
201        });
202        guards.push(BranchMutexGuard {
203            _guard: guard,
204            _arc: arc,
205            #[cfg(debug_assertions)]
206            key: r.to_string(),
207        });
208    }
209    guards
210}
211
212/// Resolve a gix `Repository`'s HEAD into a concrete fully-qualified
213/// ref name (e.g. `refs/heads/main`). Returns the symref target when
214/// HEAD is a symbolic ref (the common case for a working repository),
215/// or the literal string `"HEAD"` when HEAD is detached or unresolvable
216/// — the latter case has no stable per-branch identity, so falling back
217/// to the literal still serializes correctly within the process.
218pub(crate) fn head_branch_ref(repo: &gix::Repository) -> String {
219    match repo.head_ref() {
220        Ok(Some(reference)) => reference.name().as_bstr().to_string(),
221        _ => "HEAD".to_string(),
222    }
223}
224
225/// Deterministic committer identity — bypasses per-repo and global git
226/// config and doubles as the author fallback when no actor is known.
227const COMMITTER_NAME: &str = "engine";
228const COMMITTER_EMAIL: &str = "noreply@memstead.io";
229
230pub use memstead_base::vcs::{
231    Actor, ClientId, CommitContext, author_identity, format_commit_message, sanitise_client_name,
232};
233
234/// VCS operations trait — minimal surface. Only `commit` is needed by the
235/// engine today. `changes_since` goes straight to
236/// `gix::diff::tree` without expanding this trait.
237pub trait Vcs: Send + Sync {
238    /// Stage the paths (typically a single mem directory) into the repo's
239    /// tree and create a commit on HEAD. `message` is the caller's prose —
240    /// the implementation appends the provenance trailers derived from
241    /// `ctx` (`Tool:`, `Actor:`, `Client:`) and picks the author signature
242    /// from `ctx` too. Returns the commit SHA. Each call rebuilds the
243    /// tree from disk so deletions surface without callers having to
244    /// track them.
245    fn commit(
246        &self,
247        paths: &[&Path],
248        message: &str,
249        ctx: &CommitContext<'_>,
250    ) -> Result<String, VcsError>;
251}
252
253#[derive(Debug, thiserror::Error)]
254pub enum VcsError {
255    #[error("not a git repository: {0}")]
256    NotRepo(String),
257    #[error("object not found: {0}")]
258    ObjectNotFound(String),
259    #[error("reference conflict: {0}")]
260    RefConflict(String),
261    #[error("git error: {0}")]
262    Git(String),
263    #[error("io error: {0}")]
264    Io(#[from] std::io::Error),
265}
266
267impl From<gix::open::Error> for VcsError {
268    fn from(e: gix::open::Error) -> Self {
269        VcsError::NotRepo(e.to_string())
270    }
271}
272
273impl From<gix::init::Error> for VcsError {
274    fn from(e: gix::init::Error) -> Self {
275        VcsError::Git(format!("init: {e}"))
276    }
277}
278
279impl From<gix::commit::Error> for VcsError {
280    fn from(e: gix::commit::Error) -> Self {
281        VcsError::Git(format!("commit: {e}"))
282    }
283}
284
285impl From<gix::object::write::Error> for VcsError {
286    fn from(e: gix::object::write::Error) -> Self {
287        VcsError::Git(format!("write-object: {e}"))
288    }
289}
290
291/// Open or initialize the per-mem gix repository.
292///
293/// `git_dir` holds HEAD, refs, objects, and config. `work_tree` is the
294/// mem root (or, in the shared-gitdir idiom, the directory that owns
295/// the gitdir). Both must be absolute or already canonicalized —
296/// `commit` strips `work_tree` from each staged path to compute the
297/// in-tree relative path.
298///
299/// On first init the gitdir is bootstrapped and the per-repo config is
300/// patched with `core.logallrefupdates = true` + `commit.gpgsign = false`.
301/// `core.worktree` is written only when `git_dir.parent() != Some(work_tree)`
302/// (i.e. when the gitdir is not a direct child of the worktree).
303/// `commit.gpgsign = false` is re-applied on every re-open so that a
304/// user edit to the per-repo config cannot silently enable signing and
305/// hang every memstead mutation waiting for a passphrase.
306pub fn create_vcs(git_dir: &Path, work_tree: &Path) -> Result<Arc<dyn Vcs>, VcsError> {
307    let is_new = !git_dir.join("HEAD").exists();
308
309    if is_new {
310        std::fs::create_dir_all(work_tree)?;
311        if let Some(parent) = git_dir.parent() {
312            std::fs::create_dir_all(parent)?;
313        }
314        gix::init_bare(git_dir)?;
315
316        let mut kvs: Vec<(&str, &str, &str)> = vec![
317            ("core", "bare", "false"),
318            ("core", "logallrefupdates", "true"),
319            ("commit", "gpgsign", "false"),
320        ];
321        let worktree_rel_storage;
322        let gitdir_parent_is_worktree = git_dir
323            .parent()
324            .map(|p| paths_equal(p, work_tree))
325            .unwrap_or(false);
326        if !gitdir_parent_is_worktree {
327            worktree_rel_storage = relative_path(git_dir, work_tree)
328                .unwrap_or_else(|| work_tree.to_string_lossy().into_owned());
329            kvs.push(("core", "worktree", &worktree_rel_storage));
330        }
331        write_per_repo_config(git_dir, &kvs)?;
332    } else {
333        write_per_repo_config(git_dir, &[("commit", "gpgsign", "false")])?;
334    }
335
336    // Sanity: opening must succeed now. If it doesn't, something is wrong
337    // with the layout (e.g. config file corrupted) — surface it rather
338    // than masking with the "open works at commit time" lazy path.
339    let _repo = gix::open(git_dir)?;
340
341    // Canonicalize the stored paths. Shared-gitdir configs express
342    // `worktree` with `..` segments (e.g. `../.git`, `..`); `strip_prefix`
343    // is a purely syntactic operation and would fail against a raw
344    // `<mem>/..` prefix. Canonicalizing here once makes every later
345    // subpath computation reliable.
346    let git_dir_canon = std::fs::canonicalize(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
347    let work_tree_canon =
348        std::fs::canonicalize(work_tree).unwrap_or_else(|_| work_tree.to_path_buf());
349
350    Ok(Arc::new(GixVcs {
351        git_dir: git_dir_canon,
352        work_tree: work_tree_canon,
353    }))
354}
355
356/// Check whether two paths refer to the same location on disk.
357/// Attempts `std::fs::canonicalize` first; falls back to component-wise
358/// equality when either path does not yet exist or canonicalization
359/// otherwise fails. Used only for the `core.worktree` skip heuristic in
360/// `create_vcs` — not load-bearing for correctness.
361fn paths_equal(a: &Path, b: &Path) -> bool {
362    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
363        (Ok(ca), Ok(cb)) => ca == cb,
364        _ => a == b,
365    }
366}
367
368/// Compute a relative path from `from` (a directory) to `to`. Walks
369/// shared ancestors via lexicographic component comparison, emitting
370/// `..` for each `from`-component above the shared root and then the
371/// remaining `to`-components. Both inputs must already be absolute /
372/// canonicalized for this to be meaningful. Returns `None` when the two
373/// paths share no prefix (different drives on Windows, different
374/// canonicalized mounts).
375///
376/// Example: `from = /a/b/.git`, `to = /a/b` → `"."`.
377/// Example: `from = /a/.git`,   `to = /a/b` → `"b"`.
378/// Example: `from = /a/b/.git`, `to = /a`   → `".."`.
379fn relative_path(from: &Path, to: &Path) -> Option<String> {
380    let from_comps: Vec<_> = from.components().collect();
381    let to_comps: Vec<_> = to.components().collect();
382    // First mismatching component index.
383    let mut shared = 0;
384    while shared < from_comps.len()
385        && shared < to_comps.len()
386        && from_comps[shared] == to_comps[shared]
387    {
388        shared += 1;
389    }
390    if shared == 0 {
391        return None;
392    }
393    let ups = from_comps.len().saturating_sub(shared);
394    let mut out = PathBuf::new();
395    for _ in 0..ups {
396        out.push("..");
397    }
398    for comp in &to_comps[shared..] {
399        out.push(comp.as_os_str());
400    }
401    if out.as_os_str().is_empty() {
402        Some(".".to_string())
403    } else {
404        Some(out.to_string_lossy().into_owned())
405    }
406}
407
408/// Patch the repo-local `config` file with the given `(section, key, value)`
409/// triples. Existing values are overwritten; missing sections are created.
410/// Any other entries (set by gix init or by a user) are preserved.
411///
412/// Keys and values are owned (`String`) because `gix_config::File::set_raw_value_by`
413/// ties the inserted `ValueName` to the `File`'s lifetime parameter — passing
414/// borrowed `&str` from a non-`'static` slice triggers a lifetime mismatch
415/// against the `File<'static>` returned by `from_path_no_includes`.
416fn write_per_repo_config(git_dir: &Path, kvs: &[(&str, &str, &str)]) -> Result<(), VcsError> {
417    use gix::bstr::BStr;
418    let config_path = git_dir.join("config");
419    let mut file =
420        gix::config::File::from_path_no_includes(config_path.clone(), gix::config::Source::Local)
421            .map_err(|e| VcsError::Git(format!("config parse: {e}")))?;
422    for (section, key, value) in kvs {
423        let key_owned = String::from(*key);
424        let value_bytes: &BStr = (*value).as_bytes().into();
425        file.set_raw_value_by(*section, None, key_owned, value_bytes)
426            .map_err(|e| VcsError::Git(format!("config set {section}.{key}: {e}")))?;
427    }
428    let mut buf = Vec::new();
429    file.write_to(&mut buf)
430        .map_err(|e| VcsError::Git(format!("config serialize: {e}")))?;
431    std::fs::write(&config_path, buf)?;
432    Ok(())
433}
434
435/// Per-mem gix-backed VCS. Opens the repo on every commit — gix repos are
436/// cheap to open and this avoids any cross-thread shared-state concerns.
437struct GixVcs {
438    git_dir: PathBuf,
439    work_tree: PathBuf,
440}
441
442impl Vcs for GixVcs {
443    fn commit(
444        &self,
445        paths: &[&Path],
446        message: &str,
447        ctx: &CommitContext<'_>,
448    ) -> Result<String, VcsError> {
449        // Per-branch serialization. The inner mutex is held across
450        // tree-build + commit + ref-advance so two in-process
451        // `GixVcs::commit` calls against the same ref cannot race on
452        // its tip. We resolve HEAD's symref target up-front so that a
453        // future git-tree writer committing onto the same concrete
454        // branch (e.g. `refs/heads/main`) shares the same mutex key.
455        let repo = gix::open(&self.git_dir)?;
456        let head_ref = head_branch_ref(&repo);
457        let mutex = acquire_branch_mutex(&head_ref);
458        let _guard = mutex.lock().map_err(|_| {
459            VcsError::Git(format!(
460                "branch mutex poisoned (a previous commit panicked); inspect {} ref {} and restart the process",
461                self.git_dir.display(),
462                head_ref,
463            ))
464        })?;
465
466        // Mem-scoped commit tree. The contract is "preserve HEAD minus
467        // every mem-subpath in `paths`, then re-upsert what's on disk":
468        //
469        // 1. Callers bundle paths that are either all-isolated (empty
470        //    subpath — the mem owns the whole worktree) or all-shared
471        //    (non-empty subpaths under one shared worktree). Mixing both
472        //    shapes in one call would silently discard the shared
473        //    mem's HEAD subtree once the empty case forced an
474        //    `empty_tree()` start; the `debug_assert!` below pins that
475        //    contract so a future multi-mem caller fails loudly in
476        //    debug builds rather than corrupting history in release.
477        // 2. All-empty → start from `empty_tree` (a full rebuild;
478        //    deletions surface without per-file diff bookkeeping).
479        // 3. All-non-empty → start from HEAD's tree (or `empty_tree` on
480        //    genesis) and wholesale-remove each mem's subtree before
481        //    re-upserting so sibling mems under the same gitdir
482        //    survive our commit unchanged.
483        let subpaths: Vec<String> = paths
484            .iter()
485            .map(|p| mem_subpath(&self.work_tree, p))
486            .collect::<Result<_, _>>()?;
487        debug_assert!(
488            subpaths.iter().all(|s| s.is_empty()) || subpaths.iter().all(|s| !s.is_empty()),
489            "commit() paths must not mix isolated and shared subpaths",
490        );
491        let any_empty_subpath = subpaths.iter().any(|s| s.is_empty());
492
493        let head_commit = repo.head_commit().ok();
494        let parents = head_commit.as_ref().map(|c| vec![c.id]).unwrap_or_default();
495        let mut editor = if any_empty_subpath {
496            repo.empty_tree()
497                .edit()
498                .map_err(|e| VcsError::Git(format!("editor init: {e}")))?
499        } else if let Some(head) = head_commit.as_ref() {
500            let tree = head
501                .tree()
502                .map_err(|e| VcsError::Git(format!("head tree: {e}")))?;
503            tree.edit()
504                .map_err(|e| VcsError::Git(format!("editor init: {e}")))?
505        } else {
506            repo.empty_tree()
507                .edit()
508                .map_err(|e| VcsError::Git(format!("editor init: {e}")))?
509        };
510
511        for (path, subpath) in paths.iter().zip(subpaths.iter()) {
512            // Drop the mem's prior subtree so deletions on disk surface
513            // in the tree. Empty subpath already started from `empty_tree`.
514            if !subpath.is_empty() {
515                editor
516                    .remove(subpath.as_str())
517                    .map_err(|e| VcsError::Git(format!("tree remove: {e}")))?;
518            }
519            apply_path(&repo, &mut editor, &self.work_tree, path, subpath)?;
520        }
521
522        let tree_id = editor
523            .write()
524            .map_err(|e| VcsError::Git(format!("tree write: {e}")))?
525            .detach();
526
527        let time = gix::date::Time::now_local_or_utc();
528        let committer_sig = gix::actor::Signature {
529            name: COMMITTER_NAME.into(),
530            email: COMMITTER_EMAIL.into(),
531            time,
532        };
533        let author_sig = match author_identity(ctx) {
534            Some((name, email)) => gix::actor::Signature {
535                name: name.into(),
536                email: email.into(),
537                time,
538            },
539            None => committer_sig.clone(),
540        };
541        let mut author_buf = gix::date::parse::TimeBuf::default();
542        let mut committer_buf = gix::date::parse::TimeBuf::default();
543        let author_ref = author_sig.to_ref(&mut author_buf);
544        let committer_ref = committer_sig.to_ref(&mut committer_buf);
545
546        let full_message = format_commit_message(message, ctx);
547        let commit_id = repo.commit_as(
548            committer_ref,
549            author_ref,
550            "HEAD",
551            full_message,
552            tree_id,
553            parents,
554        )?;
555        Ok(commit_id.to_hex().to_string())
556    }
557}
558
559/// Compute the mem's subpath within the worktree as a forward-slash
560/// separated string. The empty string means "the mem owns the whole
561/// worktree" (isolated idiom); a non-empty string means "the mem lives
562/// under `<subpath>` inside a shared worktree".
563///
564/// `work_tree` is expected to be canonical (invariant:
565/// `MemState.resolved_worktree` is canonicalized at `Engine::init`;
566/// `GixVcs.work_tree` is canonicalized in `create_vcs`). `mem_path` is
567/// canonicalized defensively here because mutation callers thread it
568/// through from `state.dir`, and `strip_prefix` is a purely syntactic
569/// operation — any `..` or symlink in the raw path would defeat it.
570///
571/// Returns `VcsError::Git` when the mem path is not under the
572/// worktree. Silent fallback to an empty subpath would be destructive
573/// in shared-gitdir mode: a misconfigured mem would commit at the
574/// tree root and wipe every sibling's subtree.
575pub(crate) fn mem_subpath(work_tree: &Path, mem_path: &Path) -> Result<String, VcsError> {
576    let canon_mem = std::fs::canonicalize(mem_path).unwrap_or_else(|_| mem_path.to_path_buf());
577    let rel = canon_mem.strip_prefix(work_tree).map_err(|_| {
578        VcsError::Git(format!(
579            "mem path {} is not under worktree {}",
580            mem_path.display(),
581            work_tree.display(),
582        ))
583    })?;
584    Ok(rel
585        .components()
586        .filter_map(|c| c.as_os_str().to_str())
587        .collect::<Vec<_>>()
588        .join("/"))
589}
590
591/// Join a relative on-disk path (forward-slash-normalised) under the
592/// mem's `subpath` within the worktree. Empty subpath → the relative
593/// path stands alone. Empty relative → returns the subpath by itself.
594fn join_subpath(subpath: &str, rel: &str) -> String {
595    if subpath.is_empty() {
596        rel.to_string()
597    } else if rel.is_empty() {
598        subpath.to_string()
599    } else {
600        format!("{subpath}/{rel}")
601    }
602}
603
604/// Walk the mem's subdirectory on disk and upsert every file into the
605/// editor at its worktree-relative location (i.e. prefixed with the
606/// mem's `subpath`). Skips the gix git-dir, the `.memstead/cache/`
607/// regenerable-artefacts directory, and any stray `.git/` entries.
608///
609/// `path` is typically the mem root; a single-file path is supported
610/// for completeness, though no current caller uses that shape.
611///
612/// The walker is scoped to `path` (the mem's on-disk subdirectory) —
613/// not the worktree root. In shared-gitdir mode this is what keeps
614/// sibling mems out of mem A's commit tree: A's commit never walks
615/// B's subdirectory, so no sibling bytes can leak in.
616fn apply_path(
617    repo: &gix::Repository,
618    editor: &mut gix::object::tree::Editor<'_>,
619    work_tree: &Path,
620    path: &Path,
621    subpath: &str,
622) -> Result<(), VcsError> {
623    if path.is_file() {
624        if let Ok(rel) = path.strip_prefix(work_tree) {
625            let rel_str = rel.to_string_lossy();
626            if !is_ignored(rel.components()) {
627                upsert_file(repo, editor, path, &rel_str)?;
628            }
629        }
630        return Ok(());
631    }
632
633    if path.is_dir() {
634        for entry in walkdir::WalkDir::new(path)
635            .follow_links(false)
636            .into_iter()
637            .filter_entry(|e| {
638                if e.file_name() == OsStr::new(".git") {
639                    return false;
640                }
641                // Path components beneath the mem directory must not
642                // re-trip the cache guard; `is_ignored` compares starting
643                // at `.memstead/cache`, so a subpath-relative view is the
644                // right input.
645                match e.path().strip_prefix(path) {
646                    Ok(rel) => !is_ignored(rel.components()),
647                    Err(_) => true,
648                }
649            })
650        {
651            let entry = entry.map_err(|e| VcsError::Git(format!("walk: {e}")))?;
652            if !entry.file_type().is_file() {
653                continue;
654            }
655            let rel_in_mem = match entry.path().strip_prefix(path) {
656                Ok(p) => p
657                    .components()
658                    .filter_map(|c| c.as_os_str().to_str())
659                    .collect::<Vec<_>>()
660                    .join("/"),
661                Err(_) => continue,
662            };
663            let in_tree_path = join_subpath(subpath, &rel_in_mem);
664            upsert_file(repo, editor, entry.path(), &in_tree_path)?;
665        }
666    }
667    Ok(())
668}
669
670/// Returns true for relative paths the commit walker must skip: under
671/// `.memstead/cache/` (regenerable artefacts that must never enter
672/// the tree). `.git/` is handled one level up by the caller's walk
673/// filter (`filter_entry(|e| e.file_name() != ".git")`), not here.
674fn is_ignored(components: std::path::Components<'_>) -> bool {
675    let mut comps = components;
676    let first = comps.next().map(|c| c.as_os_str());
677    if first == Some(OsStr::new(".memstead")) {
678        return matches!(
679            comps.next().map(|c| c.as_os_str()),
680            Some(c) if c == OsStr::new("cache")
681        );
682    }
683    false
684}
685
686fn upsert_file(
687    repo: &gix::Repository,
688    editor: &mut gix::object::tree::Editor<'_>,
689    path: &Path,
690    rel: &str,
691) -> Result<(), VcsError> {
692    let bytes = std::fs::read(path)?;
693    let blob_id = repo.write_blob(&bytes)?.detach();
694    let kind = if is_executable(path) {
695        EntryKind::BlobExecutable
696    } else {
697        EntryKind::Blob
698    };
699    editor
700        .upsert(rel, kind, blob_id)
701        .map_err(|e| VcsError::Git(format!("tree upsert: {e}")))?;
702    Ok(())
703}
704
705#[cfg(unix)]
706fn is_executable(path: &Path) -> bool {
707    use std::os::unix::fs::PermissionsExt;
708    std::fs::metadata(path)
709        .map(|m| m.permissions().mode() & 0o111 != 0)
710        .unwrap_or(false)
711}
712
713#[cfg(not(unix))]
714fn is_executable(_path: &Path) -> bool {
715    false
716}
717
718/// Test-only VCS that records nothing and never errors. Used by engine tests
719/// that exercise mutation paths without touching a real repo. The returned
720/// SHAs are deterministic monotonic sentinels (`noop-0`, `noop-1`, …)
721/// distinguishable from real SHAs by prefix — `changes_since`
722/// relies on that distinction to produce empty deltas for noop mems.
723pub struct NoopVcs {
724    counter: std::sync::atomic::AtomicU64,
725}
726
727impl NoopVcs {
728    pub fn new() -> Self {
729        Self {
730            counter: std::sync::atomic::AtomicU64::new(0),
731        }
732    }
733}
734
735impl Default for NoopVcs {
736    fn default() -> Self {
737        Self::new()
738    }
739}
740
741impl Vcs for NoopVcs {
742    fn commit(
743        &self,
744        _paths: &[&Path],
745        _message: &str,
746        _ctx: &CommitContext<'_>,
747    ) -> Result<String, VcsError> {
748        let n = self
749            .counter
750            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
751        Ok(format!("noop-{n}"))
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use std::fs;
759    use tempfile::TempDir;
760
761    /// Build a fresh `<tmp>/mem` directory with a `.memstead/` subdir and
762    /// the matching `git_dir` path. Mirrors the on-disk layout of a real
763    /// mem.
764    fn make_mem_paths(tmp: &Path) -> (PathBuf, PathBuf) {
765        let mem = tmp.join("mem");
766        let git_dir = mem.join(".git");
767        fs::create_dir_all(mem.join(".memstead")).unwrap();
768        (mem, git_dir)
769    }
770
771    #[test]
772    fn create_vcs_initializes_fresh_dir() {
773        let dir = TempDir::new().unwrap();
774        let (mem, git_dir) = make_mem_paths(dir.path());
775        let vcs = create_vcs(&git_dir, &mem).unwrap();
776        let sha = vcs
777            .commit(&[&mem], "initial", &CommitContext::internal())
778            .unwrap();
779        assert_eq!(sha.len(), 40, "commit sha must be 40-char hex");
780    }
781
782    #[test]
783    fn create_vcs_writes_structural_config_on_first_init() {
784        let dir = TempDir::new().unwrap();
785        let (mem, git_dir) = make_mem_paths(dir.path());
786        let _vcs = create_vcs(&git_dir, &mem).unwrap();
787
788        let config = fs::read_to_string(git_dir.join("config")).unwrap();
789        // Isolated layout: gitdir is a direct child of the worktree, so
790        // no `core.worktree` override is written — gix's default
791        // resolution (gitdir's parent = worktree) applies.
792        assert!(
793            !config.contains("worktree = "),
794            "no core.worktree override for isolated layout, got:\n{config}"
795        );
796        assert!(
797            config.contains("logallrefupdates = true"),
798            "core.logallrefupdates must be set, got:\n{config}"
799        );
800        assert!(
801            config.contains("gpgsign = false"),
802            "commit.gpgsign must be forced false, got:\n{config}"
803        );
804    }
805
806    #[test]
807    fn create_vcs_reapplies_gpgsign_on_reopen() {
808        let dir = TempDir::new().unwrap();
809        let (mem, git_dir) = make_mem_paths(dir.path());
810
811        // First init writes the full config.
812        let _ = create_vcs(&git_dir, &mem).unwrap();
813
814        // Simulate a user editing the per-repo config to enable signing.
815        let original = fs::read_to_string(git_dir.join("config")).unwrap();
816        let tampered = original.replace("gpgsign = false", "gpgsign = true");
817        fs::write(git_dir.join("config"), tampered).unwrap();
818
819        // Re-open must clobber gpgsign back to false but leave the
820        // structural fields alone.
821        let _ = create_vcs(&git_dir, &mem).unwrap();
822        let after = fs::read_to_string(git_dir.join("config")).unwrap();
823        assert!(after.contains("gpgsign = false"), "got:\n{after}");
824        assert!(after.contains("logallrefupdates = true"), "got:\n{after}");
825    }
826
827    #[test]
828    fn commit_writes_file_into_tree() {
829        let dir = TempDir::new().unwrap();
830        let (mem, git_dir) = make_mem_paths(dir.path());
831        fs::write(mem.join("test.md"), "hello").unwrap();
832
833        let vcs = create_vcs(&git_dir, &mem).unwrap();
834        let sha = vcs
835            .commit(&[&mem], "add test.md", &CommitContext::internal())
836            .unwrap();
837        assert_eq!(sha.len(), 40);
838
839        let repo = gix::open(&git_dir).unwrap();
840        let commit = repo.head_commit().unwrap();
841        let tree = commit.tree().unwrap();
842        let entry = tree.find_entry("test.md").expect("test.md in tree");
843        let blob = entry.object().unwrap().try_into_blob().unwrap();
844        assert_eq!(blob.data, b"hello");
845    }
846
847    #[test]
848    fn commit_excludes_cache_subdir() {
849        let dir = TempDir::new().unwrap();
850        let (mem, git_dir) = make_mem_paths(dir.path());
851
852        // Author files
853        fs::write(mem.join("real.md"), "real").unwrap();
854        // Cache files under `.memstead/cache/` must never reach the tree.
855        fs::create_dir_all(mem.join(".memstead/cache/prompts")).unwrap();
856        fs::write(mem.join(".memstead/cache/prompts/p.txt"), "noise").unwrap();
857
858        let vcs = create_vcs(&git_dir, &mem).unwrap();
859        vcs.commit(&[&mem], "initial", &CommitContext::internal())
860            .unwrap();
861
862        let repo = gix::open(&git_dir).unwrap();
863        let tree = repo.head_commit().unwrap().tree().unwrap();
864
865        // Top-level: real.md + the .memstead subtree (config etc., never
866        // the ignored children).
867        assert!(tree.find_entry("real.md").is_some());
868
869        // Drill into the `.memstead` meta dir: it may not contain
870        // `cache/`. `.git/` is skipped one level up by the walk filter,
871        // not by this check.
872        if let Some(memstead_entry) = tree.find_entry(".memstead") {
873            let memstead_tree = memstead_entry.object().unwrap().try_into_tree().unwrap();
874            for entry in memstead_tree.iter() {
875                let entry = entry.unwrap();
876                let name = entry.filename().to_string();
877                assert!(name != "cache", ".memstead subtree must skip {name}");
878            }
879        }
880    }
881
882    #[test]
883    fn commit_second_time_with_deletion_removes_from_tree() {
884        let dir = TempDir::new().unwrap();
885        let (mem, git_dir) = make_mem_paths(dir.path());
886        fs::write(mem.join("keep.md"), "keep").unwrap();
887        fs::write(mem.join("drop.md"), "drop").unwrap();
888
889        let vcs = create_vcs(&git_dir, &mem).unwrap();
890        vcs.commit(&[&mem], "initial", &CommitContext::internal())
891            .unwrap();
892
893        fs::remove_file(mem.join("drop.md")).unwrap();
894        vcs.commit(&[&mem], "drop one", &CommitContext::internal())
895            .unwrap();
896
897        let repo = gix::open(&git_dir).unwrap();
898        let tree = repo.head_commit().unwrap().tree().unwrap();
899        assert!(tree.find_entry("keep.md").is_some());
900        assert!(
901            tree.find_entry("drop.md").is_none(),
902            "deleted file must disappear from the tree on the next commit"
903        );
904    }
905
906    #[test]
907    fn commit_author_is_deterministic() {
908        let dir = TempDir::new().unwrap();
909        let (mem, git_dir) = make_mem_paths(dir.path());
910        fs::write(mem.join("a.md"), "a").unwrap();
911
912        let vcs = create_vcs(&git_dir, &mem).unwrap();
913        vcs.commit(&[&mem], "x", &CommitContext::internal())
914            .unwrap();
915
916        let repo = gix::open(&git_dir).unwrap();
917        let commit = repo.head_commit().unwrap();
918        let author = commit.author().unwrap();
919        assert_eq!(author.name, COMMITTER_NAME);
920        assert_eq!(author.email, COMMITTER_EMAIL);
921    }
922
923    #[test]
924    fn noop_vcs_returns_distinguishable_shas() {
925        let vcs = NoopVcs::new();
926        let s1 = vcs.commit(&[], "x", &CommitContext::internal()).unwrap();
927        let s2 = vcs.commit(&[], "y", &CommitContext::internal()).unwrap();
928        assert!(s1.starts_with("noop-"));
929        assert!(s2.starts_with("noop-"));
930        assert_ne!(s1, s2);
931    }
932
933    // ----- Commit provenance -----
934
935    fn head_commit_parts(git_dir: &Path) -> (String, String, String) {
936        let repo = gix::open(git_dir).unwrap();
937        let commit = repo.head_commit().unwrap();
938        let author = commit.author().unwrap();
939        let message = commit.message_raw().unwrap().to_string();
940        (author.name.to_string(), author.email.to_string(), message)
941    }
942
943    #[test]
944    fn commit_with_agent_context_sets_author_and_trailers() {
945        let dir = TempDir::new().unwrap();
946        let (mem, git_dir) = make_mem_paths(dir.path());
947        fs::write(mem.join("a.md"), "a").unwrap();
948
949        let vcs = create_vcs(&git_dir, &mem).unwrap();
950        let ctx = CommitContext {
951            actor: Actor::Agent,
952            client: Some(ClientId {
953                name: "claude-code".into(),
954                version: "2.1.0".into(),
955            }),
956            tool: Some("memstead_update"),
957            note: None,
958            role: Default::default(),
959            logical_operation_id: None,
960            entity_ids: None,
961        };
962        vcs.commit(&[&mem], "memstead: update specs--a", &ctx)
963            .unwrap();
964
965        let (name, email, message) = head_commit_parts(&git_dir);
966        assert_eq!(name, "claude-code");
967        assert_eq!(email, "claude-code@memstead.io");
968        assert!(
969            message.ends_with("\n\nTool: memstead_update\nActor: agent\nClient: claude-code@2.1.0"),
970            "got message: {message:?}"
971        );
972    }
973
974    #[test]
975    fn commit_with_external_context_sets_external_author_and_actor_trailer() {
976        let dir = TempDir::new().unwrap();
977        let (mem, git_dir) = make_mem_paths(dir.path());
978        fs::write(mem.join("a.md"), "a").unwrap();
979
980        let vcs = create_vcs(&git_dir, &mem).unwrap();
981        let ctx = CommitContext {
982            actor: Actor::External,
983            client: None,
984            tool: None,
985            note: None,
986            role: Default::default(),
987            logical_operation_id: None,
988            entity_ids: None,
989        };
990        vcs.commit(&[&mem], "external edits (1 files)", &ctx)
991            .unwrap();
992
993        let (name, email, message) = head_commit_parts(&git_dir);
994        assert_eq!(name, "external");
995        assert_eq!(email, "external@memstead.io");
996        assert!(message.contains("\n\nActor: external"));
997        assert!(!message.contains("Tool:"));
998        assert!(!message.contains("Client:"));
999    }
1000
1001    #[test]
1002    fn commit_with_cli_context_emits_trailers_and_author() {
1003        let dir = TempDir::new().unwrap();
1004        let (mem, git_dir) = make_mem_paths(dir.path());
1005        fs::write(mem.join("a.md"), "a").unwrap();
1006
1007        let vcs = create_vcs(&git_dir, &mem).unwrap();
1008
1009        // Cli without a ClientId falls back to the committer identity.
1010        let ctx_no_client = CommitContext {
1011            actor: Actor::Cli,
1012            client: None,
1013            tool: None,
1014            note: None,
1015            role: Default::default(),
1016            logical_operation_id: None,
1017            entity_ids: None,
1018        };
1019        vcs.commit(&[&mem], "memstead: create specs--a", &ctx_no_client)
1020            .unwrap();
1021        let (name, email, message) = head_commit_parts(&git_dir);
1022        assert_eq!(name, COMMITTER_NAME);
1023        assert_eq!(email, COMMITTER_EMAIL);
1024        assert!(message.contains("\n\nActor: cli"));
1025        assert!(!message.contains("Client:"));
1026
1027        // Cli with a ClientId yields the derived author + Client trailer.
1028        fs::write(mem.join("b.md"), "b").unwrap();
1029        let ctx_with_client = CommitContext {
1030            actor: Actor::Cli,
1031            client: Some(ClientId {
1032                name: "memstead-cli".into(),
1033                version: "0.1.0".into(),
1034            }),
1035            tool: None,
1036            note: None,
1037            role: Default::default(),
1038            logical_operation_id: None,
1039            entity_ids: None,
1040        };
1041        vcs.commit(&[&mem], "memstead: create specs--b", &ctx_with_client)
1042            .unwrap();
1043        let (name, email, message) = head_commit_parts(&git_dir);
1044        assert_eq!(name, "memstead-cli");
1045        assert_eq!(email, "memstead-cli@memstead.io");
1046        assert!(message.contains("\n\nActor: cli\nClient: memstead-cli@0.1.0"));
1047    }
1048
1049    #[test]
1050    fn sanitise_client_name_collapses_disallowed_chars() {
1051        let out = sanitise_client_name("Claude Code/2.1 @ macOS");
1052        assert_eq!(out, "claude-code-2.1---macos");
1053        // Must be a valid git-safe local-part.
1054        assert!(
1055            out.chars()
1056                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
1057            "{out}"
1058        );
1059    }
1060
1061    #[test]
1062    fn sanitise_client_name_empty_falls_back_to_unknown() {
1063        assert_eq!(sanitise_client_name(""), "unknown");
1064        assert_eq!(sanitise_client_name("   "), "unknown");
1065        assert_eq!(sanitise_client_name("@@@"), "unknown");
1066    }
1067
1068    #[test]
1069    fn prose_and_trailers_separated_by_exactly_one_blank_line() {
1070        // Caller passes prose that already ends with a trailing newline —
1071        // the engine still produces exactly `\n\n` between prose and
1072        // trailers, never `\n\n\n`.
1073        let ctx = CommitContext {
1074            actor: Actor::Agent,
1075            client: None,
1076            tool: Some("memstead_create"),
1077            note: None,
1078            role: Default::default(),
1079            logical_operation_id: None,
1080            entity_ids: None,
1081        };
1082        let msg = format_commit_message("subject\n", &ctx);
1083        assert_eq!(msg, "subject\n\nTool: memstead_create\nActor: agent");
1084
1085        // No trailing newline in prose: same boundary.
1086        let msg = format_commit_message("subject", &ctx);
1087        assert_eq!(msg, "subject\n\nTool: memstead_create\nActor: agent");
1088    }
1089
1090    #[test]
1091    fn trailers_are_git_interpret_trailers_compatible() {
1092        // We don't shell out to `git interpret-trailers` (not a build
1093        // dependency); instead we check the invariants the tool relies
1094        // on: blank line before the trailer block, each trailer line is
1095        // `Key: Value` with no embedded blanks, and the block is the
1096        // last paragraph.
1097        let ctx = CommitContext {
1098            actor: Actor::Agent,
1099            client: Some(ClientId {
1100                name: "claude-code".into(),
1101                version: "2.1.0".into(),
1102            }),
1103            tool: Some("memstead_update"),
1104            note: None,
1105            role: Default::default(),
1106            logical_operation_id: None,
1107            entity_ids: None,
1108        };
1109        let msg = format_commit_message("memstead: update specs--a", &ctx);
1110        // Find the last paragraph — everything after the final `\n\n`.
1111        let (_prose, trailer_block) = msg.rsplit_once("\n\n").expect("blank line before trailers");
1112        for line in trailer_block.lines() {
1113            let (key, value) = line
1114                .split_once(": ")
1115                .unwrap_or_else(|| panic!("malformed trailer line: {line:?}"));
1116            assert!(!key.is_empty());
1117            assert!(!value.is_empty());
1118            // Keys are the three we emit, in the documented order.
1119            assert!(matches!(key, "Tool" | "Actor" | "Client"), "{key}");
1120        }
1121        assert_eq!(
1122            trailer_block,
1123            "Tool: memstead_update\nActor: agent\nClient: claude-code@2.1.0"
1124        );
1125    }
1126
1127    #[test]
1128    fn internal_context_preserves_deterministic_author() {
1129        // The existing `commit_author_is_deterministic` test proves this
1130        // via the public API; this unit-level sibling pins the behaviour
1131        // to `CommitContext::internal()` specifically so a refactor of
1132        // the default constructor is caught immediately.
1133        let ctx = CommitContext::internal();
1134        assert!(matches!(ctx.actor, Actor::Unknown));
1135        assert!(ctx.client.is_none());
1136        assert!(ctx.tool.is_none());
1137        assert!(ctx.note.is_none());
1138        // Author falls back to committer (no derived identity).
1139        assert!(author_identity(&ctx).is_none());
1140    }
1141
1142    #[test]
1143    fn commit_message_with_note_inserts_body_between_prose_and_trailers() {
1144        // Agent note lands between the caller's prose and the trailer
1145        // block, separated by exactly one blank line on each side.
1146        let ctx = CommitContext {
1147            actor: Actor::Agent,
1148            client: Some(ClientId {
1149                name: "claude-code".into(),
1150                version: "2.1.0".into(),
1151            }),
1152            tool: Some("memstead_update"),
1153            note: Some("documenting the foo invariant".into()),
1154            role: Default::default(),
1155            logical_operation_id: None,
1156            entity_ids: None,
1157        };
1158        let msg = format_commit_message("memstead: update specs--a", &ctx);
1159        assert_eq!(
1160            msg,
1161            "memstead: update specs--a\n\n\
1162             documenting the foo invariant\n\n\
1163             Tool: memstead_update\nActor: agent\nClient: claude-code@2.1.0"
1164        );
1165    }
1166
1167    #[test]
1168    fn commit_message_with_blank_note_behaves_like_absent() {
1169        // Whitespace-only notes collapse to `None` semantics — the wire
1170        // never surfaces an empty paragraph between prose and trailers.
1171        let ctx = CommitContext {
1172            actor: Actor::Agent,
1173            client: None,
1174            tool: Some("memstead_update"),
1175            note: Some("   \n  \t ".into()),
1176            role: Default::default(),
1177            logical_operation_id: None,
1178            entity_ids: None,
1179        };
1180        let msg = format_commit_message("subject", &ctx);
1181        assert_eq!(msg, "subject\n\nTool: memstead_update\nActor: agent");
1182    }
1183
1184    #[test]
1185    fn commit_message_with_empty_note_string_behaves_like_absent() {
1186        // Explicit `Some("")` is still a no-op — the same branch the
1187        // MCP handler takes when a caller passes a zero-length note.
1188        let ctx = CommitContext {
1189            actor: Actor::Agent,
1190            client: None,
1191            tool: Some("memstead_create"),
1192            note: Some(String::new()),
1193            role: Default::default(),
1194            logical_operation_id: None,
1195            entity_ids: None,
1196        };
1197        let msg = format_commit_message("subject", &ctx);
1198        assert_eq!(msg, "subject\n\nTool: memstead_create\nActor: agent");
1199    }
1200
1201    // ----------------------------------------------------------------
1202    // Per-branch mutex
1203    // ----------------------------------------------------------------
1204
1205    /// Build a unique ref-name suffix per test invocation so the
1206    /// process-wide mutex registry never collides across parallel
1207    /// tests. Uses a static atomic counter — sufficient for in-test
1208    /// uniqueness without bringing in `uuid`.
1209    fn unique_ref(prefix: &str) -> String {
1210        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1211        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1212        format!("refs/heads/{prefix}-{n}")
1213    }
1214
1215    #[test]
1216    fn per_branch_mutex_serialises_same_ref() {
1217        let r = unique_ref("serialises");
1218        let arc = acquire_branch_mutex(&r);
1219        let guard = arc.lock().unwrap();
1220
1221        // Spawn a second thread that tries to acquire the same key;
1222        // it must block until we drop our guard.
1223        let r_clone = r.clone();
1224        let started = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1225        let acquired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1226        let started_t = started.clone();
1227        let acquired_t = acquired.clone();
1228        let handle = std::thread::spawn(move || {
1229            started_t.store(true, std::sync::atomic::Ordering::SeqCst);
1230            let arc2 = acquire_branch_mutex(&r_clone);
1231            let _g2 = arc2.lock().unwrap();
1232            acquired_t.store(true, std::sync::atomic::Ordering::SeqCst);
1233        });
1234
1235        // Wait for the spawned thread to start and try to acquire.
1236        // 50ms is well above thread-spawn latency on macOS / Linux.
1237        std::thread::sleep(std::time::Duration::from_millis(50));
1238        assert!(
1239            started.load(std::sync::atomic::Ordering::SeqCst),
1240            "spawned thread did not start within 50ms"
1241        );
1242        assert!(
1243            !acquired.load(std::sync::atomic::Ordering::SeqCst),
1244            "spawned thread acquired the mutex while main held it"
1245        );
1246
1247        // Release: spawned thread must now make progress.
1248        drop(guard);
1249        handle.join().unwrap();
1250        assert!(
1251            acquired.load(std::sync::atomic::Ordering::SeqCst),
1252            "spawned thread did not acquire after drop"
1253        );
1254    }
1255
1256    #[test]
1257    fn per_branch_mutex_parallelises_different_refs() {
1258        let a = unique_ref("parallel-a");
1259        let b = unique_ref("parallel-b");
1260
1261        let arc_a = acquire_branch_mutex(&a);
1262        let guard_a = arc_a.lock().unwrap();
1263
1264        // Different ref must acquire without blocking on `a`.
1265        let b_clone = b.clone();
1266        let acquired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1267        let acquired_t = acquired.clone();
1268        let handle = std::thread::spawn(move || {
1269            let arc_b = acquire_branch_mutex(&b_clone);
1270            let _g = arc_b.lock().unwrap();
1271            acquired_t.store(true, std::sync::atomic::Ordering::SeqCst);
1272        });
1273        handle.join().unwrap();
1274        assert!(
1275            acquired.load(std::sync::atomic::Ordering::SeqCst),
1276            "different-ref acquisition was blocked by another ref's mutex"
1277        );
1278        drop(guard_a);
1279    }
1280
1281    #[test]
1282    fn cross_mem_acquires_in_lex_order() {
1283        // Pass refs in non-lex order; the helper must sort and
1284        // acquire in lex order. We verify by reading back the held
1285        // keys via the debug-only thread-local while the guards are
1286        // alive.
1287        let a = unique_ref("cross-aaa");
1288        let b = unique_ref("cross-bbb");
1289        let c = unique_ref("cross-ccc");
1290        // Pre-compute the expected sort order so we can compare.
1291        let mut expected = vec![a.as_str(), b.as_str(), c.as_str()];
1292        expected.sort_unstable();
1293        let _guards = acquire_branch_mutexes_in_order(&[c.as_str(), a.as_str(), b.as_str()]);
1294        #[cfg(debug_assertions)]
1295        HELD_BRANCH_KEYS.with(|held| {
1296            let held = held.borrow();
1297            // The thread-local stack stores *every* key currently
1298            // held; this test owns three, but the test runner may
1299            // also be holding others from the same thread. We only
1300            // assert that our three appear in lex order in the
1301            // suffix.
1302            let tail: Vec<&str> = held.iter().rev().take(3).map(String::as_str).collect();
1303            // `tail` is in reverse-push order; reverse to get push order.
1304            let mut pushed: Vec<&str> = tail.into_iter().rev().collect();
1305            pushed.sort();
1306            assert_eq!(pushed, expected, "lex-order acquisition violated");
1307        });
1308    }
1309
1310    #[cfg(debug_assertions)]
1311    #[test]
1312    #[should_panic(expected = "out-of-order branch-mutex acquisition")]
1313    fn out_of_order_acquisition_panics_in_debug() {
1314        // Acquire a "high" key first, then attempt to acquire a
1315        // "low" key on the same thread. Must panic. We use unique
1316        // names so this test cannot collide with other tests'
1317        // bookkeeping; the keys are kept inside this thread.
1318        let high = unique_ref("zzz-high");
1319        let low = unique_ref("aaa-low");
1320        let arc_high = acquire_branch_mutex(&high);
1321        let _g_high = arc_high.lock().unwrap();
1322        // The bookkeeping push happens inside acquire_branch_mutexes_in_order;
1323        // here we drive the assert manually by injecting `high` into the
1324        // thread-local stack and then asking for a lex-smaller key.
1325        HELD_BRANCH_KEYS.with(|held| {
1326            held.borrow_mut().push(high.clone());
1327        });
1328        let _arc_low = acquire_branch_mutex(&low); // must panic
1329    }
1330}