fix(prune): remove extraneous direct deps and honor workspace selection (#17154)

Fix three `pnpm prune` issues across pnpm v11 and pnpm v12:

1. Extraneous direct dependencies in `node_modules`:
   - In pnpm v11, `prune.handler` did not disable `optimisticRepeatInstall`, and `installDeps` could short-circuit via `canReportAlreadyUpToDate` before `headlessInstall` ran `prune()`. In addition, `pruneRemovedDependencies` skipped `prune()` when `currentLockfile` was absent (for example, when a project had zero dependencies). Disable `optimisticRepeatInstall` in `prune.handler`, prevent `canReportAlreadyUpToDate` when `pruneDirectDependencies` or `pruneStore` is set, and pass an empty `currentLockfile` to `prune()` when `pruneDirectDependencies` is enabled and no current lockfile exists.
   - In pnpm v12 (`pacquet`), `prune_stale_modules` only unlinked direct dependencies that were present in `current_lockfile` and removed from `wanted_lockfile`, never scanning `node_modules` on disk for extraneous entries when `prune` ran. Add `PruneTreeOptions::direct_dependencies` and `PruneExtraneousDirectDeps` to scan each selected importer's `node_modules` directory during `pnpm prune`, remove entries (and their bin shims and emptied scope directories) that are not wanted direct dependencies, excluded manifest links, or root public hoists, and force `commit_modules_state`'s virtual-store sweep during `prune`.

2. `pnpm --filter <pkg> prune` and `pnpm -r prune` in pnpm v11:
   - Separate `cliOptionsTypes` from `rcOptionsTypes` in `@pnpm/installing.commands` `prune` and add `'recursive': Boolean` so the CLI parser accepts `-r` / `--recursive` and `--filter`.
   - Do not pass `include` to `validateModules` when `pruneDirectDependencies` is set so `prune --prod` does not purge every workspace project's `node_modules` directory.
   - Do not auto-include the workspace root in `shouldInstallWorkspaceRoot` during filtered `prune`.
   - Scope `pruneDirectDependencies` to the selected `ctx.projects` and keep all dependency groups in `findOrphanDepPaths` / `getPkgsDepPathsOwnedOnlyByImporters` during a partial workspace `prune` so unselected workspace importers do not lose their devDependencies from the shared virtual store.

3. Workspace selection and `sharedWorkspaceLockfile: false` in pnpm v12:
   - Add `CliCommand::Prune(_)` to `CliCommand::recursive_by_default`.
   - Route `PrunePipeline` through `select_install_family_plan` (`PerProject`, `Shared`, `Single`) and pass `WorkspaceInstallSelection` (with `install_dirs` restricted to `selected_dirs`) into `Install::run_prune`.

Closes https://github.com/pnpm/tasks/issues/171
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-10-20 17:07:56 +02:00
1 parent f8ba6e18e7
commit 473cccecef
36 files changed
+1096 -111

No files matched your search

@@ -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.
@@ -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.
@@ -367,6 +367,7 @@ impl CliCommand {
self,
CliCommand::Install(_)
| CliCommand::Dedupe(_)
| CliCommand::Prune(_)
| CliCommand::InstallTest(_)
| CliCommand::Import(_)
| CliCommand::List(_)
@@ -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::<CliReporter>()).await?;
Ok(())
}))
@@ -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<Reporter: self::Reporter + 'static>(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::<Reporter>(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::<Reporter>(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::<Reporter>(
self.cfg,
&self.prefix,
&self.manifest_path,
self.recursive_sort,
false,
false,
)?;
self.run_plan::<Reporter>(plan).await
}
async fn run_plan<Reporter: self::Reporter + 'static>(
self,
plan: InstallFamilyPlan,
) -> miette::Result<()> {
match plan {
InstallFamilyPlan::PerProject(projects) => {
run_dedicated_prune::<Reporter>(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::<Reporter>(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::<Reporter>(state, None)).await
}
}
}
}
async fn run_dedicated_prune<Reporter: self::Reporter + 'static>(
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::<Reporter>(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<Reporter: self::Reporter + 'static>(
+22 -26
View File
@@ -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<Reporter: self::Reporter + 'static>(self, state: State) -> miette::Result<()> {
pub(crate) async fn run<Reporter: self::Reporter + 'static>(
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<DependencyGroup> =
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::<Reporter>()
.run_prune::<Reporter>(selection)
.await
.wrap_err("pruning dependencies")
}
@@ -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",
+295
View File
@@ -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));
}
@@ -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,
+2 -1
View File
@@ -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";
+19 -4
View File
@@ -254,7 +254,7 @@ fn relink_importer_tree<Reporter: self::Reporter>(
hoist: &PlannedHoist,
) -> Result<(), LinkPhaseError> {
let config = inputs.ctx.config;
prune_importer_tree::<Reporter>(inputs, hoist.plan.as_ref())?;
prune_importer_tree::<Reporter>(inputs, hoist)?;
let scheduled_builds = inputs.scheduled_builds();
let phase_start = std::time::Instant::now();
@@ -301,22 +301,37 @@ fn relink_importer_tree<Reporter: self::Reporter>(
fn prune_importer_tree<Reporter: self::Reporter>(
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::<Reporter>()
.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::<Reporter>()
.map_err(LinkPhaseError::PruneStaleModules)?;
}
Reporter::emit(&LogEvent::Stats(StatsLog {
level: LogLevel::Debug,
message: StatsMessage::Removed {
@@ -52,9 +52,16 @@ pub struct LinkPackageData<'a> {
pub cas_paths_by_pkg_id: Option<CasPathsByPkgId>,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct PruneTreeOptions<'a> {
pub orphans: bool,
pub direct_dependencies: bool,
pub importer_ids: Option<&'a std::collections::HashSet<String>>,
}
#[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
@@ -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
/// `<modules_dir>/<name>` 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<Option<Option<String>>, 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
/// `<modules_dir>/.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<Option<String>, 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::<Host>(&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)]
@@ -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<String, PathBuf>>,
pub manifests: &'a [(PathBuf, &'a PackageManifest)],
pub included_groups: &'a [DependencyGroup],
pub selected_importer_ids: Option<&'a HashSet<String>>,
}
impl<'a> PruneExtraneousDirectDeps<'a> {
pub(crate) fn run<Reporter: self::Reporter>(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::<Reporter>(
importer_id,
wanted_snapshot,
wanted_root_deps.as_ref(),
manifests_by_dir.as_ref(),
)?;
}
Ok(())
}
fn prune_importer<Reporter: self::Reporter>(
&self,
importer_id: &str,
wanted_snapshot: &ProjectSnapshot,
wanted_root_deps: Option<&HashMap<&'a PkgName, &'a ImporterDepVersion>>,
manifests_by_dir: Option<&HashMap<PathBuf, &'a PackageManifest>>,
) -> 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::<Reporter>(&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<PathBuf, &'a PackageManifest>>,
) -> HashSet<String> {
let mut wanted: HashSet<String> = 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<PathBuf, &'a PackageManifest>>,
wanted: &mut HashSet<String>,
) {
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<String>) {
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<HashMap<PathBuf, &'a PackageManifest>> {
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<HashMap<&'a PkgName, &'a ImporterDepVersion>> {
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<Reporter: self::Reporter>(
modules_dir: &Path,
importer_dir: &Path,
pkg_names: Vec<String>,
wanted: &HashSet<String>,
) -> 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(())
}
@@ -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<Reporter: self::Reporter>(
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)> {
@@ -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,
@@ -168,14 +168,20 @@ fn commit_apply_state<Reporter: self::Reporter>(
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::<Reporter>(pending_skills);
run_apply_scripts::<Reporter>(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<Reporter: self::Reporter>(
inputs: &ApplyMaterializationInputs<'_, '_>,
state: &MaterializedState<'_>,
@@ -66,6 +66,23 @@ where
Box::pin(self.run_inner::<Reporter>(InstallRunOptions::default())).await
}
/// Execute `pacquet prune`, enabling extraneous direct-dependency
/// removal and optional workspace project selection.
pub async fn run_prune<Reporter: self::Reporter + 'static>(
self,
selection: Option<WorkspaceInstallSelection<'_>>,
) -> Result<(), InstallError> {
Box::pin(self.run_inner::<Reporter>(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
@@ -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,
}
}
@@ -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 {
@@ -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(),
@@ -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,
}
}
}
@@ -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> {
@@ -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,
}
@@ -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.
+5 -3
View File
@@ -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 &lt;package_selector&gt;
Restrict `pnpm prune` to the selected workspace projects. [Read more about filtering.](../filtering.md)
+5 -3
View File
@@ -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 &lt;package_selector&gt;
Restrict `pnpm prune` to the selected workspace projects. [Read more about filtering.](../filtering.md)
@@ -154,7 +154,9 @@ export type InstallDepsOptions = Pick<Config,
workspace?: boolean
interactiveUpdate?: boolean
includeOnlyPackageFiles?: boolean
pruneDirectDependencies?: boolean
pruneLockfileImporters?: boolean
pruneStore?: boolean
/**
* Set to `false` for an install whose projects are not the workspace's own,
* such as the legacy `pnpm deploy`. The workspace state file then keeps
@@ -211,7 +213,7 @@ export async function installDeps (
* "Already up to date" when nothing needs to be installed.
*/
async function reportAlreadyUpToDate (opts: InstallDepsOptions, params: string[]): Promise<boolean> {
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')
+9 -3
View File
@@ -6,9 +6,7 @@ import { renderHelp } from 'render-help'
import * as install from './install.js'
export const rcOptionsTypes = cliOptionsTypes
export function cliOptionsTypes (): Record<string, unknown> {
export function rcOptionsTypes (): Record<string, unknown> {
return pick([
'dev',
'optional',
@@ -17,6 +15,13 @@ export function cliOptionsTypes (): Record<string, unknown> {
], allTypes)
}
export function cliOptionsTypes (): Record<string, unknown> {
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,
})
@@ -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
}
+2
View File
@@ -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')
})
+1
View File
@@ -84,6 +84,7 @@ export interface ProjectOptions {
interface HookOptions {
originalManifest?: ProjectManifest
pruneDirectDependencies?: boolean
}
export interface GetContextOptions {
@@ -130,7 +130,7 @@ async function openMutationRun (base: MutationRunBase): Promise<OpenedMutationRu
const openedCtx = await getMutationContext(base)
const scriptsOpts = createScriptsOptions(opts, openedCtx)
const rootProjectPreinstallRan = await runRootProjectEarlyHooks(base, { ctx: openedCtx, rootProjectManifest, scriptsOpts })
const ctx = await validateModulesAndReopenContext(opts, openedCtx, installsOnly)
const ctx = await validateModulesAndReopenContext(opts, openedCtx, { installsOnly, projects })
return {
...base,
allowBuild,
@@ -266,12 +266,16 @@ async function runRootProjectEarlyHooks (
async function validateModulesAndReopenContext (
opts: StrictInstallOptions,
ctx: PnpmContext,
installsOnly: boolean
{ installsOnly, projects }: { installsOnly: boolean, projects: MutatedProject[] }
): Promise<PnpmContext> {
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
}
}
}
/**
+21 -3
View File
@@ -80,7 +80,8 @@ async function installFromLockfile (opts: HeadlessOptions): Promise<Installation
async function pruneRemovedDependencies (ctx: HeadlessContext): Promise<number> {
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<number>
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<number>
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<LinkedDependencies> {
const { opts } = ctx
const { hierarchy, prevGraph } = depGraph
@@ -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<DepPath>
}
): Record<string, string> {
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!)),
+193
View File
@@ -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')
})