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