Skip to main content

memstead_git_branch/storage/
git_tree.rs

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