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