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:
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>(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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!)),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user