fix(git-resolver): installing git-hosted dependency using annotated tags (#10349)

close #10335
This commit is contained in:
Zoltan Kochan
2025-12-22 23:05:40 +01:00
committed by GitHub
parent a297ebc9f6
commit 01760da877
5 changed files with 34 additions and 52 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/git-resolver": patch
"pnpm": patch
---
Fix installation of Git dependencies using annotated tags [#10335](https://github.com/pnpm/pnpm/issues/10335).
Previously, pnpm would store the annotated tag object's SHA in the lockfile instead of the actual commit SHA. This caused `ERR_PNPM_GIT_CHECKOUT_FAILED` errors because the checked-out commit hash didn't match the stored tag object hash.

View File

@@ -30,7 +30,7 @@ test('should pick remoteTarball fetcher', async () => {
})
test.each([
'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
'https://bitbucket.org/pnpmjs/git-resolver/get/87cf6a67064d2ce56e8cd20624769a5512b83ff9.tar.gz',
'https://gitlab.com/api/v4/projects/pnpm%2Fgit-resolver/repository/archive.tar.gz',
])('should pick gitHostedTarball fetcher', async (tarball) => {

View File

@@ -267,12 +267,12 @@ test('re-adding a git repo with a different tag', async () => {
lockfile = project.readLockfile()
expect(lockfile.importers['.'].dependencies?.['is-negative']).toEqual({
specifier: 'github:kevva/is-negative#1.0.1',
version: 'https://codeload.github.com/kevva/is-negative/tar.gz/9a89df745b2ec20ae7445d3d9853ceaeef5b0b72',
version: 'https://codeload.github.com/kevva/is-negative/tar.gz/f7dec4d66a5a56719e49b9f94a24d73f924ddeb3',
})
expect(lockfile.packages).toEqual(
{
'is-negative@https://codeload.github.com/kevva/is-negative/tar.gz/9a89df745b2ec20ae7445d3d9853ceaeef5b0b72': {
resolution: { tarball: 'https://codeload.github.com/kevva/is-negative/tar.gz/9a89df745b2ec20ae7445d3d9853ceaeef5b0b72' },
'is-negative@https://codeload.github.com/kevva/is-negative/tar.gz/f7dec4d66a5a56719e49b9f94a24d73f924ddeb3': {
resolution: { tarball: 'https://codeload.github.com/kevva/is-negative/tar.gz/f7dec4d66a5a56719e49b9f94a24d73f924ddeb3' },
version: '1.0.1',
engines: { node: '>=0.10.0' },
},
@@ -348,5 +348,5 @@ test('no hash character for github subdirectory install', async () => {
], testDefaults())
expect(fs.readdirSync('./node_modules/.pnpm'))
.toContain('only-allow@https+++codeload.github.com+pnpm+only-allow+tar.gz+4d577a5a5862a43e752df37a1e8a0c71c3a0084a+path++')
.toContain('only-allow@https+++codeload.github.com+pnpm+only-allow+tar.gz+91ab41994c6a1b7319869fa8864163c9954f56ec+path++')
})

View File

@@ -83,11 +83,11 @@ function resolveVTags (vTags: string[], range: string): string | null {
async function getRepoRefs (repo: string, ref: string | null): Promise<Record<string, string>> {
const gitArgs = [repo]
if (ref !== 'HEAD') {
gitArgs.unshift('--refs')
}
if (ref) {
gitArgs.push(ref)
// Also request the peeled ref for annotated tags (e.g., refs/tags/v1.0.0^{})
// This is needed because annotated tags have their own SHA, and we need the commit SHA they point to
gitArgs.push(`${ref}^{}`)
}
// graceful-git by default retries 10 times, reduce to single retry
const result = await git(['ls-remote', ...gitArgs], { retries: 1 })
@@ -123,7 +123,8 @@ function resolveRefFromRefs (refs: { [ref: string]: string }, repo: string, ref:
if (!commitId) {
// check for a partial commit
const commits = committish ? Object.values(refs).filter((value: string) => value.startsWith(ref)) : []
// Use Set to deduplicate since multiple refs can point to the same commit
const commits = committish ? [...new Set(Object.values(refs).filter((value: string) => value.startsWith(ref)))] : []
if (commits.length === 1) {
commitId = commits[0]
} else {
@@ -133,7 +134,7 @@ function resolveRefFromRefs (refs: { [ref: string]: string }, repo: string, ref:
return commitId
} else {
const vTags =
const vTags = [...new Set(
Object.keys(refs)
// using the same semantics of version tags as https://github.com/zkat/pacote
.filter((key: string) => /^refs\/tags\/v?\d+\.\d+\.\d+(?:[-+].+)?(?:\^\{\})?$/.test(key))
@@ -143,10 +144,11 @@ function resolveRefFromRefs (refs: { [ref: string]: string }, repo: string, ref:
.replace(/\^\{\}$/, '') // accept annotated tags
})
.filter((key: string) => semver.valid(key, true))
)]
const refVTag = resolveVTags(vTags, range)
const commitId = refVTag &&
(refs[`refs/tags/${refVTag}^{}`] || // prefer annotated tags
refs[`refs/tags/${refVTag}`])
refs[`refs/tags/${refVTag}`])
if (!commitId) {
throw new Error(`Could not resolve ${range} to a commit of ${repo}. Available versions are: ${vTags.join(', ')}`)

View File

@@ -115,10 +115,10 @@ test('resolveFromGit() with branch relative to refs', async () => {
test('resolveFromGit() with tag', async () => {
const resolveResult = await resolveFromGit({ bareSpecifier: 'zkochan/is-negative#2.0.1' })
expect(resolveResult).toStrictEqual({
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
normalizedBareSpecifier: 'github:zkochan/is-negative#2.0.1',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
},
resolvedVia: 'git-repository',
})
@@ -163,10 +163,10 @@ test.skip('resolveFromGit() with strict semver (v-prefixed tag)', async () => {
test('resolveFromGit() with range semver', async () => {
const resolveResult = await resolveFromGit({ bareSpecifier: 'zkochan/is-negative#semver:^1.0.0' })
expect(resolveResult).toStrictEqual({
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/9a89df745b2ec20ae7445d3d9853ceaeef5b0b72',
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/f7dec4d66a5a56719e49b9f94a24d73f924ddeb3',
normalizedBareSpecifier: 'github:zkochan/is-negative#semver:^1.0.0',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/9a89df745b2ec20ae7445d3d9853ceaeef5b0b72',
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/f7dec4d66a5a56719e49b9f94a24d73f924ddeb3',
},
resolvedVia: 'git-repository',
})
@@ -392,10 +392,10 @@ test.skip('resolveFromGit() gitlab with tag', async () => {
test('resolveFromGit() normalizes full url', async () => {
const resolveResult = await resolveFromGit({ bareSpecifier: 'git+ssh://git@github.com:zkochan/is-negative.git#2.0.1' })
expect(resolveResult).toStrictEqual({
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
normalizedBareSpecifier: 'github:zkochan/is-negative#2.0.1',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
},
resolvedVia: 'git-repository',
})
@@ -404,10 +404,10 @@ test('resolveFromGit() normalizes full url', async () => {
test('resolveFromGit() normalizes full url with port', async () => {
const resolveResult = await resolveFromGit({ bareSpecifier: 'git+ssh://git@github.com:22:zkochan/is-negative.git#2.0.1' })
expect(resolveResult).toStrictEqual({
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
normalizedBareSpecifier: 'github:zkochan/is-negative#2.0.1',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
},
resolvedVia: 'git-repository',
})
@@ -416,10 +416,10 @@ test('resolveFromGit() normalizes full url with port', async () => {
test('resolveFromGit() normalizes full url (alternative form)', async () => {
const resolveResult = await resolveFromGit({ bareSpecifier: 'git+ssh://git@github.com/zkochan/is-negative.git#2.0.1' })
expect(resolveResult).toStrictEqual({
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
normalizedBareSpecifier: 'github:zkochan/is-negative#2.0.1',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
},
resolvedVia: 'git-repository',
})
@@ -428,17 +428,17 @@ test('resolveFromGit() normalizes full url (alternative form)', async () => {
test('resolveFromGit() normalizes full url (alternative form 2)', async () => {
const resolveResult = await resolveFromGit({ bareSpecifier: 'https://github.com/zkochan/is-negative.git#2.0.1' })
expect(resolveResult).toStrictEqual({
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
id: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
normalizedBareSpecifier: 'github:zkochan/is-negative#2.0.1',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
},
resolvedVia: 'git-repository',
})
})
// This test relies on implementation detail.
// current implementation does not try git ls-remote --refs on bareSpecifier with full commit hash, this fake repo url will pass.
// current implementation does not try git ls-remote on bareSpecifier with full commit hash, this fake repo url will pass.
test('resolveFromGit() private repo with commit hash', async () => {
// parseBareSpecifier will try to access the repository with --exit-code
git.mockImplementation(() => {
@@ -461,11 +461,6 @@ test('resolveFromGit() private repo with commit hash', async () => {
test('resolve a private repository using the HTTPS protocol without auth token', async () => {
jest.mocked(git).mockImplementation(async (args: string[]) => {
expect(args).toContain('git+ssh://git@github.com/foo/bar.git')
if (args.includes('--refs')) {
return {
stdout: `\n${'a'.repeat(40)}\trefs/heads/master\n`,
}
}
return {
stdout: '0'.repeat(40) + '\tHEAD',
}
@@ -510,13 +505,6 @@ test('resolve a private repository using the HTTPS protocol with a commit hash',
test('resolve a private repository using the HTTPS protocol and an auth token', async () => {
git.mockImplementation(async (args: string[]) => {
if (!args.includes('https://0000000000000000000000000000000000000000:x-oauth-basic@github.com/foo/bar.git')) throw new Error('')
if (args.includes('--refs')) {
return {
stdout: '\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/heads/master\
',
}
}
return { stdout: '0000000000000000000000000000000000000000\tHEAD' }
})
mockFetchAsPrivate()
@@ -536,14 +524,6 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/heads/master\
test('resolve an internal repository using SSH protocol with range semver', async () => {
git.mockImplementation(async (args: string[]) => {
if (!args.includes('ssh://git@example.com/org/repo.git')) throw new Error('')
if (args.includes('--refs')) {
return {
stdout: '\
ed3de20970d980cf21a07fd8b8732c70d5182303\trefs/tags/v0.0.38\n\
cba04669e621b85fbdb33371604de1a2898e68e9\trefs/tags/v0.0.39\
',
}
}
return {
stdout: '0000000000000000000000000000000000000000\tHEAD\n\
ed3de20970d980cf21a07fd8b8732c70d5182303\trefs/tags/v0.0.38\n\
@@ -566,14 +546,6 @@ cba04669e621b85fbdb33371604de1a2898e68e9\trefs/tags/v0.0.39',
test('resolve an internal repository using SSH protocol with range semver and SCP-like URL', async () => {
git.mockImplementation(async (args: string[]) => {
if (!args.includes('ssh://git@example.com/org/repo.git')) throw new Error('')
if (args.includes('--refs')) {
return {
stdout: '\
ed3de20970d980cf21a07fd8b8732c70d5182303\trefs/tags/v0.0.38\n\
cba04669e621b85fbdb33371604de1a2898e68e9\trefs/tags/v0.0.39\
',
}
}
return {
stdout: '0000000000000000000000000000000000000000\tHEAD\n\
ed3de20970d980cf21a07fd8b8732c70d5182303\trefs/tags/v0.0.38\n\