66//! absolute path exactly once (cached in a [`OnceLock`]) and hands every product
77//! spawn site that cached path, so the long-running daemon never re-walks `PATH`.
88//!
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.
9+ //! The public gix-first read paths in [`crate::branch`] and [`crate::worktree`]
10+ //! are unaffected: they still prefer in-process `gix` and only reach a `git`
11+ //! subprocess as a gated fallback.
1312
1413use std:: ffi:: { OsStr , OsString } ;
1514use std:: path:: { Path , PathBuf } ;
16- use std:: process:: { Command , Output } ;
15+ use std:: process:: { Child , Command , Output , Stdio } ;
1716use std:: sync:: OnceLock ;
17+ use std:: time:: { Duration , Instant } ;
1818
1919/// The literal used when resolution fails, preserving today's behavior (the OS
2020/// PATH-walks per spawn, but callers keep working).
2121const GIT_LITERAL : & str = "git" ;
22+ const GIT_CAPTURE_AT_TIMEOUT : Duration = Duration :: from_secs ( 2 ) ;
23+ const CHILD_WAIT_POLL_INTERVAL : Duration = Duration :: from_millis ( 10 ) ;
2224
2325/// Returns the resolved `git` program to spawn, as a cached `&'static OsStr`.
2426///
@@ -129,6 +131,92 @@ pub(crate) fn git_capture(repo_root: &Path, args: &[&str]) -> Option<String> {
129131 ( !trimmed. is_empty ( ) ) . then ( || trimmed. to_string ( ) )
130132}
131133
134+ /// Outcome of the bounded `git -C` capture used by repository identity lookup.
135+ #[ derive( Debug ) ]
136+ pub ( crate ) enum GitCaptureAtResult {
137+ Captured ( String ) ,
138+ Failed ,
139+ TimedOut ,
140+ }
141+
142+ /// Runs `git -C <repo_root> <args>` without setting the child process working
143+ /// directory to `repo_root`.
144+ ///
145+ /// Some network-backed or otherwise unhealthy project roots can block inside
146+ /// the child's initial `getcwd` when passed through [`Command::current_dir`].
147+ /// Git's `-C` resolves the repository after process startup and avoids that
148+ /// pre-argument cwd lookup. The child is killed and reaped at the hard deadline.
149+ pub ( crate ) fn git_capture_at ( repo_root : & Path , args : & [ & str ] ) -> GitCaptureAtResult {
150+ let mut command = git_command_at ( repo_root, args) ;
151+ command. stdout ( Stdio :: piped ( ) ) . stderr ( Stdio :: piped ( ) ) ;
152+ let Ok ( child) = command. spawn ( ) else {
153+ return GitCaptureAtResult :: Failed ;
154+ } ;
155+ match capture_child_with_deadline ( child, GIT_CAPTURE_AT_TIMEOUT ) {
156+ ChildCaptureResult :: Completed ( output) if output. status . success ( ) => {
157+ let Ok ( text) = String :: from_utf8 ( output. stdout ) else {
158+ return GitCaptureAtResult :: Failed ;
159+ } ;
160+ let trimmed = text. trim ( ) ;
161+ if trimmed. is_empty ( ) {
162+ GitCaptureAtResult :: Failed
163+ } else {
164+ GitCaptureAtResult :: Captured ( trimmed. to_string ( ) )
165+ }
166+ }
167+ ChildCaptureResult :: TimedOut => GitCaptureAtResult :: TimedOut ,
168+ ChildCaptureResult :: Completed ( _) | ChildCaptureResult :: Failed => GitCaptureAtResult :: Failed ,
169+ }
170+ }
171+
172+ fn git_command_at ( repo_root : & Path , args : & [ & str ] ) -> Command {
173+ let mut command = Command :: new ( git_program ( ) ) ;
174+ command. arg ( "-C" ) . arg ( repo_root) . args ( args) ;
175+ command
176+ }
177+
178+ #[ derive( Debug ) ]
179+ enum ChildCaptureResult {
180+ Completed ( Output ) ,
181+ Failed ,
182+ TimedOut ,
183+ }
184+
185+ fn capture_child_with_deadline ( mut child : Child , timeout : Duration ) -> ChildCaptureResult {
186+ let deadline = Instant :: now ( ) + timeout;
187+ loop {
188+ match child. try_wait ( ) {
189+ Ok ( Some ( _) ) => {
190+ return child
191+ . wait_with_output ( )
192+ . map ( ChildCaptureResult :: Completed )
193+ . unwrap_or ( ChildCaptureResult :: Failed ) ;
194+ }
195+ Ok ( None ) => { }
196+ Err ( _) => {
197+ let _ = child. kill ( ) ;
198+ let _ = child. wait ( ) ;
199+ return ChildCaptureResult :: Failed ;
200+ }
201+ }
202+
203+ let now = Instant :: now ( ) ;
204+ if now >= deadline {
205+ let _ = child. kill ( ) ;
206+ return if child. wait ( ) . is_ok ( ) {
207+ ChildCaptureResult :: TimedOut
208+ } else {
209+ ChildCaptureResult :: Failed
210+ } ;
211+ }
212+ std:: thread:: sleep (
213+ deadline
214+ . saturating_duration_since ( now)
215+ . min ( CHILD_WAIT_POLL_INTERVAL ) ,
216+ ) ;
217+ }
218+ }
219+
132220#[ cfg( test) ]
133221#[ allow( clippy:: unwrap_used, clippy:: expect_used) ]
134222mod tests {
@@ -151,6 +239,53 @@ mod tests {
151239 ) ;
152240 }
153241
242+ #[ test]
243+ fn git_at_command_uses_dash_c_without_target_current_dir ( ) {
244+ let repo_root = Path :: new ( "/problematic/project/root" ) ;
245+ let command = git_command_at (
246+ repo_root,
247+ & [ "rev-parse" , "--show-toplevel" , "--git-common-dir" ] ,
248+ ) ;
249+
250+ assert ! (
251+ command. get_current_dir( ) . is_none( ) ,
252+ "git -C must inherit the safe daemon cwd instead of entering the target root"
253+ ) ;
254+ assert_eq ! (
255+ command
256+ . get_args( )
257+ . map( std:: ffi:: OsStr :: to_os_string)
258+ . collect:: <Vec <_>>( ) ,
259+ vec![
260+ OsString :: from( "-C" ) ,
261+ repo_root. as_os_str( ) . to_os_string( ) ,
262+ OsString :: from( "rev-parse" ) ,
263+ OsString :: from( "--show-toplevel" ) ,
264+ OsString :: from( "--git-common-dir" ) ,
265+ ]
266+ ) ;
267+ }
268+
269+ #[ cfg( unix) ]
270+ #[ test]
271+ fn git_capture_deadline_kills_and_reaps_child ( ) {
272+ let child = Command :: new ( "/bin/sleep" )
273+ . arg ( "30" )
274+ . spawn ( )
275+ . expect ( "spawn sleeping child" ) ;
276+ let started = std:: time:: Instant :: now ( ) ;
277+
278+ let result = capture_child_with_deadline ( child, std:: time:: Duration :: from_millis ( 25 ) ) ;
279+
280+ let ChildCaptureResult :: TimedOut = result else {
281+ panic ! ( "sleeping child should time out, got {result:?}" ) ;
282+ } ;
283+ assert ! (
284+ started. elapsed( ) < std:: time:: Duration :: from_secs( 2 ) ,
285+ "deadline must stop and reap the child promptly"
286+ ) ;
287+ }
288+
154289 #[ test]
155290 fn git_env_override_is_honored ( ) {
156291 // resolve_git_program() reads GIT directly; test it in isolation so the
0 commit comments