Skip to main content

grit_lib/
commit_trailers.rs

1//! Cherry-pick / sign-off trailer handling compatible with Git's `sequencer.c` and `trailer.c`.
2//!
3//! Used when rewriting commit messages for `cherry-pick -x` / `-s` so spacing and trailer
4//! detection match upstream tests (e.g. `t3511-cherry-pick-x`).
5
6use crate::config::ConfigSet;
7
8const CHERRY_PICKED_PREFIX: &str = "(cherry picked from commit ";
9const SIGN_OFF_HEADER: &str = "Signed-off-by: ";
10
11// hygiene: immutable trailer prefix table (Git keyword list)
12static GIT_GENERATED_PREFIXES: &[&str] = &["Signed-off-by: ", "(cherry picked from commit "];
13
14const RESERVED_TRAILER_SUBSECTIONS: &[&str] = &["where", "ifexists", "ifmissing", "separators"];
15
16/// One configured trailer token from `trailer.<name>.*` config entries.
17#[derive(Debug, Clone)]
18struct TrailerRule {
19    /// Subsection name (e.g. `Myfooter`).
20    name: String,
21    /// Optional `trailer.<name>.key` override for token matching.
22    key: Option<String>,
23}
24
25fn load_trailer_rules(config: &ConfigSet) -> Vec<TrailerRule> {
26    let mut rules: std::collections::BTreeMap<String, TrailerRule> =
27        std::collections::BTreeMap::new();
28    for e in config.entries() {
29        let mut parts = e.key.split('.');
30        let (Some("trailer"), Some(subsection), Some(var)) =
31            (parts.next(), parts.next(), parts.next())
32        else {
33            continue;
34        };
35        if RESERVED_TRAILER_SUBSECTIONS.contains(&subsection) {
36            continue;
37        }
38        let rule = rules
39            .entry(subsection.to_string())
40            .or_insert_with(|| TrailerRule {
41                name: subsection.to_string(),
42                key: None,
43            });
44        if var == "key" {
45            if let Some(v) = &e.value {
46                rule.key = Some(v.clone());
47            }
48        }
49    }
50    rules.into_values().collect()
51}
52
53fn next_line_start(buf: &[u8], pos: usize) -> usize {
54    if pos >= buf.len() {
55        return buf.len();
56    }
57    match buf[pos..].iter().position(|&b| b == b'\n') {
58        Some(p) => pos + p + 1,
59        None => buf.len(),
60    }
61}
62
63/// Start of the line that ends at `len` (`None` when `len` is 0).
64fn last_line_start(buf: &[u8], len: usize) -> Option<usize> {
65    let before_last_byte = buf.get(..len.checked_sub(1)?)?;
66    Some(
67        before_last_byte
68            .iter()
69            .rposition(|&b| b == b'\n')
70            .map_or(0, |i| i + 1),
71    )
72}
73
74fn is_blank_line_bytes(line: &[u8]) -> bool {
75    line.iter()
76        .copied()
77        .take_while(|&b| b != b'\n')
78        .all(|b| b.is_ascii_whitespace())
79}
80
81/// Git `find_separator` with `separators = ":"`.
82fn find_separator_colon(line: &[u8]) -> Option<usize> {
83    let mut whitespace_found = false;
84    for (i, &c) in line.iter().enumerate() {
85        if c == b':' {
86            return Some(i);
87        }
88        if !whitespace_found && (c.is_ascii_alphanumeric() || c == b'-') {
89            continue;
90        }
91        if i != 0 && (c == b' ' || c == b'\t') {
92            whitespace_found = true;
93            continue;
94        }
95        break;
96    }
97    None
98}
99
100fn token_len_without_separator(token: &[u8]) -> usize {
101    let mut len = token.len();
102    while len > 0 && !token[len - 1].is_ascii_alphanumeric() {
103        len -= 1;
104    }
105    len
106}
107
108fn line_bytes_starts_with_git_generated(line: &[u8]) -> bool {
109    let line_one_line = line.split(|&b| b == b'\n').next().unwrap_or(line);
110    for p in GIT_GENERATED_PREFIXES {
111        let pb = p.as_bytes();
112        if line_one_line.len() >= pb.len() && &line_one_line[..pb.len()] == pb {
113            return true;
114        }
115    }
116    false
117}
118
119/// Whether `buf` (full message, possibly without a final `\\n`) ends with a line that Git would
120/// classify as a trailer (`trailer.c` / `sequencer.c`), including the no-final-newline case from
121/// `commit-tree` stdin.
122fn last_line_looks_like_trailer(buf: &[u8], rules: &[TrailerRule]) -> bool {
123    if buf.is_empty() {
124        return false;
125    }
126    let bol = last_line_start(buf, buf.len()).unwrap_or(0);
127    let last = &buf[bol..];
128    let mut trim_end = last.len();
129    while trim_end > 0 && matches!(last[trim_end - 1], b' ' | b'\t' | b'\r') {
130        trim_end -= 1;
131    }
132    let t = &last[..trim_end];
133    if t.is_empty() {
134        return false;
135    }
136    if line_bytes_starts_with_git_generated(t) {
137        return true;
138    }
139    if let Some(sep) = find_separator_colon(t) {
140        if sep >= 1 && !t[0].is_ascii_whitespace() {
141            return token_matches_rule(&t[..sep], rules);
142        }
143    }
144    false
145}
146
147fn token_matches_rule(token: &[u8], rules: &[TrailerRule]) -> bool {
148    let tlen = token_len_without_separator(token);
149    let token = &token[..tlen];
150    let Ok(tok_str) = std::str::from_utf8(token) else {
151        return false;
152    };
153    for r in rules {
154        if r.name.eq_ignore_ascii_case(tok_str) {
155            return true;
156        }
157        if r.key
158            .as_ref()
159            .is_some_and(|k| k.eq_ignore_ascii_case(tok_str))
160        {
161            return true;
162        }
163    }
164    false
165}
166
167/// Byte offset where the trailer block starts, or `len` if none (`find_trailer_block_start`).
168fn find_trailer_block_start(buf: &[u8], len: usize, rules: &[TrailerRule]) -> usize {
169    // First paragraph (until first blank line) is never part of the trailer block.
170    // If there is no blank line, `end_of_title` stays 0 so scanning can treat the
171    // whole message as body + trailers (matches single-line subjects in t3511).
172    let mut end_of_title = 0usize;
173    let mut pos = 0usize;
174    while pos < len {
175        let line_end = next_line_start(buf, pos);
176        let line = &buf[pos..line_end.min(len)];
177        if line.first().is_some_and(|b| *b == b'#') {
178            pos = line_end;
179            continue;
180        }
181        if is_blank_line_bytes(line) {
182            end_of_title = line_end;
183            break;
184        }
185        pos = line_end;
186    }
187
188    let mut only_spaces = true;
189    let mut recognized_prefix = false;
190    let mut trailer_lines = 0i32;
191    let mut non_trailer_lines = 0i32;
192    let mut possible_continuation_lines = 0i32;
193
194    let is_trailer_block = |recognized_prefix: bool, trailer_lines: i32, non_trailer_lines: i32| {
195        (recognized_prefix && trailer_lines * 3 >= non_trailer_lines)
196            || (trailer_lines > 0 && non_trailer_lines == 0)
197    };
198
199    let mut next = last_line_start(buf, len);
200    while let Some(l) = next {
201        if l < end_of_title {
202            // Reached the title boundary without an intervening blank line: the post-title content
203            // is entirely candidate trailers. Mirror Git's blank-line decision using the same ratio
204            // so a trailer block flush against the title (e.g. `subject\n\nSigned-off-by: ...`) is
205            // recognized rather than treated as plain body.
206            if !only_spaces
207                && is_trailer_block(
208                    recognized_prefix,
209                    trailer_lines,
210                    non_trailer_lines + possible_continuation_lines,
211                )
212            {
213                return end_of_title;
214            }
215            break;
216        }
217        next = last_line_start(buf, l);
218        let line = &buf[l..next_line_start(buf, l).min(len)];
219
220        if line.first() == Some(&b'#') {
221            non_trailer_lines += possible_continuation_lines;
222            possible_continuation_lines = 0;
223            continue;
224        }
225
226        if is_blank_line_bytes(line) {
227            if only_spaces {
228                continue;
229            }
230            non_trailer_lines += possible_continuation_lines;
231            return if is_trailer_block(recognized_prefix, trailer_lines, non_trailer_lines) {
232                next_line_start(buf, l)
233            } else {
234                len
235            };
236        }
237
238        only_spaces = false;
239
240        let indented = line.first().is_some_and(u8::is_ascii_whitespace);
241        if line_bytes_starts_with_git_generated(line) {
242            trailer_lines += 1;
243            possible_continuation_lines = 0;
244            recognized_prefix = true;
245        } else if let Some(sep_pos) = find_separator_colon(line).filter(|&p| p >= 1 && !indented) {
246            trailer_lines += 1;
247            possible_continuation_lines = 0;
248            recognized_prefix = recognized_prefix || token_matches_rule(&line[..sep_pos], rules);
249        } else if indented {
250            possible_continuation_lines += 1;
251        } else {
252            non_trailer_lines += 1 + possible_continuation_lines;
253            possible_continuation_lines = 0;
254        }
255    }
256
257    len
258}
259
260/// Iterator over raw trailer lines in Git's sense (lines in the trailer block).
261fn trailer_raw_lines<'a>(msg: &'a str, rules: &[TrailerRule]) -> Vec<&'a str> {
262    let start = find_trailer_block_start(msg.as_bytes(), msg.len(), rules);
263    msg.get(start..).unwrap_or("").lines().collect()
264}
265
266/// Where a sign-off line sits relative to the message's trailer block.
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268enum SignoffFooter {
269    /// The message has no trailer block.
270    NoTrailers,
271    /// The trailer block does not contain the sign-off.
272    Absent,
273    /// The sign-off is in the trailer block but is not its last line.
274    NotLast,
275    /// The last trailer line is the sign-off.
276    Last,
277}
278
279fn signoff_footer(msg: &str, sob_line: &str, rules: &[TrailerRule]) -> SignoffFooter {
280    let lines = trailer_raw_lines(msg, rules);
281    let sob_prefix = sob_line.strip_suffix('\n').unwrap_or(sob_line);
282    // Prefix match, like Git's `strncmp` against the sign-off line.
283    let last_sob = lines.iter().rposition(|raw| {
284        raw.strip_suffix('\r')
285            .unwrap_or(raw)
286            .starts_with(sob_prefix)
287    });
288    match last_sob {
289        _ if lines.is_empty() => SignoffFooter::NoTrailers,
290        None => SignoffFooter::Absent,
291        Some(idx) if idx + 1 == lines.len() => SignoffFooter::Last,
292        Some(_) => SignoffFooter::NotLast,
293    }
294}
295
296fn strbuf_complete_line(s: &mut String) {
297    if !s.is_empty() && !s.ends_with('\n') {
298        s.push('\n');
299    }
300}
301
302/// Append `-x` trailer matching `sequencer.c` (`record_origin`).
303pub fn append_cherry_picked_from_line(msg: &mut String, full_hex: &str, config: &ConfigSet) {
304    let rules = load_trailer_rules(config);
305    strbuf_complete_line(msg);
306    let has_footer = !trailer_raw_lines(msg, &rules).is_empty()
307        || last_line_looks_like_trailer(msg.trim_end_matches('\n').as_bytes(), &rules);
308    if !has_footer {
309        msg.push('\n');
310    }
311    msg.push_str(CHERRY_PICKED_PREFIX);
312    msg.push_str(full_hex);
313    msg.push_str(")\n");
314}
315
316/// Append sign-off matching `append_signoff` in `sequencer.c` (no `APPEND_SIGNOFF_DEDUP`).
317pub fn append_signoff_trailer(msg: &mut String, sob_line: &str, config: &ConfigSet) {
318    append_signoff_trailer_with_dedup(msg, sob_line, config, false);
319}
320
321/// Append sign-off with optional `APPEND_SIGNOFF_DEDUP` (set by `format-patch --signoff`, which
322/// suppresses adding a sign-off that already exists anywhere in the trailer block, not just at the
323/// very end).
324pub fn append_signoff_trailer_with_dedup(
325    msg: &mut String,
326    sob_line: &str,
327    config: &ConfigSet,
328    dedup: bool,
329) {
330    let rules = load_trailer_rules(config);
331    strbuf_complete_line(msg);
332
333    // A message that is exactly the sign-off line counts as already signed off.
334    let footer = if msg == sob_line {
335        SignoffFooter::Last
336    } else {
337        signoff_footer(msg, sob_line, &rules)
338    };
339
340    if footer == SignoffFooter::NoTrailers
341        && !last_line_looks_like_trailer(msg.trim_end_matches('\n').as_bytes(), &rules)
342    {
343        let separator = match msg.len() {
344            0 => "\n\n",
345            1 => "\n",
346            n if msg.as_bytes()[n - 2] != b'\n' => "\n",
347            _ => "",
348        };
349        msg.push_str(separator);
350    }
351
352    let already_signed = match footer {
353        SignoffFooter::Last => true,
354        SignoffFooter::NotLast => dedup,
355        SignoffFooter::NoTrailers | SignoffFooter::Absent => false,
356    };
357    if !already_signed {
358        msg.push_str(sob_line.strip_suffix('\n').unwrap_or(sob_line));
359        msg.push('\n');
360    }
361}
362
363/// Build `Signed-off-by: Name <email>\n` using the same identity resolution as cherry-pick.
364pub fn format_signoff_line(name: &str, email: &str) -> String {
365    format!("{SIGN_OFF_HEADER}{name} <{email}>\n")
366}
367
368/// Apply `-x` / `-s` rewriting plus optional `commit.cleanup` when `-x` is set.
369pub fn finalize_cherry_pick_message(
370    original_message: &str,
371    append_source: bool,
372    signoff: bool,
373    committer_name: &str,
374    committer_email: &str,
375    config: &ConfigSet,
376    picked_commit_hex: &str,
377) -> String {
378    let mut msg = original_message.to_owned();
379
380    let explicit_cleanup = config.get("commit.cleanup").is_some();
381    let cleanup_space = append_source && !explicit_cleanup;
382    let cleanup_strip_comments =
383        explicit_cleanup && matches!(config.get("commit.cleanup").as_deref(), Some("strip"));
384
385    if cleanup_space {
386        let processed =
387            crate::stripspace::process(msg.as_bytes(), &crate::stripspace::Mode::Default);
388        let cleaned = String::from_utf8_lossy(&processed);
389        msg = cleaned.into_owned();
390    } else if cleanup_strip_comments {
391        let processed = crate::stripspace::process(
392            msg.as_bytes(),
393            &crate::stripspace::Mode::StripComments("#".to_owned()),
394        );
395        let cleaned = String::from_utf8_lossy(&processed);
396        msg = cleaned.into_owned();
397    }
398
399    if append_source {
400        append_cherry_picked_from_line(&mut msg, picked_commit_hex, config);
401    }
402
403    if signoff {
404        let sob = format_signoff_line(committer_name, committer_email);
405        append_signoff_trailer(&mut msg, &sob, config);
406    }
407
408    msg
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn cherry_pick_x_one_line_subject_inserts_blank_before_trailer() {
417        let config = ConfigSet::new();
418        let mut msg = "base: commit message".to_owned();
419        append_cherry_picked_from_line(&mut msg, "abcd".repeat(10).as_str(), &config);
420        assert!(msg.contains("\n\n(cherry picked from commit "));
421    }
422
423    #[test]
424    fn signoff_after_non_conforming_footer_inserts_blank_paragraph() {
425        let config = ConfigSet::new();
426        let body = "base: commit message\n\nOneWordBodyThatsNotA-S-o-B";
427        let mut msg = body.to_owned();
428        let sob = format_signoff_line("C O Mitter", "committer@example.com");
429        append_signoff_trailer(&mut msg, &sob, &config);
430        assert!(msg.contains("OneWordBodyThatsNotA-S-o-B\n\nSigned-off-by:"));
431    }
432
433    #[test]
434    fn cherry_pick_x_after_sob_without_final_newline_no_extra_blank_before_cherry_line() {
435        let config = ConfigSet::new();
436        let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
437        append_cherry_picked_from_line(&mut msg, "d".repeat(40).as_str(), &config);
438        assert!(msg.ends_with(")\n"));
439        assert!(
440            msg.contains("Signed-off-by: A <a@example.com>\n(cherry picked from commit "),
441            "unexpected spacing: {msg:?}"
442        );
443    }
444
445    #[test]
446    fn signoff_after_other_sob_without_final_newline_single_separator() {
447        let config = ConfigSet::new();
448        let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
449        let sob = format_signoff_line("C O Mitter", "committer@example.com");
450        append_signoff_trailer(&mut msg, &sob, &config);
451        assert!(
452            msg.contains("Signed-off-by: A <a@example.com>\nSigned-off-by: C O Mitter"),
453            "unexpected spacing: {msg:?}"
454        );
455    }
456}