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 "];
13
14const RESERVED_TRAILER_SUBSECTIONS: &[&str] = &["where", "ifexists", "ifmissing", "separators"];
15
16#[derive(Debug, Clone)]
18struct TrailerRule {
19 name: String,
21 key: Option<String>,
23}
24
25fn load_trailer_rules(config: &ConfigSet) -> Vec<TrailerRule> {
26 let mut rules: std::collections::BTreeMap<String, TrailerRule> =
27 std::collections::BTreeMap::new();
28 for e in config.entries() {
29 if !e.key.starts_with("trailer.") {
30 continue;
31 }
32 let parts: Vec<&str> = e.key.split('.').collect();
33 if parts.len() < 3 || parts[0] != "trailer" {
34 continue;
35 }
36 let subsection = parts[1];
37 if RESERVED_TRAILER_SUBSECTIONS.contains(&subsection) {
38 continue;
39 }
40 let rule = rules
41 .entry(subsection.to_string())
42 .or_insert_with(|| TrailerRule {
43 name: subsection.to_string(),
44 key: None,
45 });
46 if parts.len() >= 3 && parts[2] == "key" {
47 if let Some(v) = &e.value {
48 rule.key = Some(v.clone());
49 }
50 }
51 }
52 rules.into_values().collect()
53}
54
55fn next_line_start(buf: &[u8], pos: usize) -> usize {
56 if pos >= buf.len() {
57 return buf.len();
58 }
59 match buf[pos..].iter().position(|&b| b == b'\n') {
60 Some(p) => pos + p + 1,
61 None => buf.len(),
62 }
63}
64
65fn last_line_start(buf: &[u8], len: usize) -> Option<usize> {
66 if len == 0 {
67 return None;
68 }
69 if len == 1 {
70 return Some(0);
71 }
72 let mut i = len - 2;
73 loop {
74 if buf[i] == b'\n' {
75 return Some(i + 1);
76 }
77 if i == 0 {
78 return Some(0);
79 }
80 i -= 1;
81 }
82}
83
84fn last_line_start_bounded(buf: &[u8], len: usize) -> usize {
86 if len == 0 {
87 return 0;
88 }
89 if len == 1 {
90 return 0;
91 }
92 let mut i = len - 2;
93 loop {
94 if buf[i] == b'\n' {
95 return i + 1;
96 }
97 if i == 0 {
98 return 0;
99 }
100 i -= 1;
101 }
102}
103
104fn is_blank_line_bytes(line: &[u8]) -> bool {
105 line.iter()
106 .copied()
107 .take_while(|&b| b != b'\n')
108 .all(|b| b.is_ascii_whitespace())
109}
110
111fn find_separator_colon(line: &[u8]) -> Option<usize> {
113 let mut whitespace_found = false;
114 for (i, &c) in line.iter().enumerate() {
115 if c == b':' {
116 return Some(i);
117 }
118 if !whitespace_found && (c.is_ascii_alphanumeric() || c == b'-') {
119 continue;
120 }
121 if i != 0 && (c == b' ' || c == b'\t') {
122 whitespace_found = true;
123 continue;
124 }
125 break;
126 }
127 None
128}
129
130fn token_len_without_separator(token: &[u8]) -> usize {
131 let mut len = token.len();
132 while len > 0 && !token[len - 1].is_ascii_alphanumeric() {
133 len -= 1;
134 }
135 len
136}
137
138fn line_bytes_starts_with_git_generated(line: &[u8]) -> bool {
139 let line_one_line = line.split(|&b| b == b'\n').next().unwrap_or(line);
140 for p in GIT_GENERATED_PREFIXES {
141 let pb = p.as_bytes();
142 if line_one_line.len() >= pb.len() && &line_one_line[..pb.len()] == pb {
143 return true;
144 }
145 }
146 false
147}
148
149fn last_line_looks_like_trailer(buf: &[u8], rules: &[TrailerRule]) -> bool {
153 if buf.is_empty() {
154 return false;
155 }
156 let bol = last_line_start_bounded(buf, buf.len());
157 let last = &buf[bol..];
158 let mut trim_end = last.len();
159 while trim_end > 0 && matches!(last[trim_end - 1], b' ' | b'\t' | b'\r') {
160 trim_end -= 1;
161 }
162 let t = &last[..trim_end];
163 if t.is_empty() {
164 return false;
165 }
166 if line_bytes_starts_with_git_generated(t) {
167 return true;
168 }
169 if let Some(sep) = find_separator_colon(t) {
170 if sep >= 1 && !t[0].is_ascii_whitespace() {
171 return token_matches_rule(&t[..sep], rules);
172 }
173 }
174 false
175}
176
177fn token_matches_rule(token: &[u8], rules: &[TrailerRule]) -> bool {
178 let tlen = token_len_without_separator(token);
179 let token = &token[..tlen];
180 let Ok(tok_str) = std::str::from_utf8(token) else {
181 return false;
182 };
183 for r in rules {
184 if r.name.eq_ignore_ascii_case(tok_str) {
185 return true;
186 }
187 if r.key
188 .as_ref()
189 .is_some_and(|k| k.eq_ignore_ascii_case(tok_str))
190 {
191 return true;
192 }
193 }
194 false
195}
196
197fn find_end_of_log_message(input: &[u8]) -> usize {
198 input.len()
199}
200
201fn find_trailer_block_start(buf: &[u8], len: usize, rules: &[TrailerRule]) -> usize {
203 let mut end_of_title = 0usize;
207 let mut pos = 0usize;
208 while pos < len {
209 let line_end = next_line_start(buf, pos);
210 let line = &buf[pos..line_end.min(len)];
211 if line.first().is_some_and(|b| *b == b'#') {
212 pos = line_end;
213 continue;
214 }
215 if is_blank_line_bytes(line) {
216 end_of_title = line_end;
217 break;
218 }
219 pos = line_end;
220 }
221
222 let mut only_spaces = true;
223 let mut recognized_prefix = false;
224 let mut trailer_lines = 0i32;
225 let mut non_trailer_lines = 0i32;
226 let mut possible_continuation_lines = 0i32;
227
228 let mut l = match last_line_start(buf, len) {
229 Some(s) => s,
230 None => return len,
231 };
232
233 loop {
234 if l < end_of_title {
235 if !only_spaces {
240 non_trailer_lines += possible_continuation_lines;
241 if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
242 return end_of_title;
243 }
244 if trailer_lines > 0 && non_trailer_lines == 0 {
245 return end_of_title;
246 }
247 }
248 break;
249 }
250 let line_end = next_line_start(buf, l).min(len);
251 let line = &buf[l..line_end];
252
253 if line.first().is_some_and(|b| *b == b'#') {
254 non_trailer_lines += possible_continuation_lines;
255 possible_continuation_lines = 0;
256 l = match last_line_start(buf, l) {
257 Some(s) => s,
258 None => break,
259 };
260 continue;
261 }
262
263 if is_blank_line_bytes(line) {
264 if only_spaces {
265 l = match last_line_start(buf, l) {
266 Some(s) => s,
267 None => break,
268 };
269 continue;
270 }
271 non_trailer_lines += possible_continuation_lines;
272 if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
273 return next_line_start(buf, l);
274 }
275 if trailer_lines > 0 && non_trailer_lines == 0 {
276 return next_line_start(buf, l);
277 }
278 return len;
279 }
280
281 only_spaces = false;
282
283 if line_bytes_starts_with_git_generated(line) {
284 trailer_lines += 1;
285 possible_continuation_lines = 0;
286 recognized_prefix = true;
287 l = match last_line_start(buf, l) {
288 Some(s) => s,
289 None => break,
290 };
291 continue;
292 }
293
294 if let Some(sep_pos) = find_separator_colon(line) {
295 if sep_pos >= 1 && !line.first().is_some_and(|b| b.is_ascii_whitespace()) {
296 trailer_lines += 1;
297 possible_continuation_lines = 0;
298 if !recognized_prefix && token_matches_rule(&line[..sep_pos], rules) {
299 recognized_prefix = true;
300 }
301 l = match last_line_start(buf, l) {
302 Some(s) => s,
303 None => break,
304 };
305 continue;
306 }
307 }
308
309 if line.first().is_some_and(|b| b.is_ascii_whitespace()) {
310 possible_continuation_lines += 1;
311 } else {
312 non_trailer_lines += 1;
313 non_trailer_lines += possible_continuation_lines;
314 possible_continuation_lines = 0;
315 }
316
317 l = match last_line_start(buf, l) {
318 Some(s) => s,
319 None => break,
320 };
321 }
322
323 len
324}
325
326fn trailer_raw_lines<'a>(msg: &'a str, rules: &[TrailerRule]) -> Vec<&'a str> {
328 let bytes = msg.as_bytes();
329 let end = find_end_of_log_message(bytes);
330 let start = find_trailer_block_start(bytes, end, rules);
331 if start >= end {
332 return Vec::new();
333 }
334 let slice = msg.get(start..end).unwrap_or("");
335 slice.lines().collect()
336}
337
338fn has_conforming_footer_with_sob(msg: &str, sob_line: Option<&str>, rules: &[TrailerRule]) -> u8 {
341 let lines = trailer_raw_lines(msg, rules);
342 if lines.is_empty() {
343 return 0;
344 }
345 let Some(sob) = sob_line else {
346 return 1;
347 };
348 let sob_prefix = sob.strip_suffix('\n').unwrap_or(sob);
349 let mut found_sob = 0usize;
350 for (idx, raw) in lines.iter().enumerate() {
351 let raw_trim = raw.strip_suffix('\r').unwrap_or(raw);
352 if raw_trim
354 .as_bytes()
355 .get(..sob_prefix.len())
356 .is_some_and(|head| head == sob_prefix.as_bytes())
357 {
358 found_sob = idx + 1;
359 }
360 }
361 let n = lines.len();
362 if found_sob == 0 {
363 return 1;
364 }
365 if found_sob == n {
366 return 3;
367 }
368 2
369}
370
371fn has_conforming_footer_any(msg: &str, rules: &[TrailerRule]) -> bool {
373 !trailer_raw_lines(msg, rules).is_empty()
374}
375
376fn strbuf_complete_line(s: &mut String) {
377 if !s.is_empty() && !s.ends_with('\n') {
378 s.push('\n');
379 }
380}
381
382pub fn append_cherry_picked_from_line(msg: &mut String, full_hex: &str, config: &ConfigSet) {
384 let rules = load_trailer_rules(config);
385 strbuf_complete_line(msg);
386 let body_wo_final_blank_lines = msg.trim_end_matches('\n');
387 let has_footer = has_conforming_footer_any(msg, &rules)
388 || last_line_looks_like_trailer(body_wo_final_blank_lines.as_bytes(), &rules);
389 if !has_footer {
390 msg.push('\n');
391 }
392 msg.push_str(CHERRY_PICKED_PREFIX);
393 msg.push_str(full_hex);
394 msg.push_str(")\n");
395}
396
397pub fn append_signoff_trailer(msg: &mut String, sob_line: &str, config: &ConfigSet) {
399 append_signoff_trailer_with_dedup(msg, sob_line, config, false);
400}
401
402pub fn append_signoff_trailer_with_dedup(
406 msg: &mut String,
407 sob_line: &str,
408 config: &ConfigSet,
409 dedup: bool,
410) {
411 let rules = load_trailer_rules(config);
412 let ignore_footer = 0usize;
413 strbuf_complete_line(msg);
414
415 let footer_kind = has_conforming_footer_with_sob(msg, Some(sob_line), &rules);
416
417 let sob_prefix = sob_line.strip_suffix('\n').unwrap_or(sob_line);
418 let msg_core_len = msg.len().saturating_sub(ignore_footer);
419 let has_footer = if msg_core_len == sob_line.len()
421 && msg.get(..sob_line.len()).is_some_and(|p| p == sob_line)
422 {
423 3u8
424 } else {
425 footer_kind
426 };
427
428 if has_footer == 0 {
429 let body_scan = msg.trim_end_matches('\n');
430 let trailer_tail = last_line_looks_like_trailer(body_scan.as_bytes(), &rules);
431 if !trailer_tail {
432 let len = msg.len().saturating_sub(ignore_footer);
433 let append_newlines: Option<&'static str> = if len == 0 {
434 Some("\n\n")
435 } else if len == 1
436 || msg
437 .as_bytes()
438 .get(len - 2)
439 .copied()
440 .is_some_and(|b| b != b'\n')
441 {
442 Some("\n")
443 } else {
444 None
445 };
446 if let Some(nl) = append_newlines {
447 let insert_at = msg.len() - ignore_footer;
448 msg.insert_str(insert_at, nl);
449 }
450 }
451 }
452
453 let no_dup_sob = dedup;
454 if has_footer != 3 && (!no_dup_sob || has_footer != 2) {
455 let insert_at = msg.len() - ignore_footer;
456 msg.insert_str(insert_at, sob_prefix);
457 msg.push('\n');
458 }
459}
460
461pub fn format_signoff_line(name: &str, email: &str) -> String {
463 format!("{SIGN_OFF_HEADER}{name} <{email}>\n")
464}
465
466pub fn finalize_cherry_pick_message(
468 original_message: &str,
469 append_source: bool,
470 signoff: bool,
471 committer_name: &str,
472 committer_email: &str,
473 config: &ConfigSet,
474 picked_commit_hex: &str,
475) -> String {
476 let mut msg = original_message.to_owned();
477
478 let explicit_cleanup = config.get("commit.cleanup").is_some();
479 let cleanup_space = append_source && !explicit_cleanup;
480 let cleanup_strip_comments =
481 explicit_cleanup && matches!(config.get("commit.cleanup").as_deref(), Some("strip"));
482
483 if cleanup_space {
484 let processed =
485 crate::stripspace::process(msg.as_bytes(), &crate::stripspace::Mode::Default);
486 let cleaned = String::from_utf8_lossy(&processed);
487 msg = cleaned.into_owned();
488 } else if cleanup_strip_comments {
489 let processed = crate::stripspace::process(
490 msg.as_bytes(),
491 &crate::stripspace::Mode::StripComments("#".to_owned()),
492 );
493 let cleaned = String::from_utf8_lossy(&processed);
494 msg = cleaned.into_owned();
495 }
496
497 if append_source {
498 append_cherry_picked_from_line(&mut msg, picked_commit_hex, config);
499 }
500
501 if signoff {
502 let sob = format_signoff_line(committer_name, committer_email);
503 append_signoff_trailer(&mut msg, &sob, config);
504 }
505
506 msg
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 #[test]
514 fn cherry_pick_x_one_line_subject_inserts_blank_before_trailer() {
515 let config = ConfigSet::new();
516 let mut msg = "base: commit message".to_owned();
517 append_cherry_picked_from_line(&mut msg, "abcd".repeat(10).as_str(), &config);
518 assert!(msg.contains("\n\n(cherry picked from commit "));
519 }
520
521 #[test]
522 fn signoff_after_non_conforming_footer_inserts_blank_paragraph() {
523 let config = ConfigSet::new();
524 let body = "base: commit message\n\nOneWordBodyThatsNotA-S-o-B";
525 let mut msg = body.to_owned();
526 let sob = format_signoff_line("C O Mitter", "committer@example.com");
527 append_signoff_trailer(&mut msg, &sob, &config);
528 assert!(msg.contains("OneWordBodyThatsNotA-S-o-B\n\nSigned-off-by:"));
529 }
530
531 #[test]
532 fn cherry_pick_x_after_sob_without_final_newline_no_extra_blank_before_cherry_line() {
533 let config = ConfigSet::new();
534 let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
535 append_cherry_picked_from_line(&mut msg, "d".repeat(40).as_str(), &config);
536 assert!(msg.ends_with(")\n"));
537 assert!(
538 msg.contains("Signed-off-by: A <a@example.com>\n(cherry picked from commit "),
539 "unexpected spacing: {msg:?}"
540 );
541 }
542
543 #[test]
544 fn signoff_after_other_sob_without_final_newline_single_separator() {
545 let config = ConfigSet::new();
546 let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
547 let sob = format_signoff_line("C O Mitter", "committer@example.com");
548 append_signoff_trailer(&mut msg, &sob, &config);
549 assert!(
550 msg.contains("Signed-off-by: A <a@example.com>\nSigned-off-by: C O Mitter"),
551 "unexpected spacing: {msg:?}"
552 );
553 }
554}