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            // Reached the title boundary without an intervening blank line: the post-title content
235            // is entirely candidate trailers. Mirror Git's blank-line decision using the same ratio
236            // so a trailer block flush against the title (e.g. `subject\n\nSigned-off-by: ...`) is
237            // recognized rather than treated as plain body.
238            if !only_spaces {
239                non_trailer_lines += possible_continuation_lines;
240                if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
241                    return end_of_title;
242                }
243                if trailer_lines > 0 && non_trailer_lines == 0 {
244                    return end_of_title;
245                }
246            }
247            break;
248        }
249        let line_end = next_line_start(buf, l).min(len);
250        let line = &buf[l..line_end];
251
252        if line.first().is_some_and(|b| *b == b'#') {
253            non_trailer_lines += possible_continuation_lines;
254            possible_continuation_lines = 0;
255            l = match last_line_start(buf, l) {
256                Some(s) => s,
257                None => break,
258            };
259            continue;
260        }
261
262        if is_blank_line_bytes(line) {
263            if only_spaces {
264                l = match last_line_start(buf, l) {
265                    Some(s) => s,
266                    None => break,
267                };
268                continue;
269            }
270            non_trailer_lines += possible_continuation_lines;
271            if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
272                return next_line_start(buf, l);
273            }
274            if trailer_lines > 0 && non_trailer_lines == 0 {
275                return next_line_start(buf, l);
276            }
277            return len;
278        }
279
280        only_spaces = false;
281
282        if line_bytes_starts_with_git_generated(line) {
283            trailer_lines += 1;
284            possible_continuation_lines = 0;
285            recognized_prefix = true;
286            l = match last_line_start(buf, l) {
287                Some(s) => s,
288                None => break,
289            };
290            continue;
291        }
292
293        if let Some(sep_pos) = find_separator_colon(line) {
294            if sep_pos >= 1 && !line.first().is_some_and(|b| b.is_ascii_whitespace()) {
295                trailer_lines += 1;
296                possible_continuation_lines = 0;
297                if !recognized_prefix && token_matches_rule(&line[..sep_pos], rules) {
298                    recognized_prefix = true;
299                }
300                l = match last_line_start(buf, l) {
301                    Some(s) => s,
302                    None => break,
303                };
304                continue;
305            }
306        }
307
308        if line.first().is_some_and(|b| b.is_ascii_whitespace()) {
309            possible_continuation_lines += 1;
310        } else {
311            non_trailer_lines += 1;
312            non_trailer_lines += possible_continuation_lines;
313            possible_continuation_lines = 0;
314        }
315
316        l = match last_line_start(buf, l) {
317            Some(s) => s,
318            None => break,
319        };
320    }
321
322    len
323}
324
325/// Iterator over raw trailer lines in Git's sense (lines in the trailer block).
326fn trailer_raw_lines<'a>(msg: &'a str, rules: &[TrailerRule]) -> Vec<&'a str> {
327    let bytes = msg.as_bytes();
328    let end = find_end_of_log_message(bytes);
329    let start = find_trailer_block_start(bytes, end, rules);
330    if start >= end {
331        return Vec::new();
332    }
333    let slice = msg.get(start..end).unwrap_or("");
334    slice.lines().collect()
335}
336
337/// Returns 0 = no conforming footer, 1 = footer without matching sob, 2 = sob in footer not last,
338/// 3 = last trailer is sob (matches `has_conforming_footer` in Git when sob is set).
339fn has_conforming_footer_with_sob(msg: &str, sob_line: Option<&str>, rules: &[TrailerRule]) -> u8 {
340    let lines = trailer_raw_lines(msg, rules);
341    if lines.is_empty() {
342        return 0;
343    }
344    let Some(sob) = sob_line else {
345        return 1;
346    };
347    let sob_prefix = sob.strip_suffix('\n').unwrap_or(sob);
348    let mut found_sob = 0usize;
349    for (idx, raw) in lines.iter().enumerate() {
350        let raw_trim = raw.strip_suffix('\r').unwrap_or(raw);
351        // Git: `!strncmp(iter.raw, sob->buf, sob->len)` on C strings; equivalent to prefix match.
352        if raw_trim
353            .as_bytes()
354            .get(..sob_prefix.len())
355            .is_some_and(|head| head == sob_prefix.as_bytes())
356        {
357            found_sob = idx + 1;
358        }
359    }
360    let n = lines.len();
361    if found_sob == 0 {
362        return 1;
363    }
364    if found_sob == n {
365        return 3;
366    }
367    2
368}
369
370/// Returns 1 if there is a conforming footer, else 0 (sob unset).
371fn has_conforming_footer_any(msg: &str, rules: &[TrailerRule]) -> bool {
372    !trailer_raw_lines(msg, rules).is_empty()
373}
374
375fn strbuf_complete_line(s: &mut String) {
376    if !s.is_empty() && !s.ends_with('\n') {
377        s.push('\n');
378    }
379}
380
381/// Append `-x` trailer matching `sequencer.c` (`record_origin`).
382pub fn append_cherry_picked_from_line(msg: &mut String, full_hex: &str, config: &ConfigSet) {
383    let rules = load_trailer_rules(config);
384    strbuf_complete_line(msg);
385    let body_wo_final_blank_lines = msg.trim_end_matches('\n');
386    let has_footer = has_conforming_footer_any(msg, &rules)
387        || last_line_looks_like_trailer(body_wo_final_blank_lines.as_bytes(), &rules);
388    if !has_footer {
389        msg.push('\n');
390    }
391    msg.push_str(CHERRY_PICKED_PREFIX);
392    msg.push_str(full_hex);
393    msg.push_str(")\n");
394}
395
396/// Append sign-off matching `append_signoff` in `sequencer.c` (no `APPEND_SIGNOFF_DEDUP`).
397pub fn append_signoff_trailer(msg: &mut String, sob_line: &str, config: &ConfigSet) {
398    append_signoff_trailer_with_dedup(msg, sob_line, config, false);
399}
400
401/// Append sign-off with optional `APPEND_SIGNOFF_DEDUP` (set by `format-patch --signoff`, which
402/// suppresses adding a sign-off that already exists anywhere in the trailer block, not just at the
403/// very end).
404pub fn append_signoff_trailer_with_dedup(
405    msg: &mut String,
406    sob_line: &str,
407    config: &ConfigSet,
408    dedup: bool,
409) {
410    let rules = load_trailer_rules(config);
411    let ignore_footer = 0usize;
412    strbuf_complete_line(msg);
413
414    let footer_kind = has_conforming_footer_with_sob(msg, Some(sob_line), &rules);
415
416    let sob_prefix = sob_line.strip_suffix('\n').unwrap_or(sob_line);
417    let msg_core_len = msg.len().saturating_sub(ignore_footer);
418    // Git: if the whole message buffer equals the sob (including final newline), treat as matching.
419    let has_footer = if msg_core_len == sob_line.len()
420        && msg.get(..sob_line.len()).is_some_and(|p| p == sob_line)
421    {
422        3u8
423    } else {
424        footer_kind
425    };
426
427    if has_footer == 0 {
428        let body_scan = msg.trim_end_matches('\n');
429        let trailer_tail = last_line_looks_like_trailer(body_scan.as_bytes(), &rules);
430        if !trailer_tail {
431            let len = msg.len().saturating_sub(ignore_footer);
432            let append_newlines: Option<&'static str> = if len == 0 {
433                Some("\n\n")
434            } else if len == 1
435                || msg
436                    .as_bytes()
437                    .get(len - 2)
438                    .copied()
439                    .is_some_and(|b| b != b'\n')
440            {
441                Some("\n")
442            } else {
443                None
444            };
445            if let Some(nl) = append_newlines {
446                let insert_at = msg.len() - ignore_footer;
447                msg.insert_str(insert_at, nl);
448            }
449        }
450    }
451
452    let no_dup_sob = dedup;
453    if has_footer != 3 && (!no_dup_sob || has_footer != 2) {
454        let insert_at = msg.len() - ignore_footer;
455        msg.insert_str(insert_at, sob_prefix);
456        msg.push('\n');
457    }
458}
459
460/// Whether the final non-blank line of `msg` looks like a trailer (per `trailer.c`
461/// rules from `config`). Used to decide whether an appended `Signed-off-by:` joins
462/// the existing trailer block (single `\n`) or starts a new paragraph (`\n\n`),
463/// matching Git's `append_signoff`.
464#[must_use]
465pub fn message_ends_with_trailer(msg: &str, config: &ConfigSet) -> bool {
466    let rules = load_trailer_rules(config);
467    let body_scan = msg.trim_end_matches('\n');
468    last_line_looks_like_trailer(body_scan.as_bytes(), &rules)
469}
470
471/// Build `Signed-off-by: Name <email>\n` using the same identity resolution as cherry-pick.
472pub fn format_signoff_line(name: &str, email: &str) -> String {
473    format!("{SIGN_OFF_HEADER}{name} <{email}>\n")
474}
475
476/// Options parsed from a `%(trailers:...)` pretty placeholder, mirroring Git's
477/// `process_trailer_options` in `pretty.c` / `trailer.c`.
478#[derive(Debug, Clone)]
479pub struct TrailerOpts {
480    pub only_trailers: bool,
481    pub unfold: bool,
482    pub keyonly: bool,
483    pub valueonly: bool,
484    pub separator: String,
485    pub key_value_separator: String,
486    pub filter_keys: Vec<String>,
487}
488
489impl Default for TrailerOpts {
490    fn default() -> Self {
491        TrailerOpts {
492            only_trailers: false,
493            unfold: false,
494            keyonly: false,
495            valueonly: false,
496            separator: "\n".to_owned(),
497            key_value_separator: ": ".to_owned(),
498            filter_keys: Vec::new(),
499        }
500    }
501}
502
503/// A single parsed trailer item: either a `key: value` trailer or a non-trailer
504/// line in the trailer block.
505struct ParsedTrailer {
506    key: String,
507    value: String,
508    /// `true` when this line did not parse as `token<sep>value` (Git's `item->token == NULL`).
509    is_non_trailer: bool,
510}
511
512/// Parse the trailer block of a commit message into individual trailers.
513/// Mirrors `trailer_info_get` + `parse_trailers` with the default separator set `:`.
514fn parse_trailer_block(msg: &str) -> Vec<ParsedTrailer> {
515    let rules: Vec<TrailerRule> = Vec::new();
516    let bytes = msg.as_bytes();
517    let end = find_end_of_log_message(bytes);
518    let start = find_trailer_block_start(bytes, end, &rules);
519    if start >= end {
520        return Vec::new();
521    }
522    let block = match msg.get(start..end) {
523        Some(b) => b,
524        None => return Vec::new(),
525    };
526    // Split the block into logical trailer lines: a line that begins with
527    // whitespace is a folded continuation of the previous line.
528    let mut logical: Vec<String> = Vec::new();
529    for raw in block.split_inclusive('\n') {
530        let line = raw;
531        // Skip comment lines entirely (Git ignores them in the trailer block).
532        let first = line.as_bytes().first().copied();
533        if first == Some(b'#') {
534            continue;
535        }
536        let is_continuation = matches!(first, Some(b' ') | Some(b'\t'));
537        if is_continuation && !logical.is_empty() {
538            if let Some(last) = logical.last_mut() {
539                last.push_str(line);
540            }
541        } else {
542            logical.push(line.to_owned());
543        }
544    }
545    let mut out = Vec::new();
546    for entry in logical {
547        // Trim a single trailing newline for value capture (keep internal newlines).
548        let trimmed = entry.strip_suffix('\n').unwrap_or(&entry);
549        if trimmed.is_empty() {
550            continue;
551        }
552        if let Some(sep) = find_separator_colon(trimmed.as_bytes()) {
553            if sep >= 1 && !trimmed.as_bytes()[0].is_ascii_whitespace() {
554                let key = trimmed[..sep].to_owned();
555                // Value is everything after the separator char, with one leading space trimmed.
556                let mut value = trimmed[sep + 1..].to_owned();
557                if let Some(stripped) = value.strip_prefix(' ') {
558                    value = stripped.to_owned();
559                }
560                out.push(ParsedTrailer {
561                    key,
562                    value,
563                    is_non_trailer: false,
564                });
565                continue;
566            }
567        }
568        out.push(ParsedTrailer {
569            key: String::new(),
570            value: trimmed.to_owned(),
571            is_non_trailer: true,
572        });
573    }
574    out
575}
576
577fn unfold_value(s: &str) -> String {
578    // Mirror Git's unfold_value: collapse runs of "\n" + whitespace into a single space,
579    // and drop leading/trailing whitespace runs.
580    let mut out = String::with_capacity(s.len());
581    let bytes = s.as_bytes();
582    let mut i = 0;
583    while i < bytes.len() {
584        let b = bytes[i];
585        if b == b'\n' {
586            // skip following whitespace
587            i += 1;
588            while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
589                i += 1;
590            }
591            if !out.is_empty() && i < bytes.len() {
592                out.push(' ');
593            }
594            continue;
595        }
596        out.push(b as char);
597        i += 1;
598    }
599    out
600}
601
602/// Format the trailers of `msg` according to `opts`, matching Git's
603/// `format_trailers_from_commit` used by the `%(trailers:...)` pretty placeholder.
604pub fn format_trailers(msg: &str, opts: &TrailerOpts) -> String {
605    let trailers = parse_trailer_block(msg);
606    let mut items: Vec<String> = Vec::new();
607    // Git: when a key filter is present, only_trailers defaults to true unless
608    // the caller explicitly set only=no (encoded as `only_trailers == false`).
609    let effective_only = opts.only_trailers;
610    for t in &trailers {
611        if t.is_non_trailer {
612            // Non-trailer lines are omitted when only_trailers is in effect;
613            // otherwise the raw line is shown regardless of keyonly/valueonly.
614            if effective_only {
615                continue;
616            }
617            items.push(unfold_or_plain(&t.value, opts.unfold));
618            continue;
619        }
620        if !opts.filter_keys.is_empty() {
621            let matches = opts
622                .filter_keys
623                .iter()
624                .any(|k| k.eq_ignore_ascii_case(&t.key));
625            if !matches {
626                continue;
627            }
628        }
629        let value = unfold_or_plain(&t.value, opts.unfold);
630        let formatted = if opts.keyonly && opts.valueonly {
631            String::new()
632        } else if opts.keyonly {
633            t.key.clone()
634        } else if opts.valueonly {
635            value
636        } else {
637            format!("{}{}{}", t.key, opts.key_value_separator, value)
638        };
639        items.push(formatted);
640    }
641    if items.is_empty() {
642        return String::new();
643    }
644    // Git joins each trailer by the separator and appends the separator after the
645    // last one only when separator is the default "\n".
646    let mut out = String::new();
647    for (i, item) in items.iter().enumerate() {
648        if i > 0 {
649            out.push_str(&opts.separator);
650        }
651        out.push_str(item);
652    }
653    // Default separator "\n": a trailing newline is added after the last trailer.
654    if opts.separator == "\n" {
655        out.push('\n');
656    }
657    out
658}
659
660fn unfold_or_plain(value: &str, unfold: bool) -> String {
661    if unfold {
662        unfold_value(value)
663    } else {
664        value.to_owned()
665    }
666}
667
668/// Apply `-x` / `-s` rewriting plus optional `commit.cleanup` when `-x` is set.
669pub fn finalize_cherry_pick_message(
670    original_message: &str,
671    append_source: bool,
672    signoff: bool,
673    committer_name: &str,
674    committer_email: &str,
675    config: &ConfigSet,
676    picked_commit_hex: &str,
677) -> String {
678    let mut msg = original_message.to_owned();
679
680    let explicit_cleanup = config.get("commit.cleanup").is_some();
681    let cleanup_space = append_source && !explicit_cleanup;
682    let cleanup_strip_comments =
683        explicit_cleanup && matches!(config.get("commit.cleanup").as_deref(), Some("strip"));
684
685    if cleanup_space {
686        let processed =
687            crate::stripspace::process(msg.as_bytes(), &crate::stripspace::Mode::Default);
688        let cleaned = String::from_utf8_lossy(&processed);
689        msg = cleaned.into_owned();
690    } else if cleanup_strip_comments {
691        let processed = crate::stripspace::process(
692            msg.as_bytes(),
693            &crate::stripspace::Mode::StripComments("#".to_owned()),
694        );
695        let cleaned = String::from_utf8_lossy(&processed);
696        msg = cleaned.into_owned();
697    }
698
699    if append_source {
700        append_cherry_picked_from_line(&mut msg, picked_commit_hex, config);
701    }
702
703    if signoff {
704        let sob = format_signoff_line(committer_name, committer_email);
705        append_signoff_trailer(&mut msg, &sob, config);
706    }
707
708    msg
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn cherry_pick_x_one_line_subject_inserts_blank_before_trailer() {
717        let config = ConfigSet::new();
718        let mut msg = "base: commit message".to_owned();
719        append_cherry_picked_from_line(&mut msg, "abcd".repeat(10).as_str(), &config);
720        assert!(msg.contains("\n\n(cherry picked from commit "));
721    }
722
723    #[test]
724    fn signoff_after_non_conforming_footer_inserts_blank_paragraph() {
725        let config = ConfigSet::new();
726        let body = "base: commit message\n\nOneWordBodyThatsNotA-S-o-B";
727        let mut msg = body.to_owned();
728        let sob = format_signoff_line("C O Mitter", "committer@example.com");
729        append_signoff_trailer(&mut msg, &sob, &config);
730        assert!(msg.contains("OneWordBodyThatsNotA-S-o-B\n\nSigned-off-by:"));
731    }
732
733    #[test]
734    fn cherry_pick_x_after_sob_without_final_newline_no_extra_blank_before_cherry_line() {
735        let config = ConfigSet::new();
736        let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
737        append_cherry_picked_from_line(&mut msg, "d".repeat(40).as_str(), &config);
738        assert!(msg.ends_with(")\n"));
739        assert!(
740            msg.contains("Signed-off-by: A <a@example.com>\n(cherry picked from commit "),
741            "unexpected spacing: {msg:?}"
742        );
743    }
744
745    #[test]
746    fn signoff_after_other_sob_without_final_newline_single_separator() {
747        let config = ConfigSet::new();
748        let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
749        let sob = format_signoff_line("C O Mitter", "committer@example.com");
750        append_signoff_trailer(&mut msg, &sob, &config);
751        assert!(
752            msg.contains("Signed-off-by: A <a@example.com>\nSigned-off-by: C O Mitter"),
753            "unexpected spacing: {msg:?}"
754        );
755    }
756}