feat(install): add --dry-run option (npm-style preview) (#12449)

## 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.
This commit is contained in:
Zoltan Kochan
2026-06-16 19:12:56 +02:00
committed by GitHub
parent 7cd5594a50
commit c112b6106b
33 changed files with 1087 additions and 49 deletions

View File

@@ -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).

View File

@@ -87,7 +87,8 @@ export interface Config extends OptionsFromRootManifest {
filter: string[]
filterProd: string[]
authConfig: Record<string, any>, // 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

View File

@@ -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:*",

View File

@@ -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)
}

View File

@@ -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,
}, [])
}

View File

@@ -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<string, unknown> {
export const cliOptionsTypes = (): Record<string, unknown> => ({
...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<Config,
| 'deployAllFiles'
| 'depth'
| 'dev'
| 'dryRun'
| 'enableGlobalVirtualStore'
| 'engineStrict'
| 'excludeLinksFromLockfile'
@@ -389,7 +398,7 @@ export type InstallCommandOptions = Pick<Config,
pnpmfile: string[]
} & Partial<Pick<Config, 'ci' | 'modulesCacheMaxAge' | 'pnpmHomeDir' | 'preferWorkspacePackages' | 'strictDepBuilds' | 'useLockfile' | 'symlink'>>
export async function handler (opts: InstallCommandOptions & { _calledFromLink?: boolean }, _params?: string[], commands?: CommandHandlerMap): Promise<void> {
export async function handler (opts: InstallCommandOptions & { _calledFromLink?: boolean }, _params?: string[], commands?: CommandHandlerMap): Promise<void | string> {
if (opts.global && !opts._calledFromLink) {
throw new PnpmError('GLOBAL_INSTALL_NOT_SUPPORTED',
'"pnpm install -g" is not supported. Use "pnpm add -g <pkg>" 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<string> {
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')
}

View File

@@ -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<Config,
* subcommand — see `runPacquet.ts`'s `noRuntime` opt.
*/
isInstallCommand?: boolean
} & Partial<Pick<Config, 'pnpmHomeDir' | 'strictDepBuilds' | 'useLockfile' | 'useGitBranchLockfile'>>
} & Partial<Pick<Config, 'dryRun' | 'pnpmHomeDir' | 'strictDepBuilds' | 'useLockfile' | 'useGitBranchLockfile'>>
export async function installDeps (
opts: InstallDepsOptions,
params: string[]
): Promise<void> {
): Promise<DryRunInstallResult | undefined> {
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<boolean | string> {
): Promise<DryRunInstallResult | undefined> {
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
}
/**

View File

@@ -48,7 +48,7 @@ export function help (): string {
export async function handler (
opts: install.InstallCommandOptions
): Promise<void> {
return install.handler({
await install.handler({
...opts,
modulesCacheMaxAge: 0,
pruneDirectDependencies: true,

View File

@@ -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<Config,
| 'dedupePeerDependents'
| 'dedupePeers'
| 'depth'
| 'dryRun'
| 'globalPnpmfile'
| 'hoistPattern'
| 'hoistingLimits'
@@ -153,6 +155,11 @@ export interface RecursiveResult {
* cache so that reverting a catalog entry is detected as an outdated state.
*/
updatedCatalogs?: Catalogs
/**
* Present only for a `dryRun` install over a shared workspace lockfile:
* the before/after wanted lockfiles for the caller to diff.
*/
dryRunResult?: DryRunInstallResult
}
export async function recursive (
@@ -329,12 +336,13 @@ export async function recursive (
updatedProjects: mutatedPkgs,
ignoredBuilds,
resolutionPolicyViolations,
dryRunResult,
} = await mutateModules(mutatedImporters, {
...installOpts,
storeController: store.ctrl,
resolutionVerifiers: store.resolutionVerifiers,
})
if (opts.save !== false) {
if (opts.save !== false && !opts.dryRun) {
// Only pick entries when we'll actually persist. Otherwise the
// info log would claim entries were added that the workspace
// manifest never saw, and the next install would re-prompt or
@@ -352,7 +360,7 @@ export async function recursive (
await Promise.all(promises)
}
await handleIgnoredBuilds(opts, ignoredBuilds)
return { passed: true, updatedCatalogs }
return { passed: true, updatedCatalogs, dryRunResult }
}
const pkgPaths = (Object.keys(opts.selectedProjectsGraph) as ProjectRootDir[]).sort()

View File

@@ -191,6 +191,9 @@ export async function handler (
storeDir: store.dir,
resolutionVerifiers: store.resolutionVerifiers,
include,
// `--dry-run` is an `install`-only preview; never let a config-level
// `dry-run` turn `remove` into a no-op check.
dryRun: false,
})
const allProjects = opts.allProjects ?? (
opts.workspaceDir

View File

@@ -316,7 +316,7 @@ async function update (
) {
updateMatching = createMatcher(dependencies)
}
return installDeps({
await installDeps({
...opts,
rebuildHandler,
allowNew: false,
@@ -329,6 +329,9 @@ async function update (
updateMatching,
updatePackageManifest: opts.save !== false,
resolutionMode: opts.save === false ? 'highest' : opts.resolutionMode,
// `--dry-run` is an `install`-only preview; never let a config-level
// `dry-run` turn `update` into a no-op check.
dryRun: false,
}, dependencies)
}

View File

@@ -4,7 +4,8 @@ import path from 'node:path'
import { describe, expect, test } from '@jest/globals'
import { STORE_VERSION } from '@pnpm/constants'
import { add, install } from '@pnpm/installing.commands'
import { prepare, prepareEmpty } from '@pnpm/prepare'
import { prepare, prepareEmpty, preparePackages } from '@pnpm/prepare'
import { filterProjectsBySelectorObjectsFromDir } from '@pnpm/workspace.projects-filter'
import { rimrafSync } from '@zkochan/rimraf'
import delay from 'delay'
import { loadJsonFileSync } from 'load-json-file'
@@ -199,3 +200,185 @@ test('install restores a deleted pnpm-lock.yaml from the current lockfile withou
expect(fs.readFileSync('pnpm-lock.yaml', 'utf8')).toBe(originalLockfile)
})
test('install --dry-run reports the changes a real install would make, without writing anything', async () => {
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<string, string> }>(path.resolve('package.json'))
expect(pkg.dependencies).toStrictEqual({ 'is-positive': '1.0.0' })
})

View File

@@ -171,6 +171,9 @@
{
"path": "../dedupe/check"
},
{
"path": "../dedupe/issues-renderer"
},
{
"path": "../deps-installer"
},

View File

@@ -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)
}
}

View File

@@ -1,2 +1,2 @@
export { DedupeCheckIssuesError } from './DedupeCheckIssuesError.js'
export { countChangedSnapshots, dedupeDiffCheck } from './dedupeDiffCheck.js'
export { calcDedupeCheckIssues, countChangedSnapshots, countDedupeCheckIssues, dedupeDiffCheck } from './dedupeDiffCheck.js'

View File

@@ -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

View File

@@ -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<string | undefined> = {
@@ -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<InstallFunctionResult>
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` /

View File

@@ -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::<Reporter>(
&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<Reporter: self::Reporter + 'static>(
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<Reporter: self::Reporter + 'static>(
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,

View File

@@ -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

View File

@@ -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));
}

View File

@@ -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.

View File

@@ -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<ImporterDiff>,
/// `snapshots:` keys present in the new lockfile but not the old.
pub added_packages: Vec<String>,
/// `snapshots:` keys present in the old lockfile but not the new.
pub removed_packages: Vec<String>,
/// `snapshots:` keys present in both whose dependency wiring changed.
pub updated_packages: Vec<String>,
}
/// 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<String, String> {
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;

View File

@@ -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:?}");
}

View File

@@ -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
// <https://github.com/pnpm/pnpm/blob/a33c4bfcb0/installing/deps-installer/src/install/index.ts#L1957>.
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
// <https://github.com/pnpm/pnpm/blob/a33c4bfcb0/installing/deps-installer/src/install/index.ts#L1784>
// and skipping `updateWorkspaceState` when `lockfileOnly` at
// <https://github.com/pnpm/pnpm/blob/a33c4bfcb0/installing/commands/src/installDeps.ts#L515>.
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(());
}

View File

@@ -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<Reporter: self::Reporter + 'static>(
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,

View File

@@ -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<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
node_linker,
supported_architectures,
lockfile_only,
dry_run,
update_seed_policy,
auth_override,
resolution_observer,
@@ -1251,7 +1257,12 @@ impl<DependencyGroupList> 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),

View File

@@ -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;

View File

@@ -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`.

View File

@@ -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,

View File

@@ -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<undefined>
})
return undefined
}
interface GetPatchContentContext {

View File

@@ -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,
})

3
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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`.