From bbebaf0717c290a9339c98f9173f83902db0fe91 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:44:32 +0000 Subject: [PATCH 1/3] node:fs: apply cpSync errorOnExist per entry, not to the destination directory fs.cpSync(src, dest, { recursive: true, force: false, errorOnExist: true }) threw ERR_FS_CP_EEXIST as soon as dest itself existed, so the merge-but-never- overwrite idiom could never run against a destination that had been created. node's cp-sync.js onDir has no such check: it merges into an existing directory and raises ERR_FS_CP_EEXIST only from mayCopyFile, on a colliding file. Its async cp.js onDir does refuse the directory, which is why internal/fs/cp.ts keeps that branch. --- src/js/internal/fs/cp-sync.ts | 12 ++---- test/js/node/fs/cp.test.ts | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/js/internal/fs/cp-sync.ts b/src/js/internal/fs/cp-sync.ts index be4de692fcbb..5b75f9eb2e73 100644 --- a/src/js/internal/fs/cp-sync.ts +++ b/src/js/internal/fs/cp-sync.ts @@ -421,17 +421,11 @@ function setDestTimestamps(src, dest) { return utimesSync(dest, updatedSrcStat.atime, updatedSrcStat.mtime); } +// `errorOnExist` is per-file here: an existing destination directory is merged +// into, and only a colliding entry raises ERR_FS_CP_EEXIST (see mayCopyFile). +// node's async cp refuses the directory itself; its cpSync does not. function onDir(srcStat, destStat, src, dest, opts) { if (!destStat) return mkDirAndCopy(srcStat.mode, src, dest, opts); - if (opts.errorOnExist && !opts.force) { - throw fsCpEExistError({ - message: `${dest} already exists`, - path: dest, - syscall: "cp", - errno: EEXIST, - code: "EEXIST", - }); - } return copyDir(src, dest, opts); } diff --git a/test/js/node/fs/cp.test.ts b/test/js/node/fs/cp.test.ts index d4e11c89a286..f20ee97ae484 100644 --- a/test/js/node/fs/cp.test.ts +++ b/test/js/node/fs/cp.test.ts @@ -423,6 +423,76 @@ for (const [name, copy] of impls) { }); } +// node applies `errorOnExist` per copied entry in cpSync, so an existing +// destination directory is merged into and only a colliding file raises +// ERR_FS_CP_EEXIST. node's async cp refuses the directory itself; match both. +describe("'force: false' + 'errorOnExist: true' on an existing destination directory", () => { + test("cpSync merges into it and keeps the entries already there", () => { + const basename = tempDirWithFiles("cp", { + "from/a.txt": "a", + "from/nested/b.txt": "b", + "result/other.txt": "keep this", + "result/nested/c.txt": "keep this too", + }); + const from = join(basename, "from"); + const result = join(basename, "result"); + + fs.cpSync(from, result, { recursive: true, force: false, errorOnExist: true }); + + expect({ + result: fs.readdirSync(result).sort(), + nested: fs.readdirSync(join(result, "nested")).sort(), + "a.txt": fs.readFileSync(join(result, "a.txt"), "utf8"), + "nested/b.txt": fs.readFileSync(join(result, "nested", "b.txt"), "utf8"), + "other.txt": fs.readFileSync(join(result, "other.txt"), "utf8"), + "nested/c.txt": fs.readFileSync(join(result, "nested", "c.txt"), "utf8"), + }).toEqual({ + result: ["a.txt", "nested", "other.txt"], + nested: ["b.txt", "c.txt"], + "a.txt": "a", + "nested/b.txt": "b", + "other.txt": "keep this", + "nested/c.txt": "keep this too", + }); + }); + + test("cpSync still throws ERR_FS_CP_EEXIST on a colliding file", () => { + const basename = tempDirWithFiles("cp", { + "from/nested/a.txt": "lose", + "result/nested/a.txt": "win", + }); + const result = join(basename, "result"); + + let err: any; + try { + fs.cpSync(join(basename, "from"), result, { recursive: true, force: false, errorOnExist: true }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_FS_CP_EEXIST"); + expect(err?.path).toBe(join(result, "nested", "a.txt")); + expect(fs.readFileSync(join(result, "nested", "a.txt"), "utf8")).toBe("win"); + }); + + test("promises.cp refuses the destination directory and copies nothing", async () => { + const basename = tempDirWithFiles("cp", { + "from/a.txt": "a", + "result/other.txt": "keep this", + }); + const result = join(basename, "result"); + + let err: any; + try { + await fs.promises.cp(join(basename, "from"), result, { recursive: true, force: false, errorOnExist: true }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_FS_CP_EEXIST"); + expect(err?.path).toBe(result); + expect(fs.readdirSync(result)).toEqual(["other.txt"]); + }); +}); + test("cp with missing callback throws", () => { expect(() => { // @ts-expect-error From 30c75b0112907722dd4113ee29d4b41380fa06fe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:11:07 +0000 Subject: [PATCH 2/3] fs: note in internal/fs/cp.ts why the async onDir keeps its errorOnExist check --- src/js/internal/fs/cp.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/js/internal/fs/cp.ts b/src/js/internal/fs/cp.ts index f7db620f191e..12e74b7d26bb 100644 --- a/src/js/internal/fs/cp.ts +++ b/src/js/internal/fs/cp.ts @@ -314,6 +314,9 @@ async function setDestTimestamps(src, dest) { return utimes(dest, updatedSrcStat.atime, updatedSrcStat.mtime); } +// node's async cp refuses an existing destination directory outright. Its +// cpSync merges into one and only raises ERR_FS_CP_EEXIST per colliding file, +// so internal/fs/cp-sync.ts onDir deliberately lacks this branch. function onDir(srcStat, destStat, src, dest, opts) { if (!destStat) return mkDirAndCopy(srcStat.mode, src, dest, opts); if (opts.errorOnExist && !opts.force) { From aeb489e8e11ebca6280709c9b59684d60b150de3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:15:04 +0000 Subject: [PATCH 3/3] ci: retrigger