mirror of
https://github.com/openai/codex.git
synced 2026-09-04 15:08:45 +00:00
849 lines
33 KiB
Rust
849 lines
33 KiB
Rust
//! Helpers for applying unified diffs using the system `git` binary.
|
||
//!
|
||
//! The entry point is [`apply_git_patch`], which writes a diff to a temporary
|
||
//! file, shells out to `git apply` with the right flags, and then parses the
|
||
//! command’s output into structured details. Callers can opt into dry-run
|
||
//! mode via [`ApplyGitRequest::preflight`] and inspect the resulting paths to
|
||
//! learn what would change before applying for real.
|
||
|
||
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::git_config_sources::ensure_no_worktree_config_sources;
|
||
use crate::patch_paths::extract_effective_paths_from_patch;
|
||
use crate::patch_paths::stage_effective_paths;
|
||
use crate::safe_git::DISABLED_HOOKS_PATH;
|
||
use crate::safe_git::ensure_no_selected_executable_git_filters;
|
||
#[cfg(test)]
|
||
use crate::safe_git::isolate_git_command_environment;
|
||
|
||
/// Parameters for invoking [`apply_git_patch`].
|
||
#[derive(Debug, Clone)]
|
||
pub struct ApplyGitRequest {
|
||
pub cwd: PathBuf,
|
||
pub diff: String,
|
||
pub revert: bool,
|
||
pub preflight: bool,
|
||
}
|
||
|
||
/// Result of running [`apply_git_patch`], including paths gleaned from stdout/stderr.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ApplyGitResult {
|
||
pub exit_code: i32,
|
||
pub applied_paths: Vec<String>,
|
||
pub skipped_paths: Vec<String>,
|
||
pub conflicted_paths: Vec<String>,
|
||
pub stdout: String,
|
||
pub stderr: String,
|
||
pub cmd_for_log: String,
|
||
}
|
||
|
||
/// Apply a unified diff to the target repository by shelling out to `git apply`.
|
||
///
|
||
/// When [`ApplyGitRequest::preflight`] is `true`, this behaves like `git apply --check` and
|
||
/// leaves the working tree untouched while still parsing the command output for diagnostics.
|
||
pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
|
||
let git = GitRunner::for_cwd_io(&req.cwd)?;
|
||
let mut cfg_parts = configured_git_config_parts();
|
||
ensure_no_worktree_config_sources(&git, &req.cwd, &cfg_parts)?;
|
||
let requested_cwd = std::fs::canonicalize(&req.cwd)?;
|
||
let git_root = resolve_git_root(&git, &req.cwd, &cfg_parts)?;
|
||
if git_root != requested_cwd {
|
||
ensure_no_worktree_config_sources(&git, &git_root, &cfg_parts)?;
|
||
}
|
||
|
||
// Write unified diff into a temporary file
|
||
let (tmpdir, patch_path) = write_temp_patch(&req.diff)?;
|
||
// Keep tmpdir alive until function end to ensure the file exists
|
||
let _guard = tmpdir;
|
||
let patch_paths =
|
||
extract_effective_paths_from_patch(&git, &git_root, &patch_path, req.revert, &cfg_parts)?;
|
||
let filter_guard =
|
||
ensure_no_selected_executable_git_filters(&git, &git_root, &patch_paths, &cfg_parts)?;
|
||
cfg_parts.extend(safe_git_config_parts());
|
||
cfg_parts.extend_from_slice(filter_guard.git_config_args());
|
||
|
||
if req.revert && !req.preflight {
|
||
// Stage WT paths first to avoid index mismatch on revert.
|
||
stage_effective_paths(&git, &git_root, &patch_paths, &cfg_parts)?;
|
||
}
|
||
|
||
// Build git args
|
||
let mut args: Vec<String> = vec!["apply".into(), "--3way".into()];
|
||
if req.revert {
|
||
args.push("-R".into());
|
||
}
|
||
|
||
args.push(patch_path.to_string_lossy().to_string());
|
||
|
||
// Optional preflight: dry-run only; do not modify working tree
|
||
if req.preflight {
|
||
let mut check_args = vec!["apply".to_string(), "--check".to_string()];
|
||
if req.revert {
|
||
check_args.push("-R".to_string());
|
||
}
|
||
check_args.push(patch_path.to_string_lossy().to_string());
|
||
let rendered = render_command_for_log(&git_root, &cfg_parts, &check_args);
|
||
let (c_code, c_out, c_err) = run_git(&git, &git_root, &cfg_parts, &check_args)?;
|
||
let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
|
||
parse_git_apply_output(&c_out, &c_err);
|
||
applied_paths.sort();
|
||
applied_paths.dedup();
|
||
skipped_paths.sort();
|
||
skipped_paths.dedup();
|
||
conflicted_paths.sort();
|
||
conflicted_paths.dedup();
|
||
return Ok(ApplyGitResult {
|
||
exit_code: c_code,
|
||
applied_paths,
|
||
skipped_paths,
|
||
conflicted_paths,
|
||
stdout: c_out,
|
||
stderr: c_err,
|
||
cmd_for_log: rendered,
|
||
});
|
||
}
|
||
|
||
let cmd_for_log = render_command_for_log(&git_root, &cfg_parts, &args);
|
||
let (code, stdout, stderr) = run_git(&git, &git_root, &cfg_parts, &args)?;
|
||
|
||
let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
|
||
parse_git_apply_output(&stdout, &stderr);
|
||
applied_paths.sort();
|
||
applied_paths.dedup();
|
||
skipped_paths.sort();
|
||
skipped_paths.dedup();
|
||
conflicted_paths.sort();
|
||
conflicted_paths.dedup();
|
||
|
||
Ok(ApplyGitResult {
|
||
exit_code: code,
|
||
applied_paths,
|
||
skipped_paths,
|
||
conflicted_paths,
|
||
stdout,
|
||
stderr,
|
||
cmd_for_log,
|
||
})
|
||
}
|
||
|
||
fn resolve_git_root(
|
||
git: &GitRunner,
|
||
cwd: &Path,
|
||
git_config_args: &[String],
|
||
) -> io::Result<PathBuf> {
|
||
let requested_cwd = std::fs::canonicalize(cwd)?;
|
||
let mut command = git.command_for_cwd(&requested_cwd)?;
|
||
command
|
||
.args(git_config_args)
|
||
.arg("rev-parse")
|
||
.arg("--show-toplevel");
|
||
let out = git.output(command)?;
|
||
let code = out.status.code().unwrap_or(-1);
|
||
if code != 0 {
|
||
return Err(io::Error::other(format!(
|
||
"not a git repository (exit {}): {}",
|
||
code,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
)));
|
||
}
|
||
let reported_root = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
|
||
let root = std::fs::canonicalize(&reported_root)?;
|
||
let expected_root = crate::get_git_repo_root(&requested_cwd)
|
||
.ok_or_else(|| {
|
||
io::Error::new(
|
||
io::ErrorKind::PermissionDenied,
|
||
format!(
|
||
"refusing to apply a patch because Git resolved worktree {} without a .git marker above requested cwd {}",
|
||
root.display(),
|
||
requested_cwd.display()
|
||
),
|
||
)
|
||
})
|
||
.and_then(std::fs::canonicalize)?;
|
||
if root != expected_root {
|
||
return Err(io::Error::new(
|
||
io::ErrorKind::PermissionDenied,
|
||
format!(
|
||
"refusing to apply a patch because Git resolved worktree {} instead of expected worktree {} for requested cwd {}",
|
||
root.display(),
|
||
expected_root.display(),
|
||
requested_cwd.display()
|
||
),
|
||
));
|
||
}
|
||
Ok(root)
|
||
}
|
||
|
||
fn configured_git_config_parts() -> Vec<String> {
|
||
let mut cfg_parts = Vec::new();
|
||
if let Ok(cfg) = std::env::var("CODEX_APPLY_GIT_CFG") {
|
||
for pair in cfg.split(',') {
|
||
let pair = pair.trim();
|
||
if pair.is_empty() || !pair.contains('=') {
|
||
continue;
|
||
}
|
||
cfg_parts.push("-c".to_string());
|
||
cfg_parts.push(pair.to_string());
|
||
}
|
||
}
|
||
cfg_parts
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
pub(crate) fn run_git(
|
||
git: &GitRunner,
|
||
cwd: &Path,
|
||
git_cfg: &[String],
|
||
args: &[String],
|
||
) -> io::Result<(i32, String, String)> {
|
||
let mut cmd = git.command_for_cwd(cwd)?;
|
||
for p in git_cfg {
|
||
cmd.arg(p);
|
||
}
|
||
for a in args {
|
||
cmd.arg(a);
|
||
}
|
||
let out = git.output(cmd)?;
|
||
let code = out.status.code().unwrap_or(-1);
|
||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||
Ok((code, stdout, stderr))
|
||
}
|
||
|
||
pub(crate) fn safe_git_config_parts() -> Vec<String> {
|
||
vec![
|
||
"-c".to_string(),
|
||
format!("core.hooksPath={DISABLED_HOOKS_PATH}"),
|
||
"-c".to_string(),
|
||
FsmonitorOverride::Disabled.git_config_arg().to_string(),
|
||
]
|
||
}
|
||
|
||
fn quote_shell(s: &str) -> String {
|
||
let simple = s
|
||
.chars()
|
||
.all(|c| c.is_ascii_alphanumeric() || "-_.:/@%+".contains(c));
|
||
if simple {
|
||
s.to_string()
|
||
} else {
|
||
format!("'{}'", s.replace('\'', "'\\''"))
|
||
}
|
||
}
|
||
|
||
fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> String {
|
||
let mut parts: Vec<String> = Vec::new();
|
||
parts.push("git".to_string());
|
||
for a in git_cfg {
|
||
parts.push(quote_shell(a));
|
||
}
|
||
for a in args {
|
||
parts.push(quote_shell(a));
|
||
}
|
||
format!(
|
||
"(cd {} && {})",
|
||
quote_shell(&cwd.display().to_string()),
|
||
parts.join(" ")
|
||
)
|
||
}
|
||
|
||
#[cfg(all(test, unix))]
|
||
#[path = "apply_transport_tests.rs"]
|
||
mod transport_tests;
|
||
|
||
#[cfg(test)]
|
||
#[path = "apply_filter_tests.rs"]
|
||
mod filter_tests;
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::ffi::OsStr;
|
||
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]);
|
||
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 run_isolated_test(test_name: &str, env: &[(&str, &OsStr)]) {
|
||
let mut command = std::process::Command::new(std::env::current_exe().expect("test binary"));
|
||
isolate_git_command_environment(&mut command);
|
||
command
|
||
.arg(test_name)
|
||
.arg("--exact")
|
||
.arg("--nocapture")
|
||
.env("CODEX_GIT_UTILS_APPLY_ENV_CHILD", "1")
|
||
.env("RUST_TEST_THREADS", "1");
|
||
for (name, value) in env {
|
||
command.env(name, value);
|
||
}
|
||
let output = command.output().expect("run isolated test process");
|
||
assert!(
|
||
output.status.success(),
|
||
"isolated test {test_name} failed:\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
}
|
||
|
||
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 commit_filter_attributes(root: &Path, tracked_path: &str) {
|
||
std::fs::write(
|
||
root.join(".gitattributes"),
|
||
format!("{tracked_path} filter=x=y\n"),
|
||
)
|
||
.expect("write attributes");
|
||
let (add_code, _, add_err) = run(root, &["git", "add", ".gitattributes"]);
|
||
assert_eq!(add_code, 0, "add attributes: {add_err}");
|
||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "attributes"]);
|
||
assert_eq!(commit_code, 0, "commit attributes: {commit_err}");
|
||
}
|
||
|
||
fn configure_clean_filter(root: &Path, tracked_path: &str) {
|
||
commit_filter_attributes(root, tracked_path);
|
||
let (config_code, _, config_err) = run(
|
||
root,
|
||
&[
|
||
"git",
|
||
"config",
|
||
"filter.x=y.clean",
|
||
"git config codex.filterran true && git hash-object --stdin",
|
||
],
|
||
);
|
||
assert_eq!(config_code, 0, "configure filter: {config_err}");
|
||
}
|
||
|
||
fn configure_worktree_clean_filter(root: &Path, tracked_path: &str) {
|
||
commit_filter_attributes(root, tracked_path);
|
||
let (extension_code, _, extension_err) = run(
|
||
root,
|
||
&["git", "config", "extensions.worktreeConfig", "true"],
|
||
);
|
||
assert_eq!(extension_code, 0, "enable worktree config: {extension_err}");
|
||
let (config_code, _, config_err) = run(
|
||
root,
|
||
&[
|
||
"git",
|
||
"config",
|
||
"--worktree",
|
||
"filter.x=y.clean",
|
||
"git config codex.filterran true && git hash-object --stdin",
|
||
],
|
||
);
|
||
assert_eq!(config_code, 0, "configure worktree filter: {config_err}");
|
||
}
|
||
|
||
fn configured_filter_ran(root: &Path) -> bool {
|
||
let (code, _, _) = run(root, &["git", "config", "--get", "codex.filterran"]);
|
||
code == 0
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn trusted_git_directory() -> PathBuf {
|
||
std::env::split_paths(&std::env::var_os("PATH").expect("PATH"))
|
||
.find(|directory| directory.is_absolute() && directory.join("git").is_file())
|
||
.expect("trusted Git directory")
|
||
}
|
||
|
||
#[test]
|
||
fn parse_output_unescapes_quoted_paths() {
|
||
let stderr = "error: patch failed: \"hello\\tworld.txt\":1\n";
|
||
let (applied, skipped, conflicted) = parse_git_apply_output("", stderr);
|
||
assert_eq!(applied, Vec::<String>::new());
|
||
assert_eq!(conflicted, Vec::<String>::new());
|
||
assert_eq!(skipped, vec!["hello\tworld.txt".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn apply_add_success() {
|
||
let _g = env_lock().lock().unwrap();
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
let nested_cwd = root.join("nested");
|
||
std::fs::create_dir(&nested_cwd).expect("nested cwd");
|
||
|
||
let diff = "diff --git a/hello.txt b/hello.txt\nnew file mode 100644\n--- /dev/null\n+++ b/hello.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n";
|
||
let req = ApplyGitRequest {
|
||
cwd: nested_cwd,
|
||
diff: diff.to_string(),
|
||
revert: false,
|
||
preflight: false,
|
||
};
|
||
let r = apply_git_patch(&req).expect("run apply");
|
||
assert_eq!(r.exit_code, 0, "exit code 0");
|
||
// File exists now
|
||
assert!(root.join("hello.txt").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn apply_uses_cwd_repo_despite_inherited_repository_selectors() {
|
||
let _g = env_lock().lock().unwrap();
|
||
if std::env::var_os("CODEX_GIT_UTILS_APPLY_ENV_CHILD").is_none() {
|
||
let alternate = init_repo();
|
||
let alternate_root = alternate.path();
|
||
std::fs::write(alternate_root.join("sentinel.txt"), "alternate\n")
|
||
.expect("write alternate sentinel");
|
||
let (add_code, _, add_err) = run(alternate_root, &["git", "add", "sentinel.txt"]);
|
||
assert_eq!(add_code, 0, "add alternate sentinel: {add_err}");
|
||
let (commit_code, _, commit_err) =
|
||
run(alternate_root, &["git", "commit", "-m", "alternate"]);
|
||
assert_eq!(commit_code, 0, "commit alternate sentinel: {commit_err}");
|
||
|
||
let alternate_git_dir = alternate_root.join(".git");
|
||
let alternate_index = alternate_git_dir.join("index");
|
||
run_isolated_test(
|
||
"apply::tests::apply_uses_cwd_repo_despite_inherited_repository_selectors",
|
||
&[
|
||
("GIT_DIR", alternate_git_dir.as_os_str()),
|
||
("GIT_WORK_TREE", alternate_root.as_os_str()),
|
||
("GIT_COMMON_DIR", alternate_git_dir.as_os_str()),
|
||
("GIT_INDEX_FILE", alternate_index.as_os_str()),
|
||
("GIT_PREFIX", OsStr::new("elsewhere/")),
|
||
],
|
||
);
|
||
assert_eq!(
|
||
read_file_normalized(&alternate_root.join("sentinel.txt")),
|
||
"alternate\n"
|
||
);
|
||
return;
|
||
}
|
||
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
std::fs::write(root.join("file.txt"), "old\n").expect("write target file");
|
||
let (add_code, _, add_err) = run(root, &["git", "add", "file.txt"]);
|
||
assert_eq!(add_code, 0, "add target file: {add_err}");
|
||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "target"]);
|
||
assert_eq!(commit_code, 0, "commit target file: {commit_err}");
|
||
|
||
let result = apply_git_patch(&ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n".to_string(),
|
||
revert: false,
|
||
preflight: false,
|
||
})
|
||
.expect("apply in cwd-selected repository");
|
||
assert_eq!(result.exit_code, 0);
|
||
assert_eq!(read_file_normalized(&root.join("file.txt")), "new\n");
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn apply_uses_logical_process_cwd_to_reject_enclosing_git() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let _g = env_lock().lock().unwrap();
|
||
if std::env::var_os("CODEX_GIT_UTILS_APPLY_LOGICAL_CWD_CHILD").is_none() {
|
||
let fixture = tempfile::tempdir().expect("fixture");
|
||
let outer = fixture.path().join("outer");
|
||
let physical_nested = fixture.path().join("physical-nested");
|
||
let lexical_nested = outer.join("nested");
|
||
let outer_bin = outer.join("bin");
|
||
let outer_git = outer_bin.join("git");
|
||
let marker = outer_bin.join("git.ran");
|
||
std::fs::create_dir_all(&outer_bin).expect("outer Git directory");
|
||
std::fs::create_dir_all(&physical_nested).expect("physical nested repository");
|
||
let (outer_init, _, outer_err) = run(&outer, &["git", "init", "-q"]);
|
||
assert_eq!(outer_init, 0, "init outer repository: {outer_err}");
|
||
let (nested_init, _, nested_err) = run(&physical_nested, &["git", "init", "-q"]);
|
||
assert_eq!(nested_init, 0, "init nested repository: {nested_err}");
|
||
std::os::unix::fs::symlink(&physical_nested, &lexical_nested)
|
||
.expect("symlink nested repository");
|
||
std::fs::write(&outer_git, "#!/bin/sh\nprintf ran >\"$0.ran\"\nexit 1\n")
|
||
.expect("outer Git shim");
|
||
let mut permissions = std::fs::metadata(&outer_git)
|
||
.expect("outer Git metadata")
|
||
.permissions();
|
||
permissions.set_mode(0o755);
|
||
std::fs::set_permissions(&outer_git, permissions).expect("executable outer Git");
|
||
let path = std::env::join_paths([outer_bin, trusted_git_directory()]).expect("PATH");
|
||
|
||
let mut command =
|
||
std::process::Command::new(std::env::current_exe().expect("test binary"));
|
||
isolate_git_command_environment(&mut command);
|
||
let output = command
|
||
.arg("apply::tests::apply_uses_logical_process_cwd_to_reject_enclosing_git")
|
||
.arg("--exact")
|
||
.arg("--nocapture")
|
||
.current_dir(&lexical_nested)
|
||
.env("CODEX_GIT_UTILS_APPLY_LOGICAL_CWD_CHILD", "1")
|
||
.env("CODEX_GIT_UTILS_APPLY_LOGICAL_CWD_MARKER", &marker)
|
||
.env("PWD", &lexical_nested)
|
||
.env("PATH", path)
|
||
.env("RUST_TEST_THREADS", "1")
|
||
.output()
|
||
.expect("run isolated logical-cwd test");
|
||
assert!(
|
||
output.status.success(),
|
||
"isolated logical-cwd test failed:\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
assert!(!marker.exists(), "enclosing Git shim must not run");
|
||
return;
|
||
}
|
||
|
||
let cwd = std::env::current_dir().expect("physical process cwd");
|
||
let marker = PathBuf::from(
|
||
std::env::var_os("CODEX_GIT_UTILS_APPLY_LOGICAL_CWD_MARKER").expect("marker path"),
|
||
);
|
||
let result = apply_git_patch(&ApplyGitRequest {
|
||
cwd,
|
||
diff: "diff --git a/hello.txt b/hello.txt\nnew file mode 100644\n--- /dev/null\n+++ b/hello.txt\n@@ -0,0 +1 @@\n+hello\n".to_string(),
|
||
revert: false,
|
||
preflight: true,
|
||
})
|
||
.expect("preflight through trusted Git");
|
||
assert_eq!(result.exit_code, 0, "preflight should succeed");
|
||
assert!(!marker.exists(), "enclosing Git shim must not run");
|
||
}
|
||
|
||
#[test]
|
||
fn apply_modify_conflict() {
|
||
let _g = env_lock().lock().unwrap();
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
// seed file and commit
|
||
std::fs::write(root.join("file.txt"), "line1\nline2\nline3\n").unwrap();
|
||
let _ = run(root, &["git", "add", "file.txt"]);
|
||
let _ = run(root, &["git", "commit", "-m", "seed"]);
|
||
// local edit (unstaged)
|
||
std::fs::write(root.join("file.txt"), "line1\nlocal2\nline3\n").unwrap();
|
||
// patch wants to change the same line differently
|
||
let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+remote2\n line3\n";
|
||
let req = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: diff.to_string(),
|
||
revert: false,
|
||
preflight: false,
|
||
};
|
||
let r = apply_git_patch(&req).expect("run apply");
|
||
assert_ne!(r.exit_code, 0, "non-zero exit on conflict");
|
||
}
|
||
|
||
#[test]
|
||
fn apply_modify_skipped_missing_index() {
|
||
let _g = env_lock().lock().unwrap();
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
// Try to modify a file that is not in the index
|
||
let diff = "diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
|
||
let req = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: diff.to_string(),
|
||
revert: false,
|
||
preflight: false,
|
||
};
|
||
let r = apply_git_patch(&req).expect("run apply");
|
||
assert_ne!(r.exit_code, 0, "non-zero exit on missing index");
|
||
}
|
||
|
||
#[test]
|
||
fn apply_then_revert_success() {
|
||
let _g = env_lock().lock().unwrap();
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
// Seed file and commit original content
|
||
std::fs::write(root.join("file.txt"), "orig\n").unwrap();
|
||
let _ = run(root, &["git", "add", "file.txt"]);
|
||
let _ = run(root, &["git", "commit", "-m", "seed"]);
|
||
|
||
// Forward patch: orig -> ORIG
|
||
let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n";
|
||
let apply_req = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: diff.to_string(),
|
||
revert: false,
|
||
preflight: false,
|
||
};
|
||
let res_apply = apply_git_patch(&apply_req).expect("apply ok");
|
||
assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
|
||
let after_apply = read_file_normalized(&root.join("file.txt"));
|
||
assert_eq!(after_apply, "ORIG\n");
|
||
|
||
// Revert patch: ORIG -> orig (stage paths first; engine handles it)
|
||
let revert_req = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: diff.to_string(),
|
||
revert: true,
|
||
preflight: false,
|
||
};
|
||
let res_revert = apply_git_patch(&revert_req).expect("revert ok");
|
||
assert_eq!(res_revert.exit_code, 0, "revert apply succeeded");
|
||
let after_revert = read_file_normalized(&root.join("file.txt"));
|
||
assert_eq!(after_revert, "orig\n");
|
||
}
|
||
|
||
#[test]
|
||
fn revert_preflight_does_not_stage_index() {
|
||
let _g = env_lock().lock().unwrap();
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
// Seed repo and apply forward patch so the working tree reflects the change.
|
||
std::fs::write(root.join("file.txt"), "orig\n").unwrap();
|
||
let _ = run(root, &["git", "add", "file.txt"]);
|
||
let _ = run(root, &["git", "commit", "-m", "seed"]);
|
||
|
||
let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n";
|
||
let apply_req = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: diff.to_string(),
|
||
revert: false,
|
||
preflight: false,
|
||
};
|
||
let res_apply = apply_git_patch(&apply_req).expect("apply ok");
|
||
assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
|
||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-am", "apply change"]);
|
||
assert_eq!(commit_code, 0, "commit applied change: {commit_err}");
|
||
|
||
let (_code_before, staged_before, _stderr_before) =
|
||
run(root, &["git", "diff", "--cached", "--name-only"]);
|
||
|
||
let preflight_req = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: diff.to_string(),
|
||
revert: true,
|
||
preflight: true,
|
||
};
|
||
let res_preflight = apply_git_patch(&preflight_req).expect("preflight ok");
|
||
assert_eq!(res_preflight.exit_code, 0, "revert preflight succeeded");
|
||
let (_code_after, staged_after, _stderr_after) =
|
||
run(root, &["git", "diff", "--cached", "--name-only"]);
|
||
assert_eq!(
|
||
staged_after.trim(),
|
||
staged_before.trim(),
|
||
"preflight should not stage new paths",
|
||
);
|
||
|
||
let after_preflight = read_file_normalized(&root.join("file.txt"));
|
||
assert_eq!(after_preflight, "ORIG\n");
|
||
}
|
||
|
||
#[test]
|
||
fn preflight_blocks_partial_changes() {
|
||
let _g = env_lock().lock().unwrap();
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
// Build a multi-file diff: one valid add (ok.txt) and one invalid modify (ghost.txt)
|
||
let diff = "diff --git a/ok.txt b/ok.txt\nnew file mode 100644\n--- /dev/null\n+++ b/ok.txt\n@@ -0,0 +1,2 @@\n+alpha\n+beta\n\n\
|
||
diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
|
||
|
||
// 1) With preflight enabled, nothing should be changed (even though ok.txt could be added)
|
||
let req1 = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: diff.to_string(),
|
||
revert: false,
|
||
preflight: true,
|
||
};
|
||
let r1 = apply_git_patch(&req1).expect("preflight apply");
|
||
assert_ne!(r1.exit_code, 0, "preflight reports failure");
|
||
assert!(
|
||
!root.join("ok.txt").exists(),
|
||
"preflight must prevent adding ok.txt"
|
||
);
|
||
assert!(
|
||
r1.cmd_for_log.contains("--check"),
|
||
"preflight path recorded --check"
|
||
);
|
||
|
||
// 2) Without preflight, we should see no --check in the executed command
|
||
let req2 = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: diff.to_string(),
|
||
revert: false,
|
||
preflight: false,
|
||
};
|
||
let r2 = apply_git_patch(&req2).expect("direct apply");
|
||
assert_ne!(r2.exit_code, 0, "apply is expected to fail overall");
|
||
assert!(
|
||
!r2.cmd_for_log.contains("--check"),
|
||
"non-preflight path should not use --check"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn apply_rejects_configured_clean_filter_without_running_it() {
|
||
let _g = env_lock().lock().unwrap();
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
std::fs::write(root.join("file.txt"), "orig\n").expect("write file");
|
||
let (add_code, _, add_err) = run(root, &["git", "add", "file.txt"]);
|
||
assert_eq!(add_code, 0, "add file: {add_err}");
|
||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "seed"]);
|
||
assert_eq!(commit_code, 0, "commit file: {commit_err}");
|
||
configure_clean_filter(root, "file.txt");
|
||
|
||
let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+next\n";
|
||
for (revert, preflight) in [(false, false), (false, true), (true, false), (true, true)] {
|
||
let request = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: diff.to_string(),
|
||
revert,
|
||
preflight,
|
||
};
|
||
let error = apply_git_patch(&request).expect_err("reject configured filter");
|
||
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
|
||
assert!(!configured_filter_ran(root));
|
||
assert_eq!(read_file_normalized(&root.join("file.txt")), "orig\n");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn apply_rejects_worktree_scoped_clean_filter_without_running_it() {
|
||
let _g = env_lock().lock().unwrap();
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
std::fs::write(root.join("file.txt"), "orig\n").expect("write file");
|
||
let (add_code, _, add_err) = run(root, &["git", "add", "file.txt"]);
|
||
assert_eq!(add_code, 0, "add file: {add_err}");
|
||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "seed"]);
|
||
assert_eq!(commit_code, 0, "commit file: {commit_err}");
|
||
configure_worktree_clean_filter(root, "file.txt");
|
||
|
||
let request = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+next\n".to_string(),
|
||
revert: false,
|
||
preflight: true,
|
||
};
|
||
let error = apply_git_patch(&request).expect_err("reject worktree filter");
|
||
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
|
||
assert!(!configured_filter_ran(root));
|
||
assert_eq!(read_file_normalized(&root.join("file.txt")), "orig\n");
|
||
}
|
||
|
||
#[test]
|
||
fn apply_probe_rejects_command_scoped_clean_filter() {
|
||
let _g = env_lock().lock().unwrap();
|
||
if std::env::var_os("CODEX_GIT_UTILS_APPLY_ENV_CHILD").is_none() {
|
||
run_isolated_test(
|
||
"apply::tests::apply_probe_rejects_command_scoped_clean_filter",
|
||
&[(
|
||
"CODEX_APPLY_GIT_CFG",
|
||
OsStr::new(
|
||
"filter.codex-test.clean=git config codex.filterran true && git hash-object --stdin",
|
||
),
|
||
)],
|
||
);
|
||
return;
|
||
}
|
||
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
std::fs::write(root.join("test.txt"), "orig\n").expect("write file");
|
||
let (add_code, _, add_err) = run(root, &["git", "add", "test.txt"]);
|
||
assert_eq!(add_code, 0, "add file: {add_err}");
|
||
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "seed"]);
|
||
assert_eq!(commit_code, 0, "commit file: {commit_err}");
|
||
std::fs::write(root.join(".gitattributes"), "test.txt filter=codex-test\n")
|
||
.expect("attributes");
|
||
|
||
let request = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: "diff --git a/test.txt b/test.txt\n--- a/test.txt\n+++ b/test.txt\n@@ -1 +1 @@\n-orig\n+next\n".to_string(),
|
||
revert: false,
|
||
preflight: true,
|
||
};
|
||
let error = apply_git_patch(&request).expect_err("reject command-scoped filter");
|
||
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
|
||
assert!(!configured_filter_ran(root));
|
||
assert_eq!(read_file_normalized(&root.join("test.txt")), "orig\n");
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_git_root_rejects_core_worktree_redirection() {
|
||
let temp = tempfile::tempdir().expect("tempdir");
|
||
let attacker = temp.path().join("attacker");
|
||
let victim = temp.path().join("victim");
|
||
std::fs::create_dir_all(&attacker).expect("attacker");
|
||
std::fs::create_dir_all(&victim).expect("victim");
|
||
let (init_code, _, init_err) = run(&attacker, &["git", "init"]);
|
||
assert_eq!(init_code, 0, "init attacker repo: {init_err}");
|
||
|
||
for redirected_worktree in [&victim, temp.path()] {
|
||
let redirected_worktree = redirected_worktree.to_string_lossy();
|
||
let (config_code, _, config_err) = run(
|
||
&attacker,
|
||
&["git", "config", "core.worktree", &redirected_worktree],
|
||
);
|
||
assert_eq!(config_code, 0, "configure core.worktree: {config_err}");
|
||
|
||
let git = GitRunner::for_cwd_io(&attacker).expect("trusted Git");
|
||
let error =
|
||
resolve_git_root(&git, &attacker, &[]).expect_err("reject redirected worktree");
|
||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||
assert!(error.to_string().contains("instead of expected worktree"));
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn apply_propagates_unsafe_repository_metadata_before_git_launch() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let repo = init_repo();
|
||
let root = repo.path();
|
||
let external = tempfile::tempdir().expect("external metadata parent");
|
||
let admin = external.path().join("admin");
|
||
std::fs::rename(root.join(".git"), &admin).expect("move metadata external");
|
||
symlink(&admin, root.join("switch")).expect("worktree metadata switch");
|
||
std::fs::write(root.join(".git"), "gitdir: switch\n").expect("write gitdir marker");
|
||
let (code, _, stderr) = run(root, &["git", "rev-parse", "--absolute-git-dir"]);
|
||
assert_eq!(code, 0, "native Git fixture: {stderr}");
|
||
|
||
let request = ApplyGitRequest {
|
||
cwd: root.to_path_buf(),
|
||
diff: String::new(),
|
||
revert: false,
|
||
preflight: true,
|
||
};
|
||
let error = apply_git_patch(&request).expect_err("unsafe metadata route");
|
||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||
assert!(error.to_string().contains("Git metadata route crosses"));
|
||
assert!(!error.to_string().contains("PATH"));
|
||
}
|
||
}
|