Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub fn local_branch_exists(project_root: &Path, branch: &str) -> bool {
if !crate::worktree::git_may_resolve_repo(project_root) {
return false;
}
std::process::Command::new("git")
std::process::Command::new(crate::git::git_program())
.args(["show-ref", "--verify", "--quiet", &refname])
.current_dir(project_root)
.status()
Expand Down Expand Up @@ -79,7 +79,7 @@ fn current_branch_gix(project_root: &Path) -> GixHead {
}

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

fn git_rev_list_count(project_root: &Path, from_ref: &str, to_ref: &str) -> Option<usize> {
let output = std::process::Command::new("git")
let output = std::process::Command::new(crate::git::git_program())
.args(["rev-list", "--count", &format!("{from_ref}..{to_ref}")])
.current_dir(project_root)
.output()
Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ fn is_ignored_by_git(project_path: &Path, git_config_global: Option<&Path>) -> O
.and_then(|path| is_ignored_by_explicit_global_excludes(project_path, path))
};
let dir_name = active_data_dir_name(project_path);
let mut command = Command::new("git");
let mut command = Command::new(crate::git::git_program());
command
.arg("-C")
.arg(project_path)
Expand Down
145 changes: 145 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//! Process-wide resolution of the `git` binary.
//!
//! The daemon and CLI spawn `git` from ~13 sites. A bare `Command::new("git")`
//! makes the OS re-walk `PATH` on every spawn — cheap on Linux/macOS but
//! ~100-300ms per spawn on Windows. This module resolves the `git` binary to an
//! absolute path exactly once (cached in a [`OnceLock`]) and hands every product
//! spawn site that cached path, so the long-running daemon never re-walks `PATH`.
//!
//! The gix-first read paths in [`crate::branch`] and [`crate::worktree`] are
//! unaffected: they still prefer in-process `gix` and only reach a `git`
//! subprocess as a gated fallback. This module only changes *which* program those
//! fallbacks (and the one-shot spawn sites) exec.

use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

/// The literal used when resolution fails, preserving today's behavior (the OS
/// PATH-walks per spawn, but callers keep working).
const GIT_LITERAL: &str = "git";

/// Returns the resolved `git` program to spawn, as a cached `&'static OsStr`.
///
/// Resolution order (performed once, then cached):
/// 1. The `GIT` environment variable, if set and non-empty (explicit override,
/// matching git's own habit of honoring a program override).
/// 2. An absolute path found by a which-style walk of `PATH` (+ `PATHEXT` on
/// Windows).
/// 3. The literal `"git"` fallback, so behavior is never worse than a bare
/// `Command::new("git")`.
///
/// Callers pass the result straight to `Command::new(..)` (both `std` and
/// `tokio` accept `impl AsRef<OsStr>`).
pub fn git_program() -> &'static OsStr {
static PROGRAM: OnceLock<OsString> = OnceLock::new();
PROGRAM.get_or_init(resolve_git_program).as_os_str()
}

fn resolve_git_program() -> OsString {
// 1. Explicit override wins. Empty values are ignored so an accidental
// `GIT=` does not break spawns.
if let Some(value) = std::env::var_os("GIT") {
if !value.is_empty() {
return value;
}
}

// 2. which-style lookup over PATH (+ PATHEXT on Windows).
if let Some(path) = find_in_path(GIT_LITERAL) {
return path.into_os_string();
}

// 3. Fallback: let the OS resolve it per-spawn, as before.
OsString::from(GIT_LITERAL)
}

/// Minimal `which`-style lookup: find `name` as an executable on `PATH`.
///
/// On Windows, each `PATH` entry is probed with every `PATHEXT` suffix (and the
/// bare name) so `git.exe` resolves from `git`. On Unix, the bare name is probed
/// and the entry must be a file (execute-permission is not separately checked —
/// git's own PATH lookup does not either, and a false positive simply degrades to
/// today's per-spawn PATH walk on exec failure).
fn find_in_path(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
if dir.as_os_str().is_empty() {
continue;
}
if let Some(found) = probe_dir(&dir, name) {
return Some(found);
}
}
None
}

#[cfg(windows)]
fn probe_dir(dir: &Path, name: &str) -> Option<PathBuf> {
// PATHEXT holds the executable suffixes (";"-separated), e.g.
// ".COM;.EXE;.BAT;.CMD". Fall back to a sane default when unset.
let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());

// If the name already carries an extension, try it verbatim first.
let bare = dir.join(name);
if bare.is_file() {
return Some(bare);
}
for ext in pathext.split(';') {
let ext = ext.trim();
if ext.is_empty() {
continue;
}
let candidate = dir.join(format!("{name}{ext}"));
if candidate.is_file() {
return Some(candidate);
}
}
None
}

#[cfg(not(windows))]
fn probe_dir(dir: &Path, name: &str) -> Option<PathBuf> {
let candidate = dir.join(name);
candidate.is_file().then_some(candidate)
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;

#[test]
fn git_program_is_stable_and_resolves() {
// Cached: two calls return the identical pointer/value.
let first = git_program();
let second = git_program();
assert_eq!(first, second);

// Either an existing absolute path was found, or we fell back to the
// literal "git" — never worse than a bare Command::new("git").
let resolved = Path::new(first);
assert!(
resolved == Path::new(GIT_LITERAL) || resolved.is_file(),
"git_program() should be the \"git\" fallback or an existing file, got {}",
resolved.display()
);
}

#[test]
fn git_env_override_is_honored() {
// resolve_git_program() reads GIT directly; test it in isolation so the
// process-wide OnceLock cache in git_program() is untouched.
let sentinel = "/nonexistent/tracedecay-test-git-override";
std::env::set_var("GIT", sentinel);
let resolved = resolve_git_program();
std::env::remove_var("GIT");
assert_eq!(resolved, OsString::from(sentinel));

// An empty GIT is ignored (falls through to PATH lookup / literal).
std::env::set_var("GIT", "");
let resolved_empty = resolve_git_program();
std::env::remove_var("GIT");
assert_ne!(resolved_empty, OsString::from(""));
}
}
2 changes: 1 addition & 1 deletion src/graph/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::errors::Result;
/// Shells out to `git log --format= --name-only --since='{days} days ago'`.
/// Returns an empty map if git is not available or not a repo.
pub async fn file_churn(project_root: &Path, days: u32) -> Result<HashMap<String, usize>> {
let output = tokio::process::Command::new("git")
let output = tokio::process::Command::new(crate::git::git_program())
.args([
"log",
"--format=",
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub mod errors;
pub mod external_tools;
pub mod extraction;
pub mod extraction_worker;
pub mod git;
pub mod global_db;
pub mod graph;
pub mod hooks;
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/tools/handlers/workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ fn tail(s: &str, n: usize) -> String {
async fn git_changed_paths(
project_root: &std::path::Path,
) -> std::result::Result<Vec<String>, String> {
let output = Command::new("git")
let output = Command::new(crate::git::git_program())
.args(["diff", "--name-only", "HEAD"])
.current_dir(project_root)
.output()
Expand Down
2 changes: 1 addition & 1 deletion src/tracedecay/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,7 @@ fn git_remote_url(project_root: &Path) -> Option<String> {
}

fn git_output(project_root: &Path, args: &[&str]) -> Option<String> {
let output = std::process::Command::new("git")
let output = std::process::Command::new(crate::git::git_program())
.args(args)
.current_dir(project_root)
.output()
Expand Down
2 changes: 1 addition & 1 deletion src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ fn git_command() -> Command {

#[cfg(not(test))]
fn git_command() -> Command {
Command::new("git")
Command::new(crate::git::git_program())
}

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