From 843f07e265f7faaba148d5999b83115d38575f4e Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Wed, 1 Jul 2026 08:37:37 -0700 Subject: [PATCH] Derive effective patch paths through Git --- codex-rs/git-utils/src/apply.rs | 441 +------------------- codex-rs/git-utils/src/apply_output.rs | 302 ++++++++++++++ codex-rs/git-utils/src/lib.rs | 8 +- codex-rs/git-utils/src/patch_paths.rs | 263 ++++++++++++ codex-rs/git-utils/src/patch_paths_tests.rs | 380 +++++++++++++++++ 5 files changed, 958 insertions(+), 436 deletions(-) create mode 100644 codex-rs/git-utils/src/apply_output.rs create mode 100644 codex-rs/git-utils/src/patch_paths.rs create mode 100644 codex-rs/git-utils/src/patch_paths_tests.rs diff --git a/codex-rs/git-utils/src/apply.rs b/codex-rs/git-utils/src/apply.rs index 12c999e9c8..46348fc284 100644 --- a/codex-rs/git-utils/src/apply.rs +++ b/codex-rs/git-utils/src/apply.rs @@ -6,15 +6,15 @@ //! mode via [`ApplyGitRequest::preflight`] and inspect the resulting paths to //! learn what would change before applying for real. -use once_cell::sync::Lazy; -use regex::Regex; -use std::ffi::OsStr; use std::io; use std::path::Path; use std::path::PathBuf; use crate::FsmonitorOverride; +use crate::apply_output::parse_git_apply_output; use crate::git_command::GitRunner; +use crate::patch_paths::extract_effective_paths_from_patch; +use crate::patch_paths::stage_effective_paths; use crate::safe_git::DISABLED_HOOKS_PATH; #[cfg(test)] use crate::safe_git::isolate_git_command_environment; @@ -53,10 +53,11 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result { let (tmpdir, patch_path) = write_temp_patch(&req.diff)?; // Keep tmpdir alive until function end to ensure the file exists let _guard = tmpdir; + let patch_paths = extract_effective_paths_from_patch(&git, &patch_path, req.revert)?; if req.revert && !req.preflight { // Stage WT paths first to avoid index mismatch on revert. - stage_paths(&git_root, &req.diff)?; + stage_effective_paths(&git, &git_root, &patch_paths)?; } // Build git args @@ -184,14 +185,14 @@ fn configured_git_config_parts() -> Vec { cfg_parts } -fn write_temp_patch(diff: &str) -> io::Result<(tempfile::TempDir, PathBuf)> { +pub(crate) 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( +pub(crate) fn run_git( git: &GitRunner, cwd: &Path, git_cfg: &[String], @@ -212,7 +213,7 @@ fn run_git( Ok((code, stdout, stderr)) } -fn safe_git_config_parts() -> Vec { +pub(crate) fn safe_git_config_parts() -> Vec { vec![ "-c".to_string(), format!("core.hooksPath={DISABLED_HOOKS_PATH}"), @@ -248,411 +249,6 @@ fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> St ) } -/// Collect every path referenced by the diff headers inside `diff --git` sections. -pub fn extract_paths_from_patch(diff_text: &str) -> Vec { - let mut set = std::collections::BTreeSet::new(); - for raw_line in diff_text.lines() { - let line = raw_line.trim(); - let Some(rest) = line.strip_prefix("diff --git ") else { - continue; - }; - let Some((a, b)) = parse_diff_git_paths(rest) else { - continue; - }; - if let Some(a) = normalize_diff_path(&a, "a/") { - set.insert(a); - } - if let Some(b) = normalize_diff_path(&b, "b/") { - set.insert(b); - } - } - set.into_iter().collect() -} - -fn parse_diff_git_paths(line: &str) -> Option<(String, String)> { - let mut chars = line.chars().peekable(); - let first = read_diff_git_token(&mut chars)?; - let second = read_diff_git_token(&mut chars)?; - Some((first, second)) -} - -fn read_diff_git_token(chars: &mut std::iter::Peekable>) -> Option { - while matches!(chars.peek(), Some(c) if c.is_whitespace()) { - chars.next(); - } - let quote = match chars.peek().copied() { - Some('"') | Some('\'') => chars.next(), - _ => None, - }; - let mut out = String::new(); - while let Some(c) = chars.next() { - if let Some(q) = quote { - if c == q { - break; - } - if c == '\\' { - out.push('\\'); - if let Some(next) = chars.next() { - out.push(next); - } - continue; - } - } else if c.is_whitespace() { - break; - } - out.push(c); - } - if out.is_empty() && quote.is_none() { - None - } else { - Some(match quote { - Some(_) => unescape_c_string(&out), - None => out, - }) - } -} - -fn normalize_diff_path(raw: &str, prefix: &str) -> Option { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - if trimmed == "/dev/null" || trimmed == format!("{prefix}dev/null") { - return None; - } - let trimmed = trimmed.strip_prefix(prefix).unwrap_or(trimmed); - if trimmed.is_empty() { - return None; - } - Some(trimmed.to_string()) -} - -fn unescape_c_string(input: &str) -> String { - let mut out = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - while let Some(c) = chars.next() { - if c != '\\' { - out.push(c); - continue; - } - let Some(next) = chars.next() else { - out.push('\\'); - break; - }; - match next { - 'n' => out.push('\n'), - 'r' => out.push('\r'), - 't' => out.push('\t'), - 'b' => out.push('\u{0008}'), - 'f' => out.push('\u{000C}'), - 'a' => out.push('\u{0007}'), - 'v' => out.push('\u{000B}'), - '\\' => out.push('\\'), - '"' => out.push('"'), - '\'' => out.push('\''), - '0'..='7' => { - let mut value = next.to_digit(8).unwrap_or(0); - for _ in 0..2 { - match chars.peek() { - Some('0'..='7') => { - if let Some(digit) = chars.next() { - value = value * 8 + digit.to_digit(8).unwrap_or(0); - } else { - break; - } - } - _ => break, - } - } - if let Some(ch) = std::char::from_u32(value) { - out.push(ch); - } - } - other => out.push(other), - } - } - out -} - -/// Stage only the files that actually exist on disk for the given diff. -pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> { - let git = GitRunner::for_cwd_io(git_root)?; - 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 = git.command(); - cmd.args(safe_git_config_parts()); - cmd.arg("add"); - cmd.arg("--"); - for p in &existing { - cmd.arg(OsStr::new(p)); - } - cmd.current_dir(git_root); - let out = git.output(cmd)?; - 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) ============ - -/// Parse `git apply` output into applied/skipped/conflicted path groupings. -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 { - unescape_c_string(&trimmed[1..trimmed.len() - 1]) - } else { - trimmed.to_string() - }; - if !unquoted.is_empty() { - set.insert(unquoted); - } - } - - 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(all(test, unix))] #[path = "apply_transport_tests.rs"] mod transport_tests; @@ -722,27 +318,6 @@ mod tests { .replace("\r\n", "\n") } - #[test] - fn extract_paths_handles_quoted_headers() { - 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"; - let paths = extract_paths_from_patch(diff); - assert_eq!(paths, vec!["hello world.txt".to_string()]); - } - - #[test] - fn extract_paths_ignores_dev_null_header() { - 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"; - let paths = extract_paths_from_patch(diff); - assert_eq!(paths, vec!["ok.txt".to_string()]); - } - - #[test] - fn extract_paths_unescapes_c_style_in_quoted_headers() { - 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"; - let paths = extract_paths_from_patch(diff); - assert_eq!(paths, vec!["hello\tworld.txt".to_string()]); - } - #[test] fn parse_output_unescapes_quoted_paths() { let stderr = "error: patch failed: \"hello\\tworld.txt\":1\n"; diff --git a/codex-rs/git-utils/src/apply_output.rs b/codex-rs/git-utils/src/apply_output.rs new file mode 100644 index 0000000000..35c34d44e4 --- /dev/null +++ b/codex-rs/git-utils/src/apply_output.rs @@ -0,0 +1,302 @@ +//! Parsing of diagnostics emitted by Git apply. + +use once_cell::sync::Lazy; +use regex::Regex; + +fn unescape_c_string(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + let Some(next) = chars.next() else { + out.push('\\'); + break; + }; + match next { + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + 'b' => out.push('\u{0008}'), + 'f' => out.push('\u{000C}'), + 'a' => out.push('\u{0007}'), + 'v' => out.push('\u{000B}'), + '\\' => out.push('\\'), + '"' => out.push('"'), + '\'' => out.push('\''), + '0'..='7' => { + let mut value = next.to_digit(8).unwrap_or(0); + for _ in 0..2 { + match chars.peek() { + Some('0'..='7') => { + if let Some(digit) = chars.next() { + value = value * 8 + digit.to_digit(8).unwrap_or(0); + } else { + break; + } + } + _ => break, + } + } + if let Some(ch) = std::char::from_u32(value) { + out.push(ch); + } + } + other => out.push(other), + } + } + out +} + +// ============ Parser ported from VS Code (TS) ============ + +/// Parse `git apply` output into applied/skipped/conflicted path groupings. +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 { + unescape_c_string(&trimmed[1..trimmed.len() - 1]) + } else { + trimmed.to_string() + }; + if !unquoted.is_empty() { + set.insert(unquoted); + } + } + + 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}")) +} diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index 550461074a..d5c225a6d1 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -1,4 +1,5 @@ mod apply; +mod apply_output; mod baseline; mod branch; mod errors; @@ -8,15 +9,14 @@ mod git_config; mod info; mod local_only; mod operations; +mod patch_paths; mod platform; mod safe_git; pub use apply::ApplyGitRequest; pub use apply::ApplyGitResult; pub use apply::apply_git_patch; -pub use apply::extract_paths_from_patch; -pub use apply::parse_git_apply_output; -pub use apply::stage_paths; +pub use apply_output::parse_git_apply_output; pub use baseline::GitBaselineChange; pub use baseline::GitBaselineChangeStatus; pub use baseline::GitBaselineDiff; @@ -47,4 +47,6 @@ pub use info::local_git_branches; pub use info::recent_commits; pub use info::resolve_root_git_project_for_trust; pub use local_only::local_only_git_env; +pub use patch_paths::extract_paths_from_patch; +pub use patch_paths::stage_paths; pub use platform::create_symlink; diff --git a/codex-rs/git-utils/src/patch_paths.rs b/codex-rs/git-utils/src/patch_paths.rs new file mode 100644 index 0000000000..ad1878e619 --- /dev/null +++ b/codex-rs/git-utils/src/patch_paths.rs @@ -0,0 +1,263 @@ +//! Effective patch-path discovery and safe staging guards. + +use std::io; +use std::path::Path; + +use crate::apply::run_git; +use crate::apply::safe_git_config_parts; +use crate::apply::write_temp_patch; +use crate::git_command::GitRunner; + +pub(crate) fn extract_effective_paths_from_patch( + git: &GitRunner, + patch_path: &Path, + revert: bool, +) -> io::Result> { + let forward_paths = git_apply_numstat_paths(git, patch_path, revert)?; + // `git apply --numstat` reports only the destination of a rename. Parse the + // opposite orientation too so both endpoints are included in the result. + let reverse_paths = git_apply_numstat_paths(git, patch_path, !revert)?; + if forward_paths.len() != reverse_paths.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "forward and reverse patch parsing returned different path counts", + )); + } + let effective_paths: std::collections::BTreeSet = + forward_paths.into_iter().chain(reverse_paths).collect(); + if effective_paths.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "patch does not identify any paths", + )); + } + effective_paths + .into_iter() + .map(validate_patch_path) + .collect() +} + +/// Best-effort extraction of the paths Git would apply. +/// +/// Security-sensitive callers must use the fallible internal extractor so an +/// invalid or ambiguous patch is rejected instead of becoming an empty list. +pub fn extract_paths_from_patch(diff_text: &str) -> Vec { + let Ok((tmpdir, patch_path)) = write_temp_patch(diff_text) else { + return Vec::new(); + }; + let paths = std::env::current_dir() + .ok() + .and_then(|cwd| GitRunner::for_cwd(&cwd).ok()) + .and_then(|git| { + extract_effective_paths_from_patch(&git, &patch_path, /*revert*/ false).ok() + }) + .unwrap_or_default(); + drop(tmpdir); + paths +} + +fn git_apply_numstat_paths( + git: &GitRunner, + patch_path: &Path, + revert: bool, +) -> io::Result> { + let mut cmd = git.command(); + cmd.args(["apply", "--numstat", "-z"]); + if revert { + cmd.arg("-R"); + } + cmd.arg("--") + .arg(patch_path) + .current_dir(patch_path.parent().unwrap_or_else(|| Path::new("."))); + let out = git.output(cmd)?; + if !out.status.success() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "failed to parse patch paths: {}", + String::from_utf8_lossy(&out.stderr).trim() + ), + )); + } + + parse_numstat_paths(&out.stdout) +} + +fn parse_numstat_paths(output: &[u8]) -> io::Result> { + if !output.is_empty() && !output.ends_with(&[0]) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "git apply returned an unterminated numstat path record", + )); + } + let mut paths = Vec::new(); + let mut records = output.split(|byte| *byte == 0).peekable(); + while let Some(record) = records.next() { + if record.is_empty() && records.peek().is_none() { + break; + } + let mut fields = record.splitn(3, |byte| *byte == b'\t'); + let _added = fields.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "git apply returned an ambiguous numstat path record", + ) + })?; + let _deleted = fields.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "git apply returned an ambiguous numstat path record", + ) + })?; + let path = fields.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "git apply returned an ambiguous numstat path record", + ) + })?; + if path.is_empty() { + let old = records + .next() + .filter(|path| !path.is_empty()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "git apply returned an incomplete rename path record", + ) + })?; + let new = records + .next() + .filter(|path| !path.is_empty()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "git apply returned an incomplete rename path record", + ) + })?; + insert_numstat_path(&mut paths, old)?; + insert_numstat_path(&mut paths, new)?; + } else { + insert_numstat_path(&mut paths, path)?; + } + } + Ok(paths) +} + +fn insert_numstat_path(paths: &mut Vec, path: &[u8]) -> io::Result<()> { + let path = std::str::from_utf8(path).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "git apply returned a non-UTF-8 patch path", + ) + })?; + paths.push(path.to_string()); + Ok(()) +} + +fn validate_patch_path(path: String) -> io::Result { + if path.starts_with('/') + || path.ends_with('/') + || invalid_platform_patch_path(&path) + || path + .split('/') + .any(|component| component.is_empty() || component == "." || component == "..") + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "patch path is not a normalized repository-relative path", + )); + } + Ok(path) +} + +#[cfg(windows)] +fn invalid_platform_patch_path(path: &str) -> bool { + invalid_windows_patch_path(path) +} + +#[cfg(not(windows))] +fn invalid_platform_patch_path(_path: &str) -> bool { + false +} + +#[cfg(any(windows, test))] +fn invalid_windows_patch_path(path: &str) -> bool { + path.split('/').any(invalid_windows_patch_component) +} + +#[cfg(any(windows, test))] +fn invalid_windows_patch_component(component: &str) -> bool { + if component.bytes().any(|byte| { + byte <= 0x1f || matches!(byte, b'\\' | b'<' | b'>' | b':' | b'"' | b'|' | b'?' | b'*') + }) || matches!(component.as_bytes().last(), Some(b'.' | b' ')) + { + return true; + } + + let reserved_suffix = |suffix: &str| { + let suffix = suffix.trim_start_matches(' '); + suffix.is_empty() || matches!(suffix.as_bytes().first(), Some(b'.' | b':')) + }; + + if ["AUX", "CON", "CONIN$", "CONOUT$", "NUL", "PRN"] + .iter() + .any(|reserved| { + component + .get(..reserved.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(reserved)) + && component.get(reserved.len()..).is_some_and(reserved_suffix) + }) + { + return true; + } + + [b"COM", b"LPT"].iter().any(|reserved| { + let Some(rest) = component.get(3..) else { + return false; + }; + let mut chars = rest.chars(); + component.as_bytes()[..3].eq_ignore_ascii_case(*reserved) + && matches!(chars.next(), Some('1'..='9' | '¹' | '²' | '³')) + && reserved_suffix(chars.as_str()) + }) +} + +/// Stage only the files that actually exist on disk for the given diff. +pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> { + let git = GitRunner::for_cwd_io(git_root)?; + let (tmpdir, patch_path) = write_temp_patch(diff)?; + let paths = extract_effective_paths_from_patch(&git, &patch_path, /*revert*/ true)?; + let _guard = tmpdir; + stage_effective_paths(&git, git_root, &paths) +} + +pub(crate) fn stage_effective_paths( + git: &GitRunner, + git_root: &Path, + paths: &[String], +) -> io::Result<()> { + 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.clone()); + } + } + if existing.is_empty() { + return Ok(()); + } + let mut args = vec![ + "--literal-pathspecs".to_string(), + "add".to_string(), + "--".to_string(), + ]; + args.extend(existing); + let config_parts = safe_git_config_parts(); + let (_code, _, _) = run_git(git, git_root, &config_parts, &args)?; + // We do not hard fail staging; best-effort is OK. Return Ok even on non-zero. + Ok(()) +} + +#[cfg(test)] +#[path = "patch_paths_tests.rs"] +mod tests; diff --git a/codex-rs/git-utils/src/patch_paths_tests.rs b/codex-rs/git-utils/src/patch_paths_tests.rs new file mode 100644 index 0000000000..7af43300e7 --- /dev/null +++ b/codex-rs/git-utils/src/patch_paths_tests.rs @@ -0,0 +1,380 @@ +use super::*; +use crate::apply::ApplyGitRequest; +use crate::apply::apply_git_patch; +use std::io; +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 mut command = std::process::Command::new(args[0]); + crate::safe_git::isolate_git_command_environment(&mut command); + let out = command + .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") +} + +fn effective_paths(diff: &str, revert: bool) -> io::Result> { + let (tmpdir, patch_path) = write_temp_patch(diff)?; + let cwd = std::env::current_dir()?; + let git = GitRunner::for_cwd_io(&cwd)?; + let paths = extract_effective_paths_from_patch(&git, &patch_path, revert)?; + drop(tmpdir); + Ok(paths) +} + +#[test] +fn effective_paths_cover_supported_patch_headers() { + let cases = [ + ( + "quoted new file", + "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", + vec!["hello world.txt"], + ), + ( + "unquoted spaced path", + "diff --git a/space name.txt b/space name.txt\n--- a/space name.txt\n+++ b/space name.txt\n@@ -1 +1 @@\n-old\n+new\n", + vec!["space name.txt"], + ), + ( + "headerless p0 inference", + "--- headerless-p0.txt\n+++ headerless-p0.txt\n@@ -1 +1 @@\n-old\n+new\n", + vec!["headerless-p0.txt"], + ), + ( + "headerless unified diff", + "--- old/headerless.txt\n+++ new/headerless.txt\n@@ -1 +1 @@\n-old\n+new\n", + vec!["headerless.txt"], + ), + ( + "arbitrary prefixes", + "diff --git left/file.txt right/file.txt\n--- before/file.txt\n+++ after/file.txt\n@@ -1 +1 @@\n-old\n+new\n", + vec!["file.txt"], + ), + ( + "deleted file", + "diff --git a/gone.txt b/gone.txt\ndeleted file mode 100644\n--- a/gone.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-old\n", + vec!["gone.txt"], + ), + ( + "literal dev/null path", + "diff --git a/dev/null b/dev/null\n--- a/dev/null\n+++ b/dev/null\n@@ -1 +1 @@\n-old\n+new\n", + vec!["dev/null"], + ), + ( + "rename", + "diff --git a/rename-old.txt b/rename-new.txt\nsimilarity index 100%\nrename from rename-old.txt\nrename to rename-new.txt\n", + vec!["rename-new.txt", "rename-old.txt"], + ), + ( + "copy", + "diff --git a/copy-old.txt b/copy-new.txt\nsimilarity index 100%\ncopy from copy-old.txt\ncopy to copy-new.txt\n", + vec!["copy-new.txt", "copy-old.txt"], + ), + ]; + + for (name, diff, expected) in cases { + for revert in [false, true] { + assert_eq!( + effective_paths(diff, revert).unwrap_or_else(|error| panic!("{name}: {error}")), + expected, + "{name}, revert={revert}" + ); + } + assert_eq!(extract_paths_from_patch(diff), expected, "{name}"); + } + + let nul_rename_paths = parse_numstat_paths(b"0\t0\t\0old name.txt\0new name.txt\0") + .expect("parse NUL-delimited rename paths"); + assert_eq!( + nul_rename_paths, + vec!["old name.txt".to_string(), "new name.txt".to_string()] + ); +} + +#[test] +fn effective_paths_follow_git_for_mismatched_headers() { + let mismatch = "diff --git a/safe.txt b/safe.txt\n--- a/nested/file.txt\n+++ b/nested/file.txt\n@@ -1 +1 @@\n-old\n+new\n"; + let expected = vec!["nested/file.txt".to_string()]; + assert_eq!( + effective_paths(mismatch, /*revert*/ false).unwrap(), + expected + ); + assert_eq!( + effective_paths(mismatch, /*revert*/ true).unwrap(), + expected + ); + assert_eq!(extract_paths_from_patch(mismatch), expected); +} + +#[test] +fn effective_paths_reject_platform_ambiguous_paths() { + let error = effective_paths("", /*revert*/ false).expect_err("reject empty patch paths"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + #[cfg(windows)] + { + let error = validate_patch_path("..\\nested\\file.txt".to_string()) + .expect_err("reject Windows path separators"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + let error = + validate_patch_path("C:/outside.txt".to_string()).expect_err("reject drive prefix"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + } + #[cfg(unix)] + { + assert_eq!( + validate_patch_path("back\\slash.txt".to_string()).expect("valid Unix filename"), + "back\\slash.txt" + ); + assert_eq!( + validate_patch_path("a:file.txt".to_string()).expect("valid Unix filename"), + "a:file.txt" + ); + } +} + +#[test] +fn windows_namespace_validator_rejects_aliases_and_reserved_names() { + let rejected_paths = [ + "file.txt:stream", + "dir/file.txt::$DATA", + "dir:stream/file.txt", + "file.", + "file ", + "dir./file.txt", + "dir /file.txt", + "dir/CoM¹ .log", + "dir/lPt² .log", + "dir/CoM³ .log", + ]; + for path in rejected_paths { + assert!(invalid_windows_patch_path(path), "must reject {path:?}"); + } + + for punctuation in ['\\', '<', '>', ':', '"', '|', '?', '*'] { + let path = format!("dir/file{punctuation}name.txt"); + assert!(invalid_windows_patch_path(&path), "must reject {path:?}"); + } + for control in ['\0', '\u{0001}', '\u{001f}'] { + let path = format!("dir/file{control}name.txt"); + assert!(invalid_windows_patch_path(&path), "must reject {path:?}"); + } + for family in ["AUX", "CON", "CONIN$", "CONOUT$", "NUL", "PRN"] { + for path in [ + family.to_string(), + format!("{}.txt", family.to_ascii_lowercase()), + format!("{family} .log"), + ] { + assert!(invalid_windows_patch_path(&path), "must reject {path:?}"); + } + } + for digit in "123456789¹²³".chars() { + for family in ["CoM", "LpT"] { + for suffix in ["", ".txt", " .log"] { + let path = format!("{family}{digit}{suffix}"); + assert!(invalid_windows_patch_path(&path), "must reject {path:?}"); + } + } + } + + for path in "COM0 COM10 LPT0 LPT10 COM⁴ LPT⁴ NULx contest.txt auxiliary.txt printer.txt conin$x conout$x ordinary.file" + .split_ascii_whitespace() + { + assert!( + !invalid_windows_patch_path(path), + "must allow near miss {path:?}" + ); + } +} + +#[cfg(windows)] +#[test] +fn apply_rejects_win32_namespace_aliases_with_protect_ntfs_disabled() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + std::fs::write(root.join("file.txt"), "original\n").expect("write fixture"); + let (add_code, _, add_err) = run(root, &["git", "add", "file.txt"]); + assert_eq!(add_code, 0, "add fixture: {add_err}"); + let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "fixture"]); + assert_eq!(commit_code, 0, "commit fixture: {commit_err}"); + let (config_code, _, config_err) = run(root, &["git", "config", "core.protectNTFS", "false"]); + assert_eq!(config_code, 0, "disable core.protectNTFS: {config_err}"); + + let cases = [ + ( + "ADS", + "diff --git a/file.txt:stream b/file.txt:stream\nnew file mode 100644\n--- /dev/null\n+++ b/file.txt:stream\n@@ -0,0 +1 @@\n+stream\n", + ), + ( + "trailing dot", + "diff --git a/file.txt. b/file.txt.\n--- a/file.txt.\n+++ b/file.txt.\n@@ -1 +1 @@\n-original\n+mutated\n", + ), + ( + "trailing space", + "diff --git \"a/file.txt \" \"b/file.txt \"\n--- \"a/file.txt \"\n+++ \"b/file.txt \"\n@@ -1 +1 @@\n-original\n+mutated\n", + ), + ( + "device name", + "diff --git a/NUL.txt b/NUL.txt\nnew file mode 100644\n--- /dev/null\n+++ b/NUL.txt\n@@ -0,0 +1 @@\n+device\n", + ), + ( + "superscript device name", + "diff --git a/COM¹.txt b/COM¹.txt\nnew file mode 100644\n--- /dev/null\n+++ b/COM¹.txt\n@@ -0,0 +1 @@\n+device\n", + ), + ]; + + for (name, diff) in cases { + for revert in [false, true] { + let error = apply_git_patch(&ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert, + preflight: false, + }) + .expect_err("reject Win32 namespace alias before mutation"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{name}"); + assert_eq!( + error.to_string(), + "patch path is not a normalized repository-relative path", + "{name}, revert={revert}" + ); + assert_eq!( + read_file_normalized(&root.join("file.txt")), + "original\n", + "{name}, revert={revert}" + ); + let (status_code, status, status_err) = run(root, &["git", "status", "--porcelain=v1"]); + assert_eq!(status_code, 0, "status after {name}: {status_err}"); + assert!(status.is_empty(), "{name}, revert={revert}: {status:?}"); + assert!( + std::fs::metadata(root.join("file.txt:stream")).is_err(), + "{name}, revert={revert}: ADS must not be created" + ); + } + } +} + +#[cfg(unix)] +#[test] +fn patch_applies_valid_unix_colon_and_backslash_paths() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + for (path, contents) in [ + ("a:file.txt", "old colon\n"), + ("back\\slash.txt", "old slash\n"), + ] { + std::fs::write(root.join(path), contents).expect("write fixture"); + } + let (add_code, _, add_err) = run( + root, + &[ + "git", + "--literal-pathspecs", + "add", + "--", + "a:file.txt", + "back\\slash.txt", + ], + ); + assert_eq!(add_code, 0, "add fixture: {add_err}"); + let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "fixture"]); + assert_eq!(commit_code, 0, "commit fixture: {commit_err}"); + + std::fs::write(root.join("a:file.txt"), "new colon\n").expect("modify colon path"); + std::fs::write(root.join("back\\slash.txt"), "new slash\n").expect("modify backslash path"); + let (diff_code, diff, diff_err) = run( + root, + &[ + "git", + "--literal-pathspecs", + "diff", + "--full-index", + "--binary", + "--", + "a:file.txt", + "back\\slash.txt", + ], + ); + assert_eq!(diff_code, 0, "create patch: {diff_err}"); + assert!(!diff.is_empty(), "fixture patch must not be empty"); + let (restore_code, _, restore_err) = run( + root, + &[ + "git", + "--literal-pathspecs", + "checkout", + "--", + "a:file.txt", + "back\\slash.txt", + ], + ); + assert_eq!(restore_code, 0, "restore fixture: {restore_err}"); + + for preflight in [true, false] { + let result = apply_git_patch(&ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.clone(), + revert: false, + preflight, + }) + .unwrap_or_else(|error| panic!("forward preflight={preflight}: {error}")); + assert_eq!(result.exit_code, 0, "forward preflight={preflight}"); + } + assert_eq!( + read_file_normalized(&root.join("a:file.txt")), + "new colon\n" + ); + assert_eq!( + read_file_normalized(&root.join("back\\slash.txt")), + "new slash\n" + ); + + for preflight in [true, false] { + let result = apply_git_patch(&ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.clone(), + revert: true, + preflight, + }) + .unwrap_or_else(|error| panic!("reverse preflight={preflight}: {error}")); + assert_eq!(result.exit_code, 0, "reverse preflight={preflight}"); + } + assert_eq!( + read_file_normalized(&root.join("a:file.txt")), + "old colon\n" + ); + assert_eq!( + read_file_normalized(&root.join("back\\slash.txt")), + "old slash\n" + ); +}