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