Skip to content

Commit 3a652d5

Browse files
add(lib)[cmp]: commitlint-style default ignore matchers
Port the default ignores from @commitlint/is-ignored as plain string matchers over the message header: merge commits (git and forges), reverts and reapplies, autosquash markers, and semver release bumps (via the semver crate, behind optional chore prefixes and skip-ci markers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a52cd6b commit 3a652d5

5 files changed

Lines changed: 248 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ chumsky = { version = "0.13", default-features = false }
2323
clap = { version = "4.6", default-features = false }
2424
clap-stdin = { version = "0.8", default-features = false }
2525
indoc = "2"
26+
semver = { version = "1.0", default-features = false }
2627
serde = { version = "1.0", default-features = false }
2728
toml = { version = "1.1", default-features = false }
2829

crates/atypical-commit/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ chumsky = { workspace = true }
2020
clap = { workspace = true }
2121
clap-stdin = { workspace = true }
2222
indoc = { workspace = true }
23+
semver = { workspace = true }
2324
serde = { workspace = true, features = ["alloc", "derive"] }
2425

2526
[dev-dependencies]
@@ -33,6 +34,7 @@ std = [
3334
"chumsky/stacker",
3435
"chumsky/std",
3536
"clap/std",
37+
"semver/std",
3638
"serde/std",
3739
]
3840

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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+
5+
use std::borrow::Cow;
6+
7+
pub type Matcher = fn(&str) -> bool;
8+
9+
/// The default ignores, checked against the message header.
10+
pub 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.
24+
pub 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`.
29+
fn 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' ...`.
38+
fn is_merge_tag(header: &str) -> bool {
39+
header.starts_with("Merge tag ")
40+
}
41+
42+
/// `Revert "..."`, as `git revert` writes it.
43+
fn 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.
48+
fn 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.
54+
fn 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`.
62+
fn 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).
74+
fn 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 '...'`.
84+
fn is_remote_tracking_merge(header: &str) -> bool {
85+
header.starts_with("Merge remote-tracking branch")
86+
}
87+
88+
/// `Automatic merge ...`.
89+
fn is_automatic_merge(header: &str) -> bool {
90+
header.starts_with("Automatic merge")
91+
}
92+
93+
/// `Auto-merged x into y`.
94+
fn 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.
101+
fn 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)`.
123+
fn 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+
145+
fn 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)]
162+
mod 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+
}

crates/atypical-commit/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use chumsky::prelude::*;
55

66
pub mod config;
7+
pub mod ignore;
78

89
pub type DelimitedBy = [char; 2];
910

0 commit comments

Comments
 (0)