-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add pnpm support for turbo prune (#1819)
* Refactor pruned yarn lockfile writing Change so we build up the lockfile in memory as opposed to writing/reading an intermediate to disk. This should allow for better testing of the lockfile marshalling logic * Move lockfile reading operation into package manager abstraction * fixup lints * Add pnpm lockfile parsing and writing * Hookup pnpm lockfile to pnpm package managers * rebase and add e2e tests for pnpm prune * support windows newlines * remove additional whitespace in generated pnpm-lock.yaml Did a quick test of prune to make sure pnpm is still happy with the pruned lockfile: ``` olszewski@chriss-mbp pnpm-prune % turbo_dev prune --scope=docs Generating pruned monorepo for docs in /private/tmp/pnpm-prune/out - Added docs - Added ui - Added tsconfig - Added eslint-config-custom olszewski@chriss-mbp pnpm-prune % cd out olszewski@chriss-mbp out % pnpm install -r --frozen-lockfile Scope: all 5 workspace projects Lockfile is up-to-date, resolution step is skipped . | +300 ++++++++++++++++++++++++++++++ Packages are hard linked from the content-addressable store to the virtual store. Content-addressable store is at: /Users/olszewski/Library/pnpm/store/v3 Virtual store is at: node_modules/.pnpm Progress: resolved 300, reused 300, downloaded 0, added 300, done olszewski@chriss-mbp out % pnpm turbo run dev --filter=docs • Packages in scope: docs • Running dev in 1 packages docs:dev: cache bypass, force executing f63ed34e7a4cd20c docs:dev: docs:dev: > docs@0.0.0 dev /private/tmp/pnpm-prune/out/apps/docs docs:dev: > next dev --port 3001 docs:dev: docs:dev: ready - started server on 0.0.0.0:3001, url: http://localhost:3001 docs:dev: info - automatically enabled Fast Refresh for 1 custom loader docs:dev: event - compiled client and server successfully in 606 ms (154 modules) ``` * fixups for PR
- Loading branch information
1 parent
7d25814
commit 8645530
Showing
12 changed files
with
2,755 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,236 @@ | ||
package lockfile | ||
|
||
import ( | ||
"fmt" | ||
"io" | ||
|
||
"github.com/pkg/errors" | ||
"gopkg.in/yaml.v3" | ||
) | ||
|
||
// PnpmLockfile Go representation of the contents of 'pnpm-lock.yaml' | ||
// Reference https://github.com/pnpm/pnpm/blob/main/packages/lockfile-types/src/index.ts | ||
type PnpmLockfile struct { | ||
Version float32 `yaml:"lockfileVersion"` | ||
NeverBuiltDependencies []string `yaml:"neverBuiltDependencies,omitempty"` | ||
OnlyBuiltDependencies []string `yaml:"onlyBuiltDependencies,omitempty"` | ||
Overrides map[string]string `yaml:"overrides,omitempty"` | ||
PackageExtensionsChecksum string `yaml:"packageExtensionsChecksum,omitempty"` | ||
PatchedDependencies map[string]PatchFile `yaml:"patchedDependencies,omitempty"` | ||
Importers map[string]ProjectSnapshot `yaml:"importers"` | ||
Packages map[string]PackageSnapshot `yaml:"packages,omitempty"` | ||
Time map[string]string `yaml:"time,omitempty"` | ||
} | ||
|
||
var _ Lockfile = (*PnpmLockfile)(nil) | ||
|
||
// ProjectSnapshot Snapshot used to represent projects in the importers section | ||
type ProjectSnapshot struct { | ||
Specifiers map[string]string `yaml:"specifiers"` | ||
Dependencies map[string]string `yaml:"dependencies,omitempty"` | ||
OptionalDependencies map[string]string `yaml:"optionalDependencies,omitempty"` | ||
DevDependencies map[string]string `yaml:"devDependencies,omitempty"` | ||
DependenciesMeta map[string]DependenciesMeta `yaml:"dependenciesMeta,omitempty"` | ||
PublishDirectory string `yaml:"publishDirectory,omitempty"` | ||
} | ||
|
||
// PackageSnapshot Snapshot used to represent a package in the packages setion | ||
type PackageSnapshot struct { | ||
Resolution PackageResolution `yaml:"resolution,flow"` | ||
ID string `yaml:"id,omitempty"` | ||
|
||
// only needed for packages that aren't in npm | ||
Name string `yaml:"name,omitempty"` | ||
Version string `yaml:"version,omitempty"` | ||
|
||
Engines struct { | ||
Node string `yaml:"node"` | ||
NPM string `yaml:"npm,omitempty"` | ||
} `yaml:"engines,omitempty,flow"` | ||
CPU []string `yaml:"cpu,omitempty,flow"` | ||
Os []string `yaml:"os,omitempty,flow"` | ||
LibC []string `yaml:"libc,omitempty"` | ||
|
||
Deprecated string `yaml:"deprecated,omitempty"` | ||
HasBin bool `yaml:"hasBin,omitempty"` | ||
Prepare bool `yaml:"prepare,omitempty"` | ||
RequiresBuild bool `yaml:"requiresBuild,omitempty"` | ||
|
||
BundledDependencies []string `yaml:"bundledDependencies,omitempty"` | ||
PeerDependencies map[string]string `yaml:"peerDependencies,omitempty"` | ||
PeerDependenciesMeta map[string]struct { | ||
Optional bool `yaml:"optional"` | ||
} `yaml:"peerDependenciesMeta,omitempty"` | ||
|
||
Dependencies map[string]string `yaml:"dependencies,omitempty"` | ||
OptionalDependencies map[string]string `yaml:"optionalDependencies,omitempty"` | ||
|
||
TransitivePeerDependencies []string `yaml:"transitivePeerDependencies,omitempty"` | ||
Dev bool `yaml:"dev"` | ||
Optional bool `yaml:"optional,omitempty"` | ||
Patched bool `yaml:"patched,omitempty"` | ||
} | ||
|
||
// PackageResolution Various resolution strategies for packages | ||
type PackageResolution struct { | ||
Type string `yaml:"type,omitempty"` | ||
// For npm or tarball | ||
Integrity string `yaml:"integrity,omitempty"` | ||
|
||
// For tarball | ||
Tarball string `yaml:"tarball,omitempty"` | ||
|
||
// For local directory | ||
Dir string `yaml:"directory,omitempty"` | ||
|
||
// For git repo | ||
Repo string `yaml:"repo,omitempty"` | ||
Commit string `yaml:"commit,omitempty"` | ||
} | ||
|
||
// PatchFile represent a patch applied to a package | ||
type PatchFile struct { | ||
Path string `yaml:"path"` | ||
Hash string `yaml:"hash"` | ||
} | ||
|
||
func isSupportedVersion(version float32) error { | ||
supportedVersions := []float32{5.3, 5.4} | ||
for _, supportedVersion := range supportedVersions { | ||
if version == supportedVersion { | ||
return nil | ||
} | ||
} | ||
return errors.Errorf("Unable to generate pnpm-lock.yaml with lockfileVersion: %f. Supported lockfile versions are %v", version, supportedVersions) | ||
} | ||
|
||
// DependenciesMeta metadata for dependencies | ||
type DependenciesMeta struct { | ||
Injected bool `yaml:"injected,omitempty"` | ||
Node string `yaml:"node,omitempty"` | ||
Patch string `yaml:"patch,omitempty"` | ||
} | ||
|
||
// DecodePnpmLockfile parse a pnpm lockfile | ||
func DecodePnpmLockfile(contents []byte) (*PnpmLockfile, error) { | ||
var lockfile PnpmLockfile | ||
if err := yaml.Unmarshal(contents, &lockfile); err != nil { | ||
return nil, errors.Wrap(err, "could not unmarshal lockfile: ") | ||
} | ||
|
||
if err := isSupportedVersion(lockfile.Version); err != nil { | ||
return nil, err | ||
} | ||
|
||
return &lockfile, nil | ||
} | ||
|
||
// ResolvePackage Given a package and version returns the key, resolved version, and if it was found | ||
func (p *PnpmLockfile) ResolvePackage(name string, version string) (string, string, bool) { | ||
resolvedVersion, ok := p.resolveSpecifier(name, version) | ||
if !ok { | ||
return "", "", false | ||
} | ||
key := fmt.Sprintf("/%s/%s", name, resolvedVersion) | ||
if entry, ok := (p.Packages)[key]; ok { | ||
var version string | ||
if entry.Version != "" { | ||
version = entry.Version | ||
} else { | ||
version = resolvedVersion | ||
} | ||
return key, version, true | ||
} | ||
|
||
return "", "", false | ||
} | ||
|
||
// AllDependencies Given a lockfile key return all (dev/optional/peer) dependencies of that package | ||
func (p *PnpmLockfile) AllDependencies(key string) (map[string]string, bool) { | ||
deps := map[string]string{} | ||
entry, ok := (p.Packages)[key] | ||
if !ok { | ||
return deps, false | ||
} | ||
|
||
for name, version := range entry.Dependencies { | ||
deps[name] = version | ||
} | ||
|
||
for name, version := range entry.OptionalDependencies { | ||
deps[name] = version | ||
} | ||
|
||
for name, version := range entry.PeerDependencies { | ||
deps[name] = version | ||
} | ||
|
||
return deps, true | ||
} | ||
|
||
// Subgraph Given a list of lockfile keys returns a Lockfile based off the original one that only contains the packages given | ||
func (p *PnpmLockfile) Subgraph(packages []string) (Lockfile, error) { | ||
lockfilePackages := make(map[string]PackageSnapshot, len(packages)) | ||
for _, key := range packages { | ||
entry, ok := p.Packages[key] | ||
if ok { | ||
lockfilePackages[key] = entry | ||
} else { | ||
return nil, fmt.Errorf("Unable to find lockfile entry for %s", key) | ||
} | ||
} | ||
|
||
lockfile := PnpmLockfile{ | ||
Version: p.Version, | ||
Importers: p.Importers, | ||
Packages: lockfilePackages, | ||
NeverBuiltDependencies: p.NeverBuiltDependencies, | ||
OnlyBuiltDependencies: p.OnlyBuiltDependencies, | ||
Overrides: p.Overrides, | ||
PackageExtensionsChecksum: p.PackageExtensionsChecksum, | ||
PatchedDependencies: p.PatchedDependencies, | ||
} | ||
|
||
return &lockfile, nil | ||
} | ||
|
||
// Encode encode the lockfile representation and write it to the given writer | ||
func (p *PnpmLockfile) Encode(w io.Writer) error { | ||
if err := isSupportedVersion(p.Version); err != nil { | ||
return err | ||
} | ||
|
||
encoder := yaml.NewEncoder(w) | ||
encoder.SetIndent(2) | ||
|
||
if err := encoder.Encode(p); err != nil { | ||
return errors.Wrap(err, "unable to encode pnpm lockfile") | ||
} | ||
return nil | ||
} | ||
|
||
func (p *PnpmLockfile) resolveSpecifier(name string, specifier string) (string, bool) { | ||
// Check if the specifier is already a resolved version | ||
_, ok := p.Packages[fmt.Sprintf("/%s/%s", name, specifier)] | ||
if ok { | ||
return specifier, true | ||
} | ||
for workspacePkg, importer := range p.Importers { | ||
for pkgName, pkgSpecifier := range importer.Specifiers { | ||
if name == pkgName && specifier == pkgSpecifier { | ||
if resolvedVersion, ok := importer.Dependencies[name]; ok { | ||
return resolvedVersion, true | ||
} | ||
if resolvedVersion, ok := importer.DevDependencies[name]; ok { | ||
return resolvedVersion, true | ||
} | ||
if resolvedVersion, ok := importer.OptionalDependencies[name]; ok { | ||
return resolvedVersion, true | ||
} | ||
|
||
panic(fmt.Sprintf("Unable to find resolved version for %s@%s in %s", name, specifier, workspacePkg)) | ||
} | ||
} | ||
} | ||
return "", false | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
package lockfile | ||
|
||
import ( | ||
"bytes" | ||
"os" | ||
"testing" | ||
|
||
"github.com/pkg/errors" | ||
"github.com/vercel/turborepo/cli/internal/fs" | ||
"gotest.tools/v3/assert" | ||
) | ||
|
||
func getFixture(t *testing.T, name string) ([]byte, error) { | ||
defaultCwd, err := os.Getwd() | ||
if err != nil { | ||
t.Errorf("failed to get cwd: %v", err) | ||
} | ||
cwd, err := fs.CheckedToAbsolutePath(defaultCwd) | ||
if err != nil { | ||
t.Fatalf("cwd is not an absolute directory %v: %v", defaultCwd, err) | ||
} | ||
lockfilePath := cwd.Join("testdata", "pnpm-lockfiles", name) | ||
if !lockfilePath.FileExists() { | ||
return nil, errors.Errorf("unable to find 'testdata/%s'", name) | ||
} | ||
return os.ReadFile(lockfilePath.ToStringDuringMigration()) | ||
} | ||
|
||
func Test_Roundtrip(t *testing.T) { | ||
lockfiles := []string{"pnpm6-workspace.yaml", "pnpm7-workspace.yaml"} | ||
|
||
for _, lockfilePath := range lockfiles { | ||
lockfileContent, err := getFixture(t, lockfilePath) | ||
if err != nil { | ||
t.Errorf("failure getting fixture: %s", err) | ||
} | ||
lockfile, err := DecodePnpmLockfile(lockfileContent) | ||
if err != nil { | ||
t.Errorf("decoding failed %s", err) | ||
} | ||
var b bytes.Buffer | ||
if err := lockfile.Encode(&b); err != nil { | ||
t.Errorf("encoding failed %s", err) | ||
} | ||
newLockfile, err := DecodePnpmLockfile(b.Bytes()) | ||
if err != nil { | ||
t.Errorf("decoding failed %s", err) | ||
} | ||
|
||
assert.DeepEqual(t, lockfile, newLockfile) | ||
} | ||
} | ||
|
||
func Test_SpecifierResolution(t *testing.T) { | ||
contents, err := getFixture(t, "pnpm7-workspace.yaml") | ||
if err != nil { | ||
t.Error(err) | ||
} | ||
lockfile, err := DecodePnpmLockfile(contents) | ||
if err != nil { | ||
t.Errorf("failure decoding lockfile: %v", err) | ||
} | ||
|
||
type Case struct { | ||
pkg string | ||
specifier string | ||
version string | ||
found bool | ||
} | ||
|
||
cases := []Case{ | ||
{pkg: "lodash", specifier: "latest", version: "4.17.21", found: true}, | ||
{pkg: "express", specifier: "^4.18.1", version: "4.18.1", found: true}, | ||
{pkg: "lodash", specifier: "other-tag", version: "", found: false}, | ||
} | ||
|
||
for _, testCase := range cases { | ||
actualVersion, actualFound := lockfile.resolveSpecifier(testCase.pkg, testCase.specifier) | ||
assert.Equal(t, actualFound, testCase.found, "%s@%s", testCase.pkg, testCase.version) | ||
assert.Equal(t, actualVersion, testCase.version, "%s@%s", testCase.pkg, testCase.version) | ||
} | ||
} |
Oops, something went wrong.