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