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 patch_id_write_line(out: &mut Vec<u8>, line: &str) {
559 out.extend_from_slice(line.as_bytes());
560 out.push(b'\n');
561}
562
563fn write_patch_id_binary_entry(out: &mut Vec<u8>, entry: &DiffEntry) -> Result<()> {
565 let old_path = entry
566 .old_path
567 .as_deref()
568 .or(entry.new_path.as_deref())
569 .unwrap_or("");
570 let new_path = entry
571 .new_path
572 .as_deref()
573 .or(entry.old_path.as_deref())
574 .unwrap_or("");
575 patch_id_write_line(out, &format!("diff --git a/{old_path} b/{new_path}"));
576 let old_mode = entry.old_mode.bits();
577 let new_mode = entry.new_mode.bits();
578 if old_mode == 0 {
579 patch_id_write_line(out, &format!("new file mode {}", entry.new_mode));
580 } else if new_mode == 0 {
581 patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode));
582 }
583 patch_id_write_line(
584 out,
585 &format!(
586 "index {}..{}",
587 patch_id_index_oid(&entry.old_oid),
588 patch_id_index_oid(&entry.new_oid),
589 ),
590 );
591 let old_side = entry
592 .old_path
593 .as_ref()
594 .map(|p| format!("a/{p}"))
595 .unwrap_or_else(|| "/dev/null".to_owned());
596 let new_side = entry
597 .new_path
598 .as_ref()
599 .map(|p| format!("b/{p}"))
600 .unwrap_or_else(|| "/dev/null".to_owned());
601 patch_id_write_line(
602 out,
603 &format!("Binary files {old_side} and {new_side} differ"),
604 );
605 Ok(())
606}
607
608fn patch_id_index_oid(oid: &ObjectId) -> String {
609 if *oid == zero_oid() {
610 return "0000000".to_owned();
611 }
612 oid.to_hex().chars().take(7).collect()
613}
614
615fn patch_id_add_string(ctx: &mut ChecksumHasher, s: &[u8]) {
616 ctx.update(s);
617}
618
619fn patch_id_add_mode(ctx: &mut ChecksumHasher, mode: u32) {
620 let text = format!("{mode:06o}");
621 ctx.update(text.as_bytes());
622}
623
624fn patch_id_build_git_show_patch(
626 odb: &Odb,
627 commit_oid: &ObjectId,
628 diffs: &[DiffEntry],
629) -> Result<Vec<u8>> {
630 let mut patch = Vec::new();
631 patch_id_write_line(&mut patch, &format!("commit {}", commit_oid.to_hex()));
632 for entry in diffs {
633 patch_id_append_git_show_entry(odb, entry, &mut patch)?;
634 }
635 Ok(patch)
636}
637
638fn patch_id_append_git_show_entry(odb: &Odb, entry: &DiffEntry, out: &mut Vec<u8>) -> Result<()> {
639 let git_old = entry
640 .old_path
641 .as_deref()
642 .or(entry.new_path.as_deref())
643 .unwrap_or("dev/null");
644 let git_new = entry
645 .new_path
646 .as_deref()
647 .or(entry.old_path.as_deref())
648 .unwrap_or("dev/null");
649 patch_id_write_line(out, &format!("diff --git a/{git_old} b/{git_new}"));
650
651 let old_mode = entry.old_mode.bits();
652 let new_mode = entry.new_mode.bits();
653 if old_mode == 0 {
654 patch_id_write_line(out, &format!("new file mode {}", entry.new_mode));
655 } else if new_mode == 0 {
656 patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode));
657 }
658
659 if entry.status == DiffStatus::Renamed {
660 let score = entry.score.unwrap_or(100);
661 patch_id_write_line(out, &format!("similarity index {score}%"));
662 if let Some(path) = &entry.old_path {
663 patch_id_write_line(out, &format!("rename from {path}"));
664 }
665 if let Some(path) = &entry.new_path {
666 patch_id_write_line(out, &format!("rename to {path}"));
667 }
668 } else if old_mode != 0 && new_mode != 0 && old_mode != new_mode {
669 patch_id_write_line(out, &format!("old mode {}", entry.old_mode));
670 patch_id_write_line(out, &format!("new mode {}", entry.new_mode));
671 }
672
673 let index_mode = if new_mode != 0 {
674 entry.new_mode
675 } else {
676 entry.old_mode
677 };
678 patch_id_write_line(
679 out,
680 &format!(
681 "index {}..{} {}",
682 patch_id_index_oid(&entry.old_oid),
683 patch_id_index_oid(&entry.new_oid),
684 index_mode,
685 ),
686 );
687
688 if entry.old_oid == entry.new_oid {
689 return Ok(());
690 }
691
692 let old_bytes = read_blob(odb, &entry.old_oid)?;
693 let new_bytes = read_blob(odb, &entry.new_oid)?;
694
695 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
696 let old_side = if old_mode == 0 {
697 "/dev/null".to_owned()
698 } else {
699 format!("a/{git_old}")
700 };
701 let new_side = if new_mode == 0 {
702 "/dev/null".to_owned()
703 } else {
704 format!("b/{git_new}")
705 };
706 patch_id_write_line(
707 out,
708 &format!("Binary files {old_side} and {new_side} differ"),
709 );
710 return Ok(());
711 }
712
713 let old_str = std::str::from_utf8(&old_bytes).unwrap_or("");
714 let new_str = std::str::from_utf8(&new_bytes).unwrap_or("");
715 let old_path = if old_mode == 0 {
716 "/dev/null".to_owned()
717 } else {
718 format!("a/{git_old}")
719 };
720 let new_path = if new_mode == 0 {
721 "/dev/null".to_owned()
722 } else {
723 format!("b/{git_new}")
724 };
725 let unified = unified_diff_histogram_with_prefix_and_funcname(
726 old_str, new_str, &old_path, &new_path, 3, 0, "", "", None, false, false,
727 );
728 out.extend_from_slice(unified.as_bytes());
729 Ok(())
730}
731
732fn patch_id_hash_diff_entry(odb: &Odb, entry: &DiffEntry, ctx: &mut ChecksumHasher) -> Result<()> {
734 if entry.status == DiffStatus::Renamed {
735 patch_id_hash_rename_entry_into_ctx(odb, entry, ctx)
736 } else {
737 patch_id_hash_file_entry_into_ctx(odb, entry, ctx)
738 }
739}
740
741fn patch_id_hash_file_entry_into_ctx(
743 odb: &Odb,
744 entry: &DiffEntry,
745 ctx: &mut ChecksumHasher,
746) -> Result<()> {
747 let old_path = entry
751 .old_path
752 .as_deref()
753 .or(entry.new_path.as_deref())
754 .unwrap_or("");
755 let new_path = entry
756 .new_path
757 .as_deref()
758 .or(entry.old_path.as_deref())
759 .unwrap_or("");
760 let mut old_path_buf = old_path.as_bytes().to_vec();
761 let mut new_path_buf = new_path.as_bytes().to_vec();
762 let len1 = remove_space_bytes(&mut old_path_buf);
763 let len2 = remove_space_bytes(&mut new_path_buf);
764
765 let old_mode = entry.old_mode.bits();
766 let new_mode = entry.new_mode.bits();
767
768 patch_id_add_string(ctx, b"diff--git");
769 patch_id_add_string(ctx, b"a/");
770 ctx.update(&old_path_buf[..len1]);
771 patch_id_add_string(ctx, b"b/");
772 ctx.update(&new_path_buf[..len2]);
773
774 if old_mode == 0 {
775 patch_id_add_string(ctx, b"newfilemode");
776 patch_id_add_mode(ctx, new_mode);
777 } else if new_mode == 0 {
778 patch_id_add_string(ctx, b"deletedfilemode");
779 patch_id_add_mode(ctx, old_mode);
780 } else if old_mode != new_mode {
781 patch_id_add_string(ctx, b"oldmode");
782 patch_id_add_mode(ctx, old_mode);
783 patch_id_add_string(ctx, b"newmode");
784 patch_id_add_mode(ctx, new_mode);
785 }
786
787 if entry.old_oid == entry.new_oid {
788 return Ok(());
789 }
790
791 let old_bytes = read_blob(odb, &entry.old_oid)?;
792 let new_bytes = read_blob(odb, &entry.new_oid)?;
793
794 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
795 let a = patch_id_index_oid(&entry.old_oid);
796 let b = patch_id_index_oid(&entry.new_oid);
797 ctx.update(a.as_bytes());
798 ctx.update(b.as_bytes());
799 } else {
800 let old_str = std::str::from_utf8(&old_bytes).unwrap_or("");
801 let new_str = std::str::from_utf8(&new_bytes).unwrap_or("");
802
803 if old_mode == 0 {
804 patch_id_add_string(ctx, b"---/dev/null");
805 patch_id_add_string(ctx, b"+++b/");
806 ctx.update(&new_path_buf[..len2]);
807 } else if new_mode == 0 {
808 patch_id_add_string(ctx, b"---a/");
809 ctx.update(&old_path_buf[..len1]);
810 patch_id_add_string(ctx, b"+++/dev/null");
811 } else {
812 patch_id_add_string(ctx, b"---a/");
813 ctx.update(&old_path_buf[..len1]);
814 patch_id_add_string(ctx, b"+++b/");
815 ctx.update(&new_path_buf[..len2]);
816 }
817
818 patch_id_hash_text_hunks(ctx, old_str, new_str);
819 }
820 Ok(())
821}
822
823fn patch_id_hash_rename_entry_into_ctx(
826 odb: &Odb,
827 entry: &DiffEntry,
828 ctx: &mut ChecksumHasher,
829) -> Result<()> {
830 let old_path = entry.old_path.as_deref().unwrap_or("");
831 let new_path = entry.new_path.as_deref().unwrap_or("");
832 let mut old_path_buf = old_path.as_bytes().to_vec();
833 let mut new_path_buf = new_path.as_bytes().to_vec();
834 let len1 = remove_space_bytes(&mut old_path_buf);
835 let len2 = remove_space_bytes(&mut new_path_buf);
836
837 patch_id_add_string(ctx, b"diff--git");
838 patch_id_add_string(ctx, b"a/");
839 ctx.update(&old_path_buf[..len1]);
840 patch_id_add_string(ctx, b"b/");
841 ctx.update(&new_path_buf[..len2]);
842
843 let score = entry.score.unwrap_or(100);
844 patch_id_add_string(ctx, b"similarityindex");
845 ctx.update(format!("{score}%").as_bytes());
846 patch_id_add_string(ctx, b"renamefrom");
847 ctx.update(&old_path_buf[..len1]);
848 patch_id_add_string(ctx, b"renameto");
849 ctx.update(&new_path_buf[..len2]);
850
851 let old_mode = entry.old_mode.bits();
852 let new_mode = entry.new_mode.bits();
853 if old_mode != new_mode {
854 patch_id_add_string(ctx, b"oldmode");
855 patch_id_add_mode(ctx, old_mode);
856 patch_id_add_string(ctx, b"newmode");
857 patch_id_add_mode(ctx, new_mode);
858 }
859
860 if entry.old_oid != entry.new_oid {
861 let old_bytes = read_blob(odb, &entry.old_oid)?;
862 let new_bytes = read_blob(odb, &entry.new_oid)?;
863 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
864 let a = entry.old_oid.to_hex();
865 let b = entry.new_oid.to_hex();
866 ctx.update(a.as_bytes());
867 ctx.update(b.as_bytes());
868 } else {
869 let old_str = std::str::from_utf8(&old_bytes).unwrap_or("");
870 let new_str = std::str::from_utf8(&new_bytes).unwrap_or("");
871 patch_id_add_string(ctx, b"---a/");
872 ctx.update(&old_path_buf[..len1]);
873 patch_id_add_string(ctx, b"+++b/");
874 ctx.update(&new_path_buf[..len2]);
875 patch_id_hash_text_hunks(ctx, old_str, new_str);
876 }
877 }
878
879 Ok(())
880}
881
882fn patch_id_hash_text_hunks(ctx: &mut ChecksumHasher, old_str: &str, new_str: &str) {
883 let diff = TextDiff::from_lines(old_str, new_str);
884 for change in diff
887 .grouped_ops(3)
888 .iter()
889 .flatten()
890 .flat_map(|op| diff.iter_changes(op))
891 {
892 let prefix = match change.tag() {
893 ChangeTag::Equal => b' ',
894 ChangeTag::Delete => b'-',
895 ChangeTag::Insert => b'+',
896 };
897 let text = change.as_str().unwrap_or("");
898 for piece in text.split_inclusive('\n') {
899 let line_body = piece.strip_suffix('\n').unwrap_or(piece);
900 let mut line_buf = Vec::with_capacity(1 + line_body.len() + 1);
901 line_buf.push(prefix);
902 line_buf.extend_from_slice(line_body.as_bytes());
903 line_buf.push(b'\n');
904 let n = remove_space_bytes(&mut line_buf);
905 ctx.update(&line_buf[..n]);
906 }
907 }
908}
909
910fn remove_space_bytes(buf: &mut [u8]) -> usize {
912 let mut dst = 0usize;
913 let mut src = 0usize;
914 while src < buf.len() {
915 let c = buf[src];
916 src += 1;
917 if !c.is_ascii_whitespace() {
918 buf[dst] = c;
919 dst += 1;
920 }
921 }
922 dst
923}
924
925fn read_blob(odb: &Odb, oid: &ObjectId) -> Result<Vec<u8>> {
929 if *oid == zero_oid() {
930 return Ok(Vec::new());
931 }
932 let obj = odb.read(oid)?;
933 Ok(obj.data)
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939 use crate::environment::RepositoryOptions;
940 use std::process::Command;
941
942 use tempfile::TempDir;
943
944 fn git_in(dir: &std::path::Path, args: &[&str]) -> String {
945 let out = Command::new("git")
946 .current_dir(dir)
947 .args(args)
948 .env("GIT_CONFIG_GLOBAL", "/dev/null")
949 .env("GIT_CONFIG_SYSTEM", "/dev/null")
950 .env("GIT_AUTHOR_NAME", "Test")
951 .env("GIT_AUTHOR_EMAIL", "t@example.com")
952 .env("GIT_COMMITTER_NAME", "Test")
953 .env("GIT_COMMITTER_EMAIL", "t@example.com")
954 .output()
955 .expect("spawn git");
956 assert!(
957 out.status.success(),
958 "git {:?} failed: {}",
959 args,
960 String::from_utf8_lossy(&out.stderr)
961 );
962 String::from_utf8_lossy(&out.stdout).into_owned()
963 }
964
965 #[cfg_attr(
966 not(feature = "full-tests"),
967 ignore = "starts system git or is heavy; run with --features full-tests"
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 #[cfg_attr(
1006 not(feature = "full-tests"),
1007 ignore = "starts system git or is heavy; run with --features full-tests"
1008 )]
1009 #[test]
1010 fn verbatim_built_patch_text_matches_git_show_patch_id() {
1011 let dir = TempDir::new().expect("tempdir");
1012 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1013 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1014 git_in(dir.path(), &["add", "f.txt"]);
1015 git_in(dir.path(), &["commit", "-q", "-m", "base"]);
1016 std::fs::write(dir.path().join("f.txt"), b"one \ntwo\n").unwrap();
1017 git_in(dir.path(), &["add", "f.txt"]);
1018 git_in(dir.path(), &["commit", "-q", "-m", "change"]);
1019 let commit: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
1020 .trim()
1021 .parse()
1022 .unwrap();
1023 let git_show = git_in(dir.path(), &["show", "-p", "HEAD"]);
1024
1025 let repo = crate::repo::Repository::open(
1026 &dir.path().join(".git"),
1027 &RepositoryOptions::empty().with_work_tree(dir.path()),
1028 )
1029 .unwrap();
1030 let grit_verbatim =
1031 compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Verbatim)
1032 .expect("verbatim")
1033 .expect("some");
1034
1035 let from_git_show =
1036 compute_patch_ids_from_text(HashAlgo::Sha1, git_show.as_bytes(), PatchIdMode::Verbatim)
1037 .into_iter()
1038 .find(|(_, c)| *c == commit)
1039 .expect("commit in git show stream")
1040 .0;
1041
1042 assert_eq!(
1043 grit_verbatim, from_git_show,
1044 "ODB verbatim should match parsing git show -p"
1045 );
1046 }
1047
1048 #[cfg_attr(
1049 not(feature = "full-tests"),
1050 ignore = "starts system git or is heavy; run with --features full-tests"
1051 )]
1052 #[test]
1053 fn stable_patch_ids_match_git_patch_id_on_log_p() {
1054 let dir = TempDir::new().expect("tempdir");
1055 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1056 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1057 git_in(dir.path(), &["add", "f.txt"]);
1058 git_in(dir.path(), &["commit", "-q", "-m", "first"]);
1059 std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
1060 git_in(dir.path(), &["add", "f.txt"]);
1061 git_in(dir.path(), &["commit", "-q", "-m", "second"]);
1062 std::fs::write(dir.path().join("g.txt"), b"new\n").unwrap();
1063 git_in(dir.path(), &["add", "g.txt"]);
1064 git_in(dir.path(), &["commit", "-q", "-m", "third"]);
1065
1066 let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
1067 let grit_ids =
1068 compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), PatchIdMode::Stable);
1069
1070 use std::io::Write;
1071 let mut child = Command::new("git")
1072 .current_dir(dir.path())
1073 .args(["patch-id", "--stable"])
1074 .stdin(std::process::Stdio::piped())
1075 .stdout(std::process::Stdio::piped())
1076 .stderr(std::process::Stdio::piped())
1077 .spawn()
1078 .expect("spawn git patch-id");
1079 child
1080 .stdin
1081 .take()
1082 .expect("stdin")
1083 .write_all(log_p.as_bytes())
1084 .expect("write log");
1085 let git_out = child.wait_with_output().expect("wait");
1086 assert!(
1087 git_out.status.success(),
1088 "git patch-id --stable failed: {}",
1089 String::from_utf8_lossy(&git_out.stderr)
1090 );
1091 let git_lines: Vec<(ObjectId, ObjectId)> = String::from_utf8_lossy(&git_out.stdout)
1092 .lines()
1093 .filter(|l| !l.is_empty())
1094 .map(|line| {
1095 let mut parts = line.split_whitespace();
1096 let patch_hex = parts.next().expect("patch id");
1097 let commit_hex = parts.next().expect("commit id");
1098 let patch = ObjectId::from_hex(patch_hex).expect("patch hex");
1099 let commit = ObjectId::from_hex(commit_hex).expect("commit hex");
1100 (patch, commit)
1101 })
1102 .collect();
1103
1104 assert_eq!(grit_ids.len(), git_lines.len(), "patch-id count mismatch");
1105 for ((g_patch, g_commit), (git_patch, git_commit)) in grit_ids.iter().zip(git_lines.iter())
1106 {
1107 assert_eq!(g_patch, git_patch, "patch-id mismatch");
1108 assert_eq!(g_commit, git_commit, "commit-id mismatch");
1109 }
1110 }
1111
1112 fn git_patch_ids_from_log(dir: &std::path::Path, mode_flag: &str) -> Vec<(ObjectId, ObjectId)> {
1113 let log_p = git_in(dir, &["log", "-p", "--reverse"]);
1114 use std::io::Write;
1115 let mut child = Command::new("git")
1116 .current_dir(dir)
1117 .args(["patch-id", mode_flag])
1118 .stdin(std::process::Stdio::piped())
1119 .stdout(std::process::Stdio::piped())
1120 .stderr(std::process::Stdio::piped())
1121 .spawn()
1122 .expect("spawn git patch-id");
1123 child
1124 .stdin
1125 .take()
1126 .expect("stdin")
1127 .write_all(log_p.as_bytes())
1128 .expect("write log");
1129 let out = child.wait_with_output().expect("wait");
1130 assert!(
1131 out.status.success(),
1132 "git patch-id failed: {}",
1133 String::from_utf8_lossy(&out.stderr)
1134 );
1135 String::from_utf8_lossy(&out.stdout)
1136 .lines()
1137 .filter(|l| !l.is_empty())
1138 .map(|line| {
1139 let mut parts = line.split_whitespace();
1140 let patch_hex = parts.next().expect("patch id");
1141 let commit_hex = parts.next().expect("commit id");
1142 (
1143 ObjectId::from_hex(patch_hex).expect("patch hex"),
1144 ObjectId::from_hex(commit_hex).expect("commit hex"),
1145 )
1146 })
1147 .collect()
1148 }
1149
1150 #[cfg_attr(
1151 not(feature = "full-tests"),
1152 ignore = "starts system git or is heavy; run with --features full-tests"
1153 )]
1154 #[test]
1155 fn t4204_patch_id_modes_match_git() {
1156 let dir = TempDir::new().expect("tempdir");
1157 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1158 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1159 git_in(dir.path(), &["add", "f.txt"]);
1160 git_in(dir.path(), &["commit", "-q", "-m", "first"]);
1161 std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
1162 git_in(dir.path(), &["add", "f.txt"]);
1163 git_in(dir.path(), &["commit", "-q", "-m", "second"]);
1164 std::fs::write(dir.path().join("g.bin"), &[0u8, 1, 2, 3]).unwrap();
1165 git_in(dir.path(), &["add", "g.bin"]);
1166 git_in(dir.path(), &["commit", "-q", "-m", "binary"]);
1167 std::fs::write(dir.path().join("h.txt"), b"a\n").unwrap();
1168 git_in(dir.path(), &["add", "h.txt"]);
1169 git_in(dir.path(), &["commit", "-q", "-m", "add-h"]);
1170 git_in(dir.path(), &["mv", "h.txt", "i.txt"]);
1171 git_in(dir.path(), &["commit", "-q", "-m", "rename"]);
1172 #[cfg(unix)]
1173 {
1174 use std::os::unix::fs::PermissionsExt;
1175 let mut perms = std::fs::metadata(dir.path().join("f.txt"))
1176 .expect("f.txt")
1177 .permissions();
1178 perms.set_mode(0o100755);
1179 std::fs::set_permissions(dir.path().join("f.txt"), perms).expect("chmod f.txt");
1180 }
1181 git_in(dir.path(), &["add", "f.txt"]);
1182 git_in(dir.path(), &["commit", "-q", "-m", "mode-only"]);
1183 let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
1184
1185 for (mode, flag) in [
1186 (PatchIdMode::Stable, "--stable"),
1187 (PatchIdMode::Unstable, "--unstable"),
1188 (PatchIdMode::Verbatim, "--verbatim"),
1189 ] {
1190 let grit_ids = compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), mode);
1191 let git_ids = git_patch_ids_from_log(dir.path(), flag);
1192 assert_eq!(grit_ids.len(), git_ids.len(), "patch-id count for {flag:?}");
1193 for ((g_patch, g_commit), (git_patch, git_commit)) in
1194 grit_ids.iter().zip(git_ids.iter())
1195 {
1196 assert_eq!(g_patch, git_patch, "patch-id {flag}");
1197 assert_eq!(g_commit, git_commit, "commit-id {flag}");
1198 }
1199 }
1200
1201 let repo = crate::repo::Repository::open(
1202 &dir.path().join(".git"),
1203 &RepositoryOptions::empty().with_work_tree(dir.path()),
1204 )
1205 .unwrap();
1206 let git_stable = git_patch_ids_from_log(dir.path(), "--stable");
1207 let assert_odb_matches_git = |commit_hex: &str, label: &str| {
1208 let commit: ObjectId = commit_hex.trim().parse().expect("commit oid");
1209 let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Stable)
1210 .expect("patch")
1211 .expect("some");
1212 let git_patch = git_stable
1213 .iter()
1214 .find(|(_, c)| c == &commit)
1215 .expect(label)
1216 .0;
1217 assert_eq!(grit, git_patch, "{label}");
1218 };
1219
1220 let second = git_in(
1221 dir.path(),
1222 &["rev-list", "--max-count=1", "--skip=4", "HEAD"],
1223 );
1224 assert_odb_matches_git(&second, "git patch-id for second commit");
1225 let binary = git_in(
1226 dir.path(),
1227 &["rev-list", "--max-count=1", "--skip=3", "HEAD"],
1228 );
1229 assert_odb_matches_git(&binary, "git patch-id for binary commit");
1230 let rename = git_in(
1231 dir.path(),
1232 &["rev-list", "--max-count=1", "--skip=1", "HEAD"],
1233 );
1234 assert_odb_matches_git(&rename, "git patch-id for rename commit");
1235 let mode_only = git_in(dir.path(), &["rev-parse", "HEAD"]);
1236 assert_odb_matches_git(&mode_only, "git patch-id for mode-only commit");
1237
1238 let add_h = git_in(
1239 dir.path(),
1240 &["rev-list", "--max-count=1", "--skip=2", "HEAD"],
1241 );
1242 let add_h: ObjectId = add_h.trim().parse().unwrap();
1243 let filtered = compute_patch_id_for_paths(
1244 HashAlgo::Sha1,
1245 &repo.odb(),
1246 &add_h,
1247 &[String::from("h.txt")],
1248 PatchIdMode::Stable,
1249 )
1250 .expect("filtered h.txt")
1251 .expect("some h.txt");
1252 let git_h = git_stable
1253 .iter()
1254 .find(|(_, c)| c == &add_h)
1255 .expect("git patch-id for add-h")
1256 .0;
1257 assert_eq!(
1258 filtered, git_h,
1259 "ODB path-filter h.txt vs full commit patch-id"
1260 );
1261 assert!(
1262 compute_patch_id_for_paths(
1263 HashAlgo::Sha1,
1264 &repo.odb(),
1265 &add_h,
1266 &[String::from("f.txt")],
1267 PatchIdMode::Stable,
1268 )
1269 .expect("filtered f.txt")
1270 .is_none(),
1271 "path filter excluding all changes must be None"
1272 );
1273
1274 git_in(dir.path(), &["checkout", "-q", "HEAD~4"]);
1275 git_in(dir.path(), &["checkout", "-q", "-b", "side"]);
1276 std::fs::write(dir.path().join("side.txt"), b"s\n").unwrap();
1277 git_in(dir.path(), &["add", "side.txt"]);
1278 git_in(dir.path(), &["commit", "-q", "-m", "side"]);
1279 git_in(dir.path(), &["checkout", "-q", "main"]);
1280 git_in(dir.path(), &["merge", "-q", "--no-ff", "side"]);
1281 let merge_tip: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
1282 .trim()
1283 .parse()
1284 .unwrap();
1285 assert!(
1286 compute_patch_id(HashAlgo::Sha1, &repo.odb(), &merge_tip, PatchIdMode::Stable)
1287 .expect("merge")
1288 .is_none()
1289 );
1290 }
1291}