git-outpost 0.1.0

Create self-contained Git outposts from a local repository for editor and devcontainer workflows.
#![allow(dead_code)]

use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};

pub struct CliFixture {
    _tmp: tempfile::TempDir,
    pub root: PathBuf,
    pub upstream: PathBuf,
    pub source: PathBuf,
    git_env: Vec<(OsString, OsString)>,
}

impl CliFixture {
    pub fn new() -> Self {
        let tmp = tempfile::tempdir().expect("tempdir");
        let root = tmp.path().to_path_buf();
        let empty_gitconfig = root.join("empty.gitconfig");
        fs::File::create(&empty_gitconfig).expect("empty gitconfig");
        let upstream = root.join("A.git");
        let source = root.join("B");
        let fixture = Self {
            _tmp: tmp,
            root,
            upstream,
            source,
            git_env: hermetic_git_env(&empty_gitconfig),
        };

        fixture.run_git_ok(
            &fixture.root,
            ["init", "--bare", "--initial-branch=main"],
            |cmd| {
                cmd.arg(&fixture.upstream);
            },
        );
        fixture.run_git_ok(&fixture.root, ["clone"], |cmd| {
            cmd.arg(&fixture.upstream).arg(&fixture.source);
        });
        fixture.run_git_ok(
            &fixture.source,
            ["config", "core.autocrlf", "false"],
            |_| {},
        );
        fixture.run_git_ok(
            &fixture.source,
            ["commit", "--allow-empty", "-m", "initial"],
            |_| {},
        );
        fixture.run_git_ok(&fixture.source, ["push", "origin", "main"], |_| {});

        fixture
    }

    pub fn gop(&self) -> Command {
        with_git_env(gop_command(), &self.git_env)
    }

    pub fn git_outpost(&self) -> Command {
        with_git_env(git_outpost_command(), &self.git_env)
    }

    pub fn git_dispatch(&self) -> Command {
        with_git_env(git_dispatch_command(), &self.git_env)
    }

    pub fn git(&self, cwd: &Path) -> Command {
        let mut command = Command::new("git");
        command.current_dir(cwd);
        with_git_env(command, &self.git_env)
    }

    pub fn outpost(&self, name: &str) -> PathBuf {
        self.root.join(name)
    }

    pub fn add_outpost(&self, name: &str) -> PathBuf {
        let outpost = self.outpost(name);
        let output = run(self
            .gop()
            .current_dir(&self.source)
            .arg("add")
            .arg(format!("../{name}"))
            .arg("main"));
        assert_success(&output, "gop add");
        outpost
    }

    pub fn commit_file(&self, repo: &Path, msg: &str, path: &str, content: &str) -> String {
        let absolute = repo.join(path);
        if let Some(parent) = absolute.parent() {
            fs::create_dir_all(parent).expect("create parent");
        }
        fs::write(&absolute, content).expect("write file");

        self.run_git_ok(repo, ["add", path], |_| {});
        self.run_git_ok(repo, ["commit", "-m", msg], |_| {});
        self.git_capture(repo, ["rev-parse", "HEAD"])
    }

    pub fn commit_upstream_file(
        &self,
        branch: &str,
        msg: &str,
        path: &str,
        content: &str,
    ) -> String {
        let scratch = tempfile::tempdir_in(&self.root).expect("scratch");
        let repo = scratch.path().join("upstream-work");
        self.run_git_ok(&self.root, ["clone"], |cmd| {
            cmd.arg(&self.upstream).arg(&repo);
        });
        self.run_git_ok(&repo, ["checkout", branch], |_| {});
        let oid = self.commit_file(&repo, msg, path, content);
        self.run_git_ok(&repo, ["push", "origin", branch], |_| {});
        oid
    }

    pub fn git_capture<const N: usize>(&self, cwd: &Path, args: [&str; N]) -> String {
        let output = run(self.git(cwd).args(args));
        assert_success(&output, "git capture");
        stdout(&output).trim().to_owned()
    }

    pub fn local_config(&self, repo: &Path, key: &str) -> Option<String> {
        let output = run(self.git(repo).args(["config", "--local", "--get", key]));
        match output.status.code() {
            Some(0) => Some(stdout(&output).trim().to_owned()),
            Some(1) => None,
            _ => panic!(
                "git config failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
                output.status.code(),
                stdout(&output),
                stderr(&output)
            ),
        }
    }

    fn run_git_ok<const N: usize, F>(&self, cwd: &Path, args: [&str; N], configure: F)
    where
        F: FnOnce(&mut Command),
    {
        let mut command = self.git(cwd);
        command.args(args);
        configure(&mut command);
        let output = run(&mut command);
        assert_success(&output, "git");
    }
}

pub fn command(bin_name: &str) -> Command {
    Command::new(binary_path(bin_name))
}

pub fn git_outpost_command() -> Command {
    command("git-outpost")
}

pub fn gop_command() -> Command {
    command("gop")
}

pub fn git_dispatch_command() -> Command {
    let mut command = Command::new("git");
    command.arg("outpost").env("PATH", path_with_binary_dir());
    command
}

