feat(git): show staged hunks in the gutter with a stage toggle

This commit is contained in:
2026-05-20 12:46:44 +02:00
parent 7c92b5eff6
commit 1a582045f6
3 changed files with 578 additions and 122 deletions
+215 -2
View File
@@ -18,8 +18,8 @@ local function setup(committed, worktree, file)
hunks._flush(buf)
t.wait_for(function()
local s = hunks.state(buf)
return s ~= nil and s.index ~= nil
end, "hunks to compute the index snapshot")
return s ~= nil and s.index ~= nil and s.head ~= nil
end, "hunks to load the index and HEAD snapshots")
local state = assert(hunks.state(buf), "buffer state should exist")
return dir, buf, state
end
@@ -296,6 +296,147 @@ t.test("stage_hunk stages a deletion", function()
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nc")
end)
t.test("stage_hunk stages only the hunk under the cursor", function()
local committed = table.concat({
"local M = {}",
"",
"function M.first()",
" return 1",
"end",
"",
"function M.last()",
" return 9",
"end",
"",
"return M",
}, "\n") .. "\n"
local worktree = table.concat({
"local M = {}",
"",
"-- helpers",
"function M.first()",
" return 1",
"end",
"",
"function M.mid()",
" return 5",
"end",
"",
"function M.last()",
" return 9",
"end",
"",
"return M",
}, "\n") .. "\n"
local dir, buf = setup(committed, worktree)
vim.api.nvim_win_set_cursor(0, { 9, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the mid hunk to land in the index")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
table.concat({
"local M = {}",
"",
"function M.first()",
" return 1",
"end",
"",
"function M.mid()",
" return 5",
"end",
"",
"function M.last()",
" return 9",
"end",
"",
"return M",
}, "\n"),
"only the cursor's hunk is staged, placed at the right line"
)
end)
t.test("stage_hunk 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 })
hunks.stage_hunk(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the change to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "x\ny\nz")
end)
t.test("stage_hunk stages a change at the start of the file", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\ne\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the change to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "A\nb\nc\nd\ne")
end)
t.test("stage_hunk stages a change at the end of the file", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "a\nb\nc\nd\nE\n")
vim.api.nvim_win_set_cursor(0, { 5, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the change to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nb\nc\nd\nE")
end)
t.test("stage_hunk stages a deletion at the start of the file", function()
local dir, buf = setup("a\nb\nc\nd\n", "b\nc\nd\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the deletion to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "b\nc\nd")
end)
t.test("stage_hunk leaves an adjacent unstaged hunk in place", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\ne\n")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the line-3 hunk to land in the index")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"a\nb\nC\nd\ne",
"only line 3 is staged; the adjacent line-1 hunk is untouched"
)
end)
t.test("stage_hunk unstages one of two adjacent staged hunks", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\ne\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 1
end, "the line-1 hunk to be staged")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 0 and #s.staged == 2
end, "both hunks to be staged")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 1
end, "the line-3 hunk to be unstaged again")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"A\nb\nc\nd\ne",
"line 3 reverts to HEAD while the staged line-1 change remains"
)
end)
t.test("stage_hunk refreshes the gutter when status stays modified", function()
local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n")
t.eq(#assert(hunks.state(buf)).hunks, 3)
@@ -313,6 +454,78 @@ t.test("stage_hunk refreshes the gutter when status stays modified", function()
end, "gutter to drop the middle staged hunk")
end)
t.test("staged hunks show with the staged highlight", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 0 and #s.staged == 1
end, "the hunk to move from unstaged to staged")
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkStagedChanged" },
})
end)
t.test("the gutter shows staged and unstaged hunks together", function()
local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 2
end, "the first hunk to leave the unstaged set")
t.eq(sign_marks(buf), {
{ row = 0, sign = "", hl = "GitHunkStagedChanged" },
{ row = 2, sign = "", hl = "GitHunkChanged" },
{ row = 4, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("stage_hunk toggles a staged hunk back to unstaged", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 0 and #s.staged == 1
end, "the hunk to become staged")
hunks.stage_hunk(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 1 and #s.staged == 0
end, "the hunk to return to unstaged")
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("stage_hunk unstages correctly when buffer lines are shifted", function()
local dir, buf = setup("a\nb\nc\n", "a\nb\nC\n")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 1
end, "the line-3 change to be staged")
vim.api.nvim_buf_set_lines(buf, 0, 0, false, { "NEW" })
vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf })
hunks._flush(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 1
end, "the unstaged add at the top to register")
vim.api.nvim_win_set_cursor(0, { 4, 0 })
hunks.stage_hunk(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 0
end, "the shifted staged hunk to be unstaged")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"a\nb\nc",
"the index reverts to HEAD content for the unstaged hunk"
)
end)
t.test("reset_hunk restores the index content for a change", function()
local _, buf, state = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })