Skip to content

Commit 53f2c95

Browse files
committed
feat(patch): exclude package.json from the diff and warn when an edit only touches it
1 parent 4ccd1ad commit 53f2c95

4 files changed

Lines changed: 116 additions & 13 deletions

File tree

lib/commands/patch.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,18 +212,25 @@ class Patch extends BaseCommand {
212212

213213
// extract a clean baseline to diff against
214214
const base = await mkdtemp(join(tmpdir(), 'npm-patch-base-'))
215-
let diff
215+
let diff, packageJsonChanged
216216
try {
217217
await pacote.extract(selectorKey(name, version), base, this.npm.flatOptions)
218-
diff = await diffDirs(base, editDir)
218+
;({ diff, packageJsonChanged } = await diffDirs(base, editDir))
219219
} finally {
220220
await rm(base, { recursive: true, force: true })
221221
}
222222

223223
if (!diff) {
224-
log.warn('patch', `no changes detected in ${editDir}; nothing to commit`)
224+
// package.json is excluded from patches, so an edit limited to it captures nothing
225+
const reason = packageJsonChanged
226+
? `only package.json changed in ${editDir}, which is not patchable; nothing to commit`
227+
: `no changes detected in ${editDir}; nothing to commit`
228+
log.warn('patch', reason)
225229
return
226230
}
231+
if (packageJsonChanged) {
232+
log.warn('patch', 'changes to package.json are not included in patches and were ignored')
233+
}
227234

228235
const patchesDir = this.npm.config.get('patches-dir')
229236
const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, version))

lib/utils/patch-diff.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ const readMaybe = async file => {
3535
}
3636
}
3737

