doctrine 0.15.2

Project tooling CLI
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
#![expect(unused, reason = "extraction; PHASE-03 prunes")]
// SPDX-License-Identifier: GPL-3.0-only
//! gc machine — extracted from worktree/mod.rs (SL-116 PHASE-02).

use super::allowlist::{
    Allowlist, allowlist_violations, is_withheld, parse_allowlist, select_copies,
};
use super::marker::{DISPATCH_WORKER_AGENT_TYPE, marker_present, write_marker};
use super::shared::{
    gather_fork_worktree, gather_tree_clean, is_linked_worktree, matches, resolve_commit,
    resolve_common_dir,
};
use crate::fsutil::{self, CopyOutcome};
use crate::git;
use crate::root;
use anyhow::{Context, bail};
use std::fs;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};

/// The gathered, impure-read state of a `<fork>` the gc classifier reasons over
/// (design §8.2). Every field is a FACT gathered in the shell — the pure
/// [`classify_gc`] never reads git/disk/env.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GcState {
    /// `<fork>` branch resolves to a commit (the branch exists).
    pub(crate) branch_exists: bool,
    /// `<fork>` has a live linked worktree checked out.
    pub(crate) worktree_present: bool,
    /// The landed-oracle verdict, computed in the shell ONLY while the branch
    /// lives (`None` when the branch is gone — the gate is skipped because the
    /// deletion of a fork branch IS the landing certificate, design §8.2).
    pub(crate) landed_verdict: Option<bool>,
}

/// The destructive steps a positive-verdict gc will take, in the design §8 forced
/// order (worktree before branch, because `git branch -D` refuses a checked-out
/// branch). A step is only set when its target is actually present — reaping an
/// absent thing is a no-op, so completed steps are simply skipped on a rerun
/// (design §8.2 idempotence).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GcPlan {
    /// `git worktree remove` the fork's live linked worktree (removes its marker).
    pub(crate) remove_worktree: bool,
    /// `git branch -D` the fork branch (never a git-ancestor on the import route).
    pub(crate) delete_branch: bool,
}

/// Why a gc refuses to reap (design §8.1). Fails closed with a named token.
/// SEPARATE from [`Refusal`]/[`LandRefusal`] — gc's reap-vs-refuse decision is its
/// own verb; do NOT widen the import/land enums.
///
/// **One refusal, not two (design-faithful collapse — orchestrator to confirm).**
/// The design names a "squash-uncertifiable" case, but a manually squash-merged
/// fork is STRUCTURALLY INDISTINGUISHABLE from a never-landed fork: a multi-commit
/// `git merge --squash` yields `git cherry HEAD <fork>` = `+` lines, exactly like a
/// never-landed fork (verified empirically; a *single*-commit squash yields `-` and
/// is correctly certified as landed). There is no empty-`cherry` squash signal, so
/// the oracle cannot split the two states. The design's "named message" is therefore
/// realised as the `not-landed` refusal message NAMING the squash remedy — the user
/// gets the `worktree land --no-ff` / `--force` guidance whether they squashed or
/// never landed, which is the right action either way.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GcRefusal {
    /// The fork has NOT provably landed (non-ancestor tip with a `+` in `git
    /// cherry` — a never-landed fork OR a manual squash-merge) and neither
    /// `--superseded-head <head>` nor `--force` was given.
    NotLanded,
}

impl GcRefusal {
    /// The distinct named token each refusal fails closed with (the property the
    /// VT goldens assert, not a proxy).
    pub(crate) fn token(self) -> &'static str {
        match self {
            GcRefusal::NotLanded => "not-landed",
        }
    }
}

/// The verdict of the pure gc classifier: a [`GcPlan`] of steps to take, or a named
/// [`GcRefusal`]. `--dry-run` short-circuits to a plan-less verdict in the shell
/// (it never reaches the destructive plan), so the classifier only ever describes
/// what WOULD happen — the shell decides whether to execute.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GcVerdict {
    /// Reap per this plan (the operator authorised it: positive oracle / matching
    /// `--superseded-head` / `--force`).
    Reap(GcPlan),
    /// Fail closed with this named refusal — destroy nothing.
    Refuse(GcRefusal),
}

