Merge local-only Git transport base

This commit is contained in:
Chris Bookholt
2026-06-30 11:59:24 -07:00
16 changed files with 1044 additions and 30 deletions
+13 -3
View File
@@ -124,7 +124,7 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
}
fn resolve_git_root(cwd: &Path) -> io::Result<PathBuf> {
let out = std::process::Command::new("git")
let out = local_git_command()
.arg("rev-parse")
.arg("--show-toplevel")
.current_dir(cwd)
@@ -149,7 +149,7 @@ fn write_temp_patch(diff: &str) -> io::Result<(tempfile::TempDir, PathBuf)> {
}
fn run_git(cwd: &Path, git_cfg: &[String], args: &[String]) -> io::Result<(i32, String, String)> {
let mut cmd = std::process::Command::new("git");
let mut cmd = local_git_command();
for p in git_cfg {
cmd.arg(p);
}
@@ -163,6 +163,12 @@ fn run_git(cwd: &Path, git_cfg: &[String], args: &[String]) -> io::Result<(i32,
Ok((code, stdout, stderr))
}
fn local_git_command() -> std::process::Command {
let mut command = std::process::Command::new("git");
command.envs(crate::local_only_git_env());
command
}
fn quote_shell(s: &str) -> String {
let simple = s
.chars()
@@ -329,7 +335,7 @@ pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
if existing.is_empty() {
return Ok(());
}
let mut cmd = std::process::Command::new("git");
let mut cmd = local_git_command();
cmd.arg("add");
cmd.arg("--");
for p in &existing {
@@ -592,6 +598,10 @@ fn regex_ci(pat: &str) -> Regex {
Regex::new(&format!("(?i){pat}")).unwrap_or_else(|e| panic!("invalid regex: {e}"))
}
#[cfg(all(test, unix))]
#[path = "apply_transport_tests.rs"]
mod transport_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,154 @@
use super::ApplyGitRequest;
use super::apply_git_patch;
use pretty_assertions::assert_eq;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::process::Output;
#[test]
fn three_way_apply_does_not_lazy_fetch_promisor_objects() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let source = temp_dir.path().join("source");
let partial = temp_dir.path().join("partial");
fs::create_dir(&source).expect("create source repository");
run_git(&source, &["init", "-q", "--initial-branch=main"]);
run_git(&source, &["config", "uploadpack.allowFilter", "true"]);
fs::write(source.join("data.txt"), "one\nbase\nthree\n").expect("write base blob");
run_git(&source, &["add", "data.txt"]);
commit_all(&source, "base");
let base_blob = run_git_stdout(&source, &["rev-parse", "HEAD:data.txt"]);
fs::write(source.join("data.txt"), "one\nbase\npatched\n").expect("write patch blob");
let patch = String::from_utf8(run_git(&source, &["diff", "--full-index", "--binary"]).stdout)
.expect("patch should be UTF-8");
run_git(&source, &["checkout", "-q", "--", "data.txt"]);
fs::write(source.join("data.txt"), "current\nbase\nthree\n").expect("write head blob");
commit_all(&source, "head");
let complete_result = apply_git_patch(&ApplyGitRequest {
cwd: source.clone(),
diff: patch.clone(),
revert: false,
preflight: false,
})
.expect("apply patch in complete clone");
assert_eq!(
(
complete_result.exit_code,
fs::read_to_string(source.join("data.txt")).expect("read complete result"),
),
(0, "current\nbase\npatched\n".to_string()),
"three-way apply should retain normal behavior when every object is local; stderr: {}",
complete_result.stderr,
);
run_git(&source, &["reset", "-q", "--hard", "HEAD"]);
let source_url = format!("file://{}", source.display());
run_git(
temp_dir.path(),
&[
"-c",
"protocol.file.allow=always",
"clone",
"-q",
"--no-local",
"--filter=blob:none",
"--no-checkout",
&source_url,
partial.to_str().expect("partial path"),
],
);
run_git(&partial, &["checkout", "-q", "main"]);
let missing = run_git_stdout(
&partial,
&["rev-list", "--objects", "--all", "--missing=print"],
);
assert!(
missing.lines().any(|line| line == format!("?{base_blob}")),
"expected patch base blob {base_blob} to remain missing:\n{missing}",
);
let helper = temp_dir.path().join("transport-helper.sh");
fs::write(&helper, "#!/bin/sh\nprintf ran >\"$0.ran\"\nexit 1\n")
.expect("write transport helper");
let mut permissions = fs::metadata(&helper)
.expect("read transport helper metadata")
.permissions();
permissions.set_mode(/*mode*/ 0o755);
fs::set_permissions(&helper, permissions).expect("make transport helper executable");
let helper_url = format!("ext::{}", helper.display());
run_git(&partial, &["config", "remote.origin.url", &helper_url]);
run_git(&partial, &["config", "protocol.ext.allow", "always"]);
let patch_path = temp_dir.path().join("change.patch");
fs::write(&patch_path, &patch).expect("write attack reproduction patch");
let unprotected = Command::new("git")
.args(["apply", "--3way", patch_path.to_str().expect("patch path")])
.env_remove("GIT_ALLOW_PROTOCOL")
.env_remove("GIT_NO_LAZY_FETCH")
.current_dir(&partial)
.output()
.expect("run unprotected Git apply");
let helper_marker = helper.with_extension("sh.ran");
assert_eq!(
(unprotected.status.success(), helper_marker.exists()),
(false, true),
"unprotected three-way apply should reach the selected promisor transport",
);
fs::remove_file(&helper_marker).expect("remove reproduction marker");
run_git(&partial, &["reset", "-q", "--hard", "HEAD"]);
let partial_result = apply_git_patch(&ApplyGitRequest {
cwd: partial,
diff: patch,
revert: false,
preflight: false,
})
.expect("run fail-closed apply");
assert_eq!(
(partial_result.exit_code == 0, helper_marker.exists(),),
(false, false),
"local-only apply must fail without invoking the promisor transport",
);
}
fn run_git(cwd: &Path, args: &[&str]) -> Output {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap_or_else(|error| panic!("run git {args:?}: {error}"));
assert!(
output.status.success(),
"git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
output
}
fn run_git_stdout(cwd: &Path, args: &[&str]) -> String {
String::from_utf8_lossy(&run_git(cwd, args).stdout)
.trim()
.to_string()
}
fn commit_all(cwd: &Path, message: &str) {
run_git(
cwd,
&[
"-c",
"user.name=Codex Test",
"-c",
"user.email=codex@example.com",
"commit",
"-qam",
message,
],
);
}
+120 -1
View File
@@ -424,7 +424,11 @@ impl crate::FsmonitorProbeRunner for LocalFsmonitorProbeRunner<'_> {
// Both probes are fast, bounded metadata queries that do not inspect the
// worktree or index, so do not reduce the requested command's timeout.
let mut command = Command::new(self.git);
command.args(args).current_dir(self.cwd).kill_on_drop(true);
command
.envs(crate::local_only_git_env())
.args(args)
.current_dir(self.cwd)
.kill_on_drop(true);
match timeout(GIT_COMMAND_TIMEOUT, command.output()).await {
Ok(Ok(output)) if output.status.success() => Some(output.stdout),
_ => None,
@@ -445,6 +449,7 @@ async fn run_git_command_with_timeout_from(
) -> Option<std::process::Output> {
let mut command = Command::new(git);
command
.envs(crate::local_only_git_env())
.env("GIT_OPTIONAL_LOCKS", "0")
// Keep internal Git commands independent of repository-selected hooks
// and fsmonitor helpers while preserving built-in fsmonitor acceleration.
@@ -908,6 +913,120 @@ mod tests {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
fn run_git(cwd: &Path, args: &[&str]) -> std::process::Output {
let output = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.expect("run Git command");
assert!(
output.status.success(),
"git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output
}
#[cfg(unix)]
fn run_git_stdout(cwd: &Path, args: &[&str]) -> String {
String::from_utf8(run_git(cwd, args).stdout)
.expect("Git output should be UTF-8")
.trim()
.to_string()
}
#[cfg(unix)]
fn commit_all(cwd: &Path, message: &str) {
run_git(
cwd,
&[
"-c",
"user.name=Codex Test",
"-c",
"user.email=codex@example.com",
"commit",
"-qam",
message,
],
);
}
#[cfg(unix)]
#[tokio::test]
async fn diff_against_sha_does_not_lazy_fetch_promisor_objects() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let source = temp_dir.path().join("source");
let clone = temp_dir.path().join("clone");
std::fs::create_dir(&source).expect("create source repository");
run_git(&source, &["init", "-q", "--initial-branch=main"]);
run_git(&source, &["config", "uploadpack.allowFilter", "true"]);
std::fs::write(source.join("data.txt"), "before\n").expect("write initial blob");
run_git(&source, &["add", "data.txt"]);
commit_all(&source, "initial");
let base_sha = run_git_stdout(&source, &["rev-parse", "HEAD"]);
let base_blob = run_git_stdout(&source, &["rev-parse", "HEAD:data.txt"]);
std::fs::write(source.join("data.txt"), "after\n").expect("write current blob");
commit_all(&source, "current");
let complete_diff = diff_against_sha(&source, &GitSha::new(&base_sha))
.await
.expect("diff complete repository");
assert!(
complete_diff.contains("-before") && complete_diff.contains("+after"),
"complete-repository diff should remain available:\n{complete_diff}"
);
let source_url = format!("file://{}", source.display());
run_git(
temp_dir.path(),
&[
"-c",
"protocol.file.allow=always",
"clone",
"-q",
"--no-local",
"--filter=blob:none",
"--no-checkout",
&source_url,
clone.to_str().expect("clone path"),
],
);
run_git(&clone, &["checkout", "-q", "main"]);
let missing = run_git_stdout(
&clone,
&["rev-list", "--objects", "--all", "--missing=print"],
);
assert!(
missing.lines().any(|line| line == format!("?{base_blob}")),
"expected historical blob {base_blob} to remain missing:\n{missing}"
);
let helper = temp_dir.path().join("transport-helper.sh");
std::fs::write(&helper, "#!/bin/sh\nprintf ran >\"$0.ran\"\nexit 1\n")
.expect("write transport helper");
let mut permissions = std::fs::metadata(&helper)
.expect("read transport helper metadata")
.permissions();
permissions.set_mode(/*mode*/ 0o755);
std::fs::set_permissions(&helper, permissions).expect("make transport helper executable");
let helper_url = format!("ext::{}", helper.display());
run_git(&clone, &["config", "remote.origin.url", &helper_url]);
run_git(&clone, &["config", "protocol.ext.allow", "always"]);
let diff = diff_against_sha(&clone, &GitSha::new(&base_sha)).await;
assert_eq!(
(diff, helper.with_extension("sh.ran").exists()),
(None, false),
"local-only diff must fail without invoking the promisor transport"
);
}
#[test]
fn canonicalize_git_remote_url_normalizes_github_variants() {
for remote in [
+2
View File
@@ -4,6 +4,7 @@ mod branch;
mod errors;
mod fsmonitor;
mod info;
mod local_only;
mod operations;
mod platform;
@@ -42,4 +43,5 @@ pub use info::git_diff_to_remote;
pub use info::local_git_branches;
pub use info::recent_commits;
pub use info::resolve_root_git_project_for_trust;
pub use local_only::local_only_git_env;
pub use platform::create_symlink;
+8
View File
@@ -0,0 +1,8 @@
/// Environment overrides for Git commands that must only use local repository data.
///
/// An empty `GIT_ALLOW_PROTOCOL` list denies every Git transport, including implicit fetches from
/// promisor remotes. `GIT_NO_LAZY_FETCH` provides defense in depth on Git versions that support it;
/// older versions safely ignore the unknown variable.
pub fn local_only_git_env() -> impl Iterator<Item = (&'static str, &'static str)> {
[("GIT_ALLOW_PROTOCOL", ""), ("GIT_NO_LAZY_FETCH", "1")].into_iter()
}
+1
View File
@@ -117,6 +117,7 @@ where
command.env(key, value);
}
}
command.envs(crate::local_only_git_env());
command.args(&args_vec);
let output = command.output()?;
if !output.status.success() {