Skip to main content

outpost_core/ops/
branch_analysis.rs

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}