/// PURE gc classifier (no git / disk / env — ADR-001 leaf, CLAUDE.md
/// pure/imperative split). Mirror of [`classify_import`]/[`classify_land`]: it
/// takes the gathered FACTS plus the operator's `force` / `superseded_match` /
/// `dry_run` intents and returns the verdict (design §8.2).
///
/// The reap GATE (whether deletion is authorised) is decided here from:
/// * a positive `state.landed_verdict` (the oracle passed — only ever `Some` while
///   the branch lives, since the gate requires the branch),
/// * OR `superseded_match` (the operator asserted `--superseded-head` == the live
///   head: a TOCTOU movement-guard, not a landing proof),
/// * OR `force` (the operator knowingly bypassed the oracle),
/// * OR **branch-gone**: a fork branch is deleted only via `branch -D` AFTER the
///   gate passed, so a gone branch is ALREADY certified — and its worktree (with the
///   in-tree `target/` that lived inside it) is already gone too, so there is nothing
///   left to reap (an idempotent no-op, design §8.2).
///
/// `force`/`superseded_match` authorise the reap and skip the refusal (the operator
/// chose to). `dry_run` does NOT change the verdict — it is honoured in the shell
/// (compute + print, act on nothing); the classifier still reports the would-be
/// plan/refusal so the dry-run print is the SAME verdict a real run would act on.
pub(crate) fn classify_gc(
    state: GcState,
    force: bool,
    superseded_match: bool,
    _dry_run: bool,
) -> GcVerdict {
    // Branch-gone ⇒ already-certified ⇒ the ONLY residue is the target dir.
    // (A live linked worktree on a gone branch is git-impossible — `branch -D`
    // refuses a checked-out branch — so worktree_present is moot here.)
    if !state.branch_exists {
        return GcVerdict::Reap(GcPlan {
            remove_worktree: false,
            delete_branch: false,
        });
    }

    // Branch alive: decide the reap gate. Operator overrides skip the oracle.
    let authorised = force || superseded_match || state.landed_verdict == Some(true);
    if !authorised {
        // Not provably landed (a `+` in `git cherry` — never-landed OR a manual
        // squash-merge; the two are indistinguishable). The message names the
        // squash remedy regardless, so the operator gets the right guidance.
        return GcVerdict::Refuse(GcRefusal::NotLanded);
    }

    // Authorised: reap the present things in the forced order (skip absent ones).
    GcVerdict::Reap(GcPlan {
        remove_worktree: state.worktree_present,
        delete_branch: true,
    })
}

/// The reap set a [`GcPlan`] would act on, as a `/`-joined token list for the
/// dry-run print — the ACTUAL legs, never a blanket `worktree/branch` (a branch-gone
/// plan reaps nothing — the in-tree `target/` died with the worktree dir, F-5).
fn reap_targets(plan: GcPlan) -> String {
    let mut parts: Vec<&str> = Vec::new();
    if plan.remove_worktree {
        parts.push("worktree");
    }
    if plan.delete_branch {
        parts.push("branch");
    }
    if parts.is_empty() {
        "nothing".to_owned()
    } else {
        parts.join("/")
    }
}

