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    ///
129    /// # Errors
130    ///
131    /// `workdir` not being inside a git repository, and a repository whose
132    /// HEAD is unborn — there is no commit to branch a checkout from. Then any
133    /// git or filesystem step: locating the top level, `worktree add`, the
134    /// checkout, and seeding the uncommitted state. A seeding failure destroys
135    /// the checkout before returning, so a failed `create` never leaves a
136    /// half-seeded worktree for a child to work against.
137    pub fn create(workdir: &Path, agent_id: &str) -> Result<Self> {
138        anyhow::ensure!(
139            is_work_tree(workdir),
140            "worktree isolation needs a git repository, and {} is not inside one",
141            workdir.display()
142        );
143        let project_top = PathBuf::from(
144            git(workdir)
145                .args(["rev-parse", "--show-toplevel"])
146                .output()
147                .context("could not locate the repository top level")?,
148        );
149        // Canonicalize what git reported. Its answer is a real path but not
150        // necessarily *the* path: on Windows it resolves the long name where
151        // `%TEMP%` may hand out an 8.3 short one, and anywhere else it may
152        // differ from the caller's route through a symlink. Every path this
153        // type hands out is rooted here, and `pending_files` feeds both the
154        // checkpoint and the merge's write locks — which the file tools take
155        // on canonicalized paths. Two spellings of one file are two lock
156        // keys, which is no lock at all.
157        let project_top = std::fs::canonicalize(&project_top).unwrap_or(project_top);
158        // An unborn HEAD has no commit to branch a worktree from.
159        anyhow::ensure!(
160            git(&project_top)
161                .args(["rev-parse", "--verify", "--quiet", "HEAD"])
162                .success()
163                .unwrap_or(false),
164            "worktree isolation needs at least one commit; this repository has none yet"
165        );
166
167        let top = worktree_dir(&project_top, agent_id);
168        if let Some(parent) = top.parent() {
169            std::fs::create_dir_all(parent)?;
170        }
171
172        git(&project_top)
173            .args(["worktree", "add", "--detach", "--no-checkout"])
174            .arg(&top)
175            .arg("HEAD")
176            .run()
177            .context("could not create the isolated worktree")?;
178        // `--no-checkout` then `checkout` keeps the add cheap on a big repo
179        // and gives a clearer error if the checkout itself is what fails.
180        git(&top)
181            .args(["checkout", "--detach", "HEAD"])
182            .run()
183            .context("could not populate the isolated worktree")?;
184
185        let mut worktree = Self {
186            root: rebase_path(workdir, &project_top, &top)?,
187            top,
188            project_top,
189            base: String::new(),
190        };
191        if let Err(e) = worktree.seed_uncommitted() {
192            // Never leave a half-seeded checkout behind: the child would
193            // silently work against a state that matches neither HEAD nor
194            // the user's tree.
195            worktree.destroy_ignoring_errors();
196            return Err(e);
197        }
198        worktree.base = worktree.commit_state()?;
199        std::fs::create_dir_all(&worktree.root)?;
200        Ok(worktree)
201    }
202
203    /// Where the child should run.
204    #[must_use]
205    pub fn root(&self) -> &Path {
206        &self.root
207    }
208
209    /// Top level of the project this work merges back into. Callers
210    /// serializing merges key their lock on this.
211    #[must_use]
212    pub fn project_root(&self) -> &Path {
213        &self.project_top
214    }
215
216    /// The commit the child's work is currently measured against.
217    #[must_use]
218    pub fn base(&self) -> &str {
219        &self.base
220    }
221
222    /// Absolute project paths the child's pending work would touch.
223    ///
224    /// Callers checkpoint these before [`Self::merge_into_project`], so a
225    /// merged patch is as recoverable through `/restore` as any other tool's
226    /// mutation. Reading them separately (rather than out of the merge
227    /// result) is what lets the snapshot happen *before* the files change.
228    ///
229    /// Sorted and deduplicated, so a caller can feed them straight to a
230    /// multi-path lock acquisition without risking a deadlock against
231    /// another caller holding the same paths in a different order.
232    ///
233    /// # Errors
234    ///
235    /// Only computing the pending patch — a git invocation against the
236    /// checkout. A child that has changed nothing yields `Ok(vec![])`.
237    pub fn pending_files(&self) -> Result<Vec<PathBuf>> {
238        let patch = self.pending_patch()?;
239        let mut absolute: Vec<PathBuf> = patch_paths(&patch)
240            .into_iter()
241            .map(|rel| self.project_top.join(rel))
242            .collect();
243        // Re-sort after joining rather than trusting that a shared prefix
244        // preserved the relative order the parse produced.
245        absolute.sort();
246        absolute.dedup();
247        Ok(absolute)
248    }
249
250    /// Apply the child's work to the project.
251    ///
252    /// Callers must serialize this across concurrent children — two patches
253    /// applying at once reintroduce exactly the interleaving the worktree
254    /// exists to prevent.
255    ///
256    /// # Errors
257    ///
258    /// Computing the patch, running the dry-run `git apply --check`, saving a
259    /// rejected patch, and the real apply. A patch that does not apply is not
260    /// among them — that is `MergeOutcome::Conflicted`, with the project
261    /// untouched. An `Err` from the real apply is the one case where the
262    /// project may be partly changed: the dry run passed, so it should not
263    /// happen, and the message says so.
264    pub fn merge_into_project(&mut self) -> Result<MergeOutcome> {
265        let patch = self.pending_patch()?;
266        if patch.is_empty() {
267            return Ok(MergeOutcome::Empty);
268        }
269        let files = count_patch_files(&patch);
270
271        // Dry-run first. `git apply` without `--check` can apply some hunks
272        // and reject others, and a partial application of an agent's work is
273        // worse than none: the user gets a tree matching no intended state.
274        let applies = git(&self.project_top)
275            .args(["apply", "--check", "--binary", "-"])
276            .stdin_bytes(patch.clone())
277            .success()?;
278        if !applies {
279            let reason = git(&self.project_top)
280                .args(["apply", "--check", "--binary", "-"])
281                .stdin_bytes(patch.clone())
282                .output()
283                .err()
284                .map(|e| e.to_string())
285                .unwrap_or_else(|| "patch does not apply".to_string());
286            let saved = self.save_patch(&patch)?;
287            return Ok(MergeOutcome::Conflicted {
288                patch: saved,
289                reason,
290            });
291        }
292
293        git(&self.project_top)
294            .args(["apply", "--binary", "-"])
295            .stdin_bytes(patch)
296            .run()
297            .context("applying the agent's patch failed after it passed --check")?;
298
299        // Re-anchor: a continuation of this child must merge only what it
300        // does next, not replay what just landed.
301        self.base = self.commit_state()?;
302        Ok(MergeOutcome::Applied { files })
303    }
304
305    /// Remove the checkout. Best-effort: a worktree we cannot delete is
306    /// disk we can reclaim later (see [`gc_orphaned_worktrees`]), never a
307    /// reason to fail the agent whose work already merged.
308    pub fn destroy(self) {
309        self.destroy_ignoring_errors();
310    }
311
312    fn destroy_ignoring_errors(&self) {
313        remove_worktree(&self.project_top, &self.top);
314    }
315
316    /// Replay the project's uncommitted state into the fresh checkout, so
317    /// the child sees the user's work in progress and not just `HEAD`.
318    fn seed_uncommitted(&self) -> Result<()> {
319        // Tracked modifications, staged and unstaged alike. `--binary` so a
320        // changed image or fixture survives the round trip.
321        let tracked = git(&self.project_top)
322            .args(["diff", "HEAD", "--binary"])
323            .output_bytes()
324            .context("could not read the project's uncommitted changes")?;
325        if !tracked.is_empty() {
326            git(&self.top)
327                .args(["apply", "--binary", "-"])
328                .stdin_bytes(tracked)
329                .run()
330                .context("could not replay the project's uncommitted changes into the worktree")?;
331        }
332
333        // Untracked but not ignored: usually the new files the user is
334        // partway through writing, which a child would otherwise "helpfully"
335        // recreate from scratch.
336        let listing = git(&self.project_top)
337            .args(["ls-files", "--others", "--exclude-standard", "-z"])
338            .output_bytes()?;
339        for rel in listing.split(|b| *b == 0).filter(|s| !s.is_empty()) {
340            let rel = Path::new(std::str::from_utf8(rel).context("non-UTF-8 path in the repo")?);
341            // `ls-files` emits repo-relative paths, but a symlinked or
342            // otherwise surprising entry must not escape the checkout.
343            if rel.is_absolute()
344                || rel
345                    .components()
346                    .any(|c| c == std::path::Component::ParentDir)
347            {
348                continue;
349            }
350            // The parent session's own transcript is not work in progress;
351            // copying it in would only give the child a stale sibling of its
352            // own log and put it in the base commit.
353            if RUNTIME_OWNED
354                .iter()
355                .any(|owned| rel.starts_with(Path::new(owned)))
356            {
357                continue;
358            }
359            let from = self.project_top.join(rel);
360            let to = self.top.join(rel);
361            if !from.is_file() {
362                continue;
363            }
364            if let Some(parent) = to.parent() {
365                std::fs::create_dir_all(parent)?;
366            }
367            std::fs::copy(&from, &to)
368                .with_context(|| format!("could not seed untracked file {}", rel.display()))?;
369        }
370        Ok(())
371    }
372
373    /// Stage everything and commit it, returning the new commit id. Used
374    /// both to anchor the base and to re-anchor after a merge.
375    fn commit_state(&self) -> Result<String> {
376        stage_child_work(&self.top)?;
377        if !git(&self.top)
378            .args(["diff", "--cached", "--quiet"])
379            .success()?
380        {
381            git(&self.top)
382                .args(["commit", "-q", "-m", BASE_SUBJECT])
383                .run()?;
384        }
385        git(&self.top).args(["rev-parse", "HEAD"]).output()
386    }
387
388    /// The child's work as a patch against the base.
389    fn pending_patch(&self) -> Result<Vec<u8>> {
390        // Staging first is what puts new files' blobs in the object database
391        // and makes them visible to `diff`; without it a created file shows
392        // up nowhere in the patch.
393        stage_child_work(&self.top)?;
394        git(&self.top)
395            .args(["diff", "--cached", "--binary", &self.base])
396            .output_bytes()
397    }
398
399    /// Park a patch that would not apply next to the worktree it came from.
400    fn save_patch(&self, patch: &[u8]) -> Result<PathBuf> {
401        let path = self.top.with_extension("patch");
402        std::fs::write(&path, patch)
403            .with_context(|| format!("could not save the patch to {}", path.display()))?;
404        Ok(path)
405    }
406}
407
408/// Where a given agent's checkout lives. Under the data dir rather than in
409/// the project, so it stays out of the user's globs, builds, and `git
410/// status`.
411fn worktree_dir(project_top: &Path, agent_id: &str) -> PathBuf {
412    let sanitized: String = agent_id
413        .chars()
414        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
415        .collect();
416    // See `WORKTREE_SEQ`: the agent id alone is not unique across processes.
417    let unique = format!(
418        "{sanitized}-{}-{}",
419        std::process::id(),
420        WORKTREE_SEQ.fetch_add(1, Ordering::Relaxed)
421    );
422    data_dir()
423        .unwrap_or_else(|_| std::env::temp_dir().join("mermaid"))
424        .join("worktrees")
425        .join(project_hash(project_top))
426        .join(unique)
427}
428
429/// Re-root `path` from under `from_root` to under `to_root`.
430fn rebase_path(path: &Path, from_root: &Path, to_root: &Path) -> Result<PathBuf> {
431    let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
432    let from = std::fs::canonicalize(from_root).unwrap_or_else(|_| from_root.to_path_buf());
433    match canonical.strip_prefix(&from) {
434        Ok(rel) => Ok(to_root.join(rel)),
435        // Not under the top level at all: the session dir is the repo root
436        // reached by some other path. Use the worktree top as-is.
437        Err(_) => Ok(to_root.to_path_buf()),
438    }
439}
440
441/// Tear a worktree down. Tries git's own bookkeeping first so the entry in
442/// `.git/worktrees` goes with it, then falls back to deleting the directory.
443fn remove_worktree(project_top: &Path, top: &Path) {
444    let _ = git(project_top)
445        .args(["worktree", "remove", "--force"])
446        .arg(top)
447        .run();
448    if top.exists() {
449        let _ = std::fs::remove_dir_all(top);
450    }
451    let _ = git(project_top).args(["worktree", "prune"]).run();
452}
453
454/// How many files a patch touches, counted from its `diff --git` headers.
455fn count_patch_files(patch: &[u8]) -> usize {
456    patch
457        .split(|b| *b == b'\n')
458        .filter(|line| line.starts_with(b"diff --git "))
459        .count()
460}
461
462/// Repo-relative paths a patch touches, read from its `diff --git a/x b/y`
463/// headers. Takes the `b/` side so a rename reports its destination.
464///
465/// Paths containing whitespace make the header ambiguous — git quotes those
466/// (`diff --git "a/two words.txt" ...`), and a quoted header is skipped
467/// rather than mis-split. The cost is a missed checkpoint entry for such a
468/// file, never a wrong path.
469fn patch_paths(patch: &[u8]) -> Vec<PathBuf> {
470    let mut paths = Vec::new();
471    for line in patch.split(|b| *b == b'\n') {
472        let Ok(line) = std::str::from_utf8(line) else {
473            continue;
474        };
475        let Some(rest) = line.strip_prefix("diff --git ") else {
476            continue;
477        };
478        if rest.starts_with('"') {
479            continue;
480        }
481        let fields: Vec<&str> = rest.split(' ').collect();
482        // Exactly two fields means neither side was quoted or space-laden.
483        if let [_, b_side] = fields[..]
484            && let Some(rel) = b_side.strip_prefix("b/")
485            && !rel.is_empty()
486        {
487            let rel = Path::new(rel);
488            if !rel.is_absolute()
489                && !rel
490                    .components()
491                    .any(|c| c == std::path::Component::ParentDir)
492            {
493                paths.push(rel.to_path_buf());
494            }
495        }
496    }
497    paths.sort();
498    paths.dedup();
499    paths
500}
501
502/// Best-effort removal of worktree directories older than `max_age_days`.
503///
504/// A crash between `create` and `destroy` strands a checkout. Agent ids are
505/// per-session, so nothing ever reclaims one by name after a restart; this
506/// is the sweep that keeps the data dir bounded. Returns how many were
507/// removed. Never fails the caller — a directory it cannot read is skipped.
508///
509/// # Errors
510///
511/// Only resolving the data dir. An absent or unreadable `worktrees` directory
512/// is `Ok(0)`, and an entry that cannot be stat'd or removed is skipped, so
513/// the count is what was actually removed, not what was eligible.
514pub fn gc_orphaned_worktrees(max_age_days: i64) -> Result<usize> {
515    let root = data_dir()?.join("worktrees");
516    let Ok(projects) = std::fs::read_dir(&root) else {
517        return Ok(0);
518    };
519    let cutoff = std::time::SystemTime::now()
520        .checked_sub(std::time::Duration::from_secs(
521            max_age_days.max(0) as u64 * 24 * 60 * 60,
522        ))
523        .unwrap_or(std::time::UNIX_EPOCH);
524    let mut removed = 0;
525    for project in projects.flatten() {
526        let Ok(agents) = std::fs::read_dir(project.path()) else {
527            continue;
528        };
529        for agent in agents.flatten() {
530            let stale = agent
531                .metadata()
532                .and_then(|m| m.modified())
533                .is_ok_and(|m| m < cutoff);
534            if stale && std::fs::remove_dir_all(agent.path()).is_ok() {
535                removed += 1;
536            }
537        }
538        // Drop the project bucket once its last agent is gone.
539        if std::fs::read_dir(project.path()).is_ok_and(|mut d| d.next().is_none()) {
540            let _ = std::fs::remove_dir(project.path());
541        }
542    }
543    Ok(removed)
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    fn unique_dir(tag: &str) -> PathBuf {
551        let dir = std::env::temp_dir().join(format!("mermaid_wt_{tag}_{}", std::process::id()));
552        let _ = std::fs::remove_dir_all(&dir);
553        std::fs::create_dir_all(&dir).unwrap();
554        dir
555    }
556
557    /// `git worktree list --porcelain` for `project`.
558    ///
559    /// Porcelain, never the plain form, and this is the whole reason:
560    /// `git worktree list` prints `<path> <abbrev-hash> [<branch>]`, so any
561    /// substring test over its output is also testing the commit hash.
562    /// `destroy_leaves_no_checkout_and_no_git_bookkeeping` asserted
563    /// `!listed.contains("a1")` and a 7-hex-char abbreviated hash contains
564    /// "a1" about once in 43 commits — measured at 7 of 300, and 6/256 by
565    /// construction — so it failed that often against bookkeeping that had in
566    /// fact been pruned.
567    ///
568    /// It did not even self-clear on retry. `init_project` commits identical
569    /// content under an identical message and git stamps commits to the
570    /// second, so a nextest retry landing in the same second rebuilds the
571    /// *same* commit and fails identically; only a job re-run minutes later
572    /// drew a new hash. Two CI failures on 2026-08-08 looked like a race in
573    /// `destroy` and were this.
574    ///
575    /// The porcelain form puts one `worktree <path>` line per checkout and no
576    /// hash on those lines.
577    fn porcelain(project: &Path) -> String {
578        git(project)
579            .args(["worktree", "list", "--porcelain"])
580            .output()
581            .unwrap()
582    }
583
584    fn head_of(project: &Path) -> String {
585        git(project)
586            .args(["rev-parse", "HEAD"])
587            .output()
588            .expect("a seeded repo has a HEAD")
589            .trim()
590            .to_string()
591    }
592
593    /// Two seeded repos must not share a base commit.
594    ///
595    /// They did. Identical tree, identical `init` message, identical author,
596    /// and git stamps commits to the second, so anything seeded inside one
597    /// second collided — measured `db33a16` three times running. This is the
598    /// property that made #319's flake stick to a retry instead of clearing,
599    /// and nothing pinned it, so nothing would notice it coming back.
600    #[test]
601    fn two_project_repos_do_not_share_a_base_commit() {
602        let first = unique_dir("hash_first");
603        let second = unique_dir("hash_second");
604        if !init_project(&first) || !init_project(&second) {
605            return;
606        }
607        assert_ne!(
608            head_of(&first),
609            head_of(&second),
610            "seeded repos collided on a base commit; a retry inside the same \
611             second will now reproduce a hash-sensitive failure exactly"
612        );
613    }
614
615    /// The porcelain rule, enforced rather than merely written down.
616    ///
617    /// `porcelain`'s doc comment has said "never the plain form" since #319,
618    /// and a call site four hundred lines below it stayed on the plain form
619    /// anyway. That one only counted lines, so it was harmless — but a
620    /// comment is not a constraint, and the next person to tighten an
621    /// assertion into a substring match re-earns the original bug.
622    #[test]
623    fn every_worktree_list_here_is_porcelain() {
624        // Built rather than written, so this line does not match itself.
625        let call = format!("{}\"worktree\", \"{}\"", "", "list");
626        let offenders: Vec<String> = include_str!("worktree.rs")
627            .lines()
628            .enumerate()
629            .filter(|(_, l)| l.contains(&call) && !l.contains("--porcelain"))
630            .map(|(i, l)| format!("  line {}: {}", i + 1, l.trim()))
631            .collect();
632        assert!(
633            offenders.is_empty(),
634            "`git worktree list` prints `<path> <abbrev-hash> [<branch>]`, so \
635             its output is unsafe to match against. Use `porcelain()`:\n{}",
636            offenders.join("\n")
637        );
638    }
639
640    /// The checkout directory's own name — `a1-<pid>-<seq>`, see
641    /// `WORKTREE_SEQ`. Specific enough that no abbreviated hash can spell it,
642    /// which is what the bare agent id was not.
643    fn leaf_of(top: &Path) -> String {
644        top.file_name()
645            .expect("a checkout path always has a final component")
646            .to_string_lossy()
647            .into_owned()
648    }
649
650    /// Whether git still holds bookkeeping for the checkout at `top`.
651    ///
652    /// Compares the final path component rather than the whole path: git
653    /// prints its own normalization of the path (forward slashes, resolved
654    /// symlinks), which does not match a `PathBuf` byte for byte on Windows or
655    /// under a symlinked `TMPDIR`.
656    fn is_listed(project: &Path, top: &Path) -> bool {
657        let leaf = leaf_of(top);
658        porcelain(project)
659            .lines()
660            .filter_map(|line| line.strip_prefix("worktree "))
661            .any(|path| path.ends_with(&leaf))
662    }
663
664    /// A project with one commit and one tracked file. `false` when git is
665    /// missing, which no-ops every test here.
666    /// Seed a project repo whose base commit hash is its own.
667    ///
668    /// The message carries the repo's unique directory name for one reason:
669    /// without it, every test repo seeded in the same second gets the *same*
670    /// commit. Identical tree, identical message, identical author, and git
671    /// stamps commits to the second — three repos initialized back to back
672    /// measured `db33a16` all three times.
673    ///
674    /// That is what turned the `contains("a1")` bug (#319) from a 2.3% flake
675    /// into a stuck one. A nextest retry lands in the same second, rebuilds
676    /// the same commit, and fails identically, so the failure reads as a race
677    /// in `destroy` rather than as a hash that happens to spell the needle.
678    /// Whatever the next hash-sensitive assertion turns out to be, it should
679    /// get a fresh draw on retry instead of the same rigged one.
680    fn init_project(dir: &Path) -> bool {
681        if git(dir).args(["init", "-q"]).run().is_err() {
682            return false;
683        }
684        let id = dir.file_name().and_then(|n| n.to_str()).unwrap_or("repo");
685        std::fs::write(dir.join("tracked.txt"), "one\n").unwrap();
686        git(dir).args(["add", "-A"]).run().unwrap();
687        // The message, not the tree: `tracked.txt` reads "one\n" in a dozen
688        // assertions and must stay that way.
689        git(dir)
690            .args(["commit", "-qm", &format!("init {id}")])
691            .run()
692            .unwrap();
693        true
694    }
695
696    /// File content with line endings normalized. A repo on a machine with
697    /// `core.autocrlf=true` checks out CRLF on both sides of the merge,
698    /// which is correct and beside the point of every assertion here.
699    fn read(path: &Path) -> String {
700        std::fs::read_to_string(path).unwrap().replace("\r\n", "\n")
701    }
702
703    #[test]
704    fn child_starts_from_the_users_uncommitted_state_not_head() {
705        let project = unique_dir("seed");
706        if !init_project(&project) {
707            return;
708        }
709        // Uncommitted work of both kinds, which a naive `worktree add HEAD`
710        // would hide from the child.
711        std::fs::write(project.join("tracked.txt"), "one\ntwo\n").unwrap();
712        std::fs::write(project.join("untracked.txt"), "new\n").unwrap();
713
714        let wt = AgentWorktree::create(&project, "a1").unwrap();
715        assert_eq!(read(&wt.root().join("tracked.txt")), "one\ntwo\n");
716        assert_eq!(read(&wt.root().join("untracked.txt")), "new\n");
717        wt.destroy();
718    }
719
720    #[test]
721    fn ignored_files_stay_behind() {
722        let project = unique_dir("ignored");
723        if !init_project(&project) {
724            return;
725        }
726        std::fs::write(project.join(".gitignore"), "secrets.env\n").unwrap();
727        std::fs::write(project.join("secrets.env"), "TOKEN=1\n").unwrap();
728
729        let wt = AgentWorktree::create(&project, "a1").unwrap();
730        assert!(
731            !wt.root().join("secrets.env").exists(),
732            "ignored files must not be copied into a child's checkout"
733        );
734        wt.destroy();
735    }
736
737    #[test]
738    fn child_edits_do_not_touch_the_project_until_merge() {
739        let project = unique_dir("isolation");
740        if !init_project(&project) {
741            return;
742        }
743        let mut wt = AgentWorktree::create(&project, "a1").unwrap();
744        std::fs::write(wt.root().join("tracked.txt"), "rewritten\n").unwrap();
745
746        // The whole point: the user's copy is untouched while the child runs.
747        assert_eq!(read(&project.join("tracked.txt")), "one\n");
748
749        let outcome = wt.merge_into_project().unwrap();
750        assert!(
751            matches!(outcome, MergeOutcome::Applied { files: 1 }),
752            "{outcome:?}"
753        );
754        assert_eq!(read(&project.join("tracked.txt")), "rewritten\n");
755        wt.destroy();
756    }
757
758    #[test]
759    fn merge_carries_new_and_deleted_files() {
760        let project = unique_dir("addremove");
761        if !init_project(&project) {
762            return;
763        }
764        let mut wt = AgentWorktree::create(&project, "a1").unwrap();
765        std::fs::write(wt.root().join("added.txt"), "added\n").unwrap();
766        std::fs::remove_file(wt.root().join("tracked.txt")).unwrap();
767
768        assert!(matches!(
769            wt.merge_into_project().unwrap(),
770            MergeOutcome::Applied { files: 2 }
771        ));
772        assert_eq!(read(&project.join("added.txt")), "added\n");
773        assert!(!project.join("tracked.txt").exists());
774        wt.destroy();
775    }
776
777    #[test]
778    fn pending_files_names_what_a_merge_would_touch() {
779        let project = unique_dir("pending");
780        if !init_project(&project) {
781            return;
782        }
783        let wt = AgentWorktree::create(&project, "a1").unwrap();
784        assert!(
785            wt.pending_files().unwrap().is_empty(),
786            "an idle child has nothing pending"
787        );
788
789        std::fs::write(wt.root().join("tracked.txt"), "edited\n").unwrap();
790        std::fs::create_dir_all(wt.root().join("sub")).unwrap();
791        std::fs::write(wt.root().join("sub").join("added.txt"), "new\n").unwrap();
792
793        let pending = wt.pending_files().unwrap();
794        // Absolute, project-side paths — what `create_checkpoint` wants, and
795        // what the merge takes its write locks on. Anchored on the canonical
796        // root rather than the path this test built: `%TEMP%` hands out 8.3
797        // short names on Windows, and elsewhere a symlinked route spells the
798        // same directory differently. Those are the spellings that made the
799        // lock keys diverge in the first place.
800        let root = wt.project_root();
801        assert_eq!(pending.len(), 2, "{pending:?}");
802        assert!(pending.contains(&root.join("tracked.txt")), "{pending:?}");
803        assert!(
804            pending.contains(&root.join("sub").join("added.txt")),
805            "{pending:?}"
806        );
807        wt.destroy();
808    }
809
810    #[test]
811    fn pending_files_are_spelled_the_way_the_file_tools_lock_them() {
812        let project = unique_dir("canonical");
813        if !init_project(&project) {
814            return;
815        }
816        let wt = AgentWorktree::create(&project, "a1").unwrap();
817        std::fs::write(wt.root().join("tracked.txt"), "edited\n").unwrap();
818
819        // The merge takes its write locks on these paths, and the file tools
820        // take theirs on canonicalized ones. Two spellings of one file are
821        // two keys, so a merge and a concurrent `write_file` would not
822        // exclude each other at all — which is silent, and exactly the
823        // interleaving isolation exists to prevent.
824        let canonical_root = std::fs::canonicalize(&project).unwrap();
825        for path in wt.pending_files().unwrap() {
826            assert!(
827                path.starts_with(&canonical_root),
828                "{} is not under the canonical root {}",
829                path.display(),
830                canonical_root.display()
831            );
832            assert_eq!(
833                std::fs::canonicalize(&path).unwrap(),
834                path,
835                "a pending path must already be canonical"
836            );
837        }
838        wt.destroy();
839    }
840
841    #[test]
842    fn patch_paths_takes_the_destination_and_skips_quoted_headers() {
843        let patch = b"diff --git a/old.txt b/new.txt\nsimilarity index 100%\n\
844                      diff --git a/keep.txt b/keep.txt\n\
845                      diff --git \"a/two words.txt\" \"b/two words.txt\"\n";
846        // A rename reports where the content ended up, and the ambiguous
847        // quoted header is dropped rather than split into a wrong path.
848        assert_eq!(
849            patch_paths(patch),
850            vec![PathBuf::from("keep.txt"), PathBuf::from("new.txt")]
851        );
852    }
853
854    #[test]
855    fn a_child_that_changed_nothing_merges_empty() {
856        let project = unique_dir("empty");
857        if !init_project(&project) {
858            return;
859        }
860        let mut wt = AgentWorktree::create(&project, "a1").unwrap();
861        assert!(matches!(
862            wt.merge_into_project().unwrap(),
863            MergeOutcome::Empty
864        ));
865        wt.destroy();
866    }
867
868    #[test]
869    fn overlapping_edits_conflict_instead_of_clobbering() {
870        let project = unique_dir("conflict");
871        if !init_project(&project) {
872            return;
873        }
874        let mut wt = AgentWorktree::create(&project, "a1").unwrap();
875        std::fs::write(wt.root().join("tracked.txt"), "from the agent\n").unwrap();
876        // Someone else — another agent, or the user — rewrites the same file
877        // while the child is running.
878        std::fs::write(project.join("tracked.txt"), "from the user\n").unwrap();
879
880        let outcome = wt.merge_into_project().unwrap();
881        let MergeOutcome::Conflicted { patch, .. } = outcome else {
882            panic!("expected a conflict, got {outcome:?}");
883        };
884        // The competing write survives untouched and the work is recoverable.
885        assert_eq!(read(&project.join("tracked.txt")), "from the user\n");
886        assert!(patch.exists(), "the rejected patch must be saved");
887        wt.destroy();
888    }
889
890    #[test]
891    fn a_continuation_merges_only_its_new_work() {
892        let project = unique_dir("reanchor");
893        if !init_project(&project) {
894            return;
895        }
896        let mut wt = AgentWorktree::create(&project, "a1").unwrap();
897        std::fs::write(wt.root().join("tracked.txt"), "first pass\n").unwrap();
898        wt.merge_into_project().unwrap();
899
900        // Second drive of the same child, in the same checkout. Without the
901        // re-anchor the patch would replay the first pass and conflict.
902        std::fs::write(wt.root().join("tracked.txt"), "second pass\n").unwrap();
903        let outcome = wt.merge_into_project().unwrap();
904        assert!(
905            matches!(outcome, MergeOutcome::Applied { files: 1 }),
906            "{outcome:?}"
907        );
908        assert_eq!(read(&project.join("tracked.txt")), "second pass\n");
909        wt.destroy();
910    }
911
912    #[test]
913    fn a_session_in_a_subdirectory_gets_a_matching_child_root() {
914        let project = unique_dir("subdir");
915        if !init_project(&project) {
916            return;
917        }
918        let sub = project.join("crates").join("inner");
919        std::fs::create_dir_all(&sub).unwrap();
920        std::fs::write(sub.join("lib.rs"), "fn main() {}\n").unwrap();
921
922        let wt = AgentWorktree::create(&sub, "a1").unwrap();
923        assert!(
924            wt.root().ends_with(Path::new("crates").join("inner")),
925            "child root {} should mirror the session's path in the repo",
926            wt.root().display()
927        );
928        assert_eq!(read(&wt.root().join("lib.rs")), "fn main() {}\n");
929        wt.destroy();
930    }
931
932    #[test]
933    fn destroy_leaves_no_checkout_and_no_git_bookkeeping() {
934        let project = unique_dir("destroy");
935        if !init_project(&project) {
936            return;
937        }
938        let wt = AgentWorktree::create(&project, "a1").unwrap();
939        let top = wt.top.clone();
940        wt.destroy();
941        assert!(!top.exists());
942
943        assert!(
944            !is_listed(&project, &top),
945            "worktree bookkeeping should be pruned; {} still listed in:\n{}",
946            leaf_of(&top),
947            porcelain(&project)
948        );
949    }
950
951    /// The matched positive control for
952    /// `destroy_leaves_no_checkout_and_no_git_bookkeeping`. Without it, that
953    /// test would keep passing if `is_listed` were silently matching nothing —
954    /// a query that never finds anything proves nothing by not finding this.
955    #[test]
956    fn a_live_checkout_is_listed_in_the_bookkeeping() {
957        let project = unique_dir("listed");
958        if !init_project(&project) {
959            return;
960        }
961        // `expect` rather than the `unwrap` its sibling tests use: every
962        // `unwrap` is counted in `.github/baselines/clippy_pedantic.txt`, which
963        // may only shrink, and a new test should not spend budget it does not
964        // need. The message earns its place anyway — this test exists to prove
965        // the bookkeeping query finds a live checkout, so "could not create
966        // one" and "created one but did not find it" are different failures and
967        // should not both surface as `called Result::unwrap()`.
968        let wt =
969            AgentWorktree::create(&project, "a1").expect("the fixture project takes a worktree");
970        let top = wt.top.clone();
971        assert!(
972            is_listed(&project, &top),
973            "a live checkout must appear in the bookkeeping:\n{}",
974            porcelain(&project)
975        );
976        wt.destroy();
977    }
978
979    #[test]
980    fn mermaids_own_session_state_never_merges_into_the_project() {
981        let project = unique_dir("runtime_owned");
982        if !init_project(&project) {
983            return;
984        }
985        let mut wt = AgentWorktree::create(&project, "a1").unwrap();
986
987        // What the runtime writes as the child runs, alongside a real edit.
988        let conversations = wt.root().join(".mermaid").join("conversations");
989        std::fs::create_dir_all(&conversations).unwrap();
990        std::fs::write(conversations.join("20260807_1.json"), "{}\n").unwrap();
991        std::fs::write(wt.root().join("tracked.txt"), "real work\n").unwrap();
992        // A user-owned file under the same directory must still merge.
993        std::fs::create_dir_all(wt.root().join(".mermaid")).unwrap();
994        std::fs::write(wt.root().join(".mermaid").join("config.toml"), "x = 1\n").unwrap();
995
996        let pending = wt.pending_files().unwrap();
997        assert!(
998            !pending
999                .iter()
1000                .any(|p| p.to_string_lossy().contains("conversations")),
1001            "Mermaid's own transcript must not be part of the child's work: {pending:?}"
1002        );
1003
1004        wt.merge_into_project().unwrap();
1005        assert_eq!(read(&project.join("tracked.txt")), "real work\n");
1006        assert_eq!(
1007            read(&project.join(".mermaid").join("config.toml")),
1008            "x = 1\n",
1009            "the user's own .mermaid files must still merge"
1010        );
1011        assert!(
1012            !project.join(".mermaid").join("conversations").exists(),
1013            "the project must not receive Mermaid's session transcripts"
1014        );
1015        wt.destroy();
1016    }
1017
1018    #[test]
1019    fn concurrent_creates_on_one_repo_all_succeed() {
1020        let project = unique_dir("concurrent");
1021        if !init_project(&project) {
1022            return;
1023        }
1024        // The fan-out case. `git worktree add` writes the repo's
1025        // `.git/worktrees/` bookkeeping and checks out through it; without
1026        // serialization the losers die on `index.lock: File exists`.
1027        let handles: Vec<_> = (0..6)
1028            .map(|i| {
1029                let project = project.clone();
1030                std::thread::spawn(move || AgentWorktree::create(&project, &format!("a{i}")))
1031            })
1032            .collect();
1033
1034        let mut roots = Vec::new();
1035        for handle in handles {
1036            let wt = handle
1037                .join()
1038                .unwrap()
1039                .expect("every concurrent create must succeed");
1040            roots.push(wt.root().to_path_buf());
1041            wt.destroy();
1042        }
1043        roots.sort();
1044        let distinct = {
1045            let mut r = roots.clone();
1046            r.dedup();
1047            r.len()
1048        };
1049        assert_eq!(distinct, 6, "each child needs its own checkout: {roots:?}");
1050    }
1051
1052    #[test]
1053    fn creating_and_destroying_at_once_does_not_corrupt_the_repo() {
1054        let project = unique_dir("churn");
1055        if !init_project(&project) {
1056            return;
1057        }
1058        // `git worktree prune` on teardown revalidates the bookkeeping for
1059        // every worktree of the repo, so it races an `add` running at the
1060        // same time. A fan-out where one child finishes while another starts
1061        // is the ordinary case, not a corner one.
1062        let handles: Vec<_> = (0..8)
1063            .map(|i| {
1064                let project = project.clone();
1065                std::thread::spawn(move || {
1066                    let wt = AgentWorktree::create(&project, &format!("c{i}"))?;
1067                    std::fs::write(wt.root().join("tracked.txt"), format!("{i}\n"))?;
1068                    wt.destroy();
1069                    anyhow::Ok(())
1070                })
1071            })
1072            .collect();
1073        for handle in handles {
1074            handle
1075                .join()
1076                .unwrap()
1077                .expect("create/destroy churn must not fail");
1078        }
1079        // The repo is still usable and knows about no leftover worktrees.
1080        // Porcelain like everywhere else here: this assertion counts lines
1081        // rather than matching substrings, so the plain form was not itself a
1082        // bug, but leaving one call site on it keeps the trap loaded for
1083        // whoever tightens this into a substring check later.
1084        let listed = porcelain(&project);
1085        let checkouts = listed
1086            .lines()
1087            .filter(|l| l.starts_with("worktree "))
1088            .count();
1089        assert_eq!(
1090            checkouts, 1,
1091            "only the main worktree should remain: {listed}"
1092        );
1093    }
1094
1095    #[test]
1096    fn two_agents_with_the_same_id_still_get_separate_checkouts() {
1097        let project = unique_dir("sameid");
1098        if !init_project(&project) {
1099            return;
1100        }
1101        // Agent ids restart at `a1` per spawner, so two Mermaid processes in
1102        // one repo both ask for `a1`. If that resolved to one directory they
1103        // would silently share a checkout and clobber each other.
1104        let first = AgentWorktree::create(&project, "a1").unwrap();
1105        let second = AgentWorktree::create(&project, "a1").unwrap();
1106        assert_ne!(first.root(), second.root());
1107
1108        std::fs::write(first.root().join("tracked.txt"), "first\n").unwrap();
1109        assert_eq!(
1110            read(&second.root().join("tracked.txt")),
1111            "one\n",
1112            "one agent's edit must not appear in another's checkout"
1113        );
1114        first.destroy();
1115        second.destroy();
1116    }
1117
1118    #[test]
1119    fn outside_a_repository_isolation_fails_loudly() {
1120        // Silently falling back to the shared cwd would reintroduce exactly
1121        // the collisions the caller asked to avoid.
1122        let plain = unique_dir("norepo");
1123        let err = AgentWorktree::create(&plain, "a1").unwrap_err().to_string();
1124        assert!(err.contains("git repository"), "{err}");
1125    }
1126}