Skip to main content

grit_lib/
patch_ids.rs

1//! Patch-ID computation for commit equivalence detection.
2//!
3//! A patch-ID is a digest of the normalised diff a commit introduces, using the
4//! repository's object hash algorithm (SHA-1 or SHA-256).
5//! Whitespace is stripped from every changed line before hashing, so two
6//! commits whose diffs differ only in whitespace (spaces, tabs, newlines)
7//! produce identical patch-IDs.  This is the semantics required by
8//! `git cherry` and `git format-patch --ignore-if-in-upstream`.
9//!
10//! Two complementary entry points are provided:
11//!
12//! - [`compute_patch_id`] operates on a repository's object database, computing
13//!   the diff from the commit's tree objects.
14//! - [`compute_patch_ids_from_text`] parses unified diff text from stdin (e.g.
15//!   output of `git log -p` or `git diff-tree --patch --stdin`), matching the
16//!   behaviour of `git patch-id`.
17
18use 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/// How to compute a patch-ID from unified diff text.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PatchIdMode {
35    /// Accumulate file diffs into one rolling SHA-1; file order affects the
36    /// result.  Compatible with Git 1.9 and older.  This is the default.
37    Unstable,
38    /// Hash each file's diff independently and accumulate results with a
39    /// carry-addition, so file order does not affect the patch-ID.
40    Stable,
41    /// Like [`PatchIdMode::Stable`] but whitespace is not stripped before
42    /// hashing.
43    Verbatim,
44}
45
46/// Compute patch-IDs from unified diff text (e.g. `git log -p` output).
47///
48/// Parses the text line by line.  Each time a `commit <oid>` or
49/// `From <oid> …` marker is found the accumulated hash for the previous patch
50/// is finalised and emitted as `(patch_id, commit_id)`.  Commits with no diff
51/// content (empty patches, merge commits) are silently skipped.
52///
53/// # Parameters
54///
55/// - `algo` — object hash algorithm (must match the repository that produced the diff).
56/// - `input` — raw bytes of the unified diff stream.
57/// - `mode`  — which patch-ID algorithm to use (see [`PatchIdMode`]).
58///
59/// # Returns
60///
61/// A `Vec` of `(patch_id, commit_id)` pairs in the order they were encountered
62/// in the stream.
63pub 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    // Current accumulated state for the patch being processed.
75    let mut ctx = ChecksumHasher::new(algo);
76    let mut result = [0u8; PATCH_ID_DIGEST_MAX];
77    let mut patchlen: usize = 0;
78    // before/after: -1 = parsing file header, 0 = awaiting @@ hunk, >0 = in hunk
79    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    // When the input starts directly with a diff (no commit header),
86    // use the zero OID as the commit ID.
87    let mut implicit_commit = true;
88
89    // Iterate lines; we keep the trailing '\n' so remove_space mirrors git's
90    // behaviour (newline counts as whitespace and is stripped).
91    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        // The line as a &str for prefix checks; non-UTF-8 treated as empty.
99        let line = std::str::from_utf8(raw).unwrap_or("");
100
101        // Try to strip "commit " or "From " prefix to reach a potential OID.
102        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                // Finalise the patch we've been accumulating.
113                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                // Reset for the new patch.
122                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        // "\ No newline at end of file" markers.
137        if line.starts_with("\\ ") && line.len() > 12 {
138            if verbatim {
139                ctx.update(raw);
140                patchlen += raw.len();
141            }
142            continue;
143        }
144
145        // Skip commit metadata before the first diff hunk.
146        if patchlen == 0 && !line.starts_with("diff ") {
147            continue;
148        }
149
150        // If we see a diff line without a preceding commit marker,
151        // treat it as a single patch with zero commit OID.
152        if implicit_commit && line.starts_with("diff ") && current_commit.is_none() {
153            current_commit = Some(ObjectId::zero());
154            implicit_commit = false;
155        }
156
157        // Parsing the per-file header (before == -1).
158        if before == -1 {
159            if line.starts_with("GIT binary patch") || line.starts_with("Binary files") {
160                // Binary: only hash the blob OID strings.
161                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                // index <pre>..<post>[  <mode>]
174                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                // Fall through to hunk-content processing below.
189            } else if !line.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
190                // Non-alpha first char signals end of this patch's diffs.
191                // Re-use the `continue` path; treat as patch boundary.
192                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        // Skip body of binary diffs; reset on the next `diff ` line.
211        if diff_is_binary {
212            if line.starts_with("diff ") {
213                diff_is_binary = false;
214                before = -1;
215                // Process this `diff ` line as a new file header.
216                i -= 1; // re-process
217            }
218            continue;
219        }
220
221        // Waiting for a hunk header or the next file.
222        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                // End of this file's content; nothing special to do—
231                // if it's a commit boundary the loop head will handle it.
232                continue;
233            }
234            // Another file diff starts; flush per-file hash in stable mode.
235            if stable {
236                text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
237            }
238            before = -1;
239            after = -1;
240            // Re-process this `diff ` line as a new file header.
241            i -= 1;
242            continue;
243        }
244
245        // Inside a hunk — update the line counters.
246        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        // Hash the line, optionally stripping whitespace.
255        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    // Flush the final patch.
265    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
277/// Finalise `ctx`, accumulate its digest into `result` with byte-wise
278/// carry-addition (mirrors git's `flush_one_hunk`), and reset `ctx`.
279fn 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
296/// Hash `raw` bytes into `ctx`, skipping ASCII whitespace.
297///
298/// Returns the number of non-whitespace bytes fed to the hasher.
299fn 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
310/// Parse a full hex OID at the start of `s` for `algo`.
311///
312/// Returns `None` when `s` does not start with a valid hex digest for `algo`
313/// optionally followed by ASCII whitespace.
314fn 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
336/// Convert a single ASCII hex digit to its value.
337fn 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
346/// Parse the before/after line counts from a `@@ -<old>[,<n>] +<new>[,<m>] @@` header.
347///
348/// Returns `(before, after)` counts, defaulting to 1 when the count is absent.
349fn scan_hunk_header(line: &str) -> (i32, i32) {
350    // line starts with "@@ -"
351    let rest = match line.strip_prefix("@@ -") {
352        Some(r) => r,
353        None => return (1, 1),
354    };
355    // Parse old count: skip start line number, grab optional comma + count.
356    let before = parse_hunk_count(rest);
357    // Find " +" separator.
358    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
365/// Parse `<start>[,<count>]` and return `count` (or 1 if absent).
366fn parse_hunk_count(s: &str) -> i32 {
367    // Skip digits of the start line number.
368    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
379/// Same as [`parse_hunk_count`] but returns `Option`.
380fn parse_hunk_count_opt(s: &str) -> Option<i32> {
381    Some(parse_hunk_count(s))
382}
383
384/// Split `input` into lines, preserving the trailing `\n` on each line.
385///
386/// The final slice may lack a trailing newline if the input doesn't end with
387/// one.
388fn 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
403/// Compute the patch-ID for a single commit.
404///
405/// Returns `None` for merge commits (more than one parent), since those do not
406/// have a well-defined single-parent diff.  Root commits (no parents) are
407/// compared against the empty tree. Text diffs use unified hunks with three
408/// context lines on each side, matching `git show --format= --no-ext-diff -U3`
409/// piped to `git patch-id` in the corresponding mode. Unchanged lines outside
410/// those hunks do not contribute to the ID.
411///
412/// # Parameters
413///
414/// - `algo` — object hash algorithm (must match `odb`'s configured format).
415/// - `odb` — object database used to read commit, tree, and blob objects.
416/// - `commit_oid` — OID of the commit to compute the patch-ID for.
417/// - `mode` — accumulation and whitespace rules for the patch-ID.
418///
419/// # Errors
420///
421/// Returns errors from object-database reads or object-parse failures.
422pub 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
431/// Compute the patch-ID for a single commit, limited to matching pathspecs.
432///
433/// This follows [`compute_patch_id`] semantics, but ignores file diffs whose
434/// old and new paths do not match `paths`.
435///
436/// # Parameters
437///
438/// - `algo` — object hash algorithm (must match `odb`'s configured format).
439/// - `odb` — object database used to read commit, tree, and blob objects.
440/// - `commit_oid` — OID of the commit to compute the patch-ID for.
441/// - `paths` — pathspec strings used to limit the files included in the diff.
442/// - `mode` — accumulation and whitespace rules for the patch-ID.
443///
444/// # Errors
445///
446/// Returns errors from object-database reads or object-parse failures.
447pub 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    // Merge commits (>1 parent) have no defined patch-id.
473    if commit.parents().len() > 1 {
474        return Ok(None);
475    }
476
477    // Resolve the parent tree (None = empty tree for root commits).
478    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    // Match `git show` / `git diff-tree -M`: pair renames before hashing.
487    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    // Sort by primary path (lexicographic), matching diffcore_std ordering.
497    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
557/// Abbreviated OID as it appears on `index` lines in `git show -p` (7 hex chars, or `0000000`).
558fn 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
563/// `git show -p` headers for a binary file (no unified hunks).
564fn 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
624/// Build a `git show -p` fragment for patch-id verbatim parsing.
625fn 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
732/// Hash one tree diff entry into `ctx` (rename or regular file).
733fn 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
741/// Hash a regular (non-rename) file diff into `ctx`.
742fn patch_id_hash_file_entry_into_ctx(
743    odb: &Odb,
744    entry: &DiffEntry,
745    ctx: &mut ChecksumHasher,
746) -> Result<()> {
747    // Git's patch-id header (`diff --git a/<x> b/<y>`) uses the present path on both sides for
748    // pure additions/deletions (e.g. `diff --git a/file b/file` for a new file), so fall back to
749    // the other side when one path is absent.
750    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
823/// Hash a rename entry the same way `git patch-id` hashes `similarity index` /
824/// `rename from` / `rename to` lines from `git show --patch`.
825fn 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    // Hash ordinary unified hunks, retaining nearby context without including
885    // unchanged portions of the file outside those hunks.
886    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
910/// Strip ASCII whitespace in-place; returns new length (prefix of `buf` is valid).
911fn 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
925/// Read a blob's raw bytes from the ODB.
926///
927/// Returns an empty `Vec` for the zero OID (representing an absent file).
928fn 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}