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}