Skip to main content

sley_diff_merge/
patch.rs

1//! Unified diff patch parsing and application.
2
3use sley_core::{GitError, Result};
4
5use crate::{name, ws};
6
7// ---------------------------------------------------------------------------
8// Unified / git diff patch parsing and application (engine for `git apply`/`git am`).
9//
10// Operates purely on in-memory byte buffers; the caller is responsible for
11// reading/writing blobs from the working tree or the object database. The
12// parser understands the textual format git produces (`diff --git`, `---`/`+++`
13// file headers, `@@` hunk headers, context/`+`/`-` body lines, the
14// `\ No newline at end of file` marker, `/dev/null` for added/deleted files,
15// file mode headers, and `rename from`/`rename to` headers).
16// ---------------------------------------------------------------------------
17
18/// A single line inside a hunk. The stored bytes never include the trailing
19/// line terminator; whether the line is terminated by `\n` is tracked
20/// separately on the [`Hunk`] (see [`Hunk::old_no_newline`] /
21/// [`Hunk::new_no_newline`]) so the no-final-newline case can be reproduced
22/// byte-for-byte.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum HunkLine {
25    /// A line present in both the old and new versions.
26    Context(Vec<u8>),
27    /// A line added by the patch (present only in the new version).
28    Insert(Vec<u8>),
29    /// A line removed by the patch (present only in the old version).
30    Delete(Vec<u8>),
31}
32
33impl HunkLine {
34    /// The line content, without any trailing newline.
35    pub fn content(&self) -> &[u8] {
36        match self {
37            Self::Context(bytes) | Self::Insert(bytes) | Self::Delete(bytes) => bytes,
38        }
39    }
40}
41
42/// A single `@@ -old_start,old_len +new_start,new_len @@` hunk.
43///
44/// `old_start` / `new_start` are 1-based line numbers as they appear in the
45/// patch header. The `*_no_newline` flags record that the final line on that
46/// side of the hunk is *not* terminated by a newline (the `\ No newline at end
47/// of file` marker).
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Hunk {
50    pub old_start: usize,
51    pub old_len: usize,
52    pub new_start: usize,
53    pub new_len: usize,
54    pub lines: Vec<HunkLine>,
55    /// The last context/deleted line of the old file lacks a trailing newline.
56    pub old_no_newline: bool,
57    /// The last context/inserted line of the new file lacks a trailing newline.
58    pub new_no_newline: bool,
59    /// The 1-based line number (in the patch input) of each entry in `lines`,
60    /// used by `git apply`'s whitespace-error reporting (git's `state->linenr`).
61    /// Empty when the patch was not parsed from input (e.g. synthesised hunks).
62    pub line_input_lines: Vec<usize>,
63}
64
65/// A patch targeting a single file. Produced by [`parse_unified_patch`].
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct FilePatch {
68    /// Path on the `a/` (old) side, or `None` for a newly created file.
69    pub old_path: Option<Vec<u8>>,
70    /// Path on the `b/` (new) side, or `None` for a deleted file.
71    pub new_path: Option<Vec<u8>>,
72    /// Mode of the old file, when a mode header was present.
73    pub old_mode: Option<u32>,
74    /// Mode of the new file, when a mode header was present.
75    pub new_mode: Option<u32>,
76    pub hunks: Vec<Hunk>,
77    /// The patch creates a new file (`--- /dev/null` / `new file mode`).
78    pub is_new: bool,
79    /// The patch deletes the file (`+++ /dev/null` / `deleted file mode`).
80    pub is_delete: bool,
81    /// The patch renames the file (`rename from`/`rename to`).
82    pub is_rename: bool,
83    /// The patch copies the file (`copy from`/`copy to`).
84    pub is_copy: bool,
85    /// Similarity score from `similarity index N%`, used for rename/copy summaries.
86    pub similarity: Option<u8>,
87    /// Dissimilarity score from `dissimilarity index N%`, used for rewrite summaries.
88    pub dissimilarity: Option<u8>,
89    /// Hex object id prefixes from the `index <old>..<new>[ mode]` line, if any.
90    /// Carried verbatim (abbreviated or full); the binary apply and the `-3`
91    /// fallback need these to resolve the pre-/post-image blobs.
92    pub old_oid_hex: Option<Vec<u8>>,
93    pub new_oid_hex: Option<Vec<u8>>,
94    /// True when the patch is binary: either a `GIT binary patch` block (with
95    /// `binary` payload) or a metadata-only `Binary files ... differ` line
96    /// (no payload — the postimage must be reconstructed from the object store).
97    pub is_binary: bool,
98    /// The `GIT binary patch` payload, when this is a binary file patch. The
99    /// fragment bytes are still zlib-deflated (the caller inflates them with
100    /// the recorded original length), matching git's two-hunk forward/reverse
101    /// layout.
102    pub binary: Option<BinaryPatch>,
103    /// True for git (`diff --git`) patches, whose names are relative to the
104    /// repository top-level; false for traditional diffs, whose names are
105    /// relative to the current directory (git's `is_toplevel_relative`). The
106    /// `apply` cwd-prefix is only prepended to non-toplevel-relative patches.
107    pub is_toplevel_relative: bool,
108}
109
110/// A `GIT binary patch` payload: a mandatory forward hunk (preimage → postimage)
111/// and an optional reverse hunk (postimage → preimage), mirroring git's
112/// `parse_binary`.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct BinaryPatch {
115    pub forward: BinaryHunk,
116    pub reverse: Option<BinaryHunk>,
117}
118
119/// One binary hunk: the encoding method and the still-deflated data, plus the
120/// declared original (inflated) length.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct BinaryHunk {
123    pub method: BinaryMethod,
124    /// Length of the data *after* inflation (the `literal <N>` / `delta <N>`
125    /// number). The caller inflates `deflated` to exactly this many bytes.
126    pub origlen: usize,
127    /// base85-decoded, still zlib-deflated bytes.
128    pub deflated: Vec<u8>,
129}
130
131/// How a binary hunk encodes the postimage.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum BinaryMethod {
134    /// The inflated bytes ARE the postimage (`literal <N>`).
135    Literal,
136    /// The inflated bytes are a git delta to apply to the preimage (`delta <N>`).
137    Delta,
138}
139
140/// Outcome of applying a [`FilePatch`] to a base buffer.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum ApplyOutcome {
143    /// The patch applied cleanly; carries the resulting file bytes.
144    Applied(Vec<u8>),
145    /// At least one hunk's context/deleted lines did not match the base.
146    Rejected,
147}
148
149/// The minimum number of context lines git's `apply` insists on keeping when
150/// it tries to fuzz a hunk into place — git's `apply_state.p_context`, which is
151/// initialised to `UINT_MAX` (the `-C<n>` option lowers it). The fuzz loop in
152/// `apply_one_fragment` stops the moment both leading and trailing context have
153/// been reduced to this floor; with the default `UINT_MAX` floor that test is
154/// already satisfied on the first failure, so **the default `git apply` / `git
155/// am` path does no context fuzz and no begin/end relaxation at all** — a hunk
156/// whose full preimage does not match at a valid position is simply rejected.
157/// We keep the floor configurable so the structure mirrors git's, but the
158/// shared apply engine only ever runs with the default.
159const MIN_FUZZ_CONTEXT: usize = usize::MAX;
160
161/// Parse a unified/git diff into one [`FilePatch`] per file it touches.
162///
163/// The parser is intentionally lenient about leading commentary (commit
164/// messages, `index <oid>..<oid>` lines, etc.): anything that is not part of a
165/// recognised header or hunk body is skipped. It errors only on structurally
166/// invalid hunks (bad `@@` headers, body lines that overflow the declared hunk
167/// counts, or hunk bodies that appear with no preceding file header).
168pub fn parse_unified_patch(input: &[u8]) -> Result<Vec<FilePatch>> {
169    parse_unified_patch_with_recount(input, false)
170}
171
172/// Parse a unified/git diff, optionally ignoring hunk header line counts and
173/// recounting them from the hunk body. This mirrors `git apply --recount`.
174pub fn parse_unified_patch_with_recount(input: &[u8], recount: bool) -> Result<Vec<FilePatch>> {
175    parse_unified_patch_with_options(input, recount, &PatchPathOptions::default())
176}
177
178/// Path-resolution options for [`parse_unified_patch_with_options`], mirroring
179/// `git apply`'s `-p<n>` strip (`p_value`) and `--directory=<root>` prefix.
180#[derive(Clone)]
181pub struct PatchPathOptions {
182    /// Number of leading path components to strip (`-p<n>`); default 1.
183    pub p_value: usize,
184    /// Whether `p_value` was given explicitly. When false, traditional (non-git)
185    /// diffs guess it from the `---`/`+++` lines.
186    pub p_value_known: bool,
187    /// `--directory=<dir>` root, normalised with a trailing slash (or empty).
188    pub root: Vec<u8>,
189    /// The cwd prefix (`state->prefix`): the current directory relative to the
190    /// work tree, with a trailing slash (empty at the top level). Used only to
191    /// guess `-p<n>` for traditional patches run from a subdirectory; the prefix
192    /// itself is prepended to names by the caller, not here.
193    pub prefix: Vec<u8>,
194}
195
196impl Default for PatchPathOptions {
197    fn default() -> Self {
198        PatchPathOptions {
199            p_value: 1,
200            p_value_known: false,
201            root: Vec::new(),
202            prefix: Vec::new(),
203        }
204    }
205}
206
207/// Parse a unified/git diff, applying `-p<n>` strip and `--directory` prefix to
208/// every resolved pathname exactly as `git apply` does.
209pub fn parse_unified_patch_with_options(
210    input: &[u8],
211    recount: bool,
212    options: &PatchPathOptions,
213) -> Result<Vec<FilePatch>> {
214    let lines = split_patch_lines(input);
215    let mut parser = PatchParser {
216        lines: &lines,
217        index: 0,
218        recount,
219        p_value: options.p_value,
220        p_value_known: options.p_value_known,
221        root: options.root.clone(),
222        prefix: options.prefix.clone(),
223    };
224    parser.parse()
225}
226
227/// Apply a single-file patch to `base`, returning the patched bytes.
228///
229/// This mirrors git's `apply.c` (`apply_one_fragment` / `find_pos` /
230/// `match_fragment`) for the default, no-whitespace-fuzz settings `git am`
231/// and `git apply` use:
232///
233/// * Each hunk builds a *preimage* (context + deleted lines) and *postimage*
234///   (context + inserted lines).
235/// * A hunk anchored at the file start (`old_start <= 1`) must match the
236///   beginning of the file (`match_beginning`); a hunk with no trailing context
237///   must match the end of the file (`match_end`).
238/// * The full preimage is matched byte-for-byte; the search starts at the
239///   recorded position and ping-pongs outward across the whole image.
240/// * Fuzz is applied *only* by dropping leading/trailing context lines (never
241///   by jumping to a spurious context-only match); if no position matches even
242///   after dropping all context, the hunk — and thus the whole patch — is
243///   [`ApplyOutcome::Rejected`].
244///
245/// Rejecting (rather than spuriously applying at a wrong offset) is what lets
246/// `git am -3` correctly fall back to its 3-way merge path.
247///
248/// New-file patches (empty/ignored base) and the no-final-newline case are
249/// handled byte-accurately. Clean exact-position applies are byte-identical to
250/// the previous behaviour.
251pub fn apply_file_patch(base: &[u8], patch: &FilePatch) -> ApplyOutcome {
252    apply_file_patch_with_options(base, patch, &ApplyFileOptions::default())
253}
254
255/// Options for [`apply_file_patch_with_options`], mirroring the `git apply`
256/// flags that change fragment placement.
257#[derive(Clone, Default)]
258pub struct ApplyFileOptions {
259    /// `--unidiff-zero`: trust the line numbers of context-free hunks instead of
260    /// forcing them to anchor at the file's beginning/end.
261    pub unidiff_zero: bool,
262}
263
264/// Reverse a file patch (`git apply -R`): swap the old/new names, modes, hunk
265/// ranges, and no-newline flags, exchange add↔delete status, and flip every
266/// `Insert`/`Delete` line. Applying the result undoes the original patch.
267pub fn reverse_file_patch(patch: &FilePatch) -> FilePatch {
268    let hunks = patch
269        .hunks
270        .iter()
271        .map(|hunk| {
272            let lines = hunk
273                .lines
274                .iter()
275                .map(|line| match line {
276                    HunkLine::Context(b) => HunkLine::Context(b.clone()),
277                    HunkLine::Insert(b) => HunkLine::Delete(b.clone()),
278                    HunkLine::Delete(b) => HunkLine::Insert(b.clone()),
279                })
280                .collect();
281            Hunk {
282                old_start: hunk.new_start,
283                old_len: hunk.new_len,
284                new_start: hunk.old_start,
285                new_len: hunk.old_len,
286                lines,
287                old_no_newline: hunk.new_no_newline,
288                new_no_newline: hunk.old_no_newline,
289                // Reversal keeps the line order (only the +/- sense flips), so the
290                // per-line patch-input line numbers carry over unchanged.
291                line_input_lines: hunk.line_input_lines.clone(),
292            }
293        })
294        .collect();
295    // git's `reverse_patches` only swaps the modes when the patch actually
296    // carries a new mode (a mode change) or is a deletion; a content-only patch
297    // keeps its (old) mode so the type-mismatch check still compares against it.
298    let (old_mode, new_mode) = if patch.new_mode.is_some() || patch.is_delete {
299        (patch.new_mode, patch.old_mode)
300    } else {
301        (patch.old_mode, patch.new_mode)
302    };
303    FilePatch {
304        old_path: patch.new_path.clone(),
305        new_path: patch.old_path.clone(),
306        old_mode,
307        new_mode,
308        hunks,
309        is_new: patch.is_delete,
310        is_delete: patch.is_new,
311        is_rename: patch.is_rename,
312        is_copy: patch.is_copy,
313        similarity: patch.similarity,
314        dissimilarity: patch.dissimilarity,
315        // Swap the index OIDs so a reverse-applied binary patch resolves the
316        // (formerly new) preimage and (formerly old) postimage correctly.
317        old_oid_hex: patch.new_oid_hex.clone(),
318        new_oid_hex: patch.old_oid_hex.clone(),
319        is_binary: patch.is_binary,
320        binary: patch.binary.as_ref().map(|binary| BinaryPatch {
321            // `-R` swaps forward and reverse hunks (git's apply_in_reverse).
322            forward: binary
323                .reverse
324                .clone()
325                .unwrap_or_else(|| binary.forward.clone()),
326            reverse: Some(binary.forward.clone()),
327        }),
328        is_toplevel_relative: patch.is_toplevel_relative,
329    }
330}
331
332/// Apply a single-file patch with explicit fragment-placement options.
333pub fn apply_file_patch_with_options(
334    base: &[u8],
335    patch: &FilePatch,
336    options: &ApplyFileOptions,
337) -> ApplyOutcome {
338    // A pure deletion with no hunks yields an empty file.
339    if patch.is_delete && patch.hunks.is_empty() {
340        return ApplyOutcome::Applied(Vec::new());
341    }
342    // A new file: the only sensible base is empty; ignore whatever was passed
343    // and build the result from the inserted lines.
344    let base_for_match: &[u8] = if patch.is_new { b"" } else { base };
345
346    // The "image" git mutates as each hunk applies. We splice in place so later
347    // hunks see the effect of earlier ones (git carries the running offset for
348    // the same reason).
349    let mut image = split_blob_lines(base_for_match);
350
351    // git seeds the search for hunk N at `newpos-1` *plus* the offset earlier
352    // hunks drifted by, so a uniform shift only costs the search once.
353    let mut running_offset: isize = 0;
354
355    for hunk in &patch.hunks {
356        match apply_one_hunk(&mut image, hunk, running_offset, options.unidiff_zero) {
357            Some(drift) => running_offset += drift,
358            None => return ApplyOutcome::Rejected,
359        }
360    }
361
362    ApplyOutcome::Applied(join_lines(&image))
363}
364
365/// The outcome of a hunk-by-hunk apply (`git apply --reject`).
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct RejectApply {
368    /// The bytes after every hunk that applied (rejected hunks are skipped).
369    pub content: Vec<u8>,
370    /// Indices into `patch.hunks` of the hunks that did not apply.
371    pub rejected: Vec<usize>,
372}
373
374/// Apply a single-file patch hunk-by-hunk, collecting the hunks that do not
375/// apply rather than rejecting the whole patch — `git apply --reject`.
376///
377/// Each hunk is tried independently against the running image; an applied hunk
378/// contributes its offset to later hunks (git's `apply_fragments` carries the
379/// running line shift), a rejected hunk is recorded and left out. The returned
380/// `content` is the image after all applicable hunks; `rejected` lists the
381/// 0-based indices of the hunks the caller must write to `<file>.rej`.
382pub fn apply_file_patch_rejecting(
383    base: &[u8],
384    patch: &FilePatch,
385    options: &ApplyFileOptions,
386) -> RejectApply {
387    if patch.is_delete && patch.hunks.is_empty() {
388        return RejectApply {
389            content: Vec::new(),
390            rejected: Vec::new(),
391        };
392    }
393    let base_for_match: &[u8] = if patch.is_new { b"" } else { base };
394    let mut image = split_blob_lines(base_for_match);
395    let mut running_offset: isize = 0;
396    let mut rejected = Vec::new();
397    for (index, hunk) in patch.hunks.iter().enumerate() {
398        match apply_one_hunk(&mut image, hunk, running_offset, options.unidiff_zero) {
399            Some(drift) => running_offset += drift,
400            None => rejected.push(index),
401        }
402    }
403    RejectApply {
404        content: join_lines(&image),
405        rejected,
406    }
407}
408
409/// Reconstruct the unified-diff text of one hunk for a `.rej` file. Mirrors the
410/// raw fragment text git copies into `<file>.rej`: the `@@ -os[,oc] +ns[,nc] @@`
411/// header (the `,1` count is omitted, matching git) followed by each line with
412/// its ` `/`+`/`-` prefix, plus the `\ No newline at end of file` note where the
413/// old/new side's final line is unterminated.
414pub fn render_reject_hunk(hunk: &Hunk) -> Vec<u8> {
415    fn range(start: usize, count: usize) -> String {
416        if count == 1 {
417            start.to_string()
418        } else {
419            format!("{start},{count}")
420        }
421    }
422    let mut out = Vec::new();
423    out.extend_from_slice(b"@@ -");
424    out.extend_from_slice(range(hunk.old_start, hunk.old_len).as_bytes());
425    out.extend_from_slice(b" +");
426    out.extend_from_slice(range(hunk.new_start, hunk.new_len).as_bytes());
427    out.extend_from_slice(b" @@\n");
428    // The last old-side line is the last Context/Delete; the last new-side line
429    // is the last Context/Insert. Their no-newline state drives the markers.
430    let last_old = hunk
431        .lines
432        .iter()
433        .rposition(|line| matches!(line, HunkLine::Context(_) | HunkLine::Delete(_)));
434    let last_new = hunk
435        .lines
436        .iter()
437        .rposition(|line| matches!(line, HunkLine::Context(_) | HunkLine::Insert(_)));
438    for (index, line) in hunk.lines.iter().enumerate() {
439        let (prefix, content) = match line {
440            HunkLine::Context(bytes) => (b' ', bytes),
441            HunkLine::Insert(bytes) => (b'+', bytes),
442            HunkLine::Delete(bytes) => (b'-', bytes),
443        };
444        out.push(prefix);
445        out.extend_from_slice(content);
446        out.push(b'\n');
447        let old_incomplete = hunk.old_no_newline && Some(index) == last_old;
448        let new_incomplete = hunk.new_no_newline && Some(index) == last_new;
449        if old_incomplete || new_incomplete {
450            out.extend_from_slice(b"\\ No newline at end of file\n");
451        }
452    }
453    out
454}
455
456// ---------------------------------------------------------------------------
457// Whitespace-aware apply (`git apply --whitespace=fix` / `--ignore-space-change`)
458//
459// A faithful port of git's `apply_one_fragment` matching path (`match_fragment`,
460// `find_pos`, `line_by_line_fuzzy_match`, `update_pre_post_images`,
461// `update_image`). It adds the matching concerns that need the whitespace rule —
462// blank-at-EOF tolerance, whitespace-corrected / whitespace-ignoring context
463// matching, and removal of newly-added blank lines at EOF — on top of the plain
464// exact-match engine above. The patch's `+` lines are expected to already carry
465// their whitespace fixes (the apply command's whitespace pass applies them);
466// this routine fixes the *context* lines as part of matching.
467// ---------------------------------------------------------------------------
468
469/// Options for [`apply_file_patch_ws`].
470#[derive(Clone, Copy)]
471pub struct WsApplyOptions {
472    /// `--unidiff-zero`.
473    pub unidiff_zero: bool,
474    /// The per-path whitespace rule.
475    pub ws_rule: ws::WsRule,
476    /// `--whitespace=fix` (git's `correct_ws_error`).
477    pub ws_fix: bool,
478    /// `--ignore-space-change` / `--ignore-whitespace` (git's `ignore_ws_change`).
479    pub ws_ignore_change: bool,
480}
481
482/// Outcome of [`apply_file_patch_ws`].
483pub enum WsApplyOutcome {
484    /// Applied; carries the bytes and the count of blank lines removed at EOF.
485    Applied {
486        content: Vec<u8>,
487        blank_at_eof_removed: usize,
488    },
489    /// At least one hunk could not be placed.
490    Rejected,
491}
492
493/// A pre/postimage line carrying git's `LINE_COMMON` flag (set for context lines,
494/// clear for added/deleted lines).
495#[derive(Clone)]
496struct WsImageLine {
497    content: Vec<u8>,
498    no_newline: bool,
499    common: bool,
500}
501
502impl WsImageLine {
503    fn bytes(&self) -> Vec<u8> {
504        let mut out = self.content.clone();
505        if !self.no_newline {
506            out.push(b'\n');
507        }
508        out
509    }
510}
511
512fn line_bytes(line: &Line) -> Vec<u8> {
513    let mut out = line.content.clone();
514    if !line.no_newline {
515        out.push(b'\n');
516    }
517    out
518}
519
520/// Split ws-fixed line bytes back into a [`WsImageLine`] (content sans trailing
521/// newline, plus the no-newline flag).
522fn ws_line_from_bytes(bytes: Vec<u8>, common: bool) -> WsImageLine {
523    if bytes.last() == Some(&b'\n') {
524        WsImageLine {
525            content: bytes[..bytes.len() - 1].to_vec(),
526            no_newline: false,
527            common,
528        }
529    } else {
530        WsImageLine {
531            content: bytes,
532            no_newline: true,
533            common,
534        }
535    }
536}
537
538/// Whitespace-aware single-file apply — git's `apply_one_fragment` matching path.
539pub fn apply_file_patch_ws(
540    base: &[u8],
541    patch: &FilePatch,
542    opts: &WsApplyOptions,
543) -> WsApplyOutcome {
544    if patch.is_delete && patch.hunks.is_empty() {
545        return WsApplyOutcome::Applied {
546            content: Vec::new(),
547            blank_at_eof_removed: 0,
548        };
549    }
550    let base_for_match: &[u8] = if patch.is_new { b"" } else { base };
551    let mut image = split_blob_lines(base_for_match);
552    let mut running_offset: isize = 0;
553    let mut blank_removed = 0usize;
554    for hunk in &patch.hunks {
555        match apply_one_fragment_ws(&mut image, hunk, running_offset, opts, &mut blank_removed) {
556            Some(drift) => running_offset += drift,
557            None => return WsApplyOutcome::Rejected,
558        }
559    }
560    WsApplyOutcome::Applied {
561        content: join_lines(&image),
562        blank_at_eof_removed: blank_removed,
563    }
564}
565
566fn apply_one_fragment_ws(
567    image: &mut Vec<Line>,
568    hunk: &Hunk,
569    running_offset: isize,
570    opts: &WsApplyOptions,
571    blank_removed: &mut usize,
572) -> Option<isize> {
573    let blank_eof = opts.ws_rule & ws::WS_BLANK_AT_EOF != 0;
574    let mut preimage: Vec<WsImageLine> = Vec::new();
575    let mut postimage: Vec<WsImageLine> = Vec::new();
576    let mut leading = 0usize;
577    let mut trailing = 0usize;
578    let mut seen_change = false;
579    // git's `new_blank_lines_at_end`: blank lines added at the end, where a blank
580    // *context* line does not reset the run but a non-blank line does.
581    let mut new_blank_lines_at_end = 0usize;
582    for hl in &hunk.lines {
583        let mut added_blank_line = false;
584        let mut is_blank_context = false;
585        match hl {
586            HunkLine::Context(bytes) => {
587                if blank_eof && ws::ws_blank_line(bytes) {
588                    is_blank_context = true;
589                }
590                preimage.push(WsImageLine {
591                    content: bytes.clone(),
592                    no_newline: false,
593                    common: true,
594                });
595                postimage.push(WsImageLine {
596                    content: bytes.clone(),
597                    no_newline: false,
598                    common: true,
599                });
600                if !seen_change {
601                    leading += 1;
602                }
603                trailing += 1;
604            }
605            HunkLine::Delete(bytes) => {
606                preimage.push(WsImageLine {
607                    content: bytes.clone(),
608                    no_newline: false,
609                    common: false,
610                });
611                seen_change = true;
612                trailing = 0;
613            }
614            HunkLine::Insert(bytes) => {
615                postimage.push(WsImageLine {
616                    content: bytes.clone(),
617                    no_newline: false,
618                    common: false,
619                });
620                if blank_eof && ws::ws_blank_line(bytes) {
621                    added_blank_line = true;
622                }
623                seen_change = true;
624                trailing = 0;
625            }
626        }
627        if added_blank_line {
628            new_blank_lines_at_end += 1;
629        } else if is_blank_context {
630            // leave the running count alone
631        } else {
632            new_blank_lines_at_end = 0;
633        }
634    }
635    if hunk.old_no_newline
636        && let Some(last) = preimage.last_mut()
637    {
638        last.no_newline = true;
639    }
640    if hunk.new_no_newline
641        && let Some(last) = postimage.last_mut()
642    {
643        last.no_newline = true;
644    }
645
646    let mut match_beginning = hunk.old_start == 0 || (hunk.old_start == 1 && !opts.unidiff_zero);
647    let mut match_end = !opts.unidiff_zero && trailing == 0;
648
649    let mut expected = if preimage.is_empty() {
650        new_side_position(hunk, running_offset)
651    } else {
652        expected_position(hunk, running_offset)
653    };
654    let hunk_expected = expected;
655    let mut leading_v = leading;
656    let mut trailing_v = trailing;
657
658    let applied_pos = loop {
659        if let Some(pos) = find_pos_ws(
660            image,
661            &mut preimage,
662            &mut postimage,
663            expected,
664            opts,
665            match_beginning,
666            match_end,
667        ) {
668            break pos;
669        }
670        #[allow(clippy::absurd_extreme_comparisons)]
671        if leading_v <= MIN_FUZZ_CONTEXT && trailing_v <= MIN_FUZZ_CONTEXT {
672            return None;
673        }
674        if match_beginning || match_end {
675            match_beginning = false;
676            match_end = false;
677            continue;
678        }
679        if leading_v >= trailing_v {
680            preimage.remove(0);
681            postimage.remove(0);
682            expected -= 1;
683            leading_v -= 1;
684        }
685        if trailing_v > leading_v {
686            preimage.pop();
687            postimage.pop();
688            trailing_v -= 1;
689        }
690    };
691
692    // Remove the blank lines added at EOF when the hunk lands at (or beyond) the
693    // end of the image — git's `--whitespace=fix` blank-at-EOF correction.
694    if new_blank_lines_at_end > 0
695        && preimage.len() + applied_pos >= image.len()
696        && blank_eof
697        && opts.ws_fix
698    {
699        for _ in 0..new_blank_lines_at_end {
700            postimage.pop();
701        }
702        *blank_removed += new_blank_lines_at_end;
703    }
704
705    // git's `update_image`: the preimage may extend beyond EOF, so only the part
706    // that falls within the image is removed.
707    let preimage_limit = preimage.len().min(image.len() - applied_pos);
708    let replacement: Vec<Line> = postimage
709        .iter()
710        .map(|line| Line {
711            content: line.content.clone(),
712            no_newline: line.no_newline,
713        })
714        .collect();
715    image.splice(applied_pos..applied_pos + preimage_limit, replacement);
716    Some(applied_pos as isize - hunk_expected)
717}
718
719/// Port of git's `find_pos`: ping-pong outward from `expected` calling
720/// [`match_fragment_ws`] at each candidate line. On a match the preimage and the
721/// common lines of the postimage may be rewritten in place (whitespace fix).
722fn find_pos_ws(
723    image: &[Line],
724    preimage: &mut Vec<WsImageLine>,
725    postimage: &mut Vec<WsImageLine>,
726    expected: isize,
727    opts: &WsApplyOptions,
728    match_beginning: bool,
729    match_end: bool,
730) -> Option<usize> {
731    let line_nr = image.len();
732    let pre_nr = preimage.len();
733    let mut line: isize = if match_beginning {
734        0
735    } else if match_end {
736        line_nr as isize - pre_nr as isize
737    } else {
738        expected
739    };
740    if line < 0 {
741        line = 0;
742    }
743    if line as usize > line_nr {
744        line = line_nr as isize;
745    }
746    let start = line as usize;
747    let mut backwards = start;
748    let mut forwards = start;
749    let mut current = start;
750    let mut i: u64 = 0;
751    loop {
752        if match_fragment_ws(
753            image,
754            preimage,
755            postimage,
756            current,
757            opts,
758            match_beginning,
759            match_end,
760        ) {
761            return Some(current);
762        }
763        loop {
764            if backwards == 0 && forwards == line_nr {
765                return None;
766            }
767            if i & 1 == 1 {
768                if backwards == 0 {
769                    i += 1;
770                    continue;
771                }
772                backwards -= 1;
773                current = backwards;
774            } else {
775                if forwards == line_nr {
776                    i += 1;
777                    continue;
778                }
779                forwards += 1;
780                current = forwards;
781            }
782            break;
783        }
784        i += 1;
785    }
786}
787
788/// Port of git's `match_fragment`. Returns whether `preimage` matches `image` at
789/// `current_lno`, trying (in order) an exact match, then — when `--whitespace=fix`
790/// or `--ignore-space-change` is in effect — a whitespace-corrected or
791/// whitespace-ignoring match, rewriting the preimage and the postimage's common
792/// lines to the matched whitespace on success.
793fn match_fragment_ws(
794    image: &[Line],
795    preimage: &mut Vec<WsImageLine>,
796    postimage: &mut Vec<WsImageLine>,
797    current_lno: usize,
798    opts: &WsApplyOptions,
799    match_beginning: bool,
800    match_end: bool,
801) -> bool {
802    let blank_eof = opts.ws_rule & ws::WS_BLANK_AT_EOF != 0;
803    let preimage_limit: usize;
804    if preimage.len() + current_lno <= image.len() {
805        preimage_limit = preimage.len();
806        if match_end && (preimage.len() + current_lno != image.len()) {
807            return false;
808        }
809    } else if opts.ws_fix && blank_eof {
810        // The hunk extends beyond EOF and we are removing blank lines there; only
811        // the in-image prefix must match, the rest of the preimage must be blank.
812        preimage_limit = image.len() - current_lno;
813    } else {
814        return false;
815    }
816
817    if match_beginning && current_lno != 0 {
818        return false;
819    }
820
821    if preimage_limit == preimage.len() {
822        // Try an exact byte match of the whole preimage.
823        let mut exact = true;
824        if match_end && current_lno + preimage_limit != image.len() {
825            exact = false;
826        }
827        if exact {
828            for i in 0..preimage_limit {
829                let img = &image[current_lno + i];
830                let pre = &preimage[i];
831                if img.content != pre.content || img.no_newline != pre.no_newline {
832                    exact = false;
833                    break;
834                }
835            }
836        }
837        if exact {
838            return true;
839        }
840    } else {
841        // The preimage extends beyond EOF: there must be at least one non-blank
842        // context line within the in-image prefix.
843        let mut all_blank = true;
844        for line in preimage.iter().take(preimage_limit) {
845            if !line.content.iter().all(|&b| ws::is_space(b)) {
846                all_blank = false;
847                break;
848            }
849        }
850        if all_blank {
851            return false;
852        }
853    }
854
855    // No exact match. Try fuzzy / whitespace-corrected matching.
856    if opts.ws_ignore_change {
857        return fuzzy_match_ws(image, preimage, postimage, current_lno, preimage_limit);
858    }
859    if !opts.ws_fix {
860        return false;
861    }
862
863    // Whitespace-corrected match: fix the in-image preimage lines and the target
864    // lines, requiring equality; the beyond-EOF preimage lines must become blank.
865    let mut fixed: Vec<WsImageLine> = Vec::with_capacity(preimage.len());
866    for i in 0..preimage_limit {
867        let fixed_pre = ws::ws_fix_bytes(&preimage[i].bytes(), opts.ws_rule);
868        let fixed_tgt = ws::ws_fix_bytes(&line_bytes(&image[current_lno + i]), opts.ws_rule);
869        if fixed_pre != fixed_tgt {
870            return false;
871        }
872        fixed.push(ws_line_from_bytes(fixed_pre, preimage[i].common));
873    }
874    for line in preimage.iter().skip(preimage_limit) {
875        let fixed_pre = ws::ws_fix_bytes(&line.bytes(), opts.ws_rule);
876        if !fixed_pre.iter().all(|&b| ws::is_space(b)) {
877            return false;
878        }
879        fixed.push(ws_line_from_bytes(fixed_pre, line.common));
880    }
881    update_pre_post_images_ws(preimage, postimage, fixed);
882    true
883}
884
885/// Port of git's `line_by_line_fuzzy_match` (the `--ignore-space-change` path):
886/// compare each line ignoring whitespace runs; on success the matched lines use
887/// the *target's* whitespace in-image and the *preimage's* whitespace beyond EOF.
888fn fuzzy_match_ws(
889    image: &[Line],
890    preimage: &mut Vec<WsImageLine>,
891    postimage: &mut Vec<WsImageLine>,
892    current_lno: usize,
893    preimage_limit: usize,
894) -> bool {
895    for i in 0..preimage_limit {
896        if !fuzzy_matchlines(&line_bytes(&image[current_lno + i]), &preimage[i].bytes()) {
897            return false;
898        }
899    }
900    // The beyond-EOF preimage lines must be all whitespace.
901    for line in preimage.iter().skip(preimage_limit) {
902        if !line.bytes().iter().all(|&b| ws::is_space(b)) {
903            return false;
904        }
905    }
906    // Build the fixed preimage: in-image lines take the target's whitespace, the
907    // beyond-EOF lines keep the preimage's whitespace.
908    let mut fixed: Vec<WsImageLine> = Vec::with_capacity(preimage.len());
909    for i in 0..preimage_limit {
910        let img = &image[current_lno + i];
911        fixed.push(WsImageLine {
912            content: img.content.clone(),
913            no_newline: img.no_newline,
914            common: preimage[i].common,
915        });
916    }
917    for line in preimage.iter().skip(preimage_limit) {
918        fixed.push(line.clone());
919    }
920    update_pre_post_images_ws(preimage, postimage, fixed);
921    true
922}
923
924/// Port of git's `fuzzy_matchlines`: compare two lines ignoring whitespace
925/// differences (any whitespace run matches any other; line endings are ignored).
926fn fuzzy_matchlines(s1: &[u8], s2: &[u8]) -> bool {
927    let trim = |s: &[u8]| {
928        let mut end = s.len();
929        while end > 0 && (s[end - 1] == b'\r' || s[end - 1] == b'\n') {
930            end -= 1;
931        }
932        end
933    };
934    let end1 = trim(s1);
935    let end2 = trim(s2);
936    let (mut i, mut j) = (0usize, 0usize);
937    while i < end1 && j < end2 {
938        if ws::is_space(s1[i]) {
939            if !ws::is_space(s2[j]) {
940                return false;
941            }
942            while i < end1 && ws::is_space(s1[i]) {
943                i += 1;
944            }
945            while j < end2 && ws::is_space(s2[j]) {
946                j += 1;
947            }
948        } else if s1[i] != s2[j] {
949            return false;
950        } else {
951            i += 1;
952            j += 1;
953        }
954    }
955    i == end1 && j == end2
956}
957
958/// Port of git's `update_pre_post_images`: replace the preimage with the fixed
959/// lines (carrying the original common flags), then rewrite each *common* line of
960/// the postimage to use the fixed preimage's content. A common postimage line
961/// whose fixed-preimage counterpart ran out (a trailing blank trimmed at EOF) is
962/// dropped (git's `reduced`).
963fn update_pre_post_images_ws(
964    preimage: &mut Vec<WsImageLine>,
965    postimage: &mut Vec<WsImageLine>,
966    fixed: Vec<WsImageLine>,
967) {
968    *preimage = fixed;
969    let mut new_post: Vec<WsImageLine> = Vec::with_capacity(postimage.len());
970    let mut ctx = 0usize;
971    for line in postimage.iter() {
972        if !line.common {
973            new_post.push(line.clone());
974            continue;
975        }
976        while ctx < preimage.len() && !preimage[ctx].common {
977            ctx += 1;
978        }
979        if ctx >= preimage.len() {
980            // preimage ran out (a fixed-away trailing blank): drop this line.
981            continue;
982        }
983        new_post.push(WsImageLine {
984            content: preimage[ctx].content.clone(),
985            no_newline: preimage[ctx].no_newline,
986            common: true,
987        });
988        ctx += 1;
989    }
990    *postimage = new_post;
991}
992
993/// Splice a single hunk into `image`, returning the offset (applied position −
994/// expected position) so later hunks can carry it forward, or `None` if the
995/// hunk cannot be located (which rejects the whole patch).
996///
997/// Faithful to git's `apply_one_fragment`: build preimage/postimage, try the
998/// full preimage at progressively-reduced context, and on a match replace the
999/// matched preimage region with the postimage.
1000fn apply_one_hunk(
1001    image: &mut Vec<Line>,
1002    hunk: &Hunk,
1003    running_offset: isize,
1004    unidiff_zero: bool,
1005) -> Option<isize> {
1006    // preimage = context + deletes (the old side we must find in the image).
1007    // postimage = context + inserts (what replaces it). They share their
1008    // leading/trailing *context* runs, which fuzz peels off symmetrically.
1009    let mut preimage: Vec<Line> = Vec::new();
1010    let mut postimage: Vec<Line> = Vec::new();
1011    let mut leading = 0usize; // context lines before the first +/-
1012    let mut trailing = 0usize; // context lines after the last +/-
1013    let mut seen_change = false;
1014    for hl in &hunk.lines {
1015        match hl {
1016            HunkLine::Context(bytes) => {
1017                preimage.push(Line {
1018                    content: bytes.clone(),
1019                    no_newline: false,
1020                });
1021                postimage.push(Line {
1022                    content: bytes.clone(),
1023                    no_newline: false,
1024                });
1025                if !seen_change {
1026                    leading += 1;
1027                }
1028                trailing += 1;
1029            }
1030            HunkLine::Delete(bytes) => {
1031                preimage.push(Line {
1032                    content: bytes.clone(),
1033                    no_newline: false,
1034                });
1035                seen_change = true;
1036                trailing = 0;
1037            }
1038            HunkLine::Insert(bytes) => {
1039                postimage.push(Line {
1040                    content: bytes.clone(),
1041                    no_newline: false,
1042                });
1043                seen_change = true;
1044                trailing = 0;
1045            }
1046        }
1047    }
1048
1049    // Mark the no-final-newline state on the last preimage/postimage line so the
1050    // exact-match check and the spliced result reproduce a missing terminal
1051    // newline byte-for-byte.
1052    if hunk.old_no_newline
1053        && let Some(last) = preimage.last_mut()
1054    {
1055        last.no_newline = true;
1056    }
1057    if hunk.new_no_newline
1058        && let Some(last) = postimage.last_mut()
1059    {
1060        last.no_newline = true;
1061    }
1062
1063    // A hunk that is `@@ -1,L ... @@` (or `@@ -0,0 ... @@` for an add-to-empty)
1064    // must match the beginning, and a hunk with no trailing context must match
1065    // the end — UNLESS `--unidiff-zero` was given, which tells apply to trust the
1066    // line numbers of a context-free hunk (`match_beginning = !oldpos ||
1067    // (oldpos == 1 && !unidiff_zero)`, `match_end = !unidiff_zero && !trailing`).
1068    let mut match_beginning = hunk.old_start == 0 || (hunk.old_start == 1 && !unidiff_zero);
1069    let mut match_end = !unidiff_zero && trailing == 0;
1070
1071    // git anchors the search at `newpos-1` (0-based), carried by the running
1072    // offset from earlier hunks. The anchor (`pos` in git) shifts up whenever a
1073    // *leading* context line is peeled, because the preimage then begins one
1074    // line later in its own content. For a context-free pure insertion the
1075    // preimage is empty and matches anywhere, so the anchor alone decides the
1076    // result — there we must use the new-side line number exactly as git's
1077    // `newpos - 1` does (the old-side `oldpos` differs for `@@ -1,0 +2,1 @@`
1078    // inserts).
1079    let mut expected = if preimage.is_empty() {
1080        new_side_position(hunk, running_offset)
1081    } else {
1082        expected_position(hunk, running_offset)
1083    };
1084    // The full hunk's expected position never moves, so the returned drift is
1085    // measured against it (not the context-reduced anchor).
1086    let hunk_expected = expected;
1087
1088    loop {
1089        if let Some(pos) = find_hunk_pos(image, &preimage, expected, match_beginning, match_end) {
1090            // Splice: drop the matched preimage lines, insert the postimage.
1091            let take = preimage.len();
1092            let replacement: Vec<Line> = postimage.clone();
1093            image.splice(pos..pos + take, replacement);
1094            return Some(pos as isize - hunk_expected);
1095        }
1096
1097        // No position matched. Mirror git's guard *order* exactly: it first
1098        // checks whether context is already at the floor (`p_context`) and, if
1099        // so, gives up BEFORE relaxing match_beginning/match_end or peeling
1100        // context. With the default `UINT_MAX` floor this fires on the very
1101        // first failure, so the default path never fuzzes and never relaxes the
1102        // begin/end anchors — it rejects. (The comparison is intentionally
1103        // against the floor so the structure stays faithful to git even though
1104        // the default floor makes it unconditionally true.)
1105        #[allow(clippy::absurd_extreme_comparisons)]
1106        if leading <= MIN_FUZZ_CONTEXT && trailing <= MIN_FUZZ_CONTEXT {
1107            return None;
1108        }
1109
1110        // git relaxes the begin/end anchors before peeling context: a hunk that
1111        // "must match the start/end" but didn't is retried free-floating first.
1112        if match_beginning || match_end {
1113            match_beginning = false;
1114            match_end = false;
1115            continue;
1116        }
1117
1118        // Reduce context: peel the larger side (both if equal), exactly as git.
1119        if leading >= trailing {
1120            // Drop the first context line from pre+post; the anchor slides up.
1121            preimage.remove(0);
1122            postimage.remove(0);
1123            expected -= 1;
1124            leading -= 1;
1125        }
1126        if trailing > leading {
1127            preimage.pop();
1128            postimage.pop();
1129            trailing -= 1;
1130        }
1131    }
1132}
1133
1134/// A line with its content (sans terminator) and whether it is newline-terminated.
1135#[derive(Debug, Clone, PartialEq, Eq)]
1136pub(crate) struct Line {
1137    pub(crate) content: Vec<u8>,
1138    pub(crate) no_newline: bool,
1139}
1140
1141/// Split a blob into [`Line`]s. A trailing `\n` does not produce an empty final
1142/// line; instead the last real line is marked `no_newline = false`. A file that
1143/// does not end in `\n` marks its final line `no_newline = true`. An empty blob
1144/// produces no lines.
1145pub(crate) fn split_blob_lines(data: &[u8]) -> Vec<Line> {
1146    let mut lines = Vec::new();
1147    let mut start = 0usize;
1148    while start < data.len() {
1149        match data[start..].iter().position(|&b| b == b'\n') {
1150            Some(rel) => {
1151                let end = start + rel;
1152                lines.push(Line {
1153                    content: data[start..end].to_vec(),
1154                    no_newline: false,
1155                });
1156                start = end + 1;
1157            }
1158            None => {
1159                lines.push(Line {
1160                    content: data[start..].to_vec(),
1161                    no_newline: true,
1162                });
1163                start = data.len();
1164            }
1165        }
1166    }
1167    lines
1168}
1169
1170/// Reassemble lines into a byte buffer, honouring per-line newline state.
1171fn join_lines(lines: &[Line]) -> Vec<u8> {
1172    let mut out = Vec::new();
1173    for line in lines {
1174        out.extend_from_slice(&line.content);
1175        if !line.no_newline {
1176            out.push(b'\n');
1177        }
1178    }
1179    out
1180}
1181
1182/// The naive 0-based position where a hunk expects to apply, given the running
1183/// offset accumulated from earlier hunks.
1184fn expected_position(hunk: &Hunk, running_offset: isize) -> isize {
1185    // `old_start` is 1-based; an empty old side (new-file hunk) uses 0.
1186    let base = if hunk.old_start == 0 {
1187        0
1188    } else {
1189        hunk.old_start as isize - 1
1190    };
1191    base + running_offset
1192}
1193
1194/// git's `pos = frag->newpos ? newpos - 1 : 0` anchor, used for a context-free
1195/// pure insertion whose empty preimage matches anywhere.
1196fn new_side_position(hunk: &Hunk, running_offset: isize) -> isize {
1197    let base = if hunk.new_start == 0 {
1198        0
1199    } else {
1200        hunk.new_start as isize - 1
1201    };
1202    base + running_offset
1203}
1204
1205/// Find the 0-based line index in `image` where `preimage` (the hunk's context
1206/// + deleted lines, possibly already context-reduced by fuzz) matches.
1207///
1208/// Port of git's `find_pos`: start the search at `expected` (clamped, or forced
1209/// to 0/end when `match_beginning`/`match_end`), then ping-pong outward across
1210/// the *whole* image — backward and forward alternately — until both ends are
1211/// exhausted. Returns the first matching line index, or `None`.
1212fn find_hunk_pos(
1213    image: &[Line],
1214    preimage: &[Line],
1215    expected: isize,
1216    match_beginning: bool,
1217    match_end: bool,
1218) -> Option<usize> {
1219    let line_nr = image.len();
1220    let pre_nr = preimage.len();
1221
1222    // git: if we must match the beginning, start at 0; if we must match the
1223    // end, start where the preimage would end exactly at EOF.
1224    let mut line: isize = if match_beginning {
1225        0
1226    } else if match_end {
1227        line_nr as isize - pre_nr as isize
1228    } else {
1229        expected
1230    };
1231    if line < 0 {
1232        line = 0;
1233    }
1234    if line as usize > line_nr {
1235        line = line_nr as isize;
1236    }
1237
1238    let start = line as usize;
1239    let mut backwards = start;
1240    let mut forwards = start;
1241    let mut current = start;
1242
1243    let mut i: u64 = 0;
1244    loop {
1245        if preimage_matches_at(image, preimage, current, match_beginning, match_end) {
1246            return Some(current);
1247        }
1248
1249        loop {
1250            // Both ends exhausted: no match anywhere.
1251            if backwards == 0 && forwards == line_nr {
1252                return None;
1253            }
1254            if i & 1 == 1 {
1255                // Step backward.
1256                if backwards == 0 {
1257                    i += 1;
1258                    continue;
1259                }
1260                backwards -= 1;
1261                current = backwards;
1262            } else {
1263                // Step forward.
1264                if forwards == line_nr {
1265                    i += 1;
1266                    continue;
1267                }
1268                forwards += 1;
1269                current = forwards;
1270            }
1271            break;
1272        }
1273        i += 1;
1274    }
1275}
1276
1277/// Whether `preimage` matches `image` starting at line `pos`.
1278///
1279/// Port of git's `match_fragment` for the default (no whitespace-fuzz) path:
1280/// a byte-exact full-preimage match. Honours `match_beginning` (pos must be 0)
1281/// and `match_end` (the preimage must reach *exactly* the end of the image),
1282/// and reproduces git's terminal-newline semantics — a preimage line marked
1283/// "no newline" only matches when it is the image's final line and that line is
1284/// itself newline-free.
1285fn preimage_matches_at(
1286    image: &[Line],
1287    preimage: &[Line],
1288    pos: usize,
1289    match_beginning: bool,
1290    match_end: bool,
1291) -> bool {
1292    if match_beginning && pos != 0 {
1293        return false;
1294    }
1295    // The whole preimage must fall within the image.
1296    if pos + preimage.len() > image.len() {
1297        return false;
1298    }
1299    if match_end && pos + preimage.len() != image.len() {
1300        return false;
1301    }
1302    for (i, pre) in preimage.iter().enumerate() {
1303        let img = &image[pos + i];
1304        if img.content != pre.content {
1305            return false;
1306        }
1307        // git compares the raw byte buffers, so a missing terminal newline on
1308        // either side only matches the other when both agree. A preimage line
1309        // that lacks a newline can only sit on the image's final line (which
1310        // must itself lack one); a preimage line that *has* a newline cannot
1311        // match a newline-free image line.
1312        if pre.no_newline != img.no_newline {
1313            return false;
1314        }
1315    }
1316    true
1317}
1318
1319/// Split raw patch bytes into lines, preserving the *content* without the
1320/// trailing `\n` (a final unterminated line is kept). Carriage returns are kept
1321/// as-is so CRLF patch bodies round-trip.
1322fn split_patch_lines(input: &[u8]) -> Vec<&[u8]> {
1323    let mut lines = Vec::new();
1324    let mut start = 0usize;
1325    while start < input.len() {
1326        match input[start..].iter().position(|&b| b == b'\n') {
1327            Some(rel) => {
1328                let end = start + rel;
1329                lines.push(&input[start..end]);
1330                start = end + 1;
1331            }
1332            None => {
1333                lines.push(&input[start..]);
1334                start = input.len();
1335            }
1336        }
1337    }
1338    lines
1339}
1340
1341struct PatchParser<'a> {
1342    lines: &'a [&'a [u8]],
1343    index: usize,
1344    recount: bool,
1345    /// `-p<n>` strip count (git's `state->p_value`); shared across the input so
1346    /// a guessed value sticks for subsequent traditional patches.
1347    p_value: usize,
1348    p_value_known: bool,
1349    /// `--directory` root (normalised, trailing slash) prepended to every name.
1350    root: Vec<u8>,
1351    /// The cwd prefix (`state->prefix`), used only to guess `-p<n>` for
1352    /// traditional patches run from a subdirectory.
1353    prefix: Vec<u8>,
1354}
1355
1356impl<'a> PatchParser<'a> {
1357    fn parse(&mut self) -> Result<Vec<FilePatch>> {
1358        let mut patches = Vec::new();
1359        while self.index < self.lines.len() {
1360            let line = self.lines[self.index];
1361            if line.starts_with(b"diff --git ") {
1362                patches.push(self.parse_file(Some(line))?);
1363            } else if line.starts_with(b"--- ") {
1364                // A bare unified diff with no `diff --git` header.
1365                patches.push(self.parse_file(None)?);
1366            } else if line.starts_with(b"@@ ") {
1367                return Err(GitError::InvalidFormat(
1368                    "hunk header encountered before any file header".to_string(),
1369                ));
1370            } else {
1371                // Skip commentary / unrelated lines.
1372                self.index += 1;
1373            }
1374        }
1375        Ok(patches)
1376    }
1377
1378    /// Parse one file's headers and hunks. When `diff_line` is `Some`, the
1379    /// current line is the `diff --git` header (already inspected by the
1380    /// caller); otherwise parsing starts at a `--- ` line of a traditional diff.
1381    fn parse_file(&mut self, diff_line: Option<&[u8]>) -> Result<FilePatch> {
1382        match diff_line {
1383            Some(diff_line) => self.parse_git_file(diff_line),
1384            None => self.parse_traditional_file(),
1385        }
1386    }
1387
1388    /// p_value with one component removed — git uses `p_value - 1` for the
1389    /// `rename`/`copy from`/`to` extended headers, whose names lack the `a/`/`b/`
1390    /// prefix the `---`/`+++` lines carry.
1391    fn p_minus_one(&self) -> usize {
1392        self.p_value.saturating_sub(1)
1393    }
1394
1395    /// Parse a git (`diff --git`) file section, resolving every pathname through
1396    /// git's `git_header_name` / `find_name` with the active `-p<n>`/`--directory`.
1397    fn parse_git_file(&mut self, diff_line: &[u8]) -> Result<FilePatch> {
1398        let mut patch = empty_file_patch();
1399        // `def_name`: the common name from the `diff --git` line, used when the
1400        // section carries no explicit `---`/`+++`/rename names.
1401        let rest = &diff_line[b"diff --git ".len()..];
1402        let mut def_name = name::git_header_name(self.p_value, rest);
1403        if let (Some(d), false) = (def_name.as_mut(), self.root.is_empty()) {
1404            let mut s = self.root.clone();
1405            s.extend_from_slice(d);
1406            *d = s;
1407        }
1408        self.index += 1;
1409
1410        // Git patches name files relative to the repository top-level, so the
1411        // `apply` cwd-prefix is never prepended to them (git's is_toplevel_relative).
1412        patch.is_toplevel_relative = true;
1413
1414        // Set once a `GIT binary patch` / `Binary files … differ` body is seen,
1415        // so the file is not run through the textual hunk parser afterwards.
1416        let mut binary_seen = false;
1417
1418        // Extended headers until the first `---`/`@@`/next `diff --git`.
1419        while self.index < self.lines.len() {
1420            let line = self.lines[self.index];
1421            if line.starts_with(b"--- ") {
1422                self.parse_git_old_header(&line[b"--- ".len()..], &mut patch);
1423                self.index += 1;
1424                break;
1425            } else if line.starts_with(b"@@ ") {
1426                // No `---`/`+++` (e.g. pure rename or mode change with no body).
1427                break;
1428            } else if line.starts_with(b"diff --git ") {
1429                // Next file began with no body for this one.
1430                break;
1431            } else if let Some(rest) = strip_prefix(line, b"old mode ") {
1432                patch.old_mode = Some(self.parse_mode_line(rest)?);
1433            } else if let Some(rest) = strip_prefix(line, b"new mode ") {
1434                patch.new_mode = Some(self.parse_mode_line(rest)?);
1435            } else if let Some(rest) = strip_prefix(line, b"new file mode ") {
1436                patch.is_new = true;
1437                patch.new_mode = Some(self.parse_mode_line(rest)?);
1438                patch.new_path = def_name.clone();
1439            } else if let Some(rest) = strip_prefix(line, b"deleted file mode ") {
1440                patch.is_delete = true;
1441                patch.old_mode = Some(self.parse_mode_line(rest)?);
1442                patch.old_path = def_name.clone();
1443            } else if let Some(rest) = strip_prefix(line, b"index ") {
1444                // `index <old>..<new>[ <mode>]`: capture the blob OIDs (needed by
1445                // the binary apply and the `-3` fallback) and the unchanged-file
1446                // mode (git's gitdiff_index → gitdiff_oldmode).
1447                self.parse_index_line(rest, &mut patch)?;
1448            } else if let Some(rest) = strip_prefix(line, b"rename from ") {
1449                patch.is_rename = true;
1450                patch.old_path = name::find_name(rest, None, self.p_minus_one(), 0, &self.root);
1451            } else if let Some(rest) = strip_prefix(line, b"rename to ") {
1452                patch.is_rename = true;
1453                patch.new_path = name::find_name(rest, None, self.p_minus_one(), 0, &self.root);
1454            } else if let Some(rest) = strip_prefix(line, b"copy from ") {
1455                patch.is_copy = true;
1456                patch.old_path = name::find_name(rest, None, self.p_minus_one(), 0, &self.root);
1457            } else if let Some(rest) = strip_prefix(line, b"copy to ") {
1458                patch.is_copy = true;
1459                patch.new_path = name::find_name(rest, None, self.p_minus_one(), 0, &self.root);
1460            } else if let Some(rest) = strip_prefix(line, b"similarity index ") {
1461                patch.similarity = parse_percent(rest);
1462            } else if let Some(rest) = strip_prefix(line, b"dissimilarity index ") {
1463                patch.dissimilarity = parse_percent(rest);
1464            } else if line == b"GIT binary patch" {
1465                // The binary payload follows (no `---`/`+++`, no `@@` hunks).
1466                let gitbin_line = self.index + 1;
1467                patch.is_binary = true;
1468                patch.binary = Some(self.parse_binary_block(gitbin_line)?);
1469                binary_seen = true;
1470                break;
1471            } else if apply_is_binary_files_differ(line) {
1472                // A `--binary`-less diff records only `Binary files … differ`;
1473                // the postimage has to come from the object store at apply time.
1474                patch.is_binary = true;
1475                binary_seen = true;
1476                self.index += 1;
1477                break;
1478            } else {
1479                // Unrecognised commentary line — ignore.
1480                self.index += 1;
1481                continue;
1482            }
1483            self.index += 1;
1484        }
1485
1486        // `+++` header (the old-file branch above already advanced past `---`).
1487        if !binary_seen
1488            && self.index < self.lines.len()
1489            && self.lines[self.index].starts_with(b"+++ ")
1490        {
1491            let line = self.lines[self.index];
1492            self.parse_git_new_header(&line[b"+++ ".len()..], &mut patch);
1493            self.index += 1;
1494        }
1495
1496        // No explicit names anywhere: fall back to `def_name`, or fail like git
1497        // when `-p<n>` stripped every component away.
1498        if patch.old_path.is_none() && patch.new_path.is_none() {
1499            match &def_name {
1500                Some(d) => {
1501                    patch.old_path = Some(d.clone());
1502                    patch.new_path = Some(d.clone());
1503                }
1504                None => {
1505                    return Err(GitError::InvalidFormat(format!(
1506                        "git diff header lacks filename information when removing {} \
1507                         leading pathname components",
1508                        self.p_value
1509                    )));
1510                }
1511            }
1512        }
1513
1514        // Binary patches carry no `@@` hunks.
1515        if !binary_seen {
1516            self.parse_hunks(&mut patch)?;
1517        }
1518        Ok(patch)
1519    }
1520
1521    /// Parse a `index <old>..<new>[ <mode>]` line, capturing the blob OIDs and,
1522    /// when present, the unchanged-file mode (git's `gitdiff_index`).
1523    fn parse_index_line(&self, rest: &[u8], patch: &mut FilePatch) -> Result<()> {
1524        let Some(dotdot) = find_subslice(rest, b"..") else {
1525            return Ok(());
1526        };
1527        let old = &rest[..dotdot];
1528        let after = &rest[dotdot + 2..];
1529        // `new` runs to the first space (mode) or end of line.
1530        let (new, mode_part) = match after.iter().position(|&b| b == b' ') {
1531            Some(space) => (&after[..space], Some(&after[space + 1..])),
1532            None => (after, None),
1533        };
1534        if !old.is_empty() {
1535            patch.old_oid_hex = Some(old.to_vec());
1536        }
1537        if !new.is_empty() {
1538            patch.new_oid_hex = Some(new.to_vec());
1539        }
1540        if let Some(mode) = mode_part
1541            && !mode.is_empty()
1542        {
1543            patch.old_mode = Some(self.parse_mode_line(mode)?);
1544        }
1545        Ok(())
1546    }
1547
1548    /// Parse a `<octal mode>` field, mirroring git's `parse_mode_line`: leading
1549    /// octal digits terminated by whitespace or end of line. Errors otherwise.
1550    fn parse_mode_line(&self, rest: &[u8]) -> Result<u32> {
1551        let mut value: u32 = 0;
1552        let mut i = 0;
1553        while i < rest.len() && (b'0'..=b'7').contains(&rest[i]) {
1554            value = value
1555                .checked_mul(8)
1556                .and_then(|value| value.checked_add((rest[i] - b'0') as u32))
1557                .ok_or_else(|| self.invalid_mode_error(rest))?;
1558            i += 1;
1559        }
1560        if i == 0 || (i < rest.len() && !rest[i].is_ascii_whitespace()) {
1561            return Err(self.invalid_mode_error(rest));
1562        }
1563        Ok(value)
1564    }
1565
1566    fn invalid_mode_error(&self, rest: &[u8]) -> GitError {
1567        GitError::InvalidFormat(format!(
1568            "invalid mode on line {}: {}",
1569            self.index + 1,
1570            lossy(rest)
1571        ))
1572    }
1573
1574    /// Parse a `GIT binary patch` body: a mandatory forward hunk and an optional
1575    /// reverse hunk, each base85-encoded over zlib-deflated data. Mirrors git's
1576    /// `parse_binary`. `gitbin_line` is the 1-based line of the `GIT binary patch`
1577    /// marker (used in the "unrecognized binary patch" message).
1578    fn parse_binary_block(&mut self, gitbin_line: usize) -> Result<BinaryPatch> {
1579        // self.index points at "GIT binary patch"; advance past it.
1580        self.index += 1;
1581        let forward = match self.parse_binary_hunk()? {
1582            Some(hunk) => hunk,
1583            None => {
1584                return Err(GitError::InvalidFormat(format!(
1585                    "binary-unrecognized:{gitbin_line}"
1586                )));
1587            }
1588        };
1589        let reverse = self.parse_binary_hunk()?;
1590        Ok(BinaryPatch { forward, reverse })
1591    }
1592
1593    /// Parse one binary hunk (method line + base85 data lines + blank terminator),
1594    /// or `Ok(None)` when the current line is not a `literal`/`delta` method line.
1595    fn parse_binary_hunk(&mut self) -> Result<Option<BinaryHunk>> {
1596        if self.index >= self.lines.len() {
1597            return Ok(None);
1598        }
1599        let line = self.lines[self.index];
1600        let (method, num) = if let Some(rest) = strip_prefix(line, b"delta ") {
1601            (BinaryMethod::Delta, rest)
1602        } else if let Some(rest) = strip_prefix(line, b"literal ") {
1603            (BinaryMethod::Literal, rest)
1604        } else {
1605            return Ok(None);
1606        };
1607        let origlen = parse_leading_usize(num).map_err(|()| self.corrupt_binary_error())?;
1608        self.index += 1;
1609
1610        let mut deflated = Vec::new();
1611        loop {
1612            if self.index >= self.lines.len() {
1613                // Ran out of input before the blank terminator (truncated patch).
1614                return Err(self.corrupt_binary_error());
1615            }
1616            let data = self.lines[self.index];
1617            if data.is_empty() {
1618                // Blank line terminates the hunk.
1619                self.index += 1;
1620                break;
1621            }
1622            // git counts the trailing newline in its line length; our split-off
1623            // lines do not carry it, so `git llen == data.len() + 1`.
1624            let len = data.len();
1625            if len < 6 || !(len - 1).is_multiple_of(5) {
1626                return Err(self.corrupt_binary_error());
1627            }
1628            let max_byte_length = (len - 1) / 5 * 4;
1629            let byte_length = match data[0] {
1630                b'A'..=b'Z' => (data[0] - b'A') as usize + 1,
1631                b'a'..=b'z' => (data[0] - b'a') as usize + 27,
1632                _ => return Err(self.corrupt_binary_error()),
1633            };
1634            if max_byte_length < byte_length || byte_length <= max_byte_length.saturating_sub(4) {
1635                return Err(self.corrupt_binary_error());
1636            }
1637            let decoded = decode_base85(&data[1..], byte_length)
1638                .ok_or_else(|| self.corrupt_binary_error())?;
1639            deflated.extend_from_slice(&decoded);
1640            self.index += 1;
1641        }
1642        Ok(Some(BinaryHunk {
1643            method,
1644            origlen,
1645            deflated,
1646        }))
1647    }
1648
1649    fn corrupt_binary_error(&self) -> GitError {
1650        GitError::InvalidFormat(format!("binary-corrupt:{}", self.index + 1))
1651    }
1652
1653    fn parse_git_old_header(&self, rest: &[u8], patch: &mut FilePatch) {
1654        if name::is_dev_null(rest) {
1655            patch.is_new = true;
1656            patch.old_path = None;
1657        } else if patch.old_path.is_none() {
1658            patch.old_path = name::find_name(rest, None, self.p_value, name::TERM_TAB, &self.root);
1659        }
1660    }
1661
1662    fn parse_git_new_header(&self, rest: &[u8], patch: &mut FilePatch) {
1663        if name::is_dev_null(rest) {
1664            patch.is_delete = true;
1665            patch.new_path = None;
1666        } else if patch.new_path.is_none() {
1667            patch.new_path = name::find_name(rest, None, self.p_value, name::TERM_TAB, &self.root);
1668        }
1669    }
1670
1671    /// Parse a traditional (non-git) diff section, mirroring git's
1672    /// `parse_traditional_patch`: guess the strip count, recognise epoch
1673    /// timestamps as creation/deletion, and prefer the shorter of the two names.
1674    fn parse_traditional_file(&mut self) -> Result<FilePatch> {
1675        let mut patch = empty_file_patch();
1676        let first_line = self.lines[self.index];
1677        let first = first_line[b"--- ".len()..].to_vec();
1678        self.index += 1;
1679        let second = if self.index < self.lines.len() && self.lines[self.index].starts_with(b"+++ ")
1680        {
1681            let s = self.lines[self.index][b"+++ ".len()..].to_vec();
1682            self.index += 1;
1683            Some(s)
1684        } else {
1685            None
1686        };
1687
1688        if let Some(second) = &second {
1689            if !self.p_value_known {
1690                let p0 = name::guess_p_value(&first, &self.root, &self.prefix);
1691                let q0 = name::guess_p_value(second, &self.root, &self.prefix);
1692                let p = if p0.is_none() { q0 } else { p0 };
1693                if let Some(pv) = p
1694                    && Some(pv) == q0
1695                {
1696                    self.p_value = pv;
1697                    self.p_value_known = true;
1698                }
1699            }
1700
1701            let name = if name::is_dev_null(&first) {
1702                patch.is_new = true;
1703                let name = name::find_name_traditional(second, None, self.p_value, &self.root);
1704                patch.new_path = name.clone();
1705                name
1706            } else if name::is_dev_null(second) {
1707                patch.is_delete = true;
1708                let name = name::find_name_traditional(&first, None, self.p_value, &self.root);
1709                patch.old_path = name.clone();
1710                name
1711            } else {
1712                let first_name =
1713                    name::find_name_traditional(&first, None, self.p_value, &self.root);
1714                let name = name::find_name_traditional(
1715                    second,
1716                    first_name.as_deref(),
1717                    self.p_value,
1718                    &self.root,
1719                );
1720                if name::has_epoch_timestamp(&first) {
1721                    patch.is_new = true;
1722                    patch.new_path = name.clone();
1723                } else if name::has_epoch_timestamp(second) {
1724                    patch.is_delete = true;
1725                    patch.old_path = name.clone();
1726                } else {
1727                    patch.old_path = name.clone();
1728                    patch.new_path = name.clone();
1729                }
1730                name
1731            };
1732            // git's `parse_traditional_patch`: a name that strips away every
1733            // component (e.g. `-p2` against a one-component `file_in_root`) is a
1734            // hard error — the whole apply fails rather than silently skipping the
1735            // unresolved file.
1736            if name.is_none() {
1737                return Err(GitError::InvalidFormat(format!(
1738                    "unable to find filename in patch at line {}",
1739                    self.index
1740                )));
1741            }
1742        }
1743
1744        self.parse_hunks(&mut patch)?;
1745        Ok(patch)
1746    }
1747
1748    /// Parse the hunk bodies that follow a file header, stopping at the next
1749    /// file header.
1750    fn parse_hunks(&mut self, patch: &mut FilePatch) -> Result<()> {
1751        while self.index < self.lines.len() {
1752            let line = self.lines[self.index];
1753            // git's `parse_single_patch` only treats a line as a fragment when it
1754            // begins with `@@ -` (old side first). A `@@ +…` line — e.g. the
1755            // malformed header a Subversion-generated diff emits — is not a hunk;
1756            // it (and the lines after it) are skipped as commentary, so a deletion
1757            // with no real hunk still applies from its metadata alone.
1758            if line.starts_with(b"@@ -") {
1759                let hunk = self.parse_hunk()?;
1760                patch.hunks.push(hunk);
1761            } else if line.starts_with(b"diff --git ") {
1762                break;
1763            } else if line.starts_with(b"--- ") {
1764                // Start of a subsequent bare diff.
1765                break;
1766            } else {
1767                // Trailing commentary between/after hunks.
1768                self.index += 1;
1769            }
1770        }
1771        Ok(())
1772    }
1773
1774    fn parse_hunk(&mut self) -> Result<Hunk> {
1775        let header = self.lines[self.index];
1776        let (old_start, old_len, new_start, new_len) = parse_hunk_header(header)?;
1777        self.index += 1;
1778
1779        let mut hunk = Hunk {
1780            old_start,
1781            old_len,
1782            new_start,
1783            new_len,
1784            lines: Vec::new(),
1785            old_no_newline: false,
1786            new_no_newline: false,
1787            line_input_lines: Vec::new(),
1788        };
1789        let mut old_seen = 0usize;
1790        let mut new_seen = 0usize;
1791
1792        while self.index < self.lines.len() {
1793            // Stop when both sides are satisfied. In recount mode the header
1794            // counts are intentionally ignored; the next hunk/file header ends
1795            // the body.
1796            if !self.recount && old_seen >= old_len && new_seen >= new_len {
1797                break;
1798            }
1799            let line = self.lines[self.index];
1800            if self.recount
1801                && (line.starts_with(b"@@ ")
1802                    || line.starts_with(b"diff --git ")
1803                    || line.starts_with(b"diff a/")
1804                    || line.starts_with(b"--- "))
1805            {
1806                break;
1807            }
1808            if line.is_empty() {
1809                // A wholly empty line in a unified diff is a context line whose
1810                // content is the empty string (git emits a bare ` `, but some
1811                // tooling/email transport strips the trailing space).
1812                hunk.lines.push(HunkLine::Context(Vec::new()));
1813                hunk.line_input_lines.push(self.index + 1);
1814                old_seen += 1;
1815                new_seen += 1;
1816                self.index += 1;
1817                continue;
1818            }
1819            match line[0] {
1820                b' ' => {
1821                    hunk.lines.push(HunkLine::Context(line[1..].to_vec()));
1822                    hunk.line_input_lines.push(self.index + 1);
1823                    old_seen += 1;
1824                    new_seen += 1;
1825                }
1826                b'+' => {
1827                    hunk.lines.push(HunkLine::Insert(line[1..].to_vec()));
1828                    hunk.line_input_lines.push(self.index + 1);
1829                    new_seen += 1;
1830                }
1831                b'-' => {
1832                    hunk.lines.push(HunkLine::Delete(line[1..].to_vec()));
1833                    hunk.line_input_lines.push(self.index + 1);
1834                    old_seen += 1;
1835                }
1836                b'\\' => {
1837                    // `\ No newline at end of file` — applies to the line just
1838                    // emitted. Set the appropriate side flag(s).
1839                    self.mark_no_newline(&mut hunk);
1840                    self.index += 1;
1841                    continue;
1842                }
1843                _ => {
1844                    return Err(GitError::InvalidFormat(format!(
1845                        "corrupt-hunk-body:{}",
1846                        self.index + 1
1847                    )));
1848                }
1849            }
1850            self.index += 1;
1851        }
1852
1853        // A trailing `\ No newline` may follow the final body line even after
1854        // the counts are satisfied; consume it.
1855        if self.index < self.lines.len() && self.lines[self.index].starts_with(b"\\") {
1856            self.mark_no_newline(&mut hunk);
1857            self.index += 1;
1858        }
1859
1860        if self.recount {
1861            hunk.old_len = old_seen;
1862            hunk.new_len = new_seen;
1863        } else if old_seen != old_len || new_seen != new_len {
1864            return Err(GitError::InvalidFormat(format!(
1865                "hunk body line counts mismatch: header declared -{old_len},+{new_len} \
1866                 but body had -{old_seen},+{new_seen}"
1867            )));
1868        }
1869
1870        Ok(hunk)
1871    }
1872
1873    /// Set the no-newline flag based on the kind of the most recently pushed
1874    /// hunk line.
1875    fn mark_no_newline(&self, hunk: &mut Hunk) {
1876        match hunk.lines.last() {
1877            Some(HunkLine::Context(_)) => {
1878                hunk.old_no_newline = true;
1879                hunk.new_no_newline = true;
1880            }
1881            Some(HunkLine::Insert(_)) => hunk.new_no_newline = true,
1882            Some(HunkLine::Delete(_)) => hunk.old_no_newline = true,
1883            None => {}
1884        }
1885    }
1886}
1887
1888/// An all-empty [`FilePatch`] for the parser to fill in.
1889fn empty_file_patch() -> FilePatch {
1890    FilePatch {
1891        old_path: None,
1892        new_path: None,
1893        old_mode: None,
1894        new_mode: None,
1895        hunks: Vec::new(),
1896        is_new: false,
1897        is_delete: false,
1898        is_rename: false,
1899        is_copy: false,
1900        similarity: None,
1901        dissimilarity: None,
1902        old_oid_hex: None,
1903        new_oid_hex: None,
1904        is_binary: false,
1905        binary: None,
1906        is_toplevel_relative: false,
1907    }
1908}
1909
1910/// Parse an `@@ -l,s +l,s @@` header into `(old_start, old_len, new_start,
1911/// new_len)`. A missing `,s` means a length of 1.
1912fn parse_hunk_header(line: &[u8]) -> Result<(usize, usize, usize, usize)> {
1913    let err = || GitError::InvalidFormat(format!("malformed hunk header: {}", lossy(line)));
1914    let rest = strip_prefix(line, b"@@ ").ok_or_else(err)?;
1915    // Up to the closing ` @@`.
1916    let close = find_subslice(rest, b" @@").ok_or_else(err)?;
1917    let ranges = &rest[..close];
1918    let mut parts = ranges.split(|&b| b == b' ').filter(|p| !p.is_empty());
1919    let old = parts.next().ok_or_else(err)?;
1920    let new = parts.next().ok_or_else(err)?;
1921    let old = strip_prefix(old, b"-").ok_or_else(err)?;
1922    let new = strip_prefix(new, b"+").ok_or_else(err)?;
1923    let (old_start, old_len) = parse_range(old).ok_or_else(err)?;
1924    let (new_start, new_len) = parse_range(new).ok_or_else(err)?;
1925    Ok((old_start, old_len, new_start, new_len))
1926}
1927
1928/// Parse `start[,len]` into `(start, len)`, defaulting `len` to 1.
1929fn parse_range(range: &[u8]) -> Option<(usize, usize)> {
1930    match range.iter().position(|&b| b == b',') {
1931        Some(comma) => {
1932            let start = parse_usize(&range[..comma])?;
1933            let len = parse_usize(&range[comma + 1..])?;
1934            Some((start, len))
1935        }
1936        None => Some((parse_usize(range)?, 1)),
1937    }
1938}
1939
1940fn parse_usize(bytes: &[u8]) -> Option<usize> {
1941    if bytes.is_empty() {
1942        return None;
1943    }
1944    let mut value: usize = 0;
1945    for &b in bytes {
1946        if !b.is_ascii_digit() {
1947            return None;
1948        }
1949        value = value.checked_mul(10)?.checked_add((b - b'0') as usize)?;
1950    }
1951    Some(value)
1952}
1953
1954fn parse_percent(bytes: &[u8]) -> Option<u8> {
1955    let trimmed = trim_ascii_end(bytes)
1956        .strip_suffix(b"%")
1957        .unwrap_or(trim_ascii_end(bytes));
1958    let value = parse_usize(trimmed)?;
1959    u8::try_from(value).ok().filter(|value| *value <= 100)
1960}
1961
1962fn strip_prefix<'b>(line: &'b [u8], prefix: &[u8]) -> Option<&'b [u8]> {
1963    if line.starts_with(prefix) {
1964        Some(&line[prefix.len()..])
1965    } else {
1966        None
1967    }
1968}
1969
1970/// Whether a diff body line is a metadata-only binary marker (`Binary files …
1971/// differ` / `Files … differ`), git's binhdr detection.
1972fn apply_is_binary_files_differ(line: &[u8]) -> bool {
1973    line.ends_with(b" differ")
1974        && (line.starts_with(b"Binary files ") || line.starts_with(b"Files "))
1975}
1976
1977/// Parse leading decimal digits (git uses `strtoul`, which ignores trailing
1978/// junk). Returns `Ok(0)` when there are no leading digits. Returns `Err(())`
1979/// when the decimal value overflows `usize`.
1980pub(crate) fn parse_leading_usize(bytes: &[u8]) -> std::result::Result<usize, ()> {
1981    let mut value = 0usize;
1982    for &b in bytes {
1983        if !b.is_ascii_digit() {
1984            break;
1985        }
1986        value = value.checked_mul(10).ok_or(())?;
1987        value = value.checked_add((b - b'0') as usize).ok_or(())?;
1988    }
1989    Ok(value)
1990}
1991
1992/// git's base85 alphabet (`base85.c` `en85`).
1993const BASE85_ALPHABET: &[u8; 85] =
1994    b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~";
1995
1996fn base85_value(ch: u8) -> Option<u32> {
1997    BASE85_ALPHABET
1998        .iter()
1999        .position(|&c| c == ch)
2000        .map(|index| index as u32)
2001}
2002
2003/// Decode `len` bytes from a base85 buffer (5 chars → 4 bytes, big-endian), a
2004/// port of git's `decode_85`. Returns `None` on an invalid alphabet character or
2005/// an overflowing 5-char group. `buffer` must contain `ceil(len/4) * 5` chars.
2006fn decode_base85(buffer: &[u8], len: usize) -> Option<Vec<u8>> {
2007    let mut out = Vec::with_capacity(len);
2008    let mut pos = 0usize;
2009    let mut remaining = len;
2010    while remaining > 0 {
2011        let mut acc: u32 = 0;
2012        // First four characters never overflow a u32 (85^4 < 2^32).
2013        for _ in 0..4 {
2014            let de = base85_value(*buffer.get(pos)?)?;
2015            pos += 1;
2016            acc = acc * 85 + de;
2017        }
2018        let de = base85_value(*buffer.get(pos)?)?;
2019        pos += 1;
2020        // The fifth character can overflow; reject it as git does.
2021        if 0xffff_ffffu32 / 85 < acc {
2022            return None;
2023        }
2024        acc *= 85;
2025        if 0xffff_ffffu32 - de < acc {
2026            return None;
2027        }
2028        acc += de;
2029
2030        let cnt = remaining.min(4);
2031        remaining -= cnt;
2032        let bytes = acc.to_be_bytes();
2033        out.extend_from_slice(&bytes[..cnt]);
2034    }
2035    Some(out)
2036}
2037
2038/// Apply a git delta (`delta.c` `patch_delta`) to reconstruct the postimage from
2039/// `base`. The delta begins with the base size and result size as varints,
2040/// followed by copy (`0x80` bit set: offset/size from base) and insert (literal
2041/// bytes) opcodes. Returns `None` on any malformed/inconsistent delta.
2042pub fn git_patch_delta(base: &[u8], delta: &[u8]) -> Option<Vec<u8>> {
2043    let mut data = 0usize;
2044    let read_hdr_size = |data: &mut usize| -> Option<usize> {
2045        let mut size = 0usize;
2046        let mut shift = 0u32;
2047        loop {
2048            let cmd = *delta.get(*data)?;
2049            *data += 1;
2050            size |= ((cmd & 0x7f) as usize).checked_shl(shift)?;
2051            shift += 7;
2052            if cmd & 0x80 == 0 {
2053                break;
2054            }
2055        }
2056        Some(size)
2057    };
2058
2059    let base_size = read_hdr_size(&mut data)?;
2060    if base_size != base.len() {
2061        return None;
2062    }
2063    let result_size = read_hdr_size(&mut data)?;
2064    let mut out = Vec::with_capacity(sley_pack::inflate::bounded_inflate_reserve(
2065        result_size,
2066        delta.len(),
2067    ));
2068
2069    while data < delta.len() {
2070        let cmd = delta[data];
2071        data += 1;
2072        if cmd & 0x80 != 0 {
2073            // Copy from base.
2074            let mut cp_off = 0usize;
2075            let mut cp_size = 0usize;
2076            if cmd & 0x01 != 0 {
2077                cp_off = *delta.get(data)? as usize;
2078                data += 1;
2079            }
2080            if cmd & 0x02 != 0 {
2081                cp_off |= (*delta.get(data)? as usize) << 8;
2082                data += 1;
2083            }
2084            if cmd & 0x04 != 0 {
2085                cp_off |= (*delta.get(data)? as usize) << 16;
2086                data += 1;
2087            }
2088            if cmd & 0x08 != 0 {
2089                cp_off |= (*delta.get(data)? as usize) << 24;
2090                data += 1;
2091            }
2092            if cmd & 0x10 != 0 {
2093                cp_size = *delta.get(data)? as usize;
2094                data += 1;
2095            }
2096            if cmd & 0x20 != 0 {
2097                cp_size |= (*delta.get(data)? as usize) << 8;
2098                data += 1;
2099            }
2100            if cmd & 0x40 != 0 {
2101                cp_size |= (*delta.get(data)? as usize) << 16;
2102                data += 1;
2103            }
2104            if cp_size == 0 {
2105                cp_size = 0x10000;
2106            }
2107            let end = cp_off.checked_add(cp_size)?;
2108            if end > base.len() || cp_size > result_size {
2109                return None;
2110            }
2111            out.extend_from_slice(&base[cp_off..end]);
2112        } else if cmd != 0 {
2113            // Insert literal bytes from the delta.
2114            let len = cmd as usize;
2115            let end = data.checked_add(len)?;
2116            if end > delta.len() {
2117                return None;
2118            }
2119            out.extend_from_slice(&delta[data..end]);
2120            data = end;
2121        } else {
2122            // Opcode 0 is reserved.
2123            return None;
2124        }
2125    }
2126
2127    if data != delta.len() || out.len() != result_size {
2128        return None;
2129    }
2130    Some(out)
2131}
2132
2133fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2134    if needle.is_empty() || needle.len() > haystack.len() {
2135        return None;
2136    }
2137    haystack
2138        .windows(needle.len())
2139        .position(|window| window == needle)
2140}
2141
2142fn trim_ascii_end(bytes: &[u8]) -> &[u8] {
2143    let mut end = bytes.len();
2144    while end > 0 && (bytes[end - 1] == b' ' || bytes[end - 1] == b'\r') {
2145        end -= 1;
2146    }
2147    &bytes[..end]
2148}
2149
2150fn lossy(bytes: &[u8]) -> String {
2151    String::from_utf8_lossy(bytes).into_owned()
2152}