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            role: Default::default(),
816            logical_operation_id: None,
817            entity_ids: None,
818        };
819        crate::storage_memstead::delete_mem_artifacts_at_gitdir(&self.gitdir, branch_leaf, &ctx)
820            .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
821    }
822
823    fn write_mem_config(&self, bytes: &[u8]) -> Result<(), memstead_base::backend::BackendError> {
824        self.write_mem_config_with_note(bytes, None)
825    }
826
827    fn write_mem_config_with_note(
828        &self,
829        bytes: &[u8],
830        note: Option<&str>,
831    ) -> Result<(), memstead_base::backend::BackendError> {
832        // Write `__MEMSTEAD:mems/<mem>/config.json` only. The legacy
833        // `mem_repo_config::read_config` consumer chain reads
834        // through `__MEMSTEAD`, so a dual-write to any retired ref would
835        // be wasted work.
836        //
837        // Mem leaf comes from `self.ref_name` (the per-mem
838        // branch); for hierarchical mounts (refs/heads/<path>/<leaf>)
839        // the helper's `resolve_full_path_at_gitdir` walks the
840        // branch list to find the matching full path. For a fresh
841        // mem not yet present in the branch list, the helper
842        // falls back to the flat `<leaf>/config.json` shape —
843        // unified `create_mem` writes the per-mem branch commit
844        // AFTER this call, so during the very first
845        // write_mem_config the branch isn't yet present.
846        // Hierarchical-path semantics for fresh mems need a
847        // small lift in a follow-up (pass full path explicitly).
848        //
849        // `note` rides the commit body so a version bump (or any
850        // config write that supplies one) carries the same provenance
851        // reason the other commit-producing lifecycle operations do.
852        let leaf = self
853            .ref_name
854            .strip_prefix("refs/heads/")
855            .unwrap_or(&self.ref_name);
856        let ctx = CommitContext {
857            actor: memstead_base::vcs::Actor::Agent,
858            client: None,
859            tool: Some("memstead_mem_config_write"),
860            note: note.map(str::to_string),
861            role: Default::default(),
862            logical_operation_id: None,
863            entity_ids: None,
864        };
865        crate::storage_memstead::commit_config_to_memstead_at_gitdir(
866            &self.gitdir,
867            leaf,
868            bytes,
869            &ctx,
870            &format!("memstead: commit __MEMSTEAD:mems/{leaf}/config.json"),
871        )
872        .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
873    }
874
875    fn record_pipeline_edit(
876        &self,
877        kind: &str,
878        edits: &[(String, Option<Vec<u8>>)],
879        note: Option<&str>,
880        verb: &str,
881    ) -> Result<(), memstead_base::backend::BackendError> {
882        // Mirror the pipeline-config edit under
883        // `__MEMSTEAD:pipeline/<kind>/<leaf>/<name>.json` — the commit
884        // (subject + Note trailer) is the provenance record for a disk
885        // write that has no commit of its own.
886        let leaf = self
887            .ref_name
888            .strip_prefix("refs/heads/")
889            .unwrap_or(&self.ref_name);
890        let tree_edits: Vec<(String, Option<Vec<u8>>)> = edits
891            .iter()
892            .map(|(name, bytes)| (format!("pipeline/{kind}/{leaf}/{name}.json"), bytes.clone()))
893            .collect();
894        let ctx = CommitContext {
895            actor: memstead_base::vcs::Actor::Agent,
896            client: None,
897            tool: Some("memstead_pipeline_edit"),
898            note: note.map(str::to_string),
899            role: Default::default(),
900            logical_operation_id: None,
901            entity_ids: None,
902        };
903        let names: Vec<&str> = edits.iter().map(|(n, _)| n.as_str()).collect();
904        crate::storage_memstead::commit_paths_to_memstead_at_gitdir(
905            &self.gitdir,
906            &tree_edits,
907            &ctx,
908            &format!("memstead: {verb} {kind} {leaf}/{}", names.join(", ")),
909        )
910        .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
911    }
912}
913
914/// Read a blob from `ref_name:path` in the gitdir. Returns
915/// `Ok(None)` when the ref is missing or the path doesn't exist
916/// in the tree. Errors propagate as `BackendError::Other`.
917///
918/// Used by `read_mem_config` to read per-mem configs from
919/// `__MEMSTEAD` without needing a full full `MemConfig` parser path —
920/// the engine parses bytes uniformly across backends.
921fn read_blob_from_ref(
922    gitdir: &Path,
923    ref_name: &str,
924    path: &str,
925) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
926    let repo = match gix::open(gitdir) {
927        Ok(r) => r,
928        Err(_) => return Ok(None),
929    };
930    let reference = match repo.try_find_reference(ref_name) {
931        Ok(Some(r)) => r,
932        Ok(None) => return Ok(None),
933        Err(e) => {
934            return Err(memstead_base::backend::BackendError::Other(format!(
935                "find ref {ref_name}: {e}"
936            )));
937        }
938    };
939    let id = reference.into_fully_peeled_id().map_err(|e| {
940        memstead_base::backend::BackendError::Other(format!("peel {ref_name}: {e}"))
941    })?;
942    let object = id.object().map_err(|e| {
943        memstead_base::backend::BackendError::Other(format!("read obj {ref_name}: {e}"))
944    })?;
945    let commit = match object.try_into_commit() {
946        Ok(c) => c,
947        Err(_) => return Ok(None),
948    };
949    let tree = commit.tree().map_err(|e| {
950        memstead_base::backend::BackendError::Other(format!("read tree {ref_name}: {e}"))
951    })?;
952    let entry = match tree.lookup_entry_by_path(path) {
953        Ok(Some(e)) => e,
954        Ok(None) => return Ok(None),
955        Err(e) => {
956            return Err(memstead_base::backend::BackendError::Other(format!(
957                "lookup {ref_name}:{path}: {e}"
958            )));
959        }
960    };
961    let blob = entry.object().map_err(|e| {
962        memstead_base::backend::BackendError::Other(format!("read blob {ref_name}:{path}: {e}"))
963    })?;
964    Ok(Some(blob.data.clone()))
965}
966
967/// Build a [`memstead_base::Provenance`] from a parsed commit note. Best-
968/// effort: unrecognised verbs map to `Update`, missing actors to
969/// `Unknown`, malformed client trailers drop the field. Matches the
970/// folder backend's tolerant-reader stance.
971fn commit_note_to_provenance(n: crate::ops::agent_notes::CommitNote) -> memstead_base::Provenance {
972    let kind = n
973        .tool_verb
974        .as_deref()
975        .and_then(memstead_base::ProvenanceKind::parse)
976        .unwrap_or(memstead_base::ProvenanceKind::Update);
977    let actor = n
978        .actor
979        .as_deref()
980        .and_then(memstead_base::vcs::Actor::from_trailer)
981        .unwrap_or(memstead_base::vcs::Actor::Unknown);
982    let client = n
983        .client
984        .as_deref()
985        .and_then(memstead_base::vcs::parse_client_id);
986    let timestamp = if n.timestamp >= 0 {
987        std::time::UNIX_EPOCH + std::time::Duration::from_secs(n.timestamp as u64)
988    } else {
989        std::time::UNIX_EPOCH
990    };
991    let mut record =
992        memstead_base::Provenance::new(timestamp, kind, n.entity_id, actor, client, n.note);
993    if let Some(id) = n.logical_operation_id {
994        record = record.with_logical_operation_id(id);
995    }
996    if let Some(role) = n
997        .role
998        .as_deref()
999        .and_then(memstead_base::vcs::Role::from_wire)
1000    {
1001        record = record.with_role(role);
1002    }
1003    record
1004}
1005
1006/// Refresh the working tree and index from `HEAD` when the just-
1007/// written `ref_name` matches the symbolic ref `HEAD` resolves to.
1008///
1009/// Engine writes go through `gix::Repository::commit_as`, which
1010/// advances the target ref in the object store but never touches the
1011/// index or the working tree. On a non-bare repo (the shape humans
1012/// open in GitHub Desktop) that drift surfaces as a spurious "deleted"
1013/// diff against every file the engine just wrote — and clicking
1014/// "commit" on that diff silently undoes the engine's work. Running
1015/// `git read-tree --reset -u HEAD` after each on-checked-out-branch
1016/// commit closes the drift. The `--reset -u` combination updates
1017/// tracked-file state to match HEAD and removes tracked files HEAD
1018/// no longer knows about; truly-untracked files in the working tree
1019/// are left alone.
1020///
1021/// Short-circuits as `Ok(())` when:
1022/// - the repo is bare (no working tree to sync);
1023/// - HEAD is detached, absent, or unreadable (no symbolic ref to
1024///   compare — a corrupted `.git/HEAD` is its own problem and should
1025///   not conflate with a write failure when the commit landed
1026///   durably);
1027/// - HEAD's full ref name does not match `ref_name` (we wrote to a
1028///   branch other than the checked-out one — syncing would clobber
1029///   the user's checked-out tree with content from a different
1030///   branch).
1031///
1032/// Spawn failure or non-zero exit maps to
1033/// [`MemWriterError::Io`] with the workdir and the captured stderr
1034/// in the message, plus an actionable hint pointing the caller at
1035/// `git -C <workdir> reset --hard HEAD` for manual recovery (the
1036/// commit itself already landed successfully — a sync failure leaves
1037/// the object store correct but the working tree stale).
1038///
1039/// Cross-process coordination is out of scope: when two engine
1040/// processes write the same ref concurrently, the worktree converges
1041/// to whoever's `read-tree` ran last; intermediate readers may see a
1042/// mix. Single-process engines today; the open seam is documented in
1043/// `mem-repo-write-cutover`'s "Open seams" section.
1044fn sync_index_and_worktree(repo: &gix::Repository, ref_name: &str) -> Result<(), MemWriterError> {
1045    let Some(workdir) = repo.workdir() else {
1046        return Ok(());
1047    };
1048    // `BStr: PartialEq<str>` is byte-exact, which matches the
1049    // engine's `refs/heads/<branch>` naming convention — non-ASCII
1050    // drift here is a real bug worth catching, not something to
1051    // normalise away. A `head_name()` failure (corrupted `.git/HEAD`,
1052    // permissions error) short-circuits the sync rather than
1053    // surfacing as a write failure: the commit already landed
1054    // durably, and a malformed HEAD will be diagnosed via the next
1055    // `git status` the operator runs.
1056    let head_matches = matches!(repo.head_name(), Ok(Some(name)) if name.as_bstr() == ref_name);
1057    if !head_matches {
1058        return Ok(());
1059    }
1060
1061    let output = std::process::Command::new("git")
1062        .arg("-C")
1063        .arg(workdir)
1064        .args(["read-tree", "--reset", "-u", "HEAD"])
1065        // Never prompt: a misconfigured `core.askpass` or
1066        // `credential.helper` could otherwise stall the sync forever
1067        // on a misrouted code path.
1068        .env("GIT_TERMINAL_PROMPT", "0")
1069        .stdin(std::process::Stdio::null())
1070        .output()
1071        .map_err(|e| {
1072            std::io::Error::other(format!(
1073                "worktree sync: spawn `git -C {} read-tree --reset -u HEAD`: {e}; \
1074                 commit already landed in the object store, recover with \
1075                 `git -C {} reset --hard HEAD`",
1076                workdir.display(),
1077                workdir.display()
1078            ))
1079        })?;
1080    if !output.status.success() {
1081        let stderr = String::from_utf8_lossy(&output.stderr);
1082        return Err(MemWriterError::Io(std::io::Error::other(format!(
1083            "worktree sync: `git -C {} read-tree --reset -u HEAD` failed (status {}): {}; \
1084             commit already landed in the object store, recover with \
1085             `git -C {} reset --hard HEAD` (or remove a stale \
1086             `<workdir>/.git/index.lock` if one exists)",
1087            workdir.display(),
1088            output.status,
1089            stderr.trim(),
1090            workdir.display()
1091        ))));
1092    }
1093    Ok(())
1094}
1095
1096/// Deterministic committer identity — must stay byte-for-byte aligned
1097/// with [`crate::vcs::COMMITTER_NAME`] / `COMMITTER_EMAIL`. Re-declared
1098/// here as private constants to keep the `vcs` module's public surface
1099/// minimal; the alignment is locked by the
1100/// `git_tree_writer_blob_oid_matches_disk_oid` test below, which only
1101/// passes when both adapters produce byte-identical commit objects.
1102const COMMITTER_NAME: &str = "engine";
1103const COMMITTER_EMAIL: &str = "noreply@memstead.io";
1104
1105/// One blob entry returned by [`read_branch_blobs`] — the
1106/// mem-relative forward-slash path and the blob bytes. The list is
1107/// sorted by `path` so callers (e.g. archive emitters) get
1108/// deterministic ordering without a re-sort.
1109#[derive(Debug, Clone)]
1110pub struct BranchBlob {
1111    pub path: String,
1112    pub bytes: Vec<u8>,
1113}
1114
1115/// Errors surfaced by [`read_branch_blobs`]. Wraps the gix repo /
1116/// reference / object operations so callers can map a missing branch
1117/// or a malformed object to a structured error without re-importing
1118/// gix's error types.
1119#[derive(Debug, thiserror::Error)]
1120pub enum BranchReadError {
1121    #[error("git-tree reader: open repo at {path}: {source}")]
1122    Open {
1123        path: String,
1124        #[source]
1125        source: gix::open::Error,
1126    },
1127    #[error("git-tree reader: branch {ref_name} not found")]
1128    BranchMissing { ref_name: String },
1129    #[error("git-tree reader: resolve branch {ref_name}: {message}")]
1130    Resolve { ref_name: String, message: String },
1131    #[error("git-tree reader: read object: {message}")]
1132    Read { message: String },
1133}
1134
1135/// Walk every blob in the tree pointed at by `<gitdir>:<ref_name>`'s
1136/// commit and return their (path, bytes) pairs sorted by path. The
1137/// branch tip's commit is peeled to a tree, then the tree is recursed
1138/// breadth-first. Subtrees are descended; symlinks and non-blob entries
1139/// are skipped (mem content is regular files only).
1140///
1141/// `ref_name` is the fully-qualified ref form, e.g.
1142/// `refs/heads/<mem>` for mem-content reads or `refs/heads/main`
1143/// for schema/config reads against the `mem-repo-git` repo.
1144///
1145/// A missing ref returns [`BranchReadError::BranchMissing`] so callers
1146/// can distinguish "branch never created" from "branch exists but is
1147/// empty" — the latter returns `Ok(vec![])`.
1148pub fn read_branch_blobs(
1149    gitdir: &Path,
1150    ref_name: &str,
1151) -> Result<Vec<BranchBlob>, BranchReadError> {
1152    let repo = gix::open(gitdir).map_err(|e| BranchReadError::Open {
1153        path: gitdir.display().to_string(),
1154        source: e,
1155    })?;
1156    let mut reference =
1157        match repo
1158            .try_find_reference(ref_name)
1159            .map_err(|e| BranchReadError::Resolve {
1160                ref_name: ref_name.to_string(),
1161                message: e.to_string(),
1162            })? {
1163            Some(r) => r,
1164            None => {
1165                return Err(BranchReadError::BranchMissing {
1166                    ref_name: ref_name.to_string(),
1167                });
1168            }
1169        };
1170    let id = reference
1171        .peel_to_id()
1172        .map_err(|e| BranchReadError::Resolve {
1173            ref_name: ref_name.to_string(),
1174            message: e.to_string(),
1175        })?;
1176    let commit = repo
1177        .find_object(id)
1178        .map_err(|e| BranchReadError::Read {
1179            message: format!("open commit {id}: {e}"),
1180        })?
1181        .into_commit();
1182    let tree = commit.tree().map_err(|e| BranchReadError::Read {
1183        message: format!("peel commit to tree: {e}"),
1184    })?;
1185
1186    let mut out: Vec<BranchBlob> = Vec::new();
1187    walk_tree(&repo, &tree, "", &mut out)?;
1188    out.sort_by(|a, b| a.path.cmp(&b.path));
1189    Ok(out)
1190}
1191
1192fn walk_tree(
1193    repo: &gix::Repository,
1194    tree: &gix::Tree<'_>,
1195    prefix: &str,
1196    out: &mut Vec<BranchBlob>,
1197) -> Result<(), BranchReadError> {
1198    use gix::objs::tree::EntryKind;
1199    let iter = tree.iter();
1200    for entry_res in iter {
1201        let entry = entry_res.map_err(|e| BranchReadError::Read {
1202            message: format!("decode tree entry: {e}"),
1203        })?;
1204        let name = entry.filename().to_string();
1205        let full = if prefix.is_empty() {
1206            name.clone()
1207        } else {
1208            format!("{prefix}/{name}")
1209        };
1210        match entry.mode().kind() {
1211            EntryKind::Blob | EntryKind::BlobExecutable => {
1212                let object = repo
1213                    .find_object(entry.oid())
1214                    .map_err(|e| BranchReadError::Read {
1215                        message: format!("read blob {full}: {e}"),
1216                    })?;
1217                out.push(BranchBlob {
1218                    path: full,
1219                    bytes: object.data.clone(),
1220                });
1221            }
1222            EntryKind::Tree => {
1223                let subtree = repo
1224                    .find_object(entry.oid())
1225                    .map_err(|e| BranchReadError::Read {
1226                        message: format!("read subtree {full}: {e}"),
1227                    })?
1228                    .into_tree();
1229                walk_tree(repo, &subtree, &full, out)?;
1230            }
1231            // Symlinks and commits (submodules) — mem content is
1232            // regular files only; ignore.
1233            EntryKind::Link | EntryKind::Commit => {}
1234        }
1235    }
1236    Ok(())
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242    use crate::vcs::{Actor, ClientId, CommitContext};
1243    use std::path::Path;
1244    use tempfile::TempDir;
1245
1246    /// Build a fresh bare repo at `<tmp>/mem-repo.git` and return the
1247    /// canonical gitdir path. Tests open the repo per-call via
1248    /// `gix::open` so the writer's `gitdir + ref_name` shape is what
1249    /// gets exercised.
1250    fn fresh_repo_dir(tmp: &Path) -> PathBuf {
1251        let git_dir = tmp.join("mem-repo.git");
1252        gix::init_bare(&git_dir).unwrap();
1253        std::fs::canonicalize(&git_dir).unwrap()
1254    }
1255
1256    fn ctx_for_test<'a>() -> CommitContext<'a> {
1257        CommitContext {
1258            actor: Actor::Cli,
1259            client: Some(ClientId {
1260                name: "claude-code".to_string(),
1261                version: "0.1.0".to_string(),
1262            }),
1263            tool: Some("test"),
1264            note: None,
1265            role: Default::default(),
1266            logical_operation_id: None,
1267            entity_ids: None,
1268        }
1269    }
1270
1271    fn read_blob(gitdir: &Path, ref_name: &str, path: &str) -> Option<Vec<u8>> {
1272        let repo = gix::open(gitdir).unwrap();
1273        let mut reference = repo.try_find_reference(ref_name).unwrap()?;
1274        let id = reference.peel_to_id().unwrap();
1275        let commit = repo.find_object(id).unwrap().into_commit();
1276        let tree = commit.tree().unwrap();
1277        let entry = tree.lookup_entry_by_path(path).unwrap()?;
1278        let object = repo.find_object(entry.id()).unwrap();
1279        Some(object.data.clone())
1280    }
1281
1282    fn tree_path_exists(gitdir: &Path, ref_name: &str, path: &str) -> bool {
1283        read_blob(gitdir, ref_name, path).is_some()
1284    }
1285
1286    #[test]
1287    fn git_tree_writer_round_trip() {
1288        let tmp = TempDir::new().unwrap();
1289        let gitdir = fresh_repo_dir(tmp.path());
1290        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1291
1292        writer
1293            .write_entity(Path::new("notes/hello.md"), b"# hi\n")
1294            .unwrap();
1295        let sha = writer.commit("first commit", &ctx_for_test()).unwrap();
1296        assert_eq!(sha.len(), 40);
1297
1298        let bytes = read_blob(&gitdir, "refs/heads/test", "notes/hello.md").unwrap();
1299        assert_eq!(bytes, b"# hi\n");
1300    }
1301
1302    #[test]
1303    fn git_tree_writer_anchors_sidecar_rides_commit_and_survives_reload() {
1304        use memstead_base::backend::MemBackend;
1305        let tmp = TempDir::new().unwrap();
1306        let gitdir = fresh_repo_dir(tmp.path());
1307        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1308
1309        // Stage an entity write and the anchors sidecar, then commit
1310        // once — both land in the same commit.
1311        MemBackend::write_entity(&writer, Path::new("hello.md"), b"# hi\n").unwrap();
1312        writer
1313            .write_anchors_sidecar(b"{\"version\":1,\"entities\":{}}")
1314            .unwrap();
1315        MemBackend::commit(&writer, "entity+anchors", &ctx_for_test()).unwrap();
1316
1317        // Sidecar blob is present in the branch tree at the reserved path.
1318        let sidecar = read_blob(&gitdir, "refs/heads/test", ".memstead/anchors.json").unwrap();
1319        assert_eq!(sidecar, b"{\"version\":1,\"entities\":{}}");
1320
1321        // A fresh writer (engine reload) reads it back.
1322        let reloaded = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1323        assert_eq!(
1324            reloaded.read_anchors_sidecar().unwrap(),
1325            Some(b"{\"version\":1,\"entities\":{}}".to_vec())
1326        );
1327        // And it never surfaces as an entity.
1328        assert_eq!(
1329            MemBackend::list_entities(&reloaded).unwrap(),
1330            vec![PathBuf::from("hello.md")]
1331        );
1332    }
1333
1334    #[test]
1335    fn git_tree_writer_delete_removes_path() {
1336        let tmp = TempDir::new().unwrap();
1337        let gitdir = fresh_repo_dir(tmp.path());
1338        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1339
1340        writer.write_entity(Path::new("a.md"), b"a").unwrap();
1341        writer.write_entity(Path::new("b.md"), b"b").unwrap();
1342        writer.commit("seed", &ctx_for_test()).unwrap();
1343
1344        writer.delete_entity(Path::new("a.md")).unwrap();
1345        writer.commit("drop a", &ctx_for_test()).unwrap();
1346
1347        assert!(!tree_path_exists(&gitdir, "refs/heads/test", "a.md"));
1348        assert!(tree_path_exists(&gitdir, "refs/heads/test", "b.md"));
1349    }
1350
1351    #[test]
1352    fn git_tree_writer_move_renames_path() {
1353        let tmp = TempDir::new().unwrap();
1354        let gitdir = fresh_repo_dir(tmp.path());
1355        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1356
1357        writer
1358            .write_entity(Path::new("from.md"), b"payload")
1359            .unwrap();
1360        writer.commit("seed", &ctx_for_test()).unwrap();
1361
1362        writer
1363            .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
1364            .unwrap();
1365        writer.commit("rename", &ctx_for_test()).unwrap();
1366
1367        assert!(!tree_path_exists(&gitdir, "refs/heads/test", "from.md"));
1368        let moved = read_blob(&gitdir, "refs/heads/test", "nested/to.md").unwrap();
1369        assert_eq!(moved, b"payload");
1370    }
1371
1372    #[test]
1373    fn git_tree_writer_multi_op_commit() {
1374        let tmp = TempDir::new().unwrap();
1375        let gitdir = fresh_repo_dir(tmp.path());
1376        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1377
1378        // Seed an entry that will be deleted in the same multi-op
1379        // commit as two new writes.
1380        writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
1381        writer.commit("seed", &ctx_for_test()).unwrap();
1382
1383        writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
1384        writer
1385            .write_entity(Path::new("nested/b.md"), b"beta")
1386            .unwrap();
1387        writer.delete_entity(Path::new("doomed.md")).unwrap();
1388        writer.commit("multi-op", &ctx_for_test()).unwrap();
1389
1390        assert!(!tree_path_exists(&gitdir, "refs/heads/test", "doomed.md"));
1391        assert_eq!(
1392            read_blob(&gitdir, "refs/heads/test", "a.md").unwrap(),
1393            b"alpha"
1394        );
1395        assert_eq!(
1396            read_blob(&gitdir, "refs/heads/test", "nested/b.md").unwrap(),
1397            b"beta"
1398        );
1399    }
1400
1401    #[test]
1402    fn git_tree_writer_cas_conflict_surfaces_hash_mismatch() {
1403        let tmp = TempDir::new().unwrap();
1404        let gitdir = fresh_repo_dir(tmp.path());
1405
1406        // Seed so both writers snapshot the same parent SHA.
1407        let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1408        seeder.write_entity(Path::new("seed.md"), b"x").unwrap();
1409        let seed_sha = seeder.commit("seed", &ctx_for_test()).unwrap();
1410
1411        let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1412        let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1413
1414        // Both writers take their snapshot at the same parent.
1415        a.write_entity(Path::new("a.md"), b"a").unwrap();
1416        b.write_entity(Path::new("b.md"), b"b").unwrap();
1417        assert_eq!(
1418            a.pending
1419                .lock()
1420                .unwrap()
1421                .parent
1422                .unwrap()
1423                .to_hex()
1424                .to_string(),
1425            seed_sha
1426        );
1427        assert_eq!(
1428            b.pending
1429                .lock()
1430                .unwrap()
1431                .parent
1432                .unwrap()
1433                .to_hex()
1434                .to_string(),
1435            seed_sha
1436        );
1437
1438        // A commits, advancing the ref. B then tries to commit and
1439        // gets the typed CAS conflict.
1440        let new_tip = a.commit("a wins", &ctx_for_test()).unwrap();
1441        let err = b
1442            .commit("b loses", &ctx_for_test())
1443            .expect_err("B's commit must fail with HashMismatch");
1444        match err {
1445            MemWriterError::HashMismatch { current } => {
1446                assert_eq!(current, new_tip);
1447            }
1448            other => panic!("expected HashMismatch, got {other:?}"),
1449        }
1450    }
1451
1452    #[test]
1453    fn cas_conflict_clears_pending_so_reads_fall_back_to_committed_truth() {
1454        // Regression: a commit that loses the CAS race must ABORT its
1455        // staged ops. Before the fix, `pending` was left populated on a
1456        // CAS conflict, and because `read_entity` prefers pending over the
1457        // committed tip, the loser's never-committed write was served as
1458        // phantom truth (and a later `reload_one_mem` pulled it into the
1459        // in-memory store) until the process restarted.
1460        let tmp = TempDir::new().unwrap();
1461        let gitdir = fresh_repo_dir(tmp.path());
1462
1463        // Seed a shared entity both writers will target, so they snapshot
1464        // the same parent SHA.
1465        let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1466        seeder.write_entity(Path::new("shared.md"), b"v1").unwrap();
1467        seeder.commit("seed", &ctx_for_test()).unwrap();
1468
1469        let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1470        let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1471
1472        // Both snapshot the same parent, then stage conflicting updates to
1473        // the SAME entity.
1474        a.write_entity(Path::new("shared.md"), b"A-committed")
1475            .unwrap();
1476        b.write_entity(Path::new("shared.md"), b"B-phantom")
1477            .unwrap();
1478
1479        // A wins the race; B's commit hits the typed CAS conflict.
1480        a.commit("a wins", &ctx_for_test()).unwrap();
1481        let err = b
1482            .commit("b loses", &ctx_for_test())
1483            .expect_err("B must lose the CAS race");
1484        assert!(
1485            matches!(err, MemWriterError::HashMismatch { .. }),
1486            "expected HashMismatch, got {err:?}"
1487        );
1488
1489        // The failed transaction must be aborted: B's pending buffer empty…
1490        assert!(
1491            b.pending.lock().unwrap().ops.is_empty(),
1492            "pending must be cleared after a failed commit"
1493        );
1494        // …so a read falls through to the committed tip and returns A's
1495        // value, NOT B's orphaned "B-phantom" staged write.
1496        let read = <GitTreeMemWriter as memstead_base::backend::MemBackend>::read_entity(
1497            &b,
1498            Path::new("shared.md"),
1499        )
1500        .unwrap();
1501        assert_eq!(
1502            read.as_deref(),
1503            Some(&b"A-committed"[..]),
1504            "read must serve committed truth, not the phantom staged write"
1505        );
1506    }
1507
1508    #[test]
1509    fn commit_with_expected_parent_succeeds_when_ref_matches_pin() {
1510        use memstead_base::backend::MemBackend;
1511
1512        let tmp = TempDir::new().unwrap();
1513        let gitdir = fresh_repo_dir(tmp.path());
1514
1515        let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1516        <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1517        let seed_sha =
1518            <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1519
1520        // Engine-style flow: snapshot head, mutate, then commit pinned.
1521        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1522        let expected = <GitTreeMemWriter as MemBackend>::current_head(&writer)
1523            .unwrap()
1524            .expect("seeded ref has a head");
1525        assert_eq!(expected, seed_sha);
1526
1527        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("after.md"), b"after")
1528            .unwrap();
1529
1530        let new_tip = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1531            &writer,
1532            "pinned commit",
1533            &ctx_for_test(),
1534            Some(&expected),
1535        )
1536        .expect("parent matches pin → commit must succeed");
1537        assert_ne!(new_tip, seed_sha);
1538        assert_eq!(
1539            read_blob(&gitdir, "refs/heads/test", "after.md").unwrap(),
1540            b"after"
1541        );
1542    }
1543
1544    #[test]
1545    fn commit_with_expected_parent_surfaces_parent_mismatch_when_sibling_advances_ref() {
1546        use memstead_base::backend::{BackendError, MemBackend};
1547
1548        let tmp = TempDir::new().unwrap();
1549        let gitdir = fresh_repo_dir(tmp.path());
1550
1551        // Seed so both writers start from the same commit.
1552        let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1553        <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1554        let seed_sha =
1555            <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1556
1557        // Engine A snapshots head — this is the pin it will retain
1558        // through any number of intermediate writes.
1559        let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1560        let pin = <GitTreeMemWriter as MemBackend>::current_head(&a)
1561            .unwrap()
1562            .expect("seeded ref has a head");
1563        assert_eq!(pin, seed_sha);
1564
1565        // A sibling writer (another engine instance, manual git op,
1566        // out-of-band CLI invocation, …) advances the ref between A's
1567        // snapshot and A's commit attempt.
1568        let sibling = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1569        <GitTreeMemWriter as MemWriter>::write_entity(&sibling, Path::new("drift.md"), b"drift")
1570            .unwrap();
1571        let new_tip =
1572            <GitTreeMemWriter as MemWriter>::commit(&sibling, "sibling advance", &ctx_for_test())
1573                .unwrap();
1574        assert_ne!(new_tip, seed_sha);
1575
1576        // A now tries to land a pinned commit. The pin no longer
1577        // matches the live tip → typed `ParentMismatch`.
1578        <GitTreeMemWriter as MemWriter>::write_entity(&a, Path::new("a.md"), b"a").unwrap();
1579        let err = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1580            &a,
1581            "pinned commit",
1582            &ctx_for_test(),
1583            Some(&pin),
1584        )
1585        .expect_err("pin no longer matches live tip → commit must refuse");
1586        match err {
1587            BackendError::ParentMismatch { expected, actual } => {
1588                assert_eq!(expected, pin);
1589                assert_eq!(actual, new_tip);
1590            }
1591            other => panic!("expected ParentMismatch, got {other:?}"),
1592        }
1593    }
1594
1595    #[test]
1596    fn commit_with_expected_parent_none_pin_is_equivalent_to_commit() {
1597        use memstead_base::backend::MemBackend;
1598
1599        let tmp = TempDir::new().unwrap();
1600        let gitdir = fresh_repo_dir(tmp.path());
1601        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1602
1603        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("hello.md"), b"hi")
1604            .unwrap();
1605        let sha = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1606            &writer,
1607            "unpinned",
1608            &ctx_for_test(),
1609            None,
1610        )
1611        .expect("None pin → plain commit semantics, must succeed against empty ref");
1612        assert_eq!(sha.len(), 40);
1613        assert_eq!(
1614            read_blob(&gitdir, "refs/heads/test", "hello.md").unwrap(),
1615            b"hi"
1616        );
1617    }
1618
1619    #[test]
1620    fn git_tree_writer_blob_oid_is_content_addressed() {
1621        // The git-tree writer is content-addressed: writing the same
1622        // bytes through two independent writers must yield the same
1623        // blob OID, since the OID is a hash of the content.
1624        let tmp_a = TempDir::new().unwrap();
1625        let tmp_b = TempDir::new().unwrap();
1626        let payload = b"shared content\n";
1627
1628        let gitdir_a = fresh_repo_dir(tmp_a.path());
1629        let writer_a = GitTreeMemWriter::new(gitdir_a.clone(), "refs/heads/a".to_string());
1630        writer_a
1631            .write_entity(Path::new("file.md"), payload)
1632            .unwrap();
1633        let sha_a = writer_a.commit("a", &ctx_for_test()).unwrap();
1634        let repo_a = gix::open(&gitdir_a).unwrap();
1635        let commit_a = repo_a
1636            .find_object(gix::ObjectId::from_hex(sha_a.as_bytes()).unwrap())
1637            .unwrap()
1638            .into_commit();
1639        let blob_id_a = commit_a
1640            .tree()
1641            .unwrap()
1642            .lookup_entry_by_path("file.md")
1643            .unwrap()
1644            .unwrap()
1645            .id()
1646            .detach();
1647
1648        let gitdir_b = fresh_repo_dir(tmp_b.path());
1649        let writer_b = GitTreeMemWriter::new(gitdir_b.clone(), "refs/heads/b".to_string());
1650        writer_b
1651            .write_entity(Path::new("file.md"), payload)
1652            .unwrap();
1653        let sha_b = writer_b.commit("b", &ctx_for_test()).unwrap();
1654        let repo_b = gix::open(&gitdir_b).unwrap();
1655        let commit_b = repo_b
1656            .find_object(gix::ObjectId::from_hex(sha_b.as_bytes()).unwrap())
1657            .unwrap()
1658            .into_commit();
1659        let blob_id_b = commit_b
1660            .tree()
1661            .unwrap()
1662            .lookup_entry_by_path("file.md")
1663            .unwrap()
1664            .unwrap()
1665            .id()
1666            .detach();
1667
1668        assert_eq!(
1669            blob_id_a, blob_id_b,
1670            "same content must produce byte-identical blob OIDs"
1671        );
1672    }
1673
1674    /// Initialise a non-bare repo at `<workdir>` with `refs/heads/main`
1675    /// as the symbolic HEAD. Returns `(workdir, gitdir)` — the workdir
1676    /// is what GitHub Desktop would open; the gitdir is what
1677    /// `GitTreeMemWriter::new` consumes.
1678    fn fresh_non_bare_repo(tmp: &Path) -> (PathBuf, PathBuf) {
1679        let workdir = tmp.join("mem-repo-workdir");
1680        std::fs::create_dir_all(&workdir).unwrap();
1681        let status = std::process::Command::new("git")
1682            .arg("-C")
1683            .arg(&workdir)
1684            .args(["init", "--initial-branch=main", "--quiet"])
1685            .status()
1686            .expect("git init must succeed");
1687        assert!(status.success(), "git init failed");
1688        let workdir = std::fs::canonicalize(&workdir).unwrap();
1689        let gitdir = workdir.join(".git");
1690        (workdir, gitdir)
1691    }
1692
1693    #[test]
1694    fn sync_helper_skips_on_bare_repo() {
1695        let tmp = TempDir::new().unwrap();
1696        let gitdir = fresh_repo_dir(tmp.path());
1697        let repo = gix::open(&gitdir).unwrap();
1698
1699        // No working tree exists — the helper must short-circuit Ok(())
1700        // regardless of the ref name passed.
1701        sync_index_and_worktree(&repo, "refs/heads/main").unwrap();
1702    }
1703
1704    #[test]
1705    fn sync_helper_updates_worktree_when_ref_matches_head() {
1706        let tmp = TempDir::new().unwrap();
1707        let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1708        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1709
1710        writer
1711            .write_entity(Path::new("configs/alpha.json"), b"{\"name\":\"alpha\"}\n")
1712            .unwrap();
1713        writer.commit("seed alpha", &ctx_for_test()).unwrap();
1714
1715        // The writer's commit() invokes sync_index_and_worktree via the
1716        // post-commit hook — the file must now exist on disk.
1717        let on_disk = workdir.join("configs/alpha.json");
1718        assert!(
1719            on_disk.exists(),
1720            "worktree sync must materialise the new blob at {}",
1721            on_disk.display()
1722        );
1723        let bytes = std::fs::read(&on_disk).unwrap();
1724        assert_eq!(bytes, b"{\"name\":\"alpha\"}\n");
1725
1726        // git status is also clean (HEAD == index == worktree).
1727        let output = std::process::Command::new("git")
1728            .arg("-C")
1729            .arg(&workdir)
1730            .args(["status", "--porcelain"])
1731            .output()
1732            .unwrap();
1733        assert!(
1734            output.stdout.is_empty(),
1735            "git status --porcelain must be empty post-sync, got: {:?}",
1736            String::from_utf8_lossy(&output.stdout)
1737        );
1738    }
1739
1740    #[test]
1741    fn sync_helper_skips_when_ref_does_not_match_head() {
1742        let tmp = TempDir::new().unwrap();
1743        let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1744
1745        // Write to refs/heads/feature; HEAD still points at
1746        // refs/heads/main. The worktree must NOT receive the feature
1747        // branch's content.
1748        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/feature".to_string());
1749        writer
1750            .write_entity(Path::new("only-on-feature.md"), b"feature-only\n")
1751            .unwrap();
1752        writer
1753            .commit("first commit on feature", &ctx_for_test())
1754            .unwrap();
1755
1756        // Object store has the blob on the feature branch...
1757        assert!(tree_path_exists(
1758            &gitdir,
1759            "refs/heads/feature",
1760            "only-on-feature.md"
1761        ));
1762        // ...but the worktree (which reflects main) does not.
1763        assert!(
1764            !workdir.join("only-on-feature.md").exists(),
1765            "worktree must not be polluted by writes to a non-checked-out branch"
1766        );
1767    }
1768
1769    #[test]
1770    fn sync_helper_preserves_untracked_files() {
1771        let tmp = TempDir::new().unwrap();
1772        let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1773        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1774
1775        // Drop an untracked file in the workdir before any engine
1776        // commit runs. `git read-tree --reset -u HEAD` only touches
1777        // tracked-file state; untracked content must survive.
1778        let untracked = workdir.join("scratch.txt");
1779        std::fs::write(&untracked, b"operator notes\n").unwrap();
1780
1781        writer
1782            .write_entity(Path::new("seed.md"), b"seed\n")
1783            .unwrap();
1784        writer.commit("create seed", &ctx_for_test()).unwrap();
1785
1786        assert!(
1787            untracked.exists(),
1788            "sync must leave untracked files in place"
1789        );
1790        assert_eq!(std::fs::read(&untracked).unwrap(), b"operator notes\n");
1791        // The tracked entity is also materialised.
1792        assert_eq!(std::fs::read(workdir.join("seed.md")).unwrap(), b"seed\n");
1793    }
1794
1795    #[test]
1796    fn sync_helper_updates_through_delete_and_overwrite() {
1797        let tmp = TempDir::new().unwrap();
1798        let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1799        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1800
1801        writer.write_entity(Path::new("a.md"), b"first\n").unwrap();
1802        writer.commit("create a", &ctx_for_test()).unwrap();
1803        assert_eq!(std::fs::read(workdir.join("a.md")).unwrap(), b"first\n");
1804
1805        writer.write_entity(Path::new("a.md"), b"second\n").unwrap();
1806        writer.commit("overwrite a", &ctx_for_test()).unwrap();
1807        assert_eq!(
1808            std::fs::read(workdir.join("a.md")).unwrap(),
1809            b"second\n",
1810            "overwrite must propagate to the worktree"
1811        );
1812
1813        writer.delete_entity(Path::new("a.md")).unwrap();
1814        writer.commit("delete a", &ctx_for_test()).unwrap();
1815        assert!(
1816            !workdir.join("a.md").exists(),
1817            "delete must remove the file from the worktree"
1818        );
1819
1820        // git status remains clean across all three transitions.
1821        let output = std::process::Command::new("git")
1822            .arg("-C")
1823            .arg(&workdir)
1824            .args(["status", "--porcelain"])
1825            .output()
1826            .unwrap();
1827        assert!(
1828            output.stdout.is_empty(),
1829            "git status --porcelain must be empty after every commit, got: {:?}",
1830            String::from_utf8_lossy(&output.stdout)
1831        );
1832    }
1833
1834    // ----- MemBackend impl -----------------------------------------
1835
1836    /// Build a CommitContext that produces an `memstead: <verb> <id>`
1837    /// subject with a given verb. The agent-notes parser keys off the
1838    /// subject's verb to recover the mutation kind.
1839    fn commit_with_verb(
1840        writer: &GitTreeMemWriter,
1841        verb: &str,
1842        entity_id: &str,
1843        ctx: &CommitContext<'_>,
1844    ) {
1845        let subject = format!("memstead: {verb} {entity_id}");
1846        <GitTreeMemWriter as MemWriter>::commit(writer, &subject, ctx).unwrap();
1847    }
1848
1849    fn ctx_with_note<'a>(note: &'a str) -> CommitContext<'a> {
1850        CommitContext {
1851            actor: Actor::Agent,
1852            client: Some(ClientId {
1853                name: "claude-code".to_string(),
1854                version: "2.1.0".to_string(),
1855            }),
1856            tool: Some("memstead_create"),
1857            note: Some(note.to_string()),
1858            role: Default::default(),
1859            logical_operation_id: None,
1860            entity_ids: None,
1861        }
1862    }
1863
1864    #[test]
1865    fn backend_list_entities_returns_only_md_outside_memstead_namespace() {
1866        use memstead_base::backend::MemBackend;
1867
1868        let tmp = TempDir::new().unwrap();
1869        let gitdir = fresh_repo_dir(tmp.path());
1870        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1871
1872        // Seed via MemWriter (fully-qualified to avoid trait
1873        // ambiguity once MemBackend enters scope below).
1874        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"# a").unwrap();
1875        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"# b")
1876            .unwrap();
1877        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
1878            .unwrap();
1879        <GitTreeMemWriter as MemWriter>::write_entity(
1880            &writer,
1881            Path::new(".memstead/config.json"),
1882            b"{}",
1883        )
1884        .unwrap();
1885        <GitTreeMemWriter as MemWriter>::write_entity(
1886            &writer,
1887            Path::new(".memstead/notes.md"),
1888            b"# skip me",
1889        )
1890        .unwrap();
1891        <GitTreeMemWriter as MemWriter>::write_entity(
1892            &writer,
1893            Path::new(".other/notes.md"),
1894            b"# no longer special, walked like any non-meta dir",
1895        )
1896        .unwrap();
1897        <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1898
1899        let backend: &dyn MemBackend = &writer;
1900        let mut paths: Vec<String> = backend
1901            .list_entities()
1902            .unwrap()
1903            .into_iter()
1904            .map(|p| p.to_string_lossy().into_owned())
1905            .collect();
1906        paths.sort();
1907        // `.memstead/` stays skipped; an ordinary dot-dir is walked.
1908        assert_eq!(
1909            paths,
1910            vec![
1911                ".other/notes.md".to_string(),
1912                "a.md".to_string(),
1913                "nested/b.md".to_string(),
1914            ]
1915        );
1916    }
1917
1918    #[test]
1919    fn backend_list_entities_returns_empty_for_missing_branch() {
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, "refs/heads/never".to_string());
1925        let backend: &dyn MemBackend = &writer;
1926        // Branch never created → empty, no error.
1927        assert!(backend.list_entities().unwrap().is_empty());
1928    }
1929
1930    #[test]
1931    fn backend_read_entity_consults_pending_then_branch_tip() {
1932        use memstead_base::backend::MemBackend;
1933
1934        let tmp = TempDir::new().unwrap();
1935        let gitdir = fresh_repo_dir(tmp.path());
1936        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1937
1938        // Seed a committed entry.
1939        <GitTreeMemWriter as MemWriter>::write_entity(
1940            &writer,
1941            Path::new("on_branch.md"),
1942            b"branch",
1943        )
1944        .unwrap();
1945        <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1946
1947        let backend: &dyn MemBackend = &writer;
1948        // Branch path → reads from the branch tip.
1949        assert_eq!(
1950            backend.read_entity(Path::new("on_branch.md")).unwrap(),
1951            Some(b"branch".to_vec())
1952        );
1953        // Buffered upsert wins over the branch tip.
1954        backend
1955            .write_entity(Path::new("on_branch.md"), b"buffered")
1956            .unwrap();
1957        assert_eq!(
1958            backend.read_entity(Path::new("on_branch.md")).unwrap(),
1959            Some(b"buffered".to_vec())
1960        );
1961        // Buffered delete masks the branch.
1962        backend.delete_entity(Path::new("on_branch.md")).unwrap();
1963        assert_eq!(
1964            backend.read_entity(Path::new("on_branch.md")).unwrap(),
1965            None
1966        );
1967        // Unknown path → None.
1968        assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
1969    }
1970
1971    #[test]
1972    fn backend_read_provenance_reconstructs_from_commit_log() {
1973        use memstead_base::backend::MemBackend;
1974
1975        let tmp = TempDir::new().unwrap();
1976        let gitdir = fresh_repo_dir(tmp.path());
1977        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1978
1979        // Two commits with memstead: subjects so the verb maps back to a
1980        // ProvenanceKind. The first carries an agent note, the second
1981        // does not.
1982        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
1983        commit_with_verb(&writer, "create", "v:a", &ctx_with_note("first draft"));
1984
1985        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
1986        commit_with_verb(
1987            &writer,
1988            "update",
1989            "v:a",
1990            &CommitContext {
1991                actor: Actor::Cli,
1992                client: None,
1993                tool: Some("memstead_update"),
1994                note: None,
1995                role: Default::default(),
1996                logical_operation_id: None,
1997                entity_ids: None,
1998            },
1999        );
2000
2001        let backend: &dyn MemBackend = &writer;
2002        // append_provenance is the no-op contract; calling it with a
2003        // throw-away record must not perturb the read path.
2004        backend
2005            .append_provenance(&memstead_base::Provenance::new(
2006                std::time::UNIX_EPOCH,
2007                memstead_base::ProvenanceKind::Create,
2008                Some("ignored".into()),
2009                Actor::Unknown,
2010                None,
2011                None,
2012            ))
2013            .unwrap();
2014
2015        let records = backend.read_provenance(None).unwrap();
2016        assert_eq!(records.len(), 2, "expected two commits, got {records:?}");
2017        // Oldest-first ordering (matches folder backend).
2018        assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Create);
2019        assert_eq!(records[0].entity.as_deref(), Some("v:a"));
2020        assert_eq!(records[0].actor, Actor::Agent);
2021        assert_eq!(records[0].note.as_deref(), Some("first draft"));
2022        assert_eq!(
2023            records[0]
2024                .client
2025                .as_ref()
2026                .map(|c| (c.name.as_str(), c.version.as_str())),
2027            Some(("claude-code", "2.1.0"))
2028        );
2029        assert_eq!(records[1].kind, memstead_base::ProvenanceKind::Update);
2030        assert_eq!(records[1].actor, Actor::Cli);
2031        assert!(records[1].note.is_none());
2032        assert!(records[1].client.is_none());
2033    }
2034
2035    #[test]
2036    fn backend_read_provenance_filters_by_cursor_sha() {
2037        use memstead_base::backend::MemBackend;
2038
2039        let tmp = TempDir::new().unwrap();
2040        let gitdir = fresh_repo_dir(tmp.path());
2041        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2042
2043        // Seed three commits; the cursor will be the SHA of the first.
2044        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2045        let first_sha = <GitTreeMemWriter as MemWriter>::commit(
2046            &writer,
2047            "memstead: create v:a",
2048            &ctx_for_test(),
2049        )
2050        .unwrap();
2051        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
2052        <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
2053            .unwrap();
2054        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a3").unwrap();
2055        <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
2056            .unwrap();
2057
2058        let backend: &dyn MemBackend = &writer;
2059        // Cursor at the first SHA → returns only the two newer commits.
2060        let after = backend.read_provenance(Some(&first_sha)).unwrap();
2061        assert_eq!(
2062            after.len(),
2063            2,
2064            "expected commits after cursor, got {after:?}"
2065        );
2066        for r in &after {
2067            assert_eq!(r.kind, memstead_base::ProvenanceKind::Update);
2068        }
2069    }
2070
2071    #[test]
2072    fn backend_read_provenance_empty_for_missing_branch() {
2073        use memstead_base::backend::MemBackend;
2074
2075        let tmp = TempDir::new().unwrap();
2076        let gitdir = fresh_repo_dir(tmp.path());
2077        let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
2078        let backend: &dyn MemBackend = &writer;
2079        // No commits yet → empty record list, no error.
2080        assert!(backend.read_provenance(None).unwrap().is_empty());
2081    }
2082
2083    #[test]
2084    fn backend_unknown_verb_falls_back_to_update_kind() {
2085        use memstead_base::backend::MemBackend;
2086
2087        let tmp = TempDir::new().unwrap();
2088        let gitdir = fresh_repo_dir(tmp.path());
2089        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2090
2091        <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2092        // Verb that isn't in the ProvenanceKind enum (e.g. lifecycle
2093        // verbs like `mem_create`) — round-trips as Update under the
2094        // tolerant-reader convention shared with the folder backend.
2095        commit_with_verb(&writer, "mem_create", "v:a", &ctx_for_test());
2096
2097        let backend: &dyn MemBackend = &writer;
2098        let records = backend.read_provenance(None).unwrap();
2099        assert_eq!(records.len(), 1);
2100        assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Update);
2101    }
2102
2103    #[test]
2104    fn instantiate_full_backend_constructs_git_branch_writer() {
2105        // Smoke test: instantiate_full_backend on a GitBranch mount
2106        // produces a backend that can list against an empty branch
2107        // without erroring (proves the writer is wired with the
2108        // right gitdir + ref shape).
2109        use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2110
2111        let tmp = TempDir::new().unwrap();
2112        let gitdir = fresh_repo_dir(tmp.path());
2113        let mount = Mount {
2114            mem: "engine".to_string(),
2115            schema: Some("default@1.0.0".parse().unwrap()),
2116            storage: MountStorage::GitBranch {
2117                gitdir,
2118                branch: "engine".to_string(),
2119            },
2120            capability: MountCapability::Write,
2121            lifecycle: MountLifecycle::Eager,
2122            cross_linkable: true,
2123            migration_target: None,
2124        };
2125        let backend: Box<dyn MemBackend> =
2126            crate::storage::instantiate_full_backend(&mount).unwrap();
2127        // Empty branch → empty list, no error.
2128        assert!(backend.list_entities().unwrap().is_empty());
2129        // Provenance log on a fresh branch → empty.
2130        assert!(backend.read_provenance(None).unwrap().is_empty());
2131    }
2132
2133    #[test]
2134    fn instantiate_full_backend_accepts_branch_with_or_without_refs_prefix() {
2135        // The full instantiator normalises a bare branch name
2136        // ("engine") to its fully-qualified ref ("refs/heads/engine").
2137        // Mounts may carry either shape; the writer must end up keyed
2138        // on the same per-branch mutex regardless.
2139        use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2140
2141        let tmp = TempDir::new().unwrap();
2142        let gitdir = fresh_repo_dir(tmp.path());
2143        for branch in ["engine", "refs/heads/engine"] {
2144            let mount = Mount {
2145                mem: "engine".to_string(),
2146                schema: Some("default@1.0.0".parse().unwrap()),
2147                storage: MountStorage::GitBranch {
2148                    gitdir: gitdir.clone(),
2149                    branch: branch.to_string(),
2150                },
2151                capability: MountCapability::Write,
2152                lifecycle: MountLifecycle::Eager,
2153                cross_linkable: true,
2154                migration_target: None,
2155            };
2156            let backend: Box<dyn MemBackend> =
2157                crate::storage::instantiate_full_backend(&mount).unwrap();
2158            // Both shapes resolve cleanly (no panic, no error).
2159            assert!(backend.list_entities().unwrap().is_empty());
2160        }
2161    }
2162
2163    // ---- MemBackend::current_head ----------------------------------
2164
2165    #[test]
2166    fn current_head_returns_none_for_empty_branch() {
2167        // A fresh bare repo has no commits and no branches; the
2168        // writer's `try_find_reference` returns Ok(None) and
2169        // current_head collapses to Ok(None) — drift detection on
2170        // an unborn mem is a clean no-op.
2171        let tmp = TempDir::new().unwrap();
2172        let gitdir = fresh_repo_dir(tmp.path());
2173        let writer = GitTreeMemWriter::new(gitdir, "refs/heads/specs".to_string());
2174        let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2175            .unwrap();
2176        assert!(head.is_none());
2177    }
2178
2179    #[test]
2180    fn current_head_returns_hex_sha_after_commit() {
2181        // After the first commit, current_head returns the 40-char
2182        // hex SHA matching what `commit` returned. The two values
2183        // are read through different paths (commit returns the value
2184        // straight from the writer; current_head re-opens the gitdir
2185        // and peels the ref) so equality proves end-to-end consistency.
2186        let tmp = TempDir::new().unwrap();
2187        let gitdir = fresh_repo_dir(tmp.path());
2188        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2189
2190        writer.write_entity(Path::new("a.md"), b"a").unwrap();
2191        let sha = writer.commit("first", &ctx_for_test()).unwrap();
2192        assert_eq!(sha.len(), 40);
2193
2194        let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2195            .unwrap()
2196            .expect("head present after commit");
2197        assert_eq!(head, sha);
2198    }
2199
2200    #[test]
2201    fn current_head_advances_on_subsequent_commits() {
2202        // Two back-to-back commits produce two distinct SHAs;
2203        // current_head reflects the latest after each. This is the
2204        // signal Engine::reload_if_stale compares against the
2205        // cached last_known_head to detect a sibling writer.
2206        let tmp = TempDir::new().unwrap();
2207        let gitdir = fresh_repo_dir(tmp.path());
2208        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2209
2210        writer.write_entity(Path::new("a.md"), b"a").unwrap();
2211        let first = writer.commit("first", &ctx_for_test()).unwrap();
2212        let head_after_first =
2213            <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2214                .unwrap()
2215                .unwrap();
2216        assert_eq!(head_after_first, first);
2217
2218        writer.write_entity(Path::new("b.md"), b"b").unwrap();
2219        let second = writer.commit("second", &ctx_for_test()).unwrap();
2220        assert_ne!(first, second);
2221        let head_after_second =
2222            <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2223                .unwrap()
2224                .unwrap();
2225        assert_eq!(head_after_second, second);
2226    }
2227
2228    #[test]
2229    fn current_head_returns_none_for_missing_gitdir() {
2230        // A writer pointed at a non-existent gitdir collapses to
2231        // Ok(None) (with a debug log) rather than surfacing the
2232        // open failure as an Err. Drift detection is best-effort —
2233        // a transient broken mount doesn't poison the read it
2234        // accompanies.
2235        let tmp = TempDir::new().unwrap();
2236        let writer = GitTreeMemWriter::new(
2237            tmp.path().join("does-not-exist.git"),
2238            "refs/heads/specs".to_string(),
2239        );
2240        let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2241            .unwrap();
2242        assert!(head.is_none());
2243    }
2244
2245    // ---- git-branch changes_since dispatch --------------------------
2246    //
2247    // Tests the `FULL_GIT_BRANCH_OPS.changes_since` dispatcher that
2248    // full boot installs on `memstead_base::Engine`. The dispatcher wraps
2249    // `crate::ops::changes::changes_since` and presents it through the
2250    // `memstead_base::GitBranchChangesSinceFn` signature.
2251
2252    fn dispatch_changes(
2253        gitdir: &Path,
2254        branch: &str,
2255        mem: &str,
2256        since: &str,
2257    ) -> Result<memstead_base::ops::BackendChanges, memstead_base::backend::BackendError> {
2258        (crate::storage::FULL_GIT_BRANCH_OPS.changes_since)(
2259            gitdir,
2260            branch,
2261            mem,
2262            since,
2263            memstead_base::ops::RENAME_SIMILARITY_DEFAULT,
2264        )
2265    }
2266
2267    #[test]
2268    fn changes_since_empty_repo_with_sentinel_returns_empty_changes() {
2269        // Fresh bare repo: no commits, no branches. With the empty-tree
2270        // sentinel as `since`, the dispatcher short-circuits to "no
2271        // diff, head echoes sentinel".
2272        let tmp = TempDir::new().unwrap();
2273        let gitdir = fresh_repo_dir(tmp.path());
2274        let result = dispatch_changes(
2275            &gitdir,
2276            "specs",
2277            "specs",
2278            memstead_base::ops::EMPTY_TREE_SHA,
2279        )
2280        .unwrap();
2281        assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2282        assert_eq!(result.head, memstead_base::ops::EMPTY_TREE_SHA);
2283        assert!(result.changes.is_empty());
2284    }
2285
2286    #[test]
2287    fn changes_since_after_commit_returns_added_envelopes_id_only() {
2288        // Commit two new entities, poll from the empty-tree sentinel,
2289        // and expect both as Added envelopes. Dispatch returns id-only
2290        // envelopes — the engine wrapper enriches.
2291        use memstead_base::ops::ChangeEnvelope;
2292        let tmp = TempDir::new().unwrap();
2293        let gitdir = fresh_repo_dir(tmp.path());
2294        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2295
2296        writer
2297            .write_entity(Path::new("alpha.md"), b"# Alpha")
2298            .unwrap();
2299        writer
2300            .write_entity(Path::new("beta.md"), b"# Beta")
2301            .unwrap();
2302        let head_sha = writer.commit("seed", &ctx_for_test()).unwrap();
2303
2304        let result = dispatch_changes(
2305            &gitdir,
2306            "specs",
2307            "specs",
2308            memstead_base::ops::EMPTY_TREE_SHA,
2309        )
2310        .unwrap();
2311        assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2312        assert_eq!(result.head, head_sha);
2313        assert_eq!(result.changes.len(), 2);
2314        for env in &result.changes {
2315            match env {
2316                ChangeEnvelope::Added {
2317                    id,
2318                    title,
2319                    entity_type,
2320                } => {
2321                    assert!(
2322                        id.0.starts_with("specs--"),
2323                        "expected mem-prefixed id, got {}",
2324                        id.0
2325                    );
2326                    assert!(title.is_none(), "dispatch must not enrich title");
2327                    assert!(
2328                        entity_type.is_none(),
2329                        "dispatch must not enrich entity_type"
2330                    );
2331                }
2332                other => panic!("expected Added envelope, got {other:?}"),
2333            }
2334        }
2335    }
2336
2337    #[test]
2338    fn changes_since_between_two_commits_yields_updated_envelope() {
2339        use memstead_base::ops::ChangeEnvelope;
2340        let tmp = TempDir::new().unwrap();
2341        let gitdir = fresh_repo_dir(tmp.path());
2342        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2343
2344        writer
2345            .write_entity(Path::new("alpha.md"), b"# Alpha v1")
2346            .unwrap();
2347        let sha_v1 = writer.commit("v1", &ctx_for_test()).unwrap();
2348
2349        writer
2350            .write_entity(Path::new("alpha.md"), b"# Alpha v2")
2351            .unwrap();
2352        let sha_v2 = writer.commit("v2", &ctx_for_test()).unwrap();
2353        assert_ne!(sha_v1, sha_v2);
2354
2355        let result = dispatch_changes(&gitdir, "specs", "specs", &sha_v1).unwrap();
2356        assert_eq!(result.since, sha_v1);
2357        assert_eq!(result.head, sha_v2);
2358        assert_eq!(result.changes.len(), 1);
2359        match &result.changes[0] {
2360            ChangeEnvelope::Updated {
2361                id,
2362                title,
2363                entity_type,
2364            } => {
2365                assert!(id.0.starts_with("specs--"));
2366                assert!(title.is_none());
2367                assert!(entity_type.is_none());
2368            }
2369            other => panic!("expected Updated envelope, got {other:?}"),
2370        }
2371    }
2372
2373    #[test]
2374    fn anchor_only_commit_yields_zero_entity_deltas_and_valid_cursor() {
2375        // Seed an entity, then land an anchor-only commit (only the
2376        // `.memstead/anchors.json` sidecar changed). changes_since from the
2377        // seed head must report ZERO entity deltas — the sidecar lives under
2378        // `.memstead/` which the entity-delta computation filters — while
2379        // the anchor commit's SHA is a valid `since` cursor.
2380        use memstead_base::backend::MemBackend;
2381        let tmp = TempDir::new().unwrap();
2382        let gitdir = fresh_repo_dir(tmp.path());
2383        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2384
2385        MemBackend::write_entity(&writer, Path::new("alpha.md"), b"# Alpha").unwrap();
2386        let seed_sha = MemBackend::commit(&writer, "seed", &ctx_for_test()).unwrap();
2387
2388        // Anchor-only commit: no entity write, just the sidecar.
2389        writer
2390            .write_anchors_sidecar(
2391                br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#,
2392            )
2393            .unwrap();
2394        let anchor_sha = MemBackend::commit(&writer, "anchors", &ctx_for_test()).unwrap();
2395        assert_ne!(seed_sha, anchor_sha);
2396
2397        // Zero entity deltas across the anchor-only commit.
2398        let from_seed = dispatch_changes(&gitdir, "specs", "specs", &seed_sha).unwrap();
2399        assert_eq!(from_seed.head, anchor_sha);
2400        assert_eq!(
2401            from_seed.changes.len(),
2402            0,
2403            "an anchor-only commit must produce zero entity deltas, got {:?}",
2404            from_seed.changes
2405        );
2406
2407        // The anchor commit's SHA is itself a valid cursor (resolves; no
2408        // deltas after it).
2409        let from_anchor = dispatch_changes(&gitdir, "specs", "specs", &anchor_sha).unwrap();
2410        assert_eq!(from_anchor.head, anchor_sha);
2411        assert_eq!(from_anchor.changes.len(), 0);
2412    }
2413
2414    #[test]
2415    fn changes_since_unknown_cursor_returns_typed_commit_not_found_marker() {
2416        // A `since` that
2417        // doesn't resolve is a recoverable caller-argument fault, not a
2418        // backend fault. The dispatch encodes it as the typed prefix
2419        // `COMMIT_NOT_FOUND:<sha>` (untruncated) that `Engine::changes_since`
2420        // lifts to `EngineError::InvalidChangesCursor` (code INVALID_CURSOR)
2421        // — distinct from the generic `git-branch changes_since: …`
2422        // wrapper used for real backend faults.
2423        let tmp = TempDir::new().unwrap();
2424        let gitdir = fresh_repo_dir(tmp.path());
2425        let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2426        // Seed one commit so the gitdir is not empty.
2427        writer.write_entity(Path::new("a.md"), b"a").unwrap();
2428        writer.commit("seed", &ctx_for_test()).unwrap();
2429
2430        let bad_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2431        let err = dispatch_changes(&gitdir, "specs", "specs", bad_sha).unwrap_err();
2432        match err {
2433            memstead_base::backend::BackendError::Other(msg) => {
2434                assert_eq!(
2435                    msg,
2436                    format!("COMMIT_NOT_FOUND:{bad_sha}"),
2437                    "bad-since must carry the typed marker with the untruncated sha: {msg}",
2438                );
2439            }
2440            other => panic!("expected BackendError::Other, got {other:?}"),
2441        }
2442    }
2443}