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 let mut parts = e.key.split('.');
30 let (Some("trailer"), Some(subsection), Some(var)) =
31 (parts.next(), parts.next(), parts.next())
32 else {
33 continue;
34 };
35 if RESERVED_TRAILER_SUBSECTIONS.contains(&subsection) {
36 continue;
37 }
38 let rule = rules
39 .entry(subsection.to_string())
40 .or_insert_with(|| TrailerRule {
41 name: subsection.to_string(),
42 key: None,
43 });
44 if var == "key" {
45 if let Some(v) = &e.value {
46 rule.key = Some(v.clone());
47 }
48 }
49 }
50 rules.into_values().collect()
51}
52
53fn next_line_start(buf: &[u8], pos: usize) -> usize {
54 if pos >= buf.len() {
55 return buf.len();
56 }
57 match buf[pos..].iter().position(|&b| b == b'\n') {
58 Some(p) => pos + p + 1,
59 None => buf.len(),
60 }
61}
62
63fn last_line_start(buf: &[u8], len: usize) -> Option<usize> {
65 let before_last_byte = buf.get(..len.checked_sub(1)?)?;
66 Some(
67 before_last_byte
68 .iter()
69 .rposition(|&b| b == b'\n')
70 .map_or(0, |i| i + 1),
71 )
72}
73
74fn is_blank_line_bytes(line: &[u8]) -> bool {
75 line.iter()
76 .copied()
77 .take_while(|&b| b != b'\n')
78 .all(|b| b.is_ascii_whitespace())
79}
80
81fn find_separator_colon(line: &[u8]) -> Option<usize> {
83 let mut whitespace_found = false;
84 for (i, &c) in line.iter().enumerate() {
85 if c == b':' {
86 return Some(i);
87 }
88 if !whitespace_found && (c.is_ascii_alphanumeric() || c == b'-') {
89 continue;
90 }
91 if i != 0 && (c == b' ' || c == b'\t') {
92 whitespace_found = true;
93 continue;
94 }
95 break;
96 }
97 None
98}
99
100fn token_len_without_separator(token: &[u8]) -> usize {
101 let mut len = token.len();
102 while len > 0 && !token[len - 1].is_ascii_alphanumeric() {
103 len -= 1;
104 }
105 len
106}
107
108fn line_bytes_starts_with_git_generated(line: &[u8]) -> bool {
109 let line_one_line = line.split(|&b| b == b'\n').next().unwrap_or(line);
110 for p in GIT_GENERATED_PREFIXES {
111 let pb = p.as_bytes();
112 if line_one_line.len() >= pb.len() && &line_one_line[..pb.len()] == pb {
113 return true;
114 }
115 }
116 false
117}
118
119fn last_line_looks_like_trailer(buf: &[u8], rules: &[TrailerRule]) -> bool {
123 if buf.is_empty() {
124 return false;
125 }
126 let bol = last_line_start(buf, buf.len()).unwrap_or(0);
127 let last = &buf[bol..];
128 let mut trim_end = last.len();
129 while trim_end > 0 && matches!(last[trim_end - 1], b' ' | b'\t' | b'\r') {
130 trim_end -= 1;
131 }
132 let t = &last[..trim_end];
133 if t.is_empty() {
134 return false;
135 }
136 if line_bytes_starts_with_git_generated(t) {
137 return true;
138 }
139 if let Some(sep) = find_separator_colon(t) {
140 if sep >= 1 && !t[0].is_ascii_whitespace() {
141 return token_matches_rule(&t[..sep], rules);
142 }
143 }
144 false
145}
146
147fn token_matches_rule(token: &[u8], rules: &[TrailerRule]) -> bool {
148 let tlen = token_len_without_separator(token);
149 let token = &token[..tlen];
150 let Ok(tok_str) = std::str::from_utf8(token) else {
151 return false;
152 };
153 for r in rules {
154 if r.name.eq_ignore_ascii_case(tok_str) {
155 return true;
156 }
157 if r.key
158 .as_ref()
159 .is_some_and(|k| k.eq_ignore_ascii_case(tok_str))
160 {
161 return true;
162 }
163 }
164 false
165}
166
167fn find_trailer_block_start(buf: &[u8], len: usize, rules: &[TrailerRule]) -> usize {
169 let mut end_of_title = 0usize;
173 let mut pos = 0usize;
174 while pos < len {
175 let line_end = next_line_start(buf, pos);
176 let line = &buf[pos..line_end.min(len)];
177 if line.first().is_some_and(|b| *b == b'#') {
178 pos = line_end;
179 continue;
180 }
181 if is_blank_line_bytes(line) {
182 end_of_title = line_end;
183 break;
184 }
185 pos = line_end;
186 }
187
188 let mut only_spaces = true;
189 let mut recognized_prefix = false;
190 let mut trailer_lines = 0i32;
191 let mut non_trailer_lines = 0i32;
192 let mut possible_continuation_lines = 0i32;
193
194 let is_trailer_block = |recognized_prefix: bool, trailer_lines: i32, non_trailer_lines: i32| {
195 (recognized_prefix && trailer_lines * 3 >= non_trailer_lines)
196 || (trailer_lines > 0 && non_trailer_lines == 0)
197 };
198
199 let mut next = last_line_start(buf, len);
200 while let Some(l) = next {
201 if l < end_of_title {
202 if !only_spaces
207 && is_trailer_block(
208 recognized_prefix,
209 trailer_lines,
210 non_trailer_lines + possible_continuation_lines,
211 )
212 {
213 return end_of_title;
214 }
215 break;
216 }
217 next = last_line_start(buf, l);
218 let line = &buf[l..next_line_start(buf, l).min(len)];
219
220 if line.first() == Some(&b'#') {
221 non_trailer_lines += possible_continuation_lines;
222 possible_continuation_lines = 0;
223 continue;
224 }
225
226 if is_blank_line_bytes(line) {
227 if only_spaces {
228 continue;
229 }
230 non_trailer_lines += possible_continuation_lines;
231 return if is_trailer_block(recognized_prefix, trailer_lines, non_trailer_lines) {
232 next_line_start(buf, l)
233 } else {
234 len
235 };
236 }
237
238 only_spaces = false;
239
240 let indented = line.first().is_some_and(u8::is_ascii_whitespace);
241 if line_bytes_starts_with_git_generated(line) {
242 trailer_lines += 1;
243 possible_continuation_lines = 0;
244 recognized_prefix = true;
245 } else if let Some(sep_pos) = find_separator_colon(line).filter(|&p| p >= 1 && !indented) {
246 trailer_lines += 1;
247 possible_continuation_lines = 0;
248 recognized_prefix = recognized_prefix || token_matches_rule(&line[..sep_pos], rules);
249 } else if indented {
250 possible_continuation_lines += 1;
251 } else {
252 non_trailer_lines += 1 + possible_continuation_lines;
253 possible_continuation_lines = 0;
254 }
255 }
256
257 len
258}
259
260fn trailer_raw_lines<'a>(msg: &'a str, rules: &[TrailerRule]) -> Vec<&'a str> {
262 let start = find_trailer_block_start(msg.as_bytes(), msg.len(), rules);
263 msg.get(start..).unwrap_or("").lines().collect()
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268enum SignoffFooter {
269 NoTrailers,
271 Absent,
273 NotLast,
275 Last,
277}
278
279fn signoff_footer(msg: &str, sob_line: &str, rules: &[TrailerRule]) -> SignoffFooter {
280 let lines = trailer_raw_lines(msg, rules);
281 let sob_prefix = sob_line.strip_suffix('\n').unwrap_or(sob_line);
282 let last_sob = lines.iter().rposition(|raw| {
284 raw.strip_suffix('\r')
285 .unwrap_or(raw)
286 .starts_with(sob_prefix)
287 });
288 match last_sob {
289 _ if lines.is_empty() => SignoffFooter::NoTrailers,
290 None => SignoffFooter::Absent,
291 Some(idx) if idx + 1 == lines.len() => SignoffFooter::Last,
292 Some(_) => SignoffFooter::NotLast,
293 }
294}
295
296fn strbuf_complete_line(s: &mut String) {
297 if !s.is_empty() && !s.ends_with('\n') {
298 s.push('\n');
299 }
300}
301
302pub fn append_cherry_picked_from_line(msg: &mut String, full_hex: &str, config: &ConfigSet) {
304 let rules = load_trailer_rules(config);
305 strbuf_complete_line(msg);
306 let has_footer = !trailer_raw_lines(msg, &rules).is_empty()
307 || last_line_looks_like_trailer(msg.trim_end_matches('\n').as_bytes(), &rules);
308 if !has_footer {
309 msg.push('\n');
310 }
311 msg.push_str(CHERRY_PICKED_PREFIX);
312 msg.push_str(full_hex);
313 msg.push_str(")\n");
314}
315
316pub fn append_signoff_trailer(msg: &mut String, sob_line: &str, config: &ConfigSet) {
318 append_signoff_trailer_with_dedup(msg, sob_line, config, false);
319}
320
321pub fn append_signoff_trailer_with_dedup(
325 msg: &mut String,
326 sob_line: &str,
327 config: &ConfigSet,
328 dedup: bool,
329) {
330 let rules = load_trailer_rules(config);
331 strbuf_complete_line(msg);
332
333 let footer = if msg == sob_line {
335 SignoffFooter::Last
336 } else {
337 signoff_footer(msg, sob_line, &rules)
338 };
339
340 if footer == SignoffFooter::NoTrailers
341 && !last_line_looks_like_trailer(msg.trim_end_matches('\n').as_bytes(), &rules)
342 {
343 let separator = match msg.len() {
344 0 => "\n\n",
345 1 => "\n",
346 n if msg.as_bytes()[n - 2] != b'\n' => "\n",
347 _ => "",
348 };
349 msg.push_str(separator);
350 }
351
352 let already_signed = match footer {
353 SignoffFooter::Last => true,
354 SignoffFooter::NotLast => dedup,
355 SignoffFooter::NoTrailers | SignoffFooter::Absent => false,
356 };
357 if !already_signed {
358 msg.push_str(sob_line.strip_suffix('\n').unwrap_or(sob_line));
359 msg.push('\n');
360 }
361}
362
363pub fn format_signoff_line(name: &str, email: &str) -> String {
365 format!("{SIGN_OFF_HEADER}{name} <{email}>\n")
366}
367
368pub fn finalize_cherry_pick_message(
370 original_message: &str,
371 append_source: bool,
372 signoff: bool,
373 committer_name: &str,
374 committer_email: &str,
375 config: &ConfigSet,
376 picked_commit_hex: &str,
377) -> String {
378 let mut msg = original_message.to_owned();
379
380 let explicit_cleanup = config.get("commit.cleanup").is_some();
381 let cleanup_space = append_source && !explicit_cleanup;
382 let cleanup_strip_comments =
383 explicit_cleanup && matches!(config.get("commit.cleanup").as_deref(), Some("strip"));
384
385 if cleanup_space {
386 let processed =
387 crate::stripspace::process(msg.as_bytes(), &crate::stripspace::Mode::Default);
388 let cleaned = String::from_utf8_lossy(&processed);
389 msg = cleaned.into_owned();
390 } else if cleanup_strip_comments {
391 let processed = crate::stripspace::process(
392 msg.as_bytes(),
393 &crate::stripspace::Mode::StripComments("#".to_owned()),
394 );
395 let cleaned = String::from_utf8_lossy(&processed);
396 msg = cleaned.into_owned();
397 }
398
399 if append_source {
400 append_cherry_picked_from_line(&mut msg, picked_commit_hex, config);
401 }
402
403 if signoff {
404 let sob = format_signoff_line(committer_name, committer_email);
405 append_signoff_trailer(&mut msg, &sob, config);
406 }
407
408 msg
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn cherry_pick_x_one_line_subject_inserts_blank_before_trailer() {
417 let config = ConfigSet::new();
418 let mut msg = "base: commit message".to_owned();
419 append_cherry_picked_from_line(&mut msg, "abcd".repeat(10).as_str(), &config);
420 assert!(msg.contains("\n\n(cherry picked from commit "));
421 }
422
423 #[test]
424 fn signoff_after_non_conforming_footer_inserts_blank_paragraph() {
425 let config = ConfigSet::new();
426 let body = "base: commit message\n\nOneWordBodyThatsNotA-S-o-B";
427 let mut msg = body.to_owned();
428 let sob = format_signoff_line("C O Mitter", "committer@example.com");
429 append_signoff_trailer(&mut msg, &sob, &config);
430 assert!(msg.contains("OneWordBodyThatsNotA-S-o-B\n\nSigned-off-by:"));
431 }
432
433 #[test]
434 fn cherry_pick_x_after_sob_without_final_newline_no_extra_blank_before_cherry_line() {
435 let config = ConfigSet::new();
436 let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
437 append_cherry_picked_from_line(&mut msg, "d".repeat(40).as_str(), &config);
438 assert!(msg.ends_with(")\n"));
439 assert!(
440 msg.contains("Signed-off-by: A <a@example.com>\n(cherry picked from commit "),
441 "unexpected spacing: {msg:?}"
442 );
443 }
444
445 #[test]
446 fn signoff_after_other_sob_without_final_newline_single_separator() {
447 let config = ConfigSet::new();
448 let mut msg = "title\n\nSigned-off-by: A <a@example.com>".to_owned();
449 let sob = format_signoff_line("C O Mitter", "committer@example.com");
450 append_signoff_trailer(&mut msg, &sob, &config);
451 assert!(
452 msg.contains("Signed-off-by: A <a@example.com>\nSigned-off-by: C O Mitter"),
453 "unexpected spacing: {msg:?}"
454 );
455 }
456}