mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-29 18:35:18 -04:00
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:
8
.changeset/dry-run-install.md
Normal file
8
.changeset/dry-run-install.md
Normal 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).
|
||||
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -171,6 +171,9 @@
|
||||
{
|
||||
"path": "../dedupe/check"
|
||||
},
|
||||
{
|
||||
"path": "../dedupe/issues-renderer"
|
||||
},
|
||||
{
|
||||
"path": "../deps-installer"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { DedupeCheckIssuesError } from './DedupeCheckIssuesError.js'
|
||||
export { countChangedSnapshots, dedupeDiffCheck } from './dedupeDiffCheck.js'
|
||||
export { calcDedupeCheckIssues, countChangedSnapshots, countDedupeCheckIssues, dedupeDiffCheck } from './dedupeDiffCheck.js'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` /
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
162
pacquet/crates/cli/tests/dry_run.rs
Normal file
162
pacquet/crates/cli/tests/dry_run.rs
Normal 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));
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
235
pacquet/crates/package-manager/src/dry_run.rs
Normal file
235
pacquet/crates/package-manager/src/dry_run.rs
Normal 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;
|
||||
109
pacquet/crates/package-manager/src/dry_run/tests.rs
Normal file
109
pacquet/crates/package-manager/src/dry_run/tests.rs
Normal 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:?}");
|
||||
}
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user