feat(hunks): stage selected hunks

This commit is contained in:
2026-05-29 10:22:52 +02:00
parent 7312a9832a
commit 7a243ef907
4 changed files with 159 additions and 36 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ vim.pack.add({ "https://git.owall.se/oscar/git.nvim" })
| `<Plug>(git-blame-popup)` | Cursor-line blame popup |
| `<Plug>(git-blame-view)` | Toggle full-file blame overlay |
| `<Plug>(git-hunk-select)` | Visually select the hunk under cursor |
| `<Plug>(git-hunk-stage-toggle)` | Stage / unstage hunk |
| `<Plug>(git-hunk-stage-toggle)` | Stage / unstage hunk or visual hunks |
| `<Plug>(git-hunk-reset)` | Reset hunk |
| `<Plug>(git-hunk-overlay-toggle)` | Toggle the in-buffer diff overlay |
| `<Plug>(git-hunk-next)` | Jump to next hunk |
+112 -35
View File
@@ -621,20 +621,6 @@ local function hunk_at(hunks, row)
return nil
end
---@param state ow.Git.Hunks.BufState
---@param buf integer
---@param row integer 1-indexed cursor line
---@return ow.Git.Hunks.Hunk?
local function staged_hunk_at(state, buf, row)
local line_count = vim.api.nvim_buf_line_count(buf)
for _, s in ipairs(staged_signs(state, line_count)) do
if s.row == row - 1 then
return s.hunk
end
end
return nil
end
---@param buf integer?
---@return integer buf
---@return ow.Git.Hunks.BufState? state
@@ -748,17 +734,20 @@ end
---@param h ow.Git.Hunks.Hunk
---@param old_lines string[]
---@param rel string
---@return string patch
---@param include_header boolean?
---@param context integer?
---@return string[] patch lines
---@return boolean zero_context
local function build_patch(h, old_lines, rel)
local function build_patch_lines(h, old_lines, rel, include_header, context)
context = context or PATCH_CONTEXT
local old_before, new_before = hunk_offsets(h)
local pre = {}
for i = math.max(old_before - PATCH_CONTEXT + 1, 1), old_before do
for i = math.max(old_before - context + 1, 1), old_before do
pre[#pre + 1] = old_lines[i] or ""
end
local post = {}
local after = old_before + h.old_count
for i = after + 1, math.min(after + PATCH_CONTEXT, #old_lines) do
for i = after + 1, math.min(after + context, #old_lines) do
post[#post + 1] = old_lines[i] or ""
end
local old_n = #pre + h.old_count + #post
@@ -786,9 +775,29 @@ local function build_patch(h, old_lines, rel)
for _, l in ipairs(post) do
body[#body + 1] = " " .. l
end
local lines = { "--- a/" .. rel, "+++ b/" .. rel }
local lines = {}
if include_header ~= false then
lines = { "--- a/" .. rel, "+++ b/" .. rel }
end
vim.list_extend(lines, body)
return table.concat(lines, "\n") .. "\n", #pre == 0 and #post == 0
return lines, #pre == 0 and #post == 0
end
---@param hunks ow.Git.Hunks.Hunk[]
---@param old_lines string[]
---@param rel string
---@param context integer?
---@return string patch
---@return boolean zero_context
local function build_patch(hunks, old_lines, rel, context)
local lines = { "--- a/" .. rel, "+++ b/" .. rel }
local zero_context = false
for _, h in ipairs(hunks) do
local body, zero = build_patch_lines(h, old_lines, rel, false, context)
vim.list_extend(lines, body)
zero_context = zero_context or zero
end
return table.concat(lines, "\n") .. "\n", zero_context
end
---@param state ow.Git.Hunks.BufState
@@ -818,27 +827,95 @@ local function apply_patch(state, buf, patch, zero_context)
})
end
---@param h ow.Git.Hunks.Hunk
---@return integer first 1-indexed buffer line
---@return integer last 1-indexed buffer line
local function hunk_range(h)
if h.type == "delete" then
local line = math.max(h.new_start, 1)
return line, line
end
return h.new_start, h.new_start + h.new_count - 1
end
---@param h ow.Git.Hunks.Hunk
---@param first integer
---@param last integer
---@return boolean
local function hunk_overlaps(h, first, last)
local h_first, h_last = hunk_range(h)
return h_first <= last and h_last >= first
end
---@param state ow.Git.Hunks.BufState
---@param buf integer
---@param first integer
---@param last integer
---@return ow.Git.Hunks.Hunk[]
local function staged_hunks_in_range(state, buf, first, last)
local out = {}
local seen = {}
local line_count = vim.api.nvim_buf_line_count(buf)
for _, s in ipairs(staged_signs(state, line_count)) do
local line = s.row + 1
if line >= first and line <= last and not seen[s.hunk] then
seen[s.hunk] = true
table.insert(out, s.hunk)
end
end
return out
end
---@param first integer
---@param last integer
---@param buf? integer
function M.toggle_stage(buf)
function M.toggle_stage_range(first, last, buf)
buf = resolve_buf(buf)
local state = states[buf]
if not state then
if not state or not state.index then
return
end
if first > last then
first, last = last, first
end
local unstaged = {}
for _, h in ipairs(state.hunks) do
if hunk_overlaps(h, first, last) then
table.insert(unstaged, h)
end
end
if #unstaged > 0 then
local context = #unstaged > 1 and 0 or nil
local patch, zero =
build_patch(unstaged, state.index, state.rel, context)
apply_patch(state, buf, patch, zero)
return
end
local staged = staged_hunks_in_range(state, buf, first, last)
if #staged > 0 then
local inverted = {}
for _, h in ipairs(staged) do
table.insert(inverted, invert(h))
end
local context = #inverted > 1 and 0 or nil
local patch, zero =
build_patch(inverted, state.index, state.rel, context)
apply_patch(state, buf, patch, zero)
return
end
util.warning("git hunks: no hunk in selection")
end
---@param buf? integer
function M.toggle_stage(buf)
local row = vim.api.nvim_win_get_cursor(0)[1]
local unstaged = hunk_at(state.hunks, row)
if unstaged and state.index then
local patch, zero = build_patch(unstaged, state.index, state.rel)
apply_patch(state, buf, patch, zero)
return
end
local staged = staged_hunk_at(state, buf, row)
if staged and state.index then
local patch, zero = build_patch(invert(staged), state.index, state.rel)
apply_patch(state, buf, patch, zero)
return
end
util.warning("git hunks: no hunk at cursor")
M.toggle_stage_range(row, row, buf)
end
---@param buf? integer
function M.toggle_stage_selection(buf)
local cursor_line = vim.api.nvim_win_get_cursor(0)[1]
M.toggle_stage_range(vim.fn.line("v"), cursor_line, buf)
end
---@param buf? integer
+3
View File
@@ -308,6 +308,9 @@ end, { silent = true, desc = "Jump to previous git hunk" })
vim.keymap.set("n", "<Plug>(git-hunk-stage-toggle)", function()
require("git.hunks").toggle_stage()
end, { silent = true, desc = "Stage or unstage the hunk under cursor" })
vim.keymap.set("x", "<Plug>(git-hunk-stage-toggle)", function()
require("git.hunks").toggle_stage_selection()
end, { silent = true, desc = "Stage or unstage selected git hunks" })
vim.keymap.set("n", "<Plug>(git-hunk-reset)", function()
require("git.hunks").reset_hunk()
end, { silent = true, desc = "Reset hunk under cursor" })
+43
View File
@@ -359,6 +359,49 @@ t.test("toggle_stage stages only the hunk under the cursor", function()
)
end)
t.test("toggle_stage_range stages every hunk in the selected lines", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n")
hunks.toggle_stage_range(1, 3, buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 2
end, "the selected hunks to land in the index")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"A\nb\nC\nd\ne",
"only the hunks touched by the selection are staged"
)
end)
t.test(
"toggle_stage_range stages a hunk when selection starts inside it",
function()
local dir, buf = setup("a\nb\nc\n", "A\nB\nc\n")
hunks.toggle_stage_range(2, 2, buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the touched hunk to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "A\nB\nc")
end
)
t.test("toggle_stage_range unstages selected staged hunks", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n")
hunks.toggle_stage_range(1, 5, buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 3
end, "all hunks to land in the index")
hunks.toggle_stage_range(1, 3, buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 1
end, "the selected staged hunks to be unstaged")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"a\nb\nc\nd\nE",
"only the unselected staged hunk remains in the index"
)
end)
t.test("toggle_stage stages a whole-file change with no context", function()
local dir, buf = setup("a\nb\nc\n", "x\ny\nz\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })