outpost-core 0.1.3

Core library for Git Outpost, a clone-backed alternative to git worktree workflows.
Documentation
use std::path::PathBuf;

use crate::ops::branch_analysis::{self, BranchCleanupFinding};
use crate::selector::{OutpostSelector, resolve_entry};
use crate::{BranchName, OutpostError, OutpostResult, RemoteName, SourceRepo, safety};

pub use crate::ops::branch_analysis::{
    BranchCleanupCandidate, BranchCleanupProof, BranchCleanupProvider, BranchCleanupSkipReason,
    MergedPullRequest,
};

pub struct RemoveOptions {
    pub selector: OutpostSelector,
    pub force: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoveReport {
    pub path: PathBuf,
    pub branch_cleanup: Vec<BranchCleanupOutcome>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BranchCleanupOutcome {
    Skipped {
        branch: Option<BranchName>,
        reason: BranchCleanupSkipReason,
    },
    DeclinedSourceBranch {
        branch: BranchName,
    },
    DeletedSourceBranch {
        branch: BranchName,
    },
    DeclinedUpstreamBranch {
        remote: RemoteName,
        branch: BranchName,
    },
    DeletedUpstreamBranch {
        remote: RemoteName,
        branch: BranchName,
    },
    Warning {
        branch: Option<BranchName>,
        message: String,
    },
}

pub trait BranchCleanupPrompt {
    fn confirm_source_branch_delete(&mut self, candidate: &BranchCleanupCandidate) -> bool;
    fn confirm_upstream_branch_delete(&mut self, candidate: &BranchCleanupCandidate) -> bool;
}

pub struct BranchCleanupOptions<'a> {
    pub provider: Option<&'a dyn BranchCleanupProvider>,
    pub prompt: &'a mut dyn BranchCleanupPrompt,
}

pub enum BranchCleanupMode<'a> {
    Disabled,
    NonInteractive,
    Prompt(BranchCleanupOptions<'a>),
}

pub fn run(source: &SourceRepo, opts: RemoveOptions) -> OutpostResult<()> {
    run_internal(source, opts, BranchCleanupMode::Disabled).map(|_| ())
}

pub fn run_with_cleanup(
    source: &SourceRepo,
    opts: RemoveOptions,
    mode: BranchCleanupMode<'_>,
) -> OutpostResult<RemoveReport> {
    run_internal(source, opts, mode)
}

fn run_internal(
    source: &SourceRepo,
    opts: RemoveOptions,
    mut mode: BranchCleanupMode<'_>,
) -> OutpostResult<RemoveReport> {
    let mut branch_cleanup = Vec::new();
    let entry = resolve_entry(source, &opts.selector)?.entry;
    let report_path = entry.path.clone();

    if entry.locked && !opts.force {
        return Err(OutpostError::OutpostLocked {
            path: entry.path,
            reason: lock_reason(&entry.lock_reason),
        });
    }

    if !entry.path.exists() {
        let mut registry = source.registry_mut()?;
        registry.remove_by_path(&entry.path)?;
        registry.save()?;
        record_mode_skip(
            &mode,
            &mut branch_cleanup,
            BranchCleanupSkipReason::MissingOutpost,
        );
        return Ok(RemoveReport {
            path: report_path,
            branch_cleanup,
        });
    }

    let outpost = safety::check_entry_is_managed_outpost_of(source, &entry)?;
    if !opts.force {
        safety::check_clean(outpost.work_tree(), outpost.git())?;
        safety::check_no_unpushed(&outpost, source)?;
    }

    let candidate = match &mut mode {
        BranchCleanupMode::Disabled => {
            branch_cleanup.push(BranchCleanupOutcome::Skipped {
                branch: None,
                reason: BranchCleanupSkipReason::CleanupDisabled,
            });
            None
        }
        BranchCleanupMode::NonInteractive => {
            branch_cleanup.push(BranchCleanupOutcome::Skipped {
                branch: None,
                reason: BranchCleanupSkipReason::NonInteractive,
            });
            None
        }
        BranchCleanupMode::Prompt(options) => {
            analyze_branch_cleanup(source, &outpost, options.provider, &mut branch_cleanup)
        }
    };

    let mut registry = source.registry_mut()?;
    registry.remove_by_path(&entry.path)?;
    registry.save()?;
    std::fs::remove_dir_all(&entry.path).map_err(|source| OutpostError::IoAt {
        path: entry.path,
        source,
    })?;

    if let (Some(candidate), BranchCleanupMode::Prompt(options)) = (candidate, mode) {
        perform_branch_cleanup(source, candidate, options.prompt, &mut branch_cleanup);
    }

    Ok(RemoveReport {
        path: report_path,
        branch_cleanup,
    })
}

fn lock_reason(reason: &Option<String>) -> String {
    reason
        .as_ref()
        .map(|reason| format!(": {reason}"))
        .unwrap_or_default()
}

fn record_mode_skip(
    mode: &BranchCleanupMode<'_>,
    branch_cleanup: &mut Vec<BranchCleanupOutcome>,
    missing_prompt_reason: BranchCleanupSkipReason,
) {
    let reason = match mode {
        BranchCleanupMode::Disabled => BranchCleanupSkipReason::CleanupDisabled,
        BranchCleanupMode::NonInteractive => BranchCleanupSkipReason::NonInteractive,
        BranchCleanupMode::Prompt(_) => missing_prompt_reason,
    };
    branch_cleanup.push(BranchCleanupOutcome::Skipped {
        branch: None,
        reason,
    });
}

fn analyze_branch_cleanup(
    source: &SourceRepo,
    outpost: &crate::Outpost,
    provider: Option<&dyn BranchCleanupProvider>,
    outcomes: &mut Vec<BranchCleanupOutcome>,
) -> Option<BranchCleanupCandidate> {
    let analysis = branch_analysis::analyze_branch_cleanup(source, outpost, provider);
    outcomes.extend(
        analysis
            .findings
            .into_iter()
            .map(BranchCleanupOutcome::from),
    );
    analysis.candidate
}

impl From<BranchCleanupFinding> for BranchCleanupOutcome {
    fn from(finding: BranchCleanupFinding) -> Self {
        match finding {
            BranchCleanupFinding::Skipped { branch, reason } => {
                BranchCleanupOutcome::Skipped { branch, reason }
            }
            BranchCleanupFinding::Warning { branch, message } => {
                BranchCleanupOutcome::Warning { branch, message }
            }
        }
    }
}

fn perform_branch_cleanup(
    source: &SourceRepo,
    candidate: BranchCleanupCandidate,
    prompt: &mut dyn BranchCleanupPrompt,
    outcomes: &mut Vec<BranchCleanupOutcome>,
) {
    if !prompt.confirm_source_branch_delete(&candidate) {
        outcomes.push(BranchCleanupOutcome::DeclinedSourceBranch {
            branch: candidate.branch,
        });
        return;
    }

    if let Err(err) = source.delete_branch_if_oid(&candidate.branch, &candidate.source_oid) {
        outcomes.push(warning(
            Some(candidate.branch),
            "source branch was not deleted",
            err,
        ));
        return;
    }
    outcomes.push(BranchCleanupOutcome::DeletedSourceBranch {
        branch: candidate.branch.clone(),
    });

    let Some(expected_upstream_oid) = candidate.upstream_oid.as_deref() else {
        return;
    };
    match source.remote_branch_oid(&candidate.upstream_remote, &candidate.branch) {
        Ok(Some(current_oid)) if current_oid == expected_upstream_oid => {}
        Ok(_) => {
            outcomes.push(BranchCleanupOutcome::Warning {
                branch: Some(candidate.branch),
                message: "upstream branch changed or disappeared before deletion".to_owned(),
            });
            return;
        }
        Err(err) => {
            outcomes.push(warning(
                Some(candidate.branch),
                "cannot re-check upstream branch before deletion",
                err,
            ));
            return;
        }
    }

    if !prompt.confirm_upstream_branch_delete(&candidate) {
        outcomes.push(BranchCleanupOutcome::DeclinedUpstreamBranch {
            remote: candidate.upstream_remote,
            branch: candidate.branch,
        });
        return;
    }

    match source.delete_remote_branch_if_oid(
        &candidate.upstream_remote,
        &candidate.branch,
        expected_upstream_oid,
    ) {
        Ok(()) => outcomes.push(BranchCleanupOutcome::DeletedUpstreamBranch {
            remote: candidate.upstream_remote,
            branch: candidate.branch,
        }),
        Err(err) => outcomes.push(warning(
            Some(candidate.branch),
            "upstream branch was not deleted",
            err,
        )),
    }
}

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