Skip to main content

outpost_core/ops/
remove.rs

1use std::path::PathBuf;
2
3use crate::ops::branch_analysis::{self, BranchCleanupFinding};
4use crate::selector::{OutpostSelector, resolve_entry};
5use crate::{BranchName, OutpostError, OutpostResult, RemoteName, SourceRepo, safety};
6
7pub use crate::ops::branch_analysis::{
8    BranchCleanupCandidate, BranchCleanupProof, BranchCleanupProvider, BranchCleanupSkipReason,
9    MergedPullRequest,
10};
11
12pub struct RemoveOptions {
13    pub selector: OutpostSelector,
14    pub force: bool,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct RemoveReport {
19    pub path: PathBuf,
20    pub branch_cleanup: Vec<BranchCleanupOutcome>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum BranchCleanupOutcome {
25    Skipped {
26        branch: Option<BranchName>,
27        reason: BranchCleanupSkipReason,
28    },
29    DeclinedSourceBranch {
30        branch: BranchName,
31    },
32    DeletedSourceBranch {
33        branch: BranchName,
34    },
35    DeclinedUpstreamBranch {
36        remote: RemoteName,
37        branch: BranchName,
38    },
39    DeletedUpstreamBranch {
40        remote: RemoteName,
41        branch: BranchName,
42    },
43    Warning {
44        branch: Option<BranchName>,
45        message: String,
46    },
47}
48
49pub trait BranchCleanupPrompt {
50    fn confirm_source_branch_delete(&mut self, candidate: &BranchCleanupCandidate) -> bool;
51    fn confirm_upstream_branch_delete(&mut self, candidate: &BranchCleanupCandidate) -> bool;
52}
53
54pub struct BranchCleanupOptions<'a> {
55    pub provider: Option<&'a dyn BranchCleanupProvider>,
56    pub prompt: &'a mut dyn BranchCleanupPrompt,
57}
58
59pub enum BranchCleanupMode<'a> {
60    Disabled,
61    NonInteractive,
62    Prompt(BranchCleanupOptions<'a>),
63}
64
65pub fn run(source: &SourceRepo, opts: RemoveOptions) -> OutpostResult<()> {
66    run_internal(source, opts, BranchCleanupMode::Disabled).map(|_| ())
67}
68
69pub fn run_with_cleanup(
70    source: &SourceRepo,
71    opts: RemoveOptions,
72    mode: BranchCleanupMode<'_>,
73) -> OutpostResult<RemoveReport> {
74    run_internal(source, opts, mode)
75}
76
77fn run_internal(
78    source: &SourceRepo,
79    opts: RemoveOptions,
80    mut mode: BranchCleanupMode<'_>,
81) -> OutpostResult<RemoveReport> {
82    let mut branch_cleanup = Vec::new();
83    let entry = resolve_entry(source, &opts.selector)?.entry;
84    let report_path = entry.path.clone();
85
86    if entry.locked && !opts.force {
87        return Err(OutpostError::OutpostLocked {
88            path: entry.path,
89            reason: lock_reason(&entry.lock_reason),
90        });
91    }
92
93    if !entry.path.exists() {
94        let mut registry = source.registry_mut()?;
95        registry.remove_by_path(&entry.path)?;
96        registry.save()?;
97        record_mode_skip(
98            &mode,
99            &mut branch_cleanup,
100            BranchCleanupSkipReason::MissingOutpost,
101        );
102        return Ok(RemoveReport {
103            path: report_path,
104            branch_cleanup,
105        });
106    }
107
108    let outpost = safety::check_entry_is_managed_outpost_of(source, &entry)?;
109    if !opts.force {
110        safety::check_clean(outpost.work_tree(), outpost.git())?;
111        safety::check_no_unpushed(&outpost, source)?;
112    }
113
114    let candidate = match &mut mode {
115        BranchCleanupMode::Disabled => {
116            branch_cleanup.push(BranchCleanupOutcome::Skipped {
117                branch: None,
118                reason: BranchCleanupSkipReason::CleanupDisabled,
119            });
120            None
121        }
122        BranchCleanupMode::NonInteractive => {
123            branch_cleanup.push(BranchCleanupOutcome::Skipped {
124                branch: None,
125                reason: BranchCleanupSkipReason::NonInteractive,
126            });
127            None
128        }
129        BranchCleanupMode::Prompt(options) => {
130            analyze_branch_cleanup(source, &outpost, options.provider, &mut branch_cleanup)
131        }
132    };
133
134    let mut registry = source.registry_mut()?;
135    registry.remove_by_path(&entry.path)?;
136    registry.save()?;
137    std::fs::remove_dir_all(&entry.path).map_err(|source| OutpostError::IoAt {
138        path: entry.path,
139        source,
140    })?;
141
142    if let (Some(candidate), BranchCleanupMode::Prompt(options)) = (candidate, mode) {
143        perform_branch_cleanup(source, candidate, options.prompt, &mut branch_cleanup);
144    }
145
146    Ok(RemoveReport {
147        path: report_path,
148        branch_cleanup,
149    })
150}
151
152fn lock_reason(reason: &Option<String>) -> String {
153    reason
154        .as_ref()
155        .map(|reason| format!(": {reason}"))
156        .unwrap_or_default()
157}
158
159fn record_mode_skip(
160    mode: &BranchCleanupMode<'_>,
161    branch_cleanup: &mut Vec<BranchCleanupOutcome>,
162    missing_prompt_reason: BranchCleanupSkipReason,
163) {
164    let reason = match mode {
165        BranchCleanupMode::Disabled => BranchCleanupSkipReason::CleanupDisabled,
166        BranchCleanupMode::NonInteractive => BranchCleanupSkipReason::NonInteractive,
167        BranchCleanupMode::Prompt(_) => missing_prompt_reason,
168    };
169    branch_cleanup.push(BranchCleanupOutcome::Skipped {
170        branch: None,
171        reason,
172    });
173}
174
175fn analyze_branch_cleanup(
176    source: &SourceRepo,
177    outpost: &crate::Outpost,
178    provider: Option<&dyn BranchCleanupProvider>,
179    outcomes: &mut Vec<BranchCleanupOutcome>,
180) -> Option<BranchCleanupCandidate> {
181    let analysis = branch_analysis::analyze_branch_cleanup(source, outpost, provider);
182    outcomes.extend(
183        analysis
184            .findings
185            .into_iter()
186            .map(BranchCleanupOutcome::from),
187    );
188    analysis.candidate
189}
190
191impl From<BranchCleanupFinding> for BranchCleanupOutcome {
192    fn from(finding: BranchCleanupFinding) -> Self {
193        match finding {
194            BranchCleanupFinding::Skipped { branch, reason } => {
195                BranchCleanupOutcome::Skipped { branch, reason }
196            }
197            BranchCleanupFinding::Warning { branch, message } => {
198                BranchCleanupOutcome::Warning { branch, message }
199            }
200        }
201    }
202}
203
204fn perform_branch_cleanup(
205    source: &SourceRepo,
206    candidate: BranchCleanupCandidate,
207    prompt: &mut dyn BranchCleanupPrompt,
208    outcomes: &mut Vec<BranchCleanupOutcome>,
209) {
210    if !prompt.confirm_source_branch_delete(&candidate) {
211        outcomes.push(BranchCleanupOutcome::DeclinedSourceBranch {
212            branch: candidate.branch,
213        });
214        return;
215    }
216
217    if let Err(err) = source.delete_branch_if_oid(&candidate.branch, &candidate.source_oid) {
218        outcomes.push(warning(
219            Some(candidate.branch),
220            "source branch was not deleted",
221            err,
222        ));
223        return;
224    }
225    outcomes.push(BranchCleanupOutcome::DeletedSourceBranch {
226        branch: candidate.branch.clone(),
227    });
228
229    let Some(expected_upstream_oid) = candidate.upstream_oid.as_deref() else {
230        return;
231    };
232    match source.remote_branch_oid(&candidate.upstream_remote, &candidate.branch) {
233        Ok(Some(current_oid)) if current_oid == expected_upstream_oid => {}
234        Ok(_) => {
235            outcomes.push(BranchCleanupOutcome::Warning {
236                branch: Some(candidate.branch),
237                message: "upstream branch changed or disappeared before deletion".to_owned(),
238            });
239            return;
240        }
241        Err(err) => {
242            outcomes.push(warning(
243                Some(candidate.branch),
244                "cannot re-check upstream branch before deletion",
245                err,
246            ));
247            return;
248        }
249    }
250
251    if !prompt.confirm_upstream_branch_delete(&candidate) {
252        outcomes.push(BranchCleanupOutcome::DeclinedUpstreamBranch {
253            remote: candidate.upstream_remote,
254            branch: candidate.branch,
255        });
256        return;
257    }
258
259    match source.delete_remote_branch_if_oid(
260        &candidate.upstream_remote,
261        &candidate.branch,
262        expected_upstream_oid,
263    ) {
264        Ok(()) => outcomes.push(BranchCleanupOutcome::DeletedUpstreamBranch {
265            remote: candidate.upstream_remote,
266            branch: candidate.branch,
267        }),
268        Err(err) => outcomes.push(warning(
269            Some(candidate.branch),
270            "upstream branch was not deleted",
271            err,
272        )),
273    }
274}
275
276fn warning(branch: Option<BranchName>, context: &str, err: OutpostError) -> BranchCleanupOutcome {
277    BranchCleanupOutcome::Warning {
278        branch,
279        message: format!("{context}: {err}"),
280    }
281}