1use crate::config::ConfigSet;
7
8const CHERRY_PICKED_PREFIX: &str = "(cherry picked from commit ";
9const SIGN_OFF_HEADER: &str = "Signed-off-by: ";
10
11static GIT_GENERATED_PREFIXES: &[&str] = &["Signed-off-by: ", "(cherry picked from commit "];
12
13const RESERVED_TRAILER_SUBSECTIONS: &[&str] = &["where", "ifexists", "ifmissing", "separators"];
14
15#[derive(Debug, Clone)]
17struct TrailerRule {
18 name: String,
20 key: Option<String>,
22}
23
24fn load_trailer_rules(config: &ConfigSet) -> Vec<TrailerRule> {
25 let mut rules: std::collections::BTreeMap<String, TrailerRule> =
26 std::collections::BTreeMap::new();
27 for e in config.entries() {
28 if !e.key.starts_with("trailer.") {
29 continue;
30 }
31 let parts: Vec<&str> = e.key.split('.').collect();
32 if parts.len() < 3 || parts[0] != "trailer" {
33 continue;
34 }
35 let subsection = parts[1];
36 if RESERVED_TRAILER_SUBSECTIONS.contains(&subsection) {
37 continue;
38 }
39 let rule = rules
40 .entry(subsection.to_string())
41 .or_insert_with(|| TrailerRule {
42 name: subsection.to_string(),
43 key: None,
44 });
45 if parts.len() >= 3 && parts[2] == "key" {
46 if let Some(v) = &e.value {
47 rule.key = Some(v.clone());
48 }
49 }
50 }
51 rules.into_values().collect()
52}
53
54fn next_line_start(buf: &[u8], pos: usize) -> usize {
55 if pos >= buf.len() {
56 return buf.len();
57 }
58 match buf[pos..].iter().position(|&b| b == b'\n') {
59 Some(p) => pos + p + 1,
60 None => buf.len(),
61 }
62}
63
64fn last_line_start(buf: &[u8], len: usize) -> Option<usize> {
65 if len == 0 {
66 return None;
67 }
68 if len == 1 {
69 return Some(0);
70 }
71 let mut i = len - 2;
72 loop {
73 if buf[i] == b'\n' {
74 return Some(i + 1);
75 }
76 if i == 0 {
77 return Some(0);
78 }
79 i -= 1;
80 }
81}
82
83fn last_line_start_bounded(buf: &[u8], len: usize) -> usize {
85 if len == 0 {
86 return 0;
87 }
88 if len == 1 {
89 return 0;
90 }
91 let mut i = len - 2;
92 loop {
93 if buf[i] == b'\n' {
94 return i + 1;
95 }
96 if i == 0 {
97 return 0;
98 }
99 i -= 1;
100 }
101}
102
103fn is_blank_line_bytes(line: &[u8]) -> bool {
104 line.iter()
105 .copied()
106 .take_while(|&b| b != b'\n')
107 .all(|b| b.is_ascii_whitespace())
108}
109
110fn find_separator_colon(line: &[u8]) -> Option<usize> {
112 let mut whitespace_found = false;
113 for (i, &c) in line.iter().enumerate() {
114 if c == b':' {
115 return Some(i);
116 }
117 if !whitespace_found && (c.is_ascii_alphanumeric() || c == b'-') {
118 continue;
119 }
120 if i != 0 && (c == b' ' || c == b'\t') {
121 whitespace_found = true;
122 continue;
123 }
124 break;
125 }
126 None
127}
128
129fn token_len_without_separator(token: &[u8]) -> usize {
130 let mut len = token.len();
131 while len > 0 && !token[len - 1].is_ascii_alphanumeric() {
132 len -= 1;
133 }
134 len
135}
136
137fn line_bytes_starts_with_git_generated(line: &[u8]) -> bool {
138 let line_one_line = line.split(|&b| b == b'\n').next().unwrap_or(line);
139 for p in GIT_GENERATED_PREFIXES {
140 let pb = p.as_bytes();
141 if line_one_line.len() >= pb.len() && &line_one_line[..pb.len()] == pb {
142 return true;
143 }
144 }
145 false
146}
147
148fn last_line_looks_like_trailer(buf: &[u8], rules: &[TrailerRule]) -> bool {
152 if buf.is_empty() {
153 return false;
154 }
155 let bol = last_line_start_bounded(buf, buf.len());
156 let last = &buf[bol..];
157 let mut trim_end = last.len();
158 while trim_end > 0 && matches!(last[trim_end - 1], b' ' | b'\t' | b'\r') {
159 trim_end -= 1;
160 }
161 let t = &last[..trim_end];
162 if t.is_empty() {
163 return false;
164 }
165 if line_bytes_starts_with_git_generated(t) {
166 return true;
167 }
168 if let Some(sep) = find_separator_colon(t) {
169 if sep >= 1 && !t[0].is_ascii_whitespace() {
170 return token_matches_rule(&t[..sep], rules);
171 }
172 }
173 false
174}
175
176fn token_matches_rule(token: &[u8], rules: &[TrailerRule]) -> bool {
177 let tlen = token_len_without_separator(token);
178 let token = &token[..tlen];
179 let Ok(tok_str) = std::str::from_utf8(token) else {
180 return false;
181 };
182 for r in rules {
183 if r.name.eq_ignore_ascii_case(tok_str) {
184 return true;
185 }
186 if r.key
187 .as_ref()
188 .is_some_and(|k| k.eq_ignore_ascii_case(tok_str))
189 {
190 return true;
191 }
192 }
193 false
194}
195
196fn find_end_of_log_message(input: &[u8]) -> usize {
197 input.len()
198}
199
200fn find_trailer_block_start(buf: &[u8], len: usize, rules: &[TrailerRule]) -> usize {
202 let mut end_of_title = 0usize;
206 let mut pos = 0usize;
207 while pos < len {
208 let line_end = next_line_start(buf, pos);
209 let line = &buf[pos..line_end.min(len)];
210 if line.first().is_some_and(|b| *b == b'#') {
211 pos = line_end;
212 continue;
213 }
214 if is_blank_line_bytes(line) {
215 end_of_title = line_end;
216 break;
217 }
218 pos = line_end;
219 }
220
221 let mut only_spaces = true;
222 let mut recognized_prefix = false;
223 let mut trailer_lines = 0i32;
224 let mut non_trailer_lines = 0i32;
225 let mut possible_continuation_lines = 0i32;
226
227 let mut l = match last_line_start(buf, len) {
228 Some(s) => s,
229 None => return len,
230 };
231
232 loop {
233 if l < end_of_title {
234 if !only_spaces {
239 non_trailer_lines += possible_continuation_lines;
240 if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
241 return end_of_title;
242 }
243 if trailer_lines > 0 && non_trailer_lines == 0 {
244 return end_of_title;
245 }
246 }
247 break;
248 }
249 let line_end = next_line_start(buf, l).min(len);
250 let line = &buf[l..line_end];
251
252 if line.first().is_some_and(|b| *b == b'#') {
253 non_trailer_lines += possible_continuation_lines;
254 possible_continuation_lines = 0;
255 l = match last_line_start(buf, l) {
256 Some(s) => s,
257 None => break,
258 };
259 continue;
260 }
261
262 if is_blank_line_bytes(line) {
263 if only_spaces {
264 l = match last_line_start(buf, l) {
265 Some(s) => s,
266 None => break,
267 };
268 continue;
269 }
270 non_trailer_lines += possible_continuation_lines;
271 if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
272 return next_line_start(buf, l);
273 }
274 if trailer_lines > 0 && non_trailer_lines == 0 {
275 return next_line_start(buf, l);
276 }
277 return len;
278 }
279
280 only_spaces = false;
281
282 if line_bytes_starts_with_git_generated(line) {
283 trailer_lines += 1;
284 possible_continuation_lines = 0;
285 recognized_prefix = true;
286 l = match last_line_start(buf, l) {
287 Some(s) => s,
288 None => break,
289 };
290 continue;
291 }
292
293 if let Some(sep_pos) = find_separator_colon(line) {
294 if sep_pos >= 1 && !line.first().is_some_and(|b| b.is_ascii_whitespace()) {
295 trailer_lines += 1;
296 possible_continuation_lines = 0;
297 if !recognized_prefix && token_matches_rule(&line[..sep_pos], rules) {
298 recognized_prefix = true;
299 }
300 l = match last_line_start(buf, l) {
301 Some(s) => s,
302 None => break,
303 };
304 continue;
305 }
306 }
307
308 if line.first().is_some_and(|b| b.is_ascii_whitespace()) {
309 possible_continuation_lines += 1;
310 } else {
311 non_trailer_lines += 1;
312 non_trailer_lines += possible_continuation_lines;
313 possible_continuation_lines = 0;
314 }
315
316 l = match last_line_start(buf, l) {
317 Some(s) => s,
318 None => break,
319 };
320 }
321
322 len
323}
324
325fn trailer_raw_lines<'a>(msg: &'a str, rules: &[TrailerRule]) -> Vec<&'a str> {
327 let bytes = msg.as_bytes();
328 let end = find_end_of_log_message(bytes);
329 let start = find_trailer_block_start(bytes, end, rules);
330 if start >= end {
331 return Vec::new();
332 }
333 let slice = msg.get(start..end).unwrap_or("");
334 slice.lines().collect()
335}
336
337fn has_conforming_footer_with_sob(msg: &str, sob_line: Option<&str>, rules: &[TrailerRule]) -> u8 {
340 let lines = trailer_raw_lines(msg, rules);
341 if lines.is_empty() {
342 return 0;
343 }
344 let Some(sob) = sob_line else {
345 return 1;
346 };
347 let sob_prefix = sob.strip_suffix('\n').unwrap_or(sob);
348 let mut found_sob = 0usize;
349 for (idx, raw) in lines.iter().enumerate() {
350 let raw_trim = raw.strip_suffix('\r').unwrap_or(raw);
351 if raw_trim
353 .as_bytes()
354 .get(..sob_prefix.len())
355 .is_some_and(|head| head == sob_prefix.as_bytes())
356 {
357 found_sob = idx + 1;
358 }
359 }
360 let n = lines.len();
361 if found_sob == 0 {
362 return 1;
363 }
364 if found_sob == n {
365 return 3;
366 }
367 2
368}
369
370fn has_conforming_footer_any(msg: &str, rules: &[TrailerRule]) -> bool {
372 !trailer_raw_lines(msg, rules).is_empty()
373}
374
375fn strbuf_complete_line(s: &mut String) {
376 if !s.is_empty() && !s.ends_with('\n') {
377 s.push('\n');
378 }
379}
380
381pub fn append_cherry_picked_from_line(msg: &mut String, full_hex: &str, config: &ConfigSet) {
383 let rules = load_trailer_rules(config);
384 strbuf_complete_line(msg);
385 let body_wo_final_blank_lines = msg.trim_end_matches('\n');
386 let has_footer = has_conforming_footer_any(msg, &rules)
387 || last_line_looks_like_trailer(body_wo_final_blank_lines.as_bytes(), &rules);
388 if !has_footer {
389 msg.push('\n');
390 }
391 msg.push_str(CHERRY_PICKED_PREFIX);
392 msg.push_str(full_hex);
393 msg.push_str(")\n");
394}
395
396pub fn append_signoff_trailer(msg: &mut String, sob_line: &str, config: &ConfigSet) {
398 append_signoff_trailer_with_dedup(msg, sob_line, config, false);
399}
400
401pub fn append_signoff_trailer_with_dedup(
405 msg: &mut String,
406 sob_line: &str,
407 config: &ConfigSet,
408 dedup: bool,
409) {
410 let rules = load_trailer_rules(config);
411 let ignore_footer = 0usize;
412 strbuf_complete_line(msg);
413
414 let footer_kind = has_conforming_footer_with_sob(msg, Some(sob_line), &rules);
415
416 let sob_prefix = sob_line.strip_suffix('\n').unwrap_or(sob_line);
417 let msg_core_len = msg.len().saturating_sub(ignore_footer);
418 let has_footer = if msg_core_len == sob_line.len()
420 && msg.get(..sob_line.len()).is_some_and(|p| p == sob_line)
421 {
422 3u8
423 } else {
424 footer_kind
425 };
426
427 if has_footer == 0 {
428 let body_scan = msg.trim_end_matches('\n');
429 let trailer_tail = last_line_looks_like_trailer(body_scan.as_bytes(), &rules);
430 if !trailer_tail {
431 let len = msg.len().saturating_sub(ignore_footer);
432 let append_newlines: Option<&'static str> = if len == 0 {
433 Some("\n\n")
434 } else if len == 1
435 || msg
436 .as_bytes()
437 .get(len - 2)
438 .copied()
439 .is_some_and(|b| b != b'\n')
440 {
441 Some("\n")
442 } else {
443 None
444 };
445 if let Some(nl) = append_newlines {
446 let insert_at = msg.len() - ignore_footer;
447 msg.insert_str(insert_at, nl);
448 }
449 }
450 }
451
452 let no_dup_sob = dedup;
453 if has_footer != 3 && (!no_dup_sob || has_footer != 2) {
454 let insert_at = msg.len() - ignore_footer;
455 msg.insert_str(insert_at, sob_prefix);
456 msg.push('\n');
457 }
458}
459
460#[must_use]
465pub fn message_ends_with_trailer(msg: &str, config: &ConfigSet) -> bool {
466 let rules = load_trailer_rules(config);
467 let body_scan = msg.trim_end_matches('\n');
468 last_line_looks_like_trailer(body_scan.as_bytes(), &rules)
469}
470
471pub fn format_signoff_line(name: &str, email: &str) -> String {
473 format!("{SIGN_OFF_HEADER}{name} <{email}>\n")
474}
475
476#[derive(Debug, Clone)]
479pub struct TrailerOpts {
480 pub only_trailers: bool,
481 pub unfold: bool,
482 pub keyonly: bool,
483 pub valueonly: bool,
484 pub separator: String,
485 pub key_value_separator: String,
486 pub filter_keys: Vec<String>,
487}
488
489impl Default for TrailerOpts {
490 fn default() -> Self {
491 TrailerOpts {
492 only_trailers: false,
493 unfold: false,
494 keyonly: false,
495 valueonly: false,
496 separator: "\n".to_owned(),
497 key_value_separator: ": ".to_owned(),
498 filter_keys: Vec::new(),
499 }
500 }
501}
502
503struct ParsedTrailer {
506 key: String,
507 value: String,
508 is_non_trailer: bool,
510}
511
512fn parse_trailer_block(msg: &str) -> Vec<ParsedTrailer> {
515 let rules: Vec<TrailerRule> = Vec::new();
516 let bytes = msg.as_bytes();
517 let end = find_end_of_log_message(bytes);
518 let start = find_trailer_block_start(bytes, end, &rules);
519 if start >= end {
520 return Vec::new();
521 }
522 let block = match msg.get(start..end) {
523 Some(b) => b,
524 None => return Vec::new(),
525 };
526 let mut logical: Vec<String> = Vec::new();
529 for raw in block.split_inclusive('\n') {
530 let line = raw;
531 let first = line.as_bytes().first().copied();
533 if first == Some(b'#') {
534 continue;
535 }
536 let is_continuation = matches!(first, Some(b' ') | Some(b'\t'));
537 if is_continuation && !logical.is_empty() {
538 if let Some(last) = logical.last_mut() {
539 last.push_str(line);
540 }
541 } else {
542 logical.push(line.to_owned());
543 }
544 }
545 let mut out = Vec::new();
546 for entry in logical {
547 let trimmed = entry.strip_suffix('\n').unwrap_or(&entry);
549 if trimmed.is_empty() {
550 continue;
551 }
552 if let Some(sep) = find_separator_colon(trimmed.as_bytes()) {
553 if sep >= 1 && !trimmed.as_bytes()[0].is_ascii_whitespace() {
554 let key = trimmed[..sep].to_owned();
555 let mut value = trimmed[sep + 1..].to_owned();
557 if let Some(stripped) = value.strip_prefix(' ') {
558 value = stripped.to_owned();
559 }
560 out.push(ParsedTrailer {
561 key,
562 value,
563 is_non_trailer: false,
564 });
565 continue;
566 }
567 }
568 out.push(ParsedTrailer {
569 key: String::new(),
570 value: trimmed.to_owned(),
571 is_non_trailer: true,
572 });
573 }
574 out
575}
576
577fn unfold_value(s: &str) -> String {
578 let mut out = String::with_capacity(s.len());
581 let bytes = s.as_bytes();
582 let mut i = 0;
583 while i < bytes.len() {
584 let b = bytes[i];
585 if b == b'\n' {
586 i += 1;
588 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
589 i += 1;
590 }
591 if !out.is_empty() && i < bytes.len() {
592 out.push(' ');
593 }
594 continue;
595 }
596 out.push(b as char);
597 i += 1;
598 }
599 out
600}
601
602pub fn format_trailers(msg: &str, opts: &TrailerOpts) -> String {
605 let trailers = parse_trailer_block(msg);
606 let mut items: Vec<String> = Vec::new();
607 let effective_only = opts.only_trailers;
610 for t in &trailers {
611 if t.is_non_trailer {
612 if effective_only {
615 continue;
616 }
617 items.push(unfold_or_plain(&t.value, opts.unfold));
618 continue;
619 }
620 if !opts.filter_keys.is_empty() {
621 let matches = opts
622 .filter_keys
623 .iter()
624 .any(|k| k.eq_ignore_ascii_case(&t.key));
625 if !matches {
626 continue;
627 }
628 }
629 let value = unfold_or_plain(&t.value, opts.unfold);
630 let formatted = if opts.keyonly && opts.valueonly {
631 String::new()
632 } else if opts.keyonly {
633 t.key.clone()
634 } else if opts.valueonly {
635 value
636 } else {
637 format!("{}{}{}", t.key, opts.key_value_separator, value)
638 };
639 items.push(formatted);
640 }
641 if items.is_empty() {
642 return String::new();
643 }
644 let mut out = String::new();
647 for (i, item) in items.iter().enumerate() {
648 if i > 0 {
649 out.push_str(&opts.separator);
650 }
651 out.push_str(item);
652 }
653 if opts.separator == "\n" {
655 out.push('\n');
656 }
657 out
658}
659
660fn unfold_or_plain(value: &str, unfold: bool) -> String {
661 if unfold {
662 unfold_value(value)
663 } else {
664 value.to_owned()
665 }
666}
667
668pub fn finalize_cherry_pick_message(
670 original_message: &str,
671 append_source: bool,
672 signoff: bool,
673 committer_name: &str,
674 committer_email: &str,
675 config: &ConfigSet,
676 picked_commit_hex: &str,
677) -> String {
678 let mut msg = original_message.to_owned();
679
680 let explicit_cleanup = config.get("commit.cleanup").is_some();
681 let cleanup_space = append_source && !explicit_cleanup;
682 let cleanup_strip_comments =
683 explicit_cleanup && matches!(config.get("commit.cleanup").as_deref(), Some("strip"));
684
685 if cleanup_space {
686 let processed =
687 crate::stripspace::process(msg.as_bytes(), &crate::stripspace::Mode::Default);
688 let cleaned = String::from_utf8_lossy(&processed);
689 msg = cleaned.into_owned();
690 } else if cleanup_strip_comments {
691 let processed = crate::stripspace::process(
692 msg.as_bytes(),
693 &crate::stripspace::Mode::StripComments("#".to_owned()),
694 );
695 let cleaned = String::from_utf8_lossy(&processed);
696 msg = cleaned.into_owned();
697 }
698
699 if append_source {
700 append_cherry_picked_from_line(&mut msg, picked_commit_hex, config);
701 }
702
703 if signoff {
704 let sob = format_signoff_line(committer_name, committer_email);
705 append_signoff_trailer(&mut msg, &sob, config);
706 }
707
708 msg
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 #[test]
716 fn cherry_pick_x_one_line_subject_inserts_blank_before_trailer() {
717 let config = ConfigSet::new();
718 let mut msg = "base: commit message".to_owned();
719 append_cherry_picked_from_line(&mut msg, "abcd".repeat(10).as_str(), &config);
720 assert!(msg.contains("\n\n(cherry picked from commit "));
721 }
722
723 #[test]
724 fn signoff_after_non_conforming_footer_inserts_blank_paragraph() {
725 let config = ConfigSet::new();
726 let body = "base: commit message\n\nOneWordBodyThatsNotA-S-o-B";
727 let mut msg = body.to_owned();
728 let sob = format_signoff_line("C O Mitter", "committer@example.com");
729 append_signoff_trailer(&mut msg, &sob, &config);
730 assert!(msg.contains("OneWordBodyThatsNotA-S-o-B\n\nSigned-off-by:"));
731 }
732
733 #[test]
734 fn cherry_pick_x_after_sob_without_final_newline_no_extra_blank_before_cherry_line() {
735 let config = ConfigSet::new();
736 let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
737 append_cherry_picked_from_line(&mut msg, "d".repeat(40).as_str(), &config);
738 assert!(msg.ends_with(")\n"));
739 assert!(
740 msg.contains("Signed-off-by: A <a@example.com>\n(cherry picked from commit "),
741 "unexpected spacing: {msg:?}"
742 );
743 }
744
745 #[test]
746 fn signoff_after_other_sob_without_final_newline_single_separator() {
747 let config = ConfigSet::new();
748 let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
749 let sob = format_signoff_line("C O Mitter", "committer@example.com");
750 append_signoff_trailer(&mut msg, &sob, &config);
751 assert!(
752 msg.contains("Signed-off-by: A <a@example.com>\nSigned-off-by: C O Mitter"),
753 "unexpected spacing: {msg:?}"
754 );
755 }
756}