1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
//! `git_commit_subject_matches` — each commit's subject line (the
//! first line of its message) must match a regex.
//!
//! The subject-grammar member of the commit-validation family
//! (`git_commit_signed_off`, `git_commit_no_fixup`, …): enforces a
//! prefix + shape convention like `pkg/path: lowercase summary`
//! (go / Gerrit), `subsystem: description` (node), or
//! conventional-commit types. Unlike `git_commit_message`'s
//! `pattern:` (which matches the whole subject + body), `matches:`
//! is anchored to the **subject alone**, so `^…$` describes the
//! first line exactly. For a subject-length cap use
//! `git_commit_message`'s `subject_max_length:`.
//!
//! Shares the family shape (the `commit_range` module): `since:`
//! unset checks HEAD only; `since:` set checks `<since>..HEAD`,
//! oldest-first, merge commits excluded unless `include_merges:`.
//! Silent outside a git repo / with no commits; a bad `since:` ref
//! hard-fails with a shallow-clone hint. `since:`'s `{{env.X}}`
//! interpolation is resolved at config load by `alint-dsl`.
//!
//! Check-only — alint can't rewrite the user's commit history.
use alint_core::git::CommitRecord;
use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Violation};
use regex::Regex;
use serde::Deserialize;
use crate::commit_range::{collect_commits, format_commit_violation};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
/// Regex the commit subject (first line of the message) must
/// match.
matches: String,
/// Base ref for range mode. Unset → HEAD only. The canonical
/// `{{env.X}}` interpolation is resolved at config load.
#[serde(default)]
since: Option<String>,
/// Include merge commits when checking a range. No effect
/// without `since:`.
#[serde(default)]
include_merges: bool,
}
#[derive(Debug)]
pub struct GitCommitSubjectMatchesRule {
id: String,
level: Level,
policy_url: Option<String>,
message_override: Option<String>,
matches: Regex,
since_raw: Option<String>,
include_merges: bool,
}
impl Rule for GitCommitSubjectMatchesRule {
alint_core::rule_common_impl!();
fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
let commits = collect_commits(
ctx,
self.since_raw.as_deref(),
self.include_merges,
&self.id,
)?;
Ok(commits.iter().filter_map(|c| self.check_one(c)).collect())
}
}
impl GitCommitSubjectMatchesRule {
/// Match a single commit's **subject** (first line of the
/// message) against `matches:`. The body is never consulted, so a
/// passing subject with a non-conforming body line is clean and a
/// failing subject is not rescued by a conforming body line.
fn check_one(&self, commit: &CommitRecord) -> Option<Violation> {
let subject = commit.message.split('\n').next().unwrap_or("");
if self.matches.is_match(subject) {
return None;
}
let msg = self.message_override.clone().unwrap_or_else(|| {
format_commit_violation(
commit,
&format!("subject does not match `{}`", self.matches.as_str()),
)
});
Some(Violation::new(msg))
}
}
pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
let opts: Options = spec
.deserialize_options()
.map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
if spec.fix.is_some() {
return Err(Error::rule_config(
&spec.id,
"git_commit_subject_matches has no fix op",
));
}
if opts.include_merges && opts.since.is_none() {
return Err(Error::rule_config(
&spec.id,
"`include_merges: true` has no effect without `since:`. Either remove it \
or set `since:` to enable range mode.",
));
}
let matches = Regex::new(&opts.matches).map_err(|e| {
Error::rule_config(
&spec.id,
format!("invalid `matches:` regex `{}`: {e}", opts.matches),
)
})?;
Ok(Box::new(GitCommitSubjectMatchesRule {
id: spec.id.clone(),
level: spec.level,
policy_url: spec.policy_url.clone(),
message_override: spec.message.clone(),
matches,
since_raw: opts.since,
include_merges: opts.include_merges,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(toml: &str) -> RuleSpec {
let mut full = String::from(
"id = \"subject-grammar\"\nkind = \"git_commit_subject_matches\"\nlevel = \"error\"\n",
);
full.push_str(toml);
toml::from_str(&full).unwrap()
}
/// Construct the concrete rule directly so tests can drive
/// `check_one` — the real per-commit path — without a git repo.
fn match_rule(re: &str) -> GitCommitSubjectMatchesRule {
GitCommitSubjectMatchesRule {
id: "subject-grammar".into(),
level: Level::Error,
policy_url: None,
message_override: None,
matches: Regex::new(re).unwrap(),
since_raw: None,
include_merges: false,
}
}
fn record(message: &str) -> CommitRecord {
CommitRecord {
sha: "abc1234".into(),
message: message.into(),
author_name: String::new(),
author_email: String::new(),
}
}
#[test]
fn build_accepts_minimal_and_rejects_fix() {
assert!(build(&spec("matches = \"^[a-z]+: \"\n")).is_ok());
assert!(
build(&spec(
"matches = \"^x\"\nfix = { file_create = { content = \"x\" } }\n"
))
.is_err()
);
}
#[test]
fn build_requires_matches() {
// `matches:` is the one required field.
assert!(build(&spec("")).is_err());
}
#[test]
fn build_rejects_invalid_regex() {
let err = build(&spec("matches = \"(unclosed\"\n")).unwrap_err();
assert!(err.to_string().contains("regex"), "{err}");
}
#[test]
fn build_rejects_include_merges_without_since() {
let err = build(&spec("matches = \"^x\"\ninclude_merges = true\n")).unwrap_err();
assert!(err.to_string().contains("include_merges"), "{err}");
}
#[test]
fn subject_is_matched_against_the_first_line_only() {
// Exercises `check_one` (the body of `evaluate`'s per-commit
// loop), so it locks the rule's real anchoring behaviour
// rather than a regex constructed inline by the test.
let rule = match_rule(r"^[a-z0-9_/.-]+: [a-z].{0,70}$");
// Subject conforms; a body line that does NOT conform must
// be ignored — the regex is anchored to the subject alone.
let ok = record("pkg/net: add a thing\n\nWIP: Capitalised body that fails the grammar");
assert!(
rule.check_one(&ok).is_none(),
"a conforming subject is clean regardless of the body"
);
// Subject does NOT conform; a later body line that WOULD
// conform must not rescue it — we never scan the body.
let bad = record("WIP messy subject\n\nfeat: tidy body line");
let v = rule
.check_one(&bad)
.expect("a non-conforming subject fires");
assert!(
v.message.contains("does not match"),
"message names the mismatch: {}",
v.message
);
}
}