Skip to main content

outpost_core/ops/
analyze.rs

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}