Skip to main content

amont_runtime/hooks/
commit_msg.rs

1//! commit-msg — validate the summary line and reformat the message.
2//!
3//! Validates: a subject is present and within the subject limit; it carries a
4//! conventional type prefix; a description follows, within the description
5//! budget. Formats: place the type's gitmoji where the repository asked for it,
6//! hard-wrap the body, and group the trailing footers with one blank line
7//! before them.
8//!
9//! Every limit and the emoji placement are [`commit_style`] settings — four
10//! `git config` keys with shipped defaults. What this hook enforces is
11//! configurable; **that** it enforces is not.
12//!
13//! Two invariants hold across all four gitmoji placements:
14//!
15//! * **The limits measure what you wrote.** Decoration this hook added is
16//!   removed before anything is counted, so the emoji can never eat the budget
17//!   — and re-checking an already-decorated subject counts the same characters
18//!   the author was told about the first time.
19//! * **Re-running is a no-op.** `--amend`, a rebase reword and a `--no-verify`
20//!   retry all hand this hook a subject it already wrote. See [`undecorate`].
21//!
22//! Ported from ~190 lines of JS. The one structural simplification is how the
23//! optional leading emoji is recognised — see `split_leading_emoji`.
24
25use crate::check::Verdict;
26use crate::commit_style::{self, Style};
27use crate::ui::{error_sign, highlight};
28
29use crate::vocabulary::{self, COMMIT_TYPES};
30
31pub struct Subject {
32    pub prefix: String,
33    pub scope: String,
34    pub breaking: String,
35    pub description: String,
36}
37
38/// Drop full-line comments, as git itself does.
39pub fn strip_comments(msg: &str) -> String {
40    let mut out: Vec<&str> = Vec::new();
41    for line in msg.split('\n') {
42        if !line.starts_with('#') {
43            out.push(line);
44        }
45    }
46    out.join("\n")
47}
48
49/// Skip a leading emoji cluster.
50///
51/// The JS carried a ~2KB hand-maintained list of emoji codepoints, and had
52/// already been patched once because it matched only the BASE codepoint —
53/// leaving a stray variation selector (U+FE0F) between the emoji and the type,
54/// so a perfectly good `⬆️ chore: …` was rejected as having no prefix.
55///
56/// Inverting the test removes that whole class of bug: a conventional type is
57/// ASCII lowercase letters, so skip anything that is NOT ASCII, plus spaces.
58/// Strictly more permissive than the codepoint list, and permissive in the
59/// harmless direction — the type itself is still required below, so this only
60/// decides how much leading decoration gets stripped before re-adding ours.
61pub fn split_leading_emoji(subject: &str) -> &str {
62    subject.trim_start_matches(|c: char| !c.is_ascii() || c == ' ' || c == '\t')
63}
64
65/// `^\s*(emoji)?\s*(type)(\(scope\))?(!)?:\s*(.*)$` over the FIRST line only.
66///
67/// First line only is load-bearing: the JS once used /ms flags, so `^` matched
68/// any line start and a body quoting a conventional commit (a revert citing the
69/// commit it undid) was picked up as the subject and rewritten into it.
70pub fn parse_subject(subject_line: &str) -> Option<Subject> {
71    let rest = split_leading_emoji(subject_line);
72    let (prefix, rest) = COMMIT_TYPES
73        .iter()
74        .map(|t| t.name)
75        .find(|t| rest.starts_with(t))
76        .map(|t| (t.to_string(), &rest[t.len()..]))?;
77    let (scope, breaking, description) = parse_tail(rest)?;
78    Some(Subject {
79        prefix,
80        scope,
81        breaking,
82        description,
83    })
84}
85
86/// `(\(scope\))?(!)?:\s*(.*)` — everything after the type word.
87///
88/// Split out because the type is not always a word: under the `replace`
89/// gitmoji placement it is an emoji, and re-reading such a subject means
90/// recovering the type from the emoji and then parsing exactly this tail. One
91/// implementation, so a scoped, breaking subject survives an amend the same way
92/// an unscoped one does.
93fn parse_tail(rest: &str) -> Option<(String, String, String)> {
94    let (scope, rest) = if let Some(after) = rest.strip_prefix('(') {
95        let end = after.find(')')?;
96        let inner = &after[..end];
97        if inner.is_empty()
98            || !inner
99                .chars()
100                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
101        {
102            return None;
103        }
104        (format!("({inner})"), &after[end + 1..])
105    } else {
106        (String::new(), rest)
107    };
108
109    let (breaking, rest) = match rest.strip_prefix('!') {
110        Some(r) => ("!".to_string(), r),
111        None => (String::new(), rest),
112    };
113
114    let description = rest.strip_prefix(':')?.trim_start_matches(' ').to_string();
115    Some((scope, breaking, description))
116}
117
118/// A subject with the decoration this hook itself applied taken back off.
119pub struct Undecorated<'a> {
120    /// The type recovered from a leading emoji, when that emoji is one of ours.
121    ///
122    /// `Some` only matters for the `replace` placement, where the stored
123    /// subject carries its type nowhere else. Recovering it is what stops the
124    /// hook rejecting its own output on the next `--amend`.
125    pub recovered_type: Option<&'static str>,
126    /// The subject as the author wrote it — what every limit is measured on.
127    pub text: &'a str,
128}
129
130/// Take off a leading gitmoji that this hook wrote.
131///
132/// Only an emoji from `COMMIT_TYPES` counts, matched whole. An emoji the author
133/// chose is theirs: it stays in the text and it counts against the subject
134/// limit, because the limit is about what they wrote. `split_leading_emoji`
135/// remains deliberately more permissive for *parsing* — this is about
136/// *measuring*, and the two questions want different answers.
137pub fn undecorate(subject_line: &str) -> Undecorated<'_> {
138    let trimmed = subject_line.trim_start();
139    for t in COMMIT_TYPES {
140        if let Some(rest) = trimmed.strip_prefix(t.emoji) {
141            return Undecorated {
142                recovered_type: Some(t.name),
143                text: rest.trim_start(),
144            };
145        }
146    }
147    Undecorated {
148        recovered_type: None,
149        text: subject_line,
150    }
151}
152
153/// Take off a trailing gitmoji that this hook wrote — the `suffix` placement's
154/// half of [`undecorate`].
155///
156/// Matched against the emoji for *this* type only, so `feat: ship it 🚀` keeps
157/// the rocket the author chose while `feat: ship it ✨` gives back `ship it`.
158fn undecorate_tail<'a>(description: &'a str, emoji: &str) -> &'a str {
159    if emoji.is_empty() {
160        return description;
161    }
162    match description.trim_end().strip_suffix(emoji) {
163        Some(rest) => rest.trim_end(),
164        None => description,
165    }
166}
167
168/// The subject a `replace`-decorated line describes: type from the emoji,
169/// everything else parsed from what followed it.
170fn recovered_subject(prefix: &'static str, text: &str) -> Subject {
171    let (scope, breaking, description) = parse_tail(text).unwrap_or_else(|| {
172        // No `…:` after the emoji, so there is no scope to find and the whole
173        // remainder is the description — `✨  add a cart`, the common shape.
174        (String::new(), String::new(), text.to_string())
175    });
176    Subject {
177        prefix: prefix.to_string(),
178        scope,
179        breaking,
180        description,
181    }
182}
183
184/// Greedy hard wrap at `width`, breaking on spaces — the JS
185/// `(?![^\n]{1,w}$)([^\n]{1,w})\s` replace. A word longer than `width` is left
186/// intact rather than split.
187pub fn wrap(text: &str, width: usize) -> String {
188    let mut out: Vec<String> = Vec::new();
189    for line in text.split('\n') {
190        if line.chars().count() <= width {
191            out.push(line.to_string());
192            continue;
193        }
194        let mut current = String::new();
195        for word in line.split(' ') {
196            if current.is_empty() {
197                current.push_str(word);
198            } else if current.chars().count() + 1 + word.chars().count() <= width {
199                current.push(' ');
200                current.push_str(word);
201            } else {
202                out.push(std::mem::take(&mut current));
203                current.push_str(word);
204            }
205        }
206        if !current.is_empty() {
207            out.push(current);
208        }
209    }
210    out.join("\n")
211}
212
213/// A trailing footer line: `Key-Word: value`, `BREAKING CHANGE: …`, `Refs: #1`,
214/// or blank.
215pub fn is_footer(line: &str) -> bool {
216    if line.is_empty() {
217        return true;
218    }
219    if let Some(rest) = line
220        .strip_prefix("BREAKING CHANGE:")
221        .or_else(|| line.strip_prefix("BREAKING-CHANGE:"))
222    {
223        return rest.starts_with(' ')
224            && rest
225                .trim_start()
226                .starts_with(|c: char| c.is_alphanumeric() || c == '_');
227    }
228    // The bare (no-colon) form still needs a SEPARATOR after "Refs" — a
229    // space or a '#' — or it also matches prose that merely starts with
230    // those five letters glued to a digit, like "Refs42 was the original
231    // ticket.", sweeping a body line into the footer group.
232    if let Some(rest) = line.strip_prefix("Refs:").or_else(|| {
233        line.strip_prefix("Refs")
234            .filter(|rest| rest.starts_with(' ') || rest.starts_with('#'))
235    }) {
236        let r = rest.trim_start_matches(' ');
237        let r = r.strip_prefix('#').unwrap_or(r);
238        if r.starts_with(|c: char| c.is_ascii_digit()) {
239            return true;
240        }
241    }
242    is_hyphenated_key(line)
243}
244
245/// `^[\w][\w-]*-[\w-]*\w: \w` — a hyphenated trailer key that STARTS the line.
246///
247/// The anchor is the whole point. The rule used to be the JS regex
248/// `/\w-\w{1,}:\s\w/` applied ANYWHERE in the line, and this repo's own commit
249/// subjects match it: the formatted subject
250///
251/// ```text
252/// 🐛  fix: pre-commit: stop hanging
253/// ```
254///
255/// contains the fragment `pre-commit: s`, which satisfied `\w-\w+: \w`. So the
256/// SUBJECT read as a footer, `group_footer` walked the whole message from the
257/// bottom without ever hitting a non-footer line, and split at index 0 — the
258/// emitted message became `["", subject, trailer…]`. git strips the leading
259/// blank, which leaves the subject glued to the trailer block with no blank
260/// line between them, and `%(trailers)` returns EMPTY for a commit that plainly
261/// carries a `Co-Authored-By`. Silent, because the hook still exits 0.
262///
263/// Anchoring at column 0 keeps every real trailer (`Co-Authored-By: x`,
264/// `Signed-off-by: y`) and rejects both `fix: pre-commit: stop hanging` and
265/// prose like `see the pre-commit: docs above`, because in each of those the
266/// leading key run stops at the first space and holds no hyphen.
267fn is_hyphenated_key(line: &str) -> bool {
268    let key: String = line
269        .chars()
270        .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
271        .collect();
272    // A key starts and ends with a word character — `-foo: x` is a bulleted
273    // list item, and `A-: x` was never a trailer either.
274    if !key.starts_with(|c: char| c.is_alphanumeric() || c == '_')
275        || !key.ends_with(|c: char| c.is_alphanumeric() || c == '_')
276        || !key.contains('-')
277    {
278        return false;
279    }
280    let Some(rest) = line[key.len()..].strip_prefix(": ") else {
281        return false;
282    };
283    rest.starts_with(|c: char| c.is_alphanumeric() || c == '_')
284}
285
286/// Separate the trailing run of footer lines, drop blanks inside it, and put
287/// exactly one blank line before the group.
288///
289/// The run deliberately crosses BLANK lines, which is what merges a
290/// `BREAKING CHANGE:` paragraph and a `Co-Authored-By:` paragraph into one
291/// footer block. That is a feature, not an accident, so this is not a
292/// "last paragraph only" rule.
293///
294/// The scan starts at `lines[1..]`: line 0 is the SUBJECT and can never be a
295/// footer, whatever it happens to look like. Without that anchor a subject
296/// misread as a footer (see `is_hyphenated_key`) let the run consume the entire
297/// message, `split_at` became 0, and the subject was emitted INSIDE the footer
298/// group with a blank line in front of it — destroying every trailer in the
299/// commit. `lines[1..]` makes `split_at >= 1` by construction; `split('\n')`
300/// never yields an empty vector, so the slice is always in range, and a
301/// single-line message falls out of the general path producing exactly what it
302/// produced before (`out = [subject, ""]`).
303pub fn group_footer(text: &str) -> String {
304    let trimmed = text.trim_end_matches('\n');
305    let lines: Vec<&str> = trimmed.split('\n').collect();
306    let mut footer_size = 0;
307    for line in lines[1..].iter().rev() {
308        if is_footer(line) {
309            footer_size += 1;
310        } else {
311            break;
312        }
313    }
314    let split_at = lines.len() - footer_size;
315    let body = &lines[..split_at];
316    let footer: Vec<&str> = lines[split_at..]
317        .iter()
318        .copied()
319        .filter(|l| !l.is_empty())
320        .collect();
321
322    let mut out: Vec<&str> = body.to_vec();
323    out.push("");
324    out.extend(footer);
325    format!("{}\n", out.join("\n"))
326}
327
328fn valid(settings: &crate::config::Settings, msg: &str) {
329    super::common::ok(settings, msg);
330}
331fn error(msg: &str) {
332    eprintln!("  {} {msg}", error_sign().trim());
333}
334fn orange(s: &str) -> String {
335    highlight(s)
336}
337
338/// A subject git itself wrote, or one that exists only to be autosquashed
339/// away.
340///
341/// Exact prefixes, space and quote included, so a HUMAN subject like
342/// "Merges: cleanup" or "fixup the parser" is still judged — only the shapes
343/// git's own porcelain emits stand aside. The trade is that a hand-written
344/// "Merge the two configs" passes unjudged; commitlint draws the same line,
345/// and the alternative blocks `git merge` itself.
346fn git_generated(subject: &str) -> bool {
347    [
348        "Merge ",
349        "Revert \"",
350        "Reapply \"",
351        "fixup! ",
352        "squash! ",
353        "amend! ",
354    ]
355    .iter()
356    .any(|p| subject.starts_with(p))
357}
358
359pub fn run(settings: &crate::config::Settings, args: &[std::ffi::OsString]) -> Verdict {
360    let Some(filename) = args.first().and_then(|a| a.to_str()) else {
361        println!("Usage:\n\n./commit-msg <filename>");
362        return Verdict::Block;
363    };
364    let Ok(raw) = std::fs::read_to_string(filename) else {
365        return Verdict::Block;
366    };
367    let style = Style::resolve(settings);
368    let cleaned = strip_comments(&raw);
369    let mut parts = cleaned.splitn(2, '\n');
370    let subject_line = parts.next().unwrap_or("");
371
372    // Messages GIT writes are passed through, not judged. `git merge` invokes
373    // this hook (githooks(5)) with "Merge branch '…'"; `git revert` writes
374    // `Revert "…"` (and `Reapply "…"` for a revert of a revert); `--fixup`
375    // and `--squash` write `fixup!`/`squash!`/`amend!` subjects that exist
376    // only to be autosquashed away before anyone reads them. None of these
377    // can carry a conventional type — blocking them blocks the porcelain
378    // that produced them, and the workaround people reach for is
379    // `--no-verify`, which turns off the checks that DO apply to them.
380    // Said out loud, because a check that stands aside silently is the
381    // invisibility this project refuses everywhere else.
382    if git_generated(subject_line) {
383        valid(
384            settings,
385            "A message git itself wrote — the convention is not applied",
386        );
387        return Verdict::Proceed;
388    }
389    // Everything after the subject's own newline — blank separator lines
390    // included. The format string below writes that blank line itself, so
391    // leaving them here means writing one MORE each time.
392    //
393    // That is not hypothetical: it shipped. Re-running the hook over a message
394    // it had already formatted — `--amend`, a rebase reword, a `--no-verify`
395    // retry — grew the gap between subject and body by one line every single
396    // time. Nothing caught it because the idempotence test covered
397    // `group_footer` alone rather than the whole rewrite.
398    let body = parts.next().unwrap_or("").trim_start_matches('\n');
399
400    // Our own decoration comes off before anything is counted or parsed. On a
401    // first commit there is none; on an amend there is, and measuring it would
402    // fail a subject that was accepted five seconds earlier.
403    let undecorated = undecorate(subject_line);
404    let written = undecorated.text;
405
406    if written.is_empty() || written.chars().count() > style.subject_max {
407        error(&format!(
408            "Commit's first line should exist and be at most {} characters.",
409            orange(&style.subject_max.to_string())
410        ));
411        return Verdict::Block;
412    }
413    valid(
414        settings,
415        &format!(
416            "Summary size is at most {} characters",
417            orange(&style.subject_max.to_string())
418        ),
419    );
420
421    let types: Vec<String> = COMMIT_TYPES.iter().map(|t| orange(t.name)).collect();
422    let subject = match parse_subject(written) {
423        Some(s) => s,
424        // No type in the text — but if a gitmoji of ours opened the line, the
425        // type IS there, carried by the emoji. That is the `replace` placement
426        // being handed back its own output.
427        None => match undecorated.recovered_type {
428            Some(t) => recovered_subject(t, written),
429            None => {
430                error(&format!(
431                    "Commits MUST be prefixed with a type, which consists of a noun:
432    {}
433    The prefix must be followed by the OPTIONAL scope, OPTIONAL !,
434    and REQUIRED terminal colon and space.
435    A scope MAY be provided after a type. A scope MUST consist of a noun describing
436    a section of the codebase surrounded by parenthesis, e.g., fix(parser)",
437                    types.join(", ")
438                ));
439                return Verdict::Block;
440            }
441        },
442    };
443    valid(settings, "A prefix is defined");
444
445    // The `suffix` placement's half of the same round trip.
446    let description = undecorate_tail(&subject.description, vocabulary::emoji_for(&subject.prefix));
447
448    if description.is_empty() {
449        error(&format!(
450            "A description MUST immediately follow the {} and {} after the type/scope prefix.
451    The description is a short summary of the code changes, e.g., fix: array parsing issue when multiple spaces were contained in string.",
452            orange("colon"), orange("space")
453        ));
454        return Verdict::Block;
455    }
456    valid(settings, "A description is present in the summary");
457
458    if description.chars().count() > style.description_max {
459        error(&format!(
460            "The description after the {} should be at most {} characters.",
461            orange("colon"),
462            orange(&style.description_max.to_string())
463        ));
464        return Verdict::Block;
465    }
466    valid(
467        settings,
468        &format!(
469            "Description size is at most {} characters",
470            orange(&style.description_max.to_string())
471        ),
472    );
473
474    let formatted = format!(
475        "{}\n\n{}\n",
476        commit_style::render_subject(
477            style.gitmoji,
478            &subject.prefix,
479            &subject.scope,
480            &subject.breaking,
481            description,
482        ),
483        wrap_body(&strip_comments(body), style.body_wrap)
484    );
485    if std::fs::write(filename, group_footer(&formatted)).is_err() {
486        return Verdict::Block;
487    }
488    Verdict::Proceed
489}
490
491/// The body, wrapped — or left exactly as written when the wrap column is `0`.
492///
493/// `0` is what keeps a pasted stack trace, a table or a fenced code block
494/// intact. Hard-wrapping those is the one thing this hook does that cannot be
495/// undone by reading the message again.
496fn wrap_body(body: &str, column: usize) -> String {
497    if column == 0 {
498        body.to_string()
499    } else {
500        wrap(body, column)
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn parses_the_conventional_shapes() {
510        let s = parse_subject("feat: add a thing").unwrap();
511        assert_eq!(
512            (s.prefix.as_str(), s.description.as_str()),
513            ("feat", "add a thing")
514        );
515
516        let s = parse_subject("fix(parser): trim").unwrap();
517        assert_eq!(s.scope, "(parser)");
518
519        let s = parse_subject("fix(my-scope): trim").unwrap();
520        assert_eq!(s.scope, "(my-scope)");
521
522        let s = parse_subject("feat!: breaking").unwrap();
523        assert_eq!(s.breaking, "!");
524    }
525
526    /// The bug the JS was patched for: only the BASE codepoint was consumed, so
527    /// the trailing U+FE0F sat between emoji and type and the subject failed to
528    /// match. Every one of these must parse.
529    #[test]
530    fn accepts_emoji_prefixes_including_multi_codepoint_ones() {
531        for subject in [
532            "✨ feat: x",
533            "⬆️ chore: x",     // variation selector
534            "♻️  refactor: x", // variation selector + two spaces
535            "🔧  chore: x",
536            "👨‍💻 feat: x", // ZWJ sequence
537        ] {
538            assert!(parse_subject(subject).is_some(), "failed: {subject}");
539        }
540    }
541
542    #[test]
543    fn rejects_what_is_not_a_conventional_subject() {
544        assert!(parse_subject("just a message").is_none());
545        assert!(parse_subject("feat add a thing").is_none()); // no colon
546        assert!(parse_subject("feature: x").is_none()); // unknown type
547        assert!(parse_subject("fix(bad scope): x").is_none()); // space in scope
548    }
549
550    #[test]
551    fn description_may_be_empty_and_is_caught_by_the_caller() {
552        assert_eq!(parse_subject("feat:").unwrap().description, "");
553    }
554
555    #[test]
556    fn wraps_on_spaces_without_splitting_long_words() {
557        let wrapped = wrap("aaa bbb ccc ddd", 7);
558        assert_eq!(wrapped, "aaa bbb\nccc ddd");
559        let long = "x".repeat(20);
560        assert_eq!(wrap(&long, 7), long); // never split mid-word
561    }
562
563    #[test]
564    fn recognises_footers() {
565        assert!(is_footer("Co-Authored-By: someone"));
566        assert!(is_footer("BREAKING CHANGE: it broke"));
567        assert!(is_footer("Refs: #123"));
568        assert!(is_footer(""));
569        assert!(!is_footer("just prose"));
570        assert!(!is_footer("a sentence with - a dash"));
571    }
572
573    /// The bare (no-colon) form, `Refs #123` / `Refs 123`, is intentionally
574    /// also accepted — but "Refs" glued straight to a digit with no
575    /// separator is prose, not a reference, and must not be swept into the
576    /// footer group.
577    #[test]
578    fn a_bare_refs_needs_a_separator_not_just_a_leading_digit() {
579        assert!(is_footer("Refs #123"));
580        assert!(is_footer("Refs 123"));
581        assert!(
582            !is_footer("Refs42 was the original ticket."),
583            "prose starting with Refs+digit must not read as a footer"
584        );
585    }
586
587    /// A trailer key is only a trailer key when it STARTS its line.
588    ///
589    /// The formatted subject `fix: pre-commit: stop hanging` contains
590    /// `pre-commit: s`, which the old anywhere-in-the-line rule accepted — and
591    /// a subject read as a footer took the whole message down with it.
592    #[test]
593    fn a_key_must_start_the_line_to_be_a_footer() {
594        // Real trailers, at column 0.
595        assert!(is_footer("Co-Authored-By: someone"));
596        assert!(is_footer("Signed-off-by: someone"));
597        assert!(is_footer("Reviewed-by: a"));
598        // The subject shape this repo writes constantly.
599        assert!(!is_footer("fix: pre-commit: stop hanging"));
600        assert!(!is_footer("🐛  fix: pre-commit: stop hanging"));
601        // Prose that merely mentions a hyphenated word followed by a colon.
602        assert!(!is_footer("see the pre-commit: docs above"));
603        assert!(!is_footer("  Co-Authored-By: indented is not a trailer"));
604        // A bullet is not a key, and neither is a key with nothing after the
605        // hyphen.
606        assert!(!is_footer("-foo: bar"));
607        assert!(!is_footer("A-: bar"));
608        // Neither branch below is anchored by this rule; both still work.
609        assert!(is_footer("BREAKING CHANGE: it broke"));
610        assert!(is_footer("Refs: #123"));
611    }
612
613    #[test]
614    fn groups_the_trailing_footer_with_one_blank_line() {
615        let out = group_footer("subject\n\nbody text\n\nCo-Authored-By: x\n\n");
616        assert_eq!(out, "subject\n\nbody text\n\nCo-Authored-By: x\n");
617    }
618
619    /// The shapes a real message arrives in, for the property tests below.
620    ///
621    /// Every subject here is one that the old anywhere-in-the-line footer rule
622    /// misread as a trailer, crossed with each body/footer arrangement the
623    /// formatter emits.
624    const SHAPES: &[&str] = &[
625        // subject only
626        "fix: pre-commit: stop hanging",
627        "fix: pre-commit: stop hanging\n\n\n",
628        // body only
629        "fix: pre-commit: stop hanging\n\nthe worker thread blocked on a tty\n",
630        // trailers only
631        "fix: pre-commit: stop hanging\n\nCo-Authored-By: a <a@x>\n",
632        // body and trailers
633        "fix: pre-commit: stop hanging\n\nthe worker thread blocked\n\nCo-Authored-By: a <a@x>\n",
634        // body, trailers, trailing blanks
635        "fix: pre-commit: stop hanging\n\nthe worker thread blocked\n\nCo-Authored-By: a <a@x>\n\n\n",
636        // two footer PARAGRAPHS, which the run deliberately merges
637        "feat: x\n\nbody\n\nBREAKING CHANGE: it broke\n\nCo-Authored-By: a <a@x>\n",
638        // an ordinary subject, to prove nothing regressed for the common case
639        "feat: add a thing\n\nbody\n\nCo-Authored-By: a <a@x>\n",
640    ];
641
642    /// PROPERTY: grouping the footer never drops or invents a line, and never
643    /// moves the subject off line 0.
644    ///
645    /// Both halves failed together for the subject `fix: pre-commit: stop
646    /// hanging`: the whole message was swallowed into the footer group, blank
647    /// body lines inside it were filtered away, and the emitted line 0 was the
648    /// inserted blank rather than the subject.
649    #[test]
650    fn group_footer_never_loses_a_line() {
651        for shape in SHAPES {
652            let out = group_footer(shape);
653
654            let mut before: Vec<&str> = shape
655                .trim_end_matches('\n')
656                .split('\n')
657                .filter(|l| !l.is_empty())
658                .collect();
659            let mut after: Vec<&str> = out
660                .trim_end_matches('\n')
661                .split('\n')
662                .filter(|l| !l.is_empty())
663                .collect();
664            before.sort_unstable();
665            after.sort_unstable();
666            assert_eq!(before, after, "lines changed for {shape:?} -> {out:?}");
667
668            assert_eq!(
669                out.split('\n').next(),
670                shape.split('\n').next(),
671                "the subject left line 0 for {shape:?} -> {out:?}"
672            );
673        }
674    }
675
676    /// PROPERTY: grouping is idempotent. A message that has already been
677    /// formatted once — an amend, a rebase reword, a `--no-verify` retry — must
678    /// come back byte for byte.
679    #[test]
680    fn group_footer_is_idempotent() {
681        for shape in SHAPES {
682            let once = group_footer(shape);
683            let twice = group_footer(&once);
684            assert_eq!(once, twice, "not idempotent for {shape:?}");
685        }
686    }
687
688    /// The exact damage the anchor prevents: a blank line must stand between
689    /// the subject and the footer group, and the subject must never appear
690    /// inside it. Without the blank line git reads the trailer as a
691    /// continuation of the subject and `%(trailers)` comes back empty.
692    #[test]
693    fn a_subject_and_its_trailers_stay_separated() {
694        let out = group_footer("fix: pre-commit: stop hanging\n\nCo-Authored-By: a <a@x>\n");
695        assert_eq!(
696            out, "fix: pre-commit: stop hanging\n\nCo-Authored-By: a <a@x>\n",
697            "got: {out:?}"
698        );
699        assert!(
700            !out.starts_with('\n'),
701            "the message must not begin with a blank line: {out:?}"
702        );
703    }
704
705    #[test]
706    fn strips_comment_lines() {
707        assert_eq!(strip_comments("keep\n# drop\nkeep2"), "keep\nkeep2");
708    }
709
710    use crate::commit_style::{render_subject, Gitmoji};
711
712    /// The whole subject, as the hook would store it, for a message the author
713    /// typed conventionally.
714    fn store(placement: Gitmoji, typed: &str) -> String {
715        let s = parse_subject(typed).expect("test subjects parse");
716        render_subject(
717            placement,
718            &s.prefix,
719            &s.scope,
720            &s.breaking,
721            undecorate_tail(&s.description, vocabulary::emoji_for(&s.prefix)),
722        )
723    }
724
725    /// Re-read a stored subject the way `run` does, and store it again.
726    fn restore(placement: Gitmoji, stored: &str) -> String {
727        let u = undecorate(stored);
728        let s = match parse_subject(u.text) {
729            Some(s) => s,
730            None => recovered_subject(u.recovered_type.expect("a type to recover"), u.text),
731        };
732        render_subject(
733            placement,
734            &s.prefix,
735            &s.scope,
736            &s.breaking,
737            undecorate_tail(&s.description, vocabulary::emoji_for(&s.prefix)),
738        )
739    }
740
741    /// PROPERTY: writing a subject twice writes the same subject.
742    ///
743    /// `--amend`, a rebase reword and a `--no-verify` retry all hand this hook
744    /// a line it wrote itself. Without the undecorate step `suffix` grew an
745    /// emoji per amend and `replace` REJECTED its own output — the type it
746    /// demands had been replaced by the emoji it wrote.
747    #[test]
748    fn decorating_a_subject_is_idempotent() {
749        for typed in [
750            "feat: add a cart",
751            "fix(parser): trim",
752            "feat(api)!: drop v1",
753            "docs: explain the trust model",
754        ] {
755            for placement in Gitmoji::ALL {
756                let once = store(placement, typed);
757                let twice = restore(placement, &once);
758                assert_eq!(
759                    once,
760                    twice,
761                    "{} is not idempotent for {typed:?}",
762                    placement.as_str()
763                );
764                // And a third pass, since `suffix` grew by one emoji per run.
765                assert_eq!(twice, restore(placement, &twice));
766            }
767        }
768    }
769
770    /// Each placement puts the emoji where it says it does, and `none` leaves
771    /// the line alone.
772    #[test]
773    fn each_placement_puts_the_emoji_where_it_says() {
774        assert_eq!(store(Gitmoji::None, "feat: add a cart"), "feat: add a cart");
775        assert_eq!(
776            store(Gitmoji::Prefix, "feat: add a cart"),
777            "✨  feat: add a cart"
778        );
779        assert_eq!(
780            store(Gitmoji::Suffix, "feat: add a cart"),
781            "feat: add a cart ✨"
782        );
783        assert_eq!(
784            store(Gitmoji::Replace, "feat: add a cart"),
785            "✨  add a cart"
786        );
787    }
788
789    /// `suffix` keeps a clean conventional subject at the START of the line,
790    /// which is the reason to prefer it: commitlint and changelog generators
791    /// still see the type. `replace` deliberately does not, and the docs say so.
792    #[test]
793    fn suffix_leaves_the_type_where_tooling_looks_for_it() {
794        assert!(store(Gitmoji::Suffix, "fix: a bug").starts_with("fix:"));
795        assert!(!store(Gitmoji::Replace, "fix: a bug").starts_with("fix:"));
796    }
797
798    /// A scope and a breaking marker are not types, so `replace` keeps them.
799    /// They must also survive the round trip, which is why the recovery parses
800    /// the tail rather than treating everything after the emoji as prose.
801    #[test]
802    fn replace_keeps_a_scope_and_a_breaking_marker() {
803        let stored = store(Gitmoji::Replace, "feat(api)!: drop v1");
804        assert_eq!(stored, "✨  (api)!: drop v1");
805        let u = undecorate(&stored);
806        let s = recovered_subject(u.recovered_type.unwrap(), u.text);
807        assert_eq!(
808            (s.prefix.as_str(), s.scope.as_str(), s.breaking.as_str()),
809            ("feat", "(api)", "!")
810        );
811        assert_eq!(s.description, "drop v1");
812    }
813
814    /// The type is recovered from OUR emoji only. One the author chose is
815    /// theirs, and a subject carrying it still needs a real type word.
816    #[test]
817    fn only_our_own_emoji_recovers_a_type() {
818        assert_eq!(undecorate("✨  add a cart").recovered_type, Some("feat"));
819        assert_eq!(undecorate("🐛  fix: x").recovered_type, Some("fix"));
820        assert_eq!(undecorate("🚀 ship it").recovered_type, None);
821        assert_eq!(undecorate("feat: x").recovered_type, None);
822        // The text handed on is what remains once ours is off.
823        assert_eq!(undecorate("✨  add a cart").text, "add a cart");
824        assert_eq!(undecorate("🚀 ship it").text, "🚀 ship it");
825    }
826
827    /// The trailing half: only the emoji for THIS type is ours to remove.
828    #[test]
829    fn a_trailing_emoji_is_only_stripped_when_we_wrote_it() {
830        assert_eq!(undecorate_tail("add a cart ✨", "✨"), "add a cart");
831        assert_eq!(undecorate_tail("ship it 🚀", "✨"), "ship it 🚀");
832        assert_eq!(undecorate_tail("plain", "✨"), "plain");
833        assert_eq!(undecorate_tail("nothing to strip", ""), "nothing to strip");
834    }
835
836    /// PROPERTY: the limits measure what the author wrote.
837    ///
838    /// A subject at exactly the limit must stay acceptable after decoration —
839    /// otherwise the first amend of a maximal subject is rejected for length
840    /// the hook itself added.
841    #[test]
842    fn decoration_never_counts_against_the_limit() {
843        let typed = format!("feat: {}", "x".repeat(60));
844        assert_eq!(typed.chars().count(), 66);
845        for placement in Gitmoji::ALL {
846            let stored = store(placement, &typed);
847            let remeasured = undecorate(&stored);
848            let s = match parse_subject(remeasured.text) {
849                Some(s) => s,
850                None => recovered_subject(remeasured.recovered_type.unwrap(), remeasured.text),
851            };
852            let description = undecorate_tail(&s.description, vocabulary::emoji_for(&s.prefix));
853            assert_eq!(
854                description.chars().count(),
855                60,
856                "{} changed the measured description: {stored:?}",
857                placement.as_str()
858            );
859        }
860    }
861
862    #[test]
863    fn a_zero_wrap_column_leaves_the_body_alone() {
864        let long = "x ".repeat(100);
865        assert_eq!(wrap_body(&long, 0), long);
866        assert!(wrap_body(&long, 72).contains('\n'));
867    }
868}