Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .agents/skills/verify-diff-lifecycle/covered.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ file lua/claudecode/config.lua 3afb1bdeff27bfb6
== lua/claudecode/cwd.lua
file lua/claudecode/cwd.lua b034e82c76e8dee4
== lua/claudecode/diff.lua
file lua/claudecode/diff.lua 56ed24d0d5b77e5c
block resolve_saved 4d34b0c7e34d561f
file lua/claudecode/diff.lua 9a2b9b3ec002bf64
block resolve_saved 36fd89d5b97f0173
block resolve_rejected c7a620816abff9a1
block open_blocking 13e23b610cab9f40
block setup_blocking b45a7bfd5203a085
block setup_blocking 2dc7835cec40f258
block close_by_name cc40c0d2ffdf7946
block close_pending 31bc827d1065da70
block close_for_client 330cc91666bc7884
Expand Down
2 changes: 1 addition & 1 deletion lua/claudecode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ Module map (direct files):
- When the target buffer is dirty, diff setup raises `-32000` with message "Diff setup failed". Its `data` holds the inner error, which contains "Cannot create diff: file has unsaved changes" [diff.lua:M._setup_blocking_diff]
- A diff resolves only from status `pending`. Save returns `FILE_SAVED` and the content. In the `unified` layout the content is the proposal's lines with a final line break exactly when the proposal ends with one, so every blank line at its end stays. Reject returns `DIFF_REJECTED` and the tab name [diff.lua:M._resolve_diff_as_saved] [diff_inline.lua:M.resolve_inline_as_saved]
- A write of the proposal buffer (`:w`, `:wq`, `:x`, `ZZ`, which run `BufWriteCmd`) never writes the file. It accepts a pending diff and does nothing else. `:w` leaves the windows open until `close_tab`, and `:wq`, `:x` and `ZZ` also close the proposal window. A write after the diff has its answer sends nothing and does not fail (the handler never returns `true`, which would delete the autocmd and make the next `:w` fail with E676). It warns when the buffer holds edits that Claude never gets [diff.lua:M._write_proposal]
- In the `vertical` and `horizontal` layouts the saved content is the lines of the proposal buffer, joined with line breaks, plus a final line break only when the buffer's `'eol'` is on and the buffer has a line. The diff sets `'eol'` from the proposal when it creates the buffer, so a proposal without a final line break is saved without one, and the user's edits keep that ending. A buffer with no lines at all (an empty proposal, or a buffer the user emptied with `ggdG` or `:%delete`) is saved as an empty string, because `:w` writes no bytes for it whatever `'eol'` says. A buffer with one empty line is saved as `"\n"` when `'eol'` is on and as `""` when it is off. The save tells the two apart with `wordcount().bytes`, because `nvim_buf_get_lines` returns one empty string for both [diff.lua:M._setup_blocking_diff] [diff.lua:M._resolve_diff_as_saved]
- In the `vertical` and `horizontal` layouts the saved content is the lines of the proposal buffer, joined with the line break of the buffer's `'fileformat'` (LF, CRLF for `dos`, CR for `mac`), plus a final line break only when the buffer's `'eol'` is on and the buffer has a line. When `'fileformats'` includes `dos`, a proposal that has a line break and only CRLF line breaks gets `'fileformat'` `dos` and lines without their CRs, as the same text read from a file does, so a line that the user types or joins is saved with CRLF, as `:w` writes it. Any other proposal gets `'fileformat'` `unix` whatever the first entry of `'fileformats'` is, with its CRs inside its lines, which `:w` writes back unchanged. The `unified` layout's buffer is read-only, so its text is the proposal itself. The diff sets `'eol'` from the proposal when it creates the buffer, so a proposal without a final line break is saved without one, and the user's edits keep that ending. A buffer with no lines at all (an empty proposal, or a buffer the user emptied with `ggdG` or `:%delete`) is saved as an empty string, because `:w` writes no bytes for it whatever `'eol'` says. A buffer with one empty line is saved as one line break of the buffer's `'fileformat'` when `'eol'` is on and as `""` when it is off. The save tells the two apart with `wordcount().bytes`, because `nvim_buf_get_lines` returns one empty string for both [diff.lua:M._setup_blocking_diff] [diff.lua:M._resolve_diff_as_saved]
- A pending diff rejects at once when its proposal buffer is shown in no window, in the split and the unified layout: its last window closes (`:q`, `:close`, `<C-w>c`, `:tabclose`), or that window switches to another buffer (`:edit`, `:buffer`, `:enew`, which only hide it). A copy of the proposal in another window or tab defers the reject until that copy leaves too. The switch path rejects on the next loop turn, and only when the diff is still the same one and still hidden. Both layouts keep the proposal buffer's `'bufhidden'` at `hide`: the buffer is modified, and `wipe` would make `:q` fail with E37 [diff.lua:M._register_reject_when_hidden] [diff_inline.lua:M.setup_inline_diff]
- A diff that has its answer, by either one, clears `'modified'` on its proposal buffer. While the flag stayed set, `:wq` left the window open, and `:qa` stopped with E37 or E162 and moved a window onto the hidden buffer until `close_tab` deleted it [diff.lua:mark_proposal_unmodified]
- At exit, Neovim fires `BufUnload` for each loaded buffer before `VimLeavePre`, and a proposal's `BufUnload` autocmd rejects its diff, but that loop stops as soon as an autocmd deletes the buffer it fired for. The reject of a new-file diff with `on_new_file_reject = "keep_empty"`, in a tab that the diff did not open, deletes its proposal buffer, and another plugin's buffer can delete itself in its own `BufUnload`. The diffs after either get no `BufUnload` (probed on Neovim 0.8.0, 0.9.5, 0.10.4, 0.11.5 and 0.12.3). SIGTERM and SIGHUP run `VimLeavePre` without that loop. A quit from inside an autocmd that is not `nested`, SIGKILL and a crash run no `VimLeavePre`. The `VimLeavePre` autocmd of `ClaudeCodeMCPDiff` therefore runs `cleanup_at_exit`, which first rejects each diff with the status `pending`, oldest first, each under `pcall`, and skips a diff with another status. A reject can remove its own diff (the `keep_empty` case above), so a diff can be gone before the next one is rejected. The reject of a diff whose `openDiff` setup has not finished stays in `setup_result`, because the call has no response sender yet, so the server's `stop` sends that call's response. The README at the repository root, under Global Invariants, lists what Claude receives for each kind of call [diff.lua:cleanup_at_exit] [diff.lua:M.open_diff_blocking] [server/init.lua:M.stop]
Expand Down
18 changes: 15 additions & 3 deletions lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1110,15 +1110,17 @@ function M._resolve_diff_as_saved(tab_name, buffer_id)

