1use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicU64, Ordering};
37
38use anyhow::{Context, Result};
39
40use crate::checkpoint::project_hash;
41use crate::data_dir;
42use crate::git::{git, is_work_tree};
43
44const BASE_SUBJECT: &str = "mermaid: subagent base";
49
50const RUNTIME_OWNED: &[&str] = &[".mermaid/conversations"];
63
64fn stage_child_work(top: &Path) -> Result<()> {
67 let mut cmd = git(top).args(["add", "-A", "--", "."]);
68 for path in RUNTIME_OWNED {
69 cmd = cmd.arg(format!(":(exclude){path}"));
70 }
71 cmd.run()
72}
73
74static WORKTREE_SEQ: AtomicU64 = AtomicU64::new(0);
89
90#[derive(Debug)]
92pub struct AgentWorktree {
93 root: PathBuf,
96 top: PathBuf,
98 project_top: PathBuf,
100 base: String,
103}
104
105#[derive(Debug)]
107pub enum MergeOutcome {
108 Empty,
110 Applied { files: usize },
112 Conflicted { patch: PathBuf, reason: String },
117}
118
119impl AgentWorktree {
120 pub fn create(workdir: &Path, agent_id: &str) -> Result<Self> {
129 anyhow::ensure!(
130 is_work_tree(workdir),
131 "worktree isolation needs a git repository, and {} is not inside one",
132 workdir.display()
133 );
134 let project_top = PathBuf::from(
135 git(workdir)
136 .args(["rev-parse", "--show-toplevel"])
137 .output()
138 .context("could not locate the repository top level")?,
139 );
140 let project_top = std::fs::canonicalize(&project_top).unwrap_or(project_top);
149 anyhow::ensure!(
151 git(&project_top)
152 .args(["rev-parse", "--verify", "--quiet", "HEAD"])
153 .success()
154 .unwrap_or(false),
155 "worktree isolation needs at least one commit; this repository has none yet"
156 );
157
158 let top = worktree_dir(&project_top, agent_id);
159 if let Some(parent) = top.parent() {
160 std::fs::create_dir_all(parent)?;
161 }
162
163 git(&project_top)
164 .args(["worktree", "add", "--detach", "--no-checkout"])
165 .arg(&top)
166 .arg("HEAD")
167 .run()
168 .context("could not create the isolated worktree")?;
169 git(&top)
172 .args(["checkout", "--detach", "HEAD"])
173 .run()
174 .context("could not populate the isolated worktree")?;
175
176 let mut worktree = Self {
177 root: rebase_path(workdir, &project_top, &top)?,
178 top,
179 project_top,
180 base: String::new(),
181 };
182 if let Err(e) = worktree.seed_uncommitted() {
183 worktree.destroy_ignoring_errors();
187 return Err(e);
188 }
189 worktree.base = worktree.commit_state()?;
190 std::fs::create_dir_all(&worktree.root)?;
191 Ok(worktree)
192 }
193
194 pub fn root(&self) -> &Path {
196 &self.root
197 }
198
199 pub fn project_root(&self) -> &Path {
202 &self.project_top
203 }
204
205 pub fn base(&self) -> &str {
207 &self.base
208 }
209
210 pub fn pending_files(&self) -> Result<Vec<PathBuf>> {
221 let patch = self.pending_patch()?;
222 let mut absolute: Vec<PathBuf> = patch_paths(&patch)
223 .into_iter()
224 .map(|rel| self.project_top.join(rel))
225 .collect();
226 absolute.sort();
229 absolute.dedup();
230 Ok(absolute)
231 }
232
233 pub fn merge_into_project(&mut self) -> Result<MergeOutcome> {
239 let patch = self.pending_patch()?;
240 if patch.is_empty() {
241 return Ok(MergeOutcome::Empty);
242 }
243 let files = count_patch_files(&patch);
244
245 let applies = git(&self.project_top)
249 .args(["apply", "--check", "--binary", "-"])
250 .stdin_bytes(patch.clone())
251 .success()?;
252 if !applies {
253 let reason = git(&self.project_top)
254 .args(["apply", "--check", "--binary", "-"])
255 .stdin_bytes(patch.clone())
256 .output()
257 .err()
258 .map(|e| e.to_string())
259 .unwrap_or_else(|| "patch does not apply".to_string());
260 let saved = self.save_patch(&patch)?;
261 return Ok(MergeOutcome::Conflicted {
262 patch: saved,
263 reason,
264 });
265 }
266
267 git(&self.project_top)
268 .args(["apply", "--binary", "-"])
269 .stdin_bytes(patch)
270 .run()
271 .context("applying the agent's patch failed after it passed --check")?;
272
273 self.base = self.commit_state()?;
276 Ok(MergeOutcome::Applied { files })
277 }
278
279 pub fn destroy(self) {
283 self.destroy_ignoring_errors();
284 }
285
286 fn destroy_ignoring_errors(&self) {
287 remove_worktree(&self.project_top, &self.top);
288 }
289
290 fn seed_uncommitted(&self) -> Result<()> {
293 let tracked = git(&self.project_top)
296 .args(["diff", "HEAD", "--binary"])
297 .output_bytes()
298 .context("could not read the project's uncommitted changes")?;
299 if !tracked.is_empty() {
300 git(&self.top)
301 .args(["apply", "--binary", "-"])
302 .stdin_bytes(tracked)
303 .run()
304 .context("could not replay the project's uncommitted changes into the worktree")?;
305 }
306
307 let listing = git(&self.project_top)
311 .args(["ls-files", "--others", "--exclude-standard", "-z"])
312 .output_bytes()?;
313 for rel in listing.split(|b| *b == 0).filter(|s| !s.is_empty()) {
314 let rel = Path::new(std::str::from_utf8(rel).context("non-UTF-8 path in the repo")?);
315 if rel.is_absolute()
318 || rel
319 .components()
320 .any(|c| c == std::path::Component::ParentDir)
321 {
322 continue;
323 }
324 if RUNTIME_OWNED
328 .iter()
329 .any(|owned| rel.starts_with(Path::new(owned)))
330 {
331 continue;
332 }
333 let from = self.project_top.join(rel);
334 let to = self.top.join(rel);
335 if !from.is_file() {
336 continue;
337 }
338 if let Some(parent) = to.parent() {
339 std::fs::create_dir_all(parent)?;
340 }
341 std::fs::copy(&from, &to)
342 .with_context(|| format!("could not seed untracked file {}", rel.display()))?;
343 }
344 Ok(())
345 }
346
347 fn commit_state(&self) -> Result<String> {
350 stage_child_work(&self.top)?;
351 if !git(&self.top)
352 .args(["diff", "--cached", "--quiet"])
353 .success()?
354 {
355 git(&self.top)
356 .args(["commit", "-q", "-m", BASE_SUBJECT])
357 .run()?;
358 }
359 git(&self.top).args(["rev-parse", "HEAD"]).output()
360 }
361
362 fn pending_patch(&self) -> Result<Vec<u8>> {
364 stage_child_work(&self.top)?;
368 git(&self.top)
369 .args(["diff", "--cached", "--binary", &self.base])
370 .output_bytes()
371 }
372
373 fn save_patch(&self, patch: &[u8]) -> Result<PathBuf> {
375 let path = self.top.with_extension("patch");
376 std::fs::write(&path, patch)
377 .with_context(|| format!("could not save the patch to {}", path.display()))?;
378 Ok(path)
379 }
380}
381
382fn worktree_dir(project_top: &Path, agent_id: &str) -> PathBuf {
386 let sanitized: String = agent_id
387 .chars()
388 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
389 .collect();
390 let unique = format!(
392 "{sanitized}-{}-{}",
393 std::process::id(),
394 WORKTREE_SEQ.fetch_add(1, Ordering::Relaxed)
395 );
396 data_dir()
397 .unwrap_or_else(|_| std::env::temp_dir().join("mermaid"))
398 .join("worktrees")
399 .join(project_hash(project_top))
400 .join(unique)
401}
402
403fn rebase_path(path: &Path, from_root: &Path, to_root: &Path) -> Result<PathBuf> {
405 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
406 let from = std::fs::canonicalize(from_root).unwrap_or_else(|_| from_root.to_path_buf());
407 match canonical.strip_prefix(&from) {
408 Ok(rel) => Ok(to_root.join(rel)),
409 Err(_) => Ok(to_root.to_path_buf()),
412 }
413}
414
415fn remove_worktree(project_top: &Path, top: &Path) {
418 let _ = git(project_top)
419 .args(["worktree", "remove", "--force"])
420 .arg(top)
421 .run();
422 if top.exists() {
423 let _ = std::fs::remove_dir_all(top);
424 }
425 let _ = git(project_top).args(["worktree", "prune"]).run();
426}
427
428fn count_patch_files(patch: &[u8]) -> usize {
430 patch
431 .split(|b| *b == b'\n')
432 .filter(|line| line.starts_with(b"diff --git "))
433 .count()
434}
435
436fn patch_paths(patch: &[u8]) -> Vec<PathBuf> {
444 let mut paths = Vec::new();
445 for line in patch.split(|b| *b == b'\n') {
446 let Ok(line) = std::str::from_utf8(line) else {
447 continue;
448 };
449 let Some(rest) = line.strip_prefix("diff --git ") else {
450 continue;
451 };
452 if rest.starts_with('"') {
453 continue;
454 }
455 let fields: Vec<&str> = rest.split(' ').collect();
456 if let [_, b_side] = fields[..]
458 && let Some(rel) = b_side.strip_prefix("b/")
459 && !rel.is_empty()
460 {
461 let rel = Path::new(rel);
462 if !rel.is_absolute()
463 && !rel
464 .components()
465 .any(|c| c == std::path::Component::ParentDir)
466 {
467 paths.push(rel.to_path_buf());
468 }
469 }
470 }
471 paths.sort();
472 paths.dedup();
473 paths
474}
475
476pub fn gc_orphaned_worktrees(max_age_days: i64) -> Result<usize> {
483 let root = data_dir()?.join("worktrees");
484 let Ok(projects) = std::fs::read_dir(&root) else {
485 return Ok(0);
486 };
487 let cutoff = std::time::SystemTime::now()
488 .checked_sub(std::time::Duration::from_secs(
489 max_age_days.max(0) as u64 * 24 * 60 * 60,
490 ))
491 .unwrap_or(std::time::UNIX_EPOCH);
492 let mut removed = 0;
493 for project in projects.flatten() {
494 let Ok(agents) = std::fs::read_dir(project.path()) else {
495 continue;
496 };
497 for agent in agents.flatten() {
498 let stale = agent
499 .metadata()
500 .and_then(|m| m.modified())
501 .is_ok_and(|m| m < cutoff);
502 if stale && std::fs::remove_dir_all(agent.path()).is_ok() {
503 removed += 1;
504 }
505 }
506 if std::fs::read_dir(project.path()).is_ok_and(|mut d| d.next().is_none()) {
508 let _ = std::fs::remove_dir(project.path());
509 }
510 }
511 Ok(removed)
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 fn unique_dir(tag: &str) -> PathBuf {
519 let dir = std::env::temp_dir().join(format!("mermaid_wt_{tag}_{}", std::process::id()));
520 let _ = std::fs::remove_dir_all(&dir);
521 std::fs::create_dir_all(&dir).unwrap();
522 dir
523 }
524
525 fn init_project(dir: &Path) -> bool {
528 if git(dir).args(["init", "-q"]).run().is_err() {
529 return false;
530 }
531 std::fs::write(dir.join("tracked.txt"), "one\n").unwrap();
532 git(dir).args(["add", "-A"]).run().unwrap();
533 git(dir).args(["commit", "-qm", "init"]).run().unwrap();
534 true
535 }
536
537 fn read(path: &Path) -> String {
541 std::fs::read_to_string(path).unwrap().replace("\r\n", "\n")
542 }
543
544 #[test]
545 fn child_starts_from_the_users_uncommitted_state_not_head() {
546 let project = unique_dir("seed");
547 if !init_project(&project) {
548 return;
549 }
550 std::fs::write(project.join("tracked.txt"), "one\ntwo\n").unwrap();
553 std::fs::write(project.join("untracked.txt"), "new\n").unwrap();
554
555 let wt = AgentWorktree::create(&project, "a1").unwrap();
556 assert_eq!(read(&wt.root().join("tracked.txt")), "one\ntwo\n");
557 assert_eq!(read(&wt.root().join("untracked.txt")), "new\n");
558 wt.destroy();
559 }
560
561 #[test]
562 fn ignored_files_stay_behind() {
563 let project = unique_dir("ignored");
564 if !init_project(&project) {
565 return;
566 }
567 std::fs::write(project.join(".gitignore"), "secrets.env\n").unwrap();
568 std::fs::write(project.join("secrets.env"), "TOKEN=1\n").unwrap();
569
570 let wt = AgentWorktree::create(&project, "a1").unwrap();
571 assert!(
572 !wt.root().join("secrets.env").exists(),
573 "ignored files must not be copied into a child's checkout"
574 );
575 wt.destroy();
576 }
577
578 #[test]
579 fn child_edits_do_not_touch_the_project_until_merge() {
580 let project = unique_dir("isolation");
581 if !init_project(&project) {
582 return;
583 }
584 let mut wt = AgentWorktree::create(&project, "a1").unwrap();
585 std::fs::write(wt.root().join("tracked.txt"), "rewritten\n").unwrap();
586
587 assert_eq!(read(&project.join("tracked.txt")), "one\n");
589
590 let outcome = wt.merge_into_project().unwrap();
591 assert!(
592 matches!(outcome, MergeOutcome::Applied { files: 1 }),
593 "{outcome:?}"
594 );
595 assert_eq!(read(&project.join("tracked.txt")), "rewritten\n");
596 wt.destroy();
597 }
598
599 #[test]
600 fn merge_carries_new_and_deleted_files() {
601 let project = unique_dir("addremove");
602 if !init_project(&project) {
603 return;
604 }
605 let mut wt = AgentWorktree::create(&project, "a1").unwrap();
606 std::fs::write(wt.root().join("added.txt"), "added\n").unwrap();
607 std::fs::remove_file(wt.root().join("tracked.txt")).unwrap();
608
609 assert!(matches!(
610 wt.merge_into_project().unwrap(),
611 MergeOutcome::Applied { files: 2 }
612 ));
613 assert_eq!(read(&project.join("added.txt")), "added\n");
614 assert!(!project.join("tracked.txt").exists());
615 wt.destroy();
616 }
617
618 #[test]
619 fn pending_files_names_what_a_merge_would_touch() {
620 let project = unique_dir("pending");
621 if !init_project(&project) {
622 return;
623 }
624 let wt = AgentWorktree::create(&project, "a1").unwrap();
625 assert!(
626 wt.pending_files().unwrap().is_empty(),
627 "an idle child has nothing pending"
628 );
629
630 std::fs::write(wt.root().join("tracked.txt"), "edited\n").unwrap();
631 std::fs::create_dir_all(wt.root().join("sub")).unwrap();
632 std::fs::write(wt.root().join("sub").join("added.txt"), "new\n").unwrap();
633
634 let pending = wt.pending_files().unwrap();
635 let root = wt.project_root();
642 assert_eq!(pending.len(), 2, "{pending:?}");
643 assert!(pending.contains(&root.join("tracked.txt")), "{pending:?}");
644 assert!(
645 pending.contains(&root.join("sub").join("added.txt")),
646 "{pending:?}"
647 );
648 wt.destroy();
649 }
650
651 #[test]
652 fn pending_files_are_spelled_the_way_the_file_tools_lock_them() {
653 let project = unique_dir("canonical");
654 if !init_project(&project) {
655 return;
656 }
657 let wt = AgentWorktree::create(&project, "a1").unwrap();
658 std::fs::write(wt.root().join("tracked.txt"), "edited\n").unwrap();
659
660 let canonical_root = std::fs::canonicalize(&project).unwrap();
666 for path in wt.pending_files().unwrap() {
667 assert!(
668 path.starts_with(&canonical_root),
669 "{} is not under the canonical root {}",
670 path.display(),
671 canonical_root.display()
672 );
673 assert_eq!(
674 std::fs::canonicalize(&path).unwrap(),
675 path,
676 "a pending path must already be canonical"
677 );
678 }
679 wt.destroy();
680 }
681
682 #[test]
683 fn patch_paths_takes_the_destination_and_skips_quoted_headers() {
684 let patch = b"diff --git a/old.txt b/new.txt\nsimilarity index 100%\n\
685 diff --git a/keep.txt b/keep.txt\n\
686 diff --git \"a/two words.txt\" \"b/two words.txt\"\n";
687 assert_eq!(
690 patch_paths(patch),
691 vec![PathBuf::from("keep.txt"), PathBuf::from("new.txt")]
692 );
693 }
694
695 #[test]
696 fn a_child_that_changed_nothing_merges_empty() {
697 let project = unique_dir("empty");
698 if !init_project(&project) {
699 return;
700 }
701 let mut wt = AgentWorktree::create(&project, "a1").unwrap();
702 assert!(matches!(
703 wt.merge_into_project().unwrap(),
704 MergeOutcome::Empty
705 ));
706 wt.destroy();
707 }
708
709 #[test]
710 fn overlapping_edits_conflict_instead_of_clobbering() {
711 let project = unique_dir("conflict");
712 if !init_project(&project) {
713 return;
714 }
715 let mut wt = AgentWorktree::create(&project, "a1").unwrap();
716 std::fs::write(wt.root().join("tracked.txt"), "from the agent\n").unwrap();
717 std::fs::write(project.join("tracked.txt"), "from the user\n").unwrap();
720
721 let outcome = wt.merge_into_project().unwrap();
722 let MergeOutcome::Conflicted { patch, .. } = outcome else {
723 panic!("expected a conflict, got {outcome:?}");
724 };
725 assert_eq!(read(&project.join("tracked.txt")), "from the user\n");
727 assert!(patch.exists(), "the rejected patch must be saved");
728 wt.destroy();
729 }
730
731 #[test]
732 fn a_continuation_merges_only_its_new_work() {
733 let project = unique_dir("reanchor");
734 if !init_project(&project) {
735 return;
736 }
737 let mut wt = AgentWorktree::create(&project, "a1").unwrap();
738 std::fs::write(wt.root().join("tracked.txt"), "first pass\n").unwrap();
739 wt.merge_into_project().unwrap();
740
741 std::fs::write(wt.root().join("tracked.txt"), "second pass\n").unwrap();
744 let outcome = wt.merge_into_project().unwrap();
745 assert!(
746 matches!(outcome, MergeOutcome::Applied { files: 1 }),
747 "{outcome:?}"
748 );
749 assert_eq!(read(&project.join("tracked.txt")), "second pass\n");
750 wt.destroy();
751 }
752
753 #[test]
754 fn a_session_in_a_subdirectory_gets_a_matching_child_root() {
755 let project = unique_dir("subdir");
756 if !init_project(&project) {
757 return;
758 }
759 let sub = project.join("crates").join("inner");
760 std::fs::create_dir_all(&sub).unwrap();
761 std::fs::write(sub.join("lib.rs"), "fn main() {}\n").unwrap();
762
763 let wt = AgentWorktree::create(&sub, "a1").unwrap();
764 assert!(
765 wt.root().ends_with(Path::new("crates").join("inner")),
766 "child root {} should mirror the session's path in the repo",
767 wt.root().display()
768 );
769 assert_eq!(read(&wt.root().join("lib.rs")), "fn main() {}\n");
770 wt.destroy();
771 }
772
773 #[test]
774 fn destroy_leaves_no_checkout_and_no_git_bookkeeping() {
775 let project = unique_dir("destroy");
776 if !init_project(&project) {
777 return;
778 }
779 let wt = AgentWorktree::create(&project, "a1").unwrap();
780 let top = wt.top.clone();
781 wt.destroy();
782 assert!(!top.exists());
783 let listed = git(&project).args(["worktree", "list"]).output().unwrap();
784 assert!(
785 !listed.contains("a1"),
786 "worktree bookkeeping should be pruned: {listed}"
787 );
788 }
789
790 #[test]
791 fn mermaids_own_session_state_never_merges_into_the_project() {
792 let project = unique_dir("runtime_owned");
793 if !init_project(&project) {
794 return;
795 }
796 let mut wt = AgentWorktree::create(&project, "a1").unwrap();
797
798 let conversations = wt.root().join(".mermaid").join("conversations");
800 std::fs::create_dir_all(&conversations).unwrap();
801 std::fs::write(conversations.join("20260807_1.json"), "{}\n").unwrap();
802 std::fs::write(wt.root().join("tracked.txt"), "real work\n").unwrap();
803 std::fs::create_dir_all(wt.root().join(".mermaid")).unwrap();
805 std::fs::write(wt.root().join(".mermaid").join("config.toml"), "x = 1\n").unwrap();
806
807 let pending = wt.pending_files().unwrap();
808 assert!(
809 !pending
810 .iter()
811 .any(|p| p.to_string_lossy().contains("conversations")),
812 "Mermaid's own transcript must not be part of the child's work: {pending:?}"
813 );
814
815 wt.merge_into_project().unwrap();
816 assert_eq!(read(&project.join("tracked.txt")), "real work\n");
817 assert_eq!(
818 read(&project.join(".mermaid").join("config.toml")),
819 "x = 1\n",
820 "the user's own .mermaid files must still merge"
821 );
822 assert!(
823 !project.join(".mermaid").join("conversations").exists(),
824 "the project must not receive Mermaid's session transcripts"
825 );
826 wt.destroy();
827 }
828
829 #[test]
830 fn concurrent_creates_on_one_repo_all_succeed() {
831 let project = unique_dir("concurrent");
832 if !init_project(&project) {
833 return;
834 }
835 let handles: Vec<_> = (0..6)
839 .map(|i| {
840 let project = project.clone();
841 std::thread::spawn(move || AgentWorktree::create(&project, &format!("a{i}")))
842 })
843 .collect();
844
845 let mut roots = Vec::new();
846 for handle in handles {
847 let wt = handle
848 .join()
849 .unwrap()
850 .expect("every concurrent create must succeed");
851 roots.push(wt.root().to_path_buf());
852 wt.destroy();
853 }
854 roots.sort();
855 let distinct = {
856 let mut r = roots.clone();
857 r.dedup();
858 r.len()
859 };
860 assert_eq!(distinct, 6, "each child needs its own checkout: {roots:?}");
861 }
862
863 #[test]
864 fn creating_and_destroying_at_once_does_not_corrupt_the_repo() {
865 let project = unique_dir("churn");
866 if !init_project(&project) {
867 return;
868 }
869 let handles: Vec<_> = (0..8)
874 .map(|i| {
875 let project = project.clone();
876 std::thread::spawn(move || {
877 let wt = AgentWorktree::create(&project, &format!("c{i}"))?;
878 std::fs::write(wt.root().join("tracked.txt"), format!("{i}\n"))?;
879 wt.destroy();
880 anyhow::Ok(())
881 })
882 })
883 .collect();
884 for handle in handles {
885 handle
886 .join()
887 .unwrap()
888 .expect("create/destroy churn must not fail");
889 }
890 let listed = git(&project).args(["worktree", "list"]).output().unwrap();
892 assert_eq!(
893 listed.lines().count(),
894 1,
895 "only the main worktree should remain: {listed}"
896 );
897 }
898
899 #[test]
900 fn two_agents_with_the_same_id_still_get_separate_checkouts() {
901 let project = unique_dir("sameid");
902 if !init_project(&project) {
903 return;
904 }
905 let first = AgentWorktree::create(&project, "a1").unwrap();
909 let second = AgentWorktree::create(&project, "a1").unwrap();
910 assert_ne!(first.root(), second.root());
911
912 std::fs::write(first.root().join("tracked.txt"), "first\n").unwrap();
913 assert_eq!(
914 read(&second.root().join("tracked.txt")),
915 "one\n",
916 "one agent's edit must not appear in another's checkout"
917 );
918 first.destroy();
919 second.destroy();
920 }
921
922 #[test]
923 fn outside_a_repository_isolation_fails_loudly() {
924 let plain = unique_dir("norepo");
927 let err = AgentWorktree::create(&plain, "a1").unwrap_err().to_string();
928 assert!(err.contains("git repository"), "{err}");
929 }
930}