diff --git a/README.md b/README.md index 3f349f3..52b9d4d 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ vim.pack.add({ "https://git.owall.se/oscar/git.nvim" }) | `(git-blame-popup)` | Cursor-line blame popup | | `(git-blame-view)` | Toggle full-file blame overlay | | `(git-hunk-select)` | Visually select the hunk under cursor | -| `(git-hunk-stage-toggle)` | Stage / unstage hunk | +| `(git-hunk-stage-toggle)` | Stage / unstage hunk or visual hunks | | `(git-hunk-reset)` | Reset hunk | | `(git-hunk-overlay-toggle)` | Toggle the in-buffer diff overlay | | `(git-hunk-next)` | Jump to next hunk | diff --git a/lua/git/hunks.lua b/lua/git/hunks.lua index 447a8e0..9ca667e 100644 --- a/lua/git/hunks.lua +++ b/lua/git/hunks.lua @@ -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 diff --git a/plugin/git.lua b/plugin/git.lua index a86c49f..f5d8f65 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -308,6 +308,9 @@ end, { silent = true, desc = "Jump to previous git hunk" }) vim.keymap.set("n", "(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", "(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", "(git-hunk-reset)", function() require("git.hunks").reset_hunk() end, { silent = true, desc = "Reset hunk under cursor" }) diff --git a/test/git/hunks_test.lua b/test/git/hunks_test.lua index ee9d35f..6110e68 100644 --- a/test/git/hunks_test.lua +++ b/test/git/hunks_test.lua @@ -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 })