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
//! Is this worktree's HEAD inside a MERGED pull request (#7889, route (c))?
//!
//! Why: owner ruling 2026-09-22 names three routes to landing evidence for a
//! clean tree — (a) its own branch's merged pull request, (b) landed content,
//! (c) "HEAD is an ancestor of the head commit of a merged PR". Route (c) is
//! the donor-branch shape itself: a parked branch fast-forwarded onto a
//! sibling's head, which then merged. It still admits where (b) cannot — a
//! donor whose content was later edited on `main` so the merge conflicts, or
//! whose change the pull request itself superseded.
//!
//! What: [`carried_by_merged_pr`] asks GitHub which MERGED pull requests
//! contain HEAD, then asks git whether HEAD is an ancestor of — or equal to —
//! one of their head commits.
//!
//! **One direction only.** HEAD ⊑ the pull request's head means every commit
//! here was inside what merged. The reverse — the pull request's head is an
//! ancestor of HEAD — leaves commits here the merge never saw, so it is never
//! a match. That is why this does not reuse `worktree_reclaim_pr_match`'s
//! either-way-round `vouches_for_head`, which also accepts a pull request
//! whose head is behind HEAD.
//!
//! **Every failure arm refuses** (ADR-0045): an unreadable HEAD, a commit
//! search that did not answer, and an ancestry probe that errored — which is
//! what a head commit this checkout does not hold looks like — are all
//! [`CarriedByPr::Unavailable`]. A fork's pull request is never a match.
//! Test: `worktree_carried_by_pr_tests`.
use std::path::Path;
use crate::session_manager::worktree_reclaim_pr_match::{LandingProbe, MergedPrHead};
/// The admission's name, quoted by both ladders' refusals (#7889).
pub const MERGED_PR_ANCESTRY_CHECK: &str = "merged-pr-ancestry";
/// Whether a MERGED pull request carried this worktree's HEAD (#7889).
///
/// Why: "no pull request carries it" and "the question could not be asked"
/// lead to different refusal text, and must never collapse into each other on
/// a path whose grant deletes a directory.
/// What: [`Carried`](Self::Carried) is the only variant that admits.
/// Test: `a_head_inside_a_merged_prs_history_is_carried`,
/// `a_merged_pr_head_behind_head_is_not_a_match`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CarriedByPr {
/// HEAD is `pr_head` or one of its ancestors, and pull request `pr` merged.
Carried {
/// The merged pull request.
pr: u64,
/// That pull request's own head commit.
pr_head: String,
},
/// GitHub answered, and no merged pull request's head descends from HEAD.
NotCarried {
/// The HEAD commit that was searched for.
head: String,
},
/// The question could not be answered, which never admits.
Unavailable {
/// Why, in the words of whatever failed.
detail: String,
},
}
impl CarriedByPr {
/// True only for a [`Carried`](Self::Carried) answer naming a head commit.
pub fn is_carried(&self) -> bool {
matches!(self, Self::Carried { pr_head, .. } if !pr_head.trim().is_empty())
}
/// One sentence naming what this admission decided (#7889).
///
/// Why: the guard and the sweep append it to a refusal, and one rendering
/// keeps the two ladders from describing one verdict differently.
/// What: names the pull request on a grant, the HEAD commit when none
/// carried it, and what failed when the question could not be asked.
/// Test: `every_arm_names_the_admission`.
pub fn note(&self) -> String {
match self {
Self::Carried { pr, pr_head } => format!(
"ADR-0057's `{MERGED_PR_ANCESTRY_CHECK}` admission applies: HEAD is inside the \
history of PR #{pr}'s head `{pr_head}`, which merged"
),
Self::NotCarried { head } => format!(
"ADR-0057's `{MERGED_PR_ANCESTRY_CHECK}` admission does not apply either: no \
MERGED pull request's head commit descends from HEAD `{head}`"
),
Self::Unavailable { detail } => format!(
"ADR-0057's `{MERGED_PR_ANCESTRY_CHECK}` admission could not be established \
either — {detail} — and a fact that cannot be established never grants"
),
}
}
}
/// Is `dir`'s HEAD inside the history of a MERGED pull request's head (#7889)?
///
/// Why: route (c) of the 2026-09-22 ruling. See the module doc for the one
/// direction it accepts and why.
/// What: HEAD from `probe.head_commit`; the rows GitHub returns for it from
/// `probe.merged_prs_containing`; then [`carrying_row`]. `dir` is both the
/// worktree and the root the repository is resolved from, which is what the
/// removal guard and the sweep both have in hand.
/// Test: `a_head_inside_a_merged_prs_history_is_carried`,
/// `an_unreadable_head_is_unavailable`, `a_failed_search_is_unavailable`.
pub(crate) fn carried_by_merged_pr(dir: &Path, probe: &dyn LandingProbe) -> CarriedByPr {
let head = match probe.head_commit(dir) {
Ok(h) if !h.trim().is_empty() => h.trim().to_string(),
Ok(_) => return unavailable("`git rev-parse HEAD` named no commit"),
Err(e) => return unavailable(format!("HEAD could not be resolved: {e}")),
};
match probe.merged_prs_containing(dir, &head) {
Ok(rows) => carrying_row(&rows, &head, &|a, d| probe.is_ancestor(dir, a, d)),
Err(e) => unavailable(format!(
"the MERGED pull request search for `{head}` did not answer: {e}"
)),
}
}
/// The first non-fork row whose head commit is `head` or descends from it.
///
/// Why: the decision, split from the subprocesses so every arm is unit-tested.
/// What: equal ids match without a subprocess. Otherwise `is_ancestor(head,
/// oid)` decides; a row whose probe errors is skipped, and when no row matched
/// the first such error is the answer — an ancestry question that could not be
/// asked is never "no pull request carries it".
/// Test: `a_head_inside_a_merged_prs_history_is_carried`,
/// `a_merged_pr_head_behind_head_is_not_a_match`,
/// `a_fork_row_is_never_a_match`, `an_ancestry_probe_error_is_unavailable`.
fn carrying_row(
rows: &[MergedPrHead],
head: &str,
is_ancestor: &dyn Fn(&str, &str) -> Result<bool, String>,
) -> CarriedByPr {
let mut first_error = None;
for row in rows {
let oid = row.head_ref_oid.trim();
if row.is_cross_repository || oid.is_empty() {
continue;
}
let carried = oid.eq_ignore_ascii_case(head) || {
match is_ancestor(head, oid) {
Ok(yes) => yes,
Err(e) => {
first_error.get_or_insert(format!(
"whether HEAD is an ancestor of PR #{}'s head `{oid}` could not be \
established: {e}",
row.number
));
false
}
}
};
if carried {
return CarriedByPr::Carried {
pr: row.number,
pr_head: oid.to_string(),
};
}
}
match first_error {
Some(detail) => unavailable(detail),
None => CarriedByPr::NotCarried {
head: head.to_string(),
},
}
}
fn unavailable(detail: impl Into<String>) -> CarriedByPr {
CarriedByPr::Unavailable {
detail: detail.into(),
}
}
#[cfg(test)]
#[path = "worktree_carried_by_pr_tests.rs"]
mod worktree_carried_by_pr_tests;