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 break;
235 }
236 let line_end = next_line_start(buf, l).min(len);
237 let line = &buf[l..line_end];
238
239 if line.first().is_some_and(|b| *b == b'#') {
240 non_trailer_lines += possible_continuation_lines;
241 possible_continuation_lines = 0;
242 l = match last_line_start(buf, l) {
243 Some(s) => s,
244 None => break,
245 };
246 continue;
247 }
248
249 if is_blank_line_bytes(line) {
250 if only_spaces {
251 l = match last_line_start(buf, l) {
252 Some(s) => s,
253 None => break,
254 };
255 continue;
256 }
257 non_trailer_lines += possible_continuation_lines;
258 if recognized_prefix && trailer_lines * 3 >= non_trailer_lines {
259 return next_line_start(buf, l);
260 }
261 if trailer_lines > 0 && non_trailer_lines == 0 {
262 return next_line_start(buf, l);
263 }
264 return len;
265 }
266
267 only_spaces = false;
268
269 if line_bytes_starts_with_git_generated(line) {
270 trailer_lines += 1;
271 possible_continuation_lines = 0;
272 recognized_prefix = true;
273 l = match last_line_start(buf, l) {
274 Some(s) => s,
275 None => break,
276 };
277 continue;
278 }
279
280 if let Some(sep_pos) = find_separator_colon(line) {
281 if sep_pos >= 1 && !line.first().is_some_and(|b| b.is_ascii_whitespace()) {
282 trailer_lines += 1;
283 possible_continuation_lines = 0;
284 if !recognized_prefix && token_matches_rule(&line[..sep_pos], rules) {
285 recognized_prefix = true;
286 }
287 l = match last_line_start(buf, l) {
288 Some(s) => s,
289 None => break,
290 };
291 continue;
292 }
293 }
294
295 if line.first().is_some_and(|b| b.is_ascii_whitespace()) {
296 possible_continuation_lines += 1;
297 } else {
298 non_trailer_lines += 1;
299 non_trailer_lines += possible_continuation_lines;
300 possible_continuation_lines = 0;
301 }
302
303 l = match last_line_start(buf, l) {
304 Some(s) => s,
305 None => break,
306 };
307 }
308
309 len
310}
311
312fn trailer_raw_lines<'a>(msg: &'a str, rules: &[TrailerRule]) -> Vec<&'a str> {
314 let bytes = msg.as_bytes();
315 let end = find_end_of_log_message(bytes);
316 let start = find_trailer_block_start(bytes, end, rules);
317 if start >= end {
318 return Vec::new();
319 }
320 let slice = msg.get(start..end).unwrap_or("");
321 slice.lines().collect()
322}
323
324fn has_conforming_footer_with_sob(msg: &str, sob_line: Option<&str>, rules: &[TrailerRule]) -> u8 {
327 let lines = trailer_raw_lines(msg, rules);
328 if lines.is_empty() {
329 return 0;
330 }
331 let Some(sob) = sob_line else {
332 return 1;
333 };
334 let sob_prefix = sob.strip_suffix('\n').unwrap_or(sob);
335 let mut found_sob = 0usize;
336 for (idx, raw) in lines.iter().enumerate() {
337 let raw_trim = raw.strip_suffix('\r').unwrap_or(raw);
338 if raw_trim
340 .as_bytes()
341 .get(..sob_prefix.len())
342 .is_some_and(|head| head == sob_prefix.as_bytes())
343 {
344 found_sob = idx + 1;
345 }
346 }
347 let n = lines.len();
348 if found_sob == 0 {
349 return 1;
350 }
351 if found_sob == n {
352 return 3;
353 }
354 2
355}
356
357fn has_conforming_footer_any(msg: &str, rules: &[TrailerRule]) -> bool {
359 !trailer_raw_lines(msg, rules).is_empty()
360}
361
362fn strbuf_complete_line(s: &mut String) {
363 if !s.is_empty() && !s.ends_with('\n') {
364 s.push('\n');
365 }
366}
367
368pub fn append_cherry_picked_from_line(msg: &mut String, full_hex: &str, config: &ConfigSet) {
370 let rules = load_trailer_rules(config);
371 strbuf_complete_line(msg);
372 let body_wo_final_blank_lines = msg.trim_end_matches('\n');
373 let has_footer = has_conforming_footer_any(msg, &rules)
374 || last_line_looks_like_trailer(body_wo_final_blank_lines.as_bytes(), &rules);
375 if !has_footer {
376 msg.push('\n');
377 }
378 msg.push_str(CHERRY_PICKED_PREFIX);
379 msg.push_str(full_hex);
380 msg.push_str(")\n");
381}
382
383pub fn append_signoff_trailer(msg: &mut String, sob_line: &str, config: &ConfigSet) {
385 let rules = load_trailer_rules(config);
386 let ignore_footer = 0usize;
387 strbuf_complete_line(msg);
388
389 let footer_kind = has_conforming_footer_with_sob(msg, Some(sob_line), &rules);
390
391 let sob_prefix = sob_line.strip_suffix('\n').unwrap_or(sob_line);
392 let msg_core_len = msg.len().saturating_sub(ignore_footer);
393 let has_footer = if msg_core_len == sob_line.len()
395 && msg.get(..sob_line.len()).is_some_and(|p| p == sob_line)
396 {
397 3u8
398 } else {
399 footer_kind
400 };
401
402 if has_footer == 0 {
403 let body_scan = msg.trim_end_matches('\n');
404 let trailer_tail = last_line_looks_like_trailer(body_scan.as_bytes(), &rules);
405 if !trailer_tail {
406 let len = msg.len().saturating_sub(ignore_footer);
407 let append_newlines: Option<&'static str> = if len == 0 {
408 Some("\n\n")
409 } else if len == 1
410 || msg
411 .as_bytes()
412 .get(len - 2)
413 .copied()
414 .is_some_and(|b| b != b'\n')
415 {
416 Some("\n")
417 } else {
418 None
419 };
420 if let Some(nl) = append_newlines {
421 let insert_at = msg.len() - ignore_footer;
422 msg.insert_str(insert_at, nl);
423 }
424 }
425 }
426
427 let no_dup_sob = false;
428 if has_footer != 3 && (!no_dup_sob || has_footer != 2) {
429 let insert_at = msg.len() - ignore_footer;
430 msg.insert_str(insert_at, sob_prefix);
431 msg.push('\n');
432 }
433}
434
435pub fn format_signoff_line(name: &str, email: &str) -> String {
437 format!("{SIGN_OFF_HEADER}{name} <{email}>\n")
438}
439
440pub fn finalize_cherry_pick_message(
442 original_message: &str,
443 append_source: bool,
444 signoff: bool,
445 committer_name: &str,
446 committer_email: &str,
447 config: &ConfigSet,
448 picked_commit_hex: &str,
449) -> String {
450 let mut msg = original_message.to_owned();
451
452 let explicit_cleanup = config.get("commit.cleanup").is_some();
453 let cleanup_space = append_source && !explicit_cleanup;
454 let cleanup_strip_comments =
455 explicit_cleanup && matches!(config.get("commit.cleanup").as_deref(), Some("strip"));
456
457 if cleanup_space {
458 let processed =
459 crate::stripspace::process(msg.as_bytes(), &crate::stripspace::Mode::Default);
460 let cleaned = String::from_utf8_lossy(&processed);
461 msg = cleaned.into_owned();
462 } else if cleanup_strip_comments {
463 let processed = crate::stripspace::process(
464 msg.as_bytes(),
465 &crate::stripspace::Mode::StripComments("#".to_owned()),
466 );
467 let cleaned = String::from_utf8_lossy(&processed);
468 msg = cleaned.into_owned();
469 }
470
471 if append_source {
472 append_cherry_picked_from_line(&mut msg, picked_commit_hex, config);
473 }
474
475 if signoff {
476 let sob = format_signoff_line(committer_name, committer_email);
477 append_signoff_trailer(&mut msg, &sob, config);
478 }
479
480 msg
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn cherry_pick_x_one_line_subject_inserts_blank_before_trailer() {
489 let config = ConfigSet::new();
490 let mut msg = "base: commit message".to_owned();
491 append_cherry_picked_from_line(&mut msg, "abcd".repeat(10).as_str(), &config);
492 assert!(msg.contains("\n\n(cherry picked from commit "));
493 }
494
495 #[test]
496 fn signoff_after_non_conforming_footer_inserts_blank_paragraph() {
497 let config = ConfigSet::new();
498 let body = "base: commit message\n\nOneWordBodyThatsNotA-S-o-B";
499 let mut msg = body.to_owned();
500 let sob = format_signoff_line("C O Mitter", "committer@example.com");
501 append_signoff_trailer(&mut msg, &sob, &config);
502 assert!(msg.contains("OneWordBodyThatsNotA-S-o-B\n\nSigned-off-by:"));
503 }
504
505 #[test]
506 fn cherry_pick_x_after_sob_without_final_newline_no_extra_blank_before_cherry_line() {
507 let config = ConfigSet::new();
508 let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
509 append_cherry_picked_from_line(&mut msg, "d".repeat(40).as_str(), &config);
510 assert!(msg.ends_with(")\n"));
511 assert!(
512 msg.contains("Signed-off-by: A <a@example.com>\n(cherry picked from commit "),
513 "unexpected spacing: {msg:?}"
514 );
515 }
516
517 #[test]
518 fn signoff_after_other_sob_without_final_newline_single_separator() {
519 let config = ConfigSet::new();
520 let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
521 let sob = format_signoff_line("C O Mitter", "committer@example.com");
522 append_signoff_trailer(&mut msg, &sob, &config);
523 assert!(
524 msg.contains("Signed-off-by: A <a@example.com>\nSigned-off-by: C O Mitter"),
525 "unexpected spacing: {msg:?}"
526 );
527 }
528}