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
96 changes: 81 additions & 15 deletions crates/turborepo-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,8 @@ impl ConfigurationOptions {
self.env_mode.unwrap_or_default()
}

/// Returns the default cache directory path (relative to repo root).
const DEFAULT_CACHE_DIR: &'static str = if cfg!(windows) {
/// The default cache directory path (relative to repo root).
pub const DEFAULT_CACHE_DIR: &'static str = if cfg!(windows) {
".turbo\\cache"
} else {
".turbo/cache"
Expand Down Expand Up @@ -391,7 +391,6 @@ impl ConfigurationOptions {
/// - `path`: The resolved cache directory path
/// - `is_shared_worktree`: True if using shared cache from main worktree
pub fn resolve_cache_dir(&self, repo_root: &AbsoluteSystemPath) -> CacheDirResult {
// If explicit cacheDir is configured, always use it (no worktree sharing)
if let Some(explicit_cache_dir) = &self.cache_dir {
return CacheDirResult {
path: explicit_cache_dir.clone(),
Expand All @@ -400,9 +399,28 @@ impl ConfigurationOptions {
};
}

// Try to detect worktree configuration
match WorktreeInfo::detect(repo_root) {
Ok(worktree_info) => {
let worktree_info = WorktreeInfo::detect(repo_root).ok();
self.resolve_cache_dir_with_worktree_info(worktree_info.as_ref())
}

/// Resolve cache directory using pre-computed worktree info.
///
/// This variant avoids spawning a git subprocess, which allows the caller
/// to run worktree detection on a background thread and pass the result in.
pub fn resolve_cache_dir_with_worktree_info(
&self,
worktree_info: Option<&WorktreeInfo>,
) -> CacheDirResult {
if let Some(explicit_cache_dir) = &self.cache_dir {
return CacheDirResult {
path: explicit_cache_dir.clone(),
is_shared_worktree: false,
git_root: None,
};
}

match worktree_info {
Some(worktree_info) => {
debug!(
"Worktree detection: current={}, main={}, is_linked={}",
worktree_info.worktree_root,
Expand All @@ -411,8 +429,6 @@ impl ConfigurationOptions {
);
let git_root = Some(worktree_info.git_root.clone());
if worktree_info.is_linked_worktree() {
// We're in a linked worktree - use the main worktree's cache
// Use turbopath's join_component to ensure consistent path separators
let main_cache_path = worktree_info
.main_worktree_root
.join_component(".turbo")
Expand All @@ -425,7 +441,6 @@ impl ConfigurationOptions {
debug!("Using shared worktree cache at: {}", result.path);
result
} else {
// We're in the main worktree - use local cache
debug!(
"Using local cache (main worktree): {}",
Self::DEFAULT_CACHE_DIR
Expand All @@ -437,12 +452,10 @@ impl ConfigurationOptions {
}
}
}
Err(e) => {
// Detection failed - silently fall back to local cache
// This is expected for non-git directories, so we don't warn
None => {
debug!(
"Could not detect Git worktree configuration, using local cache: {}",
e
"No worktree info available, using local cache: {}",
Self::DEFAULT_CACHE_DIR
);
CacheDirResult {
path: Utf8PathBuf::from(Self::DEFAULT_CACHE_DIR),
Expand Down Expand Up @@ -858,7 +871,10 @@ mod test {
#[test]
fn test_resolve_cache_dir_default_returns_relative_path() {
let tmp_dir = TempDir::new().unwrap();
let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap();
let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path())
.unwrap()
.to_realpath()
.unwrap();

// Initialize git repo
std::process::Command::new("git")
Expand All @@ -878,6 +894,56 @@ mod test {
);
}

#[test]
fn test_resolve_cache_dir_captures_git_root() {
let tmp_dir = TempDir::new().unwrap();
let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path())
.unwrap()
.to_realpath()
.unwrap();

std::process::Command::new("git")
.args(["init", "."])
.current_dir(&repo_root)
.output()
.expect("git init failed");

let config = ConfigurationOptions::default();
let result = config.resolve_cache_dir(&repo_root);

// git_root should be captured from worktree detection so SCM::new
// can skip its own git rev-parse subprocess
assert!(
result.git_root.is_some(),
"git_root should be captured when worktree detection succeeds"
);
assert_eq!(
result.git_root.unwrap(),
repo_root,
"git_root should match repo root in a non-worktree repo"
);
}

#[test]
fn test_resolve_cache_dir_explicit_skips_git_root() {
let tmp_dir = TempDir::new().unwrap();
let repo_root = AbsoluteSystemPath::from_std_path(tmp_dir.path()).unwrap();

let config = ConfigurationOptions {
cache_dir: Some(camino::Utf8PathBuf::from("/my/cache")),
..Default::default()
};

let result = config.resolve_cache_dir(repo_root);

// When explicit cache_dir is set, no worktree detection runs,
// so git_root is not available
assert!(
result.git_root.is_none(),
"git_root should be None when explicit cache_dir bypasses detection"
);
}

/// Integration test that verifies linked worktree returns absolute path to
/// main cache
#[test]
Expand Down
89 changes: 89 additions & 0 deletions crates/turborepo-run-summary/src/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ pub struct TaskState {
pub execution: Option<TaskExecutionSummary>,
}

impl TaskSummaryInfo for TaskState {
fn task_id(&self) -> &TaskId<'static> {
&self.task_id
}
}

impl SummaryState {
fn handle_event(&mut self, event: Event) {
match event {
Expand Down Expand Up @@ -534,4 +540,87 @@ mod test {
fn test_serialization(value: impl serde::Serialize, expected: serde_json::Value) {
assert_eq!(serde_json::to_value(value).unwrap(), expected);
}

// Verifies that failed tasks can be identified directly from TaskState,
// without needing the full TaskSummary machinery. This is the data path
// the optimized (non-summary) finish will use.
#[tokio::test]
async fn test_failed_tasks_identifiable_from_task_state() {
let summary = ExecutionTracker::new();
let success_task = TaskId::new("app", "build");
let fail_task = TaskId::new("lib", "build");
let cached_task = TaskId::new("utils", "build");

let mut handles = Vec::new();
{
let tracker = summary.task_tracker(success_task.clone());
handles.push(tokio::spawn(async move {
tracker.start().await.build_succeeded(0).await;
}));
}
{
let tracker = summary.task_tracker(fail_task.clone());
handles.push(tokio::spawn(async move {
tracker.start().await.build_failed(Some(1), "uh oh").await;
}));
}
{
let tracker = summary.task_tracker(cached_task.clone());
handles.push(tokio::spawn(async move {
tracker.start().await.cached().await;
}));
}
for h in handles {
h.await.unwrap();
}

let state = summary.finish().await.unwrap();

// TaskState.execution carries enough info to identify failures
let failed: Vec<&TaskState> = state
.tasks
.iter()
.filter(|t| t.execution.as_ref().is_some_and(|e| e.is_failure()))
.collect();
assert_eq!(failed.len(), 1);
assert_eq!(failed[0].task_id, fail_task);

// Counts are correct for ExecutionSummary construction
assert_eq!(state.attempted, 3);
assert_eq!(state.failed, 1);
assert_eq!(state.success, 1);
assert_eq!(state.cached, 1);
}

// Verifies ExecutionSummary computes successful() correctly from SummaryState
#[test]
fn test_execution_summary_stats_from_state() {
use turbopath::AnchoredSystemPath;

let state = SummaryState {
attempted: 10,
failed: 2,
cached: 5,
success: 3,
tasks: vec![],
};

let start = Local::now() - Duration::seconds(5);
let end = Local::now();
let summary = ExecutionSummary::new(
"turbo run build".to_string(),
state,
Some(AnchoredSystemPath::empty()),
1,
start,
end,
);

// successful = success + cached
assert_eq!(summary.successful(), 8);
assert_eq!(summary.attempted, 10);
assert_eq!(summary.failed, 2);
assert_eq!(summary.cached, 5);
assert_eq!(summary.exit_code, 1);
}
}
2 changes: 1 addition & 1 deletion crates/turborepo-run-summary/src/task_factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ where

Ok(SharedTaskSummary {
hash,
inputs: expanded_inputs.into_iter().collect(),
inputs: expanded_inputs,
hash_of_external_dependencies,
cache: cache_summary,
command,
Expand Down
34 changes: 34 additions & 0 deletions crates/turborepo-run-summary/src/tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,40 @@ impl RunTracker {
{
let end_time = Local::now();

// For the common case (no --dry, no --summarize), skip the expensive
// TaskSummary construction, SCMState::get (2 git subprocesses), and
// full RunSummary assembly. We only need execution stats and failed
// task identification for terminal output.
if run_opts.dry_run().is_none() && run_opts.summarize().is_none() {
let summary_state = self.execution_tracker.finish().await?;

if !is_watch {
// Extract failed tasks before moving summary_state into
// ExecutionSummary. SummaryState derives Clone, but we only
// need the task list for failure identification.
let failed_tasks: Vec<TaskState> = summary_state
.tasks
.iter()
.filter(|t| t.execution.as_ref().is_some_and(|e| e.is_failure()))
.cloned()
.collect();

let execution = ExecutionSummary::new(
self.synthesized_command.clone(),
summary_state,
package_inference_root,
exit_code,
self.started_at,
end_time,
);

let path = repo_root.join_components(&[".turbo", "runs", "dummy.json"]);
execution.print(ui, path, failed_tasks.iter().collect());
}

return Ok(());
}

let task_factory = TaskSummaryFactory::new(
pkg_dep_graph,
engine,
Expand Down
Loading
Loading