Skip to main content

git_proc/
branch.rs

1//! Git branch name type with validation.
2
3use std::borrow::Cow;
4
5/// A validated git branch name.
6///
7/// Branch names follow git's reference naming rules:
8/// - Cannot be empty
9/// - Cannot start with `-`, `.`, or `/`
10/// - Cannot end with `/`, `.`, or `.lock`
11/// - Cannot contain `..`, `//`, `@{`, or control characters
12/// - Cannot contain spaces or forbidden characters: `~^:?*[\`
13/// - Cannot be single `@`
14/// - No component can start with `.` or end with `.lock`
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct Branch(Cow<'static, str>);
17
18impl Branch {
19    /// Returns the branch name as a string slice.
20    #[must_use]
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24
25    /// Returns true if the branch name contains path separators.
26    #[must_use]
27    pub fn has_parents(&self) -> bool {
28        self.0.contains('/')
29    }
30
31    const fn is_forbidden_char(byte: u8) -> bool {
32        matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
33    }
34
35    const fn validate(input: &str) -> Result<(), BranchError> {
36        if input.is_empty() {
37            return Err(BranchError::Empty);
38        }
39
40        let bytes = input.as_bytes();
41
42        // Single @ is not allowed
43        if bytes.len() == 1 && bytes[0] == b'@' {
44            return Err(BranchError::SingleAt);
45        }
46
47        // Check first character
48        if bytes[0] == b'-' {
49            return Err(BranchError::StartsWithDash);
50        }
51        if bytes[0] == b'.' {
52            return Err(BranchError::StartsWithDot);
53        }
54        if bytes[0] == b'/' {
55            return Err(BranchError::StartsWithSlash);
56        }
57
58        // Check last character
59        if bytes[bytes.len() - 1] == b'/' {
60            return Err(BranchError::EndsWithSlash);
61        }
62        if bytes[bytes.len() - 1] == b'.' {
63            return Err(BranchError::EndsWithDot);
64        }
65
66        // Check for .lock suffix (need at least 5 chars).
67        // Using byte-by-byte comparison because array == is not const-compatible.
68        if bytes.len() >= 5
69            && bytes[bytes.len() - 5] == b'.'
70            && bytes[bytes.len() - 4] == b'l'
71            && bytes[bytes.len() - 3] == b'o'
72            && bytes[bytes.len() - 2] == b'c'
73            && bytes[bytes.len() - 1] == b'k'
74        {
75            return Err(BranchError::EndsWithLock);
76        }
77
78        // Check character-by-character constraints
79        // Using index loop because iterators are not const-compatible.
80        let mut index = 0;
81        while index < bytes.len() {
82            let byte = bytes[index];
83
84            // Control characters
85            if byte < 0x20 || byte == 0x7f {
86                return Err(BranchError::ContainsControlCharacter);
87            }
88
89            // Space
90            if byte == b' ' {
91                return Err(BranchError::ContainsSpace);
92            }
93
94            // Forbidden characters
95            if Self::is_forbidden_char(byte) {
96                return Err(BranchError::ContainsForbiddenCharacter);
97            }
98
99            // Check for .. sequence
100            if byte == b'.' && index + 1 < bytes.len() && bytes[index + 1] == b'.' {
101                return Err(BranchError::ContainsDoubleDot);
102            }
103
104            // Check for // sequence
105            if byte == b'/' && index + 1 < bytes.len() && bytes[index + 1] == b'/' {
106                return Err(BranchError::ContainsDoubleSlash);
107            }
108
109            // Check for @{ sequence
110            if byte == b'@' && index + 1 < bytes.len() && bytes[index + 1] == b'{' {
111                return Err(BranchError::ContainsAtBrace);
112            }
113
114            // Check for component starting with . (after /)
115            if byte == b'/' && index + 1 < bytes.len() && bytes[index + 1] == b'.' {
116                return Err(BranchError::ComponentStartsWithDot);
117            }
118
119            // Check for component ending with .lock (before /)
120            // Pattern: ".lock/" at position where index points to '.'
121            if byte == b'.'
122                && index + 5 < bytes.len()
123                && bytes[index + 1] == b'l'
124                && bytes[index + 2] == b'o'
125                && bytes[index + 3] == b'c'
126                && bytes[index + 4] == b'k'
127                && bytes[index + 5] == b'/'
128            {
129                return Err(BranchError::ComponentEndsWithLock);
130            }
131
132            index += 1;
133        }
134
135        Ok(())
136    }
137
138    /// Creates a branch name from a static string, panicking if invalid.
139    ///
140    /// This is useful for compile-time constants.
141    #[must_use]
142    pub const fn from_static_or_panic(input: &'static str) -> Self {
143        assert!(Self::validate(input).is_ok(), "invalid branch name");
144        Self(Cow::Borrowed(input))
145    }
146}
147
148impl std::fmt::Display for Branch {
149    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        write!(formatter, "{}", self.0)
151    }
152}
153
154impl AsRef<std::ffi::OsStr> for Branch {
155    fn as_ref(&self) -> &std::ffi::OsStr {
156        self.as_str().as_ref()
157    }
158}
159
160impl std::str::FromStr for Branch {
161    type Err = BranchError;
162
163    fn from_str(input: &str) -> Result<Self, Self::Err> {
164        Self::validate(input)?;
165        Ok(Self(Cow::Owned(input.to_string())))
166    }
167}
168
169/// Errors that can occur when parsing a branch name.
170#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
171pub enum BranchError {
172    #[error("branch name cannot be empty")]
173    Empty,
174    #[error("branch name cannot be single '@'")]
175    SingleAt,
176    #[error("branch name cannot start with '-'")]
177    StartsWithDash,
178    #[error("branch name cannot start with '.'")]
179    StartsWithDot,
180    #[error("branch name cannot start with '/'")]
181    StartsWithSlash,
182    #[error("branch name cannot end with '/'")]
183    EndsWithSlash,
184    #[error("branch name cannot end with '.'")]
185    EndsWithDot,
186    #[error("branch name cannot end with '.lock'")]
187    EndsWithLock,
188    #[error("branch name cannot contain '..'")]
189    ContainsDoubleDot,
190    #[error("branch name cannot contain '//'")]
191    ContainsDoubleSlash,
192    #[error("branch name cannot contain '@{{'")]
193    ContainsAtBrace,
194    #[error("branch component cannot start with '.'")]
195    ComponentStartsWithDot,
196    #[error("branch component cannot end with '.lock'")]
197    ComponentEndsWithLock,
198    #[error("branch name cannot contain control characters")]
199    ContainsControlCharacter,
200    #[error("branch name cannot contain spaces")]
201    ContainsSpace,
202    #[error("branch name cannot contain forbidden characters (~^:?*[\\)")]
203    ContainsForbiddenCharacter,
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_valid_branch() {
212        assert!("main".parse::<Branch>().is_ok());
213        assert!("feature/login".parse::<Branch>().is_ok());
214        assert!("feature/deeply/nested/branch".parse::<Branch>().is_ok());
215        assert!("fix-123".parse::<Branch>().is_ok());
216    }
217
218    #[test]
219    fn test_has_parents() {
220        assert!(!Branch::from_static_or_panic("main").has_parents());
221        assert!(Branch::from_static_or_panic("feature/login").has_parents());
222    }
223
224    #[test]
225    fn test_empty() {
226        assert!(matches!("".parse::<Branch>(), Err(BranchError::Empty)));
227    }
228
229    #[test]
230    fn test_single_at() {
231        assert!(matches!("@".parse::<Branch>(), Err(BranchError::SingleAt)));
232    }
233
234    #[test]
235    fn test_starts_with_dash() {
236        assert!(matches!(
237            "-branch".parse::<Branch>(),
238            Err(BranchError::StartsWithDash)
239        ));
240    }
241
242    #[test]
243    fn test_starts_with_dot() {
244        assert!(matches!(
245            ".branch".parse::<Branch>(),
246            Err(BranchError::StartsWithDot)
247        ));
248    }
249
250    #[test]
251    fn test_starts_with_slash() {
252        assert!(matches!(
253            "/branch".parse::<Branch>(),
254            Err(BranchError::StartsWithSlash)
255        ));
256    }
257
258    #[test]
259    fn test_ends_with_slash() {
260        assert!(matches!(
261            "branch/".parse::<Branch>(),
262            Err(BranchError::EndsWithSlash)
263        ));
264    }
265
266    #[test]
267    fn test_ends_with_dot() {
268        assert!(matches!(
269            "branch.".parse::<Branch>(),
270            Err(BranchError::EndsWithDot)
271        ));
272    }
273
274    #[test]
275    fn test_ends_with_lock() {
276        assert!(matches!(
277            "branch.lock".parse::<Branch>(),
278            Err(BranchError::EndsWithLock)
279        ));
280    }
281
282    #[test]
283    fn test_contains_double_dot() {
284        assert!(matches!(
285            "branch..name".parse::<Branch>(),
286            Err(BranchError::ContainsDoubleDot)
287        ));
288    }
289
290    #[test]
291    fn test_contains_double_slash() {
292        assert!(matches!(
293            "feature//branch".parse::<Branch>(),
294            Err(BranchError::ContainsDoubleSlash)
295        ));
296    }
297
298    #[test]
299    fn test_contains_at_brace() {
300        assert!(matches!(
301            "branch@{name".parse::<Branch>(),
302            Err(BranchError::ContainsAtBrace)
303        ));
304    }
305
306    #[test]
307    fn test_component_starts_with_dot() {
308        assert!(matches!(
309            "feature/.hidden".parse::<Branch>(),
310            Err(BranchError::ComponentStartsWithDot)
311        ));
312        assert!(matches!(
313            "a/b/.c/d".parse::<Branch>(),
314            Err(BranchError::ComponentStartsWithDot)
315        ));
316    }
317
318    #[test]
319    fn test_component_ends_with_lock() {
320        assert!(matches!(
321            "feature/branch.lock/next".parse::<Branch>(),
322            Err(BranchError::ComponentEndsWithLock)
323        ));
324    }
325
326    #[test]
327    fn test_contains_space() {
328        assert!(matches!(
329            "branch name".parse::<Branch>(),
330            Err(BranchError::ContainsSpace)
331        ));
332    }
333
334    #[test]
335    fn test_contains_control_character() {
336        assert!(matches!(
337            "branch\x00name".parse::<Branch>(),
338            Err(BranchError::ContainsControlCharacter)
339        ));
340        assert!(matches!(
341            "branch\tname".parse::<Branch>(),
342            Err(BranchError::ContainsControlCharacter)
343        ));
344    }
345
346    #[test]
347    fn test_contains_forbidden_characters() {
348        assert!(matches!(
349            "branch~name".parse::<Branch>(),
350            Err(BranchError::ContainsForbiddenCharacter)
351        ));
352        assert!(matches!(
353            "branch^name".parse::<Branch>(),
354            Err(BranchError::ContainsForbiddenCharacter)
355        ));
356        assert!(matches!(
357            "branch:name".parse::<Branch>(),
358            Err(BranchError::ContainsForbiddenCharacter)
359        ));
360        assert!(matches!(
361            "branch?name".parse::<Branch>(),
362            Err(BranchError::ContainsForbiddenCharacter)
363        ));
364        assert!(matches!(
365            "branch*name".parse::<Branch>(),
366            Err(BranchError::ContainsForbiddenCharacter)
367        ));
368        assert!(matches!(
369            "branch[name".parse::<Branch>(),
370            Err(BranchError::ContainsForbiddenCharacter)
371        ));
372        assert!(matches!(
373            "branch\\name".parse::<Branch>(),
374            Err(BranchError::ContainsForbiddenCharacter)
375        ));
376    }
377
378    #[test]
379    fn test_from_static_or_panic() {
380        let branch = Branch::from_static_or_panic("main");
381        assert_eq!(branch.as_str(), "main");
382    }
383
384    #[test]
385    fn test_display() {
386        let branch: Branch = "feature/test".parse().unwrap();
387        assert_eq!(format!("{branch}"), "feature/test");
388    }
389
390    #[test]
391    fn test_as_ref_os_str() {
392        use std::ffi::OsStr;
393        let branch: Branch = "main".parse().unwrap();
394        let os_str: &OsStr = branch.as_ref();
395        assert_eq!(os_str, "main");
396    }
397}