diff --git a/.changeset/prune-extraneous-and-workspace-selection.md b/.changeset/prune-extraneous-and-workspace-selection.md new file mode 100644 index 0000000000..99cf2a1ec5 --- /dev/null +++ b/.changeset/prune-extraneous-and-workspace-selection.md @@ -0,0 +1,10 @@ +--- +"@pnpm/installing.commands": patch +"@pnpm/installing.context": patch +"@pnpm/installing.deps-installer": patch +"@pnpm/installing.deps-restorer": patch +"@pnpm/installing.linking.modules-cleaner": patch +"pnpm": patch +--- + +`pnpm prune` now removes extraneous direct packages from `node_modules` when `package.json` and `pnpm-lock.yaml` are already in sync. `pnpm prune` also accepts `--filter` and `--recursive` (`-r`) to prune selected workspace projects without removing dependencies of unselected projects. diff --git a/.changeset/prune-v12-extraneous-and-workspace-selection.md b/.changeset/prune-v12-extraneous-and-workspace-selection.md new file mode 100644 index 0000000000..e0f2d5c31f --- /dev/null +++ b/.changeset/prune-v12-extraneous-and-workspace-selection.md @@ -0,0 +1,5 @@ +--- +"pacquet": patch +--- + +`pnpm prune` now removes extraneous direct packages from `node_modules` when `package.json` and `pnpm-lock.yaml` are already in sync. `pnpm prune` also honors workspace project selection (`--filter`, `-r`) and `sharedWorkspaceLockfile: false` in workspaces. diff --git a/pnpm/crates/cli/src/cli_args/cli_command/commands.rs b/pnpm/crates/cli/src/cli_args/cli_command/commands.rs index 7df160dad5..e72ea6ac65 100644 --- a/pnpm/crates/cli/src/cli_args/cli_command/commands.rs +++ b/pnpm/crates/cli/src/cli_args/cli_command/commands.rs @@ -367,6 +367,7 @@ impl CliCommand { self, CliCommand::Install(_) | CliCommand::Dedupe(_) + | CliCommand::Prune(_) | CliCommand::InstallTest(_) | CliCommand::Import(_) | CliCommand::List(_) diff --git a/pnpm/crates/cli/src/cli_args/dispatch_install/maintenance.rs b/pnpm/crates/cli/src/cli_args/dispatch_install/maintenance.rs index d6022274a3..9e4dfc38e6 100644 --- a/pnpm/crates/cli/src/cli_args/dispatch_install/maintenance.rs +++ b/pnpm/crates/cli/src/cli_args/dispatch_install/maintenance.rs @@ -72,8 +72,15 @@ pub(in super::super) fn prune<'a>( Ok(Box::pin(async move { let config_root = derive_config_root(&mut *cfg, dir, reporter) .wrap_err("derive workspace root and package manager policy")?; - let pipeline = - PrunePipeline { args, cfg, config_root, manifest_path: manifest_path.to_path_buf() }; + let recursive_sort = cfg.sort; + let pipeline = PrunePipeline { + args, + cfg, + config_root, + prefix: dir.to_path_buf(), + manifest_path: manifest_path.to_path_buf(), + recursive_sort, + }; Box::pin(pipeline.run::()).await?; Ok(()) })) diff --git a/pnpm/crates/cli/src/cli_args/pipelines/maintenance.rs b/pnpm/crates/cli/src/cli_args/pipelines/maintenance.rs index 3e531b2501..36d4268745 100644 --- a/pnpm/crates/cli/src/cli_args/pipelines/maintenance.rs +++ b/pnpm/crates/cli/src/cli_args/pipelines/maintenance.rs @@ -1,7 +1,7 @@ use super::{ Config, Context, DedicatedProjectRuns, DedicatedProjects, DedupeArgs, InstallFamilyPlan, Path, - PathBuf, PruneArgs, Reporter, RuntimePolicy, State, dedupe, prepare_root_config, - resolve_bool_override, select_install_family_plan, + PathBuf, PruneArgs, Reporter, RuntimePolicy, State, anchor_active_project, dedupe, + prepare_root_config, resolve_bool_override, select_install_family_plan, }; /// The reporter-generic body of `pacquet dedupe`: snapshots the lockfile @@ -119,19 +119,14 @@ pub(crate) struct PrunePipeline { pub(crate) args: PruneArgs, pub(crate) cfg: &'static mut Config, pub(crate) config_root: PathBuf, + pub(crate) prefix: PathBuf, pub(crate) manifest_path: PathBuf, + pub(crate) recursive_sort: bool, } impl PrunePipeline { pub(crate) async fn run(self) -> miette::Result<()> { - let PrunePipeline { - args, - cfg, - config_root, - manifest_path, - } = self; - - let root_config = (&*manifest_path, &mut *cfg, &*config_root); + let root_config = (&*self.manifest_path, &mut *self.cfg, &*self.config_root); prepare_root_config::(root_config, (false, RuntimePolicy::Always)).await?; // Validate path containment AFTER hooks: updateConfig can mutate // modules_dir / install_state_dir via WorkspaceSettings::apply_to, @@ -144,25 +139,116 @@ impl PrunePipeline { // `config_root` is `cfg.workspace_dir` when present, or the // canonicalized `--dir` otherwise — a meaningful containment // boundary in both cases. - if !cfg.modules_dir.starts_with(&config_root) { - let modules_dir = cfg.modules_dir.display(); - let cr = config_root.display(); - return Err(miette::miette!( - "refusing prune: modules_dir ({modules_dir}) is outside workspace root ({cr})", - )); - } + ensure_modules_dir_within_config_root(&self.cfg.modules_dir, &self.config_root)?; // Apply prune-specific overrides after hooks so that: // - `modules_cache_max_age = 0` forces the virtual-store sweep // on the final (post-hook) config paths. // - `--ignore-scripts` from the CLI wins over any value the // hooks set via `WorkspaceSettings::apply_to`. - cfg.modules_cache_max_age = 0; - cfg.ignore_scripts = - resolve_bool_override(args.ignore_scripts, args.no_ignore_scripts, cfg.ignore_scripts); - let cfg: &'static Config = cfg; - let state = State::init(manifest_path, cfg, false).wrap_err("initialize the state")?; - Box::pin(args.run::(state)).await + self.cfg.modules_cache_max_age = 0; + self.cfg.ignore_scripts = resolve_bool_override( + self.args.ignore_scripts, + self.args.no_ignore_scripts, + self.cfg.ignore_scripts, + ); + let plan = select_install_family_plan::( + self.cfg, + &self.prefix, + &self.manifest_path, + self.recursive_sort, + false, + false, + )?; + self.run_plan::(plan).await } + + async fn run_plan( + self, + plan: InstallFamilyPlan, + ) -> miette::Result<()> { + match plan { + InstallFamilyPlan::PerProject(projects) => { + run_dedicated_prune::(self.args, self.cfg, *projects, self.config_root) + .await + } + InstallFamilyPlan::Shared(selection) => { + if selection.selected_dirs.is_empty() { + return Ok(()); + } + let state = State::init(self.manifest_path, self.cfg, false) + .wrap_err("initialize the state")?; + Box::pin(self.args.run::(state, Some(&selection))).await + } + InstallFamilyPlan::Single => { + if !self.cfg.shares_one_lockfile() && self.cfg.workspace_dir.is_some() { + anchor_active_project(self.cfg, &self.manifest_path); + ensure_modules_dir_within_config_root( + &self.cfg.modules_dir, + &self.config_root, + )?; + } + let state = State::init(self.manifest_path, self.cfg, false) + .wrap_err("initialize the state")?; + Box::pin(self.args.run::(state, None)).await + } + } + } +} + +async fn run_dedicated_prune( + args: PruneArgs, + cfg: &'static Config, + projects: DedicatedProjects, + config_root: PathBuf, +) -> miette::Result<()> { + for project_dir in projects.dependencies.keys() { + let mut project_config = cfg.clone(); + project_config.anchor_dedicated_project( + project_dir, + projects.names.get(project_dir).map(String::as_str), + ); + ensure_modules_dir_within_config_root(&project_config.modules_dir, &config_root)?; + } + DedicatedProjectRuns { + config: cfg, + projects, + require_lockfile: false, + http_client: Some(State::new_http_client(cfg)?), + prune_excludes: false, + sync_injected_deps: false, + pipelined: false, + } + .run(move |state| { + let args = args.clone(); + let config_root = config_root.clone(); + Box::pin(async move { + ensure_modules_dir_within_config_root(&state.config.modules_dir, &config_root)?; + args.run::(state, None).await + }) + }) + .await +} + +fn ensure_modules_dir_within_config_root( + modules_dir: &Path, + config_root: &Path, +) -> miette::Result<()> { + let normalized_root = pnpm_fs::lexical_normalize(config_root); + let resolved_root = pnpm_fs::realpath_missing(&normalized_root).unwrap_or(normalized_root); + let normalized_modules = pnpm_fs::lexical_normalize(modules_dir); + let within_root = pnpm_fs::realpath_missing(&normalized_modules) + .is_ok_and(|resolved_modules| resolved_modules.starts_with(&resolved_root)) + && pnpm_fs::realpath_missing(modules_dir) + .unwrap_or(normalized_modules) + .starts_with(&resolved_root); + if !within_root { + let modules_dir = modules_dir.display(); + let cr = config_root.display(); + return Err(miette::miette!( + "refusing prune: modules_dir ({modules_dir}) is outside workspace root ({cr})", + )); + } + Ok(()) } async fn run_dedicated_dedupe( diff --git a/pnpm/crates/cli/src/cli_args/prune.rs b/pnpm/crates/cli/src/cli_args/prune.rs index da36a4373b..e3527f8faa 100644 --- a/pnpm/crates/cli/src/cli_args/prune.rs +++ b/pnpm/crates/cli/src/cli_args/prune.rs @@ -1,11 +1,16 @@ -use crate::{State, cli_args::install::resolve_bool_override}; +use crate::{ + State, + cli_args::{ + install::{resolve_bool_override, workspace_install_selection}, + pipelines::InstallFamilySelection, + }, +}; use clap::Args; use miette::Context; -use pnpm_package_manager::Install; use pnpm_package_manifest::DependencyGroup; use pnpm_reporter::Reporter; -#[derive(Debug, Args)] +#[derive(Debug, Clone, Args)] pub struct PruneArgs { #[clap(short = 'P', long, visible_alias = "production")] prod: bool, @@ -42,38 +47,29 @@ impl PruneArgs { .chain(has_optional.then_some(DependencyGroup::Optional)) } - pub async fn run(self, state: State) -> miette::Result<()> { + pub(crate) async fn run( + self, + state: State, + selection: Option<&InstallFamilySelection>, + ) -> miette::Result<()> { let lockfile_path = state.lockfile_path(); - let State { - tarball_mem_cache, - http_client, - config, - manifest, - lockfile, - resolved_packages, - dedicated: _, - } = &state; - let dependency_groups: Vec = - self.dependency_groups(config.optional).collect(); + self.dependency_groups(state.config.optional).collect(); + let selection = selection.map(|sel| { + let mut ws_selection = workspace_install_selection(sel); + ws_selection.install_dirs = sel.selected_dirs.as_ref(); + ws_selection + }); { - let mut base_install = Install::new( - std::sync::Arc::clone(tarball_mem_cache), - resolved_packages, - (http_client, std::sync::Arc::clone(http_client)), - config, - manifest, - pnpm_lockfile::MaybeLazyLockfile::Lazy(lockfile), - dependency_groups, - ); - base_install.lockfile_policy.trust = config.trust_lockfile; + let mut base_install = state.install(dependency_groups); + base_install.lockfile_policy.trust = state.config.trust_lockfile; base_install.lockfile_policy.disable_optimistic_repeat = true; base_install.execution.skip_runtimes = false; base_install.context.lockfile_path = Some(&lockfile_path); base_install } - .run::() + .run_prune::(selection) .await .wrap_err("pruning dependencies") } diff --git a/pnpm/crates/cli/src/cli_args/tests/global_options.rs b/pnpm/crates/cli/src/cli_args/tests/global_options.rs index a2e740cf3b..bdd1e792be 100644 --- a/pnpm/crates/cli/src/cli_args/tests/global_options.rs +++ b/pnpm/crates/cli/src/cli_args/tests/global_options.rs @@ -452,7 +452,7 @@ fn recursive_by_default_command_is_promoted_inside_workspace() { let workspace = tempfile::tempdir().expect("creates workspace"); std::fs::write(workspace.path().join("pnpm-workspace.yaml"), "packages: []\n") .expect("writes workspace manifest"); - for command in ["install", "dedupe", "import", "list", "why", "peers"] { + for command in ["install", "dedupe", "prune", "import", "list", "why", "peers"] { let mut parsed = CliArgs::try_parse_from([ "pacquet", "--dir", @@ -520,7 +520,8 @@ fn color_accepts_modes_and_boolean_spellings() { #[test] fn recursive_by_default_command_stays_non_recursive_outside_workspace() { let project = tempfile::tempdir().expect("creates project"); - for command in ["dedupe", "list", "why", "peers"] { + std::fs::write(project.path().join("package.json"), "{}\n").expect("writes package.json"); + for command in ["dedupe", "prune", "list", "why", "peers"] { let mut parsed = CliArgs::try_parse_from([ "pacquet", "--dir", diff --git a/pnpm/crates/cli/tests/suite/prune.rs b/pnpm/crates/cli/tests/suite/prune.rs index 0e9b4aecab..4168356ef8 100644 --- a/pnpm/crates/cli/tests/suite/prune.rs +++ b/pnpm/crates/cli/tests/suite/prune.rs @@ -309,3 +309,298 @@ fn prune_offline_honors_trust_lockfile() { drop((root, mock_instance)); } + +#[test] +fn prune_removes_extraneous_direct_packages_when_lockfile_is_up_to_date() { + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry(); + let AddMockedRegistry { mock_instance, .. } = npmrc_info; + + fs::write( + workspace.join("package.json"), + serde_json::json!({ + "dependencies": { "is-positive": "1.0.0" }, + }) + .to_string(), + ) + .expect("write package.json"); + + pacquet_in(&workspace) + .with_arg("install") + .assert() + .success(); + + let extraneous_dir = workspace.join("node_modules").join("extraneous-pkg"); + fs::create_dir_all(&extraneous_dir).expect("create extraneous package dir"); + fs::write( + extraneous_dir.join("package.json"), + r#"{"name":"extraneous-pkg","version":"1.0.0"}"#, + ) + .expect("write extraneous package.json"); + + let linked_target = workspace.join("linked-extraneous"); + fs::create_dir_all(&linked_target).expect("create linked-extraneous target"); + fs::write(linked_target.join("cli.js"), "#!/usr/bin/env node\n") + .expect("write linked-extraneous bin"); + fs::write( + linked_target.join("package.json"), + r#"{"name":"linked-extraneous","version":"1.0.0","bin":{"linked-extraneous":"cli.js"}}"#, + ) + .expect("write linked-extraneous package.json"); + let extraneous_link = workspace.join("node_modules").join("linked-extraneous"); + pnpm_fs::symlink_dir(&linked_target, &extraneous_link).expect("symlink linked-extraneous"); + let bin_dir = workspace.join("node_modules").join(".bin"); + fs::create_dir_all(&bin_dir).expect("create .bin dir"); + let extraneous_bin = + bin_dir.join(if cfg!(windows) { "linked-extraneous.cmd" } else { "linked-extraneous" }); + fs::write(&extraneous_bin, "shim").expect("write extraneous bin shim"); + + let extraneous_scoped_dir = workspace + .join("node_modules") + .join("@extraneous") + .join("scoped-pkg"); + fs::create_dir_all(&extraneous_scoped_dir).expect("create extraneous scoped package dir"); + fs::write( + extraneous_scoped_dir.join("package.json"), + r#"{"name":"@extraneous/scoped-pkg","version":"1.0.0"}"#, + ) + .expect("write extraneous scoped package.json"); + + let non_package_dir = workspace.join("node_modules").join("_tmp"); + fs::create_dir_all(&non_package_dir).expect("create _tmp dir"); + + pacquet_in(&workspace) + .with_arg("prune") + .assert() + .success(); + + assert!(has_link(&workspace, "is-positive")); + assert!(!extraneous_dir.exists(), "extraneous direct package must be removed"); + assert!( + fs::symlink_metadata(&extraneous_link).is_err(), + "extraneous direct package symlink must be removed", + ); + assert!(!extraneous_bin.exists(), "extraneous direct package bin shim must be removed"); + assert!(linked_target.exists(), "symlink target must not be deleted"); + assert!( + !workspace + .join("node_modules") + .join("@extraneous") + .exists(), + "emptied extraneous scope dir must be removed", + ); + assert!(non_package_dir.exists(), "non-package _tmp dir must not abort prune"); + + // Also verify pruning extraneous direct packages when the project has zero dependencies. + fs::write(workspace.join("package.json"), r#"{"name":"empty-project","version":"1.0.0"}"#) + .expect("write empty package.json"); + pacquet_in(&workspace) + .with_arg("install") + .assert() + .success(); + + fs::create_dir_all(&extraneous_dir).expect("recreate extraneous package dir"); + fs::write( + extraneous_dir.join("package.json"), + r#"{"name":"extraneous-pkg","version":"1.0.0"}"#, + ) + .expect("write extraneous package.json"); + + pacquet_in(&workspace) + .with_arg("prune") + .assert() + .success(); + + assert!( + !extraneous_dir.exists(), + "extraneous direct package must be removed even when project has zero dependencies", + ); + + // An in-workspace `node_modules -> src` symlink must never redirect prune into `src`. + let src_subdir = workspace.join("src").join("components"); + fs::create_dir_all(&src_subdir).expect("create src/components"); + let modules_dir = workspace.join("node_modules"); + if modules_dir.exists() { + fs::remove_dir_all(&modules_dir).expect("remove node_modules"); + } + pnpm_fs::symlink_dir(&workspace.join("src"), &modules_dir) + .expect("symlink node_modules -> src"); + let _ = pacquet_in(&workspace).with_arg("prune").output(); + assert!( + src_subdir.exists(), + "in-workspace node_modules symlink must not delete src/components", + ); + + drop((root, mock_instance)); +} + +#[test] +fn prune_respects_workspace_filter() { + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry(); + let AddMockedRegistry { mock_instance, .. } = npmrc_info; + + fs::write( + workspace.join("package.json"), + serde_json::json!({ + "name": "workspace-root", + "private": true, + "devDependencies": { FILTERED: "1.0.0" }, + }) + .to_string(), + ) + .expect("write root package.json"); + fs::write(workspace.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n") + .expect("write pnpm-workspace.yaml"); + + let pkg_a = workspace.join("packages/pkg-a"); + let pkg_b = workspace.join("packages/pkg-b"); + fs::create_dir_all(&pkg_a).expect("create pkg-a"); + fs::create_dir_all(&pkg_b).expect("create pkg-b"); + + fs::write( + pkg_a.join("package.json"), + serde_json::json!({ + "name": "pkg-a", + "version": "1.0.0", + "dependencies": { "is-positive": "1.0.0", "pkg-b": "workspace:*" }, + "devDependencies": { "is-negative": "1.0.0" }, + }) + .to_string(), + ) + .expect("write pkg-a package.json"); + fs::write( + pkg_b.join("package.json"), + serde_json::json!({ + "name": "pkg-b", + "version": "1.0.0", + "dependencies": { "is-positive": "1.0.0" }, + "devDependencies": { "is-negative": "1.0.0" }, + }) + .to_string(), + ) + .expect("write pkg-b package.json"); + + pacquet_in(&workspace) + .with_arg("install") + .assert() + .success(); + + let extraneous_a = pkg_a.join("node_modules").join("extraneous-a"); + let extraneous_b = pkg_b.join("node_modules").join("extraneous-b"); + fs::create_dir_all(&extraneous_a).expect("create extraneous-a"); + fs::create_dir_all(&extraneous_b).expect("create extraneous-b"); + + assert!(has_link(&workspace, FILTERED)); + assert!(has_link(&pkg_a, "is-positive")); + assert!(has_link(&pkg_a, "is-negative")); + assert!(has_link(&pkg_b, "is-positive")); + assert!(has_link(&pkg_b, "is-negative")); + + pacquet_in(&workspace) + .with_args(["--filter", "pkg-a", "prune", "--prod"]) + .assert() + .success(); + + assert!(has_link(&pkg_a, "is-positive")); + assert!(!has_link(&pkg_a, "is-negative"), "selected package devDependencies must be pruned"); + assert!(!extraneous_a.exists(), "selected package extraneous dep must be pruned"); + assert!(has_link(&pkg_b, "is-positive")); + assert!( + has_link(&pkg_b, "is-negative"), + "unselected sibling package devDependencies must remain linked", + ); + assert!(extraneous_b.exists(), "unselected sibling extraneous dep must remain untouched"); + assert!( + has_link(&workspace, FILTERED), + "unselected workspace root devDependencies must remain linked", + ); + + drop((root, mock_instance)); +} + +#[test] +fn prune_supports_dedicated_workspace_lockfiles() { + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry(); + let AddMockedRegistry { mock_instance, .. } = npmrc_info; + + fs::write(workspace.join("package.json"), r#"{"name":"workspace-root","private":true}"#) + .expect("write root package.json"); + fs::write( + workspace.join("pnpm-workspace.yaml"), + "packages:\n - packages/*\nsharedWorkspaceLockfile: false\n", + ) + .expect("write pnpm-workspace.yaml"); + + let pkg_a = workspace.join("packages/pkg-a"); + let pkg_b = workspace.join("packages/pkg-b"); + fs::create_dir_all(&pkg_a).expect("create pkg-a"); + fs::create_dir_all(&pkg_b).expect("create pkg-b"); + + for (name, dir) in [("pkg-a", &pkg_a), ("pkg-b", &pkg_b)] { + fs::write( + dir.join("package.json"), + serde_json::json!({ + "name": name, + "version": "1.0.0", + "dependencies": { "is-positive": "1.0.0" }, + "devDependencies": { "is-negative": "1.0.0" }, + }) + .to_string(), + ) + .expect("write member package.json"); + } + + pacquet_in(&workspace) + .with_arg("install") + .assert() + .success(); + + assert!(has_link(&pkg_a, "is-negative")); + assert!(has_link(&pkg_b, "is-negative")); + + // Filtered prune with sharedWorkspaceLockfile: false prunes only pkg-a. + pacquet_in(&workspace) + .with_args(["--filter", "pkg-a", "prune", "--prod"]) + .assert() + .success(); + + assert!(has_link(&pkg_a, "is-positive")); + assert!(!has_link(&pkg_a, "is-negative")); + assert!(has_link(&pkg_b, "is-positive")); + assert!(has_link(&pkg_b, "is-negative")); + + // Unfiltered prune at the workspace root prunes all workspace projects. + pacquet_in(&workspace) + .with_args(["prune", "--prod"]) + .assert() + .success(); + + assert!(has_link(&pkg_b, "is-positive")); + assert!(!has_link(&pkg_b, "is-negative")); + + // Per-project `packageConfigs.modulesDir` traversing outside the workspace via `..` must be refused. + let external_modules = root.path().join("external-modules"); + let external_sentinel = external_modules.join(".pnpm").join("sentinel@1.0.0"); + fs::create_dir_all(&external_sentinel).expect("create external sentinel"); + fs::write( + workspace.join("pnpm-workspace.yaml"), + "packages:\n - packages/*\nsharedWorkspaceLockfile: false\npackageConfigs:\n pkg-a:\n modulesDir: ../../../external-modules\n", + ) + .expect("write pnpm-workspace.yaml with relative external modulesDir"); + + let output = pacquet_in(&workspace) + .with_arg("prune") + .output() + .expect("run recursive prune with external modulesDir"); + assert!(!output.status.success(), "prune must reject external packageConfigs.modulesDir"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("refusing prune: modules_dir"), + "expected containment error in stderr, got: {stderr}", + ); + assert!(external_sentinel.exists(), "external sentinel must remain untouched"); + + drop((root, mock_instance)); +} diff --git a/pnpm/crates/deps-restorer/src/frozen_install_options.rs b/pnpm/crates/deps-restorer/src/frozen_install_options.rs index 9c2789ecfa..f238263a08 100644 --- a/pnpm/crates/deps-restorer/src/frozen_install_options.rs +++ b/pnpm/crates/deps-restorer/src/frozen_install_options.rs @@ -215,7 +215,7 @@ pub struct PriorMaterialization<'a> { /// See [`crate::PriorHoistedState::unbuilt_builds`]. pub unbuilt_builds: &'a crate::UnbuiltBuilds, /// See [`crate::PruneStaleModules::prune_orphans`]. - pub prune_orphans: bool, + pub prune: crate::PruneTreeOptions<'a>, /// Relink the bins of every slot the lockfile records, not only of the /// slots this install materializes. A tree that moved with its project /// needs it, because its other slots may hold bins naming where it was. @@ -247,7 +247,7 @@ pub struct FrozenInstallSeed<'a> { impl<'a> PriorMaterialization<'a> { pub(crate) fn link_state(self) -> crate::PriorLinkState<'a> { crate::PriorLinkState { - prune_orphans: self.prune_orphans, + prune: self.prune, hoisted_dependencies: self.hoisted_dependencies, hoisted_locations: self.hoisted_locations, build_present_packages: self.rebuild.is_some() || self.allow_builds_changed, diff --git a/pnpm/crates/deps-restorer/src/lib.rs b/pnpm/crates/deps-restorer/src/lib.rs index 5ac0af1eaf..53ea084b5c 100644 --- a/pnpm/crates/deps-restorer/src/lib.rs +++ b/pnpm/crates/deps-restorer/src/lib.rs @@ -85,7 +85,7 @@ pub use package_map::*; pub use phase_options::{ BuildPhaseCache, BuildPhaseDirectories, BuildPhaseGraph, BuildPhasePolicy, HoistedLinkGraph, HoistedProjects, LinkLockfiles, LinkPackageData, LinkProjects, PriorHoistedState, - PriorLinkState, + PriorLinkState, PruneTreeOptions, }; pub use pnp::*; pub use pnpm_workspace_task_scheduler::{GraphSequencerResult, PathNode, graph_sequencer}; @@ -102,6 +102,7 @@ mod custom_fetcher; mod gvs_slot_lock; mod installed_hoisted_state; mod link_pool; +mod prune_extraneous_deps; mod shared_side_effects; pub const NEEDS_BUILD_MARKER: &str = ".pnpm-needs-build"; diff --git a/pnpm/crates/deps-restorer/src/linking.rs b/pnpm/crates/deps-restorer/src/linking.rs index 64b037208a..ad9d996499 100644 --- a/pnpm/crates/deps-restorer/src/linking.rs +++ b/pnpm/crates/deps-restorer/src/linking.rs @@ -254,7 +254,7 @@ fn relink_importer_tree( hoist: &PlannedHoist, ) -> Result<(), LinkPhaseError> { let config = inputs.ctx.config; - prune_importer_tree::(inputs, hoist.plan.as_ref())?; + prune_importer_tree::(inputs, hoist)?; let scheduled_builds = inputs.scheduled_builds(); let phase_start = std::time::Instant::now(); @@ -301,22 +301,37 @@ fn relink_importer_tree( fn prune_importer_tree( inputs: &LinkPhaseInputs<'_>, - hoist_plan: Option<&HoistPlan>, + hoist: &PlannedHoist, ) -> Result<(), LinkPhaseError> { let config = inputs.ctx.config; let current_lockfile = inputs.graph.current_lockfile.unwrap_or(inputs.graph.lockfile); + let wanted_hoisted = hoist.plan.as_ref().map(|plan| &plan.result.hoisted_dependencies); let removed_count = crate::PruneStaleModules { config, workspace_root: inputs.projects.symlink_root, wanted_lockfile: inputs.graph.lockfile, current_lockfile, prior_hoisted_dependencies: inputs.prior.hoisted_dependencies, - wanted_hoisted_dependencies: hoist_plan.map(|plan| &plan.result.hoisted_dependencies), + wanted_hoisted_dependencies: wanted_hoisted, included_groups: inputs.projects.dependency_groups, - prune_orphans: inputs.prior.prune_orphans, + prune_orphans: inputs.prior.prune.orphans, } .run::() .map_err(LinkPhaseError::PruneStaleModules)?; + if inputs.prior.prune.direct_dependencies { + crate::prune_extraneous_deps::PruneExtraneousDirectDeps { + config, + workspace_root: inputs.projects.symlink_root, + wanted_lockfile: inputs.graph.lockfile, + wanted_hoisted_dependencies: wanted_hoisted, + public_hoist_targets: hoist.public_targets.as_ref(), + manifests: inputs.projects.manifests, + included_groups: inputs.projects.dependency_groups, + selected_importer_ids: inputs.prior.prune.importer_ids, + } + .run::() + .map_err(LinkPhaseError::PruneStaleModules)?; + } Reporter::emit(&LogEvent::Stats(StatsLog { level: LogLevel::Debug, message: StatsMessage::Removed { diff --git a/pnpm/crates/deps-restorer/src/phase_options.rs b/pnpm/crates/deps-restorer/src/phase_options.rs index dbdd0247de..e7750910b2 100644 --- a/pnpm/crates/deps-restorer/src/phase_options.rs +++ b/pnpm/crates/deps-restorer/src/phase_options.rs @@ -52,9 +52,16 @@ pub struct LinkPackageData<'a> { pub cas_paths_by_pkg_id: Option, } +#[derive(Debug, Default, Clone, Copy)] +pub struct PruneTreeOptions<'a> { + pub orphans: bool, + pub direct_dependencies: bool, + pub importer_ids: Option<&'a std::collections::HashSet>, +} + #[derive(Clone, Copy)] pub struct PriorLinkState<'a> { - pub prune_orphans: bool, + pub prune: PruneTreeOptions<'a>, pub hoisted_dependencies: Option<&'a crate::HoistedDependencies>, /// `hoistedLocations` recorded by the previous install's /// `.modules.yaml`; lets the hoisted linker leave packages that are diff --git a/pnpm/crates/deps-restorer/src/prune_direct_deps.rs b/pnpm/crates/deps-restorer/src/prune_direct_deps.rs index 8b72329748..5005de058c 100644 --- a/pnpm/crates/deps-restorer/src/prune_direct_deps.rs +++ b/pnpm/crates/deps-restorer/src/prune_direct_deps.rs @@ -16,7 +16,7 @@ use derive_more::{Display, Error}; use miette::Diagnostic; use pnpm_cmd_shim::{Host, get_bins_from_package_manifest, remove_bin}; use pnpm_config::Config; -use pnpm_fs::{read_symlink_dir, remove_symlink_dir}; +use pnpm_fs::{read_symlink_dir, remove_dirent, remove_symlink_dir}; use pnpm_lockfile::Lockfile; use pnpm_modules_yaml::IncludedDependencies; use pnpm_package_manifest::{DependencyGroup, parse_manifest_bytes}; @@ -57,6 +57,14 @@ pub enum PruneDirectDepsError { error: io::Error, }, + #[display("Failed to read {path:?} while pruning direct dependencies: {error}")] + #[diagnostic(code(ERR_PNPM_PACKAGE_MANAGER_PRUNE_DIRECT_DEPS_READ_MODULES_DIR))] + ReadModulesDir { + path: PathBuf, + #[error(source)] + error: io::Error, + }, + #[display("Failed to remove the excluded direct dependency at {path:?}: {error}")] #[diagnostic(code(ERR_PNPM_PACKAGE_MANAGER_PRUNE_DIRECT_DEPS_REMOVE_LINK))] RemoveLink { @@ -238,6 +246,35 @@ pub fn remove_direct_dep_link(modules_dir: &Path, name: &str) -> Result<(), Prun } } +/// Remove an extraneous entry (symlink, directory, or file) at +/// `/` during `pnpm prune`, returning its manifest +/// version (if readable) when an entry was removed. +pub(crate) fn remove_extraneous_direct_dep( + modules_dir: &Path, + name: &str, +) -> Result>, PruneDirectDepsError> { + let Ok(pkg_dir) = safe_join_modules_dir(modules_dir, name) else { + return Ok(None); + }; + if let Some(parent) = pkg_dir.parent() + && parent != modules_dir + && !is_real_dir(parent) + { + return Ok(None); + } + match fs::symlink_metadata(&pkg_dir) { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(PruneDirectDepsError::RemoveLink { path: pkg_dir, error }), + } + let version = remove_dep_bins_with_version(modules_dir, &pkg_dir)?; + match remove_dirent(&pkg_dir) { + Ok(()) => Ok(Some(version)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(PruneDirectDepsError::RemoveLink { path: pkg_dir, error }), + } +} + /// Remove the shims the package at `pkg_dir` declares from /// `/.bin`. A missing or unparsable `package.json` (a /// dangling link, a broken package) yields no bins to remove — the same @@ -246,31 +283,46 @@ pub(crate) fn remove_dep_bins( modules_dir: &Path, pkg_dir: &Path, ) -> Result<(), PruneDirectDepsError> { + remove_dep_bins_with_version(modules_dir, pkg_dir).map(|_| ()) +} + +fn remove_dep_bins_with_version( + modules_dir: &Path, + pkg_dir: &Path, +) -> Result, PruneDirectDepsError> { let manifest_path = pkg_dir.join("package.json"); let bytes = match fs::read(&manifest_path) { Ok(bytes) => bytes, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) + if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) => + { + return Ok(None); + } Err(error) => { return Err(PruneDirectDepsError::ReadManifest { path: manifest_path, error }); } }; let Ok(manifest) = parse_manifest_bytes(&bytes) else { - return Ok(()); + return Ok(None); }; + let version = manifest + .get("version") + .and_then(|v| v.as_str()) + .map(str::to_owned); // Shims are deleted with plain `remove_file`, so a `.bin` that is // itself a symlink or junction would redirect the deletions // outside the confined modules dir — skip shim removal unless // `.bin` is a real directory. (Absent `.bin` means no shims.) let bins_dir = modules_dir.join(".bin"); if !is_real_dir(&bins_dir) { - return Ok(()); + return Ok(version); } for command in get_bins_from_package_manifest::(&manifest, pkg_dir) { let shim_path = bins_dir.join(&command.name); remove_bin(&shim_path) .map_err(|error| PruneDirectDepsError::RemoveBin { path: shim_path, error })?; } - Ok(()) + Ok(version) } #[cfg(test)] diff --git a/pnpm/crates/deps-restorer/src/prune_extraneous_deps.rs b/pnpm/crates/deps-restorer/src/prune_extraneous_deps.rs new file mode 100644 index 0000000000..f375432953 --- /dev/null +++ b/pnpm/crates/deps-restorer/src/prune_extraneous_deps.rs @@ -0,0 +1,202 @@ +//! Remove extraneous entries (symlinks, directories, or files) from each +//! selected importer's `node_modules` directory during `pnpm prune`. + +use crate::{ + hoist::HoistedDependencies, + prune_direct_deps::{ + PruneDirectDepsError, confined_modules_dir, is_real_dir, remove_extraneous_direct_dep, + }, + prune_stale_modules::{direct_deps_of, remove_emptied_scope_dir}, + symlink_direct_dependencies::{importer_root_dir, validate_importer_id}, +}; +use pnpm_config::Config; +use pnpm_lockfile::{ImporterDepVersion, Lockfile, PkgName, ProjectSnapshot}; +use pnpm_modules_yaml::HoistKind; +use pnpm_package_manifest::{DependencyGroup, PackageManifest}; +use pnpm_reporter::{LogEvent, LogLevel, RemovedRoot, Reporter, RootLog, RootMessage}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + ffi::OsStr, + path::{Path, PathBuf}, +}; + +pub(crate) struct PruneExtraneousDirectDeps<'a> { + pub config: &'a Config, + pub workspace_root: &'a Path, + pub wanted_lockfile: &'a Lockfile, + pub wanted_hoisted_dependencies: Option<&'a HoistedDependencies>, + pub public_hoist_targets: Option<&'a BTreeMap>, + pub manifests: &'a [(PathBuf, &'a PackageManifest)], + pub included_groups: &'a [DependencyGroup], + pub selected_importer_ids: Option<&'a HashSet>, +} + +impl<'a> PruneExtraneousDirectDeps<'a> { + pub(crate) fn run(self) -> Result<(), PruneDirectDepsError> { + let wanted_root_deps = self.wanted_root_deps(); + let manifests_by_dir = self.manifests_by_dir(); + let mut importers: Vec<_> = self.wanted_lockfile.importers.iter().collect(); + importers.sort_by_key(|(id, _)| *id); + for (importer_id, wanted_snapshot) in importers { + if self.selected_importer_ids.is_some_and(|selected| !selected.contains(importer_id)) { + continue; + } + self.prune_importer::( + importer_id, + wanted_snapshot, + wanted_root_deps.as_ref(), + manifests_by_dir.as_ref(), + )?; + } + Ok(()) + } + + fn prune_importer( + &self, + importer_id: &str, + wanted_snapshot: &ProjectSnapshot, + wanted_root_deps: Option<&HashMap<&'a PkgName, &'a ImporterDepVersion>>, + manifests_by_dir: Option<&HashMap>, + ) -> Result<(), PruneDirectDepsError> { + if validate_importer_id(importer_id).is_err() { + return Ok(()); + } + let modules_dir_name: &OsStr = self.config.modules_dir_name(); + let importer_dir = importer_root_dir(self.workspace_root, importer_id); + let raw_modules_dir = importer_dir.join(modules_dir_name); + if !is_real_dir(&importer_dir) || !is_real_dir(&raw_modules_dir) { + return Ok(()); + } + let Some(modules_dir) = confined_modules_dir(&raw_modules_dir, self.workspace_root) else { + return Ok(()); + }; + let mut pkg_names = pnpm_fs::read_modules_dir(&modules_dir) + .map_err(|error| PruneDirectDepsError::ReadModulesDir { + path: modules_dir.clone(), + error, + })?; + if pkg_names.is_empty() { + return Ok(()); + } + pkg_names.sort(); + let dedupe_against_root = (importer_id != ".").then_some(wanted_root_deps).flatten(); + let wanted = self.wanted_direct_names( + importer_id, + &importer_dir, + wanted_snapshot, + dedupe_against_root, + manifests_by_dir, + ); + remove_unwanted_entries::(&modules_dir, &importer_dir, pkg_names, &wanted) + } + + fn wanted_direct_names( + &self, + importer_id: &str, + importer_dir: &Path, + wanted_snapshot: &ProjectSnapshot, + dedupe_against_root: Option<&HashMap<&'a PkgName, &'a ImporterDepVersion>>, + manifests_by_dir: Option<&HashMap>, + ) -> HashSet { + let mut wanted: HashSet = direct_deps_of(wanted_snapshot, self.included_groups) + .into_iter() + .filter(|(alias, spec, _)| { + !dedupe_against_root.is_some_and(|root_deps| { + root_deps + .get(alias) + .is_some_and(|version| **version == spec.version) + }) + }) + .map(|(alias, _, _)| alias.to_string()) + .collect(); + self.add_excluded_manifest_links(importer_dir, manifests_by_dir, &mut wanted); + if importer_id == "." { + self.add_root_public_hoists(&mut wanted); + } + wanted + } + + fn add_excluded_manifest_links( + &self, + importer_dir: &Path, + manifests_by_dir: Option<&HashMap>, + wanted: &mut HashSet, + ) { + let Some(manifests_by_dir) = manifests_by_dir else { + return; + }; + let norm_importer = pnpm_fs::lexical_normalize(importer_dir); + let Some(manifest) = manifests_by_dir.get(&norm_importer) else { + return; + }; + let deps = manifest.dependencies(self.included_groups.iter().copied()); + wanted.extend( + deps.into_iter() + .filter(|(_, spec)| spec.starts_with("link:") || spec.starts_with("workspace:")) + .map(|(alias, _)| alias.to_owned()), + ); + } + + fn add_root_public_hoists(&self, wanted: &mut HashSet) { + if let Some(targets) = self.public_hoist_targets { + wanted.extend(targets.keys().cloned()); + } + let Some(hoisted) = self.wanted_hoisted_dependencies else { + return; + }; + wanted.extend( + hoisted + .values() + .flatten() + .filter(|(_, kind)| **kind == HoistKind::Public) + .map(|(alias, _)| alias.clone()), + ); + } + + fn manifests_by_dir(&self) -> Option> { + self.config.exclude_links_from_lockfile.then(|| { + self.manifests + .iter() + .map(|(dir, manifest)| (pnpm_fs::lexical_normalize(dir), *manifest)) + .collect() + }) + } + + fn wanted_root_deps(&self) -> Option> { + let root_snapshot = self.config.dedupe_direct_deps.then(|| { + self.wanted_lockfile.importers.get(".") + })??; + Some( + direct_deps_of(root_snapshot, self.included_groups) + .into_iter() + .map(|(alias, spec, _)| (alias, &spec.version)) + .collect(), + ) + } +} + +fn remove_unwanted_entries( + modules_dir: &Path, + importer_dir: &Path, + pkg_names: Vec, + wanted: &HashSet, +) -> Result<(), PruneDirectDepsError> { + let prefix = importer_dir.display().to_string(); + for pkg_name in pkg_names { + if wanted.contains(&pkg_name) { + continue; + } + let removed = remove_extraneous_direct_dep(modules_dir, &pkg_name)?; + remove_emptied_scope_dir(modules_dir, &pkg_name); + if let Some(version) = removed { + Reporter::emit(&LogEvent::Root(RootLog { + level: LogLevel::Debug, + message: RootMessage::Removed { + prefix: prefix.clone(), + removed: RemovedRoot { name: pkg_name, version, dependency_type: None }, + }, + })); + } + } + Ok(()) +} diff --git a/pnpm/crates/deps-restorer/src/prune_stale_modules.rs b/pnpm/crates/deps-restorer/src/prune_stale_modules.rs index ba8402e8bb..a838d97b80 100644 --- a/pnpm/crates/deps-restorer/src/prune_stale_modules.rs +++ b/pnpm/crates/deps-restorer/src/prune_stale_modules.rs @@ -222,7 +222,7 @@ fn prune_workspace_project_hoists( /// Remove the `@scope` directory of a scoped `alias` once its last link is /// gone. The removal fails, and keeps the directory, while other scoped /// packages are in it. -fn remove_emptied_scope_dir(target_dir: &Path, alias: &str) { +pub(crate) fn remove_emptied_scope_dir(target_dir: &Path, alias: &str) { let Some((scope, _)) = alias.split_once('/') else { return }; let scope_dir = target_dir.join(scope); if is_real_dir(&scope_dir) { @@ -249,13 +249,15 @@ fn unlink_stale_direct_deps( if still_wanted && !deduped_by_root { continue; } - remove_direct_dep_link(modules_dir, &alias.to_string())?; + let alias_name = alias.to_string(); + remove_direct_dep_link(modules_dir, &alias_name)?; + remove_emptied_scope_dir(modules_dir, &alias_name); Reporter::emit(&LogEvent::Root(RootLog { level: LogLevel::Debug, message: RootMessage::Removed { prefix: prefix.to_owned(), removed: RemovedRoot { - name: alias.to_string(), + name: alias_name, version: removed_version(current_spec), dependency_type: Some(dependency_type(group)), }, @@ -372,7 +374,7 @@ fn unlink_hoisted_aliases( /// The `(alias, spec, group)` view of an importer snapshot, in the /// prod → dev → optional order pnpm merges the maps in. -fn direct_deps_of<'a>( +pub(crate) fn direct_deps_of<'a>( snapshot: &'a ProjectSnapshot, groups: &[DependencyGroup], ) -> Vec<(&'a PkgName, &'a ResolvedDependencySpec, DependencyGroup)> { diff --git a/pnpm/crates/package-manager/src/install.rs b/pnpm/crates/package-manager/src/install.rs index f6e81cc52e..1d4b1c4a34 100644 --- a/pnpm/crates/package-manager/src/install.rs +++ b/pnpm/crates/package-manager/src/install.rs @@ -541,6 +541,10 @@ struct InstallSaveOptions { #[derive(Default)] struct InstallManifestOptions<'install> { deploy_hook: bool, + /// pnpm's `pruneDirectDependencies`: scan each importer's `node_modules` + /// and remove extraneous direct entries not recorded in the wanted + /// lockfile or public hoist set. Only `pacquet prune` sets it. + prune_direct_dependencies: bool, /// Project manifests used only as the source for lockfile importer /// specifiers. `pacquet update --no-save` resolves against an in-memory /// manifest rewrite but must serialize importer specifiers from the @@ -570,6 +574,7 @@ impl Default for InstallRunOptions<'_, '_> { prompt_eligibility_override: None, manifests: crate::install::InstallManifestOptions { deploy_hook: false, + prune_direct_dependencies: false, specifier_manifests: None, hooked_paths: HashSet::new(), spec_bumps: None, diff --git a/pnpm/crates/package-manager/src/install/apply_materialization.rs b/pnpm/crates/package-manager/src/install/apply_materialization.rs index f6e72bda0b..182f6d7ea0 100644 --- a/pnpm/crates/package-manager/src/install/apply_materialization.rs +++ b/pnpm/crates/package-manager/src/install/apply_materialization.rs @@ -168,14 +168,20 @@ fn commit_apply_state( filtered_install: inputs.projects.filtered_install, }, write: inputs.write, - force_prune: matches!(inputs.scripts.mutation, crate::ProjectMutation::UninstallSome), + force_prune: inputs.force_prune(), })?; tracing::info!(target: "pacquet::install::phase", phase = "apply.commit_modules_state", elapsed_ms = phase_start.elapsed().as_millis() as u64, "phase complete"); crate::agent_skills::report_pending_skills::(pending_skills); - run_apply_scripts::(inputs, state) } +impl ApplyMaterializationInputs<'_, '_> { + const fn force_prune(&self) -> bool { + matches!(self.scripts.mutation, crate::ProjectMutation::UninstallSome) + || self.prior.prune_direct_dependencies + } +} + fn run_apply_scripts( inputs: &ApplyMaterializationInputs<'_, '_>, state: &MaterializedState<'_>, diff --git a/pnpm/crates/package-manager/src/install/entry_points.rs b/pnpm/crates/package-manager/src/install/entry_points.rs index 87b24231f0..3d9f975603 100644 --- a/pnpm/crates/package-manager/src/install/entry_points.rs +++ b/pnpm/crates/package-manager/src/install/entry_points.rs @@ -66,6 +66,23 @@ where Box::pin(self.run_inner::(InstallRunOptions::default())).await } + /// Execute `pacquet prune`, enabling extraneous direct-dependency + /// removal and optional workspace project selection. + pub async fn run_prune( + self, + selection: Option>, + ) -> Result<(), InstallError> { + Box::pin(self.run_inner::(InstallRunOptions { + selection, + manifests: crate::install::InstallManifestOptions { + prune_direct_dependencies: true, + ..Default::default() + }, + ..Default::default() + })) + .await + } + /// Execute as a check: the caller compares the lockfile the run /// produced against the one it snapshotted and restores that snapshot, /// so nothing else on disk may be left changed. pnpm's diff --git a/pnpm/crates/package-manager/src/install/materialize.rs b/pnpm/crates/package-manager/src/install/materialize.rs index eb7b18ac7e..c252fcb4f1 100644 --- a/pnpm/crates/package-manager/src/install/materialize.rs +++ b/pnpm/crates/package-manager/src/install/materialize.rs @@ -62,7 +62,7 @@ pub(super) struct MaterializationModules<'a> { pub(super) modules_manifest: Option<&'a pnpm_modules_yaml::ModulesLayout>, pub(super) prior_hoisted_dependencies: Option<&'a HoistedDependencies>, pub(super) prior_hoisted_locations: Option<&'a pnpm_deps_restorer::HoistedLocations>, - pub(super) prune_orphans: bool, + pub(super) prune: pnpm_deps_restorer::PruneTreeOptions<'a>, /// See [`pnpm_deps_restorer::PriorMaterialization::relink_every_slot_bin`]. pub(super) relink_every_slot_bin: bool, pub(super) logged_methods: &'a AtomicU8, @@ -203,7 +203,7 @@ impl<'a> MaterializationInputs<'a, '_> { self.modules.modules_manifest, self.install.context.config, ), - prune_orphans: self.modules.prune_orphans, + prune: self.modules.prune, relink_every_slot_bin: self.modules.relink_every_slot_bin, } } diff --git a/pnpm/crates/package-manager/src/install/materialize/frozen.rs b/pnpm/crates/package-manager/src/install/materialize/frozen.rs index 15e5a51804..bb6ad7146a 100644 --- a/pnpm/crates/package-manager/src/install/materialize/frozen.rs +++ b/pnpm/crates/package-manager/src/install/materialize/frozen.rs @@ -81,7 +81,7 @@ impl<'a> MaterializationInputs<'a, '_> { ), unbuilt_builds: prior_unbuilt_builds, previously_skipped, - prune_orphans: self.modules.prune_orphans, + prune: self.modules.prune, relink_every_slot_bin: self.modules.relink_every_slot_bin, }, projects: pnpm_deps_restorer::FrozenProjectInputs { diff --git a/pnpm/crates/package-manager/src/install/run/execution.rs b/pnpm/crates/package-manager/src/install/run/execution.rs index cc3a17b8dc..5364427056 100644 --- a/pnpm/crates/package-manager/src/install/run/execution.rs +++ b/pnpm/crates/package-manager/src/install/run/execution.rs @@ -216,7 +216,7 @@ impl<'a> RunExecution<'a> { ), modules: dispatched.materialization_modules( (self.mode.included, self.options.rebuild.as_ref()), - !scope.importers.filtered_install, + self.prune_tree_options(scope), logged_methods, ), execution: self.mode.materialization_execution( @@ -305,13 +305,7 @@ impl<'a> RunExecution<'a> { dry_run: self.install.execution.dry_run, peer_issues_sink_is_none: self.mode.peer_issues_sink_is_none, }, - prior: crate::install::state_options::ApplyPriorState { - lockfile: loaded.current.take(), - layout: dispatched.modules.old_modules, - metadata: dispatched.modules.previous_modules_metadata, - is_inconsistent: dispatched.modules.is_inconsistent, - tree_moved: dispatched.modules.tree_moved, - }, + prior: self.take_apply_prior_state(loaded, dispatched.modules), projects: crate::install::state_options::ApplyProjectSelection { importers: self.selected_importers(scope, project_manifests), workspace_packages: self.workspace.workspace_packages.take(), diff --git a/pnpm/crates/package-manager/src/install/run/materialization_inputs.rs b/pnpm/crates/package-manager/src/install/run/materialization_inputs.rs index 2a74a269ef..6b7720543f 100644 --- a/pnpm/crates/package-manager/src/install/run/materialization_inputs.rs +++ b/pnpm/crates/package-manager/src/install/run/materialization_inputs.rs @@ -62,7 +62,7 @@ impl Dispatched<'_> { pub(super) fn materialization_modules<'r>( &'r self, (included, rebuild): (IncludedDependencies, Option<&'r RebuildOptions>), - prune_orphans: bool, + prune: pnpm_deps_restorer::PruneTreeOptions<'r>, logged_methods: &'r AtomicU8, ) -> crate::install::materialize::MaterializationModules<'r> { let prior_modules = self.modules.previous_modules_metadata.as_ref(); @@ -72,7 +72,7 @@ impl Dispatched<'_> { modules_manifest: self.modules.old_modules.as_ref(), prior_hoisted_dependencies: prior_hoisted_dependencies(prior_modules), prior_hoisted_locations: prior_hoisted_locations(prior_modules), - prune_orphans, + prune, relink_every_slot_bin: self.modules.tree_moved, logged_methods, } @@ -125,3 +125,31 @@ impl Lockfiles<'_> { } } } + +impl super::RunExecution<'_> { + pub(super) const fn prune_tree_options<'r>( + &self, + scope: &'r InstallScope<'_>, + ) -> pnpm_deps_restorer::PruneTreeOptions<'r> { + pnpm_deps_restorer::PruneTreeOptions { + orphans: !scope.importers.filtered_install, + direct_dependencies: self.options.manifests.prune_direct_dependencies, + importer_ids: scope.importers.requested_importer_ids.as_ref(), + } + } + + pub(super) fn take_apply_prior_state( + &self, + loaded: &mut Loaded<'_>, + modules: super::super::PreparedModulesState<'_>, + ) -> crate::install::state_options::ApplyPriorState { + crate::install::state_options::ApplyPriorState { + lockfile: loaded.current.take(), + layout: modules.old_modules, + metadata: modules.previous_modules_metadata, + is_inconsistent: modules.is_inconsistent, + tree_moved: modules.tree_moved, + prune_direct_dependencies: self.options.manifests.prune_direct_dependencies, + } + } +} diff --git a/pnpm/crates/package-manager/src/install/state_options.rs b/pnpm/crates/package-manager/src/install/state_options.rs index 6b50b9fbef..5cec5d25fb 100644 --- a/pnpm/crates/package-manager/src/install/state_options.rs +++ b/pnpm/crates/package-manager/src/install/state_options.rs @@ -19,6 +19,7 @@ pub(crate) struct ApplyPriorState { pub(crate) is_inconsistent: bool, /// See [`RecordedWorkspace::moved`]. pub(crate) tree_moved: bool, + pub(crate) prune_direct_dependencies: bool, } pub(crate) struct ApplyProjectSelection<'a> { diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs index fd56142462..39329768f6 100644 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -236,7 +236,7 @@ pub(crate) struct FreshPriorInstall<'a> { /// See [`pnpm_deps_restorer::PriorLinkState::previously_skipped`]. pub(crate) previously_skipped: &'a pnpm_deps_restorer::SkippedSnapshots, /// See [`crate::PruneStaleModules::prune_orphans`]. - pub(crate) prune_orphans: bool, + pub(crate) prune: pnpm_deps_restorer::PruneTreeOptions<'a>, /// See [`pnpm_deps_restorer::PriorMaterialization::relink_every_slot_bin`]. pub(crate) relink_every_slot_bin: bool, } diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/on_disk.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/on_disk.rs index c8b11af26b..74f29f801e 100644 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/on_disk.rs +++ b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/on_disk.rs @@ -452,7 +452,7 @@ impl OnDiskProjects<'_> { impl<'a> super::FreshPriorInstall<'a> { fn link_state(&self) -> pnpm_deps_restorer::PriorLinkState<'a> { pnpm_deps_restorer::PriorLinkState { - prune_orphans: self.prune_orphans, + prune: self.prune, hoisted_dependencies: self.hoisted_dependencies, hoisted_locations: self.hoisted_locations, // Rebuilds take the frozen path; a policy change rebuilds present packages here. diff --git a/pnpm/docs/cli/prune.md b/pnpm/docs/cli/prune.md index 35721ee890..95f243e63d 100644 --- a/pnpm/docs/cli/prune.md +++ b/pnpm/docs/cli/prune.md @@ -21,8 +21,10 @@ Added in: v12.15.0 Skip verifying the lockfile against the supply-chain policies. `pnpm prune` also honors [`trustLockfile`](../settings/dependency-resolution.md#trustlockfile) from the configuration. `--no-trust-lockfile` verifies the lockfile even when the configuration trusts it. -:::warning +### --recursive, -r -The prune command does not support recursive execution on a monorepo currently. To only install production-dependencies in a monorepo `node_modules` folders can be deleted and then re-installed with `pnpm install --prod`. +Run `pnpm prune` in every package found in subdirectories, or in every workspace package when executed inside a workspace. Running `pnpm prune` at the workspace root runs recursively across the workspace by default. -::: +### --filter <package_selector> + +Restrict `pnpm prune` to the selected workspace projects. [Read more about filtering.](../filtering.md) diff --git a/pnpm11/docs/cli/prune.md b/pnpm11/docs/cli/prune.md index b3ec6737aa..38cd646d59 100644 --- a/pnpm11/docs/cli/prune.md +++ b/pnpm11/docs/cli/prune.md @@ -15,8 +15,10 @@ Remove the packages specified in `devDependencies`. Remove the packages specified in `optionalDependencies`. -:::warning +### --recursive, -r -The prune command does not support recursive execution on a monorepo currently. To only install production-dependencies in a monorepo `node_modules` folders can be deleted and then re-installed with `pnpm install --prod`. +Run `pnpm prune` in every package found in subdirectories, or in every workspace package when executed inside a workspace. -::: +### --filter <package_selector> + +Restrict `pnpm prune` to the selected workspace projects. [Read more about filtering.](../filtering.md) diff --git a/pnpm11/installing/commands/src/installDeps.ts b/pnpm11/installing/commands/src/installDeps.ts index fe2de93de9..e984e4b47f 100644 --- a/pnpm11/installing/commands/src/installDeps.ts +++ b/pnpm11/installing/commands/src/installDeps.ts @@ -154,7 +154,9 @@ export type InstallDepsOptions = Pick { - if (opts.update || opts.dedupe || opts.force || params.length !== 0 || !opts.optimisticRepeatInstall) return false + if (!canReportAlreadyUpToDate(opts, params)) return false if (opts.nodeLinker === 'hoisted' && (opts.hooks?.customFetchers?.length ?? 0) > 0) return false const { upToDate, wantedLockfileToRestore } = await checkDepsStatus({ ...opts, @@ -236,6 +238,11 @@ async function reportAlreadyUpToDate (opts: InstallDepsOptions, params: string[] return true } +function canReportAlreadyUpToDate (opts: InstallDepsOptions, params: string[]): boolean { + if (!opts.optimisticRepeatInstall || params.length !== 0) return false + return !opts.update && !opts.dedupe && !opts.force && !opts.pruneDirectDependencies && !opts.pruneStore +} + function applyWorkspaceOption (opts: InstallDepsOptions): void { if (opts.latest) { throw new PnpmError('BAD_OPTIONS', 'Cannot use --latest with --workspace simultaneously') diff --git a/pnpm11/installing/commands/src/prune.ts b/pnpm11/installing/commands/src/prune.ts index 5edcf5c497..1568bc31d0 100644 --- a/pnpm11/installing/commands/src/prune.ts +++ b/pnpm11/installing/commands/src/prune.ts @@ -6,9 +6,7 @@ import { renderHelp } from 'render-help' import * as install from './install.js' -export const rcOptionsTypes = cliOptionsTypes - -export function cliOptionsTypes (): Record { +export function rcOptionsTypes (): Record { return pick([ 'dev', 'optional', @@ -17,6 +15,13 @@ export function cliOptionsTypes (): Record { ], allTypes) } +export function cliOptionsTypes (): Record { + return { + ...rcOptionsTypes(), + recursive: Boolean, + } +} + export const commandNames = ['prune'] export const recursiveByDefault = true @@ -53,6 +58,7 @@ export async function handler ( await install.handler({ ...opts, modulesCacheMaxAge: 0, + optimisticRepeatInstall: false, pruneDirectDependencies: true, pruneStore: true, }) diff --git a/pnpm11/installing/commands/src/recursive/installWithSharedLockfile.ts b/pnpm11/installing/commands/src/recursive/installWithSharedLockfile.ts index 40f7efef09..679af6faac 100644 --- a/pnpm11/installing/commands/src/recursive/installWithSharedLockfile.ts +++ b/pnpm11/installing/commands/src/recursive/installWithSharedLockfile.ts @@ -202,7 +202,8 @@ function createInstallSomeMutation ( } function shouldInstallWorkspaceRoot ({ opts, manifestsByPath }: RecursiveContext): boolean { - return !opts.excludeWorkspaceRootProject && + return (!opts.pruneDirectDependencies || (!opts.filter?.length && !opts.filterProd?.length)) && + !opts.excludeWorkspaceRootProject && !opts.selectedProjectsGraph[opts.workspaceDir as ProjectRootDir] && manifestsByPath[opts.workspaceDir as ProjectRootDir] != null } diff --git a/pnpm11/installing/commands/test/prune.ts b/pnpm11/installing/commands/test/prune.ts index d272c2e596..3f28a4bbd4 100644 --- a/pnpm11/installing/commands/test/prune.ts +++ b/pnpm11/installing/commands/test/prune.ts @@ -186,4 +186,6 @@ test('cliOptionsTypes', () => { expect(prune.cliOptionsTypes()).toHaveProperty('dev') expect(prune.cliOptionsTypes()).toHaveProperty('ignore-scripts') expect(prune.cliOptionsTypes()).toHaveProperty('optional') + expect(prune.cliOptionsTypes()).toHaveProperty('recursive') + expect(prune.rcOptionsTypes()).not.toHaveProperty('recursive') }) diff --git a/pnpm11/installing/context/src/index.ts b/pnpm11/installing/context/src/index.ts index 9727fcea73..c308fd0994 100644 --- a/pnpm11/installing/context/src/index.ts +++ b/pnpm11/installing/context/src/index.ts @@ -84,6 +84,7 @@ export interface ProjectOptions { interface HookOptions { originalManifest?: ProjectManifest + pruneDirectDependencies?: boolean } export interface GetContextOptions { diff --git a/pnpm11/installing/deps-installer/src/install/mutateModules.ts b/pnpm11/installing/deps-installer/src/install/mutateModules.ts index f382007a06..3101f3b90a 100644 --- a/pnpm11/installing/deps-installer/src/install/mutateModules.ts +++ b/pnpm11/installing/deps-installer/src/install/mutateModules.ts @@ -130,7 +130,7 @@ async function openMutationRun (base: MutationRunBase): Promise { - if (opts.lockfileOnly || isCheckOnlyInstall(opts) || ctx.modulesFile == null) return ctx + if (opts.lockfileOnly || isCheckOnlyInstall(opts) || ctx.modulesFile == null) { + applyProjectPruneFlags(ctx, projects) + return ctx + } + const isPrune = projects.some((project) => 'pruneDirectDependencies' in project && Boolean(project.pruneDirectDependencies)) const { purged } = await validateModules(ctx.modulesFile, Object.values(ctx.projects), { forceNewModules: installsOnly, - include: opts.include, + include: isPrune ? undefined : opts.include, lockfileDir: opts.lockfileDir, modulesDir: opts.modulesDir ?? 'node_modules', registriesByScope: opts.registriesByScope, @@ -287,7 +291,17 @@ async function validateModulesAndReopenContext ( currentPublicHoistPattern: ctx.currentPublicHoistPattern, global: opts.global, }) - return purged ? getContext(opts) : ctx + const nextCtx = purged ? await getContext(opts) : ctx + applyProjectPruneFlags(nextCtx, projects) + return nextCtx +} + +function applyProjectPruneFlags (ctx: PnpmContext, projects: MutatedProject[]): void { + for (const project of projects) { + if ('pruneDirectDependencies' in project && project.pruneDirectDependencies != null && ctx.projects[project.rootDir] != null) { + ctx.projects[project.rootDir].pruneDirectDependencies = project.pruneDirectDependencies + } + } } /** diff --git a/pnpm11/installing/deps-restorer/src/index.ts b/pnpm11/installing/deps-restorer/src/index.ts index 8ad708c076..b867bbc806 100644 --- a/pnpm11/installing/deps-restorer/src/index.ts +++ b/pnpm11/installing/deps-restorer/src/index.ts @@ -80,7 +80,8 @@ async function installFromLockfile (opts: HeadlessOptions): Promise { const { opts, currentLockfile, lockfileDir } = ctx if (opts.nodeLinker === 'hoisted') return 0 - if (currentLockfile == null || opts.ignorePackageManifest) { + const pruneDirectDeps = ctx.selectedProjects.some((project) => project.pruneDirectDependencies) + if ((currentLockfile == null && !pruneDirectDeps) || opts.ignorePackageManifest) { statsLogger.debug({ prefix: lockfileDir, removed: 0, @@ -90,7 +91,10 @@ async function pruneRemovedDependencies (ctx: HeadlessContext): Promise const removedDepPaths = await prune( ctx.selectedProjects, { - currentLockfile, + currentLockfile: currentLockfile ?? { + lockfileVersion: ctx.wantedLockfile.lockfileVersion, + importers: {}, + }, dedupeDirectDeps: opts.dedupeDirectDeps, dryRun: false, hoistedDependencies: opts.hoistedDependencies, @@ -105,12 +109,26 @@ async function pruneRemovedDependencies (ctx: HeadlessContext): Promise storeController: opts.storeController, virtualStoreDir: ctx.virtualStoreDir, virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength, - wantedLockfile: filterLockfileByEngine(ctx.wantedLockfile, ctx.filterOpts).lockfile, + wantedLockfile: filterLockfileByEngine(ctx.wantedLockfile, getPruneFilterOptions(ctx)).lockfile, } ) return removedDepPaths.size } +function getPruneFilterOptions (ctx: HeadlessContext): HeadlessContext['filterOpts'] { + if (ctx.selectedProjects.length === Object.keys(ctx.wantedLockfile.importers).length) { + return ctx.filterOpts + } + return { + ...ctx.filterOpts, + include: { + dependencies: true, + devDependencies: true, + optionalDependencies: true, + }, + } +} + async function linkDependencies (ctx: HeadlessContext, depGraph: HeadlessDepGraph): Promise { const { opts } = ctx const { hierarchy, prevGraph } = depGraph diff --git a/pnpm11/installing/linking/modules-cleaner/src/prune.ts b/pnpm11/installing/linking/modules-cleaner/src/prune.ts index eeb83b2940..0fd62d8a61 100644 --- a/pnpm11/installing/linking/modules-cleaner/src/prune.ts +++ b/pnpm11/installing/linking/modules-cleaner/src/prune.ts @@ -102,7 +102,7 @@ async function pruneImporterDirectDeps ( const { binsDir, id, modulesDir, pruneDirectDependencies, removePackages, rootDir } = importer const currentImporter = opts.currentLockfile.importers[id] || {} as ProjectSnapshot const currentPkgs = Object.entries(mergeDependencies(currentImporter)) - const wantedPkgs = mergeDependencies(wantedLockfile.importers[id]) + const wantedPkgs = mergeDependencies(wantedLockfile.importers[id] ?? ({} as ProjectSnapshot)) const allCurrentPackages = new Set( (pruneDirectDependencies === true || removePackages?.length) @@ -229,7 +229,7 @@ function findOrphanDepPaths ( opts: PruneOptions ): DepPath[] { const selectedImporterIds = importers.map((importer) => importer.id).sort() - const currentPkgIdsByDepPaths = equals(selectedImporterIds, Object.keys(opts.wantedLockfile.importers)) + const currentPkgIdsByDepPaths = equals(selectedImporterIds, Object.keys(opts.wantedLockfile.importers).sort()) ? getPkgsDepPaths(opts.currentLockfile.packages ?? {}, opts.skipped) : getPkgsDepPathsOwnedOnlyByImporters(selectedImporterIds, opts.currentLockfile, opts) const wantedPkgIdsByDepPaths = getPkgsDepPaths(wantedLockfile.packages ?? {}, opts.skipped) @@ -381,15 +381,23 @@ function getPkgsDepPathsOwnedOnlyByImporters ( skipped: Set } ): Record { + const existingImporterIds = importerIds.filter((importerId) => lockfile.importers[importerId] != null) + if (existingImporterIds.length === 0) { + return {} + } const filterOpts = { failOnMissingDependencies: false, - include: opts.include, + include: { + dependencies: true, + devDependencies: true, + optionalDependencies: true, + }, resolvePeersFromWorkspaceRoot: opts.resolvePeersFromWorkspaceRoot, skipped: opts.skipped, } - const selected = filterLockfileByImporters(lockfile, importerIds, filterOpts) + const selected = filterLockfileByImporters(lockfile, existingImporterIds, filterOpts) const other = filterLockfileByImporters(lockfile, - difference(Object.keys(lockfile.importers) as ProjectId[], importerIds), + difference(Object.keys(lockfile.importers) as ProjectId[], existingImporterIds), filterOpts) const packagesOfSelectedOnly = pickAll( difference(Object.keys(selected.packages!), Object.keys(other.packages!)), diff --git a/pnpm11/pnpm/test/prune.test.ts b/pnpm11/pnpm/test/prune.test.ts index 97b48ba56f..ae48faa428 100644 --- a/pnpm11/pnpm/test/prune.test.ts +++ b/pnpm11/pnpm/test/prune.test.ts @@ -86,3 +86,196 @@ test('prune --prod removes devDependencies when lockfile is disabled', async () project.has('is-positive') project.hasNot('is-negative') }) + +test('prune removes extraneous direct packages when lockfile is already up to date', async () => { + const project = prepare({ + dependencies: { + 'is-positive': '1.0.0', + }, + }) + + await execPnpm(['install']) + project.has('is-positive') + + fs.mkdirSync('node_modules/extraneous-pkg', { recursive: true }) + fs.writeFileSync('node_modules/extraneous-pkg/package.json', JSON.stringify({ + name: 'extraneous-pkg', + version: '1.0.0', + })) + project.has('extraneous-pkg') + + await execPnpm(['prune']) + + project.has('is-positive') + project.hasNot('extraneous-pkg') +}) + +test('prune removes extraneous direct packages in a project with no dependencies', async () => { + const project = prepare({}) + + await execPnpm(['install']) + + fs.mkdirSync('node_modules/extraneous-pkg', { recursive: true }) + fs.writeFileSync('node_modules/extraneous-pkg/package.json', JSON.stringify({ + name: 'extraneous-pkg', + version: '1.0.0', + })) + project.has('extraneous-pkg') + + await execPnpm(['prune']) + + project.hasNot('extraneous-pkg') +}) + +test('prune --filter only prunes the selected workspace project', async () => { + const projects = preparePackages([ + { + location: '.', + package: { + name: 'root', + version: '1.0.0', + devDependencies: { + '@pnpm.e2e/hello-world-js-bin': '1.0.0', + }, + }, + }, + { + location: 'packages/pkg-a', + package: { + name: 'pkg-a', + version: '1.0.0', + dependencies: { + 'is-positive': '1.0.0', + }, + devDependencies: { + 'is-negative': '1.0.0', + }, + }, + }, + { + location: 'packages/pkg-b', + package: { + name: 'pkg-b', + version: '1.0.0', + dependencies: { + 'is-positive': '1.0.0', + }, + devDependencies: { + '@pnpm.e2e/foo': '100.0.0', + }, + }, + }, + ]) + + writeYamlFileSync('pnpm-workspace.yaml', { packages: ['packages/*'] }) + + await execPnpm(['install']) + + fs.mkdirSync('node_modules/extraneous-root', { recursive: true }) + fs.writeFileSync('node_modules/extraneous-root/package.json', JSON.stringify({ + name: 'extraneous-root', + version: '1.0.0', + })) + fs.mkdirSync('packages/pkg-a/node_modules/extraneous-a', { recursive: true }) + fs.writeFileSync('packages/pkg-a/node_modules/extraneous-a/package.json', JSON.stringify({ + name: 'extraneous-a', + version: '1.0.0', + })) + fs.mkdirSync('packages/pkg-b/node_modules/extraneous-b', { recursive: true }) + fs.writeFileSync('packages/pkg-b/node_modules/extraneous-b/package.json', JSON.stringify({ + name: 'extraneous-b', + version: '1.0.0', + })) + + await execPnpm(['--filter', 'pkg-a', 'prune', '--prod']) + + projects['pkg-a'].has('is-positive') + projects['pkg-a'].hasNot('is-negative') + projects['pkg-a'].hasNot('extraneous-a') + + projects['root'].has('@pnpm.e2e/hello-world-js-bin') + projects['root'].has('extraneous-root') + projects['pkg-b'].has('is-positive') + projects['pkg-b'].has('@pnpm.e2e/foo') + projects['pkg-b'].has('extraneous-b') + expect(fs.existsSync('node_modules/.pnpm/@pnpm.e2e+foo@100.0.0')).toBe(true) + + await execPnpm(['-r', 'prune', '--prod']) + + projects['root'].hasNot('@pnpm.e2e/hello-world-js-bin') + projects['root'].hasNot('extraneous-root') + projects['pkg-b'].has('is-positive') + projects['pkg-b'].hasNot('@pnpm.e2e/foo') + projects['pkg-b'].hasNot('extraneous-b') + + // Filtered prune also works when node_modules/.pnpm/lock.yaml (currentLockfile) is absent. + fs.rmSync('node_modules/.pnpm/lock.yaml', { force: true }) + fs.mkdirSync('packages/pkg-a/node_modules/extraneous-without-current-lockfile', { recursive: true }) + fs.writeFileSync('packages/pkg-a/node_modules/extraneous-without-current-lockfile/package.json', JSON.stringify({ + name: 'extraneous-without-current-lockfile', + version: '1.0.0', + })) + + await execPnpm(['--filter', 'pkg-a', 'prune']) + + projects['pkg-a'].hasNot('extraneous-without-current-lockfile') +}) + +test('prune in a workspace with sharedWorkspaceLockfile=false prunes all workspace projects', async () => { + const projects = preparePackages([ + { + location: '.', + package: { + name: 'root', + version: '1.0.0', + }, + }, + { + location: 'packages/pkg-a', + package: { + name: 'pkg-a', + version: '1.0.0', + dependencies: { + 'is-positive': '1.0.0', + }, + devDependencies: { + 'is-negative': '1.0.0', + }, + }, + }, + { + location: 'packages/pkg-b', + package: { + name: 'pkg-b', + version: '1.0.0', + dependencies: { + 'is-positive': '1.0.0', + }, + devDependencies: { + '@pnpm.e2e/foo': '100.0.0', + }, + }, + }, + ]) + + writeYamlFileSync('pnpm-workspace.yaml', { + packages: ['packages/*'], + sharedWorkspaceLockfile: false, + }) + + await execPnpm(['install']) + + fs.mkdirSync('packages/pkg-a/node_modules/extraneous-a', { recursive: true }) + fs.writeFileSync('packages/pkg-a/node_modules/extraneous-a/package.json', JSON.stringify({ + name: 'extraneous-a', + version: '1.0.0', + })) + + await execPnpm(['prune', '--prod']) + + projects['pkg-a'].has('is-positive') + projects['pkg-a'].hasNot('is-negative') + projects['pkg-a'].hasNot('extraneous-a') + projects['pkg-b'].has('is-positive') + projects['pkg-b'].hasNot('@pnpm.e2e/foo') +})