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..77ef872d8ed 100644 --- a/gix-path/src/env/mod.rs +++ b/gix-path/src/env/mod.rs @@ -139,6 +139,21 @@ 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`. +/// +/// 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) +} + 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..c7de0448e1b 100644 --- a/gix-path/tests/path/env.rs +++ b/gix-path/tests/path/env.rs @@ -55,6 +55,36 @@ fn core_dir() { ); } +#[test] +fn core_dir_program() { + 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, + "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};