use once_cell::sync::Lazy; use regex::Regex; use std::ffi::OsStr; use std::io; use std::path::Path; use std::path::PathBuf; #[derive(Debug, Clone)] pub struct ApplyGitRequest { pub cwd: PathBuf, pub diff: String, pub revert: bool, pub preflight: bool, } #[derive(Debug, Clone)] pub struct ApplyGitResult { pub exit_code: i32, pub applied_paths: Vec, pub skipped_paths: Vec, pub conflicted_paths: Vec, pub stdout: String, pub stderr: String, pub cmd_for_log: String, } pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result { let git_root = resolve_git_root(&req.cwd)?; // Write unified diff into a temporary file let (tmpdir, patch_path) = write_temp_patch(&req.diff)?; // Keep tmpdir alive until function end to ensure the file exists let _guard = tmpdir; if req.revert && !req.preflight { // Stage WT paths first to avoid index mismatch on revert. stage_paths(&git_root, &req.diff)?; } // Build git args let mut args: Vec = vec!["apply".into(), "--3way".into()]; if req.revert { args.push("-R".into()); } // Optional: additional git config via env knob (defaults OFF) let mut cfg_parts: Vec = Vec::new(); if let Ok(cfg) = std::env::var("CODEX_APPLY_GIT_CFG") { for pair in cfg.split(',') { let p = pair.trim(); if p.is_empty() || !p.contains('=') { continue; } cfg_parts.push("-c".into()); cfg_parts.push(p.to_string()); } } args.push(patch_path.to_string_lossy().to_string()); // Optional preflight: dry-run only; do not modify working tree if req.preflight { let mut check_args = vec!["apply".to_string(), "--check".to_string()]; if req.revert { check_args.push("-R".to_string()); } check_args.push(patch_path.to_string_lossy().to_string()); let rendered = render_command_for_log(&git_root, &cfg_parts, &check_args); let (c_code, c_out, c_err) = run_git(&git_root, &cfg_parts, &check_args)?; let (mut applied_paths, mut skipped_paths, mut conflicted_paths) = parse_git_apply_output(&c_out, &c_err); applied_paths.sort(); applied_paths.dedup(); skipped_paths.sort(); skipped_paths.dedup(); conflicted_paths.sort(); conflicted_paths.dedup(); return Ok(ApplyGitResult { exit_code: c_code, applied_paths, skipped_paths, conflicted_paths, stdout: c_out, stderr: c_err, cmd_for_log: rendered, }); } let cmd_for_log = render_command_for_log(&git_root, &cfg_parts, &args); let (code, stdout, stderr) = run_git(&git_root, &cfg_parts, &args)?; let (mut applied_paths, mut skipped_paths, mut conflicted_paths) = parse_git_apply_output(&stdout, &stderr); applied_paths.sort(); applied_paths.dedup(); skipped_paths.sort(); skipped_paths.dedup(); conflicted_paths.sort(); conflicted_paths.dedup(); Ok(ApplyGitResult { exit_code: code, applied_paths, skipped_paths, conflicted_paths, stdout, stderr, cmd_for_log, }) } fn resolve_git_root(cwd: &Path) -> io::Result { let out = std::process::Command::new("git") .arg("rev-parse") .arg("--show-toplevel") .current_dir(cwd) .output()?; let code = out.status.code().unwrap_or(-1); if code != 0 { return Err(io::Error::other(format!( "not a git repository (exit {}): {}", code, String::from_utf8_lossy(&out.stderr) ))); } let root = String::from_utf8_lossy(&out.stdout).trim().to_string(); Ok(PathBuf::from(root)) } fn write_temp_patch(diff: &str) -> io::Result<(tempfile::TempDir, PathBuf)> { let dir = tempfile::tempdir()?; let path = dir.path().join("patch.diff"); std::fs::write(&path, diff)?; Ok((dir, path)) } fn run_git(cwd: &Path, git_cfg: &[String], args: &[String]) -> io::Result<(i32, String, String)> { let mut cmd = std::process::Command::new("git"); for p in git_cfg { cmd.arg(p); } for a in args { cmd.arg(a); } let out = cmd.current_dir(cwd).output()?; let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); Ok((code, stdout, stderr)) } fn quote_shell(s: &str) -> String { let simple = s .chars() .all(|c| c.is_ascii_alphanumeric() || "-_.:/@%+".contains(c)); if simple { s.to_string() } else { format!("'{}'", s.replace('\'', "'\\''")) } } fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> String { let mut parts: Vec = Vec::new(); parts.push("git".to_string()); for a in git_cfg { parts.push(quote_shell(a)); } for a in args { parts.push(quote_shell(a)); } format!( "(cd {} && {})", quote_shell(&cwd.display().to_string()), parts.join(" ") ) } pub fn extract_paths_from_patch(diff_text: &str) -> Vec { static RE: Lazy = Lazy::new(|| { Regex::new(r"(?m)^diff --git a/(.*?) b/(.*)$") .unwrap_or_else(|e| panic!("invalid regex: {e}")) }); let mut set = std::collections::BTreeSet::new(); for caps in RE.captures_iter(diff_text) { if let Some(a) = caps.get(1).map(|m| m.as_str()) && a != "/dev/null" && !a.trim().is_empty() { set.insert(a.to_string()); } if let Some(b) = caps.get(2).map(|m| m.as_str()) && b != "/dev/null" && !b.trim().is_empty() { set.insert(b.to_string()); } } set.into_iter().collect() } pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> { let paths = extract_paths_from_patch(diff); let mut existing: Vec = Vec::new(); for p in paths { let joined = git_root.join(&p); if std::fs::symlink_metadata(&joined).is_ok() { existing.push(p); } } if existing.is_empty() { return Ok(()); } let mut cmd = std::process::Command::new("git"); cmd.arg("add"); cmd.arg("--"); for p in &existing { cmd.arg(OsStr::new(p)); } let out = cmd.current_dir(git_root).output()?; let _code = out.status.code().unwrap_or(-1); // We do not hard fail staging; best-effort is OK. Return Ok even on non-zero. Ok(()) } // ============ Parser ported from VS Code (TS) ============ pub fn parse_git_apply_output( stdout: &str, stderr: &str, ) -> (Vec, Vec, Vec) { let combined = [stdout, stderr] .iter() .filter(|s| !s.is_empty()) .cloned() .collect::>() .join("\n"); let mut applied = std::collections::BTreeSet::new(); let mut skipped = std::collections::BTreeSet::new(); let mut conflicted = std::collections::BTreeSet::new(); let mut last_seen_path: Option = None; fn add(set: &mut std::collections::BTreeSet, raw: &str) { let trimmed = raw.trim(); if trimmed.is_empty() { return; } let first = trimmed.chars().next().unwrap_or('\0'); let last = trimmed.chars().last().unwrap_or('\0'); let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 { &trimmed[1..trimmed.len() - 1] } else { trimmed }; if !unquoted.is_empty() { set.insert(unquoted.to_string()); } } static APPLIED_CLEAN: Lazy = Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P.+?)\\s+cleanly\\.?$")); static APPLIED_CONFLICTS: Lazy = Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P.+?)\\s+with conflicts\\.?$")); static APPLYING_WITH_REJECTS: Lazy = Lazy::new(|| { regex_ci("^Applying patch\\s+(?P.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$") }); static CHECKING_PATCH: Lazy = Lazy::new(|| regex_ci("^Checking patch\\s+(?P.+?)\\.\\.\\.$")); static UNMERGED_LINE: Lazy = Lazy::new(|| regex_ci("^U\\s+(?P.+)$")); static PATCH_FAILED: Lazy = Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P.+?)(?::\\d+)?(?:\\s|$)")); static DOES_NOT_APPLY: Lazy = Lazy::new(|| regex_ci("^error:\\s+(?P.+?):\\s+patch does not apply$")); static THREE_WAY_START: Lazy = Lazy::new(|| { regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$") }); static THREE_WAY_FAILED: Lazy = Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$")); static FALLBACK_DIRECT: Lazy = Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$")); static LACKS_BLOB: Lazy = Lazy::new(|| { regex_ci( "^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$", ) }); static INDEX_MISMATCH: Lazy = Lazy::new(|| regex_ci("^error:\\s+(?P.+?):\\s+does not match index\\b")); static NOT_IN_INDEX: Lazy = Lazy::new(|| regex_ci("^error:\\s+(?P.+?):\\s+does not exist in index\\b")); static ALREADY_EXISTS_WT: Lazy = Lazy::new(|| { regex_ci("^error:\\s+(?P.+?)\\s+already exists in (?:the )?working directory\\b") }); static FILE_EXISTS: Lazy = Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P.+?)\\s+File exists")); static RENAMED_DELETED: Lazy = Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P.+?)\\s+has been renamed\\/deleted")); static CANNOT_APPLY_BINARY: Lazy = Lazy::new(|| { regex_ci( "^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P.+?)['\\\"]?\\s+without full index line$", ) }); static BINARY_DOES_NOT_APPLY: Lazy = Lazy::new(|| { regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P.+?)['\\\"]?$") }); static BINARY_INCORRECT_RESULT: Lazy = Lazy::new(|| { regex_ci( "^error:\\s+binary patch to\\s+['\\\"]?(?P.+?)['\\\"]?\\s+creates incorrect result\\b", ) }); static CANNOT_READ_CURRENT: Lazy = Lazy::new(|| { regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P.+?)['\\\"]?$") }); static SKIPPED_PATCH: Lazy = Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P.+?)['\\\"]\\.$")); static CANNOT_MERGE_BINARY_WARN: Lazy = Lazy::new(|| { regex_ci( "^warning:\\s*Cannot merge binary files:\\s+(?P.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)", ) }); for raw_line in combined.lines() { let line = raw_line.trim(); if line.is_empty() { continue; } // === "Checking patch ..." tracking === if let Some(c) = CHECKING_PATCH.captures(line) { if let Some(m) = c.name("path") { last_seen_path = Some(m.as_str().to_string()); } continue; } // === Status lines === if let Some(c) = APPLIED_CLEAN.captures(line) { if let Some(m) = c.name("path") { add(&mut applied, m.as_str()); let p = applied.iter().next_back().cloned(); if let Some(p) = p { conflicted.remove(&p); skipped.remove(&p); last_seen_path = Some(p); } } continue; } if let Some(c) = APPLIED_CONFLICTS.captures(line) { if let Some(m) = c.name("path") { add(&mut conflicted, m.as_str()); let p = conflicted.iter().next_back().cloned(); if let Some(p) = p { applied.remove(&p); skipped.remove(&p); last_seen_path = Some(p); } } continue; } if let Some(c) = APPLYING_WITH_REJECTS.captures(line) { if let Some(m) = c.name("path") { add(&mut conflicted, m.as_str()); let p = conflicted.iter().next_back().cloned(); if let Some(p) = p { applied.remove(&p); skipped.remove(&p); last_seen_path = Some(p); } } continue; } // === “U ” after conflicts === if let Some(c) = UNMERGED_LINE.captures(line) { if let Some(m) = c.name("path") { add(&mut conflicted, m.as_str()); let p = conflicted.iter().next_back().cloned(); if let Some(p) = p { applied.remove(&p); skipped.remove(&p); last_seen_path = Some(p); } } continue; } // === Early hints === if PATCH_FAILED.is_match(line) || DOES_NOT_APPLY.is_match(line) { if let Some(c) = PATCH_FAILED .captures(line) .or_else(|| DOES_NOT_APPLY.captures(line)) && let Some(m) = c.name("path") { add(&mut skipped, m.as_str()); last_seen_path = Some(m.as_str().to_string()); } continue; } // === Ignore narration === if THREE_WAY_START.is_match(line) || FALLBACK_DIRECT.is_match(line) { continue; } // === 3-way failed entirely; attribute to last_seen_path === if THREE_WAY_FAILED.is_match(line) || LACKS_BLOB.is_match(line) { if let Some(p) = last_seen_path.clone() { add(&mut skipped, &p); applied.remove(&p); conflicted.remove(&p); } continue; } // === Skips / I/O problems === if let Some(c) = INDEX_MISMATCH .captures(line) .or_else(|| NOT_IN_INDEX.captures(line)) .or_else(|| ALREADY_EXISTS_WT.captures(line)) .or_else(|| FILE_EXISTS.captures(line)) .or_else(|| RENAMED_DELETED.captures(line)) .or_else(|| CANNOT_APPLY_BINARY.captures(line)) .or_else(|| BINARY_DOES_NOT_APPLY.captures(line)) .or_else(|| BINARY_INCORRECT_RESULT.captures(line)) .or_else(|| CANNOT_READ_CURRENT.captures(line)) .or_else(|| SKIPPED_PATCH.captures(line)) { if let Some(m) = c.name("path") { add(&mut skipped, m.as_str()); let p_now = skipped.iter().next_back().cloned(); if let Some(p) = p_now { applied.remove(&p); conflicted.remove(&p); last_seen_path = Some(p); } } continue; } // === Warnings that imply conflicts === if let Some(c) = CANNOT_MERGE_BINARY_WARN.captures(line) { if let Some(m) = c.name("path") { add(&mut conflicted, m.as_str()); let p = conflicted.iter().next_back().cloned(); if let Some(p) = p { applied.remove(&p); skipped.remove(&p); last_seen_path = Some(p); } } continue; } } // Final precedence: conflicts > applied > skipped for p in conflicted.iter() { applied.remove(p); skipped.remove(p); } for p in applied.iter() { skipped.remove(p); } ( applied.into_iter().collect(), skipped.into_iter().collect(), conflicted.into_iter().collect(), ) } fn regex_ci(pat: &str) -> Regex { Regex::new(&format!("(?i){pat}")).unwrap_or_else(|e| panic!("invalid regex: {e}")) } #[cfg(test)] mod tests { use super::*; use std::path::Path; use std::sync::Mutex; use std::sync::OnceLock; fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let out = std::process::Command::new(args[0]) .args(&args[1..]) .current_dir(cwd) .output() .expect("spawn ok"); ( out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned(), ) } fn init_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); // git init and minimal identity let _ = run(root, &["git", "init"]); let _ = run(root, &["git", "config", "user.email", "codex@example.com"]); let _ = run(root, &["git", "config", "user.name", "Codex"]); dir } fn read_file_normalized(path: &Path) -> String { std::fs::read_to_string(path) .expect("read file") .replace("\r\n", "\n") } #[test] fn apply_add_success() { let _g = env_lock().lock().unwrap(); let repo = init_repo(); let root = repo.path(); 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"; let req = ApplyGitRequest { cwd: root.to_path_buf(), diff: diff.to_string(), revert: false, preflight: false, }; let r = apply_git_patch(&req).expect("run apply"); assert_eq!(r.exit_code, 0, "exit code 0"); // File exists now assert!(root.join("hello.txt").exists()); } #[test] fn apply_modify_conflict() { let _g = env_lock().lock().unwrap(); let repo = init_repo(); let root = repo.path(); // seed file and commit std::fs::write(root.join("file.txt"), "line1\nline2\nline3\n").unwrap(); let _ = run(root, &["git", "add", "file.txt"]); let _ = run(root, &["git", "commit", "-m", "seed"]); // local edit (unstaged) std::fs::write(root.join("file.txt"), "line1\nlocal2\nline3\n").unwrap(); // patch wants to change the same line differently 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"; let req = ApplyGitRequest { cwd: root.to_path_buf(), diff: diff.to_string(), revert: false, preflight: false, }; let r = apply_git_patch(&req).expect("run apply"); assert_ne!(r.exit_code, 0, "non-zero exit on conflict"); } #[test] fn apply_modify_skipped_missing_index() { let _g = env_lock().lock().unwrap(); let repo = init_repo(); let root = repo.path(); // Try to modify a file that is not in the index 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"; let req = ApplyGitRequest { cwd: root.to_path_buf(), diff: diff.to_string(), revert: false, preflight: false, }; let r = apply_git_patch(&req).expect("run apply"); assert_ne!(r.exit_code, 0, "non-zero exit on missing index"); } #[test] fn apply_then_revert_success() { let _g = env_lock().lock().unwrap(); let repo = init_repo(); let root = repo.path(); // Seed file and commit original content std::fs::write(root.join("file.txt"), "orig\n").unwrap(); let _ = run(root, &["git", "add", "file.txt"]); let _ = run(root, &["git", "commit", "-m", "seed"]); // Forward patch: orig -> ORIG 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"; let apply_req = ApplyGitRequest { cwd: root.to_path_buf(), diff: diff.to_string(), revert: false, preflight: false, }; let res_apply = apply_git_patch(&apply_req).expect("apply ok"); assert_eq!(res_apply.exit_code, 0, "forward apply succeeded"); let after_apply = read_file_normalized(&root.join("file.txt")); assert_eq!(after_apply, "ORIG\n"); // Revert patch: ORIG -> orig (stage paths first; engine handles it) let revert_req = ApplyGitRequest { cwd: root.to_path_buf(), diff: diff.to_string(), revert: true, preflight: false, }; let res_revert = apply_git_patch(&revert_req).expect("revert ok"); assert_eq!(res_revert.exit_code, 0, "revert apply succeeded"); let after_revert = read_file_normalized(&root.join("file.txt")); assert_eq!(after_revert, "orig\n"); } #[test] fn revert_preflight_does_not_stage_index() { let _g = env_lock().lock().unwrap(); let repo = init_repo(); let root = repo.path(); // Seed repo and apply forward patch so the working tree reflects the change. std::fs::write(root.join("file.txt"), "orig\n").unwrap(); let _ = run(root, &["git", "add", "file.txt"]); let _ = run(root, &["git", "commit", "-m", "seed"]); 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"; let apply_req = ApplyGitRequest { cwd: root.to_path_buf(), diff: diff.to_string(), revert: false, preflight: false, }; let res_apply = apply_git_patch(&apply_req).expect("apply ok"); assert_eq!(res_apply.exit_code, 0, "forward apply succeeded"); let (commit_code, _, commit_err) = run(root, &["git", "commit", "-am", "apply change"]); assert_eq!(commit_code, 0, "commit applied change: {commit_err}"); let (_code_before, staged_before, _stderr_before) = run(root, &["git", "diff", "--cached", "--name-only"]); let preflight_req = ApplyGitRequest { cwd: root.to_path_buf(), diff: diff.to_string(), revert: true, preflight: true, }; let res_preflight = apply_git_patch(&preflight_req).expect("preflight ok"); assert_eq!(res_preflight.exit_code, 0, "revert preflight succeeded"); let (_code_after, staged_after, _stderr_after) = run(root, &["git", "diff", "--cached", "--name-only"]); assert_eq!( staged_after.trim(), staged_before.trim(), "preflight should not stage new paths", ); let after_preflight = read_file_normalized(&root.join("file.txt")); assert_eq!(after_preflight, "ORIG\n"); } #[test] fn preflight_blocks_partial_changes() { let _g = env_lock().lock().unwrap(); let repo = init_repo(); let root = repo.path(); // Build a multi-file diff: one valid add (ok.txt) and one invalid modify (ghost.txt) 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\ 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"; // 1) With preflight enabled, nothing should be changed (even though ok.txt could be added) let req1 = ApplyGitRequest { cwd: root.to_path_buf(), diff: diff.to_string(), revert: false, preflight: true, }; let r1 = apply_git_patch(&req1).expect("preflight apply"); assert_ne!(r1.exit_code, 0, "preflight reports failure"); assert!( !root.join("ok.txt").exists(), "preflight must prevent adding ok.txt" ); assert!( r1.cmd_for_log.contains("--check"), "preflight path recorded --check" ); // 2) Without preflight, we should see no --check in the executed command let req2 = ApplyGitRequest { cwd: root.to_path_buf(), diff: diff.to_string(), revert: false, preflight: false, }; let r2 = apply_git_patch(&req2).expect("direct apply"); assert_ne!(r2.exit_code, 0, "apply is expected to fail overall"); assert!( !r2.cmd_for_log.contains("--check"), "non-preflight path should not use --check" ); } }