outpost-core 0.1.3

Core library for Git Outpost, a clone-backed alternative to git worktree workflows.
Documentation
use crate::{BranchName, Outpost, OutpostError, OutpostResult, RemoteName, SourceRepo};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchCleanupAnalysis {
    pub candidate: Option<BranchCleanupCandidate>,
    pub findings: Vec<BranchCleanupFinding>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchCleanupCandidate {
    pub branch: BranchName,
    pub source_oid: String,
    pub upstream_remote: RemoteName,
    pub upstream_oid: Option<String>,
    pub proof: BranchCleanupProof,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BranchCleanupProof {
    MergedPullRequest(MergedPullRequest),
    AncestorOfDefaultBranch {
        remote: RemoteName,
        default_branch: BranchName,
        default_oid: String,
    },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergedPullRequest {
    pub id: String,
    pub head_ref_name: BranchName,
    pub head_ref_oid: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BranchCleanupFinding {
    Skipped {
        branch: Option<BranchName>,
        reason: BranchCleanupSkipReason,
    },
    Warning {
        branch: Option<BranchName>,
        message: String,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchCleanupSkipReason {
    CleanupDisabled,
    NonInteractive,
    MissingOutpost,
    DetachedHead,
    NoUpstreamTracking,
    UpstreamRemoteMismatch,
    UpstreamNotBranch,
    SourceBranchMissing,
    OutpostHeadMismatch,
    BranchCheckedOut,
    DefaultBranch,
    DefaultBranchUnknown,
    NoProof,
}

pub trait BranchCleanupProvider {
    fn merged_pull_request(
        &self,
        branch: &BranchName,
        source_oid: &str,
    ) -> OutpostResult<Option<MergedPullRequest>>;
}

pub fn analyze_branch_cleanup(
    source: &SourceRepo,
    outpost: &Outpost,
    provider: Option<&dyn BranchCleanupProvider>,
) -> BranchCleanupAnalysis {
    let mut findings = Vec::new();
    let candidate = analyze_candidate(source, outpost, provider, &mut findings);
    BranchCleanupAnalysis {
        candidate,
        findings,
    }
}

fn analyze_candidate(
    source: &SourceRepo,
    outpost: &Outpost,
    provider: Option<&dyn BranchCleanupProvider>,
    findings: &mut Vec<BranchCleanupFinding>,
) -> Option<BranchCleanupCandidate> {
    let upstream = match outpost.upstream_tracking() {
        Ok(Some(upstream)) => upstream,
        Ok(None) => {
            findings.push(BranchCleanupFinding::Skipped {
                branch: None,
                reason: BranchCleanupSkipReason::NoUpstreamTracking,
            });
            return None;
        }
        Err(OutpostError::BranchNotFound { .. }) => {
            findings.push(BranchCleanupFinding::Skipped {
                branch: None,
                reason: BranchCleanupSkipReason::DetachedHead,
            });
            return None;
        }
        Err(err) => {
            findings.push(warning(None, "cannot inspect outpost upstream", err));
            return None;
        }
    };

    if upstream.remote != outpost.metadata().remote_name {
        findings.push(BranchCleanupFinding::Skipped {
            branch: None,
            reason: BranchCleanupSkipReason::UpstreamRemoteMismatch,
        });
        return None;
    }

    let Some(branch) = upstream.short_branch() else {
        findings.push(BranchCleanupFinding::Skipped {
            branch: None,
            reason: BranchCleanupSkipReason::UpstreamNotBranch,
        });
        return None;
    };
    let branch = match BranchName::parse(branch.to_owned()) {
        Ok(branch) => branch,
        Err(err) => {
            findings.push(warning(None, "cannot parse outpost upstream branch", err));
            return None;
        }
    };

    let Some(source_oid) = (match source.branch_oid(&branch) {
        Ok(oid) => oid,
        Err(err) => {
            findings.push(warning(
                Some(branch.clone()),
                "cannot inspect source branch",
                err,
            ));
            return None;
        }
    }) else {
        findings.push(BranchCleanupFinding::Skipped {
            branch: Some(branch),
            reason: BranchCleanupSkipReason::SourceBranchMissing,
        });
        return None;
    };

    let outpost_oid = match outpost.git().run_capture(["rev-parse", "HEAD"]) {
        Ok(oid) => oid,
        Err(err) => {
            findings.push(warning(
                Some(branch.clone()),
                "cannot inspect outpost HEAD",
                err,
            ));
            return None;
        }
    };
    if outpost_oid != source_oid {
        findings.push(BranchCleanupFinding::Skipped {
            branch: Some(branch),
            reason: BranchCleanupSkipReason::OutpostHeadMismatch,
        });
        return None;
    }

    match source.is_branch_checked_out(&branch) {
        Ok(true) => {
            findings.push(BranchCleanupFinding::Skipped {
                branch: Some(branch),
                reason: BranchCleanupSkipReason::BranchCheckedOut,
            });
            return None;
        }
        Ok(false) => {}
        Err(err) => {
            findings.push(warning(
                Some(branch.clone()),
                "cannot inspect checked-out source branches",
                err,
            ));
            return None;
        }
    }

    let upstream_remote = source_upstream_remote(source, &branch, findings)?;

    let (default_branch, default_oid) = match source.fetch_remote_default_branch(&upstream_remote) {
        Ok(Some(default)) => default,
        Ok(None) => {
            findings.push(BranchCleanupFinding::Skipped {
                branch: Some(branch),
                reason: BranchCleanupSkipReason::DefaultBranchUnknown,
            });
            return None;
        }
        Err(err) => {
            findings.push(warning(
                Some(branch.clone()),
                "cannot inspect upstream default branch",
                err,
            ));
            findings.push(BranchCleanupFinding::Skipped {
                branch: Some(branch),
                reason: BranchCleanupSkipReason::DefaultBranchUnknown,
            });
            return None;
        }
    };
    if branch == default_branch {
        findings.push(BranchCleanupFinding::Skipped {
            branch: Some(branch),
            reason: BranchCleanupSkipReason::DefaultBranch,
        });
        return None;
    }

    let upstream_oid = match source.remote_branch_oid(&upstream_remote, &branch) {
        Ok(oid) => oid,
        Err(err) => {
            findings.push(warning(
                Some(branch.clone()),
                "cannot inspect upstream branch",
                err,
            ));
            None
        }
    };

    if let Some(provider) = provider {
        match provider.merged_pull_request(&branch, &source_oid) {
            Ok(Some(merged_pr))
                if merged_pr.head_ref_name == branch && merged_pr.head_ref_oid == source_oid =>
            {
                return Some(BranchCleanupCandidate {
                    branch,
                    source_oid,
                    upstream_remote,
                    upstream_oid,
                    proof: BranchCleanupProof::MergedPullRequest(merged_pr),
                });
            }
            Ok(Some(_)) => {
                findings.push(BranchCleanupFinding::Warning {
                    branch: Some(branch.clone()),
                    message: "provider proof did not match the source branch tip".to_owned(),
                });
            }
            Ok(None) => {}
            Err(err) => {
                findings.push(warning(
                    Some(branch.clone()),
                    "provider branch cleanup probe failed",
                    err,
                ));
            }
        }
    }

    match source.is_ancestor_oid(&source_oid, &default_oid) {
        Ok(true) => Some(BranchCleanupCandidate {
            branch,
            source_oid,
            upstream_remote: upstream_remote.clone(),
            upstream_oid,
            proof: BranchCleanupProof::AncestorOfDefaultBranch {
                remote: upstream_remote,
                default_branch,
                default_oid,
            },
        }),
        Ok(false) => {
            findings.push(BranchCleanupFinding::Skipped {
                branch: Some(branch),
                reason: BranchCleanupSkipReason::NoProof,
            });
            None
        }
        Err(err) => {
            findings.push(warning(
                Some(branch.clone()),
                "cannot prove source branch is merged",
                err,
            ));
            None
        }
    }
}

fn source_upstream_remote(
    source: &SourceRepo,
    branch: &BranchName,
    findings: &mut Vec<BranchCleanupFinding>,
) -> Option<RemoteName> {
    match source.upstream_for(branch) {
        Ok(Some(upstream)) => Some(upstream.remote),
        Ok(None) => Some(origin_remote()),
        Err(err) => {
            findings.push(warning(
                Some(branch.clone()),
                "cannot inspect source branch upstream",
                err,
            ));
            None
        }
    }
}

fn origin_remote() -> RemoteName {
    RemoteName::parse("origin").expect("origin is a valid remote name")
}

fn warning(branch: Option<BranchName>, context: &str, err: OutpostError) -> BranchCleanupFinding {
    BranchCleanupFinding::Warning {
        branch,
        message: format!("{context}: {err}"),
    }
}