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