1use std::collections::HashMap;
64use std::ffi::OsStr;
65use std::path::{Path, PathBuf};
66use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
67
68use gix::objs::tree::EntryKind;
69
70static BRANCH_MUTEXES: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
77
78#[cfg(debug_assertions)]
88thread_local! {
89 static HELD_BRANCH_KEYS: std::cell::RefCell<Vec<String>> =
90 const { std::cell::RefCell::new(Vec::new()) };
91}
92
93pub(crate) fn acquire_branch_mutex(ref_name: &str) -> Arc<Mutex<()>> {
116 #[cfg(debug_assertions)]
117 {
118 HELD_BRANCH_KEYS.with(|held| {
119 let held = held.borrow();
120 if let Some(top) = held.last() {
121 assert!(
122 top.as_str() < ref_name,
123 "out-of-order branch-mutex acquisition: \
124 thread already holds '{top}', cannot now acquire '{ref_name}' \
125 (lexicographic order required)"
126 );
127 }
128 });
129 }
130 let registry = BRANCH_MUTEXES.get_or_init(|| Mutex::new(HashMap::new()));
131 let mut map = registry
137 .lock()
138 .expect("branch mutex registry poisoned — previous commit panicked inside the registry critical section");
139 map.entry(ref_name.to_string())
140 .or_insert_with(|| Arc::new(Mutex::new(())))
141 .clone()
142}
143
144#[cfg_attr(not(test), allow(dead_code))]
150pub(crate) struct BranchMutexGuard {
151 _guard: MutexGuard<'static, ()>,
155 _arc: Arc<Mutex<()>>,
156 #[cfg(debug_assertions)]
157 key: String,
158}
159
160#[cfg(debug_assertions)]
161impl Drop for BranchMutexGuard {
162 fn drop(&mut self) {
163 HELD_BRANCH_KEYS.with(|held| {
164 let mut held = held.borrow_mut();
165 if let Some(pos) = held.iter().rposition(|k| k == &self.key) {
166 held.remove(pos);
167 }
168 });
169 }
170}
171
172#[cfg_attr(not(test), allow(dead_code))]
182pub(crate) fn acquire_branch_mutexes_in_order(refs: &[&str]) -> Vec<BranchMutexGuard> {
183 let mut sorted: Vec<&str> = refs.to_vec();
184 sorted.sort_unstable();
185 sorted.dedup();
186 let mut guards: Vec<BranchMutexGuard> = Vec::with_capacity(sorted.len());
187 for r in sorted {
188 let arc = acquire_branch_mutex(r);
189 let raw_guard: MutexGuard<'_, ()> = arc
193 .lock()
194 .expect("branch mutex poisoned during ordered acquisition");
195 let guard: MutexGuard<'static, ()> = unsafe {
196 std::mem::transmute::<MutexGuard<'_, ()>, MutexGuard<'static, ()>>(raw_guard)
197 };
198 #[cfg(debug_assertions)]
199 HELD_BRANCH_KEYS.with(|held| {
200 held.borrow_mut().push(r.to_string());
201 });
202 guards.push(BranchMutexGuard {
203 _guard: guard,
204 _arc: arc,
205 #[cfg(debug_assertions)]
206 key: r.to_string(),
207 });
208 }
209 guards
210}
211
212pub(crate) fn head_branch_ref(repo: &gix::Repository) -> String {
219 match repo.head_ref() {
220 Ok(Some(reference)) => reference.name().as_bstr().to_string(),
221 _ => "HEAD".to_string(),
222 }
223}
224
225const COMMITTER_NAME: &str = "engine";
228const COMMITTER_EMAIL: &str = "noreply@memstead.io";
229
230pub use memstead_base::vcs::{
231 Actor, ClientId, CommitContext, author_identity, format_commit_message, sanitise_client_name,
232};
233
234pub trait Vcs: Send + Sync {
238 fn commit(
246 &self,
247 paths: &[&Path],
248 message: &str,
249 ctx: &CommitContext<'_>,
250 ) -> Result<String, VcsError>;
251}
252
253#[derive(Debug, thiserror::Error)]
254pub enum VcsError {
255 #[error("not a git repository: {0}")]
256 NotRepo(String),
257 #[error("object not found: {0}")]
258 ObjectNotFound(String),
259 #[error("reference conflict: {0}")]
260 RefConflict(String),
261 #[error("git error: {0}")]
262 Git(String),
263 #[error("io error: {0}")]
264 Io(#[from] std::io::Error),
265}
266
267impl From<gix::open::Error> for VcsError {
268 fn from(e: gix::open::Error) -> Self {
269 VcsError::NotRepo(e.to_string())
270 }
271}
272
273impl From<gix::init::Error> for VcsError {
274 fn from(e: gix::init::Error) -> Self {
275 VcsError::Git(format!("init: {e}"))
276 }
277}
278
279impl From<gix::commit::Error> for VcsError {
280 fn from(e: gix::commit::Error) -> Self {
281 VcsError::Git(format!("commit: {e}"))
282 }
283}
284
285impl From<gix::object::write::Error> for VcsError {
286 fn from(e: gix::object::write::Error) -> Self {
287 VcsError::Git(format!("write-object: {e}"))
288 }
289}
290
291pub fn create_vcs(git_dir: &Path, work_tree: &Path) -> Result<Arc<dyn Vcs>, VcsError> {
307 let is_new = !git_dir.join("HEAD").exists();
308
309 if is_new {
310 std::fs::create_dir_all(work_tree)?;
311 if let Some(parent) = git_dir.parent() {
312 std::fs::create_dir_all(parent)?;
313 }
314 gix::init_bare(git_dir)?;
315
316 let mut kvs: Vec<(&str, &str, &str)> = vec![
317 ("core", "bare", "false"),
318 ("core", "logallrefupdates", "true"),
319 ("commit", "gpgsign", "false"),
320 ];
321 let worktree_rel_storage;
322 let gitdir_parent_is_worktree = git_dir
323 .parent()
324 .map(|p| paths_equal(p, work_tree))
325 .unwrap_or(false);
326 if !gitdir_parent_is_worktree {
327 worktree_rel_storage = relative_path(git_dir, work_tree)
328 .unwrap_or_else(|| work_tree.to_string_lossy().into_owned());
329 kvs.push(("core", "worktree", &worktree_rel_storage));
330 }
331 write_per_repo_config(git_dir, &kvs)?;
332 } else {
333 write_per_repo_config(git_dir, &[("commit", "gpgsign", "false")])?;
334 }
335
336 let _repo = gix::open(git_dir)?;
340
341 let git_dir_canon = std::fs::canonicalize(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
347 let work_tree_canon =
348 std::fs::canonicalize(work_tree).unwrap_or_else(|_| work_tree.to_path_buf());
349
350 Ok(Arc::new(GixVcs {
351 git_dir: git_dir_canon,
352 work_tree: work_tree_canon,
353 }))
354}
355
356fn paths_equal(a: &Path, b: &Path) -> bool {
362 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
363 (Ok(ca), Ok(cb)) => ca == cb,
364 _ => a == b,
365 }
366}
367
368fn relative_path(from: &Path, to: &Path) -> Option<String> {
380 let from_comps: Vec<_> = from.components().collect();
381 let to_comps: Vec<_> = to.components().collect();
382 let mut shared = 0;
384 while shared < from_comps.len()
385 && shared < to_comps.len()
386 && from_comps[shared] == to_comps[shared]
387 {
388 shared += 1;
389 }
390 if shared == 0 {
391 return None;
392 }
393 let ups = from_comps.len().saturating_sub(shared);
394 let mut out = PathBuf::new();
395 for _ in 0..ups {
396 out.push("..");
397 }
398 for comp in &to_comps[shared..] {
399 out.push(comp.as_os_str());
400 }
401 if out.as_os_str().is_empty() {
402 Some(".".to_string())
403 } else {
404 Some(out.to_string_lossy().into_owned())
405 }
406}
407
408fn write_per_repo_config(git_dir: &Path, kvs: &[(&str, &str, &str)]) -> Result<(), VcsError> {
417 use gix::bstr::BStr;
418 let config_path = git_dir.join("config");
419 let mut file =
420 gix::config::File::from_path_no_includes(config_path.clone(), gix::config::Source::Local)
421 .map_err(|e| VcsError::Git(format!("config parse: {e}")))?;
422 for (section, key, value) in kvs {
423 let key_owned = String::from(*key);
424 let value_bytes: &BStr = (*value).as_bytes().into();
425 file.set_raw_value_by(*section, None, key_owned, value_bytes)
426 .map_err(|e| VcsError::Git(format!("config set {section}.{key}: {e}")))?;
427 }
428 let mut buf = Vec::new();
429 file.write_to(&mut buf)
430 .map_err(|e| VcsError::Git(format!("config serialize: {e}")))?;
431 std::fs::write(&config_path, buf)?;
432 Ok(())
433}
434
435struct GixVcs {
438 git_dir: PathBuf,
439 work_tree: PathBuf,
440}
441
442impl Vcs for GixVcs {
443 fn commit(
444 &self,
445 paths: &[&Path],
446 message: &str,
447 ctx: &CommitContext<'_>,
448 ) -> Result<String, VcsError> {
449 let repo = gix::open(&self.git_dir)?;
456 let head_ref = head_branch_ref(&repo);
457 let mutex = acquire_branch_mutex(&head_ref);
458 let _guard = mutex.lock().map_err(|_| {
459 VcsError::Git(format!(
460 "branch mutex poisoned (a previous commit panicked); inspect {} ref {} and restart the process",
461 self.git_dir.display(),
462 head_ref,
463 ))
464 })?;
465
466 let subpaths: Vec<String> = paths
484 .iter()
485 .map(|p| mem_subpath(&self.work_tree, p))
486 .collect::<Result<_, _>>()?;
487 debug_assert!(
488 subpaths.iter().all(|s| s.is_empty()) || subpaths.iter().all(|s| !s.is_empty()),
489 "commit() paths must not mix isolated and shared subpaths",
490 );
491 let any_empty_subpath = subpaths.iter().any(|s| s.is_empty());
492
493 let head_commit = repo.head_commit().ok();
494 let parents = head_commit.as_ref().map(|c| vec![c.id]).unwrap_or_default();
495 let mut editor = if any_empty_subpath {
496 repo.empty_tree()
497 .edit()
498 .map_err(|e| VcsError::Git(format!("editor init: {e}")))?
499 } else if let Some(head) = head_commit.as_ref() {
500 let tree = head
501 .tree()
502 .map_err(|e| VcsError::Git(format!("head tree: {e}")))?;
503 tree.edit()
504 .map_err(|e| VcsError::Git(format!("editor init: {e}")))?
505 } else {
506 repo.empty_tree()
507 .edit()
508 .map_err(|e| VcsError::Git(format!("editor init: {e}")))?
509 };
510
511 for (path, subpath) in paths.iter().zip(subpaths.iter()) {
512 if !subpath.is_empty() {
515 editor
516 .remove(subpath.as_str())
517 .map_err(|e| VcsError::Git(format!("tree remove: {e}")))?;
518 }
519 apply_path(&repo, &mut editor, &self.work_tree, path, subpath)?;
520 }
521
522 let tree_id = editor
523 .write()
524 .map_err(|e| VcsError::Git(format!("tree write: {e}")))?
525 .detach();
526
527 let time = gix::date::Time::now_local_or_utc();
528 let committer_sig = gix::actor::Signature {
529 name: COMMITTER_NAME.into(),
530 email: COMMITTER_EMAIL.into(),
531 time,
532 };
533 let author_sig = match author_identity(ctx) {
534 Some((name, email)) => gix::actor::Signature {
535 name: name.into(),
536 email: email.into(),
537 time,
538 },
539 None => committer_sig.clone(),
540 };
541 let mut author_buf = gix::date::parse::TimeBuf::default();
542 let mut committer_buf = gix::date::parse::TimeBuf::default();
543 let author_ref = author_sig.to_ref(&mut author_buf);
544 let committer_ref = committer_sig.to_ref(&mut committer_buf);
545
546 let full_message = format_commit_message(message, ctx);
547 let commit_id = repo.commit_as(
548 committer_ref,
549 author_ref,
550 "HEAD",
551 full_message,
552 tree_id,
553 parents,
554 )?;
555 Ok(commit_id.to_hex().to_string())
556 }
557}
558
559pub(crate) fn mem_subpath(work_tree: &Path, mem_path: &Path) -> Result<String, VcsError> {
576 let canon_mem = std::fs::canonicalize(mem_path).unwrap_or_else(|_| mem_path.to_path_buf());
577 let rel = canon_mem.strip_prefix(work_tree).map_err(|_| {
578 VcsError::Git(format!(
579 "mem path {} is not under worktree {}",
580 mem_path.display(),
581 work_tree.display(),
582 ))
583 })?;
584 Ok(rel
585 .components()
586 .filter_map(|c| c.as_os_str().to_str())
587 .collect::<Vec<_>>()
588 .join("/"))
589}
590
591fn join_subpath(subpath: &str, rel: &str) -> String {
595 if subpath.is_empty() {
596 rel.to_string()
597 } else if rel.is_empty() {
598 subpath.to_string()
599 } else {
600 format!("{subpath}/{rel}")
601 }
602}
603
604fn apply_path(
617 repo: &gix::Repository,
618 editor: &mut gix::object::tree::Editor<'_>,
619 work_tree: &Path,
620 path: &Path,
621 subpath: &str,
622) -> Result<(), VcsError> {
623 if path.is_file() {
624 if let Ok(rel) = path.strip_prefix(work_tree) {
625 let rel_str = rel.to_string_lossy();
626 if !is_ignored(rel.components()) {
627 upsert_file(repo, editor, path, &rel_str)?;
628 }
629 }
630 return Ok(());
631 }
632
633 if path.is_dir() {
634 for entry in walkdir::WalkDir::new(path)
635 .follow_links(false)
636 .into_iter()
637 .filter_entry(|e| {
638 if e.file_name() == OsStr::new(".git") {
639 return false;
640 }
641 match e.path().strip_prefix(path) {
646 Ok(rel) => !is_ignored(rel.components()),
647 Err(_) => true,
648 }
649 })
650 {
651 let entry = entry.map_err(|e| VcsError::Git(format!("walk: {e}")))?;
652 if !entry.file_type().is_file() {
653 continue;
654 }
655 let rel_in_mem = match entry.path().strip_prefix(path) {
656 Ok(p) => p
657 .components()
658 .filter_map(|c| c.as_os_str().to_str())
659 .collect::<Vec<_>>()
660 .join("/"),
661 Err(_) => continue,
662 };
663 let in_tree_path = join_subpath(subpath, &rel_in_mem);
664 upsert_file(repo, editor, entry.path(), &in_tree_path)?;
665 }
666 }
667 Ok(())
668}
669
670fn is_ignored(components: std::path::Components<'_>) -> bool {
675 let mut comps = components;
676 let first = comps.next().map(|c| c.as_os_str());
677 if first == Some(OsStr::new(".memstead")) {
678 return matches!(
679 comps.next().map(|c| c.as_os_str()),
680 Some(c) if c == OsStr::new("cache")
681 );
682 }
683 false
684}
685
686fn upsert_file(
687 repo: &gix::Repository,
688 editor: &mut gix::object::tree::Editor<'_>,
689 path: &Path,
690 rel: &str,
691) -> Result<(), VcsError> {
692 let bytes = std::fs::read(path)?;
693 let blob_id = repo.write_blob(&bytes)?.detach();
694 let kind = if is_executable(path) {
695 EntryKind::BlobExecutable
696 } else {
697 EntryKind::Blob
698 };
699 editor
700 .upsert(rel, kind, blob_id)
701 .map_err(|e| VcsError::Git(format!("tree upsert: {e}")))?;
702 Ok(())
703}
704
705#[cfg(unix)]
706fn is_executable(path: &Path) -> bool {
707 use std::os::unix::fs::PermissionsExt;
708 std::fs::metadata(path)
709 .map(|m| m.permissions().mode() & 0o111 != 0)
710 .unwrap_or(false)
711}
712
713#[cfg(not(unix))]
714fn is_executable(_path: &Path) -> bool {
715 false
716}
717
718pub struct NoopVcs {
724 counter: std::sync::atomic::AtomicU64,
725}
726
727impl NoopVcs {
728 pub fn new() -> Self {
729 Self {
730 counter: std::sync::atomic::AtomicU64::new(0),
731 }
732 }
733}
734
735impl Default for NoopVcs {
736 fn default() -> Self {
737 Self::new()
738 }
739}
740
741impl Vcs for NoopVcs {
742 fn commit(
743 &self,
744 _paths: &[&Path],
745 _message: &str,
746 _ctx: &CommitContext<'_>,
747 ) -> Result<String, VcsError> {
748 let n = self
749 .counter
750 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
751 Ok(format!("noop-{n}"))
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use std::fs;
759 use tempfile::TempDir;
760
761 fn make_mem_paths(tmp: &Path) -> (PathBuf, PathBuf) {
765 let mem = tmp.join("mem");
766 let git_dir = mem.join(".git");
767 fs::create_dir_all(mem.join(".memstead")).unwrap();
768 (mem, git_dir)
769 }
770
771 #[test]
772 fn create_vcs_initializes_fresh_dir() {
773 let dir = TempDir::new().unwrap();
774 let (mem, git_dir) = make_mem_paths(dir.path());
775 let vcs = create_vcs(&git_dir, &mem).unwrap();
776 let sha = vcs
777 .commit(&[&mem], "initial", &CommitContext::internal())
778 .unwrap();
779 assert_eq!(sha.len(), 40, "commit sha must be 40-char hex");
780 }
781
782 #[test]
783 fn create_vcs_writes_structural_config_on_first_init() {
784 let dir = TempDir::new().unwrap();
785 let (mem, git_dir) = make_mem_paths(dir.path());
786 let _vcs = create_vcs(&git_dir, &mem).unwrap();
787
788 let config = fs::read_to_string(git_dir.join("config")).unwrap();
789 assert!(
793 !config.contains("worktree = "),
794 "no core.worktree override for isolated layout, got:\n{config}"
795 );
796 assert!(
797 config.contains("logallrefupdates = true"),
798 "core.logallrefupdates must be set, got:\n{config}"
799 );
800 assert!(
801 config.contains("gpgsign = false"),
802 "commit.gpgsign must be forced false, got:\n{config}"
803 );
804 }
805
806 #[test]
807 fn create_vcs_reapplies_gpgsign_on_reopen() {
808 let dir = TempDir::new().unwrap();
809 let (mem, git_dir) = make_mem_paths(dir.path());
810
811 let _ = create_vcs(&git_dir, &mem).unwrap();
813
814 let original = fs::read_to_string(git_dir.join("config")).unwrap();
816 let tampered = original.replace("gpgsign = false", "gpgsign = true");
817 fs::write(git_dir.join("config"), tampered).unwrap();
818
819 let _ = create_vcs(&git_dir, &mem).unwrap();
822 let after = fs::read_to_string(git_dir.join("config")).unwrap();
823 assert!(after.contains("gpgsign = false"), "got:\n{after}");
824 assert!(after.contains("logallrefupdates = true"), "got:\n{after}");
825 }
826
827 #[test]
828 fn commit_writes_file_into_tree() {
829 let dir = TempDir::new().unwrap();
830 let (mem, git_dir) = make_mem_paths(dir.path());
831 fs::write(mem.join("test.md"), "hello").unwrap();
832
833 let vcs = create_vcs(&git_dir, &mem).unwrap();
834 let sha = vcs
835 .commit(&[&mem], "add test.md", &CommitContext::internal())
836 .unwrap();
837 assert_eq!(sha.len(), 40);
838
839 let repo = gix::open(&git_dir).unwrap();
840 let commit = repo.head_commit().unwrap();
841 let tree = commit.tree().unwrap();
842 let entry = tree.find_entry("test.md").expect("test.md in tree");
843 let blob = entry.object().unwrap().try_into_blob().unwrap();
844 assert_eq!(blob.data, b"hello");
845 }
846
847 #[test]
848 fn commit_excludes_cache_subdir() {
849 let dir = TempDir::new().unwrap();
850 let (mem, git_dir) = make_mem_paths(dir.path());
851
852 fs::write(mem.join("real.md"), "real").unwrap();
854 fs::create_dir_all(mem.join(".memstead/cache/prompts")).unwrap();
856 fs::write(mem.join(".memstead/cache/prompts/p.txt"), "noise").unwrap();
857
858 let vcs = create_vcs(&git_dir, &mem).unwrap();
859 vcs.commit(&[&mem], "initial", &CommitContext::internal())
860 .unwrap();
861
862 let repo = gix::open(&git_dir).unwrap();
863 let tree = repo.head_commit().unwrap().tree().unwrap();
864
865 assert!(tree.find_entry("real.md").is_some());
868
869 if let Some(memstead_entry) = tree.find_entry(".memstead") {
873 let memstead_tree = memstead_entry.object().unwrap().try_into_tree().unwrap();
874 for entry in memstead_tree.iter() {
875 let entry = entry.unwrap();
876 let name = entry.filename().to_string();
877 assert!(name != "cache", ".memstead subtree must skip {name}");
878 }
879 }
880 }
881
882 #[test]
883 fn commit_second_time_with_deletion_removes_from_tree() {
884 let dir = TempDir::new().unwrap();
885 let (mem, git_dir) = make_mem_paths(dir.path());
886 fs::write(mem.join("keep.md"), "keep").unwrap();
887 fs::write(mem.join("drop.md"), "drop").unwrap();
888
889 let vcs = create_vcs(&git_dir, &mem).unwrap();
890 vcs.commit(&[&mem], "initial", &CommitContext::internal())
891 .unwrap();
892
893 fs::remove_file(mem.join("drop.md")).unwrap();
894 vcs.commit(&[&mem], "drop one", &CommitContext::internal())
895 .unwrap();
896
897 let repo = gix::open(&git_dir).unwrap();
898 let tree = repo.head_commit().unwrap().tree().unwrap();
899 assert!(tree.find_entry("keep.md").is_some());
900 assert!(
901 tree.find_entry("drop.md").is_none(),
902 "deleted file must disappear from the tree on the next commit"
903 );
904 }
905
906 #[test]
907 fn commit_author_is_deterministic() {
908 let dir = TempDir::new().unwrap();
909 let (mem, git_dir) = make_mem_paths(dir.path());
910 fs::write(mem.join("a.md"), "a").unwrap();
911
912 let vcs = create_vcs(&git_dir, &mem).unwrap();
913 vcs.commit(&[&mem], "x", &CommitContext::internal())
914 .unwrap();
915
916 let repo = gix::open(&git_dir).unwrap();
917 let commit = repo.head_commit().unwrap();
918 let author = commit.author().unwrap();
919 assert_eq!(author.name, COMMITTER_NAME);
920 assert_eq!(author.email, COMMITTER_EMAIL);
921 }
922
923 #[test]
924 fn noop_vcs_returns_distinguishable_shas() {
925 let vcs = NoopVcs::new();
926 let s1 = vcs.commit(&[], "x", &CommitContext::internal()).unwrap();
927 let s2 = vcs.commit(&[], "y", &CommitContext::internal()).unwrap();
928 assert!(s1.starts_with("noop-"));
929 assert!(s2.starts_with("noop-"));
930 assert_ne!(s1, s2);
931 }
932
933 fn head_commit_parts(git_dir: &Path) -> (String, String, String) {
936 let repo = gix::open(git_dir).unwrap();
937 let commit = repo.head_commit().unwrap();
938 let author = commit.author().unwrap();
939 let message = commit.message_raw().unwrap().to_string();
940 (author.name.to_string(), author.email.to_string(), message)
941 }
942
943 #[test]
944 fn commit_with_agent_context_sets_author_and_trailers() {
945 let dir = TempDir::new().unwrap();
946 let (mem, git_dir) = make_mem_paths(dir.path());
947 fs::write(mem.join("a.md"), "a").unwrap();
948
949 let vcs = create_vcs(&git_dir, &mem).unwrap();
950 let ctx = CommitContext {
951 actor: Actor::Agent,
952 client: Some(ClientId {
953 name: "claude-code".into(),
954 version: "2.1.0".into(),
955 }),
956 tool: Some("memstead_update"),
957 note: None,
958 role: Default::default(),
959 logical_operation_id: None,
960 entity_ids: None,
961 };
962 vcs.commit(&[&mem], "memstead: update specs--a", &ctx)
963 .unwrap();
964
965 let (name, email, message) = head_commit_parts(&git_dir);
966 assert_eq!(name, "claude-code");
967 assert_eq!(email, "claude-code@memstead.io");
968 assert!(
969 message.ends_with("\n\nTool: memstead_update\nActor: agent\nClient: claude-code@2.1.0"),
970 "got message: {message:?}"
971 );
972 }
973
974 #[test]
975 fn commit_with_external_context_sets_external_author_and_actor_trailer() {
976 let dir = TempDir::new().unwrap();
977 let (mem, git_dir) = make_mem_paths(dir.path());
978 fs::write(mem.join("a.md"), "a").unwrap();
979
980 let vcs = create_vcs(&git_dir, &mem).unwrap();
981 let ctx = CommitContext {
982 actor: Actor::External,
983 client: None,
984 tool: None,
985 note: None,
986 role: Default::default(),
987 logical_operation_id: None,
988 entity_ids: None,
989 };
990 vcs.commit(&[&mem], "external edits (1 files)", &ctx)
991 .unwrap();
992
993 let (name, email, message) = head_commit_parts(&git_dir);
994 assert_eq!(name, "external");
995 assert_eq!(email, "external@memstead.io");
996 assert!(message.contains("\n\nActor: external"));
997 assert!(!message.contains("Tool:"));
998 assert!(!message.contains("Client:"));
999 }
1000
1001 #[test]
1002 fn commit_with_cli_context_emits_trailers_and_author() {
1003 let dir = TempDir::new().unwrap();
1004 let (mem, git_dir) = make_mem_paths(dir.path());
1005 fs::write(mem.join("a.md"), "a").unwrap();
1006
1007 let vcs = create_vcs(&git_dir, &mem).unwrap();
1008
1009 let ctx_no_client = CommitContext {
1011 actor: Actor::Cli,
1012 client: None,
1013 tool: None,
1014 note: None,
1015 role: Default::default(),
1016 logical_operation_id: None,
1017 entity_ids: None,
1018 };
1019 vcs.commit(&[&mem], "memstead: create specs--a", &ctx_no_client)
1020 .unwrap();
1021 let (name, email, message) = head_commit_parts(&git_dir);
1022 assert_eq!(name, COMMITTER_NAME);
1023 assert_eq!(email, COMMITTER_EMAIL);
1024 assert!(message.contains("\n\nActor: cli"));
1025 assert!(!message.contains("Client:"));
1026
1027 fs::write(mem.join("b.md"), "b").unwrap();
1029 let ctx_with_client = CommitContext {
1030 actor: Actor::Cli,
1031 client: Some(ClientId {
1032 name: "memstead-cli".into(),
1033 version: "0.1.0".into(),
1034 }),
1035 tool: None,
1036 note: None,
1037 role: Default::default(),
1038 logical_operation_id: None,
1039 entity_ids: None,
1040 };
1041 vcs.commit(&[&mem], "memstead: create specs--b", &ctx_with_client)
1042 .unwrap();
1043 let (name, email, message) = head_commit_parts(&git_dir);
1044 assert_eq!(name, "memstead-cli");
1045 assert_eq!(email, "memstead-cli@memstead.io");
1046 assert!(message.contains("\n\nActor: cli\nClient: memstead-cli@0.1.0"));
1047 }
1048
1049 #[test]
1050 fn sanitise_client_name_collapses_disallowed_chars() {
1051 let out = sanitise_client_name("Claude Code/2.1 @ macOS");
1052 assert_eq!(out, "claude-code-2.1---macos");
1053 assert!(
1055 out.chars()
1056 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
1057 "{out}"
1058 );
1059 }
1060
1061 #[test]
1062 fn sanitise_client_name_empty_falls_back_to_unknown() {
1063 assert_eq!(sanitise_client_name(""), "unknown");
1064 assert_eq!(sanitise_client_name(" "), "unknown");
1065 assert_eq!(sanitise_client_name("@@@"), "unknown");
1066 }
1067
1068 #[test]
1069 fn prose_and_trailers_separated_by_exactly_one_blank_line() {
1070 let ctx = CommitContext {
1074 actor: Actor::Agent,
1075 client: None,
1076 tool: Some("memstead_create"),
1077 note: None,
1078 role: Default::default(),
1079 logical_operation_id: None,
1080 entity_ids: None,
1081 };
1082 let msg = format_commit_message("subject\n", &ctx);
1083 assert_eq!(msg, "subject\n\nTool: memstead_create\nActor: agent");
1084
1085 let msg = format_commit_message("subject", &ctx);
1087 assert_eq!(msg, "subject\n\nTool: memstead_create\nActor: agent");
1088 }
1089
1090 #[test]
1091 fn trailers_are_git_interpret_trailers_compatible() {
1092 let ctx = CommitContext {
1098 actor: Actor::Agent,
1099 client: Some(ClientId {
1100 name: "claude-code".into(),
1101 version: "2.1.0".into(),
1102 }),
1103 tool: Some("memstead_update"),
1104 note: None,
1105 role: Default::default(),
1106 logical_operation_id: None,
1107 entity_ids: None,
1108 };
1109 let msg = format_commit_message("memstead: update specs--a", &ctx);
1110 let (_prose, trailer_block) = msg.rsplit_once("\n\n").expect("blank line before trailers");
1112 for line in trailer_block.lines() {
1113 let (key, value) = line
1114 .split_once(": ")
1115 .unwrap_or_else(|| panic!("malformed trailer line: {line:?}"));
1116 assert!(!key.is_empty());
1117 assert!(!value.is_empty());
1118 assert!(matches!(key, "Tool" | "Actor" | "Client"), "{key}");
1120 }
1121 assert_eq!(
1122 trailer_block,
1123 "Tool: memstead_update\nActor: agent\nClient: claude-code@2.1.0"
1124 );
1125 }
1126
1127 #[test]
1128 fn internal_context_preserves_deterministic_author() {
1129 let ctx = CommitContext::internal();
1134 assert!(matches!(ctx.actor, Actor::Unknown));
1135 assert!(ctx.client.is_none());
1136 assert!(ctx.tool.is_none());
1137 assert!(ctx.note.is_none());
1138 assert!(author_identity(&ctx).is_none());
1140 }
1141
1142 #[test]
1143 fn commit_message_with_note_inserts_body_between_prose_and_trailers() {
1144 let ctx = CommitContext {
1147 actor: Actor::Agent,
1148 client: Some(ClientId {
1149 name: "claude-code".into(),
1150 version: "2.1.0".into(),
1151 }),
1152 tool: Some("memstead_update"),
1153 note: Some("documenting the foo invariant".into()),
1154 role: Default::default(),
1155 logical_operation_id: None,
1156 entity_ids: None,
1157 };
1158 let msg = format_commit_message("memstead: update specs--a", &ctx);
1159 assert_eq!(
1160 msg,
1161 "memstead: update specs--a\n\n\
1162 documenting the foo invariant\n\n\
1163 Tool: memstead_update\nActor: agent\nClient: claude-code@2.1.0"
1164 );
1165 }
1166
1167 #[test]
1168 fn commit_message_with_blank_note_behaves_like_absent() {
1169 let ctx = CommitContext {
1172 actor: Actor::Agent,
1173 client: None,
1174 tool: Some("memstead_update"),
1175 note: Some(" \n \t ".into()),
1176 role: Default::default(),
1177 logical_operation_id: None,
1178 entity_ids: None,
1179 };
1180 let msg = format_commit_message("subject", &ctx);
1181 assert_eq!(msg, "subject\n\nTool: memstead_update\nActor: agent");
1182 }
1183
1184 #[test]
1185 fn commit_message_with_empty_note_string_behaves_like_absent() {
1186 let ctx = CommitContext {
1189 actor: Actor::Agent,
1190 client: None,
1191 tool: Some("memstead_create"),
1192 note: Some(String::new()),
1193 role: Default::default(),
1194 logical_operation_id: None,
1195 entity_ids: None,
1196 };
1197 let msg = format_commit_message("subject", &ctx);
1198 assert_eq!(msg, "subject\n\nTool: memstead_create\nActor: agent");
1199 }
1200
1201 fn unique_ref(prefix: &str) -> String {
1210 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1211 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1212 format!("refs/heads/{prefix}-{n}")
1213 }
1214
1215 #[test]
1216 fn per_branch_mutex_serialises_same_ref() {
1217 let r = unique_ref("serialises");
1218 let arc = acquire_branch_mutex(&r);
1219 let guard = arc.lock().unwrap();
1220
1221 let r_clone = r.clone();
1224 let started = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1225 let acquired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1226 let started_t = started.clone();
1227 let acquired_t = acquired.clone();
1228 let handle = std::thread::spawn(move || {
1229 started_t.store(true, std::sync::atomic::Ordering::SeqCst);
1230 let arc2 = acquire_branch_mutex(&r_clone);
1231 let _g2 = arc2.lock().unwrap();
1232 acquired_t.store(true, std::sync::atomic::Ordering::SeqCst);
1233 });
1234
1235 std::thread::sleep(std::time::Duration::from_millis(50));
1238 assert!(
1239 started.load(std::sync::atomic::Ordering::SeqCst),
1240 "spawned thread did not start within 50ms"
1241 );
1242 assert!(
1243 !acquired.load(std::sync::atomic::Ordering::SeqCst),
1244 "spawned thread acquired the mutex while main held it"
1245 );
1246
1247 drop(guard);
1249 handle.join().unwrap();
1250 assert!(
1251 acquired.load(std::sync::atomic::Ordering::SeqCst),
1252 "spawned thread did not acquire after drop"
1253 );
1254 }
1255
1256 #[test]
1257 fn per_branch_mutex_parallelises_different_refs() {
1258 let a = unique_ref("parallel-a");
1259 let b = unique_ref("parallel-b");
1260
1261 let arc_a = acquire_branch_mutex(&a);
1262 let guard_a = arc_a.lock().unwrap();
1263
1264 let b_clone = b.clone();
1266 let acquired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1267 let acquired_t = acquired.clone();
1268 let handle = std::thread::spawn(move || {
1269 let arc_b = acquire_branch_mutex(&b_clone);
1270 let _g = arc_b.lock().unwrap();
1271 acquired_t.store(true, std::sync::atomic::Ordering::SeqCst);
1272 });
1273 handle.join().unwrap();
1274 assert!(
1275 acquired.load(std::sync::atomic::Ordering::SeqCst),
1276 "different-ref acquisition was blocked by another ref's mutex"
1277 );
1278 drop(guard_a);
1279 }
1280
1281 #[test]
1282 fn cross_mem_acquires_in_lex_order() {
1283 let a = unique_ref("cross-aaa");
1288 let b = unique_ref("cross-bbb");
1289 let c = unique_ref("cross-ccc");
1290 let mut expected = vec![a.as_str(), b.as_str(), c.as_str()];
1292 expected.sort_unstable();
1293 let _guards = acquire_branch_mutexes_in_order(&[c.as_str(), a.as_str(), b.as_str()]);
1294 #[cfg(debug_assertions)]
1295 HELD_BRANCH_KEYS.with(|held| {
1296 let held = held.borrow();
1297 let tail: Vec<&str> = held.iter().rev().take(3).map(String::as_str).collect();
1303 let mut pushed: Vec<&str> = tail.into_iter().rev().collect();
1305 pushed.sort();
1306 assert_eq!(pushed, expected, "lex-order acquisition violated");
1307 });
1308 }
1309
1310 #[cfg(debug_assertions)]
1311 #[test]
1312 #[should_panic(expected = "out-of-order branch-mutex acquisition")]
1313 fn out_of_order_acquisition_panics_in_debug() {
1314 let high = unique_ref("zzz-high");
1319 let low = unique_ref("aaa-low");
1320 let arc_high = acquire_branch_mutex(&high);
1321 let _g_high = arc_high.lock().unwrap();
1322 HELD_BRANCH_KEYS.with(|held| {
1326 held.borrow_mut().push(high.clone());
1327 });
1328 let _arc_low = acquire_branch_mutex(&low); }
1330}