outpost-core 0.1.3

Core library for Git Outpost, a clone-backed alternative to git worktree workflows.
Documentation
use std::fmt;
use std::process::{Command, Stdio};

use crate::{OutpostError, OutpostResult};

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BranchName(String);

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RefName(String);

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RemoteName(String);

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceRemoteRef {
    pub remote: RemoteName,
    pub branch: BranchName,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpstreamRef {
    pub remote: RemoteName,
    pub merge_ref: RefName,
}

impl BranchName {
    pub fn parse(name: impl Into<String>) -> OutpostResult<Self> {
        let name = name.into();
        reject_empty_or_leading_dash(&name)?;
        if git_check_ref_format(["check-ref-format", "--branch", name.as_str()]) {
            Ok(Self(name))
        } else {
            invalid_ref(name)
        }
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl RefName {
    pub fn parse(name: impl Into<String>) -> OutpostResult<Self> {
        let name = name.into();
        reject_empty_or_leading_dash(&name)?;
        if git_check_ref_format(["check-ref-format", name.as_str()]) {
            Ok(Self(name))
        } else {
            invalid_ref(name)
        }
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl RemoteName {
    pub fn parse(name: impl Into<String>) -> OutpostResult<Self> {
        let name = name.into();
        reject_empty_or_leading_dash(&name)?;
        if name
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
        {
            Ok(Self(name))
        } else {
            invalid_ref(name)
        }
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl SourceRemoteRef {
    pub fn parse(value: impl Into<String>) -> OutpostResult<Self> {
        let value = value.into();
        let Some((remote, branch)) = value.split_once('/') else {
            return invalid_ref(value);
        };

        Ok(Self {
            remote: RemoteName::parse(remote.to_owned())?,
            branch: BranchName::parse(branch.to_owned())?,
        })
    }
}

impl UpstreamRef {
    pub fn short_branch(&self) -> Option<&str> {
        self.merge_ref.as_str().strip_prefix("refs/heads/")
    }
}

impl fmt::Display for BranchName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl fmt::Display for RefName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl fmt::Display for RemoteName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

fn reject_empty_or_leading_dash(name: &str) -> OutpostResult<()> {
    if name.is_empty() || name.starts_with('-') {
        invalid_ref(name.to_owned())
    } else {
        Ok(())
    }
}

fn invalid_ref<T>(name: String) -> OutpostResult<T> {
    Err(OutpostError::InvalidRefName { name })
}

fn git_check_ref_format<const N: usize>(args: [&str; N]) -> bool {
    Command::new("git")
        .args(args)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|status| status.success())
        .unwrap_or(false)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn branch_parse_rejects_leading_dash_and_accepts_feature_branch() {
        let err = BranchName::parse("-evil").expect_err("leading dash should be rejected");
        assert!(matches!(
            err,
            OutpostError::InvalidRefName { name } if name == "-evil"
        ));

        let branch = BranchName::parse("feature/foo").expect("feature branch should parse");
        assert_eq!(branch.as_str(), "feature/foo");
    }

    #[test]
    fn remote_parse_rejects_shell_like_value() {
        let err =
            RemoteName::parse("origin --upload-pack=evil").expect_err("spaces should be rejected");
        assert!(matches!(
            err,
            OutpostError::InvalidRefName { name } if name == "origin --upload-pack=evil"
        ));
    }

    #[test]
    fn ref_parse_uses_full_ref_validation() {
        let heads = RefName::parse("refs/heads/main").expect("full branch ref should parse");
        assert_eq!(heads.as_str(), "refs/heads/main");

        let err = RefName::parse("main").expect_err("bare branch is not a full ref");
        assert!(matches!(
            err,
            OutpostError::InvalidRefName { name } if name == "main"
        ));
    }

    #[test]
    fn source_remote_ref_parses_remote_and_branch() {
        let source_ref = SourceRemoteRef::parse("local/feature/foo").expect("source ref parses");
        assert_eq!(source_ref.remote.as_str(), "local");
        assert_eq!(source_ref.branch.as_str(), "feature/foo");

        let err = SourceRemoteRef::parse("feature").expect_err("missing slash is invalid");
        assert!(matches!(
            err,
            OutpostError::InvalidRefName { name } if name == "feature"
        ));
    }

    #[test]
    fn upstream_short_branch_returns_only_heads_refs() {
        let upstream = UpstreamRef {
            remote: RemoteName::parse("local").expect("remote parses"),
            merge_ref: RefName::parse("refs/heads/main").expect("head ref parses"),
        };
        assert_eq!(upstream.short_branch(), Some("main"));

        let tag = UpstreamRef {
            remote: RemoteName::parse("origin").expect("remote parses"),
            merge_ref: RefName::parse("refs/tags/v1.0").expect("tag ref parses"),
        };
        assert_eq!(tag.short_branch(), None);
    }
}