atypical_commit/
ignore.rs1use std::borrow::Cow;
6
7pub type Matcher = fn(&str) -> bool;
8
9pub 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
23pub fn is_ignored(header: &str) -> bool {
25 WILDCARDS.iter().any(|matches| matches(header))
26}
27
28fn 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
37fn is_merge_tag(header: &str) -> bool {
39 header.starts_with("Merge tag ")
40}
41
42fn is_revert(header: &str) -> bool {
44 header.starts_with("Revert ") || header.starts_with("revert ")
45}
46
47fn is_reapply(header: &str) -> bool {
49 header.starts_with("Reapply ") || header.starts_with("reapply ")
50}
51
52fn is_autosquash(header: &str) -> bool {
55 ["amend!", "fixup!", "squash!"]
56 .iter()
57 .any(|prefix| header.starts_with(prefix))
58}
59
60fn 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
73fn 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
83fn is_remote_tracking_merge(header: &str) -> bool {
85 header.starts_with("Merge remote-tracking branch")
86}
87
88fn is_automatic_merge(header: &str) -> bool {
90 header.starts_with("Automatic merge")
91}
92
93fn is_auto_merged(header: &str) -> bool {
95 header
96 .strip_prefix("Auto-merged ")
97 .is_some_and(|rest| rest.contains(" into "))
98}
99
100fn 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
121fn 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}