Skip to content

Commit

Permalink
feat: Add pnpm support for turbo prune (#1819)
Browse files Browse the repository at this point in the history
* 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
chris-olszewski committed Sep 8, 2022
1 parent 7d25814 commit 8645530
Show file tree
Hide file tree
Showing 12 changed files with 2,755 additions and 17 deletions.
4 changes: 2 additions & 2 deletions cli/internal/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func (c *Context) resolveWorkspaceRootDeps(rootPackageJSON *fs.PackageJSON) erro
for dep, version := range pkg.Dependencies {
pkg.UnresolvedExternalDeps[dep] = version
}
if util.IsYarn(c.PackageManager.Name) {
if c.Lockfile != nil {
pkg.TransitiveDeps = []string{}
c.resolveDepGraph(&lockfileWg, pkg.UnresolvedExternalDeps, depSet, seen, pkg)
lockfileWg.Wait()
Expand Down Expand Up @@ -326,7 +326,7 @@ func (c *Context) parsePackageJSON(repoRoot turbopath.AbsolutePath, pkgJSONPath
}

func (c *Context) resolveDepGraph(wg *sync.WaitGroup, unresolvedDirectDeps map[string]string, resolvedDepsSet mapset.Set, seen mapset.Set, pkg *fs.PackageJSON) {
if !util.IsYarn(c.PackageManager.Name) {
if c.Lockfile == nil {
return
}
for directDepName, unresolvedVersion := range unresolvedDirectDeps {
Expand Down
236 changes: 236 additions & 0 deletions cli/internal/lockfile/pnpm_lockfile.go
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
}
82 changes: 82 additions & 0 deletions cli/internal/lockfile/pnpm_lockfile_test.go
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)
}
}
Loading

0 comments on commit 8645530

Please sign in to comment.