git-outpost 0.1.1

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

use std::path::{Path, PathBuf};
use std::process::ExitCode;

use cli::{Cli, Command, SourceCommand};
use exit::CliResult;
use outpost_core::{Outpost, OutpostError, SourceRepo, 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;

    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, path) = contextual_outpost_path("lock", &cwd, args.outpost_path)?;
            ops::lock::run(
                &source,
                ops::lock::LockOptions {
                    path: path.clone(),
                    reason: args.reason,
                },
            )?;
            println!("locked {}", path.display());
        }
        Command::Unlock(args) => {
            let (source, path) = contextual_outpost_path("unlock", &cwd, args.outpost_path)?;
            ops::unlock::run(&source, ops::unlock::UnlockOptions { path: path.clone() })?;
            println!("unlocked {}", path.display());
        }
        Command::Move(args) => {
            let source = require_source("move", &cwd)?;
            let path = resolve_path_arg(&cwd, args.outpost_path);
            let new_path = resolve_path_arg(&cwd, args.new_path);
            ops::r#move::run(
                &source,
                ops::r#move::MoveOptions {
                    path: path.clone(),
                    new_path: new_path.clone(),
                    force: args.force,
                },
            )?;
            println!("moved {} -> {}", path.display(), new_path.display());
        }
        Command::Remove(args) => {
            let source = require_source("remove", &cwd)?;
            let path = resolve_path_arg(&cwd, args.outpost_path);
            ops::remove::run(
                &source,
                ops::remove::RemoveOptions {
                    path: path.clone(),
                    force: args.force,
                },
            )?;
            println!("removed {}", path.display());
        }
        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);
        }
    }

    Ok(())
}

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