#[cfg(unix)]
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitCode;
use crate::agent_detection::EnvSource;
#[derive(Debug, thiserror::Error)]
pub(crate) enum CaptureError {
#[error("could not find real git binary")]
RealGitNotFound,
#[error("failed to spawn git: {0}")]
Spawn(#[from] std::io::Error),
}
impl CaptureError {
pub(crate) fn kind(&self) -> &'static str {
match self {
Self::RealGitNotFound => "git_not_found",
Self::Spawn(_) => "spawn_failed",
}
}
}
pub(crate) trait RealGitExec {
fn passthrough(&self, argv: &[&str]) -> ExitCode;
fn capture(&self, argv: &[&str]) -> Result<usize, CaptureError>;
}
pub(crate) struct StdRealGitExec<'e, E: EnvSource> {
pub(crate) env: &'e E,
pub(crate) argv0: &'e str,
}
impl<E: EnvSource> RealGitExec for StdRealGitExec<'_, E> {
fn passthrough(&self, argv: &[&str]) -> ExitCode {
#[cfg(unix)]
{
let binary_name = std::path::Path::new(self.argv0)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("git");
let real = match resolve_real_binary(binary_name, self.argv0, self.env) {
Some(p) => p,
None => {
eprintln!("git-prism shim: could not find real {binary_name} binary");
return ExitCode::from(127);
}
};
if self.env.get("GIT_PRISM_DEBUG_RESOLVER").is_some() {
eprintln!(
"git-prism shim: resolved real {binary_name} to {}",
real.display()
);
}
use std::os::unix::process::CommandExt as _;
let mut cmd = std::process::Command::new(&real);
cmd.args(argv.iter().skip(1)); cmd.env("GIT_PRISM_INSIDE_SHIM", "1");
let err = cmd.exec(); eprintln!("git-prism shim: exec of {} failed: {err}", real.display());
ExitCode::from(exec_failure_exit_code(&err))
}
#[cfg(not(unix))]
{
let binary_name = std::path::Path::new(self.argv0)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("git");
let real = match resolve_real_binary(binary_name, self.argv0, self.env) {
Some(p) => p,
None => {
eprintln!("git-prism shim: could not find real {binary_name} binary");
return ExitCode::from(127);
}
};
if self.env.get("GIT_PRISM_DEBUG_RESOLVER").is_some() {
eprintln!(
"git-prism shim: resolved real {binary_name} to {}",
real.display()
);
}
let status = std::process::Command::new(&real)
.args(argv.iter().skip(1))
.env("GIT_PRISM_INSIDE_SHIM", "1")
.status();
match status {
Ok(s) => {
let code = s.code().unwrap_or(1);
ExitCode::from(code as u8)
}
Err(err) => {
eprintln!("git-prism shim: spawn of {} failed: {err}", real.display());
ExitCode::from(exec_failure_exit_code(&err))
}
}
}
}
fn capture(&self, argv: &[&str]) -> Result<usize, CaptureError> {
let real = resolve_real_git(self.argv0, self.env).ok_or(CaptureError::RealGitNotFound)?;
let output = std::process::Command::new(&real)
.args(argv.iter().skip(1))
.env("GIT_PRISM_INSIDE_SHIM", "1")
.output()?;
Ok(output.stdout.len())
}
}
fn exec_failure_exit_code(err: &std::io::Error) -> u8 {
match err.kind() {
std::io::ErrorKind::PermissionDenied => 126,
_ => 127,
}
}
#[cfg(unix)]
pub(crate) fn resolve_real_git(argv0: &str, env: &dyn EnvSource) -> Option<PathBuf> {
let shim_path = shim_canonical_path(argv0);
if let Some(path_var) = env.get("PATH") {
for entry in path_var.split(':') {
if entry.is_empty() {
continue;
}
let candidate = Path::new(entry).join("git");
if !is_executable(&candidate) {
continue;
}
if let Some(shim) = &shim_path {
let shim_dir = shim.parent().map(Path::to_path_buf);
let candidate_dir = candidate.parent().map(Path::to_path_buf);
let same_dir = match (&shim_dir, &candidate_dir) {
(Some(sd), Some(cd)) => canonical_eq(sd, cd),
_ => false,
};
if same_dir || canonical_eq(&candidate, shim) {
continue;
}
}
return Some(candidate);
}
}
for fallback in &[
"/usr/bin/git",
"/usr/local/bin/git",
"/opt/homebrew/bin/git",
] {
let p = Path::new(fallback);
if is_executable(p) {
return Some(p.to_path_buf());
}
}
None
}
#[cfg(not(unix))]
pub(crate) fn resolve_real_git(argv0: &str, env: &dyn EnvSource) -> Option<PathBuf> {
use std::path::Path;
let shim_canonical = std::path::PathBuf::from(argv0)
.canonicalize()
.ok()
.or_else(|| {
std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
});
if let Some(path_var) = env.get("PATH") {
for entry in path_var.split(';') {
if entry.is_empty() {
continue;
}
for name in &["git.exe", "git"] {
let candidate = Path::new(entry).join(name);
if !candidate.exists() {
continue;
}
if let Some(shim) = &shim_canonical {
if candidate.canonicalize().ok().as_ref() == Some(shim) {
continue;
}
}
return Some(candidate);
}
}
}
None
}
#[cfg(unix)]
pub(crate) fn resolve_real_binary(
binary_name: &str,
argv0: &str,
env: &dyn EnvSource,
) -> Option<PathBuf> {
if binary_name == "git" {
return resolve_real_git(argv0, env);
}
let shim_path = shim_canonical_path(argv0);
if let Some(path_var) = env.get("PATH") {
for entry in path_var.split(':') {
if entry.is_empty() {
continue;
}
let candidate = Path::new(entry).join(binary_name);
if !is_executable(&candidate) {
continue;
}
if let Some(shim) = &shim_path {
let shim_dir = shim.parent().map(Path::to_path_buf);
let candidate_dir = candidate.parent().map(Path::to_path_buf);
let same_dir = match (&shim_dir, &candidate_dir) {
(Some(sd), Some(cd)) => canonical_eq(sd, cd),
_ => false,
};
if same_dir || canonical_eq(&candidate, shim) {
continue;
}
}
return Some(candidate);
}
}
None
}
#[cfg(not(unix))]
pub(crate) fn resolve_real_binary(
binary_name: &str,
argv0: &str,
env: &dyn EnvSource,
) -> Option<PathBuf> {
use std::path::Path;
if binary_name == "git" {
return resolve_real_git(argv0, env);
}
let shim_canonical = std::path::PathBuf::from(argv0)
.canonicalize()
.ok()
.or_else(|| {
std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
});
if let Some(path_var) = env.get("PATH") {
for entry in path_var.split(';') {
if entry.is_empty() {
continue;
}
for suffix in &[".exe", ""] {
let candidate = Path::new(entry).join(format!("{binary_name}{suffix}"));
if !candidate.exists() {
continue;
}
if let Some(shim) = &shim_canonical {
if candidate.canonicalize().ok().as_ref() == Some(shim) {
continue;
}
}
return Some(candidate);
}
}
}
None
}
#[cfg(unix)]
fn shim_canonical_path(argv0: &str) -> Option<PathBuf> {
Path::new(argv0).canonicalize().ok().or_else(|| {
std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
})
}
#[cfg(unix)]
fn canonical_eq(a: &Path, b: &Path) -> bool {
let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
ca == cb
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
path.metadata()
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
#[cfg(unix)]
use std::fs;
#[cfg(unix)]
use tempfile::TempDir;
use super::*;
struct MapEnv(HashMap<String, String>);
impl EnvSource for MapEnv {
fn get(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
}
#[cfg(unix)]
fn make_executable_file(dir: &Path, name: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let p = dir.join(name);
fs::write(&p, b"#!/bin/sh\n").unwrap();
let mut perms = fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&p, perms).unwrap();
p
}
#[cfg(unix)]
fn env_with_path(path_val: &str) -> MapEnv {
MapEnv(HashMap::from([("PATH".to_string(), path_val.to_string())]))
}
#[cfg(unix)]
#[test]
fn it_skips_shim_directory_and_finds_git_in_next_path_entry() {
let shim_dir = TempDir::new().unwrap();
let real_dir = TempDir::new().unwrap();
make_executable_file(shim_dir.path(), "git");
let expected = make_executable_file(real_dir.path(), "git");
let shim_binary = shim_dir.path().join("git-prism");
fs::write(&shim_binary, b"").unwrap();
let path_val = format!(
"{}:{}",
shim_dir.path().display(),
real_dir.path().display()
);
let env = env_with_path(&path_val);
let argv0 = shim_binary.to_string_lossy().into_owned();
let result = resolve_real_git(&argv0, &env);
assert_eq!(result, Some(expected));
}
#[cfg(unix)]
#[test]
fn it_uses_fallback_chain_when_path_has_no_git() {
let env = env_with_path("");
let _ = resolve_real_git("/nonexistent/git-prism", &env);
}
#[cfg(unix)]
#[test]
fn it_returns_none_when_no_git_found_anywhere() {
let env = MapEnv(HashMap::new());
let _ = resolve_real_git("/nonexistent/path/git-prism", &env);
}
#[cfg(unix)]
#[test]
fn it_finds_git_in_path_when_shim_dir_is_absent() {
let real_dir = TempDir::new().unwrap();
let expected = make_executable_file(real_dir.path(), "git");
let path_val = real_dir.path().display().to_string();
let env = env_with_path(&path_val);
let result = resolve_real_git("/nonexistent/git-prism", &env);
assert_eq!(result, Some(expected));
}
#[cfg(unix)]
#[test]
fn it_skips_symlink_to_shim_binary_in_different_path_entry() {
let cellar_dir = TempDir::new().unwrap();
let link_dir = TempDir::new().unwrap();
let real_dir = TempDir::new().unwrap();
let shim_binary = make_executable_file(cellar_dir.path(), "git-prism");
let symlink_git = link_dir.path().join("git");
std::os::unix::fs::symlink(&shim_binary, &symlink_git).unwrap();
let expected = make_executable_file(real_dir.path(), "git");
let path_val = format!(
"{}:{}",
link_dir.path().display(),
real_dir.path().display()
);
let env = env_with_path(&path_val);
let argv0 = shim_binary.to_string_lossy().into_owned();
let result = resolve_real_git(&argv0, &env);
assert_eq!(
result,
Some(expected),
"resolver must skip the shim symlink and return the real git binary"
);
}
#[cfg(unix)]
#[test]
fn it_maps_permission_denied_to_exit_126() {
let err = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
assert_eq!(exec_failure_exit_code(&err), 126);
}
#[test]
fn it_maps_not_found_to_exit_127() {
let err = std::io::Error::from(std::io::ErrorKind::NotFound);
assert_eq!(exec_failure_exit_code(&err), 127);
}
#[test]
fn it_maps_other_errors_to_exit_127() {
let err = std::io::Error::from(std::io::ErrorKind::Other);
assert_eq!(exec_failure_exit_code(&err), 127);
}
#[cfg(unix)]
#[test]
fn it_skips_non_executable_git_files() {
let dir = TempDir::new().unwrap();
let git_path = dir.path().join("git");
fs::write(&git_path, b"#!/bin/sh\n").unwrap();
let path_val = dir.path().display().to_string();
let env = env_with_path(&path_val);
let result = resolve_real_git("/nonexistent/git-prism", &env);
if let Some(p) = result {
assert_ne!(p, git_path);
}
}
}