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