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