diff --git a/.changeset/quiet-global-update.md b/.changeset/quiet-global-update.md new file mode 100644 index 0000000000..f79a0b7431 --- /dev/null +++ b/.changeset/quiet-global-update.md @@ -0,0 +1,7 @@ +--- +"@pnpm/global.commands": patch +"pnpm": patch +"pacquet": patch +--- + +`pnpm update --global` no longer reinstalls a global package when its dependency graph resolves to what is already installed. It reports `Already up to date` [pnpm/pnpm#12002](https://github.com/pnpm/pnpm/issues/12002). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58ff84b3ac..79e9e68d3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5107,6 +5107,12 @@ importers: '@pnpm/installing.deps-installer': specifier: workspace:* version: link:../../installing/deps-installer + '@pnpm/installing.modules-yaml': + specifier: workspace:* + version: link:../../installing/modules-yaml + '@pnpm/lockfile.fs': + specifier: workspace:* + version: link:../../lockfile/fs '@pnpm/pkg-manifest.reader': specifier: workspace:* version: link:../../pkg-manifest/reader diff --git a/pnpm/crates/cli/src/cli_args/global.rs b/pnpm/crates/cli/src/cli_args/global.rs index 82104accac..2e5ed85b1b 100644 --- a/pnpm/crates/cli/src/cli_args/global.rs +++ b/pnpm/crates/cli/src/cli_args/global.rs @@ -64,7 +64,7 @@ use pnpm_package_is_installable::SupportedArchitectures; use pnpm_package_manifest::{DependencyGroup, safe_read_package_json_from_dir}; use pnpm_package_name::is_valid_old_npm_package_name; use pnpm_registry::RangeSpecStyle; -use pnpm_reporter::{GlobalLog, LogEvent, LogLevel, Reporter}; +use pnpm_reporter::{GlobalLog, LogEvent, LogLevel, PnpmLog, Reporter, SummaryLog}; use pnpm_resolving_parse_wanted_dependency::parse_wanted_dependency; use remove::{ @@ -84,9 +84,46 @@ use shims::{ use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, fs, io, + marker::PhantomData, path::{Path, PathBuf}, }; +/// Forward resolution diagnostics while hiding install-tree events from the +/// lockfile-only comparison pass. +struct GlobalUpdateResolutionReporter(PhantomData); + +impl Reporter for GlobalUpdateResolutionReporter { + fn emit(event: &LogEvent) { + let is_terminal_up_to_date = matches!( + event, + LogEvent::Pnpm(PnpmLog { message, .. }) if message == "Already up to date", + ); + if !is_terminal_up_to_date + && !matches!( + event, + LogEvent::PackageManifest(_) + | LogEvent::Root(_) + | LogEvent::Stats(_) + | LogEvent::Summary(_), + ) + { + Sink::emit(event); + } + } +} + +/// `update -g` closes with one completion summary of its own, so the groups it +/// materializes along the way must not each close with theirs. +struct GlobalUpdateMaterializationReporter(PhantomData); + +impl Reporter for GlobalUpdateMaterializationReporter { + fn emit(event: &LogEvent) { + if !matches!(event, LogEvent::Summary(_)) { + Sink::emit(event); + } + } +} + /// Errors specific to global package management, carrying the /// `ERR_PNPM_`-prefixed codes. #[derive(Debug, Display, Error, Diagnostic)] @@ -239,8 +276,9 @@ pub async fn handle_global_update( global_pkg_dir: &global_pkg_dir, global_bin_dir: &global_bin_dir, }; + let mut changed = false; for pkg in &to_update { - target.update_group::( + changed |= target.update_group::( pkg, latest, range_spec_style, @@ -248,9 +286,22 @@ pub async fn handle_global_update( ) .await?; } + emit_global_update_result::(&global_pkg_dir, changed); Ok(()) } +fn emit_global_update_result(global_pkg_dir: &Path, changed: bool) { + if !changed { + Reporter::emit(&LogEvent::Pnpm(PnpmLog { + level: LogLevel::Info, + message: "Already up to date".to_string(), + prefix: String::new(), + })); + } + let prefix = global_pkg_dir.to_string_lossy().into_owned(); + Reporter::emit(&LogEvent::Summary(SummaryLog { level: LogLevel::Debug, prefix })); +} + /// Surface a non-fatal problem on the `pnpm:global` channel, matching /// the TypeScript CLI's `globalWarn`. fn warn_global(message: &str) { diff --git a/pnpm/crates/cli/src/cli_args/global/groups.rs b/pnpm/crates/cli/src/cli_args/global/groups.rs index 41874ac80c..7323a890a6 100644 --- a/pnpm/crates/cli/src/cli_args/global/groups.rs +++ b/pnpm/crates/cli/src/cli_args/global/groups.rs @@ -1,16 +1,19 @@ use super::{ ActivationBinSets, ArtifactCleanupError, CmdShimHost, Config, Context, GlobalInstallTarget, - GlobalPackageInfo, GroupActivation, GroupInstall, HashSet, IntoDiagnostic, PackageBinSource, - Path, RangeSpecStyle, ReplacedGlobalBinPlan, Reporter, SupportedArchitectures, + GlobalPackageInfo, GlobalUpdateMaterializationReporter, GlobalUpdateResolutionReporter, + GroupActivation, GroupInstall, HashSet, IntoDiagnostic, Lockfile, PackageBinSource, Path, + PathBuf, RangeSpecStyle, ReplacedGlobalBinPlan, Reporter, SupportedArchitectures, acquire_global_bin_lock, activate_global_install_with_extra_bin_names, bin_names_of_other_groups, check_global_bin_conflicts, check_virtual_shim_conflicts, cleanup_replaced_global_installs, collect_existing_global_installs, create_global_cache_key, - create_install_dir, discard_install_dir_on_error, get_actual_bin_names, get_hash_link, - hash_linked_packages, link_global_bins, pins_for_downgrades, plan_replaced_global_bins, - read_direct_dependencies, read_installed_packages, registries_with_default, - replacement_aliases, restore_virtual_shims, run_group_install, should_replace_existing_package, - snapshot_global_package, update_selectors, warn_global, + create_install_dir, discard_install_dir_on_error, fs, get_actual_bin_names, get_hash_link, + global_group_config, hash_linked_packages, link_global_bins, pins_for_downgrades, + plan_replaced_global_bins, prompt_approve_install_builds, read_direct_dependencies, + read_installed_packages, registries_with_default, replacement_aliases, restore_virtual_shims, + run_group_install, should_replace_existing_package, snapshot_global_package, update_selectors, + warn_global, }; +use pnpm_modules_yaml::{Host as ModulesHost, read_modules_manifest}; impl GlobalInstallTarget<'_> { /// Install one `add -g` group and activate it over the groups it @@ -93,35 +96,154 @@ impl GlobalInstallTarget<'_> { latest: bool, range_spec_style: RangeSpecStyle, supported_architectures: Option, - ) -> miette::Result<()> { - let install_dir = create_install_dir(self.global_pkg_dir) - .into_diagnostic() - .wrap_err("create global install dir")?; - let pins = Box::pin(pins_for_downgrades::( - self.base_config, - self.global_pkg_dir, - &install_dir, + ) -> miette::Result { + let (install_dir, selectors) = self.prepare_update_candidate::( pkg, latest, range_spec_style, supported_architectures.clone(), - )) - .await?; - Box::pin(run_group_install::(GroupInstall { - base_config: self.base_config, - global_pkg_dir: self.global_pkg_dir, - install_dir: &install_dir, - selectors: &update_selectors(&pkg.dependencies, latest, &pins), - range_spec_style, - supported_architectures, - // `update -g` takes no `--allow-build`; the build policy comes - // from the global `allowBuilds` loaded in `run_group_install`. - allow_build: &[], - lockfile_only: false, - })) + ) .await?; - self.activate_updated_group::(&install_dir, pkg) + if self.discard_unchanged_update::( + pkg, + &install_dir, + supported_architectures.clone(), + ) + .await? + { + return Ok(false); + } + + self.materialize_update::( + pkg, + &install_dir, + &selectors, + range_spec_style, + supported_architectures, + ) + .await?; + Ok(true) + } + + async fn prepare_update_candidate( + &self, + pkg: &GlobalPackageInfo, + latest: bool, + range_spec_style: RangeSpecStyle, + supported_architectures: Option, + ) -> miette::Result<(PathBuf, Vec)> { + let install_dir = create_install_dir(self.global_pkg_dir) + .into_diagnostic() + .wrap_err("create global install dir")?; + let resolved = Box::pin(self.resolve_update_candidate::( + pkg, + &install_dir, + latest, + range_spec_style, + supported_architectures, + )) + .await; + let selectors = discard_install_dir_on_error(&install_dir, resolved)?; + Ok((install_dir, selectors)) + } + + /// Seed the candidate with the group's manifest and resolve the update into + /// its lockfile, leaving `node_modules` absent. The selectors it returns are + /// the ones the materializing install repeats. + async fn resolve_update_candidate( + &self, + pkg: &GlobalPackageInfo, + install_dir: &Path, + latest: bool, + range_spec_style: RangeSpecStyle, + supported_architectures: Option, + ) -> miette::Result> { + fs::copy(pkg.install_dir.join("package.json"), install_dir.join("package.json")) + .into_diagnostic() + .wrap_err("seed global update manifest")?; + let downgrade_check = + Box::pin(pins_for_downgrades::>( + self.base_config, + self.global_pkg_dir, + install_dir, + pkg, + latest, + range_spec_style, + supported_architectures.clone(), + )) + .await?; + let selectors = update_selectors(&pkg.dependencies, latest, &downgrade_check.pins); + if !downgrade_check.candidate_resolved || !downgrade_check.pins.is_empty() { + Box::pin(run_group_install::>(GroupInstall { + base_config: self.base_config, + global_pkg_dir: self.global_pkg_dir, + install_dir, + selectors: &selectors, + range_spec_style, + supported_architectures, + allow_build: &[], + lockfile_only: true, + })) + .await?; + } + Ok(selectors) + } + + async fn discard_unchanged_update( + &self, + pkg: &GlobalPackageInfo, + install_dir: &Path, + supported_architectures: Option, + ) -> miette::Result { + let unchanged = + is_materialized(&pkg.install_dir) && lockfiles_are_equal(&pkg.install_dir, install_dir); + if !unchanged { + return Ok(false); + } + fs::remove_dir_all(install_dir) + .into_diagnostic() + .wrap_err("remove unchanged global install candidate")?; + let active_config = Config::leak(global_group_config( + self.base_config, + &pkg.install_dir, + self.global_pkg_dir, + supported_architectures, + )?); + prompt_approve_install_builds::( + active_config, + &pkg.install_dir, + self.global_pkg_dir, + ) + .await?; + Ok(true) + } + + async fn materialize_update( + &self, + pkg: &GlobalPackageInfo, + install_dir: &Path, + selectors: &[String], + range_spec_style: RangeSpecStyle, + supported_architectures: Option, + ) -> miette::Result<()> { + Box::pin(run_group_install::>( + GroupInstall { + base_config: self.base_config, + global_pkg_dir: self.global_pkg_dir, + install_dir, + selectors, + range_spec_style, + supported_architectures, + // `update -g` takes no `--allow-build`; the build policy comes + // from the global `allowBuilds` loaded in `run_group_install`. + allow_build: &[], + lockfile_only: false, + }, + )) + .await?; + + self.activate_updated_group::(install_dir, pkg) } fn activate_updated_group( @@ -254,6 +376,24 @@ impl GlobalInstallTarget<'_> { } } +/// The modules manifest is what an install leaves behind, so it answers what +/// equal lockfiles cannot: whether the tree they describe is still on disk. +fn is_materialized(install_dir: &Path) -> bool { + read_modules_manifest::(&install_dir.join("node_modules")) + .is_ok_and(|manifest| manifest.is_some()) +} + +fn lockfiles_are_equal(active_dir: &Path, candidate_dir: &Path) -> bool { + let Ok(Some(active)) = Lockfile::load_from_path(&active_dir.join(Lockfile::FILE_NAME)) else { + return false; + }; + let Ok(Some(candidate)) = Lockfile::load_from_path(&candidate_dir.join(Lockfile::FILE_NAME)) + else { + return false; + }; + active == candidate +} + fn warn_on_leftover(leftover: Option<&ArtifactCleanupError>) { if let Some(leftover) = leftover { warn_global::(&leftover.to_string()); diff --git a/pnpm/crates/cli/src/cli_args/global/install.rs b/pnpm/crates/cli/src/cli_args/global/install.rs index 057b8c8cbb..859e9b6ef0 100644 --- a/pnpm/crates/cli/src/cli_args/global/install.rs +++ b/pnpm/crates/cli/src/cli_args/global/install.rs @@ -13,6 +13,11 @@ pub(super) struct GlobalInstallTarget<'a> { pub(super) global_bin_dir: &'a Path, } +pub(super) struct DowngradeCheck { + pub(super) candidate_resolved: bool, + pub(super) pins: HashMap, +} + /// A freshly installed group, ready to take over the global bins of the /// groups it replaces. pub(super) struct GroupActivation<'a> { @@ -47,12 +52,12 @@ pub(super) async fn pins_for_downgrades( latest: bool, range_spec_style: RangeSpecStyle, supported_architectures: Option, -) -> miette::Result> { +) -> miette::Result { // Only `--latest` can pick a version outside the recorded range, and only a // plain version spec is dropped for it. Everything else resolves within a // range the installed version already satisfies. if !latest { - return Ok(HashMap::new()); + return Ok(DowngradeCheck { candidate_resolved: false, pins: HashMap::new() }); } let versions_before = installed_versions(&pkg.install_dir); // Nothing to compare a resolution against, so nothing to resolve. @@ -60,7 +65,7 @@ pub(super) async fn pins_for_downgrades( .iter() .any(|(alias, spec)| is_plain_version_spec(spec) && versions_before.contains_key(alias)) { - return Ok(HashMap::new()); + return Ok(DowngradeCheck { candidate_resolved: false, pins: HashMap::new() }); } run_group_install::(GroupInstall { base_config, @@ -75,7 +80,7 @@ pub(super) async fn pins_for_downgrades( .await?; let resolved = resolved_direct_versions(install_dir); - Ok(pkg.dependencies + let pins = pkg.dependencies .iter() .filter(|(_, spec)| is_plain_version_spec(spec)) .filter_map(|(alias, _)| { @@ -83,7 +88,8 @@ pub(super) async fn pins_for_downgrades( let now = resolved.get(alias)?; (*now < before).then(|| (alias.clone(), before.to_string())) }) - .collect()) + .collect(); + Ok(DowngradeCheck { candidate_resolved: true, pins }) } /// The version each direct dependency resolved to, read from the lockfile the diff --git a/pnpm/crates/cli/tests/suite/global.rs b/pnpm/crates/cli/tests/suite/global.rs index 622b129f6c..9198a06d60 100644 --- a/pnpm/crates/cli/tests/suite/global.rs +++ b/pnpm/crates/cli/tests/suite/global.rs @@ -909,6 +909,162 @@ fn global_update_latest_keeps_a_package_that_latest_would_downgrade() { drop((root, npmrc_info)); } +#[cfg(unix)] +#[test] +fn unchanged_global_update_reports_already_up_to_date_without_replacing_the_group() { + use assert_cmd::assert::OutputAssertExt; + + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry(); + let pnpm_home = root.path().join("pnpm-home"); + prepare_global_home(&pnpm_home, &npmrc_info); + + global_command(&workspace, &pnpm_home) + .with_args(["add", "-g", "@foo/touch-file-one-bin"]) + .assert() + .success(); + let global_dir = pnpm_home.join("global").join("v11"); + let links_before: Vec<_> = symlink_entries(&global_dir) + .into_iter() + .map(|link| { + let target = fs::read_link(&link).expect("read global hash link"); + (link, target) + }) + .collect(); + + let output = global_command(&workspace, &pnpm_home) + .with_args(["update", "-g"]) + .output() + .expect("run unchanged global update"); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Already up to date"), "{stdout}"); + assert!(!stdout.contains("dependencies:\n+"), "{stdout}"); + let links_after: Vec<_> = symlink_entries(&global_dir) + .into_iter() + .map(|link| { + let target = fs::read_link(&link).expect("read global hash link"); + (link, target) + }) + .collect(); + assert_eq!(links_after, links_before); + + drop((root, npmrc_info)); +} + +#[cfg(unix)] +#[test] +fn unchanged_global_update_still_approves_a_pending_build() { + use assert_cmd::assert::OutputAssertExt; + + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry(); + let pnpm_home = root.path().join("pnpm-home"); + prepare_global_home(&pnpm_home, &npmrc_info); + + global_command(&workspace, &pnpm_home) + .with_args(["add", "-g", "@pnpm.e2e/install-script-example@1.0.0"]) + .assert() + .success(); + let global_dir = pnpm_home.join("global").join("v11"); + let install_before = + pnpm_global::find_global_package(&global_dir, "@pnpm.e2e/install-script-example") + .expect("scan global packages") + .expect("find install-script group"); + let build_artifact = install_before.install_dir.join( + "node_modules/@pnpm.e2e/install-script-example/generated-by-install.js", + ); + assert!(!build_artifact.exists()); + + let output = global_command(&workspace, &pnpm_home) + .with_env("PNPM_AUTO_APPROVE_BUILDS_FOR_TESTS", "1") + .with_args(["update", "-g"]) + .output() + .expect("run unchanged global update with pending build approval"); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Already up to date"), "{stdout}"); + assert!(build_artifact.exists()); + let install_after = + pnpm_global::find_global_package(&global_dir, "@pnpm.e2e/install-script-example") + .expect("scan global packages") + .expect("find install-script group"); + assert_eq!(install_after.install_dir, install_before.install_dir); + + drop((root, npmrc_info)); +} + +/// A group whose `node_modules` was removed holds nothing to run, so an +/// unchanged resolution must not report it as current. The update cannot put +/// the tree back either: activation reads the manifests of the group it +/// replaces, and those went with the tree. It says so instead of claiming the +/// group is up to date. +#[cfg(unix)] +#[test] +fn global_update_does_not_call_a_group_without_node_modules_up_to_date() { + use assert_cmd::assert::OutputAssertExt; + + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry(); + let pnpm_home = root.path().join("pnpm-home"); + prepare_global_home(&pnpm_home, &npmrc_info); + + global_command(&workspace, &pnpm_home) + .with_args(["add", "-g", "@foo/touch-file-one-bin"]) + .assert() + .success(); + let global_dir = pnpm_home.join("global").join("v11"); + let install_before = pnpm_global::find_global_package(&global_dir, "@foo/touch-file-one-bin") + .expect("scan global packages") + .expect("find the touch-file group"); + fs::remove_dir_all(install_before.install_dir.join("node_modules")) + .expect("remove the group's node_modules"); + + let output = global_command(&workspace, &pnpm_home) + .with_args(["update", "-g"]) + .output() + .expect("run global update over a removed tree"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stdout.contains("Already up to date"), "{stdout}"); + assert!(!output.status.success(), "{stdout}\n{stderr}"); + assert!(stderr.contains("ERR_PNPM_PACKAGE_MANIFEST_IO_ERROR"), "{stderr}"); + + drop((root, npmrc_info)); +} + +#[cfg(unix)] +#[test] +fn global_update_renders_both_changed_groups_with_one_completion_summary() { + use assert_cmd::assert::OutputAssertExt; + + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry_with_own_storage(); + let pnpm_home = root.path().join("pnpm-home"); + prepare_global_home(&pnpm_home, &npmrc_info); + + npmrc_info.set_dist_tag("@pnpm.e2e/multi-version-a", "1.0.0", "latest"); + npmrc_info.set_dist_tag("@pnpm.e2e/multi-version-b", "3.0.0", "latest"); + global_command(&workspace, &pnpm_home) + .with_args(["add", "-g", "@pnpm.e2e/multi-version-a", "@pnpm.e2e/multi-version-b"]) + .assert() + .success(); + npmrc_info.set_dist_tag("@pnpm.e2e/multi-version-a", "2.1.0", "latest"); + npmrc_info.set_dist_tag("@pnpm.e2e/multi-version-b", "3.1.0", "latest"); + + let output = global_command(&workspace, &pnpm_home) + .with_args(["update", "-g", "--latest"]) + .output() + .expect("run two-group global update"); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("@pnpm.e2e/multi-version-a"), "{stdout}"); + assert!(stdout.contains("@pnpm.e2e/multi-version-b"), "{stdout}"); + assert_eq!(stdout.matches("Done in ").count(), 1, "{stdout}"); + + drop((root, npmrc_info)); +} + mod shims; mod ownership; diff --git a/pnpm11/global/commands/package.json b/pnpm11/global/commands/package.json index 12c7edd4c8..343d104064 100644 --- a/pnpm11/global/commands/package.json +++ b/pnpm11/global/commands/package.json @@ -44,6 +44,8 @@ "@pnpm/error": "workspace:*", "@pnpm/global.packages": "workspace:*", "@pnpm/installing.deps-installer": "workspace:*", + "@pnpm/installing.modules-yaml": "workspace:*", + "@pnpm/lockfile.fs": "workspace:*", "@pnpm/pkg-manifest.reader": "workspace:*", "@pnpm/pkg-manifest.utils": "workspace:*", "@pnpm/resolving.parse-wanted-dependency": "workspace:*", diff --git a/pnpm11/global/commands/src/globalUpdate.ts b/pnpm11/global/commands/src/globalUpdate.ts index bad77ab841..752df39268 100644 --- a/pnpm11/global/commands/src/globalUpdate.ts +++ b/pnpm11/global/commands/src/globalUpdate.ts @@ -1,4 +1,6 @@ +import fs from 'node:fs' import path from 'node:path' +import { isDeepStrictEqual } from 'node:util' import type { CommandHandlerMap } from '@pnpm/cli.command' import { summaryLogger } from '@pnpm/core-loggers' @@ -10,7 +12,11 @@ import { type GlobalPackageInfo, scanGlobalPackages, } from '@pnpm/global.packages' +import { readModulesManifest } from '@pnpm/installing.modules-yaml' +import { readWantedLockfile } from '@pnpm/lockfile.fs' +import { logger } from '@pnpm/logger' import type { CreateStoreControllerOptions } from '@pnpm/store.connection-manager' +import type { ProjectManifest } from '@pnpm/types' import semver from 'semver' import { getGlobalBinOwnership } from './binOwnership.js' @@ -33,7 +39,7 @@ export type GlobalUpdateOptions = CreateStoreControllerOptions & { allowBuilds?: Record saveExact?: boolean savePrefix?: string - rootProjectManifest?: unknown + rootProjectManifest?: ProjectManifest handleResolutionPolicyViolations?: (violations: readonly ResolutionPolicyViolation[]) => Promise updateResolutionPolicyManifest?: (violations: readonly ResolutionPolicyViolation[], dir: string) => Promise selectedPackageHashes?: Set @@ -76,8 +82,12 @@ export async function handleGlobalUpdate ( // Update each package group sequentially to avoid overwhelming the system + let changed = false for (const pkg of packagesToUpdate) { - await updateGlobalPackageGroup(opts, globalDir, globalBinDir, pkg, commands) // eslint-disable-line no-await-in-loop + changed = await updateGlobalPackageGroup(opts, globalDir, globalBinDir, pkg, commands) || changed // eslint-disable-line no-await-in-loop + } + if (!changed) { + logger.info({ message: 'Already up to date', prefix: opts.dir }) } summaryLogger.debug({ prefix: globalDir }) return undefined @@ -89,11 +99,35 @@ async function updateGlobalPackageGroup ( globalBinDir: string, pkg: GlobalPackageInfo, commands: CommandHandlerMap -): Promise { +): Promise { const installDir = createInstallDir(globalDir) - const pins = await pinsForDowngrades(opts, installDir, pkg) - const { ignoredBuilds, resolutionPolicyViolations } = - await installGroup(opts, installDir, depSpecsForUpdate(pkg.dependencies, opts.latest, pins)) + const downgradeCheck = await pinsForDowngrades(opts, installDir, pkg) + const depSpecs = depSpecsForUpdate(pkg.dependencies, opts.latest, downgradeCheck.pins) + const comparison = downgradeCheck.candidate != null && downgradeCheck.pins.size === 0 + ? downgradeCheck.candidate + : await installGroup( + { ...opts, lockfileOnly: true, groupDependencies: pkg.dependencies }, + installDir, + depSpecs + ) + + // Equal lockfiles mean no new packages, not that the tree they describe is + // still on disk. The modules manifest is what an install leaves behind. + const activeModules = await readModulesManifest(path.join(pkg.installDir, 'node_modules')) + if (activeModules != null && await lockfilesAreEqual(pkg.installDir, installDir)) { + await fs.promises.rm(installDir, { recursive: true, force: true }) + await promptApproveGlobalBuilds({ + globalPkgDir: globalDir, + installDir: pkg.installDir, + ignoredBuilds: activeModules.ignoredBuilds, + allowBuilds: opts.allowBuilds ?? {}, + inheritedOpts: opts, + }, commands) + await opts.updateResolutionPolicyManifest?.(comparison.resolutionPolicyViolations, globalDir) + return false + } + + const { ignoredBuilds } = await installGroup(opts, installDir, depSpecs) await promptApproveGlobalBuilds({ globalPkgDir: globalDir, @@ -146,16 +180,27 @@ async function updateGlobalPackageGroup ( activatedBins, protectedBins: ownership.protectedBins, }) - await opts.updateResolutionPolicyManifest?.(resolutionPolicyViolations, globalDir) + await opts.updateResolutionPolicyManifest?.(comparison.resolutionPolicyViolations, globalDir) + return true +} + +type InstallGroupOptions = GlobalUpdateOptions & { + lockfileOnly?: boolean + groupDependencies?: Record } /** * Installs `depSpecs` into `installDir`, which the caller has already created * under the global packages dir. The manifest and lockfile are written there; * with `lockfileOnly` nothing else is, so `node_modules` stays absent. + * + * `groupDependencies` is the manifest the install starts from. The first call + * into a fresh `installDir` passes the group's recorded dependencies, without + * which the lockfile would carry no specifiers to compare against the group's + * own. Omitting it starts from the manifest written by an earlier call. */ async function installGroup ( - opts: GlobalUpdateOptions & { lockfileOnly?: boolean }, + opts: InstallGroupOptions, installDir: string, depSpecs: string[] ): Promise { @@ -171,7 +216,7 @@ async function installGroup ( dir: installDir, lockfileDir: installDir, rootProjectManifestDir: installDir, - rootProjectManifest: undefined, + rootProjectManifest: opts.groupDependencies == null ? undefined : { dependencies: opts.groupDependencies }, saveProd: true, saveDev: false, saveOptional: false, @@ -222,26 +267,27 @@ async function pinsForDowngrades ( opts: GlobalUpdateOptions, installDir: string, pkg: GlobalPackageInfo -): Promise> { +): Promise<{ candidate?: InstallGlobalPackagesResult, pins: Map }> { const pins = new Map() // Only `--latest` can pick a version outside the recorded range, and only a // plain version spec is dropped for it. Everything else resolves within a // range the installed version already satisfies, so nothing below — not even // reading the group's installed versions — is worth doing. - if (opts.latest !== true) return pins + if (opts.latest !== true) return { pins } const versionsBefore = new Map( (await getGlobalPackageDetails(pkg)) .filter(({ alias }) => isPlainVersionSpec(pkg.dependencies[alias] ?? '')) .map(({ alias, version }) => [alias, version]) ) // Nothing to compare a resolution against, so nothing to resolve. - if (versionsBefore.size === 0) return pins + if (versionsBefore.size === 0) return { pins } - const { resolvedVersions } = await installGroup( - { ...opts, lockfileOnly: true }, + const candidate = await installGroup( + { ...opts, lockfileOnly: true, groupDependencies: pkg.dependencies }, installDir, depSpecsForUpdate(pkg.dependencies, opts.latest) ) + const { resolvedVersions } = candidate for (const [alias, before] of versionsBefore) { const resolved = resolvedVersions[alias] if (semver.valid(before) == null || semver.valid(resolved) == null) continue @@ -249,7 +295,19 @@ async function pinsForDowngrades ( pins.set(alias, before) } } - return pins + return { candidate, pins } +} + +async function lockfilesAreEqual (activeDir: string, candidateDir: string): Promise { + try { + const [active, candidate] = await Promise.all([ + readWantedLockfile(activeDir, { ignoreIncompatible: false }), + readWantedLockfile(candidateDir, { ignoreIncompatible: false }), + ]) + return active != null && candidate != null && isDeepStrictEqual(active, candidate) + } catch { + return false + } } // Only a plain version range may be dropped in favor of the bare alias. diff --git a/pnpm11/global/commands/src/installGlobalPackages.ts b/pnpm11/global/commands/src/installGlobalPackages.ts index 1bcb47ed4f..0d0a8e48ab 100644 --- a/pnpm11/global/commands/src/installGlobalPackages.ts +++ b/pnpm11/global/commands/src/installGlobalPackages.ts @@ -2,7 +2,7 @@ import { tryReadProjectManifest } from '@pnpm/cli.utils' import { mutateModulesInSingleProject } from '@pnpm/installing.deps-installer' import { getRangeSpecStyle } from '@pnpm/pkg-manifest.utils' import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager' -import type { IgnoredBuilds, IncludedDependencies, ProjectId, ProjectRootDir } from '@pnpm/types' +import type { IgnoredBuilds, IncludedDependencies, ProjectId, ProjectManifest, ProjectRootDir } from '@pnpm/types' export interface ResolutionPolicyViolation { name: string @@ -32,7 +32,7 @@ export interface InstallGlobalPackagesOptions extends CreateStoreControllerOptio include: IncludedDependencies includeDirect?: IncludedDependencies omitSummaryLog?: boolean - rootProjectManifest?: unknown + rootProjectManifest?: ProjectManifest rootProjectManifestDir?: string saveDev?: boolean saveExact?: boolean @@ -50,10 +50,8 @@ export async function installGlobalPackages ( params: string[] ): Promise { const store = await createStoreController(opts) - let { manifest, writeProjectManifest } = await tryReadProjectManifest(opts.dir, opts) - if (manifest == null) { - manifest = {} - } + const { manifest: manifestFromDisk, writeProjectManifest } = await tryReadProjectManifest(opts.dir, opts) + const manifest = opts.rootProjectManifest ?? manifestFromDisk ?? {} const installOpts = { ...opts, allowBuilds: { ...opts.allowBuilds }, diff --git a/pnpm11/global/commands/test/globalUpdate.test.ts b/pnpm11/global/commands/test/globalUpdate.test.ts index f58a3846c5..e81c4a95fb 100644 --- a/pnpm11/global/commands/test/globalUpdate.test.ts +++ b/pnpm11/global/commands/test/globalUpdate.test.ts @@ -11,13 +11,16 @@ const createInstallDir = jest.fn() const getHashLink = jest.fn() const getGlobalPackageDetails = jest.fn<(pkg: unknown) => Promise>>().mockResolvedValue([]) const getInstalledBinNames = jest.fn<(pkg: GlobalPackageInfo) => Promise>().mockResolvedValue([]) +const readModulesManifest = jest.fn<() => Promise<{ ignoredBuilds?: Set } | null>>().mockResolvedValue(null) +const readWantedLockfile = jest.fn<() => Promise>().mockResolvedValue(null) const scanGlobalPackages = jest.fn() const checkGlobalBinConflicts = jest.fn<() => Promise>>().mockResolvedValue(new Set()) -const installGlobalPackages = jest.fn<(...args: unknown[]) => Promise<{ ignoredBuilds: undefined, resolutionPolicyViolations: [], resolvedVersions: Record }>>() +const installGlobalPackages = jest.fn<(...args: unknown[]) => Promise<{ ignoredBuilds: undefined, resolutionPolicyViolations: Array<{ name: string, version: string, code: string, reason: string }>, resolvedVersions: Record }>>() .mockResolvedValue({ ignoredBuilds: undefined, resolutionPolicyViolations: [], resolvedVersions: {} }) -const promptApproveGlobalBuilds = jest.fn<() => Promise>().mockResolvedValue(undefined) +const promptApproveGlobalBuilds = jest.fn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined) const readInstalledPackages = jest.fn<(installDir: string) => Promise>>().mockResolvedValue([]) const summaryDebug = jest.fn() +const info = jest.fn() const activateGlobalInstall = jest.fn<(opts: unknown) => Promise>>().mockResolvedValue(new Set(['fresh'])) const cleanupReplacedGlobalInstalls = jest.fn<(opts: unknown) => Promise>().mockResolvedValue(undefined) const getActualBinNames = jest.fn<(opts: unknown) => Promise>>().mockResolvedValue(new Set(['fresh'])) @@ -31,6 +34,9 @@ jest.unstable_mockModule('@pnpm/global.packages', () => ({ getInstalledBinNames, scanGlobalPackages, })) +jest.unstable_mockModule('@pnpm/installing.modules-yaml', () => ({ readModulesManifest })) +jest.unstable_mockModule('@pnpm/lockfile.fs', () => ({ readWantedLockfile })) +jest.unstable_mockModule('@pnpm/logger', () => ({ logger: { info } })) jest.unstable_mockModule('../src/checkGlobalBinConflicts.js', () => ({ checkGlobalBinConflicts })) jest.unstable_mockModule('../src/globalActivation.js', () => ({ activateGlobalInstall, @@ -49,6 +55,8 @@ beforeEach(() => { cleanupReplacedGlobalInstalls.mockResolvedValue(undefined) getGlobalPackageDetails.mockResolvedValue([]) getInstalledBinNames.mockResolvedValue([]) + readModulesManifest.mockResolvedValue(null) + readWantedLockfile.mockResolvedValue(null) installGlobalPackages.mockResolvedValue({ ignoredBuilds: undefined, resolutionPolicyViolations: [], @@ -60,7 +68,7 @@ beforeEach(() => { }) test('global update emits a single summary after updating all isolated groups', async () => { - const updateResolutionPolicyManifest = jest.fn<() => Promise>().mockResolvedValue(undefined) + const updateResolutionPolicyManifest = jest.fn<(violations: unknown[], dir: string) => Promise>().mockResolvedValue(undefined) createInstallDir .mockReturnValueOnce('/global/v11/install-1') .mockReturnValueOnce('/global/v11/install-2') @@ -87,22 +95,26 @@ test('global update emits a single summary after updating all isolated groups', updateResolutionPolicyManifest, } as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any - expect(installGlobalPackages).toHaveBeenCalledTimes(2) + expect(installGlobalPackages).toHaveBeenCalledTimes(4) expect(installGlobalPackages).toHaveBeenNthCalledWith( 1, expect.objectContaining({ dir: '/global/v11/install-1', global: false, + lockfileOnly: true, omitSummaryLog: true, + rootProjectManifest: { dependencies: { foo: '^1.0.0' } }, }), ['foo@^1.0.0'] ) expect(installGlobalPackages).toHaveBeenNthCalledWith( - 2, + 3, expect.objectContaining({ dir: '/global/v11/install-2', global: false, + lockfileOnly: true, omitSummaryLog: true, + rootProjectManifest: { dependencies: { bar: '^2.0.0' } }, }), ['bar@^2.0.0'] ) @@ -151,6 +163,134 @@ test('global update emits a single summary after updating all isolated groups', expect(summaryDebug).toHaveBeenCalledWith({ prefix: '/global/v11' }) }) +test('global update reports already up to date without replacing an equal candidate', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'global-update-unchanged-')) + const globalDir = path.join(root, 'global') + const oldInstallDir = path.join(globalDir, 'old-install') + const candidateDir = path.join(globalDir, 'candidate') + fs.mkdirSync(oldInstallDir, { recursive: true }) + fs.mkdirSync(candidateDir, { recursive: true }) + createInstallDir.mockReturnValue(candidateDir) + scanGlobalPackages.mockReturnValue([ + { dependencies: { foo: '^1.0.0' }, hash: 'hash-foo', installDir: oldInstallDir }, + ]) + const lockfile = { importers: { '.': { dependencies: { foo: '1.0.0' } } }, lockfileVersion: '9.0' } + readWantedLockfile.mockResolvedValue(lockfile) + const violation = { name: 'foo', version: '1.0.0', code: 'policy', reason: 'test' } + const ignoredBuilds = new Set(['foo@1.0.0']) + readModulesManifest.mockResolvedValue({ ignoredBuilds }) + installGlobalPackages.mockResolvedValue({ + ignoredBuilds: undefined, + resolutionPolicyViolations: [violation], + resolvedVersions: { foo: '1.0.0' }, + }) + const updateResolutionPolicyManifest = jest.fn<(violations: unknown[], dir: string) => Promise>().mockResolvedValue(undefined) + + try { + const output = await handleGlobalUpdate({ + dir: root, + bin: path.join(root, 'bin'), + globalPkgDir: globalDir, + updateResolutionPolicyManifest, + } as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(output).toBeUndefined() + expect(info).toHaveBeenCalledWith({ message: 'Already up to date', prefix: root }) + expect(installGlobalPackages).toHaveBeenCalledTimes(1) + expect(installGlobalPackages).toHaveBeenCalledWith( + expect.objectContaining({ lockfileOnly: true, rootProjectManifest: { dependencies: { foo: '^1.0.0' } } }), + ['foo@^1.0.0'] + ) + expect(fs.existsSync(candidateDir)).toBe(false) + expect(fs.existsSync(oldInstallDir)).toBe(true) + expect(activateGlobalInstall).not.toHaveBeenCalled() + expect(promptApproveGlobalBuilds).toHaveBeenCalledWith({ + globalPkgDir: globalDir, + installDir: oldInstallDir, + ignoredBuilds, + allowBuilds: {}, + inheritedOpts: expect.objectContaining({ dir: root }), + }, {}) + expect(updateResolutionPolicyManifest).toHaveBeenCalledWith([violation], globalDir) + expect(summaryDebug).toHaveBeenCalledTimes(1) + expect(summaryDebug).toHaveBeenCalledWith({ prefix: globalDir }) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('global update does not report already up to date when the active group lost its node_modules', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'global-update-no-modules-')) + const globalDir = path.join(root, 'global') + const oldInstallDir = path.join(globalDir, 'old-install') + const candidateDir = path.join(globalDir, 'candidate') + fs.mkdirSync(oldInstallDir, { recursive: true }) + fs.mkdirSync(candidateDir, { recursive: true }) + createInstallDir.mockReturnValue(candidateDir) + getHashLink.mockReturnValue(path.join(globalDir, 'hash-foo')) + scanGlobalPackages.mockReturnValue([ + { dependencies: { foo: '^1.0.0' }, hash: 'hash-foo', installDir: oldInstallDir }, + ]) + readWantedLockfile.mockResolvedValue({ importers: { '.': { dependencies: { foo: '1.0.0' } } }, lockfileVersion: '9.0' }) + readModulesManifest.mockResolvedValue(null) + + try { + await handleGlobalUpdate({ + dir: root, + bin: path.join(root, 'bin'), + globalPkgDir: globalDir, + } as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(info).not.toHaveBeenCalled() + expect(installGlobalPackages).toHaveBeenCalledTimes(2) + expect(installGlobalPackages).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ dir: candidateDir, lockfileOnly: false }), + ['foo@^1.0.0'] + ) + expect(fs.existsSync(candidateDir)).toBe(true) + expect(activateGlobalInstall).toHaveBeenCalled() + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('global update fails without replacing the active group when equal-candidate cleanup fails', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'global-update-unchanged-cleanup-')) + const globalDir = path.join(root, 'global') + const oldInstallDir = path.join(globalDir, 'old-install') + const candidateDir = path.join(globalDir, 'candidate') + const oldMarker = path.join(oldInstallDir, 'marker') + fs.mkdirSync(oldInstallDir, { recursive: true }) + fs.mkdirSync(candidateDir, { recursive: true }) + fs.writeFileSync(oldMarker, 'active\n') + createInstallDir.mockReturnValue(candidateDir) + scanGlobalPackages.mockReturnValue([ + { dependencies: { foo: '^1.0.0' }, hash: 'hash-foo', installDir: oldInstallDir }, + ]) + readWantedLockfile.mockResolvedValue({ importers: {}, lockfileVersion: '9.0' }) + readModulesManifest.mockResolvedValue({}) + const cleanupError = Object.assign(new Error('candidate cleanup failed'), { code: 'EACCES' }) + const realRm = fs.promises.rm.bind(fs.promises) + const rmSpy = jest.spyOn(fs.promises, 'rm').mockImplementation(async (targetPath, options) => { + if (path.resolve(String(targetPath)) === path.resolve(candidateDir)) throw cleanupError + await realRm(targetPath, options) + }) + + try { + await expect(handleGlobalUpdate({ + bin: path.join(root, 'bin'), + globalPkgDir: globalDir, + } as any, [], {})).rejects.toBe(cleanupError) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(fs.readFileSync(oldMarker, 'utf8')).toBe('active\n') + expect(activateGlobalInstall).not.toHaveBeenCalled() + expect(cleanupReplacedGlobalInstalls).not.toHaveBeenCalled() + } finally { + rmSpy.mockRestore() + fs.rmSync(root, { recursive: true, force: true }) + } +}) + test('global update ignores incomplete survivors when every replaced bin is retained', async () => { const target: GlobalPackageInfo = { dependencies: { foo: '^1.0.0' }, @@ -355,9 +495,10 @@ test('global update only updates interactively selected groups', async () => { selectedPackageHashes: new Set(['hash-foo']), } as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any - expect(installGlobalPackages).toHaveBeenCalledTimes(1) - expect(installGlobalPackages).toHaveBeenCalledWith( - expect.objectContaining({ dir: '/global/v11/install-1' }), + expect(installGlobalPackages).toHaveBeenCalledTimes(2) + expect(installGlobalPackages).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: true }), ['foo@^1.0.0'] ) }) @@ -369,7 +510,7 @@ test('global update does not clean up or persist policy when activation fails', installDir: '/global/v11/old-foo', } const activationError = new Error('activation failed') - const updateResolutionPolicyManifest = jest.fn<() => Promise>().mockResolvedValue(undefined) + const updateResolutionPolicyManifest = jest.fn<(violations: unknown[], dir: string) => Promise>().mockResolvedValue(undefined) createInstallDir.mockReturnValue('/global/v11/install-1') getHashLink.mockReturnValue('/global/v11/hash-foo') scanGlobalPackages.mockReturnValue([group]) @@ -411,11 +552,13 @@ test('global update --latest drops the spec only of plain version dependencies', latest: true, } as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any - expect(installGlobalPackages).toHaveBeenCalledTimes(1) - expect(installGlobalPackages).toHaveBeenCalledWith( + expect(installGlobalPackages).toHaveBeenCalledTimes(2) + expect(installGlobalPackages).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ dir: '/global/v11/install-3', global: false, + lockfileOnly: true, omitSummaryLog: true, }), [ @@ -462,9 +605,10 @@ test('global update leaves the pnpm CLI to self-update', async () => { latest: true, } as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any - expect(installGlobalPackages).toHaveBeenCalledTimes(1) - expect(installGlobalPackages).toHaveBeenCalledWith( - expect.objectContaining({ dir: '/global/v11/install-1' }), + expect(installGlobalPackages).toHaveBeenCalledTimes(2) + expect(installGlobalPackages).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: true }), ['foo'] ) }) @@ -529,6 +673,11 @@ test('global update --latest holds a package that latest would downgrade', async // Only the one that went backwards is held; the other keeps its update. expect(installGlobalPackages).toHaveBeenNthCalledWith( 2, + expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: true }), + ['prerelease@2.0.0', 'stable'] + ) + expect(installGlobalPackages).toHaveBeenNthCalledWith( + 3, expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: false }), ['prerelease@2.0.0', 'stable'] ) @@ -537,7 +686,7 @@ test('global update --latest holds a package that latest would downgrade', async ) }) -test('global update without --latest resolves nothing up front', async () => { +test('global update without --latest resolves once before materialization', async () => { createInstallDir.mockReturnValueOnce('/global/v11/install-1') getHashLink.mockReturnValue('/global/v11/hash-foo') scanGlobalPackages.mockReturnValue([ @@ -550,8 +699,14 @@ test('global update without --latest resolves nothing up front', async () => { globalPkgDir: '/global/v11', } as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any - expect(installGlobalPackages).toHaveBeenCalledTimes(1) - expect(installGlobalPackages).toHaveBeenCalledWith( + expect(installGlobalPackages).toHaveBeenCalledTimes(2) + expect(installGlobalPackages).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: true }), + ['foo@^1.0.0'] + ) + expect(installGlobalPackages).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: false }), ['foo@^1.0.0'] ) diff --git a/pnpm11/global/commands/tsconfig.json b/pnpm11/global/commands/tsconfig.json index 6c61b36ce7..bc05faf070 100644 --- a/pnpm11/global/commands/tsconfig.json +++ b/pnpm11/global/commands/tsconfig.json @@ -48,6 +48,12 @@ { "path": "../../installing/deps-installer" }, + { + "path": "../../installing/modules-yaml" + }, + { + "path": "../../lockfile/fs" + }, { "path": "../../pkg-manifest/reader" }, diff --git a/pnpm11/pnpm/test/install/global.ts b/pnpm11/pnpm/test/install/global.ts index 3b667621e9..fbf0cbac81 100644 --- a/pnpm11/pnpm/test/install/global.ts +++ b/pnpm11/pnpm/test/install/global.ts @@ -407,6 +407,55 @@ test('global update to latest', async () => { expect(pkgJson.version).toBe('3.1.0') }) +test('unchanged global update reports already up to date without replacing the group', async () => { + prepare() + const global = path.resolve('..', 'global') + const pnpmHome = path.join(global, 'pnpm') + fs.mkdirSync(global) + + const env = { [PATH_NAME]: path.join(pnpmHome, 'bin'), PNPM_HOME: pnpmHome, XDG_DATA_HOME: global } + await execPnpm(['add', '--global', 'is-positive@3.1.0'], { env }) + const globalDir = globalPkgDir(pnpmHome) + const installDirBefore = findGlobalPkg(globalDir, 'is-positive') + + const result = execPnpmSync(['update', '--global'], { env, expectSuccess: true }) + const output = `${result.stdout.toString()}\n${result.stderr.toString()}` + + expect(output).toContain('Already up to date') + expect(output).not.toMatch(/Packages:\s+\+\d/) + expect(output).not.toContain('+ is-positive') + expect(output.match(/Done in /g)).toHaveLength(1) + expect(findGlobalPkg(globalDir, 'is-positive')).toBe(installDirBefore) +}) + +test('unchanged global update still approves a pending build', async () => { + prepare() + const global = path.resolve('..', 'global') + const pnpmHome = path.join(global, 'pnpm') + fs.mkdirSync(global) + + const env = { + [PATH_NAME]: `${path.join(pnpmHome, 'bin')}${path.delimiter}${process.env[PATH_NAME]!}`, + PNPM_HOME: pnpmHome, + XDG_DATA_HOME: global, + } + await execPnpm(['add', '--global', '@pnpm.e2e/install-script-example@1.0.0'], { env }) + const globalDir = globalPkgDir(pnpmHome) + const installBefore = findGlobalPkgInstall(globalDir, '@pnpm.e2e/install-script-example')! + const buildArtifact = path.join(installBefore.pkgPath, 'generated-by-install.js') + expect(fs.existsSync(buildArtifact)).toBe(false) + + const result = execPnpmSync(['update', '--global'], { + env: { ...env, PNPM_AUTO_APPROVE_BUILDS_FOR_TESTS: '1' }, + expectSuccess: true, + }) + const output = `${result.stdout.toString()}\n${result.stderr.toString()}` + + expect(output).toContain('Already up to date') + expect(fs.existsSync(buildArtifact)).toBe(true) + expect(findGlobalPkgInstall(globalDir, '@pnpm.e2e/install-script-example')?.installDir).toBe(installBefore.installDir) +}) + test('global update should not crash if there are no global packages', async () => { prepare() const global = path.resolve('..', 'global')