Files
pnpm/pnpm11/lockfile/fs/src/lockfileName.ts
d06937736c fix(lockfile): read the branch lockfiles of a detached HEAD (#15887)
With gitBranchLockfile, an install named its lockfile after the branch
HEAD pointed at. A detached HEAD, which is what a CI checkout of a
commit SHA produces, names none, so the install read only the shared
pnpm-lock.yaml and failed with ERR_PNPM_NO_LOCKFILE even though the
lockfile of a branch containing the commit sat next to it.

When HEAD is detached, the wanted-lockfile read now falls back to the
lockfiles of the local and remote-tracking branches containing the
commit, tried before the shared one. A CI checkout of a SHA usually has
only the remote-tracking ref it fetched, so those count too. The write target is unchanged: a branch containing HEAD need
not have HEAD at its tip, so a detached install still writes the shared
lockfile, as under mergeGitBranchLockfiles.

The fix covers both the TypeScript v11 CLI and the Rust v12 CLI.

Close pnpm/pnpm#7672


---------

Co-authored-by: gaishijie2025 <gaishijie@isrc.iscas.ac.cn>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:50:47 +02:00

71 lines
2.7 KiB
TypeScript

import { WANTED_LOCKFILE } from '@pnpm/constants'
import { getBranchesContainingHead, getCurrentBranch } from '@pnpm/network.git-utils'
export interface GetWantedLockfileNameOptions {
useGitBranchLockfile?: boolean
mergeGitBranchLockfiles?: boolean
cwd?: string
}
export async function getWantedLockfileName (opts: GetWantedLockfileNameOptions = {}): Promise<string> {
if (opts.useGitBranchLockfile && !opts.mergeGitBranchLockfiles) {
const currentBranchName = await getCurrentBranch({ cwd: opts.cwd })
if (currentBranchName) {
return WANTED_LOCKFILE.replace('.yaml', `.${stringifyBranchName(currentBranchName)}.yaml`)
}
}
return WANTED_LOCKFILE
}
export interface WantedLockfileSelection {
/** The lockfile of the checked-out branch, or `pnpm-lock.yaml`. */
fileName: string
/**
* On a detached HEAD, the lockfiles of the branches containing the
* checked-out commit, which the read tries before `fileName`. Empty when
* HEAD is attached to a branch.
*/
detachedHeadCandidates: string[]
}
/**
* The files the wanted-lockfile read tries.
*
* A detached HEAD names no branch, but the checked-out commit still belongs
* to the branches whose history includes it, so their lockfiles can satisfy
* the checkout. `mergeGitBranchLockfiles` reads the shared lockfile and folds
* every branch lockfile in, so it has no candidates.
*/
export async function selectWantedLockfile (opts: GetWantedLockfileNameOptions = {}): Promise<WantedLockfileSelection> {
if (!opts.useGitBranchLockfile || opts.mergeGitBranchLockfiles) {
return { fileName: WANTED_LOCKFILE, detachedHeadCandidates: [] }
}
const currentBranchName = await getCurrentBranch({ cwd: opts.cwd })
if (currentBranchName) {
return { fileName: branchLockfileName(currentBranchName), detachedHeadCandidates: [] }
}
const branchesContainingHead = await getBranchesContainingHead({ cwd: opts.cwd })
return {
fileName: WANTED_LOCKFILE,
// A name longer than a filesystem allows cannot be on disk, and probing
// it fails with ENAMETOOLONG instead of reporting it absent.
detachedHeadCandidates: branchesContainingHead
.map(branchLockfileName)
.filter((fileName) => fileName.length <= MAX_FILE_NAME_LENGTH),
}
}
const MAX_FILE_NAME_LENGTH = 255
function branchLockfileName (branchName: string): string {
return WANTED_LOCKFILE.replace('.yaml', `.${stringifyBranchName(branchName)}.yaml`)
}
/**
* 1. Git branch name may contains slashes, which is not allowed in filenames
* 2. Filesystem may be case-insensitive, so we need to convert branch name to lowercase
*/
function stringifyBranchName (branchName: string = ''): string {
return branchName.replace(/[^\w.-]/g, '!').toLowerCase()
}