Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions packages/code-link-cli/src/controller.rename.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { oldFileName: string; content: string }>()

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<string, { oldFileName: string; content: string }>()

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<string, { oldFileName: string; content: string }>()

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<string, { oldFileName: string; content: string }>()

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")
Expand Down
30 changes: 30 additions & 0 deletions packages/code-link-cli/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
}

Expand All @@ -976,6 +1004,7 @@ async function executeEffect(
})
if (!sent) {
warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`)
await undoLocalRename()
return []
}

Expand All @@ -985,6 +1014,7 @@ async function executeEffect(
})
} catch (err) {
warn(`Failed to send rename ${effect.oldFileName} -> ${effect.newFileName}`)
await undoLocalRename()
}

return []
Expand Down
Loading