outpost-core 0.1.3

Core library for Git Outpost, a clone-backed alternative to git worktree workflows.
Documentation
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Path, PathBuf};

use outpost_core::ops::add::{AddCheckout, AddOptions, run as add_run};
use outpost_core::{
    BranchName, GitInvoker, OutpostError, OutpostResult, RemoteName, Reporter, SourceRepo, StepKind,
};

pub struct AbcFixture {
    _tmp: tempfile::TempDir,
    pub root: PathBuf,
    pub upstream: PathBuf,
    pub source: PathBuf,
    pub git_env: BTreeMap<OsString, OsString>,
}

impl AbcFixture {
    pub fn new() -> Self {
        Self::try_new().expect("build A/B fixture")
    }

    pub fn try_new() -> OutpostResult<Self> {
        let tmp = tempfile::tempdir().map_err(|source| OutpostError::IoAt {
            path: std::env::temp_dir(),
            source,
        })?;
        let root = tmp.path().to_path_buf();
        let empty_gitconfig = root.join("empty.gitconfig");
        fs::File::create(&empty_gitconfig).map_err(|source| OutpostError::IoAt {
            path: empty_gitconfig.clone(),
            source,
        })?;

        let git_env = hermetic_git_env(&empty_gitconfig);
        let upstream = root.join("A.git");
        let source = root.join("B");
        let fixture = Self {
            _tmp: tmp,
            root,
            upstream,
            source,
            git_env,
        };

        fixture.invoker(&fixture.root).run_check([
            os("init"),
            os("--bare"),
            os("--initial-branch=main"),
            fixture.upstream.as_os_str(),
        ])?;
        fixture.invoker(&fixture.root).run_check([
            os("clone"),
            fixture.upstream.as_os_str(),
            fixture.source.as_os_str(),
        ])?;
        fixture.invoker(&fixture.source).run_check([
            os("config"),
            os("core.autocrlf"),
            os("false"),
        ])?;
        fixture.invoker(&fixture.source).run_check([
            os("commit"),
            os("--allow-empty"),
            os("-m"),
            os("initial"),
        ])?;
        fixture
            .invoker(&fixture.source)
            .run_check([os("push"), os("origin"), os("main")])?;

        Ok(fixture)
    }

    pub fn invoker(&self, cwd: &Path) -> GitInvoker {
        self.git_env
            .iter()
            .fold(GitInvoker::at(cwd), |git, (key, val)| {
                git.with_env(key.clone(), val.clone())
            })
    }

    pub fn source_repo(&self) -> OutpostResult<SourceRepo> {
        SourceRepo::at_with(&self.source, &self.git_env)
    }

    pub fn commit_in_source(&self, msg: &str) -> OutpostResult<String> {
        self.invoker(&self.source).run_check([
            os("commit"),
            os("--allow-empty"),
            os("-m"),
            OsStr::new(msg),
        ])?;
        self.invoker(&self.source)
            .run_capture([os("rev-parse"), os("HEAD")])
    }

    #[allow(dead_code)]
    pub fn commit_file_in_source(
        &self,
        msg: &str,
        path: &str,
        content: &str,
    ) -> OutpostResult<String> {
        commit_file(self, &self.source, msg, path, content)
    }

    #[allow(dead_code)]
    pub fn create_source_branch(&self, branch: &str) -> OutpostResult<BranchName> {
        let branch = BranchName::parse(branch.to_owned())?;
        self.invoker(&self.source)
            .run_check([os("branch"), OsStr::new(branch.as_str())])?;
        Ok(branch)
    }

    #[allow(dead_code)]
    pub fn add_outpost(&self, name: &str) -> OutpostResult<PathBuf> {
        self.add_outpost_on_branch(name, None)
    }

    #[allow(dead_code)]
    pub fn add_outpost_on_branch(
        &self,
        name: &str,
        target_branch: Option<BranchName>,
    ) -> OutpostResult<PathBuf> {
        self.add_outpost_with_remote_on_branch(name, "local", target_branch)
    }

    #[allow(dead_code)]
    pub fn add_outpost_with_remote(&self, name: &str, remote_name: &str) -> OutpostResult<PathBuf> {
        self.add_outpost_with_remote_on_branch(name, remote_name, None)
    }

    #[allow(dead_code)]
    pub fn add_outpost_with_remote_on_branch(
        &self,
        name: &str,
        remote_name: &str,
        target_branch: Option<BranchName>,
    ) -> OutpostResult<PathBuf> {
        let source = self.source_repo()?;
        let destination = self.root.join(name);
        let mut reporter = SilentReporter;
        add_run(
            &source,
            AddOptions {
                destination: destination.clone(),
                checkout: AddCheckout::CheckoutExisting { target_branch },
                remote_name: RemoteName::parse(remote_name)?,
            },
            &mut reporter,
        )?;
        Ok(destination)
    }

    #[allow(dead_code)]
    pub fn dirty_outpost(&self, name: &str) -> OutpostResult<PathBuf> {
        let outpost = self.add_outpost(name)?;
        fs::write(outpost.join("x.txt"), "dirty").map_err(|source| OutpostError::IoAt {
            path: outpost.join("x.txt"),
            source,
        })?;
        Ok(outpost)
    }

