From f6c89f9af317a0ac7cf7980661a81fc3cb918c56 Mon Sep 17 00:00:00 2001 From: krinoid Date: Fri, 17 Oct 2025 11:35:54 +0200 Subject: [PATCH] feat: allow configuring custom includes for prune subcommand --- crates/turborepo-lib/src/commands/prune.rs | 274 +++++++++++++++++- crates/turborepo-lib/src/turbo_json/extend.rs | 12 +- crates/turborepo-lib/src/turbo_json/mod.rs | 13 +- .../turborepo-lib/src/turbo_json/processed.rs | 151 +++++++++- crates/turborepo-lib/src/turbo_json/raw.rs | 16 + packages/turbo-types/schemas/schema.json | 24 ++ packages/turbo-types/schemas/schema.v2.json | 24 ++ packages/turbo-types/src/types/config-v2.ts | 29 ++ .../integration/tests/prune/custom-includes.t | 245 ++++++++++++++++ 9 files changed, 779 insertions(+), 9 deletions(-) create mode 100644 turborepo-tests/integration/tests/prune/custom-includes.t diff --git a/crates/turborepo-lib/src/commands/prune.rs b/crates/turborepo-lib/src/commands/prune.rs index dfdb9eadead82..c08c59a54c977 100644 --- a/crates/turborepo-lib/src/commands/prune.rs +++ b/crates/turborepo-lib/src/commands/prune.rs @@ -1,6 +1,6 @@ #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -use std::sync::OnceLock; +use std::{str::FromStr, sync::OnceLock}; use lazy_static::lazy_static; use miette::Diagnostic; @@ -18,7 +18,7 @@ use turborepo_ui::BOLD; use super::CommandBase; use crate::{ config::{CONFIG_FILE, CONFIG_FILE_JSONC}, - turbo_json::{RawRootTurboJson, RawTurboJson}, + turbo_json::{ProcessedPruneIncludes, RawPackageTurboJson, RawRootTurboJson, RawTurboJson}, }; pub const DEFAULT_OUTPUT_DIR: &str = "out"; @@ -52,6 +52,10 @@ pub enum Error { MissingLockfile, #[error("Unable to read config: {0}")] Config(#[from] crate::config::Error), + #[error("Glob pattern error: {0}")] + Glob(#[from] globwalk::GlobError), + #[error("Glob walk error: {0}")] + GlobWalk(#[from] globwalk::WalkError), } // Files that should be copied from root and if they're required for install @@ -185,6 +189,18 @@ pub async fn prune( prune.copy_directory(&path, *required_for_install)?; } + // Copy custom includes from turbo.json configurations + trace!( + "Collecting prune includes for workspaces: {:?}", + workspace_paths + ); + if let Some(prune_includes) = prune.collect_prune_includes(&workspace_paths)? { + trace!("Found prune includes, copying files"); + prune.copy_custom_includes(&prune_includes)?; + } else { + trace!("No prune includes found"); + } + prune.copy_turbo_json(&workspace_names)?; let original_patches = prune @@ -196,8 +212,7 @@ pub async fn prune( let pruned_patches = lockfile.patches()?; trace!( "original patches: {:?}, pruned patches: {:?}", - original_patches, - pruned_patches + original_patches, pruned_patches ); let repo_root = &prune.root; @@ -477,4 +492,255 @@ impl<'a> Prune<'a> { RawRootTurboJson::parse(&turbo_json_contents, turbo_json_name.as_str())?.into(); Ok(Some((turbo_json, turbo_json_name))) } + + /// Load workspace turbo.json file (try both turbo.json and turbo.jsonc) + fn load_workspace_turbo_json( + &self, + workspace_path: &AnchoredSystemPath, + ) -> Result, Error> { + // Try turbo.json first + let turbo_json_path = workspace_path.join_component(CONFIG_FILE); + let turbo_json_abs = self.root.resolve(&turbo_json_path); + + if let Some(contents) = turbo_json_abs.read_existing_to_string()? { + let raw = RawPackageTurboJson::parse(&contents, turbo_json_path.as_str())?; + return Ok(Some(raw.into())); + } + + // Try turbo.jsonc as fallback + let turbo_jsonc_path = workspace_path.join_component(CONFIG_FILE_JSONC); + let turbo_jsonc_abs = self.root.resolve(&turbo_jsonc_path); + + if let Some(contents) = turbo_jsonc_abs.read_existing_to_string()? { + let raw = RawPackageTurboJson::parse(&contents, turbo_jsonc_path.as_str())?; + return Ok(Some(raw.into())); + } + + Ok(None) + } + + /// Collect prune includes from root and all workspace configs + fn collect_prune_includes( + &self, + workspaces: &[String], + ) -> Result, Error> { + let mut all_globs = Vec::new(); + + // First, load root turbo.json and extract prune.includes + if let Some((turbo_json, _)) = self + .get_turbo_json(turbo_json()) + .transpose() + .or_else(|| self.get_turbo_json(turbo_jsonc()).transpose()) + .transpose()? + { + if let Some(prune_config) = turbo_json.prune { + if let Some(includes) = prune_config.includes { + all_globs.extend(includes); + } + } + } + + // For each workspace, load workspace turbo.json and collect prune.includes + // Workspace-relative patterns need to be prefixed with the workspace path + for workspace in workspaces { + let workspace_path = AnchoredSystemPathBuf::from_raw(workspace)?; + if let Some(workspace_turbo) = self.load_workspace_turbo_json(&workspace_path)? { + if let Some(prune_config) = workspace_turbo.prune { + if let Some(includes) = prune_config.includes { + // Prefix workspace-relative globs with the workspace path + for glob in includes { + let glob_str = glob.as_str(); + + // Check if this is a $TURBO_ROOT$/ pattern (already repo-relative) + let is_turbo_root = glob_str.starts_with("$TURBO_ROOT$/") + || glob_str.starts_with("!$TURBO_ROOT$/"); + + if is_turbo_root { + // Already repo-relative, add as-is + all_globs.push(glob); + } else { + // Workspace-relative pattern - prefix with workspace path + let (negation, pattern) = + if let Some(stripped) = glob_str.strip_prefix('!') { + ("!", stripped) + } else { + ("", glob_str) + }; + + // Create prefixed glob: workspace_path/pattern + let prefixed = format!("{}{}/{}", negation, workspace, pattern); + let prefixed_glob = + turborepo_unescape::UnescapedString::from(prefixed); + all_globs.push(turborepo_errors::Spanned::new(prefixed_glob)); + } + } + } + } + } + } + + if all_globs.is_empty() { + return Ok(None); + } + + // Combine all globs into a single ProcessedPruneIncludes + Ok(Some(ProcessedPruneIncludes::new(all_globs)?)) + } + + /// Copy files/directories matching custom prune includes + fn copy_custom_includes(&self, prune_includes: &ProcessedPruneIncludes) -> Result<(), Error> { + use globwalk::{ValidatedGlob, WalkType}; + + // Resolve globs with turbo root path + let turbo_root_path = RelativeUnixPath::new("").unwrap(); + let resolved_globs = prune_includes.resolve(turbo_root_path); + + // Separate into inclusions and exclusions + let mut inclusions = Vec::new(); + let mut exclusions = Vec::new(); + + for glob_str in resolved_globs { + if let Some(stripped) = glob_str.strip_prefix('!') { + exclusions.push(ValidatedGlob::from_str(stripped)?); + } else { + inclusions.push(ValidatedGlob::from_str(&glob_str)?); + } + } + + if inclusions.is_empty() { + return Ok(()); + } + + // Use globwalk to find matching files only + // We use Files instead of All to ensure exclusions work properly. + // If we matched directories, copy_directory would copy all files in it, + // ignoring exclusions. + let matches = globwalk::globwalk( + &self.root, + &inclusions, + &exclusions, + WalkType::Files, // Only files - exclusions work at file level + )?; + + // Warn if no files matched + if matches.is_empty() { + println!( + "Warning: No files matched custom prune includes patterns. Check your turbo.json \ + prune.includes configuration." + ); + return Ok(()); + } + + // Copy each matched file + for matched_path in matches { + let relative_path = self.root.anchor(&matched_path)?; + self.copy_file(&relative_path, Some(CopyDestination::All))?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use turborepo_errors::Spanned; + use turborepo_unescape::UnescapedString; + + #[test] + fn test_workspace_relative_paths_are_prefixed() { + // Test that workspace-relative patterns are properly prefixed + // This validates the fix for the bug where workspace paths weren't being used + + // Simulates globs from a workspace turbo.json (apps/web/turbo.json) + let workspace_path = "apps/web"; + let workspace_globs = vec![ + Spanned::new(UnescapedString::from("next.config.ts")), + Spanned::new(UnescapedString::from("tailwind.config.ts")), + Spanned::new(UnescapedString::from("public/**")), + Spanned::new(UnescapedString::from("!public/temp/**")), + ]; + + // Expected: workspace-relative patterns should be prefixed + let expected_patterns = vec![ + format!("{}/next.config.ts", workspace_path), + format!("{}/tailwind.config.ts", workspace_path), + format!("{}/public/**", workspace_path), + format!("!{}/public/temp/**", workspace_path), + ]; + + // Simulate the prefixing logic from collect_prune_includes + let mut actual_patterns = Vec::new(); + for glob in workspace_globs { + let glob_str = glob.as_str(); + let is_turbo_root = + glob_str.starts_with("$TURBO_ROOT$/") || glob_str.starts_with("!$TURBO_ROOT$/"); + + if is_turbo_root { + actual_patterns.push(glob_str.to_string()); + } else { + let (negation, pattern) = if let Some(stripped) = glob_str.strip_prefix('!') { + ("!", stripped) + } else { + ("", glob_str) + }; + actual_patterns.push(format!("{}{}/{}", negation, workspace_path, pattern)); + } + } + + assert_eq!(actual_patterns, expected_patterns); + } + + #[test] + fn test_turbo_root_patterns_not_prefixed() { + // Test that $TURBO_ROOT$/ patterns remain repo-relative + let workspace_path = "apps/web"; + let workspace_globs = vec![ + Spanned::new(UnescapedString::from("$TURBO_ROOT$/shared-config.json")), + Spanned::new(UnescapedString::from("!$TURBO_ROOT$/secret.env")), + Spanned::new(UnescapedString::from("local.config.js")), // Should be prefixed + ]; + + let mut actual_patterns = Vec::new(); + for glob in workspace_globs { + let glob_str = glob.as_str(); + let is_turbo_root = + glob_str.starts_with("$TURBO_ROOT$/") || glob_str.starts_with("!$TURBO_ROOT$/"); + + if is_turbo_root { + actual_patterns.push(glob_str.to_string()); + } else { + let (negation, pattern) = if let Some(stripped) = glob_str.strip_prefix('!') { + ("!", stripped) + } else { + ("", glob_str) + }; + actual_patterns.push(format!("{}{}/{}", negation, workspace_path, pattern)); + } + } + + assert_eq!(actual_patterns[0], "$TURBO_ROOT$/shared-config.json"); + assert_eq!(actual_patterns[1], "!$TURBO_ROOT$/secret.env"); + assert_eq!(actual_patterns[2], "apps/web/local.config.js"); + } + + #[test] + fn test_scoped_package_name_to_path() { + // Test demonstrates that we must use workspace paths (like "apps/web") + // instead of package names (like "@acme/web") + + // This would be invalid as a file path + let package_name = "@acme/web"; + let relative_pattern = "next.config.ts"; + + // Using package name directly would create invalid path + let invalid_path = format!("{}/{}", package_name, relative_pattern); + assert_eq!(invalid_path, "@acme/web/next.config.ts"); + // This path contains @ and / which are invalid for many filesystems + + // Using workspace path creates valid path + let workspace_path = "apps/web"; + let valid_path = format!("{}/{}", workspace_path, relative_pattern); + assert_eq!(valid_path, "apps/web/next.config.ts"); + // This is a valid relative path that can be resolved from repo root + } } diff --git a/crates/turborepo-lib/src/turbo_json/extend.rs b/crates/turborepo-lib/src/turbo_json/extend.rs index df46416bb052a..7495aef4f335e 100644 --- a/crates/turborepo-lib/src/turbo_json/extend.rs +++ b/crates/turborepo-lib/src/turbo_json/extend.rs @@ -2,7 +2,7 @@ use super::processed::{ ProcessedDependsOn, ProcessedEnv, ProcessedInputs, ProcessedOutputs, ProcessedPassThroughEnv, - ProcessedTaskDefinition, ProcessedWith, + ProcessedPruneIncludes, ProcessedTaskDefinition, ProcessedWith, }; /// Trait for types that can be merged with extends behavior @@ -84,6 +84,14 @@ impl Extendable for ProcessedInputs { } } +impl Extendable for ProcessedPruneIncludes { + fn extend(&mut self, other: Self) { + // Always extend (combine) prune includes, never replace + // This is different from other fields - we always want the union of patterns + self.globs.extend(other.globs); + } +} + impl FromIterator for ProcessedTaskDefinition { fn from_iter>(iter: T) -> Self { iter.into_iter() @@ -158,8 +166,8 @@ mod test { use crate::{ cli::OutputLogsMode, turbo_json::{ - processed::{ProcessedEnv, ProcessedInputs, ProcessedOutputs}, FutureFlags, + processed::{ProcessedEnv, ProcessedInputs, ProcessedOutputs}, }, }; diff --git a/crates/turborepo-lib/src/turbo_json/mod.rs b/crates/turborepo-lib/src/turbo_json/mod.rs index 5315d2a462643..8084bcc5b264c 100644 --- a/crates/turborepo-lib/src/turbo_json/mod.rs +++ b/crates/turborepo-lib/src/turbo_json/mod.rs @@ -31,7 +31,7 @@ pub mod validator; pub use future_flags::FutureFlags; pub use loader::{TurboJsonLoader, TurboJsonReader}; -pub use processed::ProcessedTaskDefinition; +pub use processed::{ProcessedPruneIncludes, ProcessedTaskDefinition}; pub use raw::{ RawPackageTurboJson, RawRemoteCacheOptions, RawRootTurboJson, RawTaskDefinition, RawTurboJson, }; @@ -67,6 +67,7 @@ pub struct TurboJson { pub(crate) global_deps: Vec, pub(crate) global_env: Vec, pub(crate) global_pass_through_env: Option>, + pub(crate) prune_includes: Option, pub(crate) tasks: Pipeline, pub(crate) future_flags: FutureFlags, } @@ -325,6 +326,13 @@ impl TryFrom for TurboJson { let tasks = raw_turbo.tasks.clone().unwrap_or_default(); + // Process prune includes + let prune_includes = raw_turbo + .prune + .and_then(|prune_config| prune_config.includes) + .map(processed::ProcessedPruneIncludes::new) + .transpose()?; + Ok(TurboJson { text: raw_turbo.span.text, path: raw_turbo.span.path, @@ -351,6 +359,7 @@ impl TryFrom for TurboJson { global_deps }, + prune_includes, tasks, // copy these over, we don't need any changes here. extends: raw_turbo @@ -1175,6 +1184,6 @@ mod tests { let deps = boundaries.dependencies.as_ref().unwrap(); assert!(deps.allow.is_some()); assert!(deps.deny.is_none()); // This should be None, not serialized as - // null + // null } } diff --git a/crates/turborepo-lib/src/turbo_json/processed.rs b/crates/turborepo-lib/src/turbo_json/processed.rs index cd9548d245b6b..9d256fd54f113 100644 --- a/crates/turborepo-lib/src/turbo_json/processed.rs +++ b/crates/turborepo-lib/src/turbo_json/processed.rs @@ -112,7 +112,24 @@ impl ProcessedGlob { let glob = &self.glob; if self.turbo_root { - format!("{prefix}{turbo_root_path}/{glob}") + // Compute relative path from turbo_root_path back to root + // For "packages/web", we need "../.." + // For "", we need "." + let path_back_to_root = if turbo_root_path.as_str().is_empty() { + String::new() + } else { + let depth = turbo_root_path.as_str().split('/').count(); + std::iter::repeat("..") + .take(depth) + .collect::>() + .join("/") + }; + + if path_back_to_root.is_empty() { + format!("{prefix}{glob}") + } else { + format!("{prefix}{path_back_to_root}/{glob}") + } } else { format!("{prefix}{glob}") } @@ -296,6 +313,32 @@ impl ProcessedOutputs { } } +/// Processed prune includes configuration +#[derive(Debug, Clone, PartialEq)] +pub struct ProcessedPruneIncludes { + pub globs: Vec, +} + +impl ProcessedPruneIncludes { + /// Create ProcessedPruneIncludes from raw glob patterns + pub fn new(raw_globs: Vec>) -> Result { + let mut globs = Vec::with_capacity(raw_globs.len()); + for raw_glob in raw_globs { + // Use from_spanned_input to get negation and $TURBO_ROOT$/ support + globs.push(ProcessedGlob::from_spanned_input(raw_glob)?); + } + Ok(ProcessedPruneIncludes { globs }) + } + + /// Resolve all globs with turbo root path + pub fn resolve(&self, turbo_root_path: &RelativeUnixPath) -> Vec { + self.globs + .iter() + .map(|glob| glob.resolve(turbo_root_path)) + .collect() + } +} + /// Processed with field with DSL detection #[derive(Debug, Clone, PartialEq)] pub struct ProcessedWith { @@ -645,4 +688,110 @@ mod tests { assert!(result.is_err()); assert_matches!(result, Err(Error::InvalidDependsOnValue { .. })); } + + #[test] + fn test_prune_includes_basic() { + let raw_globs = vec![ + Spanned::new(UnescapedString::from("README.md")), + Spanned::new(UnescapedString::from("docs/**")), + ]; + + let prune_includes = ProcessedPruneIncludes::new(raw_globs).unwrap(); + assert_eq!(prune_includes.globs.len(), 2); + + let resolved = prune_includes.resolve(RelativeUnixPath::new("").unwrap()); + assert_eq!(resolved.len(), 2); + assert!(resolved.contains(&"README.md".to_string())); + assert!(resolved.contains(&"docs/**".to_string())); + } + + #[test] + fn test_prune_includes_negation() { + let raw_globs = vec![ + Spanned::new(UnescapedString::from("docs/**")), + Spanned::new(UnescapedString::from("!docs/secret.md")), + ]; + + let prune_includes = ProcessedPruneIncludes::new(raw_globs).unwrap(); + assert_eq!(prune_includes.globs.len(), 2); + + let resolved = prune_includes.resolve(RelativeUnixPath::new("").unwrap()); + assert_eq!(resolved.len(), 2); + assert!(resolved.contains(&"docs/**".to_string())); + assert!(resolved.contains(&"!docs/secret.md".to_string())); + } + + #[test] + fn test_prune_includes_turbo_root() { + let raw_globs = vec![Spanned::new(UnescapedString::from( + "$TURBO_ROOT$/config.json", + ))]; + + let prune_includes = ProcessedPruneIncludes::new(raw_globs).unwrap(); + assert_eq!(prune_includes.globs.len(), 1); + assert!(prune_includes.globs[0].turbo_root); + + let resolved = prune_includes.resolve(RelativeUnixPath::new("packages/web").unwrap()); + assert_eq!(resolved.len(), 1); + assert!(resolved[0].starts_with("../../")); + assert!(resolved[0].contains("config.json")); + } + + #[test] + fn test_prune_includes_empty() { + let raw_globs = vec![]; + + let prune_includes = ProcessedPruneIncludes::new(raw_globs).unwrap(); + assert_eq!(prune_includes.globs.len(), 0); + + let resolved = prune_includes.resolve(RelativeUnixPath::new("").unwrap()); + assert_eq!(resolved.len(), 0); + } + + #[test] + fn test_prune_includes_multiple_patterns() { + let raw_globs = vec![ + Spanned::new(UnescapedString::from("*.md")), + Spanned::new(UnescapedString::from("LICENSE")), + Spanned::new(UnescapedString::from("config/**")), + Spanned::new(UnescapedString::from("!config/local.json")), + ]; + + let prune_includes = ProcessedPruneIncludes::new(raw_globs).unwrap(); + assert_eq!(prune_includes.globs.len(), 4); + + // Check that negated glob is properly detected + assert!(!prune_includes.globs[0].negated); + assert!(!prune_includes.globs[1].negated); + assert!(!prune_includes.globs[2].negated); + assert!(prune_includes.globs[3].negated); + + let resolved = prune_includes.resolve(RelativeUnixPath::new("").unwrap()); + assert_eq!(resolved.len(), 4); + assert!(resolved.contains(&"*.md".to_string())); + assert!(resolved.contains(&"LICENSE".to_string())); + assert!(resolved.contains(&"config/**".to_string())); + assert!(resolved.contains(&"!config/local.json".to_string())); + } + + #[test] + fn test_prune_includes_workspace_relative_with_prefix() { + // Simulates workspace-level includes that have been prefixed with workspace + // path This is what happens in collect_prune_includes for workspace + // configs + let raw_globs = vec![ + Spanned::new(UnescapedString::from("apps/web/README.md")), + Spanned::new(UnescapedString::from("apps/web/docs/**")), + Spanned::new(UnescapedString::from("!apps/web/docs/secret.md")), + ]; + + let prune_includes = ProcessedPruneIncludes::new(raw_globs).unwrap(); + assert_eq!(prune_includes.globs.len(), 3); + + let resolved = prune_includes.resolve(RelativeUnixPath::new("").unwrap()); + assert_eq!(resolved.len(), 3); + assert!(resolved.contains(&"apps/web/README.md".to_string())); + assert!(resolved.contains(&"apps/web/docs/**".to_string())); + assert!(resolved.contains(&"!apps/web/docs/secret.md".to_string())); + } } diff --git a/crates/turborepo-lib/src/turbo_json/raw.rs b/crates/turborepo-lib/src/turbo_json/raw.rs index 38a5eaa281b41..1d808281aeab6 100644 --- a/crates/turborepo-lib/src/turbo_json/raw.rs +++ b/crates/turborepo-lib/src/turbo_json/raw.rs @@ -41,6 +41,14 @@ pub struct RawRemoteCacheOptions { pub upload_timeout: Option>, } +// Prune configuration +#[derive(Serialize, Default, Debug, Clone, Iterable, Deserializable)] +#[serde(rename_all = "camelCase")] +pub struct RawPruneConfig { + #[serde(rename = "includes", skip_serializing_if = "Option::is_none")] + pub(crate) includes: Option>>, +} + // Root turbo.json #[derive(Default, Debug, Clone, Iterable, Deserializable)] pub struct RawRootTurboJson { @@ -54,6 +62,8 @@ pub struct RawRootTurboJson { pub(crate) global_dependencies: Option>>, pub(crate) global_env: Option>>, pub(crate) global_pass_through_env: Option>>, + // Prune configuration + pub(crate) prune: Option, // Tasks is a map of task entries which define the task graph // and cache behavior on a per task or per package-task basis. pub(crate) tasks: Option, @@ -87,6 +97,7 @@ pub struct RawPackageTurboJson { pub(crate) pipeline: Option>, pub(crate) tags: Option>>>, pub(crate) boundaries: Option>, + pub(crate) prune: Option, #[deserializable(rename = "//")] pub(crate) _comment: Option, } @@ -110,6 +121,9 @@ pub struct RawTurboJson { pub(crate) global_env: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) global_pass_through_env: Option>>, + // Prune configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) prune: Option, // Tasks is a map of task entries which define the task graph // and cache behavior on a per task or per package-task basis. #[serde(skip_serializing_if = "Option::is_none")] @@ -189,6 +203,7 @@ impl From for RawTurboJson { global_dependencies: root.global_dependencies, global_env: root.global_env, global_pass_through_env: root.global_pass_through_env, + prune: root.prune, tasks: root.tasks, pipeline: root.pipeline, remote_cache: root.remote_cache, @@ -218,6 +233,7 @@ impl From for RawTurboJson { pipeline: pkg.pipeline, boundaries: pkg.boundaries, tags: pkg.tags, + prune: pkg.prune, _comment: pkg._comment, ..Default::default() } diff --git a/packages/turbo-types/schemas/schema.json b/packages/turbo-types/schemas/schema.json index 1543b141f5e4b..bb0c19aea7d39 100644 --- a/packages/turbo-types/schemas/schema.json +++ b/packages/turbo-types/schemas/schema.json @@ -59,6 +59,11 @@ "description": "An allowlist of environment variables that should be made to all tasks, but should not contribute to the task's cache key, e.g. `AWS_SECRET_KEY`.\n\nDocumentation: https://turborepo.com/docs/reference/configuration#globalpassthroughenv", "default": null }, + "prune": { + "$ref": "#/definitions/Prune", + "description": "Configuration for the `turbo prune` command.\n\nDocumentation: https://turborepo.com/docs/reference/configuration#prune", + "default": {} + }, "remoteCache": { "$ref": "#/definitions/RemoteCache", "description": "Configuration options that control how turbo interfaces with the remote cache.\n\nDocumentation: https://turborepo.com/docs/core-concepts/remote-caching", @@ -214,6 +219,20 @@ "none" ] }, + "Prune": { + "type": "object", + "properties": { + "includes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional files and directories to include in pruned output. Supports glob patterns with ! for exclusions and $TURBO_ROOT$/ for repo-relative paths. When specified in workspace configs, patterns are combined with root config patterns." + } + }, + "additionalProperties": false, + "description": "Prune configuration" + }, "RemoteCache": { "type": "object", "properties": { @@ -371,6 +390,11 @@ "boundaries": { "$ref": "#/definitions/BoundariesConfig", "description": "Configuration for `turbo boundaries` that is specific to this package" + }, + "prune": { + "$ref": "#/definitions/Prune", + "description": "Configuration for the `turbo prune` command.\n\nDocumentation: https://turborepo.com/docs/reference/configuration#prune", + "default": {} } }, "required": [ diff --git a/packages/turbo-types/schemas/schema.v2.json b/packages/turbo-types/schemas/schema.v2.json index 1543b141f5e4b..bb0c19aea7d39 100644 --- a/packages/turbo-types/schemas/schema.v2.json +++ b/packages/turbo-types/schemas/schema.v2.json @@ -59,6 +59,11 @@ "description": "An allowlist of environment variables that should be made to all tasks, but should not contribute to the task's cache key, e.g. `AWS_SECRET_KEY`.\n\nDocumentation: https://turborepo.com/docs/reference/configuration#globalpassthroughenv", "default": null }, + "prune": { + "$ref": "#/definitions/Prune", + "description": "Configuration for the `turbo prune` command.\n\nDocumentation: https://turborepo.com/docs/reference/configuration#prune", + "default": {} + }, "remoteCache": { "$ref": "#/definitions/RemoteCache", "description": "Configuration options that control how turbo interfaces with the remote cache.\n\nDocumentation: https://turborepo.com/docs/core-concepts/remote-caching", @@ -214,6 +219,20 @@ "none" ] }, + "Prune": { + "type": "object", + "properties": { + "includes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional files and directories to include in pruned output. Supports glob patterns with ! for exclusions and $TURBO_ROOT$/ for repo-relative paths. When specified in workspace configs, patterns are combined with root config patterns." + } + }, + "additionalProperties": false, + "description": "Prune configuration" + }, "RemoteCache": { "type": "object", "properties": { @@ -371,6 +390,11 @@ "boundaries": { "$ref": "#/definitions/BoundariesConfig", "description": "Configuration for `turbo boundaries` that is specific to this package" + }, + "prune": { + "$ref": "#/definitions/Prune", + "description": "Configuration for the `turbo prune` command.\n\nDocumentation: https://turborepo.com/docs/reference/configuration#prune", + "default": {} } }, "required": [ diff --git a/packages/turbo-types/src/types/config-v2.ts b/packages/turbo-types/src/types/config-v2.ts index 38ee396f90f2d..513ff6e933acf 100644 --- a/packages/turbo-types/src/types/config-v2.ts +++ b/packages/turbo-types/src/types/config-v2.ts @@ -61,6 +61,14 @@ export interface WorkspaceSchema extends BaseSchema { * Configuration for `turbo boundaries` that is specific to this package */ boundaries?: BoundariesConfig; + /** + * Configuration for the `turbo prune` command. + * + * Documentation: https://turborepo.com/docs/reference/configuration#prune + * + * @defaultValue `{}` + */ + prune?: Prune; } export interface RootSchema extends BaseSchema { @@ -105,6 +113,15 @@ export interface RootSchema extends BaseSchema { */ globalPassThroughEnv?: null | Array; + /** + * Configuration for the `turbo prune` command. + * + * Documentation: https://turborepo.com/docs/reference/configuration#prune + * + * @defaultValue `{}` + */ + prune?: Prune; + /** * Configuration options that control how turbo interfaces with the remote cache. * @@ -466,6 +483,18 @@ export interface RootBoundariesConfig extends BoundariesConfig { tags?: BoundariesRulesMap; } +/** + * Prune configuration + */ +export interface Prune { + /** + * Additional files and directories to include in pruned output. + * Supports glob patterns with ! for exclusions and $TURBO_ROOT$/ for repo-relative paths. + * When specified in workspace configs, patterns are combined with root config patterns. + */ + includes?: string[]; +} + export const isRootSchemaV2 = (schema: Schema): schema is RootSchema => !("extends" in schema); diff --git a/turborepo-tests/integration/tests/prune/custom-includes.t b/turborepo-tests/integration/tests/prune/custom-includes.t new file mode 100644 index 0000000000000..b4efb5c29e550 --- /dev/null +++ b/turborepo-tests/integration/tests/prune/custom-includes.t @@ -0,0 +1,245 @@ +Setup + $ . ${TESTDIR}/../../../helpers/setup_integration_test.sh monorepo_with_root_dep pnpm@7.25.1 + +Create test files that should be included via prune.includes + $ echo "# Test README" > README.md + $ echo "MIT License" > LICENSE + $ mkdir -p docs + $ echo "# Documentation" > docs/guide.md + $ mkdir -p config + $ echo '{"env": "production"}' > config/prod.json + $ echo '{"env": "local"}' > config/local.json + $ mkdir -p shared-assets + $ echo "asset content" > shared-assets/logo.svg + +Test basic custom includes in root turbo.json + $ cat > turbo.json << EOF + > { + > "\$schema": "https://turbo.build/schema.json", + > "prune": { + > "includes": ["README.md", "LICENSE"] + > } + > } + > EOF + + $ ${TURBO} prune web --docker + Generating pruned monorepo for web in .*out (re) + - Added shared + - Added util + - Added web + +Verify basic includes are copied to all destinations + $ test -f out/full/README.md + $ test -f out/full/LICENSE + $ test -f out/json/README.md + $ test -f out/json/LICENSE + $ test -f out/README.md + $ test -f out/LICENSE + +Test glob patterns with custom includes + $ cat > turbo.json << EOF + > { + > "\$schema": "https://turbo.build/schema.json", + > "prune": { + > "includes": ["docs/**", "*.md"] + > } + > } + > EOF + + $ rm -rf out + $ ${TURBO} prune web --docker + Generating pruned monorepo for web in .*out (re) + - Added shared + - Added util + - Added web + +Verify glob patterns work + $ test -d out/full/docs + $ test -f out/full/docs/guide.md + $ test -f out/full/README.md + +Test exclusion patterns with negation at root level + $ cat > turbo.json << EOF + > { + > "\$schema": "https://turbo.build/schema.json", + > "prune": { + > "includes": ["config/**", "!config/local.json"] + > } + > } + > EOF + + $ rm -rf out + $ ${TURBO} prune web --docker + Generating pruned monorepo for web in .*out (re) + - Added shared + - Added util + - Added web + +Verify included config files are copied + $ test -f out/full/config/prod.json + +Verify excluded config file is NOT copied + $ test ! -f out/full/config/local.json + +Verify config directory exists but excluded file is not in it + $ ls out/full/config + prod.json + +Verify exclusions work in all output directories + $ test -f out/config/prod.json + $ test ! -f out/config/local.json + $ test -f out/json/config/prod.json + $ test ! -f out/json/config/local.json + +Test multiple exclusion patterns + $ mkdir -p secrets + $ echo "secret1" > secrets/api-key.txt + $ echo "secret2" > secrets/password.txt + $ echo "not secret" > secrets/readme.txt + $ mkdir -p logs + $ echo "log data" > logs/debug.log + $ echo "more logs" > logs/error.log + + $ cat > turbo.json << EOF + > { + > "\$schema": "https://turbo.build/schema.json", + > "prune": { + > "includes": ["secrets/**", "logs/**", "!secrets/api-key.txt", "!secrets/password.txt", "!logs/*.log"] + > } + > } + > EOF + + $ rm -rf out + $ ${TURBO} prune web --docker + Generating pruned monorepo for web in .*out (re) + - Added shared + - Added util + - Added web + +Verify only non-excluded files are copied + $ test -f out/full/secrets/readme.txt + $ test ! -f out/full/secrets/api-key.txt + $ test ! -f out/full/secrets/password.txt + $ test ! -f out/full/logs/debug.log + $ test ! -f out/full/logs/error.log + +Test workspace-level custom includes with workspace-relative paths + $ mkdir -p apps/web/docs + $ echo "# Web App Docs" > apps/web/docs/README.md + $ echo "# Web README" > apps/web/README.md + $ echo "module.exports = {}" > apps/web/next.config.js + $ echo "module.exports = {}" > apps/web/tailwind.config.js + +Reset root turbo.json to have no custom includes + $ cat > turbo.json << EOF + > { + > "\$schema": "https://turbo.build/schema.json" + > } + > EOF + +Add workspace-level turbo.json with custom includes + $ cat > apps/web/turbo.json << EOF + > { + > "\$schema": "https://turbo.build/schema.json", + > "prune": { + > "includes": ["README.md", "docs/**", "*.config.js"] + > } + > } + > EOF + + $ rm -rf out + $ ${TURBO} prune web --docker + Generating pruned monorepo for web in .*out (re) + - Added shared + - Added util + - Added web + +Verify workspace-relative includes are properly prefixed and copied + $ test -f out/full/apps/web/README.md + $ test -d out/full/apps/web/docs + $ test -f out/full/apps/web/docs/README.md + $ test -f out/full/apps/web/next.config.js + $ test -f out/full/apps/web/tailwind.config.js + $ cat out/full/apps/web/README.md + # Web README + +Verify workspace files are also in docker output directories + $ test -f out/json/apps/web/README.md + $ test -f out/json/apps/web/next.config.js + +Verify workspace files are NOT copied to repo root (only in apps/web/) + $ test ! -f out/full/README.md + $ test ! -f out/full/next.config.js + +Test workspace-level includes with $TURBO_ROOT$ (repo-relative paths) + $ cat > apps/web/turbo.json << EOF + > { + > "\$schema": "https://turbo.build/schema.json", + > "prune": { + > "includes": ["\$TURBO_ROOT\$/shared-assets/**"] + > } + > } + > EOF + + $ rm -rf out + $ ${TURBO} prune web --docker + Generating pruned monorepo for web in .*out (re) + - Added shared + - Added util + - Added web + +Verify $TURBO_ROOT$ patterns work from workspace configs + $ test -d out/full/shared-assets + $ test -f out/full/shared-assets/logo.svg + +Test mixed workspace and root includes + $ cat > turbo.json << EOF + > { + > "\$schema": "https://turbo.build/schema.json", + > "prune": { + > "includes": ["LICENSE"] + > } + > } + > EOF + + $ cat > apps/web/turbo.json << EOF + > { + > "\$schema": "https://turbo.build/schema.json", + > "prune": { + > "includes": ["README.md"] + > } + > } + > EOF + + $ rm -rf out + $ ${TURBO} prune web --docker + Generating pruned monorepo for web in .*out (re) + - Added shared + - Added util + - Added web + +Verify both root and workspace includes are applied + $ test -f out/full/LICENSE + $ test -f out/full/apps/web/README.md + +Test without docker mode + $ cat > turbo.json << EOF + > { + > "\$schema": "https://turbo.build/schema.json", + > "prune": { + > "includes": ["README.md"] + > } + > } + > EOF + + $ rm -rf out + $ ${TURBO} prune web + Generating pruned monorepo for web in .*out (re) + - Added shared + - Added util + - Added web + +Verify files are copied when not in docker mode + $ test -f out/README.md + $ test ! -d out/json + $ test ! -d out/full