Skip to main content

memstead_git_branch/storage/
git_tree.rs

1//! Git-tree-backed [`MemWriter`](super::MemWriter) — the second
2//! storage adapter. Buffers mutations
3//! in memory and applies them to a tree built via
4//! `gix::object::tree::Editor`, then advances the target ref via
5//! [`gix::Repository::commit_as`].
6//!
7//! No working tree is written: the mem's content lives only in the
8//! multi-root `mem-repo-git` object store, one branch per mem. Each
9//! commit rebuilds the tree from the buffered op log against the
10//! snapshotted parent tree.
11//!
12//! ## Snapshot + CAS
13//!
14//! On the first mutation of a "session" (the period between two
15//! successful commits, or between construction and the first commit),
16//! the writer snapshots the current ref tip's [`gix::ObjectId`]. That
17//! snapshot is the `parents` argument to
18//! [`gix::Repository::commit_as`]; gix's underlying ref-edit transaction
19//! enforces `PreviousValue::ExistingMustMatch(previous)` for non-`HEAD`
20//! refs, which is the exact CAS guard we want. If a concurrent writer
21//! advanced the ref between snapshot and commit, the gix call returns
22//! [`gix::commit::Error::ReferenceEdit`]; we re-resolve the live tip and
23//! surface [`super::MemWriterError::HashMismatch`] with the new tip's
24//! hex OID. That maps into
25//! [`crate::EngineError::HashMismatch`] so MCP agents see a stable
26//! `_hash` to retry with.
27//!
28//! No internal retry loop: every CAS conflict bubbles up. Cross-process
29//! contention in Phase 1 is intentionally simple — concurrency hardening
30//! comes later (D7 in the design doc).
31
32use std::collections::HashMap;
33use std::path::{Path, PathBuf};
34use std::sync::Mutex;
35
36use gix::objs::tree::EntryKind;
37
38use super::{CommitId, MemWriter, MemWriterError};
39use crate::vcs::{CommitContext, acquire_branch_mutex, author_identity, format_commit_message};
40
41/// Per-path final state for the buffered op log. Move operations
42/// resolve at call time into a `Delete(from)` + `Upsert(to, bytes)`
43/// pair so commit-time replay only ever sees these two terminal states.
44enum PendingState {
45    Upsert(Vec<u8>),
46    Delete,
47}
48
49/// In-flight mutation buffer. Snapshotted parent SHA + the per-path
50/// final-state map. Both reset to `(None, empty)` after a successful
51/// `commit()`.
52struct Pending {
53    /// Parent ref tip captured on the first mutation of this session.
54    /// `None` either when the ref does not yet exist (commit creates
55    /// it) or before the first mutation. The same `parent` value is
56    /// passed verbatim to `commit_as`'s `parents` argument; gix uses
57    /// it as the CAS guard.
58    parent: Option<gix::ObjectId>,
59    /// Per-path final state. Values are stored mem-relative as
60    /// forward-slash strings — git tree entries are slash-separated
61    /// regardless of host OS, and the editor APIs take string keys.
62    ops: HashMap<String, PendingState>,
63}
64
65impl Pending {
66    fn new() -> Self {
67        Self {
68            parent: None,
69            ops: HashMap::new(),
70        }
71    }
72
73    fn clear(&mut self) {
74        self.parent = None;
75        self.ops.clear();
76    }
77}
78
79/// Git-tree-backed implementation of [`MemWriter`]. Holds the
80/// gitdir path and target ref name; opens the [`gix::Repository`]
81/// per call (matches the [`crate::vcs::GixVcs`] pattern, since
82/// `gix::Repository` is `Send` but not `Sync` — its object-database
83/// cache uses interior mutability via `RefCell`).
84///
85/// Mutations buffer in memory until [`Self::commit`].
86pub struct GitTreeMemWriter {
87    gitdir: PathBuf,
88    ref_name: String,
89    pending: Mutex<Pending>,
90}
91
92impl GitTreeMemWriter {
93    /// Build a writer against the repository at `gitdir` targeting
94    /// `ref_name`. The ref need not exist yet — the first commit
95    /// creates it. `ref_name` is the per-branch mutex key; pass the
96    /// fully-qualified form (e.g. `refs/heads/main`) so writers
97    /// targeting the same branch under one gitdir share the same key.
98    pub fn new(gitdir: PathBuf, ref_name: String) -> Self {
99        Self {
100            gitdir,
101            ref_name,
102            pending: Mutex::new(Pending::new()),
103        }
104    }
105
106    fn open_repo(&self) -> Result<gix::Repository, MemWriterError> {
107        gix::open(&self.gitdir).map_err(|e| {
108            MemWriterError::Path(format!(
109                "git-tree writer: open repo at {}: {e}",
110                self.gitdir.display()
111            ))
112        })
113    }
114
115    /// Capture the current tip of `ref_name` if no snapshot has been
116    /// taken in this session. Idempotent: subsequent mutations reuse
117    /// the same snapshot. A missing ref leaves `parent = None`.
118    fn ensure_snapshot(&self, pending: &mut Pending) -> Result<(), MemWriterError> {
119        if pending.parent.is_some() || !pending.ops.is_empty() {
120            return Ok(());
121        }
122        let repo = self.open_repo()?;
123        let mut reference = match repo.try_find_reference(&self.ref_name).map_err(|e| {
124            MemWriterError::Path(format!(
125                "git-tree writer: resolve ref {}: {e}",
126                self.ref_name
127            ))
128        })? {
129            Some(r) => r,
130            None => return Ok(()),
131        };
132        let id = reference.peel_to_id().map_err(|e| {
133            MemWriterError::Path(format!(
134                "git-tree writer: peel ref {} to id: {e}",
135                self.ref_name
136            ))
137        })?;
138        pending.parent = Some(id.detach());
139        Ok(())
140    }
141
142    /// Peel the live `ref_name` tip to its commit id, or `None` when the
143    /// ref does not exist yet. Unlike [`Self::ensure_snapshot`] this
144    /// does *not* pin anything onto `pending` — it is the fresh-read
145    /// path used between write transactions, so a sibling engine's
146    /// commit is visible on the next read rather than frozen at the
147    /// snapshot captured by the first read of the session.
148    fn live_tip(&self) -> Result<Option<gix::ObjectId>, MemWriterError> {
149        let repo = self.open_repo()?;
150        let mut reference = match repo.try_find_reference(&self.ref_name).map_err(|e| {
151            MemWriterError::Path(format!(
152                "git-tree writer: resolve ref {}: {e}",
153                self.ref_name
154            ))
155        })? {
156            Some(r) => r,
157            None => return Ok(None),
158        };
159        let id = reference.peel_to_id().map_err(|e| {
160            MemWriterError::Path(format!(
161                "git-tree writer: peel ref {} to id: {e}",
162                self.ref_name
163            ))
164        })?;
165        Ok(Some(id.detach()))
166    }
167
168    /// Read a blob at `path` from the snapshotted parent tree. Used by
169    /// `move_entity` to fetch the source bytes when the path has no
170    /// pending upsert.
171    fn read_blob_from_parent(
172        &self,
173        parent: gix::ObjectId,
174        path: &str,
175    ) -> Result<Option<Vec<u8>>, MemWriterError> {
176        let repo = self.open_repo()?;
177        let commit = repo
178            .find_object(parent)
179            .map_err(|e| MemWriterError::Path(format!("git-tree writer: open parent commit: {e}")))?
180            .into_commit();
181        let tree = commit.tree().map_err(|e| {
182            MemWriterError::Path(format!("git-tree writer: peel commit to tree: {e}"))
183        })?;
184        let entry = match tree.lookup_entry_by_path(path).map_err(|e| {
185            MemWriterError::Path(format!(
186                "git-tree writer: lookup {path} in parent tree: {e}"
187            ))
188        })? {
189            Some(e) => e,
190            None => return Ok(None),
191        };
192        if !entry.mode().is_blob() {
193            return Ok(None);
194        }
195        let object = repo
196            .find_object(entry.id())
197            .map_err(|e| MemWriterError::Path(format!("git-tree writer: read blob {path}: {e}")))?;
198        Ok(Some(object.data.clone()))
199    }
200
201    /// Tree-lookup-class existence probe: ref → commit → tree →
202    /// `lookup_entry_by_path`, stopping at the entry — the blob's
203    /// bytes are never read (flywheel W7/02: the write-time cross-mem
204    /// target check's primitive; the listing walk reads every blob and
205    /// is the wrong tool for one path's existence).
206    fn path_exists_from_parent(
207        &self,
208        parent: gix::ObjectId,
209        path: &str,
210    ) -> Result<bool, MemWriterError> {
211        let repo = self.open_repo()?;
212        let commit = repo
213            .find_object(parent)
214            .map_err(|e| MemWriterError::Path(format!("git-tree writer: open parent commit: {e}")))?
215            .into_commit();
216        let tree = commit.tree().map_err(|e| {
217            MemWriterError::Path(format!("git-tree writer: peel commit to tree: {e}"))
218        })?;
219        let entry = tree.lookup_entry_by_path(path).map_err(|e| {
220            MemWriterError::Path(format!(
221                "git-tree writer: lookup {path} in parent tree: {e}"
222            ))
223        })?;
224        Ok(entry.is_some_and(|e| e.mode().is_blob()))
225    }
226}
227
228/// Normalise a mem-relative path to forward-slash form. Rejects
229/// empty paths and any path that contains `..` segments — git tree
230/// entries cannot escape upward and this guards the caller against
231/// accidentally writing past the mem root via a relative-path bug.
232fn normalise_rel_path(rel_path: &Path) -> Result<String, MemWriterError> {
233    if rel_path.as_os_str().is_empty() {
234        return Err(MemWriterError::Path(
235            "mem-relative path is empty".to_string(),
236        ));
237    }
238    let mut parts: Vec<String> = Vec::new();
239    for component in rel_path.components() {
240        use std::path::Component;
241        match component {
242            Component::Normal(s) => match s.to_str() {
243                Some(p) if !p.is_empty() => parts.push(p.to_string()),
244                _ => {
245                    return Err(MemWriterError::Path(format!(
246                        "non-utf-8 or empty path component in {}",
247                        rel_path.display()
248                    )));
249                }
250            },
251            Component::CurDir => continue,
252            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
253                return Err(MemWriterError::Path(format!(
254                    "path traversal or absolute component in {}",
255                    rel_path.display()
256                )));
257            }
258        }
259    }
260    if parts.is_empty() {
261        return Err(MemWriterError::Path(
262            "mem-relative path is empty after normalisation".to_string(),
263        ));
264    }
265    Ok(parts.join("/"))
266}
267
268impl MemWriter for GitTreeMemWriter {
269    fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), MemWriterError> {
270        let key = normalise_rel_path(rel_path)?;
271        let mut pending = self.pending.lock().map_err(|_| {
272            MemWriterError::Path("git-tree writer pending state poisoned".to_string())
273        })?;
274        self.ensure_snapshot(&mut pending)?;
275        pending
276            .ops
277            .insert(key, PendingState::Upsert(content.to_vec()));
278        Ok(())
279    }
280
281    fn delete_entity(&self, rel_path: &Path) -> Result<(), MemWriterError> {
282        let key = normalise_rel_path(rel_path)?;
283        let mut pending = self.pending.lock().map_err(|_| {
284            MemWriterError::Path("git-tree writer pending state poisoned".to_string())
285        })?;
286        self.ensure_snapshot(&mut pending)?;
287        pending.ops.insert(key, PendingState::Delete);
288        Ok(())
289    }
290
291    fn move_entity(&self, from: &Path, to: &Path) -> Result<(), MemWriterError> {
292        let from_key = normalise_rel_path(from)?;
293        let to_key = normalise_rel_path(to)?;
294        let mut pending = self.pending.lock().map_err(|_| {
295            MemWriterError::Path("git-tree writer pending state poisoned".to_string())
296        })?;
297        self.ensure_snapshot(&mut pending)?;
298
299        // Resolve the from-content. If a pending upsert exists, take
300        // its bytes; otherwise look up the blob in the snapshotted
301        // parent tree. Absent from both: nothing to move.
302        let bytes = match pending.ops.remove(&from_key) {
303            Some(PendingState::Upsert(b)) => b,
304            Some(PendingState::Delete) => {
305                pending.ops.insert(from_key, PendingState::Delete);
306                return Err(MemWriterError::Path(format!(
307                    "move source {} is already pending deletion",
308                    from.display()
309                )));
310            }
311            None => {
312                let parent = pending.parent;
313                let blob = match parent {
314                    Some(p) => self.read_blob_from_parent(p, &from_key)?,
315                    None => None,
316                };
317                match blob {
318                    Some(b) => b,
319                    None => {
320                        return Err(MemWriterError::Path(format!(
321                            "move source {} does not exist",
322                            from.display()
323                        )));
324                    }
325                }
326            }
327        };
328
329        if matches!(pending.ops.get(&to_key), Some(PendingState::Upsert(_))) {
330            // A move refuses when the target already has a pending write.
331            return Err(MemWriterError::Path(format!(
332                "move target {} already has a pending write",
333                to.display()
334            )));
335        }
336        pending.ops.insert(from_key, PendingState::Delete);
337        pending.ops.insert(to_key, PendingState::Upsert(bytes));
338        Ok(())
339    }
340
341    fn commit(&self, message: &str, ctx: &CommitContext<'_>) -> Result<CommitId, MemWriterError> {
342        // Serialise commits against the same target ref at process
343        // scope. Different refs under the same gitdir proceed in
344        // parallel — that is the whole point of the per-branch key.
345        let mutex = acquire_branch_mutex(&self.ref_name);
346        let _guard = mutex.lock().map_err(|_| {
347            MemWriterError::Path(format!(
348                "git-tree writer mutex poisoned for ref {} (gitdir {})",
349                self.ref_name,
350                self.gitdir.display()
351            ))
352        })?;
353        let repo = self.open_repo()?;
354
355        let mut pending = self.pending.lock().map_err(|_| {
356            MemWriterError::Path("git-tree writer pending state poisoned".to_string())
357        })?;
358
359        // Make sure we have a parent snapshot even if the caller went
360        // straight to commit() without any mutations — exercises the
361        // `no-op commit` edge case sensibly.
362        self.ensure_snapshot(&mut pending)?;
363        let parent_snapshot = pending.parent;
364
365        // Build the editor on top of the snapshotted tree.
366        let mut editor = match parent_snapshot {
367            Some(parent_id) => {
368                let commit = repo
369                    .find_object(parent_id)
370                    .map_err(|e| {
371                        MemWriterError::Path(format!(
372                            "git-tree writer: open parent {parent_id}: {e}"
373                        ))
374                    })?
375                    .into_commit();
376                let tree = commit.tree().map_err(|e| {
377                    MemWriterError::Path(format!("git-tree writer: peel parent tree: {e}"))
378                })?;
379                tree.edit().map_err(|e| {
380                    MemWriterError::Path(format!("git-tree writer: editor init: {e}"))
381                })?
382            }
383            None => repo.empty_tree().edit().map_err(|e| {
384                MemWriterError::Path(format!("git-tree writer: empty editor init: {e}"))
385            })?,
386        };
387
388        // Replay ops. Order is irrelevant since map keys are unique
389        // and final-state semantics already collapsed any duplicates.
390        for (path, state) in pending.ops.iter() {
391            match state {
392                PendingState::Upsert(bytes) => {
393                    let blob_id = repo
394                        .write_blob(bytes.as_slice())
395                        .map_err(|e| {
396                            MemWriterError::Path(format!(
397                                "git-tree writer: write blob for {path}: {e}"
398                            ))
399                        })?
400                        .detach();
401                    editor
402                        .upsert(path.as_str(), EntryKind::Blob, blob_id)
403                        .map_err(|e| {
404                            MemWriterError::Path(format!(
405                                "git-tree writer: tree upsert {path}: {e}"
406                            ))
407                        })?;
408                }
409                PendingState::Delete => {
410                    editor.remove(path.as_str()).map_err(|e| {
411                        MemWriterError::Path(format!("git-tree writer: tree remove {path}: {e}"))
412                    })?;
413                }
414            }
415        }
416
417        let tree_id = editor
418            .write()
419            .map_err(|e| MemWriterError::Path(format!("git-tree writer: tree write: {e}")))?
420            .detach();
421
422        // Build signatures via the same convention the disk adapter
423        // uses (see vcs::format_commit_message + author_identity).
424        let time = gix::date::Time::now_local_or_utc();
425        let committer_sig = gix::actor::Signature {
426            name: COMMITTER_NAME.into(),
427            email: COMMITTER_EMAIL.into(),
428            time,
429        };
430        let author_sig = match author_identity(ctx) {
431            Some((name, email)) => gix::actor::Signature {
432                name: name.into(),
433                email: email.into(),
434                time,
435            },
436            None => committer_sig.clone(),
437        };
438        let mut author_buf = gix::date::parse::TimeBuf::default();
439        let mut committer_buf = gix::date::parse::TimeBuf::default();
440        let author_ref = author_sig.to_ref(&mut author_buf);
441        let committer_ref = committer_sig.to_ref(&mut committer_buf);
442
443        let full_message = format_commit_message(message, ctx);
444
445        let parents: Vec<gix::ObjectId> = match parent_snapshot {
446            Some(p) => vec![p],
447            None => Vec::new(),
448        };
449        let commit_result = repo.commit_as(
450            committer_ref,
451            author_ref,
452            self.ref_name.as_str(),
453            full_message,
454            tree_id,
455            parents,
456        );
457        // The staged ops were fully replayed into `tree_id` above, so
458        // `pending` is no longer needed regardless of the commit outcome.
459        // Clear it here so EVERY exit ends the transaction — success, CAS
460        // conflict, or any other commit failure. Leaving it populated on a
461        // failed commit is a coherence bug: `read_entity` prefers pending
462        // over the committed tip, so an orphaned op would be served as
463        // phantom truth (and pulled into the in-memory store by a later
464        // `reload_one_mem`) until the process restarts.
465        pending.clear();
466        let commit_id = match commit_result {
467            Ok(id) => id,
468            Err(gix::commit::Error::ReferenceEdit(_)) => {
469                // CAS conflict. Re-resolve the live tip and surface
470                // the new sha so the caller can retry with a fresh
471                // `_hash`.
472                let mut reference = repo
473                    .try_find_reference(&self.ref_name)
474                    .map_err(|e| {
475                        MemWriterError::Path(format!(
476                            "git-tree writer: re-resolve ref after CAS: {e}"
477                        ))
478                    })?
479                    .ok_or_else(|| {
480                        MemWriterError::Path(format!(
481                            "git-tree writer: ref {} vanished during CAS recovery",
482                            self.ref_name
483                        ))
484                    })?;
485                let live_id = reference.peel_to_id().map_err(|e| {
486                    MemWriterError::Path(format!("git-tree writer: peel live tip after CAS: {e}"))
487                })?;
488                return Err(MemWriterError::HashMismatch {
489                    current: live_id.to_hex().to_string(),
490                });
491            }
492            Err(e) => {
493                return Err(MemWriterError::Path(format!(
494                    "git-tree writer: commit_as failed: {e}"
495                )));
496            }
497        };
498
499        let sha_hex = commit_id.to_hex().to_string();
500
501        // Refresh index + working tree if the just-written ref is what
502        // HEAD currently points at. Keeps `git status` clean for human
503        // visualizers (GitHub Desktop and friends) which would
504        // otherwise misread the engine's tree-editor commits as a
505        // pending "delete" diff. No-op for bare repos and for writes
506        // to a ref that is not the checked-out branch.
507        sync_index_and_worktree(&repo, &self.ref_name)?;
508
509        Ok(sha_hex)
510    }
511}
512
513impl memstead_base::backend::MemBackend for GitTreeMemWriter {
514    /// The per-mem branch ref exists. `list_entities` folds a missing
515    /// branch into an empty list (a fresh mem has no commits yet), so
516    /// this is the only way boot can tell "never created" from "empty"
517    /// and say `missing_ref` instead of `empty`.
518    fn storage_present(&self) -> Result<bool, memstead_base::backend::BackendError> {
519        let repo = gix::open(&self.gitdir).map_err(|e| {
520            memstead_base::backend::BackendError::Other(format!(
521                "git-tree backend storage_present: open {}: {e}",
522                self.gitdir.display()
523            ))
524        })?;
525        repo.try_find_reference(&self.ref_name)
526            .map(|r| r.is_some())
527            .map_err(|e| {
528                memstead_base::backend::BackendError::Other(format!(
529                    "git-tree backend storage_present: resolve {}: {e}",
530                    self.ref_name
531                ))
532            })
533    }
534
535    fn list_entities(&self) -> Result<Vec<PathBuf>, memstead_base::backend::BackendError> {
536        // Walk the per-mem branch tree, return only `.md` paths
537        // outside the `.memstead/` umbrella (config / schemas / changelog
538        // live there and don't surface as entities at this layer).
539        // Branch-missing → empty list (a fresh mem has no commits yet).
540        let blobs = match read_branch_blobs(&self.gitdir, &self.ref_name) {
541            Ok(b) => b,
542            Err(BranchReadError::BranchMissing { .. }) => return Ok(Vec::new()),
543            Err(e) => {
544                return Err(memstead_base::backend::BackendError::Other(format!(
545                    "git-tree backend list_entities: {e}"
546                )));
547            }
548        };
549        Ok(blobs
550            .into_iter()
551            .filter_map(|b| {
552                if b.path.ends_with(".md") && !b.path.starts_with(".memstead/") {
553                    Some(PathBuf::from(b.path))
554                } else {
555                    None
556                }
557            })
558            .collect())
559    }
560
561    fn read_entity(
562        &self,
563        rel_path: &Path,
564    ) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
565        let key = normalise_rel_path(rel_path)?;
566        // Pending ops win over the branch tip — same precedence as the
567        // folder backend.
568        let pending = self.pending.lock().map_err(|_| {
569            memstead_base::backend::BackendError::Other(
570                "git-tree backend pending state poisoned".to_string(),
571            )
572        })?;
573        if let Some(state) = pending.ops.get(&key) {
574            return Ok(match state {
575                PendingState::Upsert(b) => Some(b.clone()),
576                PendingState::Delete => None,
577            });
578        }
579        // Mid-transaction (one or more writes already staged): reads
580        // must see the same snapshotted parent the buffered ops will be
581        // composed onto, for a consistent commit. Between transactions
582        // (no pending ops — boot loads, `reload_one_mem` re-reads, any
583        // read before the first write of an op), read the *live* ref tip
584        // so a sibling engine's commit is visible. The previous code
585        // pinned the parent on the first read of the session and froze
586        // every later read at that snapshot, which defeated
587        // reload-before-operation for entities that already existed at
588        // boot (a sibling's modification came back stale).
589        let snapshot_parent = if pending.ops.is_empty() {
590            None
591        } else {
592            pending.parent
593        };
594        drop(pending);
595
596        let source = match snapshot_parent {
597            Some(p) => Some(p),
598            None => self
599                .live_tip()
600                .map_err(memstead_base::backend::BackendError::from)?,
601        };
602        match source {
603            Some(p) => self
604                .read_blob_from_parent(p, &key)
605                .map_err(memstead_base::backend::BackendError::from),
606            None => Ok(None),
607        }
608    }
609
610    /// Existence probe with `read_entity`'s exact source-selection
611    /// semantics (pending buffer first, snapshotted parent
612    /// mid-transaction, live tip between transactions) — but stopping
613    /// at the tree entry, never reading the blob.
614    fn entity_exists(&self, rel_path: &Path) -> Result<bool, memstead_base::backend::BackendError> {
615        let key = normalise_rel_path(rel_path)?;
616        let pending = self.pending.lock().map_err(|_| {
617            memstead_base::backend::BackendError::Other(
618                "git-tree backend pending state poisoned".to_string(),
619            )
620        })?;
621        if let Some(state) = pending.ops.get(&key) {
622            return Ok(matches!(state, PendingState::Upsert(_)));
623        }
624        let snapshot_parent = if pending.ops.is_empty() {
625            None
626        } else {
627            pending.parent
628        };
629        drop(pending);
630
631        let source = match snapshot_parent {
632            Some(p) => Some(p),
633            None => self
634                .live_tip()
635                .map_err(memstead_base::backend::BackendError::from)?,
636        };
637        match source {
638            Some(p) => self
639                .path_exists_from_parent(p, &key)
640                .map_err(memstead_base::backend::BackendError::from),
641            None => Ok(false),
642        }
643    }
644
645    fn write_entity(
646        &self,
647        rel_path: &Path,
648        content: &[u8],
649    ) -> Result<(), memstead_base::backend::BackendError> {
650        <Self as MemWriter>::write_entity(self, rel_path, content).map_err(Into::into)
651    }
652
653    fn delete_entity(&self, rel_path: &Path) -> Result<(), memstead_base::backend::BackendError> {
654        <Self as MemWriter>::delete_entity(self, rel_path).map_err(Into::into)
655    }
656
657    fn move_entity(
658        &self,
659        from: &Path,
660        to: &Path,
661    ) -> Result<(), memstead_base::backend::BackendError> {
662        <Self as MemWriter>::move_entity(self, from, to).map_err(Into::into)
663    }
664
665    fn discard_pending(&self) -> Result<(), memstead_base::backend::BackendError> {
666        // Drop the staged tree edits and the captured parent snapshot
667        // without committing — symmetric with the `pending.clear()`
668        // that `commit` runs on success. The atomic batch path calls
669        // this to roll back staged writes when a later item refuses
670        // the whole batch.
671        let mut pending = self.pending.lock().map_err(|_| {
672            memstead_base::backend::BackendError::Other(
673                "git-tree writer pending state poisoned".to_string(),
674            )
675        })?;
676        pending.clear();
677        Ok(())
678    }
679
680    fn commit(
681        &self,
682        message: &str,
683        ctx: &CommitContext<'_>,
684    ) -> Result<CommitId, memstead_base::backend::BackendError> {
685        <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
686    }
687
688    fn commit_with_expected_parent(
689        &self,
690        message: &str,
691        ctx: &CommitContext<'_>,
692        expected_parent: Option<&str>,
693    ) -> Result<CommitId, memstead_base::backend::BackendError> {
694        // No pin requested → identical to commit().
695        let Some(expected) = expected_parent else {
696            return <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into);
697        };
698
699        // Acquire the same per-ref mutex `commit` uses so the parent
700        // check and the subsequent commit are sequenced w.r.t. other
701        // in-process writers on this ref. The mutex must be released
702        // before delegating to `commit` (std `Mutex` is not reentrant);
703        // any in-process writer that slips in between the drop and
704        // `commit`'s re-acquire would advance the ref past the
705        // already-captured `pending.parent`, and gix's CAS inside
706        // `commit_as` would surface that as `HashMismatch` — semantically
707        // equivalent to `ParentMismatch` for the engine layer above.
708        let mutex = acquire_branch_mutex(&self.ref_name);
709        let guard = mutex.lock().map_err(|_| {
710            memstead_base::backend::BackendError::Other(format!(
711                "git-tree writer mutex poisoned for ref {} (gitdir {})",
712                self.ref_name,
713                self.gitdir.display()
714            ))
715        })?;
716
717        let actual = match gix::open(&self.gitdir) {
718            Ok(repo) => match repo.try_find_reference(&self.ref_name) {
719                Ok(Some(mut r)) => r
720                    .peel_to_id()
721                    .ok()
722                    .map(|id| id.detach().to_hex().to_string()),
723                Ok(None) => None,
724                Err(e) => {
725                    return Err(memstead_base::backend::BackendError::Other(format!(
726                        "git-tree writer: resolve ref {} for parent check: {e}",
727                        self.ref_name
728                    )));
729                }
730            },
731            Err(e) => {
732                return Err(memstead_base::backend::BackendError::Other(format!(
733                    "git-tree writer: open repo at {} for parent check: {e}",
734                    self.gitdir.display()
735                )));
736            }
737        };
738        let actual_str = actual.unwrap_or_default();
739        if actual_str != expected {
740            return Err(memstead_base::backend::BackendError::ParentMismatch {
741                expected: expected.to_string(),
742                actual: actual_str,
743            });
744        }
745
746        drop(guard);
747        <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
748    }
749
750    fn append_provenance(
751        &self,
752        _record: &memstead_base::Provenance,
753    ) -> Result<(), memstead_base::backend::BackendError> {
754        // No-op. The git-branch backend encodes provenance directly in
755        // the commit object: subject (`memstead: <verb> <entity>`) carries
756        // the kind + entity, the trailer block carries actor / client /
757        // tool, and the body paragraph carries the agent note. The next
758        // `commit()` call writes all of it via `format_commit_message`.
759        // `read_provenance` reconstructs `Provenance` records by walking
760        // commits and re-parsing the bodies — symmetric round-trip
761        // without a side-channel log. Folder backend writes a separate
762        // JSONL line because it has no commit object to carry the data.
763        Ok(())
764    }
765
766    fn read_provenance(
767        &self,
768        cursor: Option<&str>,
769    ) -> Result<Vec<memstead_base::Provenance>, memstead_base::backend::BackendError> {
770        let since = cursor.unwrap_or(crate::ops::changes::EMPTY_TREE_SHA);
771        let report = match crate::ops::agent_notes::agent_notes_since(
772            "",
773            &self.gitdir,
774            since,
775            Some(&self.ref_name),
776        ) {
777            Ok(r) => r,
778            Err(e) => {
779                return Err(memstead_base::backend::BackendError::Other(format!(
780                    "git-tree backend read_provenance: {e}"
781                )));
782            }
783        };
784        // `agent_notes_since` returns newest-first (`git log` default).
785        // The folder backend's `read_provenance` returns oldest-first
786        // (insertion order in the JSONL). Reverse here so consumers
787        // observe a single ordering convention regardless of backend.
788        let mut out: Vec<memstead_base::Provenance> = report
789            .notes
790            .into_iter()
791            .map(commit_note_to_provenance)
792            .collect();
793        out.reverse();
794        Ok(out)
795    }
796
797    fn current_head(&self) -> Result<Option<String>, memstead_base::backend::BackendError> {
798        // Open the gitdir and peel the per-mem branch ref to its
799        // commit object id. Missing ref / missing repo / peel failure
800        // collapse to `Ok(None)` — the engine treats them as "no
801        // drift signal", same as folder/archive. Surfaced log lines
802        // give operators a breadcrumb when a branch genuinely
803        // disappears between probes.
804        let repo = match gix::open(&self.gitdir) {
805            Ok(r) => r,
806            Err(e) => {
807                tracing::debug!(
808                    gitdir = %self.gitdir.display(),
809                    error = %e,
810                    "current_head: open gitdir failed; treating as no baseline"
811                );
812                return Ok(None);
813            }
814        };
815        let mut reference = match repo.try_find_reference(&self.ref_name) {
816            Ok(Some(r)) => r,
817            Ok(None) => return Ok(None),
818            Err(e) => {
819                tracing::debug!(
820                    ref_name = %self.ref_name,
821                    error = %e,
822                    "current_head: ref lookup failed; treating as no baseline"
823                );
824                return Ok(None);
825            }
826        };
827        Ok(reference
828            .peel_to_id()
829            .ok()
830            .map(|id| id.detach().to_hex().to_string()))
831    }
832
833    fn read_mem_config(&self) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
834        // Resolve the mem leaf from `self.ref_name`. V1 unified
835        // mounts are flat (`refs/heads/<leaf>`); hierarchical
836        // layouts are not yet supported on the unified path.
837        let leaf = self
838            .ref_name
839            .strip_prefix("refs/heads/")
840            .unwrap_or(&self.ref_name);
841
842        // `__MEMSTEAD:mems/<leaf>/config.json` is the only read path.
843        // Every workspace the engine touches has `__MEMSTEAD` populated
844        // by boot — the legacy registry-class refs are no longer
845        // read at runtime.
846        read_blob_from_ref(
847            &self.gitdir,
848            "refs/heads/__MEMSTEAD",
849            &format!("mems/{leaf}/config.json"),
850        )
851    }
852
853    fn read_anchors_sidecar(
854        &self,
855    ) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
856        // Read via the MemBackend entity path so pending-buffer
857        // precedence applies (a staged sidecar write is visible before
858        // its commit) and a sibling engine's committed sidecar is seen on
859        // a between-transaction read — identical semantics to entity reads.
860        <Self as memstead_base::backend::MemBackend>::read_entity(
861            self,
862            Path::new(memstead_base::anchor::ANCHOR_SIDECAR_PATH),
863        )
864    }
865
866    fn write_anchors_sidecar(
867        &self,
868        bytes: &[u8],
869    ) -> Result<(), memstead_base::backend::BackendError> {
870        // Stage into the same pending op buffer entity writes use, under
871        // the `.memstead/anchors.json` path, so the next commit() carries
872        // entity + sidecar atomically. `list_entities` filters `.memstead/`,
873        // so the sidecar never surfaces as an entity.
874        <Self as MemWriter>::write_entity(
875            self,
876            Path::new(memstead_base::anchor::ANCHOR_SIDECAR_PATH),
877            bytes,
878        )
879        .map_err(Into::into)
880    }
881
882    fn delete_artifacts(&self) -> Result<(), memstead_base::backend::BackendError> {
883        // The branch leaf is the per-mem ref minus the
884        // `refs/heads/` prefix — symmetric with the resolution done
885        // by `read_mem_config` / `write_mem_config` above.
886        // Hierarchical layouts (e.g. `refs/heads/planning/plan-q4`)
887        // strip to `planning/plan-q4`; flat layouts to the bare leaf.
888        let branch_leaf = self
889            .ref_name
890            .strip_prefix("refs/heads/")
891            .unwrap_or(&self.ref_name);
892        let ctx = CommitContext {
893            actor: memstead_base::vcs::Actor::Agent,
894            client: None,
895            tool: Some("memstead_mem_delete"),
896            note: None,
897            role: Default::default(),
898            logical_operation_id: None,
899            entity_ids: None,
900        };
901        crate::storage_memstead::delete_mem_artifacts_at_gitdir(&self.gitdir, branch_leaf, &ctx)
902            .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
903    }
904
905    fn write_mem_config(&self, bytes: &[u8]) -> Result<(), memstead_base::backend::BackendError> {
906        self.write_mem_config_with_note(bytes, None)
907    }
908
909    fn write_mem_config_with_note(
910        &self,
911        bytes: &[u8],
912        note: Option<&str>,
913    ) -> Result<(), memstead_base::backend::BackendError> {
914        // Write `__MEMSTEAD:mems/<mem>/config.json` only. The legacy
915        // `mem_repo_config::read_config` consumer chain reads
916        // through `__MEMSTEAD`, so a dual-write to any retired ref would
917        // be wasted work.
918        //
919        // Mem leaf comes from `self.ref_name` (the per-mem
920        // branch); for hierarchical mounts (refs/heads/<path>/<leaf>)
921        // the helper's `resolve_full_path_at_gitdir` walks the
922        // branch list to find the matching full path. For a fresh
923        // mem not yet present in the branch list, the helper
924        // falls back to the flat `<leaf>/config.json` shape —
925        // unified `create_mem` writes the per-mem branch commit
926        // AFTER this call, so during the very first
927        // write_mem_config the branch isn't yet present.
928        // Hierarchical-path semantics for fresh mems need a
929        // small lift in a follow-up (pass full path explicitly).
930        //
931        // `note` rides the commit body so a version bump (or any
932        // config write that supplies one) carries the same provenance
933        // reason the other commit-producing lifecycle operations do.
934        let leaf = self
935            .ref_name
936            .strip_prefix("refs/heads/")
937            .unwrap_or(&self.ref_name);
938        let ctx = CommitContext {
939            actor: memstead_base::vcs::Actor::Agent,
940            client: None,
941            tool: Some("memstead_mem_config_write"),
942            note: note.map(str::to_string),
943            role: Default::default(),
944            logical_operation_id: None,
945            entity_ids: None,
946        };
947        crate::storage_memstead::commit_config_to_memstead_at_gitdir(
948            &self.gitdir,
949            leaf,
950            bytes,
951            &ctx,
952            &format!("memstead: commit __MEMSTEAD:mems/{leaf}/config.json"),
953        )
954        .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
955    }
956
957    fn record_pipeline_edit(
958        &self,
959        kind: &str,
960        edits: &[(String, Option<Vec<u8>>)],
961        note: Option<&str>,
962        verb: &str,
963    ) -> Result<(), memstead_base::backend::BackendError> {
964        // Mirror the pipeline-config edit under
965        // `__MEMSTEAD:pipeline/<kind>/<leaf>/<name>.json` — the commit
966        // (subject + Note trailer) is the provenance record for a disk
967        // write that has no commit of its own.
968        let leaf = self
969            .ref_name
970            .strip_prefix("refs/heads/")
971            .unwrap_or(&self.ref_name);
972        let tree_edits: Vec<(String, Option<Vec<u8>>)> = edits
973            .iter()
974            .map(|(name, bytes)| (format!("pipeline/{kind}/{leaf}/{name}.json"), bytes.clone()))
975            .collect();
976        let ctx = CommitContext {
977            actor: memstead_base::vcs::Actor::Agent,
978            client: None,
979            tool: Some("memstead_pipeline_edit"),
980            note: note.map(str::to_string),
981            role: Default::default(),
982            logical_operation_id: None,
983            entity_ids: None,
984        };
985        let names: Vec<&str> = edits.iter().map(|(n, _)| n.as_str()).collect();
986        crate::storage_memstead::commit_paths_to_memstead_at_gitdir(
987            &self.gitdir,
988            &tree_edits,
989            &ctx,
990            &format!("memstead: {verb} {kind} {leaf}/{}", names.join(", ")),
991        )
992        .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
993    }
994}
995
996/// Read a blob from `ref_name:path` in the gitdir. Returns
997/// `Ok(None)` when the ref is missing or the path doesn't exist
998/// in the tree. Errors propagate as `BackendError::Other`.
999///
1000/// Used by `read_mem_config` to read per-mem configs from
1001/// `__MEMSTEAD` without needing a full full `MemConfig` parser path —
1002/// the engine parses bytes uniformly across backends.
1003fn read_blob_from_ref(
1004    gitdir: &Path,
1005    ref_name: &str,
1006    path: &str,
1007) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
1008    let repo = match gix::open(gitdir) {
1009        Ok(r) => r,
1010        Err(_) => return Ok(None),
1011    };
1012    let reference = match repo.try_find_reference(ref_name) {
1013        Ok(Some(r)) => r,
1014        Ok(None) => return Ok(None),
1015        Err(e) => {
1016            return Err(memstead_base::backend::BackendError::Other(format!(
1017                "find ref {ref_name}: {e}"
1018            )));
1019        }
1020    };
1021    let id = reference.into_fully_peeled_id().map_err(|e| {
1022        memstead_base::backend::BackendError::Other(format!("peel {ref_name}: {e}"))
1023    })?;
1024    let object = id.object().map_err(|e| {
1025        memstead_base::backend::BackendError::Other(format!("read obj {ref_name}: {e}"))
1026    })?;
1027    let commit = match object.try_into_commit() {
1028        Ok(c) => c,
1029        Err(_) => return Ok(None),
1030    };
1031    let tree = commit.tree().map_err(|e| {
1032        memstead_base::backend::BackendError::Other(format!("read tree {ref_name}: {e}"))
1033    })?;
1034    let entry = match tree.lookup_entry_by_path(path) {
1035        Ok(Some(e)) => e,
1036        Ok(None) => return Ok(None),
1037        Err(e) => {
1038            return Err(memstead_base::backend::BackendError::Other(format!(
1039                "lookup {ref_name}:{path}: {e}"
1040            )));
1041        }
1042    };
1043    let blob = entry.object().map_err(|e| {
1044        memstead_base::backend::BackendError::Other(format!("read blob {ref_name}:{path}: {e}"))
1045    })?;
1046    Ok(Some(blob.data.clone()))
1047}
1048
1049/// Build a [`memstead_base::Provenance`] from a parsed commit note. Best-
1050/// effort: unrecognised verbs map to `Update`, missing actors to
1051/// `Unknown`, malformed client trailers drop the field. Matches the
1052/// folder backend's tolerant-reader stance.
1053fn commit_note_to_provenance(n: crate::ops::agent_notes::CommitNote) -> memstead_base::Provenance {
1054    let kind = n
1055        .tool_verb
1056        .as_deref()
1057        .and_then(memstead_base::ProvenanceKind::parse)
1058        .unwrap_or(memstead_base::ProvenanceKind::Update);
1059    let actor = n
1060        .actor
1061        .as_deref()
1062        .and_then(memstead_base::vcs::Actor::from_trailer)
1063        .unwrap_or(memstead_base::vcs::Actor::Unknown);
1064    let client = n
1065        .client
1066        .as_deref()
1067        .and_then(memstead_base::vcs::parse_client_id);
1068    let timestamp = if n.timestamp >= 0 {
1069        std::time::UNIX_EPOCH + std::time::Duration::from_secs(n.timestamp as u64)
1070    } else {
1071        std::time::UNIX_EPOCH
1072    };
1073    let mut record =
1074        memstead_base::Provenance::new(timestamp, kind, n.entity_id, actor, client, n.note);
1075    if let Some(id) = n.logical_operation_id {
1076        record = record.with_logical_operation_id(id);
1077    }
1078    if let Some(role) = n
1079        .role
1080        .as_deref()
1081        .and_then(memstead_base::vcs::Role::from_wire)
1082    {
1083        record = record.with_role(role);
1084    }
1085    record
1086}
1087
1088/// Refresh the working tree and index from `HEAD` when the just-
1089/// written `ref_name` matches the symbolic ref `HEAD` resolves to.
1090///
1091/// Engine writes go through `gix::Repository::commit_as`, which
1092/// advances the target ref in the object store but never touches the
1093/// index or the working tree. On a non-bare repo (the shape humans
1094/// open in GitHub Desktop) that drift surfaces as a spurious "deleted"
1095/// diff against every file the engine just wrote — and clicking
1096/// "commit" on that diff silently undoes the engine's work. Running
1097/// `git read-tree --reset -u HEAD` after each on-checked-out-branch
1098/// commit closes the drift. The `--reset -u` combination updates
1099/// tracked-file state to match HEAD and removes tracked files HEAD
1100/// no longer knows about; truly-untracked files in the working tree
1101/// are left alone.
1102///
1103/// Short-circuits as `Ok(())` when:
1104/// - the repo is bare (no working tree to sync);
1105/// - HEAD is detached, absent, or unreadable (no symbolic ref to
1106///   compare — a corrupted `.git/HEAD` is its own problem and should
1107///   not conflate with a write failure when the commit landed
1108///   durably);
1109/// - HEAD's full ref name does not match `ref_name` (we wrote to a
1110///   branch other than the checked-out one — syncing would clobber
1111///   the user's checked-out tree with content from a different
1112///   branch).
1113///
1114/// Spawn failure or non-zero exit maps to
1115/// [`MemWriterError::Io`] with the workdir and the captured stderr
1116/// in the message, plus an actionable hint pointing the caller at
1117/// `git -C <workdir> reset --hard HEAD` for manual recovery (the
1118/// commit itself already landed successfully — a sync failure leaves
1119/// the object store correct but the working tree stale).
1120///
1121/// Cross-process coordination is out of scope: when two engine
1122/// processes write the same ref concurrently, the worktree converges
1123/// to whoever's `read-tree` ran last; intermediate readers may see a
1124/// mix. Single-process engines today; the open seam is documented in
1125/// `mem-repo-write-cutover`'s "Open seams" section.
1126fn sync_index_and_worktree(repo: &gix::Repository, ref_name: &str) -> Result<(), MemWriterError> {
1127    let Some(workdir) = repo.workdir() else {
1128        return Ok(());
1129    };
1130    // `BStr: PartialEq<str>` is byte-exact, which matches the
1131    // engine's `refs/heads/<branch>` naming convention — non-ASCII
1132    // drift here is a real bug worth catching, not something to
1133    // normalise away. A `head_name()` failure (corrupted `.git/HEAD`,
1134    // permissions error) short-circuits the sync rather than
1135    // surfacing as a write failure: the commit already landed
1136    // durably, and a malformed HEAD will be diagnosed via the next
1137    // `git status` the operator runs.
1138    let head_matches = matches!(repo.head_name(), Ok(Some(name)) if name.as_bstr() == ref_name);
1139    if !head_matches {
1140        return Ok(());
1141    }
1142
1143    let output = std::process::Command::new("git")
1144        .arg("-C")
1145        .arg(workdir)
1146        .args(["read-tree", "--reset", "-u", "HEAD"])
1147        // Never prompt: a misconfigured `core.askpass` or
1148        // `credential.helper` could otherwise stall the sync forever
1149        // on a misrouted code path.
1150        .env("GIT_TERMINAL_PROMPT", "0")
1151        .stdin(std::process::Stdio::null())
1152        .output()
1153        .map_err(|e| {
1154            std::io::Error::other(format!(
1155                "worktree sync: spawn `git -C {} read-tree --reset -u HEAD`: {e}; \
1156                 commit already landed in the object store, recover with \
1157                 `git -C {} reset --hard HEAD`",
1158                workdir.display(),
1159                workdir.display()
1160            ))
1161        })?;
1162    if !output.status.success() {
1163        let stderr = String::from_utf8_lossy(&output.stderr);
1164        return Err(MemWriterError::Io(std::io::Error::other(format!(
1165            "worktree sync: `git -C {} read-tree --reset -u HEAD` failed (status {}): {}; \
1166             commit already landed in the object store, recover with \
1167             `git -C {} reset --hard HEAD` (or remove a stale \
1168             `<workdir>/.git/index.lock` if one exists)",
1169            workdir.display(),
1170            output.status,
1171            stderr.trim(),
1172            workdir.display()
1173        ))));
1174    }
1175    Ok(())
1176}
1177
1178/// Deterministic committer identity — must stay byte-for-byte aligned
1179/// with [`crate::vcs::COMMITTER_NAME`] / `COMMITTER_EMAIL`. Re-declared
1180/// here as private constants to keep the `vcs` module's public surface
1181/// minimal; the alignment is locked by the
1182/// `git_tree_writer_blob_oid_matches_disk_oid` test below, which only
1183/// passes when both adapters produce byte-identical commit objects.
1184const COMMITTER_NAME: &str = "engine";
1185const COMMITTER_EMAIL: &str = "noreply@memstead.io";
1186
1187/// One blob entry returned by [`read_branch_blobs`] — the
1188/// mem-relative forward-slash path and the blob bytes. The list is
1189/// sorted by `path` so callers (e.g. archive emitters) get
1190/// deterministic ordering without a re-sort.
1191#[derive(Debug, Clone)]
1192pub struct BranchBlob {
1193    pub path: String,
1194    pub bytes: Vec<u8>,
1195}
1196
1197/// Errors surfaced by [`read_branch_blobs`]. Wraps the gix repo /
1198/// reference / object operations so callers can map a missing branch
1199/// or a malformed object to a structured error without re-importing
1200/// gix's error types.
1201#[derive(Debug, thiserror::Error)]
1202pub enum BranchReadError {
1203    #[error("git-tree reader: open repo at {path}: {source}")]
1204    Open {
1205        path: String,
1206        #[source]
1207        source: gix::open::Error,
1208    },
1209    #[error("git-tree reader: branch {ref_name} not found")]
1210    BranchMissing { ref_name: String },
1211    #[error("git-tree reader: resolve branch {ref_name}: {message}")]
1212    Resolve { ref_name: String, message: String },
1213    #[error("git-tree reader: read object: {message}")]
1214    Read { message: String },
1215}
1216
1217/// Walk every blob in the tree pointed at by `<gitdir>:<ref_name>`'s
1218/// commit and return their (path, bytes) pairs sorted by path. The
1219/// branch tip's commit is peeled to a tree, then the tree is recursed
1220/// breadth-first. Subtrees are descended; symlinks and non-blob entries
1221/// are skipped (mem content is regular files only).
1222///
1223/// `ref_name` is the fully-qualified ref form, e.g.
1224/// `refs/heads/<mem>` for mem-content reads or `refs/heads/main`
1225/// for schema/config reads against the `mem-repo-git` repo.
1226///
1227/// A missing ref returns [`BranchReadError::BranchMissing`] so callers
1228/// can distinguish "branch never created" from "branch exists but is
1229/// empty" — the latter returns `Ok(vec![])`.
1230pub fn read_branch_blobs(
1231    gitdir: &Path,
1232    ref_name: &str,
1233) -> Result<Vec<BranchBlob>, BranchReadError> {
1234    let repo = gix::open(gitdir).map_err(|e| BranchReadError::Open {
1235        path: gitdir.display().to_string(),
1236        source: e,
1237    })?;
1238    let mut reference =
1239        match repo
1240            .try_find_reference(ref_name)
1241            .map_err(|e| BranchReadError::Resolve {
1242                ref_name: ref_name.to_string(),
1243                message: e.to_string(),
1244            })? {
1245            Some(r) => r,
1246            None => {
1247                return Err(BranchReadError::BranchMissing {
1248                    ref_name: ref_name.to_string(),
1249                });
1250            }
1251        };
1252    let id = reference
1253        .peel_to_id()
1254        .map_err(|e| BranchReadError::Resolve {
1255            ref_name: ref_name.to_string(),
1256            message: e.to_string(),
1257        })?;
1258    let commit = repo
1259        .find_object(id)
1260        .map_err(|e| BranchReadError::Read {
1261            message: format!("open commit {id}: {e}"),
1262        })?
1263        .into_commit();
1264    let tree = commit.tree().map_err(|e| BranchReadError::Read {
1265        message: format!("peel commit to tree: {e}"),
1266    })?;
1267
1268    let mut out: Vec<BranchBlob> = Vec::new();
1269    walk_tree(&repo, &tree, "", &mut out)?;
1270    out.sort_by(|a, b| a.path.cmp(&b.path));
1271    Ok(out)
1272}
1273
1274fn walk_tree(
1275    repo: &gix::Repository,
1276    tree: &gix::Tree<'_>,
1277    prefix: &str,
1278    out: &mut Vec<BranchBlob>,
1279) -> Result<(), BranchReadError> {
1280    use gix::objs::tree::EntryKind;
1281    let iter = tree.iter();
1282    for entry_res in iter {
1283        let entry = entry_res.map_err(|e| BranchReadError::Read {
1284            message: format!("decode tree entry: {e}"),
1285        })?;
1286        let name = entry.filename().to_string();
1287        let full = if prefix.is_empty() {
1288            name.clone()
1289        } else {
1290            format!("{prefix}/{name}")
1291        };
1292        match entry.mode().kind() {
1293            EntryKind::Blob | EntryKind::BlobExecutable => {
1294                let object = repo
1295                    .find_object(entry.oid())
1296                    .map_err(|e| BranchReadError::Read {
1297                        message: format!("read blob {full}: {e}"),
1298                    })?;
1299                out.push(BranchBlob {
1300                    path: full,
1301                    bytes: object.data.clone(),
1302                });
1303            }
1304            EntryKind::Tree => {
1305                let subtree = repo
1306                    .find_object(entry.oid())
1307                    .map_err(|e| BranchReadError::Read {
1308                        message: format!("read subtree {full}: {e}"),
1309                    })?
1310                    .into_tree();
1311                walk_tree(repo, &subtree, &full, out)?;
1312            }
1313            // Symlinks and commits (submodules) — mem content is
1314            // regular files only; ignore.
1315            EntryKind::Link | EntryKind::Commit => {}
1316        }
1317    }
1318    Ok(())
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323    use super::*;
1324    use crate::vcs::{Actor, ClientId, CommitContext};
1325    use std::path::Path;
1326    use tempfile::TempDir;
1327
1328    /// Build a fresh bare repo at `<tmp>/mem-repo.git` and return the
1329    /// canonical gitdir path. Tests open the repo per-call via
1330    /// `gix::open` so the writer's `gitdir + ref_name` shape is what
1331    /// gets exercised.
1332    fn fresh_repo_dir(tmp: &Path) -> PathBuf {
1333        let git_dir = tmp.join("mem-repo.git");
1334        gix::init_bare(&git_dir).unwrap();
1335        std::fs::canonicalize(&git_dir).unwrap()
1336    }
1337
1338    fn ctx_for_test<'a>() -> CommitContext<'a> {
1339        CommitContext {
1340            actor: Actor::Cli,
1341            client: Some(ClientId {
1342                name: "claude-code".to_string(),
1343                version: "0.1.0".to_string(),
1344            }),
1345            tool: Some("test"),
1346            note: None,
1347            role: Default::default(),
1348            logical_operation_id: None,
1349            entity_ids: None,
1350        }
1351    }
1352
1353    /// `storage_present` answers for the ref, not the entity count: a
1354    /// branch that was never created is absent (and `list_entities`
1355    /// still says empty, which is the ambiguity boot's `MOUNT_UNBACKED`
1356    /// probe exists to resolve); after the first commit it is present.
1357    #[test]
1358    fn storage_present_tracks_the_branch_ref() {
1359        use memstead_base::backend::MemBackend;
1360        let tmp = TempDir::new().unwrap();
1361        let gitdir = fresh_repo_dir(tmp.path());
1362        let writer = GitTreeMemWriter::new(gitdir, "refs/heads/probe".to_string());
1363        assert!(!MemBackend::storage_present(&writer).unwrap(), "no ref yet");
1364        assert!(
1365            MemBackend::list_entities(&writer).unwrap().is_empty(),
1366            "and it lists as empty"
1367        );
1368        MemWriter::write_entity(&writer, Path::new("one.md"), b"# one\n").unwrap();
1369        MemWriter::commit(&writer, "seed", &ctx_for_test()).unwrap();
1370        assert!(
1371            MemBackend::storage_present(&writer).unwrap(),
1372            "the first commit creates the ref"
1373        );
1374        assert_eq!(MemBackend::list_entities(&writer).unwrap().len(), 1);
1375    }
1376
1377    fn read_blob(gitdir: &Path, ref_name: &str, path: &str) -> Option<Vec<u8>> {
1378        let repo = gix::open(gitdir).unwrap();
1379        let mut reference = repo.try_find_reference(ref_name).unwrap()?;
1380        let id = reference.peel_to_id().unwrap();
1381        let commit = repo.find_object(id).unwrap().into_commit();
1382        let tree = commit.tree().unwrap();
1383        let entry = tree.lookup_entry_by_path(path).unwrap()?;
1384        let object = repo.find_object(entry.id()).unwrap();
1385        Some(object.data.clone())
1386    }
1387
1388    fn tree_path_exists(gitdir: &Path, ref_name: &str, path: &str) -> bool {
1389        read_blob(gitdir, ref_name, path).is_some()
1390    }
1391
1392    /// `entity_exists` mirrors `read_entity`'s source selection —
1393    /// pending upsert true / pending delete false before commit, live
1394    /// tip between transactions, unborn ref false — while stopping at
1395    /// the tree entry (flywheel W7/02 primitive).
1396    #[test]
1397    fn entity_exists_tree_probe_and_pending_precedence() {
1398        use memstead_base::backend::MemBackend;
1399        let tmp = TempDir::new().unwrap();
1400        let gitdir = fresh_repo_dir(tmp.path());
1401        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1402
1403        assert!(
1404            !MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
1405            "unborn ref answers false"
1406        );
1407
1408        MemBackend::write_entity(
1409            &writer,
1410            Path::new("notes/a.md"),
1411            b"# a
1412",
1413        )
1414        .unwrap();
1415        assert!(
1416            MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
1417            "staged upsert answers true before commit"
1418        );
1419
1420        MemWriter::commit(&writer, "land a", &ctx_for_test()).unwrap();
1421        assert!(MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
1422        assert!(!MemBackend::entity_exists(&writer, Path::new("notes/b.md")).unwrap());
1423
1424        MemBackend::delete_entity(&writer, Path::new("notes/a.md")).unwrap();
1425        assert!(
1426            !MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
1427            "staged delete answers false while the branch tip still holds the blob"
1428        );
1429    }
1430
1431    #[test]
1432    fn git_tree_writer_round_trip() {
1433        let tmp = TempDir::new().unwrap();
1434        let gitdir = fresh_repo_dir(tmp.path());
1435        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1436
1437        writer
1438            .write_entity(Path::new("notes/hello.md"), b"# hi\n")
1439            .unwrap();
1440        let sha = writer.commit("first commit", &ctx_for_test()).unwrap();
1441        assert_eq!(sha.len(), 40);
1442
1443        let bytes = read_blob(&gitdir, "refs/heads/test", "notes/hello.md").unwrap();
1444        assert_eq!(bytes, b"# hi\n");
1445    }
1446
1447    #[test]
1448    fn git_tree_writer_anchors_sidecar_rides_commit_and_survives_reload() {
1449        use memstead_base::backend::MemBackend;
1450        let tmp = TempDir::new().unwrap();
1451        let gitdir = fresh_repo_dir(tmp.path());
1452        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1453
1454        // Stage an entity write and the anchors sidecar, then commit
1455        // once — both land in the same commit.
1456        MemBackend::write_entity(&writer, Path::new("hello.md"), b"# hi\n").unwrap();
1457        writer
1458            .write_anchors_sidecar(b"{\"version\":1,\"entities\":{}}")
1459            .unwrap();
1460        MemBackend::commit(&writer, "entity+anchors", &ctx_for_test()).unwrap();
1461
1462        // Sidecar blob is present in the branch tree at the reserved path.
1463        let sidecar = read_blob(&gitdir, "refs/heads/test", ".memstead/anchors.json").unwrap();
1464        assert_eq!(sidecar, b"{\"version\":1,\"entities\":{}}");
1465
1466        // A fresh writer (engine reload) reads it back.
1467        let reloaded = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1468        assert_eq!(
1469            reloaded.read_anchors_sidecar().unwrap(),
1470            Some(b"{\"version\":1,\"entities\":{}}".to_vec())
1471        );
1472        // And it never surfaces as an entity.
1473        assert_eq!(
1474            MemBackend::list_entities(&reloaded).unwrap(),
1475            vec![PathBuf::from("hello.md")]
1476        );
1477    }
1478
1479    #[test]
1480    fn git_tree_writer_delete_removes_path() {
1481        let tmp = TempDir::new().unwrap();
1482        let gitdir = fresh_repo_dir(tmp.path());
1483        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1484
1485        writer.write_entity(Path::new("a.md"), b"a").unwrap();
1486        writer.write_entity(Path::new("b.md"), b"b").unwrap();
1487        writer.commit("seed", &ctx_for_test()).unwrap();
1488
1489        writer.delete_entity(Path::new("a.md")).unwrap();
1490        writer.commit("drop a", &ctx_for_test()).unwrap();
1491
1492        assert!(!tree_path_exists(&gitdir, "refs/heads/test", "a.md"));
1493        assert!(tree_path_exists(&gitdir, "refs/heads/test", "b.md"));
1494    }
1495
1496    #[test]
1497    fn git_tree_writer_move_renames_path() {
1498        let tmp = TempDir::new().unwrap();
1499        let gitdir = fresh_repo_dir(tmp.path());
1500        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1501
1502        writer
1503            .write_entity(Path::new("from.md"), b"payload")
1504            .unwrap();
1505        writer.commit("seed", &ctx_for_test()).unwrap();
1506
1507        writer
1508            .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
1509            .unwrap();
1510        writer.commit("rename", &ctx_for_test()).unwrap();
1511
1512        assert!(!tree_path_exists(&gitdir, "refs/heads/test", "from.md"));
1513        let moved = read_blob(&gitdir, "refs/heads/test", "nested/to.md").unwrap();
1514        assert_eq!(moved, b"payload");
1515    }
1516
1517    #[test]
1518    fn git_tree_writer_multi_op_commit() {
1519        let tmp = TempDir::new().unwrap();
1520        let gitdir = fresh_repo_dir(tmp.path());
1521        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1522
1523        // Seed an entry that will be deleted in the same multi-op
1524        // commit as two new writes.
1525        writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
1526        writer.commit("seed", &ctx_for_test()).unwrap();
1527
1528        writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
1529        writer
1530            .write_entity(Path::new("nested/b.md"), b"beta")
1531            .unwrap();
1532        writer.delete_entity(Path::new("doomed.md")).unwrap();
1533        writer.commit("multi-op", &ctx_for_test()).unwrap();
1534
1535        assert!(!tree_path_exists(&gitdir, "refs/heads/test", "doomed.md"));
1536        assert_eq!(
1537            read_blob(&gitdir, "refs/heads/test", "a.md").unwrap(),
1538            b"alpha"
1539        );
1540        assert_eq!(
1541            read_blob(&gitdir, "refs/heads/test", "nested/b.md").unwrap(),
1542            b"beta"
1543        );
1544    }
1545
1546    #[test]
1547    fn git_tree_writer_cas_conflict_surfaces_hash_mismatch() {
1548        let tmp = TempDir::new().unwrap();
1549        let gitdir = fresh_repo_dir(tmp.path());
1550
1551        // Seed so both writers snapshot the same parent SHA.
1552        let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1553        seeder.write_entity(Path::new("seed.md"), b"x").unwrap();
1554        let seed_sha = seeder.commit("seed", &ctx_for_test()).unwrap();
1555
1556        let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1557        let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1558
1559        // Both writers take their snapshot at the same parent.
1560        a.write_entity(Path::new("a.md"), b"a").unwrap();
1561        b.write_entity(Path::new("b.md"), b"b").unwrap();
1562        assert_eq!(
1563            a.pending
1564                .lock()
1565                .unwrap()
1566                .parent
1567                .unwrap()
1568                .to_hex()
1569                .to_string(),
1570            seed_sha
1571        );
1572        assert_eq!(
1573            b.pending
1574                .lock()
1575                .unwrap()
1576                .parent
1577                .unwrap()
1578                .to_hex()
1579                .to_string(),
1580            seed_sha
1581        );
1582
1583        // A commits, advancing the ref. B then tries to commit and
1584        // gets the typed CAS conflict.
1585        let new_tip = a.commit("a wins", &ctx_for_test()).unwrap();
1586        let err = b
1587            .commit("b loses", &ctx_for_test())
1588            .expect_err("B's commit must fail with HashMismatch");
1589        match err {
1590            MemWriterError::HashMismatch { current } => {
1591                assert_eq!(current, new_tip);
1592            }
1593            other => panic!("expected HashMismatch, got {other:?}"),
1594        }
1595    }
1596
1597    #[test]
1598    fn cas_conflict_clears_pending_so_reads_fall_back_to_committed_truth() {
1599        // Regression: a commit that loses the CAS race must ABORT its
1600        // staged ops. Before the fix, `pending` was left populated on a
1601        // CAS conflict, and because `read_entity` prefers pending over the
1602        // committed tip, the loser's never-committed write was served as
1603        // phantom truth (and a later `reload_one_mem` pulled it into the
1604        // in-memory store) until the process restarted.
1605        let tmp = TempDir::new().unwrap();
1606        let gitdir = fresh_repo_dir(tmp.path());
1607
1608        // Seed a shared entity both writers will target, so they snapshot
1609        // the same parent SHA.
1610        let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1611        seeder.write_entity(Path::new("shared.md"), b"v1").unwrap();
1612        seeder.commit("seed", &ctx_for_test()).unwrap();
1613
1614        let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1615        let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1616
1617        // Both snapshot the same parent, then stage conflicting updates to
1618        // the SAME entity.
1619        a.write_entity(Path::new("shared.md"), b"A-committed")
1620            .unwrap();
1621        b.write_entity(Path::new("shared.md"), b"B-phantom")
1622            .unwrap();
1623
1624        // A wins the race; B's commit hits the typed CAS conflict.
1625        a.commit("a wins", &ctx_for_test()).unwrap();
1626        let err = b
1627            .commit("b loses", &ctx_for_test())
1628            .expect_err("B must lose the CAS race");
1629        assert!(
1630            matches!(err, MemWriterError::HashMismatch { .. }),
1631            "expected HashMismatch, got {err:?}"
1632        );
1633
1634        // The failed transaction must be aborted: B's pending buffer empty…
1635        assert!(
1636            b.pending.lock().unwrap().ops.is_empty(),
1637            "pending must be cleared after a failed commit"
1638        );
1639        // …so a read falls through to the committed tip and returns A's
1640        // value, NOT B's orphaned "B-phantom" staged write.
1641        let read = <GitTreeMemWriter as memstead_base::backend::MemBackend>::read_entity(
1642            &b,
1643            Path::new("shared.md"),
1644        )
1645        .unwrap();
1646        assert_eq!(
1647            read.as_deref(),
1648            Some(&b"A-committed"[..]),
1649            "read must serve committed truth, not the phantom staged write"
1650        );
1651    }
1652
1653    #[test]
1654    fn commit_with_expected_parent_succeeds_when_ref_matches_pin() {
1655        use memstead_base::backend::MemBackend;
1656
1657        let tmp = TempDir::new().unwrap();
1658        let gitdir = fresh_repo_dir(tmp.path());
1659
1660        let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1661        <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1662        let seed_sha =
1663            <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1664
1665        // Engine-style flow: snapshot head, mutate, then commit pinned.
1666        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1667        let expected = <GitTreeMemWriter as MemBackend>::current_head(&writer)
1668            .unwrap()
1669            .expect("seeded ref has a head");
1670        assert_eq!(expected, seed_sha);
1671
1672        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("after.md"), b"after")
1673            .unwrap();
1674
1675        let new_tip = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1676            &writer,
1677            "pinned commit",
1678            &ctx_for_test(),
1679            Some(&expected),
1680        )
1681        .expect("parent matches pin → commit must succeed");
1682        assert_ne!(new_tip, seed_sha);
1683        assert_eq!(
1684            read_blob(&gitdir, "refs/heads/test", "after.md").unwrap(),
1685            b"after"
1686        );
1687    }
1688
1689    #[test]
1690    fn commit_with_expected_parent_surfaces_parent_mismatch_when_sibling_advances_ref() {
1691        use memstead_base::backend::{BackendError, MemBackend};
1692
1693        let tmp = TempDir::new().unwrap();
1694        let gitdir = fresh_repo_dir(tmp.path());
1695
1696        // Seed so both writers start from the same commit.
1697        let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1698        <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1699        let seed_sha =
1700            <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1701
1702        // Engine A snapshots head — this is the pin it will retain
1703        // through any number of intermediate writes.
1704        let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1705        let pin = <GitTreeMemWriter as MemBackend>::current_head(&a)
1706            .unwrap()
1707            .expect("seeded ref has a head");
1708        assert_eq!(pin, seed_sha);
1709
1710        // A sibling writer (another engine instance, manual git op,
1711        // out-of-band CLI invocation, …) advances the ref between A's
1712        // snapshot and A's commit attempt.
1713        let sibling = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1714        <GitTreeMemWriter as MemWriter>::write_entity(&sibling, Path::new("drift.md"), b"drift")
1715            .unwrap();
1716        let new_tip =
1717            <GitTreeMemWriter as MemWriter>::commit(&sibling, "sibling advance", &ctx_for_test())
1718                .unwrap();
1719        assert_ne!(new_tip, seed_sha);
1720
1721        // A now tries to land a pinned commit. The pin no longer
1722        // matches the live tip → typed `ParentMismatch`.
1723        <GitTreeMemWriter as MemWriter>::write_entity(&a, Path::new("a.md"), b"a").unwrap();
1724        let err = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1725            &a,
1726            "pinned commit",
1727            &ctx_for_test(),
1728            Some(&pin),
1729        )
1730        .expect_err("pin no longer matches live tip → commit must refuse");
1731        match err {
1732            BackendError::ParentMismatch { expected, actual } => {
1733                assert_eq!(expected, pin);
1734                assert_eq!(actual, new_tip);
1735            }
1736            other => panic!("expected ParentMismatch, got {other:?}"),
1737        }
1738    }
1739
1740    #[test]
1741    fn commit_with_expected_parent_none_pin_is_equivalent_to_commit() {
1742        use memstead_base::backend::MemBackend;
1743
1744        let tmp = TempDir::new().unwrap();
1745        let gitdir = fresh_repo_dir(tmp.path());
1746        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1747
1748        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("hello.md"), b"hi")
1749            .unwrap();
1750        let sha = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1751            &writer,
1752            "unpinned",
1753            &ctx_for_test(),
1754            None,
1755        )
1756        .expect("None pin → plain commit semantics, must succeed against empty ref");
1757        assert_eq!(sha.len(), 40);
1758        assert_eq!(
1759            read_blob(&gitdir, "refs/heads/test", "hello.md").unwrap(),
1760            b"hi"
1761        );
1762    }
1763
1764    #[test]
1765    fn git_tree_writer_blob_oid_is_content_addressed() {
1766        // The git-tree writer is content-addressed: writing the same
1767        // bytes through two independent writers must yield the same
1768        // blob OID, since the OID is a hash of the content.
1769        let tmp_a = TempDir::new().unwrap();
1770        let tmp_b = TempDir::new().unwrap();
1771        let payload = b"shared content\n";
1772
1773        let gitdir_a = fresh_repo_dir(tmp_a.path());
1774        let writer_a = GitTreeMemWriter::new(gitdir_a.clone(), "refs/heads/a".to_string());
1775        writer_a
1776            .write_entity(Path::new("file.md"), payload)
1777            .unwrap();
1778        let sha_a = writer_a.commit("a", &ctx_for_test()).unwrap();
1779        let repo_a = gix::open(&gitdir_a).unwrap();
1780        let commit_a = repo_a
1781            .find_object(gix::ObjectId::from_hex(sha_a.as_bytes()).unwrap())
1782            .unwrap()
1783            .into_commit();
1784        let blob_id_a = commit_a
1785            .tree()
1786            .unwrap()
1787            .lookup_entry_by_path("file.md")
1788            .unwrap()
1789            .unwrap()
1790            .id()
1791            .detach();
1792
1793        let gitdir_b = fresh_repo_dir(tmp_b.path());
1794        let writer_b = GitTreeMemWriter::new(gitdir_b.clone(), "refs/heads/b".to_string());
1795        writer_b
1796            .write_entity(Path::new("file.md"), payload)
1797            .unwrap();
1798        let sha_b = writer_b.commit("b", &ctx_for_test()).unwrap();
1799        let repo_b = gix::open(&gitdir_b).unwrap();
1800        let commit_b = repo_b
1801            .find_object(gix::ObjectId::from_hex(sha_b.as_bytes()).unwrap())
1802            .unwrap()
1803            .into_commit();
1804        let blob_id_b = commit_b
1805            .tree()
1806            .unwrap()
1807            .lookup_entry_by_path("file.md")
1808            .unwrap()
1809            .unwrap()
1810            .id()
1811            .detach();
1812
1813        assert_eq!(
1814            blob_id_a, blob_id_b,
1815            "same content must produce byte-identical blob OIDs"
1816        );
1817    }
1818
1819    /// Initialise a non-bare repo at `<workdir>` with `refs/heads/main`
1820    /// as the symbolic HEAD. Returns `(workdir, gitdir)` — the workdir
1821    /// is what GitHub Desktop would open; the gitdir is what
1822    /// `GitTreeMemWriter::new` consumes.
1823    fn fresh_non_bare_repo(tmp: &Path) -> (PathBuf, PathBuf) {
1824        let workdir = tmp.join("mem-repo-workdir");
1825        std::fs::create_dir_all(&workdir).unwrap();
1826        let status = std::process::Command::new("git")
1827            .arg("-C")
1828            .arg(&workdir)
1829            .args(["init", "--initial-branch=main", "--quiet"])
1830            .status()
1831            .expect("git init must succeed");
1832        assert!(status.success(), "git init failed");
1833        let workdir = std::fs::canonicalize(&workdir).unwrap();
1834        let gitdir = workdir.join(".git");
1835        (workdir, gitdir)
1836    }
1837
1838    #[test]
1839    fn sync_helper_skips_on_bare_repo() {
1840        let tmp = TempDir::new().unwrap();
1841        let gitdir = fresh_repo_dir(tmp.path());
1842        let repo = gix::open(&gitdir).unwrap();
1843
1844        // No working tree exists — the helper must short-circuit Ok(())
1845        // regardless of the ref name passed.
1846        sync_index_and_worktree(&repo, "refs/heads/main").unwrap();
1847    }
1848
1849    #[test]
1850    fn sync_helper_updates_worktree_when_ref_matches_head() {
1851        let tmp = TempDir::new().unwrap();
1852        let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1853        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1854
1855        writer
1856            .write_entity(Path::new("configs/alpha.json"), b"{\"name\":\"alpha\"}\n")
1857            .unwrap();
1858        writer.commit("seed alpha", &ctx_for_test()).unwrap();
1859
1860        // The writer's commit() invokes sync_index_and_worktree via the
1861        // post-commit hook — the file must now exist on disk.
1862        let on_disk = workdir.join("configs/alpha.json");
1863        assert!(
1864            on_disk.exists(),
1865            "worktree sync must materialise the new blob at {}",
1866            on_disk.display()
1867        );
1868        let bytes = std::fs::read(&on_disk).unwrap();
1869        assert_eq!(bytes, b"{\"name\":\"alpha\"}\n");
1870
1871        // git status is also clean (HEAD == index == worktree).
1872        let output = std::process::Command::new("git")
1873            .arg("-C")
1874            .arg(&workdir)
1875            .args(["status", "--porcelain"])
1876            .output()
1877            .unwrap();
1878        assert!(
1879            output.stdout.is_empty(),
1880            "git status --porcelain must be empty post-sync, got: {:?}",
1881            String::from_utf8_lossy(&output.stdout)
1882        );
1883    }
1884
1885    #[test]
1886    fn sync_helper_skips_when_ref_does_not_match_head() {
1887        let tmp = TempDir::new().unwrap();
1888        let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1889
1890        // Write to refs/heads/feature; HEAD still points at
1891        // refs/heads/main. The worktree must NOT receive the feature
1892        // branch's content.
1893        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/feature".to_string());
1894        writer
1895            .write_entity(Path::new("only-on-feature.md"), b"feature-only\n")
1896            .unwrap();
1897        writer
1898            .commit("first commit on feature", &ctx_for_test())
1899            .unwrap();
1900
1901        // Object store has the blob on the feature branch...
1902        assert!(tree_path_exists(
1903            &gitdir,
1904            "refs/heads/feature",
1905            "only-on-feature.md"
1906        ));
1907        // ...but the worktree (which reflects main) does not.
1908        assert!(
1909            !workdir.join("only-on-feature.md").exists(),
1910            "worktree must not be polluted by writes to a non-checked-out branch"
1911        );
1912    }
1913
1914    #[test]
1915    fn sync_helper_preserves_untracked_files() {
1916        let tmp = TempDir::new().unwrap();
1917        let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1918        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1919
1920        // Drop an untracked file in the workdir before any engine
1921        // commit runs. `git read-tree --reset -u HEAD` only touches
1922        // tracked-file state; untracked content must survive.
1923        let untracked = workdir.join("scratch.txt");
1924        std::fs::write(&untracked, b"operator notes\n").unwrap();
1925
1926        writer
1927            .write_entity(Path::new("seed.md"), b"seed\n")
1928            .unwrap();
1929        writer.commit("create seed", &ctx_for_test()).unwrap();
1930
1931        assert!(
1932            untracked.exists(),
1933            "sync must leave untracked files in place"
1934        );
1935        assert_eq!(std::fs::read(&untracked).unwrap(), b"operator notes\n");
1936        // The tracked entity is also materialised.
1937        assert_eq!(std::fs::read(workdir.join("seed.md")).unwrap(), b"seed\n");
1938    }
1939
1940    #[test]
1941    fn sync_helper_updates_through_delete_and_overwrite() {
1942        let tmp = TempDir::new().unwrap();
1943        let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1944        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1945
1946        writer.write_entity(Path::new("a.md"), b"first\n").unwrap();
1947        writer.commit("create a", &ctx_for_test()).unwrap();
1948        assert_eq!(std::fs::read(workdir.join("a.md")).unwrap(), b"first\n");
1949
1950        writer.write_entity(Path::new("a.md"), b"second\n").unwrap();
1951        writer.commit("overwrite a", &ctx_for_test()).unwrap();
1952        assert_eq!(
1953            std::fs::read(workdir.join("a.md")).unwrap(),
1954            b"second\n",
1955            "overwrite must propagate to the worktree"
1956        );
1957
1958        writer.delete_entity(Path::new("a.md")).unwrap();
1959        writer.commit("delete a", &ctx_for_test()).unwrap();
1960        assert!(
1961            !workdir.join("a.md").exists(),
1962            "delete must remove the file from the worktree"
1963        );
1964
1965        // git status remains clean across all three transitions.
1966        let output = std::process::Command::new("git")
1967            .arg("-C")
1968            .arg(&workdir)
1969            .args(["status", "--porcelain"])
1970            .output()
1971            .unwrap();
1972        assert!(
1973            output.stdout.is_empty(),
1974            "git status --porcelain must be empty after every commit, got: {:?}",
1975            String::from_utf8_lossy(&output.stdout)
1976        );
1977    }
1978
1979    // ----- MemBackend impl -----------------------------------------
1980
1981    /// Build a CommitContext that produces an `memstead: <verb> <id>`
1982    /// subject with a given verb. The agent-notes parser keys off the
1983    /// subject's verb to recover the mutation kind.
1984    fn commit_with_verb(
1985        writer: &GitTreeMemWriter,
1986        verb: &str,
1987        entity_id: &str,
1988        ctx: &CommitContext<'_>,
1989    ) {
1990        let subject = format!("memstead: {verb} {entity_id}");
1991        <GitTreeMemWriter as MemWriter>::commit(writer, &subject, ctx).unwrap();
1992    }
1993
1994    fn ctx_with_note<'a>(note: &'a str) -> CommitContext<'a> {
1995        CommitContext {
1996            actor: Actor::Agent,
1997            client: Some(ClientId {
1998                name: "claude-code".to_string(),
1999                version: "2.1.0".to_string(),
2000            }),
2001            tool: Some("memstead_create"),
2002            note: Some(note.to_string()),
2003            role: Default::default(),
2004            logical_operation_id: None,
2005            entity_ids: None,
2006        }
2007    }
2008
2009    #[test]
2010    fn backend_list_entities_returns_only_md_outside_memstead_namespace() {
2011        use memstead_base::backend::MemBackend;
2012
2013        let tmp = TempDir::new().unwrap();
2014        let gitdir = fresh_repo_dir(tmp.path());
2015        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2016
2017        // Seed via MemWriter (fully-qualified to avoid trait
2018        // ambiguity once MemBackend enters scope below).
2019        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"# a").unwrap();
2020        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"# b")
2021            .unwrap();
2022        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
2023            .unwrap();
2024        <GitTreeMemWriter as MemWriter>::write_entity(
2025            &writer,
2026            Path::new(".memstead/config.json"),
2027            b"{}",
2028        )
2029        .unwrap();
2030        <GitTreeMemWriter as MemWriter>::write_entity(
2031            &writer,
2032            Path::new(".memstead/notes.md"),
2033            b"# skip me",
2034        )
2035        .unwrap();
2036        <GitTreeMemWriter as MemWriter>::write_entity(
2037            &writer,
2038            Path::new(".other/notes.md"),
2039            b"# no longer special, walked like any non-meta dir",
2040        )
2041        .unwrap();
2042        <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
2043
2044        let backend: &dyn MemBackend = &writer;
2045        let mut paths: Vec<String> = backend
2046            .list_entities()
2047            .unwrap()
2048            .into_iter()
2049            .map(|p| p.to_string_lossy().into_owned())
2050            .collect();
2051        paths.sort();
2052        // `.memstead/` stays skipped; an ordinary dot-dir is walked.
2053        assert_eq!(
2054            paths,
2055            vec![
2056                ".other/notes.md".to_string(),
2057                "a.md".to_string(),
2058                "nested/b.md".to_string(),
2059            ]
2060        );
2061    }
2062
2063    #[test]
2064    fn backend_list_entities_returns_empty_for_missing_branch() {
2065        use memstead_base::backend::MemBackend;
2066
2067        let tmp = TempDir::new().unwrap();
2068        let gitdir = fresh_repo_dir(tmp.path());
2069        let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
2070        let backend: &dyn MemBackend = &writer;
2071        // Branch never created → empty, no error.
2072        assert!(backend.list_entities().unwrap().is_empty());
2073    }
2074
2075    #[test]
2076    fn backend_read_entity_consults_pending_then_branch_tip() {
2077        use memstead_base::backend::MemBackend;
2078
2079        let tmp = TempDir::new().unwrap();
2080        let gitdir = fresh_repo_dir(tmp.path());
2081        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2082
2083        // Seed a committed entry.
2084        <GitTreeMemWriter as MemWriter>::write_entity(
2085            &writer,
2086            Path::new("on_branch.md"),
2087            b"branch",
2088        )
2089        .unwrap();
2090        <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
2091
2092        let backend: &dyn MemBackend = &writer;
2093        // Branch path → reads from the branch tip.
2094        assert_eq!(
2095            backend.read_entity(Path::new("on_branch.md")).unwrap(),
2096            Some(b"branch".to_vec())
2097        );
2098        // Buffered upsert wins over the branch tip.
2099        backend
2100            .write_entity(Path::new("on_branch.md"), b"buffered")
2101            .unwrap();
2102        assert_eq!(
2103            backend.read_entity(Path::new("on_branch.md")).unwrap(),
2104            Some(b"buffered".to_vec())
2105        );
2106        // Buffered delete masks the branch.
2107        backend.delete_entity(Path::new("on_branch.md")).unwrap();
2108        assert_eq!(
2109            backend.read_entity(Path::new("on_branch.md")).unwrap(),
2110            None
2111        );
2112        // Unknown path → None.
2113        assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
2114    }
2115
2116    #[test]
2117    fn backend_read_provenance_reconstructs_from_commit_log() {
2118        use memstead_base::backend::MemBackend;
2119
2120        let tmp = TempDir::new().unwrap();
2121        let gitdir = fresh_repo_dir(tmp.path());
2122        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2123
2124        // Two commits with memstead: subjects so the verb maps back to a
2125        // ProvenanceKind. The first carries an agent note, the second
2126        // does not.
2127        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2128        commit_with_verb(&writer, "create", "v:a", &ctx_with_note("first draft"));
2129
2130        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
2131        commit_with_verb(
2132            &writer,
2133            "update",
2134            "v:a",
2135            &CommitContext {
2136                actor: Actor::Cli,
2137                client: None,
2138                tool: Some("memstead_update"),
2139                note: None,
2140                role: Default::default(),
2141                logical_operation_id: None,
2142                entity_ids: None,
2143            },
2144        );
2145
2146        let backend: &dyn MemBackend = &writer;
2147        // append_provenance is the no-op contract; calling it with a
2148        // throw-away record must not perturb the read path.
2149        backend
2150            .append_provenance(&memstead_base::Provenance::new(
2151                std::time::UNIX_EPOCH,
2152                memstead_base::ProvenanceKind::Create,
2153                Some("ignored".into()),
2154                Actor::Unknown,
2155                None,
2156                None,
2157            ))
2158            .unwrap();
2159
2160        let records = backend.read_provenance(None).unwrap();
2161        assert_eq!(records.len(), 2, "expected two commits, got {records:?}");
2162        // Oldest-first ordering (matches folder backend).
2163        assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Create);
2164        assert_eq!(records[0].entity.as_deref(), Some("v:a"));
2165        assert_eq!(records[0].actor, Actor::Agent);
2166        assert_eq!(records[0].note.as_deref(), Some("first draft"));
2167        assert_eq!(
2168            records[0]
2169                .client
2170                .as_ref()
2171                .map(|c| (c.name.as_str(), c.version.as_str())),
2172            Some(("claude-code", "2.1.0"))
2173        );
2174        assert_eq!(records[1].kind, memstead_base::ProvenanceKind::Update);
2175        assert_eq!(records[1].actor, Actor::Cli);
2176        assert!(records[1].note.is_none());
2177        assert!(records[1].client.is_none());
2178    }
2179
2180    #[test]
2181    fn backend_read_provenance_filters_by_cursor_sha() {
2182        use memstead_base::backend::MemBackend;
2183
2184        let tmp = TempDir::new().unwrap();
2185        let gitdir = fresh_repo_dir(tmp.path());
2186        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2187
2188        // Seed three commits; the cursor will be the SHA of the first.
2189        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2190        let first_sha = <GitTreeMemWriter as MemWriter>::commit(
2191            &writer,
2192            "memstead: create v:a",
2193            &ctx_for_test(),
2194        )
2195        .unwrap();
2196        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
2197        <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
2198            .unwrap();
2199        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a3").unwrap();
2200        <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
2201            .unwrap();
2202
2203        let backend: &dyn MemBackend = &writer;
2204        // Cursor at the first SHA → returns only the two newer commits.
2205        let after = backend.read_provenance(Some(&first_sha)).unwrap();
2206        assert_eq!(
2207            after.len(),
2208            2,
2209            "expected commits after cursor, got {after:?}"
2210        );
2211        for r in &after {
2212            assert_eq!(r.kind, memstead_base::ProvenanceKind::Update);
2213        }
2214    }
2215
2216    #[test]
2217    fn backend_read_provenance_empty_for_missing_branch() {
2218        use memstead_base::backend::MemBackend;
2219
2220        let tmp = TempDir::new().unwrap();
2221        let gitdir = fresh_repo_dir(tmp.path());
2222        let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
2223        let backend: &dyn MemBackend = &writer;
2224        // No commits yet → empty record list, no error.
2225        assert!(backend.read_provenance(None).unwrap().is_empty());
2226    }
2227
2228    #[test]
2229    fn backend_unknown_verb_falls_back_to_update_kind() {
2230        use memstead_base::backend::MemBackend;
2231
2232        let tmp = TempDir::new().unwrap();
2233        let gitdir = fresh_repo_dir(tmp.path());
2234        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2235
2236        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2237        // Verb that isn't in the ProvenanceKind enum (e.g. lifecycle
2238        // verbs like `mem_create`) — round-trips as Update under the
2239        // tolerant-reader convention shared with the folder backend.
2240        commit_with_verb(&writer, "mem_create", "v:a", &ctx_for_test());
2241
2242        let backend: &dyn MemBackend = &writer;
2243        let records = backend.read_provenance(None).unwrap();
2244        assert_eq!(records.len(), 1);
2245        assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Update);
2246    }
2247
2248    #[test]
2249    fn instantiate_full_backend_constructs_git_branch_writer() {
2250        // Smoke test: instantiate_full_backend on a GitBranch mount
2251        // produces a backend that can list against an empty branch
2252        // without erroring (proves the writer is wired with the
2253        // right gitdir + ref shape).
2254        use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2255
2256        let tmp = TempDir::new().unwrap();
2257        let gitdir = fresh_repo_dir(tmp.path());
2258        let mount = Mount {
2259            mem: "engine".to_string(),
2260            schema: Some("default@1.0.0".parse().unwrap()),
2261            storage: MountStorage::GitBranch {
2262                gitdir,
2263                branch: "engine".to_string(),
2264            },
2265            capability: MountCapability::Write,
2266            lifecycle: MountLifecycle::Eager,
2267            cross_linkable: true,
2268            migration_target: None,
2269        };
2270        let backend: Box<dyn MemBackend> =
2271            crate::storage::instantiate_full_backend(&mount).unwrap();
2272        // Empty branch → empty list, no error.
2273        assert!(backend.list_entities().unwrap().is_empty());
2274        // Provenance log on a fresh branch → empty.
2275        assert!(backend.read_provenance(None).unwrap().is_empty());
2276    }
2277
2278    #[test]
2279    fn instantiate_full_backend_accepts_branch_with_or_without_refs_prefix() {
2280        // The full instantiator normalises a bare branch name
2281        // ("engine") to its fully-qualified ref ("refs/heads/engine").
2282        // Mounts may carry either shape; the writer must end up keyed
2283        // on the same per-branch mutex regardless.
2284        use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2285
2286        let tmp = TempDir::new().unwrap();
2287        let gitdir = fresh_repo_dir(tmp.path());
2288        for branch in ["engine", "refs/heads/engine"] {
2289            let mount = Mount {
2290                mem: "engine".to_string(),
2291                schema: Some("default@1.0.0".parse().unwrap()),
2292                storage: MountStorage::GitBranch {
2293                    gitdir: gitdir.clone(),
2294                    branch: branch.to_string(),
2295                },
2296                capability: MountCapability::Write,
2297                lifecycle: MountLifecycle::Eager,
2298                cross_linkable: true,
2299                migration_target: None,
2300            };
2301            let backend: Box<dyn MemBackend> =
2302                crate::storage::instantiate_full_backend(&mount).unwrap();
2303            // Both shapes resolve cleanly (no panic, no error).
2304            assert!(backend.list_entities().unwrap().is_empty());
2305        }
2306    }
2307
2308    // ---- MemBackend::current_head ----------------------------------
2309
2310    #[test]
2311    fn current_head_returns_none_for_empty_branch() {
2312        // A fresh bare repo has no commits and no branches; the
2313        // writer's `try_find_reference` returns Ok(None) and
2314        // current_head collapses to Ok(None) — drift detection on
2315        // an unborn mem is a clean no-op.
2316        let tmp = TempDir::new().unwrap();
2317        let gitdir = fresh_repo_dir(tmp.path());
2318        let writer = GitTreeMemWriter::new(gitdir, "refs/heads/specs".to_string());
2319        let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2320            .unwrap();
2321        assert!(head.is_none());
2322    }
2323
2324    #[test]
2325    fn current_head_returns_hex_sha_after_commit() {
2326        // After the first commit, current_head returns the 40-char
2327        // hex SHA matching what `commit` returned. The two values
2328        // are read through different paths (commit returns the value
2329        // straight from the writer; current_head re-opens the gitdir
2330        // and peels the ref) so equality proves end-to-end consistency.
2331        let tmp = TempDir::new().unwrap();
2332        let gitdir = fresh_repo_dir(tmp.path());
2333        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2334
2335        writer.write_entity(Path::new("a.md"), b"a").unwrap();
2336        let sha = writer.commit("first", &ctx_for_test()).unwrap();
2337        assert_eq!(sha.len(), 40);
2338
2339        let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2340            .unwrap()
2341            .expect("head present after commit");
2342        assert_eq!(head, sha);
2343    }
2344
2345    #[test]
2346    fn current_head_advances_on_subsequent_commits() {
2347        // Two back-to-back commits produce two distinct SHAs;
2348        // current_head reflects the latest after each. This is the
2349        // signal Engine::reload_if_stale compares against the
2350        // cached last_known_head to detect a sibling writer.
2351        let tmp = TempDir::new().unwrap();
2352        let gitdir = fresh_repo_dir(tmp.path());
2353        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2354
2355        writer.write_entity(Path::new("a.md"), b"a").unwrap();
2356        let first = writer.commit("first", &ctx_for_test()).unwrap();
2357        let head_after_first =
2358            <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2359                .unwrap()
2360                .unwrap();
2361        assert_eq!(head_after_first, first);
2362
2363        writer.write_entity(Path::new("b.md"), b"b").unwrap();
2364        let second = writer.commit("second", &ctx_for_test()).unwrap();
2365        assert_ne!(first, second);
2366        let head_after_second =
2367            <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2368                .unwrap()
2369                .unwrap();
2370        assert_eq!(head_after_second, second);
2371    }
2372
2373    #[test]
2374    fn current_head_returns_none_for_missing_gitdir() {
2375        // A writer pointed at a non-existent gitdir collapses to
2376        // Ok(None) (with a debug log) rather than surfacing the
2377        // open failure as an Err. Drift detection is best-effort —
2378        // a transient broken mount doesn't poison the read it
2379        // accompanies.
2380        let tmp = TempDir::new().unwrap();
2381        let writer = GitTreeMemWriter::new(
2382            tmp.path().join("does-not-exist.git"),
2383            "refs/heads/specs".to_string(),
2384        );
2385        let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2386            .unwrap();
2387        assert!(head.is_none());
2388    }
2389
2390    // ---- git-branch changes_since dispatch --------------------------
2391    //
2392    // Tests the `FULL_GIT_BRANCH_OPS.changes_since` dispatcher that
2393    // full boot installs on `memstead_base::Engine`. The dispatcher wraps
2394    // `crate::ops::changes::changes_since` and presents it through the
2395    // `memstead_base::GitBranchChangesSinceFn` signature.
2396
2397    fn dispatch_changes(
2398        gitdir: &Path,
2399        branch: &str,
2400        mem: &str,
2401        since: &str,
2402    ) -> Result<memstead_base::ops::BackendChanges, memstead_base::backend::BackendError> {
2403        (crate::storage::FULL_GIT_BRANCH_OPS.changes_since)(
2404            gitdir,
2405            branch,
2406            mem,
2407            since,
2408            memstead_base::ops::RENAME_SIMILARITY_DEFAULT,
2409        )
2410    }
2411
2412    #[test]
2413    fn changes_since_empty_repo_with_sentinel_returns_empty_changes() {
2414        // Fresh bare repo: no commits, no branches. With the empty-tree
2415        // sentinel as `since`, the dispatcher short-circuits to "no
2416        // diff, head echoes sentinel".
2417        let tmp = TempDir::new().unwrap();
2418        let gitdir = fresh_repo_dir(tmp.path());
2419        let result = dispatch_changes(
2420            &gitdir,
2421            "specs",
2422            "specs",
2423            memstead_base::ops::EMPTY_TREE_SHA,
2424        )
2425        .unwrap();
2426        assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2427        assert_eq!(result.head, memstead_base::ops::EMPTY_TREE_SHA);
2428        assert!(result.changes.is_empty());
2429    }
2430
2431    #[test]
2432    fn changes_since_after_commit_returns_added_envelopes_id_only() {
2433        // Commit two new entities, poll from the empty-tree sentinel,
2434        // and expect both as Added envelopes. Dispatch returns id-only
2435        // envelopes — the engine wrapper enriches.
2436        use memstead_base::ops::ChangeEnvelope;
2437        let tmp = TempDir::new().unwrap();
2438        let gitdir = fresh_repo_dir(tmp.path());
2439        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2440
2441        writer
2442            .write_entity(Path::new("alpha.md"), b"# Alpha")
2443            .unwrap();
2444        writer
2445            .write_entity(Path::new("beta.md"), b"# Beta")
2446            .unwrap();
2447        let head_sha = writer.commit("seed", &ctx_for_test()).unwrap();
2448
2449        let result = dispatch_changes(
2450            &gitdir,
2451            "specs",
2452            "specs",
2453            memstead_base::ops::EMPTY_TREE_SHA,
2454        )
2455        .unwrap();
2456        assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2457        assert_eq!(result.head, head_sha);
2458        assert_eq!(result.changes.len(), 2);
2459        for env in &result.changes {
2460            match env {
2461                ChangeEnvelope::Added {
2462                    id,
2463                    title,
2464                    entity_type,
2465                } => {
2466                    assert!(
2467                        id.0.starts_with("specs--"),
2468                        "expected mem-prefixed id, got {}",
2469                        id.0
2470                    );
2471                    assert!(title.is_none(), "dispatch must not enrich title");
2472                    assert!(
2473                        entity_type.is_none(),
2474                        "dispatch must not enrich entity_type"
2475                    );
2476                }
2477                other => panic!("expected Added envelope, got {other:?}"),
2478            }
2479        }
2480    }
2481
2482    #[test]
2483    fn changes_since_between_two_commits_yields_updated_envelope() {
2484        use memstead_base::ops::ChangeEnvelope;
2485        let tmp = TempDir::new().unwrap();
2486        let gitdir = fresh_repo_dir(tmp.path());
2487        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2488
2489        writer
2490            .write_entity(Path::new("alpha.md"), b"# Alpha v1")
2491            .unwrap();
2492        let sha_v1 = writer.commit("v1", &ctx_for_test()).unwrap();
2493
2494        writer
2495            .write_entity(Path::new("alpha.md"), b"# Alpha v2")
2496            .unwrap();
2497        let sha_v2 = writer.commit("v2", &ctx_for_test()).unwrap();
2498        assert_ne!(sha_v1, sha_v2);
2499
2500        let result = dispatch_changes(&gitdir, "specs", "specs", &sha_v1).unwrap();
2501        assert_eq!(result.since, sha_v1);
2502        assert_eq!(result.head, sha_v2);
2503        assert_eq!(result.changes.len(), 1);
2504        match &result.changes[0] {
2505            ChangeEnvelope::Updated {
2506                id,
2507                title,
2508                entity_type,
2509            } => {
2510                assert!(id.0.starts_with("specs--"));
2511                assert!(title.is_none());
2512                assert!(entity_type.is_none());
2513            }
2514            other => panic!("expected Updated envelope, got {other:?}"),
2515        }
2516    }
2517
2518    #[test]
2519    fn anchor_only_commit_yields_zero_entity_deltas_and_valid_cursor() {
2520        // Seed an entity, then land an anchor-only commit (only the
2521        // `.memstead/anchors.json` sidecar changed). changes_since from the
2522        // seed head must report ZERO entity deltas — the sidecar lives under
2523        // `.memstead/` which the entity-delta computation filters — while
2524        // the anchor commit's SHA is a valid `since` cursor.
2525        use memstead_base::backend::MemBackend;
2526        let tmp = TempDir::new().unwrap();
2527        let gitdir = fresh_repo_dir(tmp.path());
2528        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2529
2530        MemBackend::write_entity(&writer, Path::new("alpha.md"), b"# Alpha").unwrap();
2531        let seed_sha = MemBackend::commit(&writer, "seed", &ctx_for_test()).unwrap();
2532
2533        // Anchor-only commit: no entity write, just the sidecar.
2534        writer
2535            .write_anchors_sidecar(
2536                br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#,
2537            )
2538            .unwrap();
2539        let anchor_sha = MemBackend::commit(&writer, "anchors", &ctx_for_test()).unwrap();
2540        assert_ne!(seed_sha, anchor_sha);
2541
2542        // Zero entity deltas across the anchor-only commit.
2543        let from_seed = dispatch_changes(&gitdir, "specs", "specs", &seed_sha).unwrap();
2544        assert_eq!(from_seed.head, anchor_sha);
2545        assert_eq!(
2546            from_seed.changes.len(),
2547            0,
2548            "an anchor-only commit must produce zero entity deltas, got {:?}",
2549            from_seed.changes
2550        );
2551
2552        // The anchor commit's SHA is itself a valid cursor (resolves; no
2553        // deltas after it).
2554        let from_anchor = dispatch_changes(&gitdir, "specs", "specs", &anchor_sha).unwrap();
2555        assert_eq!(from_anchor.head, anchor_sha);
2556        assert_eq!(from_anchor.changes.len(), 0);
2557    }
2558
2559    #[test]
2560    fn changes_since_unknown_cursor_returns_typed_commit_not_found_marker() {
2561        // A `since` that
2562        // doesn't resolve is a recoverable caller-argument fault, not a
2563        // backend fault. The dispatch encodes it as the typed prefix
2564        // `COMMIT_NOT_FOUND:<sha>` (untruncated) that `Engine::changes_since`
2565        // lifts to `EngineError::InvalidChangesCursor` (code INVALID_CURSOR)
2566        // — distinct from the generic `git-branch changes_since: …`
2567        // wrapper used for real backend faults.
2568        let tmp = TempDir::new().unwrap();
2569        let gitdir = fresh_repo_dir(tmp.path());
2570        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2571        // Seed one commit so the gitdir is not empty.
2572        writer.write_entity(Path::new("a.md"), b"a").unwrap();
2573        writer.commit("seed", &ctx_for_test()).unwrap();
2574
2575        let bad_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2576        let err = dispatch_changes(&gitdir, "specs", "specs", bad_sha).unwrap_err();
2577        match err {
2578            memstead_base::backend::BackendError::Other(msg) => {
2579                assert_eq!(
2580                    msg,
2581                    format!("COMMIT_NOT_FOUND:{bad_sha}"),
2582                    "bad-since must carry the typed marker with the untruncated sha: {msg}",
2583                );
2584            }
2585            other => panic!("expected BackendError::Other, got {other:?}"),
2586        }
2587    }
2588}