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
114#[derive(Debug)]
115struct TrailerBlock {
116    blank_line_before: bool,
117    start: usize,
118    end: usize,
119    lines: Vec<String>,
120}
121
122/// Parse `trailer.where` / `--where` values (Git spelling).
123pub fn trailer_where_from_str(s: &str) -> Option<TrailerWhere> {
124    set_where(s)
125}
126
127/// Parse `trailer.ifexists` / `--if-exists` values.
128pub fn trailer_if_exists_from_str(s: &str) -> Option<TrailerIfExists> {
129    set_if_exists(s)
130}
131
132/// Parse `trailer.ifmissing` / `--if-missing` values.
133pub fn trailer_if_missing_from_str(s: &str) -> Option<TrailerIfMissing> {
134    set_if_missing(s)
135}
136
137fn set_where(s: &str) -> Option<TrailerWhere> {
138    match s.to_ascii_lowercase().as_str() {
139        "after" => Some(TrailerWhere::After),
140        "before" => Some(TrailerWhere::Before),
141        "end" => Some(TrailerWhere::End),
142        "start" => Some(TrailerWhere::Start),
143        _ => None,
144    }
145}
146
147fn set_if_exists(s: &str) -> Option<TrailerIfExists> {
148    match s.to_ascii_lowercase().as_str() {
149        "addifdifferent" => Some(TrailerIfExists::AddIfDifferent),
150        "addifdifferentneighbor" => Some(TrailerIfExists::AddIfDifferentNeighbor),
151        "add" => Some(TrailerIfExists::Add),
152        "replace" => Some(TrailerIfExists::Replace),
153        "donothing" => Some(TrailerIfExists::DoNothing),
154        _ => None,
155    }
156}
157
158fn set_if_missing(s: &str) -> Option<TrailerIfMissing> {
159    match s.to_ascii_lowercase().as_str() {
160        "add" => Some(TrailerIfMissing::Add),
161        "donothing" => Some(TrailerIfMissing::DoNothing),
162        _ => None,
163    }
164}
165
166fn after_or_end(where_: TrailerWhere) -> bool {
167    matches!(where_, TrailerWhere::After | TrailerWhere::End)
168}
169
170fn token_len_without_separator(token: &str) -> usize {
171    let b = token.as_bytes();
172    let mut len = token.len();
173    while len > 0 && !b[len - 1].is_ascii_alphanumeric() {
174        len -= 1;
175    }
176    len
177}
178
179fn same_token(a_token: Option<&str>, b_token: &str) -> bool {
180    let Some(a) = a_token else {
181        return false;
182    };
183    let a_len = token_len_without_separator(a);
184    let b_len = token_len_without_separator(b_token);
185    let min_len = a_len.min(b_len);
186    a[..min_len].eq_ignore_ascii_case(&b_token[..min_len])
187}
188
189fn same_value(a: &TrailerItem, b_val: &str) -> bool {
190    a.value.eq_ignore_ascii_case(b_val)
191}
192
193fn same_trailer(a: &TrailerItem, b: &ArgItem) -> bool {
194    same_token(a.token.as_deref(), &b.token) && same_value(a, &b.value)
195}
196
197fn is_blank_line(s: &str) -> bool {
198    s.chars().all(|c| c.is_whitespace())
199}
200
201fn last_non_space_char(s: &str) -> Option<char> {
202    s.chars().rev().find(|c| !c.is_whitespace())
203}
204
205fn line_end(buf: &str, bol: usize, limit: usize) -> usize {
206    bol + buf[bol..limit].find('\n').unwrap_or(limit - bol)
207}
208
209fn after_line(buf: &str, bol: usize, limit: usize) -> usize {
210    let le = line_end(buf, bol, limit);
211    if le < limit {
212        le + 1
213    } else {
214        limit
215    }
216}
217
218fn last_line_start(buf: &str, len: usize) -> Option<usize> {
219    if len == 0 {
220        return None;
221    }
222    if len == 1 {
223        return Some(0);
224    }
225    let slice = &buf.as_bytes()[..len];
226    let mut i = len - 2;
227    loop {
228        if slice[i] == b'\n' {
229            return Some(i + 1);
230        }
231        if i == 0 {
232            return Some(0);
233        }
234        i -= 1;
235    }
236}
237
238fn starts_with_comment_line(line: &str, prefix: &str) -> bool {
239    line.starts_with(prefix)
240}
241
242fn wt_status_locate_end(s: &str, len: usize, comment_prefix: &str) -> usize {
243    let pattern = format!("\n{comment_prefix} {CUT_LINE}\n");
244    let head = format!("{comment_prefix} {CUT_LINE}\n");
245    if s.len() >= head.len() && s[..head.len()] == head {
246        return 0;
247    }
248    if let Some(p) = s[..len].find(&pattern) {
249        let newlen = p + 1;
250        if newlen < len {
251            return newlen;
252        }
253    }
254    len
255}
256
257fn ignored_log_message_bytes(buf: &str, len: usize, comment_prefix: &str) -> usize {
258    let cutoff = wt_status_locate_end(buf, len, comment_prefix);
259    let mut bol = 0usize;
260    let mut boc = 0usize;
261    let mut in_old_conflicts = false;
262
263    while bol < cutoff {
264        let le = line_end(buf, bol, cutoff);
265        let line = &buf[bol..le];
266
267        let is_comment = starts_with_comment_line(line, comment_prefix) || line.is_empty();
268        if is_comment {
269            if boc == 0 {
270                boc = bol;
271            }
272        } else if line.starts_with("Conflicts:") {
273            in_old_conflicts = true;
274            if boc == 0 {
275                boc = bol;
276            }
277        } else if in_old_conflicts && line.starts_with('\t') {
278            // path in conflicts block
279        } else if boc != 0 {
280            boc = 0;
281            in_old_conflicts = false;
282        }
283
284        bol = if le < cutoff { le + 1 } else { cutoff };
285    }
286
287    if boc != 0 {
288        len - boc
289    } else {
290        len - cutoff
291    }
292}
293
294fn find_end_of_log_message(input: &str, no_divider: bool, comment_prefix: &str) -> usize {
295    let mut end = input.len();
296    if !no_divider {
297        let mut pos = 0usize;
298        while pos < input.len() {
299            let rest = &input[pos..];
300            if rest.len() >= 3 && rest.as_bytes().get(0..3) == Some(b"---") {
301                let after = rest.as_bytes().get(3).copied();
302                if after.is_none() || after.is_some_and(|c| c.is_ascii_whitespace()) {
303                    end = pos;
304                    break;
305                }
306            }
307            pos = after_line(input, pos, input.len());
308            if pos >= input.len() {
309                break;
310            }
311        }
312    }
313    end - ignored_log_message_bytes(input, end, comment_prefix)
314}
315
316fn find_separator(line: &str, separators: &str) -> Option<usize> {
317    let mut whitespace_found = false;
318    for (i, c) in line.char_indices() {
319        if separators.contains(c) {
320            return Some(i);
321        }
322        if !whitespace_found && (c.is_ascii_alphanumeric() || c == '-') {
323            continue;
324        }
325        if i > 0 && (c == ' ' || c == '\t') {
326            whitespace_found = true;
327            continue;
328        }
329        break;
330    }
331    None
332}
333
334fn token_matches_item(line: &str, item: &ConfInfo, sep_pos: usize) -> bool {
335    let tok = line[..sep_pos].trim_end();
336    let name = &item.name;
337    let name_len = name.len();
338    let tok_len = token_len_without_separator(tok);
339    if tok_len >= name_len && tok[..name_len].eq_ignore_ascii_case(name) {
340        return true;
341    }
342    if let Some(ref key) = item.key {
343        let key_len = token_len_without_separator(key);
344        if tok_len >= key_len && tok[..key_len].eq_ignore_ascii_case(&key[..key_len]) {
345            return true;
346        }
347    }
348    false
349}
350
351fn find_trailer_block_start(
352    buf: &str,
353    len: usize,
354    conf: &[ConfInfo],
355    separators: &str,
356    comment_prefix: &str,
357) -> usize {
358    let mut end_of_title = len;
359    let mut pos = 0usize;
360    while pos < len {
361        let le = line_end(buf, pos, len);
362        let line = &buf[pos..le];
363        if starts_with_comment_line(line, comment_prefix) {
364            pos = if le < len { le + 1 } else { len };
365            continue;
366        }
367        if is_blank_line(line) {
368            end_of_title = pos;
369            break;
370        }
371        pos = if le < len { le + 1 } else { len };
372    }
373
374    let mut l = last_line_start(buf, len);
375    let mut only_spaces = true;
376    let mut recognized_prefix = false;
377    let mut trailer_lines = 0i32;
378    let mut non_trailer_lines = 0i32;
379    let mut possible_continuation = 0i32;
380
381    while let Some(bol) = l {
382        if bol < end_of_title {
383            break;
384        }
385        let le = line_end(buf, bol, len);
386        let line = &buf[bol..le];
387
388        if starts_with_comment_line(line, comment_prefix) {
389            non_trailer_lines += possible_continuation;
390            possible_continuation = 0;
391            l = if bol == 0 {
392                None
393            } else {
394                last_line_start(buf, bol)
395            };
396            continue;
397        }
398
399        if is_blank_line(line) {
400            if only_spaces {
401                l = if bol == 0 {
402                    None
403                } else {
404                    last_line_start(buf, bol)
405                };
406                continue;
407            }
408            non_trailer_lines += possible_continuation;
409            if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
410                return after_line(buf, bol, len);
411            }
412            if trailer_lines > 0 && non_trailer_lines == 0 {
413                return after_line(buf, bol, len);
414            }
415            return len;
416        }
417        only_spaces = false;
418
419        let mut matched_gen = false;
420        for p in GIT_GENERATED_PREFIXES {
421            if line.starts_with(p) {
422                trailer_lines += 1;
423                possible_continuation = 0;
424                recognized_prefix = true;
425                matched_gen = true;
426                break;
427            }
428        }
429        if matched_gen {
430            l = if bol == 0 {
431                None
432            } else {
433                last_line_start(buf, bol)
434            };
435            continue;
436        }
437
438        if let Some(sep_pos) = find_separator(line, separators) {
439            if sep_pos >= 1 && !line.starts_with(|c: char| c.is_whitespace()) {
440                trailer_lines += 1;
441                possible_continuation = 0;
442                if !recognized_prefix {
443                    for item in conf {
444                        if token_matches_item(line, item, sep_pos) {
445                            recognized_prefix = true;
446                            break;
447                        }
448                    }
449                }
450            } else if line.starts_with(|c: char| c.is_whitespace()) {
451                possible_continuation += 1;
452            } else {
453                non_trailer_lines += 1;
454                non_trailer_lines += possible_continuation;
455                possible_continuation = 0;
456            }
457        } else if line.starts_with(|c: char| c.is_whitespace()) {
458            possible_continuation += 1;
459        } else {
460            non_trailer_lines += 1;
461            non_trailer_lines += possible_continuation;
462            possible_continuation = 0;
463        }
464
465        l = if bol == 0 {
466            None
467        } else {
468            last_line_start(buf, bol)
469        };
470    }
471
472    len
473}
474
475fn ends_with_blank_line(buf: &str, trailer_block_start: usize) -> bool {
476    if trailer_block_start == 0 {
477        return false;
478    }
479    let slice = &buf[..trailer_block_start];
480    last_line_start(slice, slice.len()).is_some_and(|i| is_blank_line(&slice[i..]))
481}
482
483fn unfold_value(s: &str) -> String {
484    let mut out = String::with_capacity(s.len());
485    let mut chars = s.chars().peekable();
486    while let Some(c) = chars.next() {
487        if c == '\n' {
488            while chars.peek().is_some_and(|x| x.is_whitespace()) {
489                chars.next();
490            }
491            if !out.is_empty() && !out.ends_with(' ') {
492                out.push(' ');
493            }
494        } else {
495            out.push(c);
496        }
497    }
498    out.trim().to_string()
499}
500
501impl Default for ConfInfo {
502    fn default() -> Self {
503        Self {
504            name: String::new(),
505            key: None,
506            command: None,
507            cmd: None,
508            where_: TrailerWhere::End,
509            if_exists: TrailerIfExists::AddIfDifferentNeighbor,
510            if_missing: TrailerIfMissing::Add,
511        }
512    }
513}
514
515fn duplicate_conf(src: &ConfInfo) -> ConfInfo {
516    ConfInfo {
517        name: src.name.clone(),
518        key: src.key.clone(),
519        command: src.command.clone(),
520        cmd: src.cmd.clone(),
521        where_: src.where_,
522        if_exists: src.if_exists,
523        if_missing: src.if_missing,
524    }
525}
526
527fn token_from_item(item: &ConfInfo, tok_from_arg: Option<&str>) -> String {
528    if let Some(k) = &item.key {
529        return k.clone();
530    }
531    tok_from_arg.map_or_else(|| item.name.clone(), str::to_string)
532}
533
534fn parse_trailer_into(
535    trailer: &str,
536    separators: &str,
537    conf: &[ConfInfo],
538    apply_conf: bool,
539) -> (String, String, ConfInfo) {
540    let sep_pos = find_separator(trailer, separators);
541    let (mut tok, val, mut picked) = if let Some(pos) = sep_pos {
542        (
543            trailer[..pos].trim().to_string(),
544            trailer[pos + 1..].trim().to_string(),
545            ConfInfo {
546                name: String::new(),
547                ..Default::default()
548            },
549        )
550    } else {
551        (
552            trailer.trim().to_string(),
553            String::new(),
554            ConfInfo {
555                name: String::new(),
556                ..Default::default()
557            },
558        )
559    };
560
561    if apply_conf {
562        let tok_len = token_len_without_separator(&tok);
563        for item in conf {
564            if tok_len >= item.name.len() && tok[..item.name.len()].eq_ignore_ascii_case(&item.name)
565            {
566                let tbuf = std::mem::take(&mut tok);
567                tok = token_from_item(item, Some(&tbuf));
568                picked = duplicate_conf(item);
569                break;
570            }
571            if let Some(ref key) = item.key {
572                let kl = token_len_without_separator(key);
573                if tok_len >= kl && tok[..kl].eq_ignore_ascii_case(&key[..kl]) {
574                    let tbuf = std::mem::take(&mut tok);
575                    tok = token_from_item(item, Some(&tbuf));
576                    picked = duplicate_conf(item);
577                    break;
578                }
579            }
580        }
581    }
582
583    (tok, val, picked)
584}
585
586fn var_ci_eq(s: &str, lit: &str) -> bool {
587    s.eq_ignore_ascii_case(lit)
588}
589
590fn comment_line_prefix(cfg: &ConfigSet) -> String {
591    match cfg.get("core.commentChar") {
592        Some(s) => {
593            let t = s.trim();
594            if t.is_empty() || t.eq_ignore_ascii_case("auto") {
595                "#".to_string()
596            } else {
597                t.chars().next().unwrap_or('#').to_string()
598            }
599        }
600        None => "#".to_string(),
601    }
602}
603
604fn load_trailer_config(cfg: &ConfigSet) -> (ConfInfo, Vec<ConfInfo>, String) {
605    // Git reads `trailer.*` globals first (`git_trailer_default_config`), then per-alias keys.
606    // Each `trailer.<alias>.*` entry inherits the current global defaults at creation time.
607    let hardcoded = ConfInfo::default();
608    let mut default_conf = duplicate_conf(&hardcoded);
609    let mut map: HashMap<String, ConfInfo> = HashMap::new();
610    let mut separators = ":".to_string();
611
612    for e in cfg.entries() {
613        let ConfigEntry { key, value, .. } = e;
614        let Some(rest) = key.strip_prefix("trailer.") else {
615            continue;
616        };
617        if rest.rsplit_once('.').is_none() {
618            if var_ci_eq(rest, "where") {
619                if let Some(v) = value.as_deref().and_then(set_where) {
620                    default_conf.where_ = v;
621                }
622            } else if var_ci_eq(rest, "ifexists") {
623                if let Some(v) = value.as_deref().and_then(set_if_exists) {
624                    default_conf.if_exists = v;
625                }
626            } else if var_ci_eq(rest, "ifmissing") {
627                if let Some(v) = value.as_deref().and_then(set_if_missing) {
628                    default_conf.if_missing = v;
629                }
630            } else if var_ci_eq(rest, "separators") {
631                if let Some(v) = value {
632                    separators = v.clone();
633                }
634            }
635        }
636    }
637
638    for e in cfg.entries() {
639        let ConfigEntry { key, value, .. } = e;
640        let Some(rest) = key.strip_prefix("trailer.") else {
641            continue;
642        };
643        let Some((name_part, var)) = rest.rsplit_once('.') else {
644            continue;
645        };
646
647        let entry = map
648            .entry(name_part.to_string())
649            .or_insert_with(|| ConfInfo {
650                name: name_part.to_string(),
651                ..duplicate_conf(&default_conf)
652            });
653
654        if var_ci_eq(var, "key") {
655            if let Some(v) = value {
656                entry.key = Some(v.clone());
657            }
658        } else if var_ci_eq(var, "command") {
659            if let Some(v) = value {
660                entry.command = Some(v.clone());
661            }
662        } else if var_ci_eq(var, "cmd") {
663            if let Some(v) = value {
664                entry.cmd = Some(v.clone());
665            }
666        } else if var_ci_eq(var, "where") {
667            if let Some(v) = value.as_deref().and_then(set_where) {
668                entry.where_ = v;
669            }
670        } else if var_ci_eq(var, "ifexists") {
671            if let Some(v) = value.as_deref().and_then(set_if_exists) {
672                entry.if_exists = v;
673            }
674        } else if var_ci_eq(var, "ifmissing") {
675            if let Some(v) = value.as_deref().and_then(set_if_missing) {
676                entry.if_missing = v;
677            }
678        }
679    }
680
681    (default_conf, map.into_values().collect(), separators)
682}
683
684fn trailer_block_get(
685    input: &str,
686    opts: &ProcessTrailerOptions,
687    conf: &[ConfInfo],
688    separators: &str,
689    comment_prefix: &str,
690) -> TrailerBlock {
691    let end_of_log = find_end_of_log_message(input, opts.no_divider, comment_prefix);
692    let trailer_start =
693        find_trailer_block_start(input, end_of_log, conf, separators, comment_prefix);
694    let slice = &input[trailer_start..end_of_log];
695    let mut lines: Vec<String> = Vec::new();
696    let mut pos = 0usize;
697    let mut last_trailer_idx: Option<usize> = None;
698
699    while pos < slice.len() {
700        let le = line_end(slice, pos, slice.len());
701        let line = slice[pos..le].to_string();
702        pos = if le < slice.len() {
703            le + 1
704        } else {
705            slice.len()
706        };
707
708        if let Some(idx) = last_trailer_idx {
709            if !line.is_empty() && line.chars().next().is_some_and(|c| c == ' ' || c == '\t') {
710                lines[idx].push('\n');
711                lines[idx].push_str(&line);
712                continue;
713            }
714        }
715
716        let is_trailer_line = find_separator(&line, separators)
717            .is_some_and(|p| p >= 1 && !line.starts_with(|c: char| c.is_whitespace()));
718        last_trailer_idx = if is_trailer_line {
719            lines.push(line);
720            Some(lines.len() - 1)
721        } else {
722            lines.push(line);
723            None
724        };
725    }
726
727    TrailerBlock {
728        blank_line_before: ends_with_blank_line(input, trailer_start),
729        start: trailer_start,
730        end: end_of_log,
731        lines,
732    }
733}
734
735fn parse_trailers_from_input(
736    block: &TrailerBlock,
737    opts: &ProcessTrailerOptions,
738    conf: &[ConfInfo],
739    separators: &str,
740    comment_prefix: &str,
741) -> Vec<TrailerItem> {
742    let mut out = Vec::new();
743    for line in &block.lines {
744        if starts_with_comment_line(line, comment_prefix) {
745            continue;
746        }
747        if let Some(sep) = find_separator(line, separators) {
748            if sep >= 1 {
749                let (tok, mut val, _) = parse_trailer_into(line, separators, conf, true);
750                if opts.unfold {
751                    val = unfold_value(&val);
752                }
753                out.push(TrailerItem {
754                    token: Some(tok),
755                    value: val,
756                });
757            }
758        } else if !opts.only_trailers {
759            out.push(TrailerItem {
760                token: None,
761                value: line.clone(),
762            });
763        }
764    }
765    out
766}
767
768fn apply_command(conf: &ConfInfo, arg: Option<&str>, cwd: Option<&Path>) -> String {
769    let arg = arg.unwrap_or("");
770    let dir = cwd.unwrap_or_else(|| Path::new("."));
771    use crate::command_runner::{
772        system_command_runner, CommandEnvironment, CommandSpec, CommandStdin, CommandStdio,
773        ShellInvocation,
774    };
775    let runner = system_command_runner();
776    let output = if let Some(cmd) = &conf.cmd {
777        let script = format!("{cmd} \"$@\"");
778        let spec = CommandSpec {
779            program: "sh".into(),
780            args: Vec::new(),
781            shell: Some(ShellInvocation::DashC {
782                script,
783                argv0: Some(cmd.clone()),
784                args: vec![std::ffi::OsString::from(arg)],
785            }),
786            cwd: Some(dir.to_path_buf()),
787            env: CommandEnvironment::inherit_process_only(),
788            stdin: CommandStdin::Null,
789            stdout: CommandStdio::Pipe,
790            stderr: CommandStdio::Inherit,
791        };
792        runner
793            .spawn(&spec)
794            .ok()
795            .and_then(|h| h.wait_with_output().ok())
796    } else if let Some(command) = &conf.command {
797        let cmd_line = command.replace(TRAILER_ARG_PLACEHOLDER, arg);
798        crate::command_runner::run_sh_dash_c(
799            runner.as_ref(),
800            &cmd_line,
801            Some(dir),
802            CommandStdin::Null,
803            CommandStdio::Inherit,
804        )
805        .ok()
806    } else {
807        return String::new();
808    };
809
810    match output {
811        Some(o) if o.status.success => String::from_utf8_lossy(&o.stdout).trim().to_string(),
812        _ => String::new(),
813    }
814}
815
816/// Git `check_if_different`: walk the trailer list in placement direction; return false if any
817/// visited line is the same trailer (token + value) as `arg`.
818fn check_if_different(
819    head: &[TrailerItem],
820    start_idx: usize,
821    arg: &ArgItem,
822    check_all: bool,
823) -> bool {
824    let where_ = arg.conf.where_;
825    let mut idx = start_idx;
826    loop {
827        if same_trailer(&head[idx], arg) {
828            return false;
829        }
830        let next = if after_or_end(where_) {
831            idx.checked_sub(1)
832        } else if idx + 1 < head.len() {
833            Some(idx + 1)
834        } else {
835            None
836        };
837        let Some(ni) = next else {
838            return true;
839        };
840        idx = ni;
841        if !check_all {
842            return true;
843        }
844    }
845}
846
847fn insert_relative_to(
848    head: &mut Vec<TrailerItem>,
849    anchor_idx: usize,
850    item: TrailerItem,
851    where_: TrailerWhere,
852) {
853    if head.is_empty() {
854        head.push(item);
855        return;
856    }
857    let anchor_idx = anchor_idx.min(head.len().saturating_sub(1));
858    let at = if after_or_end(where_) {
859        (anchor_idx + 1).min(head.len())
860    } else {
861        anchor_idx.min(head.len())
862    };
863    head.insert(at, item);
864}
865
866fn apply_item_command(
867    head: &[TrailerItem],
868    in_idx: Option<usize>,
869    arg: &mut ArgItem,
870    cwd: Option<&Path>,
871) {
872    if arg.conf.command.is_none() && arg.conf.cmd.is_none() {
873        return;
874    }
875    let in_val = in_idx.and_then(|i| head.get(i)).and_then(|t| {
876        if t.token.is_some() {
877            Some(t.value.as_str())
878        } else {
879            None
880        }
881    });
882    let arg_for_cmd = if !arg.value.is_empty() {
883        Some(arg.value.as_str())
884    } else {
885        in_val
886    };
887    arg.value = apply_command(&arg.conf, arg_for_cmd, cwd);
888}
889
890fn apply_arg_if_exists(
891    head: &mut Vec<TrailerItem>,
892    in_idx: usize,
893    mut arg: ArgItem,
894    on_idx: usize,
895    cwd: Option<&Path>,
896) {
897    let if_exists = arg.conf.if_exists;
898    match if_exists {
899        TrailerIfExists::DoNothing => {}
900        TrailerIfExists::Replace => {
901            apply_item_command(head, Some(in_idx), &mut arg, cwd);
902            let where_for_insert = arg.conf.where_;
903            let new_item = TrailerItem {
904                token: Some(arg.token),
905                value: arg.value,
906            };
907            // Git adds the new trailer next to `on_tok`, then deletes `in_tok`.
908            let on_idx = on_idx.min(head.len().saturating_sub(1));
909            let insert_pos = if after_or_end(where_for_insert) {
910                (on_idx + 1).min(head.len())
911            } else {
912                on_idx.min(head.len())
913            };
914            head.insert(insert_pos, new_item);
915            let del = if insert_pos <= in_idx {
916                in_idx + 1
917            } else {
918                in_idx
919            };
920            head.remove(del);
921        }
922        TrailerIfExists::Add => {
923            apply_item_command(head, Some(in_idx), &mut arg, cwd);
924            let new_item = TrailerItem {
925                token: Some(arg.token),
926                value: arg.value,
927            };
928            insert_relative_to(head, on_idx, new_item, arg.conf.where_);
929        }
930        TrailerIfExists::AddIfDifferent => {
931            apply_item_command(head, Some(in_idx), &mut arg, cwd);
932            if check_if_different(head, in_idx, &arg, true) {
933                let new_item = TrailerItem {
934                    token: Some(arg.token.clone()),
935                    value: arg.value.clone(),
936                };
937                insert_relative_to(head, on_idx, new_item, arg.conf.where_);
938            }
939        }
940        TrailerIfExists::AddIfDifferentNeighbor => {
941            apply_item_command(head, Some(in_idx), &mut arg, cwd);
942            if check_if_different(head, on_idx, &arg, false) {
943                let new_item = TrailerItem {
944                    token: Some(arg.token.clone()),
945                    value: arg.value.clone(),
946                };
947                insert_relative_to(head, on_idx, new_item, arg.conf.where_);
948            }
949        }
950        TrailerIfExists::Default => {}
951    }
952}
953
954fn apply_arg_if_missing(head: &mut Vec<TrailerItem>, mut arg: ArgItem, cwd: Option<&Path>) {
955    match arg.conf.if_missing {
956        TrailerIfMissing::DoNothing => {}
957        TrailerIfMissing::Add | TrailerIfMissing::Default => {
958            apply_item_command(head, None, &mut arg, cwd);
959            let where_ = arg.conf.where_;
960            let item = TrailerItem {
961                token: Some(arg.token),
962                value: arg.value,
963            };
964            if after_or_end(where_) {
965                head.push(item);
966            } else {
967                head.insert(0, item);
968            }
969        }
970    }
971}
972
973/// Returns `None` if `arg` was applied to an existing trailer, `Some(arg)` if no match.
974fn find_same_and_apply_arg(
975    head: &mut Vec<TrailerItem>,
976    arg: ArgItem,
977    cwd: Option<&Path>,
978) -> Option<ArgItem> {
979    let where_ = arg.conf.where_;
980    let middle = matches!(where_, TrailerWhere::After | TrailerWhere::Before);
981    let backwards = after_or_end(where_);
982
983    if head.is_empty() {
984        return Some(arg);
985    }
986
987    let start_idx = if backwards { head.len() - 1 } else { 0 };
988
989    let mut i: isize = if backwards {
990        head.len() as isize - 1
991    } else {
992        0
993    };
994
995    loop {
996        if i < 0 || (i as usize) >= head.len() {
997            break;
998        }
999        let idx = i as usize;
1000        if same_token(head[idx].token.as_deref(), &arg.token) {
1001            let on_idx = if middle { idx } else { start_idx };
1002            apply_arg_if_exists(head, idx, arg, on_idx, cwd);
1003            return None;
1004        }
1005        i = if backwards { i - 1 } else { i + 1 };
1006    }
1007    Some(arg)
1008}
1009
1010fn merge_arg_conf(base: &ConfInfo, new_arg: &NewTrailerArg) -> ConfInfo {
1011    let mut c = duplicate_conf(base);
1012    if new_arg.where_ != TrailerWhere::Default {
1013        c.where_ = new_arg.where_;
1014    }
1015    if new_arg.if_exists != TrailerIfExists::Default {
1016        c.if_exists = new_arg.if_exists;
1017    }
1018    if new_arg.if_missing != TrailerIfMissing::Default {
1019        c.if_missing = new_arg.if_missing;
1020    }
1021    c
1022}
1023
1024fn parse_trailers_from_config(conf_list: &[ConfInfo]) -> Vec<ArgItem> {
1025    let mut v = Vec::new();
1026    for item in conf_list {
1027        if item.command.is_some() {
1028            v.push(ArgItem {
1029                token: token_from_item(item, None),
1030                value: String::new(),
1031                conf: duplicate_conf(item),
1032            });
1033        }
1034    }
1035    v
1036}
1037
1038fn parse_command_line_trailers(
1039    new_args: &[NewTrailerArg],
1040    default_conf: &ConfInfo,
1041    conf_list: &[ConfInfo],
1042    separators: &str,
1043) -> Vec<ArgItem> {
1044    let cl_separators: String = format!("={separators}");
1045    let mut out = Vec::new();
1046    for nt in new_args {
1047        let sep = find_separator(&nt.text, &cl_separators);
1048        if sep == Some(0) {
1049            continue;
1050        }
1051        let (tok, val, picked) = parse_trailer_into(&nt.text, &cl_separators, conf_list, true);
1052        let base = if !picked.name.is_empty() {
1053            picked
1054        } else {
1055            duplicate_conf(default_conf)
1056        };
1057        let conf = merge_arg_conf(&base, nt);
1058        out.push(ArgItem {
1059            token: tok,
1060            value: val,
1061            conf,
1062        });
1063    }
1064    out
1065}
1066
1067fn process_trailers_lists(head: &mut Vec<TrailerItem>, args: Vec<ArgItem>, cwd: Option<&Path>) {
1068    for arg_tok in args {
1069        if let Some(a) = find_same_and_apply_arg(head, arg_tok, cwd) {
1070            apply_arg_if_missing(head, a, cwd);
1071        }
1072    }
1073}
1074
1075fn format_one_trailer(
1076    item: &TrailerItem,
1077    trim_empty: bool,
1078    only_trailers: bool,
1079    separators: &str,
1080    out: &mut String,
1081) {
1082    let Some(ref tok) = item.token else {
1083        if only_trailers {
1084            return;
1085        }
1086        out.push_str(&item.value);
1087        out.push('\n');
1088        return;
1089    };
1090    if trim_empty && item.value.is_empty() {
1091        return;
1092    }
1093    out.push_str(tok);
1094    let c = last_non_space_char(tok);
1095    let need_sep = c.is_none_or(|ch| !separators.contains(ch));
1096    if need_sep {
1097        out.push(separators.chars().next().unwrap_or(':'));
1098        out.push(' ');
1099    }
1100    out.push_str(&item.value);
1101    out.push('\n');
1102}
1103
1104fn format_trailers_list(
1105    items: &[TrailerItem],
1106    opts: &ProcessTrailerOptions,
1107    separators: &str,
1108    out: &mut String,
1109) {
1110    for item in items {
1111        format_one_trailer(item, opts.trim_empty, opts.only_trailers, separators, out);
1112    }
1113}
1114
1115/// Process a commit message: parse trailer block, apply config and `--trailer` args, emit result.
1116///
1117/// `git_dir` selects which repository config to load (with standard cascade). When `None`, only
1118/// non-repo config layers are used (matches `git interpret-trailers` outside a repo).
1119pub fn process_trailers(
1120    input: &str,
1121    opts: &ProcessTrailerOptions,
1122    new_trailer_args: &[NewTrailerArg],
1123    git_dir: Option<&Path>,
1124) -> String {
1125    let cfg = ConfigSet::load(&crate::environment::Environment::empty(), git_dir, true)
1126        .unwrap_or_default();
1127    let comment_prefix = comment_line_prefix(&cfg);
1128    let (default_conf, conf_list, separators) = load_trailer_config(&cfg);
1129
1130    let block = trailer_block_get(input, opts, &conf_list, &separators, &comment_prefix);
1131    let mut head =
1132        parse_trailers_from_input(&block, opts, &conf_list, &separators, &comment_prefix);
1133
1134    if !opts.only_input {
1135        let mut arg_queue = parse_trailers_from_config(&conf_list);
1136        arg_queue.extend(parse_command_line_trailers(
1137            new_trailer_args,
1138            &default_conf,
1139            &conf_list,
1140            &separators,
1141        ));
1142        let cwd = Some(crate::environment::Environment::empty().cwd.clone());
1143        process_trailers_lists(&mut head, arg_queue, cwd.as_deref());
1144    }
1145
1146    let mut out = String::new();
1147    if !opts.only_trailers {
1148        out.push_str(&input[..block.start]);
1149        if !block.blank_line_before {
1150            out.push('\n');
1151        }
1152    }
1153    format_trailers_list(&head, opts, &separators, &mut out);
1154    if !opts.only_trailers {
1155        out.push_str(&input[block.end..]);
1156    }
1157    out
1158}
1159
1160/// Complete stdin/file input with a trailing newline when missing (Git `strbuf_complete_line`).
1161pub fn complete_line(s: &str) -> String {
1162    if s.is_empty() || !s.ends_with('\n') {
1163        let mut o = s.to_string();
1164        o.push('\n');
1165        o
1166    } else {
1167        s.to_string()
1168    }
1169}
1170
1171#[cfg(test)]
1172mod line_coverage_tests {
1173    use super::*;
1174    use tempfile::tempdir;
1175
1176    #[test]
1177    fn trailer_enum_parsers_and_process_trailers_matrix() {
1178        assert_eq!(trailer_where_from_str("after"), Some(TrailerWhere::After));
1179        assert_eq!(
1180            trailer_if_exists_from_str("addIfDifferent"),
1181            Some(TrailerIfExists::AddIfDifferent)
1182        );
1183        assert_eq!(
1184            trailer_if_missing_from_str("doNothing"),
1185            Some(TrailerIfMissing::DoNothing)
1186        );
1187        assert_eq!(complete_line("x"), "x\n");
1188        assert_eq!(complete_line("x\n"), "x\n");
1189
1190        let dir = tempdir().expect("tempdir");
1191        std::process::Command::new("git")
1192            .current_dir(dir.path())
1193            .args(["init", "-q", "-b", "main", "."])
1194            .status()
1195            .expect("init");
1196        let git_dir = dir.path().join(".git");
1197        let input = "Subject\n\nSigned-off-by: A <a@example.com>\n";
1198        for only_input in [true, false] {
1199            for only_trailers in [true, false] {
1200                let opts = ProcessTrailerOptions {
1201                    trim_empty: true,
1202                    only_trailers,
1203                    only_input,
1204                    unfold: true,
1205                    no_divider: false,
1206                };
1207                let _ = process_trailers(input, &opts, &[], Some(git_dir.as_path()));
1208                let arg = NewTrailerArg {
1209                    text: "Reviewed-by: B".into(),
1210                    where_: TrailerWhere::End,
1211                    if_exists: TrailerIfExists::Add,
1212                    if_missing: TrailerIfMissing::Add,
1213                };
1214                let _ = process_trailers(input, &opts, &[arg], Some(git_dir.as_path()));
1215            }
1216        }
1217    }
1218}