use std::borrow::Cow;
pub type Matcher = fn(&str) -> bool;
pub const WILDCARDS: &[Matcher] = &[
is_merge,
is_merge_tag,
is_revert,
is_reapply,
is_autosquash,
is_semver,
is_merged,
is_remote_tracking_merge,
is_automatic_merge,
is_auto_merged,
];
pub fn is_ignored(header: &str) -> bool {
WILDCARDS.iter().any(|matches| matches(header))
}
fn is_merge(header: &str) -> bool {
header.starts_with("Merge pull request")
|| header.starts_with("Merge branch ")
|| header
.strip_prefix("Merge ")
.is_some_and(|rest| rest.contains(" into "))
}
fn is_merge_tag(header: &str) -> bool {
header.starts_with("Merge tag ")
}
fn is_revert(header: &str) -> bool {
header.starts_with("Revert ") || header.starts_with("revert ")
}
fn is_reapply(header: &str) -> bool {
header.starts_with("Reapply ") || header.starts_with("reapply ")
}
fn is_autosquash(header: &str) -> bool {
["amend!", "fixup!", "squash!"]
.iter()
.any(|prefix| header.starts_with(prefix))
}
fn is_semver(header: &str) -> bool {
let stripped = strip_chore_prefix(header);
let stripped = remove_skip_marker(stripped, '[', ']');
let stripped = remove_skip_marker(&stripped, '(', ')');
let stripped = stripped.trim();
let version = stripped.strip_prefix('v').unwrap_or(stripped);
semver::Version::parse(version).is_ok()
}
fn is_merged(header: &str) -> bool {
header
.strip_prefix("Merged PR ")
.is_some_and(|rest| rest.contains(": "))
|| header
.strip_prefix("Merged ")
.is_some_and(|rest| rest.contains("in ") || rest.contains("into "))
}
fn is_remote_tracking_merge(header: &str) -> bool {
header.starts_with("Merge remote-tracking branch")
}
fn is_automatic_merge(header: &str) -> bool {
header.starts_with("Automatic merge")
}
fn is_auto_merged(header: &str) -> bool {
header
.strip_prefix("Auto-merged ")
.is_some_and(|rest| rest.contains(" into "))
}
fn strip_chore_prefix(header: &str) -> &str {
let Some(rest) = header.strip_prefix("chore") else {
return header;
};
if let Some(rest) = rest.strip_prefix(':') {
return rest;
}
if let Some(rest) = rest.strip_prefix('(')
&& let Some((scope, rest)) = rest.split_once(')')
&& !scope.is_empty()
&& let Some(rest) = rest.strip_prefix(':')
{
return rest;
}
header
}
fn remove_skip_marker(header: &str, open: char, close: char) -> Cow<'_, str> {
for (start, _) in header.match_indices(open) {
let inner = start + open.len_utf8();
let Some(length) = header[inner..].find(close) else {
break;
};
if is_skip_marker(&header[inner..inner + length]) {
let end = inner + length + close.len_utf8();
return Cow::Owned(format!(
"{}{}",
&header[..start],
&header[end..]
));
}
}
Cow::Borrowed(header)
}
fn is_skip_marker(inner: &str) -> bool {
let inner = inner.to_ascii_lowercase();
let Some(rest) = inner
.strip_prefix("skip")
.or_else(|| inner.strip_prefix("ci"))
else {
return false;
};
let mut rest = rest.chars();
let joined = rest.next().is_some_and(|c| c == '-' || c.is_whitespace());
joined && matches!(rest.as_str(), "ci" | "skip")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_merges() {
assert!(is_ignored("Merge pull request #369 from repo/branch"));
assert!(is_ignored("Merge branch 'main' of github.com:x/y"));
assert!(is_ignored("Merge my-feature into develop"));
assert!(is_ignored("Merge tag 'v1.2.3' into main"));
assert!(is_ignored("Merge remote-tracking branch 'origin/main'"));
assert!(!is_ignored("Merge conflicts resolved"));
assert!(!is_ignored("merge branch 'main'"));
}
#[test]
fn test_forge_merges() {
assert!(is_ignored("Merged in feature/x (pull request #99)"));
assert!(is_ignored("Merged develop into master"));
assert!(is_ignored("Merged PR 123: fix stuff"));
assert!(is_ignored("Automatic merge from CodeStream"));
assert!(is_ignored("Auto-merged develop into master"));
assert!(!is_ignored("Merged nothing"));
assert!(!is_ignored("Merged PR 123"));
assert!(!is_ignored("Auto-merged develop"));
}
#[test]
fn test_reverts_and_reapplies() {
assert!(is_ignored("Revert \"add(lib): something\""));
assert!(is_ignored("revert \"add(lib): something\""));
assert!(is_ignored("Reapply \"add(lib): something\""));
assert!(is_ignored("reapply \"add(lib): something\""));
assert!(!is_ignored("Reverted the change"));
assert!(!is_ignored("Revert"));
}
#[test]
fn test_autosquash() {
assert!(is_ignored("fixup! add(lib): something"));
assert!(is_ignored("squash! add(lib): something"));
assert!(is_ignored("amend! add(lib): something"));
assert!(!is_ignored("fixup add(lib): something"));
}
#[test]
fn test_semver() {
assert!(is_ignored("1.2.3"));
assert!(is_ignored("v1.2.3"));
assert!(is_ignored("1.2.3-alpha.1+build.5"));
assert!(is_ignored("chore: 1.2.3"));
assert!(is_ignored("chore(release): v1.2.3"));
assert!(is_ignored("1.2.3 [skip ci]"));
assert!(is_ignored("1.2.3 (CI-Skip)"));
assert!(is_ignored("chore: v1.2.3 [skip-ci]"));
assert!(!is_ignored("1.2"));
assert!(!is_ignored("1.2.3.4"));
assert!(!is_ignored("chore(): 1.2.3"));
assert!(!is_ignored("chore(release: 1.2.3"));
assert!(!is_ignored("release: v1.2.3"));
assert!(!is_ignored("1.2.3 [skip ci] and more"));
assert!(!is_ignored("1.2.3 [not ci]"));
assert!(!is_ignored("1.2.3 [skip"));
}
#[test]
fn test_plain_headers_are_not_ignored() {
assert!(!is_ignored(""));
assert!(!is_ignored("add(lib)[int]: something"));
assert!(!is_ignored("feat: conventional but invalid here"));
}
}