1use crate::{BranchName, Outpost, OutpostError, OutpostResult, RemoteName, SourceRepo};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct BranchCleanupAnalysis {
5 pub candidate: Option<BranchCleanupCandidate>,
6 pub findings: Vec<BranchCleanupFinding>,
7}
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct BranchCleanupCandidate {
11 pub branch: BranchName,
12 pub source_oid: String,
13 pub upstream_remote: RemoteName,
14 pub upstream_oid: Option<String>,
15 pub proof: BranchCleanupProof,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum BranchCleanupProof {
20 MergedPullRequest(MergedPullRequest),
21 AncestorOfDefaultBranch {
22 remote: RemoteName,
23 default_branch: BranchName,
24 default_oid: String,
25 },
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct MergedPullRequest {
30 pub id: String,
31 pub head_ref_name: BranchName,
32 pub head_ref_oid: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum BranchCleanupFinding {
37 Skipped {
38 branch: Option<BranchName>,
39 reason: BranchCleanupSkipReason,
40 },
41 Warning {
42 branch: Option<BranchName>,
43 message: String,
44 },
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum BranchCleanupSkipReason {
49 CleanupDisabled,
50 NonInteractive,
51 MissingOutpost,
52 DetachedHead,
53 NoUpstreamTracking,
54 UpstreamRemoteMismatch,
55 UpstreamNotBranch,
56 SourceBranchMissing,
57 OutpostHeadMismatch,
58 BranchCheckedOut,
59 DefaultBranch,
60 DefaultBranchUnknown,
61 NoProof,
62}
63
64pub trait BranchCleanupProvider {
65 fn merged_pull_request(
66 &self,
67 branch: &BranchName,
68 source_oid: &str,
69 ) -> OutpostResult<Option<MergedPullRequest>>;
70}
71
72pub fn analyze_branch_cleanup(
73 source: &SourceRepo,
74 outpost: &Outpost,
75 provider: Option<&dyn BranchCleanupProvider>,
76) -> BranchCleanupAnalysis {
77 let mut findings = Vec::new();
78 let candidate = analyze_candidate(source, outpost, provider, &mut findings);
79 BranchCleanupAnalysis {
80 candidate,
81 findings,
82 }
83}
84
85fn analyze_candidate(
86 source: &SourceRepo,
87 outpost: &Outpost,
88 provider: Option<&dyn BranchCleanupProvider>,
89 findings: &mut Vec<BranchCleanupFinding>,
90) -> Option<BranchCleanupCandidate> {
91 let upstream = match outpost.upstream_tracking() {
92 Ok(Some(upstream)) => upstream,
93 Ok(None) => {
94 findings.push(BranchCleanupFinding::Skipped {
95 branch: None,
96 reason: BranchCleanupSkipReason::NoUpstreamTracking,
97 });
98 return None;
99 }
100 Err(OutpostError::BranchNotFound { .. }) => {
101 findings.push(BranchCleanupFinding::Skipped {
102 branch: None,
103 reason: BranchCleanupSkipReason::DetachedHead,
104 });
105 return None;
106 }
107 Err(err) => {
108 findings.push(warning(None, "cannot inspect outpost upstream", err));
109 return None;
110 }
111 };
112
113 if upstream.remote != outpost.metadata().remote_name {
114 findings.push(BranchCleanupFinding::Skipped {
115 branch: None,
116 reason: BranchCleanupSkipReason::UpstreamRemoteMismatch,
117 });
118 return None;
119 }
120
121 let Some(branch) = upstream.short_branch() else {
122 findings.push(BranchCleanupFinding::Skipped {
123 branch: None,
124 reason: BranchCleanupSkipReason::UpstreamNotBranch,
125 });
126 return None;
127 };
128 let branch = match BranchName::parse(branch.to_owned()) {
129 Ok(branch) => branch,
130 Err(err) => {
131 findings.push(warning(None, "cannot parse outpost upstream branch", err));
132 return None;
133 }
134 };
135
136 let Some(source_oid) = (match source.branch_oid(&branch) {
137 Ok(oid) => oid,
138 Err(err) => {
139 findings.push(warning(
140 Some(branch.clone()),
141 "cannot inspect source branch",
142 err,
143 ));
144 return None;
145 }
146 }) else {
147 findings.push(BranchCleanupFinding::Skipped {
148 branch: Some(branch),
149 reason: BranchCleanupSkipReason::SourceBranchMissing,
150 });
151 return None;
152 };
153
154 let outpost_oid = match outpost.git().run_capture(["rev-parse", "HEAD"]) {
155 Ok(oid) => oid,
156 Err(err) => {
157 findings.push(warning(
158 Some(branch.clone()),
159 "cannot inspect outpost HEAD",
160 err,
161 ));
162 return None;
163 }
164 };
165 if outpost_oid != source_oid {
166 findings.push(BranchCleanupFinding::Skipped {
167 branch: Some(branch),
168 reason: BranchCleanupSkipReason::OutpostHeadMismatch,
169 });
170 return None;
171 }
172
173 match source.is_branch_checked_out(&branch) {
174 Ok(true) => {
175 findings.push(BranchCleanupFinding::Skipped {
176 branch: Some(branch),
177 reason: BranchCleanupSkipReason::BranchCheckedOut,
178 });
179 return None;
180 }
181 Ok(false) => {}
182 Err(err) => {
183 findings.push(warning(
184 Some(branch.clone()),
185 "cannot inspect checked-out source branches",
186 err,
187 ));
188 return None;
189 }
190 }
191
192 let upstream_remote = source_upstream_remote(source, &branch, findings)?;
193
194 let (default_branch, default_oid) = match source.fetch_remote_default_branch(&upstream_remote) {
195 Ok(Some(default)) => default,
196 Ok(None) => {
197 findings.push(BranchCleanupFinding::Skipped {
198 branch: Some(branch),
199 reason: BranchCleanupSkipReason::DefaultBranchUnknown,
200 });
201 return None;
202 }
203 Err(err) => {
204 findings.push(warning(
205 Some(branch.clone()),
206 "cannot inspect upstream default branch",
207 err,
208 ));
209 findings.push(BranchCleanupFinding::Skipped {
210 branch: Some(branch),
211 reason: BranchCleanupSkipReason::DefaultBranchUnknown,
212 });
213 return None;
214 }
215 };
216 if branch == default_branch {
217 findings.push(BranchCleanupFinding::Skipped {
218 branch: Some(branch),
219 reason: BranchCleanupSkipReason::DefaultBranch,
220 });
221 return None;
222 }
223
224 let upstream_oid = match source.remote_branch_oid(&upstream_remote, &branch) {
225 Ok(oid) => oid,
226 Err(err) => {
227 findings.push(warning(
228 Some(branch.clone()),
229 "cannot inspect upstream branch",
230 err,
231 ));
232 None
233 }
234 };
235
236 if let Some(provider) = provider {
237 match provider.merged_pull_request(&branch, &source_oid) {
238 Ok(Some(merged_pr))
239 if merged_pr.head_ref_name == branch && merged_pr.head_ref_oid == source_oid =>
240 {
241 return Some(BranchCleanupCandidate {
242 branch,
243 source_oid,
244 upstream_remote,
245 upstream_oid,
246 proof: BranchCleanupProof::MergedPullRequest(merged_pr),
247 });
248 }
249 Ok(Some(_)) => {
250 findings.push(BranchCleanupFinding::Warning {
251 branch: Some(branch.clone()),
252 message: "provider proof did not match the source branch tip".to_owned(),
253 });
254 }
255 Ok(None) => {}
256 Err(err) => {
257 findings.push(warning(
258 Some(branch.clone()),
259 "provider branch cleanup probe failed",
260 err,
261 ));
262 }
263 }
264 }
265
266 match source.is_ancestor_oid(&source_oid, &default_oid) {
267 Ok(true) => Some(BranchCleanupCandidate {
268 branch,
269 source_oid,
270 upstream_remote: upstream_remote.clone(),
271 upstream_oid,
272 proof: BranchCleanupProof::AncestorOfDefaultBranch {
273 remote: upstream_remote,
274 default_branch,
275 default_oid,
276 },
277 }),
278 Ok(false) => {
279 findings.push(BranchCleanupFinding::Skipped {
280 branch: Some(branch),
281 reason: BranchCleanupSkipReason::NoProof,
282 });
283 None
284 }
285 Err(err) => {
286 findings.push(warning(
287 Some(branch.clone()),
288 "cannot prove source branch is merged",
289 err,
290 ));
291 None
292 }
293 }
294}
295
296fn source_upstream_remote(
297 source: &SourceRepo,
298 branch: &BranchName,
299 findings: &mut Vec<BranchCleanupFinding>,
300) -> Option<RemoteName> {
301 match source.upstream_for(branch) {
302 Ok(Some(upstream)) => Some(upstream.remote),
303 Ok(None) => Some(origin_remote()),
304 Err(err) => {
305 findings.push(warning(
306 Some(branch.clone()),
307 "cannot inspect source branch upstream",
308 err,
309 ));
310 None
311 }
312 }
313}
314
315fn origin_remote() -> RemoteName {
316 RemoteName::parse("origin").expect("origin is a valid remote name")
317}
318
319fn warning(branch: Option<BranchName>, context: &str, err: OutpostError) -> BranchCleanupFinding {
320 BranchCleanupFinding::Warning {
321 branch,
322 message: format!("{context}: {err}"),
323 }
324}