Skip to content

Commit 94f7839

Browse files
authored
perf: Reduce git subprocess calls on startup (#11934)
## Summary Reduces the number of sequential git subprocess calls on the critical startup path from 5 to 1. | Repo size | Before | After | Speedup | |---|---|---|---| | Small (1 package) | 868ms ± 27ms | 757ms ± 25ms | **1.14x** | | Medium (~10 packages) | 1.233s ± 0.063s | 1.127s ± 0.038s | **1.09x** | | Large (~50 packages) | 1.896s ± 0.124s | 1.735s ± 0.086s | **1.09x** | Measured with `hyperfine --warmup 5` on `turbo run <task> --skip-infer --dry`. "Before" is main with the shared HTTP client optimization already applied. ## What changed ### 1. Combined worktree detection into a single git call `WorktreeInfo::detect` previously spawned two separate `git rev-parse` subprocesses (`--show-toplevel` and `--git-common-dir`). Now it runs a single `git rev-parse --show-toplevel --git-common-dir --show-cdup` and parses all three results from the output. The `--show-cdup` result (git root) is stored on `WorktreeInfo` so it can be reused later by `SCM::new`. ### 2. Deferred run summary git calls to finish time `RunTracker::new` previously called `SCMState::get()` eagerly, which spawns `git branch --show-current` and `git rev-parse HEAD` — two subprocesses purely for run summary metadata. These have no impact on task execution or caching. They're now computed in `to_summary()`, after tasks complete. ### 3. Reuse git root from worktree detection in SCM::new `SCM::new` previously spawned its own `git rev-parse --show-cdup` to find the git root — the same information already resolved during worktree detection. Now the git root flows from `WorktreeInfo` → `CacheDirResult` → `Opts` → `RunBuilder`, and `SCM::new_with_git_root` skips the redundant subprocess entirely. Together this reduces 5 sequential subprocess spawns on the critical path to 1 (the combined worktree detection call), with the 2 summary calls deferred to after task execution. ## Testing - All 5 worktree tests pass with new `git_root` assertions (main worktree, linked worktree, subdirectories, non-git directories) - The deferred SCMState has no early readers — the `scm` field was removed from `RunTracker` entirely
1 parent 5bc5ae7 commit 94f7839

File tree

9 files changed

+127
-61
lines changed

9 files changed

+127
-61
lines changed

crates/turborepo-config/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ pub struct CacheDirResult {
6363
/// This is used for messaging to inform users when worktree cache sharing
6464
/// is active.
6565
pub is_shared_worktree: bool,
66+
/// The git repository root, if detected during worktree resolution.
67+
/// Captured here so `SCM::new` can skip its own `git rev-parse` call.
68+
pub git_root: Option<AbsoluteSystemPathBuf>,
6669
}
6770

6871
/// Configuration errors for turborepo.
@@ -393,6 +396,7 @@ impl ConfigurationOptions {
393396
return CacheDirResult {
394397
path: explicit_cache_dir.clone(),
395398
is_shared_worktree: false,
399+
git_root: None,
396400
};
397401
}
398402

@@ -405,6 +409,7 @@ impl ConfigurationOptions {
405409
worktree_info.main_worktree_root,
406410
worktree_info.is_linked_worktree()
407411
);
412+
let git_root = Some(worktree_info.git_root.clone());
408413
if worktree_info.is_linked_worktree() {
409414
// We're in a linked worktree - use the main worktree's cache
410415
// Use turbopath's join_component to ensure consistent path separators
@@ -415,6 +420,7 @@ impl ConfigurationOptions {
415420
let result = CacheDirResult {
416421
path: Utf8PathBuf::from(main_cache_path.as_str()),
417422
is_shared_worktree: true,
423+
git_root,
418424
};
419425
debug!("Using shared worktree cache at: {}", result.path);
420426
result
@@ -427,6 +433,7 @@ impl ConfigurationOptions {
427433
CacheDirResult {
428434
path: Utf8PathBuf::from(Self::DEFAULT_CACHE_DIR),
429435
is_shared_worktree: false,
436+
git_root,
430437
}
431438
}
432439
}
@@ -440,6 +447,7 @@ impl ConfigurationOptions {
440447
CacheDirResult {
441448
path: Utf8PathBuf::from(Self::DEFAULT_CACHE_DIR),
442449
is_shared_worktree: false,
450+
git_root: None,
443451
}
444452
}
445453
}

crates/turborepo-lib/src/opts.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use camino::Utf8PathBuf;
44
use serde::Serialize;
55
use thiserror::Error;
66
use tracing::debug;
7-
use turbopath::{AbsoluteSystemPath, AnchoredSystemPathBuf};
7+
use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf, AnchoredSystemPathBuf};
88
use turborepo_api_client::APIAuth;
99
use turborepo_cache::{CacheOpts, RemoteCacheOpts};
1010
// Re-export RunCacheOpts from turborepo-run-cache
@@ -66,6 +66,9 @@ pub struct Opts {
6666
pub scope_opts: ScopeOpts,
6767
pub tui_opts: TuiOpts,
6868
pub future_flags: FutureFlags,
69+
/// Pre-resolved git root from worktree detection, if available.
70+
/// Allows `SCM::new` to skip its own `git rev-parse` subprocess.
71+
pub git_root: Option<AbsoluteSystemPathBuf>,
6972
}
7073

