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 delete_artifacts(&self) -> Result<(), memstead_base::backend::BackendError> {
772 let branch_leaf = self
778 .ref_name
779 .strip_prefix("refs/heads/")
780 .unwrap_or(&self.ref_name);
781 let ctx = CommitContext {
782 actor: memstead_base::vcs::Actor::Agent,
783 client: None,
784 tool: Some("memstead_mem_delete"),
785 note: None,
786 logical_operation_id: None,
787 entity_ids: None,
788 };
789 crate::storage_memstead::delete_mem_artifacts_at_gitdir(&self.gitdir, branch_leaf, &ctx)
790 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
791 }
792
793 fn write_mem_config(&self, bytes: &[u8]) -> Result<(), memstead_base::backend::BackendError> {
794 self.write_mem_config_with_note(bytes, None)
795 }
796
797 fn write_mem_config_with_note(
798 &self,
799 bytes: &[u8],
800 note: Option<&str>,
801 ) -> Result<(), memstead_base::backend::BackendError> {
802 let leaf = self
823 .ref_name
824 .strip_prefix("refs/heads/")
825 .unwrap_or(&self.ref_name);
826 let ctx = CommitContext {
827 actor: memstead_base::vcs::Actor::Agent,
828 client: None,
829 tool: Some("memstead_mem_config_write"),
830 note: note.map(str::to_string),
831 logical_operation_id: None,
832 entity_ids: None,
833 };
834 crate::storage_memstead::commit_config_to_memstead_at_gitdir(
835 &self.gitdir,
836 leaf,
837 bytes,
838 &ctx,
839 &format!("memstead: commit __MEMSTEAD:mems/{leaf}/config.json"),
840 )
841 .map_err(|e| memstead_base::backend::BackendError::Other(e.to_string()))
842 }
843}
844
845fn read_blob_from_ref(
853 gitdir: &Path,
854 ref_name: &str,
855 path: &str,
856) -> Result<Option<Vec<u8>>, memstead_base::backend::BackendError> {
857 let repo = match gix::open(gitdir) {
858 Ok(r) => r,
859 Err(_) => return Ok(None),
860 };
861 let reference = match repo.try_find_reference(ref_name) {
862 Ok(Some(r)) => r,
863 Ok(None) => return Ok(None),
864 Err(e) => {
865 return Err(memstead_base::backend::BackendError::Other(format!(
866 "find ref {ref_name}: {e}"
867 )));
868 }
869 };
870 let id = reference.into_fully_peeled_id().map_err(|e| {
871 memstead_base::backend::BackendError::Other(format!("peel {ref_name}: {e}"))
872 })?;
873 let object = id.object().map_err(|e| {
874 memstead_base::backend::BackendError::Other(format!("read obj {ref_name}: {e}"))
875 })?;
876 let commit = match object.try_into_commit() {
877 Ok(c) => c,
878 Err(_) => return Ok(None),
879 };
880 let tree = commit.tree().map_err(|e| {
881 memstead_base::backend::BackendError::Other(format!("read tree {ref_name}: {e}"))
882 })?;
883 let entry = match tree.lookup_entry_by_path(path) {
884 Ok(Some(e)) => e,
885 Ok(None) => return Ok(None),
886 Err(e) => {
887 return Err(memstead_base::backend::BackendError::Other(format!(
888 "lookup {ref_name}:{path}: {e}"
889 )));
890 }
891 };
892 let blob = entry.object().map_err(|e| {
893 memstead_base::backend::BackendError::Other(format!("read blob {ref_name}:{path}: {e}"))
894 })?;
895 Ok(Some(blob.data.clone()))
896}
897
898fn commit_note_to_provenance(n: crate::ops::agent_notes::CommitNote) -> memstead_base::Provenance {
903 let kind = n
904 .tool_verb
905 .as_deref()
906 .and_then(memstead_base::ProvenanceKind::parse)
907 .unwrap_or(memstead_base::ProvenanceKind::Update);
908 let actor = n
909 .actor
910 .as_deref()
911 .and_then(memstead_base::vcs::Actor::from_trailer)
912 .unwrap_or(memstead_base::vcs::Actor::Unknown);
913 let client = n
914 .client
915 .as_deref()
916 .and_then(memstead_base::vcs::parse_client_id);
917 let timestamp = if n.timestamp >= 0 {
918 std::time::UNIX_EPOCH + std::time::Duration::from_secs(n.timestamp as u64)
919 } else {
920 std::time::UNIX_EPOCH
921 };
922 let mut record =
923 memstead_base::Provenance::new(timestamp, kind, n.entity_id, actor, client, n.note);
924 if let Some(id) = n.logical_operation_id {
925 record = record.with_logical_operation_id(id);
926 }
927 record
928}
929
930fn sync_index_and_worktree(repo: &gix::Repository, ref_name: &str) -> Result<(), MemWriterError> {
969 let Some(workdir) = repo.workdir() else {
970 return Ok(());
971 };
972 let head_matches = matches!(repo.head_name(), Ok(Some(name)) if name.as_bstr() == ref_name);
981 if !head_matches {
982 return Ok(());
983 }
984
985 let output = std::process::Command::new("git")
986 .arg("-C")
987 .arg(workdir)
988 .args(["read-tree", "--reset", "-u", "HEAD"])
989 .env("GIT_TERMINAL_PROMPT", "0")
993 .stdin(std::process::Stdio::null())
994 .output()
995 .map_err(|e| {
996 std::io::Error::other(format!(
997 "worktree sync: spawn `git -C {} read-tree --reset -u HEAD`: {e}; \
998 commit already landed in the object store, recover with \
999 `git -C {} reset --hard HEAD`",
1000 workdir.display(),
1001 workdir.display()
1002 ))
1003 })?;
1004 if !output.status.success() {
1005 let stderr = String::from_utf8_lossy(&output.stderr);
1006 return Err(MemWriterError::Io(std::io::Error::other(format!(
1007 "worktree sync: `git -C {} read-tree --reset -u HEAD` failed (status {}): {}; \
1008 commit already landed in the object store, recover with \
1009 `git -C {} reset --hard HEAD` (or remove a stale \
1010 `<workdir>/.git/index.lock` if one exists)",
1011 workdir.display(),
1012 output.status,
1013 stderr.trim(),
1014 workdir.display()
1015 ))));
1016 }
1017 Ok(())
1018}
1019
1020const COMMITTER_NAME: &str = "engine";
1027const COMMITTER_EMAIL: &str = "noreply@memstead.io";
1028
1029#[derive(Debug, Clone)]
1034pub struct BranchBlob {
1035 pub path: String,
1036 pub bytes: Vec<u8>,
1037}
1038
1039#[derive(Debug, thiserror::Error)]
1044pub enum BranchReadError {
1045 #[error("git-tree reader: open repo at {path}: {source}")]
1046 Open {
1047 path: String,
1048 #[source]
1049 source: gix::open::Error,
1050 },
1051 #[error("git-tree reader: branch {ref_name} not found")]
1052 BranchMissing { ref_name: String },
1053 #[error("git-tree reader: resolve branch {ref_name}: {message}")]
1054 Resolve { ref_name: String, message: String },
1055 #[error("git-tree reader: read object: {message}")]
1056 Read { message: String },
1057}
1058
1059pub fn read_branch_blobs(
1073 gitdir: &Path,
1074 ref_name: &str,
1075) -> Result<Vec<BranchBlob>, BranchReadError> {
1076 let repo = gix::open(gitdir).map_err(|e| BranchReadError::Open {
1077 path: gitdir.display().to_string(),
1078 source: e,
1079 })?;
1080 let mut reference =
1081 match repo
1082 .try_find_reference(ref_name)
1083 .map_err(|e| BranchReadError::Resolve {
1084 ref_name: ref_name.to_string(),
1085 message: e.to_string(),
1086 })? {
1087 Some(r) => r,
1088 None => {
1089 return Err(BranchReadError::BranchMissing {
1090 ref_name: ref_name.to_string(),
1091 });
1092 }
1093 };
1094 let id = reference
1095 .peel_to_id()
1096 .map_err(|e| BranchReadError::Resolve {
1097 ref_name: ref_name.to_string(),
1098 message: e.to_string(),
1099 })?;
1100 let commit = repo
1101 .find_object(id)
1102 .map_err(|e| BranchReadError::Read {
1103 message: format!("open commit {id}: {e}"),
1104 })?
1105 .into_commit();
1106 let tree = commit.tree().map_err(|e| BranchReadError::Read {
1107 message: format!("peel commit to tree: {e}"),
1108 })?;
1109
1110 let mut out: Vec<BranchBlob> = Vec::new();
1111 walk_tree(&repo, &tree, "", &mut out)?;
1112 out.sort_by(|a, b| a.path.cmp(&b.path));
1113 Ok(out)
1114}
1115
1116fn walk_tree(
1117 repo: &gix::Repository,
1118 tree: &gix::Tree<'_>,
1119 prefix: &str,
1120 out: &mut Vec<BranchBlob>,
1121) -> Result<(), BranchReadError> {
1122 use gix::objs::tree::EntryKind;
1123 let iter = tree.iter();
1124 for entry_res in iter {
1125 let entry = entry_res.map_err(|e| BranchReadError::Read {
1126 message: format!("decode tree entry: {e}"),
1127 })?;
1128 let name = entry.filename().to_string();
1129 let full = if prefix.is_empty() {
1130 name.clone()
1131 } else {
1132 format!("{prefix}/{name}")
1133 };
1134 match entry.mode().kind() {
1135 EntryKind::Blob | EntryKind::BlobExecutable => {
1136 let object = repo
1137 .find_object(entry.oid())
1138 .map_err(|e| BranchReadError::Read {
1139 message: format!("read blob {full}: {e}"),
1140 })?;
1141 out.push(BranchBlob {
1142 path: full,
1143 bytes: object.data.clone(),
1144 });
1145 }
1146 EntryKind::Tree => {
1147 let subtree = repo
1148 .find_object(entry.oid())
1149 .map_err(|e| BranchReadError::Read {
1150 message: format!("read subtree {full}: {e}"),
1151 })?
1152 .into_tree();
1153 walk_tree(repo, &subtree, &full, out)?;
1154 }
1155 EntryKind::Link | EntryKind::Commit => {}
1158 }
1159 }
1160 Ok(())
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165 use super::*;
1166 use crate::vcs::{Actor, ClientId, CommitContext};
1167 use std::path::Path;
1168 use tempfile::TempDir;
1169
1170 fn fresh_repo_dir(tmp: &Path) -> PathBuf {
1175 let git_dir = tmp.join("mem-repo.git");
1176 gix::init_bare(&git_dir).unwrap();
1177 std::fs::canonicalize(&git_dir).unwrap()
1178 }
1179
1180 fn ctx_for_test<'a>() -> CommitContext<'a> {
1181 CommitContext {
1182 actor: Actor::Cli,
1183 client: Some(ClientId {
1184 name: "claude-code".to_string(),
1185 version: "0.1.0".to_string(),
1186 }),
1187 tool: Some("test"),
1188 note: None,
1189 logical_operation_id: None,
1190 entity_ids: None,
1191 }
1192 }
1193
1194 fn read_blob(gitdir: &Path, ref_name: &str, path: &str) -> Option<Vec<u8>> {
1195 let repo = gix::open(gitdir).unwrap();
1196 let mut reference = repo.try_find_reference(ref_name).unwrap()?;
1197 let id = reference.peel_to_id().unwrap();
1198 let commit = repo.find_object(id).unwrap().into_commit();
1199 let tree = commit.tree().unwrap();
1200 let entry = tree.lookup_entry_by_path(path).unwrap()?;
1201 let object = repo.find_object(entry.id()).unwrap();
1202 Some(object.data.clone())
1203 }
1204
1205 fn tree_path_exists(gitdir: &Path, ref_name: &str, path: &str) -> bool {
1206 read_blob(gitdir, ref_name, path).is_some()
1207 }
1208
1209 #[test]
1210 fn git_tree_writer_round_trip() {
1211 let tmp = TempDir::new().unwrap();
1212 let gitdir = fresh_repo_dir(tmp.path());
1213 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1214
1215 writer
1216 .write_entity(Path::new("notes/hello.md"), b"# hi\n")
1217 .unwrap();
1218 let sha = writer.commit("first commit", &ctx_for_test()).unwrap();
1219 assert_eq!(sha.len(), 40);
1220
1221 let bytes = read_blob(&gitdir, "refs/heads/test", "notes/hello.md").unwrap();
1222 assert_eq!(bytes, b"# hi\n");
1223 }
1224
1225 #[test]
1226 fn git_tree_writer_delete_removes_path() {
1227 let tmp = TempDir::new().unwrap();
1228 let gitdir = fresh_repo_dir(tmp.path());
1229 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1230
1231 writer.write_entity(Path::new("a.md"), b"a").unwrap();
1232 writer.write_entity(Path::new("b.md"), b"b").unwrap();
1233 writer.commit("seed", &ctx_for_test()).unwrap();
1234
1235 writer.delete_entity(Path::new("a.md")).unwrap();
1236 writer.commit("drop a", &ctx_for_test()).unwrap();
1237
1238 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "a.md"));
1239 assert!(tree_path_exists(&gitdir, "refs/heads/test", "b.md"));
1240 }
1241
1242 #[test]
1243 fn git_tree_writer_move_renames_path() {
1244 let tmp = TempDir::new().unwrap();
1245 let gitdir = fresh_repo_dir(tmp.path());
1246 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1247
1248 writer
1249 .write_entity(Path::new("from.md"), b"payload")
1250 .unwrap();
1251 writer.commit("seed", &ctx_for_test()).unwrap();
1252
1253 writer
1254 .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
1255 .unwrap();
1256 writer.commit("rename", &ctx_for_test()).unwrap();
1257
1258 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "from.md"));
1259 let moved = read_blob(&gitdir, "refs/heads/test", "nested/to.md").unwrap();
1260 assert_eq!(moved, b"payload");
1261 }
1262
1263 #[test]
1264 fn git_tree_writer_multi_op_commit() {
1265 let tmp = TempDir::new().unwrap();
1266 let gitdir = fresh_repo_dir(tmp.path());
1267 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1268
1269 writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
1272 writer.commit("seed", &ctx_for_test()).unwrap();
1273
1274 writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
1275 writer
1276 .write_entity(Path::new("nested/b.md"), b"beta")
1277 .unwrap();
1278 writer.delete_entity(Path::new("doomed.md")).unwrap();
1279 writer.commit("multi-op", &ctx_for_test()).unwrap();
1280
1281 assert!(!tree_path_exists(&gitdir, "refs/heads/test", "doomed.md"));
1282 assert_eq!(
1283 read_blob(&gitdir, "refs/heads/test", "a.md").unwrap(),
1284 b"alpha"
1285 );
1286 assert_eq!(
1287 read_blob(&gitdir, "refs/heads/test", "nested/b.md").unwrap(),
1288 b"beta"
1289 );
1290 }
1291
1292 #[test]
1293 fn git_tree_writer_cas_conflict_surfaces_hash_mismatch() {
1294 let tmp = TempDir::new().unwrap();
1295 let gitdir = fresh_repo_dir(tmp.path());
1296
1297 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1299 seeder.write_entity(Path::new("seed.md"), b"x").unwrap();
1300 let seed_sha = seeder.commit("seed", &ctx_for_test()).unwrap();
1301
1302 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1303 let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1304
1305 a.write_entity(Path::new("a.md"), b"a").unwrap();
1307 b.write_entity(Path::new("b.md"), b"b").unwrap();
1308 assert_eq!(
1309 a.pending
1310 .lock()
1311 .unwrap()
1312 .parent
1313 .unwrap()
1314 .to_hex()
1315 .to_string(),
1316 seed_sha
1317 );
1318 assert_eq!(
1319 b.pending
1320 .lock()
1321 .unwrap()
1322 .parent
1323 .unwrap()
1324 .to_hex()
1325 .to_string(),
1326 seed_sha
1327 );
1328
1329 let new_tip = a.commit("a wins", &ctx_for_test()).unwrap();
1332 let err = b
1333 .commit("b loses", &ctx_for_test())
1334 .expect_err("B's commit must fail with HashMismatch");
1335 match err {
1336 MemWriterError::HashMismatch { current } => {
1337 assert_eq!(current, new_tip);
1338 }
1339 other => panic!("expected HashMismatch, got {other:?}"),
1340 }
1341 }
1342
1343 #[test]
1344 fn cas_conflict_clears_pending_so_reads_fall_back_to_committed_truth() {
1345 let tmp = TempDir::new().unwrap();
1352 let gitdir = fresh_repo_dir(tmp.path());
1353
1354 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1357 seeder.write_entity(Path::new("shared.md"), b"v1").unwrap();
1358 seeder.commit("seed", &ctx_for_test()).unwrap();
1359
1360 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1361 let b = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1362
1363 a.write_entity(Path::new("shared.md"), b"A-committed")
1366 .unwrap();
1367 b.write_entity(Path::new("shared.md"), b"B-phantom")
1368 .unwrap();
1369
1370 a.commit("a wins", &ctx_for_test()).unwrap();
1372 let err = b
1373 .commit("b loses", &ctx_for_test())
1374 .expect_err("B must lose the CAS race");
1375 assert!(
1376 matches!(err, MemWriterError::HashMismatch { .. }),
1377 "expected HashMismatch, got {err:?}"
1378 );
1379
1380 assert!(
1382 b.pending.lock().unwrap().ops.is_empty(),
1383 "pending must be cleared after a failed commit"
1384 );
1385 let read = <GitTreeMemWriter as memstead_base::backend::MemBackend>::read_entity(
1388 &b,
1389 Path::new("shared.md"),
1390 )
1391 .unwrap();
1392 assert_eq!(
1393 read.as_deref(),
1394 Some(&b"A-committed"[..]),
1395 "read must serve committed truth, not the phantom staged write"
1396 );
1397 }
1398
1399 #[test]
1400 fn commit_with_expected_parent_succeeds_when_ref_matches_pin() {
1401 use memstead_base::backend::MemBackend;
1402
1403 let tmp = TempDir::new().unwrap();
1404 let gitdir = fresh_repo_dir(tmp.path());
1405
1406 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1407 <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1408 let seed_sha =
1409 <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1410
1411 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1413 let expected = <GitTreeMemWriter as MemBackend>::current_head(&writer)
1414 .unwrap()
1415 .expect("seeded ref has a head");
1416 assert_eq!(expected, seed_sha);
1417
1418 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("after.md"), b"after")
1419 .unwrap();
1420
1421 let new_tip = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1422 &writer,
1423 "pinned commit",
1424 &ctx_for_test(),
1425 Some(&expected),
1426 )
1427 .expect("parent matches pin → commit must succeed");
1428 assert_ne!(new_tip, seed_sha);
1429 assert_eq!(
1430 read_blob(&gitdir, "refs/heads/test", "after.md").unwrap(),
1431 b"after"
1432 );
1433 }
1434
1435 #[test]
1436 fn commit_with_expected_parent_surfaces_parent_mismatch_when_sibling_advances_ref() {
1437 use memstead_base::backend::{BackendError, MemBackend};
1438
1439 let tmp = TempDir::new().unwrap();
1440 let gitdir = fresh_repo_dir(tmp.path());
1441
1442 let seeder = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1444 <GitTreeMemWriter as MemWriter>::write_entity(&seeder, Path::new("seed.md"), b"x").unwrap();
1445 let seed_sha =
1446 <GitTreeMemWriter as MemWriter>::commit(&seeder, "seed", &ctx_for_test()).unwrap();
1447
1448 let a = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1451 let pin = <GitTreeMemWriter as MemBackend>::current_head(&a)
1452 .unwrap()
1453 .expect("seeded ref has a head");
1454 assert_eq!(pin, seed_sha);
1455
1456 let sibling = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1460 <GitTreeMemWriter as MemWriter>::write_entity(&sibling, Path::new("drift.md"), b"drift")
1461 .unwrap();
1462 let new_tip =
1463 <GitTreeMemWriter as MemWriter>::commit(&sibling, "sibling advance", &ctx_for_test())
1464 .unwrap();
1465 assert_ne!(new_tip, seed_sha);
1466
1467 <GitTreeMemWriter as MemWriter>::write_entity(&a, Path::new("a.md"), b"a").unwrap();
1470 let err = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1471 &a,
1472 "pinned commit",
1473 &ctx_for_test(),
1474 Some(&pin),
1475 )
1476 .expect_err("pin no longer matches live tip → commit must refuse");
1477 match err {
1478 BackendError::ParentMismatch { expected, actual } => {
1479 assert_eq!(expected, pin);
1480 assert_eq!(actual, new_tip);
1481 }
1482 other => panic!("expected ParentMismatch, got {other:?}"),
1483 }
1484 }
1485
1486 #[test]
1487 fn commit_with_expected_parent_none_pin_is_equivalent_to_commit() {
1488 use memstead_base::backend::MemBackend;
1489
1490 let tmp = TempDir::new().unwrap();
1491 let gitdir = fresh_repo_dir(tmp.path());
1492 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1493
1494 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("hello.md"), b"hi")
1495 .unwrap();
1496 let sha = <GitTreeMemWriter as MemBackend>::commit_with_expected_parent(
1497 &writer,
1498 "unpinned",
1499 &ctx_for_test(),
1500 None,
1501 )
1502 .expect("None pin → plain commit semantics, must succeed against empty ref");
1503 assert_eq!(sha.len(), 40);
1504 assert_eq!(
1505 read_blob(&gitdir, "refs/heads/test", "hello.md").unwrap(),
1506 b"hi"
1507 );
1508 }
1509
1510 #[test]
1511 fn git_tree_writer_blob_oid_is_content_addressed() {
1512 let tmp_a = TempDir::new().unwrap();
1516 let tmp_b = TempDir::new().unwrap();
1517 let payload = b"shared content\n";
1518
1519 let gitdir_a = fresh_repo_dir(tmp_a.path());
1520 let writer_a = GitTreeMemWriter::new(gitdir_a.clone(), "refs/heads/a".to_string());
1521 writer_a
1522 .write_entity(Path::new("file.md"), payload)
1523 .unwrap();
1524 let sha_a = writer_a.commit("a", &ctx_for_test()).unwrap();
1525 let repo_a = gix::open(&gitdir_a).unwrap();
1526 let commit_a = repo_a
1527 .find_object(gix::ObjectId::from_hex(sha_a.as_bytes()).unwrap())
1528 .unwrap()
1529 .into_commit();
1530 let blob_id_a = commit_a
1531 .tree()
1532 .unwrap()
1533 .lookup_entry_by_path("file.md")
1534 .unwrap()
1535 .unwrap()
1536 .id()
1537 .detach();
1538
1539 let gitdir_b = fresh_repo_dir(tmp_b.path());
1540 let writer_b = GitTreeMemWriter::new(gitdir_b.clone(), "refs/heads/b".to_string());
1541 writer_b
1542 .write_entity(Path::new("file.md"), payload)
1543 .unwrap();
1544 let sha_b = writer_b.commit("b", &ctx_for_test()).unwrap();
1545 let repo_b = gix::open(&gitdir_b).unwrap();
1546 let commit_b = repo_b
1547 .find_object(gix::ObjectId::from_hex(sha_b.as_bytes()).unwrap())
1548 .unwrap()
1549 .into_commit();
1550 let blob_id_b = commit_b
1551 .tree()
1552 .unwrap()
1553 .lookup_entry_by_path("file.md")
1554 .unwrap()
1555 .unwrap()
1556 .id()
1557 .detach();
1558
1559 assert_eq!(
1560 blob_id_a, blob_id_b,
1561 "same content must produce byte-identical blob OIDs"
1562 );
1563 }
1564
1565 fn fresh_non_bare_repo(tmp: &Path) -> (PathBuf, PathBuf) {
1570 let workdir = tmp.join("mem-repo-workdir");
1571 std::fs::create_dir_all(&workdir).unwrap();
1572 let status = std::process::Command::new("git")
1573 .arg("-C")
1574 .arg(&workdir)
1575 .args(["init", "--initial-branch=main", "--quiet"])
1576 .status()
1577 .expect("git init must succeed");
1578 assert!(status.success(), "git init failed");
1579 let workdir = std::fs::canonicalize(&workdir).unwrap();
1580 let gitdir = workdir.join(".git");
1581 (workdir, gitdir)
1582 }
1583
1584 #[test]
1585 fn sync_helper_skips_on_bare_repo() {
1586 let tmp = TempDir::new().unwrap();
1587 let gitdir = fresh_repo_dir(tmp.path());
1588 let repo = gix::open(&gitdir).unwrap();
1589
1590 sync_index_and_worktree(&repo, "refs/heads/main").unwrap();
1593 }
1594
1595 #[test]
1596 fn sync_helper_updates_worktree_when_ref_matches_head() {
1597 let tmp = TempDir::new().unwrap();
1598 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1599 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1600
1601 writer
1602 .write_entity(Path::new("configs/alpha.json"), b"{\"name\":\"alpha\"}\n")
1603 .unwrap();
1604 writer.commit("seed alpha", &ctx_for_test()).unwrap();
1605
1606 let on_disk = workdir.join("configs/alpha.json");
1609 assert!(
1610 on_disk.exists(),
1611 "worktree sync must materialise the new blob at {}",
1612 on_disk.display()
1613 );
1614 let bytes = std::fs::read(&on_disk).unwrap();
1615 assert_eq!(bytes, b"{\"name\":\"alpha\"}\n");
1616
1617 let output = std::process::Command::new("git")
1619 .arg("-C")
1620 .arg(&workdir)
1621 .args(["status", "--porcelain"])
1622 .output()
1623 .unwrap();
1624 assert!(
1625 output.stdout.is_empty(),
1626 "git status --porcelain must be empty post-sync, got: {:?}",
1627 String::from_utf8_lossy(&output.stdout)
1628 );
1629 }
1630
1631 #[test]
1632 fn sync_helper_skips_when_ref_does_not_match_head() {
1633 let tmp = TempDir::new().unwrap();
1634 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1635
1636 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/feature".to_string());
1640 writer
1641 .write_entity(Path::new("only-on-feature.md"), b"feature-only\n")
1642 .unwrap();
1643 writer
1644 .commit("first commit on feature", &ctx_for_test())
1645 .unwrap();
1646
1647 assert!(tree_path_exists(
1649 &gitdir,
1650 "refs/heads/feature",
1651 "only-on-feature.md"
1652 ));
1653 assert!(
1655 !workdir.join("only-on-feature.md").exists(),
1656 "worktree must not be polluted by writes to a non-checked-out branch"
1657 );
1658 }
1659
1660 #[test]
1661 fn sync_helper_preserves_untracked_files() {
1662 let tmp = TempDir::new().unwrap();
1663 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1664 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1665
1666 let untracked = workdir.join("scratch.txt");
1670 std::fs::write(&untracked, b"operator notes\n").unwrap();
1671
1672 writer
1673 .write_entity(Path::new("seed.md"), b"seed\n")
1674 .unwrap();
1675 writer.commit("create seed", &ctx_for_test()).unwrap();
1676
1677 assert!(
1678 untracked.exists(),
1679 "sync must leave untracked files in place"
1680 );
1681 assert_eq!(std::fs::read(&untracked).unwrap(), b"operator notes\n");
1682 assert_eq!(std::fs::read(workdir.join("seed.md")).unwrap(), b"seed\n");
1684 }
1685
1686 #[test]
1687 fn sync_helper_updates_through_delete_and_overwrite() {
1688 let tmp = TempDir::new().unwrap();
1689 let (workdir, gitdir) = fresh_non_bare_repo(tmp.path());
1690 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/main".to_string());
1691
1692 writer.write_entity(Path::new("a.md"), b"first\n").unwrap();
1693 writer.commit("create a", &ctx_for_test()).unwrap();
1694 assert_eq!(std::fs::read(workdir.join("a.md")).unwrap(), b"first\n");
1695
1696 writer.write_entity(Path::new("a.md"), b"second\n").unwrap();
1697 writer.commit("overwrite a", &ctx_for_test()).unwrap();
1698 assert_eq!(
1699 std::fs::read(workdir.join("a.md")).unwrap(),
1700 b"second\n",
1701 "overwrite must propagate to the worktree"
1702 );
1703
1704 writer.delete_entity(Path::new("a.md")).unwrap();
1705 writer.commit("delete a", &ctx_for_test()).unwrap();
1706 assert!(
1707 !workdir.join("a.md").exists(),
1708 "delete must remove the file from the worktree"
1709 );
1710
1711 let output = std::process::Command::new("git")
1713 .arg("-C")
1714 .arg(&workdir)
1715 .args(["status", "--porcelain"])
1716 .output()
1717 .unwrap();
1718 assert!(
1719 output.stdout.is_empty(),
1720 "git status --porcelain must be empty after every commit, got: {:?}",
1721 String::from_utf8_lossy(&output.stdout)
1722 );
1723 }
1724
1725 fn commit_with_verb(
1731 writer: &GitTreeMemWriter,
1732 verb: &str,
1733 entity_id: &str,
1734 ctx: &CommitContext<'_>,
1735 ) {
1736 let subject = format!("memstead: {verb} {entity_id}");
1737 <GitTreeMemWriter as MemWriter>::commit(writer, &subject, ctx).unwrap();
1738 }
1739
1740 fn ctx_with_note<'a>(note: &'a str) -> CommitContext<'a> {
1741 CommitContext {
1742 actor: Actor::Agent,
1743 client: Some(ClientId {
1744 name: "claude-code".to_string(),
1745 version: "2.1.0".to_string(),
1746 }),
1747 tool: Some("memstead_create"),
1748 note: Some(note.to_string()),
1749 logical_operation_id: None,
1750 entity_ids: None,
1751 }
1752 }
1753
1754 #[test]
1755 fn backend_list_entities_returns_only_md_outside_memstead_namespace() {
1756 use memstead_base::backend::MemBackend;
1757
1758 let tmp = TempDir::new().unwrap();
1759 let gitdir = fresh_repo_dir(tmp.path());
1760 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1761
1762 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"# a").unwrap();
1765 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"# b")
1766 .unwrap();
1767 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
1768 .unwrap();
1769 <GitTreeMemWriter as MemWriter>::write_entity(
1770 &writer,
1771 Path::new(".memstead/config.json"),
1772 b"{}",
1773 )
1774 .unwrap();
1775 <GitTreeMemWriter as MemWriter>::write_entity(
1776 &writer,
1777 Path::new(".memstead/notes.md"),
1778 b"# skip me",
1779 )
1780 .unwrap();
1781 <GitTreeMemWriter as MemWriter>::write_entity(
1782 &writer,
1783 Path::new(".other/notes.md"),
1784 b"# no longer special, walked like any non-meta dir",
1785 )
1786 .unwrap();
1787 <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1788
1789 let backend: &dyn MemBackend = &writer;
1790 let mut paths: Vec<String> = backend
1791 .list_entities()
1792 .unwrap()
1793 .into_iter()
1794 .map(|p| p.to_string_lossy().into_owned())
1795 .collect();
1796 paths.sort();
1797 assert_eq!(
1799 paths,
1800 vec![
1801 ".other/notes.md".to_string(),
1802 "a.md".to_string(),
1803 "nested/b.md".to_string(),
1804 ]
1805 );
1806 }
1807
1808 #[test]
1809 fn backend_list_entities_returns_empty_for_missing_branch() {
1810 use memstead_base::backend::MemBackend;
1811
1812 let tmp = TempDir::new().unwrap();
1813 let gitdir = fresh_repo_dir(tmp.path());
1814 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
1815 let backend: &dyn MemBackend = &writer;
1816 assert!(backend.list_entities().unwrap().is_empty());
1818 }
1819
1820 #[test]
1821 fn backend_read_entity_consults_pending_then_branch_tip() {
1822 use memstead_base::backend::MemBackend;
1823
1824 let tmp = TempDir::new().unwrap();
1825 let gitdir = fresh_repo_dir(tmp.path());
1826 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1827
1828 <GitTreeMemWriter as MemWriter>::write_entity(
1830 &writer,
1831 Path::new("on_branch.md"),
1832 b"branch",
1833 )
1834 .unwrap();
1835 <GitTreeMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1836
1837 let backend: &dyn MemBackend = &writer;
1838 assert_eq!(
1840 backend.read_entity(Path::new("on_branch.md")).unwrap(),
1841 Some(b"branch".to_vec())
1842 );
1843 backend
1845 .write_entity(Path::new("on_branch.md"), b"buffered")
1846 .unwrap();
1847 assert_eq!(
1848 backend.read_entity(Path::new("on_branch.md")).unwrap(),
1849 Some(b"buffered".to_vec())
1850 );
1851 backend.delete_entity(Path::new("on_branch.md")).unwrap();
1853 assert_eq!(
1854 backend.read_entity(Path::new("on_branch.md")).unwrap(),
1855 None
1856 );
1857 assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
1859 }
1860
1861 #[test]
1862 fn backend_read_provenance_reconstructs_from_commit_log() {
1863 use memstead_base::backend::MemBackend;
1864
1865 let tmp = TempDir::new().unwrap();
1866 let gitdir = fresh_repo_dir(tmp.path());
1867 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1868
1869 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
1873 commit_with_verb(&writer, "create", "v:a", &ctx_with_note("first draft"));
1874
1875 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
1876 commit_with_verb(
1877 &writer,
1878 "update",
1879 "v:a",
1880 &CommitContext {
1881 actor: Actor::Cli,
1882 client: None,
1883 tool: Some("memstead_update"),
1884 note: None,
1885 logical_operation_id: None,
1886 entity_ids: None,
1887 },
1888 );
1889
1890 let backend: &dyn MemBackend = &writer;
1891 backend
1894 .append_provenance(&memstead_base::Provenance::new(
1895 std::time::UNIX_EPOCH,
1896 memstead_base::ProvenanceKind::Create,
1897 Some("ignored".into()),
1898 Actor::Unknown,
1899 None,
1900 None,
1901 ))
1902 .unwrap();
1903
1904 let records = backend.read_provenance(None).unwrap();
1905 assert_eq!(records.len(), 2, "expected two commits, got {records:?}");
1906 assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Create);
1908 assert_eq!(records[0].entity.as_deref(), Some("v:a"));
1909 assert_eq!(records[0].actor, Actor::Agent);
1910 assert_eq!(records[0].note.as_deref(), Some("first draft"));
1911 assert_eq!(
1912 records[0]
1913 .client
1914 .as_ref()
1915 .map(|c| (c.name.as_str(), c.version.as_str())),
1916 Some(("claude-code", "2.1.0"))
1917 );
1918 assert_eq!(records[1].kind, memstead_base::ProvenanceKind::Update);
1919 assert_eq!(records[1].actor, Actor::Cli);
1920 assert!(records[1].note.is_none());
1921 assert!(records[1].client.is_none());
1922 }
1923
1924 #[test]
1925 fn backend_read_provenance_filters_by_cursor_sha() {
1926 use memstead_base::backend::MemBackend;
1927
1928 let tmp = TempDir::new().unwrap();
1929 let gitdir = fresh_repo_dir(tmp.path());
1930 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1931
1932 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
1934 let first_sha = <GitTreeMemWriter as MemWriter>::commit(
1935 &writer,
1936 "memstead: create v:a",
1937 &ctx_for_test(),
1938 )
1939 .unwrap();
1940 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a2").unwrap();
1941 <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
1942 .unwrap();
1943 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a3").unwrap();
1944 <GitTreeMemWriter as MemWriter>::commit(&writer, "memstead: update v:a", &ctx_for_test())
1945 .unwrap();
1946
1947 let backend: &dyn MemBackend = &writer;
1948 let after = backend.read_provenance(Some(&first_sha)).unwrap();
1950 assert_eq!(
1951 after.len(),
1952 2,
1953 "expected commits after cursor, got {after:?}"
1954 );
1955 for r in &after {
1956 assert_eq!(r.kind, memstead_base::ProvenanceKind::Update);
1957 }
1958 }
1959
1960 #[test]
1961 fn backend_read_provenance_empty_for_missing_branch() {
1962 use memstead_base::backend::MemBackend;
1963
1964 let tmp = TempDir::new().unwrap();
1965 let gitdir = fresh_repo_dir(tmp.path());
1966 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/never".to_string());
1967 let backend: &dyn MemBackend = &writer;
1968 assert!(backend.read_provenance(None).unwrap().is_empty());
1970 }
1971
1972 #[test]
1973 fn backend_unknown_verb_falls_back_to_update_kind() {
1974 use memstead_base::backend::MemBackend;
1975
1976 let tmp = TempDir::new().unwrap();
1977 let gitdir = fresh_repo_dir(tmp.path());
1978 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/test".to_string());
1979
1980 <GitTreeMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
1981 commit_with_verb(&writer, "mem_create", "v:a", &ctx_for_test());
1985
1986 let backend: &dyn MemBackend = &writer;
1987 let records = backend.read_provenance(None).unwrap();
1988 assert_eq!(records.len(), 1);
1989 assert_eq!(records[0].kind, memstead_base::ProvenanceKind::Update);
1990 }
1991
1992 #[test]
1993 fn instantiate_full_backend_constructs_git_branch_writer() {
1994 use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
1999
2000 let tmp = TempDir::new().unwrap();
2001 let gitdir = fresh_repo_dir(tmp.path());
2002 let mount = Mount {
2003 mem: "engine".to_string(),
2004 schema: Some("default@1.0.0".parse().unwrap()),
2005 storage: MountStorage::GitBranch {
2006 gitdir,
2007 branch: "engine".to_string(),
2008 },
2009 capability: MountCapability::Write,
2010 lifecycle: MountLifecycle::Eager,
2011 cross_linkable: true,
2012 migration_target: None,
2013 };
2014 let backend: Box<dyn MemBackend> =
2015 crate::storage::instantiate_full_backend(&mount).unwrap();
2016 assert!(backend.list_entities().unwrap().is_empty());
2018 assert!(backend.read_provenance(None).unwrap().is_empty());
2020 }
2021
2022 #[test]
2023 fn instantiate_full_backend_accepts_branch_with_or_without_refs_prefix() {
2024 use memstead_base::{MemBackend, Mount, MountCapability, MountLifecycle, MountStorage};
2029
2030 let tmp = TempDir::new().unwrap();
2031 let gitdir = fresh_repo_dir(tmp.path());
2032 for branch in ["engine", "refs/heads/engine"] {
2033 let mount = Mount {
2034 mem: "engine".to_string(),
2035 schema: Some("default@1.0.0".parse().unwrap()),
2036 storage: MountStorage::GitBranch {
2037 gitdir: gitdir.clone(),
2038 branch: branch.to_string(),
2039 },
2040 capability: MountCapability::Write,
2041 lifecycle: MountLifecycle::Eager,
2042 cross_linkable: true,
2043 migration_target: None,
2044 };
2045 let backend: Box<dyn MemBackend> =
2046 crate::storage::instantiate_full_backend(&mount).unwrap();
2047 assert!(backend.list_entities().unwrap().is_empty());
2049 }
2050 }
2051
2052 #[test]
2055 fn current_head_returns_none_for_empty_branch() {
2056 let tmp = TempDir::new().unwrap();
2061 let gitdir = fresh_repo_dir(tmp.path());
2062 let writer = GitTreeMemWriter::new(gitdir, "refs/heads/specs".to_string());
2063 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2064 .unwrap();
2065 assert!(head.is_none());
2066 }
2067
2068 #[test]
2069 fn current_head_returns_hex_sha_after_commit() {
2070 let tmp = TempDir::new().unwrap();
2076 let gitdir = fresh_repo_dir(tmp.path());
2077 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2078
2079 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2080 let sha = writer.commit("first", &ctx_for_test()).unwrap();
2081 assert_eq!(sha.len(), 40);
2082
2083 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2084 .unwrap()
2085 .expect("head present after commit");
2086 assert_eq!(head, sha);
2087 }
2088
2089 #[test]
2090 fn current_head_advances_on_subsequent_commits() {
2091 let tmp = TempDir::new().unwrap();
2096 let gitdir = fresh_repo_dir(tmp.path());
2097 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2098
2099 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2100 let first = writer.commit("first", &ctx_for_test()).unwrap();
2101 let head_after_first =
2102 <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2103 .unwrap()
2104 .unwrap();
2105 assert_eq!(head_after_first, first);
2106
2107 writer.write_entity(Path::new("b.md"), b"b").unwrap();
2108 let second = writer.commit("second", &ctx_for_test()).unwrap();
2109 assert_ne!(first, second);
2110 let head_after_second =
2111 <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2112 .unwrap()
2113 .unwrap();
2114 assert_eq!(head_after_second, second);
2115 }
2116
2117 #[test]
2118 fn current_head_returns_none_for_missing_gitdir() {
2119 let tmp = TempDir::new().unwrap();
2125 let writer = GitTreeMemWriter::new(
2126 tmp.path().join("does-not-exist.git"),
2127 "refs/heads/specs".to_string(),
2128 );
2129 let head = <GitTreeMemWriter as memstead_base::backend::MemBackend>::current_head(&writer)
2130 .unwrap();
2131 assert!(head.is_none());
2132 }
2133
2134 fn dispatch_changes(
2142 gitdir: &Path,
2143 branch: &str,
2144 mem: &str,
2145 since: &str,
2146 ) -> Result<memstead_base::ops::BackendChanges, memstead_base::backend::BackendError> {
2147 (crate::storage::FULL_GIT_BRANCH_OPS.changes_since)(
2148 gitdir,
2149 branch,
2150 mem,
2151 since,
2152 memstead_base::ops::RENAME_SIMILARITY_DEFAULT,
2153 )
2154 }
2155
2156 #[test]
2157 fn changes_since_empty_repo_with_sentinel_returns_empty_changes() {
2158 let tmp = TempDir::new().unwrap();
2162 let gitdir = fresh_repo_dir(tmp.path());
2163 let result = dispatch_changes(
2164 &gitdir,
2165 "specs",
2166 "specs",
2167 memstead_base::ops::EMPTY_TREE_SHA,
2168 )
2169 .unwrap();
2170 assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2171 assert_eq!(result.head, memstead_base::ops::EMPTY_TREE_SHA);
2172 assert!(result.changes.is_empty());
2173 }
2174
2175 #[test]
2176 fn changes_since_after_commit_returns_added_envelopes_id_only() {
2177 use memstead_base::ops::ChangeEnvelope;
2181 let tmp = TempDir::new().unwrap();
2182 let gitdir = fresh_repo_dir(tmp.path());
2183 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2184
2185 writer
2186 .write_entity(Path::new("alpha.md"), b"# Alpha")
2187 .unwrap();
2188 writer
2189 .write_entity(Path::new("beta.md"), b"# Beta")
2190 .unwrap();
2191 let head_sha = writer.commit("seed", &ctx_for_test()).unwrap();
2192
2193 let result = dispatch_changes(
2194 &gitdir,
2195 "specs",
2196 "specs",
2197 memstead_base::ops::EMPTY_TREE_SHA,
2198 )
2199 .unwrap();
2200 assert_eq!(result.since, memstead_base::ops::EMPTY_TREE_SHA);
2201 assert_eq!(result.head, head_sha);
2202 assert_eq!(result.changes.len(), 2);
2203 for env in &result.changes {
2204 match env {
2205 ChangeEnvelope::Added {
2206 id,
2207 title,
2208 entity_type,
2209 } => {
2210 assert!(
2211 id.0.starts_with("specs--"),
2212 "expected mem-prefixed id, got {}",
2213 id.0
2214 );
2215 assert!(title.is_none(), "dispatch must not enrich title");
2216 assert!(
2217 entity_type.is_none(),
2218 "dispatch must not enrich entity_type"
2219 );
2220 }
2221 other => panic!("expected Added envelope, got {other:?}"),
2222 }
2223 }
2224 }
2225
2226 #[test]
2227 fn changes_since_between_two_commits_yields_updated_envelope() {
2228 use memstead_base::ops::ChangeEnvelope;
2229 let tmp = TempDir::new().unwrap();
2230 let gitdir = fresh_repo_dir(tmp.path());
2231 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2232
2233 writer
2234 .write_entity(Path::new("alpha.md"), b"# Alpha v1")
2235 .unwrap();
2236 let sha_v1 = writer.commit("v1", &ctx_for_test()).unwrap();
2237
2238 writer
2239 .write_entity(Path::new("alpha.md"), b"# Alpha v2")
2240 .unwrap();
2241 let sha_v2 = writer.commit("v2", &ctx_for_test()).unwrap();
2242 assert_ne!(sha_v1, sha_v2);
2243
2244 let result = dispatch_changes(&gitdir, "specs", "specs", &sha_v1).unwrap();
2245 assert_eq!(result.since, sha_v1);
2246 assert_eq!(result.head, sha_v2);
2247 assert_eq!(result.changes.len(), 1);
2248 match &result.changes[0] {
2249 ChangeEnvelope::Updated {
2250 id,
2251 title,
2252 entity_type,
2253 } => {
2254 assert!(id.0.starts_with("specs--"));
2255 assert!(title.is_none());
2256 assert!(entity_type.is_none());
2257 }
2258 other => panic!("expected Updated envelope, got {other:?}"),
2259 }
2260 }
2261
2262 #[test]
2263 fn changes_since_unknown_cursor_returns_typed_commit_not_found_marker() {
2264 let tmp = TempDir::new().unwrap();
2272 let gitdir = fresh_repo_dir(tmp.path());
2273 let writer = GitTreeMemWriter::new(gitdir.clone(), "refs/heads/specs".to_string());
2274 writer.write_entity(Path::new("a.md"), b"a").unwrap();
2276 writer.commit("seed", &ctx_for_test()).unwrap();
2277
2278 let bad_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2279 let err = dispatch_changes(&gitdir, "specs", "specs", bad_sha).unwrap_err();
2280 match err {
2281 memstead_base::backend::BackendError::Other(msg) => {
2282 assert_eq!(
2283 msg,
2284 format!("COMMIT_NOT_FOUND:{bad_sha}"),
2285 "bad-since must carry the typed marker with the untruncated sha: {msg}",
2286 );
2287 }
2288 other => panic!("expected BackendError::Other, got {other:?}"),
2289 }
2290 }
2291}