1use 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
41enum PendingState {
45 Upsert(Vec<u8>),
46 Delete,
47}
48
49struct Pending {
53 parent: Option<gix::ObjectId>,
59 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
79pub struct GitTreeMemWriter {
87 gitdir: PathBuf,
88 ref_name: String,
89 pending: Mutex<Pending>,
90}
91
92impl GitTreeMemWriter {
93 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 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 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 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 fn path_exists_from_parent(
207 &self,
208 parent: gix::ObjectId,
209 path: &str,
210 ) -> Result<bool, MemWriterError> {
211 let repo = self.open_repo()?;
212 let commit = repo
213 .find_object(parent)
214 .map_err(|e| MemWriterError::Path(format!("git-tree writer: open parent commit: {e}")))?
215 .into_commit();
216 let tree = commit.tree().map_err(|e| {
217 MemWriterError::Path(format!("git-tree writer: peel commit to tree: {e}"))
218 })?;
219 let entry = tree.lookup_entry_by_path(path).map_err(|e| {
220 MemWriterError::Path(format!(
221 "git-tree writer: lookup {path} in parent tree: {e}"
222 ))
223 })?;
224 Ok(entry.is_some_and(|e| e.mode().is_blob()))
225 }
226}
227
228fn normalise_rel_path(rel_path: &Path) -> Result<String, MemWriterError> {
233 if rel_path.as_os_str().is_empty() {
234 return Err(MemWriterError::Path(
235 "mem-relative path is empty".to_string(),
236 ));
237 }
238 let mut parts: Vec<String> = Vec::new();
239 for component in rel_path.components() {
240 use std::path::Component;
241 match component {
242 Component::Normal(s) => match s.to_str() {
243 Some(p) if !p.is_empty() => parts.push(p.to_string()),
244 _ => {
245 return Err(MemWriterError::Path(format!(
246 "non-utf-8 or empty path component in {}",
247 rel_path.display()
248 )));
249 }
250 },
251 Component::CurDir => continue,
252 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
253 return Err(MemWriterError::Path(format!(
254 "path traversal or absolute component in {}",
255 rel_path.display()
256 )));
257 }
258 }
259 }
260 if parts.is_empty() {
261 return Err(MemWriterError::Path(
262 "mem-relative path is empty after normalisation".to_string(),
263 ));
264 }
265 Ok(parts.join("/"))
266}
267
268impl MemWriter for GitTreeMemWriter {
269 fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), MemWriterError> {
270 let key = normalise_rel_path(rel_path)?;
271 let mut pending = self.pending.lock().map_err(|_| {
272 MemWriterError::Path("git-tree writer pending state poisoned".to_string())
273 })?;
274 self.ensure_snapshot(&mut pending)?;
275 pending
276 .ops
277 .insert(key, PendingState::Upsert(content.to_vec()));
278 Ok(())
279 }
280
281 fn delete_entity(&self, rel_path: &Path) -> Result<(), MemWriterError> {
282 let key = normalise_rel_path(rel_path)?;
283 let mut pending = self.pending.lock().map_err(|_| {
284 MemWriterError::Path("git-tree writer pending state poisoned".to_string())
285 })?;
286 self.ensure_snapshot(&mut pending)?;
287 pending.ops.insert(key, PendingState::Delete);
288 Ok(())
289 }
290
291 fn move_entity(&self, from: &Path, to: &Path) -> Result<(), MemWriterError> {
292 let from_key = normalise_rel_path(from)?;
293 let to_key = normalise_rel_path(to)?;
294 let mut pending = self.pending.lock().map_err(|_| {
295 MemWriterError::Path("git-tree writer pending state poisoned".to_string())
296 })?;
297 self.ensure_snapshot(&mut pending)?;
298
299 let bytes = match pending.ops.remove(&from_key) {
303 Some(PendingState::Upsert(b)) => b,
304 Some(PendingState::Delete) => {
305 pending.ops.insert(from_key, PendingState::Delete);
306 return Err(MemWriterError::Path(format!(
307 "move source {} is already pending deletion",
308 from.display()
309 )));
310 }
311 None => {
312 let parent = pending.parent;
313 let blob = match parent {
314 Some(p) => self.read_blob_from_parent(p, &from_key)?,
315 None => None,
316 };
317 match blob {
318 Some(b) => b,
319 None => {
320 return Err(MemWriterError::Path(format!(
321 "move source {} does not exist",
322 from.display()
323 )));
324 }
325 }
326 }
327 };
328
329 if matches!(pending.ops.get(&to_key), Some(PendingState::Upsert(_))) {
330 return Err(MemWriterError::Path(format!(
332 "move target {} already has a pending write",
333 to.display()
334 )));
335 }
336 pending.ops.insert(from_key, PendingState::Delete);
337 pending.ops.insert(to_key, PendingState::Upsert(bytes));
338 Ok(())
339 }
340
341 fn commit(&self, message: &str, ctx: &CommitContext<'_>) -> Result<CommitId, MemWriterError> {
342 let mutex = acquire_branch_mutex(&self.ref_name);
346 let _guard = mutex.lock().map_err(|_| {
347 MemWriterError::Path(format!(
348 "git-tree writer mutex poisoned for ref {} (gitdir {})",
349 self.ref_name,
350 self.gitdir.display()
351 ))
352 })?;
353 let repo = self.open_repo()?;
354
355 let mut pending = self.pending.lock().map_err(|_| {
356 MemWriterError::Path("git-tree writer pending state poisoned".to_string())
357 })?;
358
359 self.ensure_snapshot(&mut pending)?;
363 let parent_snapshot = pending.parent;
364
365 let mut editor = match parent_snapshot {
367 Some(parent_id) => {
368 let commit = repo
369 .find_object(parent_id)
370 .map_err(|e| {
371 MemWriterError::Path(format!(
372 "git-tree writer: open parent {parent_id}: {e}"
373 ))
374 })?
375 .into_commit();
376 let tree = commit.tree().map_err(|e| {
377 MemWriterError::Path(format!("git-tree writer: peel parent tree: {e}"))
378 })?;
379 tree.edit().map_err(|e| {
380 MemWriterError::Path(format!("git-tree writer: editor init: {e}"))
381 })?
382 }
383 None => repo.empty_tree().edit().map_err(|e| {
384 MemWriterError::Path(format!("git-tree writer: empty editor init: {e}"))
385 })?,
386 };
387
388 for (path, state) in pending.ops.iter() {
391 match state {
392 PendingState::Upsert(bytes) => {
393 let blob_id = repo
394 .write_blob(bytes.as_slice())
395 .map_err(|e| {
396 MemWriterError::Path(format!(
397 "git-tree writer: write blob for {path}: {e}"
398 ))
399 })?
400 .detach();
401 editor
402 .upsert(path.as_str(), EntryKind::Blob, blob_id)
403 .map_err(|e| {
404 MemWriterError::Path(format!(
405 "git-tree writer: tree upsert {path}: {e}"
406 ))
407 })?;
408 }
409 PendingState::Delete => {
410 editor.remove(path.as_str()).map_err(|e| {
411 MemWriterError::Path(format!("git-tree writer: tree remove {path}: {e}"))
412 })?;
413 }
414 }
415 }
416
417 let tree_id = editor
418 .write()
419 .map_err(|e| MemWriterError::Path(format!("git-tree writer: tree write: {e}")))?
420 .detach();
421
422 let time = gix::date::Time::now_local_or_utc();
425 let committer_sig = gix::actor::Signature {
426 name: COMMITTER_NAME.into(),
427 email: COMMITTER_EMAIL.into(),
428 time,
429 };
430 let author_sig = match author_identity(ctx) {
431 Some((name, email)) => gix::actor::Signature {
432 name: name.into(),
433 email: email.into(),
434 time,
435 },
436 None => committer_sig.clone(),
437 };
438 let mut author_buf = gix::date::parse::TimeBuf::default();
439 let mut committer_buf = gix::date::parse::TimeBuf::default();
440 let author_ref = author_sig.to_ref(&mut author_buf);
441 let committer_ref = committer_sig.to_ref(&mut committer_buf);
442
443 let full_message = format_commit_message(message, ctx);
444
445 let parents: Vec<gix::ObjectId> = match parent_snapshot {
446 Some(p) => vec![p],
447 None => Vec::new(),
448 };
449 let commit_result = repo.commit_as(
450 committer_ref,
451 author_ref,
452 self.ref_name.as_str(),
453 full_message,
454 tree_id,
455 parents,
456 );
457 pending.clear();
466 let commit_id = match commit_result {
467 Ok(id) => id,
468 Err(gix::commit::Error::ReferenceEdit(_)) => {
469 let mut reference = repo
473 .try_find_reference(&self.ref_name)
474 .map_err(|e| {
475 MemWriterError::Path(format!(
476 "git-tree writer: re-resolve ref after CAS: {e}"
477 ))
478 })?
479 .ok_or_else(|| {
480 MemWriterError::Path(format!(
481 "git-tree writer: ref {} vanished during CAS recovery",
482 self.ref_name
483 ))
484 })?;
485 let live_id = reference.peel_to_id().map_err(|e| {
486 MemWriterError::Path(format!("git-tree writer: peel live tip after CAS: {e}"))
487 })?;
488 return Err(MemWriterError::HashMismatch {
489 current: live_id.to_hex().to_string(),
490 });
491 }
492 Err(e) => {
493 return Err(MemWriterError::Path(format!(
494 "git-tree writer: commit_as failed: {e}"
495 )));
496 }
497 };
498
499 let sha_hex = commit_id.to_hex().to_string();
500
501 sync_index_and_worktree(&repo, &self.ref_name)?;
508
509 Ok(sha_hex)
510 }
511}
512
513impl memstead_base::backend::MemBackend for GitTreeMemWriter {
514 fn storage_present(&self) -> Result<bool, memstead_base::backend::BackendError> {
519 let repo = gix::open(&self.gitdir).map_err(|e| {
520 memstead_base::backend::BackendError::Other(format!(
521 "git-tree backend storage_present: open {}: {e}",
522 self.gitdir.display()
523 ))
524 })?;
525 repo.try_find_reference(&self.ref_name)
526 .map(|r| r.is_some())
527 .map_err(|e| {
528 memstead_base::backend::BackendError::Other(format!(
529 "git-tree backend storage_present: resolve {}: {e}",
530 self.ref_name
531 ))
532 })
533 }
534
535 fn list_entities(&self) -> Result<Vec<PathBuf>, memstead_base::backend::BackendError> {
536 let blobs = match read_branch_blobs(&self.gitdir, &self.ref_name) {
541 Ok(b) => b,
542 Err(BranchReadError::BranchMissing { .. }) => return Ok(Vec::new()),
543 Err(e) => {
544 return Err(memstead_base::backend::BackendError::Other(format!(
545 "git-tree backend list_entities: {e}"
546 )));
547 }
548 };
549 Ok(blobs
550 .into_iter()
551 .filter_map(|b| {
552 if b.path.ends_with(".md") && !b.path.starts_with(".memstead/") {
553 Some(PathBuf::from(b.path))
554 } else {
555 None
556 }
557 })
558 .collect())
559 }
560
561 fn read_entity(
562 &self,
563 rel_path: &Path,
564 ) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
565 let key = normalise_rel_path(rel_path)?;
566 let pending = self.pending.lock().map_err(|_| {
569 memstead_base::backend::BackendError::Other(
570 "git-tree backend pending state poisoned".to_string(),
571 )
572 })?;
573 if let Some(state) = pending.ops.get(&key) {
574 return Ok(match state {
575 PendingState::Upsert(b) => Some(b.clone()),
576 PendingState::Delete => None,
577 });
578 }
579 let snapshot_parent = if pending.ops.is_empty() {
590 None
591 } else {
592 pending.parent
593 };
594 drop(pending);
595
596 let source = match snapshot_parent {
597 Some(p) => Some(p),
598 None => self
599 .live_tip()
600 .map_err(memstead_base::backend::BackendError::from)?,
601 };
602 match source {
603 Some(p) => self
604 .read_blob_from_parent(p, &key)
605 .map_err(memstead_base::backend::BackendError::from),
606 None => Ok(None),
607 }
608 }
609
610 fn entity_exists(&self, rel_path: &Path) -> Result<bool, memstead_base::backend::BackendError> {
615 let key = normalise_rel_path(rel_path)?;
616 let pending = self.pending.lock().map_err(|_| {
617 memstead_base::backend::BackendError::Other(
618 "git-tree backend pending state poisoned".to_string(),
619 )
620 })?;
621 if let Some(state) = pending.ops.get(&key) {
622 return Ok(matches!(state, PendingState::Upsert(_)));
623 }
624 let snapshot_parent = if pending.ops.is_empty() {
625 None
626 } else {
627 pending.parent
628 };
629 drop(pending);
630
631 let source = match snapshot_parent {
632 Some(p) => Some(p),
633 None => self
634 .live_tip()
635 .map_err(memstead_base::backend::BackendError::from)?,
636 };
637 match source {
638 Some(p) => self
639 .path_exists_from_parent(p, &key)
640 .map_err(memstead_base::backend::BackendError::from),
641 None => Ok(false),
642 }
643 }
644
645 fn write_entity(
646 &self,
647 rel_path: &Path,
648 content: &[u8],
649 ) -> Result<(), memstead_base::backend::BackendError> {
650 <Self as MemWriter>::write_entity(self, rel_path, content).map_err(Into::into)
651 }
652
653 fn delete_entity(&self, rel_path: &Path) -> Result<(), memstead_base::backend::BackendError> {
654 <Self as MemWriter>::delete_entity(self, rel_path).map_err(Into::into)
655 }
656
657 fn move_entity(
658 &self,
659 from: &Path,
660 to: &Path,
661 ) -> Result<(), memstead_base::backend::BackendError> {
662 <Self as MemWriter>::move_entity(self, from, to).map_err(Into::into)
663 }
664
665 fn discard_pending(&self) -> Result<(), memstead_base::backend::BackendError> {
666 let mut pending = self.pending.lock().map_err(|_| {
672 memstead_base::backend::BackendError::Other(
673 "git-tree writer pending state poisoned".to_string(),
674 )
675 })?;
676 pending.clear();
677 Ok(())
678 }
679
680 fn commit(
681 &self,
682 message: &str,
683 ctx: &CommitContext<'_>,
684 ) -> Result<CommitId, memstead_base::backend::BackendError> {
685 <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
686 }
687
688 fn commit_with_expected_parent(
689 &self,
690 message: &str,
691 ctx: &CommitContext<'_>,
692 expected_parent: Option<&str>,
693 ) -> Result<CommitId, memstead_base::backend::BackendError> {
694 let Some(expected) = expected_parent else {
696 return <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into);
697 };
698
699 let mutex = acquire_branch_mutex(&self.ref_name);
709 let guard = mutex.lock().map_err(|_| {
710 memstead_base::backend::BackendError::Other(format!(
711 "git-tree writer mutex poisoned for ref {} (gitdir {})",
712 self.ref_name,
713 self.gitdir.display()
714 ))
715 })?;
716
717 let actual = match gix::open(&self.gitdir) {
718 Ok(repo) => match repo.try_find_reference(&self.ref_name) {
719 Ok(Some(mut r)) => r
720 .peel_to_id()
721 .ok()
722 .map(|id| id.detach().to_hex().to_string()),
723 Ok(None) => None,
724 Err(e) => {
725 return Err(memstead_base::backend::BackendError::Other(format!(
726 "git-tree writer: resolve ref {} for parent check: {e}",
727 self.ref_name
728 )));
729 }
730 },
731 Err(e) => {
732 return Err(memstead_base::backend::BackendError::Other(format!(
733 "git-tree writer: open repo at {} for parent check: {e}",
734 self.gitdir.display()
735 )));
736 }
737 };
738 let actual_str = actual.unwrap_or_default();
739 if actual_str != expected {
740 return Err(memstead_base::backend::BackendError::ParentMismatch {
741 expected: expected.to_string(),
742 actual: actual_str,
743 });
744 }
745
746 drop(guard);
747 <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
748 }
749
750 fn append_provenance(
751 &self,
752 _record: &memstead_base::Provenance,
753 ) -> Result<(), memstead_base::backend::BackendError> {
754 Ok(())
764 }
765
766 fn read_provenance(
767 &self,
768 cursor: Option<&str>,
769 ) -> Result<Vec<memstead_base::Provenance>, memstead_base::backend::BackendError> {
770 let since = cursor.unwrap_or(crate::ops::changes::EMPTY_TREE_SHA);
771 let report = match crate::ops::agent_notes::agent_notes_since(
772 "",
773 &self.gitdir,
774 since,
775 Some(&self.ref_name),
776 ) {
777 Ok(r) => r,
778 Err(e) => {
779 return Err(memstead_base::backend::BackendError::Other(format!(
780 "git-tree backend read_provenance: {e}"
781 )));
782 }
783 };
784 let mut out: Vec<memstead_base::Provenance> = report
789 .notes
790 .into_iter()
791 .map(commit_note_to_provenance)
792 .collect();
793 out.reverse();
794 Ok(out)
795 }
796
797 fn current_head(&self) -> Result<Option<String>, memstead_base::backend::BackendError> {
798 let repo = match gix::open(&self.gitdir) {
805 Ok(r) => r,
806 Err(e) => {
807 tracing::debug!(
808 gitdir = %self.gitdir.display(),
809 error = %e,
810 "current_head: open gitdir failed; treating as no baseline"
811 );
812 return Ok(None);
813 }
814 };
815 let mut reference = match repo.try_find_reference(&self.ref_name) {
816 Ok(Some(r)) => r,
817 Ok(None) => return Ok(None),
818 Err(e) => {
819 tracing::debug!(
820 ref_name = %self.ref_name,
821 error = %e,
822 "current_head: ref lookup failed; treating as no baseline"
823 );
824 return Ok(None);
825 }
826 };
827 Ok(reference
828 .peel_to_id()
829 .ok()
830 .map(|id| id.detach().to_hex().to_string()))
831 }
832
833 fn read_mem_config(&self) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
834 let leaf = self
838 .ref_name
839 .strip_prefix("refs/heads/")
840 .unwrap_or(&self.ref_name);
841
842 read_blob_from_ref(
847 &self.gitdir,
848 "refs/heads/__MEMSTEAD",
849 &format!("mems/{leaf}/config.json"),
850 )
851 }
852
853 fn read_anchors_sidecar(
854 &self,
855 ) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
856 <Self as memstead_base::backend::MemBackend>::read_entity(
861 self,
862 Path::new(memstead_base::anchor::ANCHOR_SIDECAR_PATH),
863 )
864 }
865
866 fn write_anchors_sidecar(
867 &self,
868 bytes: &[u8],
869 ) -> Result<(), memstead_base::backend::BackendError> {
870 <Self as MemWriter>::write_entity(
875 self,
876 Path::new(memstead_base::anchor::ANCHOR_SIDECAR_PATH),
877 bytes,
878 )
879 .map_err(Into::into)
880 }
881
882 fn delete_artifacts(&self) -> Result<(), memstead_base::backend::BackendError> {
883 let branch_leaf = self
889 .ref_name
890 .strip_prefix("refs/heads/")
891 .unwrap_or(&self.ref_name);
892 let ctx = CommitContext {
893 actor: memstead_base::vcs::Actor::Agent,
894 client: None,
895 tool: Some("memstead_mem_delete"),
896 note: None,
897 role: Default::default(),
898 logical_operation_id: None,
899 entity_ids: None,
900 };
901 crate::storage_memstead::delete_mem_artifacts_at_gitdir(&self.gitdir, branch_leaf, &ctx)
902 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
903 }
904
905 fn write_mem_config(&self, bytes: &[u8]) -> Result<(), memstead_base::backend::BackendError> {
906 self.write_mem_config_with_note(bytes, None)
907 }
908
909 fn write_mem_config_with_note(
910 &self,
911 bytes: &[u8],
912 note: Option<&str>,
913 ) -> Result<(), memstead_base::backend::BackendError> {
914 let leaf = self
935 .ref_name
936 .strip_prefix("refs/heads/")
937 .unwrap_or(&self.ref_name);
938 let ctx = CommitContext {
939 actor: memstead_base::vcs::Actor::Agent,
940 client: None,
941 tool: Some("memstead_mem_config_write"),
942 note: note.map(str::to_string),
943 role: Default::default(),
944 logical_operation_id: None,
945 entity_ids: None,
946 };
947 crate::storage_memstead::commit_config_to_memstead_at_gitdir(
948 &self.gitdir,
949 leaf,
950 bytes,
951 &ctx,
952 &format!("memstead: commit __MEMSTEAD:mems/{leaf}/config.json"),
953 )
954 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
955 }
956
957 fn record_pipeline_edit(
958 &self,
959 kind: &str,
960 edits: &[(String, Option<Vec<u8>>)],
961 note: Option<&str>,
962 verb: &str,
963 ) -> Result<(), memstead_base::backend::BackendError> {
964 let leaf = self
969 .ref_name
970 .strip_prefix("refs/heads/")
971 .unwrap_or(&self.ref_name);
972 let tree_edits: Vec<(String, Option<Vec<u8>>)> = edits
973 .iter()
974 .map(|(name, bytes)| (format!("pipeline/{kind}/{leaf}/{name}.json"), bytes.clone()))
975 .collect();
976 let ctx = CommitContext {
977 actor: memstead_base::vcs::Actor::Agent,
978 client: None,
979 tool: Some("memstead_pipeline_edit"),
980 note: note.map(str::to_string),
981 role: Default::default(),
982 logical_operation_id: None,
983 entity_ids: None,
984 };
985 let names: Vec<&str> = edits.iter().map(|(n, _)| n.as_str()).collect();
986 crate::storage_memstead::commit_paths_to_memstead_at_gitdir(
987 &self.gitdir,
988 &tree_edits,
989 &ctx,
990 &format!("memstead: {verb} {kind} {leaf}/{}", names.join(", ")),
991 )
992 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
993 }
994}
995
996fn read_blob_from_ref(
1004 gitdir: &Path,
1005 ref_name: &str,
1006 path: &str,
1007) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
1008 let repo = match gix::open(gitdir) {
1009 Ok(r) => r,
1010 Err(_) => return Ok(None),
1011 };
1012 let reference = match repo.try_find_reference(ref_name) {
1013 Ok(Some(r)) => r,
1014 Ok(None) => return Ok(None),
1015 Err(e) => {
1016 return Err(memstead_base::backend::BackendError::Other(format!(
1017 "find ref {ref_name}: {e}"
1018 )));
1019 }
1020 };
1021 let id = reference.into_fully_peeled_id().map_err(|e| {
1022 memstead_base::backend::BackendError::Other(format!("peel {ref_name}: {e}"))
1023 })?;
1024 let object = id.object().map_err(|e| {
1025 memstead_base::backend::BackendError::Other(format!("read obj {ref_name}: {e}"))
1026 })?;
1027 let commit = match object.try_into_commit() {
1028 Ok(c) => c,
1029 Err(_) => return Ok(None),
1030 };
1031 let tree = commit.tree().map_err(|e| {
1032 memstead_base::backend::BackendError::Other(format!("read tree {ref_name}: {e}"))
1033 })?;
1034 let entry = match tree.lookup_entry_by_path(path) {
1035 Ok(Some(e)) => e,
1036 Ok(None) => return Ok(None),
1037 Err(e) => {
1038 return Err(memstead_base::backend::BackendError::Other(format!(
1039 "lookup {ref_name}:{path}: {e}"
1040 )));
1041 }
1042 };
1043 let blob = entry.object().map_err(|e| {
1044 memstead_base::backend::BackendError::Other(format!("read blob {ref_name}:{path}: {e}"))
1045 })?;
1046 Ok(Some(blob.data.clone()))
1047}
1048
1049fn commit_note_to_provenance(n: crate::ops::agent_notes::CommitNote) -> memstead_base::Provenance {
1054 let kind = n
1055 .tool_verb
1056 .as_deref()
1057 .and_then(memstead_base::ProvenanceKind::parse)
1058 .unwrap_or(memstead_base::ProvenanceKind::Update);
1059 let actor = n
1060 .actor
1061 .as_deref()
1062 .and_then(memstead_base::vcs::Actor::from_trailer)
1063 .unwrap_or(memstead_base::vcs::Actor::Unknown);
1064 let client = n
1065 .client
1066 .as_deref()
1067 .and_then(memstead_base::vcs::parse_client_id);
1068 let timestamp = if n.timestamp >= 0 {
1069 std::time::UNIX_EPOCH + std::time::Duration::from_secs(n.timestamp as u64)
1070 } else {
1071 std::time::UNIX_EPOCH
1072 };
1073 let mut record =
1074 memstead_base::Provenance::new(timestamp, kind, n.entity_id, actor, client, n.note);
1075 if let Some(id) = n.logical_operation_id {
1076 record = record.with_logical_operation_id(id);
1077 }
1078 if let Some(role) = n
1079 .role
1080 .as_deref()
1081 .and_then(memstead_base::vcs::Role::from_wire)
1082 {
1083 record = record.with_role(role);
1084 }
1085 record
1086}
1087
1088fn sync_index_and_worktree(repo: &gix::Repository, ref_name: &str) -> Result<(), MemWriterError> {
1127 let Some(workdir) = repo.workdir() else {
1128 return Ok(());
1129 };
1130 let head_matches = matches!(repo.head_name(), Ok(Some(name)) if name.as_bstr() == ref_name);
1139 if !head_matches {
1140 return Ok(());
1141 }
1142
1143 let output = std::process::Command::new("git")
1144 .arg("-C")
1145 .arg(workdir)
1146 .args(["read-tree", "--reset", "-u", "HEAD"])
1147 .env("GIT_TERMINAL_PROMPT", "0")
1151 .stdin(std::process::Stdio::null())
1152 .output()
1153 .map_err(|e| {
1154 std::io::Error::other(format!(
1155 "worktree sync: spawn `git -C {} read-tree --reset -u HEAD`: {e}; \
1156 commit already landed in the object store, recover with \
1157 `git -C {} reset --hard HEAD`",
1158 workdir.display(),
1159 workdir.display()
1160 ))
1161 })?;
1162 if !output.status.success() {
1163 let stderr = String::from_utf8_lossy(&output.stderr);
1164 return Err(MemWriterError::Io(std::io::Error::other(format!(
1165 "worktree sync: `git -C {} read-tree --reset -u HEAD` failed (status {}): {}; \
1166 commit already landed in the object store, recover with \
1167 `git -C {} reset --hard HEAD` (or remove a stale \
1168 `<workdir>/.git/index.lock` if one exists)",
1169 workdir.display(),
1170 output.status,
1171 stderr.trim(),
1172 workdir.display()
1173 ))));
1174 }
1175 Ok(())
1176}
1177
1178const COMMITTER_NAME: &str = "engine";
1185const COMMITTER_EMAIL: &str = "noreply@memstead.io";
1186
1187#[derive(Debug, Clone)]
1192pub struct BranchBlob {
1193 pub path: String,
1194 pub bytes: Vec<u8>,
1195}
1196
1197#[derive(Debug, thiserror::Error)]
1202pub enum BranchReadError {
1203 #[error("git-tree reader: open repo at {path}: {source}")]
1204 Open {
1205 path: String,
1206 #[source]
1207 source: gix::open::Error,
1208 },
1209 #[error("git-tree reader: branch {ref_name} not found")]
1210 BranchMissing { ref_name: String },
1211 #[error("git-tree reader: resolve branch {ref_name}: {message}")]
1212 Resolve { ref_name: String, message: String },
1213 #[error("git-tree reader: read object: {message}")]
1214 Read { message: String },
1215}
1216
1217pub fn read_branch_blobs(
1231 gitdir: &Path,
1232 ref_name: &str,
1233) -> Result<Vec<BranchBlob>, BranchReadError> {
1234 let repo = gix::open(gitdir).map_err(|e| BranchReadError::Open {
1235 path: gitdir.display().to_string(),
1236 source: e,
1237 })?;
1238 let mut reference =
1239 match repo
1240 .try_find_reference(ref_name)
1241 .map_err(|e| BranchReadError::Resolve {
1242 ref_name: ref_name.to_string(),
1243 message: e.to_string(),
1244 })? {
1245 Some(r) => r,
1246 None => {
1247 return Err(BranchReadError::BranchMissing {
1248 ref_name: ref_name.to_string(),
1249 });
1250 }
1251 };
1252 let id = reference
1253 .peel_to_id()
1254 .map_err(|e| BranchReadError::Resolve {
1255 ref_name: ref_name.to_string(),
1256 message: e.to_string(),
1257 })?;
1258 let commit = repo
1259 .find_object(id)
1260 .map_err(|e| BranchReadError::Read {
1261 message: format!("open commit {id}: {e}"),
1262 })?
1263 .into_commit();
1264 let tree = commit.tree().map_err(|e| BranchReadError::Read {
1265 message: format!("peel commit to tree: {e}"),
1266 })?;
1267
1268 let mut out: Vec<BranchBlob> = Vec::new();
1269 walk_tree(&repo, &tree, "", &mut out)?;
1270 out.sort_by(|a, b| a.path.cmp(&b.path));
1271 Ok(out)
1272}
1273
1274fn walk_tree(
1275 repo: &gix::Repository,
1276 tree: &gix::Tree<'_>,
1277 prefix: &str,
1278 out: &mut Vec<BranchBlob>,
1279) -> Result<(), BranchReadError> {
1280 use gix::objs::tree::EntryKind;
1281 let iter = tree.iter();
1282 for entry_res in iter {
1283 let entry = entry_res.map_err(|e| BranchReadError::Read {
1284 message: format!("decode tree entry: {e}"),
1285 })?;
1286 let name = entry.filename().to_string();
1287 let full = if prefix.is_empty() {
1288 name.clone()
1289 } else {
1290 format!("{prefix}/{name}")
1291 };
1292 match entry.mode().kind() {
1293 EntryKind::Blob | EntryKind::BlobExecutable => {
1294 let object = repo
1295 .find_object(entry.oid())
1296 .map_err(|e| BranchReadError::Read {
1297 message: format!("read blob {full}: {e}"),
1298 })?;
1299 out.push(BranchBlob {
1300 path: full,
1301 bytes: object.data.clone(),
1302 });
1303 }
1304 EntryKind::Tree => {
1305 let subtree = repo
1306 .find_object(entry.oid())
1307 .map_err(|e| BranchReadError::Read {
1308 message: format!("read subtree {full}: {e}"),
1309 })?
1310 .into_tree();
1311 walk_tree(repo, &subtree, &full, out)?;
1312 }
1313 EntryKind::Link | EntryKind::Commit => {}
1316 }
1317 }
1318 Ok(())
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323 use super::*;
1324 use crate::vcs::{Actor, ClientId, CommitContext};
1325 use std::path::Path;
1326 use tempfile::TempDir;
1327
1328 fn fresh_repo_dir(tmp: &Path) -> PathBuf {
1333 let git_dir = tmp.join("mem-repo.git");
1334 gix::init_bare(&git_dir).unwrap();
1335 std::fs::canonicalize(&git_dir).unwrap()
1336 }
1337
1338 fn ctx_for_test<'a>() -> CommitContext<'a> {
1339 CommitContext {
1340 actor: Actor::Cli,
1341 client: Some(ClientId {
1342 name: "claude-code".to_string(),
1343 version: "0.1.0".to_string(),
1344 }),
1345 tool: Some("test"),
1346 note: None,
1347 role: Default::default(),
1348 logical_operation_id: None,
1349 entity_ids: None,
1350 }
1351 }
1352
1353 #[test]
1358 fn storage_present_tracks_the_branch_ref() {
1359 use memstead_base::backend::MemBackend;
1360 let tmp = TempDir::new().unwrap();
1361 let gitdir = fresh_repo_dir(tmp.path());
1362 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/probe".to_string());
1363 assert!(!MemBackend::storage_present(&writer).unwrap(), "no ref yet");
1364 assert!(
1365 MemBackend::list_entities(&writer).unwrap().is_empty(),
1366 "and it lists as empty"
1367 );
1368 MemWriter::write_entity(&writer, Path::new("one.md"), b"# one\n").unwrap();
1369 MemWriter::commit(&writer, "seed", &ctx_for_test()).unwrap();
1370 assert!(
1371 MemBackend::storage_present(&writer).unwrap(),
1372 "the first commit creates the ref"
1373 );
1374 assert_eq!(MemBackend::list_entities(&writer).unwrap().len(), 1);
1375 }
1376
1377 fn read_blob(gitdir: &Path, ref_name: &str, path: &str) -> Option<Vec<u8>> {
1378 let repo = gix::open(gitdir).unwrap();
1379 let mut reference = repo.try_find_reference(ref_name).unwrap()?;
1380 let id = reference.peel_to_id().unwrap();
1381 let commit = repo.find_object(id).unwrap().into_commit();
1382 let tree = commit.tree().unwrap();
1383 let entry = tree.lookup_entry_by_path(path).unwrap()?;
1384 let object = repo.find_object(entry.id()).unwrap();
1385 Some(object.data.clone())
1386 }
1387
1388 fn tree_path_exists(gitdir: &Path, ref_name: &str, path: &str) -> bool {
1389 read_blob(gitdir, ref_name, path).is_some()
1390 }
1391
1392 #[test]
1397 fn entity_exists_tree_probe_and_pending_precedence() {
1398 use memstead_base::backend::MemBackend;
1399 let tmp = TempDir::new().unwrap();
1400 let gitdir = fresh_repo_dir(tmp.path());
1401 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1402
1403 assert!(
1404 !MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
1405 "unborn ref answers false"
1406 );
1407
1408 MemBackend::write_entity(
1409 &writer,
1410 Path::new("notes/a.md"),
1411 b"# a
1412",
1413 )
1414 .unwrap();
1415 assert!(
1416 MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
1417 "staged upsert answers true before commit"
1418 );
1419
1420 MemWriter::commit(&writer, "land a", &ctx_for_test()).unwrap();
1421 assert!(MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
1422 assert!(!MemBackend::entity_exists(&writer, Path::new("notes/b.md")).unwrap());
1423
1424 MemBackend::delete_entity(&writer, Path::new("notes/a.md")).unwrap();
1425 assert!(
1426 !MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
1427 "staged delete answers false while the branch tip still holds the blob"
1428 );
1429 }
1430
1431 #[test]
1432 fn git_tree_writer_round_trip() {
1433 let tmp = TempDir::new().unwrap();
1434 let gitdir = fresh_repo_dir(tmp.path());
1435 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1436
1437 writer
1438 .write_entity(Path::new("notes/hello.md"), b"# hi\n")
1439 .unwrap();
1440 let sha = writer.commit("first commit", &ctx_for_test()).unwrap();
1441 assert_eq!(sha.len(), 40);
1442
1443 let bytes = read_blob(&gitdir, "refs/heads/test", "notes/hello.md").unwrap();
1444 assert_eq!(bytes, b"# hi\n");
1445 }
1446
1447 #[test]
1448 fn git_tree_writer_anchors_sidecar_rides_commit_and_survives_reload() {
1449 use memstead_base::backend::MemBackend;
1450 let tmp = TempDir::new().unwrap();
1451 let gitdir = fresh_repo_dir(tmp.path());
1452 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1453
1454 MemBackend::write_entity(&writer, Path::new("hello.md"), b"# hi\n").unwrap();
1457 writer
1458 .write_anchors_sidecar(b"{\"version\":1,\"entities\":{}}")
1459 .unwrap();
1460 MemBackend::commit(&writer, "entity+anchors", &ctx_for_test()).unwrap();
1461
1462 let sidecar = read_blob(&gitdir, "refs/heads/test", ".memstead/anchors.json").unwrap();
1464 assert_eq!(sidecar, b"{\"version\":1,\"entities\":{}}");
1465
1466 let reloaded = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1468 assert_eq!(
1469 reloaded.read_anchors_sidecar().unwrap(),
1470 Some(b"{\"version\":1,\"entities\":{}}".to_vec())
1471 );
1472 assert_eq!(
1474 MemBackend::list_entities(&reloaded).unwrap(),
1475 vec![PathBuf::from("hello.md")]
1476 );
1477 }
1478
1479 #[test]
1480 fn git_tree_writer_delete_removes_path() {
1481 let tmp = TempDir::new().unwrap();
1482 let gitdir = fresh_repo_dir(tmp.path());
1483 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1484
1485 writer.write_entity(Path::new("a.md"), b"a").unwrap();
1486 writer.write_entity(Path::new("b.md"), b"b").unwrap();
1487 writer.commit("seed", &ctx_for_test()).unwrap();
1488
1489 writer.delete_entity(Path::new("a.md")).unwrap();
1490 writer.commit("drop a", &ctx_for_test()).unwrap();
1491
1492 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "a.md"));
1493 assert!(tree_path_exists(&gitdir, "refs/heads/test", "b.md"));
1494 }
1495
1496 #[test]
1497 fn git_tree_writer_move_renames_path() {
1498 let tmp = TempDir::new().unwrap();
1499 let gitdir = fresh_repo_dir(tmp.path());
1500 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1501
1502 writer
1503 .write_entity(Path::new("from.md"), b"payload")
1504 .unwrap();
1505 writer.commit("seed", &ctx_for_test()).unwrap();
1506
1507 writer
1508 .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
1509 .unwrap();
1510 writer.commit("rename", &ctx_for_test()).unwrap();
1511
1512 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "from.md"));
1513 let moved = read_blob(&gitdir, "refs/heads/test", "nested/to.md").unwrap();
1514 assert_eq!(moved, b"payload");
1515 }
1516
1517 #[test]
1518 fn git_tree_writer_multi_op_commit() {
1519 let tmp = TempDir::new().unwrap();
1520 let gitdir = fresh_repo_dir(tmp.path());
1521 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1522
1523 writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
1526 writer.commit("seed", &ctx_for_test()).unwrap();
1527
1528 writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
1529 writer
1530 .write_entity(Path::new("nested/b.md"), b"beta")
1531 .unwrap();
1532 writer.delete_entity(Path::new("doomed.md")).unwrap();
1533 writer.commit("multi-op", &ctx_for_test()).unwrap();
1534
1535 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "doomed.md"));
1536 assert_eq!(
1537 read_blob(&gitdir, "refs/heads/test", "a.md").unwrap(),
1538 b"alpha"
1539 );
1540 assert_eq!(
1541 read_blob(&gitdir, "refs/heads/test", "nested/b.md").unwrap(),
1542 b"beta"
1543 );
1544 }
1545
1546 #[test]
1547 fn git_tree_writer_cas_conflict_surfaces_hash_mismatch() {
1548 let tmp = TempDir::new().unwrap();
1549 let gitdir = fresh_repo_dir(tmp.path());
1550
1551 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1553 seeder.write_entity(Path::new("seed.md"), b"x").unwrap();
1554 let seed_sha = seeder.commit("seed", &ctx_for_test()).unwrap();
1555
1556 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1557 let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1558
1559 a.write_entity(Path::new("a.md"), b"a").unwrap();
1561 b.write_entity(Path::new("b.md"), b"b").unwrap();
1562 assert_eq!(
1563 a.pending
1564 .lock()
1565 .unwrap()
1566 .parent
1567 .unwrap()
1568 .to_hex()
1569 .to_string(),
1570 seed_sha
1571 );
1572 assert_eq!(
1573 b.pending
1574 .lock()
1575 .unwrap()
1576 .parent
1577 .unwrap()
1578 .to_hex()
1579 .to_string(),
1580 seed_sha
1581 );
1582
1583 let new_tip = a.commit("a wins", &ctx_for_test()).unwrap();
1586 let err = b
1587 .commit("b loses", &ctx_for_test())
1588 .expect_err("B's commit must fail with HashMismatch");
1589 match err {
1590 MemWriterError::HashMismatch { current } => {
1591 assert_eq!(current, new_tip);
1592 }
1593 other => panic!("expected HashMismatch, got {other:?}"),
1594 }
1595 }
1596
1597 #[test]
1598 fn cas_conflict_clears_pending_so_reads_fall_back_to_committed_truth() {
1599 let tmp = TempDir::new().unwrap();
1606 let gitdir = fresh_repo_dir(tmp.path());
1607
1608 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1611 seeder.write_entity(Path::new("shared.md"), b"v1").unwrap();
1612 seeder.commit("seed", &ctx_for_test()).unwrap();
1613
1614 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1615 let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1616
1617 a.write_entity(Path::new("shared.md"), b"A-committed")
1620 .unwrap();
1621 b.write_entity(Path::new("shared.md"), b"B-phantom")
1622 .unwrap();
1623
1624 a.commit("a wins", &ctx_for_test()).unwrap();
1626 let err = b
1627 .commit("b loses", &ctx_for_test())
1628 .expect_err("B must lose the CAS race");
1629 assert!(
1630 matches!(err, MemWriterError::HashMismatch { .. }),
1631 "expected HashMismatch, got {err:?}"
1632 );
1633
1634 assert!(
1636 b.pending.lock().unwrap().ops.is_empty(),
1637 "pending must be cleared after a failed commit"
1638 );
1639 let read = <GitTreeMemWriter as memstead_base::backend::MemBackend>::read_entity(
1642 &b,
1643 Path::new("shared.md"),
1644 )
1645 .unwrap();
1646 assert_eq!(
1647 read.as_deref(),
1648 Some(&b"A-committed"[..]),
1649 "read must serve committed truth, not the phantom staged write"
1650 );
1651 }
1652
1653 #[test]
1654 fn commit_with_expected_parent_succeeds_when_ref_matches_pin() {
1655 use memstead_base::backend::MemBackend;
1656
1657 let tmp = TempDir::new().unwrap();
1658 let gitdir = fresh_repo_dir(tmp.path());
1659
1660 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1661 <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1662 let seed_sha =
1663 <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1664
1665 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1667 let expected = <GitTreeMemWriter as MemBackend>::current_head(&writer)
1668 .unwrap()
1669 .expect("seeded ref has a head");
1670 assert_eq!(expected, seed_sha);
1671
1672 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("after.md"), b"after")
1673 .unwrap();
1674
1675 let new_tip = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1676 &writer,
1677 "pinned commit",
1678 &ctx_for_test(),
1679 Some(&expected),
1680 )
1681 .expect("parent matches pin → commit must succeed");
1682 assert_ne!(new_tip, seed_sha);
1683 assert_eq!(
1684 read_blob(&gitdir, "refs/heads/test", "after.md").unwrap(),
1685 b"after"
1686 );
1687 }
1688
1689 #[test]
1690 fn commit_with_expected_parent_surfaces_parent_mismatch_when_sibling_advances_ref() {
1691 use memstead_base::backend::{BackendError, MemBackend};
1692
1693 let tmp = TempDir::new().unwrap();
1694 let gitdir = fresh_repo_dir(tmp.path());
1695
1696 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1698 <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1699 let seed_sha =
1700 <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1701
1702 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1705 let pin = <GitTreeMemWriter as MemBackend>::current_head(&a)
1706 .unwrap()
1707 .expect("seeded ref has a head");
1708 assert_eq!(pin, seed_sha);
1709
1710 let sibling = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1714 <GitTreeMemWriter as MemWriter>::write_entity(&sibling, Path::new("drift.md"), b"drift")
1715 .unwrap();
1716 let new_tip =
1717 <GitTreeMemWriter as MemWriter>::commit(&sibling, "sibling advance", &ctx_for_test())
1718 .unwrap();
1719 assert_ne!(new_tip, seed_sha);
1720
1721 <GitTreeMemWriter as MemWriter>::write_entity(&a, Path::new("a.md"), b"a").unwrap();
1724 let err = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1725 &a,
1726 "pinned commit",
1727 &ctx_for_test(),
1728 Some(&pin),
1729 )
1730 .expect_err("pin no longer matches live tip → commit must refuse");
1731 match err {
1732 BackendError::ParentMismatch { expected, actual } => {
1733 assert_eq!(expected, pin);
1734 assert_eq!(actual, new_tip);
1735 }
1736 other => panic!("expected ParentMismatch, got {other:?}"),
1737 }
1738 }
1739
1740 #[test]
1741 fn commit_with_expected_parent_none_pin_is_equivalent_to_commit() {
1742 use memstead_base::backend::MemBackend;
1743
1744 let tmp = TempDir::new().unwrap();
1745 let gitdir = fresh_repo_dir(tmp.path());
1746 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1747
1748 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("hello.md"), b"hi")
1749 .unwrap();
1750 let sha = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1751 &writer,
1752 "unpinned",
1753 &ctx_for_test(),
1754 None,
1755 )
1756 .expect("None pin → plain commit semantics, must succeed against empty ref");
1757 assert_eq!(sha.len(), 40);
1758 assert_eq!(
1759 read_blob(&gitdir, "refs/heads/test", "hello.md").unwrap(),
1760 b"hi"
1761 );
1762 }
1763
1764 #[test]
1765 fn git_tree_writer_blob_oid_is_content_addressed() {
1766 let tmp_a = TempDir::new().unwrap();
1770 let tmp_b = TempDir::new().unwrap();
1771 let payload = b"shared content\n";
1772
1773 let gitdir_a = fresh_repo_dir(tmp_a.path());
1774 let writer_a = GitTreeMemWriter::new(gitdir_a.clone(), "refs/heads/a".to_string());
1775 writer_a
1776 .write_entity(Path::new("file.md"), payload)
1777 .unwrap();
1778 let sha_a = writer_a.commit("a", &ctx_for_test()).unwrap();
1779 let repo_a = gix::open(&gitdir_a).unwrap();
1780 let commit_a = repo_a
1781 .find_object(gix::ObjectId::from_hex(sha_a.as_bytes()).unwrap())
1782 .unwrap()
1783 .into_commit();
1784 let blob_id_a = commit_a
1785 .tree()
1786 .unwrap()
1787 .lookup_entry_by_path("file.md")
1788 .unwrap()
1789 .unwrap()
1790 .id()
1791 .detach();
1792
1793 let gitdir_b = fresh_repo_dir(tmp_b.path());
1794 let writer_b = GitTreeMemWriter::new(gitdir_b.clone(), "refs/heads/b".to_string());
1795 writer_b
1796 .write_entity(Path::new("file.md"), payload)
1797 .unwrap();
1798 let sha_b = writer_b.commit("b", &ctx_for_test()).unwrap();
1799 let repo_b = gix::open(&gitdir_b).unwrap();
1800 let commit_b = repo_b
1801 .find_object(gix::ObjectId::from_hex(sha_b.as_bytes()).unwrap())
1802 .unwrap()
1803 .into_commit();
1804 let blob_id_b = commit_b
1805 .tree()
1806 .unwrap()
1807 .lookup_entry_by_path("file.md")
1808 .unwrap()
1809 .unwrap()
1810 .id()
1811 .detach();
1812
1813 assert_eq!(
1814 blob_id_a, blob_id_b,
1815 "same content must produce byte-identical blob OIDs"
1816 );
1817 }
1818
1819 fn fresh_non_bare_repo(tmp: &Path) -> (PathBuf, PathBuf) {
1824 let workdir = tmp.join("mem-repo-workdir");
1825 std::fs::create_dir_all(&workdir).unwrap();
1826 let status = std::process::Command::new("git")
1827 .arg("-C")
1828 .arg(&workdir)
1829 .args(["init", "--initial-branch=main", "--quiet"])
1830 .status()
1831 .expect("git init must succeed");
1832 assert!(status.success(), "git init failed");
1833 let workdir = std::fs::canonicalize(&workdir).unwrap();
1834 let gitdir = workdir.join(".git");
1835 (workdir, gitdir)
1836 }
1837
1838 #[test]
1839 fn sync_helper_skips_on_bare_repo() {
1840 let tmp = TempDir::new().unwrap();
1841 let gitdir = fresh_repo_dir(tmp.path());
1842 let repo = gix::open(&gitdir).unwrap();
1843
1844 sync_index_and_worktree(&repo, "refs/heads/main").unwrap();
1847 }
1848
1849 #[test]
1850 fn sync_helper_updates_worktree_when_ref_matches_head() {
1851 let tmp = TempDir::new().unwrap();
1852 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1853 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1854
1855 writer
1856 .write_entity(Path::new("configs/alpha.json"), b"{\"name\":\"alpha\"}\n")
1857 .unwrap();
1858 writer.commit("seed alpha", &ctx_for_test()).unwrap();
1859
1860 let on_disk = workdir.join("configs/alpha.json");
1863 assert!(
1864 on_disk.exists(),
1865 "worktree sync must materialise the new blob at {}",
1866 on_disk.display()
1867 );
1868 let bytes = std::fs::read(&on_disk).unwrap();
1869 assert_eq!(bytes, b"{\"name\":\"alpha\"}\n");
1870
1871 let output = std::process::Command::new("git")
1873 .arg("-C")
1874 .arg(&workdir)
1875 .args(["status", "--porcelain"])
1876 .output()
1877 .unwrap();
1878 assert!(
1879 output.stdout.is_empty(),
1880 "git status --porcelain must be empty post-sync, got: {:?}",
1881 String::from_utf8_lossy(&output.stdout)
1882 );
1883 }
1884
1885 #[test]
1886 fn sync_helper_skips_when_ref_does_not_match_head() {
1887 let tmp = TempDir::new().unwrap();
1888 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1889
1890 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/feature".to_string());
1894 writer
1895 .write_entity(Path::new("only-on-feature.md"), b"feature-only\n")
1896 .unwrap();
1897 writer
1898 .commit("first commit on feature", &ctx_for_test())
1899 .unwrap();
1900
1901 assert!(tree_path_exists(
1903 &gitdir,
1904 "refs/heads/feature",
1905 "only-on-feature.md"
1906 ));
1907 assert!(
1909 !workdir.join("only-on-feature.md").exists(),
1910 "worktree must not be polluted by writes to a non-checked-out branch"
1911 );
1912 }
1913
1914 #[test]
1915 fn sync_helper_preserves_untracked_files() {
1916 let tmp = TempDir::new().unwrap();
1917 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1918 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1919
1920 let untracked = workdir.join("scratch.txt");
1924 std::fs::write(&untracked, b"operator notes\n").unwrap();
1925
1926 writer
1927 .write_entity(Path::new("seed.md"), b"seed\n")
1928 .unwrap();
1929 writer.commit("create seed", &ctx_for_test()).unwrap();
1930
1931 assert!(
1932 untracked.exists(),
1933 "sync must leave untracked files in place"
1934 );
1935 assert_eq!(std::fs::read(&untracked).unwrap(), b"operator notes\n");
1936 assert_eq!(std::fs::read(workdir.join("seed.md")).unwrap(), b"seed\n");
1938 }
1939
1940 #[test]
1941 fn sync_helper_updates_through_delete_and_overwrite() {
1942 let tmp = TempDir::new().unwrap();
1943 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1944 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1945
1946 writer.write_entity(Path::new("a.md"), b"first\n").unwrap();
1947 writer.commit("create a", &ctx_for_test()).unwrap();
1948 assert_eq!(std::fs::read(workdir.join("a.md")).unwrap(), b"first\n");
1949
1950 writer.write_entity(Path::new("a.md"), b"second\n").unwrap();
1951 writer.commit("overwrite a", &ctx_for_test()).unwrap();
1952 assert_eq!(
1953 std::fs::read(workdir.join("a.md")).unwrap(),
1954 b"second\n",
1955 "overwrite must propagate to the worktree"
1956 );
1957
1958 writer.delete_entity(Path::new("a.md")).unwrap();
1959 writer.commit("delete a", &ctx_for_test()).unwrap();
1960 assert!(
1961 !workdir.join("a.md").exists(),
1962 "delete must remove the file from the worktree"
1963 );
1964
1965 let output = std::process::Command::new("git")
1967 .arg("-C")
1968 .arg(&workdir)
1969 .args(["status", "--porcelain"])
1970 .output()
1971 .unwrap();
1972 assert!(
1973 output.stdout.is_empty(),
1974 "git status --porcelain must be empty after every commit, got: {:?}",
1975 String::from_utf8_lossy(&output.stdout)
1976 );
1977 }
1978
1979 fn commit_with_verb(
1985 writer: &GitTreeMemWriter,
1986 verb: &str,
1987 entity_id: &str,
1988 ctx: &CommitContext<'_>,
1989 ) {
1990 let subject = format!("memstead: {verb} {entity_id}");
1991 <GitTreeMemWriter as MemWriter>::commit(writer, &subject, ctx).unwrap();
1992 }
1993
1994 fn ctx_with_note<'a>(note: &'a str) -> CommitContext<'a> {
1995 CommitContext {
1996 actor: Actor::Agent,
1997 client: Some(ClientId {
1998 name: "claude-code".to_string(),
1999 version: "2.1.0".to_string(),
2000 }),
2001 tool: Some("memstead_create"),
2002 note: Some(note.to_string()),
2003 role: Default::default(),
2004 logical_operation_id: None,
2005 entity_ids: None,
2006 }
2007 }
2008
2009 #[test]
2010 fn backend_list_entities_returns_only_md_outside_memstead_namespace() {
2011 use memstead_base::backend::MemBackend;
2012
2013 let tmp = TempDir::new().unwrap();
2014 let gitdir = fresh_repo_dir(tmp.path());
2015 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2016
2017 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"# a").unwrap();
2020 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"# b")
2021 .unwrap();
2022 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
2023 .unwrap();
2024 <GitTreeMemWriter as MemWriter>::write_entity(
2025 &writer,
2026 Path::new(".memstead/config.json"),
2027 b"{}",
2028 )
2029 .unwrap();
2030 <GitTreeMemWriter as MemWriter>::write_entity(
2031 &writer,
2032 Path::new(".memstead/notes.md"),
2033 b"# skip me",
2034 )
2035 .unwrap();
2036 <GitTreeMemWriter as MemWriter>::write_entity(
2037 &writer,
2038 Path::new(".other/notes.md"),
2039 b"# no longer special, walked like any non-meta dir",
2040 )
2041 .unwrap();
2042 <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
2043
2044 let backend: &dyn MemBackend = &writer;
2045 let mut paths: Vec<String> = backend
2046 .list_entities()
2047 .unwrap()
2048 .into_iter()
2049 .map(|p| p.to_string_lossy().into_owned())
2050 .collect();
2051 paths.sort();
2052 assert_eq!(
2054 paths,
2055 vec![
2056 ".other/notes.md".to_string(),
2057 "a.md".to_string(),
2058 "nested/b.md".to_string(),
2059 ]
2060 );
2061 }
2062
2063 #[test]
2064 fn backend_list_entities_returns_empty_for_missing_branch() {
2065 use memstead_base::backend::MemBackend;
2066
2067 let tmp = TempDir::new().unwrap();
2068 let gitdir = fresh_repo_dir(tmp.path());
2069 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
2070 let backend: &dyn MemBackend = &writer;
2071 assert!(backend.list_entities().unwrap().is_empty());
2073 }
2074
2075 #[test]
2076 fn backend_read_entity_consults_pending_then_branch_tip() {
2077 use memstead_base::backend::MemBackend;
2078
2079 let tmp = TempDir::new().unwrap();
2080 let gitdir = fresh_repo_dir(tmp.path());
2081 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2082
2083 <GitTreeMemWriter as MemWriter>::write_entity(
2085 &writer,
2086 Path::new("on_branch.md"),
2087 b"branch",
2088 )
2089 .unwrap();
2090 <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
2091
2092 let backend: &dyn MemBackend = &writer;
2093 assert_eq!(
2095 backend.read_entity(Path::new("on_branch.md")).unwrap(),
2096 Some(b"branch".to_vec())
2097 );
2098 backend
2100 .write_entity(Path::new("on_branch.md"), b"buffered")
2101 .unwrap();
2102 assert_eq!(
2103 backend.read_entity(Path::new("on_branch.md")).unwrap(),
2104 Some(b"buffered".to_vec())
2105 );
2106 backend.delete_entity(Path::new("on_branch.md")).unwrap();
2108 assert_eq!(
2109 backend.read_entity(Path::new("on_branch.md")).unwrap(),
2110 None
2111 );
2112 assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
2114 }
2115
2116 #[test]
2117 fn backend_read_provenance_reconstructs_from_commit_log() {
2118 use memstead_base::backend::MemBackend;
2119
2120 let tmp = TempDir::new().unwrap();
2121 let gitdir = fresh_repo_dir(tmp.path());
2122 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2123
2124 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2128 commit_with_verb(&writer, "create", "v:a", &ctx_with_note("first draft"));
2129
2130 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
2131 commit_with_verb(
2132 &writer,
2133 "update",
2134 "v:a",
2135 &CommitContext {
2136 actor: Actor::Cli,
2137 client: None,
2138 tool: Some("memstead_update"),
2139 note: None,
2140 role: Default::default(),
2141 logical_operation_id: None,
2142 entity_ids: None,
2143 },
2144 );
2145
2146 let backend: &dyn MemBackend = &writer;
2147 backend
2150 .append_provenance(&memstead_base::Provenance::new(
2151 std::time::UNIX_EPOCH,
2152 memstead_base::ProvenanceKind::Create,
2153 Some("ignored".into()),
2154 Actor::Unknown,
2155 None,
2156 None,
2157 ))
2158 .unwrap();
2159
2160 let records = backend.read_provenance(None).unwrap();
2161 assert_eq!(records.len(), 2, "expected two commits, got {records:?}");
2162 assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Create);
2164 assert_eq!(records[0].entity.as_deref(), Some("v:a"));
2165 assert_eq!(records[0].actor, Actor::Agent);
2166 assert_eq!(records[0].note.as_deref(), Some("first draft"));
2167 assert_eq!(
2168 records[0]
2169 .client
2170 .as_ref()
2171 .map(|c| (c.name.as_str(), c.version.as_str())),
2172 Some(("claude-code", "2.1.0"))
2173 );
2174 assert_eq!(records[1].kind, memstead_base::ProvenanceKind::Update);
2175 assert_eq!(records[1].actor, Actor::Cli);
2176 assert!(records[1].note.is_none());
2177 assert!(records[1].client.is_none());
2178 }
2179
2180 #[test]
2181 fn backend_read_provenance_filters_by_cursor_sha() {
2182 use memstead_base::backend::MemBackend;
2183
2184 let tmp = TempDir::new().unwrap();
2185 let gitdir = fresh_repo_dir(tmp.path());
2186 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2187
2188 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2190 let first_sha = <GitTreeMemWriter as MemWriter>::commit(
2191 &writer,
2192 "memstead: create v:a",
2193 &ctx_for_test(),
2194 )
2195 .unwrap();
2196 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
2197 <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
2198 .unwrap();
2199 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a3").unwrap();
2200 <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
2201 .unwrap();
2202
2203 let backend: &dyn MemBackend = &writer;
2204 let after = backend.read_provenance(Some(&first_sha)).unwrap();
2206 assert_eq!(
2207 after.len(),
2208 2,
2209 "expected commits after cursor, got {after:?}"
2210 );
2211 for r in &after {
2212 assert_eq!(r.kind, memstead_base::ProvenanceKind::Update);
2213 }
2214 }
2215
2216 #[test]
2217 fn backend_read_provenance_empty_for_missing_branch() {
2218 use memstead_base::backend::MemBackend;
2219
2220 let tmp = TempDir::new().unwrap();
2221 let gitdir = fresh_repo_dir(tmp.path());
2222 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
2223 let backend: &dyn MemBackend = &writer;
2224 assert!(backend.read_provenance(None).unwrap().is_empty());
2226 }
2227
2228 #[test]
2229 fn backend_unknown_verb_falls_back_to_update_kind() {
2230 use memstead_base::backend::MemBackend;
2231
2232 let tmp = TempDir::new().unwrap();
2233 let gitdir = fresh_repo_dir(tmp.path());
2234 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2235
2236 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2237 commit_with_verb(&writer, "mem_create", "v:a", &ctx_for_test());
2241
2242 let backend: &dyn MemBackend = &writer;
2243 let records = backend.read_provenance(None).unwrap();
2244 assert_eq!(records.len(), 1);
2245 assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Update);
2246 }
2247
2248 #[test]
2249 fn instantiate_full_backend_constructs_git_branch_writer() {
2250 use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2255
2256 let tmp = TempDir::new().unwrap();
2257 let gitdir = fresh_repo_dir(tmp.path());
2258 let mount = Mount {
2259 mem: "engine".to_string(),
2260 schema: Some("default@1.0.0".parse().unwrap()),
2261 storage: MountStorage::GitBranch {
2262 gitdir,
2263 branch: "engine".to_string(),
2264 },
2265 capability: MountCapability::Write,
2266 lifecycle: MountLifecycle::Eager,
2267 cross_linkable: true,
2268 migration_target: None,
2269 };
2270 let backend: Box<dyn MemBackend> =
2271 crate::storage::instantiate_full_backend(&mount).unwrap();
2272 assert!(backend.list_entities().unwrap().is_empty());
2274 assert!(backend.read_provenance(None).unwrap().is_empty());
2276 }
2277
2278 #[test]
2279 fn instantiate_full_backend_accepts_branch_with_or_without_refs_prefix() {
2280 use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2285
2286 let tmp = TempDir::new().unwrap();
2287 let gitdir = fresh_repo_dir(tmp.path());
2288 for branch in ["engine", "refs/heads/engine"] {
2289 let mount = Mount {
2290 mem: "engine".to_string(),
2291 schema: Some("default@1.0.0".parse().unwrap()),
2292 storage: MountStorage::GitBranch {
2293 gitdir: gitdir.clone(),
2294 branch: branch.to_string(),
2295 },
2296 capability: MountCapability::Write,
2297 lifecycle: MountLifecycle::Eager,
2298 cross_linkable: true,
2299 migration_target: None,
2300 };
2301 let backend: Box<dyn MemBackend> =
2302 crate::storage::instantiate_full_backend(&mount).unwrap();
2303 assert!(backend.list_entities().unwrap().is_empty());
2305 }
2306 }
2307
2308 #[test]
2311 fn current_head_returns_none_for_empty_branch() {
2312 let tmp = TempDir::new().unwrap();
2317 let gitdir = fresh_repo_dir(tmp.path());
2318 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/specs".to_string());
2319 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2320 .unwrap();
2321 assert!(head.is_none());
2322 }
2323
2324 #[test]
2325 fn current_head_returns_hex_sha_after_commit() {
2326 let tmp = TempDir::new().unwrap();
2332 let gitdir = fresh_repo_dir(tmp.path());
2333 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2334
2335 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2336 let sha = writer.commit("first", &ctx_for_test()).unwrap();
2337 assert_eq!(sha.len(), 40);
2338
2339 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2340 .unwrap()
2341 .expect("head present after commit");
2342 assert_eq!(head, sha);
2343 }
2344
2345 #[test]
2346 fn current_head_advances_on_subsequent_commits() {
2347 let tmp = TempDir::new().unwrap();
2352 let gitdir = fresh_repo_dir(tmp.path());
2353 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2354
2355 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2356 let first = writer.commit("first", &ctx_for_test()).unwrap();
2357 let head_after_first =
2358 <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2359 .unwrap()
2360 .unwrap();
2361 assert_eq!(head_after_first, first);
2362
2363 writer.write_entity(Path::new("b.md"), b"b").unwrap();
2364 let second = writer.commit("second", &ctx_for_test()).unwrap();
2365 assert_ne!(first, second);
2366 let head_after_second =
2367 <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2368 .unwrap()
2369 .unwrap();
2370 assert_eq!(head_after_second, second);
2371 }
2372
2373 #[test]
2374 fn current_head_returns_none_for_missing_gitdir() {
2375 let tmp = TempDir::new().unwrap();
2381 let writer = GitTreeMemWriter::new(
2382 tmp.path().join("does-not-exist.git"),
2383 "refs/heads/specs".to_string(),
2384 );
2385 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2386 .unwrap();
2387 assert!(head.is_none());
2388 }
2389
2390 fn dispatch_changes(
2398 gitdir: &Path,
2399 branch: &str,
2400 mem: &str,
2401 since: &str,
2402 ) -> Result<memstead_base::ops::BackendChanges, memstead_base::backend::BackendError> {
2403 (crate::storage::FULL_GIT_BRANCH_OPS.changes_since)(
2404 gitdir,
2405 branch,
2406 mem,
2407 since,
2408 memstead_base::ops::RENAME_SIMILARITY_DEFAULT,
2409 )
2410 }
2411
2412 #[test]
2413 fn changes_since_empty_repo_with_sentinel_returns_empty_changes() {
2414 let tmp = TempDir::new().unwrap();
2418 let gitdir = fresh_repo_dir(tmp.path());
2419 let result = dispatch_changes(
2420 &gitdir,
2421 "specs",
2422 "specs",
2423 memstead_base::ops::EMPTY_TREE_SHA,
2424 )
2425 .unwrap();
2426 assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2427 assert_eq!(result.head, memstead_base::ops::EMPTY_TREE_SHA);
2428 assert!(result.changes.is_empty());
2429 }
2430
2431 #[test]
2432 fn changes_since_after_commit_returns_added_envelopes_id_only() {
2433 use memstead_base::ops::ChangeEnvelope;
2437 let tmp = TempDir::new().unwrap();
2438 let gitdir = fresh_repo_dir(tmp.path());
2439 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2440
2441 writer
2442 .write_entity(Path::new("alpha.md"), b"# Alpha")
2443 .unwrap();
2444 writer
2445 .write_entity(Path::new("beta.md"), b"# Beta")
2446 .unwrap();
2447 let head_sha = writer.commit("seed", &ctx_for_test()).unwrap();
2448
2449 let result = dispatch_changes(
2450 &gitdir,
2451 "specs",
2452 "specs",
2453 memstead_base::ops::EMPTY_TREE_SHA,
2454 )
2455 .unwrap();
2456 assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2457 assert_eq!(result.head, head_sha);
2458 assert_eq!(result.changes.len(), 2);
2459 for env in &result.changes {
2460 match env {
2461 ChangeEnvelope::Added {
2462 id,
2463 title,
2464 entity_type,
2465 } => {
2466 assert!(
2467 id.0.starts_with("specs--"),
2468 "expected mem-prefixed id, got {}",
2469 id.0
2470 );
2471 assert!(title.is_none(), "dispatch must not enrich title");
2472 assert!(
2473 entity_type.is_none(),
2474 "dispatch must not enrich entity_type"
2475 );
2476 }
2477 other => panic!("expected Added envelope, got {other:?}"),
2478 }
2479 }
2480 }
2481
2482 #[test]
2483 fn changes_since_between_two_commits_yields_updated_envelope() {
2484 use memstead_base::ops::ChangeEnvelope;
2485 let tmp = TempDir::new().unwrap();
2486 let gitdir = fresh_repo_dir(tmp.path());
2487 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2488
2489 writer
2490 .write_entity(Path::new("alpha.md"), b"# Alpha v1")
2491 .unwrap();
2492 let sha_v1 = writer.commit("v1", &ctx_for_test()).unwrap();
2493
2494 writer
2495 .write_entity(Path::new("alpha.md"), b"# Alpha v2")
2496 .unwrap();
2497 let sha_v2 = writer.commit("v2", &ctx_for_test()).unwrap();
2498 assert_ne!(sha_v1, sha_v2);
2499
2500 let result = dispatch_changes(&gitdir, "specs", "specs", &sha_v1).unwrap();
2501 assert_eq!(result.since, sha_v1);
2502 assert_eq!(result.head, sha_v2);
2503 assert_eq!(result.changes.len(), 1);
2504 match &result.changes[0] {
2505 ChangeEnvelope::Updated {
2506 id,
2507 title,
2508 entity_type,
2509 } => {
2510 assert!(id.0.starts_with("specs--"));
2511 assert!(title.is_none());
2512 assert!(entity_type.is_none());
2513 }
2514 other => panic!("expected Updated envelope, got {other:?}"),
2515 }
2516 }
2517
2518 #[test]
2519 fn anchor_only_commit_yields_zero_entity_deltas_and_valid_cursor() {
2520 use memstead_base::backend::MemBackend;
2526 let tmp = TempDir::new().unwrap();
2527 let gitdir = fresh_repo_dir(tmp.path());
2528 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2529
2530 MemBackend::write_entity(&writer, Path::new("alpha.md"), b"# Alpha").unwrap();
2531 let seed_sha = MemBackend::commit(&writer, "seed", &ctx_for_test()).unwrap();
2532
2533 writer
2535 .write_anchors_sidecar(
2536 br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#,
2537 )
2538 .unwrap();
2539 let anchor_sha = MemBackend::commit(&writer, "anchors", &ctx_for_test()).unwrap();
2540 assert_ne!(seed_sha, anchor_sha);
2541
2542 let from_seed = dispatch_changes(&gitdir, "specs", "specs", &seed_sha).unwrap();
2544 assert_eq!(from_seed.head, anchor_sha);
2545 assert_eq!(
2546 from_seed.changes.len(),
2547 0,
2548 "an anchor-only commit must produce zero entity deltas, got {:?}",
2549 from_seed.changes
2550 );
2551
2552 let from_anchor = dispatch_changes(&gitdir, "specs", "specs", &anchor_sha).unwrap();
2555 assert_eq!(from_anchor.head, anchor_sha);
2556 assert_eq!(from_anchor.changes.len(), 0);
2557 }
2558
2559 #[test]
2560 fn changes_since_unknown_cursor_returns_typed_commit_not_found_marker() {
2561 let tmp = TempDir::new().unwrap();
2569 let gitdir = fresh_repo_dir(tmp.path());
2570 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2571 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2573 writer.commit("seed", &ctx_for_test()).unwrap();
2574
2575 let bad_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2576 let err = dispatch_changes(&gitdir, "specs", "specs", bad_sha).unwrap_err();
2577 match err {
2578 memstead_base::backend::BackendError::Other(msg) => {
2579 assert_eq!(
2580 msg,
2581 format!("COMMIT_NOT_FOUND:{bad_sha}"),
2582 "bad-since must carry the typed marker with the untruncated sha: {msg}",
2583 );
2584 }
2585 other => panic!("expected BackendError::Other, got {other:?}"),
2586 }
2587 }
2588}