Compare commits

...

29 Commits

Author SHA1 Message Date
warg 942dbdcaa0 refactor(git): rename stage_hunk to toggle_stage 2026-05-20 13:19:05 +02:00
warg 1a582045f6 feat(git): show staged hunks in the gutter with a stage toggle 2026-05-20 12:46:45 +02:00
warg 7c92b5eff6 feat(git/repo): add Repo:head_sha for cached HEAD blob lookups 2026-05-20 12:46:05 +02:00
warg 6230c2663c fix(onedark): override GitUnstaged 2026-05-20 11:28:34 +02:00
warg 5b869334d6 fix(onedark): remove overridden diff/git highlights 2026-05-20 10:19:29 +02:00
warg 01ca0025dd fix(git): refresh the gutter after staging a hunk 2026-05-20 08:09:07 +02:00
warg b52f34ce9a fix(git): use GitHunkRemoved for the delete-hunk sign 2026-05-20 08:00:56 +02:00
warg e050896dc0 test(git): speed up the hunks test setup with a flush hook 2026-05-20 07:56:07 +02:00
warg 4c8b3f0d3e fix(git): align hunk signs with :diffsplit 2026-05-20 07:55:55 +02:00
warg 72ab9059fa fix(git): rename hunk highlights 2026-05-20 07:53:51 +02:00
warg 7c8975af10 fix(git): show merge commits diffed against the first parent 2026-05-20 07:14:37 +02:00
warg 2064c629ed feat(git): syntax-highlight deleted lines in the diff overlay 2026-05-20 06:42:42 +02:00
warg aaef6621dd fix(hunks): change delete sign 2026-05-20 06:27:52 +02:00
warg d629302625 perf(git): cache index blob sha, drop rev-parse from the edit path 2026-05-20 06:25:15 +02:00
warg f77d26db6b feat(git): trim hunk preview header, focus float on re-invoke 2026-05-20 06:17:58 +02:00
warg f4181b89fc feat(git): add in-house hunks module, replace gitsigns.nvim 2026-05-20 06:10:18 +02:00
warg d979c961a2 test(git): remove unused git uri 2026-05-19 20:42:06 +02:00
warg d132c00032 feat(git): bind gd to open-under-cursor in log and object views 2026-05-19 20:30:23 +02:00
warg 73fa92afc8 fix(core): reset foldlevel on entering a diff window 2026-05-19 20:26:59 +02:00
warg 3b8951758e fix(git): reset diff-pair cursor on sidebar selection 2026-05-19 19:55:03 +02:00
warg b692f23fe2 refactor(git): rework log_view, drop URI scheme 2026-05-19 19:40:13 +02:00
warg ffd5584a05 refactor(git): replace status_view URI scheme with path-style name 2026-05-19 16:29:10 +02:00
warg 4461a65b90 refactor(git): rework status_view header 2026-05-19 15:48:10 +02:00
warg 8121227ba4 refactor(git): share OpenOpts type between status_view open and toggle 2026-05-19 14:50:11 +02:00
warg 26d074c464 feat(git): status_view help hint, mouse open, extmark highlights 2026-05-19 14:47:40 +02:00
warg 897de35688 refactor(git): rewrite diff module around :diffsplit 2026-05-19 14:30:35 +02:00
warg 8fe4d0c6a7 refactor(git): drop reset_diff_win from status_view 2026-05-19 11:43:27 +02:00
warg 8a8c73ca8b refactor(git): rebuild status_view diff layout per selection 2026-05-19 11:40:03 +02:00
warg ab9b70c70a refactor(git): rename refresh event to change, gate on actual diff 2026-05-19 10:42:14 +02:00
23 changed files with 2529 additions and 662 deletions
-1
View File
@@ -30,7 +30,6 @@ require("pack").setup({
"https://github.com/owallb/mason-auto-install.nvim",
"https://github.com/mfussenegger/nvim-dap",
"https://github.com/numToStr/Comment.nvim",
"https://github.com/lewis6991/gitsigns.nvim",
"https://github.com/MagicDuck/grug-far.nvim",
"https://github.com/nvim-tree/nvim-tree.lua",
"https://github.com/stevearc/oil.nvim",
+12
View File
@@ -66,6 +66,18 @@ vim.api.nvim_create_autocmd({ "BufReadPost" }, {
command = 'silent! normal! g`"zv',
})
vim.api.nvim_create_autocmd("BufWinEnter", {
desc = "Reset foldlevel to 0 when entering a diff window."
.. " Vim's partial diff-state restoration on buffer re-entry"
.. " (e.g. via <C-o>) doesn't re-apply foldlevel=0, so"
.. " foldlevelstart leaks through and folds appear open.",
callback = function()
if vim.wo.diff then
vim.wo.foldlevel = 0
end
end,
})
vim.api.nvim_create_autocmd("FileType", {
pattern = { "c" },
callback = function()
+11 -4
View File
@@ -226,11 +226,18 @@ vim.keymap.set("n", "<leader>fD", vim.diagnostic.setqflist)
vim.keymap.set("n", "grt", vim.lsp.buf.type_definition)
vim.keymap.set("n", "gd", vim.lsp.buf.definition)
vim.keymap.set("n", "<leader>gd", "<Plug>(git-diff-vertical)")
vim.keymap.set("n", "<leader>gD", "<Plug>(git-diff-vertical-head)")
vim.keymap.set("n", "<leader>gh", "<Plug>(git-diff-horizontal)")
vim.keymap.set("n", "<leader>gH", "<Plug>(git-diff-horizontal-head)")
vim.keymap.set("n", "<leader>gd", "<Plug>(git-diffsplit-vertical)")
vim.keymap.set("n", "<leader>gD", "<Plug>(git-diffsplit-vertical-head)")
vim.keymap.set("n", "<leader>gh", "<Plug>(git-diffsplit-horizontal)")
vim.keymap.set("n", "<leader>gH", "<Plug>(git-diffsplit-horizontal-head)")
vim.keymap.set("n", "<leader>gg", "<Plug>(git-status-toggle)")
vim.keymap.set("n", "<leader>gc", "<Plug>(git-commit)")
vim.keymap.set("n", "<leader>ga", "<Plug>(git-commit-amend)")
vim.keymap.set("n", "<leader>gl", "<Plug>(git-log)")
vim.keymap.set("n", "<leader>gv", "<Plug>(git-hunk-select)")
vim.keymap.set("n", "<leader>gs", "<Plug>(git-hunk-stage-toggle)")
vim.keymap.set("n", "<leader>gr", "<Plug>(git-hunk-reset)")
vim.keymap.set("n", "<C-w>g", "<Plug>(git-hunk-preview)")
vim.keymap.set("n", "<leader>go", "<Plug>(git-overlay-toggle)")
vim.keymap.set({ "n", "x" }, "]g", "<Plug>(git-hunk-next)")
vim.keymap.set({ "n", "x" }, "[g", "<Plug>(git-hunk-prev)")
+51 -14
View File
@@ -21,7 +21,7 @@ end
---@field index_mode string?
---@alias ow.Git.Repo.Event
---| "refresh"
---| "change"
local global = util.Emitter.new()
@@ -42,6 +42,7 @@ end
---@class ow.Git.Repo.Change
---@field paths table<string, true>
---@field branch_changed boolean
---@class ow.Git.Repo.RefreshOpts
---@field invalidate boolean?
@@ -122,6 +123,16 @@ local function affects_resolve(relpath)
or relpath == "FETCH_HEAD"
end
---@private
---@param prefix string
function Repo:_clear_cache_prefix(prefix)
for key in pairs(self._cache) do
if vim.startswith(key, prefix) then
self._cache[key] = nil
end
end
end
---@private
---@param relpath string
function Repo:_invalidate(relpath)
@@ -131,11 +142,11 @@ function Repo:_invalidate(relpath)
end
end
if affects_resolve(relpath) then
for key in pairs(self._cache) do
if vim.startswith(key, "resolve:") then
self._cache[key] = nil
end
self:_clear_cache_prefix("resolve:")
self:_clear_cache_prefix("head_blob:")
end
if relpath == "index" then
self:_clear_cache_prefix("index:")
end
end
@@ -196,6 +207,7 @@ function Repo:_fetch_status()
self._pending_invalidate = false
end
local prior_entries = self.status.entries
local prior_branch = self.status.branch
self._fetch_epoch = self._fetch_epoch + 1
local epoch = self._fetch_epoch
util.git(STATUS_ARGS, {
@@ -217,9 +229,16 @@ function Repo:_fetch_status()
prior_entries,
self.status.entries
),
branch_changed = not vim.deep_equal(
prior_branch,
self.status.branch
),
}
self._events:emit("refresh", change, self.status)
global:emit("refresh", self, change, self.status)
if next(change.paths) == nil and not change.branch_changed then
return
end
self._events:emit("change", change, self.status)
global:emit("change", self, change, self.status)
end,
})
end
@@ -337,7 +356,7 @@ function Repo:_register_submodule(name)
end
self._submodules[name] = {
worktree = wt,
unsub = child:on("refresh", function()
unsub = child:on("change", function()
self:refresh()
end),
}
@@ -479,7 +498,7 @@ function Repo:close()
self._events:clear()
end
---@overload fun(event: "refresh", fn: fun(change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
---@overload fun(event: "change", fn: fun(change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
function Repo:on(event, fn)
return self._events:on(event, fn)
end
@@ -578,6 +597,24 @@ function Repo:rev_parse(rev, short)
return trimmed ~= "" and trimmed or nil
end
---@param rel string worktree-relative path
---@return string?
function Repo:index_sha(rel)
local sha = self:get_cached("index:" .. rel, function(self)
return self:rev_parse(":" .. rel, false) or false
end)
return sha or nil
end
---@param rel string worktree-relative path
---@return string?
function Repo:head_sha(rel)
local sha = self:get_cached("head_blob:" .. rel, function(self)
return self:rev_parse("HEAD:" .. rel, false) or false
end)
return sha or nil
end
---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing"
---@param abbrev string
@@ -636,7 +673,7 @@ end
---@type table<string, true>
local no_repo_dirs = {}
---@overload fun(event: "refresh", fn: fun(r: ow.Git.Repo, change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
---@overload fun(event: "change", fn: fun(r: ow.Git.Repo, change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
function M.on(event, fn)
return global:on(event, fn)
end
@@ -644,8 +681,8 @@ end
---@param prefix string
---@param fn fun(buf: integer, r: ow.Git.Repo)
---@return fun() unsubscribe
function M.on_uri_refresh(prefix, fn)
return M.on("refresh", function(r)
function M.on_uri_change(prefix, fn)
return M.on("change", function(r)
for buf in pairs(r.buffers) do
if vim.api.nvim_buf_is_loaded(buf) then
local name = vim.api.nvim_buf_get_name(buf)
@@ -810,7 +847,7 @@ end
---@param buf integer
---@return boolean
local function is_worktree_buf(buf)
function M.is_worktree_buf(buf)
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
return false
end
@@ -821,7 +858,7 @@ end
---@param buf? integer
function M.track(buf)
buf = expand_buf(buf)
if not is_worktree_buf(buf) then
if not M.is_worktree_buf(buf) then
return
end
local r = M.resolve(buf)
+59
View File
@@ -194,6 +194,65 @@ function M.debounce(fn, delay)
}
end
---@class ow.Git.Util.KeyedDebounceHandle<K>
---@field cancel fun(key: K)
---@field flush fun(key: K)
---@field pending fun(key: K): boolean
---@field close fun()
---@generic K, F: fun(key: K, ...)
---@param fn F
---@param delay integer
---@return F, ow.Git.Util.KeyedDebounceHandle<K>
function M.keyed_debounce(fn, delay)
---@type table<any, { call: fun(...), handle: ow.Git.Util.DebounceHandle }>
local slots = {}
local function call(key, ...)
local t = type(key)
assert(
t == "string" or t == "number" or t == "boolean",
"key must be a primitive (string, number, boolean)"
)
local slot = slots[key]
if not slot then
local c, h = M.debounce(function(...)
fn(key, ...)
end, delay)
slot = { call = c, handle = h }
slots[key] = slot
end
slot.call(...)
end
return call,
{
cancel = function(key)
local slot = slots[key]
if slot then
slot.handle.close()
slots[key] = nil
end
end,
flush = function(key)
local slot = slots[key]
if slot then
slot.handle.flush()
end
end,
pending = function(key)
local slot = slots[key]
return slot ~= nil and slot.handle.pending()
end,
close = function()
for _, slot in pairs(slots) do
slot.handle.close()
end
slots = {}
end,
}
end
---@class ow.Git.Util.ExecOpts
---@field cwd string?
---@field stdin string?
-175
View File
@@ -1,175 +0,0 @@
local Revision = require("git.core.revision")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
---@class ow.Git.Diff.Side
---@field buf integer
---@field name string?
---@class ow.Git.Diff.Pair
---@field left ow.Git.Diff.Side
---@field right ow.Git.Diff.Side
---@param win integer
---@param enabled boolean
function M.set_diff(win, enabled)
vim.api.nvim_win_call(win, function()
vim.cmd(enabled and "diffthis" or "diffoff")
end)
end
---@param left integer
---@param right integer
---@param vertical boolean
function M.open(left, right, vertical)
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(right)
vim.cmd.diffthis()
vim.api.nvim_open_win(left, true, {
split = vertical and "left" or "above",
})
vim.cmd.diffthis()
end
---@param left_win integer
---@param right_win integer
---@param pair ow.Git.Diff.Pair
function M.update_pair(left_win, right_win, pair)
M.set_diff(left_win, false)
M.set_diff(right_win, false)
vim.api.nvim_win_set_buf(left_win, pair.left.buf)
vim.api.nvim_win_set_buf(right_win, pair.right.buf)
for _, side in ipairs({ pair.left, pair.right }) do
if side.name then
util.set_buf_name(side.buf, side.name)
end
end
M.set_diff(left_win, true)
M.set_diff(right_win, true)
vim.api.nvim_win_set_cursor(left_win, { 1, 0 })
vim.api.nvim_win_set_cursor(right_win, { 1, 0 })
vim.cmd.syncbind()
end
---@param buf_a integer
---@param buf_b integer
---@param a_left boolean
---@param vertical boolean
local function place_pair(buf_a, buf_b, a_left, vertical)
if a_left then
M.open(buf_a, buf_b, vertical)
else
M.open(buf_b, buf_a, vertical)
end
end
---@param opts ow.Git.Diff.SplitOpts
---@param buf integer
---@param rev ow.Git.Revision
local function uri_split(opts, buf, rev)
local r = repo.resolve(buf)
if not r then
util.error("git URI buffer has no worktree")
return
end
if not rev.path then
util.error("git URI has no path, cannot diff against worktree")
return
end
local object = require("git.object")
if opts.rev and opts.rev:find(":", 1, true) then
if not r:rev_parse(opts.rev, true) then
util.error("invalid rev: %s", opts.rev)
return
end
place_pair(
buf,
object.buf_for(r, Revision.parse(opts.rev)),
false,
opts.vertical
)
return
end
if not opts.rev then
local worktree_path = vim.fs.joinpath(r.worktree, rev.path)
if not vim.uv.fs_stat(worktree_path) then
util.error("worktree file does not exist: %s", rev.path)
return
end
local worktree_buf = vim.fn.bufadd(worktree_path)
vim.fn.bufload(worktree_buf)
place_pair(buf, worktree_buf, true, opts.vertical)
return
end
if rev.stage == 1 then
util.warning("gD on merge base is ambiguous, use :Gdiffsplit <rev>")
return
end
local mapping = {
[2] = { Revision.new({ stage = 3, path = rev.path }), true },
[3] = { Revision.new({ stage = 2, path = rev.path }), false },
[0] = { Revision.new({ base = "HEAD", path = rev.path }), false },
}
local m = mapping[rev.stage]
or { Revision.new({ stage = 0, path = rev.path }), true }
local other_rev, left = m[1], m[2]
if not r:rev_parse(other_rev:format(), true) then
util.error("invalid rev: %s", other_rev:format())
return
end
place_pair(buf, object.buf_for(r, other_rev), left, opts.vertical)
end
---@class ow.Git.Diff.SplitOpts
---@field rev string?
---@field vertical boolean
---@param opts ow.Git.Diff.SplitOpts
function M.split(opts)
local cur_buf = vim.api.nvim_get_current_buf()
local cur_path = vim.api.nvim_buf_get_name(cur_buf)
local cur_rev = require("git.object").parse_uri(cur_path)
if cur_rev then
return uri_split(opts, cur_buf, cur_rev)
end
if cur_path == "" then
util.error("no file in current buffer")
return
end
if vim.bo[cur_buf].buftype ~= "" then
util.error("cannot diff this buffer (not a worktree file)")
return
end
cur_path = vim.fn.resolve(cur_path)
local r = repo.resolve(cur_path)
if not r then
util.error("not in a git repository")
return
end
local rel = vim.fs.relpath(r.worktree, cur_path)
local rev
if not opts.rev then
rev = Revision.new({ stage = 0, path = rel })
elseif opts.rev:find(":", 1, true) then
rev = Revision.parse(opts.rev)
else
rev = Revision.new({ base = opts.rev, path = rel })
end
if not r:rev_parse(rev:format(), true) then
util.error("invalid rev: %s", rev:format())
return
end
local buf = require("git.object").buf_for(r, rev)
place_pair(buf, cur_buf, true, opts.vertical)
end
return M
+134
View File
@@ -0,0 +1,134 @@
local Revision = require("git.core.revision")
local object = require("git.object")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
---@class ow.Git.Diffsplit.OpenOpts
---@field target string?
---@field mods vim.api.keyset.cmd.mods?
---@param cur_buf integer
---@return string? target
---@return string? err
local function infer_target(cur_buf)
local cur_name = vim.api.nvim_buf_get_name(cur_buf)
local cur_rev = object.parse_uri(cur_name)
if cur_rev then
local r = repo.resolve(cur_buf)
if not r then
return nil, "git URI buffer has no worktree"
end
if not cur_rev.path then
return nil, "git URI has no path, cannot diff against worktree"
end
local worktree_path = vim.fs.joinpath(r.worktree, cur_rev.path)
if not vim.uv.fs_stat(worktree_path) then
return nil, "worktree file does not exist: " .. cur_rev.path
end
return worktree_path, nil
end
if cur_name == "" then
return nil, "no file in current buffer"
end
if vim.bo[cur_buf].buftype ~= "" then
return nil, "cannot diff this buffer (not a worktree file)"
end
local resolved = vim.fn.resolve(cur_name)
local r = repo.resolve(resolved)
if not r then
return nil, "not in a git repository"
end
local rel = vim.fs.relpath(r.worktree, resolved)
if not rel then
return nil, "current buffer is outside the worktree"
end
return object.format_uri(Revision.new({ stage = 0, path = rel })), nil
end
---@param target string
---@param cur_buf integer
---@return string? resolved
---@return string? err
local function resolve_target(target, cur_buf)
if vim.startswith(target, object.URI_PREFIX) then
return target, nil
end
if vim.fn.filereadable(target) == 1 then
return target, nil
end
local cur_name = vim.api.nvim_buf_get_name(cur_buf)
local cur_rev = object.parse_uri(cur_name)
local r, rel
if cur_rev and cur_rev.path then
r = repo.resolve(cur_buf)
rel = cur_rev.path
elseif cur_name ~= "" then
local resolved = vim.fn.resolve(cur_name)
r = repo.resolve(resolved)
if r then
rel = vim.fs.relpath(r.worktree, resolved)
end
end
if not r then
return nil, "not in a git repository"
end
if not rel then
return nil, "current buffer has no path"
end
if not r:rev_parse(target, true) then
return nil, "invalid rev: " .. target
end
return object.format_uri(Revision.new({ base = target, path = rel })), nil
end
---@param cur_buf integer
---@param target string
---@return 'aboveleft'|'belowright'|nil
local function default_split(cur_buf, target)
local cur_rev = object.parse_uri(vim.api.nvim_buf_get_name(cur_buf))
local target_rev = object.parse_uri(target)
if not cur_rev and target_rev then
return "aboveleft"
end
if cur_rev and not target_rev then
return "belowright"
end
if cur_rev and target_rev then
if cur_rev.stage == 0 and target_rev.base then
return "aboveleft"
end
if cur_rev.base and target_rev.stage == 0 then
return "belowright"
end
end
return nil
end
---@param opts? ow.Git.Diffsplit.OpenOpts
function M.open(opts)
opts = opts or {}
local cur_buf = vim.api.nvim_get_current_buf()
local target, err
if opts.target then
target, err = resolve_target(opts.target, cur_buf)
else
target, err = infer_target(cur_buf)
end
if not target then
util.error("%s", err or "no diff target")
return
end
local mods = opts.mods
if not mods or mods.split == nil then
local placement = default_split(cur_buf, target)
if placement then
mods = vim.tbl_extend("force", mods or {}, { split = placement })
end
end
vim.cmd.diffsplit({ args = { target }, mods = mods })
end
return M
+970
View File
@@ -0,0 +1,970 @@
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
local NS_SIGNS = vim.api.nvim_create_namespace("ow.git.hunks")
local NS_OVERLAY = vim.api.nvim_create_namespace("ow.git.hunks.overlay")
---@alias ow.Git.Hunks.HunkType "add"|"change"|"delete"
---@class ow.Git.Hunks.Hunk
---@field old_start integer 1-indexed first old line
---@field old_count integer
---@field new_start integer 1-indexed first new line
---@field new_count integer
---@field type ow.Git.Hunks.HunkType
---@field old_lines string[]
---@field new_lines string[]
---@class ow.Git.Hunks.BufState
---@field repo ow.Git.Repo
---@field rel string
---@field index string[]?
---@field index_sha string?
---@field head string[]?
---@field head_sha string?
---@field index_hl { src: string[], lines: table[][]? }?
---@field hunks ow.Git.Hunks.Hunk[]
---@field staged ow.Git.Hunks.Hunk[]
---@field overlay boolean
---@field autocmds integer[]
---@type table<integer, ow.Git.Hunks.BufState>
local states = {}
---@param buf integer
---@return ow.Git.Hunks.BufState?
function M.state(buf)
return states[buf]
end
---@param buf integer?
---@return integer
local function resolve_buf(buf)
return buf and buf ~= 0 and buf or vim.api.nvim_get_current_buf()
end
---Mirror the hunk-affecting parts of the user's 'diffopt' so the gutter
---lines up with what `:diffsplit` shows.
---@return table
local function diff_opts()
local opts = { result_type = "indices", algorithm = "myers" }
for _, item in ipairs(vim.split(vim.o.diffopt, ",", { plain = true })) do
if item == "indent-heuristic" then
opts.indent_heuristic = true
else
local algorithm = item:match("^algorithm:(.+)$")
if algorithm then
opts.algorithm = algorithm
end
local linematch = item:match("^linematch:(%d+)$")
if linematch then
opts.linematch = tonumber(linematch)
end
end
end
return opts
end
---@param old_lines string[]
---@param new_lines string[]
---@return ow.Git.Hunks.Hunk[]
local function compute_hunks(old_lines, new_lines)
local raw = vim.text.diff(
table.concat(old_lines, "\n"),
table.concat(new_lines, "\n"),
diff_opts()
)
---@type ow.Git.Hunks.Hunk[]
local hunks = {}
if type(raw) ~= "table" then
return hunks
end
for _, h in ipairs(raw) do
local os_ = h[1] --[[@as integer]]
local oc = h[2] --[[@as integer]]
local ns_ = h[3] --[[@as integer]]
local nc = h[4] --[[@as integer]]
local typ ---@type ow.Git.Hunks.HunkType
if oc == 0 then
typ = "add"
elseif nc == 0 then
typ = "delete"
else
typ = "change"
end
local old = {}
if typ ~= "add" then
for i = os_, os_ + oc - 1 do
table.insert(old, old_lines[i] or "")
end
end
local new = {}
if typ ~= "delete" then
for i = ns_, ns_ + nc - 1 do
table.insert(new, new_lines[i] or "")
end
end
table.insert(hunks, {
old_start = os_,
old_count = oc,
new_start = ns_,
new_count = nc,
type = typ,
old_lines = old,
new_lines = new,
})
end
return hunks
end
---@type table<ow.Git.Hunks.HunkType, string>
local DEFAULT_SIGNS = { add = "", change = "", delete = "" }
---@return table<ow.Git.Hunks.HunkType, string>
local function resolve_signs()
local cfg = vim.g.git_hunk_signs
if type(cfg) ~= "table" then
return DEFAULT_SIGNS
end
return vim.tbl_extend("force", DEFAULT_SIGNS, cfg)
end
---@type table<ow.Git.Hunks.HunkType, string>
local SIGN_HL = {
add = "GitHunkAdded",
change = "GitHunkChanged",
delete = "GitHunkRemoved",
}
---@type table<ow.Git.Hunks.HunkType, string>
local STAGED_SIGN_HL = {
add = "GitHunkStagedAdded",
change = "GitHunkStagedChanged",
delete = "GitHunkStagedRemoved",
}
---@param h ow.Git.Hunks.Hunk
---@param line_count integer
---@return integer[] 0-indexed buffer rows for the hunk
local function hunk_rows(h, line_count)
if h.type == "delete" then
local row = math.max(h.new_start, 1) - 1
if row >= line_count then
row = math.max(line_count - 1, 0)
end
return { row }
end
local rows = {}
for r = h.new_start, h.new_start + h.new_count - 1 do
local row = r - 1
if row >= 0 and row < line_count then
table.insert(rows, row)
end
end
return rows
end
---@param h ow.Git.Hunks.Hunk
---@return integer 1-indexed last index line the hunk occupies
local function index_end(h)
if h.old_count == 0 then
return h.old_start
end
return h.old_start + h.old_count - 1
end
---@param unstaged ow.Git.Hunks.Hunk[]
---@param iline integer 1-indexed index line
---@return integer? 1-indexed buffer line
local function index_to_buffer(unstaged, iline)
local delta = 0
for _, h in ipairs(unstaged) do
if
h.old_count > 0
and iline >= h.old_start
and iline <= index_end(h)
then
return nil
end
if iline > index_end(h) then
delta = delta + h.new_count - h.old_count
end
end
return iline + delta
end
---@param state ow.Git.Hunks.BufState
---@param line_count integer
---@return { row: integer, hunk: ow.Git.Hunks.Hunk }[] row is a 0-indexed buffer row
local function staged_signs(state, line_count)
local out = {}
for _, h in ipairs(state.staged) do
local index_lines = {}
if h.type == "delete" then
table.insert(index_lines, math.max(h.new_start, 1))
else
for i = h.new_start, h.new_start + h.new_count - 1 do
table.insert(index_lines, i)
end
end
for _, iline in ipairs(index_lines) do
local bline = index_to_buffer(state.hunks, iline)
if bline then
local row = math.min(math.max(bline - 1, 0), line_count - 1)
table.insert(out, { row = row, hunk = h })
end
end
end
return out
end
---@param buf integer
local function render_signs(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1)
local state = states[buf]
if not state or state.overlay then
return
end
local signs = resolve_signs()
local line_count = vim.api.nvim_buf_line_count(buf)
local signed = {}
for _, h in ipairs(state.hunks) do
for _, row in ipairs(hunk_rows(h, line_count)) do
signed[row] = true
pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, row, 0, {
sign_text = signs[h.type],
sign_hl_group = SIGN_HL[h.type],
priority = 100,
})
end
end
for _, s in ipairs(staged_signs(state, line_count)) do
if not signed[s.row] then
pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, s.row, 0, {
sign_text = signs[s.hunk.type],
sign_hl_group = STAGED_SIGN_HL[s.hunk.type],
priority = 100,
})
end
end
end
local SKIP_CAPTURES = { spell = true, nospell = true, conceal = true }
---@param buf integer
---@param lines string[]
---@return table[][]?
local function highlight_index(buf, lines)
if not vim.treesitter.highlighter.active[buf] then
return nil
end
local got, parser = pcall(vim.treesitter.get_parser, buf)
if not got or not parser then
return nil
end
local lang = parser:lang()
local query = vim.treesitter.query.get(lang, "highlights")
if not query then
return nil
end
local source = table.concat(lines, "\n")
local got_root, root = pcall(function()
local trees = vim.treesitter.get_string_parser(source, lang):parse()
local tree = trees and trees[1]
return tree and tree:root()
end)
if not got_root or not root then
return nil
end
---@type table<integer, table<integer, string>>
local groups = {}
for id, node in query:iter_captures(root, source) do
local name = query.captures[id]
if name and name:sub(1, 1) ~= "_" and not SKIP_CAPTURES[name] then
local sr, sc, er, ec = node:range()
for row = sr, math.min(er, #lines - 1) do
local row_groups = groups[row] or {}
groups[row] = row_groups
local from = row == sr and sc or 0
local to = row == er and ec or #(lines[row + 1] or "")
for col = from, to - 1 do
row_groups[col] = name
end
end
end
end
local out = {}
for row = 0, #lines - 1 do
local line = lines[row + 1] or ""
local row_groups = groups[row] or {}
local chunks = {}
local col = 0
while col < #line do
local name = row_groups[col]
local stop = col + 1
while stop < #line and row_groups[stop] == name do
stop = stop + 1
end
local hl ---@type string|string[]
if name then
hl = { "GitHunkDeleteLine", "@" .. name }
else
hl = "GitHunkDeleteLine"
end
table.insert(chunks, { line:sub(col + 1, stop), hl })
col = stop
end
out[row + 1] = chunks
end
return out
end
---@param h ow.Git.Hunks.Hunk
---@param hl_lines table[][]? per-index-line syntax chunks, or nil
---@return table[]
local function delete_virt_lines(h, hl_lines)
local width = vim.o.columns
local virt = {}
for i, line in ipairs(h.old_lines) do
local pad = math.max(width - vim.api.nvim_strwidth(line), 0)
local cached = hl_lines and hl_lines[h.old_start + i - 1]
if cached then
local chunks = vim.list_extend({}, cached)
table.insert(chunks, {
string.rep(" ", pad),
"GitHunkDeleteLine",
})
table.insert(virt, chunks)
else
table.insert(virt, {
{ line .. string.rep(" ", pad), "GitHunkDeleteLine" },
})
end
end
return virt
end
---@param state ow.Git.Hunks.BufState
---@param buf integer
---@return table[][]?
local function index_spans(state, buf)
if not state.index then
return nil
end
local cache = state.index_hl
if cache and cache.src == state.index then
return cache.lines
end
local lines = highlight_index(buf, state.index)
state.index_hl = { src = state.index, lines = lines }
return lines
end
---@param buf integer
local function render_overlay(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1)
local state = states[buf]
if not state or not state.overlay then
return
end
local line_count = vim.api.nvim_buf_line_count(buf)
local hl_lines = index_spans(state, buf)
for _, h in ipairs(state.hunks) do
if h.type ~= "delete" then
for r = h.new_start, h.new_start + h.new_count - 1 do
local row = r - 1
if row >= 0 and row < line_count then
pcall(
vim.api.nvim_buf_set_extmark,
buf,
NS_OVERLAY,
row,
0,
{
line_hl_group = "GitHunkAddLine",
priority = 100,
}
)
end
end
end
if h.type ~= "add" then
local row, above
if h.type == "delete" then
if h.new_start <= 0 then
row, above = 0, true
elseif h.new_start >= line_count then
row, above = math.max(line_count - 1, 0), false
else
row, above = h.new_start, true
end
else
row, above = math.max(h.new_start - 1, 0), true
end
pcall(vim.api.nvim_buf_set_extmark, buf, NS_OVERLAY, row, 0, {
virt_lines = delete_virt_lines(h, hl_lines),
virt_lines_above = above,
right_gravity = false,
invalidate = true,
})
end
end
end
---@param buf integer
local function render(buf)
render_signs(buf)
render_overlay(buf)
end
---@param state ow.Git.Hunks.BufState
---@param buf integer
---@param rev string
---@param want string? the wanted blob sha
---@param have string? the currently-loaded blob sha
---@param apply fun(lines: string[]?, sha: string?)
---@param after fun()
local function ensure_content(state, buf, rev, want, have, apply, after)
if not want then
apply(nil, nil)
return after()
end
if want == have then
return after()
end
util.git({ "cat-file", "-p", rev }, {
cwd = state.repo.worktree,
silent = true,
on_exit = function(res)
if states[buf] ~= state or not vim.api.nvim_buf_is_valid(buf) then
return
end
if res.code == 0 then
apply(util.split_lines(res.stdout or ""), want)
else
apply(nil, nil)
end
after()
end,
})
end
---@param buf integer
local function recompute(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
local state = states[buf]
if not state then
return
end
local r = state.repo
ensure_content(
state,
buf,
":0:" .. state.rel,
r:index_sha(state.rel),
state.index_sha,
function(lines, sha)
state.index = lines
state.index_sha = sha
end,
function()
ensure_content(
state,
buf,
"HEAD:" .. state.rel,
r:head_sha(state.rel),
state.head_sha,
function(lines, sha)
state.head = lines
state.head_sha = sha
end,
function()
local new =
vim.api.nvim_buf_get_lines(buf, 0, -1, false)
state.hunks = state.index
and compute_hunks(state.index, new)
or {}
state.staged = state.head
and state.index
and compute_hunks(state.head, state.index)
or {}
render(buf)
end
)
end
)
end
local schedule, sched_handle = util.keyed_debounce(recompute, 100)
---@param buf integer
function M._flush(buf)
sched_handle.flush(buf)
end
---@param buf integer
function M.attach(buf)
if states[buf] then
return
end
if not repo.is_worktree_buf(buf) then
return
end
local r = repo.find(buf)
if not r then
return
end
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(vim.api.nvim_buf_get_name(buf)))
if not rel then
return
end
---@type ow.Git.Hunks.BufState
local state = {
repo = r,
rel = rel,
index = nil,
index_sha = nil,
head = nil,
head_sha = nil,
hunks = {},
staged = {},
overlay = vim.g.git_hunk_overlay_default == true,
autocmds = {},
}
states[buf] = state
local group =
vim.api.nvim_create_augroup("ow.git.hunks." .. buf, { clear = true })
table.insert(
state.autocmds,
vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, {
group = group,
buffer = buf,
callback = function()
schedule(buf)
end,
})
)
table.insert(
state.autocmds,
vim.api.nvim_create_autocmd("BufWritePost", {
group = group,
buffer = buf,
callback = function()
schedule(buf)
end,
})
)
schedule(buf)
end
---@param buf integer
function M.detach(buf)
local state = states[buf]
if not state then
return
end
if vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1)
end
for _, id in ipairs(state.autocmds) do
pcall(vim.api.nvim_del_autocmd, id)
end
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks." .. buf)
sched_handle.cancel(buf)
states[buf] = nil
end
---@param buf integer?
function M.toggle_overlay(buf)
buf = resolve_buf(buf)
local state = states[buf]
if not state then
util.warning("git hunks: buffer not attached")
return
end
state.overlay = not state.overlay
render(buf)
end
---@param hunks ow.Git.Hunks.Hunk[]
---@param row integer 1-indexed cursor line
---@return ow.Git.Hunks.Hunk?
local function hunk_at(hunks, row)
for _, h in ipairs(hunks) do
if h.type == "delete" then
if math.max(h.new_start, 1) == row then
return h
end
elseif row >= h.new_start and row <= h.new_start + h.new_count - 1 then
return h
end
end
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
---@return ow.Git.Hunks.Hunk? hunk
local function cursor_hunk(buf)
buf = resolve_buf(buf)
local state = states[buf]
if not state then
return buf, nil, nil
end
return buf, state, hunk_at(state.hunks, vim.api.nvim_win_get_cursor(0)[1])
end
---@param h ow.Git.Hunks.Hunk
---@return integer 1-indexed buffer line to anchor the cursor on
local function anchor_line(h)
if h.type == "delete" then
return math.max(h.new_start, 1)
end
return h.new_start
end
---@param direction "next"|"prev"
function M.nav(direction)
local buf = vim.api.nvim_get_current_buf()
local state = states[buf]
if not state or #state.hunks == 0 then
return
end
local cur = vim.api.nvim_win_get_cursor(0)[1]
local hunks = state.hunks
local target = direction == "next" and hunks[1] or hunks[#hunks]
if direction == "next" then
for _, h in ipairs(hunks) do
if anchor_line(h) > cur then
target = h
break
end
end
else
for i = #hunks, 1, -1 do
if anchor_line(hunks[i]) < cur then
target = hunks[i]
break
end
end
end
if not target then
return
end
vim.api.nvim_win_set_cursor(0, { anchor_line(target), 0 })
end
---@param h ow.Git.Hunks.Hunk
---@return string[]
local function hunk_body(h)
local lines = {
string.format(
"@@ -%d,%d +%d,%d @@",
h.old_start,
h.old_count,
h.new_start,
h.new_count
),
}
for _, l in ipairs(h.old_lines) do
table.insert(lines, "-" .. l)
end
for _, l in ipairs(h.new_lines) do
table.insert(lines, "+" .. l)
end
return lines
end
local PATCH_CONTEXT = 3
---@param h ow.Git.Hunks.Hunk
---@return integer old_before count of old lines before the hunk's changed content
---@return integer new_before count of new lines before the hunk's changed content
local function hunk_offsets(h)
if h.type == "add" then
return h.old_start, h.new_start - 1
elseif h.type == "delete" then
return h.old_start - 1, h.new_start
end
return h.old_start - 1, h.new_start - 1
end
---@param h ow.Git.Hunks.Hunk
---@return ow.Git.Hunks.Hunk
local function invert(h)
local typ ---@type ow.Git.Hunks.HunkType
if h.type == "add" then
typ = "delete"
elseif h.type == "delete" then
typ = "add"
else
typ = "change"
end
return {
old_start = h.new_start,
old_count = h.new_count,
new_start = h.old_start,
new_count = h.old_count,
type = typ,
old_lines = h.new_lines,
new_lines = h.old_lines,
}
end
---@param h ow.Git.Hunks.Hunk
---@param old_lines string[]
---@param rel string
---@return string patch
---@return boolean zero_context
local function build_patch(h, old_lines, rel)
local old_before, new_before = hunk_offsets(h)
local pre = {}
for i = math.max(old_before - PATCH_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
post[#post + 1] = old_lines[i] or ""
end
local old_n = #pre + h.old_count + #post
local new_n = #pre + h.new_count + #post
local old_start = old_n > 0 and old_before - #pre + 1 or old_before
local new_start = new_n > 0 and new_before - #pre + 1 or new_before
local body = {
string.format(
"@@ -%d,%d +%d,%d @@",
old_start,
old_n,
new_start,
new_n
),
}
for _, l in ipairs(pre) do
body[#body + 1] = " " .. l
end
for _, l in ipairs(h.old_lines) do
body[#body + 1] = "-" .. l
end
for _, l in ipairs(h.new_lines) do
body[#body + 1] = "+" .. l
end
for _, l in ipairs(post) do
body[#body + 1] = " " .. l
end
local lines = { "--- a/" .. rel, "+++ b/" .. rel }
vim.list_extend(lines, body)
return table.concat(lines, "\n") .. "\n", #pre == 0 and #post == 0
end
---@param state ow.Git.Hunks.BufState
---@param buf integer
---@param patch string
---@param zero_context boolean
local function apply_patch(state, buf, patch, zero_context)
local args = { "apply", "--cached" }
if zero_context then
table.insert(args, "--unidiff-zero")
end
table.insert(args, "-")
util.git(args, {
cwd = state.repo.worktree,
stdin = patch,
on_exit = function(res)
if res.code ~= 0 then
util.error("git apply failed: %s", vim.trim(res.stderr or ""))
return
end
local s = states[buf]
if s then
s.index_sha = nil
schedule(buf)
end
end,
})
end
---@param buf? integer
function M.toggle_stage(buf)
buf = resolve_buf(buf)
local state = states[buf]
if not state then
return
end
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")
end
---@param buf? integer
function M.reset_hunk(buf)
local target, state, h = cursor_hunk(buf)
if not state then
return
end
if not h then
util.warning("git hunks: no hunk at cursor")
return
end
if h.type == "add" then
vim.api.nvim_buf_set_lines(
target,
h.new_start - 1,
h.new_start - 1 + h.new_count,
false,
{}
)
elseif h.type == "delete" then
vim.api.nvim_buf_set_lines(
target,
h.new_start,
h.new_start,
false,
h.old_lines
)
else
vim.api.nvim_buf_set_lines(
target,
h.new_start - 1,
h.new_start - 1 + h.new_count,
false,
h.old_lines
)
end
end
---@param buf? integer
function M.select_hunk(buf)
local _, _, h = cursor_hunk(buf)
if not h or h.type == "delete" then
return
end
local first = h.new_start
local last = h.new_start + math.max(h.new_count, 1) - 1
vim.api.nvim_win_set_cursor(0, { first, 0 })
vim.cmd("normal! V")
vim.api.nvim_win_set_cursor(0, { last, 0 })
end
local preview_win ---@type integer?
---@param buf? integer
function M.preview_hunk(buf)
if preview_win and vim.api.nvim_win_is_valid(preview_win) then
vim.api.nvim_set_current_win(preview_win)
return
end
local target, state, h = cursor_hunk(buf)
if not state then
return
end
if not h then
return
end
local lines = hunk_body(h)
local pbuf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines)
vim.bo[pbuf].filetype = "diff"
vim.bo[pbuf].bufhidden = "wipe"
local width = 0
for _, l in ipairs(lines) do
if #l > width then
width = #l
end
end
width = math.min(math.max(width + 2, 40), vim.o.columns - 4)
local height = math.min(#lines, math.floor(vim.o.lines / 2))
local win = vim.api.nvim_open_win(pbuf, false, {
relative = "cursor",
row = 1,
col = 0,
width = width,
height = height,
style = "minimal",
})
preview_win = win
local function close()
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
end
end
local group =
vim.api.nvim_create_augroup("ow.git.hunks.preview", { clear = true })
vim.api.nvim_create_autocmd(
{ "CursorMoved", "CursorMovedI", "InsertEnter" },
{ group = group, buffer = target, callback = close }
)
vim.api.nvim_create_autocmd("WinLeave", {
group = group,
buffer = pbuf,
callback = close,
})
vim.api.nvim_create_autocmd("WinClosed", {
group = group,
pattern = tostring(win),
callback = function()
preview_win = nil
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks.preview")
end,
})
vim.keymap.set("n", "q", close, { buffer = pbuf, nowait = true })
end
repo.on("change", function(r, change)
for buf, state in pairs(states) do
if
state.repo == r
and (change.paths[state.rel] or change.branch_changed)
then
schedule(buf)
end
end
end)
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then
M.attach(buf)
end
end
return M
+33 -65
View File
@@ -3,29 +3,36 @@ local util = require("git.core.util")
local M = {}
M.URI_PREFIX = "gitlog://"
local LOG_FORMAT = "%h %ad {%an}%d %s"
local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
---@param buf integer
local function attach_dispatch(buf)
vim.keymap.set("n", "<CR>", function()
---@return boolean opened
local function open_under_cursor(buf)
local r = repo.resolve(buf)
-- Anchor past the leading graph chars (matches the leading sha column,
-- not any hex word that happens to appear later in the subject).
local sha = r
and vim.api
.nvim_get_current_line()
:match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)")
if sha then
and vim.api.nvim_get_current_line():match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)")
if not sha then
return false
end
---@cast r -nil
require("git.object").open(r, sha, { split = false })
else
return true
end
---@param buf integer
local function attach_dispatch(buf)
vim.keymap.set("n", "<CR>", function()
if not open_under_cursor(buf) then
vim.api.nvim_feedkeys(cr, "n", false)
end
end, { buffer = buf, silent = true, desc = "Open commit" })
vim.keymap.set("n", "gd", function()
open_under_cursor(buf)
end, { buffer = buf, silent = true, desc = "Open commit" })
end
---@param worktree string
@@ -56,50 +63,7 @@ local function populate(buf, r)
if not stdout then
return
end
local new_lines = util.split_lines(stdout)
local old_str = table.concat(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n"
) .. "\n"
local new_str = table.concat(new_lines, "\n") .. "\n"
local hunks = vim.text.diff(old_str, new_str, {
result_type = "indices",
algorithm = "histogram",
})
---@cast hunks [integer, integer, integer, integer][]
if #hunks == 0 then
return
end
for i = #hunks, 1, -1 do
local sa, ca, sb, cb = unpack(hunks[i])
local start = ca == 0 and sa or sa - 1
util.set_buf_lines(
buf,
start,
start + ca,
vim.list_slice(new_lines, sb, sb + cb - 1)
)
end
end
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local worktree = name:sub(#M.URI_PREFIX + 1)
if worktree == "" then
return
end
local r = repo.resolve(worktree)
if not r then
return
end
repo.bind(buf, r)
util.setup_scratch(buf, { bufhidden = "hide" })
vim.bo[buf].filetype = "gitlog"
attach_dispatch(buf)
populate(buf, r)
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
end
---@class ow.Git.Log.OpenOpts
@@ -120,19 +84,25 @@ function M.open(opts)
end
max_counts[r.worktree] = opts.max_count
local buf = vim.fn.bufadd(M.URI_PREFIX .. r.worktree)
local was_loaded = vim.api.nvim_buf_is_loaded(buf)
local buf = vim.fn.bufadd(r.worktree .. "/GitLog")
local win = vim.fn.bufwinid(buf)
if win == -1 then
util.place_buf(buf, nil)
else
vim.api.nvim_set_current_win(win)
end
if was_loaded then
local visible = vim.fn.bufwinid(buf)
if visible ~= -1 then
vim.api.nvim_set_current_win(visible)
populate(buf, r)
vim.api.nvim_win_set_cursor(visible, { 1, 0 })
return
end
vim.fn.bufload(buf)
repo.bind(buf, r)
util.setup_scratch(buf, { bufhidden = "hide" })
vim.bo[buf].filetype = "gitlog"
attach_dispatch(buf)
local win = util.place_buf(buf, nil)
vim.api.nvim_win_set_cursor(win, { 1, 0 })
populate(buf, r)
end
---@param cmd_opts table
@@ -170,6 +140,4 @@ function M.complete_glog(arg_lead)
return matches
end
repo.on_uri_refresh(M.URI_PREFIX, populate)
return M
+11 -4
View File
@@ -150,6 +150,9 @@ function M.attach_dispatch(buf)
vim.api.nvim_feedkeys(cr, "n", false)
end
end, { buffer = buf, silent = true, desc = "Open file at commit" })
vim.keymap.set("n", "gd", function()
M.open_under_cursor()
end, { buffer = buf, silent = true, desc = "Open file at commit" })
end
---@param r ow.Git.Repo
@@ -181,8 +184,7 @@ local function populate(buf, r, rev, state, rev_sha)
local patch = util.git({
"diff-tree",
"-p",
"-m",
"--first-parent",
"--diff-merges=first-parent",
"--root",
"--no-commit-id",
commit_sha,
@@ -334,7 +336,12 @@ local function open_section(r, section)
local left = side_buf(r, section.blob_a, section.path_a)
local right = side_buf(r, section.blob_b, section.path_b)
if left and right then
require("git.diff").open(left, right, true)
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(right)
require("git.diffsplit").open({
target = vim.api.nvim_buf_get_name(left),
mods = { vertical = true },
})
return
end
if not left and not right then
@@ -428,6 +435,6 @@ function M.open_under_cursor()
return false
end
repo.on_uri_refresh(M.URI_PREFIX, refresh)
repo.on_uri_change(M.URI_PREFIX, refresh)
return M
+158 -243
View File
@@ -1,5 +1,5 @@
local Revision = require("git.core.revision")
local diff = require("git.diff")
local diffsplit = require("git.diffsplit")
local object = require("git.object")
local repo = require("git.core.repo")
local status = require("git.core.status")
@@ -7,8 +7,6 @@ local util = require("git.core.util")
local M = {}
M.URI_PREFIX = "gitstatus://"
---@type ow.Git.StatusView.Placement[]
M.PLACEMENTS = { "sidebar", "split", "current" }
@@ -16,14 +14,10 @@ M.PLACEMENTS = { "sidebar", "split", "current" }
local SECTIONS = { "untracked", "unstaged", "staged", "unmerged" }
local WINDOW_WIDTH = 50
---@param name string
---@return integer? bufnr
local function find_buf(name)
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b) == name then
return b
end
end
---@param r ow.Git.Repo
---@return string
local function buf_name_for(r)
return r.worktree .. "/GitStatus"
end
---@alias ow.Git.StatusView.Placement "sidebar"|"split"|"current"
@@ -39,11 +33,7 @@ end
---@field placement ow.Git.StatusView.Placement
---@field lines table<integer, ow.Git.StatusView.Item>
---@field win integer?
---@field invocation_win integer?
---@field diff_left_win integer?
---@field diff_right_win integer?
---@field unsubscribe fun()?
---@field last_shown_key string?
---@type table<integer, ow.Git.StatusView.State>
local state = {}
@@ -107,32 +97,77 @@ local function display_name(section)
end
---@param bufnr integer
---@param status ow.Git.Status
local function render(bufnr, status)
---@param r ow.Git.Repo
local function render(bufnr, r)
local status = r.status
local branch = status.branch
local lines = { "Head: " .. (branch.head or "?") }
local lines = {}
local marks = {}
local meta = {}
local function label(row, len)
table.insert(marks, { row = row, col = 0, end_col = len, hl = "Label" })
end
local repo_line = vim.fn.fnamemodify(r.worktree, ":t")
table.insert(lines, repo_line)
table.insert(marks, {
row = #lines - 1,
col = 0,
end_col = #repo_line,
hl = "Directory",
})
table.insert(lines, "Branch: " .. (branch.head or "?"))
label(#lines - 1, 6)
if branch.upstream then
local push = "Push: " .. branch.upstream
local up = "Upstream: " .. branch.upstream
local extras = {}
if branch.ahead > 0 then
push = push .. " +" .. branch.ahead
local col = #up + 1
up = up .. " +" .. branch.ahead
table.insert(extras, {
col = col,
end_col = #up,
hl = "GitUnpushed",
})
end
if branch.behind > 0 then
push = push .. " -" .. branch.behind
local col = #up + 1
up = up .. " -" .. branch.behind
table.insert(extras, {
col = col,
end_col = #up,
hl = "GitUnpulled",
})
end
table.insert(lines, push)
table.insert(lines, up)
local row = #lines - 1
label(row, 8)
for _, e in ipairs(extras) do
e.row = row
table.insert(marks, e)
end
end
table.insert(lines, "")
local meta = {}
local marks = {}
for _, section in ipairs(SECTIONS) do
local rows = status:rows(section)
if #rows > 0 then
table.insert(
lines,
string.format("%s (%d)", display_name(section), #rows)
)
local name = display_name(section)
local header = string.format("%s (%d)", name, #rows)
table.insert(lines, header)
local header_row = #lines - 1
meta[#lines] = { is_header = true, section = section }
label(header_row, #name)
table.insert(marks, {
row = header_row,
col = #name + 2,
end_col = #header - 1,
hl = "Number",
})
for _, row in ipairs(rows) do
local line, hl, hl_len = format_row(row)
table.insert(lines, line)
@@ -165,8 +200,7 @@ local function refresh(bufnr)
if not s or not vim.api.nvim_buf_is_valid(bufnr) then
return
end
s.last_shown_key = nil
render(bufnr, s.repo.status)
render(bufnr, s.repo)
end
---@param bufnr integer
@@ -181,9 +215,13 @@ local function current_entry(bufnr)
return s, s.lines[lnum]
end
---@class ow.Git.StatusView.Pane
---@field buf integer
---@field name string?
---@param r ow.Git.Repo
---@param path string
---@return ow.Git.Diff.Side
---@return ow.Git.StatusView.Pane
local function head_pane(r, path)
local rev = Revision.new({ base = "HEAD", path = path })
return {
@@ -194,7 +232,7 @@ end
---@param r ow.Git.Repo
---@param path string
---@return ow.Git.Diff.Side
---@return ow.Git.StatusView.Pane
local function worktree_pane(r, path)
local buf = vim.fn.bufadd(vim.fs.joinpath(r.worktree, path))
vim.fn.bufload(buf)
@@ -203,7 +241,7 @@ end
---@param s ow.Git.StatusView.State
---@param path string
---@return ow.Git.Diff.Side
---@return ow.Git.StatusView.Pane
local function index_pane(s, path)
local rev = Revision.new({ stage = 0, path = path })
return {
@@ -214,7 +252,7 @@ end
---@param s ow.Git.StatusView.State
---@param row ow.Git.Status.Row
---@return ow.Git.Diff.Side?
---@return ow.Git.StatusView.Pane?
local function older_pane(s, row)
local entry = row.entry
if row.section == "staged" then
@@ -232,7 +270,7 @@ end
---@param s ow.Git.StatusView.State
---@param row ow.Git.Status.Row
---@return ow.Git.Diff.Side?
---@return ow.Git.StatusView.Pane?
local function newer_pane(s, row)
local entry = row.entry
if row.section == "staged" then
@@ -255,71 +293,6 @@ local function newer_pane(s, row)
return nil
end
---@param win integer
local function reset_diff_win(win)
vim.api.nvim_win_call(win, function()
vim.cmd(
"setlocal winfixwidth< number< relativenumber< signcolumn< wrap< cursorline<"
)
end)
end
---@param s ow.Git.StatusView.State
---@return integer?
local function invocation_win_for(s)
local win = s.invocation_win
if not win or not vim.api.nvim_win_is_valid(win) then
return nil
end
if win == s.win then
return nil
end
if
vim.api.nvim_win_get_tabpage(win)
~= vim.api.nvim_get_current_tabpage()
then
return nil
end
return win
end
---@param s ow.Git.StatusView.State
---@param status_win integer
---@return integer? left
---@return integer? right
local function adopt_diff_wins(s, status_win)
local left = valid_in_current_tab(s.diff_left_win) and s.diff_left_win
or nil
local right = valid_in_current_tab(s.diff_right_win) and s.diff_right_win
or nil
if left and right then
return left, right
end
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if win ~= status_win then
local role = vim.w[win].git_diff_role
if role == "left" and not left then
left = win
elseif role == "right" and not right then
right = win
end
end
end
return left, right
end
---@param row ow.Git.Status.Row
---@return string
local function row_key(row)
local entry = row.entry
local orig
if entry.kind == "changed" then
---@cast entry ow.Git.Status.ChangedEntry
orig = entry.orig
end
return row.section .. "|" .. entry.path .. "|" .. (orig or "")
end
---@param target_win integer
---@param dir "left"|"right"
---@return integer
@@ -329,27 +302,39 @@ local function vsplit_at(target_win, dir)
true,
{ split = dir, win = target_win }
)
vim.api.nvim_win_call(win, function()
vim.cmd("setlocal winfixwidth<")
end)
vim.cmd.clearjumps()
return win
end
---@param s ow.Git.StatusView.State
---@param status_win integer
---@param right_win integer?
---@return integer
local function ensure_right_win(s, status_win, right_win)
if right_win then
return right_win
---@return integer?
local function previous_target_win(status_win)
local n = vim.fn.winnr("#")
if n == 0 then
return nil
end
local win = vim.fn.win_getid(n)
if win == 0 or win == status_win or not valid_in_current_tab(win) then
return nil
end
local cfg = vim.api.nvim_win_get_config(win)
if cfg.relative and cfg.relative ~= "" then
return nil
end
return win
end
---@param status_win integer
---@param keep integer
local function close_other_diff_wins(status_win, keep)
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if win ~= status_win and win ~= keep and vim.wo[win].diff then
pcall(vim.api.nvim_win_close, win, false)
end
local target = invocation_win_for(s)
if target then
right_win = target
else
right_win = vsplit_at(status_win, "right")
vim.api.nvim_win_set_width(status_win, WINDOW_WIDTH)
end
reset_diff_win(right_win)
return right_win
end
---@param s ow.Git.StatusView.State
@@ -374,8 +359,7 @@ local function view_row(s, row, focus_left)
if s.placement ~= "sidebar" then
local pane = right or left
---@cast pane ow.Git.Diff.Side
diff.set_diff(status_win, false)
---@cast pane -nil
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_win_set_buf(status_win, pane.buf)
if pane.name then
@@ -384,68 +368,46 @@ local function view_row(s, row, focus_left)
return
end
local key = row_key(row)
local left_win, right_win = adopt_diff_wins(s, status_win)
local want_pair = left and right
local target = previous_target_win(status_win)
if not target then
target = vsplit_at(status_win, "right")
end
close_other_diff_wins(status_win, target)
vim.api.nvim_win_set_width(status_win, WINDOW_WIDTH)
vim.api.nvim_win_call(target, function()
vim.cmd.diffoff()
end)
if s.last_shown_key == key then
local intact = (want_pair and left_win and right_win)
or (not want_pair and right_win and not left_win)
if intact then
local target = focus_left and (left_win or right_win) or status_win
vim.api.nvim_set_current_win(target)
return
end
end
if not want_pair then
if left_win and vim.api.nvim_win_is_valid(left_win) then
pcall(vim.api.nvim_win_close, left_win, false)
left_win = nil
s.diff_left_win = nil
end
right_win = ensure_right_win(s, status_win, right_win)
s.diff_right_win = right_win
vim.w[right_win].git_diff_role = "right"
local side = left or right
---@cast side ow.Git.Diff.Side
diff.set_diff(right_win, false)
vim.api.nvim_win_set_buf(right_win, side.buf)
if not (left and right) then
local side = right or left
---@cast side ow.Git.StatusView.Pane
vim.api.nvim_win_set_buf(target, side.buf)
if side.name then
util.set_buf_name(side.buf, side.name)
end
s.last_shown_key = key
vim.api.nvim_set_current_win(focus_left and right_win or status_win)
vim.api.nvim_set_current_win(focus_left and target or status_win)
return
end
---@cast left ow.Git.Diff.Side
---@cast right ow.Git.Diff.Side
---@cast left ow.Git.StatusView.Pane
---@cast right ow.Git.StatusView.Pane
if left_win and not right_win then
right_win = vsplit_at(left_win, "right")
reset_diff_win(right_win)
elseif right_win and not left_win then
left_win = vsplit_at(right_win, "left")
reset_diff_win(left_win)
elseif not (left_win or right_win) then
right_win = ensure_right_win(s, status_win, nil)
left_win = vsplit_at(right_win, "left")
reset_diff_win(left_win)
local combined = vim.api.nvim_win_get_width(left_win)
+ vim.api.nvim_win_get_width(right_win)
vim.api.nvim_win_set_width(left_win, math.floor(combined / 2))
vim.api.nvim_win_set_buf(target, right.buf)
if right.name then
util.set_buf_name(right.buf, right.name)
end
local older = left.name or vim.api.nvim_buf_get_name(left.buf)
local left_win
vim.api.nvim_win_call(target, function()
diffsplit.open({
target = older,
mods = { vertical = true },
})
left_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_cursor(left_win, { 1, 0 })
vim.api.nvim_win_set_cursor(target, { 1, 0 })
end)
---@cast left_win -nil
---@cast right_win -nil
vim.w[left_win].git_diff_role = "left"
vim.w[right_win].git_diff_role = "right"
s.diff_left_win = left_win
s.diff_right_win = right_win
diff.update_pair(left_win, right_win, { left = left, right = right })
s.last_shown_key = key
vim.api.nvim_set_current_win(focus_left and left_win or status_win)
end
@@ -599,8 +561,10 @@ local function action_help(placement)
if placement == "sidebar" then
table.insert(lines, " <Tab> preview diff (keep focus)")
table.insert(lines, " <CR> open diff (focus left pane)")
table.insert(lines, " <2-LeftMouse> open diff (focus left pane)")
else
table.insert(lines, " <CR> open file")
table.insert(lines, " <2-LeftMouse> open file")
end
table.insert(lines, " s stage file")
table.insert(lines, " u unstage file")
@@ -640,14 +604,12 @@ end
---@param r ow.Git.Repo
---@param placement ow.Git.StatusView.Placement
---@param win integer?
---@param invocation_win integer?
local function setup_buffer(bufnr, r, placement, win, invocation_win)
local function setup_buffer(bufnr, r, placement, win)
state[bufnr] = {
repo = r,
placement = placement,
lines = {},
win = win,
invocation_win = invocation_win,
}
local function k(lhs, rhs, desc)
@@ -661,6 +623,9 @@ local function setup_buffer(bufnr, r, placement, win, invocation_win)
k("<CR>", function()
preview_or_open(true)
end, "Open")
k("<2-LeftMouse>", function()
preview_or_open(true)
end, "Open")
k("s", action_stage, "Stage file")
k("u", action_unstage, "Unstage file")
k("X", action_discard, "Discard worktree changes")
@@ -671,7 +636,7 @@ local function setup_buffer(bufnr, r, placement, win, invocation_win)
action_help(state[bufnr].placement)
end, "Help")
state[bufnr].unsubscribe = r:on("refresh", function()
state[bufnr].unsubscribe = r:on("change", function()
refresh(bufnr)
end)
vim.api.nvim_create_autocmd("BufEnter", {
@@ -709,7 +674,10 @@ local function set_keymaps(bufnr, placement)
end
end
---@param opts? { placement: ow.Git.StatusView.Placement? }
---@class ow.Git.StatusView.OpenOpts
---@field placement ow.Git.StatusView.Placement?
---@param opts? ow.Git.StatusView.OpenOpts
function M.open(opts)
opts = opts or {}
local placement = opts.placement or "sidebar"
@@ -726,8 +694,9 @@ function M.open(opts)
util.error("not in a git repository")
return
end
local previous_win = vim.api.nvim_get_current_win()
local buf = vim.fn.bufadd(M.URI_PREFIX .. r.worktree)
local buf = vim.fn.bufadd(buf_name_for(r))
local visible = vim.fn.bufwinid(buf)
if visible ~= -1 then
@@ -736,90 +705,36 @@ function M.open(opts)
return
end
local was_loaded = vim.api.nvim_buf_is_loaded(buf)
local win = place(buf, placement)
vim.bo[buf].bufhidden = placement == "sidebar" and "wipe" or "hide"
local s = state[buf]
if s then
s.win = win
s.invocation_win = previous_win
s.placement = placement
if not state[buf] then
vim.fn.bufload(buf)
repo.bind(buf, r)
util.setup_scratch(buf, {})
vim.bo[buf].filetype = "gitstatus"
setup_buffer(buf, r, placement)
end
vim.bo[buf].bufhidden = placement == "sidebar" and "wipe" or "hide"
local win = place(buf, placement)
state[buf].win = win
state[buf].placement = placement
set_keymaps(buf, placement)
if placement == "sidebar" then
vim.api.nvim_set_current_win(previous_win)
end
if was_loaded then
refresh(buf)
end
r:refresh()
end
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local raw = name:sub(#M.URI_PREFIX + 1)
if raw == "" then
return
end
local worktree = vim.fs.abspath(raw)
local r = repo.resolve(worktree)
if not r then
util.error("not a git worktree: %s", raw)
return
end
if r.worktree ~= worktree then
util.warning("%s is not a worktree root, using %s", raw, r.worktree)
end
local canonical = M.URI_PREFIX .. r.worktree
if name ~= canonical then
local existing = find_buf(canonical)
if existing and existing ~= buf then
local win = vim.api.nvim_get_current_win()
if vim.api.nvim_win_get_buf(win) == buf then
vim.api.nvim_win_set_buf(win, existing)
end
vim.api.nvim_buf_delete(buf, { force = true })
local s = state[existing]
if s then
s.win = win
s.placement = "current"
end
refresh(existing)
r:refresh()
return
end
pcall(vim.api.nvim_buf_set_name, buf, canonical)
end
repo.bind(buf, r)
util.setup_scratch(buf, { bufhidden = "hide" })
vim.bo[buf].filetype = "gitstatus"
---@type integer?
local win = vim.fn.bufwinid(buf)
if win == -1 then
win = nil
end
if not state[buf] then
setup_buffer(buf, r, "current", win, nil)
else
state[buf].win = win
end
refresh(buf)
r:refresh()
end
function M.toggle()
---@param opts? ow.Git.StatusView.OpenOpts
function M.toggle(opts)
local existing = find_view()
if existing then
vim.api.nvim_win_close(existing, false)
return
end
M.open({ placement = "sidebar" })
M.open(opts)
end
return M
+1 -1
View File
@@ -64,7 +64,7 @@ local function update_buf(buf, r)
set_status(buf, r, rel)
end
repo.on("refresh", function(r)
repo.on("change", function(r)
local any_visible = false
for buf in pairs(r.buffers) do
if vim.api.nvim_buf_is_loaded(buf) then
+12
View File
@@ -134,6 +134,10 @@
"rev": "ae5199db47757f785e43a14b332118a5474de1a2",
"src": "https://github.com/tree-sitter-grammars/tree-sitter-svelte"
},
"tree-sitter-tumblr": {
"rev": "45938c25e96351adf4140dce42795e61e944904e",
"src": "https://git.owall.dev/warg/tree-sitter-tumblr.git"
},
"tree-sitter-typescript": {
"rev": "75b3874edb2dc714fb1fd77a32013d0f8699989f",
"src": "https://github.com/tree-sitter/tree-sitter-typescript"
@@ -149,6 +153,14 @@
"tree-sitter-zsh": {
"rev": "86b37f8d515a529722411bc7bf3c9e993a4743bf",
"src": "https://github.com/georgeharker/tree-sitter-zsh"
},
"vim-flog": {
"rev": "665b16ac8915f746bc43c9572b4581a5e9047216",
"src": "https://github.com/rbong/vim-flog"
},
"vim-fugitive": {
"rev": "3b753cf8c6a4dcde6edee8827d464ba9b8c4a6f0",
"src": "https://github.com/tpope/vim-fugitive"
}
}
}
+94 -35
View File
@@ -34,17 +34,59 @@ local DEFAULT_HIGHLIGHTS = {
GitUnmergedBothModified = "GitUnmerged",
GitUnmergedDeletedByThem = "GitUnmerged",
GitUnmergedDeletedByUs = "GitUnmerged",
GitHunkAdded = "Added",
GitHunkChanged = "Changed",
GitHunkRemoved = "Removed",
GitHunkAddLine = "DiffAdd",
GitHunkDeleteLine = "DiffDelete",
}
local STAGED_HUNK_HL = {
GitHunkStagedAdded = "GitHunkAdded",
GitHunkStagedChanged = "GitHunkChanged",
GitHunkStagedRemoved = "GitHunkRemoved",
}
local function blend(a, b, t)
local function mix(shift)
local x = bit.band(bit.rshift(a, shift), 0xff)
local y = bit.band(bit.rshift(b, shift), 0xff)
return bit.lshift(math.floor(x + (y - x) * t + 0.5), shift)
end
return mix(16) + mix(8) + mix(0)
end
local function apply_highlights()
for name, link in pairs(DEFAULT_HIGHLIGHTS) do
vim.api.nvim_set_hl(0, name, { link = link, default = true })
end
local bg = vim.api.nvim_get_hl(0, { name = "Normal" }).bg or 0x000000
for name, base in pairs(STAGED_HUNK_HL) do
local src = vim.api.nvim_get_hl(0, { name = base, link = false })
local hl = {}
if src.fg then
hl.fg = blend(src.fg, bg, 0.45)
end
if src.bg then
hl.bg = blend(src.bg, bg, 0.45)
end
vim.api.nvim_set_hl(0, name, hl)
end
end
apply_highlights()
local group = vim.api.nvim_create_augroup("ow.git", { clear = true })
vim.api.nvim_create_autocmd("ColorScheme", {
group = group,
callback = apply_highlights,
})
vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, {
group = group,
callback = function(args)
require("git.core.repo").track(args.buf)
require("git.hunks").attach(args.buf)
end,
})
vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, {
@@ -64,6 +106,7 @@ vim.api.nvim_create_autocmd({ "ShellCmdPost", "TermLeave", "FocusGained" }, {
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
group = group,
callback = function(args)
require("git.hunks").detach(args.buf)
require("git.core.repo").unbind(args.buf)
end,
})
@@ -96,21 +139,6 @@ vim.api.nvim_create_autocmd("BufReadCmd", {
require("git.object").read_uri(args.buf)
end,
})
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "gitlog://*",
group = group,
callback = function(args)
require("git.log_view").read_uri(args.buf)
end,
})
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "gitstatus://*",
group = group,
callback = function(args)
require("git.status_view").read_uri(args.buf)
end,
})
vim.api.nvim_create_user_command("G", function(opts)
local cmd = require("git.cmd")
cmd.run(cmd.parse_args(opts.args), { bang = opts.bang })
@@ -142,22 +170,18 @@ end
local DIFF_DIRECTIONS = { "vertical", "horizontal" }
local function default_vertical()
return vim.tbl_contains(vim.opt.diffopt:get(), "vertical")
end
vim.api.nvim_create_user_command("Gdiffsplit", function(opts)
local fargs = opts.fargs
local vertical = default_vertical()
local mods = nil
local rev_idx = 1
if fargs[1] == "vertical" then
vertical = true
mods = { vertical = true }
rev_idx = 2
elseif fargs[1] == "horizontal" then
vertical = false
mods = { vertical = false }
rev_idx = 2
end
require("git.diff").split({ rev = fargs[rev_idx], vertical = vertical })
require("git.diffsplit").open({ target = fargs[rev_idx], mods = mods })
end, {
nargs = "*",
complete = function(arg_lead, cmd_line, _)
@@ -218,19 +242,28 @@ vim.keymap.set("n", "<Plug>(git-edit)", function()
})
end, { silent = true, desc = "Edit a git object" })
vim.keymap.set("n", "<Plug>(git-diff-vertical)", function()
require("git.diff").split({ vertical = true })
end, { silent = true, desc = "Diff against index (vertical)" })
vim.keymap.set("n", "<Plug>(git-diff-horizontal)", function()
require("git.diff").split({ vertical = false })
end, { silent = true, desc = "Diff against index (horizontal)" })
vim.keymap.set("n", "<Plug>(git-diff-vertical-head)", function()
require("git.diff").split({ rev = "HEAD", vertical = true })
end, { silent = true, desc = "Diff against HEAD (vertical)" })
vim.keymap.set("n", "<Plug>(git-diff-horizontal-head)", function()
require("git.diff").split({ rev = "HEAD", vertical = false })
end, { silent = true, desc = "Diff against HEAD (horizontal)" })
vim.keymap.set("n", "<Plug>(git-diffsplit-vertical)", function()
require("git.diffsplit").open({ mods = { vertical = true } })
end, { silent = true, desc = "Open a diff split against index (vertical)" })
vim.keymap.set("n", "<Plug>(git-diffsplit-horizontal)", function()
require("git.diffsplit").open({ mods = { vertical = false } })
end, { silent = true, desc = "Open a diff split against index (horizontal)" })
vim.keymap.set("n", "<Plug>(git-diffsplit-vertical-head)", function()
require("git.diffsplit").open({
target = "HEAD",
mods = { vertical = true },
})
end, { silent = true, desc = "Open a diff split against HEAD (vertical)" })
vim.keymap.set("n", "<Plug>(git-diffsplit-horizontal-head)", function()
require("git.diffsplit").open({
target = "HEAD",
mods = { vertical = false },
})
end, { silent = true, desc = "Open a diff split against HEAD (horizontal)" })
vim.keymap.set("n", "<Plug>(git-status-open)", function()
require("git.status_view").open()
end, { silent = true, desc = "Open git status sidebar" })
vim.keymap.set("n", "<Plug>(git-status-toggle)", function()
require("git.status_view").toggle()
end, { silent = true, desc = "Toggle git status sidebar" })
@@ -255,3 +288,29 @@ if vim.g.git_statusline ~= false then
end,
})
end
vim.keymap.set({ "n", "x" }, "<Plug>(git-hunk-next)", function()
require("git.hunks").nav("next")
end, { silent = true, desc = "Jump to next git hunk" })
vim.keymap.set({ "n", "x" }, "<Plug>(git-hunk-prev)", function()
require("git.hunks").nav("prev")
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("n", "<Plug>(git-hunk-reset)", function()
require("git.hunks").reset_hunk()
end, { silent = true, desc = "Reset hunk under cursor" })
vim.keymap.set("n", "<Plug>(git-hunk-preview)", function()
require("git.hunks").preview_hunk()
end, { silent = true, desc = "Preview hunk under cursor" })
vim.keymap.set("n", "<Plug>(git-hunk-select)", function()
require("git.hunks").select_hunk()
end, { silent = true, desc = "Select hunk under cursor" })
vim.keymap.set("n", "<Plug>(git-overlay-toggle)", function()
require("git.hunks").toggle_overlay()
end, { silent = true, desc = "Toggle the git diff overlay" })
vim.api.nvim_create_user_command("GitDiffOverlay", function()
require("git.hunks").toggle_overlay()
end, { desc = "Toggle the git diff overlay in the current buffer" })
-48
View File
@@ -1,48 +0,0 @@
require("gitsigns").setup({
preview_config = {
border = "single",
},
on_attach = function(bufnr)
local gs = require("gitsigns")
vim.keymap.set("n", "<leader>gv", gs.select_hunk, { buffer = bufnr })
vim.keymap.set("n", "<leader>gs", gs.stage_hunk, { buffer = bufnr })
vim.keymap.set("x", "<leader>gs", function()
gs.stage_hunk({ vim.fn.line("."), vim.fn.line("v") })
end, { buffer = bufnr })
vim.keymap.set("n", "<leader>gr", gs.reset_hunk, { buffer = bufnr })
vim.keymap.set(
"x",
"<leader>gr",
":Gitsigns reset_hunk<CR>",
{ buffer = bufnr }
)
vim.keymap.set("n", "<leader>g?", gs.preview_hunk, { buffer = bufnr })
vim.keymap.set("n", "<leader>gb", function()
gs.blame_line({ full = true, ignore_whitespace = true })
end, { buffer = bufnr })
vim.keymap.set({ "n", "x" }, "]g", function()
gs.nav_hunk("next", {
wrap = true,
foldopen = true,
navigation_message = true,
greedy = true,
preview = true,
count = 1,
target = "all",
})
end)
vim.keymap.set({ "n", "x" }, "[g", function()
gs.nav_hunk("prev", {
wrap = true,
foldopen = true,
navigation_message = true,
greedy = true,
preview = true,
count = 1,
target = "all",
})
end)
end,
attach_to_untracked = false,
sign_priority = 100,
})
+1 -1
View File
@@ -7,7 +7,7 @@ local Decorator = require("nvim-tree.api").Decorator
local repo = require("git.core.repo")
repo.on("refresh", function()
repo.on("change", function()
require("nvim-tree.api").tree.reload()
end)
+1 -3
View File
@@ -45,10 +45,8 @@ local highlights = {
TabLineFill = { bg = c.bg1 },
EndOfBuffer = { fg = "NONE", bg = "NONE" },
DiffAdd = { bg = "#1a2f22" },
DiffChange = { bg = "#15304a" },
DiffDelete = { bg = "#311c1e" },
Changed = { fg = c.yellow },
NvimTreeIndentMarker = { fg = c.bg3 },
GitUnstaged = { fg = c.yellow },
}
for kind, color in pairs(completion_kind_colors) do
highlights["LspKind" .. kind] = { fg = color }
-44
View File
@@ -1,44 +0,0 @@
if exists("b:current_syntax")
finish
endif
syntax match gitStatusLabel /\v^(Head|Push)\ze:/
syntax match gitStatusBranch /\v(^(Head|Push):\s+)@<=\S+/
syntax match gitStatusAhead /\v\+\d+/
syntax match gitStatusBehind /\v-\d+/
syntax region gitStatusUntrackedHeader start=/\v^Untracked>/ end=/\v^$/
syntax region gitStatusUnstagedHeader start=/\v^Unstaged>/ end=/\v^$/
syntax region gitStatusStagedHeader start=/\v^Staged>/ end=/\v^$/
syntax region gitStatusUnmergedHeader start=/\v^Unmerged>/ end=/\v^$/
syntax region gitStatusUnpushedHeader start=/\v^Unpushed>/ end=/\v^$/
syntax region gitStatusUnpulledHeader start=/\v^Unpulled>/ end=/\v^$/
syntax match gitStatusUntrackedLabel /\v^Untracked/ contained containedin=gitStatusUntrackedHeader
syntax match gitStatusUnstagedLabel /\v^Unstaged/ contained containedin=gitStatusUnstagedHeader
syntax match gitStatusStagedLabel /\v^Staged/ contained containedin=gitStatusStagedHeader
syntax match gitStatusUnmergedLabel /\v^Unmerged/ contained containedin=gitStatusUnmergedHeader
syntax match gitStatusUnpushedLabel /\v^Unpushed/ contained containedin=gitStatusUnpushedHeader
syntax match gitStatusUnpulledLabel /\v^Unpulled/ contained containedin=gitStatusUnpulledHeader
syntax match gitStatusHeaderCount /\v\(\zs\d+\ze\)/ contained containedin=gitStatusUntrackedHeader,
\ gitStatusUnstagedHeader,
\ gitStatusStagedHeader,
\ gitStatusUnmergedHeader,
\ gitStatusUnpushedHeader,
\ gitStatusUnpulledHeader
highlight default link gitStatusLabel Label
highlight default link gitStatusBranch None
highlight default link gitStatusAhead GitUnpushed
highlight default link gitStatusBehind GitUnpulled
highlight default link gitStatusHeaderCount Number
highlight default link gitStatusUntrackedLabel gitStatusLabel
highlight default link gitStatusUnstagedLabel gitStatusLabel
highlight default link gitStatusStagedLabel gitStatusLabel
highlight default link gitStatusUnmergedLabel gitStatusLabel
highlight default link gitStatusUnpushedLabel gitStatusLabel
highlight default link gitStatusUnpulledLabel gitStatusLabel
let b:current_syntax = "gitStatus"
+2 -2
View File
@@ -338,10 +338,10 @@ t.test("<leader>gl log buffer refills after jumping back", function()
h.git(dir, "commit", "-q", "-m", "second")
require("git.log_view").open({ max_count = 1000 })
wait_buf_populated("^gitlog://")
wait_buf_populated("/GitLog$")
local log_buf = vim.api.nvim_get_current_buf()
local log_win = vim.api.nvim_get_current_win()
t.truthy(vim.api.nvim_buf_get_name(log_buf):match("^gitlog://"))
t.truthy(vim.api.nvim_buf_get_name(log_buf):match("/GitLog$"))
local initial_lines = #vim.api.nvim_buf_get_lines(log_buf, 0, -1, false)
t.truthy(initial_lines >= 2)
+634
View File
@@ -0,0 +1,634 @@
local h = require("test.git.helpers")
local hunks = require("git.hunks")
local t = require("test")
---@param committed string
---@param worktree string
---@param file string?
---@return string dir
---@return integer buf
---@return ow.Git.Hunks.BufState state
local function setup(committed, worktree, file)
file = file or "a.txt"
local dir = h.make_repo({ [file] = committed })
t.write(dir, file, worktree)
vim.cmd.edit(dir .. "/" .. file)
local buf = vim.api.nvim_get_current_buf()
hunks.attach(buf)
hunks._flush(buf)
t.wait_for(function()
local s = hunks.state(buf)
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
---@param buf integer
---@return { row: integer, sign: string, hl: string }[]
local function sign_marks(buf)
local ns = vim.api.nvim_get_namespaces()["ow.git.hunks"]
local out = {}
for _, m in ipairs(vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, {
details = true,
})) do
local d = assert(m[4])
table.insert(out, {
row = m[2],
sign = vim.trim(d.sign_text or ""),
hl = d.sign_hl_group,
})
end
table.sort(out, function(a, b)
return a.row < b.row
end)
return out
end
---@param buf integer
---@param ns_name string
---@return vim.api.keyset.get_extmark_item[]
local function detailed_marks(buf, ns_name)
local ns = vim.api.nvim_get_namespaces()[ns_name]
return vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true })
end
---@return integer?
local function find_float()
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.api.nvim_win_get_config(w).relative ~= "" then
return w
end
end
end
t.test("pure add: hunk shape and add signs", function()
local _, buf, state = setup("a\nd\n", "a\nb\nc\nd\n")
t.eq(#state.hunks, 1, "one hunk for a pure addition")
local hk = assert(state.hunks[1])
t.eq(hk.type, "add")
t.eq(hk.new_start, 2)
t.eq(hk.new_count, 2)
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkAdded" },
{ row = 2, sign = "", hl = "GitHunkAdded" },
})
end)
t.test("pure delete (middle): hunk shape and delete sign", function()
local _, buf, state = setup("a\nb\nc\n", "a\nc\n")
t.eq(#state.hunks, 1)
local hk = assert(state.hunks[1])
t.eq(hk.type, "delete")
t.eq(hk.new_count, 0)
t.eq(hk.old_lines, { "b" })
t.eq(sign_marks(buf), {
{ row = 0, sign = "", hl = "GitHunkRemoved" },
})
end)
t.test("top-of-file delete: sign anchors on line 1", function()
local _, buf, state = setup("a\nb\nc\n", "b\nc\n")
t.eq(#state.hunks, 1)
local hk = assert(state.hunks[1])
t.eq(hk.type, "delete")
t.eq(hk.new_start, 0)
t.eq(hk.old_lines, { "a" })
t.eq(sign_marks(buf), {
{ row = 0, sign = "", hl = "GitHunkRemoved" },
})
end)
t.test("change of N lines: hunk shape and change signs", function()
local _, buf, state = setup("a\nb\nc\nd\n", "a\nB\nC\nd\n")
t.eq(#state.hunks, 1)
local hk = assert(state.hunks[1])
t.eq(hk.type, "change")
t.eq(hk.old_start, 2)
t.eq(hk.old_count, 2)
t.eq(hk.new_start, 2)
t.eq(hk.new_count, 2)
t.eq(hk.old_lines, { "b", "c" })
t.eq(hk.new_lines, { "B", "C" })
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkChanged" },
{ row = 2, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("multi-hunk file: two separate change hunks", function()
local _, buf, state = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\nE\n")
t.eq(#state.hunks, 2, "two hunks for two disjoint changes")
t.eq(sign_marks(buf), {
{ row = 0, sign = "", hl = "GitHunkChanged" },
{ row = 4, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("clean file produces no hunks or signs", function()
local _, buf, state = setup("a\nb\nc\n", "a\nb\nc\n")
t.eq(#state.hunks, 0)
t.eq(sign_marks(buf), {})
end)
t.test("editing the buffer refreshes signs", function()
local _, buf, state = setup("a\nb\nc\n", "a\nb\nc\n")
t.eq(#state.hunks, 0)
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "CHANGED" })
vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf })
hunks._flush(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 1
end, "hunks to pick up the in-buffer edit")
local hk = assert(assert(hunks.state(buf)).hunks[1])
t.eq(hk.type, "change")
end)
t.test("overlay: change hunk shows deletion and addition", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
hunks.toggle_overlay(buf)
---@type integer?
local add_row
---@type vim.api.keyset.extmark_details?
local add_d
---@type vim.api.keyset.extmark_details?
local virt_d
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
local d = assert(m[4])
if d.line_hl_group then
add_row, add_d = m[2], d
elseif d.virt_lines then
virt_d = d
end
end
add_d = assert(add_d, "the added line should get a line highlight")
t.eq(add_row, 1, "addition highlighted on the changed line")
t.eq(add_d.line_hl_group, "GitHunkAddLine")
virt_d = assert(virt_d, "the deletion should render as virtual lines")
local piece = assert(assert(assert(virt_d.virt_lines)[1])[1])
t.truthy(vim.startswith(piece[1], "b"), "deleted line shows the old content")
t.eq(piece[2], "GitHunkDeleteLine")
end)
t.test("overlay: delete hunk shows only deletion lines", function()
local _, buf = setup("a\nb\nc\n", "a\nc\n")
hunks.toggle_overlay(buf)
local marks = detailed_marks(buf, "ow.git.hunks.overlay")
t.eq(#marks, 1, "a pure delete has no addition highlight")
local d = assert(assert(marks[1])[4])
local piece = assert(assert(assert(d.virt_lines)[1])[1])
t.truthy(vim.startswith(piece[1], "b"))
t.eq(piece[2], "GitHunkDeleteLine")
end)
t.test("overlay: add hunk highlights the added lines", function()
local _, buf = setup("a\nd\n", "a\nb\nc\nd\n")
hunks.toggle_overlay(buf)
local rows = {}
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
local d = assert(m[4])
t.falsy(d.virt_lines, "a pure add has no deletion virtual lines")
t.eq(d.line_hl_group, "GitHunkAddLine")
table.insert(rows, m[2])
end
table.sort(rows)
t.eq(rows, { 1, 2 }, "both added lines highlighted")
end)
t.test("overlay: deleted lines are treesitter-highlighted", function()
local _, buf = setup(
"-- a note\nlocal x = 1\nlocal y = 2\n",
"local y = 2\n",
"a.lua"
)
t.truthy(
pcall(vim.treesitter.start, buf, "lua"),
"the lua parser should be available"
)
hunks.toggle_overlay(buf)
---@type table[]?
local virt
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
local d = assert(m[4])
if d.virt_lines then
virt = d.virt_lines
end
end
virt = assert(virt, "a deletion virtual line should render")
---@type table<string, boolean>
local seen = {}
for _, line in ipairs(virt) do
for _, c in ipairs(line) do
local hl = c[2]
if
type(hl) == "table"
and hl[1] == "GitHunkDeleteLine"
and hl[2]
then
seen[hl[2]] = true
end
end
end
t.truthy(seen["@comment"], "the deleted comment keeps its @comment group")
t.truthy(seen["@keyword"], "deleted code keeps its syntax groups")
end)
t.test("overlay: toggling swaps gutter signs for the overlay", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
t.truthy(
#detailed_marks(buf, "ow.git.hunks") > 0,
"gutter signs present while the overlay is off"
)
t.eq(#detailed_marks(buf, "ow.git.hunks.overlay"), 0)
hunks.toggle_overlay(buf)
t.truthy(
#detailed_marks(buf, "ow.git.hunks.overlay") > 0,
"overlay present once it is on"
)
t.eq(
#detailed_marks(buf, "ow.git.hunks"),
0,
"gutter signs replaced while the overlay is on"
)
hunks.toggle_overlay(buf)
t.eq(#detailed_marks(buf, "ow.git.hunks.overlay"), 0)
t.truthy(
#detailed_marks(buf, "ow.git.hunks") > 0,
"gutter signs restored after toggling the overlay off"
)
end)
t.test("toggle_stage stages the change into the index", function()
local dir, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "stage to land in the index")
t.eq(h.git(dir, "diff", "--cached", "--name-only").stdout, "a.txt")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"a\nB\nc",
"index blob reflects the staged change"
)
end)
t.test("toggle_stage stages a pure addition", function()
local dir, buf = setup("a\nb\n", "a\nb\nc\n")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "stage to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nb\nc")
end)
t.test("toggle_stage stages a deletion", function()
local dir, buf = setup("a\nb\nc\n", "a\nc\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "stage to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nc")
end)
t.test("toggle_stage 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.toggle_stage(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("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 })
hunks.toggle_stage(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("toggle_stage 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.toggle_stage(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("toggle_stage 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.toggle_stage(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("toggle_stage 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.toggle_stage(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("toggle_stage 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.toggle_stage(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("toggle_stage 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.toggle_stage(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.toggle_stage(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.toggle_stage(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("toggle_stage 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)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 2
end, "gutter to drop the first staged hunk")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 1
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.toggle_stage(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.toggle_stage(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("toggle_stage 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.toggle_stage(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.toggle_stage(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("toggle_stage 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.toggle_stage(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.toggle_stage(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 })
hunks.reset_hunk(buf)
t.eq(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
state.index,
"buffer matches the index after reset"
)
end)
t.test("reset_hunk re-inserts deleted lines", function()
local _, buf = setup("a\nb\nc\n", "a\nc\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.reset_hunk(buf)
t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "a", "b", "c" })
end)
t.test("reset_hunk removes a pure addition", function()
local _, buf = setup("a\nd\n", "a\nb\nc\nd\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.reset_hunk(buf)
t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "a", "d" })
end)
t.test("git_hunk_signs overrides the sign character per kind", function()
local prev = vim.g.git_hunk_signs
vim.g.git_hunk_signs = { change = "C" }
t.defer(function()
vim.g.git_hunk_signs = prev
end)
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
t.eq(sign_marks(buf), {
{ row = 1, sign = "C", hl = "GitHunkChanged" },
})
end)
t.test("git_hunk_signs falls back to the default for unset kinds", function()
local prev = vim.g.git_hunk_signs
vim.g.git_hunk_signs = { add = "A" }
t.defer(function()
vim.g.git_hunk_signs = prev
end)
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("preview_hunk shows the hunk body without file headers", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.preview_hunk(buf)
local float = assert(find_float(), "preview float should open")
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
local lines = vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
t.truthy(
vim.startswith(lines[1] or "", "@@ "),
"first line is the @@ header"
)
for _, l in ipairs(lines) do
t.falsy(vim.startswith(l, "--- "), "no --- file header line")
t.falsy(vim.startswith(l, "+++ "), "no +++ file header line")
end
end)
t.test("preview_hunk re-invocation focuses the open float", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.preview_hunk(buf)
local float = assert(find_float(), "preview float should open")
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
t.truthy(
vim.api.nvim_get_current_win() ~= float,
"the float opens unfocused"
)
hunks.preview_hunk(buf)
t.eq(
vim.api.nvim_get_current_win(),
float,
"re-invoking focuses the existing float"
)
end)
t.test("nav jumps to next and previous hunks with wrap", function()
local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\nE\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.nav("next")
t.eq(vim.api.nvim_win_get_cursor(0)[1], 5, "next hunk is line 5")
hunks.nav("next")
t.eq(vim.api.nvim_win_get_cursor(0)[1], 1, "next wraps back to line 1")
hunks.nav("prev")
t.eq(vim.api.nvim_win_get_cursor(0)[1], 5, "prev wraps back to line 5")
end)
+17 -1
View File
@@ -51,7 +51,6 @@ end)
t.test("parse_uri returns nil for non-git URIs", function()
t.falsy(object.parse_uri("file:///tmp/x"))
t.falsy(object.parse_uri("/tmp/x"))
t.falsy(object.parse_uri("gitlog:///tmp/x"))
end)
t.test("M.open(HEAD) names buffer with full sha", function()
@@ -87,6 +86,23 @@ t.test("M.open(HEAD:<path>) loads file content at HEAD", function()
)
end)
t.test("M.open on a merge commit diffs against the first parent only", function()
local dir = h.make_repo({ ["a.txt"] = "one\n" })
t.write(dir, "a.txt", "two\n")
h.git(dir, "stash")
local stash = h.git(dir, "rev-parse", "stash@{0}").stdout
local r = assert(require("git.core.repo").resolve(dir))
object.open(r, stash, { split = false })
local count = 0
for _, l in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do
if l:match("^diff %-%-git ") then
count = count + 1
end
end
t.eq(count, 1, "the stashed file's diff appears once, not per-parent")
end)
t.test("M.open errors on a bogus base, no buffer is opened", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
+47 -2
View File
@@ -71,6 +71,51 @@ t.test("get_cached memoizes by key", function()
t.truthy(v1 == v2, "second call should return cached table")
end)
t.test("index_sha returns the blob sha and caches it", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = r:index_sha("a")
t.truthy(sha and #sha > 0, "index_sha returns the stage-0 blob sha")
t.truthy(r._cache["index:a"] ~= nil, "the result is cached")
t.eq(r:index_sha("a"), sha, "a cached call returns the same sha")
end)
t.test("index_sha caches a negative result for an untracked path", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
t.eq(r:index_sha("nope"), nil, "an untracked path has no index sha")
t.eq(r._cache["index:nope"], false, "the negative result is cached")
end)
t.test("index_sha cache clears when the index is written", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
r:index_sha("a")
t.truthy(r._cache["index:a"] ~= nil, "sha is cached before the stage")
t.write(dir, "a", "y\n")
h.git(dir, "add", "a")
wait_cleared(r, "index:a", 2000)
end)
t.test("head_sha returns the blob sha and caches it", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = r:head_sha("a")
t.truthy(sha and #sha > 0, "head_sha returns the HEAD blob sha")
t.truthy(r._cache["head_blob:a"] ~= nil, "the result is cached")
t.eq(r:head_sha("a"), sha, "a cached call returns the same sha")
end)
t.test("head_sha cache clears when HEAD moves", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
r:head_sha("a")
t.truthy(r._cache["head_blob:a"] ~= nil, "sha is cached before the commit")
t.write(dir, "a", "y\n")
h.git(dir, "commit", "-aqm", "change")
wait_cleared(r, "head_blob:a", 2000)
end)
t.test("cache clears after top-level .git change (commit)", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
@@ -233,7 +278,7 @@ t.test("refresh emits change.paths listing structurally-changed paths", function
t.write(dir, "a", "2")
---@type ow.Git.Repo.Change?
local change_seen
local unsub = r:on("refresh", function(change)
local unsub = r:on("change", function(change)
change_seen = change
end)
r:refresh()
@@ -263,7 +308,7 @@ t.test("submodule: eagerly creates child Repos and subscribes by default", funct
t.write(outer_path .. "/sub", "a", "modified\n")
---@type ow.Git.Repo.Change?
local outer_change
local unsub = outer:on("refresh", function(change)
local unsub = outer:on("change", function(change)
outer_change = change
end)
inner:refresh()
+266 -4
View File
@@ -39,16 +39,28 @@ local function find_sidebar()
end
end
---Find a diff-role window in the given tabpage (or current).
---Find a diff window in the given tabpage (or current). "left" / "right"
---is determined by column position: the layout is [sidebar | left | right],
---so the leftmost &diff window is the left pane and the rightmost is the
---right pane.
---@param role "left"|"right"
---@param tab integer?
---@return integer?
local function find_diff_win(role, tab)
local diffs = {}
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(tab or 0)) do
if vim.w[w].git_diff_role == role then
return w
if vim.wo[w].diff then
table.insert(diffs, w)
end
end
table.sort(diffs, function(a, b)
return vim.api.nvim_win_get_position(a)[2]
< vim.api.nvim_win_get_position(b)[2]
end)
if role == "left" then
return diffs[1]
end
return diffs[#diffs]
end
---@param file_path string
@@ -161,7 +173,6 @@ t.test(
setup_sidebar_with_unstaged_file("foo.txt", "v1\n", "v2\n")
local tab1 = vim.api.nvim_get_current_tabpage()
-- First show diff in tab1, so state.diff_*_win point at tab1.
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
@@ -223,3 +234,254 @@ t.test("refresh on stage updates the index URI buffer's content", function()
"index pane should reflect staged content after refresh"
)
end)
t.test(
"re-selecting same entry after close + diffsplit keeps fold state in sync",
function()
local committed, worktree = {}, {}
for i = 1, 30 do
committed[i] = "line " .. i
worktree[i] = i == 15 and "CHANGED" or ("line " .. i)
end
local sidebar_win, line = setup_sidebar_with_unstaged_file(
"foo.txt",
table.concat(committed, "\n") .. "\n",
table.concat(worktree, "\n") .. "\n"
)
local prev_foldlevel = vim.o.foldlevel
vim.o.foldlevel = 99
t.defer(function()
vim.o.foldlevel = prev_foldlevel
end)
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
and find_diff_win("right") ~= nil
end, "first diff pair to appear")
local first_left = assert(find_diff_win("left"))
vim.api.nvim_win_close(first_left, false)
local remaining
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if w ~= sidebar_win then
remaining = w
break
end
end
if not remaining then
error("a non-sidebar window should remain after close")
end
vim.api.nvim_set_current_win(remaining)
require("git.diffsplit").open({ mods = { vertical = true } })
t.wait_for(function()
local count = 0
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.wo[w].diff then
count = count + 1
end
end
return count == 2
end, "diffsplit to produce a diff pair")
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
local count = 0
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.wo[w].diff then
count = count + 1
end
end
return count == 2
end, "diff pair after re-selecting entry")
local left_win = assert(find_diff_win("left"))
local right_win = assert(find_diff_win("right"))
t.eq(
vim.wo[left_win].foldlevel,
0,
"left pane foldlevel should be 0 after re-select"
)
t.eq(
vim.wo[right_win].foldlevel,
0,
"right pane foldlevel should be 0 after re-select"
)
end
)
t.test("sidebar buffer is named <worktree>/GitStatus", function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
local buf = find_sidebar()
assert(buf, "sidebar buffer should exist")
t.eq(
vim.api.nvim_buf_get_name(buf),
r.worktree .. "/GitStatus",
"buffer name should be <worktree>/GitStatus"
)
end)
t.test(
"calling open twice without closing focuses the existing sidebar",
function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local first = find_sidebar()
assert(first, "first sidebar buffer should exist")
require("git.status_view").open({ placement = "sidebar" })
local second = find_sidebar()
assert(second, "second sidebar buffer should exist")
t.eq(
first,
second,
"consecutive opens should reuse the visible sidebar"
)
local count = 0
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.bo[b].filetype == "gitstatus" then
count = count + 1
end
end
t.eq(count, 1, "only one gitstatus buffer should exist")
end
)
t.test("opening for different worktrees creates separate buffers", function()
local repo_a = h.make_repo({ ["a.txt"] = "x\n" })
local repo_b = h.make_repo({ ["b.txt"] = "y\n" })
vim.cmd("cd " .. repo_a)
require("git.status_view").open({ placement = "sidebar" })
local buf_a = find_sidebar()
require("git.status_view").toggle()
vim.cmd("cd " .. repo_b)
require("git.status_view").open({ placement = "sidebar" })
local buf_b = find_sidebar()
assert(buf_a and buf_b)
t.truthy(
buf_a ~= buf_b,
"different worktrees should produce different buffers"
)
end)
t.test("sidebar buffer is buftype=nofile and not buflisted", function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local buf = find_sidebar()
assert(buf, "sidebar buffer should exist")
t.eq(vim.bo[buf].buftype, "nofile", "buftype should be nofile")
t.eq(vim.bo[buf].buflisted, false, "buflisted should be false")
end)
t.test("sidebar buffer name does not get written to disk", function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local buf = find_sidebar()
assert(buf, "sidebar buffer should exist")
local name = vim.api.nvim_buf_get_name(buf)
vim.api.nvim_buf_call(buf, function()
pcall(function()
vim.cmd("silent! write")
end)
end)
t.eq(
vim.uv.fs_stat(name),
nil,
"no real file should be created at the sidebar buffer's path"
)
end)
t.test(
"diffsplit from sidebar resets cursor so panes stay in sync",
function()
local committed, worktree = {}, {}
for i = 1, 100 do
committed[i] = "line " .. i
worktree[i] = i == 10
and "CHANGED " .. i
or i == 40 and "CHANGED " .. i
or i == 70 and "CHANGED " .. i
or i == 90 and "CHANGED " .. i
or ("line " .. i)
end
local repo = h.make_repo({
["file.txt"] = table.concat(committed, "\n") .. "\n",
})
t.write(repo, "file.txt", table.concat(worktree, "\n") .. "\n")
vim.cmd("cd " .. repo)
-- Open the worktree file in a normal window and position cursor in
-- what becomes a folded section after diff is set up.
vim.cmd("edit file.txt")
vim.api.nvim_win_set_cursor(0, { 50, 0 })
require("git.status_view").open({ placement = "sidebar" })
local sidebar_buf, sidebar_win = find_sidebar()
assert(sidebar_buf and sidebar_win)
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
r:refresh()
t.wait_for(function()
return r.status and #r.status:rows("unstaged") > 0
end, "git status to report unstaged changes")
local entry_line
for i, l in
ipairs(vim.api.nvim_buf_get_lines(sidebar_buf, 0, -1, false))
do
if l:match("file.txt$") then
entry_line = i
break
end
end
if not entry_line then
error("entry line should exist")
end
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { entry_line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
and find_diff_win("right") ~= nil
end, "diff pair to appear")
local left_win = assert(find_diff_win("left"))
local right_win = assert(find_diff_win("right"))
local left_top =
vim.api.nvim_win_call(left_win, function() return vim.fn.line("w0") end)
local right_top = vim.api.nvim_win_call(
right_win,
function() return vim.fn.line("w0") end
)
t.eq(
left_top,
right_top,
"left and right panes should have the same topline after diffsplit"
)
t.eq(
vim.api.nvim_win_get_cursor(left_win),
{ 1, 0 },
"left pane should start at line 1"
)
t.eq(
vim.api.nvim_win_get_cursor(right_win),
{ 1, 0 },
"right pane should start at line 1"
)
end
)