    #[allow(dead_code)]
    pub fn commit_in_outpost(&self, outpost: &Path, msg: &str) -> OutpostResult<String> {
        self.invoker(outpost).run_check([
            os("commit"),
            os("--allow-empty"),
            os("-m"),
            OsStr::new(msg),
        ])?;
        self.invoker(outpost)
            .run_capture([os("rev-parse"), os("HEAD")])
    }

    #[allow(dead_code)]
    pub fn commit_file_in_outpost(
        &self,
        outpost: &Path,
        msg: &str,
        path: &str,
        content: &str,
    ) -> OutpostResult<String> {
        commit_file(self, outpost, msg, path, content)
    }

    pub fn commit_in_upstream(&self, branch: &str, msg: &str) -> OutpostResult<String> {
        let scratch = tempfile::tempdir_in(&self.root).map_err(|source| OutpostError::IoAt {
            path: self.root.clone(),
            source,
        })?;
        let repo = scratch.path().join("upstream-work");

        self.invoker(&self.root).run_check([
            os("clone"),
            self.upstream.as_os_str(),
            repo.as_os_str(),
        ])?;
        let git = self.invoker(&repo);
        git.run_check([os("checkout"), OsStr::new(branch)])?;
        git.run_check([os("commit"), os("--allow-empty"), os("-m"), OsStr::new(msg)])?;
        let oid = git.run_capture([os("rev-parse"), os("HEAD")])?;
        git.run_check([os("push"), os("origin"), OsStr::new(branch)])?;

        Ok(oid)
    }

    #[allow(dead_code)]
    pub fn commit_file_in_upstream(
        &self,
        branch: &str,
        msg: &str,
        path: &str,
        content: &str,
    ) -> OutpostResult<String> {
        let scratch = tempfile::tempdir_in(&self.root).map_err(|source| OutpostError::IoAt {
            path: self.root.clone(),
            source,
        })?;
        let repo = scratch.path().join("upstream-work");

        self.invoker(&self.root).run_check([
            os("clone"),
            self.upstream.as_os_str(),
            repo.as_os_str(),
        ])?;
        let git = self.invoker(&repo);
        git.run_check([os("checkout"), OsStr::new(branch)])?;
        let oid = commit_file(self, &repo, msg, path, content)?;
        git.run_check([os("push"), os("origin"), OsStr::new(branch)])?;

        Ok(oid)
    }

    #[allow(dead_code)]
    pub fn push_source_branch(&self, branch: &BranchName) -> OutpostResult<()> {
        let refspec = format!(
            "refs/heads/{}:refs/heads/{}",
            branch.as_str(),
            branch.as_str()
        );
        self.invoker(&self.source)
            .run_check([os("push"), os("origin"), OsStr::new(&refspec)])
    }

    #[allow(dead_code)]
    pub fn delete_source_branch(&self, branch: &BranchName) -> OutpostResult<()> {
        self.invoker(&self.source)
            .run_check([os("branch"), os("-D"), OsStr::new(branch.as_str())])
    }

    #[allow(dead_code)]
    pub fn rev_parse(&self, repo: &Path, rev: &str) -> OutpostResult<String> {
        self.invoker(repo)
            .run_capture([os("rev-parse"), OsStr::new(rev)])
    }

    #[allow(dead_code)]
    pub fn current_branch_name(&self, repo: &Path) -> OutpostResult<String> {
        self.invoker(repo).run_capture([
            os("symbolic-ref"),
            os("--quiet"),
            os("--short"),
            os("HEAD"),
        ])
    }
}

fn commit_file(
    fixture: &AbcFixture,
    repo: &Path,
    msg: &str,
    path: &str,
    content: &str,
) -> OutpostResult<String> {
    let absolute = repo.join(path);
    if let Some(parent) = absolute.parent() {
        fs::create_dir_all(parent).map_err(|source| OutpostError::IoAt {
            path: parent.to_path_buf(),
            source,
        })?;
    }
    fs::write(&absolute, content).map_err(|source| OutpostError::IoAt {
        path: absolute.clone(),
        source,
    })?;

    let git = fixture.invoker(repo);
    git.run_check([os("add"), OsStr::new(path)])?;
    git.run_check([os("commit"), os("-m"), OsStr::new(msg)])?;
    git.run_capture([os("rev-parse"), os("HEAD")])
}

fn hermetic_git_env(empty_gitconfig: &Path) -> BTreeMap<OsString, OsString> {
    BTreeMap::from([
        (
            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")),
    ])
}

fn os(value: &'static str) -> &'static OsStr {
    OsStr::new(value)
}

#[allow(dead_code)]
struct SilentReporter;

impl Reporter for SilentReporter {
    fn step(&mut self, _kind: StepKind, _message: &str) {}

    fn warn(&mut self, _message: &str) {}
}

#[derive(Default)]
pub struct CapturingReporter {
    pub steps: Vec<(StepKind, String)>,
    pub warnings: Vec<String>,
}

impl CapturingReporter {
    #[allow(dead_code)]
    pub fn step_kinds(&self) -> Vec<StepKind> {
        self.steps.iter().map(|(kind, _)| *kind).collect()
    }
}

impl Reporter for CapturingReporter {
    fn step(&mut self, kind: StepKind, message: &str) {
        self.steps.push((kind, message.to_owned()));
    }

    fn warn(&mut self, message: &str) {
        self.warnings.push(message.to_owned());
    }
}