Skip to main content

memstead_git_branch/storage/
git_tree.rs

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