diff --git a/packages/code-link-cli/src/controller.rename.test.ts b/packages/code-link-cli/src/controller.rename.test.ts index 3b4043c49..89dc639de 100644 --- a/packages/code-link-cli/src/controller.rename.test.ts +++ b/packages/code-link-cli/src/controller.rename.test.ts @@ -351,6 +351,227 @@ 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) + + // Clean up tracker timers to avoid dangling setTimeout + hashTracker.clearDelete("New.tsx") + hashTracker.forget("Old.tsx") + + 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) + + // Clean up tracker timers to avoid dangling setTimeout + hashTracker.clearDelete("New.tsx") + hashTracker.forget("Old.tsx") + + 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) + + // Clean up tracker timers to avoid dangling setTimeout + hashTracker.clearDelete("New.tsx") + hashTracker.forget("Old.tsx") + + 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) + + // 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 }) + }) + 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..0765e0441 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -962,9 +962,37 @@ 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 { + // 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, 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 }) + await fs.rename(newPath, oldPath) + 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) + } + } + try { if (!syncState.socket) { warn(`No socket available to send rename ${effect.oldFileName} -> ${effect.newFileName}`) + await undoLocalRename() return [] } @@ -976,6 +1004,7 @@ async function executeEffect( }) if (!sent) { warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`) + await undoLocalRename() return [] } @@ -985,6 +1014,7 @@ async function executeEffect( }) } catch (err) { warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`) + await undoLocalRename() } return []