/// The SHARED landed-oracle (design §8.1), gathered in the shell: true ONLY when
/// `<fork>`'s commit has provably landed against `target` (an arbitrary landing ref,
/// not just coordination HEAD — SL-190 PHASE-04 lift), tested against durable git
/// state — TWO LEGS, UNION:
/// * **ancestry leg** — `<fork-tip>` is an ancestor of `target` (the `land` route,
///   `merge-base --is-ancestor` exit 0) ⇒ landed;
/// * **patch-id leg** — `git cherry <target> <fork>` lists at least one commit
///   and EVERY listed commit is `-` prefixed (the `import` route: ancestry severed,
///   but each patch landed) ⇒ landed. A `+` prefix = a commit whose patch is NOT
///   upstream ⇒ not landed.
///
/// **Crash-proof:** a crash between apply and commit leaves no commit ⇒ `git
/// cherry` reports `+` ⇒ NOT landed ⇒ gc refuses (a receipt would have lied
/// "landed" and reaped the only copy).
///
/// **Squash:** a multi-commit `git merge --squash` yields `+` lines (each fork
/// commit's patch-id is unmatched by the combined squash commit) — STRUCTURALLY
/// INDISTINGUISHABLE from a never-landed fork (a *single*-commit squash yields `-`
/// and IS correctly certified — its content is in `target`). There is no empty-`cherry`
/// squash signal, so the oracle returns plain `not-landed`; the refusal message
/// names the squash remedy. (See [`GcRefusal`] — design-faithful collapse.)
///
/// An EMPTY `git cherry` with a non-ancestor tip means no fork commit's patch is
/// reachable AND none is unmatched — i.e. nothing to certify ⇒ NOT landed (conservative:
/// never reap on a vacuous true). Impure (the two git reads).
///
/// The return stays a clean total `bool` over a VALID `target`: the missing-target →
/// unknown tri-state is the CALLER's concern (PHASE-05 inventory), never the oracle's,
/// so this shared machinery stays behaviour-preserving. gc passes `"HEAD"`.
pub(crate) fn landed_against(root: &Path, target: &str, fork: &str) -> anyhow::Result<bool> {
    // ancestry leg: <fork> is an ancestor of <target>.
    if git::git_status_ok(root, &["merge-base", "--is-ancestor", fork, target])? {
        return Ok(true);
    }
    // patch-id leg: a non-empty `git cherry <target> <fork>` whose every line is `-`.
    let cherry = git::git_cherry(root, target, fork)?;
    Ok(!cherry.is_empty() && cherry.iter().all(|line| line.starts_with('-')))
}

