Skip to main content

patchloom/ops/
patch.rs

1//! Unified-diff parse and apply (git renames, pure renames, hunk fuzz).
2//!
3//! size-waiver: single-domain patch parse + apply + pure renames (#2101).
4//! Co-located tests push line count. Policy #1408 — do not split for LOC alone.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum PatchLine {
12    Context(String),
13    Remove(String),
14    Add(String),
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Hunk {
19    pub old_start: usize,
20    pub old_count: usize,
21    pub new_start: usize,
22    pub new_count: usize,
23    pub lines: Vec<PatchLine>,
24    /// `\ No newline at end of file` appeared after a remove/context line.
25    pub old_no_final_newline: bool,
26    /// `\ No newline at end of file` appeared after an add line.
27    pub new_no_final_newline: bool,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct PatchFile {
32    pub path: String,
33    pub hunks: Vec<Hunk>,
34    /// `true` when the `---` line is `/dev/null` (new file creation).
35    pub is_creation: bool,
36    /// `true` when the `+++` line is `/dev/null` (file deletion).
37    pub is_deletion: bool,
38    /// When `--- a/old` and `+++ b/new` differ (git rename), the pre-rename path.
39    /// Content is loaded from this path; result is written to [`path`] (#2101).
40    pub rename_from: Option<String>,
41    /// Git 100% copy source (`copy from`). Dest is [`path`]. Source is kept
42    /// (`rename_from` stays `None`). (#2171)
43    pub copy_from: Option<String>,
44    /// Dest is listed for preflight but apply refuses (`GIT binary patch`,
45    /// `Binary files … differ`, mode-only chmod). (#2173)
46    pub unsupported: Option<String>,
47}
48
49impl PatchFile {
50    fn with_path(path: String) -> Self {
51        Self {
52            path,
53            hunks: Vec::new(),
54            is_creation: false,
55            is_deletion: false,
56            rename_from: None,
57            copy_from: None,
58            unsupported: None,
59        }
60    }
61
62    /// True when dest/source should use entry PathGuard (no-follow last
63    /// component): git rename and empty-hunk delete. Hunked delete applies
64    /// minus lines and leftover rewrites, so follow-mode dest-deny applies.
65    #[must_use]
66    pub fn uses_entry_containment(&self) -> bool {
67        self.rename_from.is_some() || (self.is_deletion && self.hunks.is_empty())
68    }
69
70    /// Dest and source paths this file entry would touch (C-unescaped).
71    #[must_use]
72    pub fn declared_paths(&self) -> Vec<String> {
73        let mut paths = vec![self.path.clone()];
74        if let Some(from) = self.rename_from.as_ref() {
75            paths.push(from.clone());
76        }
77        if let Some(from) = self.copy_from.as_ref() {
78            paths.push(from.clone());
79        }
80        paths
81    }
82}
83
84/// True when a git rename would overwrite an existing destination that is not
85/// a case-only rename of the source (parity with `file.rename` without force).
86///
87/// Callers supply `dest_exists` from disk or tx pending state.
88pub fn rename_would_clobber_dest(from: &str, to: &str, dest_exists: bool) -> bool {
89    if !dest_exists || from == to {
90        return false;
91    }
92    let from_path = std::path::Path::new(from);
93    let to_path = std::path::Path::new(to);
94    let case_only = from_path.parent() == to_path.parent()
95        && from_path.file_name().map(|n| n.to_ascii_lowercase())
96            == to_path.file_name().map(|n| n.to_ascii_lowercase());
97    !case_only
98}
99
100/// Error message when [`rename_would_clobber_dest`] is true.
101pub fn rename_dest_exists_msg(to: &str) -> String {
102    format!(
103        "destination already exists: {to} (patch rename refuses overwrite; remove dest or use file.rename --force)"
104    )
105}
106
107/// Error message when a git copy dest already exists.
108pub fn copy_dest_exists_msg(to: &str) -> String {
109    format!("destination already exists: {to} (patch copy refuses overwrite; remove dest)")
110}
111
112/// Error message when a patch-create dest already exists.
113pub fn create_dest_exists_msg(to: &str) -> String {
114    format!("destination already exists: {to} (patch create refuses overwrite; remove dest)")
115}
116
117/// Already-exists message when dest must not be overwritten.
118///
119/// Covers rename clobber, git copy dest, and create dest (`--- /dev/null`,
120/// with or without hunks). Case-only rename returns `None` even when dest
121/// exists.
122pub(crate) fn dest_clobber_msg(
123    path: &str,
124    dest_exists: bool,
125    rename_from: Option<&str>,
126    is_copy: bool,
127    is_creation: bool,
128) -> Option<String> {
129    if let Some(from) = rename_from
130        && rename_would_clobber_dest(from, path, dest_exists)
131    {
132        return Some(rename_dest_exists_msg(path));
133    }
134    if is_copy && dest_exists {
135        return Some(copy_dest_exists_msg(path));
136    }
137    if is_creation && dest_exists {
138        return Some(create_dest_exists_msg(path));
139    }
140    None
141}
142
143impl PatchFile {
144    pub(crate) fn dest_clobber_msg(&self, dest_exists: bool) -> Option<String> {
145        dest_clobber_msg(
146            &self.path,
147            dest_exists,
148            self.rename_from.as_deref(),
149            self.copy_from.is_some(),
150            self.is_creation && self.copy_from.is_none(),
151        )
152    }
153}
154
155/// Dest existence as apply sees it: pending creates minus deletions, else disk.
156pub(crate) fn staged_path_exists(
157    path: &Path,
158    created: &HashSet<PathBuf>,
159    deleted: &HashSet<PathBuf>,
160) -> bool {
161    if created.contains(path) {
162        return true;
163    }
164    if deleted.contains(path) {
165        return false;
166    }
167    crate::ops::file::path_entry_exists(path)
168}
169
170/// Record dests this file entry would create or free (check/apply parity).
171pub(crate) fn record_staged_patch_dest(
172    cwd: &Path,
173    pf: &PatchFile,
174    created: &mut HashSet<PathBuf>,
175    deleted: &mut HashSet<PathBuf>,
176) {
177    let dest = cwd.join(&pf.path);
178    if pf.is_deletion {
179        created.remove(&dest);
180        deleted.insert(dest);
181        return;
182    }
183    if let Some(from) = pf.rename_from.as_ref() {
184        let from_abs = cwd.join(from);
185        created.remove(&from_abs);
186        deleted.insert(from_abs);
187    }
188    // Any non-delete write occupies dest (pending), matching tx dest_exists.
189    deleted.remove(&dest);
190    created.insert(dest);
191}
192
193/// Apply-refuse message for a dest listed from git-meta that we do not write.
194pub fn unsupported_git_meta_msg(path: &str, reason: &str) -> String {
195    format!(
196        "patch apply: {path} -- unsupported git-meta ({reason}); dest is listed for preflight but apply refuses"
197    )
198}
199
200/// Check whether line `idx` is a real file header ("--- " followed by "+++"),
201/// not a removed line whose content happens to start with "-- " (e.g. SQL
202/// comments produce `--- comment text` in the diff).
203///
204/// Requires additional evidence beyond the "--- "/"+++" prefix pair:
205/// - `a/` or `b/` path prefixes (git diff format)
206/// - `/dev/null` (file creation/deletion)
207/// - a tab character (traditional diff timestamps)
208/// - a preceding `diff ` line
209/// - position at the very start of the input
210///
211/// This prevents false positives inside hunks (#1185).
212fn is_file_header(lines: &[&str], idx: usize) -> bool {
213    if !lines[idx].starts_with("--- ")
214        || idx + 1 >= lines.len()
215        || !lines[idx + 1].starts_with("+++ ")
216    {
217        return false;
218    }
219    let minus_rest = &lines[idx][4..];
220    let plus_rest = &lines[idx + 1][4..];
221    // Standard git diff paths start with a/ or b/ (or a\ / b\ from Windows agents).
222    if minus_rest.starts_with("a/")
223        || minus_rest.starts_with("a\\")
224        || minus_rest.starts_with("/dev/null")
225    {
226        return true;
227    }
228    if plus_rest.starts_with("b/")
229        || plus_rest.starts_with("b\\")
230        || plus_rest.starts_with("/dev/null")
231    {
232        return true;
233    }
234    // Traditional diff uses tabs before timestamps.
235    if minus_rest.contains('\t') || plus_rest.contains('\t') {
236        return true;
237    }
238    // Preceded by a `diff ` line.
239    if idx > 0 && lines[idx - 1].starts_with("diff ") {
240        return true;
241    }
242    // At the very start of the input (no prior context).
243    idx == 0
244}
245
246/// Strip optional Git C-quotes from a path token (`"my file.rs"` → `my file.rs`).
247fn unquote_git_path_meta(s: &str) -> String {
248    let s = s.trim();
249    if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
250        unquote_git_c_string(&s[1..s.len() - 1])
251    } else {
252        s.to_string()
253    }
254}
255
256/// Parse `diff --git a/old b/new` into (old, new) relative paths.
257///
258/// Accepts the full line or the path pair after `diff --git `.
259/// Handles C-quoted paths and mixed quoting (`"a/my file.rs" b/ok.rs`).
260/// Do not split `diff --git` on whitespace in hosts; use this helper. (#2176)
261#[must_use]
262pub fn parse_diff_git_paths(line: &str) -> Option<(String, String)> {
263    let rest = line.strip_prefix("diff --git ").unwrap_or(line).trim();
264    if rest.is_empty() {
265        return None;
266    }
267    let (raw_a, raw_b) = split_two_git_path_tokens(rest)?;
268    // Tokenizer already dropped surrounding quotes; still C-unescape the body
269    // (`b/\056env` → `b/.env`).
270    let a = strip_diff_ab_prefix(&unquote_git_c_string(&raw_a))?;
271    let b = strip_diff_ab_prefix(&unquote_git_c_string(&raw_b))?;
272    if a.is_empty() || b.is_empty() {
273        return None;
274    }
275    Some((a, b))
276}
277
278fn strip_diff_ab_prefix(s: &str) -> Option<String> {
279    s.strip_prefix("a/")
280        .or_else(|| s.strip_prefix("b/"))
281        .or_else(|| s.strip_prefix("a\\"))
282        .or_else(|| s.strip_prefix("b\\"))
283        .map(str::to_string)
284}
285
286/// True when a line is a git unified-diff file header (`diff --git`,
287/// `--- a/` / `+++ b/`, or the Windows-agent `a\` / `b\` forms).
288#[must_use]
289pub(crate) fn line_looks_like_unified_file_header(line: &str) -> bool {
290    let t = line.trim_start();
291    if t.starts_with("diff --git ") {
292        return true;
293    }
294    t.strip_prefix("--- ")
295        .or_else(|| t.strip_prefix("+++ "))
296        .is_some_and(|path| strip_diff_ab_prefix(path).is_some())
297}
298
299fn split_two_git_path_tokens(rest: &str) -> Option<(String, String)> {
300    let mut chars = rest.chars().peekable();
301    let first = next_git_path_token(&mut chars)?;
302    let second = next_git_path_token(&mut chars)?;
303    Some((first, second))
304}
305
306fn next_git_path_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Option<String> {
307    while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
308        chars.next();
309    }
310    match chars.peek() {
311        None => None,
312        Some('"') => {
313            chars.next();
314            let mut raw = String::new();
315            while let Some(c) = chars.next() {
316                if c == '\\' {
317                    raw.push('\\');
318                    if let Some(n) = chars.next() {
319                        raw.push(n);
320                    }
321                } else if c == '"' {
322                    break;
323                } else {
324                    raw.push(c);
325                }
326            }
327            Some(raw)
328        }
329        Some(_) => {
330            let mut raw = String::new();
331            while let Some(&c) = chars.peek() {
332                if c.is_whitespace() {
333                    break;
334                }
335                raw.push(c);
336                chars.next();
337            }
338            if raw.is_empty() { None } else { Some(raw) }
339        }
340    }
341}
342
343pub fn parse_patch(input: &str) -> Result<Vec<PatchFile>, String> {
344    let lines: Vec<&str> = input.lines().collect();
345    let mut files: Vec<PatchFile> = Vec::new();
346    let mut i = 0;
347    // When `diff --git` has copy from/to then ---/+++, do not treat path
348    // inequality as rename (would delete the copy source on apply).
349    let mut suppress_path_rename = false;
350    // Hunked `copy from` falls through to the ---/+++ parser. Remember the
351    // source so apply loads it instead of editing the destination in place.
352    let mut pending_copy_from: Option<String> = None;
353
354    while i < lines.len() {
355        // Pure git rename (100% similarity) often has no ---/+++ headers:
356        //   diff --git a/old b/new
357        //   similarity index 100%
358        //   rename from old
359        //   rename to new
360        if lines[i].starts_with("diff --git ")
361            && (i + 1 >= lines.len() || !is_file_header(&lines, i + 1))
362        {
363            // Look ahead for rename/copy/git-meta before next file/diff.
364            let mut rename_from_meta: Option<String> = None;
365            let mut rename_to_meta: Option<String> = None;
366            let mut copy_from_meta: Option<String> = None;
367            let mut copy_to_meta: Option<String> = None;
368            let mut saw_copy = false;
369            let mut saw_new_file_mode = false;
370            let mut saw_deleted_file_mode = false;
371            let mut saw_empty_index = false;
372            let mut saw_git_binary = false;
373            let mut saw_binary_files = false;
374            let mut saw_old_mode = false;
375            let mut saw_new_mode = false;
376            let mut j = i + 1;
377            while j < lines.len() && !lines[j].starts_with("diff ") && !is_file_header(&lines, j) {
378                if let Some(rest) = lines[j].strip_prefix("rename from ") {
379                    // C-quoted paths with spaces: rename from "my file.rs"
380                    rename_from_meta = Some(unquote_git_path_meta(rest));
381                } else if let Some(rest) = lines[j].strip_prefix("rename to ") {
382                    rename_to_meta = Some(unquote_git_path_meta(rest));
383                } else if let Some(rest) = lines[j].strip_prefix("copy from ") {
384                    copy_from_meta = Some(unquote_git_path_meta(rest));
385                    saw_copy = true;
386                } else if let Some(rest) = lines[j].strip_prefix("copy to ") {
387                    copy_to_meta = Some(unquote_git_path_meta(rest));
388                    saw_copy = true;
389                } else if lines[j].starts_with("new file mode ") {
390                    saw_new_file_mode = true;
391                } else if lines[j].starts_with("deleted file mode ") {
392                    saw_deleted_file_mode = true;
393                } else if lines[j].starts_with("old mode ") {
394                    saw_old_mode = true;
395                } else if lines[j].starts_with("new mode ") {
396                    saw_new_mode = true;
397                } else if lines[j].starts_with("GIT binary patch") {
398                    saw_git_binary = true;
399                } else if lines[j].starts_with("Binary files ") && lines[j].contains(" differ") {
400                    saw_binary_files = true;
401                } else if let Some(rest) = lines[j].strip_prefix("index ") {
402                    // Empty blob create: `index 0000000..e69de29`.
403                    if rest.contains("0000000..e69de29") || rest.starts_with("0000000..") {
404                        saw_empty_index = true;
405                    }
406                } else if lines[j].starts_with("@@ ") {
407                    // Has hunks under this diff without ---/+++; fall through
408                    // to normal scan (should not happen in git format).
409                    break;
410                }
411                j += 1;
412            }
413            // If the next real header is ---/+++ for this same rename, let the
414            // normal path consume it (may include content hunks).
415            if j < lines.len() && is_file_header(&lines, j) {
416                suppress_path_rename = saw_copy;
417                if saw_copy {
418                    pending_copy_from = copy_from_meta.clone();
419                }
420                i = j;
421                // fall through to ---/+++ handler below (no continue)
422            } else if let (Some(from), Some(to)) = (rename_from_meta, rename_to_meta) {
423                // Pure rename: require explicit rename from/to (never infer from
424                // path inequality alone — that mis-classifies copy as rename
425                // and would delete the source on apply).
426                let mut pf = PatchFile::with_path(to);
427                pf.rename_from = Some(from);
428                files.push(pf);
429                i = j;
430                continue;
431            } else if let (Some(from), Some(to)) = (copy_from_meta, copy_to_meta) {
432                // 100% git copy: dest is created, source is kept. (#2171)
433                let mut pf = PatchFile::with_path(to);
434                pf.is_creation = true;
435                pf.copy_from = Some(from);
436                files.push(pf);
437                i = j;
438                continue;
439            } else if saw_git_binary || saw_binary_files {
440                let dest = parse_diff_git_paths(lines[i]).map(|(_, b)| b);
441                let Some(dest) = dest else {
442                    return Err("binary git-meta dest could not be parsed".to_string());
443                };
444                let reason = if saw_git_binary {
445                    "GIT binary patch"
446                } else {
447                    "Binary files differ"
448                };
449                let mut pf = PatchFile::with_path(dest);
450                pf.unsupported = Some(reason.to_string());
451                files.push(pf);
452                i = j;
453                continue;
454            } else if saw_new_file_mode && saw_empty_index {
455                let dest = parse_diff_git_paths(lines[i]).map(|(_, b)| b);
456                let Some(dest) = dest else {
457                    return Err("empty-create dest could not be parsed".to_string());
458                };
459                let mut pf = PatchFile::with_path(dest);
460                pf.is_creation = true;
461                files.push(pf);
462                i = j;
463                continue;
464            } else if saw_deleted_file_mode {
465                let dest = parse_diff_git_paths(lines[i]).map(|(a, _)| a);
466                let Some(dest) = dest else {
467                    return Err("deleted-file dest could not be parsed".to_string());
468                };
469                let mut pf = PatchFile::with_path(dest);
470                pf.is_deletion = true;
471                files.push(pf);
472                i = j;
473                continue;
474            } else if saw_old_mode && saw_new_mode {
475                let dest = parse_diff_git_paths(lines[i]).map(|(_, b)| b);
476                let Some(dest) = dest else {
477                    return Err("mode-only dest could not be parsed".to_string());
478                };
479                let mut pf = PatchFile::with_path(dest);
480                pf.unsupported = Some("mode-only chmod".to_string());
481                files.push(pf);
482                i = j;
483                continue;
484            } else {
485                // Incomplete meta (similarity only, etc.): skip this diff line.
486                i += 1;
487                continue;
488            }
489        }
490
491        if !is_file_header(&lines, i) {
492            i += 1;
493            continue;
494        }
495
496        // For deletions the `+++` line is `/dev/null`; take path from `---`.
497        // Git renames use different minus/plus paths (neither `/dev/null`).
498        let minus_path = parse_file_path(lines[i]);
499        let plus_path = parse_file_path(lines[i + 1]);
500        let is_creation = minus_path == "/dev/null";
501        let is_deletion = plus_path == "/dev/null";
502        let rename_from =
503            if !is_creation && !is_deletion && minus_path != plus_path && !suppress_path_rename {
504                Some(minus_path.clone())
505            } else {
506                None
507            };
508        suppress_path_rename = false;
509        let path = if is_deletion { minus_path } else { plus_path };
510        i += 2;
511
512        let mut hunks: Vec<Hunk> = Vec::new();
513        while i < lines.len() && !is_file_header(&lines, i) {
514            if lines[i].starts_with("@@ ") {
515                let hunk = parse_hunk_header(lines[i])?;
516                let mut hunk_lines: Vec<PatchLine> = Vec::new();
517                let mut old_no_final_newline = false;
518                let mut new_no_final_newline = false;
519                let mut remaining_old = hunk.old_count;
520                let mut remaining_new = hunk.new_count;
521                i += 1;
522
523                while i < lines.len()
524                    && !lines[i].starts_with("@@ ")
525                    && !is_file_header(&lines, i)
526                    && !lines[i].starts_with("diff ")
527                {
528                    if remaining_old == 0 && remaining_new == 0 {
529                        break;
530                    }
531                    let line = lines[i];
532                    if let Some(rest) = line.strip_prefix('+') {
533                        hunk_lines.push(PatchLine::Add(rest.to_string()));
534                        remaining_new = remaining_new.saturating_sub(1);
535                    } else if let Some(rest) = line.strip_prefix('-') {
536                        hunk_lines.push(PatchLine::Remove(rest.to_string()));
537                        remaining_old = remaining_old.saturating_sub(1);
538                    } else if let Some(rest) = line.strip_prefix(' ') {
539                        hunk_lines.push(PatchLine::Context(rest.to_string()));
540                        remaining_old = remaining_old.saturating_sub(1);
541                        remaining_new = remaining_new.saturating_sub(1);
542                    } else if line == "\\ No newline at end of file" {
543                        // Marker applies to the previous hunk line (git format).
544                        match hunk_lines.last() {
545                            Some(PatchLine::Add(_)) => new_no_final_newline = true,
546                            Some(PatchLine::Remove(_) | PatchLine::Context(_)) => {
547                                old_no_final_newline = true;
548                            }
549                            None => {}
550                        }
551                    } else {
552                        hunk_lines.push(PatchLine::Context(line.to_string()));
553                        remaining_old = remaining_old.saturating_sub(1);
554                        remaining_new = remaining_new.saturating_sub(1);
555                    }
556                    i += 1;
557                }
558
559                if remaining_old == 0 && remaining_new == 0 {
560                    while i < lines.len() && lines[i] == "\\ No newline at end of file" {
561                        match hunk_lines.last() {
562                            Some(PatchLine::Add(_)) => new_no_final_newline = true,
563                            Some(PatchLine::Remove(_) | PatchLine::Context(_)) => {
564                                old_no_final_newline = true;
565                            }
566                            None => {}
567                        }
568                        i += 1;
569                    }
570                } else {
571                    return Err(format!(
572                        "incomplete hunk for {path}: remaining -{remaining_old} +{remaining_new}"
573                    ));
574                }
575
576                hunks.push(Hunk {
577                    old_start: hunk.old_start,
578                    old_count: hunk.old_count,
579                    new_start: hunk.new_start,
580                    new_count: hunk.new_count,
581                    lines: hunk_lines,
582                    old_no_final_newline,
583                    new_no_final_newline,
584                });
585            } else if lines[i].starts_with("diff ") {
586                break;
587            } else {
588                i += 1;
589            }
590        }
591
592        // Empty hunks are valid for pure renames (100% similarity, no content
593        // change). Other empty-hunk files remain a parse error.
594        if hunks.is_empty() && rename_from.is_none() && !is_creation && !is_deletion {
595            return Err(format!("no hunks found for file {path}"));
596        }
597        if hunks.is_empty() && is_creation {
598            return Err(format!("no hunks found for file {path}"));
599        }
600        // Deletion without hunks is already allowed by apply path.
601
602        files.push(PatchFile {
603            path,
604            hunks,
605            is_creation,
606            is_deletion,
607            rename_from,
608            copy_from: pending_copy_from.take(),
609            unsupported: None,
610        });
611    }
612
613    if files.is_empty() {
614        return Err("no files found in patch".to_string());
615    }
616
617    Ok(files)
618}
619
620fn parse_file_path(line: &str) -> String {
621    let raw = line
622        .strip_prefix("+++ ")
623        .or_else(|| line.strip_prefix("--- "))
624        .unwrap_or(line);
625
626    // Strip tab-separated timestamp from `diff -u` output first so C-quoted
627    // paths with spaces are not split mid-name.
628    let mut path = raw.split('\t').next().unwrap_or(raw);
629
630    // Git C-quotes special paths: `+++ "b/my file.rs"`. Unquote before a/b
631    // strip so agents replaying `git diff` resolve the real path.
632    let owned_unquoted;
633    if path.len() >= 2 && path.starts_with('"') && path.ends_with('"') {
634        owned_unquoted = unquote_git_c_string(&path[1..path.len() - 1]);
635        path = owned_unquoted.as_str();
636    }
637
638    let path = path
639        .strip_prefix("b/")
640        .or_else(|| path.strip_prefix("a/"))
641        .or_else(|| path.strip_prefix("b\\"))
642        .or_else(|| path.strip_prefix("a\\"))
643        .unwrap_or(path);
644    path.to_string()
645}
646
647/// Dest and source paths a unified diff would touch (C-unescaped).
648///
649/// Includes `---`/`+++`, rename from/to, copy from/to, empty creates, and
650/// git-meta dests that apply refuses (binary / mode-only). Parse errors are
651/// `Err` (do not return a partial dest list). (#2172)
652pub fn patch_declared_paths(text: &str) -> Result<Vec<String>, String> {
653    let files = parse_patch(text)?;
654    Ok(files.iter().flat_map(PatchFile::declared_paths).collect())
655}
656
657/// Parse one unified-diff path token (`+++ b/foo`, `rename to "\\056env"`).
658///
659/// Accepts a raw token with or without a `+++ ` / `--- ` / `rename` / `copy`
660/// prefix so hosts can feed single lines. (#2170)
661#[must_use]
662pub fn parse_diff_file_path(token: &str) -> String {
663    let t = token.trim();
664    let t = t
665        .strip_prefix("+++ ")
666        .or_else(|| t.strip_prefix("--- "))
667        .or_else(|| t.strip_prefix("rename from "))
668        .or_else(|| t.strip_prefix("rename to "))
669        .or_else(|| t.strip_prefix("copy from "))
670        .or_else(|| t.strip_prefix("copy to "))
671        .unwrap_or(t);
672    parse_file_path(&format!("+++ {t}"))
673}
674
675/// Git C-string unescape for a quoted path body (no surrounding quotes).
676///
677/// Handles `\"`, `\\`, `\a`/`\b`/`\f`/`\n`/`\r`/`\t`/`\v`, and up to
678/// three-digit octal byte escapes (`\303\251` → UTF-8 `é`, `\056` → `.`)
679/// used by `core.quotePath`. (#2170 / #2175)
680#[must_use]
681pub fn unquote_git_c_string(s: &str) -> String {
682    let mut bytes = Vec::with_capacity(s.len());
683    let mut chars = s.chars().peekable();
684    while let Some(c) = chars.next() {
685        if c == '\\' {
686            match chars.next() {
687                Some('a') => bytes.push(b'\x07'),
688                Some('b') => bytes.push(b'\x08'),
689                Some('f') => bytes.push(b'\x0c'),
690                Some('n') => bytes.push(b'\n'),
691                Some('r') => bytes.push(b'\r'),
692                Some('t') => bytes.push(b'\t'),
693                Some('v') => bytes.push(b'\x0b'),
694                Some('"') => bytes.push(b'"'),
695                Some('\\') => bytes.push(b'\\'),
696                Some(d) if d.is_digit(8) => {
697                    // Up to three octal digits (git C-quoted path bytes).
698                    let mut val = d.to_digit(8).unwrap_or(0);
699                    for _ in 0..2 {
700                        match chars.peek().and_then(|n| n.to_digit(8)) {
701                            Some(nd) => {
702                                chars.next();
703                                val = val * 8 + nd;
704                            }
705                            None => break,
706                        }
707                    }
708                    bytes.push(val as u8);
709                }
710                Some(other) => {
711                    let mut buf = [0u8; 4];
712                    bytes.extend(other.encode_utf8(&mut buf).as_bytes());
713                }
714                None => bytes.push(b'\\'),
715            }
716        } else {
717            let mut buf = [0u8; 4];
718            bytes.extend(c.encode_utf8(&mut buf).as_bytes());
719        }
720    }
721    String::from_utf8_lossy(&bytes).into_owned()
722}
723
724fn parse_hunk_header(line: &str) -> Result<Hunk, String> {
725    let trimmed = line
726        .strip_prefix("@@ ")
727        .ok_or_else(|| format!("invalid hunk header: {line}"))?;
728
729    let end = trimmed
730        .find(" @@")
731        .ok_or_else(|| format!("invalid hunk header (no closing @@): {line}"))?;
732    let range_part = &trimmed[..end];
733
734    let parts: Vec<&str> = range_part.split_whitespace().collect();
735    if parts.len() != 2 {
736        return Err(format!("invalid hunk header ranges: {line}"));
737    }
738
739    let (old_start, old_count) = parse_range(parts[0].strip_prefix('-').unwrap_or(parts[0]))?;
740    let (new_start, new_count) = parse_range(parts[1].strip_prefix('+').unwrap_or(parts[1]))?;
741
742    Ok(Hunk {
743        old_start,
744        old_count,
745        new_start,
746        new_count,
747        lines: Vec::new(),
748        old_no_final_newline: false,
749        new_no_final_newline: false,
750    })
751}
752
753fn parse_range(s: &str) -> Result<(usize, usize), String> {
754    if let Some((a, b)) = s.split_once(',') {
755        let start = a
756            .parse::<usize>()
757            .map_err(|e| format!("bad range start '{a}': {e}"))?;
758        let count = b
759            .parse::<usize>()
760            .map_err(|e| format!("bad range count '{b}': {e}"))?;
761        Ok((start, count))
762    } else {
763        let start = s
764            .parse::<usize>()
765            .map_err(|e| format!("bad range '{s}': {e}"))?;
766        Ok((start, 1))
767    }
768}
769
770const FUZZ_RANGE: usize = 3;
771
772const CONFLICT_OURS: &str = "<<<<<<< patchloom (ours)";
773const CONFLICT_SEP: &str = "=======";
774const CONFLICT_THEIRS: &str = ">>>>>>> patch (theirs)";
775
776#[derive(
777    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
778)]
779#[serde(rename_all = "lowercase")]
780pub enum OnStale {
781    #[default]
782    Fail,
783    Merge,
784}
785
786#[derive(Debug, Clone, Copy, PartialEq, Eq)]
787pub struct ApplyHunksOptions {
788    pub on_stale: OnStale,
789    pub allow_conflicts: bool,
790}
791impl Default for ApplyHunksOptions {
792    fn default() -> Self {
793        Self {
794            on_stale: OnStale::Fail,
795            allow_conflicts: false,
796        }
797    }
798}
799
800#[derive(Debug, Clone, PartialEq, Eq)]
801pub struct ConflictRange {
802    pub start_line: usize,
803    pub end_line: usize,
804}
805#[derive(Debug, Clone, PartialEq, Eq)]
806pub struct MergeResult {
807    pub content: String,
808    pub conflicts: Vec<ConflictRange>,
809}
810#[derive(Debug, Clone, PartialEq, Eq)]
811pub struct MergeError {
812    pub message: String,
813}
814impl std::fmt::Display for MergeError {
815    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
816        write!(f, "{}", self.message)
817    }
818}
819#[derive(Debug, Clone, Copy, PartialEq, Eq)]
820pub enum ApplyHunksStatus {
821    Clean,
822    Merged,
823    Conflict,
824}
825impl ApplyHunksStatus {
826    pub fn as_str(self) -> &'static str {
827        match self {
828            ApplyHunksStatus::Clean => "clean",
829            ApplyHunksStatus::Merged => "merged",
830            ApplyHunksStatus::Conflict => "conflict",
831        }
832    }
833}
834#[derive(Debug, Clone, PartialEq, Eq)]
835pub struct ApplyHunksResult {
836    pub content: String,
837    pub status: ApplyHunksStatus,
838    pub conflicts: Vec<ConflictRange>,
839}
840#[derive(Debug, Clone, PartialEq, Eq)]
841pub struct PatchApplyFileResult {
842    pub path: String,
843    pub content: String,
844    pub status: ApplyHunksStatus,
845    pub conflicts: Vec<ConflictRange>,
846    /// `true` when the patch creates a new file (`--- /dev/null`).
847    pub is_creation: bool,
848    /// `true` when the patch deletes a file (`+++ /dev/null`).
849    pub is_deletion: bool,
850    /// Pre-rename path when applying a git rename patch (#2101).
851    pub rename_from: Option<String>,
852    /// Git copy source when dest is a 100% copy (source is kept). (#2171)
853    pub copy_from: Option<String>,
854}
855
856pub fn apply_hunks(original: &str, hunks: &[Hunk]) -> Result<String, String> {
857    let eol = detect_eol(original);
858    let mut src_lines: Vec<String> = original.lines().map(String::from).collect();
859    let mut had_final_newline =
860        original.ends_with('\n') || original.ends_with("\r\n") || original.is_empty();
861    let mut offset: isize = 0;
862
863    for (hunk_idx, hunk) in hunks.iter().enumerate() {
864        let expected = hunk_expected_start(hunk, offset)
865            .map_err(|msg| format!("hunk {} failed: {msg}", hunk_idx + 1))?;
866
867        // Collect &str refs directly, avoiding N string clones per hunk.
868        let old_refs: Vec<&str> = hunk
869            .lines
870            .iter()
871            .filter_map(|pl| match pl {
872                PatchLine::Context(s) | PatchLine::Remove(s) => Some(s.as_str()),
873                _ => None,
874            })
875            .collect();
876
877        let src_refs: Vec<&str> = src_lines.iter().map(std::string::String::as_str).collect();
878
879        let pos = find_match(&src_refs, &old_refs, expected, FUZZ_RANGE).ok_or_else(|| {
880            let snippet = old_refs
881                .iter()
882                .take(3)
883                .copied()
884                .collect::<Vec<_>>()
885                .join("\n");
886            format!(
887                "hunk {} failed: stale context near line {} — expected:\n{}",
888                hunk_idx + 1,
889                hunk.old_start,
890                snippet,
891            )
892        })?;
893
894        let new_lines: Vec<String> = hunk
895            .lines
896            .iter()
897            .filter_map(|pl| match pl {
898                PatchLine::Context(s) => Some(s.clone()),
899                PatchLine::Add(s) => Some(s.clone()),
900                _ => None,
901            })
902            .collect();
903
904        let old_len = old_refs.len();
905        let new_len = new_lines.len();
906        // When the match covers the end of the file, git "\ No newline" markers
907        // decide whether the result ends with a newline.
908        let touches_eof = pos + old_len >= src_lines.len();
909        src_lines.splice(pos..pos + old_len, new_lines);
910        if touches_eof {
911            let last_new = hunk
912                .lines
913                .iter()
914                .rev()
915                .find(|pl| matches!(pl, PatchLine::Add(_) | PatchLine::Context(_)));
916            match last_new {
917                Some(PatchLine::Add(_)) => {
918                    if hunk.new_no_final_newline {
919                        // Explicit: new file ends without NL.
920                        had_final_newline = false;
921                    } else if hunk.old_no_final_newline {
922                        // Old last line lacked NL; new last line has no marker → has NL.
923                        had_final_newline = true;
924                    }
925                    // Else keep prior had_final_newline (preserve EOF when only
926                    // rewriting the last line without git markers).
927                }
928                Some(PatchLine::Context(_)) if hunk.old_no_final_newline => {
929                    // EOF context line that lacked NL on the old side.
930                    had_final_newline = false;
931                }
932                Some(PatchLine::Context(_)) if hunk.new_no_final_newline => {
933                    had_final_newline = false;
934                }
935                _ => {}
936            }
937        }
938        let delta = isize::try_from(new_len).unwrap_or(isize::MAX)
939            - isize::try_from(old_len).unwrap_or(isize::MAX);
940        offset = offset.saturating_add(delta);
941    }
942
943    Ok(join_lines_with(&src_lines, had_final_newline, eol))
944}
945
946pub fn apply_hunks_with_options(
947    ours: &str,
948    hunks: &[Hunk],
949    options: ApplyHunksOptions,
950) -> Result<ApplyHunksResult, String> {
951    match options.on_stale {
952        OnStale::Fail => {
953            let content = apply_hunks(ours, hunks)?;
954            Ok(ApplyHunksResult {
955                content,
956                status: ApplyHunksStatus::Clean,
957                conflicts: Vec::new(),
958            })
959        }
960        OnStale::Merge => {
961            if let Ok(content) = apply_hunks(ours, hunks) {
962                return Ok(ApplyHunksResult {
963                    content,
964                    status: ApplyHunksStatus::Clean,
965                    conflicts: Vec::new(),
966                });
967            }
968            let merge_result = merge_hunks(ours, hunks).map_err(|e| e.message)?;
969            // apply_hunks already failed above (line 315); since it is pure and
970            // deterministic, re-calling it with the same inputs would fail again.
971            // The merge path always produces Merged or Conflict status.
972            let status = if !merge_result.conflicts.is_empty() {
973                ApplyHunksStatus::Conflict
974            } else {
975                ApplyHunksStatus::Merged
976            };
977            if status == ApplyHunksStatus::Conflict && !options.allow_conflicts {
978                return Err(format!(
979                    "patch merge produced {} conflict(s); pass --allow-conflicts to write conflict markers",
980                    merge_result.conflicts.len()
981                ));
982            }
983            Ok(ApplyHunksResult {
984                content: merge_result.content,
985                status,
986                conflicts: merge_result.conflicts,
987            })
988        }
989    }
990}
991
992pub fn merge_hunks(ours: &str, hunks: &[Hunk]) -> Result<MergeResult, MergeError> {
993    let eol = detect_eol(ours);
994    let mut src_lines: Vec<String> = ours.lines().map(String::from).collect();
995    let had_final_newline = ours.ends_with('\n') || ours.ends_with("\r\n") || ours.is_empty();
996    let mut offset: isize = 0;
997    let mut conflicts = Vec::new();
998    for (hunk_idx, hunk) in hunks.iter().enumerate() {
999        let expected = hunk_expected_start(hunk, offset).map_err(|msg| MergeError {
1000            message: format!("hunk {} failed: {msg}", hunk_idx + 1),
1001        })?;
1002        let old_refs = hunk_old_refs(hunk);
1003        let base_lines = hunk_base_lines(hunk);
1004        let theirs_lines = hunk_theirs_lines(hunk);
1005        let src_refs: Vec<&str> = src_lines.iter().map(String::as_str).collect();
1006        let pos = locate_hunk_region(&src_refs, hunk, expected).ok_or_else(|| MergeError {
1007            message: format!(
1008                "hunk {} failed: stale context near line {}",
1009                hunk_idx + 1,
1010                hunk.old_start
1011            ),
1012        })?;
1013        let old_len = old_refs.len();
1014        let ours_region: Vec<String> = src_lines[pos..pos + old_len].to_vec();
1015        let (replacement, hunk_conflicts) =
1016            if ours_region.iter().map(String::as_str).collect::<Vec<_>>() == old_refs {
1017                (theirs_lines, Vec::new())
1018            } else {
1019                merge_three_way(&base_lines, &ours_region, &theirs_lines, pos + 1)
1020            };
1021        conflicts.extend(hunk_conflicts);
1022        let new_len = replacement.len();
1023        src_lines.splice(pos..pos + old_len, replacement);
1024        offset = offset.saturating_add(
1025            isize::try_from(new_len).unwrap_or(isize::MAX)
1026                - isize::try_from(old_len).unwrap_or(isize::MAX),
1027        );
1028    }
1029    Ok(MergeResult {
1030        content: join_lines_with(&src_lines, had_final_newline, eol),
1031        conflicts,
1032    })
1033}
1034
1035fn hunk_expected_start(hunk: &Hunk, offset: isize) -> Result<isize, String> {
1036    if hunk.old_start == 0 {
1037        return Ok(0);
1038    }
1039    // Pure insertion `@@ -N,0` inserts after line N (index N). Other hunks
1040    // use 1-based old_start converted to a 0-based index.
1041    let base = if hunk.old_count == 0 {
1042        isize::try_from(hunk.old_start).ok()
1043    } else {
1044        isize::try_from(hunk.old_start)
1045            .ok()
1046            .and_then(|s| s.checked_sub(1))
1047    };
1048    base.and_then(|s| s.checked_add(offset))
1049        .ok_or_else(|| format!("line number {} out of range", hunk.old_start))
1050}
1051fn hunk_old_refs(hunk: &Hunk) -> Vec<&str> {
1052    hunk.lines
1053        .iter()
1054        .filter_map(|pl| match pl {
1055            PatchLine::Context(s) | PatchLine::Remove(s) => Some(s.as_str()),
1056            _ => None,
1057        })
1058        .collect()
1059}
1060fn hunk_base_lines(hunk: &Hunk) -> Vec<String> {
1061    hunk.lines
1062        .iter()
1063        .filter_map(|pl| match pl {
1064            PatchLine::Context(s) | PatchLine::Remove(s) => Some(s.clone()),
1065            _ => None,
1066        })
1067        .collect()
1068}
1069fn hunk_theirs_lines(hunk: &Hunk) -> Vec<String> {
1070    hunk.lines
1071        .iter()
1072        .filter_map(|pl| match pl {
1073            PatchLine::Context(s) | PatchLine::Add(s) => Some(s.clone()),
1074            _ => None,
1075        })
1076        .collect()
1077}
1078fn locate_hunk_region(haystack: &[&str], hunk: &Hunk, expected: isize) -> Option<usize> {
1079    let old_refs = hunk_old_refs(hunk);
1080    find_match(haystack, &old_refs, expected, FUZZ_RANGE)
1081        .or_else(|| find_match_global(haystack, &old_refs))
1082        .or_else(|| locate_by_context_anchors(haystack, hunk, expected))
1083}
1084fn locate_by_context_anchors(haystack: &[&str], hunk: &Hunk, expected: isize) -> Option<usize> {
1085    let old_refs = hunk_old_refs(hunk);
1086    let base_len = old_refs.len();
1087    if base_len == 0 {
1088        return Some((expected.max(0) as usize).min(haystack.len()));
1089    }
1090    let (prefix_ctx, suffix_ctx) = hunk_context_anchors(hunk);
1091    if prefix_ctx.is_empty() && suffix_ctx.is_empty() {
1092        return None;
1093    }
1094    let prefix_refs: Vec<&str> = prefix_ctx.iter().map(String::as_str).collect();
1095    let pos = if prefix_ctx.is_empty() {
1096        None
1097    } else {
1098        find_match(haystack, &prefix_refs, expected, FUZZ_RANGE)
1099            .or_else(|| find_match_global(haystack, &prefix_refs))
1100    };
1101    let pos = if let Some(pos) = pos {
1102        pos
1103    } else if !suffix_ctx.is_empty() {
1104        let suffix_refs: Vec<&str> = suffix_ctx.iter().map(String::as_str).collect();
1105        let suffix_expected = expected
1106            .saturating_add(isize::try_from(base_len).unwrap_or(isize::MAX))
1107            .saturating_sub(isize::try_from(suffix_refs.len()).unwrap_or(isize::MAX));
1108        let suffix_pos = find_match(haystack, &suffix_refs, suffix_expected, FUZZ_RANGE)
1109            .or_else(|| find_match_global(haystack, &suffix_refs))?;
1110        suffix_pos.saturating_sub(base_len.saturating_sub(suffix_refs.len()))
1111    } else {
1112        return None;
1113    };
1114    if !suffix_ctx.is_empty() {
1115        let suffix_start = pos + base_len.saturating_sub(suffix_ctx.len());
1116        if suffix_start + suffix_ctx.len() > haystack.len() {
1117            return None;
1118        }
1119        let suffix_refs: Vec<&str> = suffix_ctx.iter().map(String::as_str).collect();
1120        if haystack[suffix_start..suffix_start + suffix_refs.len()] != *suffix_refs {
1121            return None;
1122        }
1123    }
1124    if pos + base_len > haystack.len() {
1125        return None;
1126    }
1127    Some(pos)
1128}
1129fn hunk_context_anchors(hunk: &Hunk) -> (Vec<String>, Vec<String>) {
1130    // Prefix: leading context lines before the first change.
1131    let mut prefix_ctx = Vec::new();
1132    for pl in &hunk.lines {
1133        match pl {
1134            PatchLine::Context(s) => prefix_ctx.push(s.clone()),
1135            _ => break,
1136        }
1137    }
1138    // Suffix: trailing context lines after the last change.
1139    // Scan backwards so mid-hunk context between change blocks is excluded.
1140    let mut suffix_ctx = Vec::new();
1141    for pl in hunk.lines.iter().rev() {
1142        match pl {
1143            PatchLine::Context(s) => suffix_ctx.push(s.clone()),
1144            _ => break,
1145        }
1146    }
1147    suffix_ctx.reverse();
1148    (prefix_ctx, suffix_ctx)
1149}
1150fn merge_three_way(
1151    base: &[String],
1152    ours: &[String],
1153    theirs: &[String],
1154    region_start_line: usize,
1155) -> (Vec<String>, Vec<ConflictRange>) {
1156    if base.len() == ours.len() && base.len() == theirs.len() {
1157        merge_three_way_lines(base, ours, theirs, region_start_line)
1158    } else {
1159        merge_three_way_block(base, ours, theirs, region_start_line)
1160    }
1161}
1162fn merge_three_way_lines(
1163    base: &[String],
1164    ours: &[String],
1165    theirs: &[String],
1166    region_start_line: usize,
1167) -> (Vec<String>, Vec<ConflictRange>) {
1168    let mut out = Vec::new();
1169    let mut conflicts = Vec::new();
1170    let mut line_no = region_start_line;
1171    for i in 0..base.len() {
1172        let (b, o, t) = (&base[i], &ours[i], &theirs[i]);
1173        if o == b && t == b {
1174            out.push(o.clone());
1175            line_no += 1;
1176        } else if o == b {
1177            out.push(t.clone());
1178            line_no += 1;
1179        } else if t == b || o == t {
1180            out.push(o.clone());
1181            line_no += 1;
1182        } else {
1183            let start = line_no;
1184            out.extend([
1185                CONFLICT_OURS.to_string(),
1186                o.clone(),
1187                CONFLICT_SEP.to_string(),
1188                t.clone(),
1189                CONFLICT_THEIRS.to_string(),
1190            ]);
1191            conflicts.push(ConflictRange {
1192                start_line: start,
1193                end_line: start + 4,
1194            });
1195            line_no += 5;
1196        }
1197    }
1198    (out, conflicts)
1199}
1200fn merge_three_way_block(
1201    base: &[String],
1202    ours: &[String],
1203    theirs: &[String],
1204    region_start_line: usize,
1205) -> (Vec<String>, Vec<ConflictRange>) {
1206    if ours == base {
1207        return (theirs.to_vec(), Vec::new());
1208    }
1209    if theirs == base {
1210        return (ours.to_vec(), Vec::new());
1211    }
1212    if ours == theirs {
1213        return (ours.to_vec(), Vec::new());
1214    }
1215    let start = region_start_line;
1216    let mut out = vec![CONFLICT_OURS.to_string()];
1217    out.extend(ours.iter().cloned());
1218    out.push(CONFLICT_SEP.to_string());
1219    out.extend(theirs.iter().cloned());
1220    out.push(CONFLICT_THEIRS.to_string());
1221    let end = start + out.len().saturating_sub(1);
1222    (
1223        out,
1224        vec![ConflictRange {
1225            start_line: start,
1226            end_line: end,
1227        }],
1228    )
1229}
1230fn find_match_global(haystack: &[&str], needle: &[&str]) -> Option<usize> {
1231    if needle.is_empty() {
1232        return Some(0);
1233    }
1234    if needle.len() > haystack.len() {
1235        return None;
1236    }
1237    let max_start = haystack.len() - needle.len();
1238    for pos in 0..=max_start {
1239        if haystack[pos..pos + needle.len()] == *needle {
1240            return Some(pos);
1241        }
1242    }
1243    None
1244}
1245fn join_lines_with(lines: &[String], final_newline: bool, eol: &str) -> String {
1246    if lines.is_empty() {
1247        return String::new();
1248    }
1249    let mut out = lines.join(eol);
1250    if final_newline {
1251        out.push_str(eol);
1252    }
1253    out
1254}
1255
1256/// Detect dominant line ending in the original text.
1257fn detect_eol(text: &str) -> &'static str {
1258    crate::write::detect_eol(text)
1259}
1260
1261fn find_match(haystack: &[&str], needle: &[&str], expected: isize, fuzz: usize) -> Option<usize> {
1262    if needle.is_empty() {
1263        let pos = expected.max(0) as usize;
1264        return Some(pos.min(haystack.len()));
1265    }
1266
1267    for delta in 0..=fuzz {
1268        for &sign in &[1isize, -1isize] {
1269            let Some(offset) = isize::try_from(delta).ok() else {
1270                continue;
1271            };
1272            let Some(candidate) = offset
1273                .checked_mul(sign)
1274                .and_then(|o| expected.checked_add(o))
1275            else {
1276                continue;
1277            };
1278            if candidate < 0 {
1279                continue;
1280            }
1281            let pos = candidate as usize;
1282            if pos + needle.len() > haystack.len() {
1283                continue;
1284            }
1285            if haystack[pos..pos + needle.len()] == *needle {
1286                return Some(pos);
1287            }
1288        }
1289    }
1290
1291    None
1292}
1293
1294/// Recovery sentence when a hunked `+++ /dev/null` fails on stale minus lines.
1295/// Shared by CLI, tx, MCP, and the library apply path so the wording cannot drift.
1296pub(crate) const STALE_HUNKED_DELETE_RECOVERY: &str = "This was a deletion hunk; the file was not removed. Regenerate minus lines from the current file, or use file.delete for path-only unlink.";
1297
1298pub(crate) fn append_stale_hunked_delete_recovery(msg: &str) -> String {
1299    format!("{msg} {STALE_HUNKED_DELETE_RECOVERY}")
1300}
1301
1302#[cfg(any(feature = "cli", feature = "files"))]
1303pub(crate) fn apply_patch_with_loader<F>(
1304    diff_text: &str,
1305    mut load_original: F,
1306    options: ApplyHunksOptions,
1307) -> anyhow::Result<Vec<PatchApplyFileResult>>
1308where
1309    F: FnMut(&str) -> anyhow::Result<String>,
1310{
1311    let patch_files = parse_patch(diff_text)
1312        .map_err(|msg| crate::ops::search_replace::unified_parse_anyhow(diff_text, &msg))?;
1313    let mut results = Vec::new();
1314    for pf in &patch_files {
1315        if let Some(reason) = pf.unsupported.as_deref() {
1316            return Err(anyhow::Error::new(crate::exit::InvalidInputError {
1317                msg: unsupported_git_meta_msg(&pf.path, reason),
1318            }));
1319        }
1320        // New file creation: original is empty, don't try to load from disk.
1321        // Git rename: load from rename_from (old path), write to path (new).
1322        // Git copy: load from copy_from, write dest, keep source. (#2171)
1323        let load_path = pf
1324            .copy_from
1325            .as_deref()
1326            .or(pf.rename_from.as_deref())
1327            .unwrap_or(pf.path.as_str());
1328        let pure_rename =
1329            pf.rename_from.is_some() && pf.hunks.is_empty() && !pf.is_deletion && !pf.is_creation;
1330        let pure_copy = pf.copy_from.is_some() && pf.hunks.is_empty();
1331        let original = if pf.copy_from.is_some() {
1332            load_original(load_path)?
1333        } else if pf.is_creation || (pf.is_deletion && pf.hunks.is_empty()) {
1334            // Creation and empty-hunk delete: do not load. load_original
1335            // follows symlinks; file_delete snapshot is the caller's job.
1336            String::new()
1337        } else {
1338            match load_original(load_path) {
1339                Ok(s) => s,
1340                // Pure path rename of binary / invalid UTF-8: soft empty snapshot
1341                // (commit uses fs::rename so bytes stay intact; #2031 parity).
1342                Err(e)
1343                    if pure_rename
1344                        && (crate::exit::is_binary(&e) || crate::exit::is_invalid_encoding(&e)) =>
1345                {
1346                    String::new()
1347                }
1348                Err(e) => return Err(e),
1349            }
1350        };
1351        // Pure rename (empty hunks): keep content, stage rename only.
1352        if pure_rename {
1353            results.push(PatchApplyFileResult {
1354                path: pf.path.clone(),
1355                content: original,
1356                status: ApplyHunksStatus::Clean,
1357                conflicts: Vec::new(),
1358                is_creation: false,
1359                is_deletion: false,
1360                rename_from: pf.rename_from.clone(),
1361                copy_from: None,
1362            });
1363            continue;
1364        }
1365        // Pure copy (empty hunks): write dest, keep source.
1366        if pure_copy {
1367            results.push(PatchApplyFileResult {
1368                path: pf.path.clone(),
1369                content: original,
1370                status: ApplyHunksStatus::Clean,
1371                conflicts: Vec::new(),
1372                is_creation: true,
1373                is_deletion: false,
1374                rename_from: None,
1375                copy_from: pf.copy_from.clone(),
1376            });
1377            continue;
1378        }
1379        // File deletion: still run hunk application so stale context fails
1380        // closed (check and apply must agree). Skipping hunks always deleted
1381        // even when the file was edited after the patch was made.
1382        if pf.is_deletion {
1383            if pf.hunks.is_empty() {
1384                // Pure delete marker without hunks: remove as-is.
1385                results.push(PatchApplyFileResult {
1386                    path: pf.path.clone(),
1387                    content: String::new(),
1388                    status: ApplyHunksStatus::Clean,
1389                    conflicts: Vec::new(),
1390                    is_creation: false,
1391                    is_deletion: true,
1392                    rename_from: None,
1393                    copy_from: None,
1394                });
1395                continue;
1396            }
1397            let applied = match apply_hunks_with_options(&original, &pf.hunks, options) {
1398                Ok(applied) => applied,
1399                Err(msg) if msg.contains("conflict(s)") => {
1400                    return Err(crate::exit::ConflictsError {
1401                        msg: format!("patch apply: {} -- {msg}", pf.path),
1402                    }
1403                    .into());
1404                }
1405                Err(msg) if msg.contains("stale context") => {
1406                    return Err(crate::exit::AmbiguousError {
1407                        msg: format!(
1408                            "patch apply: {} -- {}",
1409                            pf.path,
1410                            append_stale_hunked_delete_recovery(&msg)
1411                        ),
1412                    }
1413                    .into());
1414                }
1415                Err(msg) => {
1416                    return Err(anyhow::Error::new(crate::exit::InvalidInputError {
1417                        msg: format!("patch apply: {} -- {msg}", pf.path),
1418                    }));
1419                }
1420            };
1421            // Only treat as deletion when context matched (Clean empty result).
1422            // Stale/conflict already returned Err above under OnStale::Fail.
1423            let is_clean_delete =
1424                applied.status == ApplyHunksStatus::Clean && applied.content.is_empty();
1425            results.push(PatchApplyFileResult {
1426                path: pf.path.clone(),
1427                content: applied.content,
1428                status: applied.status,
1429                conflicts: applied.conflicts,
1430                is_creation: false,
1431                is_deletion: is_clean_delete,
1432                rename_from: None,
1433                copy_from: None,
1434            });
1435            continue;
1436        }
1437        let applied = match apply_hunks_with_options(&original, &pf.hunks, options) {
1438            Ok(applied) => applied,
1439            // Merge conflicts without allow_conflicts: typed kind so CLI/tx
1440            // map to exit CONFLICTS (8) and error_kind "conflicts".
1441            Err(msg) if msg.contains("conflict(s)") => {
1442                return Err(crate::exit::ConflictsError {
1443                    msg: format!("patch apply: {} -- {msg}", pf.path),
1444                }
1445                .into());
1446            }
1447            Err(msg) if msg.contains("stale context") => {
1448                return Err(crate::exit::AmbiguousError {
1449                    msg: format!("patch apply: {} -- {msg}", pf.path),
1450                }
1451                .into());
1452            }
1453            Err(msg) => {
1454                return Err(anyhow::Error::new(crate::exit::InvalidInputError {
1455                    msg: format!("patch apply: {} -- {msg}", pf.path),
1456                }));
1457            }
1458        };
1459        results.push(PatchApplyFileResult {
1460            path: pf.path.clone(),
1461            content: applied.content,
1462            status: applied.status,
1463            conflicts: applied.conflicts,
1464            is_creation: pf.is_creation,
1465            is_deletion: false,
1466            rename_from: pf.rename_from.clone(),
1467            copy_from: pf.copy_from.clone(),
1468        });
1469    }
1470    Ok(results)
1471}
1472
1473#[path = "patch_tests.rs"]
1474#[cfg(test)]
1475mod tests;