Thanks to visit codestin.com
Credit goes to github.com

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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
vertical_split = true,
open_in_current_tab = true,
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens
on_unsaved_changes = "error" -- "error" or "discard" (discard uses :edit! to reload the file and will lose unsaved changes)
},
},
keys = {
Expand Down
11 changes: 11 additions & 0 deletions lua/claudecode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ M.defaults = {
vertical_split = true,
open_in_current_tab = true, -- Use current tab instead of creating new tab
keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens
on_unsaved_changes = "error", -- "error", "discard" (discard uses :edit! to reload the file and will lose unsaved changes)
},
models = {
{ name = "Claude Opus 4.1 (Latest)", value = "opus" },
Expand Down Expand Up @@ -110,6 +111,16 @@ function M.validate(config)
assert(type(config.diff_opts.open_in_current_tab) == "boolean", "diff_opts.open_in_current_tab must be a boolean")
assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean")

local valid_behaviors = { "error", "discard" }
local is_valid_behavior = false
for _, behavior in ipairs(valid_behaviors) do
if config.diff_opts.on_unsaved_changes == behavior then
is_valid_behavior = true
break
end
end
assert(is_valid_behavior, "diff_opts.on_unsaved_changes must be one of: " .. table.concat(valid_behaviors, ", "))

-- Validate env
assert(type(config.env) == "table", "env must be a table")
for key, value in pairs(config.env) do
Expand Down
51 changes: 45 additions & 6 deletions lua/claudecode/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,29 @@ local function is_buffer_dirty(file_path)
return is_dirty, nil
end

---Discard unsaved changes in a buffer by reloading from disk.
---@param file_path string The file path whose buffer changes should be discarded
---@return boolean success True if changes were discarded successfully
---@return string? error Error message if discard failed
local function discard_buffer_changes(file_path)
local bufnr = vim.fn.bufnr(file_path)
if bufnr == -1 then
return false, "Buffer for " .. file_path .. " is not available"
end

local discard_success, discard_error = pcall(function()
vim.api.nvim_buf_call(bufnr, function()
vim.cmd("edit!") -- Force reload from disk, discarding changes
end)
end)

if not discard_success then
return false, "Discard error: " .. tostring(discard_error)
end

return true, nil
end

---Setup the diff module
---@param user_config ClaudeCodeConfig? The configuration passed from init.lua
function M.setup(user_config)
Expand Down Expand Up @@ -712,15 +735,31 @@ function M._setup_blocking_diff(params, resolution_callback)
local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1
local is_new_file = not old_file_exists

-- Step 1.5: Check if the file buffer has unsaved changes
-- Step 1.5: Handle unsaved changes based on configuration
if old_file_exists then
local is_dirty = is_buffer_dirty(params.old_file_path)
if is_dirty then
error({
code = -32000,
message = "Cannot create diff: file has unsaved changes",
data = "Please save (:w) or discard (:e!) changes to " .. params.old_file_path .. " before creating diff",
})
local behavior = config and config.diff_opts and config.diff_opts.on_unsaved_changes or "error"

if behavior == "error" then
error({
code = -32000,
message = "Cannot create diff: file has unsaved changes",
data = "Please save (:w) or discard (:e!) changes to " .. params.old_file_path .. " before creating diff",
})
elseif behavior == "discard" then
-- Discard unsaved changes using the extracted function
local discard_success, discard_err = discard_buffer_changes(params.old_file_path)
if not discard_success then
error({
code = -32000,
message = "Failed to discard unsaved changes before creating diff",
data = discard_err,
})
else
logger.warn("diff", "Discarded unsaved changes in " .. params.old_file_path)
end
end
end
end

Expand Down
1 change: 1 addition & 0 deletions lua/claudecode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
---@field vertical_split boolean
---@field open_in_current_tab boolean
---@field keep_terminal_focus boolean
---@field on_unsaved_changes "error"|"discard"

-- Model selection option
---@class ClaudeCodeModelOption
Expand Down
164 changes: 114 additions & 50 deletions tests/unit/diff_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -376,56 +376,6 @@ describe("Diff Module", function()
rawset(io, "open", old_io_open)
end)

it("should detect dirty buffer and throw error", function()
-- Mock vim.fn.bufnr to return a valid buffer number
local old_bufnr = _G.vim.fn.bufnr
_G.vim.fn.bufnr = function(path)
if path == "/path/to/dirty.lua" then
return 2
end
return -1
end

-- Mock vim.api.nvim_buf_get_option to return modified
local old_get_option = _G.vim.api.nvim_buf_get_option
_G.vim.api.nvim_buf_get_option = function(bufnr, option)
if bufnr == 2 and option == "modified" then
return true -- Buffer is dirty
end
return nil
end

local dirty_params = {
tab_name = "test_dirty",
old_file_path = "/path/to/dirty.lua",
new_file_path = "/path/to/dirty.lua",
content = "test content",
}

-- Mock file operations
_G.vim.fn.filereadable = function()
return 1
end

-- This should throw an error for dirty buffer
local success, err = pcall(function()
diff._setup_blocking_diff(dirty_params, function() end)
end)

expect(success).to_be_false()
expect(err).to_be_table()
expect(err.code).to_be(-32000)
expect(err.message).to_be("Diff setup failed")
expect(err.data).to_be_string()
-- For now, let's just verify the basic error structure
-- The important thing is that it fails when buffer is dirty, not the exact message
expect(#err.data > 0).to_be_true()

-- Restore mocks
_G.vim.fn.bufnr = old_bufnr
_G.vim.api.nvim_buf_get_option = old_get_option
end)

it("should handle non-existent buffer", function()
-- Mock vim.fn.bufnr to return -1 (buffer not found)
local old_bufnr = _G.vim.fn.bufnr
Expand Down Expand Up @@ -535,6 +485,120 @@ describe("Diff Module", function()

rawset(io, "open", old_io_open)
end)

it("should detect dirty buffer and discard changes when on_unsaved_changes is 'discard'", function()
diff.setup({
diff_opts = {
on_unsaved_changes = "discard",
},
})

local old_bufnr = _G.vim.fn.bufnr
_G.vim.fn.bufnr = function(path)
if path == "/path/to/discard.lua" then
return 2
end
return -1
end

-- Mock vim.api.nvim_buf_get_option to return modified
local old_get_option = _G.vim.api.nvim_buf_get_option
_G.vim.api.nvim_buf_get_option = function(bufnr, option)
if bufnr == 2 and option == "modified" then
return true -- Buffer is dirty
end
return nil
end

-- Test the is_buffer_dirty function indirectly through _setup_blocking_diff
local discard_params = {
tab_name = "test_clean",
old_file_path = "/path/to/discard.lua",
new_file_path = "/path/to/discard.lua",
new_file_contents = "test content",
}

-- Mock file operations
_G.vim.fn.filereadable = function()
return 1
end
_G.vim.api.nvim_list_wins = function()
return { 1 }
end
_G.vim.api.nvim_buf_call = function(bufnr, callback)
callback() -- Execute the callback so vim.cmd gets called
end

spy.on(_G.vim, "cmd")

-- This should not throw an error for dirty buffer since we discard changes
local success, err = pcall(function()
diff._setup_blocking_diff(discard_params, function() end)
end)

expect(err).to_be_nil()
expect(success).to_be_true()

local edit_called = false
local cmd_calls = _G.vim.cmd.calls or {}

for _, call in ipairs(cmd_calls) do
if call.vals[1]:find("edit!", 1, true) then
edit_called = true
break
end
end
expect(edit_called).to_be_true()

-- Restore mocks
_G.vim.fn.bufnr = old_bufnr
_G.vim.api.nvim_buf_get_option = old_get_option
end)

it("should detect dirty buffer and throw error when on_unsaved_changes is 'error'", function()
diff.setup({
diff_opts = {
on_unsaved_changes = "error",
},
})

local old_bufnr = _G.vim.fn.bufnr
_G.vim.fn.bufnr = function(path)
if path == "/path/to/dirty.lua" then
return 2
end
return -1
end

local old_get_option = _G.vim.api.nvim_buf_get_option
_G.vim.api.nvim_buf_get_option = function(bufnr, option)
if bufnr == 2 and option == "modified" then
return true
end
return nil
end

-- this should throw an error for dirty buffer
local success, err = pcall(function()
diff._setup_blocking_diff({
tab_name = "test_error",
old_file_path = "/path/to/dirty.lua",
new_file_path = "/path/to/dirty.lua",
content = "test content",
}, function() end)
end)

expect(success).to_be_false()
expect(err.code).to_be(-32000)
expect(err.message).to_be("Diff setup failed")
expect(err.data).to_be_string()
-- For now, let's just verify the basic error structure
-- The important thing is that it fails when buffer is dirty, not the exact message
expect(#err.data > 0).to_be_true()

_G.vim.fn.bufnr = old_bufnr
_G.vim.api.nvim_buf_get_option = old_get_option
end)
end)

teardown()
Expand Down
Loading