1use std::path::{Path, PathBuf};
2
3use crate::ops::branch_analysis::{
4 self, BranchCleanupCandidate, BranchCleanupFinding, BranchCleanupProvider,
5 BranchCleanupSkipReason,
6};
7use crate::selector::{OutpostSelector, resolve_entry};
8use crate::source_repo::read_optional_config;
9use crate::{
10 AheadBehind, BranchName, GitInvoker, Outpost, OutpostError, OutpostResult, RemoteName,
11 Reporter, SourceRepo, StepKind,
12};
13
14pub struct AnalyzeOptions {
15 pub selector: OutpostSelector,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AnalyzeReport {
20 pub outpost_path: PathBuf,
21 pub source_path: PathBuf,
22 pub locked: bool,
23 pub lock_reason: Option<String>,
24 pub branch: Option<BranchName>,
25 pub outpost_dirty: bool,
26 pub upstream_remote: Probe<UpstreamRemote>,
27 pub outpost_vs_source: Probe<AheadBehind>,
28 pub source_vs_upstream: Probe<AheadBehind>,
29 pub source_vs_upstream_default: Probe<AheadBehind>,
30 pub upstream_default_branch: Probe<RemoteBranchIdentity>,
31 pub upstream_branch: Probe<RemoteBranchIdentity>,
32 pub source_push_hazard: Probe<SourcePushHazard>,
33 pub safe_delete: BranchDeleteSafety,
34 pub safe_delete_findings: Vec<BranchCleanupFinding>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum Probe<T> {
39 Known(T),
40 Unknown(String),
41 Unavailable(String),
42}
43
44impl<T> Probe<T> {
45 pub fn as_ref(&self) -> Probe<&T> {
46 match self {
47 Self::Known(value) => Probe::Known(value),
48 Self::Unknown(reason) => Probe::Unknown(reason.clone()),
49 Self::Unavailable(reason) => Probe::Unavailable(reason.clone()),
50 }
51 }
52
53 pub fn map<U, F>(self, f: F) -> Probe<U>
54 where
55 F: FnOnce(T) -> U,
56 {
57 match self {
58 Self::Known(value) => Probe::Known(f(value)),
59 Self::Unknown(reason) => Probe::Unknown(reason),
60 Self::Unavailable(reason) => Probe::Unavailable(reason),
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct RemoteBranchIdentity {
67 pub remote: RemoteName,
68 pub branch: BranchName,
69 pub oid: String,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct UpstreamRemote {
74 pub remote: RemoteName,
75 pub url: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79struct SourceUpstreamBranch {
80 remote: RemoteName,
81 branch: BranchName,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct SourcePushHazard {
86 pub checked_out: bool,
87 pub push_would_fail: bool,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum BranchDeleteSafety {
92 Yes(BranchCleanupCandidate),
93 No {
94 branch: Option<BranchName>,
95 reason: BranchCleanupSkipReason,
96 },
97 Unknown(String),
98}
99
100pub fn run(
101 source: &SourceRepo,
102 opts: AnalyzeOptions,
103 provider: Option<&dyn BranchCleanupProvider>,
104) -> OutpostResult<AnalyzeReport> {
105 let mut reporter = SilentReporter;
106 run_with_reporter(source, opts, provider, &mut reporter)
107}
108
109pub fn run_with_reporter(
110 source: &SourceRepo,
111 opts: AnalyzeOptions,
112 provider: Option<&dyn BranchCleanupProvider>,
113 reporter: &mut dyn Reporter,
114) -> OutpostResult<AnalyzeReport> {
115 reporter.step(StepKind::Analysis, "resolving outpost");
116 let resolved = resolve_entry(source, &opts.selector)?;
117 let entry = resolved.entry;
118 let outpost = crate::safety::check_entry_is_managed_outpost_of(source, &entry)?;
119 reporter.step(
120 StepKind::Analysis,
121 &format!("resolved: {}", outpost.work_tree().display()),
122 );
123
124 reporter.step(StepKind::Analysis, "checking outpost state");
125 let branch = current_branch_optional(&outpost)?;
126 let outpost_dirty = outpost.is_dirty()?;
127 reporter.step(
128 StepKind::Analysis,
129 &format!(
130 "{}, branch {}, lock {}",
131 if outpost_dirty { "dirty" } else { "clean" },
132 branch
133 .as_ref()
134 .map(|branch| branch.as_str())
135 .unwrap_or("detached"),
136 if entry.locked { "locked" } else { "unlocked" }
137 ),
138 );
139
140 reporter.step(StepKind::Analysis, "comparing outpost and source");
141 let outpost_vs_source = probe_ahead_behind_outpost_source(&outpost);
142 reporter.step(
143 StepKind::Analysis,
144 &format_probe_ahead_behind(&outpost_vs_source),
145 );
146
147 let source_upstream = match branch.as_ref() {
148 Some(branch) => probe_source_upstream(source, branch),
149 None => Probe::Unknown("outpost HEAD is detached".to_owned()),
150 };
151
152 reporter.step(StepKind::Analysis, "checking upstream remote");
153 let upstream_remote = match source_upstream.as_ref() {
154 Probe::Known(upstream) => probe_upstream_remote(source, &upstream.remote),
155 Probe::Unknown(reason) => Probe::Unknown(reason),
156 Probe::Unavailable(reason) => Probe::Unavailable(reason),
157 };
158 reporter.step(
159 StepKind::Analysis,
160 &format_probe_upstream_remote(&upstream_remote),
161 );
162
163 reporter.step(StepKind::Analysis, "checking upstream branch");
164 let upstream_branch = match source_upstream.as_ref() {
165 Probe::Known(upstream) => probe_remote_branch(source, &upstream.remote, &upstream.branch),
166 Probe::Unknown(reason) => Probe::Unknown(reason),
167 Probe::Unavailable(reason) => Probe::Unavailable(reason),
168 };
169 reporter.step(StepKind::Analysis, &format_probe_identity(&upstream_branch));
170
171 reporter.step(StepKind::Analysis, "discovering upstream default branch");
172 let upstream_default_branch = match source_upstream.as_ref() {
173 Probe::Known(upstream) => probe_default_branch(source, &upstream.remote),
174 Probe::Unknown(reason) => Probe::Unknown(reason),
175 Probe::Unavailable(reason) => Probe::Unavailable(reason),
176 };
177 reporter.step(
178 StepKind::Analysis,
179 &format_probe_identity(&upstream_default_branch),
180 );
181 reporter.step(StepKind::Analysis, "comparing source and upstream");
182 let source_vs_upstream = match (
183 branch.as_ref(),
184 source_upstream.as_ref(),
185 upstream_branch.as_ref(),
186 ) {
187 (Some(branch), Probe::Known(upstream), Probe::Known(_)) => probe_source_vs_remote_ref(
188 source,
189 branch,
190 &remote_branch_ref(&upstream.remote, &upstream.branch),
191 ),
192 (Some(_), Probe::Unknown(reason), _) | (Some(_), _, Probe::Unknown(reason)) => {
193 Probe::Unknown(reason)
194 }
195 (Some(_), Probe::Unavailable(reason), _) | (Some(_), _, Probe::Unavailable(reason)) => {
196 Probe::Unavailable(reason)
197 }
198 (None, _, _) => Probe::Unknown("outpost HEAD is detached".to_owned()),
199 };
200 reporter.step(
201 StepKind::Analysis,
202 &format_probe_ahead_behind(&source_vs_upstream),
203 );
204 reporter.step(StepKind::Analysis, "comparing source and upstream default");
205 let source_vs_upstream_default = match (branch.as_ref(), upstream_default_branch.as_ref()) {
206 (Some(branch), Probe::Known(default)) => probe_source_vs_remote_ref(
207 source,
208 branch,
209 &remote_branch_ref(&default.remote, &default.branch),
210 ),
211 (Some(_), Probe::Unknown(reason)) => Probe::Unknown(reason),
212 (Some(_), Probe::Unavailable(reason)) => Probe::Unavailable(reason),
213 (None, _) => Probe::Unknown("outpost HEAD is detached".to_owned()),
214 };
215 reporter.step(
216 StepKind::Analysis,
217 &format_probe_ahead_behind(&source_vs_upstream_default),
218 );
219 reporter.step(StepKind::Analysis, "checking source push hazard");
220 let source_push_hazard = match branch.as_ref() {
221 Some(branch) => probe_source_push_hazard(source, branch),
222 None => Probe::Unknown("outpost HEAD is detached".to_owned()),
223 };
224 reporter.step(
225 StepKind::Analysis,
226 &format_probe_push_hazard(&source_push_hazard),
227 );
228 reporter.step(StepKind::Analysis, "checking safe branch deletion proof");
229 let delete_analysis = branch_analysis::analyze_branch_cleanup(source, &outpost, provider);
230 let safe_delete = branch_delete_safety(&delete_analysis);
231 reporter.step(StepKind::Analysis, &format_safe_delete(&safe_delete));
232
233 Ok(AnalyzeReport {
234 outpost_path: outpost.work_tree().to_path_buf(),
235 source_path: source.work_tree().to_path_buf(),
236 locked: entry.locked,
237 lock_reason: entry.lock_reason,
238 branch,
239 outpost_dirty,
240 upstream_remote,
241 outpost_vs_source,
242 source_vs_upstream,
243 source_vs_upstream_default,
244 upstream_default_branch,
245 upstream_branch,
246 source_push_hazard,
247 safe_delete,
248 safe_delete_findings: delete_analysis.findings,
249 })
250}
251
252struct SilentReporter;
253
254impl Reporter for SilentReporter {
255 fn step(&mut self, _kind: StepKind, _message: &str) {}
256
257 fn warn(&mut self, _message: &str) {}
258}
259
260fn current_branch_optional(outpost: &Outpost) -> OutpostResult<Option<BranchName>> {
261 match outpost.current_branch() {
262 Ok(branch) => Ok(Some(branch)),
263 Err(OutpostError::BranchNotFound { branch, .. }) if branch == "HEAD" => Ok(None),
264 Err(err) => Err(err),
265 }
266}
267
268fn probe_ahead_behind_outpost_source(outpost: &Outpost) -> Probe<AheadBehind> {
269 match outpost.ahead_behind_source() {
270 Ok(value) => Probe::Known(value),
271 Err(OutpostError::BranchNotFound { branch, .. }) if branch == "HEAD" => {
272 Probe::Unknown("outpost HEAD is detached".to_owned())
273 }
274 Err(OutpostError::NoUpstreamTracking { .. }) => {
275 Probe::Unknown("outpost has no upstream tracking branch".to_owned())
276 }
277 Err(OutpostError::UpstreamNotABranch { .. }) => {
278 Probe::Unknown("outpost upstream is not a branch".to_owned())
279 }
280 Err(err) => Probe::Unavailable(err.to_string()),
281 }
282}
283
284fn format_probe_ahead_behind(value: &Probe<AheadBehind>) -> String {
285 match value {
286 Probe::Known(value) => format!("ahead {}, behind {}", value.ahead, value.behind),
287 Probe::Unknown(reason) => format!("unknown: {reason}"),
288 Probe::Unavailable(reason) => format!("unavailable: {reason}"),
289 }
290}
291
292fn format_probe_identity(value: &Probe<RemoteBranchIdentity>) -> String {
293 match value {
294 Probe::Known(identity) => {
295 format!(
296 "{}/{} at {}",
297 identity.remote.as_str(),
298 identity.branch.as_str(),
299 short_oid(&identity.oid)
300 )
301 }
302 Probe::Unknown(reason) => format!("unknown: {reason}"),
303 Probe::Unavailable(reason) => format!("unavailable: {reason}"),
304 }
305}
306
307fn format_probe_upstream_remote(value: &Probe<UpstreamRemote>) -> String {
308 match value {
309 Probe::Known(upstream) => format!("{} {}", upstream.remote.as_str(), upstream.url),
310 Probe::Unknown(reason) => format!("unknown: {reason}"),
311 Probe::Unavailable(reason) => format!("unavailable: {reason}"),
312 }
313}
314
315fn format_probe_push_hazard(value: &Probe<SourcePushHazard>) -> String {
316 match value {
317 Probe::Known(hazard) if hazard.push_would_fail => "yes".to_owned(),
318 Probe::Known(_) => "no".to_owned(),
319 Probe::Unknown(reason) => format!("unknown: {reason}"),
320 Probe::Unavailable(reason) => format!("unavailable: {reason}"),
321 }
322}
323
324fn format_safe_delete(value: &BranchDeleteSafety) -> String {
325 match value {
326 BranchDeleteSafety::Yes(candidate) => {
327 format!("yes: {}", candidate.branch.as_str())
328 }
329 BranchDeleteSafety::No { branch, reason } => {
330 let branch = branch
331 .as_ref()
332 .map(|branch| format!("{}: ", branch.as_str()))
333 .unwrap_or_default();
334 format!("no: {branch}{}", branch_cleanup_reason_text(*reason))
335 }
336 BranchDeleteSafety::Unknown(reason) => format!("unknown: {reason}"),
337 }
338}
339
340fn branch_cleanup_reason_text(reason: BranchCleanupSkipReason) -> &'static str {
341 match reason {
342 BranchCleanupSkipReason::CleanupDisabled => "cleanup disabled",
343 BranchCleanupSkipReason::NonInteractive => "non-interactive",
344 BranchCleanupSkipReason::MissingOutpost => "outpost path was already missing",
345 BranchCleanupSkipReason::DetachedHead => "outpost HEAD is detached",
346 BranchCleanupSkipReason::NoUpstreamTracking => "outpost has no upstream tracking branch",
347 BranchCleanupSkipReason::UpstreamRemoteMismatch => "outpost upstream remote mismatch",
348 BranchCleanupSkipReason::UpstreamNotBranch => "outpost upstream is not a branch",
349 BranchCleanupSkipReason::SourceBranchMissing => "source branch is missing",
350 BranchCleanupSkipReason::OutpostHeadMismatch => {
351 "outpost HEAD does not match source branch tip"
352 }
353 BranchCleanupSkipReason::BranchCheckedOut => "branch is checked out",
354 BranchCleanupSkipReason::DefaultBranch => "branch is the upstream default branch",
355 BranchCleanupSkipReason::DefaultBranchUnknown => "upstream default branch is unknown",
356 BranchCleanupSkipReason::NoProof => "no safe deletion proof found",
357 }
358}
359
360fn short_oid(oid: &str) -> &str {
361 oid.get(..12).unwrap_or(oid)
362}
363
364fn probe_source_upstream(source: &SourceRepo, branch: &BranchName) -> Probe<SourceUpstreamBranch> {
365 match source.upstream_for(branch) {
366 Ok(Some(upstream)) => {
367 let Some(upstream_branch) = upstream.short_branch() else {
368 return Probe::Unknown("source upstream is not a branch".to_owned());
369 };
370 match BranchName::parse(upstream_branch.to_owned()) {
371 Ok(branch) => Probe::Known(SourceUpstreamBranch {
372 remote: upstream.remote,
373 branch,
374 }),
375 Err(err) => Probe::Unavailable(err.to_string()),
376 }
377 }
378 Ok(None) => Probe::Known(SourceUpstreamBranch {
379 remote: origin_remote(),
380 branch: branch.clone(),
381 }),
382 Err(err) => Probe::Unavailable(err.to_string()),
383 }
384}
385
386fn probe_upstream_remote(source: &SourceRepo, remote: &RemoteName) -> Probe<UpstreamRemote> {
387 match source.remote_url(remote) {
388 Ok(url) => Probe::Known(UpstreamRemote {
389 remote: remote.clone(),
390 url,
391 }),
392 Err(err) => Probe::Unavailable(err.to_string()),
393 }
394}
395
396fn probe_default_branch(source: &SourceRepo, remote: &RemoteName) -> Probe<RemoteBranchIdentity> {
397 match source.fetch_remote_default_branch(remote) {
398 Ok(Some((branch, oid))) => Probe::Known(RemoteBranchIdentity {
399 remote: remote.clone(),
400 branch,
401 oid,
402 }),
403 Ok(None) => Probe::Unknown(format!("{} default branch is unknown", remote.as_str())),
404 Err(err) => Probe::Unavailable(err.to_string()),
405 }
406}
407
408fn probe_remote_branch(
409 source: &SourceRepo,
410 remote: &RemoteName,
411 branch: &BranchName,
412) -> Probe<RemoteBranchIdentity> {
413 match source.remote_branch_oid(remote, branch) {
414 Ok(Some(_)) => {}
415 Ok(None) => {
416 return Probe::Unknown(format!(
417 "{}/{} is missing",
418 remote.as_str(),
419 branch.as_str()
420 ));
421 }
422 Err(err) => return Probe::Unavailable(err.to_string()),
423 }
424
425 let remote_tracking_ref = remote_branch_ref(remote, branch);
426 let fetch_refspec = format!("+{}:{remote_tracking_ref}", source_branch_ref(branch));
427 if let Err(err) = source
428 .git()
429 .run_check(["fetch", remote.as_str(), &fetch_refspec])
430 {
431 return Probe::Unavailable(err.to_string());
432 }
433
434 match rev_parse(source.git(), &remote_tracking_ref) {
435 Ok(oid) => Probe::Known(RemoteBranchIdentity {
436 remote: remote.clone(),
437 branch: branch.clone(),
438 oid,
439 }),
440 Err(err) => Probe::Unavailable(err.to_string()),
441 }
442}
443
444fn probe_source_vs_remote_ref(
445 source: &SourceRepo,
446 branch: &BranchName,
447 remote_ref: &str,
448) -> Probe<AheadBehind> {
449 match source.branch_exists(branch) {
450 Ok(true) => {}
451 Ok(false) => return Probe::Unknown("source branch is missing".to_owned()),
452 Err(err) => return Probe::Unavailable(err.to_string()),
453 }
454 match ref_exists(source.git(), remote_ref) {
455 Ok(true) => {}
456 Ok(false) => return Probe::Unknown(format!("{remote_ref} is missing")),
457 Err(err) => return Probe::Unavailable(err.to_string()),
458 }
459
460 let local_ref = source_branch_ref(branch);
461 match ahead_behind_existing_refs(source.git(), &local_ref, remote_ref) {
462 Ok(value) => Probe::Known(value),
463 Err(err) => Probe::Unavailable(err.to_string()),
464 }
465}
466
467fn probe_source_push_hazard(source: &SourceRepo, branch: &BranchName) -> Probe<SourcePushHazard> {
468 match source.branch_exists(branch) {
469 Ok(true) => {}
470 Ok(false) => return Probe::Unknown("source branch is missing".to_owned()),
471 Err(err) => return Probe::Unavailable(err.to_string()),
472 }
473
474 let checked_out = match source.is_branch_checked_out(branch) {
475 Ok(value) => value,
476 Err(err) => return Probe::Unavailable(err.to_string()),
477 };
478 let update_instead = match read_optional_config(source.git(), "receive.denyCurrentBranch") {
479 Ok(value) => value.as_deref() == Some("updateInstead"),
480 Err(err) => return Probe::Unavailable(err.to_string()),
481 };
482
483 Probe::Known(SourcePushHazard {
484 checked_out,
485 push_would_fail: checked_out && !update_instead,
486 })
487}
488
489fn branch_delete_safety(analysis: &branch_analysis::BranchCleanupAnalysis) -> BranchDeleteSafety {
490 if let Some(candidate) = &analysis.candidate {
491 return BranchDeleteSafety::Yes(candidate.clone());
492 }
493
494 analysis
495 .findings
496 .iter()
497 .rev()
498 .find_map(|finding| match finding {
499 BranchCleanupFinding::Skipped { branch, reason } => Some(BranchDeleteSafety::No {
500 branch: branch.clone(),
501 reason: *reason,
502 }),
503 BranchCleanupFinding::Warning { .. } => None,
504 })
505 .unwrap_or_else(|| {
506 BranchDeleteSafety::Unknown(
507 "branch cleanup analysis did not produce a proof or skip reason".to_owned(),
508 )
509 })
510}
511
512fn ahead_behind_existing_refs(
513 git: &GitInvoker,
514 local_ref: &str,
515 remote_ref: &str,
516) -> OutpostResult<AheadBehind> {
517 let range = format!("{local_ref}...{remote_ref}");
518 let output = git.run_capture(["rev-list", "--left-right", "--count", &range])?;
519 parse_ahead_behind(git.cwd(), &output)
520}
521
522fn ref_exists(git: &GitInvoker, ref_name: &str) -> OutpostResult<bool> {
523 git.run_status(["rev-parse", "--verify", "--quiet", ref_name])
524}
525
526fn rev_parse(git: &GitInvoker, reference: &str) -> OutpostResult<String> {
527 git.run_capture(["rev-parse", reference])
528}
529
530fn parse_ahead_behind(repo: &Path, output: &str) -> OutpostResult<AheadBehind> {
531 let mut parts = output.split_whitespace();
532 let ahead = parts
533 .next()
534 .and_then(|value| value.parse::<u32>().ok())
535 .ok_or_else(|| invalid_ahead_behind_output(repo, output))?;
536 let behind = parts
537 .next()
538 .and_then(|value| value.parse::<u32>().ok())
539 .ok_or_else(|| invalid_ahead_behind_output(repo, output))?;
540 if parts.next().is_some() {
541 return Err(invalid_ahead_behind_output(repo, output));
542 }
543
544 Ok(AheadBehind { ahead, behind })
545}
546
547fn invalid_ahead_behind_output(repo: &Path, output: &str) -> OutpostError {
548 OutpostError::IoAt {
549 path: repo.to_path_buf(),
550 source: std::io::Error::new(
551 std::io::ErrorKind::InvalidData,
552 format!("unexpected rev-list output: {output}"),
553 ),
554 }
555}
556
557fn source_branch_ref(branch: &BranchName) -> String {
558 format!("refs/heads/{}", branch.as_str())
559}
560
561fn remote_branch_ref(remote: &RemoteName, branch: &BranchName) -> String {
562 format!("refs/remotes/{}/{}", remote.as_str(), branch.as_str())
563}
564
565fn origin_remote() -> RemoteName {
566 RemoteName::parse("origin").expect("origin is a valid remote name")
567}