1use std::collections::HashMap;
33use std::path::{Path, PathBuf};
34use std::sync::Mutex;
35
36use gix::objs::tree::EntryKind;
37
38use super::{CommitId, MemWriter, MemWriterError};
39use crate::vcs::{CommitContext, acquire_branch_mutex, author_identity, format_commit_message};
40
41enum PendingState {
45 Upsert(Vec<u8>),
46 Delete,
47}
48
49struct Pending {
53 parent: Option<gix::ObjectId>,
59 ops: HashMap<String, PendingState>,
63}
64
65impl Pending {
66 fn new() -> Self {
67 Self {
68 parent: None,
69 ops: HashMap::new(),
70 }
71 }
72
73 fn clear(&mut self) {
74 self.parent = None;
75 self.ops.clear();
76 }
77}
78
79pub struct GitTreeMemWriter {
87 gitdir: PathBuf,
88 ref_name: String,
89 pending: Mutex<Pending>,
90}
91
92impl GitTreeMemWriter {
93 pub fn new(gitdir: PathBuf, ref_name: String) -> Self {
99 Self {
100 gitdir,
101 ref_name,
102 pending: Mutex::new(Pending::new()),
103 }
104 }
105
106 fn open_repo(&self) -> Result<gix::Repository, MemWriterError> {
107 gix::open(&self.gitdir).map_err(|e| {
108 MemWriterError::Path(format!(
109 "git-tree writer: open repo at {}: {e}",
110 self.gitdir.display()
111 ))
112 })
113 }
114
115 fn ensure_snapshot(&self, pending: &mut Pending) -> Result<(), MemWriterError> {
119 if pending.parent.is_some() || !pending.ops.is_empty() {
120 return Ok(());
121 }
122 let repo = self.open_repo()?;
123 let mut reference = match repo.try_find_reference(&self.ref_name).map_err(|e| {
124 MemWriterError::Path(format!(
125 "git-tree writer: resolve ref {}: {e}",
126 self.ref_name
127 ))
128 })? {
129 Some(r) => r,
130 None => return Ok(()),
131 };
132 let id = reference.peel_to_id().map_err(|e| {
133 MemWriterError::Path(format!(
134 "git-tree writer: peel ref {} to id: {e}",
135 self.ref_name
136 ))
137 })?;
138 pending.parent = Some(id.detach());
139 Ok(())
140 }
141
142 fn live_tip(&self) -> Result<Option<gix::ObjectId>, MemWriterError> {
149 let repo = self.open_repo()?;
150 let mut reference = match repo.try_find_reference(&self.ref_name).map_err(|e| {
151 MemWriterError::Path(format!(
152 "git-tree writer: resolve ref {}: {e}",
153 self.ref_name
154 ))
155 })? {
156 Some(r) => r,
157 None => return Ok(None),
158 };
159 let id = reference.peel_to_id().map_err(|e| {
160 MemWriterError::Path(format!(
161 "git-tree writer: peel ref {} to id: {e}",
162 self.ref_name
163 ))
164 })?;
165 Ok(Some(id.detach()))
166 }
167
168 fn read_blob_from_parent(
172 &self,
173 parent: gix::ObjectId,
174 path: &str,
175 ) -> Result<Option<Vec<u8>>, MemWriterError> {
176 let repo = self.open_repo()?;
177 let commit = repo
178 .find_object(parent)
179 .map_err(|e| MemWriterError::Path(format!("git-tree writer: open parent commit: {e}")))?
180 .into_commit();
181 let tree = commit.tree().map_err(|e| {
182 MemWriterError::Path(format!("git-tree writer: peel commit to tree: {e}"))
183 })?;
184 let entry = match tree.lookup_entry_by_path(path).map_err(|e| {
185 MemWriterError::Path(format!(
186 "git-tree writer: lookup {path} in parent tree: {e}"
187 ))
188 })? {
189 Some(e) => e,
190 None => return Ok(None),
191 };
192 if !entry.mode().is_blob() {
193 return Ok(None);
194 }
195 let object = repo
196 .find_object(entry.id())
197 .map_err(|e| MemWriterError::Path(format!("git-tree writer: read blob {path}: {e}")))?;
198 Ok(Some(object.data.clone()))
199 }
200
201 fn path_exists_from_parent(
207 &self,
208 parent: gix::ObjectId,
209 path: &str,
210 ) -> Result<bool, MemWriterError> {
211 let repo = self.open_repo()?;
212 let commit = repo
213 .find_object(parent)
214 .map_err(|e| MemWriterError::Path(format!("git-tree writer: open parent commit: {e}")))?
215 .into_commit();
216 let tree = commit.tree().map_err(|e| {
217 MemWriterError::Path(format!("git-tree writer: peel commit to tree: {e}"))
218 })?;
219 let entry = tree.lookup_entry_by_path(path).map_err(|e| {
220 MemWriterError::Path(format!(
221 "git-tree writer: lookup {path} in parent tree: {e}"
222 ))
223 })?;
224 Ok(entry.is_some_and(|e| e.mode().is_blob()))
225 }
226}
227
228fn normalise_rel_path(rel_path: &Path) -> Result<String, MemWriterError> {
233 if rel_path.as_os_str().is_empty() {
234 return Err(MemWriterError::Path(
235 "mem-relative path is empty".to_string(),
236 ));
237 }
238 let mut parts: Vec<String> = Vec::new();
239 for component in rel_path.components() {
240 use std::path::Component;
241 match component {
242 Component::Normal(s) => match s.to_str() {
243 Some(p) if !p.is_empty() => parts.push(p.to_string()),
244 _ => {
245 return Err(MemWriterError::Path(format!(
246 "non-utf-8 or empty path component in {}",
247 rel_path.display()
248 )));
249 }
250 },
251 Component::CurDir => continue,
252 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
253 return Err(MemWriterError::Path(format!(
254 "path traversal or absolute component in {}",
255 rel_path.display()
256 )));
257 }
258 }
259 }
260 if parts.is_empty() {
261 return Err(MemWriterError::Path(
262 "mem-relative path is empty after normalisation".to_string(),
263 ));
264 }
265 Ok(parts.join("/"))
266}
267
268impl MemWriter for GitTreeMemWriter {
269 fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), MemWriterError> {
270 let key = normalise_rel_path(rel_path)?;
271 let mut pending = self.pending.lock().map_err(|_| {
272 MemWriterError::Path("git-tree writer pending state poisoned".to_string())
273 })?;
274 self.ensure_snapshot(&mut pending)?;
275 pending
276 .ops
277 .insert(key, PendingState::Upsert(content.to_vec()));
278 Ok(())
279 }
280
281 fn delete_entity(&self, rel_path: &Path) -> Result<(), MemWriterError> {
282 let key = normalise_rel_path(rel_path)?;
283 let mut pending = self.pending.lock().map_err(|_| {
284 MemWriterError::Path("git-tree writer pending state poisoned".to_string())
285 })?;
286 self.ensure_snapshot(&mut pending)?;
287 pending.ops.insert(key, PendingState::Delete);
288 Ok(())
289 }
290
291 fn move_entity(&self, from: &Path, to: &Path) -> Result<(), MemWriterError> {
292 let from_key = normalise_rel_path(from)?;
293 let to_key = normalise_rel_path(to)?;
294 let mut pending = self.pending.lock().map_err(|_| {
295 MemWriterError::Path("git-tree writer pending state poisoned".to_string())
296 })?;
297 self.ensure_snapshot(&mut pending)?;
298
299 let bytes = match pending.ops.remove(&from_key) {
303 Some(PendingState::Upsert(b)) => b,
304 Some(PendingState::Delete) => {
305 pending.ops.insert(from_key, PendingState::Delete);
306 return Err(MemWriterError::Path(format!(
307 "move source {} is already pending deletion",
308 from.display()
309 )));
310 }
311 None => {
312 let parent = pending.parent;
313 let blob = match parent {
314 Some(p) => self.read_blob_from_parent(p, &from_key)?,
315 None => None,
316 };
317 match blob {
318 Some(b) => b,
319 None => {
320 return Err(MemWriterError::Path(format!(
321 "move source {} does not exist",
322 from.display()
323 )));
324 }
325 }
326 }
327 };
328
329 if matches!(pending.ops.get(&to_key), Some(PendingState::Upsert(_))) {
330 return Err(MemWriterError::Path(format!(
332 "move target {} already has a pending write",
333 to.display()
334 )));
335 }
336 pending.ops.insert(from_key, PendingState::Delete);
337 pending.ops.insert(to_key, PendingState::Upsert(bytes));
338 Ok(())
339 }
340
341 fn commit(&self, message: &str, ctx: &CommitContext<'_>) -> Result<CommitId, MemWriterError> {
342 let mutex = acquire_branch_mutex(&self.ref_name);
346 let _guard = mutex.lock().map_err(|_| {
347 MemWriterError::Path(format!(
348 "git-tree writer mutex poisoned for ref {} (gitdir {})",
349 self.ref_name,
350 self.gitdir.display()
351 ))
352 })?;
353 let repo = self.open_repo()?;
354
355 let mut pending = self.pending.lock().map_err(|_| {
356 MemWriterError::Path("git-tree writer pending state poisoned".to_string())
357 })?;
358
359 self.ensure_snapshot(&mut pending)?;
363 let parent_snapshot = pending.parent;
364
365 let mut editor = match parent_snapshot {
367 Some(parent_id) => {
368 let commit = repo
369 .find_object(parent_id)
370 .map_err(|e| {
371 MemWriterError::Path(format!(
372 "git-tree writer: open parent {parent_id}: {e}"
373 ))
374 })?
375 .into_commit();
376 let tree = commit.tree().map_err(|e| {
377 MemWriterError::Path(format!("git-tree writer: peel parent tree: {e}"))
378 })?;
379 tree.edit().map_err(|e| {
380 MemWriterError::Path(format!("git-tree writer: editor init: {e}"))
381 })?
382 }
383 None => repo.empty_tree().edit().map_err(|e| {
384 MemWriterError::Path(format!("git-tree writer: empty editor init: {e}"))
385 })?,
386 };
387
388 for (path, state) in pending.ops.iter() {
391 match state {
392 PendingState::Upsert(bytes) => {
393 let blob_id = repo
394 .write_blob(bytes.as_slice())
395 .map_err(|e| {
396 MemWriterError::Path(format!(
397 "git-tree writer: write blob for {path}: {e}"
398 ))
399 })?
400 .detach();
401 editor
402 .upsert(path.as_str(), EntryKind::Blob, blob_id)
403 .map_err(|e| {
404 MemWriterError::Path(format!(
405 "git-tree writer: tree upsert {path}: {e}"
406 ))
407 })?;
408 }
409 PendingState::Delete => {
410 editor.remove(path.as_str()).map_err(|e| {
411 MemWriterError::Path(format!("git-tree writer: tree remove {path}: {e}"))
412 })?;
413 }
414 }
415 }
416
417 let tree_id = editor
418 .write()
419 .map_err(|e| MemWriterError::Path(format!("git-tree writer: tree write: {e}")))?
420 .detach();
421
422 let time = gix::date::Time::now_local_or_utc();
425 let committer_sig = gix::actor::Signature {
426 name: COMMITTER_NAME.into(),
427 email: COMMITTER_EMAIL.into(),
428 time,
429 };
430 let author_sig = match author_identity(ctx) {
431 Some((name, email)) => gix::actor::Signature {
432 name: name.into(),
433 email: email.into(),
434 time,
435 },
436 None => committer_sig.clone(),
437 };
438 let mut author_buf = gix::date::parse::TimeBuf::default();
439 let mut committer_buf = gix::date::parse::TimeBuf::default();
440 let author_ref = author_sig.to_ref(&mut author_buf);
441 let committer_ref = committer_sig.to_ref(&mut committer_buf);
442
443 let full_message = format_commit_message(message, ctx);
444
445 let parents: Vec<gix::ObjectId> = match parent_snapshot {
446 Some(p) => vec![p],
447 None => Vec::new(),
448 };
449 let commit_result = repo.commit_as(
450 committer_ref,
451 author_ref,
452 self.ref_name.as_str(),
453 full_message,
454 tree_id,
455 parents,
456 );
457 pending.clear();
466 let commit_id = match commit_result {
467 Ok(id) => id,
468 Err(gix::commit::Error::ReferenceEdit(_)) => {
469 let mut reference = repo
473 .try_find_reference(&self.ref_name)
474 .map_err(|e| {
475 MemWriterError::Path(format!(
476 "git-tree writer: re-resolve ref after CAS: {e}"
477 ))
478 })?
479 .ok_or_else(|| {
480 MemWriterError::Path(format!(
481 "git-tree writer: ref {} vanished during CAS recovery",
482 self.ref_name
483 ))
484 })?;
485 let live_id = reference.peel_to_id().map_err(|e| {
486 MemWriterError::Path(format!("git-tree writer: peel live tip after CAS: {e}"))
487 })?;
488 return Err(MemWriterError::HashMismatch {
489 current: live_id.to_hex().to_string(),
490 });
491 }
492 Err(e) => {
493 return Err(MemWriterError::Path(format!(
494 "git-tree writer: commit_as failed: {e}"
495 )));
496 }
497 };
498
499 let sha_hex = commit_id.to_hex().to_string();
500
501 sync_index_and_worktree(&repo, &self.ref_name)?;
508
509 Ok(sha_hex)
510 }
511}
512
513impl memstead_base::backend::MemBackend for GitTreeMemWriter {
514 fn list_entities(&self) -> Result<Vec<PathBuf>, memstead_base::backend::BackendError> {
515 let blobs = match read_branch_blobs(&self.gitdir, &self.ref_name) {
520 Ok(b) => b,
521 Err(BranchReadError::BranchMissing { .. }) => return Ok(Vec::new()),
522 Err(e) => {
523 return Err(memstead_base::backend::BackendError::Other(format!(
524 "git-tree backend list_entities: {e}"
525 )));
526 }
527 };
528 Ok(blobs
529 .into_iter()
530 .filter_map(|b| {
531 if b.path.ends_with(".md") && !b.path.starts_with(".memstead/") {
532 Some(PathBuf::from(b.path))
533 } else {
534 None
535 }
536 })
537 .collect())
538 }
539
540 fn read_entity(
541 &self,
542 rel_path: &Path,
543 ) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
544 let key = normalise_rel_path(rel_path)?;
545 let pending = self.pending.lock().map_err(|_| {
548 memstead_base::backend::BackendError::Other(
549 "git-tree backend pending state poisoned".to_string(),
550 )
551 })?;
552 if let Some(state) = pending.ops.get(&key) {
553 return Ok(match state {
554 PendingState::Upsert(b) => Some(b.clone()),
555 PendingState::Delete => None,
556 });
557 }
558 let snapshot_parent = if pending.ops.is_empty() {
569 None
570 } else {
571 pending.parent
572 };
573 drop(pending);
574
575 let source = match snapshot_parent {
576 Some(p) => Some(p),
577 None => self
578 .live_tip()
579 .map_err(memstead_base::backend::BackendError::from)?,
580 };
581 match source {
582 Some(p) => self
583 .read_blob_from_parent(p, &key)
584 .map_err(memstead_base::backend::BackendError::from),
585 None => Ok(None),
586 }
587 }
588
589 fn entity_exists(&self, rel_path: &Path) -> Result<bool, memstead_base::backend::BackendError> {
594 let key = normalise_rel_path(rel_path)?;
595 let pending = self.pending.lock().map_err(|_| {
596 memstead_base::backend::BackendError::Other(
597 "git-tree backend pending state poisoned".to_string(),
598 )
599 })?;
600 if let Some(state) = pending.ops.get(&key) {
601 return Ok(matches!(state, PendingState::Upsert(_)));
602 }
603 let snapshot_parent = if pending.ops.is_empty() {
604 None
605 } else {
606 pending.parent
607 };
608 drop(pending);
609
610 let source = match snapshot_parent {
611 Some(p) => Some(p),
612 None => self
613 .live_tip()
614 .map_err(memstead_base::backend::BackendError::from)?,
615 };
616 match source {
617 Some(p) => self
618 .path_exists_from_parent(p, &key)
619 .map_err(memstead_base::backend::BackendError::from),
620 None => Ok(false),
621 }
622 }
623
624 fn write_entity(
625 &self,
626 rel_path: &Path,
627 content: &[u8],
628 ) -> Result<(), memstead_base::backend::BackendError> {
629 <Self as MemWriter>::write_entity(self, rel_path, content).map_err(Into::into)
630 }
631
632 fn delete_entity(&self, rel_path: &Path) -> Result<(), memstead_base::backend::BackendError> {
633 <Self as MemWriter>::delete_entity(self, rel_path).map_err(Into::into)
634 }
635
636 fn move_entity(
637 &self,
638 from: &Path,
639 to: &Path,
640 ) -> Result<(), memstead_base::backend::BackendError> {
641 <Self as MemWriter>::move_entity(self, from, to).map_err(Into::into)
642 }
643
644 fn discard_pending(&self) -> Result<(), memstead_base::backend::BackendError> {
645 let mut pending = self.pending.lock().map_err(|_| {
651 memstead_base::backend::BackendError::Other(
652 "git-tree writer pending state poisoned".to_string(),
653 )
654 })?;
655 pending.clear();
656 Ok(())
657 }
658
659 fn commit(
660 &self,
661 message: &str,
662 ctx: &CommitContext<'_>,
663 ) -> Result<CommitId, memstead_base::backend::BackendError> {
664 <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
665 }
666
667 fn commit_with_expected_parent(
668 &self,
669 message: &str,
670 ctx: &CommitContext<'_>,
671 expected_parent: Option<&str>,
672 ) -> Result<CommitId, memstead_base::backend::BackendError> {
673 let Some(expected) = expected_parent else {
675 return <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into);
676 };
677
678 let mutex = acquire_branch_mutex(&self.ref_name);
688 let guard = mutex.lock().map_err(|_| {
689 memstead_base::backend::BackendError::Other(format!(
690 "git-tree writer mutex poisoned for ref {} (gitdir {})",
691 self.ref_name,
692 self.gitdir.display()
693 ))
694 })?;
695
696 let actual = match gix::open(&self.gitdir) {
697 Ok(repo) => match repo.try_find_reference(&self.ref_name) {
698 Ok(Some(mut r)) => r
699 .peel_to_id()
700 .ok()
701 .map(|id| id.detach().to_hex().to_string()),
702 Ok(None) => None,
703 Err(e) => {
704 return Err(memstead_base::backend::BackendError::Other(format!(
705 "git-tree writer: resolve ref {} for parent check: {e}",
706 self.ref_name
707 )));
708 }
709 },
710 Err(e) => {
711 return Err(memstead_base::backend::BackendError::Other(format!(
712 "git-tree writer: open repo at {} for parent check: {e}",
713 self.gitdir.display()
714 )));
715 }
716 };
717 let actual_str = actual.unwrap_or_default();
718 if actual_str != expected {
719 return Err(memstead_base::backend::BackendError::ParentMismatch {
720 expected: expected.to_string(),
721 actual: actual_str,
722 });
723 }
724
725 drop(guard);
726 <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
727 }
728
729 fn append_provenance(
730 &self,
731 _record: &memstead_base::Provenance,
732 ) -> Result<(), memstead_base::backend::BackendError> {
733 Ok(())
743 }
744
745 fn read_provenance(
746 &self,
747 cursor: Option<&str>,
748 ) -> Result<Vec<memstead_base::Provenance>, memstead_base::backend::BackendError> {
749 let since = cursor.unwrap_or(crate::ops::changes::EMPTY_TREE_SHA);
750 let report = match crate::ops::agent_notes::agent_notes_since(
751 "",
752 &self.gitdir,
753 since,
754 Some(&self.ref_name),
755 ) {
756 Ok(r) => r,
757 Err(e) => {
758 return Err(memstead_base::backend::BackendError::Other(format!(
759 "git-tree backend read_provenance: {e}"
760 )));
761 }
762 };
763 let mut out: Vec<memstead_base::Provenance> = report
768 .notes
769 .into_iter()
770 .map(commit_note_to_provenance)
771 .collect();
772 out.reverse();
773 Ok(out)
774 }
775
776 fn current_head(&self) -> Result<Option<String>, memstead_base::backend::BackendError> {
777 let repo = match gix::open(&self.gitdir) {
784 Ok(r) => r,
785 Err(e) => {
786 tracing::debug!(
787 gitdir = %self.gitdir.display(),
788 error = %e,
789 "current_head: open gitdir failed; treating as no baseline"
790 );
791 return Ok(None);
792 }
793 };
794 let mut reference = match repo.try_find_reference(&self.ref_name) {
795 Ok(Some(r)) => r,
796 Ok(None) => return Ok(None),
797 Err(e) => {
798 tracing::debug!(
799 ref_name = %self.ref_name,
800 error = %e,
801 "current_head: ref lookup failed; treating as no baseline"
802 );
803 return Ok(None);
804 }
805 };
806 Ok(reference
807 .peel_to_id()
808 .ok()
809 .map(|id| id.detach().to_hex().to_string()))
810 }
811
812 fn read_mem_config(&self) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
813 let leaf = self
817 .ref_name
818 .strip_prefix("refs/heads/")
819 .unwrap_or(&self.ref_name);
820
821 read_blob_from_ref(
826 &self.gitdir,
827 "refs/heads/__MEMSTEAD",
828 &format!("mems/{leaf}/config.json"),
829 )
830 }
831
832 fn read_anchors_sidecar(
833 &self,
834 ) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
835 <Self as memstead_base::backend::MemBackend>::read_entity(
840 self,
841 Path::new(memstead_base::anchor::ANCHOR_SIDECAR_PATH),
842 )
843 }
844
845 fn write_anchors_sidecar(
846 &self,
847 bytes: &[u8],
848 ) -> Result<(), memstead_base::backend::BackendError> {
849 <Self as MemWriter>::write_entity(
854 self,
855 Path::new(memstead_base::anchor::ANCHOR_SIDECAR_PATH),
856 bytes,
857 )
858 .map_err(Into::into)
859 }
860
861 fn delete_artifacts(&self) -> Result<(), memstead_base::backend::BackendError> {
862 let branch_leaf = self
868 .ref_name
869 .strip_prefix("refs/heads/")
870 .unwrap_or(&self.ref_name);
871 let ctx = CommitContext {
872 actor: memstead_base::vcs::Actor::Agent,
873 client: None,
874 tool: Some("memstead_mem_delete"),
875 note: None,
876 role: Default::default(),
877 logical_operation_id: None,
878 entity_ids: None,
879 };
880 crate::storage_memstead::delete_mem_artifacts_at_gitdir(&self.gitdir, branch_leaf, &ctx)
881 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
882 }
883
884 fn write_mem_config(&self, bytes: &[u8]) -> Result<(), memstead_base::backend::BackendError> {
885 self.write_mem_config_with_note(bytes, None)
886 }
887
888 fn write_mem_config_with_note(
889 &self,
890 bytes: &[u8],
891 note: Option<&str>,
892 ) -> Result<(), memstead_base::backend::BackendError> {
893 let leaf = self
914 .ref_name
915 .strip_prefix("refs/heads/")
916 .unwrap_or(&self.ref_name);
917 let ctx = CommitContext {
918 actor: memstead_base::vcs::Actor::Agent,
919 client: None,
920 tool: Some("memstead_mem_config_write"),
921 note: note.map(str::to_string),
922 role: Default::default(),
923 logical_operation_id: None,
924 entity_ids: None,
925 };
926 crate::storage_memstead::commit_config_to_memstead_at_gitdir(
927 &self.gitdir,
928 leaf,
929 bytes,
930 &ctx,
931 &format!("memstead: commit __MEMSTEAD:mems/{leaf}/config.json"),
932 )
933 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
934 }
935
936 fn record_pipeline_edit(
937 &self,
938 kind: &str,
939 edits: &[(String, Option<Vec<u8>>)],
940 note: Option<&str>,
941 verb: &str,
942 ) -> Result<(), memstead_base::backend::BackendError> {
943 let leaf = self
948 .ref_name
949 .strip_prefix("refs/heads/")
950 .unwrap_or(&self.ref_name);
951 let tree_edits: Vec<(String, Option<Vec<u8>>)> = edits
952 .iter()
953 .map(|(name, bytes)| (format!("pipeline/{kind}/{leaf}/{name}.json"), bytes.clone()))
954 .collect();
955 let ctx = CommitContext {
956 actor: memstead_base::vcs::Actor::Agent,
957 client: None,
958 tool: Some("memstead_pipeline_edit"),
959 note: note.map(str::to_string),
960 role: Default::default(),
961 logical_operation_id: None,
962 entity_ids: None,
963 };
964 let names: Vec<&str> = edits.iter().map(|(n, _)| n.as_str()).collect();
965 crate::storage_memstead::commit_paths_to_memstead_at_gitdir(
966 &self.gitdir,
967 &tree_edits,
968 &ctx,
969 &format!("memstead: {verb} {kind} {leaf}/{}", names.join(", ")),
970 )
971 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
972 }
973}
974
975fn read_blob_from_ref(
983 gitdir: &Path,
984 ref_name: &str,
985 path: &str,
986) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
987 let repo = match gix::open(gitdir) {
988 Ok(r) => r,
989 Err(_) => return Ok(None),
990 };
991 let reference = match repo.try_find_reference(ref_name) {
992 Ok(Some(r)) => r,
993 Ok(None) => return Ok(None),
994 Err(e) => {
995 return Err(memstead_base::backend::BackendError::Other(format!(
996 "find ref {ref_name}: {e}"
997 )));
998 }
999 };
1000 let id = reference.into_fully_peeled_id().map_err(|e| {
1001 memstead_base::backend::BackendError::Other(format!("peel {ref_name}: {e}"))
1002 })?;
1003 let object = id.object().map_err(|e| {
1004 memstead_base::backend::BackendError::Other(format!("read obj {ref_name}: {e}"))
1005 })?;
1006 let commit = match object.try_into_commit() {
1007 Ok(c) => c,
1008 Err(_) => return Ok(None),
1009 };
1010 let tree = commit.tree().map_err(|e| {
1011 memstead_base::backend::BackendError::Other(format!("read tree {ref_name}: {e}"))
1012 })?;
1013 let entry = match tree.lookup_entry_by_path(path) {
1014 Ok(Some(e)) => e,
1015 Ok(None) => return Ok(None),
1016 Err(e) => {
1017 return Err(memstead_base::backend::BackendError::Other(format!(
1018 "lookup {ref_name}:{path}: {e}"
1019 )));
1020 }
1021 };
1022 let blob = entry.object().map_err(|e| {
1023 memstead_base::backend::BackendError::Other(format!("read blob {ref_name}:{path}: {e}"))
1024 })?;
1025 Ok(Some(blob.data.clone()))
1026}
1027
1028fn commit_note_to_provenance(n: crate::ops::agent_notes::CommitNote) -> memstead_base::Provenance {
1033 let kind = n
1034 .tool_verb
1035 .as_deref()
1036 .and_then(memstead_base::ProvenanceKind::parse)
1037 .unwrap_or(memstead_base::ProvenanceKind::Update);
1038 let actor = n
1039 .actor
1040 .as_deref()
1041 .and_then(memstead_base::vcs::Actor::from_trailer)
1042 .unwrap_or(memstead_base::vcs::Actor::Unknown);
1043 let client = n
1044 .client
1045 .as_deref()
1046 .and_then(memstead_base::vcs::parse_client_id);
1047 let timestamp = if n.timestamp >= 0 {
1048 std::time::UNIX_EPOCH + std::time::Duration::from_secs(n.timestamp as u64)
1049 } else {
1050 std::time::UNIX_EPOCH
1051 };
1052 let mut record =
1053 memstead_base::Provenance::new(timestamp, kind, n.entity_id, actor, client, n.note);
1054 if let Some(id) = n.logical_operation_id {
1055 record = record.with_logical_operation_id(id);
1056 }
1057 if let Some(role) = n
1058 .role
1059 .as_deref()
1060 .and_then(memstead_base::vcs::Role::from_wire)
1061 {
1062 record = record.with_role(role);
1063 }
1064 record
1065}
1066
1067fn sync_index_and_worktree(repo: &gix::Repository, ref_name: &str) -> Result<(), MemWriterError> {
1106 let Some(workdir) = repo.workdir() else {
1107 return Ok(());
1108 };
1109 let head_matches = matches!(repo.head_name(), Ok(Some(name)) if name.as_bstr() == ref_name);
1118 if !head_matches {
1119 return Ok(());
1120 }
1121
1122 let output = std::process::Command::new("git")
1123 .arg("-C")
1124 .arg(workdir)
1125 .args(["read-tree", "--reset", "-u", "HEAD"])
1126 .env("GIT_TERMINAL_PROMPT", "0")
1130 .stdin(std::process::Stdio::null())
1131 .output()
1132 .map_err(|e| {
1133 std::io::Error::other(format!(
1134 "worktree sync: spawn `git -C {} read-tree --reset -u HEAD`: {e}; \
1135 commit already landed in the object store, recover with \
1136 `git -C {} reset --hard HEAD`",
1137 workdir.display(),
1138 workdir.display()
1139 ))
1140 })?;
1141 if !output.status.success() {
1142 let stderr = String::from_utf8_lossy(&output.stderr);
1143 return Err(MemWriterError::Io(std::io::Error::other(format!(
1144 "worktree sync: `git -C {} read-tree --reset -u HEAD` failed (status {}): {}; \
1145 commit already landed in the object store, recover with \
1146 `git -C {} reset --hard HEAD` (or remove a stale \
1147 `<workdir>/.git/index.lock` if one exists)",
1148 workdir.display(),
1149 output.status,
1150 stderr.trim(),
1151 workdir.display()
1152 ))));
1153 }
1154 Ok(())
1155}
1156
1157const COMMITTER_NAME: &str = "engine";
1164const COMMITTER_EMAIL: &str = "noreply@memstead.io";
1165
1166#[derive(Debug, Clone)]
1171pub struct BranchBlob {
1172 pub path: String,
1173 pub bytes: Vec<u8>,
1174}
1175
1176#[derive(Debug, thiserror::Error)]
1181pub enum BranchReadError {
1182 #[error("git-tree reader: open repo at {path}: {source}")]
1183 Open {
1184 path: String,
1185 #[source]
1186 source: gix::open::Error,
1187 },
1188 #[error("git-tree reader: branch {ref_name} not found")]
1189 BranchMissing { ref_name: String },
1190 #[error("git-tree reader: resolve branch {ref_name}: {message}")]
1191 Resolve { ref_name: String, message: String },
1192 #[error("git-tree reader: read object: {message}")]
1193 Read { message: String },
1194}
1195
1196pub fn read_branch_blobs(
1210 gitdir: &Path,
1211 ref_name: &str,
1212) -> Result<Vec<BranchBlob>, BranchReadError> {
1213 let repo = gix::open(gitdir).map_err(|e| BranchReadError::Open {
1214 path: gitdir.display().to_string(),
1215 source: e,
1216 })?;
1217 let mut reference =
1218 match repo
1219 .try_find_reference(ref_name)
1220 .map_err(|e| BranchReadError::Resolve {
1221 ref_name: ref_name.to_string(),
1222 message: e.to_string(),
1223 })? {
1224 Some(r) => r,
1225 None => {
1226 return Err(BranchReadError::BranchMissing {
1227 ref_name: ref_name.to_string(),
1228 });
1229 }
1230 };
1231 let id = reference
1232 .peel_to_id()
1233 .map_err(|e| BranchReadError::Resolve {
1234 ref_name: ref_name.to_string(),
1235 message: e.to_string(),
1236 })?;
1237 let commit = repo
1238 .find_object(id)
1239 .map_err(|e| BranchReadError::Read {
1240 message: format!("open commit {id}: {e}"),
1241 })?
1242 .into_commit();
1243 let tree = commit.tree().map_err(|e| BranchReadError::Read {
1244 message: format!("peel commit to tree: {e}"),
1245 })?;
1246
1247 let mut out: Vec<BranchBlob> = Vec::new();
1248 walk_tree(&repo, &tree, "", &mut out)?;
1249 out.sort_by(|a, b| a.path.cmp(&b.path));
1250 Ok(out)
1251}
1252
1253fn walk_tree(
1254 repo: &gix::Repository,
1255 tree: &gix::Tree<'_>,
1256 prefix: &str,
1257 out: &mut Vec<BranchBlob>,
1258) -> Result<(), BranchReadError> {
1259 use gix::objs::tree::EntryKind;
1260 let iter = tree.iter();
1261 for entry_res in iter {
1262 let entry = entry_res.map_err(|e| BranchReadError::Read {
1263 message: format!("decode tree entry: {e}"),
1264 })?;
1265 let name = entry.filename().to_string();
1266 let full = if prefix.is_empty() {
1267 name.clone()
1268 } else {
1269 format!("{prefix}/{name}")
1270 };
1271 match entry.mode().kind() {
1272 EntryKind::Blob | EntryKind::BlobExecutable => {
1273 let object = repo
1274 .find_object(entry.oid())
1275 .map_err(|e| BranchReadError::Read {
1276 message: format!("read blob {full}: {e}"),
1277 })?;
1278 out.push(BranchBlob {
1279 path: full,
1280 bytes: object.data.clone(),
1281 });
1282 }
1283 EntryKind::Tree => {
1284 let subtree = repo
1285 .find_object(entry.oid())
1286 .map_err(|e| BranchReadError::Read {
1287 message: format!("read subtree {full}: {e}"),
1288 })?
1289 .into_tree();
1290 walk_tree(repo, &subtree, &full, out)?;
1291 }
1292 EntryKind::Link | EntryKind::Commit => {}
1295 }
1296 }
1297 Ok(())
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302 use super::*;
1303 use crate::vcs::{Actor, ClientId, CommitContext};
1304 use std::path::Path;
1305 use tempfile::TempDir;
1306
1307 fn fresh_repo_dir(tmp: &Path) -> PathBuf {
1312 let git_dir = tmp.join("mem-repo.git");
1313 gix::init_bare(&git_dir).unwrap();
1314 std::fs::canonicalize(&git_dir).unwrap()
1315 }
1316
1317 fn ctx_for_test<'a>() -> CommitContext<'a> {
1318 CommitContext {
1319 actor: Actor::Cli,
1320 client: Some(ClientId {
1321 name: "claude-code".to_string(),
1322 version: "0.1.0".to_string(),
1323 }),
1324 tool: Some("test"),
1325 note: None,
1326 role: Default::default(),
1327 logical_operation_id: None,
1328 entity_ids: None,
1329 }
1330 }
1331
1332 fn read_blob(gitdir: &Path, ref_name: &str, path: &str) -> Option<Vec<u8>> {
1333 let repo = gix::open(gitdir).unwrap();
1334 let mut reference = repo.try_find_reference(ref_name).unwrap()?;
1335 let id = reference.peel_to_id().unwrap();
1336 let commit = repo.find_object(id).unwrap().into_commit();
1337 let tree = commit.tree().unwrap();
1338 let entry = tree.lookup_entry_by_path(path).unwrap()?;
1339 let object = repo.find_object(entry.id()).unwrap();
1340 Some(object.data.clone())
1341 }
1342
1343 fn tree_path_exists(gitdir: &Path, ref_name: &str, path: &str) -> bool {
1344 read_blob(gitdir, ref_name, path).is_some()
1345 }
1346
1347 #[test]
1352 fn entity_exists_tree_probe_and_pending_precedence() {
1353 use memstead_base::backend::MemBackend;
1354 let tmp = TempDir::new().unwrap();
1355 let gitdir = fresh_repo_dir(tmp.path());
1356 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1357
1358 assert!(
1359 !MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
1360 "unborn ref answers false"
1361 );
1362
1363 MemBackend::write_entity(
1364 &writer,
1365 Path::new("notes/a.md"),
1366 b"# a
1367",
1368 )
1369 .unwrap();
1370 assert!(
1371 MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
1372 "staged upsert answers true before commit"
1373 );
1374
1375 MemWriter::commit(&writer, "land a", &ctx_for_test()).unwrap();
1376 assert!(MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
1377 assert!(!MemBackend::entity_exists(&writer, Path::new("notes/b.md")).unwrap());
1378
1379 MemBackend::delete_entity(&writer, Path::new("notes/a.md")).unwrap();
1380 assert!(
1381 !MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
1382 "staged delete answers false while the branch tip still holds the blob"
1383 );
1384 }
1385
1386 #[test]
1387 fn git_tree_writer_round_trip() {
1388 let tmp = TempDir::new().unwrap();
1389 let gitdir = fresh_repo_dir(tmp.path());
1390 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1391
1392 writer
1393 .write_entity(Path::new("notes/hello.md"), b"# hi\n")
1394 .unwrap();
1395 let sha = writer.commit("first commit", &ctx_for_test()).unwrap();
1396 assert_eq!(sha.len(), 40);
1397
1398 let bytes = read_blob(&gitdir, "refs/heads/test", "notes/hello.md").unwrap();
1399 assert_eq!(bytes, b"# hi\n");
1400 }
1401
1402 #[test]
1403 fn git_tree_writer_anchors_sidecar_rides_commit_and_survives_reload() {
1404 use memstead_base::backend::MemBackend;
1405 let tmp = TempDir::new().unwrap();
1406 let gitdir = fresh_repo_dir(tmp.path());
1407 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1408
1409 MemBackend::write_entity(&writer, Path::new("hello.md"), b"# hi\n").unwrap();
1412 writer
1413 .write_anchors_sidecar(b"{\"version\":1,\"entities\":{}}")
1414 .unwrap();
1415 MemBackend::commit(&writer, "entity+anchors", &ctx_for_test()).unwrap();
1416
1417 let sidecar = read_blob(&gitdir, "refs/heads/test", ".memstead/anchors.json").unwrap();
1419 assert_eq!(sidecar, b"{\"version\":1,\"entities\":{}}");
1420
1421 let reloaded = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1423 assert_eq!(
1424 reloaded.read_anchors_sidecar().unwrap(),
1425 Some(b"{\"version\":1,\"entities\":{}}".to_vec())
1426 );
1427 assert_eq!(
1429 MemBackend::list_entities(&reloaded).unwrap(),
1430 vec![PathBuf::from("hello.md")]
1431 );
1432 }
1433
1434 #[test]
1435 fn git_tree_writer_delete_removes_path() {
1436 let tmp = TempDir::new().unwrap();
1437 let gitdir = fresh_repo_dir(tmp.path());
1438 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1439
1440 writer.write_entity(Path::new("a.md"), b"a").unwrap();
1441 writer.write_entity(Path::new("b.md"), b"b").unwrap();
1442 writer.commit("seed", &ctx_for_test()).unwrap();
1443
1444 writer.delete_entity(Path::new("a.md")).unwrap();
1445 writer.commit("drop a", &ctx_for_test()).unwrap();
1446
1447 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "a.md"));
1448 assert!(tree_path_exists(&gitdir, "refs/heads/test", "b.md"));
1449 }
1450
1451 #[test]
1452 fn git_tree_writer_move_renames_path() {
1453 let tmp = TempDir::new().unwrap();
1454 let gitdir = fresh_repo_dir(tmp.path());
1455 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1456
1457 writer
1458 .write_entity(Path::new("from.md"), b"payload")
1459 .unwrap();
1460 writer.commit("seed", &ctx_for_test()).unwrap();
1461
1462 writer
1463 .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
1464 .unwrap();
1465 writer.commit("rename", &ctx_for_test()).unwrap();
1466
1467 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "from.md"));
1468 let moved = read_blob(&gitdir, "refs/heads/test", "nested/to.md").unwrap();
1469 assert_eq!(moved, b"payload");
1470 }
1471
1472 #[test]
1473 fn git_tree_writer_multi_op_commit() {
1474 let tmp = TempDir::new().unwrap();
1475 let gitdir = fresh_repo_dir(tmp.path());
1476 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1477
1478 writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
1481 writer.commit("seed", &ctx_for_test()).unwrap();
1482
1483 writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
1484 writer
1485 .write_entity(Path::new("nested/b.md"), b"beta")
1486 .unwrap();
1487 writer.delete_entity(Path::new("doomed.md")).unwrap();
1488 writer.commit("multi-op", &ctx_for_test()).unwrap();
1489
1490 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "doomed.md"));
1491 assert_eq!(
1492 read_blob(&gitdir, "refs/heads/test", "a.md").unwrap(),
1493 b"alpha"
1494 );
1495 assert_eq!(
1496 read_blob(&gitdir, "refs/heads/test", "nested/b.md").unwrap(),
1497 b"beta"
1498 );
1499 }
1500
1501 #[test]
1502 fn git_tree_writer_cas_conflict_surfaces_hash_mismatch() {
1503 let tmp = TempDir::new().unwrap();
1504 let gitdir = fresh_repo_dir(tmp.path());
1505
1506 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1508 seeder.write_entity(Path::new("seed.md"), b"x").unwrap();
1509 let seed_sha = seeder.commit("seed", &ctx_for_test()).unwrap();
1510
1511 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1512 let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1513
1514 a.write_entity(Path::new("a.md"), b"a").unwrap();
1516 b.write_entity(Path::new("b.md"), b"b").unwrap();
1517 assert_eq!(
1518 a.pending
1519 .lock()
1520 .unwrap()
1521 .parent
1522 .unwrap()
1523 .to_hex()
1524 .to_string(),
1525 seed_sha
1526 );
1527 assert_eq!(
1528 b.pending
1529 .lock()
1530 .unwrap()
1531 .parent
1532 .unwrap()
1533 .to_hex()
1534 .to_string(),
1535 seed_sha
1536 );
1537
1538 let new_tip = a.commit("a wins", &ctx_for_test()).unwrap();
1541 let err = b
1542 .commit("b loses", &ctx_for_test())
1543 .expect_err("B's commit must fail with HashMismatch");
1544 match err {
1545 MemWriterError::HashMismatch { current } => {
1546 assert_eq!(current, new_tip);
1547 }
1548 other => panic!("expected HashMismatch, got {other:?}"),
1549 }
1550 }
1551
1552 #[test]
1553 fn cas_conflict_clears_pending_so_reads_fall_back_to_committed_truth() {
1554 let tmp = TempDir::new().unwrap();
1561 let gitdir = fresh_repo_dir(tmp.path());
1562
1563 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1566 seeder.write_entity(Path::new("shared.md"), b"v1").unwrap();
1567 seeder.commit("seed", &ctx_for_test()).unwrap();
1568
1569 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1570 let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1571
1572 a.write_entity(Path::new("shared.md"), b"A-committed")
1575 .unwrap();
1576 b.write_entity(Path::new("shared.md"), b"B-phantom")
1577 .unwrap();
1578
1579 a.commit("a wins", &ctx_for_test()).unwrap();
1581 let err = b
1582 .commit("b loses", &ctx_for_test())
1583 .expect_err("B must lose the CAS race");
1584 assert!(
1585 matches!(err, MemWriterError::HashMismatch { .. }),
1586 "expected HashMismatch, got {err:?}"
1587 );
1588
1589 assert!(
1591 b.pending.lock().unwrap().ops.is_empty(),
1592 "pending must be cleared after a failed commit"
1593 );
1594 let read = <GitTreeMemWriter as memstead_base::backend::MemBackend>::read_entity(
1597 &b,
1598 Path::new("shared.md"),
1599 )
1600 .unwrap();
1601 assert_eq!(
1602 read.as_deref(),
1603 Some(&b"A-committed"[..]),
1604 "read must serve committed truth, not the phantom staged write"
1605 );
1606 }
1607
1608 #[test]
1609 fn commit_with_expected_parent_succeeds_when_ref_matches_pin() {
1610 use memstead_base::backend::MemBackend;
1611
1612 let tmp = TempDir::new().unwrap();
1613 let gitdir = fresh_repo_dir(tmp.path());
1614
1615 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1616 <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1617 let seed_sha =
1618 <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1619
1620 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1622 let expected = <GitTreeMemWriter as MemBackend>::current_head(&writer)
1623 .unwrap()
1624 .expect("seeded ref has a head");
1625 assert_eq!(expected, seed_sha);
1626
1627 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("after.md"), b"after")
1628 .unwrap();
1629
1630 let new_tip = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1631 &writer,
1632 "pinned commit",
1633 &ctx_for_test(),
1634 Some(&expected),
1635 )
1636 .expect("parent matches pin → commit must succeed");
1637 assert_ne!(new_tip, seed_sha);
1638 assert_eq!(
1639 read_blob(&gitdir, "refs/heads/test", "after.md").unwrap(),
1640 b"after"
1641 );
1642 }
1643
1644 #[test]
1645 fn commit_with_expected_parent_surfaces_parent_mismatch_when_sibling_advances_ref() {
1646 use memstead_base::backend::{BackendError, MemBackend};
1647
1648 let tmp = TempDir::new().unwrap();
1649 let gitdir = fresh_repo_dir(tmp.path());
1650
1651 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1653 <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1654 let seed_sha =
1655 <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1656
1657 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1660 let pin = <GitTreeMemWriter as MemBackend>::current_head(&a)
1661 .unwrap()
1662 .expect("seeded ref has a head");
1663 assert_eq!(pin, seed_sha);
1664
1665 let sibling = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1669 <GitTreeMemWriter as MemWriter>::write_entity(&sibling, Path::new("drift.md"), b"drift")
1670 .unwrap();
1671 let new_tip =
1672 <GitTreeMemWriter as MemWriter>::commit(&sibling, "sibling advance", &ctx_for_test())
1673 .unwrap();
1674 assert_ne!(new_tip, seed_sha);
1675
1676 <GitTreeMemWriter as MemWriter>::write_entity(&a, Path::new("a.md"), b"a").unwrap();
1679 let err = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1680 &a,
1681 "pinned commit",
1682 &ctx_for_test(),
1683 Some(&pin),
1684 )
1685 .expect_err("pin no longer matches live tip → commit must refuse");
1686 match err {
1687 BackendError::ParentMismatch { expected, actual } => {
1688 assert_eq!(expected, pin);
1689 assert_eq!(actual, new_tip);
1690 }
1691 other => panic!("expected ParentMismatch, got {other:?}"),
1692 }
1693 }
1694
1695 #[test]
1696 fn commit_with_expected_parent_none_pin_is_equivalent_to_commit() {
1697 use memstead_base::backend::MemBackend;
1698
1699 let tmp = TempDir::new().unwrap();
1700 let gitdir = fresh_repo_dir(tmp.path());
1701 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1702
1703 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("hello.md"), b"hi")
1704 .unwrap();
1705 let sha = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1706 &writer,
1707 "unpinned",
1708 &ctx_for_test(),
1709 None,
1710 )
1711 .expect("None pin → plain commit semantics, must succeed against empty ref");
1712 assert_eq!(sha.len(), 40);
1713 assert_eq!(
1714 read_blob(&gitdir, "refs/heads/test", "hello.md").unwrap(),
1715 b"hi"
1716 );
1717 }
1718
1719 #[test]
1720 fn git_tree_writer_blob_oid_is_content_addressed() {
1721 let tmp_a = TempDir::new().unwrap();
1725 let tmp_b = TempDir::new().unwrap();
1726 let payload = b"shared content\n";
1727
1728 let gitdir_a = fresh_repo_dir(tmp_a.path());
1729 let writer_a = GitTreeMemWriter::new(gitdir_a.clone(), "refs/heads/a".to_string());
1730 writer_a
1731 .write_entity(Path::new("file.md"), payload)
1732 .unwrap();
1733 let sha_a = writer_a.commit("a", &ctx_for_test()).unwrap();
1734 let repo_a = gix::open(&gitdir_a).unwrap();
1735 let commit_a = repo_a
1736 .find_object(gix::ObjectId::from_hex(sha_a.as_bytes()).unwrap())
1737 .unwrap()
1738 .into_commit();
1739 let blob_id_a = commit_a
1740 .tree()
1741 .unwrap()
1742 .lookup_entry_by_path("file.md")
1743 .unwrap()
1744 .unwrap()
1745 .id()
1746 .detach();
1747
1748 let gitdir_b = fresh_repo_dir(tmp_b.path());
1749 let writer_b = GitTreeMemWriter::new(gitdir_b.clone(), "refs/heads/b".to_string());
1750 writer_b
1751 .write_entity(Path::new("file.md"), payload)
1752 .unwrap();
1753 let sha_b = writer_b.commit("b", &ctx_for_test()).unwrap();
1754 let repo_b = gix::open(&gitdir_b).unwrap();
1755 let commit_b = repo_b
1756 .find_object(gix::ObjectId::from_hex(sha_b.as_bytes()).unwrap())
1757 .unwrap()
1758 .into_commit();
1759 let blob_id_b = commit_b
1760 .tree()
1761 .unwrap()
1762 .lookup_entry_by_path("file.md")
1763 .unwrap()
1764 .unwrap()
1765 .id()
1766 .detach();
1767
1768 assert_eq!(
1769 blob_id_a, blob_id_b,
1770 "same content must produce byte-identical blob OIDs"
1771 );
1772 }
1773
1774 fn fresh_non_bare_repo(tmp: &Path) -> (PathBuf, PathBuf) {
1779 let workdir = tmp.join("mem-repo-workdir");
1780 std::fs::create_dir_all(&workdir).unwrap();
1781 let status = std::process::Command::new("git")
1782 .arg("-C")
1783 .arg(&workdir)
1784 .args(["init", "--initial-branch=main", "--quiet"])
1785 .status()
1786 .expect("git init must succeed");
1787 assert!(status.success(), "git init failed");
1788 let workdir = std::fs::canonicalize(&workdir).unwrap();
1789 let gitdir = workdir.join(".git");
1790 (workdir, gitdir)
1791 }
1792
1793 #[test]
1794 fn sync_helper_skips_on_bare_repo() {
1795 let tmp = TempDir::new().unwrap();
1796 let gitdir = fresh_repo_dir(tmp.path());
1797 let repo = gix::open(&gitdir).unwrap();
1798
1799 sync_index_and_worktree(&repo, "refs/heads/main").unwrap();
1802 }
1803
1804 #[test]
1805 fn sync_helper_updates_worktree_when_ref_matches_head() {
1806 let tmp = TempDir::new().unwrap();
1807 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1808 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1809
1810 writer
1811 .write_entity(Path::new("configs/alpha.json"), b"{\"name\":\"alpha\"}\n")
1812 .unwrap();
1813 writer.commit("seed alpha", &ctx_for_test()).unwrap();
1814
1815 let on_disk = workdir.join("configs/alpha.json");
1818 assert!(
1819 on_disk.exists(),
1820 "worktree sync must materialise the new blob at {}",
1821 on_disk.display()
1822 );
1823 let bytes = std::fs::read(&on_disk).unwrap();
1824 assert_eq!(bytes, b"{\"name\":\"alpha\"}\n");
1825
1826 let output = std::process::Command::new("git")
1828 .arg("-C")
1829 .arg(&workdir)
1830 .args(["status", "--porcelain"])
1831 .output()
1832 .unwrap();
1833 assert!(
1834 output.stdout.is_empty(),
1835 "git status --porcelain must be empty post-sync, got: {:?}",
1836 String::from_utf8_lossy(&output.stdout)
1837 );
1838 }
1839
1840 #[test]
1841 fn sync_helper_skips_when_ref_does_not_match_head() {
1842 let tmp = TempDir::new().unwrap();
1843 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1844
1845 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/feature".to_string());
1849 writer
1850 .write_entity(Path::new("only-on-feature.md"), b"feature-only\n")
1851 .unwrap();
1852 writer
1853 .commit("first commit on feature", &ctx_for_test())
1854 .unwrap();
1855
1856 assert!(tree_path_exists(
1858 &gitdir,
1859 "refs/heads/feature",
1860 "only-on-feature.md"
1861 ));
1862 assert!(
1864 !workdir.join("only-on-feature.md").exists(),
1865 "worktree must not be polluted by writes to a non-checked-out branch"
1866 );
1867 }
1868
1869 #[test]
1870 fn sync_helper_preserves_untracked_files() {
1871 let tmp = TempDir::new().unwrap();
1872 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1873 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1874
1875 let untracked = workdir.join("scratch.txt");
1879 std::fs::write(&untracked, b"operator notes\n").unwrap();
1880
1881 writer
1882 .write_entity(Path::new("seed.md"), b"seed\n")
1883 .unwrap();
1884 writer.commit("create seed", &ctx_for_test()).unwrap();
1885
1886 assert!(
1887 untracked.exists(),
1888 "sync must leave untracked files in place"
1889 );
1890 assert_eq!(std::fs::read(&untracked).unwrap(), b"operator notes\n");
1891 assert_eq!(std::fs::read(workdir.join("seed.md")).unwrap(), b"seed\n");
1893 }
1894
1895 #[test]
1896 fn sync_helper_updates_through_delete_and_overwrite() {
1897 let tmp = TempDir::new().unwrap();
1898 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1899 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1900
1901 writer.write_entity(Path::new("a.md"), b"first\n").unwrap();
1902 writer.commit("create a", &ctx_for_test()).unwrap();
1903 assert_eq!(std::fs::read(workdir.join("a.md")).unwrap(), b"first\n");
1904
1905 writer.write_entity(Path::new("a.md"), b"second\n").unwrap();
1906 writer.commit("overwrite a", &ctx_for_test()).unwrap();
1907 assert_eq!(
1908 std::fs::read(workdir.join("a.md")).unwrap(),
1909 b"second\n",
1910 "overwrite must propagate to the worktree"
1911 );
1912
1913 writer.delete_entity(Path::new("a.md")).unwrap();
1914 writer.commit("delete a", &ctx_for_test()).unwrap();
1915 assert!(
1916 !workdir.join("a.md").exists(),
1917 "delete must remove the file from the worktree"
1918 );
1919
1920 let output = std::process::Command::new("git")
1922 .arg("-C")
1923 .arg(&workdir)
1924 .args(["status", "--porcelain"])
1925 .output()
1926 .unwrap();
1927 assert!(
1928 output.stdout.is_empty(),
1929 "git status --porcelain must be empty after every commit, got: {:?}",
1930 String::from_utf8_lossy(&output.stdout)
1931 );
1932 }
1933
1934 fn commit_with_verb(
1940 writer: &GitTreeMemWriter,
1941 verb: &str,
1942 entity_id: &str,
1943 ctx: &CommitContext<'_>,
1944 ) {
1945 let subject = format!("memstead: {verb} {entity_id}");
1946 <GitTreeMemWriter as MemWriter>::commit(writer, &subject, ctx).unwrap();
1947 }
1948
1949 fn ctx_with_note<'a>(note: &'a str) -> CommitContext<'a> {
1950 CommitContext {
1951 actor: Actor::Agent,
1952 client: Some(ClientId {
1953 name: "claude-code".to_string(),
1954 version: "2.1.0".to_string(),
1955 }),
1956 tool: Some("memstead_create"),
1957 note: Some(note.to_string()),
1958 role: Default::default(),
1959 logical_operation_id: None,
1960 entity_ids: None,
1961 }
1962 }
1963
1964 #[test]
1965 fn backend_list_entities_returns_only_md_outside_memstead_namespace() {
1966 use memstead_base::backend::MemBackend;
1967
1968 let tmp = TempDir::new().unwrap();
1969 let gitdir = fresh_repo_dir(tmp.path());
1970 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1971
1972 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"# a").unwrap();
1975 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"# b")
1976 .unwrap();
1977 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
1978 .unwrap();
1979 <GitTreeMemWriter as MemWriter>::write_entity(
1980 &writer,
1981 Path::new(".memstead/config.json"),
1982 b"{}",
1983 )
1984 .unwrap();
1985 <GitTreeMemWriter as MemWriter>::write_entity(
1986 &writer,
1987 Path::new(".memstead/notes.md"),
1988 b"# skip me",
1989 )
1990 .unwrap();
1991 <GitTreeMemWriter as MemWriter>::write_entity(
1992 &writer,
1993 Path::new(".other/notes.md"),
1994 b"# no longer special, walked like any non-meta dir",
1995 )
1996 .unwrap();
1997 <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1998
1999 let backend: &dyn MemBackend = &writer;
2000 let mut paths: Vec<String> = backend
2001 .list_entities()
2002 .unwrap()
2003 .into_iter()
2004 .map(|p| p.to_string_lossy().into_owned())
2005 .collect();
2006 paths.sort();
2007 assert_eq!(
2009 paths,
2010 vec![
2011 ".other/notes.md".to_string(),
2012 "a.md".to_string(),
2013 "nested/b.md".to_string(),
2014 ]
2015 );
2016 }
2017
2018 #[test]
2019 fn backend_list_entities_returns_empty_for_missing_branch() {
2020 use memstead_base::backend::MemBackend;
2021
2022 let tmp = TempDir::new().unwrap();
2023 let gitdir = fresh_repo_dir(tmp.path());
2024 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
2025 let backend: &dyn MemBackend = &writer;
2026 assert!(backend.list_entities().unwrap().is_empty());
2028 }
2029
2030 #[test]
2031 fn backend_read_entity_consults_pending_then_branch_tip() {
2032 use memstead_base::backend::MemBackend;
2033
2034 let tmp = TempDir::new().unwrap();
2035 let gitdir = fresh_repo_dir(tmp.path());
2036 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2037
2038 <GitTreeMemWriter as MemWriter>::write_entity(
2040 &writer,
2041 Path::new("on_branch.md"),
2042 b"branch",
2043 )
2044 .unwrap();
2045 <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
2046
2047 let backend: &dyn MemBackend = &writer;
2048 assert_eq!(
2050 backend.read_entity(Path::new("on_branch.md")).unwrap(),
2051 Some(b"branch".to_vec())
2052 );
2053 backend
2055 .write_entity(Path::new("on_branch.md"), b"buffered")
2056 .unwrap();
2057 assert_eq!(
2058 backend.read_entity(Path::new("on_branch.md")).unwrap(),
2059 Some(b"buffered".to_vec())
2060 );
2061 backend.delete_entity(Path::new("on_branch.md")).unwrap();
2063 assert_eq!(
2064 backend.read_entity(Path::new("on_branch.md")).unwrap(),
2065 None
2066 );
2067 assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
2069 }
2070
2071 #[test]
2072 fn backend_read_provenance_reconstructs_from_commit_log() {
2073 use memstead_base::backend::MemBackend;
2074
2075 let tmp = TempDir::new().unwrap();
2076 let gitdir = fresh_repo_dir(tmp.path());
2077 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2078
2079 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2083 commit_with_verb(&writer, "create", "v:a", &ctx_with_note("first draft"));
2084
2085 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
2086 commit_with_verb(
2087 &writer,
2088 "update",
2089 "v:a",
2090 &CommitContext {
2091 actor: Actor::Cli,
2092 client: None,
2093 tool: Some("memstead_update"),
2094 note: None,
2095 role: Default::default(),
2096 logical_operation_id: None,
2097 entity_ids: None,
2098 },
2099 );
2100
2101 let backend: &dyn MemBackend = &writer;
2102 backend
2105 .append_provenance(&memstead_base::Provenance::new(
2106 std::time::UNIX_EPOCH,
2107 memstead_base::ProvenanceKind::Create,
2108 Some("ignored".into()),
2109 Actor::Unknown,
2110 None,
2111 None,
2112 ))
2113 .unwrap();
2114
2115 let records = backend.read_provenance(None).unwrap();
2116 assert_eq!(records.len(), 2, "expected two commits, got {records:?}");
2117 assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Create);
2119 assert_eq!(records[0].entity.as_deref(), Some("v:a"));
2120 assert_eq!(records[0].actor, Actor::Agent);
2121 assert_eq!(records[0].note.as_deref(), Some("first draft"));
2122 assert_eq!(
2123 records[0]
2124 .client
2125 .as_ref()
2126 .map(|c| (c.name.as_str(), c.version.as_str())),
2127 Some(("claude-code", "2.1.0"))
2128 );
2129 assert_eq!(records[1].kind, memstead_base::ProvenanceKind::Update);
2130 assert_eq!(records[1].actor, Actor::Cli);
2131 assert!(records[1].note.is_none());
2132 assert!(records[1].client.is_none());
2133 }
2134
2135 #[test]
2136 fn backend_read_provenance_filters_by_cursor_sha() {
2137 use memstead_base::backend::MemBackend;
2138
2139 let tmp = TempDir::new().unwrap();
2140 let gitdir = fresh_repo_dir(tmp.path());
2141 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2142
2143 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2145 let first_sha = <GitTreeMemWriter as MemWriter>::commit(
2146 &writer,
2147 "memstead: create v:a",
2148 &ctx_for_test(),
2149 )
2150 .unwrap();
2151 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
2152 <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
2153 .unwrap();
2154 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a3").unwrap();
2155 <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
2156 .unwrap();
2157
2158 let backend: &dyn MemBackend = &writer;
2159 let after = backend.read_provenance(Some(&first_sha)).unwrap();
2161 assert_eq!(
2162 after.len(),
2163 2,
2164 "expected commits after cursor, got {after:?}"
2165 );
2166 for r in &after {
2167 assert_eq!(r.kind, memstead_base::ProvenanceKind::Update);
2168 }
2169 }
2170
2171 #[test]
2172 fn backend_read_provenance_empty_for_missing_branch() {
2173 use memstead_base::backend::MemBackend;
2174
2175 let tmp = TempDir::new().unwrap();
2176 let gitdir = fresh_repo_dir(tmp.path());
2177 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
2178 let backend: &dyn MemBackend = &writer;
2179 assert!(backend.read_provenance(None).unwrap().is_empty());
2181 }
2182
2183 #[test]
2184 fn backend_unknown_verb_falls_back_to_update_kind() {
2185 use memstead_base::backend::MemBackend;
2186
2187 let tmp = TempDir::new().unwrap();
2188 let gitdir = fresh_repo_dir(tmp.path());
2189 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
2190
2191 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
2192 commit_with_verb(&writer, "mem_create", "v:a", &ctx_for_test());
2196
2197 let backend: &dyn MemBackend = &writer;
2198 let records = backend.read_provenance(None).unwrap();
2199 assert_eq!(records.len(), 1);
2200 assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Update);
2201 }
2202
2203 #[test]
2204 fn instantiate_full_backend_constructs_git_branch_writer() {
2205 use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2210
2211 let tmp = TempDir::new().unwrap();
2212 let gitdir = fresh_repo_dir(tmp.path());
2213 let mount = Mount {
2214 mem: "engine".to_string(),
2215 schema: Some("default@1.0.0".parse().unwrap()),
2216 storage: MountStorage::GitBranch {
2217 gitdir,
2218 branch: "engine".to_string(),
2219 },
2220 capability: MountCapability::Write,
2221 lifecycle: MountLifecycle::Eager,
2222 cross_linkable: true,
2223 migration_target: None,
2224 };
2225 let backend: Box<dyn MemBackend> =
2226 crate::storage::instantiate_full_backend(&mount).unwrap();
2227 assert!(backend.list_entities().unwrap().is_empty());
2229 assert!(backend.read_provenance(None).unwrap().is_empty());
2231 }
2232
2233 #[test]
2234 fn instantiate_full_backend_accepts_branch_with_or_without_refs_prefix() {
2235 use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2240
2241 let tmp = TempDir::new().unwrap();
2242 let gitdir = fresh_repo_dir(tmp.path());
2243 for branch in ["engine", "refs/heads/engine"] {
2244 let mount = Mount {
2245 mem: "engine".to_string(),
2246 schema: Some("default@1.0.0".parse().unwrap()),
2247 storage: MountStorage::GitBranch {
2248 gitdir: gitdir.clone(),
2249 branch: branch.to_string(),
2250 },
2251 capability: MountCapability::Write,
2252 lifecycle: MountLifecycle::Eager,
2253 cross_linkable: true,
2254 migration_target: None,
2255 };
2256 let backend: Box<dyn MemBackend> =
2257 crate::storage::instantiate_full_backend(&mount).unwrap();
2258 assert!(backend.list_entities().unwrap().is_empty());
2260 }
2261 }
2262
2263 #[test]
2266 fn current_head_returns_none_for_empty_branch() {
2267 let tmp = TempDir::new().unwrap();
2272 let gitdir = fresh_repo_dir(tmp.path());
2273 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/specs".to_string());
2274 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2275 .unwrap();
2276 assert!(head.is_none());
2277 }
2278
2279 #[test]
2280 fn current_head_returns_hex_sha_after_commit() {
2281 let tmp = TempDir::new().unwrap();
2287 let gitdir = fresh_repo_dir(tmp.path());
2288 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2289
2290 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2291 let sha = writer.commit("first", &ctx_for_test()).unwrap();
2292 assert_eq!(sha.len(), 40);
2293
2294 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2295 .unwrap()
2296 .expect("head present after commit");
2297 assert_eq!(head, sha);
2298 }
2299
2300 #[test]
2301 fn current_head_advances_on_subsequent_commits() {
2302 let tmp = TempDir::new().unwrap();
2307 let gitdir = fresh_repo_dir(tmp.path());
2308 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2309
2310 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2311 let first = writer.commit("first", &ctx_for_test()).unwrap();
2312 let head_after_first =
2313 <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2314 .unwrap()
2315 .unwrap();
2316 assert_eq!(head_after_first, first);
2317
2318 writer.write_entity(Path::new("b.md"), b"b").unwrap();
2319 let second = writer.commit("second", &ctx_for_test()).unwrap();
2320 assert_ne!(first, second);
2321 let head_after_second =
2322 <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2323 .unwrap()
2324 .unwrap();
2325 assert_eq!(head_after_second, second);
2326 }
2327
2328 #[test]
2329 fn current_head_returns_none_for_missing_gitdir() {
2330 let tmp = TempDir::new().unwrap();
2336 let writer = GitTreeMemWriter::new(
2337 tmp.path().join("does-not-exist.git"),
2338 "refs/heads/specs".to_string(),
2339 );
2340 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2341 .unwrap();
2342 assert!(head.is_none());
2343 }
2344
2345 fn dispatch_changes(
2353 gitdir: &Path,
2354 branch: &str,
2355 mem: &str,
2356 since: &str,
2357 ) -> Result<memstead_base::ops::BackendChanges, memstead_base::backend::BackendError> {
2358 (crate::storage::FULL_GIT_BRANCH_OPS.changes_since)(
2359 gitdir,
2360 branch,
2361 mem,
2362 since,
2363 memstead_base::ops::RENAME_SIMILARITY_DEFAULT,
2364 )
2365 }
2366
2367 #[test]
2368 fn changes_since_empty_repo_with_sentinel_returns_empty_changes() {
2369 let tmp = TempDir::new().unwrap();
2373 let gitdir = fresh_repo_dir(tmp.path());
2374 let result = dispatch_changes(
2375 &gitdir,
2376 "specs",
2377 "specs",
2378 memstead_base::ops::EMPTY_TREE_SHA,
2379 )
2380 .unwrap();
2381 assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2382 assert_eq!(result.head, memstead_base::ops::EMPTY_TREE_SHA);
2383 assert!(result.changes.is_empty());
2384 }
2385
2386 #[test]
2387 fn changes_since_after_commit_returns_added_envelopes_id_only() {
2388 use memstead_base::ops::ChangeEnvelope;
2392 let tmp = TempDir::new().unwrap();
2393 let gitdir = fresh_repo_dir(tmp.path());
2394 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2395
2396 writer
2397 .write_entity(Path::new("alpha.md"), b"# Alpha")
2398 .unwrap();
2399 writer
2400 .write_entity(Path::new("beta.md"), b"# Beta")
2401 .unwrap();
2402 let head_sha = writer.commit("seed", &ctx_for_test()).unwrap();
2403
2404 let result = dispatch_changes(
2405 &gitdir,
2406 "specs",
2407 "specs",
2408 memstead_base::ops::EMPTY_TREE_SHA,
2409 )
2410 .unwrap();
2411 assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2412 assert_eq!(result.head, head_sha);
2413 assert_eq!(result.changes.len(), 2);
2414 for env in &result.changes {
2415 match env {
2416 ChangeEnvelope::Added {
2417 id,
2418 title,
2419 entity_type,
2420 } => {
2421 assert!(
2422 id.0.starts_with("specs--"),
2423 "expected mem-prefixed id, got {}",
2424 id.0
2425 );
2426 assert!(title.is_none(), "dispatch must not enrich title");
2427 assert!(
2428 entity_type.is_none(),
2429 "dispatch must not enrich entity_type"
2430 );
2431 }
2432 other => panic!("expected Added envelope, got {other:?}"),
2433 }
2434 }
2435 }
2436
2437 #[test]
2438 fn changes_since_between_two_commits_yields_updated_envelope() {
2439 use memstead_base::ops::ChangeEnvelope;
2440 let tmp = TempDir::new().unwrap();
2441 let gitdir = fresh_repo_dir(tmp.path());
2442 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2443
2444 writer
2445 .write_entity(Path::new("alpha.md"), b"# Alpha v1")
2446 .unwrap();
2447 let sha_v1 = writer.commit("v1", &ctx_for_test()).unwrap();
2448
2449 writer
2450 .write_entity(Path::new("alpha.md"), b"# Alpha v2")
2451 .unwrap();
2452 let sha_v2 = writer.commit("v2", &ctx_for_test()).unwrap();
2453 assert_ne!(sha_v1, sha_v2);
2454
2455 let result = dispatch_changes(&gitdir, "specs", "specs", &sha_v1).unwrap();
2456 assert_eq!(result.since, sha_v1);
2457 assert_eq!(result.head, sha_v2);
2458 assert_eq!(result.changes.len(), 1);
2459 match &result.changes[0] {
2460 ChangeEnvelope::Updated {
2461 id,
2462 title,
2463 entity_type,
2464 } => {
2465 assert!(id.0.starts_with("specs--"));
2466 assert!(title.is_none());
2467 assert!(entity_type.is_none());
2468 }
2469 other => panic!("expected Updated envelope, got {other:?}"),
2470 }
2471 }
2472
2473 #[test]
2474 fn anchor_only_commit_yields_zero_entity_deltas_and_valid_cursor() {
2475 use memstead_base::backend::MemBackend;
2481 let tmp = TempDir::new().unwrap();
2482 let gitdir = fresh_repo_dir(tmp.path());
2483 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2484
2485 MemBackend::write_entity(&writer, Path::new("alpha.md"), b"# Alpha").unwrap();
2486 let seed_sha = MemBackend::commit(&writer, "seed", &ctx_for_test()).unwrap();
2487
2488 writer
2490 .write_anchors_sidecar(
2491 br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#,
2492 )
2493 .unwrap();
2494 let anchor_sha = MemBackend::commit(&writer, "anchors", &ctx_for_test()).unwrap();
2495 assert_ne!(seed_sha, anchor_sha);
2496
2497 let from_seed = dispatch_changes(&gitdir, "specs", "specs", &seed_sha).unwrap();
2499 assert_eq!(from_seed.head, anchor_sha);
2500 assert_eq!(
2501 from_seed.changes.len(),
2502 0,
2503 "an anchor-only commit must produce zero entity deltas, got {:?}",
2504 from_seed.changes
2505 );
2506
2507 let from_anchor = dispatch_changes(&gitdir, "specs", "specs", &anchor_sha).unwrap();
2510 assert_eq!(from_anchor.head, anchor_sha);
2511 assert_eq!(from_anchor.changes.len(), 0);
2512 }
2513
2514 #[test]
2515 fn changes_since_unknown_cursor_returns_typed_commit_not_found_marker() {
2516 let tmp = TempDir::new().unwrap();
2524 let gitdir = fresh_repo_dir(tmp.path());
2525 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2526 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2528 writer.commit("seed", &ctx_for_test()).unwrap();
2529
2530 let bad_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2531 let err = dispatch_changes(&gitdir, "specs", "specs", bad_sha).unwrap_err();
2532 match err {
2533 memstead_base::backend::BackendError::Other(msg) => {
2534 assert_eq!(
2535 msg,
2536 format!("COMMIT_NOT_FOUND:{bad_sha}"),
2537 "bad-since must carry the typed marker with the untruncated sha: {msg}",
2538 );
2539 }
2540 other => panic!("expected BackendError::Other, got {other:?}"),
2541 }
2542 }
2543}