/// `doctrine worktree gc --fork <branch> [--superseded-head <SHA>] [--force]
/// [--dry-run]` — reap a spent worktree fork in ONE idempotent act (design §8),
/// deleting ONLY when the fork has provably landed (design §8.1) and completing /
/// naming any leftover on a crash-rerun (design §8.2). Runs at the coordination
/// root. Orchestrator-classed; refused under worker-mode by `worker_guard`.
///
/// Gather → pure-classify → act, patterned after [`run_land`]:
/// 1. gather the FACTS — `<fork>` existence; its live linked worktree (via the
///    SHARED [`gather_fork_worktree`]); the landed oracle (via [`landed_against`]
///    with `"HEAD"`, ONLY while the branch lives); and the `--superseded-head == current-head`
///    movement-guard match,
/// 2. [`classify_gc`] returns `Reap(plan)` or `Refuse(token)`,
/// 3. on `--dry-run`, PRINT the verdict and destroy NOTHING; otherwise execute the
///    plan in the forced order (worktree → branch), each destructive step honest on
///    failure (names its leftover, exits non-zero), folding a stale admin worktree
///    entry via `git worktree prune`. The fork's in-tree `target/` dies with the
///    worktree dir (SL-156 — no separate reap). Finally stderr-WARN the
///    `CARGO_MANIFEST_DIR`-baked-test-binary recompile.
pub(crate) fn run_gc(
    path: Option<PathBuf>,
    fork: &str,
    superseded_head: Option<&str>,
    force: bool,
    dry_run: bool,
) -> anyhow::Result<()> {
    let root = root::find(path, &root::default_markers())?;
    let root =
        fs::canonicalize(&root).with_context(|| format!("canonicalize root {}", root.display()))?;

    // --- gather: branch existence (resolves to a commit) ---
    let branch_ref = format!("refs/heads/{fork}");
    let branch_head = git::git_opt(
        &root,
        &[
            "rev-parse",
            "--verify",
            "--quiet",
            &format!("{branch_ref}^{{commit}}"),
        ],
    )?;
    let branch_exists = branch_head.is_some();

    // --- gather: the fork's live linked worktree (shared gather) ---
    // The fork's in-tree `target/` lives INSIDE this worktree dir (SL-156), so
    // removing the worktree reaps the target with it — no separate target gather.
    let fork_wt = gather_fork_worktree(&root, fork)?;
    let worktree_present = fork_wt.is_some();

    // --- gather: the landed oracle (ONLY while the branch lives — design §8.2) ---
    let landed_verdict = if branch_exists {
        // gc lands against coordination HEAD (the shared oracle's `target`).
        Some(landed_against(&root, "HEAD", fork)?)
    } else {
        None
    };

    // --- gather: --superseded-head movement-guard match (SHA == CURRENT head) ---
    // A movement-guard, not a landing proof: reaps iff the asserted SHA equals the
    // branch's current head (TOCTOU guard — a stale SHA cannot match a live head).
    let superseded_match = match (superseded_head, &branch_head) {
        (Some(sha), Some(head)) => {
            // Resolve the operator's SHA to a commit before comparing (never trust a
            // symbolic ref verbatim); an unresolvable SHA simply cannot match.
            match git::git_opt(
                &root,
                &[
                    "rev-parse",
                    "--verify",
                    "--quiet",
                    &format!("{sha}^{{commit}}"),
                ],
            )? {
                Some(resolved) => matches(&resolved, head),
                None => false,
            }
        }
        _ => false,
    };

    let state = GcState {
        branch_exists,
        worktree_present,
        landed_verdict,
    };

    // --- pure classify ---
    let verdict = classify_gc(state, force, superseded_match, dry_run);

    // --- dry-run: PRINT the verdict, destroy NOTHING (the operator never --forces blind) ---
    if dry_run {
        match verdict {
            GcVerdict::Reap(plan) => {
                // Report the TRUTH the operator needs before a real run: the actual
                // landed verdict + whether the reap is oracle- or override-authorised,
                // and the ACTUAL reap set — never a blanket `landed ✓ (worktree/
                // branch)` that lies on a forced or branch-gone reap (F-5).
                let basis = if !branch_exists {
                    "already-certified (branch gone)".to_owned()
                } else if landed_verdict == Some(true) {
                    "landed ✓ (oracle)".to_owned()
                } else {
                    let how = if force {
                        "--force"
                    } else {
                        "--superseded-head"
                    };
                    format!("NOT landed — reap authorised by {how} (oracle override)")
                };
                writeln!(
                    io::stdout(),
                    "{fork}: {basis} — would reap ({})",
                    reap_targets(plan)
                )?;
            }
            GcVerdict::Refuse(GcRefusal::NotLanded) => {
                writeln!(
                    io::stdout(),
                    "{fork}: not-landed — `--force` to reap, or `--superseded-head <SHA>` if spent-and-abandoned. If you squash-merged, re-land via `worktree land` (--no-ff)."
                )?;
            }
        }
        return Ok(());
    }

    // --- act ---
    // The lone refusal NAMES the squash remedy too (a squash-merge is
    // indistinguishable from a never-landed fork — see `GcRefusal`).
    let plan = match verdict {
        GcVerdict::Refuse(GcRefusal::NotLanded) => bail!(
            "gc-refused: {} — fork {fork} has not provably landed; `--force` to reap, or `--superseded-head <SHA>` to assert it is spent-and-abandoned. Cannot certify a squash-merge — re-land via `worktree land` (--no-ff), or `--force` knowingly.",
            GcRefusal::NotLanded.token()
        ),
        GcVerdict::Reap(plan) => plan,
    };

    let mut leftovers: Vec<String> = Vec::new();

    // Step 1: remove the live linked worktree FIRST (it holds the marker, and
    // `branch -D` would refuse a checked-out branch). Fold a stale administrative
    // entry via `git worktree prune` before believing a removal failed.
    if let (true, Some(wt)) = (plan.remove_worktree, fork_wt.as_deref()) {
        let removed = git::git_opt(
            &root,
            &["worktree", "remove", "--force", &wt.to_string_lossy()],
        )?;
        if removed.is_none() {
            // Fold a stale admin entry, then re-check whether the dir survives.
            drop(git::git_opt(&root, &["worktree", "prune"]));
            if wt.exists() {
                leftovers.push(format!("worktree {}", wt.display()));
            }
        }
    }

    // Step 2: delete the branch (never a git-ancestor on the import route, so `-d`
    // always refuses — the patch-id gate, not `-d`, is the safety; use `-D`).
    if plan.delete_branch {
        let deleted = git::git_opt(&root, &["branch", "-D", fork])?;
        if deleted.is_none()
            && git::git_opt(&root, &["rev-parse", "--verify", "--quiet", &branch_ref])?.is_some()
        {
            leftovers.push(format!("branch {fork}"));
        }
    }

    // The fork's in-tree `target/` needs no separate reap step — it lived inside the
    // worktree dir and died with the `git worktree remove` above (SL-156).

    // Step 3 (NET-NEW, SL-198 PHASE-01): delete the per-worktree dispatch record,
    // co-located with the worktree+branch reap — no record survives a reaped worktree
    // (closes the stale-oracle, design §5.3 EX-2). Keyed off the fork NAME (strip the
    // `dispatch/` prefix); a no-op for a non-dispatch fork or an idempotent rerun.
    let record_name = fork.strip_prefix("dispatch/").unwrap_or(fork);
    if let Err(cause) = super::dispatch_record::delete_dispatch_record(&root, record_name) {
        leftovers.push(format!("dispatch record: {cause:#}"));
    }

    if !leftovers.is_empty() {
        bail!(
            "gc-incomplete: leftover(s) need manual cleanup: {}",
            leftovers.join(", ")
        );
    }

    // Step 3: WARN that env!(CARGO_MANIFEST_DIR)-baked test binaries now point at a
    // deleted fork path and must be recompiled (mem.pattern.dispatch.worktree-
    // removal-stale-manifest-dir-false-red).
    writeln!(
        io::stderr(),
        "warning: test binaries baked with the reaped fork's CARGO_MANIFEST_DIR are now stale — recompile before trusting a RED"
    )?;
    writeln!(
        io::stdout(),
        "gc {fork}: reaped (worktree/branch as present)"
    )?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Unit tests — the GENERALIZED (non-HEAD `target`) contract of the shared
// [`landed_against`] oracle (SL-190 PHASE-04, EX-3 / VT-1). gc's own HEAD-target
// behaviour is proven UNCHANGED by `tests/e2e_worktree_gc.rs` (EX-2); here we
// exercise a real non-HEAD landing ref against each of the three verdicts:
// landed-via-ancestry, landed-via-patch-id, and not-landed. Missing-target →
// soft unknown is the inventory caller's concern (PHASE-05), not the oracle's.
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
    use super::landed_against;
    use std::path::{Path, PathBuf};
    use std::process::Command;

    /// A throwaway git repo with pinned identity — the fixture idiom shared with
    /// `git.rs`'s `ScratchRepo`, minimised for the oracle's needs.
    struct ScratchRepo {
        _dir: tempfile::TempDir,
        path: PathBuf,
    }

    impl ScratchRepo {
        fn new() -> Self {
            let dir = tempfile::tempdir().expect("tempdir");
            let path = dir.path().to_path_buf();
            let repo = Self { _dir: dir, path };
            repo.git(&["init", "-q", "-b", "main"]);
            repo.git(&["config", "user.email", "t@example.com"]);
            repo.git(&["config", "user.name", "Test"]);
            repo
        }

        fn path(&self) -> &Path {
            &self.path
        }

        fn git(&self, args: &[&str]) -> String {
            let out = Command::new("git")
                .arg("-C")
                .arg(&self.path)
                .args(args)
                .output()
                .expect("spawn git");
            assert!(
                out.status.success(),
                "git {args:?} failed: {}",
                String::from_utf8_lossy(&out.stderr).trim()
            );
            String::from_utf8_lossy(&out.stdout).trim().to_string()
        }

        fn commit(&self, rel: &str, contents: &str, message: &str) -> String {
            std::fs::write(self.path.join(rel), contents).expect("write file");
            self.git(&["add", rel]);
            self.git(&["commit", "-q", "-m", message]);
            self.git(&["rev-parse", "HEAD"])
        }
    }

    /// ANCESTRY leg against a NON-HEAD `target`: the fork tip is an ancestor of
    /// `release` (merged in via `--no-ff`) but NOT of HEAD (`main`), so the oracle
    /// must consult the passed `target`, not a hardcoded HEAD.
    #[test]
    fn landed_against_non_head_target_via_ancestry() {
        let repo = ScratchRepo::new();
        repo.commit("base.txt", "0", "C0");
        repo.git(&["checkout", "-q", "-b", "feature"]);
        let fork = "feature";
        repo.commit("feat.txt", "f", "feature work");
        // `release` is the non-HEAD landing target; merge the fork into it.
        repo.git(&["checkout", "-q", "main"]);
        repo.git(&["checkout", "-q", "-b", "release"]);
        repo.git(&["merge", "--no-ff", "-q", "-m", "land feature", fork]);
        // HEAD returns to `main`, which does NOT contain the fork.
        repo.git(&["checkout", "-q", "main"]);
        let target = "release";

        // ancestry leg: `merge-base --is-ancestor <fork> <target>` exit 0 ⇒ landed.
        assert!(landed_against(repo.path(), target, fork).expect("oracle"));
        // ...and NOT landed against HEAD (main), proving the `target` param is honoured.
        assert!(!landed_against(repo.path(), "HEAD", fork).expect("oracle"));
    }

    /// PATCH-ID leg against a NON-HEAD `target`: the fork's patch is cherry-picked
    /// onto `release` (ancestry severed, patch-id equal), so `git cherry <target>
    /// <fork>` yields an all-`-` list ⇒ landed.
    #[test]
    fn landed_against_non_head_target_via_patch_id_cherry() {
        let repo = ScratchRepo::new();
        repo.commit("base.txt", "0", "C0");
        repo.git(&["checkout", "-q", "-b", "feature"]);
        let fork = "feature";
        let fork_tip = repo.commit("feat.txt", "content", "add feat");
        // `release` gets an EQUIVALENT patch via cherry-pick — same patch-id, new SHA.
        repo.git(&["checkout", "-q", "main"]);
        repo.git(&["checkout", "-q", "-b", "release"]);
        repo.git(&["cherry-pick", &fork_tip]);
        repo.git(&["checkout", "-q", "main"]);
        let target = "release";

        // not an ancestor, but `git cherry <target> <fork>` is all `-` ⇒ landed.
        assert!(landed_against(repo.path(), target, fork).expect("oracle"));
    }

    /// NOT LANDED against a NON-HEAD `target`: the fork carries a commit whose patch
    /// is absent from `release` (a `+` in `git cherry`) and is not an ancestor.
    #[test]
    fn not_landed_against_non_head_target() {
        let repo = ScratchRepo::new();
        repo.commit("base.txt", "0", "C0");
        repo.git(&["checkout", "-q", "-b", "feature"]);
        let fork = "feature";
        repo.commit("only.txt", "only", "unlanded work");
        // `release` diverges with unrelated work — the fork's patch never lands.
        repo.git(&["checkout", "-q", "main"]);
        repo.git(&["checkout", "-q", "-b", "release"]);
        repo.commit("other.txt", "other", "unrelated release work");
        repo.git(&["checkout", "-q", "main"]);
        let target = "release";

        // neither `--is-ancestor` nor an all-`-` `cherry` ⇒ not landed.
        assert!(!landed_against(repo.path(), target, fork).expect("oracle"));
    }

    // --- SL-198 PHASE-01 (VT-2): reap deletes the per-worktree DispatchRecord -----
    // A reaped worker worktree must leave NO record behind (closes the stale-oracle):
    // after gc removes the worktree+branch, a `resolve_agent` of the same agent yields
    // `unknown-agent`.
    #[test]
    fn reap_deletes_dispatch_record_and_resolve_yields_unknown_agent() {
        use super::run_gc;
        use crate::worktree::dispatch_record::{
            RECORD_SUBPATH, ResolveRefusal, provision_dispatch_record, resolve_agent,
        };

        let repo = ScratchRepo::new();
        let base = repo.commit("a.txt", "0", "base");
        let coord = std::fs::canonicalize(repo.path()).unwrap();

        // Stand up a live worker worktree on dispatch/<name> + its trusted record.
        let name = "agent-cafe";
        let branch = format!("dispatch/{name}");
        let dir = coord.join(".worktrees").join(name);
        let dir_s = dir.to_string_lossy().to_string();
        repo.git(&["worktree", "add", "-b", &branch, &dir_s, &base]);
        provision_dispatch_record(&coord, name, &base, &dir, &branch).unwrap();

        let record_file = coord.join(RECORD_SUBPATH).join(format!("{name}.toml"));
        assert!(
            record_file.exists(),
            "the DispatchRecord is written for a live worker"
        );
        assert!(
            resolve_agent(&coord, name).is_ok(),
            "a live, consistent worker resolves pre-reap"
        );

        // Reap the worker fork (--force bypasses the landed oracle).
        run_gc(Some(coord.clone()), &branch, None, true, false).expect("gc reap");

        assert!(
            !record_file.exists(),
            "reap deleted the DispatchRecord — none survives a reaped worktree"
        );
        assert_eq!(
            resolve_agent(&coord, name),
            Err(ResolveRefusal::UnknownAgent),
            "post-reap resolve of the same agent yields unknown-agent"
        );
    }
}