fix: re-link local tarball when contents change (without rename) during filtered install (#9805)

* test: ensure current lockfile updates when tarball integrity changes

* fix: update store when local tarball contents change without rename
This commit is contained in:
Brandon Cheng
2025-07-30 05:31:24 -04:00
committed by GitHub
parent d1edf732ad
commit 9908269a12
4 changed files with 86 additions and 2 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/headless": patch
"@pnpm/deps.graph-builder": patch
"@pnpm/core": patch
pnpm: patch
---
Fix an edge case bug causing local tarballs to not re-link into the virtual store. This bug would happen when changing the contents of the tarball without renaming the file and running a filtered install.

View File

@@ -33,6 +33,7 @@ export interface DependenciesGraphNode {
modules: string
name: string
fetching?: () => Promise<PkgRequestFetchResult>
forceImportPackage?: boolean // Used to force re-imports from the store of local tarballs that have changed.
dir: string
children: Record<string, string>
optionalDependencies: Set<string>
@@ -186,6 +187,8 @@ async function buildGraphFromPackages (
currentPackages[depPath] &&
equals(currentPackages[depPath].dependencies, pkgSnapshot.dependencies)
const depIntegrityIsUnchanged = isIntegrityEqual(pkgSnapshot.resolution, currentPackages[depPath]?.resolution)
const modules = path.join(opts.virtualStoreDir, dirNameInVirtualStore, 'node_modules')
const dir = path.join(modules, pkgName)
locationByDepPath[depPath] = dir
@@ -193,6 +196,7 @@ async function buildGraphFromPackages (
let dirExists: boolean | undefined
if (
depIsPresent &&
depIntegrityIsUnchanged &&
isEmpty(currentPackages[depPath].optionalDependencies ?? {}) &&
isEmpty(pkgSnapshot.optionalDependencies ?? {}) &&
!opts.includeUnchangedDeps
@@ -203,7 +207,7 @@ async function buildGraphFromPackages (
}
let fetchResponse!: Partial<FetchResponse>
if (depIsPresent && equals(currentPackages[depPath].optionalDependencies, pkgSnapshot.optionalDependencies)) {
if (depIsPresent && depIntegrityIsUnchanged && equals(currentPackages[depPath].optionalDependencies, pkgSnapshot.optionalDependencies)) {
if (dirExists ?? await pathExists(dir)) {
fetchResponse = {}
} else {
@@ -236,6 +240,7 @@ async function buildGraphFromPackages (
dir,
fetching: fetchResponse.fetching,
filesIndexFile: fetchResponse.filesIndexFile,
forceImportPackage: !depIntegrityIsUnchanged,
hasBin: pkgSnapshot.hasBin === true,
hasBundledDependencies: pkgSnapshot.bundledDependencies != null,
modules,
@@ -290,3 +295,13 @@ function getChildrenPaths (
}
return children
}
function isIntegrityEqual (resolutionA?: LockfileResolution, resolutionB?: LockfileResolution) {
// The LockfileResolution type is a union, but it doesn't have a "tag"
// field to perform a discriminant match on. Using a type assertion is
// required to get the integrity field.
const integrityA = (resolutionA as ({ integrity?: string } | undefined))?.integrity
const integrityB = (resolutionB as ({ integrity?: string } | undefined))?.integrity
return integrityA === integrityB
}

View File

@@ -12,6 +12,7 @@ import {
mutateModules,
type MutatedProject,
mutateModulesInSingleProject,
type ProjectOptions,
} from '@pnpm/core'
import { sync as rimraf } from '@zkochan/rimraf'
import normalizePath from 'normalize-path'
@@ -240,6 +241,66 @@ test('update tarball local package when its integrity changes', async () => {
expect(manifestOfTarballDep.dependencies['is-positive']).toBe('^2.0.0')
})
// Similar to the test above, but for a filtered install.
// Regression test for https://github.com/pnpm/pnpm/pull/9805.
test('update tarball local package when its integrity changes (filtered install)', async () => {
const rootProject = prepareEmpty()
const lockfileDir = rootProject.dir()
const manifests = {
project1: {
name: 'project1',
},
project2: {
name: 'project2',
},
}
preparePackages(Object.values(manifests), { tempDir: lockfileDir })
const allProjects: ProjectOptions[] = Object.entries(manifests)
.map(([id, manifest]) => ({
buildIndex: 0,
manifest,
rootDir: path.join(lockfileDir, id) as ProjectRootDir,
}))
const options = {
...testDefaults({
allProjects,
}),
lockfileDir,
}
f.copy('tar-pkg-with-dep-1/tar-pkg-with-dep-1.0.0.tgz', path.resolve('.', 'tar.tgz'))
await addDependenciesToPackage(
manifests['project1'],
['../tar.tgz'],
{
...options,
dir: path.join(options.lockfileDir, 'project1'),
})
const manifestOfTarballDep1 = JSON.parse(fs.readFileSync('project1/node_modules/tar-pkg-with-dep/package.json').toString())
expect(manifestOfTarballDep1.dependencies['is-positive']).toBe('^1.0.0')
f.copy('tar-pkg-with-dep-2/tar-pkg-with-dep-1.0.0.tgz', path.resolve('.', 'tar.tgz'))
// Re-initialize the store controller that's created within the testDefaults()
// function. Otherwise the fetchingLocker will contain results from a prior
// installation and skip store fetches for the same package ID.
const nextOptions = {
...options,
...testDefaults(allProjects),
}
const project1InstallOptions: MutatedProject = {
mutation: 'install',
rootDir: path.join(lockfileDir, 'project1') as ProjectRootDir,
}
await mutateModules([project1InstallOptions], nextOptions)
const manifestOfTarballDep2 = JSON.parse(fs.readFileSync('project1/node_modules/tar-pkg-with-dep/package.json').toString())
expect(manifestOfTarballDep2.dependencies['is-positive']).toBe('^2.0.0')
})
// Covers https://github.com/pnpm/pnpm/issues/1878
test('do not update deps when installing in a project that has local tarball dep', async () => {
await addDistTag({ package: '@pnpm.e2e/peer-a', version: '1.0.0', distTag: 'latest' })

View File

@@ -886,7 +886,7 @@ async function linkAllPkgs (
}
const { importMethod, isBuilt } = await storeController.importPackage(depNode.dir, {
filesResponse,
force: opts.force,
force: depNode.forceImportPackage ?? opts.force,
disableRelinkLocalDirDeps: opts.disableRelinkLocalDirDeps,
requiresBuild: depNode.patch != null || depNode.requiresBuild,
sideEffectsCacheKey,