1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GitBranchNameError {
10 Empty,
12 InvalidName,
14 MissingRemoteOrBranch,
16}
17
18impl fmt::Display for GitBranchNameError {
19 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::Empty => formatter.write_str("Git branch name cannot be empty"),
22 Self::InvalidName => formatter.write_str("invalid Git branch name"),
23 Self::MissingRemoteOrBranch => {
24 formatter.write_str("remote-tracking branch must contain remote and branch names")
25 },
26 }
27 }
28}
29
30impl Error for GitBranchNameError {}
31
32fn has_lock_suffix(value: &str) -> bool {
33 value
34 .get(value.len().saturating_sub(5)..)
35 .is_some_and(|suffix| suffix.eq_ignore_ascii_case(".lock"))
36}
37
38fn validate_branch_name(value: impl AsRef<str>) -> Result<String, GitBranchNameError> {
39 let trimmed = value.as_ref().trim();
40
41 if trimmed.is_empty() {
42 return Err(GitBranchNameError::Empty);
43 }
44
45 let invalid = trimmed == "HEAD"
46 || trimmed.starts_with('/')
47 || trimmed.ends_with('/')
48 || trimmed.starts_with('.')
49 || trimmed.ends_with('.')
50 || has_lock_suffix(trimmed)
51 || trimmed.contains("//")
52 || trimmed.contains("..")
53 || trimmed.contains("@{")
54 || trimmed.chars().any(|character| {
55 character.is_ascii_control()
56 || character.is_ascii_whitespace()
57 || matches!(character, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
58 })
59 || trimmed.split('/').any(|component| component.ends_with('.'));
60
61 if invalid {
62 Err(GitBranchNameError::InvalidName)
63 } else {
64 Ok(trimmed.to_string())
65 }
66}
67
68#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
70pub struct GitBranchName(String);
71
72impl GitBranchName {
73 pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
79 validate_branch_name(value).map(Self)
80 }
81
82 #[must_use]
84 pub fn is_mainline(&self) -> bool {
85 matches!(self.as_str(), "main" | "master" | "trunk")
86 }
87
88 #[must_use]
90 pub fn is_feature(&self) -> bool {
91 self.as_str().starts_with("feature/") || self.as_str().starts_with("feat/")
92 }
93
94 #[must_use]
96 pub fn is_release(&self) -> bool {
97 self.as_str().starts_with("release/")
98 }
99
100 #[must_use]
102 pub fn is_hotfix(&self) -> bool {
103 self.as_str().starts_with("hotfix/")
104 }
105
106 #[must_use]
108 pub fn as_str(&self) -> &str {
109 &self.0
110 }
111
112 #[must_use]
114 pub fn into_string(self) -> String {
115 self.0
116 }
117}
118
119impl AsRef<str> for GitBranchName {
120 fn as_ref(&self) -> &str {
121 self.as_str()
122 }
123}
124
125impl fmt::Display for GitBranchName {
126 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127 formatter.write_str(self.as_str())
128 }
129}
130
131impl FromStr for GitBranchName {
132 type Err = GitBranchNameError;
133
134 fn from_str(value: &str) -> Result<Self, Self::Err> {
135 Self::new(value)
136 }
137}
138
139impl TryFrom<&str> for GitBranchName {
140 type Error = GitBranchNameError;
141
142 fn try_from(value: &str) -> Result<Self, Self::Error> {
143 Self::new(value)
144 }
145}
146
147#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
149pub struct LocalBranchName(GitBranchName);
150
151impl LocalBranchName {
152 pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
158 GitBranchName::new(value).map(Self)
159 }
160
161 #[must_use]
163 pub const fn branch(&self) -> &GitBranchName {
164 &self.0
165 }
166
167 #[must_use]
169 pub fn as_str(&self) -> &str {
170 self.0.as_str()
171 }
172}
173
174impl AsRef<str> for LocalBranchName {
175 fn as_ref(&self) -> &str {
176 self.as_str()
177 }
178}
179
180impl fmt::Display for LocalBranchName {
181 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
182 formatter.write_str(self.as_str())
183 }
184}
185
186impl FromStr for LocalBranchName {
187 type Err = GitBranchNameError;
188
189 fn from_str(value: &str) -> Result<Self, Self::Err> {
190 Self::new(value)
191 }
192}
193
194#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
196pub struct RemoteTrackingBranchName {
197 value: String,
198 remote: String,
199 branch: GitBranchName,
200}
201
202impl RemoteTrackingBranchName {
203 pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
209 let value = value.as_ref().trim();
210 let Some((remote, branch)) = value.split_once('/') else {
211 return Err(GitBranchNameError::MissingRemoteOrBranch);
212 };
213
214 if remote.is_empty() || branch.is_empty() || remote.contains(char::is_whitespace) {
215 return Err(GitBranchNameError::MissingRemoteOrBranch);
216 }
217
218 let branch = GitBranchName::new(branch)?;
219
220 Ok(Self {
221 value: value.to_string(),
222 remote: remote.to_string(),
223 branch,
224 })
225 }
226
227 #[must_use]
229 pub fn remote(&self) -> &str {
230 &self.remote
231 }
232
233 #[must_use]
235 pub const fn branch(&self) -> &GitBranchName {
236 &self.branch
237 }
238
239 #[must_use]
241 pub fn as_str(&self) -> &str {
242 &self.value
243 }
244}
245
246impl fmt::Display for RemoteTrackingBranchName {
247 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
248 formatter.write_str(self.as_str())
249 }
250}
251
252impl FromStr for RemoteTrackingBranchName {
253 type Err = GitBranchNameError;
254
255 fn from_str(value: &str) -> Result<Self, Self::Err> {
256 Self::new(value)
257 }
258}
259
260#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
262pub struct DefaultBranchName(GitBranchName);
263
264impl DefaultBranchName {
265 pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
271 GitBranchName::new(value).map(Self)
272 }
273
274 #[must_use]
276 pub fn main() -> Self {
277 Self(GitBranchName(String::from("main")))
278 }
279
280 #[must_use]
282 pub fn master() -> Self {
283 Self(GitBranchName(String::from("master")))
284 }
285
286 #[must_use]
288 pub fn as_str(&self) -> &str {
289 self.0.as_str()
290 }
291}
292
293impl fmt::Display for DefaultBranchName {
294 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
295 formatter.write_str(self.as_str())
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::{DefaultBranchName, GitBranchName, GitBranchNameError, RemoteTrackingBranchName};
302
303 #[test]
304 fn validates_branch_categories() -> Result<(), GitBranchNameError> {
305 let feature = GitBranchName::new("feature/use-git")?;
306 let release = GitBranchName::new("release/0.1")?;
307 let hotfix = GitBranchName::new("hotfix/docs")?;
308
309 assert!(feature.is_feature());
310 assert!(release.is_release());
311 assert!(hotfix.is_hotfix());
312 assert!(DefaultBranchName::main().as_str() == "main");
313 Ok(())
314 }
315
316 #[test]
317 fn parses_remote_tracking_branch() -> Result<(), GitBranchNameError> {
318 let branch = RemoteTrackingBranchName::new("origin/main")?;
319
320 assert_eq!(branch.remote(), "origin");
321 assert_eq!(branch.branch().as_str(), "main");
322 Ok(())
323 }
324
325 #[test]
326 fn rejects_invalid_branch_names() {
327 assert_eq!(GitBranchName::new(""), Err(GitBranchNameError::Empty));
328 assert_eq!(
329 GitBranchName::new("HEAD"),
330 Err(GitBranchNameError::InvalidName)
331 );
332 assert_eq!(
333 GitBranchName::new("feature//x"),
334 Err(GitBranchNameError::InvalidName)
335 );
336 }
337}