Skip to main content

alint_rules/
git_commit_message.rs

1//! `git_commit_message` — assert HEAD's commit message matches a
2//! shape (regex, max subject length, body required).
3//!
4//! Use cases: enforce Conventional Commits / Angular-style
5//! prefixes, cap the subject at a screen-friendly width
6//! (50–72), require commits that fix issues to include a body
7//! linking the issue. CI integration: run `alint check
8//! --changed` (or `alint check`) on every PR; alint reads the
9//! tip commit and fires if the shape is off.
10//!
11//! Outside a git repo, with no commits yet, or when `git` isn't
12//! on PATH, the rule silently no-ops. This is the same advisory
13//! posture as `git_tracked_only` and `git_no_denied_paths`: a
14//! rule about git only fires when there's git to inspect.
15//!
16//! Check-only — alint can't rewrite the user's commit
17//! message, and `git commit --amend` is a sensitive operation
18//! we don't automate.
19
20use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Violation};
21use alint_core::git::head_commit_message;
22use regex::Regex;
23use serde::Deserialize;
24
25#[derive(Debug, Deserialize)]
26#[serde(deny_unknown_fields)]
27struct Options {
28    /// Regex the full commit message (subject + body, joined
29    /// with newlines) must match. When omitted, no regex check
30    /// is applied. Use `(?s)` to make `.` match newlines if you
31    /// want to assert about content past the subject.
32    #[serde(default)]
33    pattern: Option<String>,
34    /// Maximum length of the subject line (the message's first
35    /// line, before any body). When omitted, no length cap.
36    /// Common values: 50 (Tim Pope's recommendation), 72
37    /// (GitHub's PR-title cutoff).
38    #[serde(default)]
39    subject_max_length: Option<usize>,
40    /// When `true`, the message must have a non-empty body —
41    /// at least one line of content after the subject's
42    /// trailing blank line. Useful for mandating an
43    /// explanation on `fix:` commits etc.
44    #[serde(default)]
45    requires_body: bool,
46}
47
48#[derive(Debug)]
49pub struct GitCommitMessageRule {
50    id: String,
51    level: Level,
52    policy_url: Option<String>,
53    message_override: Option<String>,
54    pattern: Option<Regex>,
55    subject_max_length: Option<usize>,
56    requires_body: bool,
57}
58
59impl Rule for GitCommitMessageRule {
60    fn id(&self) -> &str {
61        &self.id
62    }
63    fn level(&self) -> Level {
64        self.level
65    }
66    fn policy_url(&self) -> Option<&str> {
67        self.policy_url.as_deref()
68    }
69
70    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
71        let mut violations = Vec::new();
72        let Some(message) = head_commit_message(ctx.root) else {
73            // No git, no commits, or git not on PATH — silent
74            // no-op. This rule only makes sense when there's a
75            // commit to inspect.
76            return Ok(violations);
77        };
78
79        let (subject, body) = split_subject_body(&message);
80
81        if let Some(re) = &self.pattern
82            && !re.is_match(&message)
83        {
84            violations.push(self.make_violation(format!(
85                "HEAD commit message does not match pattern `{}`",
86                re.as_str(),
87            )));
88        }
89
90        if let Some(max) = self.subject_max_length
91            && subject.chars().count() > max
92        {
93            violations.push(self.make_violation(format!(
94                "HEAD commit subject is {} chars; max allowed is {max}",
95                subject.chars().count(),
96            )));
97        }
98
99        if self.requires_body && body.trim().is_empty() {
100            violations.push(self.make_violation(
101                "HEAD commit message has no body; this rule requires one".to_string(),
102            ));
103        }
104
105        Ok(violations)
106    }
107}
108
109impl GitCommitMessageRule {
110    fn make_violation(&self, default_msg: String) -> Violation {
111        Violation::new(self.message_override.clone().unwrap_or(default_msg))
112    }
113}
114
115/// Split a commit message into (subject, body). The subject is
116/// the first line; the body is everything after the first
117/// blank line that follows it. Messages with no blank-line
118/// separator have an empty body. Trailing whitespace on the
119/// subject is preserved as-is — the length check counts it.
120fn split_subject_body(message: &str) -> (&str, &str) {
121    let (subject, rest) = message.split_once('\n').unwrap_or((message, ""));
122    // Skip exactly one trailing blank-line separator if present
123    // (the canonical "subject\n\nbody" shape). Multiple blank
124    // lines fall through into the body — they're unusual but
125    // we don't want to silently swallow user content.
126    let body = rest.strip_prefix('\n').unwrap_or(rest);
127    (subject, body)
128}
129
130pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
131    let opts: Options = spec
132        .deserialize_options()
133        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
134
135    if opts.pattern.is_none()
136        && opts.subject_max_length.is_none()
137        && !opts.requires_body
138    {
139        return Err(Error::rule_config(
140            &spec.id,
141            "git_commit_message needs at least one of `pattern:`, `subject_max_length:`, \
142             or `requires_body: true`",
143        ));
144    }
145    if spec.fix.is_some() {
146        return Err(Error::rule_config(
147            &spec.id,
148            "git_commit_message has no fix op",
149        ));
150    }
151
152    let pattern = opts
153        .pattern
154        .as_deref()
155        .map(|p| {
156            Regex::new(p).map_err(|e| {
157                Error::rule_config(&spec.id, format!("invalid `pattern:` regex `{p}`: {e}"))
158            })
159        })
160        .transpose()?;
161
162    Ok(Box::new(GitCommitMessageRule {
163        id: spec.id.clone(),
164        level: spec.level,
165        policy_url: spec.policy_url.clone(),
166        message_override: spec.message.clone(),
167        pattern,
168        subject_max_length: opts.subject_max_length,
169        requires_body: opts.requires_body,
170    }))
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn split_one_line_message() {
179        let (subj, body) = split_subject_body("just a subject");
180        assert_eq!(subj, "just a subject");
181        assert_eq!(body, "");
182    }
183
184    #[test]
185    fn split_subject_body_with_canonical_blank_line() {
186        let (subj, body) = split_subject_body("Add feature\n\nLong description here.\nMore.");
187        assert_eq!(subj, "Add feature");
188        assert_eq!(body, "Long description here.\nMore.");
189    }
190
191    #[test]
192    fn split_subject_no_blank_separator() {
193        // git-style messages should have a blank line, but
194        // tools like `git commit -m "first\nsecond"` produce
195        // bodies without one. Treat the second line on as body
196        // even without a separator.
197        let (subj, body) = split_subject_body("subject\nrest of content");
198        assert_eq!(subj, "subject");
199        assert_eq!(body, "rest of content");
200    }
201
202    #[test]
203    fn pattern_rejects_unrelated_subject() {
204        let re = Regex::new(r"^(feat|fix|chore): ").unwrap();
205        assert!(!re.is_match("WIP changes"));
206        assert!(re.is_match("feat: add markdown formatter"));
207    }
208
209    #[test]
210    fn subject_length_uses_chars_not_bytes() {
211        // Multi-byte unicode in the subject should count by
212        // grapheme-ish chars, not bytes — a 50-char subject of
213        // emoji should be 50 chars, not 200 bytes.
214        let subj = "🚀".repeat(50);
215        assert_eq!(subj.chars().count(), 50);
216        assert_eq!(subj.len(), 50 * 4); // bytes
217    }
218
219    #[test]
220    fn requires_body_detects_subject_only() {
221        let (_, body) = split_subject_body("just a subject");
222        assert!(body.trim().is_empty());
223    }
224
225    #[test]
226    fn requires_body_accepts_canonical_form() {
227        let (_, body) = split_subject_body("subject\n\nbody content");
228        assert!(!body.trim().is_empty());
229    }
230}