From c112b6106bcaa7349165b40b56ca2b061b4adad2 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 16 Jun 2026 19:12:56 +0200 Subject: [PATCH] feat(install): add --dry-run option (npm-style preview) (#12449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds a `--dry-run` option to `pnpm install` with **npm-style preview semantics**: it runs a full dependency resolution and reports what a real install **would** add/remove/update, but writes **nothing** to disk (no lockfile, no `node_modules`, no `.modules.yaml`, no workspace-state file) and **always exits 0**. ``` $ pnpm install --dry-run Dry run complete. A real install would make the following changes (nothing was written to disk): Importers . + is-negative 1.0.0 Packages + is-negative@1.0.0 ``` When the lockfile is already up to date it prints `Dry run complete. pnpm-lock.yaml is up to date; a real install would make no changes.` Resolves https://github.com/pnpm/pnpm/issues/7340. ### Why this shape An earlier attempt (#12270, now closed) implemented `--dry-run` as `--frozen-lockfile --lockfile-only` — i.e. a fail-on-drift *lockfile validator*. That collides with the well-established meaning of `--dry-run` across npm/yarn ("preview, never fail") and duplicated existing behaviour (`pnpm install --frozen-lockfile --lockfile-only` already does that). This PR implements the intuitive preview meaning instead. ### How it works (pnpm) - Reuses the existing `lockfileCheck` callback (resolve fully, skip the lockfile write, hand back the before/after wanted lockfile) plus `lockfileOnly` (skip `node_modules`, the workspace-state file, and metadata-cache writes). - The frozen/headless fast path is disabled whenever `lockfileCheck` is set, so a check-only install always resolves and never materialises anything. - The before/after lockfiles are diffed (reusing the dedupe diff engine, now exported as `calcDedupeCheckIssues`) and rendered into the report. - `--dry-run` with a configured pnpr server is rejected (that path resolves/links through the server). ### Pacquet Ported in the second commit — `pacquet install --dry-run` forces the fresh-resolve path, skips every write (a new `dry_run` flag on `InstallWithFreshLockfile` skips the `pnpm-lock.yaml` save), and a new `dry_run` module diffs the existing lockfile against the freshly-resolved one and prints the same report. --- .changeset/dry-run-install.md | 8 + config/reader/src/Config.ts | 3 +- installing/commands/package.json | 1 + installing/commands/src/add.ts | 9 +- installing/commands/src/dedupe.ts | 4 +- installing/commands/src/install.ts | 58 ++++- installing/commands/src/installDeps.ts | 23 +- installing/commands/src/prune.ts | 2 +- installing/commands/src/recursive.ts | 12 +- installing/commands/src/remove.ts | 3 + installing/commands/src/update/index.ts | 5 +- installing/commands/test/install.ts | 185 +++++++++++++- installing/commands/tsconfig.json | 3 + .../dedupe/check/src/dedupeDiffCheck.ts | 44 +++- installing/dedupe/check/src/index.ts | 2 +- .../src/install/extendInstallOptions.ts | 7 + .../deps-installer/src/install/index.ts | 68 ++++- pacquet/crates/cli/src/cli_args/install.rs | 33 +++ .../crates/cli/src/cli_args/install/tests.rs | 10 + pacquet/crates/cli/tests/dry_run.rs | 162 ++++++++++++ pacquet/crates/package-manager/src/add.rs | 1 + pacquet/crates/package-manager/src/dry_run.rs | 235 ++++++++++++++++++ .../package-manager/src/dry_run/tests.rs | 109 ++++++++ pacquet/crates/package-manager/src/install.rs | 49 +++- .../package-manager/src/install/tests.rs | 73 +++++- .../src/install_with_fresh_lockfile.rs | 13 +- pacquet/crates/package-manager/src/lib.rs | 1 + pacquet/crates/package-manager/src/remove.rs | 1 + pacquet/crates/package-manager/src/update.rs | 1 + patching/commands/src/patchCommit.ts | 5 +- patching/commands/src/patchRemove.ts | 2 +- pnpm-lock.yaml | 3 + pnpr/crates/pnpr/src/resolver/resolve.rs | 1 + 33 files changed, 1087 insertions(+), 49 deletions(-) create mode 100644 .changeset/dry-run-install.md create mode 100644 pacquet/crates/cli/tests/dry_run.rs create mode 100644 pacquet/crates/package-manager/src/dry_run.rs create mode 100644 pacquet/crates/package-manager/src/dry_run/tests.rs diff --git a/.changeset/dry-run-install.md b/.changeset/dry-run-install.md new file mode 100644 index 0000000000..7368ef6ed9 --- /dev/null +++ b/.changeset/dry-run-install.md @@ -0,0 +1,8 @@ +--- +"@pnpm/installing.dedupe.check": minor +"@pnpm/installing.deps-installer": patch +"@pnpm/installing.commands": minor +"pnpm": minor +--- + +Added a `--dry-run` option to `pnpm install`. It runs a full dependency resolution and reports what an install would change, but writes nothing to disk (no lockfile, no `node_modules`) and always exits with code 0. This mirrors the preview semantics of `npm install --dry-run` [#7340](https://github.com/pnpm/pnpm/issues/7340). diff --git a/config/reader/src/Config.ts b/config/reader/src/Config.ts index fb13091343..c3b6c2ee03 100644 --- a/config/reader/src/Config.ts +++ b/config/reader/src/Config.ts @@ -87,7 +87,8 @@ export interface Config extends OptionsFromRootManifest { filter: string[] filterProd: string[] authConfig: Record, // eslint-disable-line - dryRun?: boolean // This option might be not supported ever + /** When true, `pnpm install` resolves and reports what would change but writes nothing to disk. */ + dryRun?: boolean global?: boolean dir: string bin: string diff --git a/installing/commands/package.json b/installing/commands/package.json index 4f106264f3..a95611a8bd 100644 --- a/installing/commands/package.json +++ b/installing/commands/package.json @@ -57,6 +57,7 @@ "@pnpm/hooks.pnpmfile": "workspace:*", "@pnpm/installing.context": "workspace:*", "@pnpm/installing.dedupe.check": "workspace:*", + "@pnpm/installing.dedupe.issues-renderer": "workspace:*", "@pnpm/installing.deps-installer": "workspace:*", "@pnpm/installing.env-installer": "workspace:*", "@pnpm/lockfile.fs": "workspace:*", diff --git a/installing/commands/src/add.ts b/installing/commands/src/add.ts index 40e261caff..4cc1e6bf08 100644 --- a/installing/commands/src/add.ts +++ b/installing/commands/src/add.ts @@ -305,20 +305,25 @@ export async function handler ( for (const pkg of opts.allowBuild) { mergedAllowBuilds[pkg] = true } - return installDeps({ + await installDeps({ ...opts, allowBuilds: mergedAllowBuilds, rebuildHandler: commands?.rebuild, fetchFullMetadata: getFetchFullMetadata(opts), include, includeDirect: include, + // `--dry-run` is an `install`-only preview; never let a config-level + // `dry-run` turn `add` into a no-op check. + dryRun: false, }, params) + return } - return installDeps({ + await installDeps({ ...opts, rebuildHandler: commands?.rebuild, fetchFullMetadata: getFetchFullMetadata(opts), include, includeDirect: include, + dryRun: false, }, params) } diff --git a/installing/commands/src/dedupe.ts b/installing/commands/src/dedupe.ts index 2715fb5090..da6b4d3974 100644 --- a/installing/commands/src/dedupe.ts +++ b/installing/commands/src/dedupe.ts @@ -61,12 +61,14 @@ export async function handler (opts: DedupeCommandOptions, _params?: string[], c devDependencies: opts.dev !== false, optionalDependencies: opts.optional !== false, } - return installDeps({ + await installDeps({ ...opts, rebuildHandler: commands?.rebuild, dedupe: true, include, includeDirect: include, lockfileCheck: opts.check ? dedupeDiffCheck : undefined, + // `--dry-run` is an `install`-only preview; `dedupe` has its own `--check`. + dryRun: false, }, []) } diff --git a/installing/commands/src/install.ts b/installing/commands/src/install.ts index 963ac7d699..254b9c9fcc 100644 --- a/installing/commands/src/install.ts +++ b/installing/commands/src/install.ts @@ -4,6 +4,9 @@ import { docsUrl } from '@pnpm/cli.utils' import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader' import { WANTED_LOCKFILE } from '@pnpm/constants' import { PnpmError } from '@pnpm/error' +import { calcDedupeCheckIssues, countDedupeCheckIssues } from '@pnpm/installing.dedupe.check' +import { renderDedupeCheckIssues } from '@pnpm/installing.dedupe.issues-renderer' +import type { DryRunInstallResult } from '@pnpm/installing.deps-installer' import type { CreateStoreControllerOptions } from '@pnpm/store.connection-manager' import { pick } from 'ramda' import { renderHelp } from 'render-help' @@ -84,6 +87,7 @@ export function rcOptionsTypes (): Record { export const cliOptionsTypes = (): Record => ({ ...rcOptionsTypes(), ...pick(['force'], allTypes), + 'dry-run': Boolean, 'fix-lockfile': Boolean, 'update-checksums': Boolean, 'resolution-only': Boolean, @@ -138,6 +142,10 @@ For options that may be used with `-r`, see "pnpm help recursive"', description: 'Skip reinstall if the workspace state is up-to-date', name: '--optimistic-repeat-install', }, + { + description: 'Report what an install would change without writing anything to disk (no lockfile, no node_modules). Resolution still runs against the registry.', + name: '--dry-run', + }, { description: '`optionalDependencies` are not installed', name: '--no-optional', @@ -304,6 +312,7 @@ export type InstallCommandOptions = Pick> -export async function handler (opts: InstallCommandOptions & { _calledFromLink?: boolean }, _params?: string[], commands?: CommandHandlerMap): Promise { +export async function handler (opts: InstallCommandOptions & { _calledFromLink?: boolean }, _params?: string[], commands?: CommandHandlerMap): Promise { if (opts.global && !opts._calledFromLink) { throw new PnpmError('GLOBAL_INSTALL_NOT_SUPPORTED', '"pnpm install -g" is not supported. Use "pnpm add -g " to install global packages.') @@ -416,5 +425,50 @@ export async function handler (opts: InstallCommandOptions & { _calledFromLink?: installDepsOptions.lockfileOnly = true installDepsOptions.forceFullResolution = true } - return installDeps(installDepsOptions, []) + if (opts.dryRun) { + return dryRunInstall(installDepsOptions, opts) + } + await installDeps(installDepsOptions, []) +} + +/** + * Runs a full resolution but writes nothing to disk (no lockfile, no + * `node_modules`), then reports what a real install would change. Exits + * successfully regardless of whether changes were found — mirroring the + * preview semantics of `npm install --dry-run`. + */ +async function dryRunInstall (installDepsOptions: InstallDepsOptions, opts: InstallCommandOptions): Promise { + if (opts.pnprServer) { + throw new PnpmError('CONFIG_CONFLICT_DRY_RUN_WITH_PNPR_SERVER', + 'Cannot use --dry-run with a configured pnpr server because the pnpr install path resolves and links through the server') + } + // `dryRun` makes the installer resolve fully and return the before/after + // wanted lockfile without writing anything. `lockfileOnly` keeps it from + // materializing `node_modules` and skips the metadata cache (resolution + // skips fetching). The optimistic fast path is disabled so resolution + // always runs. + installDepsOptions.optimisticRepeatInstall = false + installDepsOptions.lockfileOnly = true + installDepsOptions.dryRun = true + const dryRunResult = await installDeps(installDepsOptions, []) + if (dryRunResult == null) { + // No comparison was produced — this install configuration's resolve path + // doesn't surface the dry-run lockfiles (e.g. a workspace without a + // shared lockfile). Report that explicitly instead of claiming "up to + // date", but keep `--dry-run`'s exit-0 contract. + return 'Dry run complete. Could not compute the changes for this install configuration (no shared lockfile to compare).' + } + return renderDryRunReport(dryRunResult) +} + +function renderDryRunReport (dryRunResult: DryRunInstallResult): string { + const issues = calcDedupeCheckIssues(dryRunResult.originalLockfile, dryRunResult.wantedLockfile, { includeImporterSpecifiers: true }) + if (countDedupeCheckIssues(issues) === 0) { + return `Dry run complete. ${WANTED_LOCKFILE} is up to date; a real install would make no changes.` + } + return [ + 'Dry run complete. A real install would make the following changes (nothing was written to disk):', + '', + renderDedupeCheckIssues(issues), + ].join('\n') } diff --git a/installing/commands/src/installDeps.ts b/installing/commands/src/installDeps.ts index 15df3a5a05..674d6b8c10 100644 --- a/installing/commands/src/installDeps.ts +++ b/installing/commands/src/installDeps.ts @@ -13,6 +13,7 @@ import { checkDepsStatus } from '@pnpm/deps.status' import { PnpmError } from '@pnpm/error' import { arrayOfWorkspacePackagesToMap } from '@pnpm/installing.context' import { + type DryRunInstallResult, install, mutateModulesInSingleProject, type MutateModulesOptions, @@ -175,12 +176,12 @@ export type InstallDepsOptions = Pick> +} & Partial> export async function installDeps ( opts: InstallDepsOptions, params: string[] -): Promise { +): Promise { if (!opts.update && !opts.dedupe && params.length === 0 && opts.optimisticRepeatInstall) { const { upToDate, wantedLockfileToRestore } = await checkDepsStatus({ ...opts, @@ -290,7 +291,7 @@ export async function installDeps ( linkWorkspacePackages: Boolean(opts.linkWorkspacePackages), }).graph - await recursiveInstallThenUpdateWorkspaceState(allProjects, + return recursiveInstallThenUpdateWorkspaceState(allProjects, params, { ...opts, @@ -303,7 +304,6 @@ export async function installDeps ( }, opts.update ? 'update' : (params.length === 0 ? 'install' : 'add') ) - return } } // `pnpm install ""` is going to be just `pnpm install` @@ -408,8 +408,8 @@ export async function installDeps ( rootDir: opts.dir as ProjectRootDir, targetDependenciesField: getSaveType(opts), } - const { updatedCatalogs, updatedProject, ignoredBuilds, resolutionPolicyViolations } = await mutateModulesInSingleProject(mutatedProject, installOpts) - if (opts.save !== false) { + const { updatedCatalogs, updatedProject, ignoredBuilds, resolutionPolicyViolations, dryRunResult } = await mutateModulesInSingleProject(mutatedProject, installOpts) + if (opts.save !== false && !opts.dryRun) { // Only pick entries when we'll actually persist. Otherwise the // info log would claim we added entries the workspace manifest // never saw, and the next install would re-prompt or fail @@ -436,10 +436,10 @@ export async function installDeps ( }) } await handleIgnoredBuilds(opts, ignoredBuilds) - return + return dryRunResult } - const { updatedCatalogs, updatedManifest, ignoredBuilds, resolutionPolicyViolations } = await install(manifest, { + const { updatedCatalogs, updatedManifest, ignoredBuilds, resolutionPolicyViolations, dryRunResult } = await install(manifest, { ...installOpts, updatePackageManifest, updateMatching, @@ -448,7 +448,7 @@ export async function installDeps ( // from this install" — both package.json and the workspace manifest. // Skip the pick so the info log doesn't claim entries were added that // were never written; the next install will resurface them. - if (opts.save !== false) { + if (opts.save !== false && !opts.dryRun) { const policyUpdates = policyHandlers?.pickManifestUpdates(resolutionPolicyViolations) if (opts.update === true) { await Promise.all([ @@ -518,6 +518,7 @@ export async function installDeps ( }) } } + return dryRunResult } function selectProjectByDir (projects: Project[], searchedDir: string): ProjectsGraph | undefined { @@ -532,7 +533,7 @@ async function recursiveInstallThenUpdateWorkspaceState ( opts: RecursiveOptions & WorkspaceStateSettings, cmdFullName: CommandFullName, updatedCatalogs?: Catalogs -): Promise { +): Promise { const recursiveResult = await recursive(allProjects, params, opts, cmdFullName) if (!opts.lockfileOnly) { await updateWorkspaceState({ @@ -544,7 +545,7 @@ async function recursiveInstallThenUpdateWorkspaceState ( configDependencies: opts.configDependencies, }) } - return recursiveResult.passed + return recursiveResult.dryRunResult } /** diff --git a/installing/commands/src/prune.ts b/installing/commands/src/prune.ts index 7a4828fbbb..0d83c4b601 100644 --- a/installing/commands/src/prune.ts +++ b/installing/commands/src/prune.ts @@ -48,7 +48,7 @@ export function help (): string { export async function handler ( opts: install.InstallCommandOptions ): Promise { - return install.handler({ + await install.handler({ ...opts, modulesCacheMaxAge: 0, pruneDirectDependencies: true, diff --git a/installing/commands/src/recursive.ts b/installing/commands/src/recursive.ts index a5eef2fc16..8e6f626f50 100755 --- a/installing/commands/src/recursive.ts +++ b/installing/commands/src/recursive.ts @@ -22,6 +22,7 @@ import { requireHooks } from '@pnpm/hooks.pnpmfile' import { arrayOfWorkspacePackagesToMap } from '@pnpm/installing.context' import { addDependenciesToPackage, + type DryRunInstallResult, install, type InstallOptions, type MutatedProject, @@ -65,6 +66,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick { + const project = prepare({ + dependencies: { + 'is-positive': '1.0.0', + }, + }) + + await install.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + }) + + // Add a new dependency so a real install would change the lockfile and node_modules. + fs.writeFileSync('package.json', JSON.stringify({ + dependencies: { 'is-positive': '1.0.0', 'is-negative': '1.0.0' }, + })) + const lockfileBefore = fs.readFileSync('pnpm-lock.yaml', 'utf8') + + const output = await install.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + dryRun: true, + }) + + expect(output).toContain('is-negative') + // Nothing is written: the lockfile is untouched and the new dependency is not linked. + expect(fs.readFileSync('pnpm-lock.yaml', 'utf8')).toBe(lockfileBefore) + project.hasNot('is-negative') +}) + +test('install --dry-run reports no changes when the project is already up to date', async () => { + prepare({ + dependencies: { + 'is-positive': '1.0.0', + }, + }) + + await install.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + }) + + const output = await install.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + dryRun: true, + }) + + expect(output).toContain('up to date') +}) + +test('install --dry-run reports a specifier-only change to a direct dependency', async () => { + prepare({ + dependencies: { + 'is-positive': '1.0.0', + }, + }) + + await install.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + }) + + // Change only the specifier; it still resolves to the same version. + fs.writeFileSync('package.json', JSON.stringify({ + dependencies: { 'is-positive': '~1.0.0' }, + })) + const lockfileBefore = fs.readFileSync('pnpm-lock.yaml', 'utf8') + + const output = await install.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + dryRun: true, + }) + + // A real install would rewrite the lockfile's specifier, so this is a change. + expect(output).not.toContain('up to date') + expect(output).toContain('is-positive') + expect(fs.readFileSync('pnpm-lock.yaml', 'utf8')).toBe(lockfileBefore) +}) + +test('install --dry-run reports a direct dependency moving between groups', async () => { + prepare({ + dependencies: { + 'is-positive': '1.0.0', + }, + }) + + await install.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + }) + + // Move is-positive from dependencies to devDependencies; the specifier and + // resolved version are unchanged, but a real install rewrites the importer + // section of the lockfile. + fs.writeFileSync('package.json', JSON.stringify({ + devDependencies: { 'is-positive': '1.0.0' }, + })) + const lockfileBefore = fs.readFileSync('pnpm-lock.yaml', 'utf8') + + const output = await install.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + dryRun: true, + }) + + expect(output).not.toContain('up to date') + expect(output).toContain('is-positive') + expect(fs.readFileSync('pnpm-lock.yaml', 'utf8')).toBe(lockfileBefore) +}) + +test('install --dry-run reports changes in a workspace without writing', async () => { + preparePackages([ + { + name: 'project-1', + version: '1.0.0', + dependencies: { 'is-positive': '1.0.0' }, + }, + ]) + + const selectWorkspace = () => filterProjectsBySelectorObjectsFromDir(process.cwd(), []) + + { + const { allProjects, selectedProjectsGraph } = await selectWorkspace() + await install.handler({ + ...DEFAULT_OPTS, + allProjects, + dir: process.cwd(), + recursive: true, + selectedProjectsGraph, + lockfileDir: process.cwd(), + sharedWorkspaceLockfile: true, + workspaceDir: process.cwd(), + }) + } + + // Add a dependency to a workspace project so the shared lockfile is stale. + fs.writeFileSync('project-1/package.json', JSON.stringify({ + name: 'project-1', + version: '1.0.0', + dependencies: { 'is-positive': '1.0.0', 'is-negative': '1.0.0' }, + })) + const lockfileBefore = fs.readFileSync('pnpm-lock.yaml', 'utf8') + const projectManifestBefore = fs.readFileSync('project-1/package.json', 'utf8') + + const { allProjects, selectedProjectsGraph } = await selectWorkspace() + const output = await install.handler({ + ...DEFAULT_OPTS, + allProjects, + dir: process.cwd(), + recursive: true, + selectedProjectsGraph, + lockfileDir: process.cwd(), + sharedWorkspaceLockfile: true, + workspaceDir: process.cwd(), + dryRun: true, + }) + + // The recursive path must surface the change rather than mask it as up to date. + expect(output).not.toContain('up to date') + expect(output).toContain('is-negative') + // Nothing is written: not the lockfile, nor the project manifest. + expect(fs.readFileSync('pnpm-lock.yaml', 'utf8')).toBe(lockfileBefore) + expect(fs.readFileSync('project-1/package.json', 'utf8')).toBe(projectManifestBefore) +}) + +test('a config-level dryRun does not turn add into a no-op', async () => { + prepareEmpty() + + // `--dry-run` is install-only; a config-level `dry-run` (it is a real config + // key) must not silently make `add` a check-only no-op. + await add.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + dryRun: true, + }, ['is-positive@1.0.0']) + + const pkg = loadJsonFileSync<{ dependencies?: Record }>(path.resolve('package.json')) + expect(pkg.dependencies).toStrictEqual({ 'is-positive': '1.0.0' }) +}) diff --git a/installing/commands/tsconfig.json b/installing/commands/tsconfig.json index 68fde6a9b4..20abcbe80e 100644 --- a/installing/commands/tsconfig.json +++ b/installing/commands/tsconfig.json @@ -171,6 +171,9 @@ { "path": "../dedupe/check" }, + { + "path": "../dedupe/issues-renderer" + }, { "path": "../deps-installer" }, diff --git a/installing/dedupe/check/src/dedupeDiffCheck.ts b/installing/dedupe/check/src/dedupeDiffCheck.ts index bc648ce11b..d04f93cfd3 100644 --- a/installing/dedupe/check/src/dedupeDiffCheck.ts +++ b/installing/dedupe/check/src/dedupeDiffCheck.ts @@ -10,17 +10,51 @@ import { DedupeCheckIssuesError } from './DedupeCheckIssuesError.js' const PACKAGE_SNAPSHOT_DEP_FIELDS = ['dependencies', 'optionalDependencies'] as const -export function dedupeDiffCheck (prev: LockfileObject, next: LockfileObject): void { - const issues: DedupeCheckIssues = { - importerIssuesByImporterId: diffSnapshots(prev.importers, next.importers, DEPENDENCIES_FIELDS), +// Dry-run diffs importers by both the per-group dependency fields and the +// flat `specifiers` map, so it catches every importer rewrite a real install +// would persist: the per-group fields surface a dependency moving between +// `dependencies`/`devDependencies`/`optionalDependencies`, while `specifiers` +// surfaces a specifier-only edit (same resolved version). `specifiers` is +// last so that on a per-alias collision its update wins — rendering the +// specifier delta rather than the re-resolved version, which is cleared in +// memory for a specifier-mismatched dep. +const IMPORTER_DRY_RUN_FIELDS = [...DEPENDENCIES_FIELDS, 'specifiers'] as const + +/** + * Compute the changes between two lockfiles, as added/removed/updated + * importer and package snapshots. Unlike {@link dedupeDiffCheck} this never + * throws — callers that only want to report the diff (e.g. `install + * --dry-run`) consume the result directly. + * + * `includeImporterSpecifiers` also diffs each importer's direct-dependency + * `specifier`s, not just their resolved versions. `pnpm install --dry-run` + * sets it so a specifier-only manifest edit (which a real install would + * persist to the lockfile) is reported; `dedupe --check` leaves it off + * because a specifier change is irrelevant to deduplication. + */ +export function calcDedupeCheckIssues ( + prev: LockfileObject, + next: LockfileObject, + opts?: { includeImporterSpecifiers?: boolean } +): DedupeCheckIssues { + const importerFields = opts?.includeImporterSpecifiers ? IMPORTER_DRY_RUN_FIELDS : DEPENDENCIES_FIELDS + return { + importerIssuesByImporterId: diffSnapshots(prev.importers, next.importers, importerFields), packageIssuesByDepPath: diffSnapshots(prev.packages ?? {}, next.packages ?? {}, PACKAGE_SNAPSHOT_DEP_FIELDS), } +} - const changesCount = +export function countDedupeCheckIssues (issues: DedupeCheckIssues): number { + return ( countChangedSnapshots(issues.importerIssuesByImporterId) + countChangedSnapshots(issues.packageIssuesByDepPath) + ) +} - if (changesCount > 0) { +export function dedupeDiffCheck (prev: LockfileObject, next: LockfileObject): void { + const issues = calcDedupeCheckIssues(prev, next) + + if (countDedupeCheckIssues(issues) > 0) { throw new DedupeCheckIssuesError(issues) } } diff --git a/installing/dedupe/check/src/index.ts b/installing/dedupe/check/src/index.ts index 6061806e03..bc3e60a1b1 100644 --- a/installing/dedupe/check/src/index.ts +++ b/installing/dedupe/check/src/index.ts @@ -1,2 +1,2 @@ export { DedupeCheckIssuesError } from './DedupeCheckIssuesError.js' -export { countChangedSnapshots, dedupeDiffCheck } from './dedupeDiffCheck.js' +export { calcDedupeCheckIssues, countChangedSnapshots, countDedupeCheckIssues, dedupeDiffCheck } from './dedupeDiffCheck.js' diff --git a/installing/deps-installer/src/install/extendInstallOptions.ts b/installing/deps-installer/src/install/extendInstallOptions.ts index 3aa181e400..21f06dbcd5 100644 --- a/installing/deps-installer/src/install/extendInstallOptions.ts +++ b/installing/deps-installer/src/install/extendInstallOptions.ts @@ -65,6 +65,13 @@ export interface StrictInstallOptions { preferFrozenLockfile: boolean saveWorkspaceProtocol: boolean | 'rolling' lockfileCheck?: (prev: LockfileObject, next: LockfileObject) => void + /** + * When true, resolve fully but write nothing to disk (no lockfile, no + * `node_modules`). The before/after wanted lockfiles are returned in the + * install result's `dryRunResult` so the caller can report what an install + * would change. Powers `pnpm install --dry-run`. + */ + dryRun?: boolean lockfileIncludeTarballUrl?: boolean preferWorkspacePackages: boolean preserveWorkspaceProtocol: boolean diff --git a/installing/deps-installer/src/install/index.ts b/installing/deps-installer/src/install/index.ts index a75f942057..364cc666e3 100644 --- a/installing/deps-installer/src/install/index.ts +++ b/installing/deps-installer/src/install/index.ts @@ -169,6 +169,8 @@ export interface InstallResult { ignoredBuilds: IgnoredBuilds | undefined /** Forwarded from {@link MutateModulesResult.resolutionPolicyViolations}. */ resolutionPolicyViolations: ResolutionPolicyViolation[] + /** Forwarded from {@link MutateModulesResult.dryRunResult}. */ + dryRunResult?: DryRunInstallResult } export async function install ( @@ -183,7 +185,7 @@ export async function install ( return installViaPnprServer(manifest, rootDir, opts) } - const { updatedCatalogs, updatedProjects: projects, ignoredBuilds, resolutionPolicyViolations } = await mutateModules( + const { updatedCatalogs, updatedProjects: projects, ignoredBuilds, resolutionPolicyViolations, dryRunResult } = await mutateModules( [ { mutation: 'install', @@ -205,7 +207,7 @@ export async function install ( }], } ) - return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds, resolutionPolicyViolations } + return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds, resolutionPolicyViolations, dryRunResult } } interface ProjectToBeInstalled { @@ -231,6 +233,8 @@ export interface MutateModulesInSingleProjectResult { ignoredBuilds: IgnoredBuilds | undefined /** Forwarded from {@link MutateModulesResult.resolutionPolicyViolations}. */ resolutionPolicyViolations: ResolutionPolicyViolation[] + /** Forwarded from {@link MutateModulesResult.dryRunResult}. */ + dryRunResult?: DryRunInstallResult } export async function mutateModulesInSingleProject ( @@ -265,6 +269,7 @@ export async function mutateModulesInSingleProject ( updatedProject: result.updatedProjects[0], ignoredBuilds: result.ignoredBuilds, resolutionPolicyViolations: result.resolutionPolicyViolations, + dryRunResult: result.dryRunResult, } } @@ -283,6 +288,11 @@ export interface MutateModulesResult { * verifier reported a violation or no policy was active. */ resolutionPolicyViolations: ResolutionPolicyViolation[] + /** + * Present only for a `dryRun` install: the before/after wanted lockfiles + * the resolve produced without writing, for the caller to diff. + */ + dryRunResult?: DryRunInstallResult } const pickCatalogSpecifier: CatalogResultMatcher = { @@ -392,7 +402,7 @@ export async function mutateModules ( opts.useLockfile && !opts.useGitBranchLockfile && !opts.mergeGitBranchLockfiles && - opts.lockfileCheck == null && + !isCheckOnlyInstall(opts) && opts.enableModulesDir && installsOnly && !opts.lockfileOnly && @@ -555,6 +565,7 @@ export async function mutateModules ( depsRequiringBuild: result.depsRequiringBuild, ignoredBuilds, resolutionPolicyViolations: result.resolutionPolicyViolations ?? [], + dryRunResult: result.dryRunResult, } interface InnerInstallResult { @@ -563,6 +574,7 @@ export async function mutateModules ( readonly stats?: InstallationResultStats readonly depsRequiringBuild?: DepPath[] readonly ignoredBuilds: IgnoredBuilds | undefined + readonly dryRunResult?: DryRunInstallResult readonly resolutionPolicyViolations?: ResolutionPolicyViolation[] } @@ -939,6 +951,7 @@ export async function mutateModules ( depsRequiringBuild: result.depsRequiringBuild, ignoredBuilds: result.ignoredBuilds, resolutionPolicyViolations: result.resolutionPolicyViolations, + dryRunResult: result.dryRunResult, } } @@ -984,6 +997,12 @@ export async function mutateModules ( !opts.fixLockfile && !opts.dedupe && + // A check-only install (`lockfileCheck`, used by `--dry-run` and + // `dedupe --check`) must always run a full resolution so the wanted + // lockfile can be compared, and must never materialize anything. The + // frozen path would skip resolution and/or perform a real install. + !isCheckOnlyInstall(opts) && + installsOnly && ( // If the user explicitly requested a frozen lockfile install, attempt @@ -1082,7 +1101,7 @@ Note that in CI environments, this setting is enabled by default.`, } else { logger.info({ message: 'Lockfile is up to date, resolution step is skipped', prefix: opts.lockfileDir }) } - if (opts.runPacquet != null && opts.useLockfile && !opts.useGitBranchLockfile && !opts.mergeGitBranchLockfiles && opts.lockfileCheck == null && opts.enableModulesDir) { + if (opts.runPacquet != null && opts.useLockfile && !opts.useGitBranchLockfile && !opts.mergeGitBranchLockfiles && !isCheckOnlyInstall(opts) && opts.enableModulesDir) { try { await opts.runPacquet.run() } catch (err) { @@ -1377,6 +1396,26 @@ export interface UpdatedProject { rootDir: ProjectRootDir } +/** + * The before/after wanted lockfiles a `dryRun` install resolved without + * writing. The caller diffs them to report what a real install would change. + */ +export interface DryRunInstallResult { + originalLockfile: LockfileObject + wantedLockfile: LockfileObject +} + +/** + * A "check-only" install resolves fully but writes nothing: `dryRun` + * (`pnpm install --dry-run`) and `lockfileCheck` (`pnpm dedupe --check`) + * both take this path. The shared flag suppresses every write and forces a + * full resolution (the frozen/headless fast paths are skipped) so the wanted + * lockfile can always be compared. + */ +function isCheckOnlyInstall (opts: { lockfileCheck?: unknown, dryRun?: boolean }): boolean { + return opts.lockfileCheck != null || opts.dryRun === true +} + interface InstallFunctionResult { updatedCatalogs?: Catalogs newLockfile: LockfileObject @@ -1385,6 +1424,7 @@ interface InstallFunctionResult { depsRequiringBuild: DepPath[] ignoredBuilds?: IgnoredBuilds resolutionPolicyViolations: ResolutionPolicyViolation[] + dryRunResult?: DryRunInstallResult } type InstallFunction = ( @@ -1407,19 +1447,20 @@ type InstallFunction = ( ) => Promise const _installInContext: InstallFunction = async (projects, ctx, opts) => { + // Aliasing for clarity in boolean expressions below. True for both + // `--dry-run` and `dedupe --check`: resolve fully, write nothing. + const isInstallationOnlyForLockfileCheck = isCheckOnlyInstall(opts) + // The wanted lockfile is mutated during installation. To compare changes, a // deep copy before installation is needed. This copy should represent the // original wanted lockfile on disk as close as possible. // // This object can be quite large. Intentionally avoiding an expensive copy - // if no lockfileCheck option was passed in. - const originalLockfileForCheck = opts.lockfileCheck != null + // unless this is a check-only install that needs the comparison. + const originalLockfileForCheck = isInstallationOnlyForLockfileCheck ? clone(ctx.wantedLockfile) : null - // Aliasing for clarity in boolean expressions below. - const isInstallationOnlyForLockfileCheck = opts.lockfileCheck != null - ctx.wantedLockfile.importers = ctx.wantedLockfile.importers || {} for (const { id } of projects) { if (!ctx.wantedLockfile.importers[id]) { @@ -1952,6 +1993,9 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { depsRequiringBuild, ignoredBuilds, resolutionPolicyViolations, + dryRunResult: (opts.dryRun && originalLockfileForCheck != null) + ? { originalLockfile: originalLockfileForCheck, wantedLockfile: newLockfile } + : undefined, } } @@ -2031,7 +2075,7 @@ const installInContext: InstallFunction = async (projects, ctx, opts) => { if (!opts.frozenLockfile && opts.useLockfile) { const allProjectsLocatedInsideWorkspace = Object.values(ctx.projects) .filter((project) => isPathInsideWorkspace(project.rootDirRealPath ?? project.rootDir)) - if (allProjectsLocatedInsideWorkspace.length > projects.length && opts.lockfileCheck == null && opts.enableModulesDir) { + if (allProjectsLocatedInsideWorkspace.length > projects.length && !isCheckOnlyInstall(opts) && opts.enableModulesDir) { const newProjects = [...projects] const getWantedDepsOpts = { autoInstallPeers: opts.autoInstallPeers, @@ -2083,7 +2127,7 @@ const installInContext: InstallFunction = async (projects, ctx, opts) => { } } } - if (opts.nodeLinker === 'hoisted' && !opts.lockfileOnly && opts.lockfileCheck == null && opts.enableModulesDir) { + if (opts.nodeLinker === 'hoisted' && !opts.lockfileOnly && !isCheckOnlyInstall(opts) && opts.enableModulesDir) { const result = await _installInContext(projects, ctx, { ...opts, lockfileOnly: true, @@ -2112,7 +2156,7 @@ const installInContext: InstallFunction = async (projects, ctx, opts) => { // Isolated `nodeLinker` (the default) with a non-frozen install. // The frozen branch is handled earlier in `tryFrozenInstall`; the // hoisted branch above runs a resolve-then-materialize sequence. - if (opts.runPacquet != null && opts.useLockfile && opts.saveLockfile && !opts.useGitBranchLockfile && !opts.mergeGitBranchLockfiles && !opts.lockfileOnly && opts.lockfileCheck == null && opts.enableModulesDir) { + if (opts.runPacquet != null && opts.useLockfile && opts.saveLockfile && !opts.useGitBranchLockfile && !opts.mergeGitBranchLockfiles && !opts.lockfileOnly && !isCheckOnlyInstall(opts) && opts.enableModulesDir) { // pacquet >= 0.11.7 resolves itself: hand it the whole install // (resolve + fetch + import + link + build, writing the lockfile) // in a single non-frozen pass. Only for plain installs — `add` / diff --git a/pacquet/crates/cli/src/cli_args/install.rs b/pacquet/crates/cli/src/cli_args/install.rs index dece38bf99..001c63d946 100644 --- a/pacquet/crates/cli/src/cli_args/install.rs +++ b/pacquet/crates/cli/src/cli_args/install.rs @@ -95,6 +95,13 @@ pub struct InstallArgs { #[clap(long = "lockfile-only")] pub lockfile_only: bool, + /// Report what an install would change without writing anything to + /// disk (no `pnpm-lock.yaml`, no `node_modules`). Resolution still + /// runs against the registry. Exits 0 whether or not changes were + /// found. Mirrors pnpm's `install --dry-run`. + #[clap(long = "dry-run")] + pub dry_run: bool, + /// Force-enable `preferFrozenLockfile` for this invocation. /// Overrides `pnpm-workspace.yaml` / `PNPM_CONFIG_PREFER_FROZEN_LOCKFILE`. /// Mirrors pnpm's `--prefer-frozen-lockfile`. Conflicts with @@ -343,6 +350,7 @@ impl InstallArgs { supported_architectures, frozen_lockfile, lockfile_only, + dry_run, prefer_frozen_lockfile, no_prefer_frozen_lockfile, ignore_manifest_check, @@ -419,6 +427,12 @@ impl InstallArgs { // server-produced lockfile via the normal frozen install. Mirrors // pnpm's `install()` delegating to `installFromPnpmRegistry`. if let Some(pnpr_server) = config.pnpr_server.as_deref() { + // The pnpr path resolves and links through the server, so it + // can't honor `--dry-run`'s no-write contract. Reject up front, + // mirroring pnpm's CONFIG_CONFLICT_DRY_RUN_WITH_PNPR_SERVER. + if dry_run { + return Err(DryRunIncompatibleWithPnpr.into()); + } return install_via_pnpr::( &state, pnpr_server, @@ -462,6 +476,7 @@ impl InstallArgs { supported_architectures, node_linker, lockfile_only, + dry_run, update_seed_policy: UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -545,6 +560,22 @@ struct PnprLink<'a> { )] struct FrozenStoreIncompatibleWithPnpr; +/// `--dry-run` was requested with a configured `pnprServer`. The pnpr path +/// resolves and links through the server, so it can't honor the dry-run +/// "writes nothing" contract. Mirrors pnpm's +/// `ERR_PNPM_CONFIG_CONFLICT_DRY_RUN_WITH_PNPR_SERVER`. +#[derive(Debug, Display, Error, Diagnostic)] +#[display( + "Cannot use --dry-run with a configured pnpr server because the pnpr install path resolves and links through the server." +)] +#[diagnostic( + code(ERR_PNPM_CONFIG_CONFLICT_DRY_RUN_WITH_PNPR_SERVER), + help( + "Unset the pnpr server (`--pnpr-server` / `pnprServer` in pnpm-workspace.yaml) to preview locally, or drop --dry-run." + ) +)] +struct DryRunIncompatibleWithPnpr; + /// Resolve a single project through a `pnpr` server, then link it. /// /// Sends the client's registries to the server, which resolves against @@ -701,6 +732,7 @@ async fn install_via_pnpr( supported_architectures: link.supported_architectures, node_linker: link.node_linker, lockfile_only: false, + dry_run: false, update_seed_policy: UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -828,6 +860,7 @@ async fn install_via_pnpr( supported_architectures: link.supported_architectures, node_linker: link.node_linker, lockfile_only: false, + dry_run: false, update_seed_policy: UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, diff --git a/pacquet/crates/cli/src/cli_args/install/tests.rs b/pacquet/crates/cli/src/cli_args/install/tests.rs index d064127a60..9bbbb4d37a 100644 --- a/pacquet/crates/cli/src/cli_args/install/tests.rs +++ b/pacquet/crates/cli/src/cli_args/install/tests.rs @@ -129,6 +129,16 @@ fn ignore_manifest_check_flag_parses() { assert!(parsed.args.ignore_manifest_check, "flag present → true"); } +#[test] +fn dry_run_flag_parses() { + let parsed = InstallArgsHarness::try_parse_from(["pacquet-test"]).expect("parses"); + assert!(!parsed.args.dry_run, "flag absent → false"); + + let parsed = InstallArgsHarness::try_parse_from(["pacquet-test", "--dry-run"]) + .expect("parses --dry-run"); + assert!(parsed.args.dry_run, "flag present → true"); +} + /// `--frozen-store` parses to `true`. Absent → `false`. The flag is /// folded into `config.frozen_store` at the dispatch in `cli_args.rs` /// (any `--frozen-store` upgrades a yaml `false` to `true`), so the diff --git a/pacquet/crates/cli/tests/dry_run.rs b/pacquet/crates/cli/tests/dry_run.rs new file mode 100644 index 0000000000..4781b9f7b5 --- /dev/null +++ b/pacquet/crates/cli/tests/dry_run.rs @@ -0,0 +1,162 @@ +//! `--dry-run` coverage for `pacquet install`. +//! +//! `pacquet install --dry-run` runs a full resolution and reports what a +//! real install would change, but writes nothing to disk (no +//! `pnpm-lock.yaml`, no `node_modules`) and exits 0 regardless of whether +//! changes were found. Mirrors pnpm's `install --dry-run` (pnpm/pnpm#7340). + +use assert_cmd::prelude::*; +use command_extra::CommandExtra; +use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd}; +use std::{fs, path::Path, process::Command}; + +/// A fresh `pacquet` command rooted at `workspace`. +fn pacquet_at(workspace: &Path) -> Command { + Command::cargo_bin("pacquet").expect("find the pacquet binary").with_current_dir(workspace) +} + +/// On a fresh project (no lockfile), `--dry-run` reports the dependencies a +/// real install would add and writes nothing: no `pnpm-lock.yaml`, no +/// `node_modules`. +#[test] +fn dry_run_reports_changes_without_writing() { + let CommandTempCwd { pacquet, 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"); + + let output = pacquet.with_args(["install", "--dry-run"]).output().expect("spawn pacquet"); + assert!( + output.status.success(), + "--dry-run must exit 0 (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("is-positive"), + "the report must name the dependency a real install would add; got:\n{stdout}", + ); + + assert!(!workspace.join("pnpm-lock.yaml").exists(), "--dry-run must not write pnpm-lock.yaml"); + assert!(!workspace.join("node_modules").exists(), "--dry-run must not create node_modules"); + + drop((root, mock_instance)); +} + +/// Against an existing lockfile, `--dry-run` reports the new dependency a +/// real install would add and leaves the lockfile byte-for-byte unchanged. +#[test] +fn dry_run_reports_added_dependency_without_touching_the_lockfile() { + let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry(); + let AddMockedRegistry { mock_instance, .. } = npmrc_info; + + let manifest_path = workspace.join("package.json"); + fs::write( + &manifest_path, + serde_json::json!({ "dependencies": { "is-positive": "1.0.0" } }).to_string(), + ) + .expect("write package.json"); + + // Seed a lockfile. + pacquet.with_args(["install", "--lockfile-only"]).assert().success(); + let lockfile_path = workspace.join("pnpm-lock.yaml"); + let lockfile_before = fs::read_to_string(&lockfile_path).expect("read seeded lockfile"); + + // Drift the manifest: add a dependency. + fs::write( + &manifest_path, + serde_json::json!({ "dependencies": { "is-positive": "1.0.0", "is-negative": "1.0.0" } }) + .to_string(), + ) + .expect("rewrite package.json"); + + let output = + pacquet_at(&workspace).with_args(["install", "--dry-run"]).output().expect("spawn pacquet"); + assert!( + output.status.success(), + "--dry-run must exit 0 even when the lockfile is stale (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("is-negative"), + "the report must name the would-be-added dependency; got:\n{stdout}", + ); + + let lockfile_after = fs::read_to_string(&lockfile_path).expect("read lockfile after --dry-run"); + assert_eq!(lockfile_before, lockfile_after, "--dry-run must not rewrite pnpm-lock.yaml"); + assert!(!workspace.join("node_modules").exists(), "--dry-run must not create node_modules"); + + drop((root, mock_instance)); +} + +/// When the lockfile is already up to date, `--dry-run` reports no changes +/// and still exits 0. +#[test] +fn dry_run_reports_no_changes_when_up_to_date() { + let CommandTempCwd { pacquet, 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.with_args(["install", "--lockfile-only"]).assert().success(); + + let output = + pacquet_at(&workspace).with_args(["install", "--dry-run"]).output().expect("spawn pacquet"); + assert!( + output.status.success(), + "--dry-run must exit 0 (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("up to date"), + "the report must say the lockfile is up to date; got:\n{stdout}", + ); + + drop((root, mock_instance)); +} + +/// `--dry-run` is rejected when a pnpr server is configured: that path +/// resolves and links through the server, so it can't honor the no-write +/// contract. Mirrors pnpm's `CONFIG_CONFLICT_DRY_RUN_WITH_PNPR_SERVER`. +#[test] +fn dry_run_rejects_pnpr_server() { + let CommandTempCwd { pacquet, 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"); + + let output = pacquet + .with_args(["install", "--dry-run", "--pnpr-server", "http://localhost:1"]) + .output() + .expect("spawn pacquet"); + assert!( + !output.status.success(), + "--dry-run with a pnpr server must fail (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Cannot use --dry-run with a configured pnpr server"), + "stderr must name the dry-run/pnpr conflict; got:\n{stderr}", + ); + + drop((root, mock_instance)); +} diff --git a/pacquet/crates/package-manager/src/add.rs b/pacquet/crates/package-manager/src/add.rs index 9de97932c9..1f42920341 100644 --- a/pacquet/crates/package-manager/src/add.rs +++ b/pacquet/crates/package-manager/src/add.rs @@ -289,6 +289,7 @@ where supported_architectures, node_linker: config.node_linker, lockfile_only, + dry_run: false, // `add` keeps every lockfile pin; the freshly-added range // is the only thing that re-resolves. `update`'s bump is a // separate operation. diff --git a/pacquet/crates/package-manager/src/dry_run.rs b/pacquet/crates/package-manager/src/dry_run.rs new file mode 100644 index 0000000000..6083726367 --- /dev/null +++ b/pacquet/crates/package-manager/src/dry_run.rs @@ -0,0 +1,235 @@ +//! Diff + report for `pacquet install --dry-run`. +//! +//! Compares the freshly-resolved lockfile against the existing on-disk one +//! and renders a human report of what a real install would change, without +//! writing anything. Mirrors pnpm's `install --dry-run` preview. + +use std::collections::{BTreeMap, BTreeSet}; + +use pacquet_lockfile::{Lockfile, ProjectSnapshot, SnapshotEntry}; + +/// What a real install would change, derived from two lockfiles. +/// +/// Package-level changes are diffed over the v9 `snapshots:` map — the +/// peer-aware dependency wiring a real install rewrites — to match pnpm's +/// `dedupeDiffCheck`, whose in-memory `packages` map is depPath-keyed. +#[derive(Debug, Default)] +pub struct LockfileDiff { + /// Per-importer direct-dependency changes, in importer-id order. + pub importers: Vec, + /// `snapshots:` keys present in the new lockfile but not the old. + pub added_packages: Vec, + /// `snapshots:` keys present in the old lockfile but not the new. + pub removed_packages: Vec, + /// `snapshots:` keys present in both whose dependency wiring changed. + pub updated_packages: Vec, +} + +/// Direct-dependency changes for a single importer, keyed by manifest +/// specifier. +#[derive(Debug)] +pub struct ImporterDiff { + pub id: String, + /// `(alias, specifier)` pairs newly added. + pub added: Vec<(String, String)>, + /// `(alias, specifier)` pairs removed. + pub removed: Vec<(String, String)>, + /// `(alias, old_specifier, new_specifier)` pairs whose specifier changed. + pub updated: Vec<(String, String, String)>, +} + +impl ImporterDiff { + fn is_empty(&self) -> bool { + self.added.is_empty() && self.removed.is_empty() && self.updated.is_empty() + } +} + +impl LockfileDiff { + #[must_use] + pub fn is_empty(&self) -> bool { + self.importers.is_empty() + && self.added_packages.is_empty() + && self.removed_packages.is_empty() + && self.updated_packages.is_empty() + } +} + +/// Diff the existing lockfile (`old`) against the freshly-resolved one +/// (`new`). A `None` `new` yields an empty diff — there is nothing a real +/// install would produce to compare against. +#[must_use] +pub fn diff_lockfiles(old: Option<&Lockfile>, new: Option<&Lockfile>) -> LockfileDiff { + let Some(new) = new else { + return LockfileDiff::default(); + }; + + let mut diff = LockfileDiff::default(); + + let mut importer_ids: BTreeSet<&str> = new.importers.keys().map(String::as_str).collect(); + if let Some(old) = old { + importer_ids.extend(old.importers.keys().map(String::as_str)); + } + for id in importer_ids { + let importer_diff = diff_importer( + id, + old.and_then(|lockfile| lockfile.importers.get(id)), + new.importers.get(id), + ); + if !importer_diff.is_empty() { + diff.importers.push(importer_diff); + } + } + + diff_snapshots(old, Some(new), &mut diff); + + diff +} + +/// Diff the v9 `snapshots:` map — the peer-aware dependency wiring a real +/// install rewrites — by key set and by `dependencies` / +/// `optionalDependencies`. Mirrors pnpm's `dedupeDiffCheck`, which diffs its +/// depPath-keyed `packages` snapshots the same way. Results are sorted. +fn diff_snapshots(old: Option<&Lockfile>, new: Option<&Lockfile>, diff: &mut LockfileDiff) { + let old_snapshots = old.and_then(|lockfile| lockfile.snapshots.as_ref()); + let new_snapshots = new.and_then(|lockfile| lockfile.snapshots.as_ref()); + + for (key, new_entry) in new_snapshots.into_iter().flatten() { + match old_snapshots.and_then(|snapshots| snapshots.get(key)) { + None => diff.added_packages.push(key.to_string()), + Some(old_entry) if snapshot_wiring_differs(old_entry, new_entry) => { + diff.updated_packages.push(key.to_string()); + } + Some(_) => {} + } + } + for key in old_snapshots.into_iter().flatten().map(|(key, _)| key) { + if new_snapshots.is_none_or(|snapshots| !snapshots.contains_key(key)) { + diff.removed_packages.push(key.to_string()); + } + } + + diff.added_packages.sort(); + diff.removed_packages.sort(); + diff.updated_packages.sort(); +} + +/// Whether a real install would rewrite this snapshot's dependency wiring. +/// Compares only `dependencies` / `optionalDependencies`, matching pnpm's +/// `PACKAGE_SNAPSHOT_DEP_FIELDS`. +fn snapshot_wiring_differs(old: &SnapshotEntry, new: &SnapshotEntry) -> bool { + old.dependencies != new.dependencies || old.optional_dependencies != new.optional_dependencies +} + +fn diff_importer( + id: &str, + old: Option<&ProjectSnapshot>, + new: Option<&ProjectSnapshot>, +) -> ImporterDiff { + let mut added = Vec::new(); + let mut removed = Vec::new(); + let mut updated = Vec::new(); + + // Diff each dependency group independently so a dependency that moves + // between groups (e.g. dev -> prod) registers as a change. Mirrors + // pnpm's `dedupeDiffCheck`, which diffs `dependencies`, + // `devDependencies`, and `optionalDependencies` separately. The diff key + // is each direct dependency's manifest `specifier`, not its resolved + // version: a real install rewrites the lockfile whenever a specifier + // changes (even if it still resolves to the same version), and for a + // direct dependency the resolved version only changes when the specifier + // does — so the specifier captures every importer-level change. + for group in 0..3 { + let old_deps = group_specifiers(old, group); + let new_deps = group_specifiers(new, group); + for (alias, new_specifier) in &new_deps { + match old_deps.get(alias) { + None => added.push((alias.clone(), new_specifier.clone())), + Some(old_specifier) if old_specifier != new_specifier => { + updated.push((alias.clone(), old_specifier.clone(), new_specifier.clone())); + } + Some(_) => {} + } + } + for (alias, old_specifier) in &old_deps { + if !new_deps.contains_key(alias) { + removed.push((alias.clone(), old_specifier.clone())); + } + } + } + + ImporterDiff { id: id.to_string(), added, removed, updated } +} + +/// The `alias -> specifier` map for one dependency group of an importer +/// (0 = prod, 1 = dev, 2 = optional). +fn group_specifiers(snapshot: Option<&ProjectSnapshot>, group: usize) -> BTreeMap { + let mut map = BTreeMap::new(); + let Some(snapshot) = snapshot else { + return map; + }; + let deps = match group { + 0 => &snapshot.dependencies, + 1 => &snapshot.dev_dependencies, + _ => &snapshot.optional_dependencies, + }; + if let Some(deps) = deps { + for (name, spec) in deps { + map.insert(name.to_string(), spec.specifier.clone()); + } + } + map +} + +/// Render a [`LockfileDiff`] into the report `pacquet install --dry-run` +/// prints to stdout. +#[must_use] +pub fn render_dry_run_report(diff: &LockfileDiff) -> String { + if diff.is_empty() { + return "Dry run complete. pnpm-lock.yaml is up to date; a real install would make no changes." + .to_string(); + } + + let mut lines = vec![ + "Dry run complete. A real install would make the following changes (nothing was written to disk):" + .to_string(), + String::new(), + ]; + + if !diff.importers.is_empty() { + lines.push("Importers".to_string()); + for importer in &diff.importers { + lines.push(importer.id.clone()); + for (alias, version) in &importer.added { + lines.push(format!(" + {alias} {version}")); + } + for (alias, version) in &importer.removed { + lines.push(format!(" - {alias} {version}")); + } + for (alias, old, new) in &importer.updated { + lines.push(format!(" {alias} {old} -> {new}")); + } + } + lines.push(String::new()); + } + + if !diff.added_packages.is_empty() + || !diff.removed_packages.is_empty() + || !diff.updated_packages.is_empty() + { + lines.push("Packages".to_string()); + for key in &diff.added_packages { + lines.push(format!("+ {key}")); + } + for key in &diff.removed_packages { + lines.push(format!("- {key}")); + } + for key in &diff.updated_packages { + lines.push(format!("~ {key}")); + } + } + + lines.join("\n") +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/package-manager/src/dry_run/tests.rs b/pacquet/crates/package-manager/src/dry_run/tests.rs new file mode 100644 index 0000000000..d155a344ef --- /dev/null +++ b/pacquet/crates/package-manager/src/dry_run/tests.rs @@ -0,0 +1,109 @@ +use std::{collections::HashMap, str::FromStr}; + +use pacquet_lockfile::{ + ImporterDepVersion, PkgName, PkgVerPeer, ProjectSnapshot, ResolvedDependencyMap, + ResolvedDependencySpec, SnapshotDepRef, SnapshotEntry, +}; + +use super::{ + ImporterDiff, LockfileDiff, diff_importer, render_dry_run_report, snapshot_wiring_differs, +}; + +fn pkg(name: &str) -> PkgName { + PkgName::from_str(name).expect("parse PkgName") +} + +fn ver(version: &str) -> PkgVerPeer { + version.parse().expect("parse PkgVerPeer") +} + +/// Build an importer dependency map from `(alias, specifier, version)` triples. +fn importer_map(entries: &[(&str, &str, &str)]) -> ResolvedDependencyMap { + entries + .iter() + .map(|(alias, specifier, version)| { + ( + pkg(alias), + ResolvedDependencySpec { + specifier: (*specifier).to_string(), + version: ImporterDepVersion::Regular(ver(version)), + }, + ) + }) + .collect() +} + +#[test] +fn empty_diff_reports_no_changes() { + let report = render_dry_run_report(&LockfileDiff::default()); + assert!(report.contains("up to date"), "got: {report}"); + assert!(report.contains("no changes"), "got: {report}"); +} + +#[test] +fn non_empty_diff_lists_importer_and_package_changes() { + let diff = LockfileDiff { + importers: vec![ImporterDiff { + id: ".".to_string(), + added: vec![("is-negative".to_string(), "1.0.0".to_string())], + removed: vec![], + updated: vec![("is-positive".to_string(), "1.0.0".to_string(), "2.0.0".to_string())], + }], + added_packages: vec!["is-negative@1.0.0".to_string()], + removed_packages: vec![], + updated_packages: vec![], + }; + let report = render_dry_run_report(&diff); + assert!(report.contains("+ is-negative 1.0.0"), "got: {report}"); + assert!(report.contains("is-positive 1.0.0 -> 2.0.0"), "got: {report}"); + assert!(report.contains("+ is-negative@1.0.0"), "got: {report}"); +} + +/// A snapshot whose dependency wiring changed (same key, different resolved +/// edge) is a lockfile rewrite a real install would perform — e.g. a +/// peer-variant re-resolution. Mirrors pnpm diffing snapshot +/// `dependencies` / `optionalDependencies`. +#[test] +fn snapshot_wiring_change_is_detected() { + let old = SnapshotEntry::default(); + let mut new = SnapshotEntry::default(); + assert!(!snapshot_wiring_differs(&old, &new), "identical snapshots must not differ"); + + new.dependencies = + Some(HashMap::from([(pkg("is-positive"), SnapshotDepRef::Plain(ver("1.0.0")))])); + assert!(snapshot_wiring_differs(&old, &new), "a new dependency edge must register as a change"); +} + +/// A dependency moving between groups (dev -> prod) with the same resolved +/// version must register as a change, because a real install would rewrite +/// the lockfile. The groups are diffed independently, matching pnpm. +#[test] +fn group_move_is_reported_even_when_version_is_unchanged() { + let old = ProjectSnapshot { + dev_dependencies: Some(importer_map(&[("is-positive", "^1.0.0", "1.0.0")])), + ..Default::default() + }; + let new = ProjectSnapshot { + dependencies: Some(importer_map(&[("is-positive", "^1.0.0", "1.0.0")])), + ..Default::default() + }; + let diff = diff_importer(".", Some(&old), Some(&new)); + assert!(!diff.is_empty(), "a dev -> prod move must register as a change: {diff:?}"); +} + +/// A specifier-only change (same group, same resolved version) is reported: +/// a real install would rewrite the lockfile's specifier, so `--dry-run` +/// surfaces it as a direct-dependency change. +#[test] +fn specifier_only_change_is_reported() { + let old = ProjectSnapshot { + dependencies: Some(importer_map(&[("is-positive", "^1.0.0", "1.0.0")])), + ..Default::default() + }; + let new = ProjectSnapshot { + dependencies: Some(importer_map(&[("is-positive", "~1.0.0", "1.0.0")])), + ..Default::default() + }; + let diff = diff_importer(".", Some(&old), Some(&new)); + assert!(!diff.is_empty(), "a specifier-only change must be reported: {diff:?}"); +} diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 5b5fa76be6..6137ae9f51 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -208,6 +208,13 @@ where /// [`lockfileOnly`](https://github.com/pnpm/pnpm/blob/3b62f9da31/config/reader/src/Config.ts#L170) /// (`like npm's --package-lock-only`). pub lockfile_only: bool, + /// `--dry-run`: resolve fully but write nothing, then report what a + /// real install would change. Forces the fresh-resolve path (so the + /// would-be lockfile is always computed), suppresses every write — + /// `pnpm-lock.yaml`, `node_modules`, `.modules.yaml`, the current + /// lockfile, the workspace-state file — and exits 0 regardless of + /// whether changes were found. Mirrors pnpm's `install --dry-run`. + pub dry_run: bool, /// Which lockfile pins to withhold from the preferred-versions seed. /// [`UpdateSeedPolicy::KeepAll`] for `install` / `add`; the `DropAll` /// / `DropOnly` variants drive `pacquet update`'s compatible bump by @@ -470,12 +477,18 @@ where supported_architectures, node_linker, lockfile_only, + dry_run, update_seed_policy, auth_override, resolution_observer, catalogs_override, } = self; + // `--dry-run` resolves but never materializes, so it borrows the + // lockfile-only plumbing (skip node_modules / `.modules.yaml` / + // workspace-state) while additionally skipping the lockfile write. + let resolve_only = lockfile_only || dry_run; + // `--lockfile-only` with `lockfile: false` (pnpm's // `useLockfile: false`) is a config conflict: the only output the // flag produces is the lockfile, and that write is disabled. @@ -755,6 +768,11 @@ where None }; let lockfile_synthesized_from_current = synthesized_lockfile.is_some(); + // The dry-run diff baseline is the actual on-disk `pnpm-lock.yaml` + // (`None` when it is absent), captured before the synthesized-from- + // current fallback below. Diffing against the synthesized lockfile + // would hide the change of a real install creating `pnpm-lock.yaml`. + let existing_wanted_lockfile = lockfile; let lockfile = lockfile.or(synthesized_lockfile.as_ref()); // One per-install packument cache shared with both the @@ -879,7 +897,15 @@ where // for both state 1 (--frozen-lockfile) and state 2 (auto-frozen // via prefer-frozen-lockfile). The freshness check fires for both // — fatal for state 1, fall-through for state 2. - let take_frozen_path = if frozen_lockfile { + // + // `--dry-run` always takes the fresh-resolve path: it must compute + // the would-be lockfile to diff against the existing one, and the + // frozen freshness gate would otherwise abort on a stale lockfile + // instead of reporting the change. Mirrors pnpm disabling its + // frozen fast path whenever the lockfile-check callback is set. + let take_frozen_path = if dry_run { + false + } else if frozen_lockfile { let Some(lockfile) = lockfile else { return Err(InstallError::NoLockfile); }; @@ -1167,7 +1193,7 @@ where // filter is irrelevant to its output. Mirrors pnpm gating its // lockfileOnly-specific handling on `!opts.lockfileOnly` at // . - if !lockfile_only && skip_runtimes { + if !resolve_only && skip_runtimes { return Err(InstallError::UnsupportedFreshInstallSkipRuntimes); } @@ -1241,7 +1267,8 @@ where wanted_lockfile: lockfile, node_linker, supported_architectures: supported_architectures.as_ref(), - lockfile_only, + lockfile_only: resolve_only, + dry_run, update_seed_policy, auth_override, resolution_observer, @@ -1287,7 +1314,21 @@ where // // and skipping `updateWorkspaceState` when `lockfileOnly` at // . - if lockfile_only { + if resolve_only { + // `--dry-run` resolved a fresh lockfile but wrote nothing. Diff + // it against the existing on-disk lockfile and print a report, + // then exit 0 — npm-style preview semantics. + if dry_run { + use std::io::Write as _; + let report = + crate::dry_run::render_dry_run_report(&crate::dry_run::diff_lockfiles( + existing_wanted_lockfile, + fresh_lockfile.as_ref(), + )); + let mut stdout = std::io::stdout(); + let _ = writeln!(stdout, "{report}"); + let _ = stdout.flush(); + } Reporter::emit(&LogEvent::Summary(SummaryLog { level: LogLevel::Debug, prefix })); return Ok(()); } diff --git a/pacquet/crates/package-manager/src/install/tests.rs b/pacquet/crates/package-manager/src/install/tests.rs index eace662a4f..892bab4362 100644 --- a/pacquet/crates/package-manager/src/install/tests.rs +++ b/pacquet/crates/package-manager/src/install/tests.rs @@ -111,6 +111,7 @@ async fn should_install_dependencies() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -208,6 +209,7 @@ async fn lockfile_only_routes_scoped_packages_to_configured_scoped_registry() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: true, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -261,6 +263,7 @@ async fn should_error_when_frozen_lockfile_is_requested_but_none_exists() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -311,6 +314,7 @@ async fn should_error_when_frozen_lockfile_and_update_checksums_are_both_set() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -390,6 +394,7 @@ async fn frozen_lockfile_flag_overrides_config_lockfile_false() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -462,6 +467,7 @@ async fn npm_alias_dependency_installs_under_alias_key() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -553,6 +559,7 @@ async fn unversioned_npm_alias_defaults_to_latest() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -627,6 +634,7 @@ async fn frozen_lockfile_flag_with_no_lockfile_errors() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -724,6 +732,7 @@ async fn install_emits_pnpm_event_sequence() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -878,6 +887,7 @@ async fn install_writes_modules_yaml() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -992,6 +1002,7 @@ async fn install_writes_workspace_state() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -1234,6 +1245,7 @@ async fn install_optional_failing_postinstall_dep_via_registry_mock_succeeds() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -1313,6 +1325,7 @@ async fn auto_install_peers_does_not_cascade_optional_peers() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -1415,6 +1428,7 @@ async fn auto_install_peers_skips_meta_only_optional_peers() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -1553,6 +1567,7 @@ async fn warm_reinstall_skips_snapshot_when_current_lockfile_matches() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -1657,6 +1672,7 @@ async fn warm_reinstall_emits_broken_modules_when_dir_is_missing() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -1769,6 +1785,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -1825,6 +1842,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -1923,6 +1941,7 @@ async fn warm_reinstall_reports_added_zero_and_emits_no_imported_events() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -2032,6 +2051,7 @@ async fn frozen_lockfile_errors_when_manifest_drifts_from_lockfile() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -2102,6 +2122,7 @@ async fn ignore_manifest_check_bypasses_manifest_freshness_gate() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -2173,6 +2194,7 @@ async fn frozen_lockfile_errors_when_overrides_drift_from_lockfile() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -2270,6 +2292,7 @@ async fn frozen_lockfile_applies_overrides_to_manifest_before_freshness_check() supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -2383,6 +2406,7 @@ async fn frozen_lockfile_resolves_catalog_protocol_in_overrides_before_freshness supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -2450,6 +2474,7 @@ async fn frozen_lockfile_errors_when_lockfile_has_no_root_importer() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -2544,6 +2569,7 @@ async fn frozen_lockfile_under_gvs_registers_project_and_runs_clean() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -2657,6 +2683,7 @@ async fn gvs_persists_global_virtual_store_dir_in_modules_yaml_and_context_log() supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -2777,6 +2804,7 @@ async fn frozen_lockfile_with_gvs_off_skips_project_registry() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -2863,6 +2891,7 @@ async fn frozen_lockfile_under_gvs_registers_workspace_root_only() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -3070,6 +3099,7 @@ async fn frozen_install_preserves_seeded_skipped_across_reinstall() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -3200,6 +3230,7 @@ async fn frozen_install_silently_swallows_unreachable_optional_tarball() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -3306,6 +3337,7 @@ async fn frozen_install_propagates_non_optional_fetch_failure() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -3418,6 +3450,7 @@ async fn frozen_install_no_optional_drops_optional_only_snapshots() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -3515,6 +3548,7 @@ async fn frozen_install_optional_included_surfaces_missing_metadata() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -3615,6 +3649,7 @@ async fn frozen_install_no_optional_keeps_shared_non_optional_snapshot() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -3714,6 +3749,7 @@ async fn hoisted_node_linker_empty_lockfile_writes_modules_yaml() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::Hoisted, lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -3808,6 +3844,7 @@ async fn hoisted_node_linker_does_not_create_virtual_store_root() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::Hoisted, lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -3911,6 +3948,7 @@ async fn frozen_lockfile_install_errors_when_no_variant_matches_host() { resolved_packages: &Default::default(), node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -4011,7 +4049,7 @@ async fn frozen_lockfile_install_skips_runtime_when_skip_runtimes_set() { resolved_packages: &Default::default(), node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, - update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, catalogs_override: None, @@ -4114,6 +4152,7 @@ async fn install_rejects_invalid_minimum_release_age_exclude_pattern() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -4220,6 +4259,7 @@ async fn frozen_lockfile_gate_rejects_under_huge_minimum_release_age() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -4312,6 +4352,7 @@ async fn fresh_install_writes_pnpm_lock_yaml_with_expected_shape() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -4393,6 +4434,7 @@ async fn fresh_install_uses_final_peer_suffix_for_transitive_pending_peer() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -4472,6 +4514,7 @@ async fn fresh_install_splits_dev_and_prod_dependency_sections() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -4548,6 +4591,7 @@ async fn fresh_install_records_user_written_specifier() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -4620,6 +4664,7 @@ async fn fresh_install_lockfile_round_trips_through_load_save_load() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -4691,6 +4736,7 @@ async fn fresh_install_with_lockfile_disabled_does_not_write_a_lockfile() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -4765,6 +4811,7 @@ async fn fresh_install_also_writes_current_lockfile_under_virtual_store() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -4855,6 +4902,7 @@ async fn fresh_install_with_lockfile_disabled_skips_current_lockfile_too() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -4923,6 +4971,7 @@ async fn fresh_install_marks_optional_snapshots_in_pnpm_lock_yaml() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -5016,6 +5065,7 @@ async fn fresh_install_hoisted_node_linker_records_modules_yaml() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::Hoisted, lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -5089,6 +5139,7 @@ async fn fresh_install_refuses_skip_runtimes_before_writing_state() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -5166,6 +5217,7 @@ async fn prefer_frozen_lockfile_takes_frozen_path_when_lockfile_is_fresh() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -5244,6 +5296,7 @@ async fn no_prefer_frozen_lockfile_flag_forces_fresh_resolve() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -5316,6 +5369,7 @@ async fn stale_lockfile_under_no_flag_falls_through_to_fresh_resolve() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -5592,6 +5646,7 @@ async fn frozen_install_short_circuits_when_modules_and_lockfile_are_consistent( supported_architectures: None, node_linker: pacquet_config::NodeLinker::Isolated, lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -5779,6 +5834,7 @@ async fn optimistic_repeat_install_skips_entire_pipeline_when_state_is_fresh() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::Isolated, lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -6016,6 +6072,7 @@ async fn frozen_lockfile_disables_optimistic_short_circuit() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::Isolated, lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -6161,6 +6218,7 @@ async fn partial_install_disables_optimistic_short_circuit() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::Isolated, lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -6302,6 +6360,7 @@ async fn optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing( supported_architectures: None, node_linker: pacquet_config::NodeLinker::Isolated, lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -6386,6 +6445,7 @@ async fn optimistic_repeat_install_round_trips_on_single_project_install() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -6443,6 +6503,7 @@ async fn optimistic_repeat_install_round_trips_on_single_project_install() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -6532,6 +6593,7 @@ async fn fresh_install_records_lockfile_verification_for_mtime_bypassed_noop() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -6597,6 +6659,7 @@ async fn fresh_install_records_lockfile_verification_for_mtime_bypassed_noop() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -6685,6 +6748,7 @@ async fn install_then_go_offline() -> (tempfile::TempDir, &'static Config, Packa supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -6774,6 +6838,7 @@ async fn optimistic_repeat_install_short_circuits_offline_when_touched_manifest_ supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -6855,6 +6920,7 @@ async fn optimistic_repeat_install_restores_missing_lockfile_offline() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -7000,6 +7066,7 @@ async fn fresh_lockfile_only_with_overrides( supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: true, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -7105,6 +7172,7 @@ async fn fresh_lockfile_only_with_compatibility_db( supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: true, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -7196,6 +7264,7 @@ async fn fresh_install_applies_package_extensions_to_dependency_manifest() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, @@ -7298,6 +7367,7 @@ async fn frozen_lockfile_errors_when_package_extensions_drift_from_lockfile() { supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, resolution_observer: None, @@ -7380,6 +7450,7 @@ async fn install_with_pnpmfile_reporter( supported_architectures: None, node_linker: pacquet_config::NodeLinker::default(), lockfile_only: false, + dry_run: false, resolved_packages: &Default::default(), update_seed_policy: crate::UpdateSeedPolicy::KeepAll, auth_override: None, diff --git a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs index 9c5b60d19c..dbaae7e481 100644 --- a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -166,6 +166,11 @@ pub struct InstallWithFreshLockfile<'a, DependencyGroupList> { /// `dryRun: opts.lockfileOnly` resolve pass. See /// [`crate::Install::lockfile_only`]. pub lockfile_only: bool, + /// `--dry-run`: build the would-be lockfile but do not write it to + /// disk. Implies [`Self::lockfile_only`] (nothing is materialized); + /// the caller diffs the returned [`InstallWithFreshLockfileResult::wanted_lockfile`] + /// against the existing one and reports the changes. + pub dry_run: bool, /// Which lockfile pins to withhold from the preferred-versions seed /// so the affected names re-resolve to the highest version /// satisfying their manifest range. Drives `pacquet update`'s @@ -476,6 +481,7 @@ impl InstallWithFreshLockfile<'_, DependencyGroupList> { node_linker, supported_architectures, lockfile_only, + dry_run, update_seed_policy, auth_override, resolution_observer, @@ -1251,7 +1257,12 @@ impl InstallWithFreshLockfile<'_, DependencyGroupList> { pnpmfile_checksum: pnpmfile_checksum.as_deref(), patched_dependency_hashes: patched_dependency_hashes.as_ref(), }); - let (wanted_lockfile, can_record_lockfile_verification) = if config.lockfile { + // `--dry-run` builds the would-be lockfile so the caller can + // diff it, but never persists it. A plain `--lockfile-only` + // writes it (unless `lockfile: false`). + let (wanted_lockfile, can_record_lockfile_verification) = if dry_run { + (Some(built_lockfile), false) + } else if config.lockfile { let can_record_lockfile_verification = save_wanted_lockfile( &built_lockfile, &lockfile_dir.join(Lockfile::FILE_NAME), diff --git a/pacquet/crates/package-manager/src/lib.rs b/pacquet/crates/package-manager/src/lib.rs index 3e8353bdc3..8efc4655b5 100644 --- a/pacquet/crates/package-manager/src/lib.rs +++ b/pacquet/crates/package-manager/src/lib.rs @@ -12,6 +12,7 @@ mod create_virtual_store; mod current_lockfile; mod dependencies_graph_to_lockfile; mod deps_graph; +mod dry_run; mod graph_sequencer; mod hoist; mod hoisted_dep_graph; diff --git a/pacquet/crates/package-manager/src/remove.rs b/pacquet/crates/package-manager/src/remove.rs index 2db6fc1bfd..5e93e82eeb 100644 --- a/pacquet/crates/package-manager/src/remove.rs +++ b/pacquet/crates/package-manager/src/remove.rs @@ -135,6 +135,7 @@ impl Remove<'_> { supported_architectures, node_linker: config.node_linker, lockfile_only, + dry_run: false, // Removing a dependency must not bump the survivors: keep // every remaining lockfile pin in the preferred-versions // seed, same as `install` / `add`. diff --git a/pacquet/crates/package-manager/src/update.rs b/pacquet/crates/package-manager/src/update.rs index 321d75f889..55c2523fb4 100644 --- a/pacquet/crates/package-manager/src/update.rs +++ b/pacquet/crates/package-manager/src/update.rs @@ -560,6 +560,7 @@ impl Update<'_> { supported_architectures, node_linker: config.node_linker, lockfile_only, + dry_run: false, update_seed_policy: seed_policy, auth_override: None, resolution_observer: None, diff --git a/patching/commands/src/patchCommit.ts b/patching/commands/src/patchCommit.ts index abdf943b88..fc0240e669 100644 --- a/patching/commands/src/patchCommit.ts +++ b/patching/commands/src/patchCommit.ts @@ -112,11 +112,12 @@ export async function handler (opts: PatchCommitCommandOptions, params: string[] workspaceDir: opts.workspaceDir ?? opts.rootProjectManifestDir, }) - return install.handler({ + await install.handler({ ...opts, patchedDependencies, frozenLockfile: false, - }) as Promise + }) + return undefined } interface GetPatchContentContext { diff --git a/patching/commands/src/patchRemove.ts b/patching/commands/src/patchRemove.ts index f0a7f9300b..e2e6965bf7 100644 --- a/patching/commands/src/patchRemove.ts +++ b/patching/commands/src/patchRemove.ts @@ -99,7 +99,7 @@ export async function handler (opts: PatchRemoveCommandOptions, params: string[] workspaceDir: opts.workspaceDir ?? opts.rootProjectManifestDir, }) - return install.handler({ + await install.handler({ ...opts, patchedDependencies, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 130ae03484..dcd5b0ac62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5312,6 +5312,9 @@ importers: '@pnpm/installing.dedupe.check': specifier: workspace:* version: link:../dedupe/check + '@pnpm/installing.dedupe.issues-renderer': + specifier: workspace:* + version: link:../dedupe/issues-renderer '@pnpm/installing.deps-installer': specifier: workspace:* version: link:../deps-installer diff --git a/pnpr/crates/pnpr/src/resolver/resolve.rs b/pnpr/crates/pnpr/src/resolver/resolve.rs index 959feaea3b..9336b12c12 100644 --- a/pnpr/crates/pnpr/src/resolver/resolve.rs +++ b/pnpr/crates/pnpr/src/resolver/resolve.rs @@ -182,6 +182,7 @@ pub async fn resolve( supported_architectures: None, node_linker: NodeLinker::Isolated, lockfile_only: true, + dry_run: false, update_seed_policy: pacquet_package_manager::UpdateSeedPolicy::KeepAll, // Resolve as the caller (forwarded credentials) without baking // per-user auth into the interned `&'static Config`.