Skip to main content

grit_lib/apply/
mod.rs

1//! Unified/`git`-diff patch parsing and blob-level hunk application.
2//!
3//! Parses patch text into structured [`FilePatch`]/[`Hunk`] data with no I/O or
4//! environment access. [`apply_hunks_to_blob`] applies hunks to a single blob
5//! in memory (worktree/index application is built on this in later layers).
6//!
7//! Library guide: [apply](https://grit-scm.com/docs/library/embedding)
8//!
9//! ```rust
10//! use grit_lib::repo::{InitOptions, Repository};
11//! # fn main() -> grit_lib::error::Result<()> {
12//! let dir = tempfile::tempdir()?;
13//! let _repo = Repository::init(dir.path(), &InitOptions::default())?;
14//! # Ok(())
15//! # }
16//! ```
17
18mod apply_blob;
19
20pub use apply_blob::{apply_hunks_to_blob, ApplyOptions};
21
22use crate::error::{ApplyError, Error, Result};
23use regex::Regex;
24use std::sync::OnceLock;
25
26/// A single hunk in a unified diff.
27#[derive(Debug, Clone)]
28pub struct Hunk {
29    /// 1-based line number in the old file.
30    pub old_start: usize,
31    /// Number of lines in the old side.
32    pub old_count: usize,
33    /// 1-based line number in the new file.
34    pub new_start: usize,
35    /// Number of lines on the new side.
36    pub new_count: usize,
37    /// 1-based line number in the patch file of the first hunk body line (line after `@@`).
38    pub first_body_line: usize,
39    /// Lines of the hunk body (' ', '+', '-' prefixed, or bare '\' no newline).
40    pub lines: Vec<HunkLine>,
41}
42
43/// One line in a unified-diff hunk body.
44#[derive(Debug, Clone)]
45pub enum HunkLine {
46    /// Unchanged context line (leading space in the patch).
47    Context(String),
48    /// Added line (leading `+`).
49    Add(String),
50    /// Removed line (leading `-`).
51    Remove(String),
52    /// "\ No newline at end of file"
53    NoNewline,
54}
55
56/// Represents one file in a unified diff.
57#[derive(Debug, Clone)]
58pub struct FilePatch {
59    /// Path from `diff --git` old side (`a/...`) when present.
60    pub diff_old_path: Option<String>,
61    /// Path from `diff --git` new side (`b/...`) when present.
62    pub diff_new_path: Option<String>,
63    /// Path on the old side (None for new files).
64    pub old_path: Option<String>,
65    /// Path on the new side (None for deleted files).
66    pub new_path: Option<String>,
67    /// Whether an explicit `---` header line was present.
68    pub saw_old_header: bool,
69    /// Whether an explicit `+++` header line was present.
70    pub saw_new_header: bool,
71    /// Old mode from extended header.
72    pub old_mode: Option<String>,
73    /// New mode from extended header.
74    pub new_mode: Option<String>,
75    /// Source line (1-based) of `old mode` / `deleted file mode` for diagnostics.
76    pub old_mode_line: Option<usize>,
77    /// Source line (1-based) of `new mode` / `new file mode` for diagnostics.
78    pub new_mode_line: Option<usize>,
79    /// Whether this file is being newly created.
80    pub is_new: bool,
81    /// Whether this file is being deleted.
82    pub is_deleted: bool,
83    /// Whether this is a rename.
84    pub is_rename: bool,
85    /// Whether this is a copy.
86    pub is_copy: bool,
87    /// Similarity index (e.g., 90 for 90%).
88    pub similarity_index: Option<u32>,
89    /// Dissimilarity index for rewrites.
90    pub dissimilarity_index: Option<u32>,
91    /// Old blob OID from the index header (abbreviated).
92    pub old_oid: Option<String>,
93    /// New blob OID from the index header (abbreviated).
94    pub new_oid: Option<String>,
95    /// Parsed binary patch payload (`GIT binary patch`) if present.
96    pub binary_patch: Option<BinaryPatchPayload>,
97    /// Whether this is a binary change (`GIT binary patch` payload or a
98    /// `Binary files ... differ` marker); stat/numstat show `Bin` / `-`.
99    pub is_binary: bool,
100    /// Hunks to apply.
101    pub hunks: Vec<Hunk>,
102    /// Merged `core.whitespace` + `whitespace` attribute (Git `ws_rule`); `0` before assignment.
103    pub ws_rule: u32,
104    /// Git `patch->is_toplevel_relative`: set for `diff --git` patches only. When false, paths are
105    /// prefixed with the setup directory (work-tree-relative CWD) like `prefix_patch` in Git.
106    pub is_toplevel_relative: bool,
107}
108
109/// Whether a `GIT binary patch` section holds a full literal or a delta against the other side.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum BinaryPatchSectionKind {
112    /// Zlib-compressed full file contents (`literal N`).
113    Literal,
114    /// Zlib-compressed Git binary delta (`delta N`) applied to the preimage blob.
115    Delta,
116}
117
118/// One forward or reverse chunk in a `GIT binary patch`.
119#[derive(Debug, Clone)]
120pub struct BinaryPatchSection {
121    /// Whether this chunk is a full literal or a delta.
122    pub kind: BinaryPatchSectionKind,
123    /// Zlib-compressed payload from the `GIT binary patch` section.
124    pub compressed: Vec<u8>,
125    /// Declared uncompressed size from the section header (`literal N` / `delta N`).
126    pub declared_size: usize,
127}
128
129/// Binary patch payload as compressed base85 chunks for forward/reverse apply.
130#[derive(Debug, Clone)]
131pub struct BinaryPatchPayload {
132    /// Forward-direction patch chunk applied when checking out the new blob.
133    pub forward: BinaryPatchSection,
134    /// Reverse section when present (second `literal`/`delta` block).
135    pub reverse: Option<BinaryPatchSection>,
136}
137
138impl FilePatch {
139    fn empty() -> Self {
140        Self {
141            diff_old_path: None,
142            diff_new_path: None,
143            old_path: None,
144            new_path: None,
145            saw_old_header: false,
146            saw_new_header: false,
147            old_mode: None,
148            new_mode: None,
149            old_mode_line: None,
150            new_mode_line: None,
151            is_new: false,
152            is_deleted: false,
153            is_rename: false,
154            is_copy: false,
155            similarity_index: None,
156            dissimilarity_index: None,
157            old_oid: None,
158            new_oid: None,
159            binary_patch: None,
160            is_binary: false,
161            hunks: Vec::new(),
162            ws_rule: 0,
163            is_toplevel_relative: false,
164        }
165    }
166
167    /// Effective path for the file.
168    /// For deletions, use old_path (new is /dev/null).
169    /// For additions, use new_path (old is /dev/null).
170    /// Otherwise prefer new_path.
171    pub fn effective_path(&self) -> Option<&str> {
172        if self.is_deleted {
173            return self
174                .old_path
175                .as_deref()
176                .filter(|p| *p != "/dev/null")
177                .or(self.new_path.as_deref().filter(|p| *p != "/dev/null"));
178        }
179        if self.is_new {
180            return self
181                .new_path
182                .as_deref()
183                .filter(|p| *p != "/dev/null")
184                .or(self.old_path.as_deref().filter(|p| *p != "/dev/null"));
185        }
186        self.new_path
187            .as_deref()
188            .filter(|p| *p != "/dev/null")
189            .or(self.old_path.as_deref().filter(|p| *p != "/dev/null"))
190    }
191
192    /// Source path to read preimage content from.
193    ///
194    /// For rename/copy patches this is the old path, otherwise this is the
195    /// effective path.
196    pub fn source_path(&self) -> Option<&str> {
197        if self.is_rename || self.is_copy {
198            self.old_path
199                .as_deref()
200                .filter(|p| *p != "/dev/null")
201                .or(self.effective_path())
202        } else if let (Some(old), Some(new)) = (self.old_path.as_deref(), self.new_path.as_deref())
203        {
204            if old != "/dev/null" && new != "/dev/null" && old != new {
205                Some(old)
206            } else {
207                self.effective_path()
208            }
209        } else {
210            self.effective_path()
211        }
212    }
213
214    /// Destination path to write postimage content to.
215    ///
216    /// For additions/renames/copies this is the new path, otherwise this is
217    /// the effective path.
218    pub fn target_path(&self) -> Option<&str> {
219        if self.is_new || self.is_rename || self.is_copy {
220            self.new_path
221                .as_deref()
222                .filter(|p| *p != "/dev/null")
223                .or(self.effective_path())
224        } else {
225            self.effective_path()
226        }
227    }
228
229    /// True when this patch touches a gitlink/submodule (mode `160000`).
230    pub fn involves_gitlink(&self) -> bool {
231        self.old_mode.as_deref() == Some("160000") || self.new_mode.as_deref() == Some("160000")
232    }
233
234    /// Work-tree-relative path for filesystem IO and `.gitattributes` (Git `prefix_patch`).
235    pub fn worktree_rel_operational(&self, adjusted: &str, setup_prefix: &str) -> String {
236        if self.is_toplevel_relative {
237            adjusted.to_string()
238        } else {
239            format!("{setup_prefix}{adjusted}")
240        }
241    }
242}
243
244/// Strip trailing `\r` and surrounding whitespace from parsed header tokens.
245///
246/// `git diff` may emit CRLF line endings; without this, `new mode 160000\r` fails to match
247/// submodule handling (`t4137-apply-submodule`).
248fn sanitize_patch_header_value(s: &mut String) {
249    *s = s.trim().trim_end_matches('\r').to_string();
250}
251
252/// Strip Git's `diff --git a/... b/...` path prefix when it leaked into stored paths.
253///
254/// Binary patches often omit `---`/`+++` lines that would normally resynchronize names; without
255/// this, paths like `a/bin.png` are misinterpreted as real file paths (`t4108-apply-threeway`).
256fn strip_git_diff_path_prefix(path: &str) -> String {
257    if path == "/dev/null" {
258        return path.to_string();
259    }
260    let p = path.trim_start_matches("./");
261    if let Some(rest) = p.strip_prefix("a/") {
262        return rest.to_string();
263    }
264    if let Some(rest) = p.strip_prefix("b/") {
265        return rest.to_string();
266    }
267    path.to_string()
268}
269
270fn sanitize_file_patch_headers(fp: &mut FilePatch) {
271    if let Some(ref mut s) = fp.old_mode {
272        sanitize_patch_header_value(s);
273        if s.is_empty() {
274            fp.old_mode = None;
275        }
276    }
277    if let Some(ref mut s) = fp.new_mode {
278        sanitize_patch_header_value(s);
279        if s.is_empty() {
280            fp.new_mode = None;
281        }
282    }
283    if let Some(ref mut s) = fp.old_oid {
284        sanitize_patch_header_value(s);
285    }
286    if let Some(ref mut s) = fp.new_oid {
287        sanitize_patch_header_value(s);
288    }
289    for ref mut s in [
290        &mut fp.diff_old_path,
291        &mut fp.diff_new_path,
292        &mut fp.old_path,
293        &mut fp.new_path,
294    ]
295    .into_iter()
296    .flatten()
297    {
298        sanitize_patch_header_value(s);
299        **s = strip_git_diff_path_prefix(s);
300    }
301}
302
303/// Collapse runs of `/` to a single slash (Git `squash_slash`).
304fn squash_slash_path(s: &str) -> String {
305    let mut out = String::with_capacity(s.len());
306    let mut prev_slash = false;
307    for ch in s.chars() {
308        if ch == '/' {
309            if !prev_slash {
310                out.push('/');
311            }
312            prev_slash = true;
313        } else {
314            prev_slash = false;
315            out.push(ch);
316        }
317    }
318    out
319}
320
321/// Unquote a leading C-style `"..."` from `line`; returns decoded bytes and remainder after closing `"`.
322/// Matches Git `unquote_c_style` / `quote.c` escapes used in diff headers.
323fn unquote_c_style_diff_prefix(line: &str) -> Option<(Vec<u8>, &str)> {
324    let b = line.as_bytes();
325    if b.first() != Some(&b'"') {
326        return None;
327    }
328    let mut q = &b[1..];
329    let mut out = Vec::new();
330    loop {
331        let len = q
332            .iter()
333            .position(|&c| c == b'"' || c == b'\\')
334            .unwrap_or(q.len());
335        out.extend_from_slice(&q[..len]);
336        q = &q[len..];
337        if q.is_empty() {
338            return None;
339        }
340        match q[0] {
341            b'"' => {
342                let rest = std::str::from_utf8(&q[1..]).ok()?;
343                return Some((out, rest));
344            }
345            b'\\' => {
346                q = &q[1..];
347                if q.is_empty() {
348                    return None;
349                }
350                let ch = q[0];
351                q = &q[1..];
352                match ch {
353                    b'a' => out.push(0x07),
354                    b'b' => out.push(0x08),
355                    b'f' => out.push(0x0c),
356                    b'n' => out.push(b'\n'),
357                    b'r' => out.push(b'\r'),
358                    b't' => out.push(b'\t'),
359                    b'v' => out.push(0x0b),
360                    b'\\' => out.push(b'\\'),
361                    b'"' => out.push(b'"'),
362                    b'0'..=b'3' => {
363                        if q.len() < 2 {
364                            return None;
365                        }
366                        let ch2 = q[0];
367                        let ch3 = q[1];
368                        if !(b'0'..=b'7').contains(&ch2) || !(b'0'..=b'7').contains(&ch3) {
369                            return None;
370                        }
371                        let ac = u32::from(ch - b'0') * 64
372                            + u32::from(ch2 - b'0') * 8
373                            + u32::from(ch3 - b'0');
374                        out.push(ac as u8);
375                        q = &q[2..];
376                    }
377                    _ => return None,
378                }
379            }
380            _ => return None,
381        }
382    }
383}
384
385fn bytes_to_path_string(bytes: &[u8]) -> Result<String> {
386    let s = String::from_utf8(bytes.to_vec())
387        .map_err(|e| Error::Message(format!("diff path is not valid UTF-8: {e}")))?;
388    Ok(squash_slash_path(&s))
389}
390
391/// Skip `p_value` leading path components (Git `skip_tree_prefix`); `p_value == 0` allows absolute paths.
392fn skip_tree_prefix_bytes(line: &[u8], p_value: usize) -> Option<&[u8]> {
393    if p_value == 0 {
394        return Some(line);
395    }
396    let mut nslash = p_value;
397    let mut i = 0usize;
398    while i < line.len() {
399        if line[i] == b'/' {
400            nslash = nslash.saturating_sub(1);
401            if nslash == 0 {
402                return if i == 0 { None } else { Some(&line[i + 1..]) };
403            }
404        }
405        i += 1;
406    }
407    None
408}
409
410/// Strip `p_value` leading `/`-separated components from a UTF-8 path (for `rename from` etc.).
411fn skip_tree_prefix_str(path: &str, p_value: usize) -> Option<String> {
412    let stripped = skip_tree_prefix_bytes(path.as_bytes(), p_value)?;
413    Some(String::from_utf8_lossy(stripped).into_owned())
414}
415
416fn sane_tz_len(line: &[u8]) -> usize {
417    const SUFFIX: &[u8] = b" +0500";
418    if line.len() < SUFFIX.len() || line[line.len() - SUFFIX.len()] != b' ' {
419        return 0;
420    }
421    let tz = &line[line.len() - SUFFIX.len()..];
422    if tz[1] != b'+' && tz[1] != b'-' {
423        return 0;
424    }
425    for p in &tz[2..] {
426        if !p.is_ascii_digit() {
427            return 0;
428        }
429    }
430    SUFFIX.len()
431}
432
433fn tz_with_colon_len(line: &[u8]) -> usize {
434    // Git: suffix is ` ±HH:MM` (space, sign, two hour digits, colon, two minute digits) = 7 bytes.
435    const SUFFIX_LEN: usize = 7;
436    if line.len() < SUFFIX_LEN || line[line.len() - 3] != b':' {
437        return 0;
438    }
439    let tz = &line[line.len() - SUFFIX_LEN..];
440    if tz[0] != b' ' || (tz[1] != b'+' && tz[1] != b'-') {
441        return 0;
442    }
443    let p = &tz[2..];
444    if p.len() != 5
445        || !p[0].is_ascii_digit()
446        || !p[1].is_ascii_digit()
447        || p[2] != b':'
448        || !p[3].is_ascii_digit()
449        || !p[4].is_ascii_digit()
450    {
451        return 0;
452    }
453    SUFFIX_LEN
454}
455
456fn date_len(line: &[u8]) -> usize {
457    const SHORT: &[u8] = b"72-02-05";
458    if line.len() < SHORT.len() || line[line.len() - 3] != b'-' {
459        return 0;
460    }
461    let mut p = line.len() - SHORT.len();
462    let date = &line[p..];
463    if !date[0].is_ascii_digit()
464        || !date[1].is_ascii_digit()
465        || date[2] != b'-'
466        || !date[3].is_ascii_digit()
467        || !date[4].is_ascii_digit()
468        || date[5] != b'-'
469        || !date[6].is_ascii_digit()
470        || !date[7].is_ascii_digit()
471    {
472        return 0;
473    }
474    if p >= 2 {
475        let y1 = line[p - 1];
476        let y2 = line[p - 2];
477        if y1.is_ascii_digit() && y2.is_ascii_digit() {
478            p -= 2;
479        }
480    }
481    line.len() - p
482}
483
484fn short_time_len(line: &[u8]) -> usize {
485    const PAT: &[u8] = b" 07:01:32";
486    if line.len() < PAT.len() || line[line.len() - 3] != b':' {
487        return 0;
488    }
489    let p = line.len() - PAT.len();
490    let time = &line[p..];
491    if time[0] != b' '
492        || !time[1].is_ascii_digit()
493        || !time[2].is_ascii_digit()
494        || time[3] != b':'
495        || !time[4].is_ascii_digit()
496        || !time[5].is_ascii_digit()
497        || time[6] != b':'
498        || !time[7].is_ascii_digit()
499        || !time[8].is_ascii_digit()
500    {
501        return 0;
502    }
503    PAT.len()
504}
505
506fn fractional_time_len(line: &[u8]) -> usize {
507    if line.is_empty() || !line[line.len() - 1].is_ascii_digit() {
508        return 0;
509    }
510    let mut p = line.len() - 1;
511    while p > 0 && line[p].is_ascii_digit() {
512        p -= 1;
513    }
514    if p == 0 || line[p] != b'.' {
515        return 0;
516    }
517    let n = short_time_len(&line[..p]);
518    if n == 0 {
519        return 0;
520    }
521    line.len() - p + n
522}
523
524fn trailing_spaces_len(line: &[u8]) -> usize {
525    if line.is_empty() || line[line.len() - 1] != b' ' {
526        return 0;
527    }
528    let mut p = line.len();
529    while p > 0 {
530        p -= 1;
531        if line[p] != b' ' {
532            return line.len() - (p + 1);
533        }
534    }
535    line.len()
536}
537
538fn diff_timestamp_len(line: &[u8]) -> usize {
539    if line.is_empty() || !line[line.len() - 1].is_ascii_digit() {
540        return 0;
541    }
542    let mut end = line.len();
543    let mut n = sane_tz_len(&line[..end]);
544    if n == 0 {
545        n = tz_with_colon_len(&line[..end]);
546    }
547    if n == 0 {
548        return 0;
549    }
550    end -= n;
551
552    n = short_time_len(&line[..end]);
553    if n == 0 {
554        n = fractional_time_len(&line[..end]);
555    }
556    if n == 0 {
557        return 0;
558    }
559    end -= n;
560
561    n = date_len(&line[..end]);
562    if n == 0 {
563        return 0;
564    }
565    end -= n;
566
567    if end == 0 {
568        return 0;
569    }
570    match line[end - 1] {
571        b'\t' => {
572            end -= 1;
573            line.len() - end
574        }
575        b' ' => {
576            end -= trailing_spaces_len(&line[..end]);
577            line.len() - end
578        }
579        _ => 0,
580    }
581}
582
583/// Git `find_name_common` with optional `end` bound (exclusive).
584fn find_name_common_bounded(
585    line: &[u8],
586    def: Option<&[u8]>,
587    p_value: usize,
588    end: usize,
589) -> Option<Vec<u8>> {
590    let end = end.min(line.len());
591    let mut start: Option<usize> = if p_value == 0 { Some(0) } else { None };
592    let mut p = p_value;
593    let mut i = 0usize;
594    while i < end {
595        let c = line[i];
596        i += 1;
597        if c == b'/' && p > 0 {
598            p -= 1;
599            if p == 0 {
600                start = Some(i);
601            }
602        }
603    }
604    let start = start?;
605    let len = i - start;
606    if len == 0 {
607        return def.map(|d| d.to_vec());
608    }
609    let slice = &line[start..i];
610    if let Some(d) = def {
611        if d.len() < len && slice.starts_with(d) {
612            return Some(d.to_vec());
613        }
614    }
615    Some(slice.to_vec())
616}
617
618/// Git `find_name_traditional` on the line after `--- ` / `+++ ` (no prefix).
619fn find_name_traditional(line: &[u8], def: Option<&[u8]>, p_value: usize) -> Option<Vec<u8>> {
620    if line.first() == Some(&b'"') {
621        let (decoded, _) = unquote_c_style_diff_prefix(std::str::from_utf8(line).ok()?)?;
622        let skip = skip_tree_prefix_bytes(&decoded, p_value)?;
623        return Some(skip.to_vec());
624    }
625    let ts = diff_timestamp_len(line);
626    let name_end = line.len().saturating_sub(ts);
627    find_name_common_bounded(line, def, p_value, name_end)
628}
629
630fn find_name_tab_terminated(line: &[u8], p_value: usize) -> Option<Vec<u8>> {
631    if line.first() == Some(&b'"') {
632        let (decoded, _) = unquote_c_style_diff_prefix(std::str::from_utf8(line).ok()?)?;
633        let skip = skip_tree_prefix_bytes(&decoded, p_value)?;
634        return Some(skip.to_vec());
635    }
636    let end = line
637        .iter()
638        .position(|&b| b == b'\t' || b == b'\n' || b == b'\r')
639        .unwrap_or(line.len());
640    find_name_common_bounded(line, None, p_value, end)
641}
642
643fn is_dev_null_nameline(line: &[u8]) -> bool {
644    line.strip_prefix(b"/dev/null")
645        .map(|rest| rest.is_empty() || rest.first().is_some_and(|b| b.is_ascii_whitespace()))
646        .unwrap_or(false)
647}
648
649fn count_slashes_in_prefix(prefix: &str) -> usize {
650    prefix.bytes().filter(|&b| b == b'/').count()
651}
652
653/// Git `guess_p_value` for traditional patches (`apply.c`). Uses `setup_git_directory` prefix.
654fn guess_p_value_from_nameline(line: &[u8], setup_prefix: Option<&str>) -> Option<usize> {
655    if is_dev_null_nameline(line) {
656        return None;
657    }
658    let name = find_name_traditional(line, None, 0)?;
659    let name_str = String::from_utf8_lossy(&name);
660    if !name_str.contains('/') {
661        return Some(0);
662    }
663    let pfx = setup_prefix.filter(|p| !p.is_empty())?;
664    if name_str.starts_with(pfx) {
665        return Some(count_slashes_in_prefix(pfx));
666    }
667    let slash = name_str.find('/')?;
668    let rest = name_str.get(slash + 1..)?;
669    if rest.starts_with(pfx) {
670        return Some(count_slashes_in_prefix(pfx) + 1);
671    }
672    None
673}
674
675fn epoch_stamp_regex() -> &'static Regex {
676    // hygiene: immutable compiled regex for patch epoch stamps
677    static RE: OnceLock<Regex> = OnceLock::new();
678    RE.get_or_init(|| {
679        // Provably infallible: the pattern is a fixed string literal that is a valid regex.
680        #[allow(clippy::expect_used)]
681        Regex::new(r"^([0-2][0-9]):([0-5][0-9]):00(?:\.0+)? ([-+][0-2][0-9]:?[0-5][0-9])")
682            .expect("epoch stamp regex is a valid constant pattern")
683    })
684}
685
686/// True when the `---`/`+++` line has a tab-separated epoch timestamp (Git `has_epoch_timestamp`).
687fn has_epoch_timestamp(nameline: &[u8]) -> bool {
688    let Some(tab) = nameline.iter().position(|&b| b == b'\t') else {
689        return false;
690    };
691    let mut ts = &nameline[tab + 1..];
692    let epoch_hour = if let Some(r) = ts.strip_prefix(b"1969-12-31 ") {
693        ts = r;
694        24i32
695    } else if let Some(r) = ts.strip_prefix(b"1970-01-01 ") {
696        ts = r;
697        0i32
698    } else {
699        return false;
700    };
701    let end = ts.iter().position(|&b| b == b'\n').unwrap_or(ts.len());
702    let stamp = &ts[..end];
703    let stamp_str = match std::str::from_utf8(stamp) {
704        Ok(s) => s,
705        Err(_) => return false,
706    };
707    let caps = match epoch_stamp_regex().captures(stamp_str) {
708        Some(c) => c,
709        None => return false,
710    };
711    let hour: i32 = caps
712        .get(1)
713        .and_then(|m| m.as_str().parse().ok())
714        .unwrap_or(-1);
715    let minute: i32 = caps
716        .get(2)
717        .and_then(|m| m.as_str().parse().ok())
718        .unwrap_or(-1);
719    let tz_s = match caps.get(3).map(|m| m.as_str()) {
720        Some(s) if !s.is_empty() => s,
721        _ => return false,
722    };
723    if hour < 0 || minute < 0 {
724        return false;
725    }
726    let tz_byte = tz_s.as_bytes()[0];
727    let tz_rest = &tz_s[1..];
728    let zoneoffset: i32 = if let Some(colon_pos) = tz_rest.find(':') {
729        let h: i32 = tz_rest[..colon_pos].parse().unwrap_or(0);
730        let mm: i32 = tz_rest[colon_pos + 1..].parse().unwrap_or(0);
731        h * 60 + mm
732    } else if tz_rest.len() >= 4 {
733        let n: i32 = tz_rest[..4].parse().unwrap_or(0);
734        (n / 100) * 60 + (n % 100)
735    } else {
736        return false;
737    };
738    let zoneoffset = if tz_byte == b'-' {
739        -zoneoffset
740    } else {
741        zoneoffset
742    };
743    hour * 60 + minute - zoneoffset == epoch_hour * 60
744}
745
746/// Parse `---` / `+++` pair for a traditional unified diff (Git `parse_traditional_patch`).
747fn parse_traditional_patch_pair(
748    old_line: &[u8],
749    new_line: &[u8],
750    strip: usize,
751    p_guess: &mut Option<usize>,
752    setup_prefix: Option<&str>,
753) -> Result<FilePatch> {
754    let old_p = old_line.strip_prefix(b"--- ").unwrap_or(old_line);
755    let new_p = new_line.strip_prefix(b"+++ ").unwrap_or(new_line);
756
757    if p_guess.is_none() {
758        let p = guess_p_value_from_nameline(old_p, setup_prefix);
759        let q = guess_p_value_from_nameline(new_p, setup_prefix);
760        let chosen = match (p, q) {
761            (None, None) => None,
762            (Some(a), None) => Some(a),
763            (None, Some(b)) => Some(b),
764            (Some(a), Some(b)) if a == b => Some(a),
765            _ => None,
766        };
767        *p_guess = chosen;
768    }
769    let p_val = p_guess.unwrap_or(strip);
770
771    let mut fp = FilePatch {
772        saw_old_header: true,
773        saw_new_header: true,
774        ..FilePatch::empty()
775    };
776
777    if is_dev_null_nameline(old_p) {
778        fp.is_new = true;
779        let name = find_name_traditional(new_p, None, p_val).ok_or_else(|| {
780            Error::Message("unable to find filename in traditional patch".to_string())
781        })?;
782        fp.new_path = Some(bytes_to_path_string(&name)?);
783    } else if is_dev_null_nameline(new_p) {
784        fp.is_deleted = true;
785        let name = find_name_traditional(old_p, None, p_val).ok_or_else(|| {
786            Error::Message("unable to find filename in traditional patch".to_string())
787        })?;
788        fp.old_path = Some(bytes_to_path_string(&name)?);
789    } else {
790        let first_name = find_name_traditional(old_p, None, p_val).ok_or_else(|| {
791            Error::Message("unable to find filename in traditional patch".to_string())
792        })?;
793        let name = find_name_traditional(new_p, Some(&first_name), p_val).ok_or_else(|| {
794            Error::Message("unable to find filename in traditional patch".to_string())
795        })?;
796        let name_str = bytes_to_path_string(&name)?;
797        if has_epoch_timestamp(old_p) {
798            fp.is_new = true;
799            fp.new_path = Some(name_str);
800        } else if has_epoch_timestamp(new_p) {
801            fp.is_deleted = true;
802            fp.old_path = Some(name_str);
803        } else {
804            // Git uses the `+++` filename for both sides when neither line carries an epoch
805            // marker; the `---` line only participates via `def` when shortening `.orig` etc.
806            fp.old_path = Some(name_str.clone());
807            fp.new_path = Some(name_str);
808        }
809    }
810
811    Ok(fp)
812}
813
814/// Default filename from `diff --git` when both sides agree (Git `git_header_name`).
815fn git_header_def_name(line: &str, p_value: usize) -> Option<String> {
816    let rest = line.strip_prefix("diff --git ").unwrap_or(line);
817    let rest_b = rest.as_bytes();
818
819    if rest_b.first() == Some(&b'"') {
820        let (first_decoded, second_raw) = unquote_c_style_diff_prefix(rest)?;
821        let rel_first = skip_tree_prefix_bytes(&first_decoded, p_value)?;
822        let second = second_raw.trim_start_matches(|c: char| c.is_ascii_whitespace());
823        if second.is_empty() {
824            return None;
825        }
826        if second.as_bytes().first() == Some(&b'"') {
827            let (second_decoded, _) = unquote_c_style_diff_prefix(second)?;
828            let rel2 = skip_tree_prefix_bytes(&second_decoded, p_value)?;
829            if rel2 != rel_first {
830                return None;
831            }
832        } else {
833            let rel2 = skip_tree_prefix_bytes(second.as_bytes(), p_value)?;
834            if rel2.len() != rel_first.len() || rel2 != rel_first {
835                return None;
836            }
837        }
838        return bytes_to_path_string(rel_first).ok();
839    }
840
841    let name = skip_tree_prefix_bytes(rest_b, p_value)?;
842    let name_start = name.as_ptr() as usize - rest_b.as_ptr() as usize;
843
844    for offset in 0..name.len() {
845        if name[offset] != b'"' {
846            continue;
847        }
848        let second_slice = &rest_b[name_start + offset..];
849        let (decoded, _) = unquote_c_style_diff_prefix(std::str::from_utf8(second_slice).ok()?)?;
850        let np = skip_tree_prefix_bytes(&decoded, p_value)?;
851        let plen = np.len();
852        if plen < offset
853            && name.len() > plen
854            && &name[..plen] == np
855            && name[plen].is_ascii_whitespace()
856        {
857            return bytes_to_path_string(np).ok();
858        }
859        return None;
860    }
861
862    let line_len = rest.len().saturating_sub(name_start);
863    let mut len = 0usize;
864    while len < line_len {
865        match rest_b[name_start + len] {
866            b'\n' => return None,
867            b'\t' | b' ' => {
868                let after = name_start + len + 1;
869                if after > name_start + line_len {
870                    return None;
871                }
872                let second =
873                    skip_tree_prefix_bytes(&rest_b[after..name_start + line_len], p_value)?;
874                let names_match =
875                    name.len() >= len && second.len() >= len && name[..len] == second[..len];
876                let boundary_ok = second.get(len) == Some(&b'\n') || second.len() == len;
877                if names_match && boundary_ok {
878                    return bytes_to_path_string(&name[..len]).ok();
879                }
880            }
881            _ => {}
882        }
883        len += 1;
884    }
885    None
886}
887
888/// Path from `rename from` / `copy from` lines (Git `find_name` with `terminate == 0`).
889fn find_name_extended_header(rest: &str, p_extended: usize) -> Option<String> {
890    let rest = rest.trim_end_matches(['\r', '\n']);
891    let b = rest.as_bytes();
892    if b.first() == Some(&b'"') {
893        let (decoded, tail) = unquote_c_style_diff_prefix(rest)?;
894        if !tail.trim().is_empty() {
895            return None;
896        }
897        let skip = skip_tree_prefix_bytes(&decoded, p_extended)?;
898        return bytes_to_path_string(skip).ok();
899    }
900    let end = b
901        .iter()
902        .position(|&c| c == b'\t' || c == b'\n' || c == b'\r' || c == b' ')
903        .unwrap_or(b.len());
904    let name = find_name_common_bounded(b, None, p_extended, end)?;
905    bytes_to_path_string(&name).ok()
906}
907
908/// Parse a unified diff into a list of `FilePatch` entries.
909///
910/// `strip` is Git's `p_value` (`-p` count, default 1). `setup_prefix` is Git's
911/// `setup_git_directory` prefix (work-tree-relative path from CWD to the repo
912/// root, with trailing `/`); pass `None` when running from the work-tree root.
913/// The caller computes it because it is a CLI/environment concern.
914pub fn parse_patch(
915    input: &str,
916    strip: usize,
917    input_name: &str,
918    recount: bool,
919    setup_prefix: Option<&str>,
920) -> Result<Vec<FilePatch>> {
921    let lines: Vec<&str> = input.lines().collect();
922    let mut patches = Vec::new();
923    let mut i = 0;
924    let mut p_guess_for_traditional = None;
925    let setup_prefix_for_guess = setup_prefix.filter(|p| !p.is_empty());
926
927    while i < lines.len() {
928        let mut fp = if lines[i].starts_with("diff --git ") {
929            let (fp, next_i) = parse_git_patch_header(&lines, i, strip)?;
930            i = next_i;
931            fp
932        } else if lines[i].starts_with("--- ")
933            && i + 1 < lines.len()
934            && lines[i + 1].starts_with("+++ ")
935        {
936            let fp = parse_traditional_patch_pair(
937                lines[i].as_bytes(),
938                lines[i + 1].as_bytes(),
939                strip,
940                &mut p_guess_for_traditional,
941                setup_prefix_for_guess,
942            )?;
943            i += 2;
944            while i < lines.len() && lines[i].starts_with("index ") {
945                i += 1;
946            }
947            fp
948        } else {
949            i += 1;
950            continue;
951        };
952
953        if i < lines.len() && lines[i] == "GIT binary patch" {
954            let (binary_patch, next_i) = parse_binary_patch(&lines, i + 1)?;
955            fp.binary_patch = Some(binary_patch);
956            fp.is_binary = true;
957            i = next_i;
958        } else {
959            while i < lines.len() && lines[i].starts_with("@@ ") {
960                let (hunk, next_i) = parse_hunk(&lines, i, input_name, recount)?;
961                fp.hunks.push(hunk);
962                i = next_i;
963            }
964        }
965
966        sanitize_file_patch_headers(&mut fp);
967        patches.push(fp);
968    }
969
970    Ok(patches)
971}
972
973// Binary sections among extended headers are consumed here; those following
974// explicit file headers are consumed by the common patch-body loop.
975fn parse_git_patch_header(
976    lines: &[&str],
977    mut i: usize,
978    strip: usize,
979) -> Result<(FilePatch, usize)> {
980    let p_strip = strip;
981    let p_extended = strip.saturating_sub(1);
982    let mut fp = FilePatch {
983        is_toplevel_relative: true,
984        ..FilePatch::empty()
985    };
986
987    let header_line = lines[i];
988    let def_name = git_header_def_name(header_line, p_strip);
989
990    // Parse "diff --git a/foo b/foo"
991    let rest = &header_line["diff --git ".len()..];
992    if let Some((a, b)) = split_diff_git_paths(rest) {
993        fp.diff_old_path = Some(a.clone());
994        fp.diff_new_path = Some(b.clone());
995        fp.old_path = Some(skip_tree_prefix_str(&a, p_strip).ok_or_else(|| {
996            Error::Message(format!("malformed old path in diff --git header: {a}"))
997        })?);
998        fp.new_path = Some(skip_tree_prefix_str(&b, p_strip).ok_or_else(|| {
999            Error::Message(format!("malformed new path in diff --git header: {b}"))
1000        })?);
1001    }
1002    i += 1;
1003
1004    // Parse extended headers
1005    while i < lines.len()
1006        && !lines[i].starts_with("--- ")
1007        && !lines[i].starts_with("diff --git ")
1008        && !lines[i].starts_with("@@ ")
1009    {
1010        let line = lines[i];
1011        let line_no = i + 1;
1012        if let Some(val) = line.strip_prefix("old mode ") {
1013            let v = val.trim_end_matches('\r').trim_end();
1014            if v.is_empty() {
1015                return Err(Error::Message(format!(
1016                    "invalid mode on line {line_no}: {line}"
1017                )));
1018            }
1019            fp.old_mode = Some(v.to_string());
1020            fp.old_mode_line = Some(line_no);
1021        } else if let Some(val) = line.strip_prefix("new mode ") {
1022            let v = val.trim_end_matches('\r').trim_end();
1023            if v.is_empty() {
1024                return Err(Error::Message(format!(
1025                    "invalid mode on line {line_no}: {line}"
1026                )));
1027            }
1028            fp.new_mode = Some(v.to_string());
1029            fp.new_mode_line = Some(line_no);
1030        } else if let Some(val) = line.strip_prefix("new file mode ") {
1031            let v = val.trim_end_matches('\r').trim_end();
1032            if v.is_empty() {
1033                return Err(Error::Message(format!(
1034                    "invalid mode on line {line_no}: {line}"
1035                )));
1036            }
1037            fp.is_new = true;
1038            fp.new_mode = Some(v.to_string());
1039            fp.new_mode_line = Some(line_no);
1040        } else if let Some(val) = line.strip_prefix("deleted file mode ") {
1041            let v = val.trim_end_matches('\r').trim_end();
1042            if v.is_empty() {
1043                return Err(Error::Message(format!(
1044                    "invalid mode on line {line_no}: {line}"
1045                )));
1046            }
1047            fp.is_deleted = true;
1048            fp.old_mode = Some(v.to_string());
1049            fp.old_mode_line = Some(line_no);
1050        } else if let Some(val) = line.strip_prefix("rename from ") {
1051            fp.is_rename = true;
1052            if let Some(p) = find_name_extended_header(val, p_extended) {
1053                fp.old_path = Some(p);
1054            }
1055        } else if let Some(val) = line.strip_prefix("rename to ") {
1056            fp.is_rename = true;
1057            if let Some(p) = find_name_extended_header(val, p_extended) {
1058                fp.new_path = Some(p);
1059            }
1060        } else if let Some(val) = line.strip_prefix("copy from ") {
1061            fp.is_copy = true;
1062            if let Some(p) = find_name_extended_header(val, p_extended) {
1063                fp.old_path = Some(p);
1064            }
1065        } else if let Some(val) = line.strip_prefix("copy to ") {
1066            fp.is_copy = true;
1067            if let Some(p) = find_name_extended_header(val, p_extended) {
1068                fp.new_path = Some(p);
1069            }
1070        } else if let Some(val) = line.strip_prefix("similarity index ") {
1071            fp.similarity_index = val.trim_end_matches('%').parse().ok();
1072        } else if let Some(val) = line.strip_prefix("dissimilarity index ") {
1073            fp.dissimilarity_index = val.trim_end_matches('%').parse().ok();
1074        } else if let Some(val) = line.strip_prefix("index ") {
1075            // Parse "index abc123..def456 100644" or "index abc123..def456"
1076            let mut parts = val.split_whitespace();
1077            let hash_part = parts.next().unwrap_or("");
1078            if let Some((old, new)) = hash_part.split_once("..") {
1079                fp.old_oid = Some(old.to_string());
1080                fp.new_oid = Some(new.to_string());
1081            }
1082            if let Some(mode_tok) = parts.next() {
1083                let v = mode_tok.trim_end_matches('\r').trim_end();
1084                if !v.is_empty() {
1085                    fp.old_mode = Some(v.to_string());
1086                    fp.old_mode_line = Some(line_no);
1087                }
1088            }
1089        } else if line == "GIT binary patch" {
1090            let (binary_patch, next_i) = parse_binary_patch(lines, i + 1)?;
1091            fp.binary_patch = Some(binary_patch);
1092            fp.is_binary = true;
1093            i = next_i;
1094            break;
1095        } else if line.starts_with("Binary files ") && line.ends_with(" differ") {
1096            // Plain (non --binary) diff of a binary change; no payload to
1097            // apply but stat/numstat must report it as binary.
1098            fp.is_binary = true;
1099        }
1100        // skip other extended headers
1101        i += 1;
1102    }
1103
1104    if let Some(dn) = def_name {
1105        if fp.old_path.is_none() {
1106            fp.old_path = Some(dn.clone());
1107        }
1108        if fp.new_path.is_none() {
1109            fp.new_path = Some(dn);
1110        }
1111    }
1112
1113    // Parse ---/+++ headers if present
1114    if i < lines.len() && lines[i].starts_with("--- ") {
1115        let old_p = lines[i]["--- ".len()..].trim_end_matches(['\r', '\n']);
1116        let old_b = old_p.as_bytes();
1117        if is_dev_null_nameline(old_b) {
1118            fp.old_path = Some("/dev/null".to_string());
1119        } else if let Some(p) = find_name_tab_terminated(old_b, p_strip) {
1120            fp.old_path = Some(bytes_to_path_string(&p)?);
1121        }
1122        fp.saw_old_header = true;
1123        i += 1;
1124        if i < lines.len() && lines[i].starts_with("+++ ") {
1125            let new_p = lines[i]["+++ ".len()..].trim_end_matches(['\r', '\n']);
1126            let new_b = new_p.as_bytes();
1127            if is_dev_null_nameline(new_b) {
1128                fp.new_path = Some("/dev/null".to_string());
1129            } else if let Some(p) = find_name_tab_terminated(new_b, p_strip) {
1130                fp.new_path = Some(bytes_to_path_string(&p)?);
1131            }
1132            fp.saw_new_header = true;
1133            i += 1;
1134        }
1135    }
1136
1137    while i < lines.len() && lines[i].starts_with("index ") {
1138        let line = lines[i];
1139        let val = line["index ".len()..].trim_end();
1140        if let Some((old, new)) = val
1141            .split_whitespace()
1142            .next()
1143            .and_then(|h| h.split_once(".."))
1144        {
1145            fp.old_oid = Some(old.to_string());
1146            fp.new_oid = Some(new.to_string());
1147        }
1148        i += 1;
1149    }
1150
1151    Ok((fp, i))
1152}
1153
1154/// Parse a `GIT binary patch` payload.
1155fn parse_binary_patch(lines: &[&str], mut i: usize) -> Result<(BinaryPatchPayload, usize)> {
1156    let forward = parse_binary_section(lines, &mut i)?;
1157    let reverse = if i < lines.len()
1158        && (lines[i].starts_with("literal ") || lines[i].starts_with("delta "))
1159    {
1160        Some(parse_binary_section(lines, &mut i)?)
1161    } else {
1162        None
1163    };
1164
1165    Ok((BinaryPatchPayload { forward, reverse }, i))
1166}
1167
1168/// Parse one `literal <size>` or `delta <size>` block from a binary patch.
1169fn parse_binary_section(lines: &[&str], i: &mut usize) -> Result<BinaryPatchSection> {
1170    let header = lines.get(*i).copied().unwrap_or_default();
1171    let (kind, size_str) = if let Some(s) = header.strip_prefix("literal ") {
1172        (BinaryPatchSectionKind::Literal, s)
1173    } else if let Some(s) = header.strip_prefix("delta ") {
1174        (BinaryPatchSectionKind::Delta, s)
1175    } else {
1176        return Err(Error::Message(format!(
1177            "unsupported binary patch section: '{header}'"
1178        )));
1179    };
1180    let declared_size: usize = size_str
1181        .trim()
1182        .parse()
1183        .map_err(|e: std::num::ParseIntError| {
1184            Error::Message(format!("invalid binary patch section size: {e}"))
1185        })?;
1186    *i += 1;
1187
1188    let mut compressed = Vec::new();
1189    while *i < lines.len() {
1190        let line = lines[*i];
1191        if line.is_empty() {
1192            *i += 1;
1193            break;
1194        }
1195        decode_binary_patch_line(line, &mut compressed)?;
1196        *i += 1;
1197    }
1198
1199    Ok(BinaryPatchSection {
1200        kind,
1201        compressed,
1202        declared_size,
1203    })
1204}
1205
1206/// Decode one binary patch payload line into compressed bytes.
1207fn decode_binary_patch_line(line: &str, out: &mut Vec<u8>) -> Result<()> {
1208    let mut chars = line.chars();
1209    let Some(len_ch) = chars.next() else {
1210        return Err(Error::Message(
1211            "empty binary patch payload line".to_string(),
1212        ));
1213    };
1214    let expected_len = decode_binary_line_len(len_ch)?;
1215    let body = chars.as_str().as_bytes();
1216    let decoded = crate::git_binary_base85::decode_body(body, expected_len)
1217        .map_err(|e| Error::Message(format!("invalid binary patch base85: {e}")))?;
1218    out.extend_from_slice(&decoded);
1219    Ok(())
1220}
1221
1222fn decode_binary_line_len(ch: char) -> Result<usize> {
1223    if ch.is_ascii_uppercase() {
1224        return Ok((ch as u8 - b'A' + 1) as usize);
1225    }
1226    if ch.is_ascii_lowercase() {
1227        return Ok((ch as u8 - b'a' + 27) as usize);
1228    }
1229    Err(Error::Message(format!(
1230        "invalid binary patch line length marker: '{ch}'"
1231    )))
1232}
1233
1234fn encode_binary_line_len(raw_len: usize) -> Result<char> {
1235    if raw_len == 0 || raw_len > 52 {
1236        return Err(Error::Message(format!(
1237            "binary patch line length out of range: {raw_len}"
1238        )));
1239    }
1240    let ch = if raw_len <= 26 {
1241        char::from(b'A' + (raw_len as u8) - 1)
1242    } else {
1243        char::from(b'a' + (raw_len as u8) - 27)
1244    };
1245    Ok(ch)
1246}
1247
1248fn zlib_compress_binary_payload(data: &[u8]) -> Result<Vec<u8>> {
1249    use flate2::write::ZlibEncoder;
1250    use flate2::Compression;
1251    use std::io::Write;
1252
1253    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
1254    encoder
1255        .write_all(data)
1256        .map_err(|e| Error::Message(format!("zlib compress failed: {e}")))?;
1257    encoder
1258        .finish()
1259        .map_err(|e| Error::Message(format!("zlib compress finish failed: {e}")))
1260}
1261
1262fn append_binary_literal_section(out: &mut String, uncompressed: &[u8]) -> Result<()> {
1263    let compressed = zlib_compress_binary_payload(uncompressed)?;
1264    out.push_str(&format!("literal {}\n", uncompressed.len()));
1265    for chunk in compressed.chunks(52) {
1266        let len_ch = encode_binary_line_len(chunk.len())?;
1267        let body = crate::git_binary_base85::encode(chunk);
1268        out.push_str(&format!("{len_ch}{body}\n"));
1269    }
1270    out.push('\n');
1271    Ok(())
1272}
1273
1274/// Write the `GIT binary patch` body (literal forward then reverse) to `out`.
1275///
1276/// Forward literal is `new`; reverse literal is `old`, matching Git's `git diff --binary`.
1277/// The `index` line and `diff --git` header are the caller's responsibility.
1278pub fn write_git_binary_patch_sections<W: std::io::Write>(
1279    out: &mut W,
1280    old: &[u8],
1281    new: &[u8],
1282) -> Result<()> {
1283    out.write_all(b"GIT binary patch\n")
1284        .map_err(|e| Error::Message(format!("write binary patch header: {e}")))?;
1285    let mut buf = String::new();
1286    append_binary_literal_section(&mut buf, new)?;
1287    out.write_all(buf.as_bytes())
1288        .map_err(|e| Error::Message(format!("write binary patch forward: {e}")))?;
1289    buf.clear();
1290    append_binary_literal_section(&mut buf, old)?;
1291    out.write_all(buf.as_bytes())
1292        .map_err(|e| Error::Message(format!("write binary patch reverse: {e}")))?;
1293    Ok(())
1294}
1295
1296/// Emit a unified diff whose body is a `GIT binary patch` with zlib+base85 literals.
1297///
1298/// Forward literal is `new`; reverse literal is `old`, matching Git's `git diff --binary`.
1299pub fn write_git_binary_patch(
1300    old: &[u8],
1301    new: &[u8],
1302    _old_path: &str,
1303    _new_path: &str,
1304) -> Result<String> {
1305    use crate::objects::{HashAlgo, ObjectKind};
1306
1307    let old_oid = HashAlgo::Sha1.hash_object(ObjectKind::Blob, old)?;
1308    let new_oid = HashAlgo::Sha1.hash_object(ObjectKind::Blob, new)?;
1309    let mut out = String::with_capacity(old.len() + new.len() + 128);
1310    out.push_str(&format!(
1311        "index {}..{} 100644\n",
1312        old_oid.to_hex(),
1313        new_oid.to_hex()
1314    ));
1315    out.push_str("GIT binary patch\n");
1316    append_binary_literal_section(&mut out, new)?;
1317    append_binary_literal_section(&mut out, old)?;
1318    Ok(out)
1319}
1320
1321/// Inflate zlib-compressed binary payload.
1322pub fn inflate_binary_payload(compressed: &[u8]) -> Result<Vec<u8>> {
1323    use flate2::read::ZlibDecoder;
1324    use std::io::Read;
1325
1326    let mut decoder = ZlibDecoder::new(compressed);
1327    let mut out = Vec::new();
1328    decoder
1329        .read_to_end(&mut out)
1330        .map_err(|e| Error::Message(format!("failed to inflate binary patch payload: {e}")))?;
1331    Ok(out)
1332}
1333
1334/// Expand one binary patch section to uncompressed bytes.
1335///
1336/// Delta sections require `base` (the preimage blob Git applies the delta against).
1337pub fn materialize_binary_patch_section(
1338    section: &BinaryPatchSection,
1339    base: Option<&[u8]>,
1340) -> Result<Vec<u8>> {
1341    let inflated = inflate_binary_payload(&section.compressed)?;
1342    match section.kind {
1343        BinaryPatchSectionKind::Literal => {
1344            if inflated.len() != section.declared_size {
1345                return Err(Error::Message(format!(
1346                    "binary patch literal size {} != declared {}",
1347                    inflated.len(),
1348                    section.declared_size
1349                )));
1350            }
1351            Ok(inflated)
1352        }
1353        BinaryPatchSectionKind::Delta => {
1354            let base = base.ok_or_else(|| {
1355                Error::Message("binary patch delta requires a base blob".to_string())
1356            })?;
1357            crate::unpack_objects::apply_delta(base, &inflated)
1358        }
1359    }
1360}
1361
1362/// Reconstruct the post-image blob from the forward section and the old file contents.
1363pub fn materialize_binary_patch_forward(
1364    payload: &BinaryPatchPayload,
1365    old: &[u8],
1366) -> Result<Vec<u8>> {
1367    materialize_binary_patch_section(&payload.forward, Some(old))
1368}
1369
1370/// Reconstruct the pre-image blob from the reverse section and the new file contents.
1371pub fn materialize_binary_patch_reverse(
1372    payload: &BinaryPatchPayload,
1373    new: &[u8],
1374) -> Result<Vec<u8>> {
1375    let reverse = payload
1376        .reverse
1377        .as_ref()
1378        .ok_or_else(|| Error::Message("binary patch has no reverse section".to_string()))?;
1379    materialize_binary_patch_section(reverse, Some(new))
1380}
1381
1382/// Split the two path tokens from the remainder of a `diff --git` line (after `diff --git `).
1383fn split_diff_git_paths(s: &str) -> Option<(String, String)> {
1384    let s = s.trim_end_matches(['\r', '\n']);
1385
1386    if s.as_bytes().first() == Some(&b'"') {
1387        let (first, rest_raw) = unquote_c_style_diff_prefix(s)?;
1388        let rest = rest_raw.trim_start_matches(|c: char| c.is_ascii_whitespace());
1389        if rest.is_empty() {
1390            return None;
1391        }
1392        if rest.as_bytes().first() == Some(&b'"') {
1393            let (second, _) = unquote_c_style_diff_prefix(rest)?;
1394            return Some((
1395                String::from_utf8_lossy(&first).into_owned(),
1396                String::from_utf8_lossy(&second).into_owned(),
1397            ));
1398        }
1399        let second = rest;
1400        if second.len() != first.len() || second.as_bytes() != first.as_slice() {
1401            return None;
1402        }
1403        return Some((
1404            String::from_utf8_lossy(&first).into_owned(),
1405            second.to_string(),
1406        ));
1407    }
1408
1409    if let Some(pos) = s.find(" b/") {
1410        let a = &s[..pos];
1411        let b = &s[pos + 1..];
1412        return Some((a.to_string(), b.to_string()));
1413    }
1414    if s.starts_with("a/") {
1415        if let Some(pos) = s.find(" /dev/null") {
1416            let a = &s[..pos];
1417            return Some((a.to_string(), "/dev/null".to_string()));
1418        }
1419    }
1420    if let Some(b) = s.strip_prefix("/dev/null ") {
1421        return Some(("/dev/null".to_string(), b.to_string()));
1422    }
1423
1424    let name = s.as_bytes();
1425    let line_len = name.len();
1426    let mut len = 0usize;
1427    while len < line_len {
1428        match name[len] {
1429            b'\n' => return None,
1430            b'\t' | b' ' => {
1431                if len + 1 > line_len {
1432                    return None;
1433                }
1434                let second = &name[len + 1..line_len];
1435                let names_match =
1436                    name.len() >= len && second.len() >= len && name[..len] == second[..len];
1437                let boundary_ok = second.get(len) == Some(&b'\n') || second.len() == len;
1438                if names_match && boundary_ok {
1439                    return Some((
1440                        String::from_utf8_lossy(&name[..len]).into_owned(),
1441                        String::from_utf8_lossy(second).into_owned(),
1442                    ));
1443                }
1444            }
1445            _ => {}
1446        }
1447        len += 1;
1448    }
1449    None
1450}
1451
1452/// Parse a single hunk starting at line `i` (which should be an `@@` line).
1453fn parse_hunk(
1454    lines: &[&str],
1455    start: usize,
1456    input_name: &str,
1457    recount: bool,
1458) -> Result<(Hunk, usize)> {
1459    let header = lines[start];
1460    let (old_start, old_count, new_start, new_count) = parse_hunk_header(header)
1461        .map_err(|e| Error::Message(format!("invalid hunk header: {header}: {e}")))?;
1462
1463    let mut hunk = Hunk {
1464        old_start,
1465        old_count,
1466        new_start,
1467        new_count,
1468        first_body_line: start + 2,
1469        lines: Vec::new(),
1470    };
1471
1472    // Track how many old/new lines the body actually provides so a hunk that
1473    // ends prematurely is diagnosed like Git: "corrupt patch at <file>:<line>"
1474    // (t4012; Git parse_fragment returns -1 when oldlines/newlines remain).
1475    let mut old_seen = 0usize;
1476    let mut new_seen = 0usize;
1477    let mut i = start + 1;
1478    while i < lines.len() {
1479        let line = lines[i];
1480        if line.starts_with("@@ ") || line.starts_with("diff --git ") {
1481            break;
1482        }
1483        // `---` / `+++` with a space begin a new file header; do not treat `---` as a `-` hunk line
1484        // (would misparse `--- /dev/null` as a remove of `-- /dev/null` and merge the next file).
1485        if line.starts_with("--- ") || line.starts_with("+++ ") {
1486            break;
1487        }
1488        if line == "-- " {
1489            // format-patch signature separator; not part of hunk body
1490            break;
1491        }
1492        if let Some(rest) = line.strip_prefix('+') {
1493            hunk.lines.push(HunkLine::Add(rest.to_string()));
1494            new_seen += 1;
1495        } else if let Some(rest) = line.strip_prefix('-') {
1496            hunk.lines.push(HunkLine::Remove(rest.to_string()));
1497            old_seen += 1;
1498        } else if line.is_empty() {
1499            hunk.lines.push(HunkLine::Context(String::new()));
1500            old_seen += 1;
1501            new_seen += 1;
1502        } else if let Some(rest) = line.strip_prefix(' ') {
1503            // context line
1504            hunk.lines.push(HunkLine::Context(rest.to_string()));
1505            old_seen += 1;
1506            new_seen += 1;
1507        } else if line.starts_with('\\') {
1508            hunk.lines.push(HunkLine::NoNewline);
1509        } else {
1510            // Unknown line type — could be start of something else
1511            break;
1512        }
1513        i += 1;
1514    }
1515
1516    if recount {
1517        hunk.old_count = old_seen;
1518        hunk.new_count = new_seen;
1519    } else if old_seen < old_count || new_seen < new_count {
1520        return Err(ApplyError::CorruptPatch {
1521            input: input_name.to_owned(),
1522            line: i + 1,
1523        }
1524        .into());
1525    }
1526
1527    Ok((hunk, i))
1528}
1529
1530/// Parse "@@ -old_start[,old_count] +new_start[,new_count] @@..."
1531fn parse_hunk_header(line: &str) -> Result<(usize, usize, usize, usize)> {
1532    // Find the range part between @@ markers
1533    let trimmed = line.trim_start_matches('@').trim_start();
1534    let end = trimmed.find(" @@").unwrap_or(trimmed.len());
1535    let range_part = &trimmed[..end];
1536
1537    let parts: Vec<&str> = range_part.split_whitespace().collect();
1538    if parts.len() < 2 {
1539        return Err(Error::Message(
1540            "expected old and new range in hunk header".to_string(),
1541        ));
1542    }
1543
1544    let (old_start, old_count) = parse_range(parts[0].trim_start_matches('-'))?;
1545    let (new_start, new_count) = parse_range(parts[1].trim_start_matches('+'))?;
1546
1547    Ok((old_start, old_count, new_start, new_count))
1548}
1549
1550/// Parse "N" or "N,M" into (start, count).
1551fn parse_range(s: &str) -> Result<(usize, usize)> {
1552    if let Some((start_s, count_s)) = s.split_once(',') {
1553        let start = start_s
1554            .parse::<usize>()
1555            .map_err(|e| Error::Message(e.to_string()))?;
1556        let count = count_s
1557            .parse::<usize>()
1558            .map_err(|e| Error::Message(e.to_string()))?;
1559        Ok((start, count))
1560    } else {
1561        let n: usize = s
1562            .parse()
1563            .map_err(|e: std::num::ParseIntError| Error::Message(e.to_string()))?;
1564        Ok((n, 1))
1565    }
1566}
1567
1568mod blob_apply;
1569mod index_apply;
1570
1571pub use blob_apply::{apply_patch_to_blob, BlobApplyOptions};
1572pub use index_apply::{
1573    apply_patch_to_index, apply_patch_to_index_and_write, IndexApplyOptions, IndexApplyOutcome,
1574};
1575
1576#[cfg(test)]
1577mod tests {
1578    use super::*;
1579
1580    #[test]
1581    fn binary_bodies_resume_at_following_git_patch() {
1582        let old = b"before\0";
1583        let new = b"after\0";
1584        let body = write_git_binary_patch(old, new, "a/bin", "b/bin").expect("binary body");
1585        for (header, git_style) in [
1586            ("diff --git a/bin b/bin\n", true),
1587            ("diff --git a/bin b/bin\n--- a/bin\n+++ b/bin\n", true),
1588            ("--- a/bin\n+++ b/bin\n", false),
1589        ] {
1590            let input = format!(
1591                "{header}{body}diff --git a/text b/text\n--- a/text\n+++ b/text\n@@ -1 +1 @@\n-old\n+new\n"
1592            );
1593            let files = parse_patch(&input, 1, "mixed", false, None).expect("parse mixed patch");
1594            assert_eq!(files.len(), 2);
1595            let binary = &files[0];
1596            assert_eq!(binary.effective_path(), Some("bin"));
1597            assert_eq!(binary.is_toplevel_relative, git_style);
1598            assert_eq!(binary.old_oid.is_some(), git_style);
1599            assert!(binary.is_binary);
1600            assert!(binary.hunks.is_empty());
1601            let payload = binary.binary_patch.as_ref().expect("binary payload");
1602            assert_eq!(materialize_binary_patch_forward(payload, old).unwrap(), new);
1603            assert_eq!(materialize_binary_patch_reverse(payload, new).unwrap(), old);
1604            assert_eq!(files[1].effective_path(), Some("text"));
1605            assert!(files[1].is_toplevel_relative);
1606            assert!(!files[1].is_binary);
1607            assert_eq!(files[1].hunks.len(), 1);
1608            assert_eq!(files[1].hunks[0].first_body_line, input.lines().count() - 1);
1609        }
1610    }
1611
1612    #[test]
1613    fn git_headers_keep_index_after_file_headers() {
1614        let input = "diff --git a/f b/f\nold mode 100644\nnew mode 100755\n\
1615                     --- a/f\n+++ b/f\nindex abc..def\nindex def..123\n";
1616        let files = parse_patch(input, 1, "modes", false, None).expect("parse headers");
1617        assert_eq!(files.len(), 1);
1618        assert_eq!(files[0].old_oid.as_deref(), Some("def"));
1619        assert_eq!(files[0].new_oid.as_deref(), Some("123"));
1620        assert_eq!(files[0].old_mode.as_deref(), Some("100644"));
1621        assert_eq!(files[0].new_mode.as_deref(), Some("100755"));
1622        assert_eq!(files[0].old_mode_line, Some(2));
1623        assert_eq!(files[0].new_mode_line, Some(3));
1624        assert!(files[0].saw_old_header && files[0].saw_new_header);
1625        assert!(files[0].hunks.is_empty());
1626    }
1627
1628    #[test]
1629    fn corrupt_hunks_keep_line_numbers_for_both_header_styles() {
1630        for header in [
1631            "--- a/f\n+++ b/f\n",
1632            "diff --git a/f b/f\n--- a/f\n+++ b/f\n",
1633        ] {
1634            let input = format!("{header}@@ -1,3 +1,3 @@\n one\n");
1635            let err = parse_patch(&input, 1, "mixed", false, None).unwrap_err();
1636            assert!(matches!(
1637                err,
1638                Error::Apply(ApplyError::CorruptPatch { input, line })
1639                    if input == "mixed" && line == header.lines().count() + 3
1640            ));
1641        }
1642    }
1643
1644    #[test]
1645    fn parses_simple_git_diff_into_one_file_patch() {
1646        let input = "diff --git a/foo.txt b/foo.txt\n\
1647                     index e69de29..d95f3ad 100644\n\
1648                     --- a/foo.txt\n\
1649                     +++ b/foo.txt\n\
1650                     @@ -0,0 +1 @@\n\
1651                     +hello\n";
1652        let patches = parse_patch(input, 1, "<test>", false, None).expect("parse");
1653        assert_eq!(patches.len(), 1);
1654        let fp = &patches[0];
1655        assert_eq!(fp.old_path.as_deref(), Some("foo.txt"));
1656        assert_eq!(fp.new_path.as_deref(), Some("foo.txt"));
1657        assert_eq!(fp.hunks.len(), 1);
1658        let hunk = &fp.hunks[0];
1659        assert_eq!(hunk.new_count, 1);
1660        assert!(matches!(hunk.lines.as_slice(), [HunkLine::Add(s)] if s == "hello"));
1661    }
1662
1663    #[test]
1664    fn parses_new_file_mode_and_deletion() {
1665        let new_file = "diff --git a/n b/n\n\
1666                        new file mode 100644\n\
1667                        index 0000000..9daeafb\n\
1668                        --- /dev/null\n\
1669                        +++ b/n\n\
1670                        @@ -0,0 +1 @@\n\
1671                        +x\n";
1672        let patches = parse_patch(new_file, 1, "<test>", false, None).expect("parse");
1673        assert_eq!(patches.len(), 1);
1674        assert!(patches[0].is_new);
1675        assert_eq!(patches[0].new_mode.as_deref(), Some("100644"));
1676
1677        let deleted = "diff --git a/d b/d\n\
1678                       deleted file mode 100644\n\
1679                       index 9daeafb..0000000\n\
1680                       --- a/d\n\
1681                       +++ /dev/null\n\
1682                       @@ -1 +0,0 @@\n\
1683                       -x\n";
1684        let patches = parse_patch(deleted, 1, "<test>", false, None).expect("parse");
1685        assert!(patches[0].is_deleted);
1686    }
1687
1688    #[test]
1689    fn corrupt_hunk_is_reported_with_input_name_and_line() {
1690        // The body provides fewer lines than the header declares.
1691        let input = "--- a/x\n\
1692                     +++ b/x\n\
1693                     @@ -1,3 +1,3 @@\n\
1694                      one\n";
1695        let err = parse_patch(input, 1, "patch", false, None).unwrap_err();
1696        assert!(matches!(
1697            err,
1698            Error::Apply(ApplyError::CorruptPatch {
1699                ref input,
1700                line: 4
1701            }) if input == "patch"
1702        ));
1703        assert_eq!(
1704            err.git_stderr_message(),
1705            crate::diagnostics::error_line("corrupt patch at patch:4")
1706        );
1707    }
1708
1709    #[test]
1710    fn parse_hunk_header_parses_ranges() {
1711        assert_eq!(parse_hunk_header("@@ -1,3 +2,4 @@").unwrap(), (1, 3, 2, 4));
1712        assert_eq!(parse_hunk_header("@@ -5 +6 @@ ctx").unwrap(), (5, 1, 6, 1));
1713    }
1714
1715    #[test]
1716    fn invalid_hunk_header_chains_inner_message() {
1717        let err = parse_hunk_header("@@ -x +1 @@").unwrap_err();
1718        // The numeric parse failure must surface its own message.
1719        assert_eq!(err.to_string(), "invalid digit found in string");
1720    }
1721
1722    #[test]
1723    fn write_git_binary_patch_applies_with_system_git() {
1724        use std::fs;
1725        use std::process::Command;
1726
1727        let old = b"\0BIN\0";
1728        let new = b"\0BIN2\0";
1729        let body = write_git_binary_patch(old, new, "a/b", "b/b").expect("write");
1730        let patch = format!("diff --git a/b b/b\n{body}");
1731        let dir = tempfile::tempdir().expect("tempdir");
1732        let b = dir.path().join("b");
1733        fs::write(&b, old).expect("write base");
1734        let patch_path = dir.path().join("p.diff");
1735        fs::write(&patch_path, &patch).expect("write patch");
1736        let check = Command::new("git")
1737            .current_dir(dir.path())
1738            .args(["apply", "--check", patch_path.to_str().expect("utf8")])
1739            .output()
1740            .expect("git apply --check");
1741        assert!(
1742            check.status.success(),
1743            "git apply --check rejected grit binary patch: {}",
1744            String::from_utf8_lossy(&check.stderr)
1745        );
1746        let apply = Command::new("git")
1747            .current_dir(dir.path())
1748            .args(["apply", patch_path.to_str().expect("utf8")])
1749            .output()
1750            .expect("git apply");
1751        assert!(apply.status.success(), "git apply failed");
1752        assert_eq!(fs::read(&b).expect("read"), new);
1753    }
1754
1755    fn shattered_prefix_fixture() -> Vec<u8> {
1756        std::fs::read(
1757            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1758                .join("tests/data/shattered-1-prefix.bin"),
1759        )
1760        .expect("fixture")
1761    }
1762
1763    #[test]
1764    fn apply_accepts_shattered_result_blob_like_git() {
1765        let shattered = shattered_prefix_fixture();
1766        let body = write_git_binary_patch(b"ok", &shattered, "a/f", "b/f").expect("binary patch");
1767        let patch = format!("diff --git a/f b/f\n{body}");
1768        let patches = parse_patch(&patch, 1, "p", false, None).expect("parse");
1769        let payload = patches[0].binary_patch.as_ref().expect("binary payload");
1770        let materialized =
1771            materialize_binary_patch_forward(payload, b"ok").expect("materialize shattered blob");
1772        assert_eq!(materialized, shattered);
1773    }
1774
1775    #[test]
1776    fn write_git_binary_patch_roundtrips_through_parse_and_inflate() {
1777        let old = b"\0BIN\0";
1778        let new = b"\0BIN2\0";
1779        let body = write_git_binary_patch(old, new, "a/b", "b/b").expect("write");
1780        let patch = format!("diff --git a/b b/b\n{body}");
1781        assert!(patch.contains("GIT binary patch"));
1782        let patches = parse_patch(&patch, 1, "p", false, None).expect("parse");
1783        assert_eq!(patches.len(), 1);
1784        let payload = patches[0].binary_patch.as_ref().expect("binary payload");
1785        let forward = materialize_binary_patch_forward(payload, old).expect("materialize new");
1786        let reverse = materialize_binary_patch_reverse(payload, new).expect("materialize old");
1787        assert_eq!(forward, new);
1788        assert_eq!(reverse, old);
1789    }
1790
1791    #[test]
1792    fn git_produced_delta_binary_patch_parses_and_materializes() {
1793        use std::process::Command;
1794
1795        let mut old = Vec::from(&b"\0BIN"[..]);
1796        old.extend((0u8..=255).cycle().take(996));
1797        assert_eq!(old.len(), 1000);
1798        let mut new = old.clone();
1799        new[500..510].copy_from_slice(b"CHANGED!!!");
1800
1801        let dir = tempfile::tempdir().expect("tempdir");
1802        let old_path = dir.path().join("old.bin");
1803        let new_path = dir.path().join("new.bin");
1804        std::fs::write(&old_path, &old).expect("write old");
1805        std::fs::write(&new_path, &new).expect("write new");
1806        let out = Command::new("git")
1807            .args([
1808                "diff",
1809                "--no-index",
1810                "--binary",
1811                old_path.to_str().expect("utf8"),
1812                new_path.to_str().expect("utf8"),
1813            ])
1814            .output()
1815            .expect("git diff");
1816        assert!(
1817            out.status.code() == Some(1) || out.status.success(),
1818            "git diff failed: {}",
1819            String::from_utf8_lossy(&out.stderr)
1820        );
1821        let patch = String::from_utf8_lossy(&out.stdout);
1822        assert!(
1823            patch.contains("delta "),
1824            "expected git to emit delta sections: {patch}"
1825        );
1826        let patches = parse_patch(&patch, 1, "p", false, None).expect("parse git binary patch");
1827        let payload = patches[0].binary_patch.as_ref().expect("binary payload");
1828        assert_eq!(payload.forward.kind, BinaryPatchSectionKind::Delta);
1829        let got = materialize_binary_patch_forward(payload, &old).expect("apply forward delta");
1830        assert_eq!(got, new);
1831    }
1832}