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//!
18//! Non-UTF-8 bytes in text blobs are decoded with the same lossy UTF-8 rules as
19//! [`crate::diff::structured`] text diffs so context lines stay aligned with `git diff`.
20
21use crate::diff::{
22    diff_trees_with_renames, gitlink_line, structured::text_bytes_for_diff,
23    unified_diff_with_prefix_and_funcname, unified_hunk_body_for_diff_options, zero_oid, DiffEntry,
24    DiffOptions, DiffStatus,
25};
26use crate::error::Result;
27use crate::hash::ChecksumHasher;
28use crate::merge_file;
29use crate::objects::{CommitRef, FileMode, HashAlgo, ObjectId, ObjectKind};
30use crate::odb::Odb;
31
32const PATCH_ID_DIGEST_MAX: usize = 32;
33
34/// How to compute a patch-ID from unified diff text.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum PatchIdMode {
37    /// Accumulate file diffs into one rolling SHA-1; file order affects the
38    /// result.  Compatible with Git 1.9 and older.  This is the default.
39    Unstable,
40    /// Hash each file's diff independently and accumulate results with a
41    /// carry-addition, so file order does not affect the patch-ID.
42    Stable,
43    /// Like [`PatchIdMode::Stable`] but whitespace is not stripped before
44    /// hashing.
45    Verbatim,
46}
47
48/// Compute patch-IDs from unified diff text (e.g. `git log -p` output).
49///
50/// Parses the text line by line.  Each time a `commit <oid>` or
51/// `From <oid> …` marker is found the accumulated hash for the previous patch
52/// is finalised and emitted as `(patch_id, commit_id)`.  Commits with no diff
53/// content (empty patches, merge commits) are silently skipped.
54///
55/// # Parameters
56///
57/// - `algo` — object hash algorithm (must match the repository that produced the diff).
58/// - `input` — raw bytes of the unified diff stream.
59/// - `mode`  — which patch-ID algorithm to use (see [`PatchIdMode`]).
60///
61/// # Returns
62///
63/// A `Vec` of `(patch_id, commit_id)` pairs in the order they were encountered
64/// in the stream.
65pub fn compute_patch_ids_from_text(
66    algo: HashAlgo,
67    input: &[u8],
68    mode: PatchIdMode,
69) -> Vec<(ObjectId, ObjectId)> {
70    let stable = mode != PatchIdMode::Unstable;
71    let verbatim = mode == PatchIdMode::Verbatim;
72    let digest_len = algo.len();
73
74    let mut results: Vec<(ObjectId, ObjectId)> = Vec::new();
75
76    // Current accumulated state for the patch being processed.
77    let mut ctx = ChecksumHasher::new(algo);
78    let mut result = [0u8; PATCH_ID_DIGEST_MAX];
79    let mut patchlen: usize = 0;
80    // before/after: -1 = parsing file header, 0 = awaiting @@ hunk, >0 = in hunk
81    let mut before: i32 = -1;
82    let mut after: i32 = -1;
83    let mut diff_is_binary = false;
84    let mut pre_oid_str = String::new();
85    let mut post_oid_str = String::new();
86    let mut current_commit: Option<ObjectId> = None;
87    // When the input starts directly with a diff (no commit header),
88    // use the zero OID as the commit ID.
89    let mut implicit_commit = true;
90
91    // Iterate lines; we keep the trailing '\n' so remove_space mirrors git's
92    // behaviour (newline counts as whitespace and is stripped).
93    let lines = split_lines_with_nl(input);
94
95    let mut i = 0;
96    while i < lines.len() {
97        let raw = lines[i];
98        i += 1;
99
100        // The line as a &str for prefix checks; non-UTF-8 treated as empty.
101        let line = std::str::from_utf8(raw).unwrap_or("");
102
103        // Try to strip "commit " or "From " prefix to reach a potential OID.
104        let oid_candidate: Option<&str> = if let Some(rest) = line.strip_prefix("commit ") {
105            Some(rest)
106        } else if let Some(rest) = line.strip_prefix("From ") {
107            Some(rest)
108        } else {
109            None
110        };
111
112        if let Some(candidate) = oid_candidate {
113            if let Some(oid) = try_parse_oid_prefix(candidate, algo) {
114                // Finalise the patch we've been accumulating.
115                text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
116                if patchlen > 0 {
117                    if let Some(coid) = current_commit.take() {
118                        if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
119                            results.push((pid, coid));
120                        }
121                    }
122                }
123                // Reset for the new patch.
124                result = [0u8; PATCH_ID_DIGEST_MAX];
125                ctx = ChecksumHasher::new(algo);
126                patchlen = 0;
127                before = -1;
128                after = -1;
129                diff_is_binary = false;
130                pre_oid_str.clear();
131                post_oid_str.clear();
132                current_commit = Some(oid);
133                implicit_commit = false;
134                continue;
135            }
136        }
137
138        // "\ No newline at end of file" markers.
139        if line.starts_with("\\ ") && line.len() > 12 {
140            if verbatim {
141                ctx.update(raw);
142                patchlen += raw.len();
143            }
144            continue;
145        }
146
147        // Skip commit metadata before the first diff hunk.
148        if patchlen == 0 && !line.starts_with("diff ") {
149            continue;
150        }
151
152        // If we see a diff line without a preceding commit marker,
153        // treat it as a single patch with zero commit OID.
154        if implicit_commit && line.starts_with("diff ") && current_commit.is_none() {
155            current_commit = Some(ObjectId::zero());
156            implicit_commit = false;
157        }
158
159        // Parsing the per-file header (before == -1).
160        if before == -1 {
161            if line.starts_with("GIT binary patch") || line.starts_with("Binary files") {
162                // Binary: only hash the blob OID strings.
163                diff_is_binary = true;
164                before = 0;
165                let pre = pre_oid_str.clone();
166                let post = post_oid_str.clone();
167                ctx.update(pre.as_bytes());
168                ctx.update(post.as_bytes());
169                patchlen += pre.len() + post.len();
170                if stable {
171                    text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
172                }
173                continue;
174            } else if let Some(rest) = line.strip_prefix("index ") {
175                // index <pre>..<post>[  <mode>]
176                if let Some(dd) = rest.find("..") {
177                    pre_oid_str = rest[..dd].to_owned();
178                    let tail = &rest[dd + 2..];
179                    let end = tail
180                        .find(|c: char| c.is_ascii_whitespace())
181                        .unwrap_or_else(|| {
182                            tail.trim_end_matches('\n').trim_end_matches('\r').len()
183                        });
184                    post_oid_str = tail[..end].to_owned();
185                }
186                continue;
187            } else if line.starts_with("--- ") {
188                before = 1;
189                after = 1;
190                // Fall through to hunk-content processing below.
191            } else if !line.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
192                // Non-alpha first char signals end of this patch's diffs.
193                // Re-use the `continue` path; treat as patch boundary.
194                text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
195                if patchlen > 0 {
196                    if let Some(coid) = current_commit.take() {
197                        if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
198                            results.push((pid, coid));
199                        }
200                    }
201                }
202                result = [0u8; PATCH_ID_DIGEST_MAX];
203                ctx = ChecksumHasher::new(algo);
204                patchlen = 0;
205                before = -1;
206                after = -1;
207                diff_is_binary = false;
208                continue;
209            }
210        }
211
212        // Skip body of binary diffs; reset on the next `diff ` line.
213        if diff_is_binary {
214            if line.starts_with("diff ") {
215                diff_is_binary = false;
216                before = -1;
217                // Process this `diff ` line as a new file header.
218                i -= 1; // re-process
219            }
220            continue;
221        }
222
223        // Waiting for a hunk header or the next file.
224        if before == 0 && after == 0 {
225            if line.starts_with("@@ -") {
226                let (b, a) = scan_hunk_header(line);
227                before = b;
228                after = a;
229                continue;
230            }
231            if !line.starts_with("diff ") {
232                // End of this file's content; nothing special to do—
233                // if it's a commit boundary the loop head will handle it.
234                continue;
235            }
236            // Another file diff starts; flush per-file hash in stable mode.
237            if stable {
238                text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
239            }
240            before = -1;
241            after = -1;
242            // Re-process this `diff ` line as a new file header.
243            i -= 1;
244            continue;
245        }
246
247        // Inside a hunk — update the line counters.
248        let first = raw.first().copied().unwrap_or(b' ');
249        if first == b'-' || first == b' ' {
250            before -= 1;
251        }
252        if first == b'+' || first == b' ' {
253            after -= 1;
254        }
255
256        // Hash the line, optionally stripping whitespace.
257        let hashed = if verbatim {
258            ctx.update(raw);
259            raw.len()
260        } else {
261            hash_without_whitespace(&mut ctx, raw)
262        };
263        patchlen += hashed;
264    }
265
266    // Flush the final patch.
267    text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
268    if patchlen > 0 {
269        if let Some(coid) = current_commit {
270            if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
271                results.push((pid, coid));
272            }
273        }
274    }
275
276    results
277}
278
279/// Finalise `ctx`, accumulate its digest into `result` with byte-wise
280/// carry-addition (mirrors git's `flush_one_hunk`), and reset `ctx`.
281fn text_flush_one_hunk(
282    result: &mut [u8],
283    digest_len: usize,
284    ctx: &mut ChecksumHasher,
285    algo: HashAlgo,
286) {
287    let mut hash_buf = [0u8; PATCH_ID_DIGEST_MAX];
288    let old = std::mem::replace(ctx, ChecksumHasher::new(algo));
289    let _ = old.finalize_into(&mut hash_buf);
290    let mut carry: u16 = 0;
291    for i in 0..digest_len {
292        carry = carry + result[i] as u16 + hash_buf[i] as u16;
293        result[i] = carry as u8;
294        carry >>= 8;
295    }
296}
297
298/// Whether patch-ID hashing drops `b`: space, tab, LF and CR only.
299///
300/// Form feed and vertical tab are kept, so a line differing only by one hashes differently,
301/// as in `git patch-id`.
302fn is_patch_id_space(b: &u8) -> bool {
303    matches!(b, b' ' | b'\t' | b'\n' | b'\r')
304}
305
306/// Hash `raw` bytes into `ctx`, skipping [`is_patch_id_space`] bytes.
307///
308/// Returns the number of bytes fed to the hasher.
309fn hash_without_whitespace(ctx: &mut ChecksumHasher, raw: &[u8]) -> usize {
310    raw.split(is_patch_id_space)
311        .filter(|run| !run.is_empty())
312        .map(|run| {
313            ctx.update(run);
314            run.len()
315        })
316        .sum()
317}
318
319/// Parse a full hex OID at the start of `s` for `algo`.
320///
321/// Returns `None` when `s` does not start with a valid hex digest for `algo`
322/// optionally followed by ASCII whitespace.
323fn try_parse_oid_prefix(s: &str, algo: HashAlgo) -> Option<ObjectId> {
324    let s = s.trim_end_matches('\n').trim_end_matches('\r');
325    let hex_len = algo.len() * 2;
326    if s.len() < hex_len {
327        return None;
328    }
329    let hex = &s[..hex_len];
330    if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
331        return None;
332    }
333    if s.len() > hex_len && !s.as_bytes()[hex_len].is_ascii_whitespace() {
334        return None;
335    }
336    let mut bytes = [0u8; PATCH_ID_DIGEST_MAX];
337    for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
338        let hi = hex_val(chunk[0])?;
339        let lo = hex_val(chunk[1])?;
340        bytes[i] = (hi << 4) | lo;
341    }
342    ObjectId::from_bytes(&bytes[..algo.len()]).ok()
343}
344
345/// Convert a single ASCII hex digit to its value.
346fn hex_val(b: u8) -> Option<u8> {
347    match b {
348        b'0'..=b'9' => Some(b - b'0'),
349        b'a'..=b'f' => Some(b - b'a' + 10),
350        b'A'..=b'F' => Some(b - b'A' + 10),
351        _ => None,
352    }
353}
354
355/// Parse the before/after line counts from a `@@ -<old>[,<n>] +<new>[,<m>] @@` header.
356///
357/// Returns `(before, after)` counts, defaulting to 1 when the count is absent.
358fn scan_hunk_header(line: &str) -> (i32, i32) {
359    // line starts with "@@ -"
360    let rest = match line.strip_prefix("@@ -") {
361        Some(r) => r,
362        None => return (1, 1),
363    };
364    // Parse old count: skip start line number, grab optional comma + count.
365    let before = parse_hunk_count(rest);
366    // Find " +" separator.
367    let after = rest
368        .find(" +")
369        .and_then(|p| parse_hunk_count_opt(&rest[p + 2..]))
370        .unwrap_or(1);
371    (before, after)
372}
373
374/// Parse `<start>[,<count>]` and return `count` (or 1 if absent).
375fn parse_hunk_count(s: &str) -> i32 {
376    // Skip digits of the start line number.
377    let after_start = s.trim_start_matches(|c: char| c.is_ascii_digit());
378    if let Some(rest) = after_start.strip_prefix(',') {
379        rest.split(|c: char| !c.is_ascii_digit())
380            .next()
381            .and_then(|n| n.parse().ok())
382            .unwrap_or(1)
383    } else {
384        1
385    }
386}
387
388/// Same as [`parse_hunk_count`] but returns `Option`.
389fn parse_hunk_count_opt(s: &str) -> Option<i32> {
390    Some(parse_hunk_count(s))
391}
392
393/// Split `input` into lines, preserving the trailing `\n` on each line.
394///
395/// The final slice may lack a trailing newline if the input doesn't end with
396/// one.
397fn split_lines_with_nl(input: &[u8]) -> Vec<&[u8]> {
398    let mut lines = Vec::new();
399    let mut start = 0;
400    for (i, &b) in input.iter().enumerate() {
401        if b == b'\n' {
402            lines.push(&input[start..=i]);
403            start = i + 1;
404        }
405    }
406    if start < input.len() {
407        lines.push(&input[start..]);
408    }
409    lines
410}
411
412/// Compute the patch-ID for a single commit.
413///
414/// Returns `None` for merge commits (more than one parent), since those do not
415/// have a well-defined single-parent diff.  Root commits (no parents) are
416/// compared against the empty tree. Text diffs use the hunks of Git's default
417/// diff (Myers with change-compact and the indent heuristic, three context
418/// lines on each side), so the ID matches `git show --format= --no-ext-diff`
419/// piped to `git patch-id` in the corresponding mode, including where an
420/// ambiguous insertion or deletion is placed. Unchanged lines outside those
421/// hunks do not contribute to the ID. A gitlink side is hashed as its
422/// `Subproject commit <oid>` line without reading the submodule commit, and a
423/// type change is hashed as a deletion followed by an addition.
424///
425/// # Parameters
426///
427/// - `algo` — object hash algorithm (must match `odb`'s configured format).
428/// - `odb` — object database used to read commit, tree, and blob objects.
429/// - `commit_oid` — OID of the commit to compute the patch-ID for.
430/// - `mode` — accumulation and whitespace rules for the patch-ID.
431///
432/// # Errors
433///
434/// Returns errors from object-database reads or object-parse failures.
435///
436/// Text blobs that are not valid UTF-8 are still diffed (lossy decode), matching
437/// `git diff` and `git patch-id` on the same commit.
438pub fn compute_patch_id(
439    algo: HashAlgo,
440    odb: &Odb,
441    commit_oid: &ObjectId,
442    mode: PatchIdMode,
443) -> Result<Option<ObjectId>> {
444    compute_patch_id_filtered(algo, odb, commit_oid, &[], mode)
445}
446
447/// Compute the patch-ID for a single commit, limited to matching pathspecs.
448///
449/// This follows [`compute_patch_id`] semantics, but ignores file diffs whose
450/// old and new paths do not match `paths`.
451///
452/// # Parameters
453///
454/// - `algo` — object hash algorithm (must match `odb`'s configured format).
455/// - `odb` — object database used to read commit, tree, and blob objects.
456/// - `commit_oid` — OID of the commit to compute the patch-ID for.
457/// - `paths` — pathspec strings used to limit the files included in the diff.
458/// - `mode` — accumulation and whitespace rules for the patch-ID.
459///
460/// # Errors
461///
462/// Returns errors from object-database reads or object-parse failures.
463pub fn compute_patch_id_for_paths(
464    algo: HashAlgo,
465    odb: &Odb,
466    commit_oid: &ObjectId,
467    paths: &[String],
468    mode: PatchIdMode,
469) -> Result<Option<ObjectId>> {
470    compute_patch_id_filtered(algo, odb, commit_oid, paths, mode)
471}
472
473fn compute_patch_id_filtered(
474    algo: HashAlgo,
475    odb: &Odb,
476    commit_oid: &ObjectId,
477    paths: &[String],
478    mode: PatchIdMode,
479) -> Result<Option<ObjectId>> {
480    let per_file_flush = mode != PatchIdMode::Unstable;
481    let digest_len = algo.len();
482    let obj = odb.read(commit_oid)?;
483    if obj.kind != ObjectKind::Commit {
484        return Ok(None);
485    }
486    let commit = CommitRef::parse(&obj.data)?;
487
488    // Merge commits (>1 parent) have no defined patch-id.
489    if commit.parents().len() > 1 {
490        return Ok(None);
491    }
492
493    // Resolve the parent tree (None = empty tree for root commits).
494    let parent_tree_oid = if commit.parents().is_empty() {
495        None
496    } else {
497        let parent_obj = odb.read(&commit.parents()[0])?;
498        let parent_commit = CommitRef::parse(&parent_obj.data)?;
499        Some(parent_commit.tree())
500    };
501
502    // Match `git show` / `git diff-tree -M`: pair renames before hashing.
503    let mut diffs =
504        diff_trees_with_renames(odb, parent_tree_oid.as_ref(), Some(&commit.tree()), "", 50)?;
505    if !paths.is_empty() {
506        diffs.retain(|entry| diff_entry_matches_paths(entry, paths));
507    }
508    if diffs.is_empty() {
509        return Ok(None);
510    }
511    let mut diffs = split_type_changes(diffs);
512
513    // Sort by primary path (lexicographic), matching diffcore_std ordering.
514    // The sort is stable, so a split type change keeps its deletion first.
515    diffs.sort_by(|a, b| a.path().cmp(b.path()));
516
517    if mode == PatchIdMode::Verbatim {
518        let patch = patch_id_build_git_show_patch(odb, commit_oid, &diffs)?;
519        let ids = compute_patch_ids_from_text(algo, &patch, mode);
520        return Ok(ids.into_iter().next().map(|(patch_id, _)| patch_id));
521    }
522
523    if diffs.len() == 1 {
524        let entry = &diffs[0];
525        if entry.old_oid != entry.new_oid {
526            let (old_bytes, new_bytes) = read_sides(odb, entry)?;
527            if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
528                let mut patch = Vec::new();
529                patch_id_write_line(&mut patch, &format!("commit {}", commit_oid.to_hex()));
530                write_patch_id_binary_entry(&mut patch, entry)?;
531                let ids = compute_patch_ids_from_text(algo, &patch, mode);
532                return Ok(ids.into_iter().next().map(|(patch_id, _)| patch_id));
533            }
534        }
535    }
536
537    let mut result = [0u8; PATCH_ID_DIGEST_MAX];
538    let mut ctx = ChecksumHasher::new(algo);
539    // Stable IDs split only where a file's hunks or binary line end, so a
540    // header-only entry (empty file, mode change, pure rename) shares one
541    // digest with the entry that follows it.
542    let mut ended_with_body = false;
543    for entry in &diffs {
544        if per_file_flush && ended_with_body {
545            text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
546        }
547        patch_id_hash_diff_entry(odb, entry, &mut ctx)?;
548        ended_with_body = patch_id_has_content_diff(entry);
549    }
550    text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
551
552    if result.iter().take(digest_len).all(|&b| b == 0) {
553        return Ok(None);
554    }
555    ObjectId::from_bytes(&result[..digest_len]).map(Some)
556}
557
558/// Replace each type change (e.g. file to symlink or gitlink) with a deletion
559/// followed by an addition of the same path, the two file diffs `git show`
560/// prints for it.
561fn split_type_changes(diffs: Vec<DiffEntry>) -> Vec<DiffEntry> {
562    if diffs.iter().all(|e| e.status != DiffStatus::TypeChanged) {
563        return diffs;
564    }
565    let mut out = Vec::with_capacity(diffs.len());
566    for entry in diffs {
567        if entry.status != DiffStatus::TypeChanged {
568            out.push(entry);
569            continue;
570        }
571        out.push(DiffEntry {
572            status: DiffStatus::Deleted,
573            new_path: None,
574            new_mode: FileMode::ABSENT,
575            new_oid: zero_oid(),
576            ..entry.clone()
577        });
578        out.push(DiffEntry {
579            status: DiffStatus::Added,
580            old_path: None,
581            old_mode: FileMode::ABSENT,
582            old_oid: zero_oid(),
583            ..entry
584        });
585    }
586    out
587}
588
589fn diff_entry_matches_paths(entry: &DiffEntry, paths: &[String]) -> bool {
590    entry
591        .old_path
592        .as_deref()
593        .into_iter()
594        .chain(entry.new_path.as_deref())
595        .any(|path| crate::pathspec::matches_pathspec_list(path, paths))
596}
597
598/// Abbreviated OID as it appears on `index` lines in `git show -p` (7 hex chars, or `0000000`).
599fn patch_id_write_line(out: &mut Vec<u8>, line: &str) {
600    out.extend_from_slice(line.as_bytes());
601    out.push(b'\n');
602}
603
604/// `git show -p` headers for a binary file (no unified hunks).
605fn write_patch_id_binary_entry(out: &mut Vec<u8>, entry: &DiffEntry) -> Result<()> {
606    let old_path = entry
607        .old_path
608        .as_deref()
609        .or(entry.new_path.as_deref())
610        .unwrap_or("");
611    let new_path = entry
612        .new_path
613        .as_deref()
614        .or(entry.old_path.as_deref())
615        .unwrap_or("");
616    patch_id_write_line(out, &format!("diff --git a/{old_path} b/{new_path}"));
617    let old_mode = entry.old_mode.bits();
618    let new_mode = entry.new_mode.bits();
619    if old_mode == 0 {
620        patch_id_write_line(out, &format!("new file mode {}", entry.new_mode));
621    } else if new_mode == 0 {
622        patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode));
623    }
624    patch_id_write_line(
625        out,
626        &format!(
627            "index {}..{}",
628            entry.old_oid.to_short_hex(),
629            entry.new_oid.to_short_hex(),
630        ),
631    );
632    let old_side = entry
633        .old_path
634        .as_ref()
635        .map(|p| format!("a/{p}"))
636        .unwrap_or_else(|| "/dev/null".to_owned());
637    let new_side = entry
638        .new_path
639        .as_ref()
640        .map(|p| format!("b/{p}"))
641        .unwrap_or_else(|| "/dev/null".to_owned());
642    patch_id_write_line(
643        out,
644        &format!("Binary files {old_side} and {new_side} differ"),
645    );
646    Ok(())
647}
648
649fn patch_id_add_string(ctx: &mut ChecksumHasher, s: &[u8]) {
650    ctx.update(s);
651}
652
653fn patch_id_add_mode(ctx: &mut ChecksumHasher, mode: u32) {
654    let text = format!("{mode:06o}");
655    ctx.update(text.as_bytes());
656}
657
658/// Build a `git show -p` fragment for patch-id verbatim parsing.
659fn patch_id_build_git_show_patch(
660    odb: &Odb,
661    commit_oid: &ObjectId,
662    diffs: &[DiffEntry],
663) -> Result<Vec<u8>> {
664    let mut patch = Vec::new();
665    patch_id_write_line(&mut patch, &format!("commit {}", commit_oid.to_hex()));
666    for entry in diffs {
667        patch_id_append_git_show_entry(odb, entry, &mut patch)?;
668    }
669    Ok(patch)
670}
671
672fn patch_id_append_git_show_entry(odb: &Odb, entry: &DiffEntry, out: &mut Vec<u8>) -> Result<()> {
673    let git_old = entry
674        .old_path
675        .as_deref()
676        .or(entry.new_path.as_deref())
677        .unwrap_or("dev/null");
678    let git_new = entry
679        .new_path
680        .as_deref()
681        .or(entry.old_path.as_deref())
682        .unwrap_or("dev/null");
683    patch_id_write_line(out, &format!("diff --git a/{git_old} b/{git_new}"));
684
685    let old_mode = entry.old_mode.bits();
686    let new_mode = entry.new_mode.bits();
687    if old_mode == 0 {
688        patch_id_write_line(out, &format!("new file mode {}", entry.new_mode));
689    } else if new_mode == 0 {
690        patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode));
691    }
692
693    if entry.status == DiffStatus::Renamed {
694        let score = entry.score.unwrap_or(100);
695        patch_id_write_line(out, &format!("similarity index {score}%"));
696        if let Some(path) = &entry.old_path {
697            patch_id_write_line(out, &format!("rename from {path}"));
698        }
699        if let Some(path) = &entry.new_path {
700            patch_id_write_line(out, &format!("rename to {path}"));
701        }
702    } else if old_mode != 0 && new_mode != 0 && old_mode != new_mode {
703        patch_id_write_line(out, &format!("old mode {}", entry.old_mode));
704        patch_id_write_line(out, &format!("new mode {}", entry.new_mode));
705    }
706
707    let index_mode = if new_mode != 0 {
708        entry.new_mode
709    } else {
710        entry.old_mode
711    };
712    patch_id_write_line(
713        out,
714        &format!(
715            "index {}..{} {}",
716            entry.old_oid.to_short_hex(),
717            entry.new_oid.to_short_hex(),
718            index_mode,
719        ),
720    );
721
722    if !patch_id_has_content_diff(entry) {
723        return Ok(());
724    }
725
726    let (old_bytes, new_bytes) = read_sides(odb, entry)?;
727
728    if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
729        let old_side = if old_mode == 0 {
730            "/dev/null".to_owned()
731        } else {
732            format!("a/{git_old}")
733        };
734        let new_side = if new_mode == 0 {
735            "/dev/null".to_owned()
736        } else {
737            format!("b/{git_new}")
738        };
739        patch_id_write_line(
740            out,
741            &format!("Binary files {old_side} and {new_side} differ"),
742        );
743        return Ok(());
744    }
745
746    let old_str = text_bytes_for_diff(&old_bytes);
747    let new_str = text_bytes_for_diff(&new_bytes);
748    let old_path = if old_mode == 0 {
749        "/dev/null".to_owned()
750    } else {
751        format!("a/{git_old}")
752    };
753    let new_path = if new_mode == 0 {
754        "/dev/null".to_owned()
755    } else {
756        format!("b/{git_new}")
757    };
758    let unified = unified_diff_with_prefix_and_funcname(
759        old_str.as_ref(),
760        new_str.as_ref(),
761        &old_path,
762        &new_path,
763        3,
764        0,
765        "",
766        "",
767        None,
768        true,
769        false,
770    );
771    out.extend_from_slice(unified.as_bytes());
772    Ok(())
773}
774
775/// Whether `git show` prints `---`/`+++` lines and a body for `entry`.
776///
777/// Git prints only the header block when the content is unchanged or when
778/// both sides are empty (adding or deleting an empty file), and that
779/// header block is all the patch ID covers.
780fn patch_id_has_content_diff(entry: &DiffEntry) -> bool {
781    let empty = |oid: &ObjectId| oid.is_zero() || oid.is_empty_blob();
782    entry.old_oid != entry.new_oid && !(empty(&entry.old_oid) && empty(&entry.new_oid))
783}
784
785/// Hash one tree diff entry into `ctx` (rename or regular file).
786fn patch_id_hash_diff_entry(odb: &Odb, entry: &DiffEntry, ctx: &mut ChecksumHasher) -> Result<()> {
787    if entry.status == DiffStatus::Renamed {
788        patch_id_hash_rename_entry_into_ctx(odb, entry, ctx)
789    } else {
790        patch_id_hash_file_entry_into_ctx(odb, entry, ctx)
791    }
792}
793
794/// Hash a regular (non-rename) file diff into `ctx`.
795fn patch_id_hash_file_entry_into_ctx(
796    odb: &Odb,
797    entry: &DiffEntry,
798    ctx: &mut ChecksumHasher,
799) -> Result<()> {
800    // Git's patch-id header (`diff --git a/<x> b/<y>`) uses the present path on both sides for
801    // pure additions/deletions (e.g. `diff --git a/file b/file` for a new file), so fall back to
802    // the other side when one path is absent.
803    let old_path = entry
804        .old_path
805        .as_deref()
806        .or(entry.new_path.as_deref())
807        .unwrap_or("");
808    let new_path = entry
809        .new_path
810        .as_deref()
811        .or(entry.old_path.as_deref())
812        .unwrap_or("");
813    let mut old_path_buf = old_path.as_bytes().to_vec();
814    let mut new_path_buf = new_path.as_bytes().to_vec();
815    let len1 = remove_space_bytes(&mut old_path_buf);
816    let len2 = remove_space_bytes(&mut new_path_buf);
817
818    let old_mode = entry.old_mode.bits();
819    let new_mode = entry.new_mode.bits();
820
821    patch_id_add_string(ctx, b"diff--git");
822    patch_id_add_string(ctx, b"a/");
823    ctx.update(&old_path_buf[..len1]);
824    patch_id_add_string(ctx, b"b/");
825    ctx.update(&new_path_buf[..len2]);
826
827    if old_mode == 0 {
828        patch_id_add_string(ctx, b"newfilemode");
829        patch_id_add_mode(ctx, new_mode);
830    } else if new_mode == 0 {
831        patch_id_add_string(ctx, b"deletedfilemode");
832        patch_id_add_mode(ctx, old_mode);
833    } else if old_mode != new_mode {
834        patch_id_add_string(ctx, b"oldmode");
835        patch_id_add_mode(ctx, old_mode);
836        patch_id_add_string(ctx, b"newmode");
837        patch_id_add_mode(ctx, new_mode);
838    }
839
840    if !patch_id_has_content_diff(entry) {
841        return Ok(());
842    }
843
844    let (old_bytes, new_bytes) = read_sides(odb, entry)?;
845
846    if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
847        let a = entry.old_oid.to_short_hex();
848        let b = entry.new_oid.to_short_hex();
849        ctx.update(a.as_bytes());
850        ctx.update(b.as_bytes());
851    } else {
852        let old_str = text_bytes_for_diff(&old_bytes);
853        let new_str = text_bytes_for_diff(&new_bytes);
854
855        if old_mode == 0 {
856            patch_id_add_string(ctx, b"---/dev/null");
857            patch_id_add_string(ctx, b"+++b/");
858            ctx.update(&new_path_buf[..len2]);
859        } else if new_mode == 0 {
860            patch_id_add_string(ctx, b"---a/");
861            ctx.update(&old_path_buf[..len1]);
862            patch_id_add_string(ctx, b"+++/dev/null");
863        } else {
864            patch_id_add_string(ctx, b"---a/");
865            ctx.update(&old_path_buf[..len1]);
866            patch_id_add_string(ctx, b"+++b/");
867            ctx.update(&new_path_buf[..len2]);
868        }
869
870        patch_id_hash_text_hunks(ctx, old_str.as_ref(), new_str.as_ref());
871    }
872    Ok(())
873}
874
875/// Hash a rename entry the same way `git patch-id` hashes `similarity index` /
876/// `rename from` / `rename to` lines from `git show --patch`.
877fn patch_id_hash_rename_entry_into_ctx(
878    odb: &Odb,
879    entry: &DiffEntry,
880    ctx: &mut ChecksumHasher,
881) -> Result<()> {
882    let old_path = entry.old_path.as_deref().unwrap_or("");
883    let new_path = entry.new_path.as_deref().unwrap_or("");
884    let mut old_path_buf = old_path.as_bytes().to_vec();
885    let mut new_path_buf = new_path.as_bytes().to_vec();
886    let len1 = remove_space_bytes(&mut old_path_buf);
887    let len2 = remove_space_bytes(&mut new_path_buf);
888
889    patch_id_add_string(ctx, b"diff--git");
890    patch_id_add_string(ctx, b"a/");
891    ctx.update(&old_path_buf[..len1]);
892    patch_id_add_string(ctx, b"b/");
893    ctx.update(&new_path_buf[..len2]);
894
895    let score = entry.score.unwrap_or(100);
896    patch_id_add_string(ctx, b"similarityindex");
897    ctx.update(format!("{score}%").as_bytes());
898    patch_id_add_string(ctx, b"renamefrom");
899    ctx.update(&old_path_buf[..len1]);
900    patch_id_add_string(ctx, b"renameto");
901    ctx.update(&new_path_buf[..len2]);
902
903    let old_mode = entry.old_mode.bits();
904    let new_mode = entry.new_mode.bits();
905    if old_mode != new_mode {
906        patch_id_add_string(ctx, b"oldmode");
907        patch_id_add_mode(ctx, old_mode);
908        patch_id_add_string(ctx, b"newmode");
909        patch_id_add_mode(ctx, new_mode);
910    }
911
912    if entry.old_oid != entry.new_oid {
913        let (old_bytes, new_bytes) = read_sides(odb, entry)?;
914        if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
915            let a = entry.old_oid.to_hex();
916            let b = entry.new_oid.to_hex();
917            ctx.update(a.as_bytes());
918            ctx.update(b.as_bytes());
919        } else {
920            let old_str = text_bytes_for_diff(&old_bytes);
921            let new_str = text_bytes_for_diff(&new_bytes);
922            patch_id_add_string(ctx, b"---a/");
923            ctx.update(&old_path_buf[..len1]);
924            patch_id_add_string(ctx, b"+++b/");
925            ctx.update(&new_path_buf[..len2]);
926            patch_id_hash_text_hunks(ctx, old_str.as_ref(), new_str.as_ref());
927        }
928    }
929
930    Ok(())
931}
932
933/// Hash the context, removed and added lines of the default `git diff` hunks
934/// (Myers, change-compact and indent heuristic, `-U3`), whitespace stripped.
935///
936/// Slidable insertions and deletions have several equally short placements;
937/// the ID only matches Git's when the hunk lines come out in Git's order.
938fn patch_id_hash_text_hunks(ctx: &mut ChecksumHasher, old_str: &str, new_str: &str) {
939    let body = unified_hunk_body_for_diff_options(old_str, new_str, &DiffOptions::default());
940    for line in body.split_inclusive('\n') {
941        if matches!(line.as_bytes().first(), Some(b' ' | b'-' | b'+')) {
942            hash_without_whitespace(ctx, line.as_bytes());
943        }
944    }
945}
946
947/// Strip [`is_patch_id_space`] bytes in-place; returns new length (prefix of `buf` is valid).
948fn remove_space_bytes(buf: &mut [u8]) -> usize {
949    let mut dst = 0usize;
950    let mut src = 0usize;
951    while src < buf.len() {
952        let c = buf[src];
953        src += 1;
954        if !is_patch_id_space(&c) {
955            buf[dst] = c;
956            dst += 1;
957        }
958    }
959    dst
960}
961
962/// The content each side of `entry` contributes to its patch, as `(old, new)`.
963///
964/// A blob side is read from the ODB and the zero OID (an absent side) is empty.
965/// A gitlink side is its `Subproject commit` line, never an object read: the
966/// submodule's commit lives in another repository.
967fn read_sides(odb: &Odb, entry: &DiffEntry) -> Result<(Vec<u8>, Vec<u8>)> {
968    let read_side = |mode: FileMode, oid: &ObjectId| -> Result<Vec<u8>> {
969        if mode == FileMode::GITLINK {
970            return Ok(gitlink_line(oid).into_bytes());
971        }
972        if *oid == zero_oid() {
973            return Ok(Vec::new());
974        }
975        Ok(odb.read(oid)?.data)
976    };
977    Ok((
978        read_side(entry.old_mode, &entry.old_oid)?,
979        read_side(entry.new_mode, &entry.new_oid)?,
980    ))
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986    use crate::environment::RepositoryOptions;
987    use std::process::Command;
988
989    use tempfile::TempDir;
990
991    fn git_in(dir: &std::path::Path, args: &[&str]) -> String {
992        let out = Command::new("git")
993            .current_dir(dir)
994            .args(args)
995            .env("GIT_CONFIG_GLOBAL", "/dev/null")
996            .env("GIT_CONFIG_SYSTEM", "/dev/null")
997            .env("GIT_AUTHOR_NAME", "Test")
998            .env("GIT_AUTHOR_EMAIL", "t@example.com")
999            .env("GIT_COMMITTER_NAME", "Test")
1000            .env("GIT_COMMITTER_EMAIL", "t@example.com")
1001            .output()
1002            .expect("spawn git");
1003        assert!(
1004            out.status.success(),
1005            "git {:?} failed: {}",
1006            args,
1007            String::from_utf8_lossy(&out.stderr)
1008        );
1009        String::from_utf8_lossy(&out.stdout).into_owned()
1010    }
1011
1012    #[cfg_attr(
1013        not(feature = "full-tests"),
1014        ignore = "starts system git or is heavy; run with --features full-tests"
1015    )]
1016    #[test]
1017    fn text_hunks_hash_only_three_context_lines() {
1018        let old: String = (1..=10).map(|i| format!("{i}\n")).collect();
1019        for (new, expected) in [
1020            (
1021                old.replace("10\n", "changed \t\n"),
1022                b"789-10+changed".as_slice(),
1023            ),
1024            (
1025                old.replacen("1\n", "changed\n", 1),
1026                b"-1+changed234".as_slice(),
1027            ),
1028            (old.replace("10\n", "changed"), b"789-10+changed".as_slice()),
1029        ] {
1030            for algo in [HashAlgo::Sha1, HashAlgo::Sha256] {
1031                let mut ctx = ChecksumHasher::new(algo);
1032                patch_id_hash_text_hunks(&mut ctx, &old, &new);
1033                assert_eq!(ctx.finalize(), algo.checksum_digest(expected));
1034            }
1035        }
1036    }
1037
1038    #[test]
1039    fn text_hunks_hash_overlapping_context_once() {
1040        let old: String = (1..=20).map(|i| format!("{i}\n")).collect();
1041        let new = old
1042            .replacen("8\n", "first\n", 1)
1043            .replace("12\n", "second\n");
1044        let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1045        patch_id_hash_text_hunks(&mut ctx, &old, &new);
1046        assert_eq!(
1047            ctx.finalize(),
1048            HashAlgo::Sha1.checksum_digest(b"567-8+first91011-12+second131415")
1049        );
1050    }
1051
1052    #[test]
1053    fn text_hunks_place_slidable_insertion_like_git_diff() {
1054        let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1055        patch_id_hash_text_hunks(
1056            &mut ctx,
1057            "a\nb\n\n#t\nfn e\nx\ny\n",
1058            "a\nb\n\n#t\nfn c\n#t\nfn e\nx\ny\n",
1059        );
1060        assert_eq!(
1061            ctx.finalize(),
1062            HashAlgo::Sha1.checksum_digest(b"ab+#t+fnc#tfnex")
1063        );
1064    }
1065
1066    #[test]
1067    fn text_hunks_do_not_slide_across_missing_final_newline() {
1068        let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1069        patch_id_hash_text_hunks(
1070            &mut ctx,
1071            "{\n  \"a\": 1\n}\n",
1072            "{\n  \"a\": 2\n}\n{\n  \"b\": 2\n}",
1073        );
1074        assert_eq!(
1075            ctx.finalize(),
1076            HashAlgo::Sha1.checksum_digest(b"{-\"a\":1+\"a\":2}+{+\"b\":2+}")
1077        );
1078    }
1079
1080    #[test]
1081    fn hash_without_whitespace_skips_every_ascii_space() {
1082        let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1083        assert_eq!(hash_without_whitespace(&mut ctx, b" \t+a b\r\nc  \n"), 4);
1084        assert_eq!(ctx.finalize(), HashAlgo::Sha1.checksum_digest(b"+abc"));
1085    }
1086
1087    #[test]
1088    fn hash_without_whitespace_keeps_form_feed_and_vertical_tab() {
1089        let mut ctx = ChecksumHasher::new(HashAlgo::Sha1);
1090        assert_eq!(hash_without_whitespace(&mut ctx, b"+4\x0c+ \x0b\n"), 5);
1091        assert_eq!(
1092            ctx.finalize(),
1093            HashAlgo::Sha1.checksum_digest(b"+4\x0c+\x0b")
1094        );
1095        let mut path = *b"a/\x0cf b";
1096        assert_eq!(remove_space_bytes(&mut path), 5);
1097        assert_eq!(&path[..5], b"a/\x0cfb");
1098    }
1099
1100    #[cfg_attr(
1101        not(feature = "full-tests"),
1102        ignore = "starts system git or is heavy; run with --features full-tests"
1103    )]
1104    #[test]
1105    fn verbatim_built_patch_text_matches_git_show_patch_id() {
1106        let dir = TempDir::new().expect("tempdir");
1107        git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1108        std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1109        git_in(dir.path(), &["add", "f.txt"]);
1110        git_in(dir.path(), &["commit", "-q", "-m", "base"]);
1111        std::fs::write(dir.path().join("f.txt"), b"one  \ntwo\n").unwrap();
1112        git_in(dir.path(), &["add", "f.txt"]);
1113        git_in(dir.path(), &["commit", "-q", "-m", "change"]);
1114        let commit: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
1115            .trim()
1116            .parse()
1117            .unwrap();
1118        let git_show = git_in(dir.path(), &["show", "-p", "HEAD"]);
1119
1120        let repo = crate::repo::Repository::open(
1121            &dir.path().join(".git"),
1122            &RepositoryOptions::empty().with_work_tree(dir.path()),
1123        )
1124        .unwrap();
1125        let grit_verbatim =
1126            compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Verbatim)
1127                .expect("verbatim")
1128                .expect("some");
1129
1130        let from_git_show =
1131            compute_patch_ids_from_text(HashAlgo::Sha1, git_show.as_bytes(), PatchIdMode::Verbatim)
1132                .into_iter()
1133                .find(|(_, c)| *c == commit)
1134                .expect("commit in git show stream")
1135                .0;
1136
1137        assert_eq!(
1138            grit_verbatim, from_git_show,
1139            "ODB verbatim should match parsing git show -p"
1140        );
1141    }
1142
1143    #[cfg_attr(
1144        not(feature = "full-tests"),
1145        ignore = "starts system git or is heavy; run with --features full-tests"
1146    )]
1147    #[test]
1148    fn stable_patch_ids_match_git_patch_id_on_log_p() {
1149        let dir = TempDir::new().expect("tempdir");
1150        git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1151        std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1152        git_in(dir.path(), &["add", "f.txt"]);
1153        git_in(dir.path(), &["commit", "-q", "-m", "first"]);
1154        std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
1155        git_in(dir.path(), &["add", "f.txt"]);
1156        git_in(dir.path(), &["commit", "-q", "-m", "second"]);
1157        std::fs::write(dir.path().join("g.txt"), b"new\n").unwrap();
1158        git_in(dir.path(), &["add", "g.txt"]);
1159        git_in(dir.path(), &["commit", "-q", "-m", "third"]);
1160
1161        let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
1162        let grit_ids =
1163            compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), PatchIdMode::Stable);
1164
1165        use std::io::Write;
1166        let mut child = Command::new("git")
1167            .current_dir(dir.path())
1168            .args(["patch-id", "--stable"])
1169            .stdin(std::process::Stdio::piped())
1170            .stdout(std::process::Stdio::piped())
1171            .stderr(std::process::Stdio::piped())
1172            .spawn()
1173            .expect("spawn git patch-id");
1174        child
1175            .stdin
1176            .take()
1177            .expect("stdin")
1178            .write_all(log_p.as_bytes())
1179            .expect("write log");
1180        let git_out = child.wait_with_output().expect("wait");
1181        assert!(
1182            git_out.status.success(),
1183            "git patch-id --stable failed: {}",
1184            String::from_utf8_lossy(&git_out.stderr)
1185        );
1186        let git_lines: Vec<(ObjectId, ObjectId)> = String::from_utf8_lossy(&git_out.stdout)
1187            .lines()
1188            .filter(|l| !l.is_empty())
1189            .map(|line| {
1190                let mut parts = line.split_whitespace();
1191                let patch_hex = parts.next().expect("patch id");
1192                let commit_hex = parts.next().expect("commit id");
1193                let patch = ObjectId::from_hex(patch_hex).expect("patch hex");
1194                let commit = ObjectId::from_hex(commit_hex).expect("commit hex");
1195                (patch, commit)
1196            })
1197            .collect();
1198
1199        assert_eq!(grit_ids.len(), git_lines.len(), "patch-id count mismatch");
1200        for ((g_patch, g_commit), (git_patch, git_commit)) in grit_ids.iter().zip(git_lines.iter())
1201        {
1202            assert_eq!(g_patch, git_patch, "patch-id mismatch");
1203            assert_eq!(g_commit, git_commit, "commit-id mismatch");
1204        }
1205    }
1206
1207    fn git_patch_ids_from_log(dir: &std::path::Path, mode_flag: &str) -> Vec<(ObjectId, ObjectId)> {
1208        let log_p = git_in(dir, &["log", "-p", "--reverse"]);
1209        use std::io::Write;
1210        let mut child = Command::new("git")
1211            .current_dir(dir)
1212            .args(["patch-id", mode_flag])
1213            .stdin(std::process::Stdio::piped())
1214            .stdout(std::process::Stdio::piped())
1215            .stderr(std::process::Stdio::piped())
1216            .spawn()
1217            .expect("spawn git patch-id");
1218        child
1219            .stdin
1220            .take()
1221            .expect("stdin")
1222            .write_all(log_p.as_bytes())
1223            .expect("write log");
1224        let out = child.wait_with_output().expect("wait");
1225        assert!(
1226            out.status.success(),
1227            "git patch-id failed: {}",
1228            String::from_utf8_lossy(&out.stderr)
1229        );
1230        String::from_utf8_lossy(&out.stdout)
1231            .lines()
1232            .filter(|l| !l.is_empty())
1233            .map(|line| {
1234                let mut parts = line.split_whitespace();
1235                let patch_hex = parts.next().expect("patch id");
1236                let commit_hex = parts.next().expect("commit id");
1237                (
1238                    ObjectId::from_hex(patch_hex).expect("patch hex"),
1239                    ObjectId::from_hex(commit_hex).expect("commit hex"),
1240                )
1241            })
1242            .collect()
1243    }
1244
1245    #[cfg_attr(
1246        not(feature = "full-tests"),
1247        ignore = "starts system git or is heavy; run with --features full-tests"
1248    )]
1249    #[test]
1250    fn t4204_patch_id_modes_match_git() {
1251        let dir = TempDir::new().expect("tempdir");
1252        git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
1253        std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
1254        git_in(dir.path(), &["add", "f.txt"]);
1255        git_in(dir.path(), &["commit", "-q", "-m", "first"]);
1256        std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
1257        git_in(dir.path(), &["add", "f.txt"]);
1258        git_in(dir.path(), &["commit", "-q", "-m", "second"]);
1259        std::fs::write(dir.path().join("g.bin"), &[0u8, 1, 2, 3]).unwrap();
1260        git_in(dir.path(), &["add", "g.bin"]);
1261        git_in(dir.path(), &["commit", "-q", "-m", "binary"]);
1262        std::fs::write(dir.path().join("h.txt"), b"a\n").unwrap();
1263        git_in(dir.path(), &["add", "h.txt"]);
1264        git_in(dir.path(), &["commit", "-q", "-m", "add-h"]);
1265        git_in(dir.path(), &["mv", "h.txt", "i.txt"]);
1266        git_in(dir.path(), &["commit", "-q", "-m", "rename"]);
1267        #[cfg(unix)]
1268        {
1269            use std::os::unix::fs::PermissionsExt;
1270            let mut perms = std::fs::metadata(dir.path().join("f.txt"))
1271                .expect("f.txt")
1272                .permissions();
1273            perms.set_mode(0o100755);
1274            std::fs::set_permissions(dir.path().join("f.txt"), perms).expect("chmod f.txt");
1275        }
1276        git_in(dir.path(), &["add", "f.txt"]);
1277        git_in(dir.path(), &["commit", "-q", "-m", "mode-only"]);
1278        let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
1279
1280        for (mode, flag) in [
1281            (PatchIdMode::Stable, "--stable"),
1282            (PatchIdMode::Unstable, "--unstable"),
1283            (PatchIdMode::Verbatim, "--verbatim"),
1284        ] {
1285            let grit_ids = compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), mode);
1286            let git_ids = git_patch_ids_from_log(dir.path(), flag);
1287            assert_eq!(grit_ids.len(), git_ids.len(), "patch-id count for {flag:?}");
1288            for ((g_patch, g_commit), (git_patch, git_commit)) in
1289                grit_ids.iter().zip(git_ids.iter())
1290            {
1291                assert_eq!(g_patch, git_patch, "patch-id {flag}");
1292                assert_eq!(g_commit, git_commit, "commit-id {flag}");
1293            }
1294        }
1295
1296        let repo = crate::repo::Repository::open(
1297            &dir.path().join(".git"),
1298            &RepositoryOptions::empty().with_work_tree(dir.path()),
1299        )
1300        .unwrap();
1301        let git_stable = git_patch_ids_from_log(dir.path(), "--stable");
1302        let assert_odb_matches_git = |commit_hex: &str, label: &str| {
1303            let commit: ObjectId = commit_hex.trim().parse().expect("commit oid");
1304            let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Stable)
1305                .expect("patch")
1306                .expect("some");
1307            let git_patch = git_stable
1308                .iter()
1309                .find(|(_, c)| c == &commit)
1310                .expect(label)
1311                .0;
1312            assert_eq!(grit, git_patch, "{label}");
1313        };
1314
1315        let second = git_in(
1316            dir.path(),
1317            &["rev-list", "--max-count=1", "--skip=4", "HEAD"],
1318        );
1319        assert_odb_matches_git(&second, "git patch-id for second commit");
1320        let binary = git_in(
1321            dir.path(),
1322            &["rev-list", "--max-count=1", "--skip=3", "HEAD"],
1323        );
1324        assert_odb_matches_git(&binary, "git patch-id for binary commit");
1325        let rename = git_in(
1326            dir.path(),
1327            &["rev-list", "--max-count=1", "--skip=1", "HEAD"],
1328        );
1329        assert_odb_matches_git(&rename, "git patch-id for rename commit");
1330        let mode_only = git_in(dir.path(), &["rev-parse", "HEAD"]);
1331        assert_odb_matches_git(&mode_only, "git patch-id for mode-only commit");
1332
1333        let add_h = git_in(
1334            dir.path(),
1335            &["rev-list", "--max-count=1", "--skip=2", "HEAD"],
1336        );
1337        let add_h: ObjectId = add_h.trim().parse().unwrap();
1338        let filtered = compute_patch_id_for_paths(
1339            HashAlgo::Sha1,
1340            &repo.odb(),
1341            &add_h,
1342            &[String::from("h.txt")],
1343            PatchIdMode::Stable,
1344        )
1345        .expect("filtered h.txt")
1346        .expect("some h.txt");
1347        let git_h = git_stable
1348            .iter()
1349            .find(|(_, c)| c == &add_h)
1350            .expect("git patch-id for add-h")
1351            .0;
1352        assert_eq!(
1353            filtered, git_h,
1354            "ODB path-filter h.txt vs full commit patch-id"
1355        );
1356        assert!(
1357            compute_patch_id_for_paths(
1358                HashAlgo::Sha1,
1359                &repo.odb(),
1360                &add_h,
1361                &[String::from("f.txt")],
1362                PatchIdMode::Stable,
1363            )
1364            .expect("filtered f.txt")
1365            .is_none(),
1366            "path filter excluding all changes must be None"
1367        );
1368
1369        git_in(dir.path(), &["checkout", "-q", "HEAD~4"]);
1370        git_in(dir.path(), &["checkout", "-q", "-b", "side"]);
1371        std::fs::write(dir.path().join("side.txt"), b"s\n").unwrap();
1372        git_in(dir.path(), &["add", "side.txt"]);
1373        git_in(dir.path(), &["commit", "-q", "-m", "side"]);
1374        git_in(dir.path(), &["checkout", "-q", "main"]);
1375        git_in(dir.path(), &["merge", "-q", "--no-ff", "side"]);
1376        let merge_tip: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
1377            .trim()
1378            .parse()
1379            .unwrap();
1380        assert!(
1381            compute_patch_id(HashAlgo::Sha1, &repo.odb(), &merge_tip, PatchIdMode::Stable)
1382                .expect("merge")
1383                .is_none()
1384        );
1385    }
1386
1387    fn git_show_patch_id(dir: &std::path::Path, commit: &str, mode_flag: &str) -> ObjectId {
1388        use std::io::Write;
1389        let show = git_in(dir, &["show", commit]);
1390        let mut child = Command::new("git")
1391            .current_dir(dir)
1392            .args(["patch-id", mode_flag])
1393            .stdin(std::process::Stdio::piped())
1394            .stdout(std::process::Stdio::piped())
1395            .spawn()
1396            .expect("spawn git patch-id");
1397        child
1398            .stdin
1399            .take()
1400            .expect("stdin")
1401            .write_all(show.as_bytes())
1402            .expect("write show");
1403        let out = child.wait_with_output().expect("wait");
1404        let stdout = String::from_utf8_lossy(&out.stdout);
1405        let hex = stdout.split_whitespace().next().expect("patch id");
1406        ObjectId::from_hex(hex).expect("patch hex")
1407    }
1408
1409    #[cfg_attr(
1410        not(feature = "full-tests"),
1411        ignore = "starts system git or is heavy; run with --features full-tests"
1412    )]
1413    #[test]
1414    fn empty_file_add_and_delete_patch_ids_match_git() {
1415        let dir = TempDir::new().expect("tempdir");
1416        let path = dir.path();
1417        git_in(path, &["init", "-q", "--initial-branch=main", "."]);
1418        std::fs::write(path.join("a"), b"a\n").unwrap();
1419        git_in(path, &["add", "a"]);
1420        git_in(path, &["commit", "-q", "-m", "c1"]);
1421
1422        let mut commits = Vec::new();
1423        let mut commit = |label: &str| {
1424            git_in(path, &["commit", "-q", "-m", label]);
1425            commits.push((label.to_owned(), git_in(path, &["rev-parse", "HEAD"])));
1426        };
1427        std::fs::write(path.join("empty"), b"").unwrap();
1428        git_in(path, &["add", "empty"]);
1429        commit("add empty");
1430        git_in(path, &["rm", "-q", "empty"]);
1431        commit("delete empty");
1432        std::fs::write(path.join("run"), b"").unwrap();
1433        git_in(path, &["add", "--chmod=+x", "run"]);
1434        commit("add empty executable");
1435        std::fs::write(path.join("a"), b"a\nb\n").unwrap();
1436        std::fs::write(path.join("z-empty"), b"").unwrap();
1437        git_in(path, &["add", "a", "z-empty"]);
1438        commit("edit and add empty");
1439        std::fs::write(path.join("0-empty"), b"").unwrap();
1440        std::fs::write(path.join("a"), b"a\nb\nc\n").unwrap();
1441        git_in(path, &["add", "0-empty", "a"]);
1442        commit("add empty before edit");
1443        git_in(path, &["rm", "-q", "0-empty", "z-empty"]);
1444        std::fs::write(path.join("b"), b"b\n").unwrap();
1445        git_in(path, &["add", "b"]);
1446        commit("delete empties around add");
1447        git_in(path, &["update-index", "--chmod=+x", "a"]);
1448        std::fs::write(path.join("b"), b"b\nc\n").unwrap();
1449        git_in(path, &["add", "b"]);
1450        commit("mode change before edit");
1451        git_in(path, &["mv", "a", "0-moved"]);
1452        std::fs::write(path.join("b"), b"b\nc\nd\n").unwrap();
1453        git_in(path, &["add", "b"]);
1454        commit("pure rename before edit");
1455        std::fs::write(path.join("b"), b"").unwrap();
1456        git_in(path, &["add", "b"]);
1457        commit("truncate to empty");
1458
1459        let repo = crate::repo::Repository::open(
1460            &path.join(".git"),
1461            &RepositoryOptions::empty().with_work_tree(path),
1462        )
1463        .unwrap();
1464        for (label, hex) in &commits {
1465            let oid: ObjectId = hex.trim().parse().expect("commit oid");
1466            for (mode, flag) in [
1467                (PatchIdMode::Stable, "--stable"),
1468                (PatchIdMode::Unstable, "--unstable"),
1469                (PatchIdMode::Verbatim, "--verbatim"),
1470            ] {
1471                let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &oid, mode)
1472                    .expect("patch id")
1473                    .expect("non-merge commit");
1474                let git = git_show_patch_id(path, hex.trim(), flag);
1475                assert_eq!(grit, git, "{label} {flag}");
1476            }
1477        }
1478    }
1479
1480    #[test]
1481    fn stable_patch_id_matches_git_with_invalid_utf8_in_unchanged_context() {
1482        let dir = TempDir::new().expect("tempdir");
1483        let path = dir.path();
1484        git_in(path, &["init", "-q", "--initial-branch=main", "."]);
1485        let mut base = Vec::new();
1486        for i in 0..420 {
1487            base.extend_from_slice(format!("line{i}\n").as_bytes());
1488        }
1489        base.extend_from_slice(b"context one\n");
1490        base.extend_from_slice(b"invalid \xF8 in line\n");
1491        base.extend_from_slice(b"context two\n");
1492        base.extend_from_slice(b"near end one\n");
1493        base.extend_from_slice(b"near end two\n");
1494        base.extend_from_slice(b"near end three\n");
1495        base.extend_from_slice(b"\n");
1496        base.extend_from_slice(b"test_done\n");
1497
1498        let mut new = base.clone();
1499        let insert_at = base.len() - b"test_done\n".len();
1500        new.splice(
1501            insert_at..insert_at,
1502            b"inserted one\ninserted two\ninserted three\ninserted four\ninserted five\ninserted six\n"
1503                .iter()
1504                .copied(),
1505        );
1506
1507        std::fs::write(path.join("f"), &base).unwrap();
1508        git_in(path, &["add", "f"]);
1509        git_in(path, &["commit", "-q", "-m", "base"]);
1510        std::fs::write(path.join("f"), &new).unwrap();
1511        git_in(path, &["add", "f"]);
1512        git_in(path, &["commit", "-q", "-m", "change"]);
1513
1514        let commit: ObjectId = git_in(path, &["rev-parse", "HEAD"])
1515            .trim()
1516            .parse()
1517            .expect("commit oid");
1518        let repo = crate::repo::Repository::open(
1519            &path.join(".git"),
1520            &RepositoryOptions::empty().with_work_tree(path),
1521        )
1522        .expect("open repo");
1523        let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Stable)
1524            .expect("patch id")
1525            .expect("non-empty patch");
1526        let git = git_show_patch_id(path, commit.to_hex().as_str(), "--stable");
1527        assert_eq!(
1528            grit, git,
1529            "stable patch-id with invalid utf8 in unchanged context"
1530        );
1531    }
1532}