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}