7174
impl Opts {
@@ -189,6 +192,7 @@ impl Opts {
189192
api_client_opts,
190193
tui_opts,
191194
future_flags,
195+
git_root: cache_dir_result.git_root,
192196
})
193197
}
194198
}
@@ -733,6 +737,7 @@ mod test {
733737
runcache_opts,
734738
tui_opts,
735739
future_flags: Default::default(),
740+
git_root: None,
736741
};
737742
let synthesized = opts.synthesize_command();
738743
assert_eq!(synthesized, expected);

crates/turborepo-lib/src/run/builder.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,12 @@ impl RunBuilder {
203203

204204
let scm_task = {
205205
let repo_root = self.repo_root.clone();
206+
let git_root = self.opts.git_root.clone();
206207
tokio::task::spawn_blocking(move || {
207-
let scm = SCM::new(&repo_root);
208+
let scm = match git_root {
209+
Some(root) => SCM::new_with_git_root(&repo_root, root),
210+
None => SCM::new(&repo_root),
211+
};
208212
let repo_index = scm.build_repo_index_eager();
209213
(scm, repo_index)
210214
})

crates/turborepo-lib/src/run/mod.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -698,11 +698,8 @@ impl Run {
698698
let run_tracker = RunTracker::new(
699699
self.start_at,
700700
self.opts.synthesize_command(),
701-
&self.env_at_execution_start,
702-
&self.repo_root,
703701
self.version,
704702
Vendor::get_user(),
705-
&self.scm,
706703
);
707704

708705
let mut visitor = Visitor::new(
@@ -766,6 +763,7 @@ impl Run {
766763
global_hash_inputs,
767764
&self.engine,
768765
&self.env_at_execution_start,
766+
&self.scm,
769767
self.opts.scope_opts.pkg_inference_root.as_deref(),
770768
)
771769
.await?;

crates/turborepo-lib/src/task_graph/visitor/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use turborepo_errors::TURBO_SITE;
2424
use turborepo_process::ProcessManager;
2525
use turborepo_repository::package_graph::{PackageGraph, PackageName, ROOT_PKG_NAME};
2626
use turborepo_run_summary::{self as summary, GlobalHashSummary, RunTracker};
27+
use turborepo_scm::SCM;
2728
// Re-export output types and shared functions from turborepo-task-executor
2829
pub use turborepo_task_executor::{turbo_regex, StdWriter, TaskOutput};
2930
use turborepo_task_id::TaskId;
@@ -369,7 +370,8 @@ impl<'a> Visitor<'a> {
369370
packages,
370371
global_hash_inputs,
371372
engine,
372-
env_at_execution_start
373+
env_at_execution_start,
374+
scm,
373375
))]
374376
pub(crate) async fn finish(
375377
self,
@@ -378,6 +380,7 @@ impl<'a> Visitor<'a> {
378380
global_hash_inputs: GlobalHashableInputs<'_>,
379381
engine: &Engine,
380382
env_at_execution_start: &EnvironmentVariableMap,
383+
scm: &SCM,
381384
pkg_inference_root: Option<&AnchoredSystemPath>,
382385
) -> Result<(), Error> {
383386
let Self {
@@ -427,6 +430,7 @@ impl<'a> Visitor<'a> {
427430
engine,
428431
&task_hasher.task_hash_tracker(),
429432
env_at_execution_start,
433+
scm,
430434
is_watch,
431435
)
432436
.await?)

crates/turborepo-run-summary/src/scm.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ pub struct SCMState {
1919
}
2020

2121
impl SCMState {
22+
/// Resolve SCM state from CI environment variables, falling back to git
23+
/// subprocess calls if not in CI. The git fallback spawns two subprocesses
24+
/// (`git branch --show-current` and `git rev-parse HEAD`), so callers
25+
/// should defer this until the data is actually needed (e.g., summary
26+
/// generation) rather than calling it eagerly at run start.
2227
pub fn get(env_vars: &EnvironmentVariableMap, scm: &SCM, dir: &AbsoluteSystemPath) -> Self {
2328
let mut state = SCMState {
2429
ty: SCMType::Git,

crates/turborepo-run-summary/src/tracker.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ pub struct RunSummary<'a> {
8686
/// We use this to track the run, so it's constructed before the run.
8787
#[derive(Debug)]
8888
pub struct RunTracker {
89-
scm: SCMState,
9089
version: &'static str,
9190
started_at: DateTime<Local>,
9291
execution_tracker: ExecutionTracker,
@@ -95,20 +94,13 @@ pub struct RunTracker {
9594
}
9695

9796
impl RunTracker {
98-
#[allow(clippy::too_many_arguments)]
9997
pub fn new(
10098
started_at: DateTime<Local>,
10199
synthesized_command: String,
102-
env_at_execution_start: &EnvironmentVariableMap,
103-
repo_root: &AbsoluteSystemPath,
104100
version: &'static str,
105101
user: Option<String>,
106-
scm: &SCM,
107102
) -> Self {
108-
let scm = SCMState::get(env_at_execution_start, scm, repo_root);
109-
110103
RunTracker {
111-
scm,
112104
version,
113105
started_at,
114106
execution_tracker: ExecutionTracker::new(),
@@ -137,6 +129,8 @@ impl RunTracker {
137129
global_hash_summary: GlobalHashSummary<'a>,
138130
global_env_mode: EnvMode,
139131
task_factory: TaskSummaryFactory<'a, E, H, R>,
132+
env_at_execution_start: &EnvironmentVariableMap,
133+
scm: &SCM,
140134
) -> Result<RunSummary<'a>, Error>
141135
where
142136
E: EngineInfo,
@@ -152,6 +146,10 @@ impl RunTracker {
152146
Some(DryRunMode::Text) => RunType::DryText,
153147
};
154148

149+
// Deferred to summary time so we don't pay the cost of git subprocess
150+
// calls (branch + sha) during the critical path of task execution.
151+
let scm_state = SCMState::get(env_at_execution_start, scm, repo_root);
152+
155153
let summary_state = self.execution_tracker.finish().await?;
156154

157155
let tasks = summary_state
@@ -179,7 +177,7 @@ impl RunTracker {
179177
framework_inference: run_opts.framework_inference(),
180178
tasks,
181179
global_hash_summary,
182-
scm: self.scm,
180+
scm: scm_state,
183181
user: self.user.unwrap_or_default(),
184182
monorepo: !single_package,
185183
repo_root,
@@ -196,7 +194,8 @@ impl RunTracker {
196194
global_hash_summary,
197195
engine,
198196
hash_tracker,
199-
env_at_execution_start
197+
env_at_execution_start,
198+
scm,
200199
))]
201200
#[allow(clippy::too_many_arguments)]
202201
pub async fn finish<'a, E, H, R>(
@@ -213,6 +212,7 @@ impl RunTracker {
213212
engine: &'a E,
214213
hash_tracker: &'a H,
215214
env_at_execution_start: &'a EnvironmentVariableMap,
215+
scm: &SCM,
216216
is_watch: bool,
217217
) -> Result<(), Error>
218218
where
@@ -242,6 +242,8 @@ impl RunTracker {
242242
global_hash_summary,
243243
global_env_mode,
244244
task_factory,
245+
env_at_execution_start,
246+
scm,
245247
)
246248
.await?;
247249

crates/turborepo-scm/src/lib.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,29 @@ impl SCM {
293293
})
294294
}
295295

296+
/// Creates an SCM instance using a pre-resolved git root, avoiding the
297+
/// `git rev-parse --show-cdup` subprocess call that `new` would perform.
298+
/// Falls back to `new` if the git binary cannot be found.
299+
#[tracing::instrument]
300+
pub fn new_with_git_root(
301+
path_in_repo: &AbsoluteSystemPath,
302+
git_root: AbsoluteSystemPathBuf,
303+
) -> SCM {
304+
match GitRepo::find_bin() {
305+
Ok(bin) => SCM::Git(GitRepo {
306+
root: git_root,
307+
bin,
308+
}),
309+
Err(e) => {
310+
debug!(
311+
"git binary not found: {}, continuing with manual hashing",
312+
e
313+
);
314+
SCM::Manual
315+
}
316+
}
317+
}
318+
296319
pub fn is_manual(&self) -> bool {
297320
matches!(self, SCM::Manual)
298321
}

0 commit comments

Comments
 (0)