Skip to main content

sley_diff_merge/
patch.rs

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