1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
//! Shared REAL-git fixtures for the #4091 dirty-worktree-guard tests.
//!
//! Why: the guard's whole value is that it reads real `git status` /
//! `git rev-list` output, so a mocked git would test nothing. Both
//! `worktree_safety_tests` (the checker in isolation) and `prune_orphan_tests`
//! (the checker wired into the reclaim path) need the same non-trivial
//! setup — a checkout with a remote, a pushed `main`, and a per-session
//! worktree carrying an aged #3649 ownership sentinel so the owner gate lets
//! the candidate through to the dirty gate — so it is built once here rather
//! than twice.
//!
//! These fixtures are built entirely inside a fresh `tempfile::TempDir`.
//! Nothing here ever touches a real worktree; deleting a live worktree is the
//! exact failure #4091 exists to prevent.
//!
//! What: [`GitWorktreeFixture`], which owns the temp dir and exposes
//! `repos_root` (the `<repos_root>/<owner>/<repo>/` layout
//! `find_orphaned_worktrees` is pointed at) plus helpers to add worktrees, park
//! them outside the project, lock them, and dirty them.
//! Test: exercised by every `#4091` test in `worktree_safety_tests` and
//! `prune_orphan_tests`.
use std::path::{Path, PathBuf};
use std::process::Command;
use super::record::ManagedSessionId;
/// A throwaway `<repos_root>/owner/repo` checkout with a bare remote (#4091).
///
/// Why: `prune_orphaned_worktrees` enumerates per managed project under
/// `<repos_root>/<owner>/<repo>/`, the `git worktree list` cross-check resolves
/// the owning repo from the candidate itself (#4207 — it used to guess the
/// candidate's GRANDPARENT), and the unpushed-commit check needs real
/// remote-tracking refs. One fixture has to satisfy all three at once.
/// What: owns the `TempDir` (dropped = everything cleaned up) and exposes the
/// repos root and the checkout path.
/// Test: used by `inspect_dirt_clean_pushed_worktree_is_none` and friends.
pub(crate) struct GitWorktreeFixture {
/// Kept alive so the temp tree outlives the test.
_tmp: tempfile::TempDir,
/// The `<repos_root>` that `prune_orphaned_worktrees` should be pointed at.
pub repos_root: PathBuf,
/// The checkout at `<repos_root>/owner/repo`.
pub repo: PathBuf,
}
/// Run `git -C <dir> <args>`, panicking with git's own stderr on failure.
///
/// Why: a fixture step that silently no-ops produces a test that passes for
/// the wrong reason — exactly the "green by deleting coverage" failure mode.
/// What: asserts the command spawned AND exited zero.
fn git_ok(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("fixture: `git {}` could not be run: {e}", args.join(" ")));
assert!(
out.status.success(),
"fixture: `git {}` failed in {}: {}",
args.join(" "),
dir.display(),
String::from_utf8_lossy(&out.stderr)
);
}
/// Run `git -C <dir> <args>` and return stdout, panicking on failure (#6507).
///
/// Why: [`git_ok`] discards stdout, and the squash fixture has to read the
/// worktree's own branch name back before it can merge it.
fn git_stdout_ok(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("fixture: `git {}` could not be run: {e}", args.join(" ")));
assert!(
out.status.success(),
"fixture: `git {}` failed in {}: {}",
args.join(" "),
dir.display(),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
/// Restores a path's permission bits when it drops, pass or panic (#4732).
///
/// Why: the "git cannot read this" tests work by `chmod 000`-ing a real `.git`
/// entry. A mode left behind survives the assertion that unwinds past it and
/// can defeat `TempDir`'s own cleanup, so the restore has to be a `Drop`, not a
/// line at the end of the test.
/// What: captures the current mode on construction and re-applies it on drop.
/// Test: `an_unreadable_git_entry_is_protected`,
/// `remove_refuses_an_unreadable_git_entry`.
pub(crate) struct RestoreMode {
path: PathBuf,
mode: u32,
}
impl Drop for RestoreMode {
fn drop(&mut self) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(self.mode));
}
}
/// `chmod 000 <path>`, restored when the returned guard drops (#4732).
pub(crate) fn deny_all(path: &Path) -> RestoreMode {
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::symlink_metadata(path)
.expect("fixture: stat before chmod")
.permissions()
.mode();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000))
.expect("fixture: chmod 000");
RestoreMode {
path: path.to_path_buf(),
mode,
}
}
/// The tmux name behind [`GitWorktreeFixture::stamp_reclaimable_sentinel`]'s
/// owner id (#7652). No store in any test registers it.
const RECLAIMABLE_OWNER_TMUX: &str = "tm-fixture-reclaimable-owner";
impl GitWorktreeFixture {
/// Build a checkout on `main`, pushed to a bare remote in the same temp dir.
///
/// Why: "clean and fully pushed" is the state the guard must still allow to
/// be reclaimed, so the happy path needs a genuine remote-tracking ref to
/// measure against — without one, every commit is legitimately unpushed and
/// the guard would (correctly, but uninterestingly) skip everything.
/// What: `git init` a bare remote and a working checkout, make one commit,
/// push it, and fetch so `refs/remotes/origin/main` exists locally.
/// Test: `inspect_dirt_clean_pushed_worktree_is_none`.
pub(crate) fn new() -> Self {
let tmp = tempfile::tempdir().expect("fixture: tempdir");
// #4207: canonicalize the root. On macOS `tempfile` hands back a path
// under `/var`, which is a symlink to `/private/var`; git records the
// RESOLVED path in its worktree registry, so a fixture that kept the
// unresolved form would compare git-reported paths against a different
// spelling of the same directory and fail for a reason unrelated to
// what any of these tests are about.
let tmp_root = std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().into());
let repos_root = tmp_root.join("repos");
let repo = repos_root.join("owner").join("repo");
let remote = tmp_root.join("remote.git");
std::fs::create_dir_all(&repo).expect("fixture: create repo dir");
std::fs::create_dir_all(&remote).expect("fixture: create remote dir");
git_ok(&remote, &["init", "--bare", "--initial-branch=main"]);
git_ok(&repo, &["init", "--initial-branch=main"]);
git_ok(&repo, &["config", "user.email", "ci@test.invalid"]);
git_ok(&repo, &["config", "user.name", "CI"]);
git_ok(&repo, &["config", "commit.gpgsign", "false"]);
std::fs::write(repo.join("README.md"), "base\n").expect("fixture: write README");
git_ok(&repo, &["add", "README.md"]);
git_ok(&repo, &["commit", "-m", "base"]);
git_ok(
&repo,
&[
"remote",
"add",
"origin",
remote.to_str().expect("utf8 remote"),
],
);
git_ok(&repo, &["push", "origin", "main"]);
git_ok(&repo, &["fetch", "origin"]);
Self {
_tmp: tmp,
repos_root,
repo,
}
}
/// Add a real per-session worktree at `<repo>/.worktrees/<name>`.
///
/// Why: `.worktrees/<name>` is where the provisioner actually parks a
/// per-session worktree, so this is the ordinary shape most tests want, and
/// `git_worktree_list_agrees` requires the path to be a genuinely
/// registered worktree — a bare `mkdir` would be rejected before the dirty
/// gate ever ran. Note the reclaim path is no longer LIMITED to this shape
/// (#4207); use [`Self::add_worktree_at`] to prove that.
/// What: `git worktree add -b session/<name>` off the current `HEAD`.
/// Returns the worktree path.
/// Test: `inspect_dirt_clean_pushed_worktree_is_none`.
pub(crate) fn add_worktree(&self, name: &str) -> PathBuf {
let wt = self.repo.join(".worktrees").join(name);
git_ok(
&self.repo,
&[
"worktree",
"add",
"-b",
&format!("session/{name}"),
wt.to_str().expect("utf8 worktree path"),
],
);
git_ok(&wt, &["config", "user.email", "ci@test.invalid"]);
git_ok(&wt, &["config", "user.name", "CI"]);
git_ok(&wt, &["config", "commit.gpgsign", "false"]);
wt
}
/// Add a real worktree at `<parent>/<name>`, anywhere on disk (#4207).
///
/// Why: [`Self::add_worktree`] parks every worktree at
/// `<repo>/.worktrees/<name>`, whose grandparent happens to be the owning
/// checkout — so it cannot distinguish git-derived ownership from the
/// grandparent inference #4207 replaces, nor prove that enumeration is
/// independent of location. This helper takes the parent directory
/// explicitly so a test can park a worktree somewhere no hard-coded shape
/// covers, or outside the repos root entirely.
/// What: `git worktree add -b wt/<name> <parent>/<name>` off the current
/// `HEAD`, creating `<parent>` if needed. Returns the worktree path.
/// Commit `file` in `wt`, then SQUASH-merge that branch to `origin/main`
/// and drop every remote ref that made the commit reachable (#6507).
///
/// Why: this is the exact end state `gh pr merge --squash --delete-branch`
/// plus `git fetch --prune` leaves behind, and it is the state the reclaim
/// path misread as "holds unsaved work". Reproducing it needs a real squash
/// — the landed commit must be a DIFFERENT SHA carrying the SAME patch —
/// so nothing here can be faked with a fast-forward.
/// What: commits `file` on the worktree's own branch, squash-merges that
/// branch onto `main` in the owning checkout, pushes `main`, and fetches.
/// The branch is never pushed, so it has no upstream and no remote-tracking
/// ref — the pruned-upstream shape.
/// Test: `inspect_dirt_clears_a_squash_merged_branch_whose_upstream_was_pruned`.
pub(crate) fn squash_merge_to_origin(&self, wt: &Path, file: &str) {
let branch = git_stdout_ok(wt, &["rev-parse", "--abbrev-ref", "HEAD"]);
std::fs::write(wt.join(file), "landed content\n").expect("fixture: write file");
git_ok(wt, &["add", file]);
git_ok(wt, &["commit", "-m", "feat: work that later squash-merges"]);
git_ok(&self.repo, &["merge", "--squash", branch.trim()]);
git_ok(&self.repo, &["commit", "-m", "feat: work (#1)"]);
git_ok(&self.repo, &["push", "origin", "main"]);
git_ok(&self.repo, &["fetch", "--prune", "origin"]);
}
/// Land `wt`'s patch on the BARE REMOTE's `main` without telling this
/// checkout (#7889).
///
/// Why: every other squash helper here merges inside `self.repo` and pushes,
/// and a push updates `refs/remotes/origin/main` as a side effect — so none
/// of them can produce the state #7889 is about, where the merge happened on
/// GitHub (under an `-r2` branch name, via `gh pr merge --squash`) and this
/// checkout has not fetched since. That stale ref is what gate 6 reads.
/// What: commits `file` in `wt` and never pushes it, then lands the SAME
/// patch as one commit through a SEPARATE clone of the bare remote. This
/// fixture's own `refs/remotes/origin/main` is untouched and stale
/// afterwards, which is the point.
/// Test: `worktree_7889_a_stale_origin_main_no_longer_spares_a_squash_merged_branch`.
pub(crate) fn land_on_the_remote_only(&self, wt: &Path, file: &str) {
let content = format!("landed content of {file}\n");
std::fs::write(wt.join(file), &content).expect("fixture: write file");
git_ok(wt, &["add", file]);
git_ok(wt, &["commit", "-m", "feat: parked work, continued on -r2"]);
let tmp_root = self
.repos_root
.parent()
.expect("fixture: repos root has a parent");
let remote = tmp_root.join("remote.git");
let scratch_name = format!("landing-{file}");
git_ok(
tmp_root,
&[
"clone",
remote.to_str().expect("utf8 remote"),
&scratch_name,
],
);
let scratch = tmp_root.join(&scratch_name);
git_ok(&scratch, &["config", "user.email", "ci@test.invalid"]);
git_ok(&scratch, &["config", "user.name", "CI"]);
git_ok(&scratch, &["config", "commit.gpgsign", "false"]);
std::fs::write(scratch.join(file), &content).expect("fixture: write landed file");
git_ok(&scratch, &["add", file]);
git_ok(
&scratch,
&["commit", "-m", "feat: squashed -r2 work (#7889)"],
);
git_ok(&scratch, &["push", "origin", "main"]);
}
/// The real donor-branch shape (#7889): `wt` commits `file`, and the remote
/// lands that content in ONE squash together with a sibling's `extra`.
///
/// Why: [`Self::land_on_the_remote_only`] lands the donor's patch alone, so
/// `git cherry` matches it and gate 6 reads the tree as clean. A real `-r2`
/// squash also carries the continuing agent's work, so no patch id matches
/// and gate 6 counts the donor's commit as unpushed. Only this shape shows
/// whether the sweep's admission can fire at all.
/// What: as [`Self::land_on_the_remote_only`], with `extra` added to the
/// landing commit. The worktree's `origin/main` is stale afterwards.
/// Test: `worktree_7889_the_sweep_admits_a_real_donor_branch`.
pub(crate) fn land_with_sibling_work(&self, wt: &Path, file: &str, extra: &str) {
self.land_on_the_remote_only(wt, file);
let scratch = self
.repos_root
.parent()
.expect("fixture: repos root has a parent")
.join(format!("landing-{file}"));
git_ok(&scratch, &["reset", "--soft", "HEAD~1"]);
std::fs::write(scratch.join(extra), "the sibling's own work\n")
.expect("fixture: write sibling file");
git_ok(&scratch, &["add", extra]);
git_ok(
&scratch,
&["commit", "-m", "feat: donor + -r2 work, squashed"],
);
git_ok(&scratch, &["push", "--force", "origin", "main"]);
}
/// Commit `files` as SEPARATE commits in `wt`, then squash-merge the branch
/// to `origin/main` and prune the refs that made them reachable (#6507).
///
/// Why: [`Self::squash_merge_to_origin`] lands ONE commit, and one commit is
/// the only case a patch-id comparison per commit can clear — the squash
/// carries that commit's patch unchanged. Every real pull request with two
/// or more commits produces a squash whose patch matches none of them
/// individually, which is the shape PR #6705 was in when
/// `prune-worktrees --merged-prs` refused its worktree on 2026-09-03.
/// What: one commit per entry in `files`, then `merge --squash` +
/// `commit` + `push` on `main` in the owning checkout, then a pruning
/// fetch. The branch is never pushed, so it has no upstream — the same
/// pruned shape, with a divergence of more than one commit.
/// Test: `inspect_dirt_clears_a_two_commit_squash_merge`,
/// `survey_reclaims_a_worktree_whose_two_commits_squash_merged_as_one`.
pub(crate) fn squash_merge_commits_to_origin(&self, wt: &Path, files: &[&str]) {
let branch = git_stdout_ok(wt, &["rev-parse", "--abbrev-ref", "HEAD"]);
for file in files {
std::fs::write(wt.join(file), format!("landed content of {file}\n"))
.expect("fixture: write file");
git_ok(wt, &["add", file]);
git_ok(wt, &["commit", "-m", &format!("feat: {file}")]);
}
git_ok(&self.repo, &["merge", "--squash", branch.trim()]);
git_ok(&self.repo, &["commit", "-m", "feat: squashed work (#6705)"]);
git_ok(&self.repo, &["push", "origin", "main"]);
git_ok(&self.repo, &["fetch", "--prune", "origin"]);
}
/// Commit `file` in `wt`, PUSH that commit under `remote_branch`, and ALSO
/// squash-merge its patch onto `origin/main` (#6507).
///
/// Why: this builds the one shape that separates a per-commit patch filter
/// from a naive count subtraction. The commit ends up reachable from a
/// remote ref, so `rev-list HEAD --not --remotes` never counts it — while
/// `git cherry origin/main HEAD` still marks it `-`, because its patch is on
/// `main` too. Subtracting the number of `-` lines from the candidate count
/// therefore discounts one commit too many, and the commit it eats is
/// whichever genuinely unpushed one the caller adds next.
/// What: commits `file`, pushes the branch to `origin/<remote_branch>`,
/// squash-merges it onto `main` in the owning checkout, pushes `main`, and
/// fetches so both remote refs exist locally. No upstream is configured, so
/// the dirty check still takes its no-upstream arm.
/// Test: `inspect_dirt_discounts_only_the_commits_the_query_counted`.
pub(crate) fn land_patch_and_keep_the_remote_branch(
&self,
wt: &Path,
file: &str,
remote_branch: &str,
) {
let branch = git_stdout_ok(wt, &["rev-parse", "--abbrev-ref", "HEAD"]);
let branch = branch.trim().to_string();
std::fs::write(wt.join(file), "landed and pushed\n").expect("fixture: write file");
git_ok(wt, &["add", file]);
git_ok(wt, &["commit", "-m", "feat: landed under two refs"]);
git_ok(
wt,
&[
"push",
"origin",
&format!("HEAD:refs/heads/{remote_branch}"),
],
);
git_ok(&self.repo, &["merge", "--squash", &branch]);
git_ok(&self.repo, &["commit", "-m", "feat: landed (#2)"]);
git_ok(&self.repo, &["push", "origin", "main"]);
git_ok(&self.repo, &["fetch", "origin"]);
}
/// Test: `enumerate_finds_worktree_at_an_unwalked_location`,
/// `registry_root_for_ignores_where_the_worktree_is_parked`.
pub(crate) fn add_worktree_at(&self, parent: &Path, name: &str) -> PathBuf {
std::fs::create_dir_all(parent).expect("fixture: create worktree parent");
let wt = parent.join(name);
git_ok(
&self.repo,
&[
"worktree",
"add",
"-b",
&format!("wt/{name}"),
wt.to_str().expect("utf8 worktree path"),
],
);
wt
}
/// Mark `wt` as git-`locked` — the operator's explicit "do not remove
/// this" (#4224 review).
///
/// Why: git itself honours the lock — `git worktree remove --force` exits
/// 128 rather than removing it. Until #4732
/// `decommission::remove_session_worktree` treated that non-zero exit as a
/// git failure and fell back to `std::fs::remove_dir_all`, deleting the
/// directory regardless; both the enumeration filter and the remover now
/// honour it. Proving either requires a really-locked worktree; setting the
/// flag on a parsed record instead would test the parser, not the gate.
/// What: `git -C <repo> worktree lock <wt>`.
/// Test: `enumerate_excludes_a_locked_worktree`,
/// `remove_session_worktree_refuses_a_git_locked_worktree`,
/// `a_locked_worktree_is_protected`.
pub(crate) fn lock_worktree(&self, wt: &Path) {
git_ok(
&self.repo,
&["worktree", "lock", wt.to_str().expect("utf8 worktree path")],
);
}
/// Lock `wt` the way the Claude Code HARNESS locks an agent's worktree
/// (#6561).
///
/// Why: the harness's agent-lifetime lock and an operator's standing veto
/// are the same `locked` flag and differ only in the reason string, so a
/// test that used [`Self::lock_worktree`] would prove nothing about the
/// distinction. Reproducing the real reason is the whole fixture — this is
/// the exact shape observed on this machine (git 2.54.0).
/// What: `git worktree lock --reason "claude agent <id> (pid … start …)"`.
/// Test: `scan_separates_a_harness_agent_lock_from_an_operator_lock`,
/// `survey_discloses_a_harness_locked_agent_worktree`.
pub(crate) fn harness_lock_worktree(&self, wt: &Path, agent_id: &str) {
// #7771: a HELD lock names a running pid with its real start time, so
// it names this test process — a fixed pid would read as stale.
let pid = std::process::id();
let start = super::worktree_owner_gate::process_start_secs(pid)
.expect("fixture: this process's start time");
self.harness_lock_worktree_with_reason(
wt,
&format!(
"claude agent {agent_id} (pid {pid} start {})",
super::worktree_owner_gate::format_lock_start(start)
),
);
}
/// An owner map proving `owner`'s dispatching session ended (#7771 d).
pub(crate) fn parent_ended(
owner: &super::worktree_ownership::AgentWorktreeOwner,
) -> super::worktree_reclaim_ownership::SessionOwners {
super::worktree_reclaim_ownership::SessionOwners::observed([(
owner.parent_session_id.0.to_string(),
super::worktree_reclaim_claim::ClaimLiveness::SessionGone,
)])
}
/// Lock `wt` with an exact `reason` (#7771).
pub(crate) fn harness_lock_worktree_with_reason(&self, wt: &Path, reason: &str) {
git_ok(
&self.repo,
&[
"worktree",
"lock",
"--reason",
reason,
wt.to_str().expect("utf8 worktree path"),
],
);
}
/// [`Self::harness_lock_worktree`] with the dispatched pid chosen (#7974).
///
/// Why: adoption's lock fallback turns on whether that pid is RUNNING, so
/// the two arms it has to separate — a dead agent's tree and a live one's —
/// differ only in this number. A fixed 4242 can be either, depending on
/// what else the machine is running, which would make both tests
/// nondeterministic.
/// What: the same reason string, with `pid` substituted.
/// Test: `adopt_worktree_route_takes_a_dead_agents_tree_after_a_daemon_restart`,
/// `adopt_worktree_route_still_refuses_a_live_owner_after_a_daemon_restart`.
pub(crate) fn harness_lock_worktree_with_pid(&self, wt: &Path, agent_id: &str, pid: u32) {
git_ok(
&self.repo,
&[
"worktree",
"lock",
"--reason",
&format!("claude agent {agent_id} (pid {pid} start Mon Sep 1 20:33:51 2026)"),
wt.to_str().expect("utf8 worktree path"),
],
);
}
/// Create a bare `.base` clone inside the checkout and register a worktree
/// in ITS registry (#4207).
///
/// Why: a managed project has two checkouts that can own a worktree
/// registry, and `enumerate_registered_worktrees` interrogates both. Without
/// a fixture for the `.base` anchor, only the project-checkout half of that
/// contract is covered.
/// What: `git clone --bare` the checkout to `<repo>/.base`, then
/// `git -C <repo>/.base worktree add` at `<repo>/.base/.worktrees/<name>`.
/// Returns the worktree path.
/// Test: `enumerate_finds_worktree_registered_to_base_clone`.
pub(crate) fn add_base_clone_worktree(&self, name: &str) -> PathBuf {
let base = self.repo.join(".base");
git_ok(
&self.repo,
&[
"clone",
"--bare",
".",
base.to_str().expect("utf8 base clone path"),
],
);
let wt = base.join(".worktrees").join(name);
git_ok(
&base,
&[
"worktree",
"add",
"-b",
&format!("session/{name}"),
wt.to_str().expect("utf8 worktree path"),
],
);
wt
}
/// Stamp `wt` with an ownership sentinel old enough to clear the #3649
/// grace window, naming an owner that will never resolve to a record.
///
/// Why: without this the #3649 gate classifies the candidate as
/// owner-unknown and skips it BEFORE the #4091 dirty gate runs, so the
/// test would pass without exercising anything. The sentinel is what makes
/// these fixtures genuinely reclaimable, which is the only condition under
/// which the dirty gate is load-bearing.
/// What: writes an aged [`super::worktree_ownership::WorktreeSentinel`] for
/// a never-registered [`ManagedSessionId`], and returns it. #7652: the id is
/// the fixed one [`Self::reclaimable_owner_gone`] reports as ended, so a
/// merged-PR reclaim test can prove the owner gone without a session store.
/// Test: `prune_orphaned_worktrees_reclaims_clean_pushed_worktree`.
pub(crate) fn stamp_reclaimable_sentinel(wt: &Path) -> ManagedSessionId {
let aged = chrono::Utc::now()
- super::worktree_ownership::OWNERLESS_GRACE
- chrono::Duration::minutes(1);
let owner = ManagedSessionId::for_adopted_tmux_name(RECLAIMABLE_OWNER_TMUX);
let payload = serde_json::to_vec(&super::worktree_ownership::WorktreeSentinel {
owner_session_id: Some(owner),
created_at: aged,
agent: None,
})
.expect("fixture: serialize sentinel");
std::fs::write(
wt.join(super::decommission::WORKTREE_SENTINEL_FILE),
payload,
)
.expect("fixture: write sentinel");
owner
}
/// An owner map in which [`Self::stamp_reclaimable_sentinel`]'s owner has
/// provably ended (#7652) — what a store read reports for a tombstoned
/// record whose tmux session tmux no longer lists.
pub(crate) fn reclaimable_owner_gone() -> super::worktree_reclaim_ownership::SessionOwners {
let owner = ManagedSessionId::for_adopted_tmux_name(RECLAIMABLE_OWNER_TMUX);
super::worktree_reclaim_ownership::SessionOwners::observed([(
owner.to_string(),
super::worktree_reclaim_claim::ClaimLiveness::SessionGone,
)])
}
/// Stamp `wt` with an AGENT ownership sentinel naming `agent_id` (#5661).
///
/// Why: the merged-PR reclaim gate keys on the agent the sentinel names, so
/// a test of that gate needs a real `#[4311]`-shaped payload on disk rather
/// than a hand-rolled JSON blob that could drift from the parser.
/// What: writes a [`super::worktree_ownership::WorktreeSentinel::for_agent`]
/// payload through the module's own serializer and returns the owner it
/// recorded, so a test can hand the same value to its liveness probe.
/// Test: `classify_blocks_a_live_agents_worktree`,
/// `classify_allows_a_finished_agents_merged_worktree`.
pub(crate) fn stamp_agent_sentinel(
wt: &Path,
agent_id: &str,
) -> super::worktree_ownership::AgentWorktreeOwner {
let owner = super::worktree_ownership::AgentWorktreeOwner {
agent_id: agent_id.to_string(),
delegation_id: crate::core::agent::DelegationId::new(),
parent_session_id: crate::core::session::SessionId::new(),
};
super::worktree_ownership::write_agent_sentinel(wt, owner.clone())
.expect("fixture: write agent sentinel");
owner
}
/// Add a nested git worktree of the SAME repo INSIDE `parent`, at
/// `<parent>/<rel>` (#4118).
///
/// Why: this is the shape that made the guard bypassable — Claude Code's
/// `isolation: "worktree"` agents create `.claude/worktrees/<name>` inside a
/// session checkout, this repo's `main` gitignores that path, and seven of
/// them were live inside `.base/.worktrees/2eb72dca-…` while the parent
/// reported `dirty_files = 0`.
/// What: `git worktree add -b agent/<name>` under `parent`. Returns the
/// nested worktree path.
/// Test: `inspect_dirt_reports_nested_gitignored_worktree`.
pub(crate) fn add_nested_worktree(&self, parent: &Path, rel: &str, name: &str) -> PathBuf {
let nested = parent.join(rel).join(name);
git_ok(
&self.repo,
&[
"worktree",
"add",
"-b",
&format!("agent/{name}"),
nested.to_str().expect("utf8 nested worktree path"),
],
);
git_ok(&nested, &["config", "user.email", "ci@test.invalid"]);
git_ok(&nested, &["config", "user.name", "CI"]);
git_ok(&nested, &["config", "commit.gpgsign", "false"]);
nested
}
/// Commit everything in `wt` and push it, so the worktree reads CLEAN.
///
/// Why: several #4118 tests need a parent worktree whose ONLY dirt is the
/// thing under test (a nested checkout, a `.trusty-mpm/` artefact). Setting
/// those up requires committing a `.gitignore` first, and an uncommitted or
/// unpushed `.gitignore` would make the parent dirty for the wrong reason —
/// the test would pass without exercising anything.
/// What: stages all, commits, pushes the current branch to `origin` under
/// its own name, and fetches so the remote-tracking ref exists locally.
/// Test: `inspect_dirt_reports_nested_gitignored_worktree`.
pub(crate) fn commit_all_and_push(wt: &Path, msg: &str) {
git_ok(wt, &["add", "-A"]);
git_ok(wt, &["commit", "-m", msg]);
git_ok(wt, &["push", "origin", "HEAD"]);
git_ok(wt, &["fetch", "origin"]);
}
/// Commit a new file inside `wt` without pushing it anywhere.
///
/// Why: this is the "committed but unpushed" case — the one that looks
/// safe (the work IS committed) but is destroyed anyway, because
/// `decommission::remove_session_worktree` runs
/// `git branch -D session/<leaf>` after a successful removal and takes the
/// commit's last reachable ref with it.
/// What: writes `unpushed.txt`, stages it, and commits.
/// Test: `inspect_dirt_reports_unpushed_commit`.
pub(crate) fn commit_unpushed(wt: &Path) {
std::fs::write(wt.join("unpushed.txt"), "local only\n").expect("fixture: write file");
git_ok(wt, &["add", "unpushed.txt"]);
git_ok(wt, &["commit", "-m", "local only"]);
}
}
/// A live `sleep` whose cwd is a directory under test, killed and reaped on
/// drop so a failed assertion leaks no process (#9444).
pub(crate) struct StandingProcess(std::process::Child);
impl StandingProcess {
/// Spawn the process standing in `dir`.
pub(crate) fn in_dir(dir: &Path) -> Self {
let child = Command::new("sleep")
.arg("120")
.current_dir(dir)
.spawn()
.expect("fixture: spawn a process standing in the directory");
Self(child)
}
/// Whether a removal's refusal `reason` names this process, or says the
/// `lsof` probe could not run, which refuses by design (ADR-0045).
pub(crate) fn named_in(&self, reason: &str) -> bool {
let unavailable = reason.contains("could not run `lsof`")
|| reason.contains("`lsof` timed out after ")
|| (reason.contains("`lsof` exited ")
&& reason.contains(" while checking for live processes:"));
unavailable || reason.contains(&format!("pid {} ", self.0.id()))
}
}
/// A live-process probe that could not answer, in the words
/// `worktree_liveness::process_holding` uses when `lsof` cannot run (#9444).
pub(crate) fn probe_cannot_answer(_: &Path) -> Option<String> {
Some("could not run `lsof` to check for live processes: injected failure".to_string())
}
impl Drop for StandingProcess {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}