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::OsString;
use std::path::{Path, PathBuf};

use crate::metadata::{Metadata, RawMetadata};
use crate::source_repo::{
    SourceRepo, canonicalize_path, current_branch, invoker_at, is_dirty, read_optional_config,
};
use crate::{BranchName, GitInvoker, OutpostError, OutpostResult, RefName, UpstreamRef};

pub struct Outpost {
    work_tree: PathBuf,
    git_dir: PathBuf,
    git: GitInvoker,
    metadata: Metadata,
    env: BTreeMap<OsString, OsString>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AheadBehind {
    pub ahead: u32,
    pub behind: u32,
}

impl Outpost {
    pub fn discover(start: &Path) -> OutpostResult<Self> {
        Self::discover_with(start, &BTreeMap::new())
    }

    pub fn discover_with(start: &Path, env: &BTreeMap<OsString, OsString>) -> OutpostResult<Self> {
        let git = invoker_at(start, env);
        let work_tree = git
            .run_capture(["rev-parse", "--show-toplevel"])
            .map_err(|err| map_discovery_error(err, start))?;
        Self::at_with(work_tree, env)
    }

    pub fn at(path: impl Into<PathBuf>) -> OutpostResult<Self> {
        Self::at_with(path, &BTreeMap::new())
    }

    pub fn at_with(
        path: impl Into<PathBuf>,
        env: &BTreeMap<OsString, OsString>,
    ) -> OutpostResult<Self> {
        let start = path.into();
        let git = invoker_at(&start, env);
        let work_tree_raw = git
            .run_capture(["rev-parse", "--show-toplevel"])
            .map_err(|err| map_discovery_error(err, &start))?;
        let git_dir_raw = git
            .run_capture(["rev-parse", "--git-dir"])
            .map_err(|err| map_discovery_error(err, &start))?;
        let work_tree = canonicalize_path(Path::new(&work_tree_raw))?;
        let git_dir = canonicalize_git_path(&start, &git_dir_raw)?;
        let git = invoker_at(&work_tree, env);
        let raw = RawMetadata::read(&git)?;
        let metadata = Metadata::from_raw(&work_tree, raw)?;

        Ok(Self {
            work_tree,
            git_dir,
            git,
            metadata,
            env: env.clone(),
        })
    }

    pub fn work_tree(&self) -> &Path {
        &self.work_tree
    }

    pub fn git_dir(&self) -> &Path {
        &self.git_dir
    }

    pub fn metadata(&self) -> &Metadata {
        &self.metadata
    }

    pub fn source_repo(&self) -> OutpostResult<SourceRepo> {
        if !self.metadata.source_repo.exists() {
            return Err(OutpostError::SourceMissing(
                self.metadata.source_repo.clone(),
            ));
        }
        SourceRepo::at_with(&self.metadata.source_repo, &self.env)
    }

    pub fn current_branch(&self) -> OutpostResult<BranchName> {
        current_branch(&self.git, &self.work_tree)
    }

    pub fn is_dirty(&self) -> OutpostResult<bool> {
        is_dirty(&self.git)
    }

    pub fn ahead_behind_source(&self) -> OutpostResult<AheadBehind> {
        let branch = self.current_branch()?;
        let upstream =
            self.upstream_tracking()?
                .ok_or_else(|| OutpostError::NoUpstreamTracking {
                    branch: branch.as_str().to_owned(),
                })?;
        if upstream.remote != self.metadata.remote_name {
            return Err(OutpostError::NoUpstreamTracking {
                branch: branch.as_str().to_owned(),
            });
        }
        let remote_branch =
            upstream
                .short_branch()
                .ok_or_else(|| OutpostError::UpstreamNotABranch {
                    merge_ref: upstream.merge_ref.as_str().to_owned(),
                })?;
        let remote_tracking_ref = format!(
            "refs/remotes/{}/{}",
            self.metadata.remote_name.as_str(),
            remote_branch
        );
        let fetch_refspec = format!("{}:{remote_tracking_ref}", upstream.merge_ref.as_str());
        self.git
            .run_check(["fetch", self.metadata.remote_name.as_str(), &fetch_refspec])?;

        let local_ref = format!("refs/heads/{}", branch.as_str());
        let range = format!("{local_ref}...{remote_tracking_ref}");
        parse_ahead_behind(
            &self.work_tree,
            self.git
                .run_capture(["rev-list", "--left-right", "--count", &range])?,
        )
    }

