1use crate::diff::{
22 diff_trees_with_renames, gitlink_line, structured::text_bytes_for_diff,
23 unified_diff_with_prefix_and_funcname, unified_hunk_body_for_diff_options, zero_oid, DiffEntry,
24 DiffOptions, DiffStatus,
25};
26use crate::error::Result;
27use crate::hash::ChecksumHasher;
28use crate::merge_file;
29use crate::objects::{CommitRef, FileMode, HashAlgo, ObjectId, ObjectKind};
30use crate::odb::Odb;
31
32const PATCH_ID_DIGEST_MAX: usize = 32;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum PatchIdMode {
37 Unstable,
40 Stable,
43 Verbatim,
46}
47
48pub fn compute_patch_ids_from_text(
66 algo: HashAlgo,
67 input: &[u8],
68 mode: PatchIdMode,
69) -> Vec<(ObjectId, ObjectId)> {
70 let stable = mode != PatchIdMode::Unstable;
71 let verbatim = mode == PatchIdMode::Verbatim;
72 let digest_len = algo.len();
73
74 let mut results: Vec<(ObjectId, ObjectId)> = Vec::new();
75
76 let mut ctx = ChecksumHasher::new(algo);
78 let mut result = [0u8; PATCH_ID_DIGEST_MAX];
79 let mut patchlen: usize = 0;
80 let mut before: i32 = -1;
82 let mut after: i32 = -1;
83 let mut diff_is_binary = false;
84 let mut pre_oid_str = String::new();
85 let mut post_oid_str = String::new();
86 let mut current_commit: Option<ObjectId> = None;
87 let mut implicit_commit = true;
90
91 let lines = split_lines_with_nl(input);
94
95 let mut i = 0;
96 while i < lines.len() {
97 let raw = lines[i];
98 i += 1;
99
100 let line = std::str::from_utf8(raw).unwrap_or("");
102
103 let oid_candidate: Option<&str> = if let Some(rest) = line.strip_prefix("commit ") {
105 Some(rest)
106 } else if let Some(rest) = line.strip_prefix("From ") {
107 Some(rest)
108 } else {
109 None
110 };
111
112 if let Some(candidate) = oid_candidate {
113 if let Some(oid) = try_parse_oid_prefix(candidate, algo) {
114 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
116 if patchlen > 0 {
117 if let Some(coid) = current_commit.take() {
118 if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
119 results.push((pid, coid));
120 }
121 }
122 }
123 result = [0u8; PATCH_ID_DIGEST_MAX];
125 ctx = ChecksumHasher::new(algo);
126 patchlen = 0;
127 before = -1;
128 after = -1;
129 diff_is_binary = false;
130 pre_oid_str.clear();
131 post_oid_str.clear();
132 current_commit = Some(oid);
133 implicit_commit = false;
134 continue;
135 }
136 }
137
138 if line.starts_with("\\ ") && line.len() > 12 {
140 if verbatim {
141 ctx.update(raw);
142 patchlen += raw.len();
143 }
144 continue;
145 }
146
147 if patchlen == 0 && !line.starts_with("diff ") {
149 continue;
150 }
151
152 if implicit_commit && line.starts_with("diff ") && current_commit.is_none() {
155 current_commit = Some(ObjectId::zero());
156 implicit_commit = false;
157 }
158
159 if before == -1 {
161 if line.starts_with("GIT binary patch") || line.starts_with("Binary files") {
162 diff_is_binary = true;
164 before = 0;
165 let pre = pre_oid_str.clone();
166 let post = post_oid_str.clone();
167 ctx.update(pre.as_bytes());
168 ctx.update(post.as_bytes());
169 patchlen += pre.len() + post.len();
170 if stable {
171 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
172 }
173 continue;
174 } else if let Some(rest) = line.strip_prefix("index ") {
175 if let Some(dd) = rest.find("..") {
177 pre_oid_str = rest[..dd].to_owned();
178 let tail = &rest[dd + 2..];
179 let end = tail
180 .find(|c: char| c.is_ascii_whitespace())
181 .unwrap_or_else(|| {
182 tail.trim_end_matches('\n').trim_end_matches('\r').len()
183 });
184 post_oid_str = tail[..end].to_owned();
185 }
186 continue;
187 } else if line.starts_with("--- ") {
188 before = 1;
189 after = 1;
190 } else if !line.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
192 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
195 if patchlen > 0 {
196 if let Some(coid) = current_commit.take() {
197 if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
198 results.push((pid, coid));
199 }
200 }
201 }
202 result = [0u8; PATCH_ID_DIGEST_MAX];
203 ctx = ChecksumHasher::new(algo);
204 patchlen = 0;
205 before = -1;
206 after = -1;
207 diff_is_binary = false;
208 continue;
209 }
210 }
211
212 if diff_is_binary {
214 if line.starts_with("diff ") {
215 diff_is_binary = false;
216 before = -1;
217 i -= 1; }
220 continue;
221 }
222
223 if before == 0 && after == 0 {
225 if line.starts_with("@@ -") {
226 let (b, a) = scan_hunk_header(line);
227 before = b;
228 after = a;
229 continue;
230 }
231 if !line.starts_with("diff ") {
232 continue;
235 }
236 if stable {
238 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
239 }
240 before = -1;
241 after = -1;
242 i -= 1;
244 continue;
245 }
246
247 let first = raw.first().copied().unwrap_or(b' ');
249 if first == b'-' || first == b' ' {
250 before -= 1;
251 }
252 if first == b'+' || first == b' ' {
253 after -= 1;
254 }
255
256 let hashed = if verbatim {
258 ctx.update(raw);
259 raw.len()
260 } else {
261 hash_without_whitespace(&mut ctx, raw)
262 };
263 patchlen += hashed;
264 }
265
266 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
268 if patchlen > 0 {
269 if let Some(coid) = current_commit {
270 if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
271 results.push((pid, coid));
272 }
273 }
274 }
275
276 results
277}
278
279fn text_flush_one_hunk(
282 result: &mut [u8],
283 digest_len: usize,
284 ctx: &mut ChecksumHasher,
285 algo: HashAlgo,
286) {
287 let mut hash_buf = [0u8; PATCH_ID_DIGEST_MAX];
288 let old = std::mem::replace(ctx, ChecksumHasher::new(algo));
289 let _ = old.finalize_into(&mut hash_buf);
290 let mut carry: u16 = 0;
291 for i in 0..digest_len {
292 carry = carry + result[i] as u16 + hash_buf[i] as u16;
293 result[i] = carry as u8;
294 carry >>= 8;
295 }
296}
297
298fn is_patch_id_space(b: &u8) -> bool {
303 matches!(b, b' ' | b'\t' | b'\n' | b'\r')
304}
305
306fn hash_without_whitespace(ctx: &mut ChecksumHasher, raw: &[u8]) -> usize {
310 raw.split(is_patch_id_space)
311 .filter(|run| !run.is_empty())
312 .map(|run| {
313 ctx.update(run);
314 run.len()
315 })
316 .sum()
317}
318
319fn try_parse_oid_prefix(s: &str, algo: HashAlgo) -> Option<ObjectId> {
324 let s = s.trim_end_matches('\n').trim_end_matches('\r');
325 let hex_len = algo.len() * 2;
326 if s.len() < hex_len {
327 return None;
328 }
329 let hex = &s[..hex_len];
330 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
331 return None;
332 }
333 if s.len() > hex_len && !s.as_bytes()[hex_len].is_ascii_whitespace() {
334 return None;
335 }
336 let mut bytes = [0u8; PATCH_ID_DIGEST_MAX];
337 for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
338 let hi = hex_val(chunk[0])?;
339 let lo = hex_val(chunk[1])?;
340 bytes[i] = (hi << 4) | lo;
341 }
342 ObjectId::from_bytes(&bytes[..algo.len()]).ok()
343}
344
345fn hex_val(b: u8) -> Option<u8> {
347 match b {
348 b'0'..=b'9' => Some(b - b'0'),
349 b'a'..=b'f' => Some(b - b'a' + 10),
350 b'A'..=b'F' => Some(b - b'A' + 10),
351 _ => None,
352 }
353}
354
355fn scan_hunk_header(line: &str) -> (i32, i32) {
359 let rest = match line.strip_prefix("@@ -") {
361 Some(r) => r,
362 None => return (1, 1),
363 };
364 let before = parse_hunk_count(rest);
366 let after = rest
368 .find(" +")
369 .and_then(|p| parse_hunk_count_opt(&rest[p + 2..]))
370 .unwrap_or(1);
371 (before, after)
372}
373
374fn parse_hunk_count(s: &str) -> i32 {
376 let after_start = s.trim_start_matches(|c: char| c.is_ascii_digit());
378 if let Some(rest) = after_start.strip_prefix(',') {
379 rest.split(|c: char| !c.is_ascii_digit())
380 .next()
381 .and_then(|n| n.parse().ok())
382 .unwrap_or(1)
383 } else {
384 1
385 }
386}
387
388fn parse_hunk_count_opt(s: &str) -> Option<i32> {
390 Some(parse_hunk_count(s))
391}
392
393fn split_lines_with_nl(input: &[u8]) -> Vec<&[u8]> {
398 let mut lines = Vec::new();
399 let mut start = 0;
400 for (i, &b) in input.iter().enumerate() {
401 if b == b'\n' {
402 lines.push(&input[start..=i]);
403 start = i + 1;
404 }
405 }
406 if start < input.len() {
407 lines.push(&input[start..]);
408 }
409 lines
410}
411
412pub fn compute_patch_id(
439 algo: HashAlgo,
440 odb: &Odb,
441 commit_oid: &ObjectId,
442 mode: PatchIdMode,
443) -> Result<Option<ObjectId>> {
444 compute_patch_id_filtered(algo, odb, commit_oid, &[], mode)
445}
446
447pub fn compute_patch_id_for_paths(
464 algo: HashAlgo,
465 odb: &Odb,
466 commit_oid: &ObjectId,
467 paths: &[String],
468 mode: PatchIdMode,
469) -> Result<Option<ObjectId>> {
470 compute_patch_id_filtered(algo, odb, commit_oid, paths, mode)
471}
472
473fn compute_patch_id_filtered(
474 algo: HashAlgo,
475 odb: &Odb,
476 commit_oid: &ObjectId,
477 paths: &[String],
478 mode: PatchIdMode,
479) -> Result<Option<ObjectId>> {
480 let per_file_flush = mode != PatchIdMode::Unstable;
481 let digest_len = algo.len();
482 let obj = odb.read(commit_oid)?;
483 if obj.kind != ObjectKind::Commit {
484 return Ok(None);
485 }
486 let commit = CommitRef::parse(&obj.data)?;
487
488 if commit.parents().len() > 1 {
490 return Ok(None);
491 }
492
493 let parent_tree_oid = if commit.parents().is_empty() {
495 None
496 } else {
497 let parent_obj = odb.read(&commit.parents()[0])?;
498 let parent_commit = CommitRef::parse(&parent_obj.data)?;
499 Some(parent_commit.tree())
500 };
501
502 let mut diffs =
504 diff_trees_with_renames(odb, parent_tree_oid.as_ref(), Some(&commit.tree()), "", 50)?;
505 if !paths.is_empty() {
506 diffs.retain(|entry| diff_entry_matches_paths(entry, paths));
507 }
508 if diffs.is_empty() {
509 return Ok(None);
510 }
511 let mut diffs = split_type_changes(diffs);
512
513 diffs.sort_by(|a, b| a.path().cmp(b.path()));
516
517 if mode == PatchIdMode::Verbatim {
518 let patch = patch_id_build_git_show_patch(odb, commit_oid, &diffs)?;
519 let ids = compute_patch_ids_from_text(algo, &patch, mode);
520 return Ok(ids.into_iter().next().map(|(patch_id, _)| patch_id));
521 }
522
523 if diffs.len() == 1 {
524 let entry = &diffs[0];
525 if entry.old_oid != entry.new_oid {
526 let (old_bytes, new_bytes) = read_sides(odb, entry)?;
527 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
528 let mut patch = Vec::new();
529 patch_id_write_line(&mut patch, &format!("commit {}", commit_oid.to_hex()));
530 write_patch_id_binary_entry(&mut patch, entry)?;
531 let ids = compute_patch_ids_from_text(algo, &patch, mode);
532 return Ok(ids.into_iter().next().map(|(patch_id, _)| patch_id));
533 }
534 }
535 }
536
537 let mut result = [0u8; PATCH_ID_DIGEST_MAX];
538 let mut ctx = ChecksumHasher::new(algo);
539 let mut ended_with_body = false;
543 for entry in &diffs {
544 if per_file_flush && ended_with_body {
545 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
546 }
547 patch_id_hash_diff_entry(odb, entry, &mut ctx)?;
548 ended_with_body = patch_id_has_content_diff(entry);
549 }
550 text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
551
552 if result.iter().take(digest_len).all(|&b| b == 0) {
553 return Ok(None);
554 }
555 ObjectId::from_bytes(&result[..digest_len]).map(Some)
556}
557
558fn split_type_changes(diffs: Vec<DiffEntry>) -> Vec<DiffEntry> {
562 if diffs.iter().all(|e| e.status != DiffStatus::TypeChanged) {
563 return diffs;
564 }
565 let mut out = Vec::with_capacity(diffs.len());
566 for entry in diffs {
567 if entry.status != DiffStatus::TypeChanged {
568 out.push(entry);
569 continue;
570 }
571 out.push(DiffEntry {
572 status: DiffStatus::Deleted,
573 new_path: None,
574 new_mode: FileMode::ABSENT,
575 new_oid: zero_oid(),
576 ..entry.clone()
577 });
578 out.push(DiffEntry {
579 status: DiffStatus::Added,
580 old_path: None,
581 old_mode: FileMode::ABSENT,
582 old_oid: zero_oid(),
583 ..entry
584 });
585 }
586 out
587}
588
589fn diff_entry_matches_paths(entry: &DiffEntry, paths: &[String]) -> bool {
590 entry
591 .old_path
592 .as_deref()
593 .into_iter()
594 .chain(entry.new_path.as_deref())
595 .any(|path| crate::pathspec::matches_pathspec_list(path, paths))
596}
597
598fn patch_id_write_line(out: &mut Vec<u8>, line: &str) {
600 out.extend_from_slice(line.as_bytes());
601 out.push(b'\n');
602}
603
604fn write_patch_id_binary_entry(out: &mut Vec<u8>, entry: &DiffEntry) -> Result<()> {
606 let old_path = entry
607 .old_path
608 .as_deref()
609 .or(entry.new_path.as_deref())
610 .unwrap_or("");
611 let new_path = entry
612 .new_path
613 .as_deref()
614 .or(entry.old_path.as_deref())
615 .unwrap_or("");
616 patch_id_write_line(out, &format!("diff --git a/{old_path} b/{new_path}"));
617 let old_mode = entry.old_mode.bits();
618 let new_mode = entry.new_mode.bits();
619 if old_mode == 0 {
620 patch_id_write_line(out, &format!("new file mode {}", entry.new_mode));
621 } else if new_mode == 0 {
622 patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode));
623 }
624 patch_id_write_line(
625 out,
626 &format!(
627 "index {}..{}",
628 patch_id_index_oid(&entry.old_oid),
629 patch_id_index_oid(&entry.new_oid),
630 ),
631 );
632 let old_side = entry
633 .old_path
634 .as_ref()
635 .map(|p| format!("a/{p}"))
636 .unwrap_or_else(|| "/dev/null".to_owned());
637 let new_side = entry
638 .new_path
639 .as_ref()
640 .map(|p| format!("b/{p}"))
641 .unwrap_or_else(|| "/dev/null".to_owned());
642 patch_id_write_line(
643 out,
644 &format!("Binary files {old_side} and {new_side} differ"),
645 );
646 Ok(())
647}
648
649fn patch_id_index_oid(oid: &ObjectId) -> String {
650 if *oid == zero_oid() {
651 return "0000000".to_owned();
652 }
653 oid.to_hex().chars().take(7).collect()
654}
655
656fn patch_id_add_string(ctx: &mut ChecksumHasher, s: &[u8]) {
657 ctx.update(s);
658}
659
660fn patch_id_add_mode(ctx: &mut ChecksumHasher, mode: u32) {
661 let text = format!("{mode:06o}");
662 ctx.update(text.as_bytes());
663}
664
665fn patch_id_build_git_show_patch(
667 odb: &Odb,
668 commit_oid: &ObjectId,
669 diffs: &[DiffEntry],
670) -> Result<Vec<u8>> {
671 let mut patch = Vec::new();
672 patch_id_write_line(&mut patch, &format!("commit {}", commit_oid.to_hex()));
673 for entry in diffs {
674 patch_id_append_git_show_entry(odb, entry, &mut patch)?;
675 }
676 Ok(patch)
677}
678
679fn patch_id_append_git_show_entry(odb: &Odb, entry: &DiffEntry, out: &mut Vec<u8>) -> Result<()> {
680 let git_old = entry
681 .old_path
682 .as_deref()
683 .or(entry.new_path.as_deref())
684 .unwrap_or("dev/null");
685 let git_new = entry
686 .new_path
687 .as_deref()
688 .or(entry.old_path.as_deref())
689 .unwrap_or("dev/null");
690 patch_id_write_line(out, &format!("diff --git a/{git_old} b/{git_new}"));
691
692 let old_mode = entry.old_mode.bits();
693 let new_mode = entry.new_mode.bits();
694 if old_mode == 0 {
695 patch_id_write_line(out, &format!("new file mode {}", entry.new_mode));
696 } else if new_mode == 0 {
697 patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode));
698 }
699
700 if entry.status == DiffStatus::Renamed {
701 let score = entry.score.unwrap_or(100);
702 patch_id_write_line(out, &format!("similarity index {score}%"));
703 if let Some(path) = &entry.old_path {
704 patch_id_write_line(out, &format!("rename from {path}"));
705 }
706 if let Some(path) = &entry.new_path {
707 patch_id_write_line(out, &format!("rename to {path}"));
708 }
709 } else if old_mode != 0 && new_mode != 0 && old_mode != new_mode {
710 patch_id_write_line(out, &format!("old mode {}", entry.old_mode));
711 patch_id_write_line(out, &format!("new mode {}", entry.new_mode));
712 }
713
714 let index_mode = if new_mode != 0 {
715 entry.new_mode
716 } else {
717 entry.old_mode
718 };
719 patch_id_write_line(
720 out,
721 &format!(
722 "index {}..{} {}",
723 patch_id_index_oid(&entry.old_oid),
724 patch_id_index_oid(&entry.new_oid),
725 index_mode,
726 ),
727 );
728
729 if !patch_id_has_content_diff(entry) {
730 return Ok(());
731 }
732
733 let (old_bytes, new_bytes) = read_sides(odb, entry)?;
734
735 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
736 let old_side = if old_mode == 0 {
737 "/dev/null".to_owned()
738 } else {
739 format!("a/{git_old}")
740 };
741 let new_side = if new_mode == 0 {
742 "/dev/null".to_owned()
743 } else {
744 format!("b/{git_new}")
745 };
746 patch_id_write_line(
747 out,
748 &format!("Binary files {old_side} and {new_side} differ"),
749 );
750 return Ok(());
751 }
752
753 let old_str = text_bytes_for_diff(&old_bytes);
754 let new_str = text_bytes_for_diff(&new_bytes);
755 let old_path = if old_mode == 0 {
756 "/dev/null".to_owned()
757 } else {
758 format!("a/{git_old}")
759 };
760 let new_path = if new_mode == 0 {
761 "/dev/null".to_owned()
762 } else {
763 format!("b/{git_new}")
764 };
765 let unified = unified_diff_with_prefix_and_funcname(
766 old_str.as_ref(),
767 new_str.as_ref(),
768 &old_path,
769 &new_path,
770 3,
771 0,
772 "",
773 "",
774 None,
775 true,
776 false,
777 );
778 out.extend_from_slice(unified.as_bytes());
779 Ok(())
780}
781
782fn patch_id_has_content_diff(entry: &DiffEntry) -> bool {
788 let empty = |oid: &ObjectId| oid.is_zero() || oid.is_empty_blob();
789 entry.old_oid != entry.new_oid && !(empty(&entry.old_oid) && empty(&entry.new_oid))
790}
791
792fn patch_id_hash_diff_entry(odb: &Odb, entry: &DiffEntry, ctx: &mut ChecksumHasher) -> Result<()> {
794 if entry.status == DiffStatus::Renamed {
795 patch_id_hash_rename_entry_into_ctx(odb, entry, ctx)
796 } else {
797 patch_id_hash_file_entry_into_ctx(odb, entry, ctx)
798 }
799}
800
801fn patch_id_hash_file_entry_into_ctx(
803 odb: &Odb,
804 entry: &DiffEntry,
805 ctx: &mut ChecksumHasher,
806) -> Result<()> {
807 let old_path = entry
811 .old_path
812 .as_deref()
813 .or(entry.new_path.as_deref())
814 .unwrap_or("");
815 let new_path = entry
816 .new_path
817 .as_deref()
818 .or(entry.old_path.as_deref())
819 .unwrap_or("");
820 let mut old_path_buf = old_path.as_bytes().to_vec();
821 let mut new_path_buf = new_path.as_bytes().to_vec();
822 let len1 = remove_space_bytes(&mut old_path_buf);
823 let len2 = remove_space_bytes(&mut new_path_buf);
824
825 let old_mode = entry.old_mode.bits();
826 let new_mode = entry.new_mode.bits();
827
828 patch_id_add_string(ctx, b"diff--git");
829 patch_id_add_string(ctx, b"a/");
830 ctx.update(&old_path_buf[..len1]);
831 patch_id_add_string(ctx, b"b/");
832 ctx.update(&new_path_buf[..len2]);
833
834 if old_mode == 0 {
835 patch_id_add_string(ctx, b"newfilemode");
836 patch_id_add_mode(ctx, new_mode);
837 } else if new_mode == 0 {
838 patch_id_add_string(ctx, b"deletedfilemode");
839 patch_id_add_mode(ctx, old_mode);
840 } else if old_mode != new_mode {
841 patch_id_add_string(ctx, b"oldmode");
842 patch_id_add_mode(ctx, old_mode);
843 patch_id_add_string(ctx, b"newmode");
844 patch_id_add_mode(ctx, new_mode);
845 }
846
847 if !patch_id_has_content_diff(entry) {
848 return Ok(());
849 }
850
851 let (old_bytes, new_bytes) = read_sides(odb, entry)?;
852
853 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
854 let a = patch_id_index_oid(&entry.old_oid);
855 let b = patch_id_index_oid(&entry.new_oid);
856 ctx.update(a.as_bytes());
857 ctx.update(b.as_bytes());
858 } else {
859 let old_str = text_bytes_for_diff(&old_bytes);
860 let new_str = text_bytes_for_diff(&new_bytes);
861
862 if old_mode == 0 {
863 patch_id_add_string(ctx, b"---/dev/null");
864 patch_id_add_string(ctx, b"+++b/");
865 ctx.update(&new_path_buf[..len2]);
866 } else if new_mode == 0 {
867 patch_id_add_string(ctx, b"---a/");
868 ctx.update(&old_path_buf[..len1]);
869 patch_id_add_string(ctx, b"+++/dev/null");
870 } else {
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 }
876
877 patch_id_hash_text_hunks(ctx, old_str.as_ref(), new_str.as_ref());
878 }
879 Ok(())
880}
881
882fn patch_id_hash_rename_entry_into_ctx(
885 odb: &Odb,
886 entry: &DiffEntry,
887 ctx: &mut ChecksumHasher,
888) -> Result<()> {
889 let old_path = entry.old_path.as_deref().unwrap_or("");
890 let new_path = entry.new_path.as_deref().unwrap_or("");
891 let mut old_path_buf = old_path.as_bytes().to_vec();
892 let mut new_path_buf = new_path.as_bytes().to_vec();
893 let len1 = remove_space_bytes(&mut old_path_buf);
894 let len2 = remove_space_bytes(&mut new_path_buf);
895
896 patch_id_add_string(ctx, b"diff--git");
897 patch_id_add_string(ctx, b"a/");
898 ctx.update(&old_path_buf[..len1]);
899 patch_id_add_string(ctx, b"b/");
900 ctx.update(&new_path_buf[..len2]);
901
902 let score = entry.score.unwrap_or(100);
903 patch_id_add_string(ctx, b"similarityindex");
904 ctx.update(format!("{score}%").as_bytes());
905 patch_id_add_string(ctx, b"renamefrom");
906 ctx.update(&old_path_buf[..len1]);
907 patch_id_add_string(ctx, b"renameto");
908 ctx.update(&new_path_buf[..len2]);
909
910 let old_mode = entry.old_mode.bits();
911 let new_mode = entry.new_mode.bits();
912 if old_mode != new_mode {
913 patch_id_add_string(ctx, b"oldmode");
914 patch_id_add_mode(ctx, old_mode);
915 patch_id_add_string(ctx, b"newmode");
916 patch_id_add_mode(ctx, new_mode);
917 }
918
919 if entry.old_oid != entry.new_oid {
920 let (old_bytes, new_bytes) = read_sides(odb, entry)?;
921 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
922 let a = entry.old_oid.to_hex();
923 let b = entry.new_oid.to_hex();
924 ctx.update(a.as_bytes());
925 ctx.update(b.as_bytes());
926 } else {
927 let old_str = text_bytes_for_diff(&old_bytes);
928 let new_str = text_bytes_for_diff(&new_bytes);
929 patch_id_add_string(ctx, b"---a/");
930 ctx.update(&old_path_buf[..len1]);
931 patch_id_add_string(ctx, b"+++b/");
932 ctx.update(&new_path_buf[..len2]);
933 patch_id_hash_text_hunks(ctx, old_str.as_ref(), new_str.as_ref());
934 }
935 }
936
937 Ok(())
938}
939
940fn patch_id_hash_text_hunks(ctx: &mut ChecksumHasher, old_str: &str, new_str: &str) {
946 let body = unified_hunk_body_for_diff_options(old_str, new_str, &DiffOptions::default());
947 for line in body.split_inclusive('\n') {
948 if matches!(line.as_bytes().first(), Some(b' ' | b'-' | b'+')) {
949 hash_without_whitespace(ctx, line.as_bytes());
950 }
951 }
952}
953
954fn remove_space_bytes(buf: &mut [u8]) -> usize {
956 let mut dst = 0usize;
957 let mut src = 0usize;
958 while src < buf.len() {
959 let c = buf[src];
960 src += 1;
961 if !is_patch_id_space(&c) {
962 buf[dst] = c;
963 dst += 1;
964 }
965 }
966 dst
967}
968
969fn read_sides(odb: &Odb, entry: &DiffEntry) -> Result<(Vec<u8>, Vec<u8>)> {
975 let read_side = |mode: FileMode, oid: &ObjectId| -> Result<Vec<u8>> {
976 if mode == FileMode::GITLINK {
977 return Ok(gitlink_line(oid).into_bytes());
978 }
979 if *oid == zero_oid() {
980 return Ok(Vec::new());
981 }
982 Ok(odb.read(oid)?.data)
983 };
984 Ok((
985 read_side(entry.old_mode, &entry.old_oid)?,
986 read_side(entry.new_mode, &entry.new_oid)?,
987 ))
988}
989
990#[cfg(test)]
991mod tests {
992 use super::*;
993 use crate::environment::RepositoryOptions;
994 use std::process::Command;
995
996 use tempfile::TempDir;
997
998 fn git_in(dir: &std::path::Path, args: &[&str]) -> String {
999 let out = Command::new("git")
1000 .current_dir(dir)
1001 .args(args)
1002 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1003 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1004 .env("GIT_AUTHOR_NAME", "Test")
1005 .env("GIT_AUTHOR_EMAIL", "t@example.com")
1006 .env("GIT_COMMITTER_NAME", "Test")
1007 .env("GIT_COMMITTER_EMAIL", "t@example.com")
1008 .output()
1009 .expect("spawn git");
1010 assert!(
1011 out.status.success(),
1012 "git {:?} failed: {}",
1013 args,
1014 String::from_utf8_lossy(&out.stderr)
1015 );
1016 String::from_utf8_lossy(&out.stdout).into_owned()
1017 }
1018
1019 #[cfg_attr(
1020 not(feature = "full-tests"),
1021 ignore = "starts system git or is heavy; run with --features full-tests"
1022 )]
1023 #[test]
1024 fn text_hunks_hash_only_three_context_lines() {
1025 let old: String = (1..=10).map(|i| format!("{i}\n")).collect();
1026 for (new, expected) in [
1027 (
1028 old.replace("10\n", "changed \t\n"),
1029 b"789-10+changed".as_slice(),
1030 ),
1031 (
1032 old.replacen("1\n", "changed\n", 1),
1033 b"-1+changed234".as_slice(),
1034 ),
1035 (old.replace("10\n", "changed"), b"789-10+changed".as_slice()),
1036 ] {
1037 for algo in [HashAlgo::Sha1, HashAlgo::Sha256] {
1038 let mut ctx = ChecksumHasher::new(algo);
1039 patch_id_hash_text_hunks(&mut ctx, &old, &new);
1040 assert_eq!(ctx.finalize(), algo.checksum_digest(expected));
1041 }
1042 }
1043 }
1044
1045 #[test]
1046 fn text_hunks_hash_overlapping_context_once() {
1047 let old: String = (1..=20).map(|i| format!("{i}\n")).collect();
1048 let new = old
1049 .replacen("8\n", "first\n", 1)
1050 .replace("12\n", "second\n");
1051 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1052 patch_id_hash_text_hunks(&mut ctx, &old, &new);
1053 assert_eq!(
1054 ctx.finalize(),
1055 HashAlgo::Sha1.checksum_digest(b"567-8+first91011-12+second131415")
1056 );
1057 }
1058
1059 #[test]
1060 fn text_hunks_place_slidable_insertion_like_git_diff() {
1061 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1062 patch_id_hash_text_hunks(
1063 &mut ctx,
1064 "a\nb\n\n#t\nfn e\nx\ny\n",
1065 "a\nb\n\n#t\nfn c\n#t\nfn e\nx\ny\n",
1066 );
1067 assert_eq!(
1068 ctx.finalize(),
1069 HashAlgo::Sha1.checksum_digest(b"ab+#t+fnc#tfnex")
1070 );
1071 }
1072
1073 #[test]
1074 fn text_hunks_do_not_slide_across_missing_final_newline() {
1075 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1076 patch_id_hash_text_hunks(
1077 &mut ctx,
1078 "{\n \"a\": 1\n}\n",
1079 "{\n \"a\": 2\n}\n{\n \"b\": 2\n}",
1080 );
1081 assert_eq!(
1082 ctx.finalize(),
1083 HashAlgo::Sha1.checksum_digest(b"{-\"a\":1+\"a\":2}+{+\"b\":2+}")
1084 );
1085 }
1086
1087 #[test]
1088 fn hash_without_whitespace_skips_every_ascii_space() {
1089 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1090 assert_eq!(hash_without_whitespace(&mut ctx, b" \t+a b\r\nc \n"), 4);
1091 assert_eq!(ctx.finalize(), HashAlgo::Sha1.checksum_digest(b"+abc"));
1092 }
1093
1094 #[test]
1095 fn hash_without_whitespace_keeps_form_feed_and_vertical_tab() {
1096 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1097 assert_eq!(hash_without_whitespace(&mut ctx, b"+4\x0c+ \x0b\n"), 5);
1098 assert_eq!(
1099 ctx.finalize(),
1100 HashAlgo::Sha1.checksum_digest(b"+4\x0c+\x0b")
1101 );
1102 let mut path = *b"a/\x0cf b";
1103 assert_eq!(remove_space_bytes(&mut path), 5);
1104 assert_eq!(&path[..5], b"a/\x0cfb");
1105 }
1106
1107 #[cfg_attr(
1108 not(feature = "full-tests"),
1109 ignore = "starts system git or is heavy; run with --features full-tests"
1110 )]
1111 #[test]
1112 fn verbatim_built_patch_text_matches_git_show_patch_id() {
1113 let dir = TempDir::new().expect("tempdir");
1114 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1115 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1116 git_in(dir.path(), &["add", "f.txt"]);
1117 git_in(dir.path(), &["commit", "-q", "-m", "base"]);
1118 std::fs::write(dir.path().join("f.txt"), b"one \ntwo\n").unwrap();
1119 git_in(dir.path(), &["add", "f.txt"]);
1120 git_in(dir.path(), &["commit", "-q", "-m", "change"]);
1121 let commit: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
1122 .trim()
1123 .parse()
1124 .unwrap();
1125 let git_show = git_in(dir.path(), &["show", "-p", "HEAD"]);
1126
1127 let repo = crate::repo::Repository::open(
1128 &dir.path().join(".git"),
1129 &RepositoryOptions::empty().with_work_tree(dir.path()),
1130 )
1131 .unwrap();
1132 let grit_verbatim =
1133 compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Verbatim)
1134 .expect("verbatim")
1135 .expect("some");
1136
1137 let from_git_show =
1138 compute_patch_ids_from_text(HashAlgo::Sha1, git_show.as_bytes(), PatchIdMode::Verbatim)
1139 .into_iter()
1140 .find(|(_, c)| *c == commit)
1141 .expect("commit in git show stream")
1142 .0;
1143
1144 assert_eq!(
1145 grit_verbatim, from_git_show,
1146 "ODB verbatim should match parsing git show -p"
1147 );
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 stable_patch_ids_match_git_patch_id_on_log_p() {
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.txt"), b"new\n").unwrap();
1165 git_in(dir.path(), &["add", "g.txt"]);
1166 git_in(dir.path(), &["commit", "-q", "-m", "third"]);
1167
1168 let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
1169 let grit_ids =
1170 compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), PatchIdMode::Stable);
1171
1172 use std::io::Write;
1173 let mut child = Command::new("git")
1174 .current_dir(dir.path())
1175 .args(["patch-id", "--stable"])
1176 .stdin(std::process::Stdio::piped())
1177 .stdout(std::process::Stdio::piped())
1178 .stderr(std::process::Stdio::piped())
1179 .spawn()
1180 .expect("spawn git patch-id");
1181 child
1182 .stdin
1183 .take()
1184 .expect("stdin")
1185 .write_all(log_p.as_bytes())
1186 .expect("write log");
1187 let git_out = child.wait_with_output().expect("wait");
1188 assert!(
1189 git_out.status.success(),
1190 "git patch-id --stable failed: {}",
1191 String::from_utf8_lossy(&git_out.stderr)
1192 );
1193 let git_lines: Vec<(ObjectId, ObjectId)> = String::from_utf8_lossy(&git_out.stdout)
1194 .lines()
1195 .filter(|l| !l.is_empty())
1196 .map(|line| {
1197 let mut parts = line.split_whitespace();
1198 let patch_hex = parts.next().expect("patch id");
1199 let commit_hex = parts.next().expect("commit id");
1200 let patch = ObjectId::from_hex(patch_hex).expect("patch hex");
1201 let commit = ObjectId::from_hex(commit_hex).expect("commit hex");
1202 (patch, commit)
1203 })
1204 .collect();
1205
1206 assert_eq!(grit_ids.len(), git_lines.len(), "patch-id count mismatch");
1207 for ((g_patch, g_commit), (git_patch, git_commit)) in grit_ids.iter().zip(git_lines.iter())
1208 {
1209 assert_eq!(g_patch, git_patch, "patch-id mismatch");
1210 assert_eq!(g_commit, git_commit, "commit-id mismatch");
1211 }
1212 }
1213
1214 fn git_patch_ids_from_log(dir: &std::path::Path, mode_flag: &str) -> Vec<(ObjectId, ObjectId)> {
1215 let log_p = git_in(dir, &["log", "-p", "--reverse"]);
1216 use std::io::Write;
1217 let mut child = Command::new("git")
1218 .current_dir(dir)
1219 .args(["patch-id", mode_flag])
1220 .stdin(std::process::Stdio::piped())
1221 .stdout(std::process::Stdio::piped())
1222 .stderr(std::process::Stdio::piped())
1223 .spawn()
1224 .expect("spawn git patch-id");
1225 child
1226 .stdin
1227 .take()
1228 .expect("stdin")
1229 .write_all(log_p.as_bytes())
1230 .expect("write log");
1231 let out = child.wait_with_output().expect("wait");
1232 assert!(
1233 out.status.success(),
1234 "git patch-id failed: {}",
1235 String::from_utf8_lossy(&out.stderr)
1236 );
1237 String::from_utf8_lossy(&out.stdout)
1238 .lines()
1239 .filter(|l| !l.is_empty())
1240 .map(|line| {
1241 let mut parts = line.split_whitespace();
1242 let patch_hex = parts.next().expect("patch id");
1243 let commit_hex = parts.next().expect("commit id");
1244 (
1245 ObjectId::from_hex(patch_hex).expect("patch hex"),
1246 ObjectId::from_hex(commit_hex).expect("commit hex"),
1247 )
1248 })
1249 .collect()
1250 }
1251
1252 #[cfg_attr(
1253 not(feature = "full-tests"),
1254 ignore = "starts system git or is heavy; run with --features full-tests"
1255 )]
1256 #[test]
1257 fn t4204_patch_id_modes_match_git() {
1258 let dir = TempDir::new().expect("tempdir");
1259 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1260 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1261 git_in(dir.path(), &["add", "f.txt"]);
1262 git_in(dir.path(), &["commit", "-q", "-m", "first"]);
1263 std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
1264 git_in(dir.path(), &["add", "f.txt"]);
1265 git_in(dir.path(), &["commit", "-q", "-m", "second"]);
1266 std::fs::write(dir.path().join("g.bin"), &[0u8, 1, 2, 3]).unwrap();
1267 git_in(dir.path(), &["add", "g.bin"]);
1268 git_in(dir.path(), &["commit", "-q", "-m", "binary"]);
1269 std::fs::write(dir.path().join("h.txt"), b"a\n").unwrap();
1270 git_in(dir.path(), &["add", "h.txt"]);
1271 git_in(dir.path(), &["commit", "-q", "-m", "add-h"]);
1272 git_in(dir.path(), &["mv", "h.txt", "i.txt"]);
1273 git_in(dir.path(), &["commit", "-q", "-m", "rename"]);
1274 #[cfg(unix)]
1275 {
1276 use std::os::unix::fs::PermissionsExt;
1277 let mut perms = std::fs::metadata(dir.path().join("f.txt"))
1278 .expect("f.txt")
1279 .permissions();
1280 perms.set_mode(0o100755);
1281 std::fs::set_permissions(dir.path().join("f.txt"), perms).expect("chmod f.txt");
1282 }
1283 git_in(dir.path(), &["add", "f.txt"]);
1284 git_in(dir.path(), &["commit", "-q", "-m", "mode-only"]);
1285 let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
1286
1287 for (mode, flag) in [
1288 (PatchIdMode::Stable, "--stable"),
1289 (PatchIdMode::Unstable, "--unstable"),
1290 (PatchIdMode::Verbatim, "--verbatim"),
1291 ] {
1292 let grit_ids = compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), mode);
1293 let git_ids = git_patch_ids_from_log(dir.path(), flag);
1294 assert_eq!(grit_ids.len(), git_ids.len(), "patch-id count for {flag:?}");
1295 for ((g_patch, g_commit), (git_patch, git_commit)) in
1296 grit_ids.iter().zip(git_ids.iter())
1297 {
1298 assert_eq!(g_patch, git_patch, "patch-id {flag}");
1299 assert_eq!(g_commit, git_commit, "commit-id {flag}");
1300 }
1301 }
1302
1303 let repo = crate::repo::Repository::open(
1304 &dir.path().join(".git"),
1305 &RepositoryOptions::empty().with_work_tree(dir.path()),
1306 )
1307 .unwrap();
1308 let git_stable = git_patch_ids_from_log(dir.path(), "--stable");
1309 let assert_odb_matches_git = |commit_hex: &str, label: &str| {
1310 let commit: ObjectId = commit_hex.trim().parse().expect("commit oid");
1311 let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Stable)
1312 .expect("patch")
1313 .expect("some");
1314 let git_patch = git_stable
1315 .iter()
1316 .find(|(_, c)| c == &commit)
1317 .expect(label)
1318 .0;
1319 assert_eq!(grit, git_patch, "{label}");
1320 };
1321
1322 let second = git_in(
1323 dir.path(),
1324 &["rev-list", "--max-count=1", "--skip=4", "HEAD"],
1325 );
1326 assert_odb_matches_git(&second, "git patch-id for second commit");
1327 let binary = git_in(
1328 dir.path(),
1329 &["rev-list", "--max-count=1", "--skip=3", "HEAD"],
1330 );
1331 assert_odb_matches_git(&binary, "git patch-id for binary commit");
1332 let rename = git_in(
1333 dir.path(),
1334 &["rev-list", "--max-count=1", "--skip=1", "HEAD"],
1335 );
1336 assert_odb_matches_git(&rename, "git patch-id for rename commit");
1337 let mode_only = git_in(dir.path(), &["rev-parse", "HEAD"]);
1338 assert_odb_matches_git(&mode_only, "git patch-id for mode-only commit");
1339
1340 let add_h = git_in(
1341 dir.path(),
1342 &["rev-list", "--max-count=1", "--skip=2", "HEAD"],
1343 );
1344 let add_h: ObjectId = add_h.trim().parse().unwrap();
1345 let filtered = compute_patch_id_for_paths(
1346 HashAlgo::Sha1,
1347 &repo.odb(),
1348 &add_h,
1349 &[String::from("h.txt")],
1350 PatchIdMode::Stable,
1351 )
1352 .expect("filtered h.txt")
1353 .expect("some h.txt");
1354 let git_h = git_stable
1355 .iter()
1356 .find(|(_, c)| c == &add_h)
1357 .expect("git patch-id for add-h")
1358 .0;
1359 assert_eq!(
1360 filtered, git_h,
1361 "ODB path-filter h.txt vs full commit patch-id"
1362 );
1363 assert!(
1364 compute_patch_id_for_paths(
1365 HashAlgo::Sha1,
1366 &repo.odb(),
1367 &add_h,
1368 &[String::from("f.txt")],
1369 PatchIdMode::Stable,
1370 )
1371 .expect("filtered f.txt")
1372 .is_none(),
1373 "path filter excluding all changes must be None"
1374 );
1375
1376 git_in(dir.path(), &["checkout", "-q", "HEAD~4"]);
1377 git_in(dir.path(), &["checkout", "-q", "-b", "side"]);
1378 std::fs::write(dir.path().join("side.txt"), b"s\n").unwrap();
1379 git_in(dir.path(), &["add", "side.txt"]);
1380 git_in(dir.path(), &["commit", "-q", "-m", "side"]);
1381 git_in(dir.path(), &["checkout", "-q", "main"]);
1382 git_in(dir.path(), &["merge", "-q", "--no-ff", "side"]);
1383 let merge_tip: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
1384 .trim()
1385 .parse()
1386 .unwrap();
1387 assert!(
1388 compute_patch_id(HashAlgo::Sha1, &repo.odb(), &merge_tip, PatchIdMode::Stable)
1389 .expect("merge")
1390 .is_none()
1391 );
1392 }
1393
1394 fn git_show_patch_id(dir: &std::path::Path, commit: &str, mode_flag: &str) -> ObjectId {
1395 use std::io::Write;
1396 let show = git_in(dir, &["show", commit]);
1397 let mut child = Command::new("git")
1398 .current_dir(dir)
1399 .args(["patch-id", mode_flag])
1400 .stdin(std::process::Stdio::piped())
1401 .stdout(std::process::Stdio::piped())
1402 .spawn()
1403 .expect("spawn git patch-id");
1404 child
1405 .stdin
1406 .take()
1407 .expect("stdin")
1408 .write_all(show.as_bytes())
1409 .expect("write show");
1410 let out = child.wait_with_output().expect("wait");
1411 let stdout = String::from_utf8_lossy(&out.stdout);
1412 let hex = stdout.split_whitespace().next().expect("patch id");
1413 ObjectId::from_hex(hex).expect("patch hex")
1414 }
1415
1416 #[cfg_attr(
1417 not(feature = "full-tests"),
1418 ignore = "starts system git or is heavy; run with --features full-tests"
1419 )]
1420 #[test]
1421 fn empty_file_add_and_delete_patch_ids_match_git() {
1422 let dir = TempDir::new().expect("tempdir");
1423 let path = dir.path();
1424 git_in(path, &["init", "-q", "--initial-branch=main", "."]);
1425 std::fs::write(path.join("a"), b"a\n").unwrap();
1426 git_in(path, &["add", "a"]);
1427 git_in(path, &["commit", "-q", "-m", "c1"]);
1428
1429 let mut commits = Vec::new();
1430 let mut commit = |label: &str| {
1431 git_in(path, &["commit", "-q", "-m", label]);
1432 commits.push((label.to_owned(), git_in(path, &["rev-parse", "HEAD"])));
1433 };
1434 std::fs::write(path.join("empty"), b"").unwrap();
1435 git_in(path, &["add", "empty"]);
1436 commit("add empty");
1437 git_in(path, &["rm", "-q", "empty"]);
1438 commit("delete empty");
1439 std::fs::write(path.join("run"), b"").unwrap();
1440 git_in(path, &["add", "--chmod=+x", "run"]);
1441 commit("add empty executable");
1442 std::fs::write(path.join("a"), b"a\nb\n").unwrap();
1443 std::fs::write(path.join("z-empty"), b"").unwrap();
1444 git_in(path, &["add", "a", "z-empty"]);
1445 commit("edit and add empty");
1446 std::fs::write(path.join("0-empty"), b"").unwrap();
1447 std::fs::write(path.join("a"), b"a\nb\nc\n").unwrap();
1448 git_in(path, &["add", "0-empty", "a"]);
1449 commit("add empty before edit");
1450 git_in(path, &["rm", "-q", "0-empty", "z-empty"]);
1451 std::fs::write(path.join("b"), b"b\n").unwrap();
1452 git_in(path, &["add", "b"]);
1453 commit("delete empties around add");
1454 git_in(path, &["update-index", "--chmod=+x", "a"]);
1455 std::fs::write(path.join("b"), b"b\nc\n").unwrap();
1456 git_in(path, &["add", "b"]);
1457 commit("mode change before edit");
1458 git_in(path, &["mv", "a", "0-moved"]);
1459 std::fs::write(path.join("b"), b"b\nc\nd\n").unwrap();
1460 git_in(path, &["add", "b"]);
1461 commit("pure rename before edit");
1462 std::fs::write(path.join("b"), b"").unwrap();
1463 git_in(path, &["add", "b"]);
1464 commit("truncate to empty");
1465
1466 let repo = crate::repo::Repository::open(
1467 &path.join(".git"),
1468 &RepositoryOptions::empty().with_work_tree(path),
1469 )
1470 .unwrap();
1471 for (label, hex) in &commits {
1472 let oid: ObjectId = hex.trim().parse().expect("commit oid");
1473 for (mode, flag) in [
1474 (PatchIdMode::Stable, "--stable"),
1475 (PatchIdMode::Unstable, "--unstable"),
1476 (PatchIdMode::Verbatim, "--verbatim"),
1477 ] {
1478 let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &oid, mode)
1479 .expect("patch id")
1480 .expect("non-merge commit");
1481 let git = git_show_patch_id(path, hex.trim(), flag);
1482 assert_eq!(grit, git, "{label} {flag}");
1483 }
1484 }
1485 }
1486
1487 #[test]
1488 fn stable_patch_id_matches_git_with_invalid_utf8_in_unchanged_context() {
1489 let dir = TempDir::new().expect("tempdir");
1490 let path = dir.path();
1491 git_in(path, &["init", "-q", "--initial-branch=main", "."]);
1492 let mut base = Vec::new();
1493 for i in 0..420 {
1494 base.extend_from_slice(format!("line{i}\n").as_bytes());
1495 }
1496 base.extend_from_slice(b"context one\n");
1497 base.extend_from_slice(b"invalid \xF8 in line\n");
1498 base.extend_from_slice(b"context two\n");
1499 base.extend_from_slice(b"near end one\n");
1500 base.extend_from_slice(b"near end two\n");
1501 base.extend_from_slice(b"near end three\n");
1502 base.extend_from_slice(b"\n");
1503 base.extend_from_slice(b"test_done\n");
1504
1505 let mut new = base.clone();
1506 let insert_at = base.len() - b"test_done\n".len();
1507 new.splice(
1508 insert_at..insert_at,
1509 b"inserted one\ninserted two\ninserted three\ninserted four\ninserted five\ninserted six\n"
1510 .iter()
1511 .copied(),
1512 );
1513
1514 std::fs::write(path.join("f"), &base).unwrap();
1515 git_in(path, &["add", "f"]);
1516 git_in(path, &["commit", "-q", "-m", "base"]);
1517 std::fs::write(path.join("f"), &new).unwrap();
1518 git_in(path, &["add", "f"]);
1519 git_in(path, &["commit", "-q", "-m", "change"]);
1520
1521 let commit: ObjectId = git_in(path, &["rev-parse", "HEAD"])
1522 .trim()
1523 .parse()
1524 .expect("commit oid");
1525 let repo = crate::repo::Repository::open(
1526 &path.join(".git"),
1527 &RepositoryOptions::empty().with_work_tree(path),
1528 )
1529 .expect("open repo");
1530 let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Stable)
1531 .expect("patch id")
1532 .expect("non-empty patch");
1533 let git = git_show_patch_id(path, commit.to_hex().as_str(), "--stable");
1534 assert_eq!(
1535 grit, git,
1536 "stable patch-id with invalid utf8 in unchanged context"
1537 );
1538 }
1539}