Skip to main content

outpost_core/
refname.rs

1use std::fmt;
2use std::process::{Command, Stdio};
3
4use crate::{OutpostError, OutpostResult};
5
6#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
7pub struct BranchName(String);
8
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
10pub struct RefName(String);
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct RemoteName(String);
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct SourceRemoteRef {
17    pub remote: RemoteName,
18    pub branch: BranchName,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct UpstreamRef {
23    pub remote: RemoteName,
24    pub merge_ref: RefName,
25}
26
27impl BranchName {
28    pub fn parse(name: impl Into<String>) -> OutpostResult<Self> {
29        let name = name.into();
30        reject_empty_or_leading_dash(&name)?;
31        if git_check_ref_format(["check-ref-format", "--branch", name.as_str()]) {
32            Ok(Self(name))
33        } else {
34            invalid_ref(name)
35        }
36    }
37
38    pub fn as_str(&self) -> &str {
39        &self.0
40    }
41}
42
43impl RefName {
44    pub fn parse(name: impl Into<String>) -> OutpostResult<Self> {
45        let name = name.into();
46        reject_empty_or_leading_dash(&name)?;
47        if git_check_ref_format(["check-ref-format", name.as_str()]) {
48            Ok(Self(name))
49        } else {
50            invalid_ref(name)
51        }
52    }
53
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57}
58
59impl RemoteName {
60    pub fn parse(name: impl Into<String>) -> OutpostResult<Self> {
61        let name = name.into();
62        reject_empty_or_leading_dash(&name)?;
63        if name
64            .bytes()
65            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
66        {
67            Ok(Self(name))
68        } else {
69            invalid_ref(name)
70        }
71    }
72
73    pub fn as_str(&self) -> &str {
74        &self.0
75    }
76}
77
78impl SourceRemoteRef {
79    pub fn parse(value: impl Into<String>) -> OutpostResult<Self> {
80        let value = value.into();
81        let Some((remote, branch)) = value.split_once('/') else {
82            return invalid_ref(value);
83        };
84
85        Ok(Self {
86            remote: RemoteName::parse(remote.to_owned())?,
87            branch: BranchName::parse(branch.to_owned())?,
88        })
89    }
90}
91
92impl UpstreamRef {
93    pub fn short_branch(&self) -> Option<&str> {
94        self.merge_ref.as_str().strip_prefix("refs/heads/")
95    }
96}
97
98impl fmt::Display for BranchName {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        f.write_str(self.as_str())
101    }
102}
103
104impl fmt::Display for RefName {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.write_str(self.as_str())
107    }
108}
109
110impl fmt::Display for RemoteName {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116fn reject_empty_or_leading_dash(name: &str) -> OutpostResult<()> {
117    if name.is_empty() || name.starts_with('-') {
118        invalid_ref(name.to_owned())
119    } else {
120        Ok(())
121    }
122}
123
124fn invalid_ref<T>(name: String) -> OutpostResult<T> {
125    Err(OutpostError::InvalidRefName { name })
126}
127
128fn git_check_ref_format<const N: usize>(args: [&str; N]) -> bool {
129    Command::new("git")
130        .args(args)
131        .stdout(Stdio::null())
132        .stderr(Stdio::null())
133        .status()
134        .map(|status| status.success())
135        .unwrap_or(false)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn branch_parse_rejects_leading_dash_and_accepts_feature_branch() {
144        let err = BranchName::parse("-evil").expect_err("leading dash should be rejected");
145        assert!(matches!(
146            err,
147            OutpostError::InvalidRefName { name } if name == "-evil"
148        ));
149
150        let branch = BranchName::parse("feature/foo").expect("feature branch should parse");
151        assert_eq!(branch.as_str(), "feature/foo");
152    }
153
154    #[test]
155    fn remote_parse_rejects_shell_like_value() {
156        let err =
157            RemoteName::parse("origin --upload-pack=evil").expect_err("spaces should be rejected");
158        assert!(matches!(
159            err,
160            OutpostError::InvalidRefName { name } if name == "origin --upload-pack=evil"
161        ));
162    }
163
164    #[test]
165    fn ref_parse_uses_full_ref_validation() {
166        let heads = RefName::parse("refs/heads/main").expect("full branch ref should parse");
167        assert_eq!(heads.as_str(), "refs/heads/main");
168
169        let err = RefName::parse("main").expect_err("bare branch is not a full ref");
170        assert!(matches!(
171            err,
172            OutpostError::InvalidRefName { name } if name == "main"
173        ));
174    }
175
176    #[test]
177    fn source_remote_ref_parses_remote_and_branch() {
178        let source_ref = SourceRemoteRef::parse("local/feature/foo").expect("source ref parses");
179        assert_eq!(source_ref.remote.as_str(), "local");
180        assert_eq!(source_ref.branch.as_str(), "feature/foo");
181
182        let err = SourceRemoteRef::parse("feature").expect_err("missing slash is invalid");
183        assert!(matches!(
184            err,
185            OutpostError::InvalidRefName { name } if name == "feature"
186        ));
187    }
188
189    #[test]
190    fn upstream_short_branch_returns_only_heads_refs() {
191        let upstream = UpstreamRef {
192            remote: RemoteName::parse("local").expect("remote parses"),
193            merge_ref: RefName::parse("refs/heads/main").expect("head ref parses"),
194        };
195        assert_eq!(upstream.short_branch(), Some("main"));
196
197        let tag = UpstreamRef {
198            remote: RemoteName::parse("origin").expect("remote parses"),
199            merge_ref: RefName::parse("refs/tags/v1.0").expect("tag ref parses"),
200        };
201        assert_eq!(tag.short_branch(), None);
202    }
203}