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        if !e.key.starts_with("trailer.") {
30            continue;
31        }
32        let parts: Vec<&str> = e.key.split('.').collect();
33        if parts.len() < 3 || parts[0] != "trailer" {
34            continue;
35        }
36        let subsection = parts[1];
37        if RESERVED_TRAILER_SUBSECTIONS.contains(&subsection) {
38            continue;
39        }
40        let rule = rules
41            .entry(subsection.to_string())
42            .or_insert_with(|| TrailerRule {
43                name: subsection.to_string(),
44                key: None,
45            });
46        if parts.len() >= 3 && parts[2] == "key" {
47            if let Some(v) = &e.value {
48                rule.key = Some(v.clone());
49            }
50        }
51    }
52    rules.into_values().collect()
53}
54
55fn next_line_start(buf: &[u8], pos: usize) -> usize {
56    if pos >= buf.len() {
57        return buf.len();
58    }
59    match buf[pos..].iter().position(|&b| b == b'\n') {
60        Some(p) => pos + p + 1,
61        None => buf.len(),
62    }
63}
64
65fn last_line_start(buf: &[u8], len: usize) -> Option<usize> {
66    if len == 0 {
67        return None;
68    }
69    if len == 1 {
70        return Some(0);
71    }
72    let mut i = len - 2;
73    loop {
74        if buf[i] == b'\n' {
75            return Some(i + 1);
76        }
77        if i == 0 {
78            return Some(0);
79        }
80        i -= 1;
81    }
82}
83
84/// Start byte of the last line in `buf[..len]` (Git `last_line`).
85fn last_line_start_bounded(buf: &[u8], len: usize) -> usize {
86    if len == 0 {
87        return 0;
88    }
89    if len == 1 {
90        return 0;
91    }
92    let mut i = len - 2;
93    loop {
94        if buf[i] == b'\n' {
95            return i + 1;
96        }
97        if i == 0 {
98            return 0;
99        }
100        i -= 1;
101    }
102}
103
104fn is_blank_line_bytes(line: &[u8]) -> bool {
105    line.iter()
106        .copied()
107        .take_while(|&b| b != b'\n')
108        .all(|b| b.is_ascii_whitespace())
109}
110
111/// Git `find_separator` with `separators = ":"`.
112fn find_separator_colon(line: &[u8]) -> Option<usize> {
113    let mut whitespace_found = false;
114    for (i, &c) in line.iter().enumerate() {
115        if c == b':' {
116            return Some(i);
117        }
118        if !whitespace_found && (c.is_ascii_alphanumeric() || c == b'-') {
119            continue;
120        }
121        if i != 0 && (c == b' ' || c == b'\t') {
122            whitespace_found = true;
123            continue;
124        }
125        break;
126    }
127    None
128}
129
130fn token_len_without_separator(token: &[u8]) -> usize {
131    let mut len = token.len();
132    while len > 0 && !token[len - 1].is_ascii_alphanumeric() {
133        len -= 1;
134    }
135    len
136}
137
138fn line_bytes_starts_with_git_generated(line: &[u8]) -> bool {
139    let line_one_line = line.split(|&b| b == b'\n').next().unwrap_or(line);
140    for p in GIT_GENERATED_PREFIXES {
141        let pb = p.as_bytes();
142        if line_one_line.len() >= pb.len() && &line_one_line[..pb.len()] == pb {
143            return true;
144        }
145    }
146    false
147}
148
149/// Whether `buf` (full message, possibly without a final `\\n`) ends with a line that Git would
150/// classify as a trailer (`trailer.c` / `sequencer.c`), including the no-final-newline case from
151/// `commit-tree` stdin.
152fn last_line_looks_like_trailer(buf: &[u8], rules: &[TrailerRule]) -> bool {
153    if buf.is_empty() {
154        return false;
155    }
156    let bol = last_line_start_bounded(buf, buf.len());
157    let last = &buf[bol..];
158    let mut trim_end = last.len();
159    while trim_end > 0 && matches!(last[trim_end - 1], b' ' | b'\t' | b'\r') {
160        trim_end -= 1;
161    }
162    let t = &last[..trim_end];
163    if t.is_empty() {
164        return false;
165    }
166    if line_bytes_starts_with_git_generated(t) {
167        return true;
168    }
169    if let Some(sep) = find_separator_colon(t) {
170        if sep >= 1 && !t[0].is_ascii_whitespace() {
171            return token_matches_rule(&t[..sep], rules);
172        }
173    }
174    false
175}
176
177fn token_matches_rule(token: &[u8], rules: &[TrailerRule]) -> bool {
178    let tlen = token_len_without_separator(token);
179    let token = &token[..tlen];
180    let Ok(tok_str) = std::str::from_utf8(token) else {
181        return false;
182    };
183    for r in rules {
184        if r.name.eq_ignore_ascii_case(tok_str) {
185            return true;
186        }
187        if r.key
188            .as_ref()
189            .is_some_and(|k| k.eq_ignore_ascii_case(tok_str))
190        {
191            return true;
192        }
193    }
194    false
195}
196
197fn find_end_of_log_message(input: &[u8]) -> usize {
198    input.len()
199}
200
201/// Byte offset where the trailer block starts, or `len` if none (`find_trailer_block_start`).
202fn find_trailer_block_start(buf: &[u8], len: usize, rules: &[TrailerRule]) -> usize {
203    // First paragraph (until first blank line) is never part of the trailer block.
204    // If there is no blank line, `end_of_title` stays 0 so scanning can treat the
205    // whole message as body + trailers (matches single-line subjects in t3511).
206    let mut end_of_title = 0usize;
207    let mut pos = 0usize;
208    while pos < len {
209        let line_end = next_line_start(buf, pos);
210        let line = &buf[pos..line_end.min(len)];
211        if line.first().is_some_and(|b| *b == b'#') {
212            pos = line_end;
213            continue;
214        }
215        if is_blank_line_bytes(line) {
216            end_of_title = line_end;
217            break;
218        }
219        pos = line_end;
220    }
221
222    let mut only_spaces = true;
223    let mut recognized_prefix = false;
224    let mut trailer_lines = 0i32;
225    let mut non_trailer_lines = 0i32;
226    let mut possible_continuation_lines = 0i32;
227
228    let mut l = match last_line_start(buf, len) {
229        Some(s) => s,
230        None => return len,
231    };
232
233    loop {
234        if l < end_of_title {
235            // Reached the title boundary without an intervening blank line: the post-title content
236            // is entirely candidate trailers. Mirror Git's blank-line decision using the same ratio
237            // so a trailer block flush against the title (e.g. `subject\n\nSigned-off-by: ...`) is
238            // recognized rather than treated as plain body.
239            if !only_spaces {
240                non_trailer_lines += possible_continuation_lines;
241                if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
242                    return end_of_title;
243                }
244                if trailer_lines > 0 && non_trailer_lines == 0 {
245                    return end_of_title;
246                }
247            }
248            break;
249        }
250        let line_end = next_line_start(buf, l).min(len);
251        let line = &buf[l..line_end];
252
253        if line.first().is_some_and(|b| *b == b'#') {
254            non_trailer_lines += possible_continuation_lines;
255            possible_continuation_lines = 0;
256            l = match last_line_start(buf, l) {
257                Some(s) => s,
258                None => break,
259            };
260            continue;
261        }
262
263        if is_blank_line_bytes(line) {
264            if only_spaces {
265                l = match last_line_start(buf, l) {
266                    Some(s) => s,
267                    None => break,
268                };
269                continue;
270            }
271            non_trailer_lines += possible_continuation_lines;
272            if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
273                return next_line_start(buf, l);
274            }
275            if trailer_lines > 0 && non_trailer_lines == 0 {
276                return next_line_start(buf, l);
277            }
278            return len;
279        }
280
281        only_spaces = false;
282
283        if line_bytes_starts_with_git_generated(line) {
284            trailer_lines += 1;
285            possible_continuation_lines = 0;
286            recognized_prefix = true;
287            l = match last_line_start(buf, l) {
288                Some(s) => s,
289                None => break,
290            };
291            continue;
292        }
293
294        if let Some(sep_pos) = find_separator_colon(line) {
295            if sep_pos >= 1 && !line.first().is_some_and(|b| b.is_ascii_whitespace()) {
296                trailer_lines += 1;
297                possible_continuation_lines = 0;
298                if !recognized_prefix && token_matches_rule(&line[..sep_pos], rules) {
299                    recognized_prefix = true;
300                }
301                l = match last_line_start(buf, l) {
302                    Some(s) => s,
303                    None => break,
304                };
305                continue;
306            }
307        }
308
309        if line.first().is_some_and(|b| b.is_ascii_whitespace()) {
310            possible_continuation_lines += 1;
311        } else {
312            non_trailer_lines += 1;
313            non_trailer_lines += possible_continuation_lines;
314            possible_continuation_lines = 0;
315        }
316
317        l = match last_line_start(buf, l) {
318            Some(s) => s,
319            None => break,
320        };
321    }
322
323    len
324}
325
326/// Iterator over raw trailer lines in Git's sense (lines in the trailer block).
327fn trailer_raw_lines<'a>(msg: &'a str, rules: &[TrailerRule]) -> Vec<&'a str> {
328    let bytes = msg.as_bytes();
329    let end = find_end_of_log_message(bytes);
330    let start = find_trailer_block_start(bytes, end, rules);
331    if start >= end {
332        return Vec::new();
333    }
334    let slice = msg.get(start..end).unwrap_or("");
335    slice.lines().collect()
336}
337
338/// Returns 0 = no conforming footer, 1 = footer without matching sob, 2 = sob in footer not last,
339/// 3 = last trailer is sob (matches `has_conforming_footer` in Git when sob is set).
340fn has_conforming_footer_with_sob(msg: &str, sob_line: Option<&str>, rules: &[TrailerRule]) -> u8 {
341    let lines = trailer_raw_lines(msg, rules);
342    if lines.is_empty() {
343        return 0;
344    }
345    let Some(sob) = sob_line else {
346        return 1;
347    };
348    let sob_prefix = sob.strip_suffix('\n').unwrap_or(sob);
349    let mut found_sob = 0usize;
350    for (idx, raw) in lines.iter().enumerate() {
351        let raw_trim = raw.strip_suffix('\r').unwrap_or(raw);
352        // Git: `!strncmp(iter.raw, sob->buf, sob->len)` on C strings; equivalent to prefix match.
353        if raw_trim
354            .as_bytes()
355            .get(..sob_prefix.len())
356            .is_some_and(|head| head == sob_prefix.as_bytes())
357        {
358            found_sob = idx + 1;
359        }
360    }
361    let n = lines.len();
362    if found_sob == 0 {
363        return 1;
364    }
365    if found_sob == n {
366        return 3;
367    }
368    2
369}
370
371/// Returns 1 if there is a conforming footer, else 0 (sob unset).
372fn has_conforming_footer_any(msg: &str, rules: &[TrailerRule]) -> bool {
373    !trailer_raw_lines(msg, rules).is_empty()
374}
375
376fn strbuf_complete_line(s: &mut String) {
377    if !s.is_empty() && !s.ends_with('\n') {
378        s.push('\n');
379    }
380}
381
382/// Append `-x` trailer matching `sequencer.c` (`record_origin`).
383pub fn append_cherry_picked_from_line(msg: &mut String, full_hex: &str, config: &ConfigSet) {
384    let rules = load_trailer_rules(config);
385    strbuf_complete_line(msg);
386    let body_wo_final_blank_lines = msg.trim_end_matches('\n');
387    let has_footer = has_conforming_footer_any(msg, &rules)
388        || last_line_looks_like_trailer(body_wo_final_blank_lines.as_bytes(), &rules);
389    if !has_footer {
390        msg.push('\n');
391    }
392    msg.push_str(CHERRY_PICKED_PREFIX);
393    msg.push_str(full_hex);
394    msg.push_str(")\n");
395}
396
397/// Append sign-off matching `append_signoff` in `sequencer.c` (no `APPEND_SIGNOFF_DEDUP`).
398pub fn append_signoff_trailer(msg: &mut String, sob_line: &str, config: &ConfigSet) {
399    append_signoff_trailer_with_dedup(msg, sob_line, config, false);
400}
401
402/// Append sign-off with optional `APPEND_SIGNOFF_DEDUP` (set by `format-patch --signoff`, which
403/// suppresses adding a sign-off that already exists anywhere in the trailer block, not just at the
404/// very end).
405pub fn append_signoff_trailer_with_dedup(
406    msg: &mut String,
407    sob_line: &str,
408    config: &ConfigSet,
409    dedup: bool,
410) {
411    let rules = load_trailer_rules(config);
412    let ignore_footer = 0usize;
413    strbuf_complete_line(msg);
414
415    let footer_kind = has_conforming_footer_with_sob(msg, Some(sob_line), &rules);
416
417    let sob_prefix = sob_line.strip_suffix('\n').unwrap_or(sob_line);
418    let msg_core_len = msg.len().saturating_sub(ignore_footer);
419    // Git: if the whole message buffer equals the sob (including final newline), treat as matching.
420    let has_footer = if msg_core_len == sob_line.len()
421        && msg.get(..sob_line.len()).is_some_and(|p| p == sob_line)
422    {
423        3u8
424    } else {
425        footer_kind
426    };
427
428    if has_footer == 0 {
429        let body_scan = msg.trim_end_matches('\n');
430        let trailer_tail = last_line_looks_like_trailer(body_scan.as_bytes(), &rules);
431        if !trailer_tail {
432            let len = msg.len().saturating_sub(ignore_footer);
433            let append_newlines: Option<&'static str> = if len == 0 {
434                Some("\n\n")
435            } else if len == 1
436                || msg
437                    .as_bytes()
438                    .get(len - 2)
439                    .copied()
440                    .is_some_and(|b| b != b'\n')
441            {
442                Some("\n")
443            } else {
444                None
445            };
446            if let Some(nl) = append_newlines {
447                let insert_at = msg.len() - ignore_footer;
448                msg.insert_str(insert_at, nl);
449            }
450        }
451    }
452
453    let no_dup_sob = dedup;
454    if has_footer != 3 && (!no_dup_sob || has_footer != 2) {
455        let insert_at = msg.len() - ignore_footer;
456        msg.insert_str(insert_at, sob_prefix);
457        msg.push('\n');
458    }
459}
460
461/// Build `Signed-off-by: Name <email>\n` using the same identity resolution as cherry-pick.
462pub fn format_signoff_line(name: &str, email: &str) -> String {
463    format!("{SIGN_OFF_HEADER}{name} <{email}>\n")
464}
465
466/// Apply `-x` / `-s` rewriting plus optional `commit.cleanup` when `-x` is set.
467pub fn finalize_cherry_pick_message(
468    original_message: &str,
469    append_source: bool,
470    signoff: bool,
471    committer_name: &str,
472    committer_email: &str,
473    config: &ConfigSet,
474    picked_commit_hex: &str,
475) -> String {
476    let mut msg = original_message.to_owned();
477
478    let explicit_cleanup = config.get("commit.cleanup").is_some();
479    let cleanup_space = append_source && !explicit_cleanup;
480    let cleanup_strip_comments =
481        explicit_cleanup && matches!(config.get("commit.cleanup").as_deref(), Some("strip"));
482
483    if cleanup_space {
484        let processed =
485            crate::stripspace::process(msg.as_bytes(), &crate::stripspace::Mode::Default);
486        let cleaned = String::from_utf8_lossy(&processed);
487        msg = cleaned.into_owned();
488    } else if cleanup_strip_comments {
489        let processed = crate::stripspace::process(
490            msg.as_bytes(),
491            &crate::stripspace::Mode::StripComments("#".to_owned()),
492        );
493        let cleaned = String::from_utf8_lossy(&processed);
494        msg = cleaned.into_owned();
495    }
496
497    if append_source {
498        append_cherry_picked_from_line(&mut msg, picked_commit_hex, config);
499    }
500
501    if signoff {
502        let sob = format_signoff_line(committer_name, committer_email);
503        append_signoff_trailer(&mut msg, &sob, config);
504    }
505
506    msg
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn cherry_pick_x_one_line_subject_inserts_blank_before_trailer() {
515        let config = ConfigSet::new();
516        let mut msg = "base: commit message".to_owned();
517        append_cherry_picked_from_line(&mut msg, "abcd".repeat(10).as_str(), &config);
518        assert!(msg.contains("\n\n(cherry picked from commit "));
519    }
520
521    #[test]
522    fn signoff_after_non_conforming_footer_inserts_blank_paragraph() {
523        let config = ConfigSet::new();
524        let body = "base: commit message\n\nOneWordBodyThatsNotA-S-o-B";
525        let mut msg = body.to_owned();
526        let sob = format_signoff_line("C O Mitter", "committer@example.com");
527        append_signoff_trailer(&mut msg, &sob, &config);
528        assert!(msg.contains("OneWordBodyThatsNotA-S-o-B\n\nSigned-off-by:"));
529    }
530
531    #[test]
532    fn cherry_pick_x_after_sob_without_final_newline_no_extra_blank_before_cherry_line() {
533        let config = ConfigSet::new();
534        let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
535        append_cherry_picked_from_line(&mut msg, "d".repeat(40).as_str(), &config);
536        assert!(msg.ends_with(")\n"));
537        assert!(
538            msg.contains("Signed-off-by: A <a@example.com>\n(cherry picked from commit "),
539            "unexpected spacing: {msg:?}"
540        );
541    }
542
543    #[test]
544    fn signoff_after_other_sob_without_final_newline_single_separator() {
545        let config = ConfigSet::new();
546        let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
547        let sob = format_signoff_line("C O Mitter", "committer@example.com");
548        append_signoff_trailer(&mut msg, &sob, &config);
549        assert!(
550            msg.contains("Signed-off-by: A <a@example.com>\nSigned-off-by: C O Mitter"),
551            "unexpected spacing: {msg:?}"
552        );
553    }
554}