Files
codex/codex-rs/git-utils/src/patch_paths_tests.rs

2574 lines
93 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use crate::apply::ApplyGitRequest;
use crate::apply::apply_git_patch;
use crate::apply::safe_git_config_parts;
use crate::exact_staging::StagePathsResult;
use crate::exact_staging::update_index_exact_paths_from_apply;
use crate::exact_staging::update_index_exact_paths_standalone;
use crate::guarded_config::config_source_authorization_count;
use crate::guarded_config::reset_config_source_authorization_count;
use crate::safe_git::filter_policy_overlay_count;
use crate::safe_git::filter_policy_read_count;
use crate::safe_git::reset_filter_policy_counts;
use std::ffi::OsStr;
use std::io;
use std::path::Path;
use std::path::PathBuf;
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 run_isolated_test(test_name: &str, env: &[(&str, &OsStr)]) {
let mut command = std::process::Command::new(std::env::current_exe().expect("test binary"));
crate::safe_git::isolate_git_command_environment(&mut command);
let output = command
.args([test_name, "--exact", "--nocapture"])
.env("CODEX_GIT_UTILS_PATH_ENV_CHILD", "1")
.env("RUST_TEST_THREADS", "1")
.envs(env.iter().copied())
.output()
.expect("run isolated test process");
assert!(
output.status.success(),
"isolated test {test_name} failed: {output:?}"
);
}
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 repo = init_repo();
let cwd = repo.path();
let git = GitRunner::for_cwd_io(cwd)?;
let git_root =
crate::get_git_repo_root(cwd).ok_or_else(|| io::Error::other("not a Git repository"))?;
let git_root = std::fs::canonicalize(git_root)?;
let config = GuardedGitConfig::authorize(&git, &git_root, Vec::new())?;
let paths = extract_effective_paths_from_patch_guarded(&config, &patch_path, revert)?;
drop(tmpdir);
Ok(paths)
}
fn best_effort_paths(diff: &str) -> Vec<String> {
let repo = init_repo();
extract_paths_from_patch_from_cwd(diff, repo.path())
}
#[cfg(unix)]
#[test]
fn best_effort_extraction_selects_the_physical_repo_from_a_symlinked_nested_cwd() {
let fixture = tempfile::tempdir().expect("fixture");
let outer = fixture.path().join("outer");
let target = fixture.path().join("target");
let nested = target.join("nested");
std::fs::create_dir_all(&outer).expect("outer");
std::fs::create_dir_all(&nested).expect("nested");
assert_eq!(run(&outer, &["git", "init", "-q"]).0, 0);
assert_eq!(run(&target, &["git", "init", "-q"]).0, 0);
let lexical_cwd = outer.join("linked-nested");
std::os::unix::fs::symlink(&nested, &lexical_cwd).expect("symlink nested cwd");
assert_eq!(
extract_paths_from_patch_from_cwd(&new_file_diff("physical.txt"), &lexical_cwd),
vec!["physical.txt".to_string()]
);
}
#[cfg(unix)]
#[test]
fn standalone_stage_paths_preserves_a_symlinked_repository_route() {
let fixture = tempfile::tempdir().expect("fixture");
let outer = fixture.path().join("outer");
let target = fixture.path().join("target");
std::fs::create_dir_all(&outer).expect("outer");
std::fs::create_dir_all(&target).expect("target");
assert_eq!(run(&outer, &["git", "init", "-q"]).0, 0);
assert_eq!(run(&target, &["git", "init", "-q"]).0, 0);
std::fs::write(target.join("routed.txt"), "routed\n").expect("write target file");
let lexical_root = fixture.path().join("linked-root");
std::os::unix::fs::symlink(&target, &lexical_root).expect("symlink repository root");
reset_config_source_authorization_count();
stage_paths(&lexical_root, &new_file_diff("routed.txt")).expect("stage through symlink route");
assert_eq!(config_source_authorization_count(), 1);
assert_eq!(
run(&target, &["git", "diff", "--cached", "--name-only"]).1,
"routed.txt\n"
);
assert!(
run(&outer, &["git", "diff", "--cached", "--name-only"])
.1
.is_empty()
);
}
#[cfg(windows)]
#[test]
fn standalone_stage_paths_preserves_a_junction_repository_route() {
let fixture = tempfile::tempdir().expect("fixture");
let outer = fixture.path().join("outer");
let target = fixture.path().join("target");
std::fs::create_dir_all(&outer).expect("outer");
std::fs::create_dir_all(&target).expect("target");
assert_eq!(run(&outer, &["git", "init", "-q"]).0, 0);
assert_eq!(run(&target, &["git", "init", "-q"]).0, 0);
std::fs::write(target.join("routed.txt"), "routed\n").expect("write target file");
let lexical_root = fixture.path().join("linked-root");
let _junction = create_dir_alias(&target, &lexical_root);
reset_config_source_authorization_count();
stage_paths(&lexical_root, &new_file_diff("routed.txt")).expect("stage through junction route");
assert_eq!(config_source_authorization_count(), 1);
assert_eq!(
run(&target, &["git", "diff", "--cached", "--name-only"]).1,
"routed.txt\n"
);
assert!(
run(&outer, &["git", "diff", "--cached", "--name-only"])
.1
.is_empty()
);
}
#[cfg(any(unix, windows))]
#[test]
fn standalone_stage_paths_preserves_an_independent_repository_alias() {
let fixture = tempfile::tempdir().expect("fixture");
let independent = fixture.path().join("independent");
let target = fixture.path().join("target");
std::fs::create_dir_all(&independent).expect("independent");
std::fs::create_dir_all(&target).expect("target");
assert_eq!(run(&independent, &["git", "init", "-q"]).0, 0);
assert_eq!(run(&target, &["git", "init", "-q"]).0, 0);
std::fs::write(target.join("routed.txt"), "routed\n").expect("write target file");
let lexical_root = independent.join("linked-root");
let _route = create_dir_alias(&target, &lexical_root);
stage_paths(&lexical_root, &new_file_diff("routed.txt"))
.expect("independent repository alias remains supported");
assert_eq!(
run(&target, &["git", "diff", "--cached", "--name-only"]).1,
"routed.txt\n"
);
assert!(
run(&independent, &["git", "diff", "--cached", "--name-only"])
.1
.is_empty()
);
}
#[cfg(any(unix, windows))]
#[test]
fn standalone_stage_paths_rejects_a_route_crossing_a_related_worktree() {
let fixture = tempfile::tempdir().expect("fixture");
let primary = fixture.path().join("primary");
let linked = fixture.path().join("linked");
std::fs::create_dir_all(&primary).expect("primary");
assert_eq!(run(&primary, &["git", "init", "-q"]).0, 0);
assert_eq!(
run(
&primary,
&["git", "config", "user.email", "codex@example.com"]
)
.0,
0
);
assert_eq!(run(&primary, &["git", "config", "user.name", "Codex"]).0, 0);
std::fs::write(primary.join("seed.txt"), "seed\n").expect("write seed");
assert_eq!(run(&primary, &["git", "add", "seed.txt"]).0, 0);
assert_eq!(run(&primary, &["git", "commit", "-m", "seed"]).0, 0);
let linked_arg = linked.to_string_lossy().into_owned();
assert_eq!(
run(
&primary,
&["git", "worktree", "add", "--detach", &linked_arg]
)
.0,
0
);
std::fs::write(linked.join("routed.txt"), "routed\n").expect("write linked file");
let crossing_route = primary.join("linked-route");
let _route = create_dir_alias(&linked, &crossing_route);
let error = stage_paths(&crossing_route, &new_file_diff("routed.txt"))
.expect_err("related-worktree crossing must reject");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(
run(&linked, &["git", "diff", "--cached", "--name-only"])
.1
.is_empty()
);
}
#[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!(best_effort_paths(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!["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!(best_effort_paths(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"
);
}
fn new_file_diff(path: &str) -> String {
format!(
"diff --git a/{path} b/{path}\nnew file mode 100644\n--- /dev/null\n+++ b/{path}\n@@ -0,0 +1 @@\n+new\n"
)
}
fn git_index_bytes(root: &Path) -> Vec<u8> {
let mut command = std::process::Command::new("git");
crate::safe_git::isolate_git_command_environment(&mut command);
let output = command
.args(["ls-files", "--stage", "-z"])
.current_dir(root)
.output()
.expect("inspect index");
assert!(output.status.success(), "{:?}", output.status);
output.stdout
}
fn assert_stage_refused(root: &Path, path: &str, before: &[u8]) {
let error = stage_paths(root, &new_file_diff(path)).expect_err("reject path alias");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(git_index_bytes(root), before);
}
fn commit_seed(root: &Path) {
std::fs::write(root.join("seed.txt"), "seed\n").expect("write seed");
assert_eq!(run(root, &["git", "add", "seed.txt"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "seed"]).0, 0);
}
fn init_repo_with_selected_filter(config_key: &str) -> tempfile::TempDir {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join(".gitattributes"), "file.txt filter=selected\n")
.expect("write attributes");
std::fs::write(root.join("file.txt"), "old\n").expect("write tracked file");
assert_eq!(run(root, &["git", "add", "."]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
assert_eq!(
run(
root,
&[
"git",
"config",
config_key,
"codex-definitely-missing-filter-command",
],
)
.0,
0
);
std::fs::write(root.join("file.txt"), "new\n").expect("modify tracked file");
repo
}
#[cfg(unix)]
struct RacyFilterFixture {
repo: tempfile::TempDir,
marker: PathBuf,
}
#[cfg(unix)]
fn init_racy_filter_fixture(driver: &str, command: &str) -> RacyFilterFixture {
use std::os::unix::fs::PermissionsExt;
use std::os::unix::fs::symlink;
use std::time::Duration;
use std::time::SystemTime;
let repo = init_repo();
let root = repo.path();
std::fs::write(
root.join(".gitattributes"),
format!(
"outside.txt filter={driver}\nlink filter={driver}\nselected.txt filter={driver}\n"
),
)
.expect("write attributes");
std::fs::write(root.join("outside.txt"), "outside-racy\n").expect("write racy file");
std::fs::write(root.join("target.txt"), "old target\n").expect("write target");
std::fs::write(root.join("selected.txt"), "old selected\n").expect("write selected target");
symlink("old-target", root.join("link")).expect("create initial symlink");
let future = SystemTime::UNIX_EPOCH + Duration::from_secs(1_893_484_800);
let outside = std::fs::File::options()
.read(true)
.write(true)
.open(root.join("outside.txt"))
.expect("open racy file");
outside
.set_times(std::fs::FileTimes::new().set_modified(future))
.expect("set future racy mtime");
assert_eq!(run(root, &["git", "add", "."]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
let marker = root.join("filter-ran");
let helper = root.join("filter-helper.sh");
let helper_body = if command == "clean" {
format!("#!/bin/sh\n: > '{}'\ncat\n", marker.display())
} else {
format!("#!/bin/sh\n: > '{}'\nexit 1\n", marker.display())
};
std::fs::write(&helper, helper_body).expect("write filter helper");
let mut permissions = std::fs::metadata(&helper)
.expect("filter helper metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&helper, permissions).expect("make filter helper executable");
let key = format!("filter.{driver}.{command}");
let helper_arg = helper.to_string_lossy().into_owned();
assert_eq!(run(root, &["git", "config", &key, &helper_arg]).0, 0);
let required_key = format!("filter.{driver}.required");
assert_eq!(run(root, &["git", "config", &required_key, "true"]).0, 0);
assert!(!marker.exists(), "fixture setup must not run the filter");
RacyFilterFixture { repo, marker }
}
fn index_entry(root: &Path, path: &str) -> String {
let (code, stdout, stderr) = run(root, &["git", "ls-files", "--stage", "--", path]);
assert_eq!(code, 0, "read index entry: {stderr}");
stdout
}
fn exact_stage(root: &Path, paths: &[&str], content_filter_paths: &[&str]) -> StagePathsResult {
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let canonical_root = std::fs::canonicalize(root).expect("canonical root");
let mut config =
GuardedGitConfig::authorize(&git, &canonical_root, Vec::new()).expect("authorized config");
update_index_exact_paths_standalone(
&mut config,
&paths
.iter()
.map(|path| (*path).to_string())
.collect::<Vec<_>>(),
&content_filter_paths
.iter()
.map(|path| (*path).to_string())
.collect::<Vec<_>>(),
)
.expect("run exact staging primitive")
}
fn stage_effective_paths(
git: &GitRunner,
root: &Path,
paths: &[String],
legacy_config_args: &[String],
) -> io::Result<()> {
let _ = legacy_config_args;
let canonical_root = std::fs::canonicalize(root)?;
let mut config = GuardedGitConfig::authorize(git, &canonical_root, Vec::new())?;
stage_effective_paths_standalone(&mut config, paths)
}
#[test]
fn standalone_stage_paths_uses_one_source_authorization_and_one_filter_read() {
if std::env::var_os("CODEX_GIT_UTILS_PATH_ENV_CHILD").is_none() {
let config_dir = tempfile::tempdir().expect("config tempdir");
let global_config = config_dir.path().join("global.gitconfig");
let system_config = config_dir.path().join("system.gitconfig");
std::fs::write(&global_config, "").expect("empty global config");
std::fs::write(&system_config, "").expect("empty system config");
run_isolated_test(
"patch_paths::tests::standalone_stage_paths_uses_one_source_authorization_and_one_filter_read",
&[
("GIT_CONFIG_GLOBAL", global_config.as_os_str()),
("GIT_CONFIG_SYSTEM", system_config.as_os_str()),
],
);
return;
}
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("single.txt"), "single\n").expect("write staged file");
reset_config_source_authorization_count();
reset_filter_policy_counts();
stage_paths(root, &new_file_diff("single.txt")).expect("standalone exact staging");
assert_eq!(config_source_authorization_count(), 1);
assert_eq!(filter_policy_read_count(), 1);
assert_eq!(filter_policy_overlay_count(), 0);
}
#[test]
fn composed_staging_freshly_observes_a_new_clean_driver_and_attribute() {
if std::env::var_os("CODEX_GIT_UTILS_PATH_ENV_CHILD").is_none() {
let config_dir = tempfile::tempdir().expect("config tempdir");
let global_config = config_dir.path().join("global.gitconfig");
let system_config = config_dir.path().join("system.gitconfig");
std::fs::write(&global_config, "").expect("empty global config");
std::fs::write(&system_config, "").expect("empty system config");
run_isolated_test(
"patch_paths::tests::composed_staging_freshly_observes_a_new_clean_driver_and_attribute",
&[
("GIT_CONFIG_GLOBAL", global_config.as_os_str()),
("GIT_CONFIG_SYSTEM", system_config.as_os_str()),
],
);
return;
}
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("file.txt"), "base\n").expect("write base file");
assert_eq!(run(root, &["git", "add", "file.txt"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
std::fs::write(root.join("file.txt"), "changed\n").expect("modify file");
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let canonical_root = std::fs::canonicalize(root).expect("canonical root");
reset_config_source_authorization_count();
reset_filter_policy_counts();
let mut config =
GuardedGitConfig::authorize(&git, &canonical_root, Vec::new()).expect("authorization");
config
.authorize_filter_paths(&["file.txt".to_string()])
.expect("apply snapshot without filters");
assert_eq!(
run(
root,
&[
"git",
"config",
"filter.fresh.clean",
"git config codex.stage-filter-ran true && git hash-object --stdin",
],
)
.0,
0
);
std::fs::write(root.join(".gitattributes"), "file.txt filter=fresh\n")
.expect("select fresh filter");
let error = update_index_exact_paths_from_apply(
&mut config,
&["file.txt".to_string()],
&["file.txt".to_string()],
)
.expect_err("fresh clean driver must refuse");
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
assert_eq!(config_source_authorization_count(), 1);
assert_eq!(filter_policy_read_count(), 2);
assert_eq!(filter_policy_overlay_count(), 1);
assert_ne!(
run(root, &["git", "config", "--get", "codex.stage-filter-ran"]).0,
0,
"fresh policy must not execute the filter"
);
}
#[test]
fn composed_staging_required_read_ignores_the_apply_overlay_and_is_fresh() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("file.txt"), "base\n").expect("write base file");
assert_eq!(run(root, &["git", "add", "file.txt"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
std::fs::write(root.join("file.txt"), "changed\n").expect("modify file");
assert_eq!(
run(
root,
&[
"git",
"config",
"filter.demo.smudge",
"git config codex.stage-smudge-ran true && git hash-object --stdin",
],
)
.0,
0
);
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let canonical_root = std::fs::canonicalize(root).expect("canonical root");
reset_config_source_authorization_count();
reset_filter_policy_counts();
let mut config =
GuardedGitConfig::authorize(&git, &canonical_root, Vec::new()).expect("authorization");
config
.authorize_filter_paths(&["file.txt".to_string()])
.expect("unselected apply filter snapshot");
std::fs::write(root.join(".gitattributes"), "file.txt filter=demo\n")
.expect("select smudge-only filter");
assert_eq!(
run(root, &["git", "config", "filter.demo.required", "true"]).0,
0
);
let error = update_index_exact_paths_from_apply(
&mut config,
&["file.txt".to_string()],
&["file.txt".to_string()],
)
.expect_err("fresh required=true must refuse");
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
assert_eq!(config_source_authorization_count(), 1);
assert_eq!(filter_policy_read_count(), 2);
assert_eq!(filter_policy_overlay_count(), 2);
assert_ne!(
run(root, &["git", "config", "--get", "codex.stage-smudge-ran"]).0,
0,
"smudge helper must remain neutralized"
);
}
#[test]
fn composed_exact_staging_refuses_requested_and_mapped_paths_outside_apply_universe() {
let requested_repo = init_repo();
let requested_root = requested_repo.path();
let requested_git = GitRunner::for_cwd_io(requested_root).expect("trusted Git");
let mut requested_config = GuardedGitConfig::authorize(
&requested_git,
&std::fs::canonicalize(requested_root).expect("canonical root"),
Vec::new(),
)
.expect("authorization");
requested_config
.authorize_filter_paths(&["allowed.txt".to_string()])
.expect("apply snapshot");
let requested_error = update_index_exact_paths_from_apply(
&mut requested_config,
&["allowed.txt".to_string(), "outside.txt".to_string()],
&["outside.txt".to_string()],
)
.expect_err("requested off-universe path must refuse");
assert_eq!(requested_error.kind(), io::ErrorKind::PermissionDenied);
let mapped_repo = init_repo();
let mapped_root = mapped_repo.path();
std::fs::write(mapped_root.join("File.txt"), "tracked\n").expect("write tracked spelling");
assert_eq!(run(mapped_root, &["git", "add", "File.txt"]).0, 0);
assert_eq!(run(mapped_root, &["git", "commit", "-m", "base"]).0, 0);
std::fs::write(mapped_root.join("file.txt"), "alias\n").expect("write alias spelling");
assert_eq!(
run(mapped_root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
let mapped_git = GitRunner::for_cwd_io(mapped_root).expect("trusted Git");
let mut mapped_config = GuardedGitConfig::authorize(
&mapped_git,
&std::fs::canonicalize(mapped_root).expect("canonical root"),
Vec::new(),
)
.expect("authorization");
mapped_config
.authorize_filter_paths(&["file.txt".to_string()])
.expect("narrow apply snapshot");
let mapped_error = update_index_exact_paths_from_apply(
&mut mapped_config,
&["File.txt".to_string(), "file.txt".to_string()],
&["file.txt".to_string()],
)
.expect_err("mapped off-universe path must refuse");
assert_eq!(mapped_error.kind(), io::ErrorKind::PermissionDenied);
}
#[test]
fn composed_exact_staging_refuses_zero_or_two_prior_snapshots_even_when_empty() {
let repo = init_repo();
let root = repo.path();
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let canonical_root = std::fs::canonicalize(root).expect("canonical root");
let mut zero =
GuardedGitConfig::authorize(&git, &canonical_root, Vec::new()).expect("authorization");
let error = update_index_exact_paths_from_apply(&mut zero, &[], &[])
.expect_err("zero snapshots must refuse");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
let mut two =
GuardedGitConfig::authorize(&git, &canonical_root, Vec::new()).expect("authorization");
two.authorize_filter_paths(&[]).expect("apply snapshot");
two.authorize_git_add_filter_paths(&[])
.expect("Git-add snapshot");
let error = update_index_exact_paths_from_apply(&mut two, &[], &[])
.expect_err("two snapshots must refuse");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
}
fn assert_exact_refused_unchanged(
root: &Path,
paths: &[&str],
content_filter_paths: &[&str],
message_fragment: &str,
) -> StagePathsResult {
let before = git_index_bytes(root);
let result = exact_stage(root, paths, content_filter_paths);
assert_ne!(result.exit_code, 0, "exact staging unexpectedly succeeded");
assert!(
result.stderr.contains(message_fragment),
"missing {message_fragment:?}: {result:?}"
);
assert_eq!(git_index_bytes(root), before, "refusal mutated the index");
result
}
#[cfg(any(unix, windows))]
fn create_dir_alias(target: &Path, alias: &Path) -> DirectoryAlias {
#[cfg(unix)]
std::os::unix::fs::symlink(target, alias).expect("create directory symlink");
#[cfg(windows)]
{
// Bazel's GNU Windows runner can surface temporary paths with `/`
// separators. `mklink` treats those separators as option prefixes, so
// pass native separators to the cmd.exe built-in.
let alias = alias.as_os_str().to_string_lossy().replace('/', "\\");
let target = target.as_os_str().to_string_lossy().replace('/', "\\");
let output = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(alias)
.arg(target)
.output()
.expect("spawn mklink");
assert!(output.status.success(), "mklink /J failed: {output:?}");
}
DirectoryAlias(alias.to_path_buf())
}
#[cfg(any(unix, windows))]
struct DirectoryAlias(PathBuf);
#[cfg(any(unix, windows))]
impl Drop for DirectoryAlias {
fn drop(&mut self) {
#[cfg(unix)]
let _ = std::fs::remove_file(&self.0);
#[cfg(windows)]
let _ = std::fs::remove_dir(&self.0);
}
}
#[test]
fn confinement_skips_metadata_queries_only_for_paths_without_ancestors() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let root = repo.path();
let git = GitRunner::for_cwd_io(root).expect("Git runner");
let config = GuardedGitConfig::authorize(&git, root, Vec::new()).expect("authorization");
reset_containment_metadata_query_count();
confine_patch_paths_guarded(&config, &["leaf".to_string()]).expect("single leaf");
assert_eq!(containment_metadata_query_count(), 0);
reset_containment_metadata_query_count();
confine_patch_paths_guarded(&config, &["nested/leaf".to_string()]).expect("nested leaf");
assert_eq!(
containment_metadata_query_count(),
2,
"nested paths must still resolve private and common Git metadata"
);
}
#[test]
fn single_component_git_directory_is_refused_without_metadata_queries() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let before = git_index_bytes(repo.path());
reset_containment_metadata_query_count();
assert_stage_refused(repo.path(), ".git", &before);
assert_eq!(containment_metadata_query_count(), 0);
}
#[cfg(any(unix, windows))]
fn assert_longest_existing_alias_prefix_carries_unresolved_suffix() {
let repo = init_repo();
let root = repo.path();
std::fs::create_dir_all(root.join("container/realdir")).expect("create alias target");
let alias = root.join("alias");
let _alias = create_dir_alias(&root.join("container/realdir"), &alias);
let git = GitRunner::for_cwd_io(root).expect("git runner");
let paths = [
"alias/nested/file.txt".to_string(),
"missing/path.txt".to_string(),
"alias/nested/file.txt".to_string(),
];
let confined = confine_patch_paths(&git, root, &paths).expect("confine");
assert_eq!(
confine_patch_paths(&git, root, &paths)
.expect("confine exact leaves")
.into_exact_leaves()
.expect("extract exact leaves"),
paths
);
assert!(
confine_patch_paths(&git, root, &[])
.unwrap()
.into_exact_leaves()
.expect("extract no exact leaves")
.is_empty()
);
use ConfinedPathOrigin::*;
use ConfinedPathRole::*;
let candidate = |path: &str, origin: ConfinedPathOrigin, role: ConfinedPathRole| {
ConfinedPathCandidate::new(path.to_string(), origin, role)
};
assert_eq!(
confined.entries[0].candidates,
vec![
candidate("alias", Raw, StrictAncestor),
candidate("alias/nested", Raw, StrictAncestor),
candidate("alias/nested/file.txt", Raw, Leaf),
candidate("container", Canonical, StrictAncestor),
candidate("container/realdir", Canonical, StrictAncestor),
candidate("container/realdir/nested", Canonical, StrictAncestor),
candidate("container/realdir/nested/file.txt", Canonical, Leaf),
]
);
assert_eq!(
confined.entries[0]
.candidates
.iter()
.map(|candidate| candidate.depth)
.collect::<Vec<_>>(),
[1, 2, 3, 1, 2, 3, 4]
);
let missing = &confined.entries[1].candidates;
assert_eq!(
(
&missing[0].path,
missing[0].origin,
&missing[2].path,
missing[2].origin
),
(&missing[2].path, Raw, &missing[0].path, Canonical)
);
}
#[cfg(unix)]
#[test]
fn longest_existing_symlink_prefix_carries_the_unresolved_suffix() {
assert_longest_existing_alias_prefix_carries_unresolved_suffix();
}
#[cfg(windows)]
#[test]
fn longest_existing_junction_prefix_carries_the_unresolved_suffix() {
assert_longest_existing_alias_prefix_carries_unresolved_suffix();
}
#[cfg(any(unix, windows))]
#[test]
fn stage_rejects_strict_ancestor_alias_outside_or_to_worktree_root_without_mutation() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let root = repo.path();
let outside = tempfile::tempdir().expect("outside directory");
std::fs::write(outside.path().join("marker.txt"), "outside\n").expect("outside marker");
let before = git_index_bytes(root);
for (name, target) in [("outside-alias", outside.path()), ("root-alias", root)] {
let alias = root.join(name);
let _alias = create_dir_alias(target, &alias);
assert_stage_refused(root, &format!("{name}/marker.txt"), &before);
#[cfg(windows)]
assert_stage_refused(root, name, &before);
}
assert_eq!(
std::fs::read(outside.path().join("marker.txt")).unwrap(),
b"outside\n"
);
std::fs::create_dir(root.join("inside")).expect("create in-tree return target");
let escape = root.join("escape");
let _escape = create_dir_alias(outside.path(), &escape);
let _return = create_dir_alias(&root.join("inside"), &outside.path().join("return"));
assert_stage_refused(root, "escape/return/file.txt", &before);
}
#[cfg(any(unix, windows))]
#[test]
fn stage_rejects_aliases_into_private_and_common_git_metadata() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
commit_seed(repo.path());
let linked_holder = tempfile::tempdir().expect("linked holder");
let linked = linked_holder.path().join("linked");
let linked_arg = linked.to_string_lossy().into_owned();
assert_eq!(
run(
repo.path(),
&["git", "worktree", "add", "-b", "s3a-linked", &linked_arg]
)
.0,
0
);
let resolve = |arg| {
let output = run(&linked, &["git", "rev-parse", arg]);
assert_eq!(output.0, 0, "resolve {arg}: {}", output.2);
let path = PathBuf::from(output.1.trim());
std::fs::canonicalize(if path.is_absolute() {
path
} else {
linked.join(path)
})
.unwrap()
};
let targets = [resolve("--absolute-git-dir"), resolve("--git-common-dir")];
assert_ne!(targets[0], targets[1]);
let before = git_index_bytes(&linked);
for (index, target) in targets.into_iter().enumerate() {
let name = format!("metadata-alias-{index}");
let alias = linked.join(&name);
let _alias = create_dir_alias(&target, &alias);
assert_stage_refused(&linked, &format!("{name}/probe"), &before);
#[cfg(windows)]
assert_stage_refused(&linked, &name, &before);
}
}
#[cfg(unix)]
#[test]
fn stage_allows_outside_pointing_leaf_symlink_without_touching_target() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let outside = tempfile::tempdir().expect("outside directory");
let target = outside.path().join("target.txt");
std::fs::write(&target, "outside\n").expect("write target");
std::os::unix::fs::symlink(&target, repo.path().join("leaf")).expect("create leaf symlink");
stage_paths(repo.path(), &new_file_diff("leaf")).expect("stage leaf symlink");
assert_eq!(
std::fs::read_to_string(target).expect("read target"),
"outside\n"
);
assert!(String::from_utf8_lossy(&git_index_bytes(repo.path())).contains("\tleaf\0"));
}
#[test]
fn stage_allows_ordinary_missing_and_unrelated_unicode_paths() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let root = repo.path();
std::fs::create_dir(root.join("résumé")).expect("create Unicode directory");
std::fs::write(root.join("résumé/file.txt"), "new\n").expect("write Unicode file");
stage_paths(root, &new_file_diff("résumé/file.txt")).expect("stage Unicode path");
assert!(String::from_utf8_lossy(&git_index_bytes(root)).contains("résumé/file.txt"));
let before = git_index_bytes(root);
stage_paths(root, &new_file_diff("absent/nested.txt")).expect("allow missing path");
assert_eq!(git_index_bytes(root), before);
assert!(
!root.join("absent").exists(),
"containment must be observational"
);
std::fs::create_dir(root.join("directory")).unwrap();
std::fs::write(root.join("directory/unrelated.txt"), "unrelated\n").unwrap();
assert_stage_refused(root, "directory", &before);
}
#[test]
fn exact_staging_adds_an_untracked_file_and_removes_a_tracked_deletion() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join(".gitignore"), "gone.txt\n").expect("write ignore rule");
std::fs::write(root.join("gone.txt"), "gone\n").expect("write deletion fixture");
assert_eq!(run(root, &["git", "add", ".gitignore"]).0, 0);
assert_eq!(run(root, &["git", "add", "-f", "gone.txt"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
std::fs::remove_file(root.join("gone.txt")).expect("remove tracked file");
std::fs::write(root.join("fresh.txt"), "fresh\n").expect("write untracked file");
let result = exact_stage(root, &["gone.txt", "fresh.txt"], &["fresh.txt"]);
assert_eq!(result.exit_code, 0, "{}", result.stderr);
let (code, staged, stderr) = run(root, &["git", "diff", "--cached", "--name-status"]);
assert_eq!(code, 0, "read staged changes: {stderr}");
assert_eq!(staged, "A\tfresh.txt\nD\tgone.txt\n");
}
#[test]
fn exact_staging_preserves_executable_index_mode_when_filemode_is_false() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("mode.txt"), "old\n").expect("write mode fixture");
assert_eq!(run(root, &["git", "add", "mode.txt"]).0, 0);
assert_eq!(
run(root, &["git", "update-index", "--chmod=+x", "mode.txt"]).0,
0
);
assert_eq!(run(root, &["git", "commit", "-m", "executable"]).0, 0);
assert_eq!(run(root, &["git", "config", "core.filemode", "false"]).0, 0);
std::fs::write(root.join("mode.txt"), "new\n").expect("modify mode fixture");
let result = exact_stage(root, &["mode.txt"], &["mode.txt"]);
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert!(
index_entry(root, "mode.txt").starts_with("100755 "),
"exact staging must retain the effective index mode"
);
}
#[test]
fn ignored_untracked_path_returns_nonzero_without_partial_staging() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join(".gitignore"), "ignored.txt\n").expect("write ignore rule");
assert_eq!(run(root, &["git", "add", ".gitignore"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "ignore rule"]).0, 0);
std::fs::write(root.join("ignored.txt"), "ignored\n").expect("write ignored file");
std::fs::write(root.join("ordinary.txt"), "ordinary\n").expect("write ordinary file");
let result = exact_stage(
root,
&["ignored.txt", "ordinary.txt"],
&["ignored.txt", "ordinary.txt"],
);
assert_ne!(result.exit_code, 0);
assert!(result.stderr.contains("ignored.txt"), "{}", result.stderr);
assert!(
run(root, &["git", "diff", "--cached", "--name-only"])
.1
.is_empty(),
"the ignored-path preflight must prevent partial staging"
);
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
stage_effective_paths(
&git,
root,
&["ignored.txt".to_string(), "ordinary.txt".to_string()],
&safe_git_config_parts(),
)
.expect("public staging remains best effort for ignored paths");
assert!(
run(root, &["git", "diff", "--cached", "--name-only"])
.1
.is_empty()
);
}
#[test]
fn ignored_missing_untracked_path_returns_nonzero_without_partial_staging() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join(".gitignore"), "missing-ignored.txt\n").expect("write ignore rule");
assert_eq!(run(root, &["git", "add", ".gitignore"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "ignore rule"]).0, 0);
std::fs::write(root.join("ordinary.txt"), "ordinary\n").expect("write ordinary file");
let result = exact_stage(
root,
&["missing-ignored.txt", "ordinary.txt"],
&["ordinary.txt"],
);
assert_ne!(result.exit_code, 0);
assert!(
result.stderr.contains("missing-ignored.txt"),
"{}",
result.stderr
);
assert!(
run(root, &["git", "diff", "--cached", "--name-only"])
.1
.is_empty(),
"the missing ignored-path preflight must prevent partial staging"
);
}
#[test]
fn tracked_path_that_matches_an_ignore_rule_still_stages() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join(".gitignore"), "tracked.txt\n").expect("write ignore rule");
std::fs::write(root.join("tracked.txt"), "old\n").expect("write tracked file");
assert_eq!(
run(root, &["git", "add", "-f", ".gitignore", "tracked.txt"]).0,
0
);
assert_eq!(run(root, &["git", "commit", "-m", "tracked ignored"]).0, 0);
std::fs::write(root.join("tracked.txt"), "new\n").expect("modify tracked file");
let result = exact_stage(root, &["tracked.txt"], &["tracked.txt"]);
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert_eq!(
run(root, &["git", "diff", "--cached", "--name-only"]).1,
"tracked.txt\n"
);
}
#[test]
fn exact_staging_refuses_skip_and_assume_unchanged_without_mutation() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("existing.txt"), "old existing\n").expect("write existing fixture");
std::fs::write(root.join("missing.txt"), "old missing\n").expect("write missing fixture");
std::fs::write(root.join("assumed.txt"), "old assumed\n").expect("write assumed fixture");
assert_eq!(
run(
root,
&["git", "add", "assumed.txt", "existing.txt", "missing.txt"]
)
.0,
0
);
assert_eq!(run(root, &["git", "commit", "-m", "skip base"]).0, 0);
assert_eq!(
run(
root,
&[
"git",
"update-index",
"--skip-worktree",
"--",
"existing.txt",
"missing.txt",
],
)
.0,
0
);
assert_eq!(
run(
root,
&[
"git",
"update-index",
"--assume-unchanged",
"--",
"assumed.txt",
],
)
.0,
0
);
std::fs::write(root.join("existing.txt"), "new existing\n").expect("modify existing fixture");
std::fs::remove_file(root.join("missing.txt")).expect("remove missing fixture");
std::fs::remove_file(root.join("assumed.txt")).expect("remove assumed fixture");
let before = git_index_bytes(root);
let refused = exact_stage(
root,
&["assumed.txt", "existing.txt", "missing.txt"],
&["existing.txt"],
);
assert_ne!(refused.exit_code, 0);
assert!(refused.stderr.contains("skip-worktree"), "{refused:?}");
assert!(refused.stderr.contains("assume-unchanged"), "{refused:?}");
assert!(refused.stderr.contains("assumed.txt"), "{refused:?}");
assert!(refused.stderr.contains("existing.txt"), "{refused:?}");
assert!(refused.stderr.contains("missing.txt"), "{refused:?}");
assert_eq!(git_index_bytes(root), before);
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
stage_effective_paths(
&git,
root,
&["existing.txt".to_string()],
&safe_git_config_parts(),
)
.expect("public staging remains best effort for skip-worktree paths");
assert_eq!(git_index_bytes(root), before);
assert_eq!(
run(
root,
&[
"git",
"update-index",
"--no-skip-worktree",
"--",
"existing.txt",
"missing.txt",
],
)
.0,
0
);
assert_eq!(
run(
root,
&[
"git",
"update-index",
"--no-assume-unchanged",
"--",
"assumed.txt",
],
)
.0,
0
);
let staged = exact_stage(
root,
&["assumed.txt", "existing.txt", "missing.txt"],
&["existing.txt"],
);
assert_eq!(staged.exit_code, 0, "{}", staged.stderr);
assert_eq!(
run(root, &["git", "diff", "--cached", "--name-status"]).1,
"D\tassumed.txt\nM\texisting.txt\nD\tmissing.txt\n"
);
}
#[test]
fn exact_staging_refuses_case_mismatched_index_aliases_without_mutation() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("foo.txt"), "old\n").expect("write case fixture");
assert_eq!(run(root, &["git", "add", "foo.txt"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "case base"]).0, 0);
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
assert_eq!(
run(
root,
&["git", "update-index", "--skip-worktree", "--", "foo.txt",],
)
.0,
0
);
let before = git_index_bytes(root);
let refused = exact_stage(root, &["FOO.txt"], &["FOO.txt"]);
assert_ne!(refused.exit_code, 0);
assert!(refused.stderr.contains("core.ignoreCase"), "{refused:?}");
assert!(refused.stderr.contains("FOO.txt"), "{refused:?}");
assert_eq!(git_index_bytes(root), before);
assert_eq!(run(root, &["git", "ls-files"]).1, "foo.txt\n");
std::fs::write(root.join("FOO.txt"), "alias\n").expect("write case alias");
let mapped_flag = exact_stage(root, &["foo.txt", "FOO.txt"], &["foo.txt", "FOO.txt"]);
assert_ne!(mapped_flag.exit_code, 0);
assert!(
mapped_flag.stderr.contains("skip-worktree"),
"{mapped_flag:?}"
);
assert_eq!(git_index_bytes(root), before);
let exact_spelling = exact_stage(root, &["foo.txt"], &["foo.txt"]);
assert_ne!(exact_spelling.exit_code, 0);
assert!(
exact_spelling.stderr.contains("skip-worktree"),
"{exact_spelling:?}"
);
assert_eq!(git_index_bytes(root), before);
assert_eq!(
run(
root,
&["git", "update-index", "--no-skip-worktree", "--", "foo.txt",],
)
.0,
0
);
std::fs::write(root.join("foo.txt"), "new\n").expect("modify case fixture");
let staged = exact_stage(root, &["foo.txt"], &["foo.txt"]);
assert_eq!(staged.exit_code, 0, "{}", staged.stderr);
assert_eq!(
run(root, &["git", "diff", "--cached", "--name-only"]).1,
"foo.txt\n"
);
}
#[test]
fn exact_staging_maps_only_explicit_present_case_aliases() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("foo.txt"), "base\n").expect("write base path");
assert_eq!(run(root, &["git", "add", "foo.txt"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "case base"]).0, 0);
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
std::fs::write(root.join("foo.txt"), "lower\n").expect("modify canonical path");
std::fs::write(root.join("FOO.txt"), "upper\n").expect("write requested alias");
let expected = std::fs::read_to_string(root.join("foo.txt")).expect("read canonical path");
let staged = exact_stage(root, &["foo.txt", "FOO.txt"], &["FOO.txt"]);
assert_eq!(staged.exit_code, 0, "{}", staged.stderr);
assert_eq!(run(root, &["git", "ls-files"]).1, "foo.txt\n");
assert_eq!(run(root, &["git", "show", ":foo.txt"]).1, expected);
assert_eq!(run(root, &["git", "commit", "-m", "mapped alias"]).0, 0);
std::fs::remove_file(root.join("FOO.txt")).expect("remove requested alias");
assert_exact_refused_unchanged(
root,
&["foo.txt", "FOO.txt"],
&["foo.txt"],
"missing requested case alias",
);
}
#[test]
fn exact_staging_refuses_case_collisions_within_the_request_set() {
let repo = init_repo();
let root = repo.path();
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
std::fs::write(root.join("foo.txt"), "lower\n").expect("write lower path");
std::fs::write(root.join("FOO.txt"), "upper\n").expect("write upper path");
assert_exact_refused_unchanged(
root,
&["foo.txt", "FOO.txt"],
&["foo.txt", "FOO.txt"],
"request-set full-path aliases",
);
std::fs::create_dir_all(root.join("dir")).expect("create lower directory");
std::fs::create_dir_all(root.join("DIR")).expect("create upper directory");
std::fs::write(root.join("dir/lower.txt"), "lower\n").expect("write lower child");
std::fs::write(root.join("DIR/upper.txt"), "upper\n").expect("write upper child");
assert_exact_refused_unchanged(
root,
&["dir/lower.txt", "DIR/upper.txt"],
&["dir/lower.txt", "DIR/upper.txt"],
"request-set directory-prefix aliases",
);
assert_exact_refused_unchanged(
root,
&["Leaf", "leaf/child.txt"],
&[],
"request-set file/directory aliases",
);
}
#[test]
fn exact_staging_refuses_ambiguous_and_prefix_colliding_index_paths() {
{
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("foo.txt"), "base\n").expect("write base path");
assert_eq!(run(root, &["git", "add", "foo.txt"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "case base"]).0, 0);
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
let oid = run(root, &["git", "rev-parse", ":foo.txt"]).1;
let cacheinfo = format!("100644,{},FOO.txt", oid.trim());
assert_eq!(
run(
root,
&[
"git",
"--literal-pathspecs",
"update-index",
"--add",
"--cacheinfo",
&cacheinfo
],
)
.0,
0
);
assert_eq!(run(root, &["git", "ls-files"]).1, "FOO.txt\nfoo.txt\n");
assert_exact_refused_unchanged(root, &["foo.txt"], &["foo.txt"], "ambiguous index aliases");
}
{
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("Foo"), "leaf\n").expect("write ancestor leaf");
assert_eq!(run(root, &["git", "add", "Foo"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "ancestor base"]).0, 0);
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
std::fs::remove_file(root.join("Foo")).expect("remove ancestor leaf");
std::fs::create_dir(root.join("foo")).expect("create alias directory");
std::fs::write(root.join("foo/bar.txt"), "child\n").expect("write child");
assert_exact_refused_unchanged(
root,
&["foo/bar.txt"],
&["foo/bar.txt"],
"index file/requested-directory alias",
);
}
{
let repo = init_repo();
let root = repo.path();
std::fs::create_dir(root.join("Foo")).expect("create canonical directory");
std::fs::write(root.join("Foo/base.txt"), "base\n").expect("write indexed sibling");
assert_eq!(run(root, &["git", "add", "Foo/base.txt"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "directory base"]).0, 0);
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
std::fs::create_dir_all(root.join("foo")).expect("create alias directory");
std::fs::write(root.join("foo/new.txt"), "new\n").expect("write new sibling");
assert_exact_refused_unchanged(
root,
&["foo/new.txt"],
&["foo/new.txt"],
"index directory-prefix alias",
);
assert_exact_refused_unchanged(root, &["foo"], &[], "requested file/index-directory alias");
}
}
#[test]
fn exact_staging_allows_case_distinct_paths_when_ignore_case_is_false() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("lower.txt"), "lower\n").expect("write lower path");
std::fs::write(root.join("LOWER.txt"), "upper\n").expect("write upper path");
if std::fs::read(root.join("lower.txt")).unwrap()
== std::fs::read(root.join("LOWER.txt")).unwrap()
{
return;
}
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "false"]).0,
0
);
let staged = exact_stage(
root,
&["lower.txt", "LOWER.txt"],
&["lower.txt", "LOWER.txt"],
);
assert_eq!(staged.exit_code, 0, "{}", staged.stderr);
assert_eq!(run(root, &["git", "ls-files"]).1, "LOWER.txt\nlower.txt\n");
}
#[test]
fn exact_staging_handles_non_ascii_filesystem_case_aliases_conservatively() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("Ä.txt"), "upper base\n").expect("write indexed Unicode path");
std::fs::write(root.join("Δ.txt"), "delete me\n").expect("write Unicode deletion path");
assert_eq!(run(root, &["git", "add", "Ä.txt", "Δ.txt"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "Unicode base"]).0, 0);
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
std::fs::write(root.join("Ä.txt"), "upper changed\n").expect("modify exact Unicode path");
std::fs::remove_file(root.join("Δ.txt")).expect("remove exact Unicode path");
let exact = exact_stage(root, &["Ä.txt", "Δ.txt"], &["Ä.txt"]);
assert_eq!(exact.exit_code, 0, "{}", exact.stderr);
assert_eq!(
run(root, &["git", "diff", "--cached", "--name-only", "-z"]).1,
"Ä.txt\0Δ.txt\0"
);
assert_eq!(run(root, &["git", "commit", "-m", "exact Unicode"]).0, 0);
std::fs::write(root.join("ä.txt"), "lower alias\n").expect("write Unicode case alias");
let has_distinct_lower_entry = std::fs::read_dir(root)
.expect("read repository entries")
.any(|entry| entry.expect("read repository entry").file_name() == "ä.txt");
if has_distinct_lower_entry {
let staged = exact_stage(root, &["ä.txt"], &["ä.txt"]);
assert_eq!(staged.exit_code, 0, "{}", staged.stderr);
assert_eq!(run(root, &["git", "ls-files", "-z"]).1, "Ä.txt\0ä.txt\0");
} else {
assert_exact_refused_unchanged(
root,
&["ä.txt"],
&["ä.txt"],
"filesystem spelling aliases or unresolved paths",
);
assert_eq!(run(root, &["git", "ls-files", "-z"]).1, "Ä.txt\0");
std::fs::rename(root.join("Ä.txt"), root.join("rename.tmp"))
.expect("begin physical case rename");
std::fs::rename(root.join("rename.tmp"), root.join("ä.txt"))
.expect("finish physical case rename");
let renamed = exact_stage(root, &["ä.txt"], &["ä.txt"]);
assert_eq!(renamed.exit_code, 0, "{}", renamed.stderr);
assert_eq!(run(root, &["git", "ls-files", "-z"]).1, "Ä.txt\0ä.txt\0");
}
}
#[test]
fn exact_staging_refuses_alias_only_filesystem_lookups_without_an_index_entry() {
let repo = init_repo();
let root = repo.path();
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
std::fs::write(root.join("Foo.txt"), "stored spelling\n").expect("write stored spelling");
assert_exact_refused_unchanged(
root,
&["foo.txt"],
&["foo.txt"],
"filesystem spelling aliases or unresolved paths",
);
}
#[test]
fn exact_staging_checks_unicode_aliases_in_both_directions() {
for (indexed, requested) in [
("ELVIN.txt", "kelvin.txt"),
("kelvin.txt", "ELVIN.txt"),
("ſAFE.txt", "safe.txt"),
("safe.txt", "ſAFE.txt"),
("é.txt", "e\u{301}.txt"),
("e\u{301}.txt", "é.txt"),
] {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join(indexed), "indexed\n").expect("write indexed spelling");
assert_eq!(run(root, &["git", "add", indexed]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "spelling base"]).0, 0);
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
std::fs::write(root.join(requested), "requested\n").expect("write requested spelling");
let requested_is_physically_exact = std::fs::read_dir(root)
.expect("read repository entries")
.any(|entry| entry.expect("read repository entry").file_name() == requested);
let requested_is_exact_index = run(root, &["git", "ls-files", "-z"])
.1
.split('\0')
.any(|path| path == requested);
if requested_is_physically_exact || requested_is_exact_index {
let staged = exact_stage(root, &[requested], &[requested]);
assert_eq!(
staged.exit_code, 0,
"{indexed:?} -> {requested:?}: {staged:?}"
);
assert!(!index_entry(root, requested).is_empty());
} else {
let before = git_index_bytes(root);
let refused = exact_stage(root, &[requested], &[requested]);
assert_ne!(
refused.exit_code, 0,
"{indexed:?} -> {requested:?} unexpectedly succeeded: {refused:?}"
);
assert!(
refused
.stderr
.contains("filesystem spelling aliases or unresolved paths"),
"{indexed:?} -> {requested:?}: {refused:?}"
);
assert_eq!(git_index_bytes(root), before);
}
}
}
#[test]
fn exact_staging_allows_a_new_child_under_an_exact_unicode_directory() {
let repo = init_repo();
let root = repo.path();
std::fs::create_dir(root.join("Ärea")).expect("create Unicode directory");
std::fs::write(root.join("Ärea/base.txt"), "base\n").expect("write indexed child");
assert_eq!(run(root, &["git", "add", "Ärea/base.txt"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "directory base"]).0, 0);
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
std::fs::write(root.join("Ärea/new.txt"), "new\n").expect("write new child");
let staged = exact_stage(root, &["Ärea/new.txt"], &["Ärea/new.txt"]);
assert_eq!(staged.exit_code, 0, "{}", staged.stderr);
assert!(!index_entry(root, "Ärea/new.txt").is_empty());
}
#[cfg(unix)]
#[test]
fn exact_index_scan_allows_an_unrelated_non_utf8_path() {
use std::io::Write as _;
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("blob-source"), "unrelated\n").expect("write blob source");
let oid = run(root, &["git", "hash-object", "-w", "blob-source"]).1;
let mut command = std::process::Command::new("git");
crate::safe_git::isolate_git_command_environment(&mut command);
let mut child = command
.args(["update-index", "-z", "--index-info"])
.current_dir(root)
.stdin(std::process::Stdio::piped())
.spawn()
.expect("spawn raw index update");
let mut record = format!("100644 {}\t", oid.trim()).into_bytes();
record.extend_from_slice(b"unrelated-\xff\0");
child
.stdin
.take()
.expect("raw index stdin")
.write_all(&record)
.expect("write raw index entry");
let status = child.wait().expect("wait for raw index update");
assert!(status.success(), "raw index update failed: {status}");
assert_eq!(run(root, &["git", "commit", "-m", "non-UTF-8 base"]).0, 0);
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
std::fs::write(root.join("ordinary.txt"), "ordinary\n").expect("write ordinary path");
let staged = exact_stage(root, &["ordinary.txt"], &["ordinary.txt"]);
assert_eq!(staged.exit_code, 0, "{}", staged.stderr);
assert!(!index_entry(root, "ordinary.txt").is_empty());
}
#[test]
fn exact_staging_preserves_sparse_checkout_policy() {
let repo = init_repo();
let root = repo.path();
std::fs::create_dir_all(root.join("inside")).expect("create included directory");
std::fs::create_dir_all(root.join("outside")).expect("create excluded directory");
std::fs::write(root.join("inside/tracked.txt"), "inside\n").expect("write included file");
std::fs::write(root.join("outside/tracked.txt"), "old\n").expect("write excluded file");
assert_eq!(run(root, &["git", "add", "."]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "sparse base"]).0, 0);
assert_eq!(
run(root, &["git", "sparse-checkout", "init", "--cone"]).0,
0
);
assert_eq!(run(root, &["git", "sparse-checkout", "set", "inside"]).0, 0);
std::fs::create_dir_all(root.join("outside")).expect("recreate excluded directory");
std::fs::write(root.join("outside/tracked.txt"), "new\n")
.expect("modify excluded tracked file");
std::fs::write(root.join("outside/untracked.txt"), "new\n")
.expect("write excluded untracked file");
let outside_before = index_entry(root, "outside/tracked.txt");
let result = exact_stage(
root,
&["outside/tracked.txt", "outside/untracked.txt"],
&["outside/tracked.txt", "outside/untracked.txt"],
);
assert_ne!(result.exit_code, 0);
assert!(
result.stderr.contains("sparse-checkout"),
"{}",
result.stderr
);
assert_eq!(index_entry(root, "outside/tracked.txt"), outside_before);
assert!(index_entry(root, "outside/untracked.txt").is_empty());
assert!(
run(root, &["git", "diff", "--cached", "--name-only"])
.1
.is_empty()
);
std::fs::write(root.join("inside/untracked.txt"), "new\n")
.expect("write included untracked file");
let included = exact_stage(root, &["inside/untracked.txt"], &["inside/untracked.txt"]);
assert_eq!(included.exit_code, 0, "{}", included.stderr);
assert!(!index_entry(root, "inside/untracked.txt").is_empty());
}
#[test]
fn sparse_rule_probe_failure_is_closed_only_for_sparse_repositories() {
for sparse in [false, true] {
let repo = init_repo();
let root = repo.path();
if sparse {
assert_eq!(
run(root, &["git", "config", "core.sparseCheckout", "true"]).0,
0
);
}
std::fs::write(root.join("file.txt"), "new\n").expect("write staging file");
let result = exact_stage(root, &["file.txt"], &["file.txt"]);
if sparse {
assert_ne!(result.exit_code, 0);
assert!(result.stderr.contains("sparse-checkout"), "{result:?}");
assert!(index_entry(root, "file.txt").is_empty());
} else {
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert!(!index_entry(root, "file.txt").is_empty());
}
}
}
#[cfg(unix)]
#[test]
fn exact_staging_treats_pathspec_magic_and_newline_as_literal_bytes() {
let repo = init_repo();
let root = repo.path();
commit_seed(root);
assert_eq!(
run(root, &["git", "sparse-checkout", "init", "--cone"]).0,
0
);
assert_eq!(run(root, &["git", "sparse-checkout", "set", "."]).0, 0);
assert_eq!(
run(root, &["git", "config", "core.ignoreCase", "true"]).0,
0
);
let path = ":(glob)*[literal]\nname.txt";
std::fs::write(root.join(path), "literal\n").expect("write unusual path");
let result = exact_stage(root, &[path], &[path]);
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert!(String::from_utf8_lossy(&git_index_bytes(root)).contains(path));
}
#[test]
fn exact_staging_does_not_recurse_when_a_leaf_becomes_a_directory() {
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join("victim"), "old\n").expect("write tracked leaf");
assert_eq!(run(root, &["git", "add", "victim"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "tracked leaf"]).0, 0);
std::fs::remove_file(root.join("victim")).expect("remove tracked leaf");
std::fs::create_dir(root.join("victim")).expect("replace leaf with directory");
std::fs::write(root.join("victim/child.txt"), "child\n").expect("write descendant");
// Passing `victim` as filterable content models a classification made
// immediately before the file-to-directory swap.
let result = exact_stage(root, &["victim"], &["victim"]);
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert!(index_entry(root, "victim/child.txt").is_empty());
assert!(index_entry(root, "victim").is_empty());
}
#[cfg(unix)]
#[test]
fn raced_directory_descendant_filter_is_not_run() {
use std::os::unix::fs::PermissionsExt;
let repo = init_repo();
let root = repo.path();
std::fs::write(
root.join(".gitattributes"),
"victim/child.txt filter=descendant\n",
)
.expect("write descendant attribute");
std::fs::write(root.join("victim"), "old\n").expect("write tracked leaf");
assert_eq!(run(root, &["git", "add", ".gitattributes", "victim"]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "tracked leaf"]).0, 0);
let marker = root.join("descendant-filter-ran");
let helper = root.join("descendant-filter.sh");
std::fs::write(
&helper,
format!("#!/bin/sh\n: > '{}'\ncat\n", marker.display()),
)
.expect("write filter helper");
let mut permissions = std::fs::metadata(&helper).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&helper, permissions).expect("make filter executable");
assert_eq!(
run(
root,
&[
"git",
"config",
"filter.descendant.clean",
&helper.to_string_lossy(),
],
)
.0,
0
);
assert_eq!(
run(
root,
&["git", "config", "filter.descendant.required", "true"],
)
.0,
0
);
std::fs::remove_file(root.join("victim")).expect("remove tracked leaf");
std::fs::create_dir(root.join("victim")).expect("replace leaf with directory");
std::fs::write(root.join("victim/child.txt"), "child\n").expect("write descendant");
let hash = run(
root,
&[
"git",
"hash-object",
"--path",
"victim/child.txt",
"--",
"victim/child.txt",
],
);
assert_eq!(hash.0, 0, "filter control failed: {}", hash.2);
assert!(
marker.exists(),
"control must prove the helper is executable"
);
std::fs::remove_file(&marker).expect("reset filter marker");
let result = exact_stage(root, &["victim"], &["victim"]);
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert!(
!marker.exists(),
"exact staging must not run the descendant filter"
);
assert!(index_entry(root, "victim/child.txt").is_empty());
}
#[cfg(unix)]
#[test]
fn exact_staging_records_a_symlink_as_a_mode_120000_blob() {
use std::os::unix::fs::symlink;
let repo = init_repo();
let root = repo.path();
symlink("literal-target", root.join("link")).expect("create symlink");
let result = exact_stage(root, &["link"], &[]);
assert_eq!(result.exit_code, 0, "{}", result.stderr);
assert!(index_entry(root, "link").starts_with("120000 "));
let (code, blob, stderr) = run(root, &["git", "show", ":link"]);
assert_eq!(code, 0, "read staged symlink: {stderr}");
assert_eq!(blob, "literal-target");
}
#[test]
fn containment_does_not_claim_uninitialized_gitlinks() {
let _g = env_lock().lock().unwrap();
let repo = init_repo();
let root = repo.path();
commit_seed(root);
let oid = run(root, &["git", "rev-parse", "HEAD"]);
assert_eq!(oid.0, 0, "{}", oid.2);
let cacheinfo = format!("160000,{},nested", oid.1.trim());
assert_eq!(
run(
root,
&["git", "update-index", "--add", "--cacheinfo", &cacheinfo]
)
.0,
0
);
assert!(!root.join("nested").exists());
let git = GitRunner::for_cwd_io(root).expect("git runner");
let paths = ["nested/file.txt".to_string()];
let confined = confine_patch_paths(&git, root, &paths).expect("record candidates only");
let candidate = &confined.entries[0].candidates[0];
assert_eq!(
(candidate.path.as_str(), candidate.origin),
("nested", ConfinedPathOrigin::Raw)
);
}
#[cfg(any(unix, windows))]
#[test]
fn containment_metadata_queries_ignore_inherited_git_selection_environment() {
let _g = env_lock().lock().unwrap();
if std::env::var_os("CODEX_GIT_UTILS_PATH_ENV_CHILD").is_some() {
let root = PathBuf::from(
std::env::var_os("CODEX_GIT_UTILS_TARGET_REPO").expect("target repository"),
);
let git = GitRunner::for_cwd_io(&root).expect("target git runner");
let paths = ["metadata-alias/probe".to_string()];
let error =
confine_patch_paths(&git, &root, &paths).expect_err("use target repository metadata");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
return;
}
let target = init_repo();
let metadata_alias = target.path().join("metadata-alias");
let _metadata_alias = create_dir_alias(&target.path().join(".git"), &metadata_alias);
let alternate = init_repo();
let alternate_git_dir = alternate.path().join(".git");
let target_env = ("CODEX_GIT_UTILS_TARGET_REPO", target.path().as_os_str());
for (name, value) in [
("GIT_DIR", alternate_git_dir.as_os_str()),
("GIT_WORK_TREE", alternate.path().as_os_str()),
("GIT_COMMON_DIR", alternate_git_dir.as_os_str()),
("GIT_PREFIX", OsStr::new("elsewhere/")),
] {
run_isolated_test(
"patch_paths::tests::containment_metadata_queries_ignore_inherited_git_selection_environment",
&[target_env, (name, value)],
);
}
}
#[cfg(unix)]
#[test]
fn staging_rejects_global_lfs_filter_without_running_it() {
let _g = env_lock().lock().unwrap();
if std::env::var_os("CODEX_GIT_UTILS_PATH_ENV_CHILD").is_some() {
let root = PathBuf::from(
std::env::var_os("CODEX_GIT_UTILS_TARGET_REPO").expect("target repository"),
);
let git = GitRunner::for_cwd_io(&root).expect("trusted Git");
let error = stage_effective_paths(
&git,
&root,
&["file.txt".to_string()],
&safe_git_config_parts(),
)
.expect_err("reject global Git LFS filter");
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
return;
}
let repo = init_repo();
let root = repo.path();
std::fs::write(root.join(".gitattributes"), "file.txt filter=lfs\n").expect("write attributes");
std::fs::write(root.join("file.txt"), "old\n").expect("write file");
let (add_code, _, add_err) = run(root, &["git", "add", "."]);
assert_eq!(add_code, 0, "add base files: {add_err}");
let (commit_code, _, commit_err) = run(root, &["git", "commit", "-m", "base"]);
assert_eq!(commit_code, 0, "commit base files: {commit_err}");
std::fs::write(root.join("file.txt"), "new\n").expect("modify file");
let config_dir = tempfile::tempdir().expect("config tempdir");
let global_config = config_dir.path().join("global.gitconfig");
let system_config = config_dir.path().join("system.gitconfig");
let filter_marker = config_dir.path().join("repo-lfs-ran");
let repo_git_lfs = root.join("git-lfs");
std::fs::write(
&repo_git_lfs,
"#!/bin/sh\n: > \"$CODEX_GIT_UTILS_LFS_MARKER\"\ncat\n",
)
.expect("write repository git-lfs");
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(&repo_git_lfs)
.expect("repository git-lfs metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&repo_git_lfs, permissions)
.expect("make repository git-lfs executable");
}
std::fs::write(
&global_config,
"[filter \"lfs\"]\n\tclean = git-lfs clean -- %f\n\trequired = true\n",
)
.expect("write global config");
std::fs::write(&system_config, "").expect("write system config");
run_isolated_test(
"patch_paths::tests::staging_rejects_global_lfs_filter_without_running_it",
&[
("CODEX_GIT_UTILS_TARGET_REPO", root.as_os_str()),
("CODEX_GIT_UTILS_LFS_MARKER", filter_marker.as_os_str()),
("GIT_CONFIG_GLOBAL", global_config.as_os_str()),
("GIT_CONFIG_SYSTEM", system_config.as_os_str()),
("GIT_EXEC_PATH", root.as_os_str()),
("GIT_GLOB_PATHSPECS", OsStr::new("1")),
("GIT_ICASE_PATHSPECS", OsStr::new("1")),
],
);
assert!(!filter_marker.exists(), "Git LFS filter must not run");
let (diff_code, staged, diff_err) = run(root, &["git", "diff", "--cached", "--name-only"]);
assert_eq!(diff_code, 0, "read staged paths: {diff_err}");
assert!(staged.is_empty(), "staging changed the index: {staged}");
}
#[test]
fn staging_rejects_selected_process_but_allows_smudge_only_filter() {
let process_repo = init_repo_with_selected_filter("filter.selected.process");
let process_root = process_repo.path();
let process_git = GitRunner::for_cwd_io(process_root).expect("trusted Git");
let error = stage_effective_paths(
&process_git,
process_root,
&["file.txt".to_string()],
&safe_git_config_parts(),
)
.expect_err("reject selected process filter");
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
let smudge_repo = init_repo_with_selected_filter("filter.selected.smudge");
let smudge_root = smudge_repo.path();
let smudge_git = GitRunner::for_cwd_io(smudge_root).expect("trusted Git");
stage_effective_paths(
&smudge_git,
smudge_root,
&["file.txt".to_string()],
&safe_git_config_parts(),
)
.expect("smudge-only filters do not run during staging");
let (code, staged, stderr) = run(smudge_root, &["git", "diff", "--cached", "--name-only"]);
assert_eq!(code, 0, "read staged paths: {stderr}");
assert_eq!(staged, "file.txt\n");
}
#[cfg(unix)]
#[test]
fn staging_neutralizes_off_path_racy_filters_without_changing_their_index_entry() {
for (driver, command) in [
("selected", "clean"),
("selected", "process"),
("", "clean"),
("x=y", "clean"),
] {
let control = init_racy_filter_fixture(driver, command);
let control_root = control.repo.path();
std::fs::write(control_root.join("target.txt"), "new target\n")
.expect("modify control target");
let _ = run(
control_root,
&[
"git",
"--literal-pathspecs",
"update-index",
"--add",
"--remove",
"--",
"target.txt",
],
);
assert!(
control.marker.exists(),
"raw update-index should execute the off-path racy {driver:?} {command} filter"
);
let guarded = init_racy_filter_fixture(driver, command);
let guarded_root = guarded.repo.path();
let outside_before = index_entry(guarded_root, "outside.txt");
std::fs::write(guarded_root.join("target.txt"), "new target\n")
.expect("modify guarded target");
let git = GitRunner::for_cwd_io(guarded_root).expect("trusted Git");
stage_effective_paths(
&git,
guarded_root,
&["target.txt".to_string()],
&safe_git_config_parts(),
)
.expect("stage with off-path filters neutralized");
assert!(
!guarded.marker.exists(),
"guarded staging must not execute the off-path racy {driver:?} {command} filter"
);
assert_eq!(
index_entry(guarded_root, "outside.txt"),
outside_before,
"staging must preserve the unrelated index entry for {driver:?} {command}"
);
let (code, staged, stderr) = run(guarded_root, &["git", "diff", "--cached", "--name-only"]);
assert_eq!(code, 0, "read staged paths: {stderr}");
assert_eq!(
staged, "target.txt\n",
"driver={driver:?} command={command}"
);
}
}
#[cfg(unix)]
#[test]
fn staging_allows_selected_symlink_while_neutralizing_off_path_filters() {
use std::os::unix::fs::symlink;
for (command, core_symlinks) in [("clean", true), ("process", false)] {
let fixture = init_racy_filter_fixture("selected", command);
let root = fixture.repo.path();
assert_eq!(
run(
root,
&[
"git",
"config",
"core.symlinks",
if core_symlinks { "true" } else { "false" },
],
)
.0,
0
);
std::fs::remove_file(root.join("link")).expect("remove old symlink");
symlink("new-target", root.join("link")).expect("create updated symlink");
let outside_before = index_entry(root, "outside.txt");
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
stage_effective_paths(&git, root, &["link".to_string()], &safe_git_config_parts())
.expect("stage selected symlink");
assert!(
!fixture.marker.exists(),
"staging a symlink must not run {command} filters"
);
assert_eq!(index_entry(root, "outside.txt"), outside_before);
let link_entry = index_entry(root, "link");
assert!(
link_entry.starts_with("120000 "),
"symlink index mode with core.symlinks={core_symlinks}: {link_entry:?}"
);
let (code, blob, stderr) = run(root, &["git", "show", ":link"]);
assert_eq!(code, 0, "read staged link: {stderr}");
assert_eq!(blob, "new-target");
let (code, staged, stderr) = run(root, &["git", "diff", "--cached", "--name-only"]);
assert_eq!(code, 0, "read staged paths: {stderr}");
assert_eq!(staged, "link\n");
}
}
#[cfg(unix)]
#[test]
fn staging_allows_optional_smudge_only_but_refuses_required_or_malformed_smudge() {
use std::io::Write as _;
for (required, accepted) in [
("absent", true),
("empty", true),
("false", true),
("yes", false),
("implicit", false),
("not-a-bool", false),
] {
let fixture = init_racy_filter_fixture("selected", "smudge");
let root = fixture.repo.path();
if matches!(required, "absent" | "implicit") {
assert_eq!(
run(
root,
&["git", "config", "--unset-all", "filter.selected.required"],
)
.0,
0
);
}
match required {
"absent" => {}
"implicit" => {
let mut config = std::fs::OpenOptions::new()
.append(true)
.open(root.join(".git/config"))
.expect("open repository config");
writeln!(config, "[filter \"selected\"]\n\trequired")
.expect("write implicit true required value");
}
value => {
let value = if value == "empty" { "" } else { value };
assert_eq!(
run(root, &["git", "config", "filter.selected.required", value],).0,
0
);
}
}
std::fs::write(root.join("selected.txt"), "new selected\n")
.expect("modify selected target");
let before = git_index_bytes(root);
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let result = stage_effective_paths(
&git,
root,
&["selected.txt".to_string()],
&safe_git_config_parts(),
);
assert!(
!fixture.marker.exists(),
"smudge helper must not run for required={required}"
);
if accepted {
result.expect("stage optional smudge-only target");
let (code, staged, stderr) = run(root, &["git", "diff", "--cached", "--name-only"]);
assert_eq!(code, 0, "read staged paths: {stderr}");
assert_eq!(staged, "selected.txt\n");
} else {
let error = result.expect_err("refuse required or malformed smudge-only target");
assert_eq!(
error.kind(),
io::ErrorKind::Unsupported,
"required={required}: {error}"
);
assert_eq!(git_index_bytes(root), before);
}
}
}
#[test]
fn optional_smudge_target_does_not_mask_a_later_clean_target() {
let repo = init_repo();
let root = repo.path();
std::fs::write(
root.join(".gitattributes"),
"a.txt filter=smudger\nz.txt filter=cleaner\n",
)
.expect("write attributes");
std::fs::write(root.join("a.txt"), "old a\n").expect("write smudge target");
std::fs::write(root.join("z.txt"), "old z\n").expect("write clean target");
assert_eq!(run(root, &["git", "add", "."]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
assert_eq!(
run(
root,
&[
"git",
"config",
"filter.smudger.smudge",
"codex-definitely-missing-smudge-command",
],
)
.0,
0
);
assert_eq!(
run(
root,
&[
"git",
"config",
"filter.cleaner.clean",
"codex-definitely-missing-clean-command",
],
)
.0,
0
);
std::fs::write(root.join("a.txt"), "new a\n").expect("modify smudge target");
std::fs::write(root.join("z.txt"), "new z\n").expect("modify clean target");
let before = git_index_bytes(root);
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
let error = stage_effective_paths(
&git,
root,
&["a.txt".to_string(), "z.txt".to_string()],
&safe_git_config_parts(),
)
.expect_err("reject the selected clean target after optional smudge");
assert_eq!(error.kind(), io::ErrorKind::Unsupported);
assert_eq!(git_index_bytes(root), before);
}
#[test]
fn staging_filter_probe_uses_only_existing_non_directory_leaves() {
let repo = init_repo();
let root = repo.path();
std::fs::write(
root.join(".gitattributes"),
"missing.txt filter=selected\ndeleted.txt filter=selected\n",
)
.expect("write attributes");
std::fs::write(root.join("deleted.txt"), "old\n").expect("write deleted fixture");
std::fs::write(root.join("kept.txt"), "old\n").expect("write kept fixture");
assert_eq!(run(root, &["git", "add", "."]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
assert_eq!(
run(
root,
&[
"git",
"config",
"filter.selected.clean",
"codex-definitely-missing-filter-command",
],
)
.0,
0
);
std::fs::remove_file(root.join("deleted.txt")).expect("delete tracked file");
std::fs::write(root.join("kept.txt"), "new\n").expect("modify kept fixture");
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
stage_effective_paths(
&git,
root,
&[
"missing.txt".to_string(),
"deleted.txt".to_string(),
"kept.txt".to_string(),
],
&safe_git_config_parts(),
)
.expect("probe only the surviving staging leaf");
let (code, staged, stderr) = run(root, &["git", "diff", "--cached", "--name-only"]);
assert_eq!(code, 0, "read staged paths: {stderr}");
assert_eq!(staged, "kept.txt\n");
assert!(!root.join("missing.txt").exists());
assert!(!root.join("deleted.txt").exists());
}
#[test]
fn staging_skips_filter_probe_when_no_leaf_will_be_staged() {
let repo = init_repo();
let root = repo.path();
std::fs::write(
root.join(".gitattributes"),
"missing.txt filter=selected\ndeleted.txt filter=selected\n",
)
.expect("write attributes");
std::fs::write(root.join("deleted.txt"), "old\n").expect("write deleted fixture");
assert_eq!(run(root, &["git", "add", "."]).0, 0);
assert_eq!(run(root, &["git", "commit", "-m", "base"]).0, 0);
assert_eq!(
run(
root,
&[
"git",
"config",
"filter.selected.clean",
"codex-definitely-missing-filter-command",
],
)
.0,
0
);
std::fs::remove_file(root.join("deleted.txt")).expect("delete tracked file");
let before = git_index_bytes(root);
let git = GitRunner::for_cwd_io(root).expect("trusted Git");
stage_effective_paths(
&git,
root,
&["missing.txt".to_string(), "deleted.txt".to_string()],
&safe_git_config_parts(),
)
.expect("skip staging when every effective leaf is absent");
assert_eq!(git_index_bytes(root), before);
}