From 83bef12848ea688bcf9014111a25818f3b1321f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 17:16:11 +0000 Subject: [PATCH 1/3] refactor: Extract JavaScript prune rendering pure functions Move manifest/workspace/patch/bin prune helpers into prune_js without behavior changes so orchestration can migrate onto knowledge next. Closes TURBO-5833. Co-authored-by: Anthony Shew --- crates/turborepo-lib/src/commands/prune.rs | 190 +---------------- crates/turborepo-lib/src/commands/prune_js.rs | 197 ++++++++++++++++++ 2 files changed, 205 insertions(+), 182 deletions(-) create mode 100644 crates/turborepo-lib/src/commands/prune_js.rs diff --git a/crates/turborepo-lib/src/commands/prune.rs b/crates/turborepo-lib/src/commands/prune.rs index 4c4fa799ecb72..e58821187a515 100644 --- a/crates/turborepo-lib/src/commands/prune.rs +++ b/crates/turborepo-lib/src/commands/prune.rs @@ -1,7 +1,7 @@ #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::{ - collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, io::ErrorKind, str::FromStr, sync::{LazyLock, OnceLock}, @@ -117,6 +117,13 @@ static ADDITIONAL_DIRECTORIES: LazyLock &'static RelativeUnixPath { match RelativeUnixPath::new(path) { Ok(path) => path, @@ -573,150 +580,6 @@ fn sync_prune_finalize_files( } } -fn workspace_dependency_target<'a>(name: &'a str, version: &'a str) -> Option<&'a str> { - let specifier = version.strip_prefix("workspace:")?; - match specifier.rsplit_once('@') { - Some((target, "*" | "^" | "~")) if !target.is_empty() => Some(target), - _ => Some(name), - } -} - -fn prune_package_json_dev_dependencies( - package_json: &mut serde_json::Value, - excluded_workspaces: &HashSet, -) -> bool { - let Some(dev_dependencies) = package_json - .get_mut("devDependencies") - .and_then(serde_json::Value::as_object_mut) - else { - return false; - }; - - let original_len = dev_dependencies.len(); - dev_dependencies.retain(|name, version| { - let Some(version) = version.as_str() else { - return true; - }; - workspace_dependency_target(name, version) - .is_none_or(|target| !excluded_workspaces.contains(target)) - }); - let changed = dev_dependencies.len() != original_len; - let remove_dev_dependencies = dev_dependencies.is_empty(); - if remove_dev_dependencies { - if let Some(package_json) = package_json.as_object_mut() { - package_json.remove("devDependencies"); - } - } - changed -} - -fn prune_package_json_workspaces(package_json: &mut serde_json::Value, workspace_paths: &[String]) { - let Some(workspaces) = package_json.get_mut("workspaces") else { - return; - }; - - let pruned_workspaces = || { - workspace_paths - .iter() - .map(|workspace| serde_json::Value::String(workspace.clone())) - .collect::>() - }; - - match workspaces { - serde_json::Value::Array(packages) => *packages = pruned_workspaces(), - serde_json::Value::Object(config) => { - if let Some(packages) = config.get_mut("packages") { - *packages = serde_json::Value::Array(pruned_workspaces()); - } - } - _ => {} - } -} - -fn collect_patch_paths( - lockfile: &dyn turborepo_lockfiles::Lockfile, - root_package_json: &PackageJson, - repo_root: &turbopath::AbsoluteSystemPath, - package_manager: &PackageManager, -) -> Result, Error> { - let mut patches = lockfile.patches()?; - let patch_keys = lockfile.patch_keys(); - - if !patch_keys.is_empty() { - patches.extend(package_json_patch_paths(root_package_json, &patch_keys)); - - if package_manager.is_pnpm_family() { - let workspace_yaml_path = repo_root.join_component( - turborepo_repository::package_manager::pnpm::WORKSPACE_CONFIGURATION_PATH, - ); - patches.extend( - turborepo_repository::package_manager::pnpm::patch_paths_for_keys( - &workspace_yaml_path, - &patch_keys, - )?, - ); - } - } - - patches.sort(); - patches.dedup(); - validate_patch_source_paths(repo_root, &patches)?; - Ok(patches) -} - -fn validate_patch_source_paths( - repo_root: &AbsoluteSystemPath, - patches: &[RelativeUnixPathBuf], -) -> Result<(), Error> { - let repo_root_realpath = repo_root.to_realpath()?; - - for patch in patches { - let patch_path = repo_root.join_unix_path(patch); - if !patch_path.starts_with(repo_root.as_std_path()) { - return Err(Error::InvalidPatchPath(patch.clone())); - } - - if patch_path.try_exists()? { - let patch_realpath = patch_path.to_realpath()?; - if !patch_realpath.starts_with(repo_root_realpath.as_std_path()) { - return Err(Error::InvalidPatchPath(patch.clone())); - } - } - } - - Ok(()) -} - -fn package_json_patch_paths( - package_json: &PackageJson, - patch_keys: &[String], -) -> Vec { - let patch_keys: BTreeSet<_> = patch_keys.iter().map(String::as_str).collect(); - let mut patches = Vec::new(); - - if let Some(patched_dependencies) = package_json.patched_dependencies.as_ref() { - patches.extend( - patched_dependencies.iter().filter_map(|(key, path)| { - patch_keys.contains(key.as_str()).then_some(path.clone()) - }), - ); - } - - if let Some(patched_dependencies) = package_json - .pnpm - .as_ref() - .and_then(|config| config.patched_dependencies.as_ref()) - { - patches.extend( - patched_dependencies.iter().filter_map(|(key, path)| { - patch_keys.contains(key.as_str()).then_some(path.clone()) - }), - ); - } - - patches -} - struct Prune<'a> { package_graph: PackageGraph, root: AbsoluteSystemPathBuf, @@ -1433,46 +1296,9 @@ impl<'a> Prune<'a> { } } -fn bin_paths(package_json: &PackageJson) -> Vec<&str> { - match package_json.other.get("bin") { - Some(serde_json::Value::String(path)) => vec![path.as_str()], - Some(serde_json::Value::Object(entries)) => entries - .values() - .filter_map(serde_json::Value::as_str) - .collect(), - _ => Vec::new(), - } -} - /// Merge `pruned` values into `original`, preserving the key ordering from /// `original`. Keys present in `original` but absent from `pruned` are dropped. /// Keys present in `pruned` but absent from `original` are appended. -fn merge_preserving_key_order( - original: &serde_json::Value, - pruned: &serde_json::Value, -) -> serde_json::Value { - match (original, pruned) { - (serde_json::Value::Object(orig_map), serde_json::Value::Object(pruned_map)) => { - let mut result = serde_json::Map::new(); - for (key, orig_val) in orig_map { - if let Some(pruned_val) = pruned_map.get(key) { - result.insert( - key.clone(), - merge_preserving_key_order(orig_val, pruned_val), - ); - } - } - for (key, pruned_val) in pruned_map { - if !orig_map.contains_key(key) { - result.insert(key.clone(), pruned_val.clone()); - } - } - serde_json::Value::Object(result) - } - (_, pruned) => pruned.clone(), - } -} - #[cfg(test)] mod tests { use std::{ diff --git a/crates/turborepo-lib/src/commands/prune_js.rs b/crates/turborepo-lib/src/commands/prune_js.rs new file mode 100644 index 0000000000000..04b73606d37ab --- /dev/null +++ b/crates/turborepo-lib/src/commands/prune_js.rs @@ -0,0 +1,197 @@ +//! Pure JavaScript prune rendering helpers extracted from `prune` +//! orchestration. +//! +//! These functions rewrite manifests/workspaces/patches without performing +//! filesystem layout. `commands/prune.rs` remains responsible for copying and +//! path safety until later Phase 7 leaves migrate orchestration. + +use std::collections::{BTreeSet, HashSet}; + +use turbopath::{AbsoluteSystemPath, RelativeUnixPathBuf}; +use turborepo_repository::{package_json::PackageJson, package_manager::PackageManager}; + +use super::Error; + +pub(crate) fn workspace_dependency_target<'a>(name: &'a str, version: &'a str) -> Option<&'a str> { + let specifier = version.strip_prefix("workspace:")?; + match specifier.rsplit_once('@') { + Some((target, "*" | "^" | "~")) if !target.is_empty() => Some(target), + _ => Some(name), + } +} + +pub(crate) fn prune_package_json_dev_dependencies( + package_json: &mut serde_json::Value, + excluded_workspaces: &HashSet, +) -> bool { + let Some(dev_dependencies) = package_json + .get_mut("devDependencies") + .and_then(serde_json::Value::as_object_mut) + else { + return false; + }; + + let original_len = dev_dependencies.len(); + dev_dependencies.retain(|name, version| { + let Some(version) = version.as_str() else { + return true; + }; + workspace_dependency_target(name, version) + .is_none_or(|target| !excluded_workspaces.contains(target)) + }); + let changed = dev_dependencies.len() != original_len; + let remove_dev_dependencies = dev_dependencies.is_empty(); + if remove_dev_dependencies { + if let Some(package_json) = package_json.as_object_mut() { + package_json.remove("devDependencies"); + } + } + changed +} + +pub(crate) fn prune_package_json_workspaces( + package_json: &mut serde_json::Value, + workspace_paths: &[String], +) { + let Some(workspaces) = package_json.get_mut("workspaces") else { + return; + }; + + let pruned_workspaces = || { + workspace_paths + .iter() + .map(|workspace| serde_json::Value::String(workspace.clone())) + .collect::>() + }; + + match workspaces { + serde_json::Value::Array(packages) => *packages = pruned_workspaces(), + serde_json::Value::Object(config) => { + if let Some(packages) = config.get_mut("packages") { + *packages = serde_json::Value::Array(pruned_workspaces()); + } + } + _ => {} + } +} + +pub(crate) fn collect_patch_paths( + lockfile: &dyn turborepo_lockfiles::Lockfile, + root_package_json: &PackageJson, + repo_root: &turbopath::AbsoluteSystemPath, + package_manager: &PackageManager, +) -> Result, Error> { + let mut patches = lockfile.patches()?; + let patch_keys = lockfile.patch_keys(); + + if !patch_keys.is_empty() { + patches.extend(package_json_patch_paths(root_package_json, &patch_keys)); + + if package_manager.is_pnpm_family() { + let workspace_yaml_path = repo_root.join_component( + turborepo_repository::package_manager::pnpm::WORKSPACE_CONFIGURATION_PATH, + ); + patches.extend( + turborepo_repository::package_manager::pnpm::patch_paths_for_keys( + &workspace_yaml_path, + &patch_keys, + )?, + ); + } + } + + patches.sort(); + patches.dedup(); + validate_patch_source_paths(repo_root, &patches)?; + Ok(patches) +} + +pub(crate) fn validate_patch_source_paths( + repo_root: &AbsoluteSystemPath, + patches: &[RelativeUnixPathBuf], +) -> Result<(), Error> { + let repo_root_realpath = repo_root.to_realpath()?; + + for patch in patches { + let patch_path = repo_root.join_unix_path(patch); + if !patch_path.starts_with(repo_root.as_std_path()) { + return Err(Error::InvalidPatchPath(patch.clone())); + } + + if patch_path.try_exists()? { + let patch_realpath = patch_path.to_realpath()?; + if !patch_realpath.starts_with(repo_root_realpath.as_std_path()) { + return Err(Error::InvalidPatchPath(patch.clone())); + } + } + } + + Ok(()) +} + +pub(crate) fn package_json_patch_paths( + package_json: &PackageJson, + patch_keys: &[String], +) -> Vec { + let patch_keys: BTreeSet<_> = patch_keys.iter().map(String::as_str).collect(); + let mut patches = Vec::new(); + + if let Some(patched_dependencies) = package_json.patched_dependencies.as_ref() { + patches.extend( + patched_dependencies.iter().filter_map(|(key, path)| { + patch_keys.contains(key.as_str()).then_some(path.clone()) + }), + ); + } + + if let Some(patched_dependencies) = package_json + .pnpm + .as_ref() + .and_then(|config| config.patched_dependencies.as_ref()) + { + patches.extend( + patched_dependencies.iter().filter_map(|(key, path)| { + patch_keys.contains(key.as_str()).then_some(path.clone()) + }), + ); + } + + patches +} + +pub(crate) fn bin_paths(package_json: &PackageJson) -> Vec<&str> { + match package_json.other.get("bin") { + Some(serde_json::Value::String(path)) => vec![path.as_str()], + Some(serde_json::Value::Object(entries)) => entries + .values() + .filter_map(serde_json::Value::as_str) + .collect(), + _ => Vec::new(), + } +} + +pub(crate) fn merge_preserving_key_order( + original: &serde_json::Value, + pruned: &serde_json::Value, +) -> serde_json::Value { + match (original, pruned) { + (serde_json::Value::Object(orig_map), serde_json::Value::Object(pruned_map)) => { + let mut result = serde_json::Map::new(); + for (key, orig_val) in orig_map { + if let Some(pruned_val) = pruned_map.get(key) { + result.insert( + key.clone(), + merge_preserving_key_order(orig_val, pruned_val), + ); + } + } + for (key, pruned_val) in pruned_map { + if !orig_map.contains_key(key) { + result.insert(key.clone(), pruned_val.clone()); + } + } + serde_json::Value::Object(result) + } + (_, pruned) => pruned.clone(), + } +} From 99c022fa99615884e439e26c35b06117dcf0a2cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 17:18:59 +0000 Subject: [PATCH 2/3] refactor: Separate prune closure and layout from JS rendering Introduce an explicit JavaScript prune render step with typed inputs and artifacts so core orchestration no longer interleaves format rewriting with closure selection and path-safe layout. Closes TURBO-5834. Co-authored-by: Anthony Shew --- crates/turborepo-lib/src/commands/prune.rs | 160 +++++++----------- crates/turborepo-lib/src/commands/prune_js.rs | 107 ++++++++++++ 2 files changed, 165 insertions(+), 102 deletions(-) diff --git a/crates/turborepo-lib/src/commands/prune.rs b/crates/turborepo-lib/src/commands/prune.rs index e58821187a515..22a7a9f3f4b09 100644 --- a/crates/turborepo-lib/src/commands/prune.rs +++ b/crates/turborepo-lib/src/commands/prune.rs @@ -120,8 +120,8 @@ static ADDITIONAL_DIRECTORIES: LazyLock &'static RelativeUnixPath { @@ -345,110 +345,69 @@ pub async fn prune( prune.copy_turbo_json(&workspace_names)?; prune.copy_global_dependencies()?; - // The JavaScript lockfile subgraph, root package.json rewrite, and pnpm - // workspace patch pruning apply only when the repository has a JavaScript - // package manager and root manifest. A pure Cargo workspace has neither; - // its Cargo.lock and Cargo.toml were already rewritten by the Cargo - // toolchain's prune plan above. + // Distinct JavaScript rendering step: core already selected the package + // closure and laid out packages; JS lockfile/manifest/patch rewriting is + // produced as an explicit artifact set and then materialized with the + // same path-safe copy helpers used for layout. if let (Some(package_manager), Some(root_package_json)) = ( prune.package_graph.package_manager(), prune.package_graph.root_package_json(), ) { - let lockfile = prune - .package_graph - .lockfile() - .ok_or(Error::MissingLockfile)? - .subgraph(&workspace_paths, &lockfile_keys)?; - - let lockfile_name = package_manager.lockfile_name(); - - if prune.uses_per_workspace_lockfiles { - // Per-workspace lockfiles are already in the pruned output from - // recursive_copy in copy_workspace. Copy the original root lockfile - // as-is (it only contains root-level dependencies). - let original_root_lockfile = prune.root.join_component(lockfile_name); - let out_lockfile = prune.out_directory.join_component(lockfile_name); - turborepo_fs::copy_file(&original_root_lockfile, &out_lockfile)?; - if prune.docker { - turborepo_fs::copy_file( - &original_root_lockfile, - prune.docker_directory().join_component(lockfile_name), - )?; - } - } else { - let lockfile_contents = lockfile.encode()?; - let lockfile_path = prune.out_directory.join_component(lockfile_name); - lockfile_path.create_with_contents(&lockfile_contents)?; - if prune.docker { - prune - .docker_directory() - .join_component(lockfile_name) - .create_with_contents(&lockfile_contents)?; - } - } - let original_lockfile = prune .package_graph .lockfile() .ok_or(Error::MissingLockfile)?; - let original_patches = collect_patch_paths( - original_lockfile, - root_package_json, - &prune.root, - package_manager, - )?; - let pruned_patches = if original_patches.is_empty() { - Vec::new() - } else { - collect_patch_paths( - lockfile.as_ref(), - root_package_json, - &prune.root, - package_manager, - )? - }; - - if !original_patches.is_empty() { - trace!( - "original patches: {:?}, pruned patches: {:?}", - original_patches, - pruned_patches - ); - } - let root_definition = prune .package_graph .package_definition_path(&PackageName::Root) .ok_or_else(|| Error::MissingPackageDefinition(PackageName::Root))?; - let original_contents = prune.root.resolve(root_definition).read_to_string()?; - let original_value: serde_json::Value = serde_json::from_str(&original_contents)?; - if !original_patches.is_empty() - || original_value.get("workspaces").is_some() - || !excluded_dev_workspaces.is_empty() - { - let pruned_json = if original_patches.is_empty() { - root_package_json.clone() - } else { - package_manager.prune_patched_packages( - root_package_json, - &pruned_patches, - &prune.root, - ) - }; - - let mut pruned_value = serde_json::to_value(&pruned_json)?; - prune_package_json_workspaces(&mut pruned_value, &workspace_paths); - prune_package_json_dev_dependencies(&mut pruned_value, &excluded_dev_workspaces); - // Merge into the original JSON value so package.json key order stays stable. - let merged = merge_preserving_key_order(&original_value, &pruned_value); - let mut pruned_json_contents = serde_json::to_string_pretty(&merged)?; - // Add trailing newline to match Go behavior - pruned_json_contents.push('\n'); + let original_root_contents = prune.root.resolve(root_definition).read_to_string()?; + let rendered = render_javascript_prune(JavaScriptPruneRenderInput { + package_manager, + root_package_json, + original_lockfile, + workspace_paths: &workspace_paths, + lockfile_keys: &lockfile_keys, + excluded_dev_workspaces: &excluded_dev_workspaces, + repo_root: &prune.root, + original_root_package_json_contents: &original_root_contents, + uses_per_workspace_lockfiles: prune.uses_per_workspace_lockfiles, + })?; + + match &rendered.lockfile { + JavaScriptPruneLockfileArtifact::CopyOriginalRoot => { + // Per-workspace lockfiles are already in the pruned output from + // recursive_copy in copy_workspace. Copy the original root + // lockfile as-is (it only contains root-level dependencies). + let original_root_lockfile = prune.root.join_component(rendered.lockfile_name); + let out_lockfile = prune.out_directory.join_component(rendered.lockfile_name); + turborepo_fs::copy_file(&original_root_lockfile, &out_lockfile)?; + if prune.docker { + turborepo_fs::copy_file( + &original_root_lockfile, + prune + .docker_directory() + .join_component(rendered.lockfile_name), + )?; + } + } + JavaScriptPruneLockfileArtifact::Encoded(lockfile_contents) => { + let lockfile_path = prune.out_directory.join_component(rendered.lockfile_name); + lockfile_path.create_with_contents(lockfile_contents)?; + if prune.docker { + prune + .docker_directory() + .join_component(rendered.lockfile_name) + .create_with_contents(lockfile_contents)?; + } + } + } + if let Some(pruned_json_contents) = &rendered.root_package_json_contents { let original = prune.root.resolve(root_definition); let permissions = original.symlink_metadata()?.permissions(); let new_package_json_path = prune.full_directory.resolve(root_definition); - new_package_json_path.create_with_contents(&pruned_json_contents)?; + new_package_json_path.create_with_contents(pruned_json_contents)?; #[cfg(unix)] new_package_json_path.set_mode(permissions.mode())?; #[cfg(windows)] @@ -465,33 +424,29 @@ pub async fn prune( prune.copy_file(root_definition, Some(CopyDestination::Docker))?; } - if !original_patches.is_empty() { - for patch in &pruned_patches { - prune.copy_patch_file(patch)?; - } + for patch in &rendered.pruned_patches { + prune.copy_patch_file(patch)?; } - // Prune pnpm-workspace.yaml's patchedDependencies so it only - // references patches that are actually in the pruned output. - if package_manager.is_pnpm_family() { + if rendered.prune_pnpm_workspace_patches { let ws_config = turborepo_repository::package_manager::pnpm::WORKSPACE_CONFIGURATION_PATH; let ws_path = AnchoredSystemPathBuf::from_raw(ws_config)?; let out_ws = prune.out_directory.resolve(&ws_path); turborepo_repository::package_manager::pnpm::prune_workspace_patches( &out_ws, - &pruned_patches, + &rendered.pruned_patches, )?; let full_ws = prune.full_directory.resolve(&ws_path); turborepo_repository::package_manager::pnpm::prune_workspace_patches( &full_ws, - &pruned_patches, + &rendered.pruned_patches, )?; if prune.docker { let docker_ws = prune.docker_directory().resolve(&ws_path); turborepo_repository::package_manager::pnpm::prune_workspace_patches( &docker_ws, - &pruned_patches, + &rendered.pruned_patches, )?; } } @@ -1318,8 +1273,9 @@ mod tests { }; use super::{ - bin_paths, finalized_path_is_contained, merge_preserving_key_order, - prune_package_json_workspaces, sync_prune_finalize_files, Error, Prune, ADDITIONAL_FILES, + bin_paths, finalized_path_is_contained, + prune_js::{merge_preserving_key_order, prune_package_json_workspaces}, + sync_prune_finalize_files, Error, Prune, ADDITIONAL_FILES, }; struct MockDiscovery; diff --git a/crates/turborepo-lib/src/commands/prune_js.rs b/crates/turborepo-lib/src/commands/prune_js.rs index 04b73606d37ab..b99f5170629f9 100644 --- a/crates/turborepo-lib/src/commands/prune_js.rs +++ b/crates/turborepo-lib/src/commands/prune_js.rs @@ -195,3 +195,110 @@ pub(crate) fn merge_preserving_key_order( (_, pruned) => pruned.clone(), } } + +/// Inputs for the JavaScript-only prune rendering step. +/// +/// Closure selection and filesystem layout remain in `commands/prune.rs`; +/// this struct carries only the facts needed to rewrite JS lockfiles, +/// root manifests, patches, and workspace patch tables. +pub(crate) struct JavaScriptPruneRenderInput<'a> { + pub package_manager: &'a PackageManager, + pub root_package_json: &'a PackageJson, + pub original_lockfile: &'a dyn turborepo_lockfiles::Lockfile, + pub workspace_paths: &'a [String], + pub lockfile_keys: &'a [String], + pub excluded_dev_workspaces: &'a HashSet, + pub repo_root: &'a AbsoluteSystemPath, + pub original_root_package_json_contents: &'a str, + pub uses_per_workspace_lockfiles: bool, +} + +/// How the pruned lockfile should be materialized by layout code. +#[derive(Debug)] +pub(crate) enum JavaScriptPruneLockfileArtifact { + /// Copy the original root lockfile as-is (per-workspace lockfile mode). + CopyOriginalRoot, + /// Write these encoded bytes as the pruned root lockfile. + Encoded(Vec), +} + +/// Outputs of the JavaScript prune rendering step. +#[derive(Debug)] +pub(crate) struct JavaScriptPruneRenderResult { + pub lockfile_name: &'static str, + pub lockfile: JavaScriptPruneLockfileArtifact, + /// `None` means copy the original root package.json unchanged. + pub root_package_json_contents: Option, + pub pruned_patches: Vec, + pub prune_pnpm_workspace_patches: bool, +} + +/// Render JavaScript lockfile/manifest/patch artifacts for a pruned repository. +/// +/// Performs no filesystem writes other than reads already implied by patch +/// path collection helpers. +pub(crate) fn render_javascript_prune( + input: JavaScriptPruneRenderInput<'_>, +) -> Result { + let subgraph = input + .original_lockfile + .subgraph(input.workspace_paths, input.lockfile_keys)?; + let lockfile_name = input.package_manager.lockfile_name(); + let lockfile = if input.uses_per_workspace_lockfiles { + JavaScriptPruneLockfileArtifact::CopyOriginalRoot + } else { + JavaScriptPruneLockfileArtifact::Encoded(subgraph.encode()?) + }; + + let original_patches = collect_patch_paths( + input.original_lockfile, + input.root_package_json, + input.repo_root, + input.package_manager, + )?; + let pruned_patches = if original_patches.is_empty() { + Vec::new() + } else { + collect_patch_paths( + subgraph.as_ref(), + input.root_package_json, + input.repo_root, + input.package_manager, + )? + }; + + let original_value: serde_json::Value = + serde_json::from_str(input.original_root_package_json_contents)?; + let root_package_json_contents = if !original_patches.is_empty() + || original_value.get("workspaces").is_some() + || !input.excluded_dev_workspaces.is_empty() + { + let pruned_json = if original_patches.is_empty() { + input.root_package_json.clone() + } else { + input.package_manager.prune_patched_packages( + input.root_package_json, + &pruned_patches, + input.repo_root, + ) + }; + + let mut pruned_value = serde_json::to_value(&pruned_json)?; + prune_package_json_workspaces(&mut pruned_value, input.workspace_paths); + prune_package_json_dev_dependencies(&mut pruned_value, input.excluded_dev_workspaces); + let merged = merge_preserving_key_order(&original_value, &pruned_value); + let mut contents = serde_json::to_string_pretty(&merged)?; + contents.push('\n'); + Some(contents) + } else { + None + }; + + Ok(JavaScriptPruneRenderResult { + lockfile_name, + lockfile, + root_package_json_contents, + pruned_patches, + prune_pnpm_workspace_patches: input.package_manager.is_pnpm_family(), + }) +} From 07817d1f8ca6fa92820ef6f2953b48601d6fbb02 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 17:21:42 +0000 Subject: [PATCH 3/3] test: Add prune golden fixtures for retained files and layers Snapshot retained packages and file inventories (path, content fingerprint, permissions class) for standard and Docker prune layouts. Closes TURBO-5835. Co-authored-by: Anthony Shew --- crates/turborepo/tests/prune_test.rs | 129 +++++++++++++++++- ...une_test__prune_docker_full_inventory.snap | 18 +++ ...une_test__prune_docker_json_inventory.snap | 18 +++ ...rune_test__prune_docker_out_top_level.snap | 8 ++ ..._test__prune_docker_retained_packages.snap | 8 ++ ...ne_test__prune_standard_out_inventory.snap | 19 +++ ...est__prune_standard_retained_packages.snap | 7 + 7 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 crates/turborepo/tests/snapshots/prune_test__prune_docker_full_inventory.snap create mode 100644 crates/turborepo/tests/snapshots/prune_test__prune_docker_json_inventory.snap create mode 100644 crates/turborepo/tests/snapshots/prune_test__prune_docker_out_top_level.snap create mode 100644 crates/turborepo/tests/snapshots/prune_test__prune_docker_retained_packages.snap create mode 100644 crates/turborepo/tests/snapshots/prune_test__prune_standard_out_inventory.snap create mode 100644 crates/turborepo/tests/snapshots/prune_test__prune_standard_retained_packages.snap diff --git a/crates/turborepo/tests/prune_test.rs b/crates/turborepo/tests/prune_test.rs index 3585348e4ea90..dcee5c0e72e10 100644 --- a/crates/turborepo/tests/prune_test.rs +++ b/crates/turborepo/tests/prune_test.rs @@ -2,7 +2,7 @@ mod common; -use std::{fs, path::Path}; +use std::{collections::BTreeMap, fs, path::Path}; use common::{combined_output, run_turbo, setup}; @@ -15,6 +15,65 @@ fn ls_dir(dir: &Path) -> Vec { entries } +/// Golden inventory of a pruned tree: relative path, content hash, and kind. +/// +/// Directories are recorded as `dir`; files include a sha256 of their bytes +/// and a portable permission class (`ro` / `rw`). Paths use forward slashes. +fn inventory_tree(root: &Path) -> String { + fn walk(base: &Path, current: &Path, out: &mut BTreeMap) { + let mut entries: Vec<_> = fs::read_dir(current) + .unwrap_or_else(|error| panic!("read_dir {}: {error}", current.display())) + .map(|entry| entry.expect("dir entry")) + .collect(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let rel = path + .strip_prefix(base) + .expect("path under base") + .to_string_lossy() + .replace('\\', "/"); + let meta = entry.metadata().expect("metadata"); + if meta.is_dir() { + out.insert(rel, "dir".to_string()); + walk(base, &path, out); + } else if meta.is_file() { + let bytes = fs::read(&path).expect("read file"); + // Stable FNV-1a fingerprint — good enough for golden inventories + // without pulling a crypto hash into the turbo test crate. + let mut hash: u64 = 0xcbf29ce484222325; + for byte in &bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + let perms = if meta.permissions().readonly() { + "ro" + } else { + "rw" + }; + out.insert(rel, format!("file\t{hash:016x}\t{perms}")); + } + } + } + + let mut inventory = BTreeMap::new(); + walk(root, root, &mut inventory); + inventory + .into_iter() + .map(|(path, kind)| format!("{path}\t{kind}")) + .collect::>() + .join("\n") +} + +fn prune_retained_packages(stdout: &str) -> Vec { + let mut packages: Vec = stdout + .lines() + .filter_map(|line| line.strip_prefix(" - Added ").map(str::to_string)) + .collect(); + packages.sort(); + packages +} + #[test] fn test_prune_production_excludes_dev_dependencies() { let tempdir = tempfile::tempdir().unwrap(); @@ -1166,3 +1225,71 @@ fn test_prune_pnpm_v11_multi_document_lockfile() { "pruned lockfile should still trim workspaces from the dependency document" ); } + +/// Golden fixture covering retained packages, relative file set, content +/// hashes, and standard/Docker layer placement for the separated JS render + +/// layout path. +#[test] +fn test_prune_docker_golden_inventory() { + let tempdir = tempfile::tempdir().unwrap(); + setup::setup_integration_test( + tempdir.path(), + "monorepo_with_root_dep", + "pnpm@7.25.1", + false, + ) + .unwrap(); + + let output = run_turbo(tempdir.path(), &["prune", "web", "--docker"]); + assert!( + output.status.success(), + "prune --docker failed: {}", + combined_output(&output) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + insta::assert_snapshot!( + "prune_docker_retained_packages", + prune_retained_packages(&stdout).join("\n") + ); + + let out = tempdir.path().join("out"); + insta::assert_snapshot!("prune_docker_out_top_level", ls_dir(&out).join("\n")); + insta::assert_snapshot!( + "prune_docker_full_inventory", + inventory_tree(&out.join("full")) + ); + insta::assert_snapshot!( + "prune_docker_json_inventory", + inventory_tree(&out.join("json")) + ); +} + +#[test] +fn test_prune_standard_golden_inventory() { + let tempdir = tempfile::tempdir().unwrap(); + setup::setup_integration_test( + tempdir.path(), + "monorepo_with_root_dep", + "pnpm@7.25.1", + false, + ) + .unwrap(); + + let output = run_turbo(tempdir.path(), &["prune", "web"]); + assert!( + output.status.success(), + "prune failed: {}", + combined_output(&output) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + insta::assert_snapshot!( + "prune_standard_retained_packages", + prune_retained_packages(&stdout).join("\n") + ); + insta::assert_snapshot!( + "prune_standard_out_inventory", + inventory_tree(&tempdir.path().join("out")) + ); +} diff --git a/crates/turborepo/tests/snapshots/prune_test__prune_docker_full_inventory.snap b/crates/turborepo/tests/snapshots/prune_test__prune_docker_full_inventory.snap new file mode 100644 index 0000000000000..ecc9fcbf85d2f --- /dev/null +++ b/crates/turborepo/tests/snapshots/prune_test__prune_docker_full_inventory.snap @@ -0,0 +1,18 @@ +--- +source: crates/turborepo/tests/prune_test.rs +expression: "inventory_tree(&out.join(\"full\"))" +--- +.npmrc file 196da013f9ded0fa rw +apps dir +apps/web dir +apps/web/package.json file 2481da4712c9aca1 rw +package.json file c54a5bbdc116d12f rw +packages dir +packages/shared dir +packages/shared/package.json file 67befcf30aa75b2a rw +packages/util dir +packages/util/package.json file 4bebcc6f0ac3be5d rw +patches dir +patches/is-number@7.0.0.patch file d6022cf83b3da40d rw +pnpm-workspace.yaml file 7262bc3c890b9e1b rw +turbo.json file 375cc1d0558b7fc7 rw diff --git a/crates/turborepo/tests/snapshots/prune_test__prune_docker_json_inventory.snap b/crates/turborepo/tests/snapshots/prune_test__prune_docker_json_inventory.snap new file mode 100644 index 0000000000000..b75686b3cb316 --- /dev/null +++ b/crates/turborepo/tests/snapshots/prune_test__prune_docker_json_inventory.snap @@ -0,0 +1,18 @@ +--- +source: crates/turborepo/tests/prune_test.rs +expression: "inventory_tree(&out.join(\"json\"))" +--- +.npmrc file 196da013f9ded0fa rw +apps dir +apps/web dir +apps/web/package.json file 2481da4712c9aca1 rw +package.json file c54a5bbdc116d12f rw +packages dir +packages/shared dir +packages/shared/package.json file 67befcf30aa75b2a rw +packages/util dir +packages/util/package.json file 4bebcc6f0ac3be5d rw +patches dir +patches/is-number@7.0.0.patch file d6022cf83b3da40d rw +pnpm-lock.yaml file 0f96ce8d41b06e10 rw +pnpm-workspace.yaml file 7262bc3c890b9e1b rw diff --git a/crates/turborepo/tests/snapshots/prune_test__prune_docker_out_top_level.snap b/crates/turborepo/tests/snapshots/prune_test__prune_docker_out_top_level.snap new file mode 100644 index 0000000000000..13daef4cd8b13 --- /dev/null +++ b/crates/turborepo/tests/snapshots/prune_test__prune_docker_out_top_level.snap @@ -0,0 +1,8 @@ +--- +source: crates/turborepo/tests/prune_test.rs +expression: "ls_dir(&out).join(\"\\n\")" +--- +full +json +pnpm-lock.yaml +pnpm-workspace.yaml diff --git a/crates/turborepo/tests/snapshots/prune_test__prune_docker_retained_packages.snap b/crates/turborepo/tests/snapshots/prune_test__prune_docker_retained_packages.snap new file mode 100644 index 0000000000000..0362c4985bd0c --- /dev/null +++ b/crates/turborepo/tests/snapshots/prune_test__prune_docker_retained_packages.snap @@ -0,0 +1,8 @@ +--- +source: crates/turborepo/tests/prune_test.rs +assertion_line: 1255 +expression: "prune_retained_packages(&stdout).join(\"\\n\")" +--- +shared +util +web diff --git a/crates/turborepo/tests/snapshots/prune_test__prune_standard_out_inventory.snap b/crates/turborepo/tests/snapshots/prune_test__prune_standard_out_inventory.snap new file mode 100644 index 0000000000000..2961de4631106 --- /dev/null +++ b/crates/turborepo/tests/snapshots/prune_test__prune_standard_out_inventory.snap @@ -0,0 +1,19 @@ +--- +source: crates/turborepo/tests/prune_test.rs +expression: "inventory_tree(&tempdir.path().join(\"out\"))" +--- +.npmrc file 196da013f9ded0fa rw +apps dir +apps/web dir +apps/web/package.json file 2481da4712c9aca1 rw +package.json file c54a5bbdc116d12f rw +packages dir +packages/shared dir +packages/shared/package.json file 67befcf30aa75b2a rw +packages/util dir +packages/util/package.json file 4bebcc6f0ac3be5d rw +patches dir +patches/is-number@7.0.0.patch file d6022cf83b3da40d rw +pnpm-lock.yaml file 0f96ce8d41b06e10 rw +pnpm-workspace.yaml file 7262bc3c890b9e1b rw +turbo.json file 375cc1d0558b7fc7 rw diff --git a/crates/turborepo/tests/snapshots/prune_test__prune_standard_retained_packages.snap b/crates/turborepo/tests/snapshots/prune_test__prune_standard_retained_packages.snap new file mode 100644 index 0000000000000..f814e2f03c375 --- /dev/null +++ b/crates/turborepo/tests/snapshots/prune_test__prune_standard_retained_packages.snap @@ -0,0 +1,7 @@ +--- +source: crates/turborepo/tests/prune_test.rs +expression: "prune_retained_packages(&stdout).join(\"\\n\")" +--- +shared +util +web