From e8d3607002c440e9d41f8362640b14ccaf91abf2 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Mon, 23 Mar 2026 14:20:52 +0100 Subject: [PATCH 1/3] Undo local file rename when CLI fails to send rename to plugin --- .../src/controller.rename.test.ts | 154 ++++++++++++++++++ packages/code-link-cli/src/controller.ts | 20 +++ 2 files changed, 174 insertions(+) diff --git a/packages/code-link-cli/src/controller.rename.test.ts b/packages/code-link-cli/src/controller.rename.test.ts index 3b4043c49..1d0bdf200 100644 --- a/packages/code-link-cli/src/controller.rename.test.ts +++ b/packages/code-link-cli/src/controller.rename.test.ts @@ -351,6 +351,160 @@ describe("rename confirmation bookkeeping", () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) + it("undoes local rename on disk when sendMessage returns false", async () => { + sendMessage.mockResolvedValue(false) + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-undo-")) + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + await fs.writeFile(path.join(filesDir, "New.tsx"), "export const New = () => null", "utf-8") + + const hashTracker = createHashTracker() + const pendingRenameConfirmations = new Map() + + await executeEffect( + { + type: "SEND_FILE_RENAME", + oldFileName: "Old.tsx", + newFileName: "New.tsx", + content: "export const New = () => null", + }, + { + config: { + port: 0, + projectHash: "project", + projectDir: tmpDir, + filesDir, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + } satisfies Config, + hashTracker, + installer: null, + fileMetadataCache: { recordDelete: vi.fn() } as never, + pendingRenameConfirmations, + shutdown, + userActions: {} as never, + syncState: { + mode: "watching", + socket: {} as never, + pendingRemoteChanges: [], + }, + } + ) + + // File should be renamed back to Old.tsx + const oldExists = await fs.stat(path.join(filesDir, "Old.tsx")).then(() => true, () => false) + const newExists = await fs.stat(path.join(filesDir, "New.tsx")).then(() => true, () => false) + expect(oldExists).toBe(true) + expect(newExists).toBe(false) + + // Echo prevention should be set up for the undo rename + expect(hashTracker.shouldSkip("Old.tsx", "export const New = () => null")).toBe(true) + expect(hashTracker.shouldSkipDelete("New.tsx")).toBe(true) + + // No pending rename confirmation should be created + expect(pendingRenameConfirmations.size).toBe(0) + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("undoes local rename on disk when no socket is available", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-undo-nosock-")) + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + await fs.writeFile(path.join(filesDir, "New.tsx"), "export const New = () => null", "utf-8") + + const hashTracker = createHashTracker() + const pendingRenameConfirmations = new Map() + + await executeEffect( + { + type: "SEND_FILE_RENAME", + oldFileName: "Old.tsx", + newFileName: "New.tsx", + content: "export const New = () => null", + }, + { + config: { + port: 0, + projectHash: "project", + projectDir: tmpDir, + filesDir, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + } satisfies Config, + hashTracker, + installer: null, + fileMetadataCache: { recordDelete: vi.fn() } as never, + pendingRenameConfirmations, + shutdown, + userActions: {} as never, + syncState: { + mode: "watching", + socket: null as never, + pendingRemoteChanges: [], + }, + } + ) + + const oldExists = await fs.stat(path.join(filesDir, "Old.tsx")).then(() => true, () => false) + const newExists = await fs.stat(path.join(filesDir, "New.tsx")).then(() => true, () => false) + expect(oldExists).toBe(true) + expect(newExists).toBe(false) + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("undoes local rename on disk when sendMessage throws", async () => { + sendMessage.mockRejectedValue(new Error("connection lost")) + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-undo-throw-")) + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + await fs.writeFile(path.join(filesDir, "New.tsx"), "export const New = () => null", "utf-8") + + const hashTracker = createHashTracker() + const pendingRenameConfirmations = new Map() + + await executeEffect( + { + type: "SEND_FILE_RENAME", + oldFileName: "Old.tsx", + newFileName: "New.tsx", + content: "export const New = () => null", + }, + { + config: { + port: 0, + projectHash: "project", + projectDir: tmpDir, + filesDir, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + } satisfies Config, + hashTracker, + installer: null, + fileMetadataCache: { recordDelete: vi.fn() } as never, + pendingRenameConfirmations, + shutdown, + userActions: {} as never, + syncState: { + mode: "watching", + socket: {} as never, + pendingRemoteChanges: [], + }, + } + ) + + const oldExists = await fs.stat(path.join(filesDir, "Old.tsx")).then(() => true, () => false) + const newExists = await fs.stat(path.join(filesDir, "New.tsx")).then(() => true, () => false) + expect(oldExists).toBe(true) + expect(newExists).toBe(false) + expect(pendingRenameConfirmations.size).toBe(0) + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + it("uses current file content when cleanup runs after a newer local change", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-late-")) const filesDir = path.join(tmpDir, "files") diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index fd33dcda2..d1dd5c239 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -962,9 +962,27 @@ async function executeEffect( return [] } + const { oldFileName, content } = effect + + async function undoLocalRename() { + if (!config.filesDir) return + const oldPath = path.join(config.filesDir, oldFileName) + const newPath = path.join(config.filesDir, normalizedNewFileName) + try { + // Echo prevention: mark the undo rename so the watcher ignores it + hashTracker.remember(oldFileName, content) + hashTracker.markDelete(normalizedNewFileName) + await fs.rename(newPath, oldPath) + debug(`Undid local rename: ${normalizedNewFileName} -> ${oldFileName}`) + } catch (undoErr) { + warn(`Failed to undo local rename ${normalizedNewFileName} -> ${oldFileName}:`, undoErr) + } + } + try { if (!syncState.socket) { warn(`No socket available to send rename ${effect.oldFileName} -> ${effect.newFileName}`) + await undoLocalRename() return [] } @@ -976,6 +994,7 @@ async function executeEffect( }) if (!sent) { warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`) + await undoLocalRename() return [] } @@ -985,6 +1004,7 @@ async function executeEffect( }) } catch (err) { warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`) + await undoLocalRename() } return [] From 7a4d2f011f0d0554d9b4d943b2034225b33c8a56 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Mon, 23 Mar 2026 14:29:45 +0100 Subject: [PATCH 2/3] Ensure parent directory exists when undoing cross-folder rename --- .../src/controller.rename.test.ts | 51 +++++++++++++++++++ packages/code-link-cli/src/controller.ts | 2 + 2 files changed, 53 insertions(+) diff --git a/packages/code-link-cli/src/controller.rename.test.ts b/packages/code-link-cli/src/controller.rename.test.ts index 1d0bdf200..54ac894ed 100644 --- a/packages/code-link-cli/src/controller.rename.test.ts +++ b/packages/code-link-cli/src/controller.rename.test.ts @@ -505,6 +505,57 @@ describe("rename confirmation bookkeeping", () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) + it("undoes local rename back into a subfolder when send fails", async () => { + sendMessage.mockResolvedValue(false) + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-undo-folder-")) + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + // File was moved out of components/ to root + await fs.writeFile(path.join(filesDir, "Button.tsx"), "export const Button = () => null", "utf-8") + + const hashTracker = createHashTracker() + const pendingRenameConfirmations = new Map() + + await executeEffect( + { + type: "SEND_FILE_RENAME", + oldFileName: "components/Button.tsx", + newFileName: "Button.tsx", + content: "export const Button = () => null", + }, + { + config: { + port: 0, + projectHash: "project", + projectDir: tmpDir, + filesDir, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + } satisfies Config, + hashTracker, + installer: null, + fileMetadataCache: { recordDelete: vi.fn() } as never, + pendingRenameConfirmations, + shutdown, + userActions: {} as never, + syncState: { + mode: "watching", + socket: {} as never, + pendingRemoteChanges: [], + }, + } + ) + + // File should be moved back into the subfolder + const oldExists = await fs.stat(path.join(filesDir, "components", "Button.tsx")).then(() => true, () => false) + const newExists = await fs.stat(path.join(filesDir, "Button.tsx")).then(() => true, () => false) + expect(oldExists).toBe(true) + expect(newExists).toBe(false) + + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + it("uses current file content when cleanup runs after a newer local change", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-late-")) const filesDir = path.join(tmpDir, "files") diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index d1dd5c239..b756d5a66 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -972,6 +972,8 @@ async function executeEffect( // Echo prevention: mark the undo rename so the watcher ignores it hashTracker.remember(oldFileName, content) hashTracker.markDelete(normalizedNewFileName) + // Ensure parent directory exists (rename may have moved file out of a folder) + await fs.mkdir(path.dirname(oldPath), { recursive: true }) await fs.rename(newPath, oldPath) debug(`Undid local rename: ${normalizedNewFileName} -> ${oldFileName}`) } catch (undoErr) { From 1617f32414a5ebe4019c6fc75d2fdf0d17d22066 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Mon, 23 Mar 2026 15:21:17 +0100 Subject: [PATCH 3/3] Address PR review feedback for undo-rename logic - Read fresh file content from disk before setting echo-prevention hash, since effect.content may be stale if the user edited the renamed file - Clear echo-prevention markers in the catch block when fs.rename fails, preventing stale markers from poisoning tracker state - Clean up hashTracker timers in tests to avoid dangling setTimeout --- .../code-link-cli/src/controller.rename.test.ts | 16 ++++++++++++++++ packages/code-link-cli/src/controller.ts | 10 +++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/code-link-cli/src/controller.rename.test.ts b/packages/code-link-cli/src/controller.rename.test.ts index 54ac894ed..89dc639de 100644 --- a/packages/code-link-cli/src/controller.rename.test.ts +++ b/packages/code-link-cli/src/controller.rename.test.ts @@ -405,6 +405,10 @@ describe("rename confirmation bookkeeping", () => { // No pending rename confirmation should be created expect(pendingRenameConfirmations.size).toBe(0) + // Clean up tracker timers to avoid dangling setTimeout + hashTracker.clearDelete("New.tsx") + hashTracker.forget("Old.tsx") + await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -452,6 +456,10 @@ describe("rename confirmation bookkeeping", () => { expect(oldExists).toBe(true) expect(newExists).toBe(false) + // Clean up tracker timers to avoid dangling setTimeout + hashTracker.clearDelete("New.tsx") + hashTracker.forget("Old.tsx") + await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -502,6 +510,10 @@ describe("rename confirmation bookkeeping", () => { expect(newExists).toBe(false) expect(pendingRenameConfirmations.size).toBe(0) + // Clean up tracker timers to avoid dangling setTimeout + hashTracker.clearDelete("New.tsx") + hashTracker.forget("Old.tsx") + await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -553,6 +565,10 @@ describe("rename confirmation bookkeeping", () => { expect(oldExists).toBe(true) expect(newExists).toBe(false) + // Clean up tracker timers to avoid dangling setTimeout + hashTracker.clearDelete("Button.tsx") + hashTracker.forget("components/Button.tsx") + await fs.rm(tmpDir, { recursive: true, force: true }) }) diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index b756d5a66..0765e0441 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -969,8 +969,13 @@ async function executeEffect( const oldPath = path.join(config.filesDir, oldFileName) const newPath = path.join(config.filesDir, normalizedNewFileName) try { + // Prefer the current on-disk content for echo prevention, since effect.content + // may be stale if the user edited the file after the rename was scheduled. + const currentContent = await readFileSafe(normalizedNewFileName, config.filesDir) + const contentForHash = currentContent ?? content + // Echo prevention: mark the undo rename so the watcher ignores it - hashTracker.remember(oldFileName, content) + hashTracker.remember(oldFileName, contentForHash) hashTracker.markDelete(normalizedNewFileName) // Ensure parent directory exists (rename may have moved file out of a folder) await fs.mkdir(path.dirname(oldPath), { recursive: true }) @@ -978,6 +983,9 @@ async function executeEffect( debug(`Undid local rename: ${normalizedNewFileName} -> ${oldFileName}`) } catch (undoErr) { warn(`Failed to undo local rename ${normalizedNewFileName} -> ${oldFileName}:`, undoErr) + // Revert echo-prevention markers so a failed undo doesn't poison tracker state + hashTracker.forget(oldFileName) + hashTracker.clearDelete(normalizedNewFileName) } }