Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,24 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
diff_put = "dp", -- Put change to other buffer (like vimdiff)
open_in_prev_tab = "gf", -- Open current buffer in previous tab (or create one before)
toggle_stage = "-", -- Stage/unstage current file (works in explorer and diff buffers)
stage_hunk = "S", -- Stage hunk under cursor to git index
unstage_hunk = "U", -- Unstage hunk under cursor from git index
discard_hunk = "D", -- Discard hunk under cursor (working tree only)
},
explorer = {
select = "<CR>", -- Open diff for selected file
open = "o", -- Alias for select (open file or toggle group)
focus_file = "l", -- Jump to modified pane if file is open, otherwise open file
hover = "K", -- Show file diff preview
refresh = "R", -- Refresh git status
toggle_view_mode = "i", -- Toggle between 'list' and 'tree' views
stage_all = "S", -- Stage all files
unstage_all = "U", -- Unstage all files
restore = "X", -- Discard changes (restore file)
stage_file = "a", -- Stage file under cursor (git add)
stage_file_alt = "s", -- Alternative binding for stage
unstage_file = "u", -- Unstage file under cursor (git restore --staged)
discard_file = "d", -- Discard changes or delete untracked (with confirmation)
},
history = {
select = "<CR>", -- Select commit/file or toggle expand
Expand Down
11 changes: 10 additions & 1 deletion lua/codediff/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,21 @@ M.defaults = {
diff_put = "dp", -- Put change to other buffer (like vimdiff)
open_in_prev_tab = "gf", -- Open current buffer in previous tab (or new tab before current)
toggle_stage = "-", -- Stage/unstage current file (works in explorer and diff buffers)
stage_hunk = "S", -- Stage the hunk under cursor to git index
unstage_hunk = "U", -- Unstage the hunk under cursor from git index
discard_hunk = "D", -- Discard the hunk under cursor (working tree only)
},
explorer = {
select = "<CR>",
open = "o", -- Alias for select (open file or toggle group)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel if user wants an alias, they can setup their own keymap locally, so it might not be necessary for us to have one?

focus_file = "l", -- Jump to modified pane if file is open, otherwise open file
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To confirm the behavior, if a file was not selected, it will select that file first, then focus the modified pane, right?

Copy link
Author

@christiandesantis christiandesantis Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using 'l' would open the file if not selected already, and move the cursor straight to the right pane so you can start scrolling and reviewing immediately. In case that you use 'l' and the file was already open, it just takes the cursor to the right pane the same way. When using 'o' it opens the file without moving the cursor from the file explorer, I thought it to be a good default, maybe I should've not call it "alias" in the comment next to it

hover = "K",
refresh = "R",
toggle_view_mode = "i", -- Toggle between 'list' and 'tree' views
toggle_view_mode = "i", -- Toggle between 'list' and 'tree' views
stage_file = "a", -- Stage file under cursor (git add)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These keymaps for stage/unstage/discard the file under cursor in explorer seem to be already implemented by - and X keymaps, so are they duplicated? If so, we might want to remove them

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah maybe, I just found these more intuitive. I still don't know if this previously had a key binding for staging the file, since '-' works for unstaging, I tried using '+' to stage but didn't work

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, - not only works for unstaging but also can stage unstaged if you click. It is basically a toggle. So the stage/unstage features seem to be duplicated

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh gotcha!

stage_file_alt = "s", -- Alternative binding for stage
unstage_file = "u", -- Unstage file under cursor (git restore --staged)
discard_file = "d", -- Discard changes or delete untracked (with confirmation)
stage_all = "S", -- Stage all files
unstage_all = "U", -- Unstage all files
restore = "X", -- Discard changes to file (restore to index/HEAD)
Expand Down
235 changes: 204 additions & 31 deletions lua/codediff/core/git.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
-- All operations are async and atomic
local M = {}

-- Unquote git C-quoted paths (e.g., "my file.md" -> my file.md)
local function unquote_path(path)
if path:sub(1, 1) == '"' and path:sub(-1) == '"' then
local unquoted = path:sub(2, -2)
unquoted = unquoted:gsub('\\(.)', function(char)
local escapes = { a = '\a', b = '\b', t = '\t', n = '\n', v = '\v', f = '\f', r = '\r', ['\\'] = '\\', ['"'] = '"' }
return escapes[char] or char
end)
return unquoted
end
return path
end

-- LRU Cache for git file content
-- Stores recently fetched file content to avoid redundant git calls
local ContentCache = {}
Expand Down Expand Up @@ -340,7 +353,7 @@ function M.get_status(git_root, callback)
if #line >= 3 then
local index_status = line:sub(1, 1)
local worktree_status = line:sub(2, 2)
local path_part = line:sub(4)
local path_part = unquote_path(line:sub(4))

-- Handle renames: "old_path -> new_path"
local old_path, new_path = path_part:match("^(.+) %-> (.+)$")
Expand Down Expand Up @@ -398,19 +411,19 @@ function M.get_diff_revision(revision, git_root, callback)
staged = {},
}

for line in output:gmatch("[^\r\n]+") do
if #line > 0 then
local parts = vim.split(line, "\t")
if #parts >= 2 then
local status = parts[1]:sub(1, 1)
local path = parts[2]
local old_path = nil

-- Handle renames (R100 or similar)
if status == "R" and #parts >= 3 then
old_path = parts[2]
path = parts[3]
end
for line in output:gmatch("[^\r\n]+") do
if #line > 0 then
local parts = vim.split(line, "\t")
if #parts >= 2 then
local status = parts[1]:sub(1, 1)
local path = unquote_path(parts[2])
local old_path = nil

-- Handle renames (R100 or similar)
if status == "R" and #parts >= 3 then
old_path = unquote_path(parts[2])
path = unquote_path(parts[3])
end

table.insert(result.unstaged, {
path = path,
Expand Down Expand Up @@ -462,23 +475,23 @@ function M.get_diff_revisions(rev1, rev2, git_root, callback)
staged = {},
}

-- For revision comparison, we treat everything as "unstaged" for explorer compatibility
-- But to keep explorer compatible, we'll put them in 'staged' as they are committed changes
-- relative to each other.

for line in output:gmatch("[^\r\n]+") do
if #line > 0 then
local parts = vim.split(line, "\t")
if #parts >= 2 then
local status = parts[1]:sub(1, 1)
local path = parts[2]
local old_path = nil

-- Handle renames (R100 or similar)
if status == "R" and #parts >= 3 then
old_path = parts[2]
path = parts[3]
end
-- For revision comparison, we treat everything as "unstaged" for explorer compatibility
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you run make format to format the code before submission?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do

-- But to keep explorer compatible, we'll put them in 'staged' as they are committed changes
-- relative to each other.
for line in output:gmatch("[^\r\n]+") do
if #line > 0 then
local parts = vim.split(line, "\t")
if #parts >= 2 then
local status = parts[1]:sub(1, 1)
local path = unquote_path(parts[2])
local old_path = nil

-- Handle renames (R100 or similar)
if status == "R" and #parts >= 3 then
old_path = unquote_path(parts[2])
path = unquote_path(parts[3])
end

table.insert(result.unstaged, {
path = path,
Expand All @@ -493,6 +506,166 @@ function M.get_diff_revisions(rev1, rev2, git_root, callback)
end)
end

-- Apply a unified diff patch to the git index (async)
-- Used for hunk-level staging: generates a patch for a single hunk and applies it
-- to the index without touching the working tree.
--
-- git_root: absolute path to git repository root
-- patch: string containing a valid unified diff patch
-- reverse: if true, reverse-apply the patch (used for unstaging)
-- callback: function(err) - nil err on success
function M.apply_patch(git_root, patch, reverse, callback)
local args = { "apply", "--cached", "--unidiff-zero", "-" }
if reverse then
table.insert(args, 3, "--reverse")
end

if vim.system then
if git_root and vim.fn.isdirectory(git_root) == 0 then
callback("Directory does not exist: " .. git_root)
return
end

vim.system(
vim.list_extend({ "git" }, args),
{
cwd = git_root,
stdin = patch,
text = true,
},
function(result)
vim.schedule(function()
if result.code == 0 then
callback(nil)
else
callback(result.stderr or "git apply failed")
end
end)
end
)
else
-- Fallback for older Neovim (< 0.10)
local stderr_data = {}
local stdin_pipe = vim.loop.new_pipe(false)
local stderr_pipe = vim.loop.new_pipe(false)

local handle
---@diagnostic disable-next-line: missing-fields
handle = vim.loop.spawn("git", {
args = args,
cwd = git_root,
stdio = { stdin_pipe, nil, stderr_pipe },
}, function(code)
if stdin_pipe then stdin_pipe:close() end
if stderr_pipe then stderr_pipe:close() end
if handle then handle:close() end

vim.schedule(function()
if code == 0 then
callback(nil)
else
callback(table.concat(stderr_data) or "git apply failed")
end
end)
end)

if not handle then
callback("Failed to spawn git process")
return
end

if stderr_pipe then
stderr_pipe:read_start(function(err, data)
if data then
table.insert(stderr_data, data)
end
end)
end

-- Write patch to stdin and close
stdin_pipe:write(patch)
stdin_pipe:shutdown()
end
end

-- Discard a hunk from the working tree by reverse-applying a patch (async)
-- Used for hunk-level discarding: generates a patch for a single hunk and
-- reverse-applies it to the working tree, effectively discarding the changes.
--
-- git_root: absolute path to git repository root
-- patch: string containing a valid unified diff patch
-- callback: function(err) - nil err on success
function M.discard_hunk_patch(git_root, patch, callback)
local args = { "apply", "--reverse", "--unidiff-zero", "-" }

if vim.system then
if git_root and vim.fn.isdirectory(git_root) == 0 then
callback("Directory does not exist: " .. git_root)
return
end

vim.system(
vim.list_extend({ "git" }, args),
{
cwd = git_root,
stdin = patch,
text = true,
},
function(result)
vim.schedule(function()
if result.code == 0 then
callback(nil)
else
callback(result.stderr or "git apply failed")
end
end)
end
)
else
-- Fallback for older Neovim (< 0.10)
local stderr_data = {}
local stdin_pipe = vim.loop.new_pipe(false)
local stderr_pipe = vim.loop.new_pipe(false)

local handle
---@diagnostic disable-next-line: missing-fields
handle = vim.loop.spawn("git", {
args = args,
cwd = git_root,
stdio = { stdin_pipe, nil, stderr_pipe },
}, function(code)
if stdin_pipe then stdin_pipe:close() end
if stderr_pipe then stderr_pipe:close() end
if handle then handle:close() end

vim.schedule(function()
if code == 0 then
callback(nil)
else
callback(table.concat(stderr_data) or "git apply failed")
end
end)
end)

if not handle then
callback("Failed to spawn git process")
return
end

if stderr_pipe then
stderr_pipe:read_start(function(err, data)
if data then
table.insert(stderr_data, data)
end
end)
end

-- Write patch to stdin and close
stdin_pipe:write(patch)
stdin_pipe:shutdown()
end
end

-- Run a git command synchronously
-- Returns output string or nil on error
local function run_git_sync(args, opts)
Expand Down
3 changes: 2 additions & 1 deletion lua/codediff/core/virtual_file.lua
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ local function load_virtual_buffer_content(buf, git_root, commit, filepath)
vim.bo[buf].readonly = true

-- Detect filetype from the original file path (for TreeSitter only)
local ft = vim.filetype.match({ filename = filepath })
-- Pass buf for proper detection of some filetypes like .ts
local ft = vim.filetype.match({ filename = filepath, buf = buf })
if ft then
vim.bo[buf].filetype = ft
end
Expand Down
Loading