Skip to content

Commit 5ddf6cc

Browse files
fix(patch): keep the update marker on a no-op commit so a retry finalizes (#9567)
A conflicted `npm patch update` leaves an edit dir and a `.npm-patch-update.json` marker that the finalizing `npm patch commit` reads to finish the update — a metadata-only finalize that drops the renamed-from selector and tolerates the new version not being installed yet. `commit()` deleted that marker before checking whether the edit dir produced a diff. So a first commit that did no net work — the conflict resolved to the new version verbatim, giving "nothing to commit" — consumed the marker. A corrected retry then found none, ran a full reify, and failed `EPATCHUNUSED` (the rebased-to version isn't installed), leaving both the old and new selector in the manifest. The same loss happened on any non-finalizing path (e.g. the `EPATCHUNSAFE` throw). ## Fix The marker was deleted eagerly only to keep it out of the generated patch. Instead, keep it and teach `diffDirs` to skip it: - `diffDirs(originalDir, editedDir, ignore = new Set())` skips a set of root-relative filenames, like it already skips the root `package.json`. - `commit()` no longer deletes the marker — it reads/parses it (still throwing `EPATCHBADMARKER` on bad JSON before any diff), and passes `new Set([UPDATE_MARKER])` to `diffDirs`. The marker now survives any non-finalizing path, so a corrected retry still finalizes. A successful commit removes the whole edit dir (unless `--keep-edit-dir`), so nothing lingers. ## References Fixes #9566 Follow-up to #9439 (native dependency patching).
1 parent 1db885c commit 5ddf6cc

3 files changed

Lines changed: 45 additions & 4 deletions

File tree

lib/commands/patch.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,11 @@ class Patch extends BaseCommand {
235235
}
236236

237237
// a conflicted `patch update` leaves a marker so this commit drops the renamed-from selector
238+
// it is kept on disk (and excluded from the diff) so a re-run after a no-op resolution still finalizes the update
238239
const markerPath = join(editDir, UPDATE_MARKER)
239240
const markerRaw = await readFile(markerPath, 'utf8').catch(() => null)
240241
let marker = null
241242
if (markerRaw !== null) {
242-
// always remove the marker so it never lands in the generated diff, even if it is malformed
243-
await rm(markerPath, { force: true })
244243
try {
245244
marker = JSON.parse(markerRaw)
246245
} catch {
@@ -253,7 +252,7 @@ class Patch extends BaseCommand {
253252
let diff, packageJsonChanged
254253
try {
255254
await pacote.extract(selectorKey(name, version), base, this.npm.flatOptions)
256-
;({ diff, packageJsonChanged } = await diffDirs(base, editDir))
255+
;({ diff, packageJsonChanged } = await diffDirs(base, editDir, new Set([UPDATE_MARKER])))
257256
} finally {
258257
await rm(base, { recursive: true, force: true })
259258
}

lib/utils/patch-diff.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ const readMaybe = async file => {
3838
// Diff originalDir against editedDir, returning { diff, packageJsonChanged }.
3939
// Added files use `--- /dev/null`, deleted files use `+++ /dev/null`.
4040
// The root package.json is excluded: Arborist resolves the pre-patch manifest, so a patched manifest would apply to disk without being honored.
41-
const diffDirs = async (originalDir, editedDir) => {
41+
// ignore holds extra root-relative filenames the caller keeps out of the diff, e.g. the patch-update marker.
42+
const diffDirs = async (originalDir, editedDir, ignore = new Set()) => {
4243
const [origFiles, editFiles] = await Promise.all([
4344
listFiles(originalDir),
4445
listFiles(editedDir),
@@ -63,6 +64,11 @@ const diffDirs = async (originalDir, editedDir) => {
6364
continue
6465
}
6566

67+
// caller-owned control files (e.g. the patch-update marker) never belong in the diff
68+
if (ignore.has(file)) {
69+
continue
70+
}
71+
6672
let patch = createTwoFilesPatch(
6773
`a/${file}`, `b/${file}`, a || '', b || '', '', ''
6874
).replace('===================================================================\n', '')

test/lib/commands/patch.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,42 @@ t.test('update conflict leaves an edit dir; commit finalizes the rename', async
648648
t.notOk(fs.existsSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`)), 'old patch file removed')
649649
})
650650

651+
t.test('a no-op resolving commit keeps the marker so a corrected retry still finalizes', async t => {
652+
const name = 'upd-noop-retry'
653+
const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, {
654+
config: { 'ignore-scripts': true, audit: false },
655+
strictRegistryNock: false,
656+
prefixDir: rootWith({ [name]: '^1.0.0' }),
657+
})
658+
await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nBB\nc\n' })
659+
await npm.exec('install', [])
660+
outputs.length = 0
661+
await npm.exec('patch', ['add', name])
662+
const addDir = joinedOutput().match(/directory: (.+)/)[1].trim()
663+
fs.writeFileSync(path.join(addDir, 'index.js'), 'a\nMINE\nc\n')
664+
await npm.exec('patch', ['commit', addDir])
665+
666+
npm.config.set('to', '2.0.0')
667+
outputs.length = 0
668+
await npm.exec('patch', ['update', name])
669+
const editDir = joinedOutput().match(/Resolve the conflicts in: (.+)/)[1].trim()
670+
const markerPath = path.join(editDir, '.npm-patch-update.json')
671+
t.ok(fs.existsSync(markerPath), 'marker written on conflict')
672+
673+
// resolve to the new version verbatim (no net change) and commit: a no-op
674+
let src = fs.readFileSync(path.join(editDir, 'index.js'), 'utf8')
675+
src = src.replace(/<<<<<<<[^\n]*\n([\s\S]*?)=======\n[\s\S]*?>>>>>>>[^\n]*\n/, '$1')
676+
fs.writeFileSync(path.join(editDir, 'index.js'), src)
677+
await npm.exec('patch', ['commit', editDir])
678+
t.ok(fs.existsSync(markerPath), 'marker survives a no-op commit so the update context is not lost')
679+
680+
// now resolve properly and commit again: must finalize the rename, not throw EPATCHUNUSED on the uninstalled 2.0.0
681+
fs.writeFileSync(path.join(editDir, 'index.js'), src.replace('BB', 'MINE'))
682+
await npm.exec('patch', ['commit', editDir])
683+
t.same(readJson(path.join(npm.prefix, 'package.json')).patchedDependencies,
684+
{ [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch` }, 'finalized: old selector dropped, new one added')
685+
})
686+
651687
t.test('update conflict on a name-only selector forks and commits without EPATCHUNUSED', async t => {
652688
const name = 'upd-rconflict'
653689
const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, {

0 commit comments

Comments
 (0)