1use 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#[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
96pub fn trailer_where_from_str(s: &str) -> Option<TrailerWhere> {
98 set_where(s)
99}
100
101pub fn trailer_if_exists_from_str(s: &str) -> Option<TrailerIfExists> {
103 set_if_exists(s)
104}
105
106pub 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 } 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 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 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
774fn 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 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
931fn 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
1073pub 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
1117pub 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}