1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum TrailerWhere {
19 #[default]
20 Default,
22 End,
24 After,
26 Before,
28 Start,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum TrailerIfExists {
35 #[default]
36 Default,
38 AddIfDifferentNeighbor,
40 AddIfDifferent,
42 Add,
44 Replace,
46 DoNothing,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum TrailerIfMissing {
53 #[default]
54 Default,
56 Add,
58 DoNothing,
60}
61
62#[derive(Debug, Clone, Default)]
63pub struct ProcessTrailerOptions {
65 pub trim_empty: bool,
67 pub only_trailers: bool,
69 pub only_input: bool,
71 pub unfold: bool,
73 pub no_divider: bool,
75}
76
77#[derive(Debug, Clone)]
78pub struct NewTrailerArg {
80 pub text: String,
82 pub where_: TrailerWhere,
84 pub if_exists: TrailerIfExists,
86 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
122pub fn trailer_where_from_str(s: &str) -> Option<TrailerWhere> {
124 set_where(s)
125}
126
127pub fn trailer_if_exists_from_str(s: &str) -> Option<TrailerIfExists> {
129 set_if_exists(s)
130}
131
132pub 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 } 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 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
816fn 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 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
973fn 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
1115pub 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
1160pub 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}