git-outpost 0.1.2

Create self-contained Git outposts from a local repository for editor and devcontainer workflows.
mod cli;
mod exit;
mod gh;
mod output;
mod reporter_impls;

use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use cli::{Cli, Command, SourceCommand};
use exit::CliResult;
use outpost_core::selector::OutpostSelector;
use outpost_core::{Outpost, OutpostError, Reporter, SourceRepo, StepKind, ops};
use reporter_impls::StderrReporter;

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => exit::report(err),
    }
}

fn run() -> CliResult<()> {
    let argv: Vec<_> = std::env::args_os().collect();
    let bin = argv
        .first()
        .and_then(|arg| Path::new(arg).file_stem())
        .map(|stem| stem.to_string_lossy().into_owned())
        .unwrap_or_else(|| "gop".to_owned());

    let cli = Cli::try_parse_from_with_bin(argv, &bin).unwrap_or_else(|err| err.exit());
    cli.validate_refs()?;
    dispatch(cli)
}

fn dispatch(cli: Cli) -> CliResult<()> {
    let cwd = effective_cwd(cli.cd)?;
    let mut reporter = StderrReporter::new();

    match cli.command {
        Command::Add(args) => {
            let source = require_source("add", &cwd)?;
            let outpost = ops::add::run(&source, args.to_options(&cwd)?, &mut reporter)?;
            output::print_added(&outpost);
        }
        Command::Pull(_) => {
            let outpost = require_outpost("pull", &cwd)?;
            let report = ops::pull::run(&outpost, ops::pull::PullOptions, &mut reporter)?;
            output::print_pull(&report);
        }
        Command::Source(args) => match args.command {
            SourceCommand::Pull(args) => {
                let outpost = require_outpost("source pull", &cwd)?;
                let report = ops::source::pull(&outpost, args.to_options()?, &mut reporter)?;
                output::print_source_pull(&report);
            }
        },
        Command::Merge(args) => {
            let outpost = require_outpost("merge", &cwd)?;
            let report = ops::merge::run(&outpost, args.to_options()?, &mut reporter)?;
            output::print_merge(&report);
        }
        Command::Rebase(args) => {
            let outpost = require_outpost("rebase", &cwd)?;
            let report = ops::rebase::run(&outpost, args.to_options()?, &mut reporter)?;
            output::print_rebase(&report);
        }
        Command::Push(_) => {
            let outpost = require_outpost("push", &cwd)?;
            let report = ops::push::run(&outpost, ops::push::PushOptions, &mut reporter)?;
            output::print_push(&report);
        }
        Command::List(args) => {
            let source = list_source(&cwd)?;
            let summaries = ops::list::run(&source)?;
            output::print_list(&summaries, args.verbose);
        }
        Command::Lock(args) => {
            let (source, selector) = contextual_outpost_selector("lock", &cwd, args.outpost_path)?;
            let path = ops::lock::run(
                &source,
                ops::lock::LockOptions {
                    selector,
                    reason: args.reason,
                },
            )?;
            println!("locked {}", path.display());
        }
        Command::Unlock(args) => {
            let (source, selector) =
                contextual_outpost_selector("unlock", &cwd, args.outpost_path)?;
            let path = ops::unlock::run(&source, ops::unlock::UnlockOptions { selector })?;
            println!("unlocked {}", path.display());
        }
        Command::Move(args) => {
            let source = require_source("move", &cwd)?;
            let selector = OutpostSelector::from_cli_arg(&cwd, args.outpost_path);
            let new_path = resolve_path_arg(&cwd, args.new_path);
            let report = ops::r#move::run(
                &source,
                ops::r#move::MoveOptions {
                    selector,
                    new_path: new_path.clone(),
                    force: args.force,
                },
            )?;
            println!(
                "moved {} -> {}",
                report.old_path.display(),
                report.new_path.display()
            );
        }
        Command::Remove(args) => {
            let source = require_source("remove", &cwd)?;
            let selector = OutpostSelector::from_cli_arg(&cwd, args.outpost_path);
            let opts = ops::remove::RemoveOptions {
                selector,
                force: args.force,
            };
            let mut gh_status = None;
            let report = if args.no_branch_cleanup {
                ops::remove::run_with_cleanup(
                    &source,
                    opts,
                    ops::remove::BranchCleanupMode::Disabled,
                )?
            } else if cleanup_prompts_available() {
                let status = gh::GhStatus::detect(&source);
                let provider = status.provider();
                let mut prompt = TerminalBranchCleanupPrompt;
                let report = ops::remove::run_with_cleanup(
                    &source,
                    opts,
                    ops::remove::BranchCleanupMode::Prompt(ops::remove::BranchCleanupOptions {
                        provider,
                        prompt: &mut prompt,
                    }),
                )?;
                gh_status = Some(status);
                report
            } else {
                ops::remove::run_with_cleanup(
                    &source,
                    opts,
                    ops::remove::BranchCleanupMode::NonInteractive,
                )?
            };
            output::print_remove(&report, gh_status.as_ref());
        }
        Command::Prune(args) => {
            let source = require_source("prune", &cwd)?;
            let report = ops::prune::run(
                &source,
                ops::prune::PruneOptions {
                    dry_run: args.dry_run,
                    verbose: args.verbose,
                },
            )?;
            output::print_prune(&report, args.verbose);
        }
        Command::Status(_) => {
            let report = ops::status::run(&cwd)?;
            output::print_status(&report);
        }
        Command::Analyze(args) => {
            let (source, selector) =
                contextual_outpost_selector("analyze", &cwd, args.outpost_path)?;
            reporter.step(StepKind::Analysis, "checking GitHub availability");
            let gh_status = gh::GhStatus::detect(&source);
            reporter.step(StepKind::Analysis, &gh_status.progress_message());
            let report = ops::analyze::run_with_reporter(
                &source,
                ops::analyze::AnalyzeOptions { selector },
                gh_status.provider(),
                &mut reporter,
            )?;
            reporter.step(StepKind::Analysis, "checking GitHub metadata");
            let github = gh_status.analyze(report.branch.as_ref());
            reporter.step(StepKind::Analysis, &github.progress_message());
            output::print_analyze(&report, &github);
        }
    }

    Ok(())
}

