From 0f01b0d920ee374b563df8d15e7cb4cfba1f2df3 Mon Sep 17 00:00:00 2001 From: Amey Pawar Date: Wed, 8 Jul 2026 16:32:50 +0530 Subject: [PATCH 1/2] fix: local clones succeed even if git-upload-pack is not in PATH (#2313) Local clones spawn the service program, like git-upload-pack, by name and would fail if it could not be found in PATH. This is common on Windows, where git can be installed such that git itself is available but its subcommands are not, for instance with scoop. Now, when spawning the service program for a local repository fails because it cannot be found, find the same program in the directory that git --exec-path reports - the location git itself uses to run its subcommands - and run it from there. If it cannot be found there either, run the git binary that is always findable with the service as its subcommand, which it can always dispatch. Remote transports are unaffected - they keep the standard invocation so servers can provide their own implementations - and no shell is involved in any of this. The newly added gix_path::env::core_dir_program() provides the lookup and may serve other Git-provided programs in the future. --- Cargo.lock | 1 + gix-path/src/env/mod.rs | 11 ++ gix-path/tests/path/env.rs | 16 +++ gix-transport/Cargo.toml | 1 + gix-transport/src/client/blocking_io/file.rs | 121 +++++++++++++++++-- 5 files changed, 138 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f485a6febe..3e8d7272cb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2537,6 +2537,7 @@ dependencies = [ "gix-hash", "gix-pack", "gix-packetline", + "gix-path", "gix-quote", "gix-sec", "gix-transport", diff --git a/gix-path/src/env/mod.rs b/gix-path/src/env/mod.rs index 35792a229d9..fe7629162af 100644 --- a/gix-path/src/env/mod.rs +++ b/gix-path/src/env/mod.rs @@ -139,6 +139,17 @@ pub fn core_dir() -> Option<&'static Path> { GIT_CORE_DIR.as_deref() } +/// Return the path at which the Git-provided program with `name` resides within the [`core_dir()`], +/// or `None` if it doesn't exist there or if Git could not be found. +/// +/// This is the location `git` itself uses to find the programs implementing its subcommands, and is +/// useful to invoke programs like `git-upload-pack` that are shipped with Git but aren't necessarily +/// present in `PATH`. +pub fn core_dir_program(name: &str) -> Option { + let path = core_dir()?.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + path.is_file().then_some(path) +} + fn system_prefix_from_core_dir(core_dir_func: F) -> Option where F: Fn() -> Option<&'static Path>, diff --git a/gix-path/tests/path/env.rs b/gix-path/tests/path/env.rs index 86803db550a..af35ceab08e 100644 --- a/gix-path/tests/path/env.rs +++ b/gix-path/tests/path/env.rs @@ -55,6 +55,22 @@ fn core_dir() { ); } +#[test] +fn core_dir_program() { + let path = gix_path::env::core_dir_program("git-upload-pack") + .expect("git-upload-pack is present in the exec-path of any complete Git installation"); + assert!(path.is_file(), "the returned path refers to an existing file"); + assert!( + path.is_absolute(), + "the path is absolute as it is based on `git --exec-path`" + ); + assert_eq!( + gix_path::env::core_dir_program("git-program-that-does-not-exist"), + None, + "programs that don't exist in the core directory are not found" + ); +} + #[test] fn system_prefix() { assert_ne!( diff --git a/gix-transport/Cargo.toml b/gix-transport/Cargo.toml index 35f2257483b..905f245c916 100644 --- a/gix-transport/Cargo.toml +++ b/gix-transport/Cargo.toml @@ -92,6 +92,7 @@ required-features = ["async-client"] [dependencies] gix-command = { version = "^0.9.1", path = "../gix-command" } +gix-path = { version = "^0.12.1", path = "../gix-path" } gix-features = { version = "^0.48.1", path = "../gix-features" } gix-url = { version = "^0.36.1", path = "../gix-url" } gix-sec = { version = "^0.14.1", path = "../gix-sec" } diff --git a/gix-transport/src/client/blocking_io/file.rs b/gix-transport/src/client/blocking_io/file.rs index f4cb1836351..bedd19efa95 100644 --- a/gix-transport/src/client/blocking_io/file.rs +++ b/gix-transport/src/client/blocking_io/file.rs @@ -128,6 +128,37 @@ impl SpawnProcessOnDemand { cmd.args.push(repo_path); Ok((cmd, ssh_kind, cmd_name)) } + + /// Prepare an invocation for when the program of `service`, e.g. `git-upload-pack`, isn't present in `PATH`, + /// which can happen on Windows in particular, where `git` may be installed without its subcommands + /// being directly available (#2313). + /// + /// Prefer the same program from git's own `--exec-path`, which is where `git` itself finds it, and + /// otherwise let the `git` we can always find run it as subcommand. + /// This is only used for local repositories - remote shells keep the standard invocation. + fn prepare_fallback_command(&self, service: Service) -> (gix_command::Prepare, OsString) { + let (mut cmd, cmd_name) = match gix_path::env::core_dir_program(service.as_str()) { + Some(program) => { + let cmd_name: OsString = program.clone().into(); + (gix_command::prepare(program).stderr(Stdio::null()), cmd_name) + } + None => { + let subcommand = service + .as_str() + .strip_prefix("git-") + .expect("all services are 'git-*' subcommands"); + let git = gix_path::env::exe_invocation(); + let mut cmd_name: OsString = git.into(); + cmd_name.push(" "); + cmd_name.push(subcommand); + let mut cmd = gix_command::prepare(git).stderr(Stdio::null()); + cmd.args.push(subcommand.into()); + (cmd, cmd_name) + } + }; + cmd.args.push(self.path.to_os_str_lossy().into_owned()); + (cmd, cmd_name) + } } impl client::TransportWithoutIO for SpawnProcessOnDemand { @@ -241,21 +272,42 @@ impl client::blocking_io::Transport for SpawnProcessOnDemand { service: Service, extra_parameters: &'a [(&'a str, Option<&'a str>)], ) -> Result, client::Error> { - let (mut cmd, ssh_kind, cmd_name) = self.prepare_command(service)?; - cmd.stdin = Stdio::piped(); - cmd.stdout = Stdio::piped(); + let (cmd, ssh_kind, cmd_name) = self.prepare_command(service)?; + let envs = std::mem::take(&mut self.envs); + let into_std_command = |mut cmd: gix_command::Prepare| { + cmd.stdin = Stdio::piped(); + cmd.stdout = Stdio::piped(); - let mut cmd = std::process::Command::from(cmd); - for env_to_remove in ENV_VARS_TO_REMOVE { - cmd.env_remove(env_to_remove); - } - cmd.envs(std::mem::take(&mut self.envs)); + let mut cmd = std::process::Command::from(cmd); + for env_to_remove in ENV_VARS_TO_REMOVE { + cmd.env_remove(env_to_remove); + } + cmd.envs(envs.iter().map(|(k, v)| (k, v))); + cmd + }; + let mut cmd = into_std_command(cmd); gix_features::trace::debug!(command = ?cmd, "gix_transport::SpawnProcessOnDemand"); - let mut child = cmd.spawn().map_err(|err| client::Error::InvokeProgram { - source: err, - command: cmd_name, - })?; + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(err) if ssh_kind.is_none() && err.kind() == std::io::ErrorKind::NotFound => { + // The service program wasn't found in `PATH`, but as `git` itself can be found, + // the service can still be run through it (#2313). + let (cmd, cmd_name) = self.prepare_fallback_command(service); + let mut cmd = into_std_command(cmd); + gix_features::trace::debug!(command = ?cmd, "gix_transport::SpawnProcessOnDemand (fallback)"); + cmd.spawn().map_err(|err| client::Error::InvokeProgram { + source: err, + command: cmd_name, + })? + } + Err(err) => { + return Err(client::Error::InvokeProgram { + source: err, + command: cmd_name, + }); + } + }; let stdout: Box = match ssh_kind { Some(ssh_kind) => Box::new(supervise_stderr( ssh_kind, @@ -305,6 +357,51 @@ pub fn connect( #[cfg(test)] mod tests { + mod local { + use crate::{Protocol, Service, client::blocking_io::file::SpawnProcessOnDemand}; + + #[test] + fn fallback_command_prefers_the_core_dir_program_and_can_always_run_git_itself() { + let transport = SpawnProcessOnDemand::new_local("/repo/path".into(), Protocol::V2, false); + let (cmd, name) = transport.prepare_fallback_command(Service::UploadPack); + + assert!(!cmd.use_shell, "a shell is never used to run the local service program"); + assert_eq!( + cmd.args.last().map(std::path::Path::new), + Some(std::path::Path::new("/repo/path")), + "the repository path is the final argument" + ); + match gix_path::env::core_dir_program(Service::UploadPack.as_str()) { + Some(program) => { + assert_eq!( + std::path::Path::new(&cmd.command), + program, + "the program shipped with Git in its `--exec-path` is used directly" + ); + assert!( + std::path::Path::new(&cmd.command).is_absolute(), + "which also means it's an absolute path" + ); + assert_eq!(cmd.args.len(), 1, "there is no sub-command, just the repo path"); + assert_eq!(name, program.into_os_string()); + } + None => { + assert_eq!( + std::path::Path::new(&cmd.command), + gix_path::env::exe_invocation(), + "without it, `git` itself runs the service as subcommand" + ); + assert_eq!( + cmd.args.first().and_then(|arg| arg.to_str()), + Some("upload-pack"), + "the sub-command is passed as separate argument, without the 'git-' prefix" + ); + assert_eq!(cmd.args.len(), 2, "sub-command and repo path"); + } + } + } + } + mod ssh { mod connect { use crate::{Protocol, client::blocking_io::ssh}; From bf86e75b59c2d2e9750e95083acb8cc31a840ac0 Mon Sep 17 00:00:00 2001 From: Amey Pawar Date: Wed, 8 Jul 2026 16:56:24 +0530 Subject: [PATCH 2/2] let the core-dir test discover which programs the installation provides Git for Windows builds with SKIP_DASHED_BUILT_INS and thus does not provide programs for builtin subcommands like git-upload-pack in its core directory. The test now grounds itself in a listing of that directory instead, and the documentation of core_dir_program() points out the difference - such programs are still run through git itself thanks to the second fallback. --- gix-path/src/env/mod.rs | 4 ++++ gix-path/tests/path/env.rs | 28 +++++++++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/gix-path/src/env/mod.rs b/gix-path/src/env/mod.rs index fe7629162af..77ef872d8ed 100644 --- a/gix-path/src/env/mod.rs +++ b/gix-path/src/env/mod.rs @@ -145,6 +145,10 @@ pub fn core_dir() -> Option<&'static Path> { /// This is the location `git` itself uses to find the programs implementing its subcommands, and is /// useful to invoke programs like `git-upload-pack` that are shipped with Git but aren't necessarily /// present in `PATH`. +/// +/// Note that installations differ in which programs they provide as separate executables - builds +/// with `SKIP_DASHED_BUILT_INS`, like Git for Windows, omit programs for builtin subcommands, which +/// can then still be run through `git` itself. pub fn core_dir_program(name: &str) -> Option { let path = core_dir()?.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)); path.is_file().then_some(path) diff --git a/gix-path/tests/path/env.rs b/gix-path/tests/path/env.rs index af35ceab08e..c7de0448e1b 100644 --- a/gix-path/tests/path/env.rs +++ b/gix-path/tests/path/env.rs @@ -57,13 +57,27 @@ fn core_dir() { #[test] fn core_dir_program() { - let path = gix_path::env::core_dir_program("git-upload-pack") - .expect("git-upload-pack is present in the exec-path of any complete Git installation"); - assert!(path.is_file(), "the returned path refers to an existing file"); - assert!( - path.is_absolute(), - "the path is absolute as it is based on `git --exec-path`" - ); + let core_dir = gix_path::env::core_dir().expect("Git is always in PATH when we run tests"); + let program = std::fs::read_dir(core_dir) + .expect("the core directory can be listed") + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|t| t.is_file())) + .find_map(|entry| { + let name = entry.file_name(); + let stem = name.to_str()?.strip_suffix(std::env::consts::EXE_SUFFIX)?; + stem.starts_with("git-").then(|| stem.to_owned()) + }); + // Which programs are present as separate executables depends on how Git was built - + // Git for Windows, for instance, may not provide programs for builtin subcommands. + if let Some(stem) = program { + let path = + gix_path::env::core_dir_program(&stem).expect("a program listed in the core directory is found there"); + assert!(path.is_file(), "the returned path refers to an existing file"); + assert!( + path.is_absolute(), + "the path is absolute as it is based on `git --exec-path`" + ); + } assert_eq!( gix_path::env::core_dir_program("git-program-that-does-not-exist"), None,