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}