Skip to main content

grit_lib/
interpret_trailers.rs

1//! Commit message trailer parsing and rewriting (Git-compatible).
2//!
3//! Behaviour matches upstream `git/trailer.c` / `git interpret-trailers`.
4
5use std::collections::HashMap;
6use std::path::Path;
7
8use crate::config::{ConfigEntry, ConfigSet};
9
10const CUT_LINE: &str = "------------------------ >8 ------------------------";
11
12const GIT_GENERATED_PREFIXES: &[&str] = &["Signed-off-by: ", "(cherry picked from commit "];
13
14const TRAILER_ARG_PLACEHOLDER: &str = "$ARG";
15
16/// Placement of a new trailer relative to an anchor trailer.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum TrailerWhere {
19    #[default]
20    /// `Default` variant.
21    Default,
22    /// `End` variant.
23    End,
24    /// `After` variant.
25    After,
26    /// `Before` variant.
27    Before,
28    /// `Start` variant.
29    Start,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33/// `TrailerIfExists` enum.
34pub enum TrailerIfExists {
35    #[default]
36    /// `Default` variant.
37    Default,
38    /// `AddIfDifferentNeighbor` variant.
39    AddIfDifferentNeighbor,
40    /// `AddIfDifferent` variant.
41    AddIfDifferent,
42    /// `Add` variant.
43    Add,
44    /// `Replace` variant.
45    Replace,
46    /// `DoNothing` variant.
47    DoNothing,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51/// `TrailerIfMissing` enum.
52pub enum TrailerIfMissing {
53    #[default]
54    /// `Default` variant.
55    Default,
56    /// `Add` variant.
57    Add,
58    /// `DoNothing` variant.
59    DoNothing,
60}
61
62#[derive(Debug, Clone, Default)]
63/// `ProcessTrailerOptions` struct.
64pub struct ProcessTrailerOptions {
65    /// `trim_empty` field.
66    pub trim_empty: bool,
67    /// `only_trailers` field.
68    pub only_trailers: bool,
69    /// `only_input` field.
70    pub only_input: bool,
71    /// `unfold` field.
72    pub unfold: bool,
73    /// `no_divider` field.
74    pub no_divider: bool,
75}
76
77#[derive(Debug, Clone)]
78/// `NewTrailerArg` struct.
79pub struct NewTrailerArg {
80    /// `text` field.
81    pub text: String,
82    /// `where_` field.
83    pub where_: TrailerWhere,
84    /// `if_exists` field.
85    pub if_exists: TrailerIfExists,
86    /// `if_missing` field.
87    pub if_missing: TrailerIfMissing,
88}
89
90#[derive(Debug, Clone)]
91struct ConfInfo {
92    name: String,
93    key: Option<String>,
94    command: Option<String>,
95    cmd: Option<String>,
96    where_: TrailerWhere,
97    if_exists: TrailerIfExists,
98    if_missing: TrailerIfMissing,
99}
100
101#[derive(Debug, Clone)]
102struct TrailerItem {
103    token: Option<String>,
104    value: String,
105}
106
107#[derive(Debug, Clone)]
108struct ArgItem {
109    token: String,
110    value: String,
111    conf: ConfInfo,
112}
113
114impl ArgItem {
115    fn into_item(self) -> TrailerItem {
116        TrailerItem {
117            token: Some(self.token),
118            value: self.value,
119        }
120    }
121}
122
123#[derive(Debug)]
124struct TrailerBlock {
125    blank_line_before: bool,
126    start: usize,
127    end: usize,
128    lines: Vec<String>,
129}
130
131/// Parse `trailer.where` / `--where` values (Git spelling).
132pub fn trailer_where_from_str(s: &str) -> Option<TrailerWhere> {
133    match s.to_ascii_lowercase().as_str() {
134        "after" => Some(TrailerWhere::After),
135        "before" => Some(TrailerWhere::Before),
136        "end" => Some(TrailerWhere::End),
137        "start" => Some(TrailerWhere::Start),
138        _ => None,
139    }
140}
141
142/// Parse `trailer.ifexists` / `--if-exists` values.
143pub fn trailer_if_exists_from_str(s: &str) -> Option<TrailerIfExists> {
144    match s.to_ascii_lowercase().as_str() {
145        "addifdifferent" => Some(TrailerIfExists::AddIfDifferent),
146        "addifdifferentneighbor" => Some(TrailerIfExists::AddIfDifferentNeighbor),
147        "add" => Some(TrailerIfExists::Add),
148        "replace" => Some(TrailerIfExists::Replace),
149        "donothing" => Some(TrailerIfExists::DoNothing),
150        _ => None,
151    }
152}
153
154/// Parse `trailer.ifmissing` / `--if-missing` values.
155pub fn trailer_if_missing_from_str(s: &str) -> Option<TrailerIfMissing> {
156    match s.to_ascii_lowercase().as_str() {
157        "add" => Some(TrailerIfMissing::Add),
158        "donothing" => Some(TrailerIfMissing::DoNothing),
159        _ => None,
160    }
161}
162
163fn after_or_end(where_: TrailerWhere) -> bool {
164    matches!(where_, TrailerWhere::After | TrailerWhere::End)
165}
166
167fn token_len_without_separator(token: &str) -> usize {
168    let b = token.as_bytes();
169    let mut len = token.len();
170    while len > 0 && !b[len - 1].is_ascii_alphanumeric() {
171        len -= 1;
172    }
173    len
174}
175
176fn same_token(a_token: Option<&str>, b_token: &str) -> bool {
177    let Some(a) = a_token else {
178        return false;
179    };
180    let a_len = token_len_without_separator(a);
181    let b_len = token_len_without_separator(b_token);
182    let min_len = a_len.min(b_len);
183    a[..min_len].eq_ignore_ascii_case(&b_token[..min_len])
184}
185
186fn same_trailer(a: &TrailerItem, b: &ArgItem) -> bool {
187    same_token(a.token.as_deref(), &b.token) && a.value.eq_ignore_ascii_case(&b.value)
188}
189
190fn is_blank_line(s: &str) -> bool {
191    s.chars().all(|c| c.is_whitespace())
192}
193
194fn last_non_space_char(s: &str) -> Option<char> {
195    s.chars().rev().find(|c| !c.is_whitespace())
196}
197
198fn line_end(buf: &str, bol: usize, limit: usize) -> usize {
199    bol + buf[bol..limit].find('\n').unwrap_or(limit - bol)
200}
201
202fn after_line(buf: &str, bol: usize, limit: usize) -> usize {
203    let le = line_end(buf, bol, limit);
204    if le < limit {
205        le + 1
206    } else {
207        limit
208    }
209}
210
211/// Start of the line that ends at `len` (`None` when `len` is 0).
212fn last_line_start(buf: &str, len: usize) -> Option<usize> {
213    let before_last_byte = buf.as_bytes().get(..len.checked_sub(1)?)?;
214    Some(
215        before_last_byte
216            .iter()
217            .rposition(|&b| b == b'\n')
218            .map_or(0, |i| i + 1),
219    )
220}
221
222/// Offset of the scissors line (`# --- >8 ---`) within `s[..len]`, or `len` when there is none.
223fn cut_line_start(s: &str, len: usize, comment_prefix: &str) -> usize {
224    let head = format!("{comment_prefix} {CUT_LINE}\n");
225    if s.starts_with(&head) {
226        return 0;
227    }
228    let pattern = format!("\n{head}");
229    if let Some(p) = s[..len].find(&pattern) {
230        let newlen = p + 1;
231        if newlen < len {
232            return newlen;
233        }
234    }
235    len
236}
237
238fn ignored_log_message_bytes(buf: &str, len: usize, comment_prefix: &str) -> usize {
239    let cutoff = cut_line_start(buf, len, comment_prefix);
240    let mut bol = 0usize;
241    let mut boc = 0usize;
242    let mut in_old_conflicts = false;
243
244    while bol < cutoff {
245        let le = line_end(buf, bol, cutoff);
246        let line = &buf[bol..le];
247
248        let is_comment = line.starts_with(comment_prefix) || line.is_empty();
249        if is_comment {
250            if boc == 0 {
251                boc = bol;
252            }
253        } else if line.starts_with("Conflicts:") {
254            in_old_conflicts = true;
255            if boc == 0 {
256                boc = bol;
257            }
258        } else if in_old_conflicts && line.starts_with('\t') {
259            // path in conflicts block
260        } else if boc != 0 {
261            boc = 0;
262            in_old_conflicts = false;
263        }
264
265        bol = after_line(buf, bol, cutoff);
266    }
267
268    if boc != 0 {
269        len - boc
270    } else {
271        len - cutoff
272    }
273}
274
275fn find_end_of_log_message(input: &str, no_divider: bool, comment_prefix: &str) -> usize {
276    let is_divider = |line: &str| {
277        line.strip_prefix("---")
278            .is_some_and(|rest| rest.chars().next().is_none_or(|c| c.is_ascii_whitespace()))
279    };
280    let mut end = input.len();
281    let mut pos = 0usize;
282    while !no_divider && pos < input.len() {
283        if is_divider(&input[pos..]) {
284            end = pos;
285            break;
286        }
287        pos = after_line(input, pos, input.len());
288    }
289    end - ignored_log_message_bytes(input, end, comment_prefix)
290}
291
292fn find_separator(line: &str, separators: &str) -> Option<usize> {
293    let mut whitespace_found = false;
294    for (i, c) in line.char_indices() {
295        if separators.contains(c) {
296            return Some(i);
297        }
298        if !whitespace_found && (c.is_ascii_alphanumeric() || c == '-') {
299            continue;
300        }
301        if i > 0 && (c == ' ' || c == '\t') {
302            whitespace_found = true;
303            continue;
304        }
305        break;
306    }
307    None
308}
309
310impl ConfInfo {
311    /// Whether `token` (text before the separator) starts with this trailer's alias name or with
312    /// its configured key, compared case-insensitively and ignoring the key's trailing separator.
313    fn matches_token(&self, token: &str) -> bool {
314        let token_len = token_len_without_separator(token);
315        let has_prefix =
316            |want: &str| token_len >= want.len() && token[..want.len()].eq_ignore_ascii_case(want);
317        has_prefix(&self.name)
318            || self
319                .key
320                .as_deref()
321                .is_some_and(|key| has_prefix(&key[..token_len_without_separator(key)]))
322    }
323
324    /// Applies a `where`, `ifexists` or `ifmissing` setting; unparsable values are ignored.
325    /// Returns `false` when `var` is not one of those settings.
326    fn apply_placement_setting(&mut self, var: &str, value: Option<&str>) -> bool {
327        if var.eq_ignore_ascii_case("where") {
328            if let Some(v) = value.and_then(trailer_where_from_str) {
329                self.where_ = v;
330            }
331        } else if var.eq_ignore_ascii_case("ifexists") {
332            if let Some(v) = value.and_then(trailer_if_exists_from_str) {
333                self.if_exists = v;
334            }
335        } else if var.eq_ignore_ascii_case("ifmissing") {
336            if let Some(v) = value.and_then(trailer_if_missing_from_str) {
337                self.if_missing = v;
338            }
339        } else {
340            return false;
341        }
342        true
343    }
344}
345
346fn find_trailer_block_start(
347    buf: &str,
348    len: usize,
349    conf: &[ConfInfo],
350    separators: &str,
351    comment_prefix: &str,
352) -> usize {
353    let mut end_of_title = len;
354    let mut pos = 0usize;
355    while pos < len {
356        let line = &buf[pos..line_end(buf, pos, len)];
357        if !line.starts_with(comment_prefix) && is_blank_line(line) {
358            end_of_title = pos;
359            break;
360        }
361        pos = after_line(buf, pos, len);
362    }
363
364    let mut only_spaces = true;
365    let mut recognized_prefix = false;
366    let mut trailer_lines = 0i32;
367    let mut non_trailer_lines = 0i32;
368    let mut possible_continuation = 0i32;
369
370    let mut next = last_line_start(buf, len);
371    while let Some(bol) = next {
372        if bol < end_of_title {
373            break;
374        }
375        next = last_line_start(buf, bol);
376        let line = &buf[bol..line_end(buf, bol, len)];
377
378        if line.starts_with(comment_prefix) {
379            non_trailer_lines += possible_continuation;
380            possible_continuation = 0;
381            continue;
382        }
383
384        if is_blank_line(line) {
385            if only_spaces {
386                continue;
387            }
388            non_trailer_lines += possible_continuation;
389            let is_trailer_block = (recognized_prefix && trailer_lines * 3 >= non_trailer_lines)
390                || (trailer_lines > 0 && non_trailer_lines == 0);
391            return if is_trailer_block {
392                after_line(buf, bol, len)
393            } else {
394                len
395            };
396        }
397        only_spaces = false;
398
399        let indented = line.starts_with(char::is_whitespace);
400        if GIT_GENERATED_PREFIXES.iter().any(|p| line.starts_with(p)) {
401            trailer_lines += 1;
402            possible_continuation = 0;
403            recognized_prefix = true;
404        } else if let Some(sep_pos) =
405            find_separator(line, separators).filter(|&p| p >= 1 && !indented)
406        {
407            trailer_lines += 1;
408            possible_continuation = 0;
409            recognized_prefix = recognized_prefix
410                || conf
411                    .iter()
412                    .any(|item| item.matches_token(line[..sep_pos].trim_end()));
413        } else if indented {
414            possible_continuation += 1;
415        } else {
416            non_trailer_lines += 1 + possible_continuation;
417            possible_continuation = 0;
418        }
419    }
420
421    len
422}
423
424fn ends_with_blank_line(buf: &str, trailer_block_start: usize) -> bool {
425    if trailer_block_start == 0 {
426        return false;
427    }
428    let slice = &buf[..trailer_block_start];
429    last_line_start(slice, slice.len()).is_some_and(|i| is_blank_line(&slice[i..]))
430}
431
432fn unfold_value(s: &str) -> String {
433    let mut out = String::with_capacity(s.len());
434    let mut chars = s.chars().peekable();
435    while let Some(c) = chars.next() {
436        if c == '\n' {
437            while chars.peek().is_some_and(|x| x.is_whitespace()) {
438                chars.next();
439            }
440            if !out.is_empty() && !out.ends_with(' ') {
441                out.push(' ');
442            }
443        } else {
444            out.push(c);
445        }
446    }
447    out.trim().to_string()
448}
449
450impl Default for ConfInfo {
451    fn default() -> Self {
452        Self {
453            name: String::new(),
454            key: None,
455            command: None,
456            cmd: None,
457            where_: TrailerWhere::End,
458            if_exists: TrailerIfExists::AddIfDifferentNeighbor,
459            if_missing: TrailerIfMissing::Add,
460        }
461    }
462}
463
464fn token_from_item(item: &ConfInfo, fallback: &str) -> String {
465    item.key.clone().unwrap_or_else(|| fallback.to_string())
466}
467
468/// Splits `trailer` into token and value. When the token matches a configured alias, the token
469/// is replaced by the alias key and the matching configuration is returned alongside.
470fn parse_trailer<'c>(
471    trailer: &str,
472    separators: &str,
473    conf: &'c [ConfInfo],
474) -> (String, String, Option<&'c ConfInfo>) {
475    let (tok, val) = match find_separator(trailer, separators) {
476        Some(pos) => (trailer[..pos].trim(), trailer[pos + 1..].trim()),
477        None => (trailer.trim(), ""),
478    };
479    let picked = conf.iter().find(|item| item.matches_token(tok));
480    let tok = picked.map_or_else(|| tok.to_string(), |item| token_from_item(item, tok));
481    (tok, val.to_string(), picked)
482}
483
484fn comment_line_prefix(cfg: &ConfigSet) -> String {
485    match cfg.get("core.commentChar") {
486        Some(s) => {
487            let t = s.trim();
488            if t.is_empty() || t.eq_ignore_ascii_case("auto") {
489                "#".to_string()
490            } else {
491                t.chars().next().unwrap_or('#').to_string()
492            }
493        }
494        None => "#".to_string(),
495    }
496}
497
498fn load_trailer_config(cfg: &ConfigSet) -> (ConfInfo, Vec<ConfInfo>, String) {
499    // Git reads `trailer.*` globals first (`git_trailer_default_config`), then per-alias keys.
500    // Each `trailer.<alias>.*` entry inherits the current global defaults at creation time.
501    let mut default_conf = ConfInfo::default();
502    let mut map: HashMap<String, ConfInfo> = HashMap::new();
503    let mut separators = ":".to_string();
504
505    for ConfigEntry { key, value, .. } in cfg.entries() {
506        let Some(var) = key
507            .strip_prefix("trailer.")
508            .filter(|rest| !rest.contains('.'))
509        else {
510            continue;
511        };
512        if !default_conf.apply_placement_setting(var, value.as_deref())
513            && var.eq_ignore_ascii_case("separators")
514        {
515            if let Some(v) = value {
516                separators = v.clone();
517            }
518        }
519    }
520
521    for ConfigEntry { key, value, .. } in cfg.entries() {
522        let Some((name_part, var)) = key
523            .strip_prefix("trailer.")
524            .and_then(|rest| rest.rsplit_once('.'))
525        else {
526            continue;
527        };
528
529        let entry = map
530            .entry(name_part.to_string())
531            .or_insert_with(|| ConfInfo {
532                name: name_part.to_string(),
533                ..default_conf.clone()
534            });
535        if entry.apply_placement_setting(var, value.as_deref()) {
536            continue;
537        }
538        let slot = if var.eq_ignore_ascii_case("key") {
539            &mut entry.key
540        } else if var.eq_ignore_ascii_case("command") {
541            &mut entry.command
542        } else if var.eq_ignore_ascii_case("cmd") {
543            &mut entry.cmd
544        } else {
545            continue;
546        };
547        if let Some(v) = value {
548            *slot = Some(v.clone());
549        }
550    }
551
552    (default_conf, map.into_values().collect(), separators)
553}
554
555fn trailer_block_get(
556    input: &str,
557    opts: &ProcessTrailerOptions,
558    conf: &[ConfInfo],
559    separators: &str,
560    comment_prefix: &str,
561) -> TrailerBlock {
562    let end_of_log = find_end_of_log_message(input, opts.no_divider, comment_prefix);
563    let trailer_start =
564        find_trailer_block_start(input, end_of_log, conf, separators, comment_prefix);
565    let mut lines: Vec<String> = Vec::new();
566    let mut continues_trailer = false;
567    for line in input[trailer_start..end_of_log].split_terminator('\n') {
568        if continues_trailer && line.starts_with([' ', '\t']) {
569            if let Some(last) = lines.last_mut() {
570                last.push('\n');
571                last.push_str(line);
572            }
573            continue;
574        }
575        continues_trailer = find_separator(line, separators)
576            .is_some_and(|p| p >= 1 && !line.starts_with(char::is_whitespace));
577        lines.push(line.to_string());
578    }
579
580    TrailerBlock {
581        blank_line_before: ends_with_blank_line(input, trailer_start),
582        start: trailer_start,
583        end: end_of_log,
584        lines,
585    }
586}
587
588fn parse_trailers_from_input(
589    block: &TrailerBlock,
590    opts: &ProcessTrailerOptions,
591    conf: &[ConfInfo],
592    separators: &str,
593    comment_prefix: &str,
594) -> Vec<TrailerItem> {
595    block
596        .lines
597        .iter()
598        .filter(|line| !line.starts_with(comment_prefix))
599        .filter_map(|line| match find_separator(line, separators) {
600            Some(0) => None,
601            Some(_) => {
602                let (tok, val, _) = parse_trailer(line, separators, conf);
603                Some(TrailerItem {
604                    token: Some(tok),
605                    value: if opts.unfold { unfold_value(&val) } else { val },
606                })
607            }
608            None => (!opts.only_trailers).then(|| TrailerItem {
609                token: None,
610                value: line.clone(),
611            }),
612        })
613        .collect()
614}
615
616fn apply_command(conf: &ConfInfo, arg: Option<&str>, cwd: Option<&Path>) -> String {
617    let arg = arg.unwrap_or("");
618    let dir = cwd.unwrap_or_else(|| Path::new("."));
619    use crate::command_runner::{
620        system_command_runner, CommandEnvironment, CommandSpec, CommandStdin, CommandStdio,
621        ShellInvocation,
622    };
623    let runner = system_command_runner();
624    let output = if let Some(cmd) = &conf.cmd {
625        let script = format!("{cmd} \"$@\"");
626        let spec = CommandSpec {
627            program: "sh".into(),
628            args: Vec::new(),
629            shell: Some(ShellInvocation::DashC {
630                script,
631                argv0: Some(cmd.clone()),
632                args: vec![std::ffi::OsString::from(arg)],
633            }),
634            cwd: Some(dir.to_path_buf()),
635            env: CommandEnvironment::inherit_process_only(),
636            stdin: CommandStdin::Null,
637            stdout: CommandStdio::Pipe,
638            stderr: CommandStdio::Inherit,
639        };
640        runner
641            .spawn(&spec)
642            .ok()
643            .and_then(|h| h.wait_with_output().ok())
644    } else if let Some(command) = &conf.command {
645        let cmd_line = command.replace(TRAILER_ARG_PLACEHOLDER, arg);
646        crate::command_runner::run_sh_dash_c(
647            runner.as_ref(),
648            &cmd_line,
649            Some(dir),
650            CommandStdin::Null,
651            CommandStdio::Inherit,
652        )
653        .ok()
654    } else {
655        return String::new();
656    };
657
658    match output {
659        Some(o) if o.status.success => String::from_utf8_lossy(&o.stdout).trim().to_string(),
660        _ => String::new(),
661    }
662}
663
664/// Whether `arg` differs from every trailer visited from `start_idx`: only that trailer when
665/// `check_all` is false, otherwise every trailer from it towards the placement end.
666fn check_if_different(
667    head: &[TrailerItem],
668    start_idx: usize,
669    arg: &ArgItem,
670    check_all: bool,
671) -> bool {
672    let visited = if !check_all {
673        &head[start_idx..=start_idx]
674    } else if after_or_end(arg.conf.where_) {
675        &head[..=start_idx]
676    } else {
677        &head[start_idx..]
678    };
679    !visited.iter().any(|item| same_trailer(item, arg))
680}
681
682/// Inserts `item` after (`after` / `end`) or before `head[anchor_idx]` and returns its index.
683fn insert_relative_to(
684    head: &mut Vec<TrailerItem>,
685    anchor_idx: usize,
686    item: TrailerItem,
687    where_: TrailerWhere,
688) -> usize {
689    let at = match head.len().checked_sub(1) {
690        None => 0,
691        Some(last) if after_or_end(where_) => anchor_idx.min(last) + 1,
692        Some(last) => anchor_idx.min(last),
693    };
694    head.insert(at, item);
695    at
696}
697
698fn apply_item_command(
699    head: &[TrailerItem],
700    in_idx: Option<usize>,
701    arg: &mut ArgItem,
702    cwd: Option<&Path>,
703) {
704    if arg.conf.command.is_none() && arg.conf.cmd.is_none() {
705        return;
706    }
707    let in_val = in_idx
708        .and_then(|i| head.get(i))
709        .filter(|t| t.token.is_some())
710        .map(|t| t.value.as_str());
711    let arg_for_cmd = if arg.value.is_empty() {
712        in_val
713    } else {
714        Some(arg.value.as_str())
715    };
716    arg.value = apply_command(&arg.conf, arg_for_cmd, cwd);
717}
718
719fn apply_arg_if_exists(
720    head: &mut Vec<TrailerItem>,
721    in_idx: usize,
722    mut arg: ArgItem,
723    on_idx: usize,
724    cwd: Option<&Path>,
725) {
726    let if_exists = arg.conf.if_exists;
727    if matches!(
728        if_exists,
729        TrailerIfExists::DoNothing | TrailerIfExists::Default
730    ) {
731        return;
732    }
733    apply_item_command(head, Some(in_idx), &mut arg, cwd);
734    let add = match if_exists {
735        TrailerIfExists::AddIfDifferent => check_if_different(head, in_idx, &arg, true),
736        TrailerIfExists::AddIfDifferentNeighbor => check_if_different(head, on_idx, &arg, false),
737        _ => true,
738    };
739    if !add {
740        return;
741    }
742    let where_ = arg.conf.where_;
743    let at = insert_relative_to(head, on_idx, arg.into_item(), where_);
744    if if_exists == TrailerIfExists::Replace {
745        // The replacement is placed relative to the anchor before the old trailer is removed.
746        head.remove(if at <= in_idx { in_idx + 1 } else { in_idx });
747    }
748}
749
750fn apply_arg_if_missing(head: &mut Vec<TrailerItem>, mut arg: ArgItem, cwd: Option<&Path>) {
751    if arg.conf.if_missing == TrailerIfMissing::DoNothing {
752        return;
753    }
754    apply_item_command(head, None, &mut arg, cwd);
755    let at = if after_or_end(arg.conf.where_) {
756        head.len()
757    } else {
758        0
759    };
760    head.insert(at, arg.into_item());
761}
762
763/// Returns `None` if `arg` was applied to an existing trailer, `Some(arg)` if no match.
764fn find_same_and_apply_arg(
765    head: &mut Vec<TrailerItem>,
766    arg: ArgItem,
767    cwd: Option<&Path>,
768) -> Option<ArgItem> {
769    let where_ = arg.conf.where_;
770    let backwards = after_or_end(where_);
771    let same = |item: &TrailerItem| same_token(item.token.as_deref(), &arg.token);
772    let found = if backwards {
773        head.iter().rposition(same)
774    } else {
775        head.iter().position(same)
776    };
777    let Some(idx) = found else {
778        return Some(arg);
779    };
780    let on_idx = match where_ {
781        TrailerWhere::After | TrailerWhere::Before => idx,
782        _ if backwards => head.len() - 1,
783        _ => 0,
784    };
785    apply_arg_if_exists(head, idx, arg, on_idx, cwd);
786    None
787}
788
789fn merge_arg_conf(mut conf: ConfInfo, new_arg: &NewTrailerArg) -> ConfInfo {
790    if new_arg.where_ != TrailerWhere::Default {
791        conf.where_ = new_arg.where_;
792    }
793    if new_arg.if_exists != TrailerIfExists::Default {
794        conf.if_exists = new_arg.if_exists;
795    }
796    if new_arg.if_missing != TrailerIfMissing::Default {
797        conf.if_missing = new_arg.if_missing;
798    }
799    conf
800}
801
802fn parse_trailers_from_config(conf_list: &[ConfInfo]) -> Vec<ArgItem> {
803    conf_list
804        .iter()
805        .filter(|item| item.command.is_some())
806        .map(|item| ArgItem {
807            token: token_from_item(item, &item.name),
808            value: String::new(),
809            conf: item.clone(),
810        })
811        .collect()
812}
813
814fn parse_command_line_trailers(
815    new_args: &[NewTrailerArg],
816    default_conf: &ConfInfo,
817    conf_list: &[ConfInfo],
818    separators: &str,
819) -> Vec<ArgItem> {
820    let cl_separators = format!("={separators}");
821    new_args
822        .iter()
823        .filter(|nt| find_separator(&nt.text, &cl_separators) != Some(0))
824        .map(|nt| {
825            let (token, value, picked) = parse_trailer(&nt.text, &cl_separators, conf_list);
826            let base = picked
827                .filter(|item| !item.name.is_empty())
828                .unwrap_or(default_conf);
829            ArgItem {
830                token,
831                value,
832                conf: merge_arg_conf(base.clone(), nt),
833            }
834        })
835        .collect()
836}
837
838fn process_trailers_lists(head: &mut Vec<TrailerItem>, args: Vec<ArgItem>, cwd: Option<&Path>) {
839    for arg_tok in args {
840        if let Some(a) = find_same_and_apply_arg(head, arg_tok, cwd) {
841            apply_arg_if_missing(head, a, cwd);
842        }
843    }
844}
845
846fn format_trailers_list(
847    items: &[TrailerItem],
848    opts: &ProcessTrailerOptions,
849    separators: &str,
850    out: &mut String,
851) {
852    for item in items {
853        match &item.token {
854            None if opts.only_trailers => continue,
855            None => {}
856            Some(_) if opts.trim_empty && item.value.is_empty() => continue,
857            Some(tok) => {
858                out.push_str(tok);
859                if last_non_space_char(tok).is_none_or(|ch| !separators.contains(ch)) {
860                    out.push(separators.chars().next().unwrap_or(':'));
861                    out.push(' ');
862                }
863            }
864        }
865        out.push_str(&item.value);
866        out.push('\n');
867    }
868}
869
870/// Process a commit message: parse trailer block, apply config and `--trailer` args, emit result.
871///
872/// `git_dir` selects which repository config to load (with standard cascade). When `None`, only
873/// non-repo config layers are used (matches `git interpret-trailers` outside a repo).
874pub fn process_trailers(
875    input: &str,
876    opts: &ProcessTrailerOptions,
877    new_trailer_args: &[NewTrailerArg],
878    git_dir: Option<&Path>,
879) -> String {
880    let cfg = ConfigSet::load(&crate::environment::Environment::empty(), git_dir, true)
881        .unwrap_or_default();
882    let comment_prefix = comment_line_prefix(&cfg);
883    let (default_conf, conf_list, separators) = load_trailer_config(&cfg);
884
885    let block = trailer_block_get(input, opts, &conf_list, &separators, &comment_prefix);
886    let mut head =
887        parse_trailers_from_input(&block, opts, &conf_list, &separators, &comment_prefix);
888
889    if !opts.only_input {
890        let mut arg_queue = parse_trailers_from_config(&conf_list);
891        arg_queue.extend(parse_command_line_trailers(
892            new_trailer_args,
893            &default_conf,
894            &conf_list,
895            &separators,
896        ));
897        let cwd = Some(crate::environment::Environment::empty().cwd.clone());
898        process_trailers_lists(&mut head, arg_queue, cwd.as_deref());
899    }
900
901    let mut out = String::new();
902    if !opts.only_trailers {
903        out.push_str(&input[..block.start]);
904        if !block.blank_line_before {
905            out.push('\n');
906        }
907    }
908    format_trailers_list(&head, opts, &separators, &mut out);
909    if !opts.only_trailers {
910        out.push_str(&input[block.end..]);
911    }
912    out
913}
914
915/// Complete stdin/file input with a trailing newline when missing (Git `strbuf_complete_line`).
916pub fn complete_line(s: &str) -> String {
917    if s.ends_with('\n') {
918        s.to_string()
919    } else {
920        format!("{s}\n")
921    }
922}
923
924#[cfg(test)]
925mod line_coverage_tests {
926    use super::*;
927    use tempfile::tempdir;
928
929    #[cfg_attr(
930        not(feature = "full-tests"),
931        ignore = "starts system git or is heavy; run with --features full-tests"
932    )]
933    #[test]
934    fn last_line_start_finds_the_line_ending_at_len() {
935        let buf = "ab\ncd\n\né\n";
936        assert_eq!(last_line_start(buf, 0), None);
937        assert_eq!(last_line_start(buf, 1), Some(0));
938        assert_eq!(last_line_start(buf, 3), Some(0));
939        assert_eq!(last_line_start(buf, 6), Some(3));
940        assert_eq!(last_line_start(buf, 7), Some(6));
941        assert_eq!(last_line_start(buf, buf.len()), Some(7));
942        assert_eq!(last_line_start("é", "é".len()), Some(0));
943    }
944
945    #[test]
946    fn trailer_enum_parsers_and_process_trailers_matrix() {
947        assert_eq!(trailer_where_from_str("after"), Some(TrailerWhere::After));
948        assert_eq!(
949            trailer_if_exists_from_str("addIfDifferent"),
950            Some(TrailerIfExists::AddIfDifferent)
951        );
952        assert_eq!(
953            trailer_if_missing_from_str("doNothing"),
954            Some(TrailerIfMissing::DoNothing)
955        );
956        assert_eq!(complete_line("x"), "x\n");
957        assert_eq!(complete_line("x\n"), "x\n");
958
959        let dir = tempdir().expect("tempdir");
960        std::process::Command::new("git")
961            .current_dir(dir.path())
962            .args(["init", "-q", "-b", "main", "."])
963            .status()
964            .expect("init");
965        let git_dir = dir.path().join(".git");
966        let input = "Subject\n\nSigned-off-by: A <a@example.com>\n";
967        for only_input in [true, false] {
968            for only_trailers in [true, false] {
969                let opts = ProcessTrailerOptions {
970                    trim_empty: true,
971                    only_trailers,
972                    only_input,
973                    unfold: true,
974                    no_divider: false,
975                };
976                let _ = process_trailers(input, &opts, &[], Some(git_dir.as_path()));
977                let arg = NewTrailerArg {
978                    text: "Reviewed-by: B".into(),
979                    where_: TrailerWhere::End,
980                    if_exists: TrailerIfExists::Add,
981                    if_missing: TrailerIfMissing::Add,
982                };
983                let _ = process_trailers(input, &opts, &[arg], Some(git_dir.as_path()));
984            }
985        }
986    }
987}