Skip to main content

atypical_commit/
ignore.rs

1// Headers that git and forges write on their own — merges, reverts,
2// autosquash markers, semver release bumps — should never be linted.
3// Mirrors commitlint's default ignores (@commitlint/is-ignored).
4
5use std::borrow::Cow;
6
7pub type Matcher = fn(&str) -> bool;
8
9/// The default ignores, checked against the message header.
10pub const WILDCARDS: &[Matcher] = &[
11    is_merge,
12    is_merge_tag,
13    is_revert,
14    is_reapply,
15    is_autosquash,
16    is_semver,
17    is_merged,
18    is_remote_tracking_merge,
19    is_automatic_merge,
20    is_auto_merged,
21];
22
23/// Whether the header is machine-generated and exempt from linting.
24pub fn is_ignored(header: &str) -> bool {
25    WILDCARDS.iter().any(|matches| matches(header))
26}
27
28/// `Merge pull request #1 ...`, `Merge branch 'x'`, `Merge x into y`.
29fn is_merge(header: &str) -> bool {
30    header.starts_with("Merge pull request")
31        || header.starts_with("Merge branch ")
32        || header
33            .strip_prefix("Merge ")
34            .is_some_and(|rest| rest.contains(" into "))
35}
36
37/// `Merge tag 'v1.2.3' ...`.
38fn is_merge_tag(header: &str) -> bool {
39    header.starts_with("Merge tag ")
40}
41
42/// `Revert "..."`, as `git revert` writes it.
43fn is_revert(header: &str) -> bool {
44    header.starts_with("Revert ") || header.starts_with("revert ")
45}
46
47/// `Reapply "..."`, as `git revert` writes for a revert of a revert.
48fn is_reapply(header: &str) -> bool {
49    header.starts_with("Reapply ") || header.starts_with("reapply ")
50}
51
52/// `fixup! ...`, `squash! ...`, `amend! ...`; git drops these prefixes
53/// on `rebase --autosquash`, so only the target header gets recorded.
54fn is_autosquash(header: &str) -> bool {
55    ["amend!", "fixup!", "squash!"]
56        .iter()
57        .any(|prefix| header.starts_with(prefix))
58}
59
60/// A release bump: a bare version, optionally behind a `chore:`
61/// prefix and a `[skip ci]`-style marker, e.g. `chore(release): v1.2.3`.
62fn is_semver(header: &str) -> bool {
63    let stripped = strip_chore_prefix(header);
64    let stripped = remove_skip_marker(stripped, '[', ']');
65    let stripped = remove_skip_marker(&stripped, '(', ')');
66    let stripped = stripped.trim();
67
68    let version = stripped.strip_prefix('v').unwrap_or(stripped);
69
70    semver::Version::parse(version).is_ok()
71}
72
73/// `Merged x in(to) y` (Bitbucket), `Merged PR 1: ...` (Azure DevOps).
74fn is_merged(header: &str) -> bool {
75    header
76        .strip_prefix("Merged PR ")
77        .is_some_and(|rest| rest.contains(": "))
78        || header
79            .strip_prefix("Merged ")
80            .is_some_and(|rest| rest.contains("in ") || rest.contains("into "))
81}
82
83/// `Merge remote-tracking branch '...'`.
84fn is_remote_tracking_merge(header: &str) -> bool {
85    header.starts_with("Merge remote-tracking branch")
86}
87
88/// `Automatic merge ...`.
89fn is_automatic_merge(header: &str) -> bool {
90    header.starts_with("Automatic merge")
91}
92
93/// `Auto-merged x into y`.
94fn is_auto_merged(header: &str) -> bool {
95    header
96        .strip_prefix("Auto-merged ")
97        .is_some_and(|rest| rest.contains(" into "))
98}
99
100/// Strips `chore:` or `chore(<scope>):`, as release tools write it.
101fn strip_chore_prefix(header: &str) -> &str {
102    let Some(rest) = header.strip_prefix("chore") else {
103        return header;
104    };
105
106    if let Some(rest) = rest.strip_prefix(':') {
107        return rest;
108    }
109
110    if let Some(rest) = rest.strip_prefix('(')
111        && let Some((scope, rest)) = rest.split_once(')')
112        && !scope.is_empty()
113        && let Some(rest) = rest.strip_prefix(':')
114    {
115        return rest;
116    }
117
118    header
119}
120
121/// Removes the first `skip`/`ci` pair joined by `-` or whitespace
122/// between the given delimiters, e.g. `[skip ci]` or `(CI-skip)`.
123fn remove_skip_marker(header: &str, open: char, close: char) -> Cow<'_, str> {
124    for (start, _) in header.match_indices(open) {
125        let inner = start + open.len_utf8();
126
127        let Some(length) = header[inner..].find(close) else {
128            break;
129        };
130
131        if is_skip_marker(&header[inner..inner + length]) {
132            let end = inner + length + close.len_utf8();
133
134            return Cow::Owned(format!(
135                "{}{}",
136                &header[..start],
137                &header[end..]
138            ));
139        }
140    }
141
142    Cow::Borrowed(header)
143}
144
145fn is_skip_marker(inner: &str) -> bool {
146    let inner = inner.to_ascii_lowercase();
147
148    let Some(rest) = inner
149        .strip_prefix("skip")
150        .or_else(|| inner.strip_prefix("ci"))
151    else {
152        return false;
153    };
154
155    let mut rest = rest.chars();
156    let joined = rest.next().is_some_and(|c| c == '-' || c.is_whitespace());
157
158    joined && matches!(rest.as_str(), "ci" | "skip")
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_merges() {
167        assert!(is_ignored("Merge pull request #369 from repo/branch"));
168        assert!(is_ignored("Merge branch 'main' of github.com:x/y"));
169        assert!(is_ignored("Merge my-feature into develop"));
170        assert!(is_ignored("Merge tag 'v1.2.3' into main"));
171        assert!(is_ignored("Merge remote-tracking branch 'origin/main'"));
172
173        assert!(!is_ignored("Merge conflicts resolved"));
174        assert!(!is_ignored("merge branch 'main'"));
175    }
176
177    #[test]
178    fn test_forge_merges() {
179        assert!(is_ignored("Merged in feature/x (pull request #99)"));
180        assert!(is_ignored("Merged develop into master"));
181        assert!(is_ignored("Merged PR 123: fix stuff"));
182        assert!(is_ignored("Automatic merge from CodeStream"));
183        assert!(is_ignored("Auto-merged develop into master"));
184
185        assert!(!is_ignored("Merged nothing"));
186        assert!(!is_ignored("Merged PR 123"));
187        assert!(!is_ignored("Auto-merged develop"));
188    }
189
190    #[test]
191    fn test_reverts_and_reapplies() {
192        assert!(is_ignored("Revert \"add(lib): something\""));
193        assert!(is_ignored("revert \"add(lib): something\""));
194        assert!(is_ignored("Reapply \"add(lib): something\""));
195        assert!(is_ignored("reapply \"add(lib): something\""));
196
197        assert!(!is_ignored("Reverted the change"));
198        assert!(!is_ignored("Revert"));
199    }
200
201    #[test]
202    fn test_autosquash() {
203        assert!(is_ignored("fixup! add(lib): something"));
204        assert!(is_ignored("squash! add(lib): something"));
205        assert!(is_ignored("amend! add(lib): something"));
206
207        assert!(!is_ignored("fixup add(lib): something"));
208    }
209
210    #[test]
211    fn test_semver() {
212        assert!(is_ignored("1.2.3"));
213        assert!(is_ignored("v1.2.3"));
214        assert!(is_ignored("1.2.3-alpha.1+build.5"));
215        assert!(is_ignored("chore: 1.2.3"));
216        assert!(is_ignored("chore(release): v1.2.3"));
217        assert!(is_ignored("1.2.3 [skip ci]"));
218        assert!(is_ignored("1.2.3 (CI-Skip)"));
219        assert!(is_ignored("chore: v1.2.3 [skip-ci]"));
220
221        assert!(!is_ignored("1.2"));
222        assert!(!is_ignored("1.2.3.4"));
223        assert!(!is_ignored("chore(): 1.2.3"));
224        assert!(!is_ignored("chore(release: 1.2.3"));
225        assert!(!is_ignored("release: v1.2.3"));
226        assert!(!is_ignored("1.2.3 [skip ci] and more"));
227        assert!(!is_ignored("1.2.3 [not ci]"));
228        assert!(!is_ignored("1.2.3 [skip"));
229    }
230
231    #[test]
232    fn test_plain_headers_are_not_ignored() {
233        assert!(!is_ignored(""));
234        assert!(!is_ignored("add(lib)[int]: something"));
235        assert!(!is_ignored("feat: conventional but invalid here"));
236    }
237}