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