Skip to main content

amont_runtime/hooks/
prepare_commit_msg.rs

1//! prepare-commit-msg — append the issue id found in the branch name.
2//!
3//! JIRA first (`ABC-1234`), else a bare Kanbanize id (`1234`). Only for a
4//! commit the user is authoring: `-m`, `-t`, a merge, a squash and `--amend`
5//! all pass a source in $2 and are left alone.
6
7use crate::check::Verdict;
8use crate::git;
9use std::fs::OpenOptions;
10use std::io::Write;
11
12/// `[A-Z]{3,32}-[1-9][0-9]{1,31}`, by hand.
13///
14/// A '-' is not uppercase, so the letter run always ends exactly at it: for a
15/// start position, take the run and require its length in 3..=32. A 40-letter
16/// prefix therefore fails at offset 0 and matches at offset 8, which is what
17/// the regex's leftmost-match does too.
18pub fn find_jira(s: &str) -> Option<String> {
19    let b = s.as_bytes();
20    for start in 0..b.len() {
21        let mut e = start;
22        while e < b.len() && b[e].is_ascii_uppercase() {
23            e += 1;
24        }
25        let letters = e - start;
26        if !(3..=32).contains(&letters) || e >= b.len() || b[e] != b'-' {
27            continue;
28        }
29        let d0 = e + 1;
30        // [1-9] — a leading zero is not a JIRA number
31        if d0 >= b.len() || !(b'1'..=b'9').contains(&b[d0]) {
32            continue;
33        }
34        // [0-9]{1,31}, greedy: at least one more digit, so ABC-1 does NOT match
35        let mut d = d0 + 1;
36        while d < b.len() && b[d].is_ascii_digit() && d - d0 < 32 {
37            d += 1;
38        }
39        if d - d0 < 2 {
40            continue;
41        }
42        return Some(s[start..d].to_string());
43    }
44    None
45}
46
47/// `[0-9]{3,}`, greedy, leftmost.
48pub fn find_digits(s: &str, min: usize) -> Option<String> {
49    let b = s.as_bytes();
50    let mut i = 0;
51    while i < b.len() {
52        if !b[i].is_ascii_digit() {
53            i += 1;
54            continue;
55        }
56        let start = i;
57        while i < b.len() && b[i].is_ascii_digit() {
58            i += 1;
59        }
60        if i - start >= min {
61            return Some(s[start..i].to_string());
62        }
63    }
64    None
65}
66
67/// Append a line, best-effort.
68///
69/// Returns nothing, because there is nothing to decide: a commit is never
70/// blocked over the message file, the id being a convenience rather than a
71/// gate. That was previously expressed as an `i32` that could only ever be
72/// zero — a decision type for a function that makes no decision.
73fn append(path: &str, line: &str) {
74    // create(true): the shell's `>>` makes the file when absent, and git does
75    // not always pre-create the message file.
76    if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
77        let _ = writeln!(file, "\n{line}");
78    }
79}
80
81pub fn run(args: &[std::ffi::OsString]) -> Verdict {
82    let Some(msg_file) = args.first().and_then(|a| a.to_str()) else {
83        return Verdict::Proceed;
84    };
85    // $2 is the commit source, and the shell version switched on it with a
86    // `case` that named five values and let EVERYTHING ELSE fall through to
87    // the append. So the test is right to pass "magic" and expect an id: an
88    // unrecognised source means "treat it as an authored commit". Skipping on
89    // "not empty" instead of "one of these five" silently disabled the hook
90    // for every source git may add in future.
91    let source = args.get(1).and_then(|a| a.to_str()).unwrap_or("");
92    if matches!(
93        source,
94        "message" | "template" | "merge" | "squash" | "commit"
95    ) {
96        return Verdict::Proceed;
97    }
98
99    let branch = git::stdout(&["branch", "--show-current"]).unwrap_or_default();
100
101    if let Some(id) = find_jira(&branch) {
102        append(msg_file, &format!("Issue: {id}"));
103        return Verdict::Proceed;
104    }
105    // Only when there IS an id. The shell version tested `$?` after a pipeline
106    // ending in `head -n 1`, which is head's status and therefore always 0 —
107    // so a branch with no digits appended a dangling "Issue: #id " with an
108    // empty value. Untested there, and fixed here.
109    if let Some(id) = find_digits(&branch, 3) {
110        append(msg_file, &format!("Issue: #id {id}"));
111        return Verdict::Proceed;
112    }
113    Verdict::Proceed
114}
115
116#[cfg(test)]
117mod tests {
118    use super::{find_digits, find_jira};
119
120    #[test]
121    fn finds_a_jira_id() {
122        assert_eq!(
123            find_jira("feat/JIRA-1234-description").as_deref(),
124            Some("JIRA-1234")
125        );
126        assert_eq!(find_jira("ABC-12").as_deref(), Some("ABC-12"));
127    }
128
129    #[test]
130    fn rejects_shapes_the_regex_rejects() {
131        assert_eq!(find_jira("AB-1234"), None); // fewer than 3 letters
132        assert_eq!(find_jira("ABC-1"), None); // needs 2+ digits
133        assert_eq!(find_jira("ABC-0123"), None); // leading zero
134        assert_eq!(find_jira("abc-1234"), None); // lowercase
135        assert_eq!(find_jira("feat/nothing-here"), None);
136    }
137
138    /// 40 uppercase letters then `-12`: no match at offset 0 (run too long),
139    /// but the leftmost VALID start is offset 8. The regex behaves the same.
140    #[test]
141    fn long_letter_runs_match_at_a_later_offset() {
142        let s = format!("{}-12", "A".repeat(40));
143        assert_eq!(
144            find_jira(&s).as_deref(),
145            Some(&*format!("{}-12", "A".repeat(32)))
146        );
147    }
148
149    #[test]
150    fn finds_bare_ids_of_at_least_three_digits() {
151        assert_eq!(
152            find_digits("fix/1234-something", 3).as_deref(),
153            Some("1234")
154        );
155        assert_eq!(find_digits("fix/12-something", 3), None);
156        assert_eq!(find_digits("release/007", 3).as_deref(), Some("007"));
157        assert_eq!(find_digits("no-digits-here", 3), None);
158    }
159}