|
| 1 | +//! Process-wide resolution of the `git` binary. |
| 2 | +//! |
| 3 | +//! The daemon and CLI spawn `git` from ~13 sites. A bare `Command::new("git")` |
| 4 | +//! makes the OS re-walk `PATH` on every spawn — cheap on Linux/macOS but |
| 5 | +//! ~100-300ms per spawn on Windows. This module resolves the `git` binary to an |
| 6 | +//! absolute path exactly once (cached in a [`OnceLock`]) and hands every product |
| 7 | +//! spawn site that cached path, so the long-running daemon never re-walks `PATH`. |
| 8 | +//! |
| 9 | +//! The gix-first read paths in [`crate::branch`] and [`crate::worktree`] are |
| 10 | +//! unaffected: they still prefer in-process `gix` and only reach a `git` |
| 11 | +//! subprocess as a gated fallback. This module only changes *which* program those |
| 12 | +//! fallbacks (and the one-shot spawn sites) exec. |
| 13 | +
|
| 14 | +use std::ffi::{OsStr, OsString}; |
| 15 | +use std::path::{Path, PathBuf}; |
| 16 | +use std::sync::OnceLock; |
| 17 | + |
| 18 | +/// The literal used when resolution fails, preserving today's behavior (the OS |
| 19 | +/// PATH-walks per spawn, but callers keep working). |
| 20 | +const GIT_LITERAL: &str = "git"; |
| 21 | + |
| 22 | +/// Returns the resolved `git` program to spawn, as a cached `&'static OsStr`. |
| 23 | +/// |
| 24 | +/// Resolution order (performed once, then cached): |
| 25 | +/// 1. The `GIT` environment variable, if set and non-empty (explicit override, |
| 26 | +/// matching git's own habit of honoring a program override). |
| 27 | +/// 2. An absolute path found by a which-style walk of `PATH` (+ `PATHEXT` on |
| 28 | +/// Windows). |
| 29 | +/// 3. The literal `"git"` fallback, so behavior is never worse than a bare |
| 30 | +/// `Command::new("git")`. |
| 31 | +/// |
| 32 | +/// Callers pass the result straight to `Command::new(..)` (both `std` and |
| 33 | +/// `tokio` accept `impl AsRef<OsStr>`). |
| 34 | +pub fn git_program() -> &'static OsStr { |
| 35 | + static PROGRAM: OnceLock<OsString> = OnceLock::new(); |
| 36 | + PROGRAM.get_or_init(resolve_git_program).as_os_str() |
| 37 | +} |
| 38 | + |
| 39 | +fn resolve_git_program() -> OsString { |
| 40 | + // 1. Explicit override wins. Empty values are ignored so an accidental |
| 41 | + // `GIT=` does not break spawns. |
| 42 | + if let Some(value) = std::env::var_os("GIT") { |
| 43 | + if !value.is_empty() { |
| 44 | + return value; |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + // 2. which-style lookup over PATH (+ PATHEXT on Windows). |
| 49 | + if let Some(path) = find_in_path(GIT_LITERAL) { |
| 50 | + return path.into_os_string(); |
| 51 | + } |
| 52 | + |
| 53 | + // 3. Fallback: let the OS resolve it per-spawn, as before. |
| 54 | + OsString::from(GIT_LITERAL) |
| 55 | +} |
| 56 | + |
| 57 | +/// Minimal `which`-style lookup: find `name` as an executable on `PATH`. |
| 58 | +/// |
| 59 | +/// On Windows, each `PATH` entry is probed with every `PATHEXT` suffix (and the |
| 60 | +/// bare name) so `git.exe` resolves from `git`. On Unix, the bare name is probed |
| 61 | +/// and the entry must be a file (execute-permission is not separately checked — |
| 62 | +/// git's own PATH lookup does not either, and a false positive simply degrades to |
| 63 | +/// today's per-spawn PATH walk on exec failure). |
| 64 | +fn find_in_path(name: &str) -> Option<PathBuf> { |
| 65 | + let path_var = std::env::var_os("PATH")?; |
| 66 | + for dir in std::env::split_paths(&path_var) { |
| 67 | + if dir.as_os_str().is_empty() { |
| 68 | + continue; |
| 69 | + } |
| 70 | + if let Some(found) = probe_dir(&dir, name) { |
| 71 | + return Some(found); |
| 72 | + } |
| 73 | + } |
| 74 | + None |
| 75 | +} |
| 76 | + |
| 77 | +#[cfg(windows)] |
| 78 | +fn probe_dir(dir: &Path, name: &str) -> Option<PathBuf> { |
| 79 | + // PATHEXT holds the executable suffixes (";"-separated), e.g. |
| 80 | + // ".COM;.EXE;.BAT;.CMD". Fall back to a sane default when unset. |
| 81 | + let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); |
| 82 | + |
| 83 | + // If the name already carries an extension, try it verbatim first. |
| 84 | + let bare = dir.join(name); |
| 85 | + if bare.is_file() { |
| 86 | + return Some(bare); |
| 87 | + } |
| 88 | + for ext in pathext.split(';') { |
| 89 | + let ext = ext.trim(); |
| 90 | + if ext.is_empty() { |
| 91 | + continue; |
| 92 | + } |
| 93 | + let candidate = dir.join(format!("{name}{ext}")); |
| 94 | + if candidate.is_file() { |
| 95 | + return Some(candidate); |
| 96 | + } |
| 97 | + } |
| 98 | + None |
| 99 | +} |
| 100 | + |
| 101 | +#[cfg(not(windows))] |
| 102 | +fn probe_dir(dir: &Path, name: &str) -> Option<PathBuf> { |
| 103 | + let candidate = dir.join(name); |
| 104 | + candidate.is_file().then_some(candidate) |
| 105 | +} |
| 106 | + |
| 107 | +#[cfg(test)] |
| 108 | +#[allow(clippy::unwrap_used, clippy::expect_used)] |
| 109 | +mod tests { |
| 110 | + use super::*; |
| 111 | + |
| 112 | + #[test] |
| 113 | + fn git_program_is_stable_and_resolves() { |
| 114 | + // Cached: two calls return the identical pointer/value. |
| 115 | + let first = git_program(); |
| 116 | + let second = git_program(); |
| 117 | + assert_eq!(first, second); |
| 118 | + |
| 119 | + // Either an existing absolute path was found, or we fell back to the |
| 120 | + // literal "git" — never worse than a bare Command::new("git"). |
| 121 | + let resolved = Path::new(first); |
| 122 | + assert!( |
| 123 | + resolved == Path::new(GIT_LITERAL) || resolved.is_file(), |
| 124 | + "git_program() should be the \"git\" fallback or an existing file, got {}", |
| 125 | + resolved.display() |
| 126 | + ); |
| 127 | + } |
| 128 | + |
| 129 | + #[test] |
| 130 | + fn git_env_override_is_honored() { |
| 131 | + // resolve_git_program() reads GIT directly; test it in isolation so the |
| 132 | + // process-wide OnceLock cache in git_program() is untouched. |
| 133 | + let sentinel = "/nonexistent/tracedecay-test-git-override"; |
| 134 | + std::env::set_var("GIT", sentinel); |
| 135 | + let resolved = resolve_git_program(); |
| 136 | + std::env::remove_var("GIT"); |
| 137 | + assert_eq!(resolved, OsString::from(sentinel)); |
| 138 | + |
| 139 | + // An empty GIT is ignored (falls through to PATH lookup / literal). |
| 140 | + std::env::set_var("GIT", ""); |
| 141 | + let resolved_empty = resolve_git_program(); |
| 142 | + std::env::remove_var("GIT"); |
| 143 | + assert_ne!(resolved_empty, OsString::from("")); |
| 144 | + } |
| 145 | +} |
0 commit comments