Skip to main content

mermaid_runtime/
worktree.rs

1//! Isolated git worktrees for subagents.
2//!
3//! Parallel subagents that share one working copy collide. Per-path write
4//! locks (`providers::tool::path_lock`) stop two children from *losing* each
5//! other's bytes, but nothing stops child A's build from compiling child B's
6//! half-finished edit, and nothing stops two children from making changes
7//! that are individually fine and jointly incoherent.
8//!
9//! An isolated child gets its own checkout under the Mermaid data dir and
10//! never sees the user's working copy. Its result comes back as a patch,
11//! applied under a lock once the child is done — so overlapping work fails
12//! loudly at merge time instead of interleaving silently mid-run.
13//!
14//! ## Lifecycle
15//!
16//! 1. [`AgentWorktree::create`] adds a detached worktree at the project's
17//!    `HEAD`, replays the project's uncommitted state into it, and commits
18//!    that as the **base**. The child therefore starts from what the user
19//!    currently has, not from the last commit.
20//! 2. The child runs, rooted at [`AgentWorktree::root`].
21//! 3. [`AgentWorktree::merge_into_project`] diffs the worktree against the
22//!    base and applies that patch to the project. On success it re-anchors
23//!    the base, so a continuation of the same child merges only its *new*
24//!    work rather than replaying what already landed.
25//! 4. [`AgentWorktree::destroy`] removes the checkout.
26//!
27//! ## What is deliberately not carried in
28//!
29//! Ignored files. `target/`, `node_modules/`, and `.env` stay behind, which
30//! is what makes a worktree cheap to create and is also why a child that
31//! needs to build pays a cold-cache build. Untracked-but-not-ignored files
32//! *are* carried, since those are usually the new files the user is midway
33//! through writing.
34
35use 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
44/// Commit subject for the synthetic commits made inside a worktree. These
45/// live only as long as the worktree does: they are reachable from its
46/// detached HEAD and from no branch, so `git gc` collects them once the
47/// worktree is removed.
48const BASE_SUBJECT: &str = "mermaid: subagent base";
49
50/// Paths inside a checkout that belong to Mermaid, not to the child.
51///
52/// A child's session transcript is written to `<workdir>/.mermaid/
53/// conversations/` by the runtime as the child runs. In a shared workspace
54/// that lands in the project the same way it always has; in a checkout,
55/// `git add -A` would sweep it into the child's patch and merge Mermaid's own
56/// bookkeeping into the user's repository — files no agent wrote and nobody
57/// asked for.
58///
59/// Scoped deliberately narrow. The rest of `.mermaid/` is the user's:
60/// `config.toml` and `memory/` are theirs to commit, so a child asked to edit
61/// them must still be able to.
62const RUNTIME_OWNED: &[&str] = &[".mermaid/conversations"];
63
64/// `git add -A` restricted to the child's own work. Everything except
65/// [`RUNTIME_OWNED`].
66fn 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
74/// Disambiguates checkout directories beyond the agent id.
75///
76/// Agent ids are minted per spawner and restart at `a1`, so two Mermaid
77/// processes in one repo — two terminals, or a session plus a daemon task —
78/// both want `.../a1`. Git then resolves the name collision by inventing
79/// `a11`, `a12` and the two fight over each other's bookkeeping, which shows
80/// up as `index.lock: File exists` on whichever loses. Process id plus a
81/// counter makes the directory unique without giving up having the agent id
82/// in the path.
83///
84/// Concurrent `worktree add` / `remove` / `prune` on one repo need no lock of
85/// ours once the names are distinct; git serializes its own bookkeeping.
86/// `concurrent_creates_on_one_repo_all_succeed` and
87/// `creating_and_destroying_at_once_does_not_corrupt_the_repo` hold that.
88static WORKTREE_SEQ: AtomicU64 = AtomicU64::new(0);
89
90/// A subagent's private checkout.
91#[derive(Debug)]
92pub struct AgentWorktree {
93    /// Where the child works. Not the worktree top level when the parent
94    /// session was rooted in a subdirectory of the repo — see `create`.
95    root: PathBuf,
96    /// Top level of the private checkout (what `git worktree remove` takes).
97    top: PathBuf,
98    /// The project the work merges back into.
99    project_top: PathBuf,
100    /// Commit the child's changes are measured against. Advances on each
101    /// successful merge.
102    base: String,
103}
104
105/// What happened when a child's work was applied to the project.
106#[derive(Debug)]
107pub enum MergeOutcome {
108    /// The child changed nothing.
109    Empty,
110    /// Applied cleanly. `files` is how many paths the patch touched.
111    Applied { files: usize },
112    /// The patch would not apply to the project as it now stands — most
113    /// likely another agent (or the user) touched the same lines. The
114    /// project is **untouched**: the patch is saved for inspection and the
115    /// worktree is kept so the work is recoverable.
116    Conflicted { patch: PathBuf, reason: String },
117}
118
119impl AgentWorktree {
120    /// Create an isolated checkout for `agent_id`, seeded with `workdir`'s
121    /// current uncommitted state.
122    ///
123    /// `workdir` is the session's directory; it may be the repo top level or
124    /// any directory under it. The child is rooted at the matching relative
125    /// path inside the worktree, so a session running in `crates/foo` gives
126    /// its children a `crates/foo` too and relative paths in the prompt
127    /// still mean what they say.
128    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        // Canonicalize what git reported. Its answer is a real path but not
141        // necessarily *the* path: on Windows it resolves the long name where
142        // `%TEMP%` may hand out an 8.3 short one, and anywhere else it may
143        // differ from the caller's route through a symlink. Every path this
144        // type hands out is rooted here, and `pending_files` feeds both the
145        // checkpoint and the merge's write locks — which the file tools take
146        // on canonicalized paths. Two spellings of one file are two lock
147        // keys, which is no lock at all.
148        let project_top = std::fs::canonicalize(&project_top).unwrap_or(project_top);
149        // An unborn HEAD has no commit to branch a worktree from.
150        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        // `--no-checkout` then `checkout` keeps the add cheap on a big repo
170        // and gives a clearer error if the checkout itself is what fails.
171        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            // Never leave a half-seeded checkout behind: the child would
184            // silently work against a state that matches neither HEAD nor
185            // the user's tree.
186            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    /// Where the child should run.
195    pub fn root(&self) -> &Path {
196        &self.root
197    }
198
199    /// Top level of the project this work merges back into. Callers
200    /// serializing merges key their lock on this.
201    pub fn project_root(&self) -> &Path {
202        &self.project_top
203    }
204
205    /// The commit the child's work is currently measured against.
206    pub fn base(&self) -> &str {
207        &self.base
208    }
209
210    /// Absolute project paths the child's pending work would touch.
211    ///
212    /// Callers checkpoint these before [`Self::merge_into_project`], so a
213    /// merged patch is as recoverable through `/restore` as any other tool's
214    /// mutation. Reading them separately (rather than out of the merge
215    /// result) is what lets the snapshot happen *before* the files change.
216    ///
217    /// Sorted and deduplicated, so a caller can feed them straight to a
218    /// multi-path lock acquisition without risking a deadlock against
219    /// another caller holding the same paths in a different order.
220    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        // Re-sort after joining rather than trusting that a shared prefix
227        // preserved the relative order the parse produced.
228        absolute.sort();
229        absolute.dedup();
230        Ok(absolute)
231    }
232
233    /// Apply the child's work to the project.
234    ///
235    /// Callers must serialize this across concurrent children — two patches
236    /// applying at once reintroduce exactly the interleaving the worktree
237    /// exists to prevent.
238    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        // Dry-run first. `git apply` without `--check` can apply some hunks
246        // and reject others, and a partial application of an agent's work is
247        // worse than none: the user gets a tree matching no intended state.
248        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        // Re-anchor: a continuation of this child must merge only what it
274        // does next, not replay what just landed.
275        self.base = self.commit_state()?;
276        Ok(MergeOutcome::Applied { files })
277    }
278
279    /// Remove the checkout. Best-effort: a worktree we cannot delete is
280    /// disk we can reclaim later (see [`gc_orphaned_worktrees`]), never a
281    /// reason to fail the agent whose work already merged.
282    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    /// Replay the project's uncommitted state into the fresh checkout, so
291    /// the child sees the user's work in progress and not just `HEAD`.
292    fn seed_uncommitted(&self) -> Result<()> {
293        // Tracked modifications, staged and unstaged alike. `--binary` so a
294        // changed image or fixture survives the round trip.
295        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        // Untracked but not ignored: usually the new files the user is
308        // partway through writing, which a child would otherwise "helpfully"
309        // recreate from scratch.
310        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            // `ls-files` emits repo-relative paths, but a symlinked or
316            // otherwise surprising entry must not escape the checkout.
317            if rel.is_absolute()
318                || rel
319                    .components()
320                    .any(|c| c == std::path::Component::ParentDir)
321            {
322                continue;
323            }
324            // The parent session's own transcript is not work in progress;
325            // copying it in would only give the child a stale sibling of its
326            // own log and put it in the base commit.
327            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    /// Stage everything and commit it, returning the new commit id. Used
348    /// both to anchor the base and to re-anchor after a merge.
349    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    /// The child's work as a patch against the base.
363    fn pending_patch(&self) -> Result<Vec<u8>> {
364        // Staging first is what puts new files' blobs in the object database
365        // and makes them visible to `diff`; without it a created file shows
366        // up nowhere in the patch.
367        stage_child_work(&self.top)?;
368        git(&self.top)
369            .args(["diff", "--cached", "--binary", &self.base])
370            .output_bytes()
371    }
372
373    /// Park a patch that would not apply next to the worktree it came from.
374    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
382/// Where a given agent's checkout lives. Under the data dir rather than in
383/// the project, so it stays out of the user's globs, builds, and `git
384/// status`.
385fn 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    // See `WORKTREE_SEQ`: the agent id alone is not unique across processes.
391    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
403/// Re-root `path` from under `from_root` to under `to_root`.
404fn 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        // Not under the top level at all: the session dir is the repo root
410        // reached by some other path. Use the worktree top as-is.
411        Err(_) => Ok(to_root.to_path_buf()),
412    }
413}
414
415/// Tear a worktree down. Tries git's own bookkeeping first so the entry in
416/// `.git/worktrees` goes with it, then falls back to deleting the directory.
417fn 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
428/// How many files a patch touches, counted from its `diff --git` headers.
429fn 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
436/// Repo-relative paths a patch touches, read from its `diff --git a/x b/y`
437/// headers. Takes the `b/` side so a rename reports its destination.
438///
439/// Paths containing whitespace make the header ambiguous — git quotes those
440/// (`diff --git "a/two words.txt" ...`), and a quoted header is skipped
441/// rather than mis-split. The cost is a missed checkpoint entry for such a
442/// file, never a wrong path.
443fn 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        // Exactly two fields means neither side was quoted or space-laden.
457        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
476/// Best-effort removal of worktree directories older than `max_age_days`.
477///
478/// A crash between `create` and `destroy` strands a checkout. Agent ids are
479/// per-session, so nothing ever reclaims one by name after a restart; this
480/// is the sweep that keeps the data dir bounded. Returns how many were
481/// removed. Never fails the caller — a directory it cannot read is skipped.
482pub 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        // Drop the project bucket once its last agent is gone.
507        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    /// A project with one commit and one tracked file. `false` when git is
526    /// missing, which no-ops every test here.
527    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    /// File content with line endings normalized. A repo on a machine with
538    /// `core.autocrlf=true` checks out CRLF on both sides of the merge,
539    /// which is correct and beside the point of every assertion here.
540    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        // Uncommitted work of both kinds, which a naive `worktree add HEAD`
551        // would hide from the child.
552        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        // The whole point: the user's copy is untouched while the child runs.
588        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        // Absolute, project-side paths — what `create_checkpoint` wants, and
636        // what the merge takes its write locks on. Anchored on the canonical
637        // root rather than the path this test built: `%TEMP%` hands out 8.3
638        // short names on Windows, and elsewhere a symlinked route spells the
639        // same directory differently. Those are the spellings that made the
640        // lock keys diverge in the first place.
641        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        // The merge takes its write locks on these paths, and the file tools
661        // take theirs on canonicalized ones. Two spellings of one file are
662        // two keys, so a merge and a concurrent `write_file` would not
663        // exclude each other at all — which is silent, and exactly the
664        // interleaving isolation exists to prevent.
665        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        // A rename reports where the content ended up, and the ambiguous
688        // quoted header is dropped rather than split into a wrong path.
689        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        // Someone else — another agent, or the user — rewrites the same file
718        // while the child is running.
719        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        // The competing write survives untouched and the work is recoverable.
726        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        // Second drive of the same child, in the same checkout. Without the
742        // re-anchor the patch would replay the first pass and conflict.
743        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        // What the runtime writes as the child runs, alongside a real edit.
799        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        // A user-owned file under the same directory must still merge.
804        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        // The fan-out case. `git worktree add` writes the repo's
836        // `.git/worktrees/` bookkeeping and checks out through it; without
837        // serialization the losers die on `index.lock: File exists`.
838        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        // `git worktree prune` on teardown revalidates the bookkeeping for
870        // every worktree of the repo, so it races an `add` running at the
871        // same time. A fan-out where one child finishes while another starts
872        // is the ordinary case, not a corner one.
873        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        // The repo is still usable and knows about no leftover worktrees.
891        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        // Agent ids restart at `a1` per spawner, so two Mermaid processes in
906        // one repo both ask for `a1`. If that resolved to one directory they
907        // would silently share a checkout and clobber each other.
908        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        // Silently falling back to the shared cwd would reintroduce exactly
925        // the collisions the caller asked to avoid.
926        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}