38-
// Diff originalDir against editedDir, returning a unified diff string.
38+
// Diff originalDir against editedDir, returning { diff, packageJsonChanged }.
3939
// Added files use `--- /dev/null`, deleted files use `+++ /dev/null`.
40+
// The root package.json is excluded: Arborist resolves the pre-patch manifest, so a patched manifest would apply to disk without being honored.
4041
const diffDirs = async (originalDir, editedDir) => {
4142
const [origFiles, editFiles] = await Promise.all([
4243
listFiles(originalDir),
@@ -45,6 +46,7 @@ const diffDirs = async (originalDir, editedDir) => {
4546
const all = [...new Set([...origFiles, ...editFiles])].sort()
4647

4748
let result = ''
49+
let packageJsonChanged = false
4850
for (const file of all) {
4951
const native = file.split('/').join(sep)
5052
const [a, b] = await Promise.all([
@@ -55,6 +57,12 @@ const diffDirs = async (originalDir, editedDir) => {
5557
continue
5658
}
5759

60+
// the root package.json is never patchable; flag the change so commit can warn
61+
if (file === 'package.json') {
62+
packageJsonChanged = true
63+
continue
64+
}
65+
5866
let patch = createTwoFilesPatch(
5967
`a/${file}`, `b/${file}`, a || '', b || '', '', ''
6068
).replace('===================================================================\n', '')
@@ -68,7 +76,7 @@ const diffDirs = async (originalDir, editedDir) => {
6876
}
6977
result += patch
7078
}
71-
return result
79+
return { diff: result, packageJsonChanged }
7280
}
7381

7482
module.exports = { diffDirs }

test/lib/commands/patch.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,64 @@ t.test('commit: no changes logs a warning and does not write a patch', async t =
467467
t.notOk(pkg.patchedDependencies, 'no patchedDependencies added when nothing changed')
468468
})
469469

470+
t.test('commit: only package.json changed warns and writes no patch', async t => {
471+
const { npm, logs, registry } = await loadMockNpm(t, {
472+
config: { 'ignore-scripts': true, audit: false },
473+
strictRegistryNock: false,
474+
prefixDir: basePrefix(),
475+
})
476+
await setupDep(npm, registry)
477+
await npm.exec('install', [])
478+
479+
await npm.exec('patch', ['add', DEP_NAME])
480+
const editDir = path.join(npm.prefix, 'clean-edit')
481+
await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions)
482+
483+
// edit only package.json, which is excluded from patches
484+
const pkgPath = path.join(editDir, 'package.json')
485+
const edited = readJson(pkgPath)
486+
edited.description = 'edited'
487+
fs.writeFileSync(pkgPath, JSON.stringify(edited))
488+
489+
await npm.exec('patch', ['commit', editDir])
490+
t.notOk(
491+
fs.existsSync(path.join(npm.prefix, 'patches', `${DEP_NAME}@${DEP_VERSION}.patch`)),
492+
'no patch file written when only package.json changed'
493+
)
494+
t.match(logs.warn.join('\n'), /only package.json changed/, 'warns package.json is not patchable')
495+
})
496+
497+
t.test('commit: package.json change alongside code is dropped with a warning', async t => {
498+
const { npm, logs, registry } = await loadMockNpm(t, {
499+
config: { 'ignore-scripts': true, audit: false },
500+
strictRegistryNock: false,
501+
prefixDir: basePrefix(),
502+
})
503+
await setupDep(npm, registry)
504+
await npm.exec('install', [])
505+
506+
await npm.exec('patch', ['add', DEP_NAME])
507+
const editDir = path.join(npm.prefix, 'clean-edit')
508+
await pacote.extract(`${DEP_NAME}@${DEP_VERSION}`, editDir, npm.flatOptions)
509+
510+
// edit both package.json and a real file
511+
const pkgPath = path.join(editDir, 'package.json')
512+
const edited = readJson(pkgPath)
513+
edited.description = 'edited'
514+
fs.writeFileSync(pkgPath, JSON.stringify(edited))
515+
fs.writeFileSync(path.join(editDir, 'index.js'), 'module.exports = () => "patched"\n')
516+
517+
await npm.exec('patch', ['commit', editDir])
518+
const patchPath = path.join(npm.prefix, 'patches', `${DEP_NAME}@${DEP_VERSION}.patch`)
519+
t.ok(fs.existsSync(patchPath), 'patch written for the code change')
520+
t.notMatch(fs.readFileSync(patchPath, 'utf8'), 'package.json', 'patch excludes package.json')
521+
t.match(
522+
logs.warn.join('\n'),
523+
/changes to package.json are not included/,
524+
'warns the package.json edit was ignored'
525+
)
526+
})
527+
470528
t.test('rm: no pkg arg rejects with EUSAGE', async t => {
471529
const { npm } = await loadMockNpm(t, {
472530
config: { 'ignore-scripts': true, audit: false },

test/lib/utils/patch-diff.js

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ t.test('modified file produces a unified diff', async t => {
1212
orig: { 'index.js': 'hello\n' },
1313
edit: { 'index.js': 'world\n' },
1414
})
15-
const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
15+
const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
1616
t.match(diff, '--- a/index.js', 'has old header')
1717
t.match(diff, '+++ b/index.js', 'has new header')
1818
t.match(diff, '-hello', 'removes old line')
@@ -25,7 +25,7 @@ t.test('added file uses --- /dev/null', async t => {
2525
orig: { 'keep.js': 'same\n' },
2626
edit: { 'keep.js': 'same\n', 'added.js': 'brand new\n' },
2727
})
28-
const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
28+
const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
2929
t.match(diff, '--- /dev/null', 'old side is /dev/null')
3030
t.match(diff, '+++ b/added.js', 'new side names the added file')
3131
t.match(diff, '+brand new', 'includes added content')
@@ -37,7 +37,7 @@ t.test('deleted file uses +++ /dev/null', async t => {
3737
orig: { 'gone.js': 'remove me\n' },
3838
edit: {},
3939
})
40-
const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
40+
const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
4141
t.match(diff, '--- a/gone.js', 'old side names the deleted file')
4242
t.match(diff, '+++ /dev/null', 'new side is /dev/null')
4343
t.match(diff, '-remove me', 'includes removed content')
@@ -48,7 +48,7 @@ t.test('nested file path is posix-separated in the diff', async t => {
4848
orig: { lib: { deep: { 'x.js': 'a\n' } } },
4949
edit: { lib: { deep: { 'x.js': 'b\n' } } },
5050
})
51-
const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
51+
const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
5252
t.match(diff, '--- a/lib/deep/x.js', 'old header uses posix separators')
5353
t.match(diff, '+++ b/lib/deep/x.js', 'new header uses posix separators')
5454
})
@@ -58,7 +58,7 @@ t.test('identical files produce no diff', async t => {
5858
orig: { 'a.js': 'x\n', sub: { 'b.js': 'y\n' } },
5959
edit: { 'a.js': 'x\n', sub: { 'b.js': 'y\n' } },
6060
})
61-
const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
61+
const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
6262
t.equal(diff, '', 'empty diff for identical trees')
6363
})
6464

@@ -75,20 +75,50 @@ t.test('node_modules and .git are ignored', async t => {
7575
'.git': { HEAD: 'ref: refs/heads/other\n' },
7676
},
7777
})
78-
const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
78+
const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
7979
t.match(diff, 'index.js', 'top-level change is captured')
8080
t.notMatch(diff, 'node_modules', 'node_modules contents are excluded')
8181
t.notMatch(diff, 'HEAD', '.git contents are excluded')
8282
})
8383

