use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context, Result};
use crate::checkpoint::project_hash;
use crate::data_dir;
use crate::git::{git, is_work_tree};
const BASE_SUBJECT: &str = "mermaid: subagent base";
const RUNTIME_OWNED: &[&str] = &[".mermaid/conversations"];
fn stage_child_work(top: &Path) -> Result<()> {
let mut cmd = git(top).args(["add", "-A", "--", "."]);
for path in RUNTIME_OWNED {
cmd = cmd.arg(format!(":(exclude){path}"));
}
cmd.run()
}
static WORKTREE_SEQ: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
pub struct AgentWorktree {
root: PathBuf,
top: PathBuf,
project_top: PathBuf,
base: String,
}
#[derive(Debug)]
pub enum MergeOutcome {
Empty,
Applied { files: usize },
Conflicted { patch: PathBuf, reason: String },
}
impl AgentWorktree {
pub fn create(workdir: &Path, agent_id: &str) -> Result<Self> {
anyhow::ensure!(
is_work_tree(workdir),
"worktree isolation needs a git repository, and {} is not inside one",
workdir.display()
);
let project_top = PathBuf::from(
git(workdir)
.args(["rev-parse", "--show-toplevel"])
.output()
.context("could not locate the repository top level")?,
);
let project_top = std::fs::canonicalize(&project_top).unwrap_or(project_top);
anyhow::ensure!(
git(&project_top)
.args(["rev-parse", "--verify", "--quiet", "HEAD"])
.success()
.unwrap_or(false),
"worktree isolation needs at least one commit; this repository has none yet"
);
let top = worktree_dir(&project_top, agent_id);
if let Some(parent) = top.parent() {
std::fs::create_dir_all(parent)?;
}
git(&project_top)
.args(["worktree", "add", "--detach", "--no-checkout"])
.arg(&top)
.arg("HEAD")
.run()
.context("could not create the isolated worktree")?;
git(&top)
.args(["checkout", "--detach", "HEAD"])
.run()
.context("could not populate the isolated worktree")?;
let mut worktree = Self {
root: rebase_path(workdir, &project_top, &top)?,
top,
project_top,
base: String::new(),
};
if let Err(e) = worktree.seed_uncommitted() {
worktree.destroy_ignoring_errors();
return Err(e);
}
worktree.base = worktree.commit_state()?;
std::fs::create_dir_all(&worktree.root)?;
Ok(worktree)
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn project_root(&self) -> &Path {
&self.project_top
}
pub fn base(&self) -> &str {
&self.base
}
pub fn pending_files(&self) -> Result<Vec<PathBuf>> {
let patch = self.pending_patch()?;
let mut absolute: Vec<PathBuf> = patch_paths(&patch)
.into_iter()
.map(|rel| self.project_top.join(rel))
.collect();
absolute.sort();
absolute.dedup();
Ok(absolute)
}
pub fn merge_into_project(&mut self) -> Result<MergeOutcome> {
let patch = self.pending_patch()?;
if patch.is_empty() {
return Ok(MergeOutcome::Empty);
}
let files = count_patch_files(&patch);
let applies = git(&self.project_top)
.args(["apply", "--check", "--binary", "-"])
.stdin_bytes(patch.clone())
.success()?;
if !applies {
let reason = git(&self.project_top)
.args(["apply", "--check", "--binary", "-"])
.stdin_bytes(patch.clone())
.output()
.err()
.map(|e| e.to_string())
.unwrap_or_else(|| "patch does not apply".to_string());
let saved = self.save_patch(&patch)?;
return Ok(MergeOutcome::Conflicted {
patch: saved,
reason,
});
}
git(&self.project_top)
.args(["apply", "--binary", "-"])
.stdin_bytes(patch)
.run()
.context("applying the agent's patch failed after it passed --check")?;
self.base = self.commit_state()?;
Ok(MergeOutcome::Applied { files })
}
pub fn destroy(self) {
self.destroy_ignoring_errors();
}
fn destroy_ignoring_errors(&self) {
remove_worktree(&self.project_top, &self.top);
}
fn seed_uncommitted(&self) -> Result<()> {
let tracked = git(&self.project_top)
.args(["diff", "HEAD", "--binary"])
.output_bytes()
.context("could not read the project's uncommitted changes")?;
if !tracked.is_empty() {
git(&self.top)
.args(["apply", "--binary", "-"])
.stdin_bytes(tracked)
.run()
.context("could not replay the project's uncommitted changes into the worktree")?;
}
let listing = git(&self.project_top)
.args(["ls-files", "--others", "--exclude-standard", "-z"])
.output_bytes()?;
for rel in listing.split(|b| *b == 0).filter(|s| !s.is_empty()) {
let rel = Path::new(std::str::from_utf8(rel).context("non-UTF-8 path in the repo")?);
if rel.is_absolute()
|| rel
.components()
.any(|c| c == std::path::Component::ParentDir)
{
continue;
}
if RUNTIME_OWNED
.iter()
.any(|owned| rel.starts_with(Path::new(owned)))
{
continue;
}
let from = self.project_top.join(rel);
let to = self.top.join(rel);
if !from.is_file() {
continue;
}
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&from, &to)
.with_context(|| format!("could not seed untracked file {}", rel.display()))?;
}
Ok(())
}
fn commit_state(&self) -> Result<String> {
stage_child_work(&self.top)?;
if !git(&self.top)
.args(["diff", "--cached", "--quiet"])
.success()?
{
git(&self.top)
.args(["commit", "-q", "-m", BASE_SUBJECT])
.run()?;
}
git(&self.top).args(["rev-parse", "HEAD"]).output()
}
fn pending_patch(&self) -> Result<Vec<u8>> {
stage_child_work(&self.top)?;
git(&self.top)
.args(["diff", "--cached", "--binary", &self.base])
.output_bytes()
}
fn save_patch(&self, patch: &[u8]) -> Result<PathBuf> {
let path = self.top.with_extension("patch");
std::fs::write(&path, patch)
.with_context(|| format!("could not save the patch to {}", path.display()))?;
Ok(path)
}
}
fn worktree_dir(project_top: &Path, agent_id: &str) -> PathBuf {
let sanitized: String = agent_id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect();
let unique = format!(
"{sanitized}-{}-{}",
std::process::id(),
WORKTREE_SEQ.fetch_add(1, Ordering::Relaxed)
);
data_dir()
.unwrap_or_else(|_| std::env::temp_dir().join("mermaid"))
.join("worktrees")
.join(project_hash(project_top))
.join(unique)
}
fn rebase_path(path: &Path, from_root: &Path, to_root: &Path) -> Result<PathBuf> {
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let from = std::fs::canonicalize(from_root).unwrap_or_else(|_| from_root.to_path_buf());
match canonical.strip_prefix(&from) {
Ok(rel) => Ok(to_root.join(rel)),
Err(_) => Ok(to_root.to_path_buf()),
}
}
fn remove_worktree(project_top: &Path, top: &Path) {
let _ = git(project_top)
.args(["worktree", "remove", "--force"])
.arg(top)
.run();
if top.exists() {
let _ = std::fs::remove_dir_all(top);
}
let _ = git(project_top).args(["worktree", "prune"]).run();
}
fn count_patch_files(patch: &[u8]) -> usize {
patch
.split(|b| *b == b'\n')
.filter(|line| line.starts_with(b"diff --git "))
.count()
}
fn patch_paths(patch: &[u8]) -> Vec<PathBuf> {
let mut paths = Vec::new();
for line in patch.split(|b| *b == b'\n') {
let Ok(line) = std::str::from_utf8(line) else {
continue;
};
let Some(rest) = line.strip_prefix("diff --git ") else {
continue;
};
if rest.starts_with('"') {
continue;
}
let fields: Vec<&str> = rest.split(' ').collect();
if let [_, b_side] = fields[..]
&& let Some(rel) = b_side.strip_prefix("b/")
&& !rel.is_empty()
{
let rel = Path::new(rel);
if !rel.is_absolute()
&& !rel
.components()
.any(|c| c == std::path::Component::ParentDir)
{
paths.push(rel.to_path_buf());
}
}
}
paths.sort();
paths.dedup();
paths
}
pub fn gc_orphaned_worktrees(max_age_days: i64) -> Result<usize> {
let root = data_dir()?.join("worktrees");
let Ok(projects) = std::fs::read_dir(&root) else {
return Ok(0);
};
let cutoff = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(
max_age_days.max(0) as u64 * 24 * 60 * 60,
))
.unwrap_or(std::time::UNIX_EPOCH);
let mut removed = 0;
for project in projects.flatten() {
let Ok(agents) = std::fs::read_dir(project.path()) else {
continue;
};
for agent in agents.flatten() {
let stale = agent
.metadata()
.and_then(|m| m.modified())
.is_ok_and(|m| m < cutoff);
if stale && std::fs::remove_dir_all(agent.path()).is_ok() {
removed += 1;
}
}
if std::fs::read_dir(project.path()).is_ok_and(|mut d| d.next().is_none()) {
let _ = std::fs::remove_dir(project.path());
}
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("mermaid_wt_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn init_project(dir: &Path) -> bool {
if git(dir).args(["init", "-q"]).run().is_err() {
return false;
}
std::fs::write(dir.join("tracked.txt"), "one\n").unwrap();
git(dir).args(["add", "-A"]).run().unwrap();
git(dir).args(["commit", "-qm", "init"]).run().unwrap();
true
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap().replace("\r\n", "\n")
}
#[test]
fn child_starts_from_the_users_uncommitted_state_not_head() {
let project = unique_dir("seed");
if !init_project(&project) {
return;
}
std::fs::write(project.join("tracked.txt"), "one\ntwo\n").unwrap();
std::fs::write(project.join("untracked.txt"), "new\n").unwrap();
let wt = AgentWorktree::create(&project, "a1").unwrap();
assert_eq!(read(&wt.root().join("tracked.txt")), "one\ntwo\n");
assert_eq!(read(&wt.root().join("untracked.txt")), "new\n");
wt.destroy();
}
#[test]
fn ignored_files_stay_behind() {
let project = unique_dir("ignored");
if !init_project(&project) {
return;
}
std::fs::write(project.join(".gitignore"), "secrets.env\n").unwrap();
std::fs::write(project.join("secrets.env"), "TOKEN=1\n").unwrap();
let wt = AgentWorktree::create(&project, "a1").unwrap();
assert!(
!wt.root().join("secrets.env").exists(),
"ignored files must not be copied into a child's checkout"
);
wt.destroy();
}
#[test]
fn child_edits_do_not_touch_the_project_until_merge() {
let project = unique_dir("isolation");
if !init_project(&project) {
return;
}
let mut wt = AgentWorktree::create(&project, "a1").unwrap();
std::fs::write(wt.root().join("tracked.txt"), "rewritten\n").unwrap();
assert_eq!(read(&project.join("tracked.txt")), "one\n");
let outcome = wt.merge_into_project().unwrap();
assert!(
matches!(outcome, MergeOutcome::Applied { files: 1 }),
"{outcome:?}"
);
assert_eq!(read(&project.join("tracked.txt")), "rewritten\n");
wt.destroy();
}
#[test]
fn merge_carries_new_and_deleted_files() {
let project = unique_dir("addremove");
if !init_project(&project) {
return;
}
let mut wt = AgentWorktree::create(&project, "a1").unwrap();
std::fs::write(wt.root().join("added.txt"), "added\n").unwrap();
std::fs::remove_file(wt.root().join("tracked.txt")).unwrap();
assert!(matches!(
wt.merge_into_project().unwrap(),
MergeOutcome::Applied { files: 2 }
));
assert_eq!(read(&project.join("added.txt")), "added\n");
assert!(!project.join("tracked.txt").exists());
wt.destroy();
}
#[test]
fn pending_files_names_what_a_merge_would_touch() {
let project = unique_dir("pending");
if !init_project(&project) {
return;
}
let wt = AgentWorktree::create(&project, "a1").unwrap();
assert!(
wt.pending_files().unwrap().is_empty(),
"an idle child has nothing pending"
);
std::fs::write(wt.root().join("tracked.txt"), "edited\n").unwrap();
std::fs::create_dir_all(wt.root().join("sub")).unwrap();
std::fs::write(wt.root().join("sub").join("added.txt"), "new\n").unwrap();
let pending = wt.pending_files().unwrap();
let root = wt.project_root();
assert_eq!(pending.len(), 2, "{pending:?}");
assert!(pending.contains(&root.join("tracked.txt")), "{pending:?}");
assert!(
pending.contains(&root.join("sub").join("added.txt")),
"{pending:?}"
);
wt.destroy();
}
#[test]
fn pending_files_are_spelled_the_way_the_file_tools_lock_them() {
let project = unique_dir("canonical");
if !init_project(&project) {
return;
}
let wt = AgentWorktree::create(&project, "a1").unwrap();
std::fs::write(wt.root().join("tracked.txt"), "edited\n").unwrap();
let canonical_root = std::fs::canonicalize(&project).unwrap();
for path in wt.pending_files().unwrap() {
assert!(
path.starts_with(&canonical_root),
"{} is not under the canonical root {}",
path.display(),
canonical_root.display()
);
assert_eq!(
std::fs::canonicalize(&path).unwrap(),
path,
"a pending path must already be canonical"
);
}
wt.destroy();
}
#[test]
fn patch_paths_takes_the_destination_and_skips_quoted_headers() {
let patch = b"diff --git a/old.txt b/new.txt\nsimilarity index 100%\n\
diff --git a/keep.txt b/keep.txt\n\
diff --git \"a/two words.txt\" \"b/two words.txt\"\n";
assert_eq!(
patch_paths(patch),
vec![PathBuf::from("keep.txt"), PathBuf::from("new.txt")]
);
}
#[test]
fn a_child_that_changed_nothing_merges_empty() {
let project = unique_dir("empty");
if !init_project(&project) {
return;
}
let mut wt = AgentWorktree::create(&project, "a1").unwrap();
assert!(matches!(
wt.merge_into_project().unwrap(),
MergeOutcome::Empty
));
wt.destroy();
}
#[test]
fn overlapping_edits_conflict_instead_of_clobbering() {
let project = unique_dir("conflict");
if !init_project(&project) {
return;
}
let mut wt = AgentWorktree::create(&project, "a1").unwrap();
std::fs::write(wt.root().join("tracked.txt"), "from the agent\n").unwrap();
std::fs::write(project.join("tracked.txt"), "from the user\n").unwrap();
let outcome = wt.merge_into_project().unwrap();
let MergeOutcome::Conflicted { patch, .. } = outcome else {
panic!("expected a conflict, got {outcome:?}");
};
assert_eq!(read(&project.join("tracked.txt")), "from the user\n");
assert!(patch.exists(), "the rejected patch must be saved");
wt.destroy();
}
#[test]
fn a_continuation_merges_only_its_new_work() {
let project = unique_dir("reanchor");
if !init_project(&project) {
return;
}
let mut wt = AgentWorktree::create(&project, "a1").unwrap();
std::fs::write(wt.root().join("tracked.txt"), "first pass\n").unwrap();
wt.merge_into_project().unwrap();
std::fs::write(wt.root().join("tracked.txt"), "second pass\n").unwrap();
let outcome = wt.merge_into_project().unwrap();
assert!(
matches!(outcome, MergeOutcome::Applied { files: 1 }),
"{outcome:?}"
);
assert_eq!(read(&project.join("tracked.txt")), "second pass\n");
wt.destroy();
}
#[test]
fn a_session_in_a_subdirectory_gets_a_matching_child_root() {
let project = unique_dir("subdir");
if !init_project(&project) {
return;
}
let sub = project.join("crates").join("inner");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("lib.rs"), "fn main() {}\n").unwrap();
let wt = AgentWorktree::create(&sub, "a1").unwrap();
assert!(
wt.root().ends_with(Path::new("crates").join("inner")),
"child root {} should mirror the session's path in the repo",
wt.root().display()
);
assert_eq!(read(&wt.root().join("lib.rs")), "fn main() {}\n");
wt.destroy();
}
#[test]
fn destroy_leaves_no_checkout_and_no_git_bookkeeping() {
let project = unique_dir("destroy");
if !init_project(&project) {
return;
}
let wt = AgentWorktree::create(&project, "a1").unwrap();
let top = wt.top.clone();
wt.destroy();
assert!(!top.exists());
let listed = git(&project).args(["worktree", "list"]).output().unwrap();
assert!(
!listed.contains("a1"),
"worktree bookkeeping should be pruned: {listed}"
);
}
#[test]
fn mermaids_own_session_state_never_merges_into_the_project() {
let project = unique_dir("runtime_owned");
if !init_project(&project) {
return;
}
let mut wt = AgentWorktree::create(&project, "a1").unwrap();
let conversations = wt.root().join(".mermaid").join("conversations");
std::fs::create_dir_all(&conversations).unwrap();
std::fs::write(conversations.join("20260807_1.json"), "{}\n").unwrap();
std::fs::write(wt.root().join("tracked.txt"), "real work\n").unwrap();
std::fs::create_dir_all(wt.root().join(".mermaid")).unwrap();
std::fs::write(wt.root().join(".mermaid").join("config.toml"), "x = 1\n").unwrap();
let pending = wt.pending_files().unwrap();
assert!(
!pending
.iter()
.any(|p| p.to_string_lossy().contains("conversations")),
"Mermaid's own transcript must not be part of the child's work: {pending:?}"
);
wt.merge_into_project().unwrap();
assert_eq!(read(&project.join("tracked.txt")), "real work\n");
assert_eq!(
read(&project.join(".mermaid").join("config.toml")),
"x = 1\n",
"the user's own .mermaid files must still merge"
);
assert!(
!project.join(".mermaid").join("conversations").exists(),
"the project must not receive Mermaid's session transcripts"
);
wt.destroy();
}
#[test]
fn concurrent_creates_on_one_repo_all_succeed() {
let project = unique_dir("concurrent");
if !init_project(&project) {
return;
}
let handles: Vec<_> = (0..6)
.map(|i| {
let project = project.clone();
std::thread::spawn(move || AgentWorktree::create(&project, &format!("a{i}")))
})
.collect();
let mut roots = Vec::new();
for handle in handles {
let wt = handle
.join()
.unwrap()
.expect("every concurrent create must succeed");
roots.push(wt.root().to_path_buf());
wt.destroy();
}
roots.sort();
let distinct = {
let mut r = roots.clone();
r.dedup();
r.len()
};
assert_eq!(distinct, 6, "each child needs its own checkout: {roots:?}");
}
#[test]
fn creating_and_destroying_at_once_does_not_corrupt_the_repo() {
let project = unique_dir("churn");
if !init_project(&project) {
return;
}
let handles: Vec<_> = (0..8)
.map(|i| {
let project = project.clone();
std::thread::spawn(move || {
let wt = AgentWorktree::create(&project, &format!("c{i}"))?;
std::fs::write(wt.root().join("tracked.txt"), format!("{i}\n"))?;
wt.destroy();
anyhow::Ok(())
})
})
.collect();
for handle in handles {
handle
.join()
.unwrap()
.expect("create/destroy churn must not fail");
}
let listed = git(&project).args(["worktree", "list"]).output().unwrap();
assert_eq!(
listed.lines().count(),
1,
"only the main worktree should remain: {listed}"
);
}
#[test]
fn two_agents_with_the_same_id_still_get_separate_checkouts() {
let project = unique_dir("sameid");
if !init_project(&project) {
return;
}
let first = AgentWorktree::create(&project, "a1").unwrap();
let second = AgentWorktree::create(&project, "a1").unwrap();
assert_ne!(first.root(), second.root());
std::fs::write(first.root().join("tracked.txt"), "first\n").unwrap();
assert_eq!(
read(&second.root().join("tracked.txt")),
"one\n",
"one agent's edit must not appear in another's checkout"
);
first.destroy();
second.destroy();
}
#[test]
fn outside_a_repository_isolation_fails_loudly() {
let plain = unique_dir("norepo");
let err = AgentWorktree::create(&plain, "a1").unwrap_err().to_string();
assert!(err.contains("git repository"), "{err}");
}
}