-- Get content from buffer
local content_lines = vim.api.nvim_buf_get_lines(buffer_id, 0, -1, false)
local final_content = table.concat(content_lines, "\n")
-- :w ends each line with the line break of the buffer's 'fileformat': LF, CRLF for dos, CR for mac.
local line_break = ({ dos = "\r\n", mac = "\r" })[vim.api.nvim_buf_get_option(buffer_id, "fileformat")] or "\n"
local final_content = table.concat(content_lines, line_break)
Comment thread
ThomasK33 marked this conversation as resolved.
Resolved
-- Add trailing newline if the buffer has one, as :w does. :w writes nothing for an empty buffer, whatever 'eol' is.
-- Such a buffer has no lines, but nvim_buf_get_lines still returns one empty string for it, so the count of bytes tells
-- it from a buffer with one empty line.
local byte_count = vim.api.nvim_buf_call(buffer_id, function()
return vim.fn.wordcount().bytes
end)
if byte_count > 0 and vim.api.nvim_buf_get_option(buffer_id, "eol") then
final_content = final_content .. "\n"
final_content = final_content .. line_break
end

-- Do not modify windows/tabs here; wait for explicit close_tab tool call to clean up UI
Expand Down Expand Up @@ -1835,12 +1837,22 @@ function M._setup_blocking_diff(params, resolution_callback)

local new_unique_name = is_new_file and (tab_name .. " (NEW FILE - proposed)") or (tab_name .. " (proposed)")
vim.api.nvim_buf_set_name(new_buffer, new_unique_name)
local lines = vim.split(params.new_file_contents, "\n")
-- A proposal that has a line break, and only CRLF line breaks, takes 'fileformat' dos, as the same text read from
-- a file does, when the user's 'fileformats' includes dos. The buffer then holds the lines without their CRs, as
-- the file's own buffer does, and the save gives a line that the user types a CRLF, as :w does. Any other text is
-- unix, with its CRs inside the lines, which :w writes back unchanged. 'fileformat' is set in both cases, because
-- a new buffer takes the first entry of 'fileformats', and that entry can be dos.
local proposal = params.new_file_contents
local is_dos = vim.o.fileformats:find("dos", 1, true) ~= nil
and proposal:find("\n", 1, true) ~= nil
and not proposal:gsub("\r\n", ""):find("\n", 1, true)
local lines = vim.split(proposal, is_dos and "\r\n" or "\n", { plain = true })
-- Remove trailing empty line if content ended with \n
if #lines > 0 and lines[#lines] == "" then
table.remove(lines, #lines)
end
vim.api.nvim_buf_set_lines(new_buffer, 0, -1, false, lines)
vim.api.nvim_buf_set_option(new_buffer, "fileformat", is_dos and "dos" or "unix")
-- A new buffer has 'eol' on, and the saved text ends with a line break exactly when 'eol' is on. So the buffer takes
-- the ending of the proposal, and a proposal without a final line break is saved without one.
vim.api.nvim_buf_set_option(new_buffer, "eol", params.new_file_contents:sub(-1) == "\n")
Expand Down
Loading
Loading