Skip to content

Commit 3fa686b

Browse files
authored
Merge branch 'main' into fix/gen-copy-deps
2 parents ea09c90 + 83774bc commit 3fa686b

File tree

52 files changed

+3400
-479
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+3400
-479
lines changed

Cargo.lock

Lines changed: 506 additions & 105 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ dunce = "1.0.3"
124124
either = "1.9.0"
125125
futures = "0.3.31"
126126
git2 = { version = "0.20.4", default-features = false }
127+
gix-index = { version = "0.47.0", default-features = false }
127128
hex = "0.4.3"
128129
httpmock = { version = "0.8.0", default-features = false }
129130
indicatif = "0.18.3"

crates/turborepo-api-client/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,7 @@ impl APIClient {
605605
/// Builds a shared HTTP client with optional connect timeout. This is
606606
/// the single TLS initialization point — all consumers should share the
607607
/// resulting client.
608+
#[tracing::instrument(skip_all)]
608609
pub fn build_http_client(connect_timeout: Option<Duration>) -> Result<reqwest::Client> {
609610
let mut builder = reqwest::Client::builder();
610611
if let Some(dur) = connect_timeout {

crates/turborepo-auth/src/auth/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,12 @@ mod tests {
119119
// Mock the turborepo_dirs functions for testing
120120
fn create_mock_vercel_config_dir() -> AbsoluteSystemPathBuf {
121121
let tmp_dir = tempdir().expect("Failed to create temp dir");
122-
AbsoluteSystemPathBuf::try_from(tmp_dir.into_path()).expect("Failed to create path")
122+
AbsoluteSystemPathBuf::try_from(tmp_dir.keep()).expect("Failed to create path")
123123
}
124124

125125
fn create_mock_turbo_config_dir() -> AbsoluteSystemPathBuf {
126126
let tmp_dir = tempdir().expect("Failed to create temp dir");
127-
AbsoluteSystemPathBuf::try_from(tmp_dir.into_path()).expect("Failed to create path")
127+
AbsoluteSystemPathBuf::try_from(tmp_dir.keep()).expect("Failed to create path")
128128
}
129129

130130
fn setup_auth_file(

crates/turborepo-cache/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@ turbopath = { workspace = true }
4949
turborepo-analytics = { workspace = true }
5050
turborepo-api-client = { workspace = true }
5151
turborepo-auth = { workspace = true }
52-
zstd = "0.12.3"
52+
zstd = "0.13.3"

crates/turborepo-config/src/lib.rs

Lines changed: 89 additions & 15 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.
@@ -357,8 +360,8 @@ impl ConfigurationOptions {
357360
self.env_mode.unwrap_or_default()
358361
}
359362

360-
/// Returns the default cache directory path (relative to repo root).
361-
const DEFAULT_CACHE_DIR: &'static str = if cfg!(windows) {
363+
/// The default cache directory path (relative to repo root).
364+
pub const DEFAULT_CACHE_DIR: &'static str = if cfg!(windows) {
362365
".turbo\\cache"
363366
} else {
364367
".turbo/cache"
@@ -388,58 +391,76 @@ impl ConfigurationOptions {
388391
/// - `path`: The resolved cache directory path
389392
/// - `is_shared_worktree`: True if using shared cache from main worktree
390393
pub fn resolve_cache_dir(&self, repo_root: &AbsoluteSystemPath) -> CacheDirResult {
391-
// If explicit cacheDir is configured, always use it (no worktree sharing)
392394
if let Some(explicit_cache_dir) = &self.cache_dir {
393395
return CacheDirResult {
394396
path: explicit_cache_dir.clone(),
395397
is_shared_worktree: false,
398+
git_root: None,
396399
};
397400
}
398401

399-
// Try to detect worktree configuration
400-
match WorktreeInfo::detect(repo_root) {
401-
Ok(worktree_info) => {
402+
let worktree_info = WorktreeInfo::detect(repo_root).ok();
403+
self.resolve_cache_dir_with_worktree_info(worktree_info.as_ref())
404+
}
405+
406+
/// Resolve cache directory using pre-computed worktree info.
407+
///
408+
/// This variant avoids spawning a git subprocess, which allows the caller
409+
/// to run worktree detection on a background thread and pass the result in.
410+
pub fn resolve_cache_dir_with_worktree_info(
411+
&self,
412+
worktree_info: Option<&WorktreeInfo>,
413+
) -> CacheDirResult {
414+
if let Some(explicit_cache_dir) = &self.cache_dir {
415+
return CacheDirResult {
416+
path: explicit_cache_dir.clone(),
417+
is_shared_worktree: false,
418+
git_root: None,
419+
};
420+
}
421+
422+
match worktree_info {
423+
Some(worktree_info) => {
402424
debug!(
403425
"Worktree detection: current={}, main={}, is_linked={}",
404426
worktree_info.worktree_root,
405427
worktree_info.main_worktree_root,
406428
worktree_info.is_linked_worktree()
407429
);
430+
let git_root = Some(worktree_info.git_root.clone());
408431
if worktree_info.is_linked_worktree() {
409-
// We're in a linked worktree - use the main worktree's cache
410-
// Use turbopath's join_component to ensure consistent path separators
411432
let main_cache_path = worktree_info
412433
.main_worktree_root
413434
.join_component(".turbo")
414435
.join_component("cache");
415436
let result = CacheDirResult {
416437
path: Utf8PathBuf::from(main_cache_path.as_str()),
417438
is_shared_worktree: true,
439+
git_root,
418440
};
419441
debug!("Using shared worktree cache at: {}", result.path);
420442
result
421443
} else {
422-
// We're in the main worktree - use local cache
423444
debug!(
424445
"Using local cache (main worktree): {}",
425446
Self::DEFAULT_CACHE_DIR
426447
);
427448
CacheDirResult {
428449
path: Utf8PathBuf::from(Self::DEFAULT_CACHE_DIR),
429450
is_shared_worktree: false,
451+
git_root,
430452
}
431453
}
432454
}
433-
Err(e) => {
434-
// Detection failed - silently fall back to local cache
435-
// This is expected for non-git directories, so we don't warn
455+
None => {
436456
debug!(
437-
"Could not detect Git worktree configuration, using local cache: {}",
438-
e
457+
"No worktree info available, using local cache: {}",
458+
Self::DEFAULT_CACHE_DIR
439459
);
440460
CacheDirResult {
441461
path: Utf8PathBuf::from(Self::DEFAULT_CACHE_DIR),
442462
is_shared_worktree: false,
463+
git_root: None,
443464
}
444465
}
445466
}
@@ -850,7 +871,10 @@ mod test {
850871
#[test]
851872
fn test_resolve_cache_dir_default_returns_relative_path() {
852873
let tmp_dir = TempDir::new().unwrap();
853-
let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap();
874+
let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path())
875+
.unwrap()
876+
.to_realpath()
877+
.unwrap();
854878

855879
// Initialize git repo
856880
std::process::Command::new("git")
@@ -870,6 +894,56 @@ mod test {
870894
);
871895
}
872896

897+
#[test]
898+
fn test_resolve_cache_dir_captures_git_root() {
899+
let tmp_dir = TempDir::new().unwrap();
900+
let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path())
901+
.unwrap()
902+
.to_realpath()
903+
.unwrap();
904+
905+
std::process::Command::new("git")
906+
.args(["init", "."])
907+
.current_dir(&repo_root)
908+
.output()
909+
.expect("git init failed");
910+
911+
let config = ConfigurationOptions::default();
912+
let result = config.resolve_cache_dir(&repo_root);
913+
914+
// git_root should be captured from worktree detection so SCM::new
915+
// can skip its own git rev-parse subprocess
916+
assert!(
917+
result.git_root.is_some(),
918+
"git_root should be captured when worktree detection succeeds"
919+
);
920+
assert_eq!(
921+
result.git_root.unwrap(),
922+
repo_root,
923+
"git_root should match repo root in a non-worktree repo"
924+
);
925+
}
926+
927+
#[test]
928+
fn test_resolve_cache_dir_explicit_skips_git_root() {
929+
let tmp_dir = TempDir::new().unwrap();
930+
let repo_root = AbsoluteSystemPath::from_std_path(tmp_dir.path()).unwrap();
931+
932+
let config = ConfigurationOptions {
933+
cache_dir: Some(camino::Utf8PathBuf::from("/my/cache")),
934+
..Default::default()
935+
};
936+
937+
let result = config.resolve_cache_dir(repo_root);
938+
939+
// When explicit cache_dir is set, no worktree detection runs,
940+
// so git_root is not available
941+
assert!(
942+
result.git_root.is_none(),
943+
"git_root should be None when explicit cache_dir bypasses detection"
944+
);
945+
}
946+
873947
/// Integration test that verifies linked worktree returns absolute path to
874948
/// main cache
875949
#[test]

crates/turborepo-engine/src/builder.rs

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,19 @@ impl<'a, L: TurboJsonLoader> EngineBuilder<'a, L> {
256256

257257
let mut visited = HashSet::new();
258258
let mut engine: Engine<Building, TaskDefinition> = Engine::default();
259+
let mut turbo_json_chain_cache: HashMap<PackageName, Vec<&TurboJson>> = HashMap::new();
259260

260261
while let Some(task_id) = traversal_queue.pop_front() {
261262
{
262263
let (task_id, span) = task_id.clone().split();
263264
engine.add_task_location(task_id.into_owned(), span);
264265
}
265266

267+
// Skip before doing expensive work if we've already processed this task.
268+
if visited.contains(task_id.as_inner()) {
269+
continue;
270+
}
271+
266272
// For root tasks, verify they are either explicitly enabled OR (when using
267273
// add_all_tasks mode like devtools) have a definition in root turbo.json.
268274
// Tasks defined without the //# prefix (like "transit") in root turbo.json
@@ -323,17 +329,13 @@ impl<'a, L: TurboJsonLoader> EngineBuilder<'a, L> {
323329
)));
324330
}
325331

326-
let task_definition = self.task_definition(
332+
let task_definition = self.task_definition_cached(
327333
turbo_json_loader,
328334
&task_id,
329335
&task_id.as_non_workspace_task_name(),
336+
&mut turbo_json_chain_cache,
330337
)?;
331338

332-
// Skip this iteration of the loop if we've already seen this taskID
333-
if visited.contains(task_id.as_inner()) {
334-
continue;
335-
}
336-
337339
visited.insert(task_id.as_inner().clone());
338340

339341
// Note that the Go code has a whole if/else statement for putting stuff into
@@ -576,19 +578,51 @@ impl<'a, L: TurboJsonLoader> EngineBuilder<'a, L> {
576578
Ok(TaskDefinitionResult::not_found())
577579
}
578580

579-
fn task_definition(
581+
/// Resolves the merged `TaskDefinition` for a task, caching the turbo.json
582+
/// chain per package. The chain only depends on the package name (not the
583+
/// task), so multiple tasks in the same package share the cached chain.
584+
fn task_definition_cached<'b>(
580585
&self,
581-
turbo_json_loader: &L,
586+
turbo_json_loader: &'b L,
582587
task_id: &Spanned<TaskId>,
583588
task_name: &TaskName,
589+
chain_cache: &mut HashMap<PackageName, Vec<&'b TurboJson>>,
584590
) -> Result<TaskDefinition, BuilderError> {
585591
let processed_task_definition = ProcessedTaskDefinition::from_iter(
586-
self.task_definition_chain(turbo_json_loader, task_id, task_name)?,
592+
self.task_definition_chain_cached(turbo_json_loader, task_id, task_name, chain_cache)?,
587593
);
588594
let path_to_root = self.path_to_root(task_id.as_inner())?;
589595
TaskDefinition::from_processed(processed_task_definition, &path_to_root)
590596
}
591597

598+
/// Like `task_definition_chain` but caches the turbo.json chain per
599+
/// package.
600+
fn task_definition_chain_cached<'b>(
601+
&self,
602+
turbo_json_loader: &'b L,
603+
task_id: &Spanned<TaskId>,
604+
task_name: &TaskName,
605+
chain_cache: &mut HashMap<PackageName, Vec<&'b TurboJson>>,
606+
) -> Result<Vec<ProcessedTaskDefinition>, BuilderError> {
607+
let package_name = PackageName::from(task_id.package());
608+
let turbo_json_chain = match chain_cache.get(&package_name) {
609+
Some(cached) => cached.clone(),
610+
None => {
611+
let chain = self.turbo_json_chain(turbo_json_loader, &package_name)?;
612+
chain_cache.insert(package_name, chain.clone());
613+
chain
614+
}
615+
};
616+
617+
Self::resolve_task_definitions_from_chain(
618+
turbo_json_chain,
619+
task_id,
620+
task_name,
621+
self.is_single,
622+
self.should_validate_engine,
623+
)
624+
}
625+
592626
pub fn task_definition_chain(
593627
&self,
594628
turbo_json_loader: &L,
@@ -597,6 +631,25 @@ impl<'a, L: TurboJsonLoader> EngineBuilder<'a, L> {
597631
) -> Result<Vec<ProcessedTaskDefinition>, BuilderError> {
598632
let package_name = PackageName::from(task_id.package());
599633
let turbo_json_chain = self.turbo_json_chain(turbo_json_loader, &package_name)?;
634+
Self::resolve_task_definitions_from_chain(
635+
turbo_json_chain,
636+
task_id,
637+
task_name,
638+
self.is_single,
639+
self.should_validate_engine,
640+
)
641+
}
642+
643+
/// Given a resolved turbo.json chain for a package, extract the task
644+
/// definitions for a specific task by walking the chain and handling
645+
/// `extends: false`.
646+
fn resolve_task_definitions_from_chain(
647+
turbo_json_chain: Vec<&TurboJson>,
648+
task_id: &Spanned<TaskId>,
649+
task_name: &TaskName,
650+
is_single: bool,
651+
should_validate_engine: bool,
652+
) -> Result<Vec<ProcessedTaskDefinition>, BuilderError> {
600653
let mut task_definitions = Vec::new();
601654

602655
// Find the first package in the chain (iterating in reverse from leaf to root)
@@ -645,7 +698,7 @@ impl<'a, L: TurboJsonLoader> EngineBuilder<'a, L> {
645698
task_definitions.push(root_definition)
646699
}
647700

648-
if self.is_single {
701+
if is_single {
649702
return match task_definitions.is_empty() {
650703
true => {
651704
let (span, text) = task_id.span_and_text("turbo.json");
@@ -667,7 +720,7 @@ impl<'a, L: TurboJsonLoader> EngineBuilder<'a, L> {
667720
}
668721
}
669722

670-
if task_definitions.is_empty() && self.should_validate_engine {
723+
if task_definitions.is_empty() && should_validate_engine {
671724
let (span, text) = task_id.span_and_text("turbo.json");
672725
return Err(BuilderError::MissingPackageTask(Box::new(
673726
MissingPackageTaskError {

crates/turborepo-engine/src/execute.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ impl<T: TaskDefinitionInfo + Clone + Send + Sync + 'static> Engine<Built, T> {
6363
// finish even once a task sends back the stop signal. This is suboptimal
6464
// since it would mean the visitor would need to also track if
6565
// it is cancelled :)
66+
#[tracing::instrument(skip_all)]
6667
pub async fn execute(
6768
self: Arc<Self>,
6869
options: ExecutionOptions,

crates/turborepo-globwalk/src/lib.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,24 @@ fn needs_path_cleaning(s: &str) -> bool {
440440
false
441441
}
442442

443+
/// Returns true if the pattern contains glob metacharacters (*, ?, [, {).
444+
/// Literal file paths return false.
445+
pub fn is_glob_pattern(pattern: &str) -> bool {
446+
// Check for unescaped glob metacharacters
447+
let mut chars = pattern.chars().peekable();
448+
while let Some(c) = chars.next() {
449+
if c == '\\' {
450+
// Skip escaped character
451+
chars.next();
452+
continue;
453+
}
454+
if matches!(c, '*' | '?' | '[' | '{') {
455+
return true;
456+
}
457+
}
458+
false
459+
}
460+
443461
pub fn globwalk_with_settings(
444462
base_path: &AbsoluteSystemPath,
445463
include: &[ValidatedGlob],

0 commit comments

Comments
 (0)