Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions codex-rs/git-utils/src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
}
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
121 changes: 120 additions & 1 deletion codex-rs/git-utils/src/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,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,
Expand All @@ -437,6 +441,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.
Expand Down Expand Up @@ -918,6 +923,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 [
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/git-utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod branch;
mod errors;
mod fsmonitor;
mod info;
mod local_only;
mod operations;
mod platform;

Expand Down Expand Up @@ -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 changes: 8 additions & 0 deletions codex-rs/git-utils/src/local_only.rs
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions codex-rs/git-utils/src/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
157 changes: 156 additions & 1 deletion codex-rs/tui/src/branch_summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ async fn run_git_command(
argv.extend(args.iter().map(|arg| (*arg).to_string()));
runner
.run(
WorkspaceCommand::new(argv)
WorkspaceCommand::local_only_git(argv)
.cwd(cwd.to_path_buf())
.env("GIT_OPTIONAL_LOCKS", "0"),
)
Expand Down Expand Up @@ -513,10 +513,85 @@ mod tests {
use super::*;
use crate::workspace_command::WorkspaceCommand;
use pretty_assertions::assert_eq;
#[cfg(unix)]
use std::fs;
use std::future::Future;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::pin::Pin;
#[cfg(unix)]
use std::process::Command as ProcessCommand;
use std::sync::Mutex;

#[cfg(unix)]
#[tokio::test]
async fn branch_diff_stats_do_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");
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"), "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"]);

fs::write(source.join("data.txt"), "after\n").expect("write current blob");
commit_all(&source, "current");

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}"
);
run_git(
&clone,
&["update-ref", "refs/remotes/origin/main", &base_sha],
);

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(&clone, &["config", "remote.origin.url", &helper_url]);
run_git(&clone, &["config", "protocol.ext.allow", "always"]);

let stats = branch_diff_stats_to_default_branch(&LocalRunner, &clone).await;

assert_eq!(
(stats, helper.with_extension("sh.ran").exists()),
(None, false),
"local-only branch stats must fail without invoking the promisor transport"
);
}

#[tokio::test]
async fn branch_diff_stats_prefers_remote_default_ref_over_stale_local_branch() {
let runner = FakeRunner::new(vec![
Expand Down Expand Up @@ -736,4 +811,84 @@ mod tests {
})
}
}

#[cfg(unix)]
fn run_git(cwd: &Path, args: &[&str]) -> std::process::Output {
let output = ProcessCommand::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)]
struct LocalRunner;

#[cfg(unix)]
impl WorkspaceCommandExecutor for LocalRunner {
fn run(
&self,
command: WorkspaceCommand,
) -> Pin<
Box<
dyn Future<Output = Result<WorkspaceCommandOutput, WorkspaceCommandError>>
+ Send
+ '_,
>,
> {
Box::pin(async move {
let mut process = ProcessCommand::new(&command.argv[0]);
process
.args(&command.argv[1..])
.current_dir(command.cwd.expect("test command cwd"));
for (key, value) in command.env {
match value {
Some(value) => {
process.env(key, value);
}
None => {
process.env_remove(key);
}
}
}
let output = process.output().expect("run test command");
Ok(WorkspaceCommandOutput {
exit_code: output.status.code().expect("test command exit code"),
stdout: String::from_utf8(output.stdout).expect("utf8 stdout"),
stderr: String::from_utf8(output.stderr).expect("utf8 stderr"),
})
})
}
}
}
Loading
Loading