Skip to main content

use_git_branch/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned while parsing branch names.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GitBranchNameError {
10    /// The supplied branch name was empty.
11    Empty,
12    /// The supplied branch name used syntax this crate rejects.
13    InvalidName,
14    /// A remote-tracking name did not include both remote and branch parts.
15    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/// A validated branch name.
69#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
70pub struct GitBranchName(String);
71
72impl GitBranchName {
73    /// Creates a branch name from text.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`GitBranchNameError`] when the branch name is empty or invalid.
78    pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
79        validate_branch_name(value).map(Self)
80    }
81
82    /// Returns true for common mainline names.
83    #[must_use]
84    pub fn is_mainline(&self) -> bool {
85        matches!(self.as_str(), "main" | "master" | "trunk")
86    }
87
88    /// Returns true for feature branch spellings.
89    #[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    /// Returns true for release branch spellings.
95    #[must_use]
96    pub fn is_release(&self) -> bool {
97        self.as_str().starts_with("release/")
98    }
99
100    /// Returns true for hotfix branch spellings.
101    #[must_use]
102    pub fn is_hotfix(&self) -> bool {
103        self.as_str().starts_with("hotfix/")
104    }
105
106    /// Returns the branch name text.
107    #[must_use]
108    pub fn as_str(&self) -> &str {
109        &self.0
110    }
111
112    /// Consumes the branch name and returns owned text.
113    #[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/// A local branch name.
148#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
149pub struct LocalBranchName(GitBranchName);
150
151impl LocalBranchName {
152    /// Creates a local branch name.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`GitBranchNameError`] when the branch name is invalid.
157    pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
158        GitBranchName::new(value).map(Self)
159    }
160
161    /// Returns the inner branch name.
162    #[must_use]
163    pub const fn branch(&self) -> &GitBranchName {
164        &self.0
165    }
166
167    /// Returns the branch text.
168    #[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/// A remote-tracking branch spelling such as `origin/main`.
195#[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    /// Creates a remote-tracking branch name.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`GitBranchNameError`] when the value is invalid or missing parts.
208    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    /// Returns the remote name portion.
228    #[must_use]
229    pub fn remote(&self) -> &str {
230        &self.remote
231    }
232
233    /// Returns the branch name portion.
234    #[must_use]
235    pub const fn branch(&self) -> &GitBranchName {
236        &self.branch
237    }
238
239    /// Returns the full remote-tracking branch text.
240    #[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/// A conventional default branch name.
261#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
262pub struct DefaultBranchName(GitBranchName);
263
264impl DefaultBranchName {
265    /// Creates a default branch name.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`GitBranchNameError`] when the name is invalid.
270    pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
271        GitBranchName::new(value).map(Self)
272    }
273
274    /// Returns `main` as a default branch name.
275    #[must_use]
276    pub fn main() -> Self {
277        Self(GitBranchName(String::from("main")))
278    }
279
280    /// Returns `master` as a default branch name.
281    #[must_use]
282    pub fn master() -> Self {
283        Self(GitBranchName(String::from("master")))
284    }
285
286    /// Returns the default branch text.
287    #[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}