pub fn binary_path(bin_name: &str) -> PathBuf {
    let key = format!("CARGO_BIN_EXE_{bin_name}");
    env::var_os(&key)
        .map(PathBuf::from)
        .unwrap_or_else(|| fallback_binary_path(bin_name))
}

pub fn run(command: &mut Command) -> Output {
    command.output().expect("run command")
}

pub fn stdout(output: &Output) -> String {
    String::from_utf8_lossy(&output.stdout).into_owned()
}

pub fn stderr(output: &Output) -> String {
    String::from_utf8_lossy(&output.stderr).into_owned()
}

pub fn assert_success(output: &Output, label: &str) {
    assert!(
        output.status.success(),
        "{label} failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
        output.status.code(),
        stdout(output),
        stderr(output)
    );
}

pub fn assert_failure_code(output: &Output, code: i32, label: &str) {
    assert!(
        !output.status.success(),
        "{label} unexpectedly succeeded\nstdout:\n{}\nstderr:\n{}",
        stdout(output),
        stderr(output)
    );
    assert_eq!(
        output.status.code(),
        Some(code),
        "{label} exit mismatch\nstdout:\n{}\nstderr:\n{}",
        stdout(output),
        stderr(output)
    );
}

pub fn assert_usage_error(output: &Output, flag: &str) {
    assert!(
        !output.status.success(),
        "expected usage error for {flag}, got success"
    );
    assert_eq!(
        output.status.code(),
        Some(2),
        "expected clap usage exit for {flag}; stderr:\n{}",
        stderr(output)
    );

    let stderr = stderr(output);
    assert!(
        stderr.contains(flag),
        "expected stderr to mention {flag}; stderr:\n{stderr}"
    );
    assert!(
        stderr.contains("Usage:"),
        "expected clap usage text for {flag}; stderr:\n{stderr}"
    );
}

pub fn copy_dir_recursively(source: &Path, destination: &Path) {
    fs::create_dir(destination).expect("create destination directory");
    for entry in fs::read_dir(source).expect("read source directory") {
        let entry = entry.expect("read directory entry");
        let source_path = entry.path();
        let destination_path = destination.join(entry.file_name());
        let file_type = entry.file_type().expect("read entry file type");
        if file_type.is_dir() {
            copy_dir_recursively(&source_path, &destination_path);
        } else if file_type.is_file() {
            fs::copy(&source_path, &destination_path).expect("copy file");
        } else if file_type.is_symlink() {
            copy_symlink(&source_path, &destination_path);
        }
    }
}

fn fallback_binary_path(bin_name: &str) -> PathBuf {
    let mut path = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("workspace root")
        .join("target")
        .join("debug")
        .join(bin_name);
    path.set_extension(env::consts::EXE_EXTENSION);
    path
}

#[cfg(unix)]
fn copy_symlink(source: &Path, destination: &Path) {
    let target = fs::read_link(source).expect("read symlink");
    std::os::unix::fs::symlink(target, destination).expect("copy symlink");
}

#[cfg(windows)]
fn copy_symlink(source: &Path, destination: &Path) {
    let target = fs::read_link(source).expect("read symlink");
    if source.is_dir() {
        std::os::windows::fs::symlink_dir(target, destination).expect("copy directory symlink");
    } else {
        std::os::windows::fs::symlink_file(target, destination).expect("copy file symlink");
    }
}

#[cfg(not(any(unix, windows)))]
fn copy_symlink(source: &Path, destination: &Path) {
    fs::copy(source, destination).expect("copy symlink target");
}

fn path_with_binary_dir() -> OsString {
    let bin_dir = binary_path("git-outpost")
        .parent()
        .expect("binary directory")
        .to_path_buf();
    let existing = env::var_os("PATH").unwrap_or_default();
    let paths = std::iter::once(bin_dir).chain(env::split_paths(&existing));

    env::join_paths(paths).expect("join PATH")
}

fn with_git_env(mut command: Command, env: &[(OsString, OsString)]) -> Command {
    command.envs(env.iter().cloned());
    command
}

fn hermetic_git_env(empty_gitconfig: &Path) -> Vec<(OsString, OsString)> {
    vec![
        (
            OsString::from("GIT_CONFIG_GLOBAL"),
            empty_gitconfig.as_os_str().to_os_string(),
        ),
        (
            OsString::from("GIT_CONFIG_SYSTEM"),
            empty_gitconfig.as_os_str().to_os_string(),
        ),
        (
            OsString::from("GIT_AUTHOR_NAME"),
            OsString::from("Test Author"),
        ),
        (
            OsString::from("GIT_AUTHOR_EMAIL"),
            OsString::from("test@example.com"),
        ),
        (
            OsString::from("GIT_COMMITTER_NAME"),
            OsString::from("Test Committer"),
        ),
        (
            OsString::from("GIT_COMMITTER_EMAIL"),
            OsString::from("test@example.com"),
        ),
        (OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0")),
    ]
}