    pub fn unpushed_commits(&self, source: &SourceRepo) -> OutpostResult<u32> {
        let branch = self.current_branch()?;
        if !source.branch_exists(&branch)? {
            return Err(OutpostError::BranchNotFound {
                branch: branch.as_str().to_owned(),
                repo: source.work_tree().to_path_buf(),
            });
        }

        let upstream =
            self.upstream_tracking()?
                .ok_or_else(|| OutpostError::NoUpstreamTracking {
                    branch: branch.as_str().to_owned(),
                })?;
        if upstream.remote != self.metadata.remote_name {
            return Err(OutpostError::NoUpstreamTracking {
                branch: branch.as_str().to_owned(),
            });
        }
        let remote_branch =
            upstream
                .short_branch()
                .ok_or_else(|| OutpostError::UpstreamNotABranch {
                    merge_ref: upstream.merge_ref.as_str().to_owned(),
                })?;
        if remote_branch != branch.as_str() {
            return Err(OutpostError::NoUpstreamTracking {
                branch: branch.as_str().to_owned(),
            });
        }

        let remote_tracking_ref = format!(
            "refs/remotes/{}/{}",
            self.metadata.remote_name.as_str(),
            remote_branch
        );
        let fetch_refspec = format!("{}:{remote_tracking_ref}", upstream.merge_ref.as_str());
        self.git
            .run_check(["fetch", self.metadata.remote_name.as_str(), &fetch_refspec])?;
        let local_ref = format!("refs/heads/{}", branch.as_str());
        let range = format!("{remote_tracking_ref}..{local_ref}");
        let output = self.git.run_capture(["rev-list", "--count", &range])?;
        parse_count(&self.work_tree, &output)
    }

    pub fn upstream_tracking(&self) -> OutpostResult<Option<UpstreamRef>> {
        let branch = self.current_branch()?;
        let remote_key = format!("branch.{}.remote", branch.as_str());
        let merge_key = format!("branch.{}.merge", branch.as_str());
        let Some(remote) = read_optional_config(&self.git, &remote_key)? else {
            return Ok(None);
        };
        let Some(merge_ref) = read_optional_config(&self.git, &merge_key)? else {
            return Ok(None);
        };

        Ok(Some(UpstreamRef {
            remote: crate::RemoteName::parse(remote)?,
            merge_ref: RefName::parse(merge_ref)?,
        }))
    }

    pub(crate) fn git(&self) -> &GitInvoker {
        &self.git
    }

    #[cfg(any(test, feature = "test-helpers"))]
    pub fn test_invoker(&self) -> &GitInvoker {
        &self.git
    }
}

fn parse_ahead_behind(repo: &Path, output: String) -> OutpostResult<AheadBehind> {
    let mut parts = output.split_whitespace();
    let ahead = parts
        .next()
        .and_then(|value| value.parse::<u32>().ok())
        .ok_or_else(|| invalid_ahead_behind_output(repo, &output))?;
    let behind = parts
        .next()
        .and_then(|value| value.parse::<u32>().ok())
        .ok_or_else(|| invalid_ahead_behind_output(repo, &output))?;
    if parts.next().is_some() {
        return Err(invalid_ahead_behind_output(repo, &output));
    }

    Ok(AheadBehind { ahead, behind })
}

fn invalid_ahead_behind_output(repo: &Path, output: &str) -> OutpostError {
    OutpostError::IoAt {
        path: repo.to_path_buf(),
        source: std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!("unexpected rev-list output: {output}"),
        ),
    }
}

fn parse_count(repo: &Path, output: &str) -> OutpostResult<u32> {
    let count = output
        .split_whitespace()
        .next()
        .and_then(|value| value.parse::<u32>().ok())
        .ok_or_else(|| invalid_ahead_behind_output(repo, output))?;
    if output.split_whitespace().nth(1).is_some() {
        return Err(invalid_ahead_behind_output(repo, output));
    }
    Ok(count)
}

fn canonicalize_git_path(start: &Path, value: &str) -> OutpostResult<PathBuf> {
    let path = PathBuf::from(value);
    if path.is_absolute() {
        canonicalize_path(&path)
    } else {
        canonicalize_path(&start.join(path))
    }
}

