From 1873cdf8ab7fe9aca847bd89f8714991b07b1c88 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Nov 2026 09:31:57 +0000 Subject: [PATCH] fix(diff): open the legacy diff's temp file without a swap file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public legacy `require("claudecode.diff").open_diff` (through `_open_native_diff`) opens the proposal's temp file, then renames the buffer to " (New)". Neovim opens a swap file under the new name at the rename. If a swap file for that name exists and its header names that buffer (another Neovim changed it, or a killed one after :preserve), the rename raises E325. The user got E5108 with a stack trace, a half-open diff (one of two windows in diff mode), a buffer with a swap file and a leftover temp file. The temp file is opened with `noswapfile edit`, so the rename has no swap file to meet. The buffer is `nofile` and has `swapfile` off right after anyway. Test: tests/integration/diff_legacy_swap_spec.lua leaves a swap file for " (New)" with a Neovim that is killed after :preserve, then calls open_diff. It fails on main (E325 at diff.lua:814) on Neovim 0.12.3 and v0.8.0 and passes with the change. The verify-diff-lifecycle lock is re-recorded: only the hash of diff.lua changed, and `_open_native_diff` is outside the model. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_ --- .../skills/verify-diff-lifecycle/covered.lock | 2 +- lua/claudecode/diff.lua | 3 +- tests/integration/diff_legacy_swap_spec.lua | 66 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/integration/diff_legacy_swap_spec.lua diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index f596a9e5e..ed00b63a3 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -3,7 +3,7 @@ file lua/claudecode/config.lua 35f1bc960cb293e4 == lua/claudecode/cwd.lua file lua/claudecode/cwd.lua 55d26ea362173afa == lua/claudecode/diff.lua -file lua/claudecode/diff.lua 30f9f663a85a255d +file lua/claudecode/diff.lua ee44279a0e8d5c0d block resolve_saved 46d5f3f732711fe1 block resolve_rejected c7a620816abff9a1 block open_blocking 92067df7f9d4d4d9 diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 473ad32ca..f80a27920 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -810,7 +810,8 @@ function M._open_native_diff(old_file_path, new_file_path, new_file_contents, ta vim.cmd("edit " .. vim.fn.fnameescape(old_file_path)) vim.cmd("diffthis") create_split() - vim.cmd("edit " .. vim.fn.fnameescape(tmp_file)) + -- Without a swap file, the rename below cannot meet another Neovim's swap file for " (New)" (E325). + vim.cmd("noswapfile edit " .. vim.fn.fnameescape(tmp_file)) vim.api.nvim_buf_set_name(0, new_file_path .. " (New)") -- Propagate filetype to the proposed buffer for proper syntax highlighting (#20) diff --git a/tests/integration/diff_legacy_swap_spec.lua b/tests/integration/diff_legacy_swap_spec.lua new file mode 100644 index 000000000..7487d4fa1 --- /dev/null +++ b/tests/integration/diff_legacy_swap_spec.lua @@ -0,0 +1,66 @@ +-- Real-Neovim check that the legacy diff.open_diff opens its proposal when another Neovim left a swap file for the +-- proposal's name (" (New)"). Swap files need a real Neovim, so under busted's mock vim this is pending; +-- scripts/run_integration_tests_individually.sh runs it in Neovim. +if vim == nil or vim.fn.has("nvim") ~= 1 then + describe("legacy open_diff and a swap file for its proposal", function() + pending("needs a real Neovim") + end) + return +end + +describe("legacy open_diff and a swap file for its proposal", function() + local diff = require("claudecode.diff") + local dir, swap_dir, path, saved_directory + + before_each(function() + dir = vim.fn.tempname() + swap_dir = dir .. "/swap" + vim.fn.mkdir(swap_dir, "p") + path = dir .. "/foo.lua" + vim.fn.writefile({ "local a = 1" }, path) + saved_directory = vim.o.directory + vim.o.directory = swap_dir .. "//" + diff.setup({ diff_opts = {} }) + end) + + after_each(function() + vim.cmd("silent! diffoff!") + vim.cmd("silent! only") + vim.cmd("silent! %bwipeout!") + vim.o.directory = saved_directory + vim.fn.delete(dir, "rf") + end) + + it("opens the proposal when a killed Neovim left a swap file for ' (New)'", function() + -- Another Neovim changes a buffer named " (New)", so its swap file records that name, and is killed. + vim.fn.system({ + vim.v.progpath, + "--headless", + "-u", + "NONE", + "-i", + "NONE", + "--cmd", + "set directory=" .. vim.fn.fnameescape(swap_dir) .. "//", + "-c", + "edit " .. vim.fn.fnameescape(path .. " (New)"), + "-c", + "call setline(1, 'unsaved')", + "-c", + "preserve", + "-c", + "lua (vim.uv or vim.loop).kill(vim.fn.getpid(), 9)", + }) + assert.are.equal(1, #vim.fn.glob(swap_dir .. "/*", false, true), "the killed Neovim left its swap file") + + vim.cmd("edit " .. vim.fn.fnameescape(path)) + local ok, result = pcall(diff.open_diff, path, path, "local a = 2\n", "legacy swap") + + assert.is_true(ok, tostring(result)) + assert.is_true(result.success) + local proposal = vim.api.nvim_get_current_buf() + assert.are.equal(path .. " (New)", vim.api.nvim_buf_get_name(proposal)) + assert.is_false(vim.bo[proposal].swapfile) + assert.are.equal("", vim.fn.swapname(proposal)) + end) +end)