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 entry.old_oid.to_short_hex(),
629 entry.new_oid.to_short_hex(),
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_add_string(ctx: &mut ChecksumHasher, s: &[u8]) {
650 ctx.update(s);
651}
652
653fn patch_id_add_mode(ctx: &mut ChecksumHasher, mode: u32) {
654 let text = format!("{mode:06o}");
655 ctx.update(text.as_bytes());
656}
657
658fn patch_id_build_git_show_patch(
660 odb: &Odb,
661 commit_oid: &ObjectId,
662 diffs: &[DiffEntry],
663) -> Result<Vec<u8>> {
664 let mut patch = Vec::new();
665 patch_id_write_line(&mut patch, &format!("commit {}", commit_oid.to_hex()));
666 for entry in diffs {
667 patch_id_append_git_show_entry(odb, entry, &mut patch)?;
668 }
669 Ok(patch)
670}
671
672fn patch_id_append_git_show_entry(odb: &Odb, entry: &DiffEntry, out: &mut Vec<u8>) -> Result<()> {
673 let git_old = entry
674 .old_path
675 .as_deref()
676 .or(entry.new_path.as_deref())
677 .unwrap_or("dev/null");
678 let git_new = entry
679 .new_path
680 .as_deref()
681 .or(entry.old_path.as_deref())
682 .unwrap_or("dev/null");
683 patch_id_write_line(out, &format!("diff --git a/{git_old} b/{git_new}"));
684
685 let old_mode = entry.old_mode.bits();
686 let new_mode = entry.new_mode.bits();
687 if old_mode == 0 {
688 patch_id_write_line(out, &format!("new file mode {}", entry.new_mode));
689 } else if new_mode == 0 {
690 patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode));
691 }
692
693 if entry.status == DiffStatus::Renamed {
694 let score = entry.score.unwrap_or(100);
695 patch_id_write_line(out, &format!("similarity index {score}%"));
696 if let Some(path) = &entry.old_path {
697 patch_id_write_line(out, &format!("rename from {path}"));
698 }
699 if let Some(path) = &entry.new_path {
700 patch_id_write_line(out, &format!("rename to {path}"));
701 }
702 } else if old_mode != 0 && new_mode != 0 && old_mode != new_mode {
703 patch_id_write_line(out, &format!("old mode {}", entry.old_mode));
704 patch_id_write_line(out, &format!("new mode {}", entry.new_mode));
705 }
706
707 let index_mode = if new_mode != 0 {
708 entry.new_mode
709 } else {
710 entry.old_mode
711 };
712 patch_id_write_line(
713 out,
714 &format!(
715 "index {}..{} {}",
716 entry.old_oid.to_short_hex(),
717 entry.new_oid.to_short_hex(),
718 index_mode,
719 ),
720 );
721
722 if !patch_id_has_content_diff(entry) {
723 return Ok(());
724 }
725
726 let (old_bytes, new_bytes) = read_sides(odb, entry)?;
727
728 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
729 let old_side = if old_mode == 0 {
730 "/dev/null".to_owned()
731 } else {
732 format!("a/{git_old}")
733 };
734 let new_side = if new_mode == 0 {
735 "/dev/null".to_owned()
736 } else {
737 format!("b/{git_new}")
738 };
739 patch_id_write_line(
740 out,
741 &format!("Binary files {old_side} and {new_side} differ"),
742 );
743 return Ok(());
744 }
745
746 let old_str = text_bytes_for_diff(&old_bytes);
747 let new_str = text_bytes_for_diff(&new_bytes);
748 let old_path = if old_mode == 0 {
749 "/dev/null".to_owned()
750 } else {
751 format!("a/{git_old}")
752 };
753 let new_path = if new_mode == 0 {
754 "/dev/null".to_owned()
755 } else {
756 format!("b/{git_new}")
757 };
758 let unified = unified_diff_with_prefix_and_funcname(
759 old_str.as_ref(),
760 new_str.as_ref(),
761 &old_path,
762 &new_path,
763 3,
764 0,
765 "",
766 "",
767 None,
768 true,
769 false,
770 );
771 out.extend_from_slice(unified.as_bytes());
772 Ok(())
773}
774
775fn patch_id_has_content_diff(entry: &DiffEntry) -> bool {
781 let empty = |oid: &ObjectId| oid.is_zero() || oid.is_empty_blob();
782 entry.old_oid != entry.new_oid && !(empty(&entry.old_oid) && empty(&entry.new_oid))
783}
784
785fn patch_id_hash_diff_entry(odb: &Odb, entry: &DiffEntry, ctx: &mut ChecksumHasher) -> Result<()> {
787 if entry.status == DiffStatus::Renamed {
788 patch_id_hash_rename_entry_into_ctx(odb, entry, ctx)
789 } else {
790 patch_id_hash_file_entry_into_ctx(odb, entry, ctx)
791 }
792}
793
794fn patch_id_hash_file_entry_into_ctx(
796 odb: &Odb,
797 entry: &DiffEntry,
798 ctx: &mut ChecksumHasher,
799) -> Result<()> {
800 let old_path = entry
804 .old_path
805 .as_deref()
806 .or(entry.new_path.as_deref())
807 .unwrap_or("");
808 let new_path = entry
809 .new_path
810 .as_deref()
811 .or(entry.old_path.as_deref())
812 .unwrap_or("");
813 let mut old_path_buf = old_path.as_bytes().to_vec();
814 let mut new_path_buf = new_path.as_bytes().to_vec();
815 let len1 = remove_space_bytes(&mut old_path_buf);
816 let len2 = remove_space_bytes(&mut new_path_buf);
817
818 let old_mode = entry.old_mode.bits();
819 let new_mode = entry.new_mode.bits();
820
821 patch_id_add_string(ctx, b"diff--git");
822 patch_id_add_string(ctx, b"a/");
823 ctx.update(&old_path_buf[..len1]);
824 patch_id_add_string(ctx, b"b/");
825 ctx.update(&new_path_buf[..len2]);
826
827 if old_mode == 0 {
828 patch_id_add_string(ctx, b"newfilemode");
829 patch_id_add_mode(ctx, new_mode);
830 } else if new_mode == 0 {
831 patch_id_add_string(ctx, b"deletedfilemode");
832 patch_id_add_mode(ctx, old_mode);
833 } else if old_mode != new_mode {
834 patch_id_add_string(ctx, b"oldmode");
835 patch_id_add_mode(ctx, old_mode);
836 patch_id_add_string(ctx, b"newmode");
837 patch_id_add_mode(ctx, new_mode);
838 }
839
840 if !patch_id_has_content_diff(entry) {
841 return Ok(());
842 }
843
844 let (old_bytes, new_bytes) = read_sides(odb, entry)?;
845
846 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
847 let a = entry.old_oid.to_short_hex();
848 let b = entry.new_oid.to_short_hex();
849 ctx.update(a.as_bytes());
850 ctx.update(b.as_bytes());
851 } else {
852 let old_str = text_bytes_for_diff(&old_bytes);
853 let new_str = text_bytes_for_diff(&new_bytes);
854
855 if old_mode == 0 {
856 patch_id_add_string(ctx, b"---/dev/null");
857 patch_id_add_string(ctx, b"+++b/");
858 ctx.update(&new_path_buf[..len2]);
859 } else if new_mode == 0 {
860 patch_id_add_string(ctx, b"---a/");
861 ctx.update(&old_path_buf[..len1]);
862 patch_id_add_string(ctx, b"+++/dev/null");
863 } else {
864 patch_id_add_string(ctx, b"---a/");
865 ctx.update(&old_path_buf[..len1]);
866 patch_id_add_string(ctx, b"+++b/");
867 ctx.update(&new_path_buf[..len2]);
868 }
869
870 patch_id_hash_text_hunks(ctx, old_str.as_ref(), new_str.as_ref());
871 }
872 Ok(())
873}
874
875fn patch_id_hash_rename_entry_into_ctx(
878 odb: &Odb,
879 entry: &DiffEntry,
880 ctx: &mut ChecksumHasher,
881) -> Result<()> {
882 let old_path = entry.old_path.as_deref().unwrap_or("");
883 let new_path = entry.new_path.as_deref().unwrap_or("");
884 let mut old_path_buf = old_path.as_bytes().to_vec();
885 let mut new_path_buf = new_path.as_bytes().to_vec();
886 let len1 = remove_space_bytes(&mut old_path_buf);
887 let len2 = remove_space_bytes(&mut new_path_buf);
888
889 patch_id_add_string(ctx, b"diff--git");
890 patch_id_add_string(ctx, b"a/");
891 ctx.update(&old_path_buf[..len1]);
892 patch_id_add_string(ctx, b"b/");
893 ctx.update(&new_path_buf[..len2]);
894
895 let score = entry.score.unwrap_or(100);
896 patch_id_add_string(ctx, b"similarityindex");
897 ctx.update(format!("{score}%").as_bytes());
898 patch_id_add_string(ctx, b"renamefrom");
899 ctx.update(&old_path_buf[..len1]);
900 patch_id_add_string(ctx, b"renameto");
901 ctx.update(&new_path_buf[..len2]);
902
903 let old_mode = entry.old_mode.bits();
904 let new_mode = entry.new_mode.bits();
905 if old_mode != new_mode {
906 patch_id_add_string(ctx, b"oldmode");
907 patch_id_add_mode(ctx, old_mode);
908 patch_id_add_string(ctx, b"newmode");
909 patch_id_add_mode(ctx, new_mode);
910 }
911
912 if entry.old_oid != entry.new_oid {
913 let (old_bytes, new_bytes) = read_sides(odb, entry)?;
914 if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
915 let a = entry.old_oid.to_hex();
916 let b = entry.new_oid.to_hex();
917 ctx.update(a.as_bytes());
918 ctx.update(b.as_bytes());
919 } else {
920 let old_str = text_bytes_for_diff(&old_bytes);
921 let new_str = text_bytes_for_diff(&new_bytes);
922 patch_id_add_string(ctx, b"---a/");
923 ctx.update(&old_path_buf[..len1]);
924 patch_id_add_string(ctx, b"+++b/");
925 ctx.update(&new_path_buf[..len2]);
926 patch_id_hash_text_hunks(ctx, old_str.as_ref(), new_str.as_ref());
927 }
928 }
929
930 Ok(())
931}
932
933fn patch_id_hash_text_hunks(ctx: &mut ChecksumHasher, old_str: &str, new_str: &str) {
939 let body = unified_hunk_body_for_diff_options(old_str, new_str, &DiffOptions::default());
940 for line in body.split_inclusive('\n') {
941 if matches!(line.as_bytes().first(), Some(b' ' | b'-' | b'+')) {
942 hash_without_whitespace(ctx, line.as_bytes());
943 }
944 }
945}
946
947fn remove_space_bytes(buf: &mut [u8]) -> usize {
949 let mut dst = 0usize;
950 let mut src = 0usize;
951 while src < buf.len() {
952 let c = buf[src];
953 src += 1;
954 if !is_patch_id_space(&c) {
955 buf[dst] = c;
956 dst += 1;
957 }
958 }
959 dst
960}
961
962fn read_sides(odb: &Odb, entry: &DiffEntry) -> Result<(Vec<u8>, Vec<u8>)> {
968 let read_side = |mode: FileMode, oid: &ObjectId| -> Result<Vec<u8>> {
969 if mode == FileMode::GITLINK {
970 return Ok(gitlink_line(oid).into_bytes());
971 }
972 if *oid == zero_oid() {
973 return Ok(Vec::new());
974 }
975 Ok(odb.read(oid)?.data)
976 };
977 Ok((
978 read_side(entry.old_mode, &entry.old_oid)?,
979 read_side(entry.new_mode, &entry.new_oid)?,
980 ))
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986 use crate::environment::RepositoryOptions;
987 use std::process::Command;
988
989 use tempfile::TempDir;
990
991 fn git_in(dir: &std::path::Path, args: &[&str]) -> String {
992 let out = Command::new("git")
993 .current_dir(dir)
994 .args(args)
995 .env("GIT_CONFIG_GLOBAL", "/dev/null")
996 .env("GIT_CONFIG_SYSTEM", "/dev/null")
997 .env("GIT_AUTHOR_NAME", "Test")
998 .env("GIT_AUTHOR_EMAIL", "t@example.com")
999 .env("GIT_COMMITTER_NAME", "Test")
1000 .env("GIT_COMMITTER_EMAIL", "t@example.com")
1001 .output()
1002 .expect("spawn git");
1003 assert!(
1004 out.status.success(),
1005 "git {:?} failed: {}",
1006 args,
1007 String::from_utf8_lossy(&out.stderr)
1008 );
1009 String::from_utf8_lossy(&out.stdout).into_owned()
1010 }
1011
1012 #[cfg_attr(
1013 not(feature = "full-tests"),
1014 ignore = "starts system git or is heavy; run with --features full-tests"
1015 )]
1016 #[test]
1017 fn text_hunks_hash_only_three_context_lines() {
1018 let old: String = (1..=10).map(|i| format!("{i}\n")).collect();
1019 for (new, expected) in [
1020 (
1021 old.replace("10\n", "changed \t\n"),
1022 b"789-10+changed".as_slice(),
1023 ),
1024 (
1025 old.replacen("1\n", "changed\n", 1),
1026 b"-1+changed234".as_slice(),
1027 ),
1028 (old.replace("10\n", "changed"), b"789-10+changed".as_slice()),
1029 ] {
1030 for algo in [HashAlgo::Sha1, HashAlgo::Sha256] {
1031 let mut ctx = ChecksumHasher::new(algo);
1032 patch_id_hash_text_hunks(&mut ctx, &old, &new);
1033 assert_eq!(ctx.finalize(), algo.checksum_digest(expected));
1034 }
1035 }
1036 }
1037
1038 #[test]
1039 fn text_hunks_hash_overlapping_context_once() {
1040 let old: String = (1..=20).map(|i| format!("{i}\n")).collect();
1041 let new = old
1042 .replacen("8\n", "first\n", 1)
1043 .replace("12\n", "second\n");
1044 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1045 patch_id_hash_text_hunks(&mut ctx, &old, &new);
1046 assert_eq!(
1047 ctx.finalize(),
1048 HashAlgo::Sha1.checksum_digest(b"567-8+first91011-12+second131415")
1049 );
1050 }
1051
1052 #[test]
1053 fn text_hunks_place_slidable_insertion_like_git_diff() {
1054 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1055 patch_id_hash_text_hunks(
1056 &mut ctx,
1057 "a\nb\n\n#t\nfn e\nx\ny\n",
1058 "a\nb\n\n#t\nfn c\n#t\nfn e\nx\ny\n",
1059 );
1060 assert_eq!(
1061 ctx.finalize(),
1062 HashAlgo::Sha1.checksum_digest(b"ab+#t+fnc#tfnex")
1063 );
1064 }
1065
1066 #[test]
1067 fn text_hunks_do_not_slide_across_missing_final_newline() {
1068 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1069 patch_id_hash_text_hunks(
1070 &mut ctx,
1071 "{\n \"a\": 1\n}\n",
1072 "{\n \"a\": 2\n}\n{\n \"b\": 2\n}",
1073 );
1074 assert_eq!(
1075 ctx.finalize(),
1076 HashAlgo::Sha1.checksum_digest(b"{-\"a\":1+\"a\":2}+{+\"b\":2+}")
1077 );
1078 }
1079
1080 #[test]
1081 fn hash_without_whitespace_skips_every_ascii_space() {
1082 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1083 assert_eq!(hash_without_whitespace(&mut ctx, b" \t+a b\r\nc \n"), 4);
1084 assert_eq!(ctx.finalize(), HashAlgo::Sha1.checksum_digest(b"+abc"));
1085 }
1086
1087 #[test]
1088 fn hash_without_whitespace_keeps_form_feed_and_vertical_tab() {
1089 let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1090 assert_eq!(hash_without_whitespace(&mut ctx, b"+4\x0c+ \x0b\n"), 5);
1091 assert_eq!(
1092 ctx.finalize(),
1093 HashAlgo::Sha1.checksum_digest(b"+4\x0c+\x0b")
1094 );
1095 let mut path = *b"a/\x0cf b";
1096 assert_eq!(remove_space_bytes(&mut path), 5);
1097 assert_eq!(&path[..5], b"a/\x0cfb");
1098 }
1099
1100 #[cfg_attr(
1101 not(feature = "full-tests"),
1102 ignore = "starts system git or is heavy; run with --features full-tests"
1103 )]
1104 #[test]
1105 fn verbatim_built_patch_text_matches_git_show_patch_id() {
1106 let dir = TempDir::new().expect("tempdir");
1107 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1108 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1109 git_in(dir.path(), &["add", "f.txt"]);
1110 git_in(dir.path(), &["commit", "-q", "-m", "base"]);
1111 std::fs::write(dir.path().join("f.txt"), b"one \ntwo\n").unwrap();
1112 git_in(dir.path(), &["add", "f.txt"]);
1113 git_in(dir.path(), &["commit", "-q", "-m", "change"]);
1114 let commit: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
1115 .trim()
1116 .parse()
1117 .unwrap();
1118 let git_show = git_in(dir.path(), &["show", "-p", "HEAD"]);
1119
1120 let repo = crate::repo::Repository::open(
1121 &dir.path().join(".git"),
1122 &RepositoryOptions::empty().with_work_tree(dir.path()),
1123 )
1124 .unwrap();
1125 let grit_verbatim =
1126 compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Verbatim)
1127 .expect("verbatim")
1128 .expect("some");
1129
1130 let from_git_show =
1131 compute_patch_ids_from_text(HashAlgo::Sha1, git_show.as_bytes(), PatchIdMode::Verbatim)
1132 .into_iter()
1133 .find(|(_, c)| *c == commit)
1134 .expect("commit in git show stream")
1135 .0;
1136
1137 assert_eq!(
1138 grit_verbatim, from_git_show,
1139 "ODB verbatim should match parsing git show -p"
1140 );
1141 }
1142
1143 #[cfg_attr(
1144 not(feature = "full-tests"),
1145 ignore = "starts system git or is heavy; run with --features full-tests"
1146 )]
1147 #[test]
1148 fn stable_patch_ids_match_git_patch_id_on_log_p() {
1149 let dir = TempDir::new().expect("tempdir");
1150 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1151 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1152 git_in(dir.path(), &["add", "f.txt"]);
1153 git_in(dir.path(), &["commit", "-q", "-m", "first"]);
1154 std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
1155 git_in(dir.path(), &["add", "f.txt"]);
1156 git_in(dir.path(), &["commit", "-q", "-m", "second"]);
1157 std::fs::write(dir.path().join("g.txt"), b"new\n").unwrap();
1158 git_in(dir.path(), &["add", "g.txt"]);
1159 git_in(dir.path(), &["commit", "-q", "-m", "third"]);
1160
1161 let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
1162 let grit_ids =
1163 compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), PatchIdMode::Stable);
1164
1165 use std::io::Write;
1166 let mut child = Command::new("git")
1167 .current_dir(dir.path())
1168 .args(["patch-id", "--stable"])
1169 .stdin(std::process::Stdio::piped())
1170 .stdout(std::process::Stdio::piped())
1171 .stderr(std::process::Stdio::piped())
1172 .spawn()
1173 .expect("spawn git patch-id");
1174 child
1175 .stdin
1176 .take()
1177 .expect("stdin")
1178 .write_all(log_p.as_bytes())
1179 .expect("write log");
1180 let git_out = child.wait_with_output().expect("wait");
1181 assert!(
1182 git_out.status.success(),
1183 "git patch-id --stable failed: {}",
1184 String::from_utf8_lossy(&git_out.stderr)
1185 );
1186 let git_lines: Vec<(ObjectId, ObjectId)> = String::from_utf8_lossy(&git_out.stdout)
1187 .lines()
1188 .filter(|l| !l.is_empty())
1189 .map(|line| {
1190 let mut parts = line.split_whitespace();
1191 let patch_hex = parts.next().expect("patch id");
1192 let commit_hex = parts.next().expect("commit id");
1193 let patch = ObjectId::from_hex(patch_hex).expect("patch hex");
1194 let commit = ObjectId::from_hex(commit_hex).expect("commit hex");
1195 (patch, commit)
1196 })
1197 .collect();
1198
1199 assert_eq!(grit_ids.len(), git_lines.len(), "patch-id count mismatch");
1200 for ((g_patch, g_commit), (git_patch, git_commit)) in grit_ids.iter().zip(git_lines.iter())
1201 {
1202 assert_eq!(g_patch, git_patch, "patch-id mismatch");
1203 assert_eq!(g_commit, git_commit, "commit-id mismatch");
1204 }
1205 }
1206
1207 fn git_patch_ids_from_log(dir: &std::path::Path, mode_flag: &str) -> Vec<(ObjectId, ObjectId)> {
1208 let log_p = git_in(dir, &["log", "-p", "--reverse"]);
1209 use std::io::Write;
1210 let mut child = Command::new("git")
1211 .current_dir(dir)
1212 .args(["patch-id", mode_flag])
1213 .stdin(std::process::Stdio::piped())
1214 .stdout(std::process::Stdio::piped())
1215 .stderr(std::process::Stdio::piped())
1216 .spawn()
1217 .expect("spawn git patch-id");
1218 child
1219 .stdin
1220 .take()
1221 .expect("stdin")
1222 .write_all(log_p.as_bytes())
1223 .expect("write log");
1224 let out = child.wait_with_output().expect("wait");
1225 assert!(
1226 out.status.success(),
1227 "git patch-id failed: {}",
1228 String::from_utf8_lossy(&out.stderr)
1229 );
1230 String::from_utf8_lossy(&out.stdout)
1231 .lines()
1232 .filter(|l| !l.is_empty())
1233 .map(|line| {
1234 let mut parts = line.split_whitespace();
1235 let patch_hex = parts.next().expect("patch id");
1236 let commit_hex = parts.next().expect("commit id");
1237 (
1238 ObjectId::from_hex(patch_hex).expect("patch hex"),
1239 ObjectId::from_hex(commit_hex).expect("commit hex"),
1240 )
1241 })
1242 .collect()
1243 }
1244
1245 #[cfg_attr(
1246 not(feature = "full-tests"),
1247 ignore = "starts system git or is heavy; run with --features full-tests"
1248 )]
1249 #[test]
1250 fn t4204_patch_id_modes_match_git() {
1251 let dir = TempDir::new().expect("tempdir");
1252 git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1253 std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1254 git_in(dir.path(), &["add", "f.txt"]);
1255 git_in(dir.path(), &["commit", "-q", "-m", "first"]);
1256 std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
1257 git_in(dir.path(), &["add", "f.txt"]);
1258 git_in(dir.path(), &["commit", "-q", "-m", "second"]);
1259 std::fs::write(dir.path().join("g.bin"), &[0u8, 1, 2, 3]).unwrap();
1260 git_in(dir.path(), &["add", "g.bin"]);
1261 git_in(dir.path(), &["commit", "-q", "-m", "binary"]);
1262 std::fs::write(dir.path().join("h.txt"), b"a\n").unwrap();
1263 git_in(dir.path(), &["add", "h.txt"]);
1264 git_in(dir.path(), &["commit", "-q", "-m", "add-h"]);
1265 git_in(dir.path(), &["mv", "h.txt", "i.txt"]);
1266 git_in(dir.path(), &["commit", "-q", "-m", "rename"]);
1267 #[cfg(unix)]
1268 {
1269 use std::os::unix::fs::PermissionsExt;
1270 let mut perms = std::fs::metadata(dir.path().join("f.txt"))
1271 .expect("f.txt")
1272 .permissions();
1273 perms.set_mode(0o100755);
1274 std::fs::set_permissions(dir.path().join("f.txt"), perms).expect("chmod f.txt");
1275 }
1276 git_in(dir.path(), &["add", "f.txt"]);
1277 git_in(dir.path(), &["commit", "-q", "-m", "mode-only"]);
1278 let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
1279
1280 for (mode, flag) in [
1281 (PatchIdMode::Stable, "--stable"),
1282 (PatchIdMode::Unstable, "--unstable"),
1283 (PatchIdMode::Verbatim, "--verbatim"),
1284 ] {
1285 let grit_ids = compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), mode);
1286 let git_ids = git_patch_ids_from_log(dir.path(), flag);
1287 assert_eq!(grit_ids.len(), git_ids.len(), "patch-id count for {flag:?}");
1288 for ((g_patch, g_commit), (git_patch, git_commit)) in
1289 grit_ids.iter().zip(git_ids.iter())
1290 {
1291 assert_eq!(g_patch, git_patch, "patch-id {flag}");
1292 assert_eq!(g_commit, git_commit, "commit-id {flag}");
1293 }
1294 }
1295
1296 let repo = crate::repo::Repository::open(
1297 &dir.path().join(".git"),
1298 &RepositoryOptions::empty().with_work_tree(dir.path()),
1299 )
1300 .unwrap();
1301 let git_stable = git_patch_ids_from_log(dir.path(), "--stable");
1302 let assert_odb_matches_git = |commit_hex: &str, label: &str| {
1303 let commit: ObjectId = commit_hex.trim().parse().expect("commit oid");
1304 let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Stable)
1305 .expect("patch")
1306 .expect("some");
1307 let git_patch = git_stable
1308 .iter()
1309 .find(|(_, c)| c == &commit)
1310 .expect(label)
1311 .0;
1312 assert_eq!(grit, git_patch, "{label}");
1313 };
1314
1315 let second = git_in(
1316 dir.path(),
1317 &["rev-list", "--max-count=1", "--skip=4", "HEAD"],
1318 );
1319 assert_odb_matches_git(&second, "git patch-id for second commit");
1320 let binary = git_in(
1321 dir.path(),
1322 &["rev-list", "--max-count=1", "--skip=3", "HEAD"],
1323 );
1324 assert_odb_matches_git(&binary, "git patch-id for binary commit");
1325 let rename = git_in(
1326 dir.path(),
1327 &["rev-list", "--max-count=1", "--skip=1", "HEAD"],
1328 );
1329 assert_odb_matches_git(&rename, "git patch-id for rename commit");
1330 let mode_only = git_in(dir.path(), &["rev-parse", "HEAD"]);
1331 assert_odb_matches_git(&mode_only, "git patch-id for mode-only commit");
1332
1333 let add_h = git_in(
1334 dir.path(),
1335 &["rev-list", "--max-count=1", "--skip=2", "HEAD"],
1336 );
1337 let add_h: ObjectId = add_h.trim().parse().unwrap();
1338 let filtered = compute_patch_id_for_paths(
1339 HashAlgo::Sha1,
1340 &repo.odb(),
1341 &add_h,
1342 &[String::from("h.txt")],
1343 PatchIdMode::Stable,
1344 )
1345 .expect("filtered h.txt")
1346 .expect("some h.txt");
1347 let git_h = git_stable
1348 .iter()
1349 .find(|(_, c)| c == &add_h)
1350 .expect("git patch-id for add-h")
1351 .0;
1352 assert_eq!(
1353 filtered, git_h,
1354 "ODB path-filter h.txt vs full commit patch-id"
1355 );
1356 assert!(
1357 compute_patch_id_for_paths(
1358 HashAlgo::Sha1,
1359 &repo.odb(),
1360 &add_h,
1361 &[String::from("f.txt")],
1362 PatchIdMode::Stable,
1363 )
1364 .expect("filtered f.txt")
1365 .is_none(),
1366 "path filter excluding all changes must be None"
1367 );
1368
1369 git_in(dir.path(), &["checkout", "-q", "HEAD~4"]);
1370 git_in(dir.path(), &["checkout", "-q", "-b", "side"]);
1371 std::fs::write(dir.path().join("side.txt"), b"s\n").unwrap();
1372 git_in(dir.path(), &["add", "side.txt"]);
1373 git_in(dir.path(), &["commit", "-q", "-m", "side"]);
1374 git_in(dir.path(), &["checkout", "-q", "main"]);
1375 git_in(dir.path(), &["merge", "-q", "--no-ff", "side"]);
1376 let merge_tip: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
1377 .trim()
1378 .parse()
1379 .unwrap();
1380 assert!(
1381 compute_patch_id(HashAlgo::Sha1, &repo.odb(), &merge_tip, PatchIdMode::Stable)
1382 .expect("merge")
1383 .is_none()
1384 );
1385 }
1386
1387 fn git_show_patch_id(dir: &std::path::Path, commit: &str, mode_flag: &str) -> ObjectId {
1388 use std::io::Write;
1389 let show = git_in(dir, &["show", commit]);
1390 let mut child = Command::new("git")
1391 .current_dir(dir)
1392 .args(["patch-id", mode_flag])
1393 .stdin(std::process::Stdio::piped())
1394 .stdout(std::process::Stdio::piped())
1395 .spawn()
1396 .expect("spawn git patch-id");
1397 child
1398 .stdin
1399 .take()
1400 .expect("stdin")
1401 .write_all(show.as_bytes())
1402 .expect("write show");
1403 let out = child.wait_with_output().expect("wait");
1404 let stdout = String::from_utf8_lossy(&out.stdout);
1405 let hex = stdout.split_whitespace().next().expect("patch id");
1406 ObjectId::from_hex(hex).expect("patch hex")
1407 }
1408
1409 #[cfg_attr(
1410 not(feature = "full-tests"),
1411 ignore = "starts system git or is heavy; run with --features full-tests"
1412 )]
1413 #[test]
1414 fn empty_file_add_and_delete_patch_ids_match_git() {
1415 let dir = TempDir::new().expect("tempdir");
1416 let path = dir.path();
1417 git_in(path, &["init", "-q", "--initial-branch=main", "."]);
1418 std::fs::write(path.join("a"), b"a\n").unwrap();
1419 git_in(path, &["add", "a"]);
1420 git_in(path, &["commit", "-q", "-m", "c1"]);
1421
1422 let mut commits = Vec::new();
1423 let mut commit = |label: &str| {
1424 git_in(path, &["commit", "-q", "-m", label]);
1425 commits.push((label.to_owned(), git_in(path, &["rev-parse", "HEAD"])));
1426 };
1427 std::fs::write(path.join("empty"), b"").unwrap();
1428 git_in(path, &["add", "empty"]);
1429 commit("add empty");
1430 git_in(path, &["rm", "-q", "empty"]);
1431 commit("delete empty");
1432 std::fs::write(path.join("run"), b"").unwrap();
1433 git_in(path, &["add", "--chmod=+x", "run"]);
1434 commit("add empty executable");
1435 std::fs::write(path.join("a"), b"a\nb\n").unwrap();
1436 std::fs::write(path.join("z-empty"), b"").unwrap();
1437 git_in(path, &["add", "a", "z-empty"]);
1438 commit("edit and add empty");
1439 std::fs::write(path.join("0-empty"), b"").unwrap();
1440 std::fs::write(path.join("a"), b"a\nb\nc\n").unwrap();
1441 git_in(path, &["add", "0-empty", "a"]);
1442 commit("add empty before edit");
1443 git_in(path, &["rm", "-q", "0-empty", "z-empty"]);
1444 std::fs::write(path.join("b"), b"b\n").unwrap();
1445 git_in(path, &["add", "b"]);
1446 commit("delete empties around add");
1447 git_in(path, &["update-index", "--chmod=+x", "a"]);
1448 std::fs::write(path.join("b"), b"b\nc\n").unwrap();
1449 git_in(path, &["add", "b"]);
1450 commit("mode change before edit");
1451 git_in(path, &["mv", "a", "0-moved"]);
1452 std::fs::write(path.join("b"), b"b\nc\nd\n").unwrap();
1453 git_in(path, &["add", "b"]);
1454 commit("pure rename before edit");
1455 std::fs::write(path.join("b"), b"").unwrap();
1456 git_in(path, &["add", "b"]);
1457 commit("truncate to empty");
1458
1459 let repo = crate::repo::Repository::open(
1460 &path.join(".git"),
1461 &RepositoryOptions::empty().with_work_tree(path),
1462 )
1463 .unwrap();
1464 for (label, hex) in &commits {
1465 let oid: ObjectId = hex.trim().parse().expect("commit oid");
1466 for (mode, flag) in [
1467 (PatchIdMode::Stable, "--stable"),
1468 (PatchIdMode::Unstable, "--unstable"),
1469 (PatchIdMode::Verbatim, "--verbatim"),
1470 ] {
1471 let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &oid, mode)
1472 .expect("patch id")
1473 .expect("non-merge commit");
1474 let git = git_show_patch_id(path, hex.trim(), flag);
1475 assert_eq!(grit, git, "{label} {flag}");
1476 }
1477 }
1478 }
1479
1480 #[test]
1481 fn stable_patch_id_matches_git_with_invalid_utf8_in_unchanged_context() {
1482 let dir = TempDir::new().expect("tempdir");
1483 let path = dir.path();
1484 git_in(path, &["init", "-q", "--initial-branch=main", "."]);
1485 let mut base = Vec::new();
1486 for i in 0..420 {
1487 base.extend_from_slice(format!("line{i}\n").as_bytes());
1488 }
1489 base.extend_from_slice(b"context one\n");
1490 base.extend_from_slice(b"invalid \xF8 in line\n");
1491 base.extend_from_slice(b"context two\n");
1492 base.extend_from_slice(b"near end one\n");
1493 base.extend_from_slice(b"near end two\n");
1494 base.extend_from_slice(b"near end three\n");
1495 base.extend_from_slice(b"\n");
1496 base.extend_from_slice(b"test_done\n");
1497
1498 let mut new = base.clone();
1499 let insert_at = base.len() - b"test_done\n".len();
1500 new.splice(
1501 insert_at..insert_at,
1502 b"inserted one\ninserted two\ninserted three\ninserted four\ninserted five\ninserted six\n"
1503 .iter()
1504 .copied(),
1505 );
1506
1507 std::fs::write(path.join("f"), &base).unwrap();
1508 git_in(path, &["add", "f"]);
1509 git_in(path, &["commit", "-q", "-m", "base"]);
1510 std::fs::write(path.join("f"), &new).unwrap();
1511 git_in(path, &["add", "f"]);
1512 git_in(path, &["commit", "-q", "-m", "change"]);
1513
1514 let commit: ObjectId = git_in(path, &["rev-parse", "HEAD"])
1515 .trim()
1516 .parse()
1517 .expect("commit oid");
1518 let repo = crate::repo::Repository::open(
1519 &path.join(".git"),
1520 &RepositoryOptions::empty().with_work_tree(path),
1521 )
1522 .expect("open repo");
1523 let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Stable)
1524 .expect("patch id")
1525 .expect("non-empty patch");
1526 let git = git_show_patch_id(path, commit.to_hex().as_str(), "--stable");
1527 assert_eq!(
1528 grit, git,
1529 "stable patch-id with invalid utf8 in unchanged context"
1530 );
1531 }
1532}