mirror of
https://github.com/openai/codex.git
synced 2026-09-06 15:29:32 +00:00
Derive effective patch paths through Git
This commit is contained in:
@@ -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<ApplyGitResult> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
pub(crate) fn safe_git_config_parts() -> Vec<String> {
|
||||
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<String> {
|
||||
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<std::str::Chars<'_>>) -> Option<String> {
|
||||
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<String> {
|
||||
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<String> = 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<String>, Vec<String>, Vec<String>) {
|
||||
let combined = [stdout, stderr]
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<&str>>()
|
||||
.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<String> = None;
|
||||
|
||||
fn add(set: &mut std::collections::BTreeSet<String>, 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<Regex> =
|
||||
Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+cleanly\\.?$"));
|
||||
static APPLIED_CONFLICTS: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+with conflicts\\.?$"));
|
||||
static APPLYING_WITH_REJECTS: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^Applying patch\\s+(?P<path>.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$")
|
||||
});
|
||||
static CHECKING_PATCH: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Checking patch\\s+(?P<path>.+?)\\.\\.\\.$"));
|
||||
static UNMERGED_LINE: Lazy<Regex> = Lazy::new(|| regex_ci("^U\\s+(?P<path>.+)$"));
|
||||
static PATCH_FAILED: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)(?::\\d+)?(?:\\s|$)"));
|
||||
static DOES_NOT_APPLY: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+patch does not apply$"));
|
||||
static THREE_WAY_START: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$")
|
||||
});
|
||||
static THREE_WAY_FAILED: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$"));
|
||||
static FALLBACK_DIRECT: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$"));
|
||||
static LACKS_BLOB: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$",
|
||||
)
|
||||
});
|
||||
static INDEX_MISMATCH: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not match index\\b"));
|
||||
static NOT_IN_INDEX: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not exist in index\\b"));
|
||||
static ALREADY_EXISTS_WT: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^error:\\s+(?P<path>.+?)\\s+already exists in (?:the )?working directory\\b")
|
||||
});
|
||||
static FILE_EXISTS: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)\\s+File exists"));
|
||||
static RENAMED_DELETED: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P<path>.+?)\\s+has been renamed\\/deleted"));
|
||||
static CANNOT_APPLY_BINARY: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+without full index line$",
|
||||
)
|
||||
});
|
||||
static BINARY_DOES_NOT_APPLY: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
|
||||
});
|
||||
static BINARY_INCORRECT_RESULT: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^error:\\s+binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+creates incorrect result\\b",
|
||||
)
|
||||
});
|
||||
static CANNOT_READ_CURRENT: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
|
||||
});
|
||||
static SKIPPED_PATCH: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P<path>.+?)['\\\"]\\.$"));
|
||||
static CANNOT_MERGE_BINARY_WARN: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^warning:\\s*Cannot merge binary files:\\s+(?P<path>.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)",
|
||||
)
|
||||
});
|
||||
|
||||
for raw_line in combined.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// === "Checking patch <path>..." 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 <path>” 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";
|
||||
|
||||
302
codex-rs/git-utils/src/apply_output.rs
Normal file
302
codex-rs/git-utils/src/apply_output.rs
Normal file
@@ -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<String>, Vec<String>, Vec<String>) {
|
||||
let combined = [stdout, stderr]
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<&str>>()
|
||||
.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<String> = None;
|
||||
|
||||
fn add(set: &mut std::collections::BTreeSet<String>, 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<Regex> =
|
||||
Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+cleanly\\.?$"));
|
||||
static APPLIED_CONFLICTS: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+with conflicts\\.?$"));
|
||||
static APPLYING_WITH_REJECTS: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^Applying patch\\s+(?P<path>.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$")
|
||||
});
|
||||
static CHECKING_PATCH: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Checking patch\\s+(?P<path>.+?)\\.\\.\\.$"));
|
||||
static UNMERGED_LINE: Lazy<Regex> = Lazy::new(|| regex_ci("^U\\s+(?P<path>.+)$"));
|
||||
static PATCH_FAILED: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)(?::\\d+)?(?:\\s|$)"));
|
||||
static DOES_NOT_APPLY: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+patch does not apply$"));
|
||||
static THREE_WAY_START: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$")
|
||||
});
|
||||
static THREE_WAY_FAILED: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$"));
|
||||
static FALLBACK_DIRECT: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$"));
|
||||
static LACKS_BLOB: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$",
|
||||
)
|
||||
});
|
||||
static INDEX_MISMATCH: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not match index\\b"));
|
||||
static NOT_IN_INDEX: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not exist in index\\b"));
|
||||
static ALREADY_EXISTS_WT: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^error:\\s+(?P<path>.+?)\\s+already exists in (?:the )?working directory\\b")
|
||||
});
|
||||
static FILE_EXISTS: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)\\s+File exists"));
|
||||
static RENAMED_DELETED: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P<path>.+?)\\s+has been renamed\\/deleted"));
|
||||
static CANNOT_APPLY_BINARY: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+without full index line$",
|
||||
)
|
||||
});
|
||||
static BINARY_DOES_NOT_APPLY: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
|
||||
});
|
||||
static BINARY_INCORRECT_RESULT: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^error:\\s+binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+creates incorrect result\\b",
|
||||
)
|
||||
});
|
||||
static CANNOT_READ_CURRENT: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
|
||||
});
|
||||
static SKIPPED_PATCH: Lazy<Regex> =
|
||||
Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P<path>.+?)['\\\"]\\.$"));
|
||||
static CANNOT_MERGE_BINARY_WARN: Lazy<Regex> = Lazy::new(|| {
|
||||
regex_ci(
|
||||
"^warning:\\s*Cannot merge binary files:\\s+(?P<path>.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)",
|
||||
)
|
||||
});
|
||||
|
||||
for raw_line in combined.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// === "Checking patch <path>..." 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 <path>” 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}"))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
263
codex-rs/git-utils/src/patch_paths.rs
Normal file
263
codex-rs/git-utils/src/patch_paths.rs
Normal file
@@ -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<Vec<String>> {
|
||||
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<String> =
|
||||
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<String> {
|
||||
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<Vec<String>> {
|
||||
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<Vec<String>> {
|
||||
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<String>, 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<String> {
|
||||
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<String> = 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;
|
||||
380
codex-rs/git-utils/src/patch_paths_tests.rs
Normal file
380
codex-rs/git-utils/src/patch_paths_tests.rs
Normal file
@@ -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<Mutex<()>> = 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<Vec<String>> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user