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
//! 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)
);
}
/// 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,
}
}
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.
/// 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")],
);
}
/// 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 fresh, never-registered [`ManagedSessionId`].
/// Test: `prune_orphaned_worktrees_reclaims_clean_pushed_worktree`.
pub(crate) fn stamp_reclaimable_sentinel(wt: &Path) {
let aged = chrono::Utc::now()
- super::worktree_ownership::OWNERLESS_GRACE
- chrono::Duration::minutes(1);
let payload = serde_json::to_vec(&super::worktree_ownership::WorktreeSentinel {
owner_session_id: ManagedSessionId::new(),
created_at: aged,
})
.expect("fixture: serialize sentinel");
std::fs::write(
wt.join(super::decommission::WORKTREE_SENTINEL_FILE),
payload,
)
.expect("fixture: write sentinel");
}
/// 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"]);
}
}