1use similar::{ChangeTag, TextDiff};
19
20use crate::diff::{
21 diff_trees_with_renames, unified_diff_histogram_with_prefix_and_funcname, zero_oid, DiffEntry,
22 DiffStatus,
23};
24use crate::error::Result;
25use crate::hash::ChecksumHasher;
26use crate::merge_file;
27use crate::objects::{CommitRef, HashAlgo, ObjectId, ObjectKind};
28use crate::odb::Odb;
29
30const PATCH_ID_DIGEST_MAX: usize = 32;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PatchIdMode {
35 Unstable,
38 Stable,
41 Verbatim,
44}
45
46pub fn compute_patch_ids_from_text(
64 algo: HashAlgo,
65 input: &[u8],
66 mode: PatchIdMode,
67) -> Vec<(ObjectId, ObjectId)> {
68 let stable = mode != PatchIdMode::Unstable;
69 let verbatim = mode == PatchIdMode::Verbatim;
70 let digest_len = algo.len();
71
72 let mut results: Vec<(ObjectId, ObjectId)> = Vec::new();
73
74 let mut ctx = ChecksumHasher::new(algo);
76 let mut result = [0u8; PATCH_ID_DIGEST_MAX];
77 let mut patchlen: usize = 0;
78 let mut before: i32 = -1;
80 let mut after: i32 = -1;
81 let mut diff_is_binary = false;
82 let mut pre_oid_str = String::new();
83 let mut post_oid_str = String::new();
84 let mut current_commit: Option<ObjectId> = None;
85 let mut implicit_commit = true;
88
89 let lines = split_lines_with_nl(input);
92
93 let mut i = 0;
94 while i < lines.len() {
95 let raw = lines[i];
96 i += 1;
97
98 let line = std::str::from_utf8(raw).unwrap_or("");
100
101 let oid_candidate: Option<&str> = if let Some(rest) = line.strip_prefix("commit ") {
103 Some(rest)
104 } else if let Some(rest) = line.strip_prefix("From ") {
105 Some(rest)
106 } else {
107 None
108 };
109
110 if let Some(candidate) = oid_candidate {
111 if let Some(oid) = try_parse_oid_prefix(candidate, algo) {
112 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
114 if patchlen > 0 {
115 if let Some(coid) = current_commit.take() {
116 if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
117 results.push((pid, coid));
118 }
119 }
120 }
121 result = [0u8; PATCH_ID_DIGEST_MAX];
123 ctx = ChecksumHasher::new(algo);
124 patchlen = 0;
125 before = -1;
126 after = -1;
127 diff_is_binary = false;
128 pre_oid_str.clear();
129 post_oid_str.clear();
130 current_commit = Some(oid);
131 implicit_commit = false;
132 continue;
133 }
134 }
135
136 if line.starts_with("\\ ") && line.len() > 12 {
138 if verbatim {
139 ctx.update(raw);
140 patchlen += raw.len();
141 }
142 continue;
143 }
144
145 if patchlen == 0 && !line.starts_with("diff ") {
147 continue;
148 }
149
150 if implicit_commit && line.starts_with("diff ") && current_commit.is_none() {
153 current_commit = Some(ObjectId::zero());
154 implicit_commit = false;
155 }
156
157 if before == -1 {
159 if line.starts_with("GIT binary patch") || line.starts_with("Binary files") {
160 diff_is_binary = true;
162 before = 0;
163 let pre = pre_oid_str.clone();
164 let post = post_oid_str.clone();
165 ctx.update(pre.as_bytes());
166 ctx.update(post.as_bytes());
167 patchlen += pre.len() + post.len();
168 if stable {
169 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
170 }
171 continue;
172 } else if let Some(rest) = line.strip_prefix("index ") {
173 if let Some(dd) = rest.find("..") {
175 pre_oid_str = rest[..dd].to_owned();
176 let tail = &rest[dd + 2..];
177 let end = tail
178 .find(|c: char| c.is_ascii_whitespace())
179 .unwrap_or_else(|| {
180 tail.trim_end_matches('\n').trim_end_matches('\r').len()
181 });
182 post_oid_str = tail[..end].to_owned();
183 }
184 continue;
185 } else if line.starts_with("--- ") {
186 before = 1;
187 after = 1;
188 } else if !line.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
190 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
193 if patchlen > 0 {
194 if let Some(coid) = current_commit.take() {
195 if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
196 results.push((pid, coid));
197 }
198 }
199 }
200 result = [0u8; PATCH_ID_DIGEST_MAX];
201 ctx = ChecksumHasher::new(algo);
202 patchlen = 0;
203 before = -1;
204 after = -1;
205 diff_is_binary = false;
206 continue;
207 }
208 }
209
210 if diff_is_binary {
212 if line.starts_with("diff ") {
213 diff_is_binary = false;
214 before = -1;
215 i -= 1; }
218 continue;
219 }
220
221 if before == 0 && after == 0 {
223 if line.starts_with("@@ -") {
224 let (b, a) = scan_hunk_header(line);
225 before = b;
226 after = a;
227 continue;
228 }
229 if !line.starts_with("diff ") {
230 continue;
233 }
234 if stable {
236 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
237 }
238 before = -1;
239 after = -1;
240 i -= 1;
242 continue;
243 }
244
245 let first = raw.first().copied().unwrap_or(b' ');
247 if first == b'-' || first == b' ' {
248 before -= 1;
249 }
250 if first == b'+' || first == b' ' {
251 after -= 1;
252 }
253
254 let hashed = if verbatim {
256 ctx.update(raw);
257 raw.len()
258 } else {
259 hash_without_whitespace(&mut ctx, raw)
260 };
261 patchlen += hashed;
262 }
263
264 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
266 if patchlen > 0 {
267 if let Some(coid) = current_commit {
268 if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
269 results.push((pid, coid));
270 }
271 }
272 }
273
274 results
275}
276
277fn text_flush_one_hunk(
280 result: &mut [u8],
281 digest_len: usize,
282 ctx: &mut ChecksumHasher,
283 algo: HashAlgo,
284) {
285 let mut hash_buf = [0u8; PATCH_ID_DIGEST_MAX];
286 let old = std::mem::replace(ctx, ChecksumHasher::new(algo));
287 let _ = old.finalize_into(&mut hash_buf);
288 let mut carry: u16 = 0;
289 for i in 0..digest_len {
290 carry = carry + result[i] as u16 + hash_buf[i] as u16;
291 result[i] = carry as u8;
292 carry >>= 8;
293 }
294}
295
296fn hash_without_whitespace(ctx: &mut ChecksumHasher, raw: &[u8]) -> usize {
300 let mut count = 0;
301 for &b in raw {
302 if !b.is_ascii_whitespace() {
303 ctx.update(&[b]);
304 count += 1;
305 }
306 }
307 count
308}
309
310fn try_parse_oid_prefix(s: &str, algo: HashAlgo) -> Option<ObjectId> {
315 let s = s.trim_end_matches('\n').trim_end_matches('\r');
316 let hex_len = algo.len() * 2;
317 if s.len() < hex_len {
318 return None;
319 }
320 let hex = &s[..hex_len];
321 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
322 return None;
323 }
324 if s.len() > hex_len && !s.as_bytes()[hex_len].is_ascii_whitespace() {
325 return None;
326 }
327 let mut bytes = [0u8; PATCH_ID_DIGEST_MAX];
328 for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
329 let hi = hex_val(chunk[0])?;
330 let lo = hex_val(chunk[1])?;
331 bytes[i] = (hi << 4) | lo;
332 }
333 ObjectId::from_bytes(&bytes[..algo.len()]).ok()
334}
335
336fn hex_val(b: u8) -> Option<u8> {
338 match b {
339 b'0'..=b'9' => Some(b - b'0'),
340 b'a'..=b'f' => Some(b - b'a' + 10),
341 b'A'..=b'F' => Some(b - b'A' + 10),
342 _ => None,
343 }
344}
345
346fn scan_hunk_header(line: &str) -> (i32, i32) {
350 let rest = match line.strip_prefix("@@ -") {
352 Some(r) => r,
353 None => return (1, 1),
354 };
355 let before = parse_hunk_count(rest);
357 let after = rest
359 .find(" +")
360 .and_then(|p| parse_hunk_count_opt(&rest[p + 2..]))
361 .unwrap_or(1);
362 (before, after)
363}
364
365fn parse_hunk_count(s: &str) -> i32 {
367 let after_start = s.trim_start_matches(|c: char| c.is_ascii_digit());
369 if let Some(rest) = after_start.strip_prefix(',') {
370 rest.split(|c: char| !c.is_ascii_digit())
371 .next()
372 .and_then(|n| n.parse().ok())
373 .unwrap_or(1)
374 } else {
375 1
376 }
377}
378
379fn parse_hunk_count_opt(s: &str) -> Option<i32> {
381 Some(parse_hunk_count(s))
382}
383
384fn split_lines_with_nl(input: &[u8]) -> Vec<&[u8]> {
389 let mut lines = Vec::new();
390 let mut start = 0;
391 for (i, &b) in input.iter().enumerate() {
392 if b == b'\n' {
393 lines.push(&input[start..=i]);
394 start = i + 1;
395 }
396 }
397 if start < input.len() {
398 lines.push(&input[start..]);
399 }
400 lines
401}
402
403pub fn compute_patch_id(
423 algo: HashAlgo,
424 odb: &Odb,
425 commit_oid: &ObjectId,
426 mode: PatchIdMode,
427) -> Result<Option<ObjectId>> {
428 compute_patch_id_filtered(algo, odb, commit_oid, &[], mode)
429}
430
431pub fn compute_patch_id_for_paths(
448 algo: HashAlgo,
449 odb: &Odb,
450 commit_oid: &ObjectId,
451 paths: &[String],
452 mode: PatchIdMode,
453) -> Result<Option<ObjectId>> {
454 compute_patch_id_filtered(algo, odb, commit_oid, paths, mode)
455}
456
457fn compute_patch_id_filtered(
458 algo: HashAlgo,
459 odb: &Odb,
460 commit_oid: &ObjectId,
461 paths: &[String],
462 mode: PatchIdMode,
463) -> Result<Option<ObjectId>> {
464 let per_file_flush = mode != PatchIdMode::Unstable;
465 let digest_len = algo.len();
466 let obj = odb.read(commit_oid)?;
467 if obj.kind != ObjectKind::Commit {
468 return Ok(None);
469 }
470 let commit = CommitRef::parse(&obj.data)?;
471
472 if commit.parents().len() > 1 {
474 return Ok(None);
475 }
476
477 let parent_tree_oid = if commit.parents().is_empty() {
479 None
480 } else {
481 let parent_obj = odb.read(&commit.parents()[0])?;
482 let parent_commit = CommitRef::parse(&parent_obj.data)?;
483 Some(parent_commit.tree())
484 };
485
486 let mut diffs =
488 diff_trees_with_renames(odb, parent_tree_oid.as_ref(), Some(&commit.tree()), "", 50)?;
489 if !paths.is_empty() {
490 diffs.retain(|entry| diff_entry_matches_paths(entry, paths));
491 }
492 if diffs.is_empty() {
493 return Ok(None);
494 }
495
496 diffs.sort_by(|a, b| a.path().cmp(b.path()));
498
499 if mode == PatchIdMode::Verbatim {
500 let patch = patch_id_build_git_show_patch(odb, commit_oid, &diffs)?;
501 let ids = compute_patch_ids_from_text(algo, &patch, mode);
502 return Ok(ids.into_iter().next().map(|(patch_id, _)| patch_id));
503 }
504
505 if diffs.len() == 1 {
506 let entry = &diffs[0];
507 if entry.old_oid != entry.new_oid {
508 let old_bytes = read_blob(odb, &entry.old_oid)?;
509 let new_bytes = read_blob(odb, &entry.new_oid)?;
510 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
511 let mut patch = Vec::new();
512 patch_id_write_line(&mut patch, &format!("commit {}", commit_oid.to_hex()));
513 write_patch_id_binary_entry(&mut patch, entry)?;
514 let ids = compute_patch_ids_from_text(algo, &patch, mode);
515 return Ok(ids.into_iter().next().map(|(patch_id, _)| patch_id));
516 }
517 }
518 }
519
520 let mut result = [0u8; PATCH_ID_DIGEST_MAX];
521 let mut rolling_ctx = ChecksumHasher::new(algo);
522
523 for entry in &diffs {
524 if per_file_flush {
525 let mut ctx = ChecksumHasher::new(algo);
526 patch_id_hash_diff_entry(odb, entry, &mut ctx)?;
527 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
528 } else {
529 patch_id_hash_diff_entry(odb, entry, &mut rolling_ctx)?;
530 }
531 }
532
533 if !per_file_flush {
534 text_flush_one_hunk(
535 &mut result[..digest_len],
536 digest_len,
537 &mut rolling_ctx,
538 algo,
539 );
540 }
541
542 if result.iter().take(digest_len).all(|&b| b == 0) {
543 return Ok(None);
544 }
545 ObjectId::from_bytes(&result[..digest_len]).map(Some)
546}
547
548fn diff_entry_matches_paths(entry: &DiffEntry, paths: &[String]) -> bool {
549 entry
550 .old_path
551 .as_deref()
552 .into_iter()
553 .chain(entry.new_path.as_deref())
554 .any(|path| crate::pathspec::matches_pathspec_list(path, paths))
555}
556
557fn parse_mode_u32(mode: &str) -> u32 {
558 u32::from_str_radix(mode.trim(), 8).unwrap_or(0)
559}
560
561fn patch_id_write_line(out: &mut Vec<u8>, line: &str) {
563 out.extend_from_slice(line.as_bytes());
564 out.push(b'\n');
565}
566
567fn write_patch_id_binary_entry(out: &mut Vec<u8>, entry: &DiffEntry) -> Result<()> {
569 let old_path = entry
570 .old_path
571 .as_deref()
572 .or(entry.new_path.as_deref())
573 .unwrap_or("");
574 let new_path = entry
575 .new_path
576 .as_deref()
577 .or(entry.old_path.as_deref())
578 .unwrap_or("");
579 patch_id_write_line(out, &format!("diff --git a/{old_path} b/{new_path}"));
580 let old_mode = parse_mode_u32(&entry.old_mode);
581 let new_mode = parse_mode_u32(&entry.new_mode);
582 if old_mode == 0 {
583 patch_id_write_line(out, &format!("new file mode {}", entry.new_mode.trim()));
584 } else if new_mode == 0 {
585 patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode.trim()));
586 }
587 patch_id_write_line(
588 out,
589 &format!(
590 "index {}..{}",
591 patch_id_index_oid(&entry.old_oid),
592 patch_id_index_oid(&entry.new_oid),
593 ),
594 );
595 let old_side = entry
596 .old_path
597 .as_ref()
598 .map(|p| format!("a/{p}"))
599 .unwrap_or_else(|| "/dev/null".to_owned());
600 let new_side = entry
601 .new_path
602 .as_ref()
603 .map(|p| format!("b/{p}"))
604 .unwrap_or_else(|| "/dev/null".to_owned());
605 patch_id_write_line(
606 out,
607 &format!("Binary files {old_side} and {new_side} differ"),
608 );
609 Ok(())
610}
611
612fn patch_id_index_oid(oid: &ObjectId) -> String {
613 if *oid == zero_oid() {
614 return "0000000".to_owned();
615 }
616 oid.to_hex().chars().take(7).collect()
617}
618
619fn patch_id_add_string(ctx: &mut ChecksumHasher, s: &[u8]) {
620 ctx.update(s);
621}
622
623fn patch_id_add_mode(ctx: &mut ChecksumHasher, mode: u32) {
624 let text = format!("{mode:06o}");
625 ctx.update(text.as_bytes());
626}
627
628fn patch_id_build_git_show_patch(
630 odb: &Odb,
631 commit_oid: &ObjectId,
632 diffs: &[DiffEntry],
633) -> Result<Vec<u8>> {
634 let mut patch = Vec::new();
635 patch_id_write_line(&mut patch, &format!("commit {}", commit_oid.to_hex()));
636 for entry in diffs {
637 patch_id_append_git_show_entry(odb, entry, &mut patch)?;
638 }
639 Ok(patch)
640}
641
642fn patch_id_append_git_show_entry(odb: &Odb, entry: &DiffEntry, out: &mut Vec<u8>) -> Result<()> {
643 let git_old = entry
644 .old_path
645 .as_deref()
646 .or(entry.new_path.as_deref())
647 .unwrap_or("dev/null");
648 let git_new = entry
649 .new_path
650 .as_deref()
651 .or(entry.old_path.as_deref())
652 .unwrap_or("dev/null");
653 patch_id_write_line(out, &format!("diff --git a/{git_old} b/{git_new}"));
654
655 let old_mode = parse_mode_u32(&entry.old_mode);
656 let new_mode = parse_mode_u32(&entry.new_mode);
657 if old_mode == 0 {
658 patch_id_write_line(out, &format!("new file mode {}", entry.new_mode.trim()));
659 } else if new_mode == 0 {
660 patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode.trim()));
661 }
662
663 if entry.status == DiffStatus::Renamed {
664 let score = entry.score.unwrap_or(100);
665 patch_id_write_line(out, &format!("similarity index {score}%"));
666 if let Some(path) = &entry.old_path {
667 patch_id_write_line(out, &format!("rename from {path}"));
668 }
669 if let Some(path) = &entry.new_path {
670 patch_id_write_line(out, &format!("rename to {path}"));
671 }
672 } else if old_mode != 0 && new_mode != 0 && old_mode != new_mode {
673 patch_id_write_line(out, &format!("old mode {}", entry.old_mode.trim()));
674 patch_id_write_line(out, &format!("new mode {}", entry.new_mode.trim()));
675 }
676
677 let index_mode = if new_mode != 0 {
678 entry.new_mode.trim()
679 } else {
680 entry.old_mode.trim()
681 };
682 patch_id_write_line(
683 out,
684 &format!(
685 "index {}..{} {}",
686 patch_id_index_oid(&entry.old_oid),
687 patch_id_index_oid(&entry.new_oid),
688 index_mode,
689 ),
690 );
691
692 if entry.old_oid == entry.new_oid {
693 return Ok(());
694 }
695
696 let old_bytes = read_blob(odb, &entry.old_oid)?;
697 let new_bytes = read_blob(odb, &entry.new_oid)?;
698
699 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
700 let old_side = if old_mode == 0 {
701 "/dev/null".to_owned()
702 } else {
703 format!("a/{git_old}")
704 };
705 let new_side = if new_mode == 0 {
706 "/dev/null".to_owned()
707 } else {
708 format!("b/{git_new}")
709 };
710 patch_id_write_line(
711 out,
712 &format!("Binary files {old_side} and {new_side} differ"),
713 );
714 return Ok(());
715 }
716
717 let old_str = std::str::from_utf8(&old_bytes).unwrap_or("");
718 let new_str = std::str::from_utf8(&new_bytes).unwrap_or("");
719 let old_path = if old_mode == 0 {
720 "/dev/null".to_owned()
721 } else {
722 format!("a/{git_old}")
723 };
724 let new_path = if new_mode == 0 {
725 "/dev/null".to_owned()
726 } else {
727 format!("b/{git_new}")
728 };
729 let unified = unified_diff_histogram_with_prefix_and_funcname(
730 old_str, new_str, &old_path, &new_path, 3, 0, "", "", None, false, false,
731 );
732 out.extend_from_slice(unified.as_bytes());
733 Ok(())
734}
735
736fn patch_id_hash_diff_entry(odb: &Odb, entry: &DiffEntry, ctx: &mut ChecksumHasher) -> Result<()> {
738 if entry.status == DiffStatus::Renamed {
739 patch_id_hash_rename_entry_into_ctx(odb, entry, ctx)
740 } else {
741 patch_id_hash_file_entry_into_ctx(odb, entry, ctx)
742 }
743}
744
745fn patch_id_hash_file_entry_into_ctx(
747 odb: &Odb,
748 entry: &DiffEntry,
749 ctx: &mut ChecksumHasher,
750) -> Result<()> {
751 let old_path = entry
755 .old_path
756 .as_deref()
757 .or(entry.new_path.as_deref())
758 .unwrap_or("");
759 let new_path = entry
760 .new_path
761 .as_deref()
762 .or(entry.old_path.as_deref())
763 .unwrap_or("");
764 let mut old_path_buf = old_path.as_bytes().to_vec();
765 let mut new_path_buf = new_path.as_bytes().to_vec();
766 let len1 = remove_space_bytes(&mut old_path_buf);
767 let len2 = remove_space_bytes(&mut new_path_buf);
768
769 let old_mode = parse_mode_u32(&entry.old_mode);
770 let new_mode = parse_mode_u32(&entry.new_mode);
771
772 patch_id_add_string(ctx, b"diff--git");
773 patch_id_add_string(ctx, b"a/");
774 ctx.update(&old_path_buf[..len1]);
775 patch_id_add_string(ctx, b"b/");
776 ctx.update(&new_path_buf[..len2]);
777
778 if old_mode == 0 {
779 patch_id_add_string(ctx, b"newfilemode");
780 patch_id_add_mode(ctx, new_mode);
781 } else if new_mode == 0 {
782 patch_id_add_string(ctx, b"deletedfilemode");
783 patch_id_add_mode(ctx, old_mode);
784 } else if old_mode != new_mode {
785 patch_id_add_string(ctx, b"oldmode");
786 patch_id_add_mode(ctx, old_mode);
787 patch_id_add_string(ctx, b"newmode");
788 patch_id_add_mode(ctx, new_mode);
789 }
790
791 if entry.old_oid == entry.new_oid {
792 return Ok(());
793 }
794
795 let old_bytes = read_blob(odb, &entry.old_oid)?;
796 let new_bytes = read_blob(odb, &entry.new_oid)?;
797
798 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
799 let a = patch_id_index_oid(&entry.old_oid);
800 let b = patch_id_index_oid(&entry.new_oid);
801 ctx.update(a.as_bytes());
802 ctx.update(b.as_bytes());
803 } else {
804 let old_str = std::str::from_utf8(&old_bytes).unwrap_or("");
805 let new_str = std::str::from_utf8(&new_bytes).unwrap_or("");
806
807 if old_mode == 0 {
808 patch_id_add_string(ctx, b"---/dev/null");
809 patch_id_add_string(ctx, b"+++b/");
810 ctx.update(&new_path_buf[..len2]);
811 } else if new_mode == 0 {
812 patch_id_add_string(ctx, b"---a/");
813 ctx.update(&old_path_buf[..len1]);
814 patch_id_add_string(ctx, b"+++/dev/null");
815 } else {
816 patch_id_add_string(ctx, b"---a/");
817 ctx.update(&old_path_buf[..len1]);
818 patch_id_add_string(ctx, b"+++b/");
819 ctx.update(&new_path_buf[..len2]);
820 }
821
822 patch_id_hash_text_hunks(ctx, old_str, new_str);
823 }
824 Ok(())
825}
826
827fn patch_id_hash_rename_entry_into_ctx(
830 odb: &Odb,
831 entry: &DiffEntry,
832 ctx: &mut ChecksumHasher,
833) -> Result<()> {
834 let old_path = entry.old_path.as_deref().unwrap_or("");
835 let new_path = entry.new_path.as_deref().unwrap_or("");
836 let mut old_path_buf = old_path.as_bytes().to_vec();
837 let mut new_path_buf = new_path.as_bytes().to_vec();
838 let len1 = remove_space_bytes(&mut old_path_buf);
839 let len2 = remove_space_bytes(&mut new_path_buf);
840
841 patch_id_add_string(ctx, b"diff--git");
842 patch_id_add_string(ctx, b"a/");
843 ctx.update(&old_path_buf[..len1]);
844 patch_id_add_string(ctx, b"b/");
845 ctx.update(&new_path_buf[..len2]);
846
847 let score = entry.score.unwrap_or(100);
848 patch_id_add_string(ctx, b"similarityindex");
849 ctx.update(format!("{score}%").as_bytes());
850 patch_id_add_string(ctx, b"renamefrom");
851 ctx.update(&old_path_buf[..len1]);
852 patch_id_add_string(ctx, b"renameto");
853 ctx.update(&new_path_buf[..len2]);
854
855 let old_mode = parse_mode_u32(&entry.old_mode);
856 let new_mode = parse_mode_u32(&entry.new_mode);
857 if old_mode != new_mode {
858 patch_id_add_string(ctx, b"oldmode");
859 patch_id_add_mode(ctx, old_mode);
860 patch_id_add_string(ctx, b"newmode");
861 patch_id_add_mode(ctx, new_mode);
862 }
863
864 if entry.old_oid != entry.new_oid {
865 let old_bytes = read_blob(odb, &entry.old_oid)?;
866 let new_bytes = read_blob(odb, &entry.new_oid)?;
867 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
868 let a = entry.old_oid.to_hex();
869 let b = entry.new_oid.to_hex();
870 ctx.update(a.as_bytes());
871 ctx.update(b.as_bytes());
872 } else {
873 let old_str = std::str::from_utf8(&old_bytes).unwrap_or("");
874 let new_str = std::str::from_utf8(&new_bytes).unwrap_or("");
875 patch_id_add_string(ctx, b"---a/");
876 ctx.update(&old_path_buf[..len1]);
877 patch_id_add_string(ctx, b"+++b/");
878 ctx.update(&new_path_buf[..len2]);
879 patch_id_hash_text_hunks(ctx, old_str, new_str);
880 }
881 }
882
883 Ok(())
884}
885
886fn patch_id_hash_text_hunks(ctx: &mut ChecksumHasher, old_str: &str, new_str: &str) {
887 let diff = TextDiff::from_lines(old_str, new_str);
888 for change in diff
891 .grouped_ops(3)
892 .iter()
893 .flatten()
894 .flat_map(|op| diff.iter_changes(op))
895 {
896 let prefix = match change.tag() {
897 ChangeTag::Equal => b' ',
898 ChangeTag::Delete => b'-',
899 ChangeTag::Insert => b'+',
900 };
901 let text = change.as_str().unwrap_or("");
902 for piece in text.split_inclusive('\n') {
903 let line_body = piece.strip_suffix('\n').unwrap_or(piece);
904 let mut line_buf = Vec::with_capacity(1 + line_body.len() + 1);
905 line_buf.push(prefix);
906 line_buf.extend_from_slice(line_body.as_bytes());
907 line_buf.push(b'\n');
908 let n = remove_space_bytes(&mut line_buf);
909 ctx.update(&line_buf[..n]);
910 }
911 }
912}
913
914fn remove_space_bytes(buf: &mut [u8]) -> usize {
916 let mut dst = 0usize;
917 let mut src = 0usize;
918 while src < buf.len() {
919 let c = buf[src];
920 src += 1;
921 if !c.is_ascii_whitespace() {
922 buf[dst] = c;
923 dst += 1;
924 }
925 }
926 dst
927}
928
929fn read_blob(odb: &Odb, oid: &ObjectId) -> Result<Vec<u8>> {
933 if *oid == zero_oid() {
934 return Ok(Vec::new());
935 }
936 let obj = odb.read(oid)?;
937 Ok(obj.data)
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943 use crate::environment::RepositoryOptions;
944 use std::process::Command;
945
946 use tempfile::TempDir;
947
948 fn git_in(dir: &std::path::Path, args: &[&str]) -> String {
949 let out = Command::new("git")
950 .current_dir(dir)
951 .args(args)
952 .env("GIT_CONFIG_GLOBAL", "/dev/null")
953 .env("GIT_CONFIG_SYSTEM", "/dev/null")
954 .env("GIT_AUTHOR_NAME", "Test")
955 .env("GIT_AUTHOR_EMAIL", "t@example.com")
956 .env("GIT_COMMITTER_NAME", "Test")
957 .env("GIT_COMMITTER_EMAIL", "t@example.com")
958 .output()
959 .expect("spawn git");
960 assert!(
961 out.status.success(),
962 "git {:?} failed: {}",
963 args,
964 String::from_utf8_lossy(&out.stderr)
965 );
966 String::from_utf8_lossy(&out.stdout).into_owned()
967 }
968
969 #[test]
970 fn text_hunks_hash_only_three_context_lines() {
971 let old: String = (1..=10).map(|i| format!("{i}\n")).collect();
972 for (new, expected) in [
973 (
974 old.replace("10\n", "changed \t\n"),
975 b"789-10+changed".as_slice(),
976 ),
977 (
978 old.replacen("1\n", "changed\n", 1),
979 b"-1+changed234".as_slice(),
980 ),
981 (old.replace("10\n", "changed"), b"789-10+changed".as_slice()),
982 ] {
983 for algo in [HashAlgo::Sha1, HashAlgo::Sha256] {
984 let mut ctx = ChecksumHasher::new(algo);
985 patch_id_hash_text_hunks(&mut ctx, &old, &new);
986 assert_eq!(ctx.finalize(), algo.checksum_digest(expected));
987 }
988 }
989 }
990
991 #[test]
992 fn text_hunks_hash_overlapping_context_once() {
993 let old: String = (1..=20).map(|i| format!("{i}\n")).collect();
994 let new = old
995 .replacen("8\n", "first\n", 1)
996 .replace("12\n", "second\n");
997 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
998 patch_id_hash_text_hunks(&mut ctx, &old, &new);
999 assert_eq!(
1000 ctx.finalize(),
1001 HashAlgo::Sha1.checksum_digest(b"567-8+first91011-12+second131415")
1002 );
1003 }
1004
1005 #[test]
1006 fn verbatim_built_patch_text_matches_git_show_patch_id() {
1007 let dir = TempDir::new().expect("tempdir");
1008 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1009 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1010 git_in(dir.path(), &["add", "f.txt"]);
1011 git_in(dir.path(), &["commit", "-q", "-m", "base"]);
1012 std::fs::write(dir.path().join("f.txt"), b"one \ntwo\n").unwrap();
1013 git_in(dir.path(), &["add", "f.txt"]);
1014 git_in(dir.path(), &["commit", "-q", "-m", "change"]);
1015 let commit: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
1016 .trim()
1017 .parse()
1018 .unwrap();
1019 let git_show = git_in(dir.path(), &["show", "-p", "HEAD"]);
1020
1021 let repo = crate::repo::Repository::open(
1022 &dir.path().join(".git"),
1023 &RepositoryOptions::empty().with_work_tree(dir.path()),
1024 )
1025 .unwrap();
1026 let grit_verbatim =
1027 compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Verbatim)
1028 .expect("verbatim")
1029 .expect("some");
1030
1031 let from_git_show =
1032 compute_patch_ids_from_text(HashAlgo::Sha1, git_show.as_bytes(), PatchIdMode::Verbatim)
1033 .into_iter()
1034 .find(|(_, c)| *c == commit)
1035 .expect("commit in git show stream")
1036 .0;
1037
1038 assert_eq!(
1039 grit_verbatim, from_git_show,
1040 "ODB verbatim should match parsing git show -p"
1041 );
1042 }
1043
1044 #[test]
1045 fn stable_patch_ids_match_git_patch_id_on_log_p() {
1046 let dir = TempDir::new().expect("tempdir");
1047 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1048 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1049 git_in(dir.path(), &["add", "f.txt"]);
1050 git_in(dir.path(), &["commit", "-q", "-m", "first"]);
1051 std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
1052 git_in(dir.path(), &["add", "f.txt"]);
1053 git_in(dir.path(), &["commit", "-q", "-m", "second"]);
1054 std::fs::write(dir.path().join("g.txt"), b"new\n").unwrap();
1055 git_in(dir.path(), &["add", "g.txt"]);
1056 git_in(dir.path(), &["commit", "-q", "-m", "third"]);
1057
1058 let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
1059 let grit_ids =
1060 compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), PatchIdMode::Stable);
1061
1062 use std::io::Write;
1063 let mut child = Command::new("git")
1064 .current_dir(dir.path())
1065 .args(["patch-id", "--stable"])
1066 .stdin(std::process::Stdio::piped())
1067 .stdout(std::process::Stdio::piped())
1068 .stderr(std::process::Stdio::piped())
1069 .spawn()
1070 .expect("spawn git patch-id");
1071 child
1072 .stdin
1073 .take()
1074 .expect("stdin")
1075 .write_all(log_p.as_bytes())
1076 .expect("write log");
1077 let git_out = child.wait_with_output().expect("wait");
1078 assert!(
1079 git_out.status.success(),
1080 "git patch-id --stable failed: {}",
1081 String::from_utf8_lossy(&git_out.stderr)
1082 );
1083 let git_lines: Vec<(ObjectId, ObjectId)> = String::from_utf8_lossy(&git_out.stdout)
1084 .lines()
1085 .filter(|l| !l.is_empty())
1086 .map(|line| {
1087 let mut parts = line.split_whitespace();
1088 let patch_hex = parts.next().expect("patch id");
1089 let commit_hex = parts.next().expect("commit id");
1090 let patch = ObjectId::from_hex(patch_hex).expect("patch hex");
1091 let commit = ObjectId::from_hex(commit_hex).expect("commit hex");
1092 (patch, commit)
1093 })
1094 .collect();
1095
1096 assert_eq!(grit_ids.len(), git_lines.len(), "patch-id count mismatch");
1097 for ((g_patch, g_commit), (git_patch, git_commit)) in grit_ids.iter().zip(git_lines.iter())
1098 {
1099 assert_eq!(g_patch, git_patch, "patch-id mismatch");
1100 assert_eq!(g_commit, git_commit, "commit-id mismatch");
1101 }
1102 }
1103
1104 fn git_patch_ids_from_log(dir: &std::path::Path, mode_flag: &str) -> Vec<(ObjectId, ObjectId)> {
1105 let log_p = git_in(dir, &["log", "-p", "--reverse"]);
1106 use std::io::Write;
1107 let mut child = Command::new("git")
1108 .current_dir(dir)
1109 .args(["patch-id", mode_flag])
1110 .stdin(std::process::Stdio::piped())
1111 .stdout(std::process::Stdio::piped())
1112 .stderr(std::process::Stdio::piped())
1113 .spawn()
1114 .expect("spawn git patch-id");
1115 child
1116 .stdin
1117 .take()
1118 .expect("stdin")
1119 .write_all(log_p.as_bytes())
1120 .expect("write log");
1121 let out = child.wait_with_output().expect("wait");
1122 assert!(
1123 out.status.success(),
1124 "git patch-id failed: {}",
1125 String::from_utf8_lossy(&out.stderr)
1126 );
1127 String::from_utf8_lossy(&out.stdout)
1128 .lines()
1129 .filter(|l| !l.is_empty())
1130 .map(|line| {
1131 let mut parts = line.split_whitespace();
1132 let patch_hex = parts.next().expect("patch id");
1133 let commit_hex = parts.next().expect("commit id");
1134 (
1135 ObjectId::from_hex(patch_hex).expect("patch hex"),
1136 ObjectId::from_hex(commit_hex).expect("commit hex"),
1137 )
1138 })
1139 .collect()
1140 }
1141
1142 #[test]
1143 fn t4204_patch_id_modes_match_git() {
1144 let dir = TempDir::new().expect("tempdir");
1145 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1146 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1147 git_in(dir.path(), &["add", "f.txt"]);
1148 git_in(dir.path(), &["commit", "-q", "-m", "first"]);
1149 std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
1150 git_in(dir.path(), &["add", "f.txt"]);
1151 git_in(dir.path(), &["commit", "-q", "-m", "second"]);
1152 std::fs::write(dir.path().join("g.bin"), &[0u8, 1, 2, 3]).unwrap();
1153 git_in(dir.path(), &["add", "g.bin"]);
1154 git_in(dir.path(), &["commit", "-q", "-m", "binary"]);
1155 std::fs::write(dir.path().join("h.txt"), b"a\n").unwrap();
1156 git_in(dir.path(), &["add", "h.txt"]);
1157 git_in(dir.path(), &["commit", "-q", "-m", "add-h"]);
1158 git_in(dir.path(), &["mv", "h.txt", "i.txt"]);
1159 git_in(dir.path(), &["commit", "-q", "-m", "rename"]);
1160 #[cfg(unix)]
1161 {
1162 use std::os::unix::fs::PermissionsExt;
1163 let mut perms = std::fs::metadata(dir.path().join("f.txt"))
1164 .expect("f.txt")
1165 .permissions();
1166 perms.set_mode(0o100755);
1167 std::fs::set_permissions(dir.path().join("f.txt"), perms).expect("chmod f.txt");
1168 }
1169 git_in(dir.path(), &["add", "f.txt"]);
1170 git_in(dir.path(), &["commit", "-q", "-m", "mode-only"]);
1171 let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
1172
1173 for (mode, flag) in [
1174 (PatchIdMode::Stable, "--stable"),
1175 (PatchIdMode::Unstable, "--unstable"),
1176 (PatchIdMode::Verbatim, "--verbatim"),
1177 ] {
1178 let grit_ids = compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), mode);
1179 let git_ids = git_patch_ids_from_log(dir.path(), flag);
1180 assert_eq!(grit_ids.len(), git_ids.len(), "patch-id count for {flag:?}");
1181 for ((g_patch, g_commit), (git_patch, git_commit)) in
1182 grit_ids.iter().zip(git_ids.iter())
1183 {
1184 assert_eq!(g_patch, git_patch, "patch-id {flag}");
1185 assert_eq!(g_commit, git_commit, "commit-id {flag}");
1186 }
1187 }
1188
1189 let repo = crate::repo::Repository::open(
1190 &dir.path().join(".git"),
1191 &RepositoryOptions::empty().with_work_tree(dir.path()),
1192 )
1193 .unwrap();
1194 let git_stable = git_patch_ids_from_log(dir.path(), "--stable");
1195 let assert_odb_matches_git = |commit_hex: &str, label: &str| {
1196 let commit: ObjectId = commit_hex.trim().parse().expect("commit oid");
1197 let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Stable)
1198 .expect("patch")
1199 .expect("some");
1200 let git_patch = git_stable
1201 .iter()
1202 .find(|(_, c)| c == &commit)
1203 .expect(label)
1204 .0;
1205 assert_eq!(grit, git_patch, "{label}");
1206 };
1207
1208 let second = git_in(
1209 dir.path(),
1210 &["rev-list", "--max-count=1", "--skip=4", "HEAD"],
1211 );
1212 assert_odb_matches_git(&second, "git patch-id for second commit");
1213 let binary = git_in(
1214 dir.path(),
1215 &["rev-list", "--max-count=1", "--skip=3", "HEAD"],
1216 );
1217 assert_odb_matches_git(&binary, "git patch-id for binary commit");
1218 let rename = git_in(
1219 dir.path(),
1220 &["rev-list", "--max-count=1", "--skip=1", "HEAD"],
1221 );
1222 assert_odb_matches_git(&rename, "git patch-id for rename commit");
1223 let mode_only = git_in(dir.path(), &["rev-parse", "HEAD"]);
1224 assert_odb_matches_git(&mode_only, "git patch-id for mode-only commit");
1225
1226 let add_h = git_in(
1227 dir.path(),
1228 &["rev-list", "--max-count=1", "--skip=2", "HEAD"],
1229 );
1230 let add_h: ObjectId = add_h.trim().parse().unwrap();
1231 let filtered = compute_patch_id_for_paths(
1232 HashAlgo::Sha1,
1233 &repo.odb(),
1234 &add_h,
1235 &[String::from("h.txt")],
1236 PatchIdMode::Stable,
1237 )
1238 .expect("filtered h.txt")
1239 .expect("some h.txt");
1240 let git_h = git_stable
1241 .iter()
1242 .find(|(_, c)| c == &add_h)
1243 .expect("git patch-id for add-h")
1244 .0;
1245 assert_eq!(
1246 filtered, git_h,
1247 "ODB path-filter h.txt vs full commit patch-id"
1248 );
1249 assert!(
1250 compute_patch_id_for_paths(
1251 HashAlgo::Sha1,
1252 &repo.odb(),
1253 &add_h,
1254 &[String::from("f.txt")],
1255 PatchIdMode::Stable,
1256 )
1257 .expect("filtered f.txt")
1258 .is_none(),
1259 "path filter excluding all changes must be None"
1260 );
1261
1262 git_in(dir.path(), &["checkout", "-q", "HEAD~4"]);
1263 git_in(dir.path(), &["checkout", "-q", "-b", "side"]);
1264 std::fs::write(dir.path().join("side.txt"), b"s\n").unwrap();
1265 git_in(dir.path(), &["add", "side.txt"]);
1266 git_in(dir.path(), &["commit", "-q", "-m", "side"]);
1267 git_in(dir.path(), &["checkout", "-q", "main"]);
1268 git_in(dir.path(), &["merge", "-q", "--no-ff", "side"]);
1269 let merge_tip: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
1270 .trim()
1271 .parse()
1272 .unwrap();
1273 assert!(
1274 compute_patch_id(HashAlgo::Sha1, &repo.odb(), &merge_tip, PatchIdMode::Stable)
1275 .expect("merge")
1276 .is_none()
1277 );
1278 }
1279}