Skip to content

Commit bc63387

Browse files
perf(git): resolve git binary once via cached git_program() (#253)
The daemon and CLI spawn `git` from several sites, each a bare `Command::new("git")` that makes the OS re-walk PATH per spawn (~100-300ms on Windows). Add `git::git_program()` which resolves the git binary to an absolute path exactly once (OnceLock cache) and route all product git-CLI spawn sites through it, so the long-running daemon never re-walks PATH for git. Resolution order: `GIT` env override -> which-style PATH lookup (+ PATHEXT on Windows, hand-rolled, no new crate) -> literal "git" fallback so behavior is never worse than today. The gix-first read paths in branch.rs/worktree.rs are unchanged: they still prefer in-process gix and only reach a git subprocess as a gated fallback. Only that fallback and the one-shot spawn sites now exec the cached path. Test-only spawn helpers are left untouched. Converted sites: worktree.rs (git_command), branch.rs (x3), config.rs (check-ignore), graph/git.rs (tokio churn), workflow.rs (tokio diff), tracedecay/lifecycle.rs (git_output). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e5da196 commit bc63387

8 files changed

Lines changed: 154 additions & 8 deletions

File tree

src/branch.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pub fn local_branch_exists(project_root: &Path, branch: &str) -> bool {
3939
if !crate::worktree::git_may_resolve_repo(project_root) {
4040
return false;
4141
}
42-
std::process::Command::new("git")
42+
std::process::Command::new(crate::git::git_program())
4343
.args(["show-ref", "--verify", "--quiet", &refname])
4444
.current_dir(project_root)
4545
.status()
@@ -79,7 +79,7 @@ fn current_branch_gix(project_root: &Path) -> GixHead {
7979
}
8080

8181
fn current_branch_git(project_root: &Path) -> Option<String> {
82-
let output = std::process::Command::new("git")
82+
let output = std::process::Command::new(crate::git::git_program())
8383
.args(["symbolic-ref", "-q", "HEAD"])
8484
.current_dir(project_root)
8585
.output()
@@ -94,7 +94,7 @@ fn current_branch_git(project_root: &Path) -> Option<String> {
9494
}
9595

9696
fn git_rev_list_count(project_root: &Path, from_ref: &str, to_ref: &str) -> Option<usize> {
97-
let output = std::process::Command::new("git")
97+
let output = std::process::Command::new(crate::git::git_program())
9898
.args(["rev-list", "--count", &format!("{from_ref}..{to_ref}")])
9999
.current_dir(project_root)
100100
.output()

src/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ fn is_ignored_by_git(project_path: &Path, git_config_global: Option<&Path>) -> O
302302
.and_then(|path| is_ignored_by_explicit_global_excludes(project_path, path))
303303
};
304304
let dir_name = active_data_dir_name(project_path);
305-
let mut command = Command::new("git");
305+
let mut command = Command::new(crate::git::git_program());
306306
command
307307
.arg("-C")
308308
.arg(project_path)

src/git.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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+
}

src/graph/git.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::errors::Result;
1111
/// Shells out to `git log --format= --name-only --since='{days} days ago'`.
1212
/// Returns an empty map if git is not available or not a repo.
1313
pub async fn file_churn(project_root: &Path, days: u32) -> Result<HashMap<String, usize>> {
14-
let output = tokio::process::Command::new("git")
14+
let output = tokio::process::Command::new(crate::git::git_program())
1515
.args([
1616
"log",
1717
"--format=",

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ pub mod errors;
3939
pub mod external_tools;
4040
pub mod extraction;
4141
pub mod extraction_worker;
42+
pub mod git;
4243
pub mod global_db;
4344
pub mod graph;
4445
pub mod hooks;

src/mcp/tools/handlers/workflow.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,7 @@ fn tail(s: &str, n: usize) -> String {
531531
async fn git_changed_paths(
532532
project_root: &std::path::Path,
533533
) -> std::result::Result<Vec<String>, String> {
534-
let output = Command::new("git")
534+
let output = Command::new(crate::git::git_program())
535535
.args(["diff", "--name-only", "HEAD"])
536536
.current_dir(project_root)
537537
.output()

src/tracedecay/lifecycle.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,7 @@ fn git_remote_url(project_root: &Path) -> Option<String> {
856856
}
857857

858858
fn git_output(project_root: &Path, args: &[&str]) -> Option<String> {
859-
let output = std::process::Command::new("git")
859+
let output = std::process::Command::new(crate::git::git_program())
860860
.args(args)
861861
.current_dir(project_root)
862862
.output()

src/worktree.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ fn git_command() -> Command {
178178

179179
#[cfg(not(test))]
180180
fn git_command() -> Command {
181-
Command::new("git")
181+
Command::new(crate::git::git_program())
182182
}
183183

184184
fn git_output(dir: &Path, args: &[&str]) -> Option<String> {

0 commit comments

Comments
 (0)