You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on May 14, 2026. It is now read-only.
pnpm install --frozen-lockfile against a lockfile that contains git-hosted dependencies fails today in pacquet. Roadmap (#299) Stage 1 lists "Installation of git-hosted packages" as a remaining checkbox; this issue scopes it to materializing git-hosted lockfile entries during a frozen-lockfile install. Resolution (parsing github:user/repo, git+ssh://..., semver-range git deps, etc.) lives in pnpm's resolving/git-resolver and is not needed for frozen install — the lockfile already pins the commit (or the tarball URL + integrity). Track resolution with Stage 2.
A pnpm-lock.yaml written for a git-hosted dependency can take one of two shapes:
type: git resolution — { repo, commit }. pnpm shells out to git, clones, checks out the pinned commit, runs the package's prepare lifecycle if needed, then packlists the result into the CAS.
Tarball resolution with gitHosted: true — { tarball, integrity?, gitHosted: true }. Used when the git host (GitHub / GitLab / Bitbucket) exposes a tarball endpoint. pnpm downloads the tarball through the normal HTTP path, but the contents still need preparePackage because the host's archive endpoint does not run lifecycle hooks. The CAS key is gitHostedStoreIndexKey(pkgId, { built }) rather than the integrity-based storeIndexKey, since the cached output depends on whether the build actually ran.
Both shapes are produced by resolving/git-resolver (index.ts:65-85): hosted + non-SSH → tarball with gitHosted: true; otherwise GitResolution. Pacquet has to handle both because either can land in pnpm-lock.yaml.
Today
Both call sites that dispatch on LockfileResolution reject Git outright:
crates/package-manager/src/create_virtual_store.rs:513-520 returns the same error from the store-index lookup path.
Pacquet's crates/lockfile/src/resolution.rs already models GitResolution { repo, commit } correctly. The TarballResolution struct, however, is #[serde(deny_unknown_fields)] and lacks the gitHosted field. A real pnpm lockfile that pins a github-tarball resolution will therefore fail to deserialize with an "unknown field gitHosted" error, before pacquet even reaches the install dispatcher. That's a silent correctness gap independent of the install logic.
No git client, no preparePackage equivalent, no packlist, and no git-hosted store-index keying anywhere in the workspace.
Git fetcher — fetching/git-fetcher/src/index.ts. git clone (or git init + git fetch --depth 1 origin <commit> when the host is in gitShallowHosts), git checkout <commit>, git rev-parse HEAD to verify, preparePackage, rimraf .git, packlist, addFilesFromDir.
Git-hosted tarball fetcher — fetching/tarball-fetcher/src/gitHostedTarballFetcher.ts. Reuses remoteTarballFetcher for the download, then runs preparePackage on the extracted tree, and stores under a built or not-built CAS key. If the prepare step ends up packing the same file set as the raw tarball, the raw entry is promoted in place to skip the redundant rewrite.
preparePackage — exec/prepare-package/src/index.ts:29-90. Decides whether the package needs building (scripts.prepare, or prepublish* + missing main file), then runs <pm>-install + prepare + prepublish* lifecycles. Errors with ERR_PNPM_PREPARE_PACKAGE on failure and GIT_DEP_PREPARE_NOT_ALLOWED if the package wants to build but isn't in allowBuilds.
Store index keying — store/index/src/index.ts:60-95. pickStoreIndexKey returns gitHostedStoreIndexKey(pkgId, { built }) (= pkgId\tbuilt or pkgId\tnot-built) when gitHosted === trueor when integrity is missing; integrity-keyed otherwise.
Lockfile-types annotation — lockfile/types/src/index.ts:88-107. TarballResolution.gitHosted is optional; the loader back-fills it on entries whose URL matches a known git host, for backward compatibility with older lockfiles.
Plan
A. Lockfile types
Add git_hosted: Option<bool> to TarballResolution in crates/lockfile/src/resolution.rs (serde rename gitHosted, skip on None). The #[serde(deny_unknown_fields)] currently bites lockfiles produced by recent pnpm; this is a bug today regardless of the rest of this issue.
Backward-compat back-fill on read: when git_hosted is absent but the tarball URL parses to a known git host (codeload.github.com, gitlab.com, bitbucket.org, plus the gitlab.com/bitbucket.org archive endpoints), set it to true. Mirrors the loader's behavior described in lockfile/types/src/index.ts:97-107.
Store-index key selection: port pickStoreIndexKey semantics. For tarball resolutions with git_hosted: trueor missing integrity, key by pkgId\t{built,not-built} instead of integrity\tpkgId. This lives near store_index_key in crates/store-dir (or wherever the current pacquet equivalent sits).
B. Git fetcher (handles LockfileResolution::Git)
New module — crates/git-fetcher or under crates/tarball if shared cafs plumbing makes sense there. Port fetching/git-fetcher/src/index.ts semantics:
Shell out to git. On Windows pass -c core.longpaths=true.
When the host is in gitShallowHosts: git init → git remote add origin <repo> → git fetch --depth 1 origin <commit>. Else: git clone <repo> <tempDir>.
git checkout <commit> and git rev-parse HEAD to verify the resolved commit matches the pinned one. Surface GIT_CHECKOUT_FAILED on mismatch.
Run preparePackage (see section D). Surface ERR_PNPM_PREPARE_PACKAGE on failure.
Delete .git before computing CAS contents.
packlist-equivalent: read .npmignore + files field + npm's default ignore rules to determine what gets stored.
Import the resulting file tree into the CAS via the existing pacquet addFilesFromDir-equivalent in crates/tarball / crates/store-dir (port or reuse).
Replace the UnsupportedResolution return at install_package_by_snapshot.rs:110-115 with a dispatch to the new fetcher.
Replace the same at create_virtual_store.rs:513-520 so the store-index key path also handles git resolutions (returns the gitHostedStoreIndexKey).
Wire a new git_shallow_hosts: Vec<String> to Config and pnpm-workspace.yaml parsing. Default per upstream (currently ['github.com', 'gist.github.com', 'gitlab.com', 'bitbucket.org', 'bitbucket.com'] — verify against config/reader defaults).
Surface a clear error if git is not on PATH. Don't try to bundle git.
C. Git-hosted tarball fetcher (handles TarballResolution { git_hosted: true })
When the install dispatcher encounters TarballResolution { git_hosted: true }, take a different post-download path:
Download the tarball via the existing pacquet tarball fetcher into a raw CAS slot keyed under a \traw suffix on the files-index path (matching upstream's ${filesIndexFile}\traw at gitHostedTarballFetcher.ts:27).
Extract into a temp dir, run preparePackage on it.
packlist the prepared result.
If the prepared file set is identical to the raw set, promote the raw entry to the final key in place (the optimization at gitHostedTarballFetcher.ts:88-100 — fast path for prepare-less git-hosted tarballs).
Otherwise re-import the prepared tree under the regular files-index path.
Key under gitHostedStoreIndexKey(pkgId, { built }), not integrity. The built flag reflects whether preparePackage actually ran a build (it returns shouldBeBuilt).
Propagate the raw tarball's integrity into the lockfile result so future installs still pin the bytes from the host.
D. preparePackage
Port exec/prepare-package/src/index.ts. Order of operations: read package.json of the extracted tree, decide via packageShouldBeBuilt (any non-empty scripts.prepare, or any prepublish* script + missing main), then run <pm>-install + prepare + each non-empty PREPUBLISH_SCRIPTS (exec/prepare-package/src/index.ts:48-80) via pacquet's existing lifecycle-script runner.
Honor allowBuild — pacquet's AllowBuildPolicy already exists (commit 6af026fa); pass it in. Throw GIT_DEP_PREPARE_NOT_ALLOWED when the package wants to build but isn't allowlisted, mirroring upstream's error code + hint.
Detect the preferred package manager for the temp dir (lockfile sniffing — pnpm-lock.yaml / yarn.lock / package-lock.json); default to npm if none. Matters because the <pm>-install script is what materializes the prepare-time dependencies.
rimraf node_modules after the prepare finishes — those installed deps must not end up in the CAS.
E. Tests
Frozen-lockfile install of a fixture lockfile that pins a type: git resolution against a small public repo. Assert the snapshot lands in CAS and resolves via a virtual-store symlink. Use a fixture under crates/testing-utils/src/fixtures/ and a local repo (no network), per existing conventions.
Frozen-lockfile install of a TarballResolution { git_hosted: true } entry; assert the tarball goes through the prepare path and the CAS uses the gitHostedStoreIndexKey shape.
Lockfile round-trip: a pnpm-written lockfile containing both shapes loads, serializes, and re-loads without losing gitHosted. Closes the deny_unknown_fields gap.
Commit-mismatch case: a GitResolution whose commit no longer exists in the remote → GIT_CHECKOUT_FAILED.
Prepare-disallowed case: a git-hosted package with scripts.preparenot in allowBuilds → GIT_DEP_PREPARE_NOT_ALLOWED.
Prepare-allowed case: same package with allowBuilds: { <name>: true } → install succeeds, post-build artifacts land in CAS, requires_build flag is set.
Shallow-clone gating: when the host is in git_shallow_hosts, assert that git fetch --depth 1 was used rather than full clone (the integration-style test can spy on git invocations via PATH shimming, mirroring upstream's test approach).
Port from plans/TEST_PORTING.md:563-572 once the implementation is in place. Mark the items there as done.
Out of scope (Stage 2 / follow-ups)
Resolution.resolving/git-resolver (parsing github:user/repo and friends, resolving committish to SHA, choosing tarball vs git resolution). Needed for non-frozen pnpm install and for pnpm add github:user/repo. Track with Stage 2.
isRepoPublic / SSH discovery.network/git-utils is mostly used by pnpm publish, not install.
hosted-git-info port. Required by the resolver, not the fetcher.
Background
pnpm install --frozen-lockfileagainst a lockfile that contains git-hosted dependencies fails today in pacquet. Roadmap (#299) Stage 1 lists "Installation of git-hosted packages" as a remaining checkbox; this issue scopes it to materializing git-hosted lockfile entries during a frozen-lockfile install. Resolution (parsinggithub:user/repo,git+ssh://..., semver-range git deps, etc.) lives in pnpm'sresolving/git-resolverand is not needed for frozen install — the lockfile already pins thecommit(or the tarball URL + integrity). Track resolution with Stage 2.A
pnpm-lock.yamlwritten for a git-hosted dependency can take one of two shapes:type: gitresolution —{ repo, commit }. pnpm shells out togit, clones, checks out the pinned commit, runs the package'spreparelifecycle if needed, thenpacklists the result into the CAS.gitHosted: true—{ tarball, integrity?, gitHosted: true }. Used when the git host (GitHub / GitLab / Bitbucket) exposes a tarball endpoint. pnpm downloads the tarball through the normal HTTP path, but the contents still needpreparePackagebecause the host's archive endpoint does not run lifecycle hooks. The CAS key isgitHostedStoreIndexKey(pkgId, { built })rather than the integrity-basedstoreIndexKey, since the cached output depends on whether the build actually ran.Both shapes are produced by
resolving/git-resolver(index.ts:65-85): hosted + non-SSH → tarball withgitHosted: true; otherwiseGitResolution. Pacquet has to handle both because either can land inpnpm-lock.yaml.Today
Both call sites that dispatch on
LockfileResolutionrejectGitoutright:crates/package-manager/src/install_package_by_snapshot.rs:110-115returnsUnsupportedResolution { resolution_kind: "git" }.crates/package-manager/src/create_virtual_store.rs:513-520returns the same error from the store-index lookup path.Pacquet's
crates/lockfile/src/resolution.rsalready modelsGitResolution { repo, commit }correctly. TheTarballResolutionstruct, however, is#[serde(deny_unknown_fields)]and lacks thegitHostedfield. A real pnpm lockfile that pins a github-tarball resolution will therefore fail to deserialize with an "unknown fieldgitHosted" error, before pacquet even reaches the install dispatcher. That's a silent correctness gap independent of the install logic.No git client, no
preparePackageequivalent, nopacklist, and no git-hosted store-index keying anywhere in the workspace.Upstream references (pnpm v11
94240bc046)fetching/git-fetcher/src/index.ts.git clone(orgit init+git fetch --depth 1 origin <commit>when the host is ingitShallowHosts),git checkout <commit>,git rev-parse HEADto verify,preparePackage,rimraf .git,packlist,addFilesFromDir.fetching/tarball-fetcher/src/gitHostedTarballFetcher.ts. ReusesremoteTarballFetcherfor the download, then runspreparePackageon the extracted tree, and stores under a built or not-built CAS key. If thepreparestep ends up packing the same file set as the raw tarball, the raw entry is promoted in place to skip the redundant rewrite.preparePackage—exec/prepare-package/src/index.ts:29-90. Decides whether the package needs building (scripts.prepare, orprepublish*+ missingmainfile), then runs<pm>-install+prepare+prepublish*lifecycles. Errors withERR_PNPM_PREPARE_PACKAGEon failure andGIT_DEP_PREPARE_NOT_ALLOWEDif the package wants to build but isn't inallowBuilds.store/index/src/index.ts:60-95.pickStoreIndexKeyreturnsgitHostedStoreIndexKey(pkgId, { built })(=pkgId\tbuiltorpkgId\tnot-built) whengitHosted === trueor when integrity is missing; integrity-keyed otherwise.lockfile/types/src/index.ts:88-107.TarballResolution.gitHostedis optional; the loader back-fills it on entries whose URL matches a known git host, for backward compatibility with older lockfiles.Plan
A. Lockfile types
git_hosted: Option<bool>toTarballResolutionincrates/lockfile/src/resolution.rs(serde renamegitHosted, skip onNone). The#[serde(deny_unknown_fields)]currently bites lockfiles produced by recent pnpm; this is a bug today regardless of the rest of this issue.git_hostedis absent but the tarball URL parses to a known git host (codeload.github.com,gitlab.com,bitbucket.org, plus thegitlab.com/bitbucket.orgarchive endpoints), set it totrue. Mirrors the loader's behavior described inlockfile/types/src/index.ts:97-107.pickStoreIndexKeysemantics. For tarball resolutions withgit_hosted: trueor missing integrity, key bypkgId\t{built,not-built}instead ofintegrity\tpkgId. This lives nearstore_index_keyincrates/store-dir(or wherever the current pacquet equivalent sits).B. Git fetcher (handles
LockfileResolution::Git)crates/git-fetcheror undercrates/tarballif shared cafs plumbing makes sense there. Portfetching/git-fetcher/src/index.tssemantics:git. On Windows pass-c core.longpaths=true.gitShallowHosts:git init→git remote add origin <repo>→git fetch --depth 1 origin <commit>. Else:git clone <repo> <tempDir>.git checkout <commit>andgit rev-parse HEADto verify the resolved commit matches the pinned one. SurfaceGIT_CHECKOUT_FAILEDon mismatch.preparePackage(see section D). SurfaceERR_PNPM_PREPARE_PACKAGEon failure..gitbefore computing CAS contents.packlist-equivalent: read.npmignore+filesfield + npm's default ignore rules to determine what gets stored.addFilesFromDir-equivalent incrates/tarball/crates/store-dir(port or reuse).UnsupportedResolutionreturn atinstall_package_by_snapshot.rs:110-115with a dispatch to the new fetcher.create_virtual_store.rs:513-520so the store-index key path also handles git resolutions (returns thegitHostedStoreIndexKey).git_shallow_hosts: Vec<String>toConfigandpnpm-workspace.yamlparsing. Default per upstream (currently['github.com', 'gist.github.com', 'gitlab.com', 'bitbucket.org', 'bitbucket.com']— verify againstconfig/readerdefaults).gitis not onPATH. Don't try to bundle git.C. Git-hosted tarball fetcher (handles
TarballResolution { git_hosted: true })TarballResolution { git_hosted: true }, take a different post-download path:\trawsuffix on the files-index path (matching upstream's${filesIndexFile}\trawatgitHostedTarballFetcher.ts:27).preparePackageon it.packlistthe prepared result.gitHostedTarballFetcher.ts:88-100— fast path for prepare-less git-hosted tarballs).gitHostedStoreIndexKey(pkgId, { built }), not integrity. Thebuiltflag reflects whetherpreparePackageactually ran a build (it returnsshouldBeBuilt).integrityinto the lockfile result so future installs still pin the bytes from the host.D.
preparePackageexec/prepare-package/src/index.ts. Order of operations: readpackage.jsonof the extracted tree, decide viapackageShouldBeBuilt(any non-emptyscripts.prepare, or anyprepublish*script + missingmain), then run<pm>-install+prepare+ each non-emptyPREPUBLISH_SCRIPTS(exec/prepare-package/src/index.ts:48-80) via pacquet's existing lifecycle-script runner.allowBuild— pacquet'sAllowBuildPolicyalready exists (commit6af026fa); pass it in. ThrowGIT_DEP_PREPARE_NOT_ALLOWEDwhen the package wants to build but isn't allowlisted, mirroring upstream's error code + hint.pnpm-lock.yaml/yarn.lock/package-lock.json); default tonpmif none. Matters because the<pm>-installscript is what materializes the prepare-time dependencies.rimraf node_modulesafter the prepare finishes — those installed deps must not end up in the CAS.E. Tests
type: gitresolution against a small public repo. Assert the snapshot lands in CAS and resolves via a virtual-store symlink. Use a fixture undercrates/testing-utils/src/fixtures/and a local repo (no network), per existing conventions.TarballResolution { git_hosted: true }entry; assert the tarball goes through the prepare path and the CAS uses thegitHostedStoreIndexKeyshape.gitHosted. Closes thedeny_unknown_fieldsgap.GitResolutionwhosecommitno longer exists in the remote →GIT_CHECKOUT_FAILED.scripts.preparenot inallowBuilds→GIT_DEP_PREPARE_NOT_ALLOWED.allowBuilds: { <name>: true }→ install succeeds, post-build artifacts land in CAS,requires_buildflag is set.git_shallow_hosts, assert thatgit fetch --depth 1was used rather than full clone (the integration-style test can spy ongitinvocations viaPATHshimming, mirroring upstream's test approach).plans/TEST_PORTING.md:563-572once the implementation is in place. Mark the items there as done.Out of scope (Stage 2 / follow-ups)
resolving/git-resolver(parsinggithub:user/repoand friends, resolving committish to SHA, choosing tarball vs git resolution). Needed for non-frozenpnpm installand forpnpm add github:user/repo. Track with Stage 2.isRepoPublic/ SSH discovery.network/git-utilsis mostly used bypnpm publish, not install.hosted-git-infoport. Required by the resolver, not the fetcher.pacquet install --frozen-lockfile#432). Git-hosted packages live in CAS the same way; the only interaction is the store-index key still beinggitHostedStoreIndexKey. No extra work here.--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433). Same per-snapshot skip logic applies —current_packages[depPath]plusisIntegrityEqualon the resolution. Git resolutions compare by{ repo, commit }; git-hosted tarball resolutions compare bytarballURL +builtflag.Related
plans/TEST_PORTING.mdlines 563-572pacquet install --frozen-lockfile#431), GVS (Add global virtual store support topacquet install --frozen-lockfile#432), partial install (Partial install with--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433), hoisting (Add hoisting support:hoistPatternandpublicHoistPattern#435), allow-builds policy (commit6af026fa)Written by an agent (Claude Code, claude-opus-4-7).