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
202fn 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 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 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 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 self.ensure_snapshot(&mut pending)?;
337 let parent_snapshot = pending.parent;
338
339 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 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 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 pending.clear();
440 let commit_id = match commit_result {
441 Ok(id) => id,
442 Err(gix::commit::Error::ReferenceEdit(_)) => {
443 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 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 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 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 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 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 let Some(expected) = expected_parent else {
614 return <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into);
615 };
616
617 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 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 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 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 let leaf = self
756 .ref_name
757 .strip_prefix("refs/heads/")
758 .unwrap_or(&self.ref_name);
759
760 read_blob_from_ref(
765 &self.gitdir,
766 "refs/heads/__MEMSTEAD",
767 &format!("mems/{leaf}/config.json"),
768 )
769 }
770
771 fn read_anchors_sidecar(
772 &self,
773 ) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
774 <Self as memstead_base::backend::MemBackend>::read_entity(
779 self,
780 Path::new(memstead_base::anchor::ANCHOR_SIDECAR_PATH),
781 )
782 }
783
784 fn write_anchors_sidecar(
785 &self,
786 bytes: &[u8],
787 ) -> Result<(), memstead_base::backend::BackendError> {
788 <Self as MemWriter>::write_entity(
793 self,
794 Path::new(memstead_base::anchor::ANCHOR_SIDECAR_PATH),
795 bytes,
796 )
797 .map_err(Into::into)
798 }
799
800 fn delete_artifacts(&self) -> Result<(), memstead_base::backend::BackendError> {
801 let branch_leaf = self
807 .ref_name
808 .strip_prefix("refs/heads/")
809 .unwrap_or(&self.ref_name);
810 let ctx = CommitContext {
811 actor: memstead_base::vcs::Actor::Agent,
812 client: None,
813 tool: Some("memstead_mem_delete"),
814 note: None,
815 logical_operation_id: None,
816 entity_ids: None,
817 };
818 crate::storage_memstead::delete_mem_artifacts_at_gitdir(&self.gitdir, branch_leaf, &ctx)
819 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
820 }
821
822 fn write_mem_config(&self, bytes: &[u8]) -> Result<(), memstead_base::backend::BackendError> {
823 self.write_mem_config_with_note(bytes, None)
824 }
825
826 fn write_mem_config_with_note(
827 &self,
828 bytes: &[u8],
829 note: Option<&str>,
830 ) -> Result<(), memstead_base::backend::BackendError> {
831 let leaf = self
852 .ref_name
853 .strip_prefix("refs/heads/")
854 .unwrap_or(&self.ref_name);
855 let ctx = CommitContext {
856 actor: memstead_base::vcs::Actor::Agent,
857 client: None,
858 tool: Some("memstead_mem_config_write"),
859 note: note.map(str::to_string),
860 logical_operation_id: None,
861 entity_ids: None,
862 };
863 crate::storage_memstead::commit_config_to_memstead_at_gitdir(
864 &self.gitdir,
865 leaf,
866 bytes,
867 &ctx,
868 &format!("memstead: commit __MEMSTEAD:mems/{leaf}/config.json"),
869 )
870 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
871 }
872
873 fn record_pipeline_edit(
874 &self,
875 kind: &str,
876 edits: &[(String, Option<Vec<u8>>)],
877 note: Option<&str>,
878 verb: &str,
879 ) -> Result<(), memstead_base::backend::BackendError> {
880 let leaf = self
885 .ref_name
886 .strip_prefix("refs/heads/")
887 .unwrap_or(&self.ref_name);
888 let tree_edits: Vec<(String, Option<Vec<u8>>)> = edits
889 .iter()
890 .map(|(name, bytes)| (format!("pipeline/{kind}/{leaf}/{name}.json"), bytes.clone()))
891 .collect();
892 let ctx = CommitContext {
893 actor: memstead_base::vcs::Actor::Agent,
894 client: None,
895 tool: Some("memstead_pipeline_edit"),
896 note: note.map(str::to_string),
897 logical_operation_id: None,
898 entity_ids: None,
899 };
900 let names: Vec<&str> = edits.iter().map(|(n, _)| n.as_str()).collect();
901 crate::storage_memstead::commit_paths_to_memstead_at_gitdir(
902 &self.gitdir,
903 &tree_edits,
904 &ctx,
905 &format!("memstead: {verb} {kind} {leaf}/{}", names.join(", ")),
906 )
907 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
908 }
909}
910
911fn read_blob_from_ref(
919 gitdir: &Path,
920 ref_name: &str,
921 path: &str,
922) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
923 let repo = match gix::open(gitdir) {
924 Ok(r) => r,
925 Err(_) => return Ok(None),
926 };
927 let reference = match repo.try_find_reference(ref_name) {
928 Ok(Some(r)) => r,
929 Ok(None) => return Ok(None),
930 Err(e) => {
931 return Err(memstead_base::backend::BackendError::Other(format!(
932 "find ref {ref_name}: {e}"
933 )));
934 }
935 };
936 let id = reference.into_fully_peeled_id().map_err(|e| {
937 memstead_base::backend::BackendError::Other(format!("peel {ref_name}: {e}"))
938 })?;
939 let object = id.object().map_err(|e| {
940 memstead_base::backend::BackendError::Other(format!("read obj {ref_name}: {e}"))
941 })?;
942 let commit = match object.try_into_commit() {
943 Ok(c) => c,
944 Err(_) => return Ok(None),
945 };
946 let tree = commit.tree().map_err(|e| {
947 memstead_base::backend::BackendError::Other(format!("read tree {ref_name}: {e}"))
948 })?;
949 let entry = match tree.lookup_entry_by_path(path) {
950 Ok(Some(e)) => e,
951 Ok(None) => return Ok(None),
952 Err(e) => {
953 return Err(memstead_base::backend::BackendError::Other(format!(
954 "lookup {ref_name}:{path}: {e}"
955 )));
956 }
957 };
958 let blob = entry.object().map_err(|e| {
959 memstead_base::backend::BackendError::Other(format!("read blob {ref_name}:{path}: {e}"))
960 })?;
961 Ok(Some(blob.data.clone()))
962}
963
964fn commit_note_to_provenance(n: crate::ops::agent_notes::CommitNote) -> memstead_base::Provenance {
969 let kind = n
970 .tool_verb
971 .as_deref()
972 .and_then(memstead_base::ProvenanceKind::parse)
973 .unwrap_or(memstead_base::ProvenanceKind::Update);
974 let actor = n
975 .actor
976 .as_deref()
977 .and_then(memstead_base::vcs::Actor::from_trailer)
978 .unwrap_or(memstead_base::vcs::Actor::Unknown);
979 let client = n
980 .client
981 .as_deref()
982 .and_then(memstead_base::vcs::parse_client_id);
983 let timestamp = if n.timestamp >= 0 {
984 std::time::UNIX_EPOCH + std::time::Duration::from_secs(n.timestamp as u64)
985 } else {
986 std::time::UNIX_EPOCH
987 };
988 let mut record =
989 memstead_base::Provenance::new(timestamp, kind, n.entity_id, actor, client, n.note);
990 if let Some(id) = n.logical_operation_id {
991 record = record.with_logical_operation_id(id);
992 }
993 record
994}
995
996fn sync_index_and_worktree(repo: &gix::Repository, ref_name: &str) -> Result<(), MemWriterError> {
1035 let Some(workdir) = repo.workdir() else {
1036 return Ok(());
1037 };
1038 let head_matches = matches!(repo.head_name(), Ok(Some(name)) if name.as_bstr() == ref_name);
1047 if !head_matches {
1048 return Ok(());
1049 }
1050
1051 let output = std::process::Command::new("git")
1052 .arg("-C")
1053 .arg(workdir)
1054 .args(["read-tree", "--reset", "-u", "HEAD"])
1055 .env("GIT_TERMINAL_PROMPT", "0")
1059 .stdin(std::process::Stdio::null())
1060 .output()
1061 .map_err(|e| {
1062 std::io::Error::other(format!(
1063 "worktree sync: spawn `git -C {} read-tree --reset -u HEAD`: {e}; \
1064 commit already landed in the object store, recover with \
1065 `git -C {} reset --hard HEAD`",
1066 workdir.display(),
1067 workdir.display()
1068 ))
1069 })?;
1070 if !output.status.success() {
1071 let stderr = String::from_utf8_lossy(&output.stderr);
1072 return Err(MemWriterError::Io(std::io::Error::other(format!(
1073 "worktree sync: `git -C {} read-tree --reset -u HEAD` failed (status {}): {}; \
1074 commit already landed in the object store, recover with \
1075 `git -C {} reset --hard HEAD` (or remove a stale \
1076 `<workdir>/.git/index.lock` if one exists)",
1077 workdir.display(),
1078 output.status,
1079 stderr.trim(),
1080 workdir.display()
1081 ))));
1082 }
1083 Ok(())
1084}
1085
1086const COMMITTER_NAME: &str = "engine";
1093const COMMITTER_EMAIL: &str = "noreply@memstead.io";
1094
1095#[derive(Debug, Clone)]
1100pub struct BranchBlob {
1101 pub path: String,
1102 pub bytes: Vec<u8>,
1103}
1104
1105#[derive(Debug, thiserror::Error)]
1110pub enum BranchReadError {
1111 #[error("git-tree reader: open repo at {path}: {source}")]
1112 Open {
1113 path: String,
1114 #[source]
1115 source: gix::open::Error,
1116 },
1117 #[error("git-tree reader: branch {ref_name} not found")]
1118 BranchMissing { ref_name: String },
1119 #[error("git-tree reader: resolve branch {ref_name}: {message}")]
1120 Resolve { ref_name: String, message: String },
1121 #[error("git-tree reader: read object: {message}")]
1122 Read { message: String },
1123}
1124
1125pub fn read_branch_blobs(
1139 gitdir: &Path,
1140 ref_name: &str,
1141) -> Result<Vec<BranchBlob>, BranchReadError> {
1142 let repo = gix::open(gitdir).map_err(|e| BranchReadError::Open {
1143 path: gitdir.display().to_string(),
1144 source: e,
1145 })?;
1146 let mut reference =
1147 match repo
1148 .try_find_reference(ref_name)
1149 .map_err(|e| BranchReadError::Resolve {
1150 ref_name: ref_name.to_string(),
1151 message: e.to_string(),
1152 })? {
1153 Some(r) => r,
1154 None => {
1155 return Err(BranchReadError::BranchMissing {
1156 ref_name: ref_name.to_string(),
1157 });
1158 }
1159 };
1160 let id = reference
1161 .peel_to_id()
1162 .map_err(|e| BranchReadError::Resolve {
1163 ref_name: ref_name.to_string(),
1164 message: e.to_string(),
1165 })?;
1166 let commit = repo
1167 .find_object(id)
1168 .map_err(|e| BranchReadError::Read {
1169 message: format!("open commit {id}: {e}"),
1170 })?
1171 .into_commit();
1172 let tree = commit.tree().map_err(|e| BranchReadError::Read {
1173 message: format!("peel commit to tree: {e}"),
1174 })?;
1175
1176 let mut out: Vec<BranchBlob> = Vec::new();
1177 walk_tree(&repo, &tree, "", &mut out)?;
1178 out.sort_by(|a, b| a.path.cmp(&b.path));
1179 Ok(out)
1180}
1181
1182fn walk_tree(
1183 repo: &gix::Repository,
1184 tree: &gix::Tree<'_>,
1185 prefix: &str,
1186 out: &mut Vec<BranchBlob>,
1187) -> Result<(), BranchReadError> {
1188 use gix::objs::tree::EntryKind;
1189 let iter = tree.iter();
1190 for entry_res in iter {
1191 let entry = entry_res.map_err(|e| BranchReadError::Read {
1192 message: format!("decode tree entry: {e}"),
1193 })?;
1194 let name = entry.filename().to_string();
1195 let full = if prefix.is_empty() {
1196 name.clone()
1197 } else {
1198 format!("{prefix}/{name}")
1199 };
1200 match entry.mode().kind() {
1201 EntryKind::Blob | EntryKind::BlobExecutable => {
1202 let object = repo
1203 .find_object(entry.oid())
1204 .map_err(|e| BranchReadError::Read {
1205 message: format!("read blob {full}: {e}"),
1206 })?;
1207 out.push(BranchBlob {
1208 path: full,
1209 bytes: object.data.clone(),
1210 });
1211 }
1212 EntryKind::Tree => {
1213 let subtree = repo
1214 .find_object(entry.oid())
1215 .map_err(|e| BranchReadError::Read {
1216 message: format!("read subtree {full}: {e}"),
1217 })?
1218 .into_tree();
1219 walk_tree(repo, &subtree, &full, out)?;
1220 }
1221 EntryKind::Link | EntryKind::Commit => {}
1224 }
1225 }
1226 Ok(())
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231 use super::*;
1232 use crate::vcs::{Actor, ClientId, CommitContext};
1233 use std::path::Path;
1234 use tempfile::TempDir;
1235
1236 fn fresh_repo_dir(tmp: &Path) -> PathBuf {
1241 let git_dir = tmp.join("mem-repo.git");
1242 gix::init_bare(&git_dir).unwrap();
1243 std::fs::canonicalize(&git_dir).unwrap()
1244 }
1245
1246 fn ctx_for_test<'a>() -> CommitContext<'a> {
1247 CommitContext {
1248 actor: Actor::Cli,
1249 client: Some(ClientId {
1250 name: "claude-code".to_string(),
1251 version: "0.1.0".to_string(),
1252 }),
1253 tool: Some("test"),
1254 note: None,
1255 logical_operation_id: None,
1256 entity_ids: None,
1257 }
1258 }
1259
1260 fn read_blob(gitdir: &Path, ref_name: &str, path: &str) -> Option<Vec<u8>> {
1261 let repo = gix::open(gitdir).unwrap();
1262 let mut reference = repo.try_find_reference(ref_name).unwrap()?;
1263 let id = reference.peel_to_id().unwrap();
1264 let commit = repo.find_object(id).unwrap().into_commit();
1265 let tree = commit.tree().unwrap();
1266 let entry = tree.lookup_entry_by_path(path).unwrap()?;
1267 let object = repo.find_object(entry.id()).unwrap();
1268 Some(object.data.clone())
1269 }
1270
1271 fn tree_path_exists(gitdir: &Path, ref_name: &str, path: &str) -> bool {
1272 read_blob(gitdir, ref_name, path).is_some()
1273 }
1274
1275 #[test]
1276 fn git_tree_writer_round_trip() {
1277 let tmp = TempDir::new().unwrap();
1278 let gitdir = fresh_repo_dir(tmp.path());
1279 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1280
1281 writer
1282 .write_entity(Path::new("notes/hello.md"), b"# hi\n")
1283 .unwrap();
1284 let sha = writer.commit("first commit", &ctx_for_test()).unwrap();
1285 assert_eq!(sha.len(), 40);
1286
1287 let bytes = read_blob(&gitdir, "refs/heads/test", "notes/hello.md").unwrap();
1288 assert_eq!(bytes, b"# hi\n");
1289 }
1290
1291 #[test]
1292 fn git_tree_writer_anchors_sidecar_rides_commit_and_survives_reload() {
1293 use memstead_base::backend::MemBackend;
1294 let tmp = TempDir::new().unwrap();
1295 let gitdir = fresh_repo_dir(tmp.path());
1296 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1297
1298 MemBackend::write_entity(&writer, Path::new("hello.md"), b"# hi\n").unwrap();
1301 writer
1302 .write_anchors_sidecar(b"{\"version\":1,\"entities\":{}}")
1303 .unwrap();
1304 MemBackend::commit(&writer, "entity+anchors", &ctx_for_test()).unwrap();
1305
1306 let sidecar = read_blob(&gitdir, "refs/heads/test", ".memstead/anchors.json").unwrap();
1308 assert_eq!(sidecar, b"{\"version\":1,\"entities\":{}}");
1309
1310 let reloaded = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1312 assert_eq!(
1313 reloaded.read_anchors_sidecar().unwrap(),
1314 Some(b"{\"version\":1,\"entities\":{}}".to_vec())
1315 );
1316 assert_eq!(
1318 MemBackend::list_entities(&reloaded).unwrap(),
1319 vec![PathBuf::from("hello.md")]
1320 );
1321 }
1322
1323 #[test]
1324 fn git_tree_writer_delete_removes_path() {
1325 let tmp = TempDir::new().unwrap();
1326 let gitdir = fresh_repo_dir(tmp.path());
1327 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1328
1329 writer.write_entity(Path::new("a.md"), b"a").unwrap();
1330 writer.write_entity(Path::new("b.md"), b"b").unwrap();
1331 writer.commit("seed", &ctx_for_test()).unwrap();
1332
1333 writer.delete_entity(Path::new("a.md")).unwrap();
1334 writer.commit("drop a", &ctx_for_test()).unwrap();
1335
1336 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "a.md"));
1337 assert!(tree_path_exists(&gitdir, "refs/heads/test", "b.md"));
1338 }
1339
1340 #[test]
1341 fn git_tree_writer_move_renames_path() {
1342 let tmp = TempDir::new().unwrap();
1343 let gitdir = fresh_repo_dir(tmp.path());
1344 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1345
1346 writer
1347 .write_entity(Path::new("from.md"), b"payload")
1348 .unwrap();
1349 writer.commit("seed", &ctx_for_test()).unwrap();
1350
1351 writer
1352 .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
1353 .unwrap();
1354 writer.commit("rename", &ctx_for_test()).unwrap();
1355
1356 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "from.md"));
1357 let moved = read_blob(&gitdir, "refs/heads/test", "nested/to.md").unwrap();
1358 assert_eq!(moved, b"payload");
1359 }
1360
1361 #[test]
1362 fn git_tree_writer_multi_op_commit() {
1363 let tmp = TempDir::new().unwrap();
1364 let gitdir = fresh_repo_dir(tmp.path());
1365 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1366
1367 writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
1370 writer.commit("seed", &ctx_for_test()).unwrap();
1371
1372 writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
1373 writer
1374 .write_entity(Path::new("nested/b.md"), b"beta")
1375 .unwrap();
1376 writer.delete_entity(Path::new("doomed.md")).unwrap();
1377 writer.commit("multi-op", &ctx_for_test()).unwrap();
1378
1379 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "doomed.md"));
1380 assert_eq!(
1381 read_blob(&gitdir, "refs/heads/test", "a.md").unwrap(),
1382 b"alpha"
1383 );
1384 assert_eq!(
1385 read_blob(&gitdir, "refs/heads/test", "nested/b.md").unwrap(),
1386 b"beta"
1387 );
1388 }
1389
1390 #[test]
1391 fn git_tree_writer_cas_conflict_surfaces_hash_mismatch() {
1392 let tmp = TempDir::new().unwrap();
1393 let gitdir = fresh_repo_dir(tmp.path());
1394
1395 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1397 seeder.write_entity(Path::new("seed.md"), b"x").unwrap();
1398 let seed_sha = seeder.commit("seed", &ctx_for_test()).unwrap();
1399
1400 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1401 let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1402
1403 a.write_entity(Path::new("a.md"), b"a").unwrap();
1405 b.write_entity(Path::new("b.md"), b"b").unwrap();
1406 assert_eq!(
1407 a.pending
1408 .lock()
1409 .unwrap()
1410 .parent
1411 .unwrap()
1412 .to_hex()
1413 .to_string(),
1414 seed_sha
1415 );
1416 assert_eq!(
1417 b.pending
1418 .lock()
1419 .unwrap()
1420 .parent
1421 .unwrap()
1422 .to_hex()
1423 .to_string(),
1424 seed_sha
1425 );
1426
1427 let new_tip = a.commit("a wins", &ctx_for_test()).unwrap();
1430 let err = b
1431 .commit("b loses", &ctx_for_test())
1432 .expect_err("B's commit must fail with HashMismatch");
1433 match err {
1434 MemWriterError::HashMismatch { current } => {
1435 assert_eq!(current, new_tip);
1436 }
1437 other => panic!("expected HashMismatch, got {other:?}"),
1438 }
1439 }
1440
1441 #[test]
1442 fn cas_conflict_clears_pending_so_reads_fall_back_to_committed_truth() {
1443 let tmp = TempDir::new().unwrap();
1450 let gitdir = fresh_repo_dir(tmp.path());
1451
1452 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1455 seeder.write_entity(Path::new("shared.md"), b"v1").unwrap();
1456 seeder.commit("seed", &ctx_for_test()).unwrap();
1457
1458 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1459 let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1460
1461 a.write_entity(Path::new("shared.md"), b"A-committed")
1464 .unwrap();
1465 b.write_entity(Path::new("shared.md"), b"B-phantom")
1466 .unwrap();
1467
1468 a.commit("a wins", &ctx_for_test()).unwrap();
1470 let err = b
1471 .commit("b loses", &ctx_for_test())
1472 .expect_err("B must lose the CAS race");
1473 assert!(
1474 matches!(err, MemWriterError::HashMismatch { .. }),
1475 "expected HashMismatch, got {err:?}"
1476 );
1477
1478 assert!(
1480 b.pending.lock().unwrap().ops.is_empty(),
1481 "pending must be cleared after a failed commit"
1482 );
1483 let read = <GitTreeMemWriter as memstead_base::backend::MemBackend>::read_entity(
1486 &b,
1487 Path::new("shared.md"),
1488 )
1489 .unwrap();
1490 assert_eq!(
1491 read.as_deref(),
1492 Some(&b"A-committed"[..]),
1493 "read must serve committed truth, not the phantom staged write"
1494 );
1495 }
1496
1497 #[test]
1498 fn commit_with_expected_parent_succeeds_when_ref_matches_pin() {
1499 use memstead_base::backend::MemBackend;
1500
1501 let tmp = TempDir::new().unwrap();
1502 let gitdir = fresh_repo_dir(tmp.path());
1503
1504 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1505 <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1506 let seed_sha =
1507 <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1508
1509 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1511 let expected = <GitTreeMemWriter as MemBackend>::current_head(&writer)
1512 .unwrap()
1513 .expect("seeded ref has a head");
1514 assert_eq!(expected, seed_sha);
1515
1516 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("after.md"), b"after")
1517 .unwrap();
1518
1519 let new_tip = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1520 &writer,
1521 "pinned commit",
1522 &ctx_for_test(),
1523 Some(&expected),
1524 )
1525 .expect("parent matches pin → commit must succeed");
1526 assert_ne!(new_tip, seed_sha);
1527 assert_eq!(
1528 read_blob(&gitdir, "refs/heads/test", "after.md").unwrap(),
1529 b"after"
1530 );
1531 }
1532
1533 #[test]
1534 fn commit_with_expected_parent_surfaces_parent_mismatch_when_sibling_advances_ref() {
1535 use memstead_base::backend::{BackendError, MemBackend};
1536
1537 let tmp = TempDir::new().unwrap();
1538 let gitdir = fresh_repo_dir(tmp.path());
1539
1540 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1542 <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1543 let seed_sha =
1544 <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1545
1546 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1549 let pin = <GitTreeMemWriter as MemBackend>::current_head(&a)
1550 .unwrap()
1551 .expect("seeded ref has a head");
1552 assert_eq!(pin, seed_sha);
1553
1554 let sibling = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1558 <GitTreeMemWriter as MemWriter>::write_entity(&sibling, Path::new("drift.md"), b"drift")
1559 .unwrap();
1560 let new_tip =
1561 <GitTreeMemWriter as MemWriter>::commit(&sibling, "sibling advance", &ctx_for_test())
1562 .unwrap();
1563 assert_ne!(new_tip, seed_sha);
1564
1565 <GitTreeMemWriter as MemWriter>::write_entity(&a, Path::new("a.md"), b"a").unwrap();
1568 let err = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1569 &a,
1570 "pinned commit",
1571 &ctx_for_test(),
1572 Some(&pin),
1573 )
1574 .expect_err("pin no longer matches live tip → commit must refuse");
1575 match err {
1576 BackendError::ParentMismatch { expected, actual } => {
1577 assert_eq!(expected, pin);
1578 assert_eq!(actual, new_tip);
1579 }
1580 other => panic!("expected ParentMismatch, got {other:?}"),
1581 }
1582 }
1583
1584 #[test]
1585 fn commit_with_expected_parent_none_pin_is_equivalent_to_commit() {
1586 use memstead_base::backend::MemBackend;
1587
1588 let tmp = TempDir::new().unwrap();
1589 let gitdir = fresh_repo_dir(tmp.path());
1590 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1591
1592 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("hello.md"), b"hi")
1593 .unwrap();
1594 let sha = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1595 &writer,
1596 "unpinned",
1597 &ctx_for_test(),
1598 None,
1599 )
1600 .expect("None pin → plain commit semantics, must succeed against empty ref");
1601 assert_eq!(sha.len(), 40);
1602 assert_eq!(
1603 read_blob(&gitdir, "refs/heads/test", "hello.md").unwrap(),
1604 b"hi"
1605 );
1606 }
1607
1608 #[test]
1609 fn git_tree_writer_blob_oid_is_content_addressed() {
1610 let tmp_a = TempDir::new().unwrap();
1614 let tmp_b = TempDir::new().unwrap();
1615 let payload = b"shared content\n";
1616
1617 let gitdir_a = fresh_repo_dir(tmp_a.path());
1618 let writer_a = GitTreeMemWriter::new(gitdir_a.clone(), "refs/heads/a".to_string());
1619 writer_a
1620 .write_entity(Path::new("file.md"), payload)
1621 .unwrap();
1622 let sha_a = writer_a.commit("a", &ctx_for_test()).unwrap();
1623 let repo_a = gix::open(&gitdir_a).unwrap();
1624 let commit_a = repo_a
1625 .find_object(gix::ObjectId::from_hex(sha_a.as_bytes()).unwrap())
1626 .unwrap()
1627 .into_commit();
1628 let blob_id_a = commit_a
1629 .tree()
1630 .unwrap()
1631 .lookup_entry_by_path("file.md")
1632 .unwrap()
1633 .unwrap()
1634 .id()
1635 .detach();
1636
1637 let gitdir_b = fresh_repo_dir(tmp_b.path());
1638 let writer_b = GitTreeMemWriter::new(gitdir_b.clone(), "refs/heads/b".to_string());
1639 writer_b
1640 .write_entity(Path::new("file.md"), payload)
1641 .unwrap();
1642 let sha_b = writer_b.commit("b", &ctx_for_test()).unwrap();
1643 let repo_b = gix::open(&gitdir_b).unwrap();
1644 let commit_b = repo_b
1645 .find_object(gix::ObjectId::from_hex(sha_b.as_bytes()).unwrap())
1646 .unwrap()
1647 .into_commit();
1648 let blob_id_b = commit_b
1649 .tree()
1650 .unwrap()
1651 .lookup_entry_by_path("file.md")
1652 .unwrap()
1653 .unwrap()
1654 .id()
1655 .detach();
1656
1657 assert_eq!(
1658 blob_id_a, blob_id_b,
1659 "same content must produce byte-identical blob OIDs"
1660 );
1661 }
1662
1663 fn fresh_non_bare_repo(tmp: &Path) -> (PathBuf, PathBuf) {
1668 let workdir = tmp.join("mem-repo-workdir");
1669 std::fs::create_dir_all(&workdir).unwrap();
1670 let status = std::process::Command::new("git")
1671 .arg("-C")
1672 .arg(&workdir)
1673 .args(["init", "--initial-branch=main", "--quiet"])
1674 .status()
1675 .expect("git init must succeed");
1676 assert!(status.success(), "git init failed");
1677 let workdir = std::fs::canonicalize(&workdir).unwrap();
1678 let gitdir = workdir.join(".git");
1679 (workdir, gitdir)
1680 }
1681
1682 #[test]
1683 fn sync_helper_skips_on_bare_repo() {
1684 let tmp = TempDir::new().unwrap();
1685 let gitdir = fresh_repo_dir(tmp.path());
1686 let repo = gix::open(&gitdir).unwrap();
1687
1688 sync_index_and_worktree(&repo, "refs/heads/main").unwrap();
1691 }
1692
1693 #[test]
1694 fn sync_helper_updates_worktree_when_ref_matches_head() {
1695 let tmp = TempDir::new().unwrap();
1696 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1697 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1698
1699 writer
1700 .write_entity(Path::new("configs/alpha.json"), b"{\"name\":\"alpha\"}\n")
1701 .unwrap();
1702 writer.commit("seed alpha", &ctx_for_test()).unwrap();
1703
1704 let on_disk = workdir.join("configs/alpha.json");
1707 assert!(
1708 on_disk.exists(),
1709 "worktree sync must materialise the new blob at {}",
1710 on_disk.display()
1711 );
1712 let bytes = std::fs::read(&on_disk).unwrap();
1713 assert_eq!(bytes, b"{\"name\":\"alpha\"}\n");
1714
1715 let output = std::process::Command::new("git")
1717 .arg("-C")
1718 .arg(&workdir)
1719 .args(["status", "--porcelain"])
1720 .output()
1721 .unwrap();
1722 assert!(
1723 output.stdout.is_empty(),
1724 "git status --porcelain must be empty post-sync, got: {:?}",
1725 String::from_utf8_lossy(&output.stdout)
1726 );
1727 }
1728
1729 #[test]
1730 fn sync_helper_skips_when_ref_does_not_match_head() {
1731 let tmp = TempDir::new().unwrap();
1732 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1733
1734 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/feature".to_string());
1738 writer
1739 .write_entity(Path::new("only-on-feature.md"), b"feature-only\n")
1740 .unwrap();
1741 writer
1742 .commit("first commit on feature", &ctx_for_test())
1743 .unwrap();
1744
1745 assert!(tree_path_exists(
1747 &gitdir,
1748 "refs/heads/feature",
1749 "only-on-feature.md"
1750 ));
1751 assert!(
1753 !workdir.join("only-on-feature.md").exists(),
1754 "worktree must not be polluted by writes to a non-checked-out branch"
1755 );
1756 }
1757
1758 #[test]
1759 fn sync_helper_preserves_untracked_files() {
1760 let tmp = TempDir::new().unwrap();
1761 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1762 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1763
1764 let untracked = workdir.join("scratch.txt");
1768 std::fs::write(&untracked, b"operator notes\n").unwrap();
1769
1770 writer
1771 .write_entity(Path::new("seed.md"), b"seed\n")
1772 .unwrap();
1773 writer.commit("create seed", &ctx_for_test()).unwrap();
1774
1775 assert!(
1776 untracked.exists(),
1777 "sync must leave untracked files in place"
1778 );
1779 assert_eq!(std::fs::read(&untracked).unwrap(), b"operator notes\n");
1780 assert_eq!(std::fs::read(workdir.join("seed.md")).unwrap(), b"seed\n");
1782 }
1783
1784 #[test]
1785 fn sync_helper_updates_through_delete_and_overwrite() {
1786 let tmp = TempDir::new().unwrap();
1787 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1788 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1789
1790 writer.write_entity(Path::new("a.md"), b"first\n").unwrap();
1791 writer.commit("create a", &ctx_for_test()).unwrap();
1792 assert_eq!(std::fs::read(workdir.join("a.md")).unwrap(), b"first\n");
1793
1794 writer.write_entity(Path::new("a.md"), b"second\n").unwrap();
1795 writer.commit("overwrite a", &ctx_for_test()).unwrap();
1796 assert_eq!(
1797 std::fs::read(workdir.join("a.md")).unwrap(),
1798 b"second\n",
1799 "overwrite must propagate to the worktree"
1800 );
1801
1802 writer.delete_entity(Path::new("a.md")).unwrap();
1803 writer.commit("delete a", &ctx_for_test()).unwrap();
1804 assert!(
1805 !workdir.join("a.md").exists(),
1806 "delete must remove the file from the worktree"
1807 );
1808
1809 let output = std::process::Command::new("git")
1811 .arg("-C")
1812 .arg(&workdir)
1813 .args(["status", "--porcelain"])
1814 .output()
1815 .unwrap();
1816 assert!(
1817 output.stdout.is_empty(),
1818 "git status --porcelain must be empty after every commit, got: {:?}",
1819 String::from_utf8_lossy(&output.stdout)
1820 );
1821 }
1822
1823 fn commit_with_verb(
1829 writer: &GitTreeMemWriter,
1830 verb: &str,
1831 entity_id: &str,
1832 ctx: &CommitContext<'_>,
1833 ) {
1834 let subject = format!("memstead: {verb} {entity_id}");
1835 <GitTreeMemWriter as MemWriter>::commit(writer, &subject, ctx).unwrap();
1836 }
1837
1838 fn ctx_with_note<'a>(note: &'a str) -> CommitContext<'a> {
1839 CommitContext {
1840 actor: Actor::Agent,
1841 client: Some(ClientId {
1842 name: "claude-code".to_string(),
1843 version: "2.1.0".to_string(),
1844 }),
1845 tool: Some("memstead_create"),
1846 note: Some(note.to_string()),
1847 logical_operation_id: None,
1848 entity_ids: None,
1849 }
1850 }
1851
1852 #[test]
1853 fn backend_list_entities_returns_only_md_outside_memstead_namespace() {
1854 use memstead_base::backend::MemBackend;
1855
1856 let tmp = TempDir::new().unwrap();
1857 let gitdir = fresh_repo_dir(tmp.path());
1858 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1859
1860 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"# a").unwrap();
1863 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"# b")
1864 .unwrap();
1865 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
1866 .unwrap();
1867 <GitTreeMemWriter as MemWriter>::write_entity(
1868 &writer,
1869 Path::new(".memstead/config.json"),
1870 b"{}",
1871 )
1872 .unwrap();
1873 <GitTreeMemWriter as MemWriter>::write_entity(
1874 &writer,
1875 Path::new(".memstead/notes.md"),
1876 b"# skip me",
1877 )
1878 .unwrap();
1879 <GitTreeMemWriter as MemWriter>::write_entity(
1880 &writer,
1881 Path::new(".other/notes.md"),
1882 b"# no longer special, walked like any non-meta dir",
1883 )
1884 .unwrap();
1885 <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1886
1887 let backend: &dyn MemBackend = &writer;
1888 let mut paths: Vec<String> = backend
1889 .list_entities()
1890 .unwrap()
1891 .into_iter()
1892 .map(|p| p.to_string_lossy().into_owned())
1893 .collect();
1894 paths.sort();
1895 assert_eq!(
1897 paths,
1898 vec![
1899 ".other/notes.md".to_string(),
1900 "a.md".to_string(),
1901 "nested/b.md".to_string(),
1902 ]
1903 );
1904 }
1905
1906 #[test]
1907 fn backend_list_entities_returns_empty_for_missing_branch() {
1908 use memstead_base::backend::MemBackend;
1909
1910 let tmp = TempDir::new().unwrap();
1911 let gitdir = fresh_repo_dir(tmp.path());
1912 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
1913 let backend: &dyn MemBackend = &writer;
1914 assert!(backend.list_entities().unwrap().is_empty());
1916 }
1917
1918 #[test]
1919 fn backend_read_entity_consults_pending_then_branch_tip() {
1920 use memstead_base::backend::MemBackend;
1921
1922 let tmp = TempDir::new().unwrap();
1923 let gitdir = fresh_repo_dir(tmp.path());
1924 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1925
1926 <GitTreeMemWriter as MemWriter>::write_entity(
1928 &writer,
1929 Path::new("on_branch.md"),
1930 b"branch",
1931 )
1932 .unwrap();
1933 <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1934
1935 let backend: &dyn MemBackend = &writer;
1936 assert_eq!(
1938 backend.read_entity(Path::new("on_branch.md")).unwrap(),
1939 Some(b"branch".to_vec())
1940 );
1941 backend
1943 .write_entity(Path::new("on_branch.md"), b"buffered")
1944 .unwrap();
1945 assert_eq!(
1946 backend.read_entity(Path::new("on_branch.md")).unwrap(),
1947 Some(b"buffered".to_vec())
1948 );
1949 backend.delete_entity(Path::new("on_branch.md")).unwrap();
1951 assert_eq!(
1952 backend.read_entity(Path::new("on_branch.md")).unwrap(),
1953 None
1954 );
1955 assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
1957 }
1958
1959 #[test]
1960 fn backend_read_provenance_reconstructs_from_commit_log() {
1961 use memstead_base::backend::MemBackend;
1962
1963 let tmp = TempDir::new().unwrap();
1964 let gitdir = fresh_repo_dir(tmp.path());
1965 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1966
1967 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
1971 commit_with_verb(&writer, "create", "v:a", &ctx_with_note("first draft"));
1972
1973 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
1974 commit_with_verb(
1975 &writer,
1976 "update",
1977 "v:a",
1978 &CommitContext {
1979 actor: Actor::Cli,
1980 client: None,
1981 tool: Some("memstead_update"),
1982 note: None,
1983 logical_operation_id: None,
1984 entity_ids: None,
1985 },
1986 );
1987
1988 let backend: &dyn MemBackend = &writer;
1989 backend
1992 .append_provenance(&memstead_base::Provenance::new(
1993 std::time::UNIX_EPOCH,
1994 memstead_base::ProvenanceKind::Create,
1995 Some("ignored".into()),
1996 Actor::Unknown,
1997 None,
1998 None,
1999 ))
2000 .unwrap();
2001
2002 let records = backend.read_provenance(None).unwrap();
2003 assert_eq!(records.len(), 2, "expected two commits, got {records:?}");
2004 assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Create);
2006 assert_eq!(records[0].entity.as_deref(), Some("v:a"));
2007 assert_eq!(records[0].actor, Actor::Agent);
2008 assert_eq!(records[0].note.as_deref(), Some("first draft"));
2009 assert_eq!(
2010 records[0]
2011 .client
2012 .as_ref()
2013 .map(|c| (c.name.as_str(), c.version.as_str())),
2014 Some(("claude-code", "2.1.0"))
2015 );
2016 assert_eq!(records[1].kind, memstead_base::ProvenanceKind::Update);
2017 assert_eq!(records[1].actor, Actor::Cli);
2018 assert!(records[1].note.is_none());
2019 assert!(records[1].client.is_none());
2020 }
2021
2022 #[test]
2023 fn backend_read_provenance_filters_by_cursor_sha() {
2024 use memstead_base::backend::MemBackend;
2025
2026 let tmp = TempDir::new().unwrap();
2027 let gitdir = fresh_repo_dir(tmp.path());
2028 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2029
2030 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2032 let first_sha = <GitTreeMemWriter as MemWriter>::commit(
2033 &writer,
2034 "memstead: create v:a",
2035 &ctx_for_test(),
2036 )
2037 .unwrap();
2038 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
2039 <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
2040 .unwrap();
2041 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a3").unwrap();
2042 <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
2043 .unwrap();
2044
2045 let backend: &dyn MemBackend = &writer;
2046 let after = backend.read_provenance(Some(&first_sha)).unwrap();
2048 assert_eq!(
2049 after.len(),
2050 2,
2051 "expected commits after cursor, got {after:?}"
2052 );
2053 for r in &after {
2054 assert_eq!(r.kind, memstead_base::ProvenanceKind::Update);
2055 }
2056 }
2057
2058 #[test]
2059 fn backend_read_provenance_empty_for_missing_branch() {
2060 use memstead_base::backend::MemBackend;
2061
2062 let tmp = TempDir::new().unwrap();
2063 let gitdir = fresh_repo_dir(tmp.path());
2064 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
2065 let backend: &dyn MemBackend = &writer;
2066 assert!(backend.read_provenance(None).unwrap().is_empty());
2068 }
2069
2070 #[test]
2071 fn backend_unknown_verb_falls_back_to_update_kind() {
2072 use memstead_base::backend::MemBackend;
2073
2074 let tmp = TempDir::new().unwrap();
2075 let gitdir = fresh_repo_dir(tmp.path());
2076 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2077
2078 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2079 commit_with_verb(&writer, "mem_create", "v:a", &ctx_for_test());
2083
2084 let backend: &dyn MemBackend = &writer;
2085 let records = backend.read_provenance(None).unwrap();
2086 assert_eq!(records.len(), 1);
2087 assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Update);
2088 }
2089
2090 #[test]
2091 fn instantiate_full_backend_constructs_git_branch_writer() {
2092 use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2097
2098 let tmp = TempDir::new().unwrap();
2099 let gitdir = fresh_repo_dir(tmp.path());
2100 let mount = Mount {
2101 mem: "engine".to_string(),
2102 schema: Some("default@1.0.0".parse().unwrap()),
2103 storage: MountStorage::GitBranch {
2104 gitdir,
2105 branch: "engine".to_string(),
2106 },
2107 capability: MountCapability::Write,
2108 lifecycle: MountLifecycle::Eager,
2109 cross_linkable: true,
2110 migration_target: None,
2111 };
2112 let backend: Box<dyn MemBackend> =
2113 crate::storage::instantiate_full_backend(&mount).unwrap();
2114 assert!(backend.list_entities().unwrap().is_empty());
2116 assert!(backend.read_provenance(None).unwrap().is_empty());
2118 }
2119
2120 #[test]
2121 fn instantiate_full_backend_accepts_branch_with_or_without_refs_prefix() {
2122 use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2127
2128 let tmp = TempDir::new().unwrap();
2129 let gitdir = fresh_repo_dir(tmp.path());
2130 for branch in ["engine", "refs/heads/engine"] {
2131 let mount = Mount {
2132 mem: "engine".to_string(),
2133 schema: Some("default@1.0.0".parse().unwrap()),
2134 storage: MountStorage::GitBranch {
2135 gitdir: gitdir.clone(),
2136 branch: branch.to_string(),
2137 },
2138 capability: MountCapability::Write,
2139 lifecycle: MountLifecycle::Eager,
2140 cross_linkable: true,
2141 migration_target: None,
2142 };
2143 let backend: Box<dyn MemBackend> =
2144 crate::storage::instantiate_full_backend(&mount).unwrap();
2145 assert!(backend.list_entities().unwrap().is_empty());
2147 }
2148 }
2149
2150 #[test]
2153 fn current_head_returns_none_for_empty_branch() {
2154 let tmp = TempDir::new().unwrap();
2159 let gitdir = fresh_repo_dir(tmp.path());
2160 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/specs".to_string());
2161 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2162 .unwrap();
2163 assert!(head.is_none());
2164 }
2165
2166 #[test]
2167 fn current_head_returns_hex_sha_after_commit() {
2168 let tmp = TempDir::new().unwrap();
2174 let gitdir = fresh_repo_dir(tmp.path());
2175 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2176
2177 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2178 let sha = writer.commit("first", &ctx_for_test()).unwrap();
2179 assert_eq!(sha.len(), 40);
2180
2181 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2182 .unwrap()
2183 .expect("head present after commit");
2184 assert_eq!(head, sha);
2185 }
2186
2187 #[test]
2188 fn current_head_advances_on_subsequent_commits() {
2189 let tmp = TempDir::new().unwrap();
2194 let gitdir = fresh_repo_dir(tmp.path());
2195 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2196
2197 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2198 let first = writer.commit("first", &ctx_for_test()).unwrap();
2199 let head_after_first =
2200 <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2201 .unwrap()
2202 .unwrap();
2203 assert_eq!(head_after_first, first);
2204
2205 writer.write_entity(Path::new("b.md"), b"b").unwrap();
2206 let second = writer.commit("second", &ctx_for_test()).unwrap();
2207 assert_ne!(first, second);
2208 let head_after_second =
2209 <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2210 .unwrap()
2211 .unwrap();
2212 assert_eq!(head_after_second, second);
2213 }
2214
2215 #[test]
2216 fn current_head_returns_none_for_missing_gitdir() {
2217 let tmp = TempDir::new().unwrap();
2223 let writer = GitTreeMemWriter::new(
2224 tmp.path().join("does-not-exist.git"),
2225 "refs/heads/specs".to_string(),
2226 );
2227 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2228 .unwrap();
2229 assert!(head.is_none());
2230 }
2231
2232 fn dispatch_changes(
2240 gitdir: &Path,
2241 branch: &str,
2242 mem: &str,
2243 since: &str,
2244 ) -> Result<memstead_base::ops::BackendChanges, memstead_base::backend::BackendError> {
2245 (crate::storage::FULL_GIT_BRANCH_OPS.changes_since)(
2246 gitdir,
2247 branch,
2248 mem,
2249 since,
2250 memstead_base::ops::RENAME_SIMILARITY_DEFAULT,
2251 )
2252 }
2253
2254 #[test]
2255 fn changes_since_empty_repo_with_sentinel_returns_empty_changes() {
2256 let tmp = TempDir::new().unwrap();
2260 let gitdir = fresh_repo_dir(tmp.path());
2261 let result = dispatch_changes(
2262 &gitdir,
2263 "specs",
2264 "specs",
2265 memstead_base::ops::EMPTY_TREE_SHA,
2266 )
2267 .unwrap();
2268 assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2269 assert_eq!(result.head, memstead_base::ops::EMPTY_TREE_SHA);
2270 assert!(result.changes.is_empty());
2271 }
2272
2273 #[test]
2274 fn changes_since_after_commit_returns_added_envelopes_id_only() {
2275 use memstead_base::ops::ChangeEnvelope;
2279 let tmp = TempDir::new().unwrap();
2280 let gitdir = fresh_repo_dir(tmp.path());
2281 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2282
2283 writer
2284 .write_entity(Path::new("alpha.md"), b"# Alpha")
2285 .unwrap();
2286 writer
2287 .write_entity(Path::new("beta.md"), b"# Beta")
2288 .unwrap();
2289 let head_sha = writer.commit("seed", &ctx_for_test()).unwrap();
2290
2291 let result = dispatch_changes(
2292 &gitdir,
2293 "specs",
2294 "specs",
2295 memstead_base::ops::EMPTY_TREE_SHA,
2296 )
2297 .unwrap();
2298 assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2299 assert_eq!(result.head, head_sha);
2300 assert_eq!(result.changes.len(), 2);
2301 for env in &result.changes {
2302 match env {
2303 ChangeEnvelope::Added {
2304 id,
2305 title,
2306 entity_type,
2307 } => {
2308 assert!(
2309 id.0.starts_with("specs--"),
2310 "expected mem-prefixed id, got {}",
2311 id.0
2312 );
2313 assert!(title.is_none(), "dispatch must not enrich title");
2314 assert!(
2315 entity_type.is_none(),
2316 "dispatch must not enrich entity_type"
2317 );
2318 }
2319 other => panic!("expected Added envelope, got {other:?}"),
2320 }
2321 }
2322 }
2323
2324 #[test]
2325 fn changes_since_between_two_commits_yields_updated_envelope() {
2326 use memstead_base::ops::ChangeEnvelope;
2327 let tmp = TempDir::new().unwrap();
2328 let gitdir = fresh_repo_dir(tmp.path());
2329 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2330
2331 writer
2332 .write_entity(Path::new("alpha.md"), b"# Alpha v1")
2333 .unwrap();
2334 let sha_v1 = writer.commit("v1", &ctx_for_test()).unwrap();
2335
2336 writer
2337 .write_entity(Path::new("alpha.md"), b"# Alpha v2")
2338 .unwrap();
2339 let sha_v2 = writer.commit("v2", &ctx_for_test()).unwrap();
2340 assert_ne!(sha_v1, sha_v2);
2341
2342 let result = dispatch_changes(&gitdir, "specs", "specs", &sha_v1).unwrap();
2343 assert_eq!(result.since, sha_v1);
2344 assert_eq!(result.head, sha_v2);
2345 assert_eq!(result.changes.len(), 1);
2346 match &result.changes[0] {
2347 ChangeEnvelope::Updated {
2348 id,
2349 title,
2350 entity_type,
2351 } => {
2352 assert!(id.0.starts_with("specs--"));
2353 assert!(title.is_none());
2354 assert!(entity_type.is_none());
2355 }
2356 other => panic!("expected Updated envelope, got {other:?}"),
2357 }
2358 }
2359
2360 #[test]
2361 fn anchor_only_commit_yields_zero_entity_deltas_and_valid_cursor() {
2362 use memstead_base::backend::MemBackend;
2368 let tmp = TempDir::new().unwrap();
2369 let gitdir = fresh_repo_dir(tmp.path());
2370 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2371
2372 MemBackend::write_entity(&writer, Path::new("alpha.md"), b"# Alpha").unwrap();
2373 let seed_sha = MemBackend::commit(&writer, "seed", &ctx_for_test()).unwrap();
2374
2375 writer
2377 .write_anchors_sidecar(
2378 br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#,
2379 )
2380 .unwrap();
2381 let anchor_sha = MemBackend::commit(&writer, "anchors", &ctx_for_test()).unwrap();
2382 assert_ne!(seed_sha, anchor_sha);
2383
2384 let from_seed = dispatch_changes(&gitdir, "specs", "specs", &seed_sha).unwrap();
2386 assert_eq!(from_seed.head, anchor_sha);
2387 assert_eq!(
2388 from_seed.changes.len(),
2389 0,
2390 "an anchor-only commit must produce zero entity deltas, got {:?}",
2391 from_seed.changes
2392 );
2393
2394 let from_anchor = dispatch_changes(&gitdir, "specs", "specs", &anchor_sha).unwrap();
2397 assert_eq!(from_anchor.head, anchor_sha);
2398 assert_eq!(from_anchor.changes.len(), 0);
2399 }
2400
2401 #[test]
2402 fn changes_since_unknown_cursor_returns_typed_commit_not_found_marker() {
2403 let tmp = TempDir::new().unwrap();
2411 let gitdir = fresh_repo_dir(tmp.path());
2412 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2413 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2415 writer.commit("seed", &ctx_for_test()).unwrap();
2416
2417 let bad_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2418 let err = dispatch_changes(&gitdir, "specs", "specs", bad_sha).unwrap_err();
2419 match err {
2420 memstead_base::backend::BackendError::Other(msg) => {
2421 assert_eq!(
2422 msg,
2423 format!("COMMIT_NOT_FOUND:{bad_sha}"),
2424 "bad-since must carry the typed marker with the untruncated sha: {msg}",
2425 );
2426 }
2427 other => panic!("expected BackendError::Other, got {other:?}"),
2428 }
2429 }
2430}