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
//! Which MERGED pull request carried THIS worktree's work (#7267).
//!
//! Why: `tm session prune-worktrees --merged-prs --force` reclaimed 0 of 11
//! stale worktrees whose content was fully on `main`. Every lookup keyed on the
//! branch name git reports for the worktree, and none of the 11 branches was
//! the name its pull request was opened from — a review round renames
//! `<branch>` to `<branch>-r2`, and a re-cut branch takes a new name entirely
//! before the pull request opens. A merged pull request that carried the work
//! is landing evidence whatever the branch ended up being called, so the
//! lookup has to reach it by more than one route.
//!
//! What: [`resolve_landing`] is a three-rung ladder over the answer the branch
//! name already produced.
//!
//! 1. The branch's own pull request, from the caller. Any SETTLED answer —
//! merged, open, closed-unmerged, or a failed lookup — is returned as-is;
//! only `NoPr` and `Unknown` widen.
//! 2. The round-stem sibling, related by
//! [`strip_round_suffix`] — the same equivalence `core::pr_cleanup::plan`
//! uses to relate `foo` and `foo-r2`, and the same one the ADR-0057 removal
//! guard's `landing_evidence` applies. Not a second matcher: that is the
//! one implementation of "these two branch names are the same workstream".
//! 3. The head COMMIT. `gh pr list --state merged --search <sha>` finds the
//! merged pull requests GitHub knows contain this worktree's HEAD, and one
//! of them vouches for the tree when its `headRefOid` and that HEAD stand in
//! an ancestor relationship either way round — the pull request was opened
//! from this commit, from a descendant of it, or from an ancestor of it.
//!
//! **A widening rung may only ever produce `Merged`.** It never converts one
//! refusal into a different one: a rung that cannot answer — no repository, no
//! `gh`, a timeout, an unparseable reply, a commit git cannot resolve — leaves
//! rung 1's answer standing and logs the reason. Rung 1's answer is already a
//! refusal in every case that reaches here, so every failure path denies, which
//! is what ADR-0045 asks of a gate whose grant deletes a checkout. It also
//! keeps the operator-facing reason stable: a worktree with no pull request
//! still reports "no pull request found for this branch" when the network is
//! down, rather than a lookup failure that is really about the widening.
//!
//! The extra `gh` and `git` calls ride on `per_branch_fallback`, so the
//! `tm doctor` probe — which runs on a three-second budget and passes `false` —
//! pays for none of them. See [`resolve_with_index`].
//!
//! Test: `worktree_reclaim_pr_match_tests`.
use Path;
use Deserialize;
use ;
use ;
use worktree_reclaim_gh_gate;
use repo_slug_for;
use ;
use cratestrip_round_suffix;
/// How many merged pull requests one commit search may return.
///
/// Why: a commit is normally carried by one pull request; a handful is the
/// realistic ceiling for a commit that was cherry-picked around. A bound keeps
/// the ancestry probe below from turning into an unbounded run of `git` calls.
/// What: 20.
const COMMIT_SEARCH_LIMIT: usize = 20;
/// The `--json` field set the commit search asks `gh` for.
///
/// Why: `isCrossRepository` is requested for the reason `PR_JSON_FIELDS`
/// requests it — a fork's pull request says nothing about a local branch, and a
/// `gh` too old to report the field must fail the whole call rather than return
/// rows in which a fork is indistinguishable from this repository.
const COMMIT_SEARCH_JSON_FIELDS: &str = "number,headRefOid,isCrossRepository";
/// One MERGED pull request the commit search returned.
pub
/// The facts [`resolve_landing`] needs from GitHub and git (#7267).
///
/// Why: a trait rather than four free functions so the ladder — the part that
/// decides what counts as landing evidence — is a pure function with unit
/// tests, exactly as the ADR-0057 guard's `WorktreeRemovalProbe` makes its
/// policy testable. The production implementation is [`GhLandingProbe`]; the
/// tests drive a fake.
/// What: every method that can fail returns `Err`, and the ladder treats an
/// `Err` as "this rung did not answer", never as "there is nothing there".
/// Test: `worktree_reclaim_pr_match_tests`.
pub
/// Widen a branch-name lookup to the pull request that actually carried the
/// work (#7267).
///
/// Why: the module doc's whole subject. A renamed or re-cut branch has landing
/// evidence that its NAME cannot reach.
/// What: rung 1's `exact` answer unless it is `NoPr` or `Unknown`, in which
/// case the round-stem sibling and then the head commit are asked. Only a
/// `Merged` answer from a widening rung replaces `exact`; everything else —
/// including every error — leaves it standing, so no path here can turn a
/// refusal into a grant without positive evidence of a merge.
/// Test: `a_round_sibling_with_a_merged_pr_is_reclaimable`,
/// `a_detached_worktree_can_still_match_by_head_commit`,
/// `a_renamed_branch_matches_the_pr_opened_from_its_head_commit`,
/// `no_merged_pr_under_any_stem_is_still_refused`,
/// `an_open_pr_on_the_branch_is_never_widened`,
/// `a_failed_head_commit_read_leaves_the_refusal_standing`,
/// `a_failed_commit_search_leaves_the_refusal_standing`,
/// `a_failed_ancestry_probe_leaves_the_refusal_standing`.
pub
/// What the ladder answered by the branch's NAME, and what it answered in the
/// end (#8109).
///
/// Why: rung 3 matches a merged pull request by head COMMIT, so its `Merged`
/// can belong to another branch entirely. On 2026-09-16 it matched PR #514
/// (`…-v2`) for a `…-wt` branch that never had a pull request, and the tree was
/// reclaimed with no proof its own content had landed. The reclaim sweep needs
/// both answers to tell those two kinds of `Merged` apart.
/// What: `by_name` is rungs 1 and 2 — the branch's own name or its `-rN` round
/// stem, the one workstream equivalence `strip_round_suffix` defines.
/// `landing` adds rung 3, and is what [`resolve_landing`] returns.
/// Test: `worktree_8109_a_head_commit_match_is_not_the_branchs_own_pr`.
pub
/// [`resolve_landing`], returning the by-name answer beside the final one
/// (#8109).
/// Rung 2 — the MERGED pull request of this branch's round-stem sibling.
///
/// Why (#7267, and #7275 for the guard's half of the same problem): a review
/// round lands on `<branch>-r2` while the pull request keeps the name it was
/// opened from, so neither name finds the other by exact match. Both spellings
/// reduce to one stem, and asking GitHub for the OTHER spelling is one call.
/// What: `Some(Merged)` when the sibling's own lookup reports a merge; `None`
/// for every other answer, the failed lookup included — see the module doc on
/// why a rung never returns a different refusal. Only branches whose name
/// actually differs from their stem cost a call, and the reverse direction (a
/// stem worktree whose pull request was opened from `-r2`) is answered by
/// `PrIndex::state_for`'s own sibling scan without a network call at all.
/// Test: `a_round_sibling_with_a_merged_pr_is_reclaimable`,
/// `a_round_sibling_whose_stem_pr_is_open_is_not_widened`.
/// Rung 3 — the MERGED pull request GitHub reports as containing this HEAD.
///
/// Why: a branch that was re-cut under a new name before its pull request
/// opened shares no name with it, and no stem relates them. The COMMIT does:
/// GitHub's own search resolves a full object id to the pull requests carrying
/// it, which is the evidence the manual salvage in #7267 reconstructed by hand
/// for all 11 worktrees.
/// What: `Some(Merged)` for the first returned pull request whose `headRefOid`
/// and this worktree's HEAD stand in an ancestor relationship either way round
/// — the pull request was opened from this commit, from a descendant of it, or
/// from an ancestor of it. A fork's row is skipped for the reason
/// `PrIndex::from_json` skips it. `None` on every failure, and on a search that
/// returned nothing this tree's HEAD belongs to.
///
/// Content this tree holds BEYOND the matched pull request's head is not this
/// gate's to catch and is not let through by it: `classify` gate 6 runs
/// `inspect_dirt` afterwards, whose unpushed-commit count subtracts
/// patch-equivalent commits (#6507) and refuses anything genuinely novel.
/// Test: `a_renamed_branch_matches_the_pr_opened_from_its_head_commit`,
/// `a_fork_pull_request_containing_the_commit_is_ignored`,
/// `an_unrelated_merged_pr_containing_no_ancestor_is_not_a_match`,
/// `a_failed_ancestry_probe_leaves_the_refusal_standing`.
/// Does the pull request whose head sat on `oid` vouch for `head`?
///
/// Why: the two commits can relate in either direction and both are evidence.
/// Equal means the pull request was opened from exactly this commit; `oid`
/// descended from `head` means everything here is inside what merged; `head`
/// descended from `oid` means the merge carried a prefix of this tree, and
/// `classify` gate 6 refuses whatever is left over.
/// What: true when the two commits are the same, and otherwise when either
/// ancestry holds. An ancestry probe that ERRORS, which is what an object this
/// checkout does not have looks like, answers false: the rung then finds no
/// match and the branch-name refusal stands.
/// Test: `a_renamed_branch_matches_the_pr_opened_from_its_head_commit`,
/// `an_unrelated_merged_pr_containing_no_ancestor_is_not_a_match`,
/// `a_failed_ancestry_probe_leaves_the_refusal_standing`.
/// The pull-request state for one surveyed worktree, index first (#7267).
///
/// Why: the survey and the pre-delete re-check both need the same answer, and
/// before this they each carried their own copy of the #6561 per-branch retry.
/// One function is what keeps the widening from reaching only half the
/// destructive path.
/// What: the bulk index's answer, then — only when `per_branch_fallback` is set
/// — the #6561 targeted retry for a branch a truncated or failed index could
/// not resolve, then [`resolve_landing`]. With `per_branch_fallback` clear the
/// index's answer is returned unchanged, which is what keeps the `tm doctor`
/// probe inside its three-second budget.
///
/// #8109: returns the by-name answer beside the final one, so the sweep can
/// tell whether a `Merged` is the branch's own — see [`PrResolution`].
/// Without `per_branch_fallback` both fields hold the index's answer.
/// Test: `resolve_with_index_without_fallback_never_calls_the_probe`,
/// `resolve_with_index_retries_a_truncated_index_per_branch`,
/// `survey_reclaims_a_round_sibling_of_a_merged_pr`,
/// `worktree_8109_a_head_commit_match_is_not_the_branchs_own_pr`.
pub
/// Ask GitHub, in an ALREADY-RESOLVED repository, for the MERGED pull requests
/// carrying `sha`.
///
/// Why: split out of [`LandingProbe::merged_prs_containing`] so a caller that
/// has already resolved the repository slug does not pay for a second
/// `git config --get remote.origin.url` spawn (#7832, critic round). The
/// ADR-0057 removal guard runs inside a `PreToolUse` hook with a 5-second
/// budget, where one avoidable subprocess is worth removing.
/// What: `gh pr list --repo <repo> --state merged --search <sha>`, through the
/// #6867 hang gate under a key naming the question — a commit search is not the
/// per-branch query and the two must never share a reply. The rows are returned
/// unfiltered; deciding which of them vouches for a worktree is the caller's.
/// Test: `a_renamed_branch_matches_the_pr_opened_from_its_head_commit`,
/// `a_fork_pull_request_containing_the_commit_is_ignored`.
pub
/// The production [`LandingProbe`] — real `gh`, real `git` (#7267).
///
/// Why: every call it makes goes through the module that already owns that
/// capability — `worktree_reclaim::pr_state_for_branch` for a branch's
/// pull requests, `worktree_reclaim_gh` for spawning `gh` under the resolved
/// identity and the hang gate, `worktree_safety::git_command` for a `git` that
/// ambient config cannot steer. Nothing here re-implements one of those.
/// What: the four probe methods, each failing with a string the ladder logs.
/// Test: exercised through the reclaim sweep; the ladder's own decisions are
/// unit-tested against a fake in `worktree_reclaim_pr_match_tests`.
pub ;
pub