fn cleanup_prompts_available() -> bool {
    io::stdin().is_terminal() && io::stderr().is_terminal()
}

struct TerminalBranchCleanupPrompt;

impl ops::remove::BranchCleanupPrompt for TerminalBranchCleanupPrompt {
    fn confirm_source_branch_delete(
        &mut self,
        candidate: &ops::remove::BranchCleanupCandidate,
    ) -> bool {
        prompt_yes_no(&format!(
            "Delete source branch '{}' ({})? [y/N] ",
            candidate.branch.as_str(),
            proof_summary(&candidate.proof)
        ))
    }

    fn confirm_upstream_branch_delete(
        &mut self,
        candidate: &ops::remove::BranchCleanupCandidate,
    ) -> bool {
        prompt_yes_no(&format!(
            "Delete upstream branch '{}/{}' at {}? [y/N] ",
            candidate.upstream_remote.as_str(),
            candidate.branch.as_str(),
            candidate
                .upstream_oid
                .as_deref()
                .map(short_oid)
                .unwrap_or("-")
        ))
    }
}

fn prompt_yes_no(message: &str) -> bool {
    let mut stderr = io::stderr();
    if write!(stderr, "{message}")
        .and_then(|_| stderr.flush())
        .is_err()
    {
        return false;
    }

    let mut input = String::new();
    if io::stdin().read_line(&mut input).is_err() {
        return false;
    }

    matches!(input.trim(), "y" | "Y" | "yes" | "YES" | "Yes")
}

fn proof_summary(proof: &ops::remove::BranchCleanupProof) -> String {
    match proof {
        ops::remove::BranchCleanupProof::MergedPullRequest(pr) => {
            format!("merged pull request {}", pr.id)
        }
        ops::remove::BranchCleanupProof::AncestorOfDefaultBranch {
            remote,
            default_branch,
            default_oid,
        } => {
            format!(
                "ancestor of {}/{} at {}",
                remote.as_str(),
                default_branch.as_str(),
                short_oid(default_oid)
            )
        }
    }
}

fn short_oid(oid: &str) -> &str {
    oid.get(..12).unwrap_or(oid)
}

fn effective_cwd(cd: Option<PathBuf>) -> CliResult<PathBuf> {
    let current = std::env::current_dir().map_err(|source| OutpostError::IoAt {
        path: PathBuf::from("."),
        source,
    })?;
    let cwd = match cd {
        Some(path) if path.is_absolute() => path,
        Some(path) => current.join(path),
        None => current,
    };
    Ok(cwd)
}

fn resolve_path_arg(cwd: &Path, path: PathBuf) -> PathBuf {
    if path.is_absolute() {
        path
    } else {
        cwd.join(path)
    }
}

enum Context {
    Source(SourceRepo),
    Outpost(Outpost),
}

fn classify(cwd: &Path) -> CliResult<Context> {
    let source = SourceRepo::discover(cwd)?;
    match Outpost::at(source.work_tree()) {
        Ok(outpost) => Ok(Context::Outpost(outpost)),
        Err(OutpostError::NotAnOutpost(_)) => Ok(Context::Source(source)),
        Err(err) => Err(err),
    }
}

fn require_source(command: &'static str, cwd: &Path) -> CliResult<SourceRepo> {
    match classify(cwd)? {
        Context::Source(source) => Ok(source),
        Context::Outpost(_) => Err(OutpostError::WrongContext {
            command,
            expected: "a source repository",
            cwd: cwd.to_path_buf(),
        }),
    }
}

fn require_outpost(command: &'static str, cwd: &Path) -> CliResult<Outpost> {
    match classify(cwd)? {
        Context::Outpost(outpost) => Ok(outpost),
        Context::Source(_) => Err(OutpostError::WrongContext {
            command,
            expected: "a managed outpost",
            cwd: cwd.to_path_buf(),
        }),
    }
}

fn list_source(cwd: &Path) -> CliResult<SourceRepo> {
    match classify(cwd)? {
        Context::Source(source) => Ok(source),
        Context::Outpost(outpost) => outpost.source_repo().map_err(Into::into),
    }
}

fn contextual_outpost_selector(
    command: &'static str,
    cwd: &Path,
    path: Option<PathBuf>,
) -> CliResult<(SourceRepo, OutpostSelector)> {
    match classify(cwd)? {
        Context::Source(source) => match path {
            Some(path) => Ok((source, OutpostSelector::from_cli_arg(cwd, path))),
            None => Err(OutpostError::MissingOutpostPath {
                command,
                cwd: cwd.to_path_buf(),
            }),
        },
        Context::Outpost(outpost) => {
            let path = path
                .map(|path| OutpostSelector::from_cli_arg(cwd, path))
                .unwrap_or_else(|| OutpostSelector::from_path(outpost.work_tree().to_path_buf()));
            Ok((outpost.source_repo()?, path))
        }
    }
}