codex_git/
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    static RE: Lazy<Regex> = Lazy::new(|| {
196        Regex::new(r"(?m)^diff --git a/(.*?) b/(.*)$")
197            .unwrap_or_else(|e| panic!("invalid regex: {e}"))
198    });
199    let mut set = std::collections::BTreeSet::new();
200    for caps in RE.captures_iter(diff_text) {
201        if let Some(a) = caps.get(1).map(|m| m.as_str())
202            && a != "/dev/null"
203            && !a.trim().is_empty()
204        {
205            set.insert(a.to_string());
206        }
207        if let Some(b) = caps.get(2).map(|m| m.as_str())
208            && b != "/dev/null"
209            && !b.trim().is_empty()
210        {
211            set.insert(b.to_string());
212        }
213    }
214    set.into_iter().collect()
215}
216
217/// Stage only the files that actually exist on disk for the given diff.
218pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
219    let paths = extract_paths_from_patch(diff);
220    let mut existing: Vec<String> = Vec::new();
221    for p in paths {
222        let joined = git_root.join(&p);
223        if std::fs::symlink_metadata(&joined).is_ok() {
224            existing.push(p);
225        }
226    }
227    if existing.is_empty() {
228        return Ok(());
229    }
230    let mut cmd = std::process::Command::new("git");
231    cmd.arg("add");
232    cmd.arg("--");
233    for p in &existing {
234        cmd.arg(OsStr::new(p));
235    }
236    let out = cmd.current_dir(git_root).output()?;
237    let _code = out.status.code().unwrap_or(-1);
238    // We do not hard fail staging; best-effort is OK. Return Ok even on non-zero.
239    Ok(())
240}
241
242// ============ Parser ported from VS Code (TS) ============
243
244/// Parse `git apply` output into applied/skipped/conflicted path groupings.
245pub fn parse_git_apply_output(
246    stdout: &str,
247    stderr: &str,
248) -> (Vec<String>, Vec<String>, Vec<String>) {
249    let combined = [stdout, stderr]
250        .iter()
251        .filter(|s| !s.is_empty())
252        .cloned()
253        .collect::<Vec<&str>>()
254        .join("\n");
255
256    let mut applied = std::collections::BTreeSet::new();
257    let mut skipped = std::collections::BTreeSet::new();
258    let mut conflicted = std::collections::BTreeSet::new();
259    let mut last_seen_path: Option<String> = None;
260
261    fn add(set: &mut std::collections::BTreeSet<String>, raw: &str) {
262        let trimmed = raw.trim();
263        if trimmed.is_empty() {
264            return;
265        }
266        let first = trimmed.chars().next().unwrap_or('\0');
267        let last = trimmed.chars().last().unwrap_or('\0');
268        let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 {
269            &trimmed[1..trimmed.len() - 1]
270        } else {
271            trimmed
272        };
273        if !unquoted.is_empty() {
274            set.insert(unquoted.to_string());
275        }
276    }
277
278    static APPLIED_CLEAN: Lazy<Regex> =
279        Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+cleanly\\.?$"));
280    static APPLIED_CONFLICTS: Lazy<Regex> =
281        Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+with conflicts\\.?$"));
282    static APPLYING_WITH_REJECTS: Lazy<Regex> = Lazy::new(|| {
283        regex_ci("^Applying patch\\s+(?P<path>.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$")
284    });
285    static CHECKING_PATCH: Lazy<Regex> =
286        Lazy::new(|| regex_ci("^Checking patch\\s+(?P<path>.+?)\\.\\.\\.$"));
287    static UNMERGED_LINE: Lazy<Regex> = Lazy::new(|| regex_ci("^U\\s+(?P<path>.+)$"));
288    static PATCH_FAILED: Lazy<Regex> =
289        Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)(?::\\d+)?(?:\\s|$)"));
290    static DOES_NOT_APPLY: Lazy<Regex> =
291        Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+patch does not apply$"));
292    static THREE_WAY_START: Lazy<Regex> = Lazy::new(|| {
293        regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$")
294    });
295    static THREE_WAY_FAILED: Lazy<Regex> =
296        Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$"));
297    static FALLBACK_DIRECT: Lazy<Regex> =
298        Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$"));
299    static LACKS_BLOB: Lazy<Regex> = Lazy::new(|| {
300        regex_ci(
301            "^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$",
302        )
303    });
304    static INDEX_MISMATCH: Lazy<Regex> =
305        Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not match index\\b"));
306    static NOT_IN_INDEX: Lazy<Regex> =
307        Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not exist in index\\b"));
308    static ALREADY_EXISTS_WT: Lazy<Regex> = Lazy::new(|| {
309        regex_ci("^error:\\s+(?P<path>.+?)\\s+already exists in (?:the )?working directory\\b")
310    });
311    static FILE_EXISTS: Lazy<Regex> =
312        Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)\\s+File exists"));
313    static RENAMED_DELETED: Lazy<Regex> =
314        Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P<path>.+?)\\s+has been renamed\\/deleted"));
315    static CANNOT_APPLY_BINARY: Lazy<Regex> = Lazy::new(|| {
316        regex_ci(
317            "^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+without full index line$",
318        )
319    });
320    static BINARY_DOES_NOT_APPLY: Lazy<Regex> = Lazy::new(|| {
321        regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
322    });
323    static BINARY_INCORRECT_RESULT: Lazy<Regex> = Lazy::new(|| {
324        regex_ci(
325            "^error:\\s+binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+creates incorrect result\\b",
326        )
327    });
328    static CANNOT_READ_CURRENT: Lazy<Regex> = Lazy::new(|| {
329        regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
330    });
331    static SKIPPED_PATCH: Lazy<Regex> =
332        Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P<path>.+?)['\\\"]\\.$"));
333    static CANNOT_MERGE_BINARY_WARN: Lazy<Regex> = Lazy::new(|| {
334        regex_ci(
335            "^warning:\\s*Cannot merge binary files:\\s+(?P<path>.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)",
336        )
337    });
338
339    for raw_line in combined.lines() {
340        let line = raw_line.trim();
341        if line.is_empty() {
342            continue;
343        }
344
345        // === "Checking patch <path>..." tracking ===
346        if let Some(c) = CHECKING_PATCH.captures(line) {
347            if let Some(m) = c.name("path") {
348                last_seen_path = Some(m.as_str().to_string());
349            }
350            continue;
351        }
352
353        // === Status lines ===
354        if let Some(c) = APPLIED_CLEAN.captures(line) {
355            if let Some(m) = c.name("path") {
356                add(&mut applied, m.as_str());
357                let p = applied.iter().next_back().cloned();
358                if let Some(p) = p {
359                    conflicted.remove(&p);
360                    skipped.remove(&p);
361                    last_seen_path = Some(p);
362                }
363            }
364            continue;
365        }
366        if let Some(c) = APPLIED_CONFLICTS.captures(line) {
367            if let Some(m) = c.name("path") {
368                add(&mut conflicted, m.as_str());
369                let p = conflicted.iter().next_back().cloned();
370                if let Some(p) = p {
371                    applied.remove(&p);
372                    skipped.remove(&p);
373                    last_seen_path = Some(p);
374                }
375            }
376            continue;
377        }
378        if let Some(c) = APPLYING_WITH_REJECTS.captures(line) {
379            if let Some(m) = c.name("path") {
380                add(&mut conflicted, m.as_str());
381                let p = conflicted.iter().next_back().cloned();
382                if let Some(p) = p {
383                    applied.remove(&p);
384                    skipped.remove(&p);
385                    last_seen_path = Some(p);
386                }
387            }
388            continue;
389        }
390
391        // === “U <path>” after conflicts ===
392        if let Some(c) = UNMERGED_LINE.captures(line) {
393            if let Some(m) = c.name("path") {
394                add(&mut conflicted, m.as_str());
395                let p = conflicted.iter().next_back().cloned();
396                if let Some(p) = p {
397                    applied.remove(&p);
398                    skipped.remove(&p);
399                    last_seen_path = Some(p);
400                }
401            }
402            continue;
403        }
404
405        // === Early hints ===
406        if PATCH_FAILED.is_match(line) || DOES_NOT_APPLY.is_match(line) {
407            if let Some(c) = PATCH_FAILED
408                .captures(line)
409                .or_else(|| DOES_NOT_APPLY.captures(line))
410                && let Some(m) = c.name("path")
411            {
412                add(&mut skipped, m.as_str());
413                last_seen_path = Some(m.as_str().to_string());
414            }
415            continue;
416        }
417
418        // === Ignore narration ===
419        if THREE_WAY_START.is_match(line) || FALLBACK_DIRECT.is_match(line) {
420            continue;
421        }
422
423        // === 3-way failed entirely; attribute to last_seen_path ===
424        if THREE_WAY_FAILED.is_match(line) || LACKS_BLOB.is_match(line) {
425            if let Some(p) = last_seen_path.clone() {
426                add(&mut skipped, &p);
427                applied.remove(&p);
428                conflicted.remove(&p);
429            }
430            continue;
431        }
432
433        // === Skips / I/O problems ===
434        if let Some(c) = INDEX_MISMATCH
435            .captures(line)
436            .or_else(|| NOT_IN_INDEX.captures(line))
437            .or_else(|| ALREADY_EXISTS_WT.captures(line))
438            .or_else(|| FILE_EXISTS.captures(line))
439            .or_else(|| RENAMED_DELETED.captures(line))
440            .or_else(|| CANNOT_APPLY_BINARY.captures(line))
441            .or_else(|| BINARY_DOES_NOT_APPLY.captures(line))
442            .or_else(|| BINARY_INCORRECT_RESULT.captures(line))
443            .or_else(|| CANNOT_READ_CURRENT.captures(line))
444            .or_else(|| SKIPPED_PATCH.captures(line))
445        {
446            if let Some(m) = c.name("path") {
447                add(&mut skipped, m.as_str());
448                let p_now = skipped.iter().next_back().cloned();
449                if let Some(p) = p_now {
450                    applied.remove(&p);
451                    conflicted.remove(&p);
452                    last_seen_path = Some(p);
453                }
454            }
455            continue;
456        }
457
458        // === Warnings that imply conflicts ===
459        if let Some(c) = CANNOT_MERGE_BINARY_WARN.captures(line) {
460            if let Some(m) = c.name("path") {
461                add(&mut conflicted, m.as_str());
462                let p = conflicted.iter().next_back().cloned();
463                if let Some(p) = p {
464                    applied.remove(&p);
465                    skipped.remove(&p);
466                    last_seen_path = Some(p);
467                }
468            }
469            continue;
470        }
471    }
472
473    // Final precedence: conflicts > applied > skipped
474    for p in conflicted.iter() {
475        applied.remove(p);
476        skipped.remove(p);
477    }
478    for p in applied.iter() {
479        skipped.remove(p);
480    }
481
482    (
483        applied.into_iter().collect(),
484        skipped.into_iter().collect(),
485        conflicted.into_iter().collect(),
486    )
487}
488
489fn regex_ci(pat: &str) -> Regex {
490    Regex::new(&format!("(?i){pat}")).unwrap_or_else(|e| panic!("invalid regex: {e}"))
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use std::path::Path;
497    use std::sync::Mutex;
498    use std::sync::OnceLock;
499
500    fn env_lock() -> &'static Mutex<()> {
501        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
502        LOCK.get_or_init(|| Mutex::new(()))
503    }
504
505    fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
506        let out = std::process::Command::new(args[0])
507            .args(&args[1..])
508            .current_dir(cwd)
509            .output()
510            .expect("spawn ok");
511        (
512            out.status.code().unwrap_or(-1),
513            String::from_utf8_lossy(&out.stdout).into_owned(),
514            String::from_utf8_lossy(&out.stderr).into_owned(),
515        )
516    }
517
518    fn init_repo() -> tempfile::TempDir {
519        let dir = tempfile::tempdir().expect("tempdir");
520        let root = dir.path();
521        // git init and minimal identity
522        let _ = run(root, &["git", "init"]);
523        let _ = run(root, &["git", "config", "user.email", "codex@example.com"]);
524        let _ = run(root, &["git", "config", "user.name", "Codex"]);
525        dir
526    }
527
528    fn read_file_normalized(path: &Path) -> String {
529        std::fs::read_to_string(path)
530            .expect("read file")
531            .replace("\r\n", "\n")
532    }
533
534    #[test]
535    fn apply_add_success() {
536        let _g = env_lock().lock().unwrap();
537        let repo = init_repo();
538        let root = repo.path();
539
540        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";
541        let req = ApplyGitRequest {
542            cwd: root.to_path_buf(),
543            diff: diff.to_string(),
544            revert: false,
545            preflight: false,
546        };
547        let r = apply_git_patch(&req).expect("run apply");
548        assert_eq!(r.exit_code, 0, "exit code 0");
549        // File exists now
550        assert!(root.join("hello.txt").exists());
551    }
552
553    #[test]
554    fn apply_modify_conflict() {
555        let _g = env_lock().lock().unwrap();
556        let repo = init_repo();
557        let root = repo.path();
558        // seed file and commit
559        std::fs::write(root.join("file.txt"), "line1\nline2\nline3\n").unwrap();
560        let _ = run(root, &["git", "add", "file.txt"]);
561        let _ = run(root, &["git", "commit", "-m", "seed"]);
562        // local edit (unstaged)
563        std::fs::write(root.join("file.txt"), "line1\nlocal2\nline3\n").unwrap();
564        // patch wants to change the same line differently
565        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";
566        let req = ApplyGitRequest {
567            cwd: root.to_path_buf(),
568            diff: diff.to_string(),
569            revert: false,
570            preflight: false,
571        };
572        let r = apply_git_patch(&req).expect("run apply");
573        assert_ne!(r.exit_code, 0, "non-zero exit on conflict");
574    }
575
576    #[test]
577    fn apply_modify_skipped_missing_index() {
578        let _g = env_lock().lock().unwrap();
579        let repo = init_repo();
580        let root = repo.path();
581        // Try to modify a file that is not in the index
582        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";
583        let req = ApplyGitRequest {
584            cwd: root.to_path_buf(),
585            diff: diff.to_string(),
586            revert: false,
587            preflight: false,
588        };
589        let r = apply_git_patch(&req).expect("run apply");
590        assert_ne!(r.exit_code, 0, "non-zero exit on missing index");
591    }
592
593    #[test]
594    fn apply_then_revert_success() {
595        let _g = env_lock().lock().unwrap();
596        let repo = init_repo();
597        let root = repo.path();
598        // Seed file and commit original content
599        std::fs::write(root.join("file.txt"), "orig\n").unwrap();
600        let _ = run(root, &["git", "add", "file.txt"]);
601        let _ = run(root, &["git", "commit", "-m", "seed"]);
602
603        // Forward patch: orig -> ORIG
604        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";
605        let apply_req = ApplyGitRequest {
606            cwd: root.to_path_buf(),
607            diff: diff.to_string(),
608            revert: false,
609            preflight: false,
610        };
611        let res_apply = apply_git_patch(&apply_req).expect("apply ok");
612        assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
613        let after_apply = read_file_normalized(&root.join("file.txt"));
614        assert_eq!(after_apply, "ORIG\n");
615
616        // Revert patch: ORIG -> orig (stage paths first; engine handles it)
617        let revert_req = ApplyGitRequest {
618            cwd: root.to_path_buf(),
619            diff: diff.to_string(),
620            revert: true,
621            preflight: false,
622        };
623        let res_revert = apply_git_patch(&revert_req).expect("revert ok");
624        assert_eq!(res_revert.exit_code, 0, "revert apply succeeded");
625        let after_revert = read_file_normalized(&root.join("file.txt"));
626        assert_eq!(after_revert, "orig\n");
627    }
628
629    #[test]
630    fn revert_preflight_does_not_stage_index() {
631        let _g = env_lock().lock().unwrap();
632        let repo = init_repo();
633        let root = repo.path();
634        // Seed repo and apply forward patch so the working tree reflects the change.
635        std::fs::write(root.join("file.txt"), "orig\n").unwrap();
636        let _ = run(root, &["git", "add", "file.txt"]);
637        let _ = run(root, &["git", "commit", "-m", "seed"]);
638
639        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";
640        let apply_req = ApplyGitRequest {
641            cwd: root.to_path_buf(),
642            diff: diff.to_string(),
643            revert: false,
644            preflight: false,
645        };
646        let res_apply = apply_git_patch(&apply_req).expect("apply ok");
647        assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
648        let (commit_code, _, commit_err) = run(root, &["git", "commit", "-am", "apply change"]);
649        assert_eq!(commit_code, 0, "commit applied change: {commit_err}");
650
651        let (_code_before, staged_before, _stderr_before) =
652            run(root, &["git", "diff", "--cached", "--name-only"]);
653
654        let preflight_req = ApplyGitRequest {
655            cwd: root.to_path_buf(),
656            diff: diff.to_string(),
657            revert: true,
658            preflight: true,
659        };
660        let res_preflight = apply_git_patch(&preflight_req).expect("preflight ok");
661        assert_eq!(res_preflight.exit_code, 0, "revert preflight succeeded");
662        let (_code_after, staged_after, _stderr_after) =
663            run(root, &["git", "diff", "--cached", "--name-only"]);
664        assert_eq!(
665            staged_after.trim(),
666            staged_before.trim(),
667            "preflight should not stage new paths",
668        );
669
670        let after_preflight = read_file_normalized(&root.join("file.txt"));
671        assert_eq!(after_preflight, "ORIG\n");
672    }
673
674    #[test]
675    fn preflight_blocks_partial_changes() {
676        let _g = env_lock().lock().unwrap();
677        let repo = init_repo();
678        let root = repo.path();
679        // Build a multi-file diff: one valid add (ok.txt) and one invalid modify (ghost.txt)
680        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\
681diff --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";
682
683        // 1) With preflight enabled, nothing should be changed (even though ok.txt could be added)
684        let req1 = ApplyGitRequest {
685            cwd: root.to_path_buf(),
686            diff: diff.to_string(),
687            revert: false,
688            preflight: true,
689        };
690        let r1 = apply_git_patch(&req1).expect("preflight apply");
691        assert_ne!(r1.exit_code, 0, "preflight reports failure");
692        assert!(
693            !root.join("ok.txt").exists(),
694            "preflight must prevent adding ok.txt"
695        );
696        assert!(
697            r1.cmd_for_log.contains("--check"),
698            "preflight path recorded --check"
699        );
700
701        // 2) Without preflight, we should see no --check in the executed command
702        let req2 = ApplyGitRequest {
703            cwd: root.to_path_buf(),
704            diff: diff.to_string(),
705            revert: false,
706            preflight: false,
707        };
708        let r2 = apply_git_patch(&req2).expect("direct apply");
709        assert_ne!(r2.exit_code, 0, "apply is expected to fail overall");
710        assert!(
711            !r2.cmd_for_log.contains("--check"),
712            "non-preflight path should not use --check"
713        );
714    }
715}