fn map_discovery_error(err: OutpostError, path: &Path) -> OutpostError {
    match err {
        OutpostError::GitFailed { .. } => OutpostError::NotARepo(path.to_path_buf()),
        other => other,
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use super::*;
    use crate::RemoteName;

    #[test]
    fn outpost_at_rejects_unmanaged_repo() {
        let temp = tempfile::tempdir().expect("tempdir");
        GitInvoker::at(temp.path())
            .run_check(["init", "--initial-branch=main"])
            .expect("init");

        let Err(err) = Outpost::at(temp.path()) else {
            panic!("unmanaged repo should fail");
        };
        assert!(
            matches!(err, OutpostError::NotAnOutpost(path) if path == fs::canonicalize(temp.path()).unwrap())
        );
    }

    #[test]
    fn outpost_at_reads_metadata_and_source_repo() {
        let temp = tempfile::tempdir().expect("tempdir");
        let source = temp.path().join("source");
        let outpost = temp.path().join("outpost");
        init_repo(&source);
        init_repo(&outpost);
        let metadata = Metadata {
            source_repo: source.clone(),
            remote_name: RemoteName::parse("local").unwrap(),
        };
        metadata.write(&GitInvoker::at(&outpost)).unwrap();

        let outpost = Outpost::at(&outpost).expect("managed outpost");

        assert_eq!(outpost.metadata().remote_name.as_str(), "local");
        assert_eq!(
            outpost.source_repo().unwrap().work_tree(),
            fs::canonicalize(&source).unwrap()
        );
    }

    #[test]
    fn outpost_reports_missing_source_repo_from_metadata() {
        let temp = tempfile::tempdir().expect("tempdir");
        let source = temp.path().join("source");
        let outpost = temp.path().join("outpost");
        init_repo(&source);
        init_repo(&outpost);
        let metadata = Metadata {
            source_repo: source.clone(),
            remote_name: RemoteName::parse("local").unwrap(),
        };
        metadata.write(&GitInvoker::at(&outpost)).unwrap();
        fs::remove_dir_all(&source).expect("remove source");

        let outpost = Outpost::at(&outpost).expect("managed outpost");
        let Err(err) = outpost.source_repo() else {
            panic!("source should be missing");
        };

        assert!(
            matches!(err, OutpostError::SourceMissing(path) if path == fs::canonicalize(temp.path()).unwrap().join("source"))
        );
    }

    #[test]
    fn unpushed_commits_reports_local_commits_ahead_of_source() {
        let temp = tempfile::tempdir().expect("tempdir");
        let source = temp.path().join("source");
        let outpost = temp.path().join("outpost");
        init_repo(&source);
        init_repo(&outpost);
        let source_git = GitInvoker::at(&source);
        source_git
            .run_check(["commit", "--allow-empty", "-m", "source"])
            .expect("source commit");
        let outpost_git = GitInvoker::at(&outpost);
        outpost_git
            .run_check(["pull", &source.to_string_lossy(), "main"])
            .expect("pull source into outpost");
        outpost_git
            .run_check(["remote", "add", "local", &source.to_string_lossy()])
            .expect("add source remote");
        outpost_git
            .run_check(["fetch", "local", "main"])
            .expect("fetch source remote");
        outpost_git
            .run_check(["branch", "--set-upstream-to", "local/main", "main"])
            .expect("set upstream");
        let metadata = Metadata {
            source_repo: source.clone(),
            remote_name: RemoteName::parse("local").unwrap(),
        };
        metadata.write(&outpost_git).unwrap();
        outpost_git
            .run_check(["commit", "--allow-empty", "-m", "outpost"])
            .expect("outpost commit");

        let source = SourceRepo::at(&source).expect("source repo");
        let outpost = Outpost::at(&outpost).expect("outpost");

        assert_eq!(outpost.unpushed_commits(&source).expect("unpushed"), 1);
    }

    fn init_repo(path: &Path) {
        fs::create_dir_all(path).expect("repo dir");
        let git = GitInvoker::at(path);
        git.run_check(["init", "--initial-branch=main"])
            .expect("init");
        git.run_check(["config", "user.name", "Test Author"])
            .expect("set user.name");
        git.run_check(["config", "user.email", "test@example.com"])
            .expect("set user.email");
    }
}