Skip to content

Commit

Permalink
feat: add npm diff
Browse files Browse the repository at this point in the history
- As proposed in RFC: npm/rfcs#144
  • Loading branch information
ruyadorno committed Jan 20, 2021
1 parent 23f3d7d commit 3d2547a
Show file tree
Hide file tree
Showing 55 changed files with 9,845 additions and 9 deletions.
159 changes: 159 additions & 0 deletions docs/content/commands/npm-diff.md
@@ -0,0 +1,159 @@
---
title: npm-diff
section: 1
description: The registry diff command
---

### Synopsis

```bash
npm diff
npm diff <pkg-name>
npm diff <version-a> [<version-b>]
npm diff <spec-a> [<spec-b>]
```

### Description

Similar to its `git diff` counterpart, this command will print diff patches
of files for packages published to the npm registry.

A variation of different arguments are supported, along with a range of
familiar options from [git diff](https://git-scm.com/docs/git-diff#_options).

* `npm diff <spec-a> <spec-b>`

Compares two package versions using their registry specifiers, e.g:
`npm diff foo@1.0.0 foo@^2.0.0`. It's also possible to compare across forks
of any package, e.g: `npm diff foo@1.0.0 foo-fork@1.0.0`.

Any valid spec can be used, so that it's also possible to compare
directories or git repositories, e.g: `npm diff foo@latest ./packages/foo`

* `npm diff` (in a package directory, no arguments):

If the package is published to the registry, `npm diff` will fetch the
tarball version tagged as `latest` (this value can be configured using the
`tag` option) and proceed to compare the contents of files present in that
tarball, with the current files in your local file system.

This workflow provides a handy way for package authors to see what
package-tracked files have been changed in comparison with the latest
published version of that package.

* `npm diff <version-a> [<version-b>]`

Using `npm diff` along with semver-valid version numbers is a shorthand
to compare different versions of the current package. It needs to be run
from a package directory, such that for a package named `foo` running
`npm diff 1.0.0 1.0.1` is the same as running
`npm diff foo@1.0.0 foo@1.0.1`. If only a single argument `<version-a>` is
provided, then the current local file system is going to be compared
against that version.

* `npm diff <pkg-name>`

When using a single package name (with no version or tag specifier) as an
argument, `npm diff` will work in a similar way to
[`npm-outdated`](npm-outdated) and reach for the registry to figure out
what current published version of the package named <pkg-name> will satisfy
its dependent declared semver-range. Once that specific version is known
`npm diff` will print diff patches comparing the current version of
<pkg-name> found in the local file system with that specific version
returned by the registry.

* `npm diff <spec-a>` (single specifier argument)

Similar to using only a single package name, it's also possible to declare
a full registry specifier version if you wish to compare the local version
of a installed package with the specific version/tag/semver-range provided
in `<spec-a>`. e.g: (assuming foo@1.0.0 is installed in the current
`node_modules` folder) running `npm diff foo@2.0.0` will effectively be
an alias to `npm diff foo@1.0.0 foo@2.0.0`.

#### Filtering files

It's possible to also specify file names or globs pattern matching in order to
limit the result of diff patches to only a subset of files for a given package.

Given the fact that paths are also valid specs, a separator `--` is required
when specifying sets of files to filter in diff. Any extra argument declared
after `--` will be treated as a filenames/globs and diff results will be
limited to files included or matched by those. e.g:

`npm diff foo@2 -- lib/* CHANGELOG.md`

Note: When using `npm diff` with two spec/version arguments, the separator `--`
becomes redudant and can be removed, e.g: `npm diff foo@1.0.0 foo@1.0.1 lib/*`

### Configuration

#### name-only

* Type: Boolean
* Default: false

When set to `true` running `npm diff` only returns the names of the files that
have any difference.

#### unified

* Alias: `-U`
* Type: number
* Default: `3`

The number of lines of context to print in the unified diff format output.

#### ignore-all-space

* Alias: `-w`
* Type: Boolean
* Default: false

Ignore whitespace when comparing lines. This ignores differences even if one
line has whitespace where the other line has none.

#### no-prefix

* Type: Boolean
* Default: false

Do not show any source or destination prefix.

#### src-prefix

* Type: String
* Default: `"a/"`

Show the given source prefix in diff patches headers instead of using "a/".

#### dst-prefix

* Type: String
* Default: `"b/"`

Show the given source prefix in diff patches headers instead of using "b/".

#### text

* Alias: `-a`
* Type: Boolean
* Default: false

Treat all files as text.

#### tag

* Type: String
* Default: `"latest"`

The tag used to fetch the tarball that will be compared with local file system
files when running npm diff with no arguments.


## See Also

* [npm outdated](/commands/npm-outdated)
* [npm install](/commands/npm-install)
* [npm config](/commands/npm-config)
* [npm registry](/using-npm/registry)
200 changes: 200 additions & 0 deletions lib/diff.js
@@ -0,0 +1,200 @@
const semver = require('semver')
const libdiff = require('libnpmdiff')
const npa = require('npm-package-arg')
const Arborist = require('@npmcli/arborist')
const npmlog = require('npmlog')
const pacote = require('pacote')
const pickManifest = require('npm-pick-manifest')

const npm = require('./npm.js')
const usageUtil = require('./utils/usage.js')
const output = require('./utils/output.js')
const completion = require('./utils/completion/none.js')
const readLocalPkg = require('./utils/read-local-package.js')

const usage = usageUtil(
'diff',
'npm diff' +
'\nnpm diff [--ignore-all-space] [--name-only] [-- <path>...]' +
'\nnpm diff <pkg-name>' +
'\nnpm diff <version-a> [<version-b>]' +
'\nnpm diff <spec-a> [<spec-b>]'
)

const cmd = (args, cb) => diff(args).then(() => cb()).catch(cb)

const diff = async (args) => {
const [a, b, ...files] = parseArgs(args)
const specs = await retrieveSpecs([a, b])
npmlog.info(`diff a:${specs.a} b:${specs.b}`)
const res = await libdiff(specs, {
...npm.flatOptions,
...{ diffOpts: {
files,
...getDiffOpts(),
}},
})
return output(res)
}

const parseArgs = (args) => {
const argv = npm.config.parsedArgv.cooked
const sep = argv.indexOf('--')

if (sep > -1) {
const files = argv.slice(sep + 1)
const notFiles = argv.slice(0, sep)
const [a, b] = args.map(arg => notFiles.includes(arg) ? arg : undefined)
return [a, b, ...files]
}

return args
}

const retrieveSpecs = async (args) => {
const [a, b] = await convertVersionsToSpecs(args)

if (!a) {
const spec = await defaultSpec()
return { a: spec }
}

if (!b)
return await transformSingleSpec(a)

return { a, b }
}

const convertVersionsToSpecs = (args) =>
Promise.all(args.map(async arg => {
if (semver.valid(arg)) {
let pkgName
try {
pkgName = await readLocalPkg()
} catch (e) {}

if (!pkgName) {
throw new Error(
'Needs to be run from a project dir in order to use versions.\n\n' +
`Usage:\n${usage}`
)
}

return `${pkgName}@${arg}`
}
return arg
}))

const defaultSpec = async () => {
let pkgName
try {
pkgName = await readLocalPkg()
} catch (e) {}

if (!pkgName) {
throw new Error(
'Needs multiple arguments to compare or run from a project dir.\n\n' +
`Usage:\n${usage}`
)
}

return `${pkgName}@${npm.flatOptions.defaultTag}`
}

const transformSingleSpec = async (a) => {
const spec = npa(a)
let pkgName

try {
pkgName = await readLocalPkg()
} catch (e) {}

if (!pkgName) {
throw new Error(
'Needs to be run from a project dir in order to use a single package name.\n\n' +
`Usage:\n${usage}`
)
}

// when using a single package name as arg and it's part of the current
// install tree, then retrieve the current installed version and compare
// it against the same value `npm outdated` would suggest you to update to
if (spec.registry && spec.name !== pkgName) {
const opts = {
...npm.flatOptions,
path: npm.flatOptions.prefix,
}
const arb = new Arborist(opts)
const actualTree = await arb.loadActual(opts)
const [node] = [
...actualTree.inventory
.query('name', spec.name)
.values(),
]

if (!node || !node.name || !node.package || !node.package.version) {
throw new Error(
`Package ${a} not found in the current installed tree.\n\n` +
`Usage:\n${usage}`
)
}

const tryRootNodeSpec = () =>
(actualTree.edgesOut.get(spec.name) || {}).spec

const tryAnySpec = () => {
for (const edge of node.edgesIn)
return edge.spec
}

const aSpec = node.package.version

// finds what version of the package to compare against, if a exact
// version or tag was passed than it should use that, otherwise
// work from the top of the arborist tree to find the original semver
// range declared in the package that depends on the package.
let bSpec
if (spec.rawSpec)
bSpec = spec.rawSpec
else {
const bTargetVersion =
tryRootNodeSpec()
|| tryAnySpec()
|| `${npm.flatOptions.savePrefix}${node.package.version}`

// figure out what to compare against,
// follows same logic to npm outdated "Wanted" results
const packument = await pacote.packument(spec, {
...npm.flatOptions,
preferOnline: true,
})
bSpec = pickManifest(
packument,
bTargetVersion,
{ ...npm.flatOptions }
).version
}

return {
a: `${spec.name}@${aSpec}`,
b: `${spec.name}@${bSpec}`,
}
}

return { a }
}

const getDiffOpts = () => ({
nameOnly: npm.config.get('name-only', 'cli'),
context: npm.config.get('unified', 'cli') ||
npm.config.get('U', 'cli'),
ignoreWhitespace: npm.config.get('ignore-all-space', 'cli') ||
npm.config.get('w', 'cli'),
noPrefix: npm.config.get('no-prefix', 'cli'),
srcPrefix: npm.config.get('src-prefix', 'cli'),
dstPrefix: npm.config.get('dst-prefix', 'cli'),
text: npm.config.get('text', 'cli') ||
npm.config.get('a', 'cli'),
})

module.exports = Object.assign(cmd, { completion, usage })
1 change: 1 addition & 0 deletions lib/utils/cmd-list.js
Expand Up @@ -119,6 +119,7 @@ const cmdList = [
'prefix',
'bin',
'whoami',
'diff',
'dist-tag',
'ping',

Expand Down
1 change: 0 additions & 1 deletion node_modules/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions node_modules/@npmcli/disparity-colors/CHANGELOG.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions node_modules/@npmcli/disparity-colors/LICENSE

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 3d2547a

Please sign in to comment.