84+
t.test('root package.json is excluded and flagged, nested is kept', async t => {
85+
const dir = t.testdir({
86+
orig: {
87+
'package.json': '{ "version": "1.0.0" }\n',
88+
'index.js': 'a\n',
89+
sub: { 'package.json': '{ "private": true }\n' },
90+
},
91+
edit: {
92+
'package.json': '{ "version": "2.0.0" }\n',
93+
'index.js': 'b\n',
94+
sub: { 'package.json': '{ "private": false }\n' },
95+
},
96+
})
97+
const { diff, packageJsonChanged } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
98+
t.equal(packageJsonChanged, true, 'root package.json change is flagged')
99+
t.notMatch(diff, 'a/package.json\t', 'root package.json is not in the diff')
100+
t.match(diff, 'a/sub/package.json', 'nested package.json is still diffed')
101+
t.match(diff, 'a/index.js', 'other files are still diffed')
102+
})
103+
104+
t.test('packageJsonChanged is false when only other files change', async t => {
105+
const dir = t.testdir({
106+
orig: { 'package.json': '{ "version": "1.0.0" }\n', 'index.js': 'a\n' },
107+
edit: { 'package.json': '{ "version": "1.0.0" }\n', 'index.js': 'b\n' },
108+
})
109+
const { diff, packageJsonChanged } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
110+
t.equal(packageJsonChanged, false, 'unchanged package.json is not flagged')
111+
t.match(diff, 'a/index.js', 'the real change is captured')
112+
})
113+
84114
t.test('non-file entries like symlinks are skipped', async t => {
85115
const dir = t.testdir({
86116
orig: { 'real.js': 'a\n' },
87117
edit: { 'real.js': 'b\n' },
88118
})
89119
// A symlink is neither a directory nor a regular file so it is ignored.
90120
symlinkSync(resolve(dir, 'orig', 'real.js'), resolve(dir, 'edit', 'link.js'))
91-
const diff = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
121+
const { diff } = await diffDirs(resolve(dir, 'orig'), resolve(dir, 'edit'))
92122
t.match(diff, 'real.js', 'regular file is diffed')
93123
t.notMatch(diff, 'link.js', 'symlink entry is skipped')
94124
})
@@ -107,7 +137,7 @@ t.test('round-trip: applying the diff reproduces the edited tree', async t => {
107137
},
108138
})
109139
const orig = resolve(dir, 'orig')
110-
const diff = await diffDirs(orig, resolve(dir, 'edit'))
140+
const { diff } = await diffDirs(orig, resolve(dir, 'edit'))
111141

112142
// Apply the diff back onto a copy of the original and check the result.
113143
await applyPatchToDir({ patch: diff, cwd: orig })

0 commit comments

Comments
 (0)