Skip to main content

codex_git_utils/
apply.rs

1//! Helpers for applying unified diffs using the system `git` binary.
2//!
3//! The entry point is [`apply_git_patch`], which writes a diff to a temporary
4//! file, shells out to `git apply` with the right flags, and then parses the
5//! command’s output into structured details. Callers can opt into dry-run
6//! mode via [`ApplyGitRequest::preflight`] and inspect the resulting paths to
7//! learn what would change before applying for real.
8
9use once_cell::sync::Lazy;
10use regex::Regex;
11use std::ffi::OsStr;
12use std::io;
13use std::path::Path;
14use std::path::PathBuf;
15
16/// Parameters for invoking [`apply_git_patch`].
17#[derive(Debug, Clone)]
18pub struct ApplyGitRequest {
19    pub cwd: PathBuf,
20    pub diff: String,
21    pub revert: bool,
22    pub preflight: bool,
23}
24
25/// Result of running [`apply_git_patch`], including paths gleaned from stdout/stderr.
26#[derive(Debug, Clone)]
27pub struct ApplyGitResult {
28    pub exit_code: i32,
29    pub applied_paths: Vec<String>,
30    pub skipped_paths: Vec<String>,
31    pub conflicted_paths: Vec<String>,
32    pub stdout: String,
33    pub stderr: String,
34    pub cmd_for_log: String,
35}
36
37/// Apply a unified diff to the target repository by shelling out to `git apply`.
38///
39/// When [`ApplyGitRequest::preflight`] is `true`, this behaves like `git apply --check` and
40/// leaves the working tree untouched while still parsing the command output for diagnostics.
41pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
42    let git_root = resolve_git_root(&req.cwd)?;
43
44    // Write unified diff into a temporary file
45    let (tmpdir, patch_path) = write_temp_patch(&req.diff)?;
46    // Keep tmpdir alive until function end to ensure the file exists
47    let _guard = tmpdir;
48
49    if req.revert && !req.preflight {
50        // Stage WT paths first to avoid index mismatch on revert.
51        stage_paths(&git_root, &req.diff)?;
52    }
53
54    // Build git args
55    let mut args: Vec<String> = vec!["apply".into(), "--3way".into()];
56    if req.revert {
57        args.push("-R".into());
58    }
59
60    // Optional: additional git config via env knob (defaults OFF)
61    let mut cfg_parts: Vec<String> = Vec::new();
62    if let Ok(cfg) = std::env::var("CODEX_APPLY_GIT_CFG") {
63        for pair in cfg.split(',') {
64            let p = pair.trim();
65            if p.is_empty() || !p.contains('=') {
66                continue;
67            }
68            cfg_parts.push("-c".into());
69            cfg_parts.push(p.to_string());
70        }
71    }
72
73    args.push(patch_path.to_string_lossy().to_string());
74
75    // Optional preflight: dry-run only; do not modify working tree
76    if req.preflight {
77        let mut check_args = vec!["apply".to_string(), "--check".to_string()];
78        if req.revert {
79            check_args.push("-R".to_string());
80        }
81        check_args.push(patch_path.to_string_lossy().to_string());
82        let rendered = render_command_for_log(&git_root, &cfg_parts, &check_args);
83        let (c_code, c_out, c_err) = run_git(&git_root, &cfg_parts, &check_args)?;
84        let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
85            parse_git_apply_output(&c_out, &c_err);
86        applied_paths.sort();
87        applied_paths.dedup();
88        skipped_paths.sort();
89        skipped_paths.dedup();
90        conflicted_paths.sort();
91        conflicted_paths.dedup();
92        return Ok(ApplyGitResult {
93            exit_code: c_code,
94            applied_paths,
95            skipped_paths,
96            conflicted_paths,
97            stdout: c_out,
98            stderr: c_err,
99            cmd_for_log: rendered,
100        });
101    }
102
103    let cmd_for_log = render_command_for_log(&git_root, &cfg_parts, &args);
104    let (code, stdout, stderr) = run_git(&git_root, &cfg_parts, &args)?;
105
106    let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
107        parse_git_apply_output(&stdout, &stderr);
108    applied_paths.sort();
109    applied_paths.dedup();
110    skipped_paths.sort();
111    skipped_paths.dedup();
112    conflicted_paths.sort();
113    conflicted_paths.dedup();
114
115    Ok(ApplyGitResult {
116        exit_code: code,
117        applied_paths,
118        skipped_paths,
119        conflicted_paths,
120        stdout,
121        stderr,
122        cmd_for_log,
123    })
124}
125
126fn resolve_git_root(cwd: &Path) -> io::Result<PathBuf> {
127    let out = std::process::Command::new("git")
128        .arg("rev-parse")
129        .arg("--show-toplevel")
130        .current_dir(cwd)
131        .output()?;
132    let code = out.status.code().unwrap_or(-1);
133    if code != 0 {
134        return Err(io::Error::other(format!(
135            "not a git repository (exit {}): {}",
136            code,
137            String::from_utf8_lossy(&out.stderr)
138        )));
139    }
140    let root = String::from_utf8_lossy(&out.stdout).trim().to_string();
141    Ok(PathBuf::from(root))
142}
143
144fn write_temp_patch(diff: &str) -> io::Result<(tempfile::TempDir, PathBuf)> {
145    let dir = tempfile::tempdir()?;
146    let path = dir.path().join("patch.diff");
147    std::fs::write(&path, diff)?;
148    Ok((dir, path))
149}
150
151fn run_git(cwd: &Path, git_cfg: &[String], args: &[String]) -> io::Result<(i32, String, String)> {
152    let mut cmd = std::process::Command::new("git");
153    for p in git_cfg {
154        cmd.arg(p);
155    }
156    for a in args {
157        cmd.arg(a);
158    }
159    let out = cmd.current_dir(cwd).output()?;
160    let code = out.status.code().unwrap_or(-1);
161    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
162    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
163    Ok((code, stdout, stderr))
164}
165
166fn quote_shell(s: &str) -> String {
167    let simple = s
168        .chars()
169        .all(|c| c.is_ascii_alphanumeric() || "-_.:/@%+".contains(c));
170    if simple {
171        s.to_string()
172    } else {
173        format!("'{}'", s.replace('\'', "'\\''"))
174    }
175}
176
177fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> String {
178    let mut parts: Vec<String> = Vec::new();
179    parts.push("git".to_string());
180    for a in git_cfg {
181        parts.push(quote_shell(a));
182    }
183    for a in args {
184        parts.push(quote_shell(a));
185    }
186    format!(
187        "(cd {} && {})",
188        quote_shell(&cwd.display().to_string()),
189        parts.join(" ")
190    )
191}
192
193/// Collect every path referenced by the diff headers inside `diff --git` sections.
194pub fn extract_paths_from_patch(diff_text: &str) -> Vec<String> {
195    let mut set = std::collections::BTreeSet::new();
196    for raw_line in diff_text.lines() {
197        let line = raw_line.trim();
198        let Some(rest) = line.strip_prefix("diff --git ") else {
199            continue;
200        };
201        let Some((a, b)) = parse_diff_git_paths(rest) else {
202            continue;
203        };
204        if let Some(a) = normalize_diff_path(&a, "a/") {
205            set.insert(a);
206        }
207        if let Some(b) = normalize_diff_path(&b, "b/") {
208            set.insert(b);
209        }
210    }
211    set.into_iter().collect()
212}
213
214fn parse_diff_git_paths(line: &str) -> Option<(String, String)> {
215    let mut chars = line.chars().peekable();
216    let first = read_diff_git_token(&mut chars)?;
217    let second = read_diff_git_token(&mut chars)?;
218    Some((first, second))
219}
220
221fn read_diff_git_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Option<String> {
222    while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
223        chars.next();
224    }
225    let quote = match chars.peek().copied() {
226        Some('"') | Some('\'') => chars.next(),
227        _ => None,
228    };
229    let mut out = String::new();
230    while let Some(c) = chars.next() {
231        if let Some(q) = quote {
232            if c == q {
233                break;
234            }
235            if c == '\\' {
236                out.push('\\');
237                if let Some(next) = chars.next() {
238                    out.push(next);
239                }
240                continue;
241            }
242        } else if c.is_whitespace() {
243            break;
244        }
245        out.push(c);
246    }
247    if out.is_empty() && quote.is_none() {
248        None
249    } else {
250        Some(match quote {
251            Some(_) => unescape_c_string(&out),
252            None => out,
253        })
254    }
255}
256
257fn normalize_diff_path(raw: &str, prefix: &str) -> Option<String> {
258    let trimmed = raw.trim();
259    if trimmed.is_empty() {
260        return None;
261    }
262    if trimmed == "/dev/null" || trimmed == format!("{prefix}dev/null") {
263        return None;
264    }
265    let trimmed = trimmed.strip_prefix(prefix).unwrap_or(trimmed);
266    if trimmed.is_empty() {
267        return None;
268    }
269    Some(trimmed.to_string())
270}
271
272fn unescape_c_string(input: &str) -> String {
273    let mut out = String::with_capacity(input.len());
274    let mut chars = input.chars().peekable();
275    while let Some(c) = chars.next() {
276        if c != '\\' {
277            out.push(c);
278            continue;
279        }
280        let Some(next) = chars.next() else {
281            out.push('\\');
282            break;
283        };
284        match next {
285            'n' => out.push('\n'),
286            'r' => out.push('\r'),
287            't' => out.push('\t'),
288            'b' => out.push('\u{0008}'),
289            'f' => out.push('\u{000C}'),
290            'a' => out.push('\u{0007}'),
291            'v' => out.push('\u{000B}'),
292            '\\' => out.push('\\'),
293            '"' => out.push('"'),
294            '\'' => out.push('\''),
295            '0'..='7' => {
296                let mut value = next.to_digit(8).unwrap_or(0);
297                for _ in 0..2 {
298                    match chars.peek() {
299                        Some('0'..='7') => {
300                            if let Some(digit) = chars.next() {
301                                value = value * 8 + digit.to_digit(8).unwrap_or(0);
302                            } else {
303                                break;
304                            }
305                        }
306                        _ => break,
307                    }
308                }
309                if let Some(ch) = std::char::from_u32(value) {
310                    out.push(ch);
311                }
312            }
313            other => out.push(other),
314        }
315    }
316    out
317}
318
319/// Stage only the files that actually exist on disk for the given diff.
320pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
321    let paths = extract_paths_from_patch(diff);
322    let mut existing: Vec<String> = Vec::new();
323    for p in paths {
324        let joined = git_root.join(&p);
325        if std::fs::symlink_metadata(&joined).is_ok() {
326            existing.push(p);
327        }
328    }
329    if existing.is_empty() {
330        return Ok(());
331    }
332    let mut cmd = std::process::Command::new("git");
333    cmd.arg("add");
334    cmd.arg("--");
335    for p in &existing {
336        cmd.arg(OsStr::new(p));
337    }
338    let out = cmd.current_dir(git_root).output()?;
339    let _code = out.status.code().unwrap_or(-1);
340    // We do not hard fail staging; best-effort is OK. Return Ok even on non-zero.
341    Ok(())
342}
343
344// ============ Parser ported from VS Code (TS) ============
345
346/// Parse `git apply` output into applied/skipped/conflicted path groupings.
347pub fn parse_git_apply_output(
348    stdout: &str,
349    stderr: &str,
350) -> (Vec<String>, Vec<String>, Vec<String>) {
351    let combined = [stdout, stderr]
352        .iter()
353        .filter(|s| !s.is_empty())
354        .cloned()
355        .collect::<Vec<&str>>()
356        .join("\n");
357
358    let mut applied = std::collections::BTreeSet::new();
359    let mut skipped = std::collections::BTreeSet::new();
360    let mut conflicted = std::collections::BTreeSet::new();
361    let mut last_seen_path: Option<String> = None;
362
363    fn add(set: &mut std::collections::BTreeSet<String>, raw: &str) {
364        let trimmed = raw.trim();
365        if trimmed.is_empty() {
366            return;
367        }
368        let first = trimmed.chars().next().unwrap_or('\0');
369        let last = trimmed.chars().last().unwrap_or('\0');
370        let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 {
371            unescape_c_string(&trimmed[1..trimmed.len() - 1])
372        } else {
373            trimmed.to_string()
374        };
375        if !unquoted.is_empty() {
376            set.insert(unquoted);
377        }
378    }
379
380    static APPLIED_CLEAN: Lazy<Regex> =
381        Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+cleanly\\.?$"));
382    static APPLIED_CONFLICTS: Lazy<Regex> =
383        Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+with conflicts\\.?$"));
384    static APPLYING_WITH_REJECTS: Lazy<Regex> = Lazy::new(|| {
385        regex_ci("^Applying patch\\s+(?P<path>.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$")
386    });
387    static CHECKING_PATCH: Lazy<Regex> =
388        Lazy::new(|| regex_ci("^Checking patch\\s+(?P<path>.+?)\\.\\.\\.$"));
389    static UNMERGED_LINE: Lazy<Regex> = Lazy::new(|| regex_ci("^U\\s+(?P<path>.+)$"));
390    static PATCH_FAILED: Lazy<Regex> =
391        Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)(?::\\d+)?(?:\\s|$)"));
392    static DOES_NOT_APPLY: Lazy<Regex> =
393        Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+patch does not apply$"));
394    static THREE_WAY_START: Lazy<Regex> = Lazy::new(|| {
395        regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$")
396    });
397    static THREE_WAY_FAILED: Lazy<Regex> =
398        Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$"));
399    static FALLBACK_DIRECT: Lazy<Regex> =
400        Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$"));
401    static LACKS_BLOB: Lazy<Regex> = Lazy::new(|| {
402        regex_ci(
403            "^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$",
404        )
405    });
406    static INDEX_MISMATCH: Lazy<Regex> =
407        Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not match index\\b"));
408    static NOT_IN_INDEX: Lazy<Regex> =
409        Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not exist in index\\b"));
410    static ALREADY_EXISTS_WT: Lazy<Regex> = Lazy::new(|| {
411        regex_ci("^error:\\s+(?P<path>.+?)\\s+already exists in (?:the )?working directory\\b")
412    });
413    static FILE_EXISTS: Lazy<Regex> =
414        Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)\\s+File exists"));
415    static RENAMED_DELETED: Lazy<Regex> =
416        Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P<path>.+?)\\s+has been renamed\\/deleted"));
417    static CANNOT_APPLY_BINARY: Lazy<Regex> = Lazy::new(|| {
418        regex_ci(
419            "^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+without full index line$",
420        )
421    });
422    static BINARY_DOES_NOT_APPLY: Lazy<Regex> = Lazy::new(|| {
423        regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
424    });
425    static BINARY_INCORRECT_RESULT: Lazy<Regex> = Lazy::new(|| {
426        regex_ci(
427            "^error:\\s+binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+creates incorrect result\\b",
428        )
429    });
430    static CANNOT_READ_CURRENT: Lazy<Regex> = Lazy::new(|| {
431        regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
432    });
433    static SKIPPED_PATCH: Lazy<Regex> =
434        Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P<path>.+?)['\\\"]\\.$"));
435    static CANNOT_MERGE_BINARY_WARN: Lazy<Regex> = Lazy::new(|| {
436        regex_ci(
437            "^warning:\\s*Cannot merge binary files:\\s+(?P<path>.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)",
438        )
439    });
440
441    for raw_line in combined.lines() {
442        let line = raw_line.trim();
443        if line.is_empty() {
444            continue;
445        }
446
447        // === "Checking patch <path>..." tracking ===
448        if let Some(c) = CHECKING_PATCH.captures(line) {
449            if let Some(m) = c.name("path") {
450                last_seen_path = Some(m.as_str().to_string());
451            }
452            continue;
453        }
454
455        // === Status lines ===
456        if let Some(c) = APPLIED_CLEAN.captures(line) {
457            if let Some(m) = c.name("path") {
458                add(&mut applied, m.as_str());
459                let p = applied.iter().next_back().cloned();
460                if let Some(p) = p {
461                    conflicted.remove(&p);
462                    skipped.remove(&p);
463                    last_seen_path = Some(p);
464                }
465            }
466            continue;
467        }
468        if let Some(c) = APPLIED_CONFLICTS.captures(line) {
469            if let Some(m) = c.name("path") {
470                add(&mut conflicted, m.as_str());
471                let p = conflicted.iter().next_back().cloned();
472                if let Some(p) = p {
473                    applied.remove(&p);
474                    skipped.remove(&p);
475                    last_seen_path = Some(p);
476                }
477            }
478            continue;
479        }
480        if let Some(c) = APPLYING_WITH_REJECTS.captures(line) {
481            if let Some(m) = c.name("path") {
482                add(&mut conflicted, m.as_str());
483                let p = conflicted.iter().next_back().cloned();
484                if let Some(p) = p {
485                    applied.remove(&p);
486                    skipped.remove(&p);
487                    last_seen_path = Some(p);
488                }
489            }
490            continue;
491        }
492
493        // === “U <path>” after conflicts ===
494        if let Some(c) = UNMERGED_LINE.captures(line) {
495            if let Some(m) = c.name("path") {
496                add(&mut conflicted, m.as_str());
497                let p = conflicted.iter().next_back().cloned();
498                if let Some(p) = p {
499                    applied.remove(&p);
500                    skipped.remove(&p);
501                    last_seen_path = Some(p);
502                }
503            }
504            continue;
505        }
506
507        // === Early hints ===
508        if PATCH_FAILED.is_match(line) || DOES_NOT_APPLY.is_match(line) {
509            if let Some(c) = PATCH_FAILED
510                .captures(line)
511                .or_else(|| DOES_NOT_APPLY.captures(line))
512                && let Some(m) = c.name("path")
513            {
514                add(&mut skipped, m.as_str());
515                last_seen_path = Some(m.as_str().to_string());
516            }
517            continue;
518        }
519
520        // === Ignore narration ===
521        if THREE_WAY_START.is_match(line) || FALLBACK_DIRECT.is_match(line) {
522            continue;
523        }
524
525        // === 3-way failed entirely; attribute to last_seen_path ===
526        if THREE_WAY_FAILED.is_match(line) || LACKS_BLOB.is_match(line) {
527            if let Some(p) = last_seen_path.clone() {
528                add(&mut skipped, &p);
529                applied.remove(&p);
530                conflicted.remove(&p);
531            }
532            continue;
533        }
534
535        // === Skips / I/O problems ===
536        if let Some(c) = INDEX_MISMATCH
537            .captures(line)
538            .or_else(|| NOT_IN_INDEX.captures(line))
539            .or_else(|| ALREADY_EXISTS_WT.captures(line))
540            .or_else(|| FILE_EXISTS.captures(line))
541            .or_else(|| RENAMED_DELETED.captures(line))
542            .or_else(|| CANNOT_APPLY_BINARY.captures(line))
543            .or_else(|| BINARY_DOES_NOT_APPLY.captures(line))
544            .or_else(|| BINARY_INCORRECT_RESULT.captures(line))
545            .or_else(|| CANNOT_READ_CURRENT.captures(line))
546            .or_else(|| SKIPPED_PATCH.captures(line))
547        {
548            if let Some(m) = c.name("path") {
549                add(&mut skipped, m.as_str());
550                let p_now = skipped.iter().next_back().cloned();
551                if let Some(p) = p_now {
552                    applied.remove(&p);
553                    conflicted.remove(&p);
554                    last_seen_path = Some(p);
555                }
556            }
557            continue;
558        }
559
560        // === Warnings that imply conflicts ===
561        if let Some(c) = CANNOT_MERGE_BINARY_WARN.captures(line) {
562            if let Some(m) = c.name("path") {
563                add(&mut conflicted, m.as_str());
564                let p = conflicted.iter().next_back().cloned();
565                if let Some(p) = p {
566                    applied.remove(&p);
567                    skipped.remove(&p);
568                    last_seen_path = Some(p);
569                }
570            }
571            continue;
572        }
573    }
574
575    // Final precedence: conflicts > applied > skipped
576    for p in conflicted.iter() {
577        applied.remove(p);
578        skipped.remove(p);
579    }
580    for p in applied.iter() {
581        skipped.remove(p);
582    }
583
584    (
585        applied.into_iter().collect(),
586        skipped.into_iter().collect(),
587        conflicted.into_iter().collect(),
588    )
589}
590
591fn regex_ci(pat: &str) -> Regex {
592    Regex::new(&format!("(?i){pat}")).unwrap_or_else(|e| panic!("invalid regex: {e}"))
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use std::path::Path;
599    use std::sync::Mutex;
600    use std::sync::OnceLock;
601
602    fn env_lock() -> &'static Mutex<()> {
603        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
604        LOCK.get_or_init(|| Mutex::new(()))
605    }
606
607    fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
608        let out = std::process::Command::new(args[0])
609            .args(&args[1..])
610            .current_dir(cwd)
611            .output()
612            .expect("spawn ok");
613        (
614            out.status.code().unwrap_or(-1),
615            String::from_utf8_lossy(&out.stdout).into_owned(),
616            String::from_utf8_lossy(&out.stderr).into_owned(),
617        )
618    }
619
620    fn init_repo() -> tempfile::TempDir {
621        let dir = tempfile::tempdir().expect("tempdir");
622        let root = dir.path();
623        // git init and minimal identity
624        let _ = run(root, &["git", "init"]);
625        let _ = run(root, &["git", "config", "user.email", "codex@example.com"]);
626        let _ = run(root, &["git", "config", "user.name", "Codex"]);
627        dir
628    }
629
630    fn read_file_normalized(path: &Path) -> String {
631        std::fs::read_to_string(path)
632            .expect("read file")
633            .replace("\r\n", "\n")
634    }
635
636    #[test]
637    fn extract_paths_handles_quoted_headers() {
638        let diff = "diff --git \"a/hello world.txt\" \"b/hello world.txt\"\nnew file mode 100644\n--- /dev/null\n+++ b/hello world.txt\n@@ -0,0 +1 @@\n+hi\n";
639        let paths = extract_paths_from_patch(diff);
640        assert_eq!(paths, vec!["hello world.txt".to_string()]);
641    }
642
643    #[test]
644    fn extract_paths_ignores_dev_null_header() {
645        let diff = "diff --git a/dev/null b/ok.txt\nnew file mode 100644\n--- /dev/null\n+++ b/ok.txt\n@@ -0,0 +1 @@\n+hi\n";
646        let paths = extract_paths_from_patch(diff);
647        assert_eq!(paths, vec!["ok.txt".to_string()]);
648    }
649
650    #[test]
651    fn extract_paths_unescapes_c_style_in_quoted_headers() {
652        let diff = "diff --git \"a/hello\\tworld.txt\" \"b/hello\\tworld.txt\"\nnew file mode 100644\n--- /dev/null\n+++ b/hello\tworld.txt\n@@ -0,0 +1 @@\n+hi\n";
653        let paths = extract_paths_from_patch(diff);
654        assert_eq!(paths, vec!["hello\tworld.txt".to_string()]);
655    }
656
657    #[test]
658    fn parse_output_unescapes_quoted_paths() {
659        let stderr = "error: patch failed: \"hello\\tworld.txt\":1\n";
660        let (applied, skipped, conflicted) = parse_git_apply_output("", stderr);
661        assert_eq!(applied, Vec::<String>::new());
662        assert_eq!(conflicted, Vec::<String>::new());
663        assert_eq!(skipped, vec!["hello\tworld.txt".to_string()]);
664    }
665
666    #[test]
667    fn apply_add_success() {
668        let _g = env_lock().lock().unwrap();
669        let repo = init_repo();
670        let root = repo.path();
671
672        let diff = "diff --git a/hello.txt b/hello.txt\nnew file mode 100644\n--- /dev/null\n+++ b/hello.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n";
673        let req = ApplyGitRequest {
674            cwd: root.to_path_buf(),
675            diff: diff.to_string(),
676            revert: false,
677            preflight: false,
678        };
679        let r = apply_git_patch(&req).expect("run apply");
680        assert_eq!(r.exit_code, 0, "exit code 0");
681        // File exists now
682        assert!(root.join("hello.txt").exists());
683    }
684
685    #[test]
686    fn apply_modify_conflict() {
687        let _g = env_lock().lock().unwrap();
688        let repo = init_repo();
689        let root = repo.path();
690        // seed file and commit
691        std::fs::write(root.join("file.txt"), "line1\nline2\nline3\n").unwrap();
692        let _ = run(root, &["git", "add", "file.txt"]);
693        let _ = run(root, &["git", "commit", "-m", "seed"]);
694        // local edit (unstaged)
695        std::fs::write(root.join("file.txt"), "line1\nlocal2\nline3\n").unwrap();
696        // patch wants to change the same line differently
697        let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+remote2\n line3\n";
698        let req = ApplyGitRequest {
699            cwd: root.to_path_buf(),
700            diff: diff.to_string(),
701            revert: false,
702            preflight: false,
703        };
704        let r = apply_git_patch(&req).expect("run apply");
705        assert_ne!(r.exit_code, 0, "non-zero exit on conflict");
706    }
707
708    #[test]
709    fn apply_modify_skipped_missing_index() {
710        let _g = env_lock().lock().unwrap();
711        let repo = init_repo();
712        let root = repo.path();
713        // Try to modify a file that is not in the index
714        let diff = "diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
715        let req = ApplyGitRequest {
716            cwd: root.to_path_buf(),
717            diff: diff.to_string(),
718            revert: false,
719            preflight: false,
720        };
721        let r = apply_git_patch(&req).expect("run apply");
722        assert_ne!(r.exit_code, 0, "non-zero exit on missing index");
723    }
724
725    #[test]
726    fn apply_then_revert_success() {
727        let _g = env_lock().lock().unwrap();
728        let repo = init_repo();
729        let root = repo.path();
730        // Seed file and commit original content
731        std::fs::write(root.join("file.txt"), "orig\n").unwrap();
732        let _ = run(root, &["git", "add", "file.txt"]);
733        let _ = run(root, &["git", "commit", "-m", "seed"]);
734
735        // Forward patch: orig -> ORIG
736        let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n";
737        let apply_req = ApplyGitRequest {
738            cwd: root.to_path_buf(),
739            diff: diff.to_string(),
740            revert: false,
741            preflight: false,
742        };
743        let res_apply = apply_git_patch(&apply_req).expect("apply ok");
744        assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
745        let after_apply = read_file_normalized(&root.join("file.txt"));
746        assert_eq!(after_apply, "ORIG\n");
747
748        // Revert patch: ORIG -> orig (stage paths first; engine handles it)
749        let revert_req = ApplyGitRequest {
750            cwd: root.to_path_buf(),
751            diff: diff.to_string(),
752            revert: true,
753            preflight: false,
754        };
755        let res_revert = apply_git_patch(&revert_req).expect("revert ok");
756        assert_eq!(res_revert.exit_code, 0, "revert apply succeeded");
757        let after_revert = read_file_normalized(&root.join("file.txt"));
758        assert_eq!(after_revert, "orig\n");
759    }
760
761    #[test]
762    fn revert_preflight_does_not_stage_index() {
763        let _g = env_lock().lock().unwrap();
764        let repo = init_repo();
765        let root = repo.path();
766        // Seed repo and apply forward patch so the working tree reflects the change.
767        std::fs::write(root.join("file.txt"), "orig\n").unwrap();
768        let _ = run(root, &["git", "add", "file.txt"]);
769        let _ = run(root, &["git", "commit", "-m", "seed"]);
770
771        let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n";
772        let apply_req = ApplyGitRequest {
773            cwd: root.to_path_buf(),
774            diff: diff.to_string(),
775            revert: false,
776            preflight: false,
777        };
778        let res_apply = apply_git_patch(&apply_req).expect("apply ok");
779        assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
780        let (commit_code, _, commit_err) = run(root, &["git", "commit", "-am", "apply change"]);
781        assert_eq!(commit_code, 0, "commit applied change: {commit_err}");
782
783        let (_code_before, staged_before, _stderr_before) =
784            run(root, &["git", "diff", "--cached", "--name-only"]);
785
786        let preflight_req = ApplyGitRequest {
787            cwd: root.to_path_buf(),
788            diff: diff.to_string(),
789            revert: true,
790            preflight: true,
791        };
792        let res_preflight = apply_git_patch(&preflight_req).expect("preflight ok");
793        assert_eq!(res_preflight.exit_code, 0, "revert preflight succeeded");
794        let (_code_after, staged_after, _stderr_after) =
795            run(root, &["git", "diff", "--cached", "--name-only"]);
796        assert_eq!(
797            staged_after.trim(),
798            staged_before.trim(),
799            "preflight should not stage new paths",
800        );
801
802        let after_preflight = read_file_normalized(&root.join("file.txt"));
803        assert_eq!(after_preflight, "ORIG\n");
804    }
805
806    #[test]
807    fn preflight_blocks_partial_changes() {
808        let _g = env_lock().lock().unwrap();
809        let repo = init_repo();
810        let root = repo.path();
811        // Build a multi-file diff: one valid add (ok.txt) and one invalid modify (ghost.txt)
812        let diff = "diff --git a/ok.txt b/ok.txt\nnew file mode 100644\n--- /dev/null\n+++ b/ok.txt\n@@ -0,0 +1,2 @@\n+alpha\n+beta\n\n\
813diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
814
815        // 1) With preflight enabled, nothing should be changed (even though ok.txt could be added)
816        let req1 = ApplyGitRequest {
817            cwd: root.to_path_buf(),
818            diff: diff.to_string(),
819            revert: false,
820            preflight: true,
821        };
822        let r1 = apply_git_patch(&req1).expect("preflight apply");
823        assert_ne!(r1.exit_code, 0, "preflight reports failure");
824        assert!(
825            !root.join("ok.txt").exists(),
826            "preflight must prevent adding ok.txt"
827        );
828        assert!(
829            r1.cmd_for_log.contains("--check"),
830            "preflight path recorded --check"
831        );
832
833        // 2) Without preflight, we should see no --check in the executed command
834        let req2 = ApplyGitRequest {
835            cwd: root.to_path_buf(),
836            diff: diff.to_string(),
837            revert: false,
838            preflight: false,
839        };
840        let r2 = apply_git_patch(&req2).expect("direct apply");
841        assert_ne!(r2.exit_code, 0, "apply is expected to fail overall");
842        assert!(
843            !r2.cmd_for_log.contains("--check"),
844            "non-preflight path should not use --check"
845        );
846    }
847}