Compare commits

..

15 Commits

33 changed files with 158 additions and 10122 deletions
+1
View File
@@ -18,6 +18,7 @@
"~/.local/share/nvim/site/pack/core/opt/mason-auto-install.nvim", "~/.local/share/nvim/site/pack/core/opt/mason-auto-install.nvim",
"~/.local/share/nvim/site/pack/core/opt/nvim-dap", "~/.local/share/nvim/site/pack/core/opt/nvim-dap",
"~/.local/share/nvim/site/pack/core/opt/Comment.nvim", "~/.local/share/nvim/site/pack/core/opt/Comment.nvim",
"~/.local/share/nvim/site/pack/core/opt/git.nvim",
"~/.local/share/nvim/site/pack/core/opt/gitsigns.nvim", "~/.local/share/nvim/site/pack/core/opt/gitsigns.nvim",
"~/.local/share/nvim/site/pack/core/opt/grug-far.nvim", "~/.local/share/nvim/site/pack/core/opt/grug-far.nvim",
"~/.local/share/nvim/site/pack/core/opt/nvim-tree.lua", "~/.local/share/nvim/site/pack/core/opt/nvim-tree.lua",
+5 -4
View File
@@ -1,11 +1,12 @@
.PHONY: all check lint test .PHONY: all check lint test format
all: check check: format lint test
check: lint test
test: test:
@scripts/test @scripts/test
lint: lint:
@scripts/lint @scripts/lint
format:
@stylua .
+1 -1
View File
@@ -2,7 +2,7 @@ My Neovim config.
## System Requirements ## System Requirements
Only supports Linux, macOS and other BSD variants. Requires neovim v0.12 and git v2.25 or newer (the git module uses `git restore`, which stabilized in 2.25). Only supports Linux, macOS and other BSD variants. Requires neovim v0.12.
## License ## License
+1
View File
@@ -34,6 +34,7 @@ require("pack").setup({
"https://github.com/nvim-tree/nvim-tree.lua", "https://github.com/nvim-tree/nvim-tree.lua",
"https://github.com/stevearc/oil.nvim", "https://github.com/stevearc/oil.nvim",
"https://github.com/hedyhli/outline.nvim", "https://github.com/hedyhli/outline.nvim",
"https://git.owall.se/warg/git.nvim",
"nvim.undotree", "nvim.undotree",
{ {
src = "https://github.com/saghen/blink.cmp", src = "https://github.com/saghen/blink.cmp",
+2 -2
View File
@@ -235,11 +235,11 @@ vim.keymap.set("n", "<leader>gc", "<Plug>(git-commit)")
vim.keymap.set("n", "<leader>ga", "<Plug>(git-commit-amend)") vim.keymap.set("n", "<leader>ga", "<Plug>(git-commit-amend)")
vim.keymap.set("n", "<leader>gl", "<Plug>(git-log)") vim.keymap.set("n", "<leader>gl", "<Plug>(git-log)")
vim.keymap.set("n", "<leader>gb", "<Plug>(git-blame-popup)") vim.keymap.set("n", "<leader>gb", "<Plug>(git-blame-popup)")
vim.keymap.set("n", "<leader>gB", "<Plug>(git-blame)") vim.keymap.set("n", "<leader>gB", "<Plug>(git-blame-view)")
vim.keymap.set("n", "<leader>gv", "<Plug>(git-hunk-select)") 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>gs", "<Plug>(git-hunk-stage-toggle)")
vim.keymap.set("n", "<leader>gr", "<Plug>(git-hunk-reset)") 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", "<C-w>g", "<Plug>(git-hunk-preview)")
vim.keymap.set("n", "<leader>go", "<Plug>(git-overlay-toggle)") vim.keymap.set("n", "<leader>go", "<Plug>(git-hunk-overlay-toggle)")
vim.keymap.set({ "n", "x" }, "]g", "<Plug>(git-hunk-next)") vim.keymap.set({ "n", "x" }, "]g", "<Plug>(git-hunk-next)")
vim.keymap.set({ "n", "x" }, "[g", "<Plug>(git-hunk-prev)") vim.keymap.set({ "n", "x" }, "[g", "<Plug>(git-hunk-prev)")
-953
View File
@@ -1,953 +0,0 @@
local object = require("git.object")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
local NS_INLINE = vim.api.nvim_create_namespace("ow.git.blame.inline")
local NS_POPUP = vim.api.nvim_create_namespace("ow.git.blame.popup")
local ZERO_SHA = string.rep("0", 40)
local BLAME_EXPR = "%{%v:lua.require('git.blame').statuscolumn()%}"
-- Neovim collapses the gutter once the statuscolumn passes 47 cells.
local STATUSCOLUMN_MAX = 47
local GAP = " "
local SHA_WIDTH = 8
local AUTHOR_MAX = 16
local DATE_WIDTH = 10
local PREFERRED_WIDTH = SHA_WIDTH + AUTHOR_MAX + DATE_WIDTH + 3 * #GAP
---@class ow.Git.Blame.Commit
---@field sha string
---@field author string
---@field author_mail string
---@field author_time integer
---@field author_tz string
---@field summary string
---@class ow.Git.Blame.Result
---@field commits table<string, ow.Git.Blame.Commit>
---@field line_sha table<integer, string>
---@class ow.Git.Blame.Source
---@field repo ow.Git.Repo
---@field rel string
---@field revision string?
---@class ow.Git.Blame.BufState
---@field repo ow.Git.Repo
---@field rel string
---@field revision string? nil = working tree, else the blamed revision
---@field commits table<string, ow.Git.Blame.Commit>
---@field line_sha table<integer, string>
---@field blame_text table<integer, string>? cached overlay gutter text
---@field blame_width integer? display width of each cached segment
---@field blame_blank string? a blank segment of that width
---@field tick integer?
---@field epoch integer
---@field pending fun()[]
---@field inline boolean
---@field overlay boolean
---@field autocmds integer[]
---@type table<integer, ow.Git.Blame.BufState>
local states = {}
---@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
---@param buf integer
---@return ow.Git.Blame.BufState?
function M.state(buf)
return states[buf]
end
---@param n integer
---@param unit string
---@return string
local function plural(n, unit)
return string.format("%d %s%s ago", n, unit, n == 1 and "" or "s")
end
---@param unix_ts integer
---@return string
local function relative_time(unix_ts)
local diff = os.time() - unix_ts
if diff < 0 then
diff = 0
end
if diff < 45 then
return "just now"
elseif diff < 90 then
return "a minute ago"
elseif diff < 45 * 60 then
return plural(math.floor(diff / 60 + 0.5), "minute")
elseif diff < 90 * 60 then
return "an hour ago"
elseif diff < 22 * 3600 then
return plural(math.floor(diff / 3600 + 0.5), "hour")
elseif diff < 36 * 3600 then
return "a day ago"
elseif diff < 7 * 86400 then
return plural(math.floor(diff / 86400 + 0.5), "day")
elseif diff < 30 * 86400 then
return plural(math.floor(diff / (7 * 86400) + 0.5), "week")
elseif diff < 365 * 86400 then
return plural(math.floor(diff / (30 * 86400) + 0.5), "month")
end
return plural(math.floor(diff / (365 * 86400) + 0.5), "year")
end
M.relative_time = relative_time
---@param ts integer
---@return string
local function format_date(ts)
return os.date("%Y-%m-%d", ts) --[[@as string]]
end
---@param s string
---@param width integer
---@return string
local function pad(s, width)
local w = vim.api.nvim_strwidth(s)
if w > width then
return vim.fn.strcharpart(s, 0, width)
end
return s .. string.rep(" ", width - w)
end
---@param stdout string
---@return ow.Git.Blame.Result
local function parse_porcelain(stdout)
---@type table<string, ow.Git.Blame.Commit>
local commits = {}
---@type table<integer, string>
local line_sha = {}
local cur_sha ---@type string?
local cur_lnum ---@type integer?
for _, line in ipairs(util.split_lines(stdout)) do
if line:sub(1, 1) == "\t" then
if cur_sha and cur_lnum then
line_sha[cur_lnum] = cur_sha
end
cur_sha = nil
cur_lnum = nil
else
local sha, final = line:match("^(%x+) %d+ (%d+)")
if sha and #sha >= 40 then
cur_sha = sha
cur_lnum = tonumber(final) --[[@as integer?]]
if not commits[sha] then
commits[sha] = {
sha = sha,
author = "",
author_mail = "",
author_time = 0,
author_tz = "",
summary = "",
}
end
else
local key, value = line:match("^(%S+) (.*)$")
local commit = cur_sha and commits[cur_sha]
if commit and key then
if key == "author" then
commit.author = value
elseif key == "author-mail" then
commit.author_mail = value
elseif key == "author-time" then
commit.author_time = math.floor(tonumber(value) or 0)
elseif key == "author-tz" then
commit.author_tz = value
elseif key == "summary" then
commit.summary = value
end
end
end
end
end
return { commits = commits, line_sha = line_sha }
end
---@param line_count integer
---@return ow.Git.Blame.Result
local function synth_uncommitted(line_count)
---@type table<integer, string>
local line_sha = {}
for i = 1, line_count do
line_sha[i] = ZERO_SHA
end
return {
commits = {
[ZERO_SHA] = {
sha = ZERO_SHA,
author = "Not Committed Yet",
author_mail = "",
author_time = os.time() --[[@as integer]],
author_tz = "",
summary = "",
},
},
line_sha = line_sha,
}
end
---@param r ow.Git.Repo
---@param rel string
---@param opts { rev: string?, contents: string? }
---@param done fun(result: ow.Git.Blame.Result?)
local function fetch_blame(r, rel, opts, done)
local args = { "--no-pager", "blame", "--porcelain" }
if opts.contents then
table.insert(args, "--contents")
table.insert(args, "-")
end
if opts.rev then
table.insert(args, opts.rev)
end
table.insert(args, "--")
table.insert(args, rel)
util.git(args, {
cwd = r.worktree,
stdin = opts.contents,
silent = true,
on_exit = function(res)
if res.code ~= 0 then
done(nil)
else
done(parse_porcelain(res.stdout or ""))
end
end,
})
end
---Work out what a buffer should be blamed against: a worktree file
---(blame the buffer contents) or a `git://<rev>:<path>` object (blame
---that revision). A `git://<rev>` object with no path is not blameable.
---@param buf integer
---@return ow.Git.Blame.Source?
local function resolve_source(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return nil
end
local name = vim.api.nvim_buf_get_name(buf)
if util.is_uri(name) then
local rev = object.parse_uri(name)
if not rev or not rev.base or not rev.path then
return nil
end
local r = repo.find(buf)
if not r then
return nil
end
return { repo = r, rel = rev.path, revision = rev.base }
end
if not repo.is_worktree_buf(buf) then
return nil
end
local r = repo.find(buf)
if not r then
return nil
end
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
if not rel then
return nil
end
return { repo = r, rel = rel }
end
---@param buf integer
---@return ow.Git.Blame.BufState?
local function ensure_state(buf)
if states[buf] then
return states[buf]
end
local src = resolve_source(buf)
if not src then
return nil
end
---@type ow.Git.Blame.BufState
local state = {
repo = src.repo,
rel = src.rel,
revision = src.revision,
commits = {},
line_sha = {},
blame_text = nil,
blame_width = nil,
blame_blank = nil,
tick = nil,
epoch = 0,
pending = {},
inline = false,
overlay = false,
autocmds = {},
}
states[buf] = state
return state
end
---Blame the buffer and cache the result, keyed by `changedtick`. Worktree
---buffers blame the live buffer contents; `git://` buffers blame their
---revision. `done` runs once the cache is populated.
---@param state ow.Git.Blame.BufState
---@param buf integer
---@param done fun()?
local function run_blame(state, buf, done)
local tick = vim.api.nvim_buf_get_changedtick(buf)
if state.tick == tick then
if done then
done()
end
return
end
if done then
table.insert(state.pending, done)
end
state.epoch = state.epoch + 1
local epoch = state.epoch
local opts ---@type { rev: string?, contents: string? }
if state.revision then
opts = { rev = state.revision }
else
opts = {
contents = table.concat(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n"
) .. "\n",
}
end
fetch_blame(state.repo, state.rel, opts, function(result)
if
states[buf] ~= state
or epoch ~= state.epoch
or not vim.api.nvim_buf_is_valid(buf)
then
return
end
local data = result
or synth_uncommitted(vim.api.nvim_buf_line_count(buf))
state.commits = data.commits
state.line_sha = data.line_sha
state.blame_text = nil
state.tick = tick
local pending = state.pending
state.pending = {}
for _, fn in ipairs(pending) do
fn()
end
end)
end
---@param buf integer
local function render_inline(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
vim.api.nvim_buf_clear_namespace(buf, NS_INLINE, 0, -1)
local state = states[buf]
if not state or not state.inline then
return
end
local win = vim.api.nvim_get_current_buf() == buf
and vim.api.nvim_get_current_win()
or vim.fn.bufwinid(buf)
if win == -1 then
return
end
local lnum = vim.api.nvim_win_get_cursor(win)[1]
local sha = state.line_sha[lnum]
local commit = sha and state.commits[sha]
if not commit then
return
end
local text
if util.is_zero_sha(sha) then
text = " You - Not Committed Yet"
else
text = string.format(
" %s, %s - %s",
commit.author,
relative_time(commit.author_time),
commit.summary
)
end
pcall(vim.api.nvim_buf_set_extmark, buf, NS_INLINE, lnum - 1, 0, {
virt_text = { { text, "GitBlame" } },
virt_text_pos = "eol",
hl_mode = "combine",
})
end
---The native fold / sign / number column items, each emitted only when
---its window option is on.
---@param win integer
---@return string
local function native_items(win)
local wo = vim.wo[win]
local items = ""
if wo.foldcolumn ~= "0" then
items = items .. "%C"
end
if wo.signcolumn ~= "no" then
items = items .. "%s"
end
if wo.number or wo.relativenumber then
items = items .. "%l"
end
return items
end
---The maximum width the native fold / sign / number columns can occupy.
---Computed from the window options, not evaluated: evaluating a
---statuscolumn reports the window's current gutter width, which the
---overlay itself has already inflated.
---@param win integer
---@return integer
local function native_width(win)
local wo = vim.wo[win]
local width = 0
if wo.number or wo.relativenumber then
local buf = vim.api.nvim_win_get_buf(win)
local digits = #tostring(vim.api.nvim_buf_line_count(buf)) + 1
width = math.max(wo.numberwidth, digits)
end
local sc = wo.signcolumn
if sc:find("^yes") or sc:find("^auto") then
width = width + 2 * math.floor(tonumber(sc:match("(%d)$")) or 1)
end
local fc = tonumber(wo.foldcolumn:match("(%d)$"))
if fc then
width = width + math.floor(fc)
elseif wo.foldcolumn:find("^auto") then
width = width + 1
end
return width
end
---Split a `budget` of display cells across the blame fields. The author
---absorbs the squeeze first, then the date, then the sha.
---@param budget integer
---@return integer sha_w
---@return integer author_w
---@return integer date_w
local function layout(budget)
local body = budget - 3 * #GAP
if body <= 0 then
return 0, 0, 0
end
local sha_w = math.min(body, SHA_WIDTH)
local rest = body - sha_w
local date_w = math.min(rest, DATE_WIDTH)
return sha_w, rest - date_w, date_w
end
---Precompute the per-line blame segment so the statuscolumn expression
---stays a table lookup. Blame shares Neovim's 47-cell statuscolumn cap
---with the native columns, so it is budgeted into what they leave free.
---@param state ow.Git.Blame.BufState
---@param win integer
local function build_blame_text(state, win)
local total = math.max(
0,
math.min(STATUSCOLUMN_MAX - native_width(win), PREFERRED_WIDTH)
)
local sha_w, author_w, date_w = layout(total)
local blank = string.rep(" ", total)
---@type table<integer, string>
local text = {}
for lnum, sha in pairs(state.line_sha) do
local commit = state.commits[sha]
if commit then
if sha_w == 0 then
text[lnum] = blank
else
local author, date
if util.is_zero_sha(sha) then
author, date = "Uncommitted", ""
else
author = commit.author
date = format_date(commit.author_time)
end
text[lnum] = "%#GitBlameSha#"
.. sha:sub(1, sha_w)
.. "%#GitBlame#"
.. GAP
.. (pad(author, author_w):gsub("%%", "%%%%"))
.. GAP
.. pad(date, date_w)
.. GAP
end
end
end
state.blame_text = text
state.blame_width = total
state.blame_blank = blank
end
---Render the blame segment for one screen line. Wired into the window's
---`'statuscolumn'` while the overlay is on, so the cursor never enters
---it - it lives outside the text area, unlike inline virtual text.
---@param win integer?
---@param lnum integer
---@param virtnum integer
---@return string
local function gutter(win, lnum, virtnum)
if not win or win == 0 or not vim.api.nvim_win_is_valid(win) then
return ""
end
local state = states[vim.api.nvim_win_get_buf(win)]
if not state or not state.overlay or not state.blame_text then
return ""
end
return (virtnum == 0 and state.blame_text[lnum]) or state.blame_blank or ""
end
M._gutter = gutter
M._layout = layout
M._native_width = native_width
function M.statuscolumn()
local ok, result =
pcall(gutter, vim.api.nvim_get_current_win(), vim.v.lnum, vim.v.virtnum)
return ok and result or ""
end
---@type table<integer, string>
local saved_statuscolumn = {}
---Reconcile every window's `'statuscolumn'` with the overlay state: a
---window showing an overlay buffer gets the blame statuscolumn, and its
---previous value is saved so it can be restored on toggle-off.
local function refresh_overlay_columns()
for win in pairs(saved_statuscolumn) do
if not vim.api.nvim_win_is_valid(win) then
saved_statuscolumn[win] = nil
end
end
for _, win in ipairs(vim.api.nvim_list_wins()) do
local state = states[vim.api.nvim_win_get_buf(win)]
local on = state ~= nil and state.overlay
if on and saved_statuscolumn[win] == nil then
saved_statuscolumn[win] = vim.wo[win].statuscolumn
vim.wo[win].statuscolumn = BLAME_EXPR .. native_items(win)
elseif not on and saved_statuscolumn[win] ~= nil then
vim.wo[win].statuscolumn = saved_statuscolumn[win]
saved_statuscolumn[win] = nil
end
end
end
---@param buf integer
local function render(buf)
render_inline(buf)
local state = states[buf]
if not state or not state.overlay then
return
end
-- Rebuild against the current native column widths, then re-set
-- `'statuscolumn'` so the redraw picks up the new text instead of the
-- cached gutter.
local built = false
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(win) == buf then
if not built then
build_blame_text(state, win)
built = true
end
vim.wo[win].statuscolumn = BLAME_EXPR .. native_items(win)
end
end
end
---@param buf integer
local function reblame(buf)
local state = states[buf]
if not state or (not state.inline and not state.overlay) then
return
end
run_blame(state, buf, function()
render(buf)
end)
end
local schedule, sched_handle = util.keyed_debounce(reblame, 150)
---@param buf integer
function M._flush(buf)
sched_handle.flush(buf)
end
---@param buf integer
---@param state ow.Git.Blame.BufState
local function attach_autocmds(buf, state)
if #state.autocmds > 0 then
return
end
local group =
vim.api.nvim_create_augroup("ow.git.blame." .. buf, { clear = true })
state.autocmds = {
vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, {
group = group,
buffer = buf,
callback = function()
render_inline(buf)
end,
}),
vim.api.nvim_create_autocmd(
{ "TextChanged", "TextChangedI", "BufWritePost" },
{
group = group,
buffer = buf,
callback = function()
schedule(buf)
end,
}
),
}
end
---@param buf integer
---@param state ow.Git.Blame.BufState
local function detach_autocmds(buf, state)
for _, id in ipairs(state.autocmds) do
pcall(vim.api.nvim_del_autocmd, id)
end
state.autocmds = {}
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame." .. buf)
sched_handle.cancel(buf)
end
---@param buf integer?
function M.toggle_inline(buf)
buf = resolve_buf(buf)
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
state.inline = not state.inline
if state.inline then
attach_autocmds(buf, state)
run_blame(state, buf, function()
render_inline(buf)
end)
else
if vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_clear_namespace(buf, NS_INLINE, 0, -1)
end
if not state.overlay then
detach_autocmds(buf, state)
end
end
end
---@param buf integer?
function M.toggle_overlay(buf)
buf = resolve_buf(buf)
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
state.overlay = not state.overlay
refresh_overlay_columns()
if state.overlay then
attach_autocmds(buf, state)
run_blame(state, buf, function()
render(buf)
end)
elseif not state.inline then
detach_autocmds(buf, state)
end
end
---@param lines string[]
---@return integer width
---@return integer height
local function size_for(lines)
local width = 1
for _, l in ipairs(lines) do
local w = vim.api.nvim_strwidth(l)
if w > width then
width = w
end
end
width = math.min(math.max(width + 1, 30), vim.o.columns - 4)
local height = math.min(math.max(#lines, 1), math.floor(vim.o.lines / 2))
return width, height
end
local popup_win ---@type integer?
local function close_popup()
if popup_win and vim.api.nvim_win_is_valid(popup_win) then
vim.api.nvim_win_close(popup_win, true)
end
popup_win = nil
end
---@param pbuf integer
---@param win integer
---@param head string[]
---@param body string[]?
---@param sha_len integer?
local function apply_popup(pbuf, win, head, body, sha_len)
local lines = {}
vim.list_extend(lines, head)
if body then
vim.list_extend(lines, body)
end
vim.bo[pbuf].modifiable = true
vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines)
vim.bo[pbuf].modifiable = false
vim.api.nvim_buf_clear_namespace(pbuf, NS_POPUP, 0, -1)
if sha_len then
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, 0, {
end_col = sha_len,
hl_group = "GitBlameSha",
})
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 1, 0, {
end_col = #(head[2] or ""),
hl_group = "GitBlame",
})
end
local width, height = size_for(lines)
pcall(vim.api.nvim_win_set_width, win, width)
pcall(vim.api.nvim_win_set_height, win, height)
end
---@param watch_buf integer
---@param pbuf integer
---@param win integer
local function setup_popup_autocmds(watch_buf, pbuf, win)
local group =
vim.api.nvim_create_augroup("ow.git.blame.popup", { clear = true })
vim.api.nvim_create_autocmd(
{ "CursorMoved", "CursorMovedI", "InsertEnter" },
{ group = group, buffer = watch_buf, callback = close_popup }
)
vim.api.nvim_create_autocmd("WinLeave", {
group = group,
buffer = pbuf,
callback = close_popup,
})
vim.api.nvim_create_autocmd("WinClosed", {
group = group,
pattern = tostring(win),
callback = function()
popup_win = nil
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame.popup")
end,
})
vim.keymap.set("n", "q", close_popup, { buffer = pbuf, nowait = true })
end
---@param r ow.Git.Repo
---@param commits table<string, ow.Git.Blame.Commit>
---@param line_sha table<integer, string>
---@param lnum integer
---@param watch_buf integer
local function open_popup(r, commits, line_sha, lnum, watch_buf)
close_popup()
local sha = line_sha[lnum]
local commit = sha and commits[sha]
if not commit then
util.warning("git blame: no blame information for line %d", lnum)
return
end
local head ---@type string[]
local sha_len ---@type integer?
if util.is_zero_sha(sha) then
head = { "Not Committed Yet" }
else
local short = sha:sub(1, 8)
sha_len = #short
head = {
short .. " " .. commit.author,
commit.author_mail .. " " .. relative_time(commit.author_time),
"",
}
end
local body = sha_len and { commit.summary } or nil
local lines = {}
vim.list_extend(lines, head)
if body then
vim.list_extend(lines, body)
end
local width, height = size_for(lines)
local pbuf = vim.api.nvim_create_buf(false, true)
vim.bo[pbuf].bufhidden = "wipe"
local win = vim.api.nvim_open_win(pbuf, false, {
relative = "cursor",
row = 1,
col = 0,
width = width,
height = height,
style = "minimal",
})
popup_win = win
apply_popup(pbuf, win, head, body, sha_len)
setup_popup_autocmds(watch_buf, pbuf, win)
if not sha_len then
return
end
util.git({ "show", "-s", "--format=%B", sha }, {
cwd = r.worktree,
silent = true,
on_exit = function(res)
if
popup_win ~= win
or not vim.api.nvim_win_is_valid(win)
or not vim.api.nvim_buf_is_valid(pbuf)
or res.code ~= 0
then
return
end
local msg = util.split_lines(res.stdout or "")
if #msg > 0 then
apply_popup(pbuf, win, head, msg, sha_len)
end
end,
})
end
---@param buf integer?
function M.line_popup(buf)
buf = resolve_buf(buf)
if popup_win and vim.api.nvim_win_is_valid(popup_win) then
vim.api.nvim_set_current_win(popup_win)
return
end
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
run_blame(state, buf, function()
if
not vim.api.nvim_buf_is_valid(buf)
or vim.api.nvim_get_current_buf() ~= buf
or vim.api.nvim_win_get_cursor(0)[1] ~= lnum
then
return
end
open_popup(state.repo, state.commits, state.line_sha, lnum, buf)
end)
end
---Blame the current line of the current buffer, then hand the commit to
---`done`. Works in worktree files and `git://<rev>:<path>` buffers
---alike, so the open-* actions chain through history.
---@param done fun(state: ow.Git.Blame.BufState, sha: string)
local function blame_line(done)
local buf = vim.api.nvim_get_current_buf()
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
run_blame(state, buf, function()
if not vim.api.nvim_buf_is_valid(buf) then
return
end
local sha = state.line_sha[lnum]
if not sha or util.is_zero_sha(sha) then
util.warning("git blame: line is not committed yet")
return
end
done(state, sha)
end)
end
function M.open_commit()
blame_line(function(state, sha)
object.open(state.repo, sha, { split = false })
end)
end
function M.open_file()
blame_line(function(state, sha)
object.open(state.repo, sha .. ":" .. state.rel, { split = false })
end)
end
function M.open_file_parent()
blame_line(function(state, sha)
local parent = state.repo:rev_parse(sha .. "^", false)
if not parent then
util.warning("git blame: %s has no parent commit", sha:sub(1, 8))
return
end
object.open(state.repo, parent .. ":" .. state.rel, { split = false })
end)
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_INLINE, 0, -1)
end
detach_autocmds(buf, state)
state.epoch = state.epoch + 1
states[buf] = nil
refresh_overlay_columns()
end
local augroup = vim.api.nvim_create_augroup("ow.git.blame", { clear = true })
vim.api.nvim_create_autocmd("BufWinEnter", {
group = augroup,
callback = refresh_overlay_columns,
})
-- The blame budget depends on the gutter option widths, so re-render an
-- overlay buffer when one of them changes.
vim.api.nvim_create_autocmd("OptionSet", {
group = augroup,
pattern = {
"number",
"relativenumber",
"numberwidth",
"signcolumn",
"foldcolumn",
},
callback = function()
local buf = vim.api.nvim_get_current_buf()
local state = states[buf]
if state and state.overlay then
render(buf)
end
end,
})
-- The blame cache is keyed by `changedtick`, which a commit / checkout /
-- rebase does not bump. Drop the cache for affected worktree buffers on
-- a repo change so the next blame re-fetches; re-blame eagerly if a mode
-- is showing. `git://` buffers blame a fixed revision and are skipped.
repo.on("change", function(r, change)
for buf, state in pairs(states) do
if
state.repo == r
and not state.revision
and (change.paths[state.rel] or change.branch_changed)
then
state.tick = nil
if state.inline or state.overlay then
schedule(buf)
end
end
end
end)
return M
-904
View File
@@ -1,904 +0,0 @@
local commit = require("git.commit")
local object = require("git.object")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
---@alias ow.Git.Cmd.Run fun(r: ow.Git.Repo, args: string[])
---@type string[]?
local cached_cmds
---@return string[]
local function git_cmds()
if cached_cmds then
return cached_cmds
end
local out = util.git({ "--list-cmds=main,others,alias" })
if not out then
return {}
end
cached_cmds = {}
for line in out:gmatch("[^\r\n]+") do
if line ~= "" then
table.insert(cached_cmds, line)
end
end
table.sort(cached_cmds)
return cached_cmds
end
---@param tok string
---@return boolean
local function is_expansion_target(tok)
local first = tok:sub(1, 1)
if first == "%" or first == "#" then
return true
end
if tok:match("^<%w+>") then
return true
end
if tok == "~" or tok:sub(1, 2) == "~/" then
return true
end
return false
end
---@param line string
---@param i integer
---@param buf string[]
---@param escapes string?
---@return integer
local function parse_quoted(line, i, buf, escapes)
local quote = line:sub(i, i)
local n = #line
i = i + 1
while i <= n do
local c = line:sub(i, i)
if c == quote then
return i + 1
elseif escapes and c == "\\" and i < n then
local nxt = line:sub(i + 1, i + 1)
if escapes:find(nxt, 1, true) then
table.insert(buf, nxt)
i = i + 2
else
table.insert(buf, c)
i = i + 1
end
else
table.insert(buf, c)
i = i + 1
end
end
return i
end
---@param line string
---@return string[]
function M.parse_args(line)
local args = {}
local i, n = 1, #line
while i <= n do
local c = line:sub(i, i)
if c == " " or c == "\t" then
i = i + 1
else
local buf = {}
local quoted = false
while i <= n do
c = line:sub(i, i)
if c == " " or c == "\t" then
break
elseif c == "\\" and i < n then
table.insert(buf, line:sub(i + 1, i + 1))
i = i + 2
elseif c == '"' then
quoted = true
i = parse_quoted(line, i, buf, '"\\$`')
elseif c == "'" then
quoted = true
i = parse_quoted(line, i, buf, nil)
else
table.insert(buf, c)
i = i + 1
end
end
local tok = table.concat(buf)
if not quoted and is_expansion_target(tok) then
local expanded = vim.fn.expand(tok) --[[@as string]]
if expanded ~= "" then
tok = expanded
end
end
table.insert(args, tok)
end
end
return args
end
---@param name string
---@return integer buf
local function place_split(name)
-- bufadd resolves the name the same way nvim_buf_set_name does
-- (cwd-prefixing for non-absolute names), so calling it twice with
-- the same name returns the same buffer.
local buf = vim.fn.bufadd(name)
if not vim.api.nvim_buf_is_loaded(buf) then
vim.fn.bufload(buf)
util.setup_scratch(buf, { bufhidden = "hide" })
end
local win = vim.fn.bufwinid(buf)
if win ~= -1 then
vim.api.nvim_set_current_win(win)
else
util.place_buf(buf, nil)
end
return buf
end
---@param buf integer
local function clear_undo(buf)
local saved = vim.bo[buf].undolevels
vim.bo[buf].undolevels = -1
vim.bo[buf].modifiable = true
vim.api.nvim_buf_call(buf, function()
vim.cmd('silent! exe "normal! a \\<BS>\\<Esc>"')
end)
vim.bo[buf].modifiable = false
vim.bo[buf].undolevels = saved
end
---@param buf integer
local function attach_history_keys(buf)
local function bypass(fn)
return function()
vim.bo[buf].modifiable = true
pcall(fn)
vim.bo[buf].modifiable = false
end
end
vim.keymap.set(
"n",
"u",
bypass(vim.cmd.undo),
{ buffer = buf, desc = "Undo" }
)
vim.keymap.set(
"n",
"<C-r>",
bypass(vim.cmd.redo),
{ buffer = buf, desc = "Redo" }
)
end
---@param r ow.Git.Repo
---@param args string[]
---@param ft string
local function run_in_split(r, args, ft)
util.git(args, {
cwd = r.worktree,
on_exit = function(result)
if result.code ~= 0 then
util.error(
"git %s failed: %s",
args[1] or "?",
vim.trim(result.stderr or "")
)
return
end
local stdout = result.stdout or ""
local buf = place_split("[Git " .. table.concat(args, " ") .. "]")
repo.bind(buf, r)
object.attach_dispatch(buf)
attach_history_keys(buf)
local state = r:state(buf) --[[@as -nil]]
vim.bo[buf].filetype = ft
-- Force a new undo block so each rerun is its own undo step.
vim.bo[buf].undolevels = vim.bo[buf].undolevels
local first_run = not state.initialized
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
if first_run then
clear_undo(buf)
state.initialized = true
end
end,
})
end
---@param r ow.Git.Repo
---@param args string[]
local function run_to_messages(r, args)
local cmd = { "git" }
vim.list_extend(cmd, args)
local result = vim.system(cmd, {
cwd = r.worktree,
text = true,
env = util.DEFAULT_GIT_ENV,
}):wait()
local out = vim.trim(result.stdout or "")
local err = vim.trim(result.stderr or "")
local failed = result.code ~= 0
local chunks = {}
if out ~= "" then
table.insert(chunks, { out })
end
if err ~= "" then
if #chunks > 0 then
table.insert(chunks, { "\n" })
end
table.insert(chunks, { err, failed and "ErrorMsg" or nil })
end
if #chunks == 0 and failed then
table.insert(
chunks,
{ "git exited " .. tostring(result.code), "ErrorMsg" }
)
end
if #chunks > 0 then
vim.api.nvim_echo(chunks, failed or err ~= "", {})
end
end
---@return integer
local function find_or_create_preview_win()
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.wo[w].previewwindow then
return w
end
end
vim.cmd(("botright %dnew"):format(vim.o.previewheight))
local w = vim.api.nvim_get_current_win()
vim.wo[w].previewwindow = true
return w
end
---@param r ow.Git.Repo
---@param args string[]
local function run_in_preview(r, args)
local pwin = find_or_create_preview_win()
local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].bufhidden = "wipe"
vim.api.nvim_win_set_buf(pwin, buf)
vim.api.nvim_set_current_win(pwin)
local cmd = { "git" }
vim.list_extend(cmd, args)
local job = vim.fn.jobstart(cmd, {
cwd = r.worktree,
term = true,
})
if job <= 0 then
util.error("failed to start git job")
return
end
vim.api.nvim_create_autocmd("BufWipeout", {
buffer = buf,
once = true,
callback = function()
pcall(vim.fn.jobstop, job)
end,
})
vim.keymap.set("n", "q", "<cmd>pclose<cr>", {
buffer = buf,
nowait = true,
desc = "Close preview",
})
vim.keymap.set("n", "<C-c>", function()
pcall(vim.fn.jobstop, job)
end, { buffer = buf, nowait = true, desc = "Cancel git job" })
end
---@param ft string
---@return ow.Git.Cmd.Run
local function in_split(ft)
return function(r, args)
run_in_split(r, args, ft)
end
end
---@param line string
---@return string
local function clean_progress_line(line)
if not (line:find("\27", 1, true) or line:find("\r", 1, true)) then
return line
end
line = line:gsub("\27%[[%d;?]*[%a]", "")
local parts = vim.split(line, "\r", { plain = true })
for i = #parts, 1, -1 do
if parts[i] ~= "" then
return parts[i]
end
end
return ""
end
---@param lines string[]
---@param fallback string
---@return [string, string?][]
local function format_error_dump(lines, fallback)
if #lines == 0 then
return { { fallback, "ErrorMsg" } }
end
local chunks = {}
local matched = false
for i, line in ipairs(lines) do
if i > 1 then
table.insert(chunks, { "\n" })
end
if line:match("^fatal:") or line:match("^error:") then
table.insert(chunks, { line, "ErrorMsg" })
matched = true
else
table.insert(chunks, { line })
end
end
if not matched then
return { { table.concat(lines, "\n"), "ErrorMsg" } }
end
return chunks
end
---@param r ow.Git.Repo
---@param args string[]
local function run_streaming(r, args)
local title = "git " .. (args[1] or "")
local id = "git." .. tostring(vim.uv.hrtime())
---@type string[]
local accum = {}
local partial = ""
local last_progress = ""
---@param text string
---@param status "running"|"success"|"failed"
local function emit_progress(text, status)
vim.api.nvim_echo({ { text } }, false, {
id = id,
kind = "progress",
status = status,
title = title,
source = "git",
})
end
local function on_data(_, data, _)
if not data or #data == 0 then
return
end
if #data == 1 and data[1] == "" then
return
end
partial = partial .. data[1]
local prev = last_progress
for i = 2, #data do
local cleaned = clean_progress_line(partial)
if cleaned ~= "" then
table.insert(accum, cleaned)
last_progress = cleaned
end
partial = data[i]
end
if partial ~= "" then
local cleaned = clean_progress_line(partial)
if cleaned ~= "" then
last_progress = cleaned
end
end
if last_progress ~= prev then
emit_progress(last_progress, "running")
end
end
local function on_exit(_, code)
if partial ~= "" then
local cleaned = clean_progress_line(partial)
if cleaned ~= "" then
table.insert(accum, cleaned)
last_progress = cleaned
end
partial = ""
end
if code == 0 then
emit_progress(
last_progress ~= "" and last_progress or "done",
"success"
)
else
emit_progress(("exit %d"):format(code), "failed")
local fallback = ("%s failed: exit %d"):format(title, code)
vim.api.nvim_echo(format_error_dump(accum, fallback), true, {})
end
end
local cmd = { "git" }
vim.list_extend(cmd, args)
local job = vim.fn.jobstart(cmd, {
cwd = r.worktree,
pty = true,
env = util.DEFAULT_GIT_ENV,
on_stdout = on_data,
on_stderr = on_data,
on_exit = on_exit,
})
if job <= 0 then
util.error("failed to start git job")
end
end
---@type table<string, ow.Git.Cmd.Run>
local HANDLERS = {
log = in_split("git"),
diff = in_split("git"),
push = run_streaming,
fetch = run_streaming,
pull = run_streaming,
clone = run_streaming,
am = run_streaming,
["cherry-pick"] = run_streaming,
revert = run_streaming,
}
---@param args string[]
---@return boolean
local function has_message(args)
for _, a in ipairs(args) do
if
a == "-m"
or a == "--message"
or a:match("^%-%-message=")
or a:match("^%-m")
then
return true
end
end
return false
end
---@param args string[]
---@param opts { bang: boolean? }?
function M.run(args, opts)
local r = repo.resolve()
if not r then
util.error("not in a git repository")
return
end
local bang = opts and opts.bang or false
if bang then
run_in_preview(r, args)
return
end
local sub = args[1]
if sub == "commit" and not has_message(args) then
commit.commit({ args = vim.list_slice(args, 2) })
return
end
if sub == "show" then
if #args == 2 and args[2]:find(":", 1, true) then
object.open(r, args[2])
return
end
run_in_split(r, args, "git")
return
end
if sub == "cat-file" then
if #args == 3 and args[2] == "-p" then
object.open(r, args[3])
return
end
run_in_split(r, args, "git")
return
end
local handler = sub and HANDLERS[sub]
if handler then
handler(r, args)
else
run_to_messages(r, args)
end
end
---@param items string[]
---@param lead string
---@return string[]
local function prefix_filter(items, lead)
return vim.tbl_filter(function(it)
return vim.startswith(it, lead)
end, items)
end
---@param prefix string
---@param dir string
---@param name_lead string
---@param entries string[]
---@return string[]
local function path_segments(prefix, dir, name_lead, entries)
local matches = {}
local seen = {}
for _, full_path in ipairs(entries) do
local rel = dir == "" and full_path or full_path:sub(#dir + 1)
local slash = rel:find("/", 1, true)
local segment = slash and rel:sub(1, slash) or rel
if not seen[segment] and segment:sub(1, #name_lead) == name_lead then
seen[segment] = true
table.insert(matches, prefix .. dir .. segment)
end
end
return matches
end
---@param r ow.Git.Repo
---@param dir string
---@return string[]
local function list_files(r, dir)
return r:get_cached("files:" .. dir, function(self)
local args = { "ls-files" }
if dir ~= "" then
table.insert(args, dir)
end
local out = util.git(args, { cwd = self.worktree, silent = true })
return out and util.split_lines(out) or {}
end)
end
---@param r ow.Git.Repo
---@return string[]
local function list_remotes(r)
return r:get_cached("remotes", function(self)
local out = util.git(
{ "remote" },
{ cwd = self.worktree, silent = true }
)
return out and util.split_lines(out) or {}
end)
end
---@type table<string, string[]>
local SUBSUB_FALLBACK = {
submodule = {
"add",
"status",
"init",
"deinit",
"update",
"summary",
"foreach",
"sync",
"absorbgitdirs",
},
}
---@type table<string, string[]>
local cached_completions = {}
---@param sub string
---@return string[]
local function fetch_completions(sub)
if cached_completions[sub] then
return cached_completions[sub]
end
local out = util.git(
{ sub, "--git-completion-helper-all" },
{ silent = true }
) or util.git({ sub, "--git-completion-helper" }, { silent = true })
local items = {}
if out then
for tok in out:gmatch("%S+") do
table.insert(items, tok)
end
end
cached_completions[sub] = items
return items
end
---@param sub string
---@return string[]
local function fetch_subsubcommands(sub)
local subs = {}
for _, it in ipairs(fetch_completions(sub)) do
if it:sub(1, 1) ~= "-" and it ~= "--" then
table.insert(subs, it)
end
end
if #subs == 0 and SUBSUB_FALLBACK[sub] then
return SUBSUB_FALLBACK[sub]
end
return subs
end
---@param sub string
---@return string[]
local function fetch_flags(sub)
local flags = {}
for _, it in ipairs(fetch_completions(sub)) do
if it:sub(1, 1) == "-" and it ~= "--" then
table.insert(flags, it)
end
end
return flags
end
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_tracked_paths(r, lead)
local dir, name_lead = lead:match("^(.*/)([^/]*)$")
dir = dir or ""
name_lead = name_lead or lead
return path_segments("", dir, name_lead, list_files(r, dir))
end
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_unstaged_paths(r, lead)
local matches = {}
for path, entry in pairs(r.status.entries) do
if path:sub(1, #lead) == lead then
local include = entry.kind == "untracked"
or entry.kind == "unmerged"
if not include and entry.kind == "changed" then
---@cast entry ow.Git.Status.ChangedEntry
include = entry.unstaged ~= nil
end
if include then
table.insert(matches, path)
end
end
end
table.sort(matches)
return matches
end
---@param arg_lead string
---@return string[]
function M.complete_rev(arg_lead)
local r = repo.resolve()
if not r then
return {}
end
local stage, stage_path_lead = arg_lead:match("^:([0-3]):(.*)$")
if stage then
local out = util.git(
{ "ls-files", "--stage" },
{ cwd = r.worktree, silent = true }
)
if not out then
return {}
end
local matches = {}
for _, line in ipairs(util.split_lines(out)) do
local row_stage, row_path = line:match("^%S+ %S+ (%d)\t(.*)$")
if
row_stage == stage
and row_path
and row_path:sub(1, #stage_path_lead) == stage_path_lead
then
table.insert(matches, ":" .. stage .. ":" .. row_path)
end
end
return matches
end
local colon = arg_lead:find(":", 1, true)
if not colon then
local refs = {}
vim.list_extend(refs, r:list_refs())
vim.list_extend(refs, r:list_pseudo_refs())
vim.list_extend(refs, r:list_stash_refs())
return prefix_filter(refs, arg_lead)
end
local rev = arg_lead:sub(1, colon - 1)
local path_lead = arg_lead:sub(colon + 1)
local dir, name_lead = path_lead:match("^(.*/)([^/]*)$")
dir = dir or ""
name_lead = name_lead or path_lead
if rev ~= "" then
local args = { "ls-tree", rev }
if dir ~= "" then
table.insert(args, dir)
end
local out = util.git(args, { cwd = r.worktree, silent = true })
if not out then
return {}
end
local matches = {}
for _, line in ipairs(util.split_lines(out)) do
local typ, full_path = line:match("^%S+ (%S+) %S+\t(.*)$")
if typ and full_path then
local basename = dir == "" and full_path
or full_path:sub(#dir + 1)
if typ == "tree" then
basename = basename .. "/"
end
if basename:sub(1, #name_lead) == name_lead then
table.insert(matches, rev .. ":" .. dir .. basename)
end
end
end
return matches
end
return path_segments(":", dir, name_lead, list_files(r, dir))
end
---@alias ow.Git.Cmd.Handler fun(r: ow.Git.Repo, lead: string, sub: string, idx: integer): string[]
---@alias ow.Git.Cmd.Slot ow.Git.Cmd.Handler | ow.Git.Cmd.Handler[]
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_remote(r, lead)
return prefix_filter(list_remotes(r), lead)
end
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_ref(r, lead)
return prefix_filter(r:list_refs(), lead)
end
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_pseudo_ref(r, lead)
return prefix_filter(r:list_pseudo_refs(), lead)
end
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_stash_ref(r, lead)
return prefix_filter(r:list_stash_refs(), lead)
end
---@param _ ow.Git.Repo
---@param lead string
---@return string[]
local function complete_rev(_, lead)
return M.complete_rev(lead)
end
---@param _ ow.Git.Repo
---@param lead string
---@param sub string
---@param idx integer
---@return string[]
local function complete_subsubcmd(_, lead, sub, idx)
if idx ~= 1 then
return {}
end
return prefix_filter(fetch_subsubcommands(sub), lead)
end
local ALL_REFS = { complete_ref, complete_pseudo_ref, complete_stash_ref }
local REV_OR_PATH = { complete_rev, complete_tracked_paths }
---@type table<string, ow.Git.Cmd.Slot[]>
local POSITIONAL_HANDLER = {
push = { complete_remote, ALL_REFS },
pull = { complete_remote, ALL_REFS },
fetch = { complete_remote, ALL_REFS },
checkout = { REV_OR_PATH },
reset = { REV_OR_PATH },
restore = { complete_tracked_paths },
add = { complete_unstaged_paths },
rm = { complete_tracked_paths },
mv = { complete_tracked_paths },
blame = { complete_tracked_paths },
branch = { complete_ref },
switch = { complete_ref },
merge = { ALL_REFS },
rebase = { ALL_REFS },
["cherry-pick"] = { ALL_REFS },
revert = { ALL_REFS },
tag = { ALL_REFS },
log = { REV_OR_PATH },
diff = { REV_OR_PATH },
show = { complete_rev },
["cat-file"] = { complete_rev },
stash = { complete_subsubcmd },
remote = { complete_subsubcmd },
worktree = { complete_subsubcmd },
bisect = { complete_subsubcmd },
submodule = { complete_subsubcmd },
}
---@class ow.Git.Cmd.CompleteState
---@field prior string[] -- positional and flag tokens before the current arg_lead
---@field after_separator boolean -- whether `--` appeared in prior
---@param cmd_line string
---@return ow.Git.Cmd.CompleteState
local function parse_complete_state(cmd_line)
local rest = cmd_line:gsub("^%s*%S+%s*", "", 1)
local trailing_space = rest == "" or rest:sub(-1):match("%s") ~= nil
local tokens = vim.split(vim.trim(rest), "%s+", { trimempty = true })
local prior = trailing_space and tokens
or vim.list_slice(tokens, 1, #tokens - 1)
local after_separator = false
for _, t in ipairs(prior) do
if t == "--" then
after_separator = true
break
end
end
return { prior = prior, after_separator = after_separator }
end
---@param prior string[] -- includes the subcommand at index 1
---@return integer
local function positional_index(prior)
local pos = 0
for i = 2, #prior do
if prior[i]:sub(1, 1) ~= "-" then
pos = pos + 1
end
end
return pos + 1
end
---@param arg_lead string
---@param cmd_line string
---@return string[]
function M.complete(arg_lead, cmd_line, _)
local state = parse_complete_state(cmd_line)
local prior = state.prior
if #prior == 0 then
return prefix_filter(git_cmds(), arg_lead)
end
local sub = prior[1] --[[@as string]]
if arg_lead:sub(1, 1) == "-" then
return prefix_filter(fetch_flags(sub), arg_lead)
end
local r = repo.resolve()
if not r then
return {}
end
if state.after_separator then
return complete_tracked_paths(r, arg_lead)
end
local handlers = POSITIONAL_HANDLER[sub]
if not handlers then
return complete_tracked_paths(r, arg_lead)
end
local idx = positional_index(prior)
local slot = handlers[idx] or handlers[#handlers]
if not slot then
return {}
end
if type(slot) == "function" then
return slot(r, arg_lead, sub, idx)
end
local result = {}
for _, fn in ipairs(slot) do
vim.list_extend(result, fn(r, arg_lead, sub, idx))
end
return result
end
M._parse_complete_state = parse_complete_state
M._positional_index = positional_index
return M
-80
View File
@@ -1,80 +0,0 @@
local editor = require("git.editor")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
---@param opts { args: string[]? }?
function M.commit(opts)
local r = repo.resolve()
if not r then
util.error("not in a git repository")
return
end
local cmd = { "git", "commit" }
if opts and opts.args then
vim.list_extend(cmd, opts.args)
end
local proxy_buf, proxy_win
editor.run(cmd, { cwd = r.worktree }, function(file_path, done)
local lines = {}
local f = io.open(file_path, "r")
if f then
for line in f:lines() do
table.insert(lines, line)
end
f:close()
end
local buf, win = util.new_scratch({
name = file_path,
buftype = "acwrite",
modifiable = true,
})
proxy_buf = buf
proxy_win = win
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modified = false
vim.bo[buf].filetype = "gitcommit"
vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = buf,
callback = function()
local out = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
local fw, werr = io.open(file_path, "w")
if not fw then
util.error("failed to write %s: %s", file_path, werr or "")
return
end
fw:write(table.concat(out, "\n"))
fw:close()
vim.bo[buf].modified = false
end,
})
vim.api.nvim_create_autocmd("BufWipeout", {
buffer = buf,
once = true,
callback = done,
})
end, function(result)
if proxy_win and vim.api.nvim_win_is_valid(proxy_win) then
pcall(vim.api.nvim_win_close, proxy_win, true)
end
if proxy_buf and vim.api.nvim_buf_is_valid(proxy_buf) then
vim.api.nvim_buf_delete(proxy_buf, { force = true })
end
if result.code ~= 0 then
util.error("git commit failed: %s", vim.trim(result.stderr or ""))
return
end
local out = vim.trim(result.stdout or "")
if out ~= "" then
vim.api.nvim_echo({ { out } }, false, {})
end
end)
end
return M
-925
View File
@@ -1,925 +0,0 @@
local status = require("git.core.status")
local util = require("git.core.util")
local M = {}
---@param buf? integer
---@return integer
local function expand_buf(buf)
if not buf or buf == 0 then
return vim.api.nvim_get_current_buf()
end
return buf
end
---@class ow.Git.Repo.BufState
---@field repo ow.Git.Repo
---@field sha string?
---@field initialized boolean?
---@field immutable boolean?
---@field index_writer boolean?
---@field index_mode string?
---@alias ow.Git.Repo.Event
---| "change"
local global = util.Emitter.new()
---@type table<string, ow.Git.Repo> keyed by worktree
local repos = {}
---@param r ow.Git.Repo
local function release_if_unused(r)
if repos[r.worktree] ~= r then
return
end
if next(r.buffers) ~= nil or next(r.tabs) ~= nil then
return
end
r:close()
repos[r.worktree] = nil
end
---@class ow.Git.Repo.Change
---@field paths table<string, true>
---@field branch_changed boolean
---@class ow.Git.Repo.RefreshOpts
---@field invalidate boolean?
---@class ow.Git.Repo.SubmoduleEntry
---@field worktree string
---@field unsub fun()?
---@class ow.Git.Repo
---@field gitdir string
---@field worktree string
---@field buffers table<integer, ow.Git.Repo.BufState>
---@field tabs table<integer, true>
---@field status ow.Git.Status
---@field private _events ow.Git.Util.Emitter<ow.Git.Repo.Event>
---@field private _watchers table<string, uv.uv_fs_event_t>
---@field private _schedule_refresh fun(self: ow.Git.Repo)
---@field private _refresh_handle ow.Git.Util.DebounceHandle
---@field private _cache table<string, any>
---@field private _fetch_epoch integer
---@field private _pending_invalidate boolean
---@field package _submodules table<string, ow.Git.Repo.SubmoduleEntry>
local Repo = {}
Repo.__index = Repo
local STATUS_ARGS = {
"--no-optional-locks",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v2",
"--branch",
"--ignored",
"--untracked-files=all",
"-z",
}
local PSEUDO_REFS = {
"HEAD",
"FETCH_HEAD",
"ORIG_HEAD",
"MERGE_HEAD",
"REBASE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
}
---@type table<string, fun(relpath: string): boolean>
local INVALIDATION_RULES = {
head = function(relpath)
return relpath == "HEAD"
or vim.startswith(relpath, "refs/heads/")
or relpath == "packed-refs"
end,
refs = function(relpath)
return vim.startswith(relpath, "refs/heads/")
or vim.startswith(relpath, "refs/tags/")
or vim.startswith(relpath, "refs/remotes/")
or relpath == "packed-refs"
end,
pseudo_refs = function(relpath)
return vim.tbl_contains(PSEUDO_REFS, relpath)
end,
stash_refs = function(relpath)
return relpath == "refs/stash" or relpath == "logs/refs/stash"
end,
config = function(relpath)
return relpath == "config"
end,
}
---@param relpath string
---@return boolean
local function affects_resolve(relpath)
return vim.startswith(relpath, "refs/")
or relpath == "packed-refs"
or relpath == "HEAD"
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)
for key, affects in pairs(INVALIDATION_RULES) do
if self._cache[key] ~= nil and affects(relpath) then
self._cache[key] = nil
end
end
if affects_resolve(relpath) then
self:_clear_cache_prefix("resolve:")
self:_clear_cache_prefix("head_blob:")
end
if relpath == "index" then
self:_clear_cache_prefix("index:")
end
end
---@param path string
---@return table<string, table<string, string>>?
local function read_git_config(path)
local f = io.open(path, "r")
if not f then
return nil
end
local content = f:read("*a")
f:close()
local out = {}
local section
for line in content:gmatch("[^\n]+") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and not trimmed:match("^[#;]") then
local s = trimmed:match("^%[(.-)%]$")
if s then
section = s
out[section] = out[section] or {}
elseif section then
local key, value =
trimmed:match("^(%S+)%s*=%s*(.-)$")
if key then
out[section][key] = value
end
end
end
end
return out
end
---@param gitdir string
---@return string[]
local function find_submodules(gitdir)
local handle = vim.uv.fs_scandir(vim.fs.joinpath(gitdir, "modules"))
if not handle then
return {}
end
local out = {}
while true do
local name, typ = vim.uv.fs_scandir_next(handle)
if not name then
break
end
if typ == "directory" then
table.insert(out, name)
end
end
return out
end
---@private
function Repo:_fetch_status()
if self._pending_invalidate then
self._cache = {}
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, {
cwd = self.worktree,
on_exit = function(result)
if epoch ~= self._fetch_epoch then
return
end
if result.code ~= 0 then
util.error(
"git status failed: %s",
vim.trim(result.stderr or "")
)
return
end
self.status = status.parse(result.stdout or "")
local change = {
paths = status.diff_entries(
prior_entries,
self.status.entries
),
branch_changed = not vim.deep_equal(
prior_branch,
self.status.branch
),
}
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
---@param opts ow.Git.Repo.RefreshOpts?
function Repo:refresh(opts)
if opts and opts.invalidate then
self._pending_invalidate = true
end
self:_schedule_refresh()
end
---@param gitdir string
---@param worktree string
---@return ow.Git.Repo
function Repo.new(gitdir, worktree)
local self = setmetatable({
gitdir = gitdir,
worktree = worktree,
buffers = {},
tabs = {},
status = status.parse(""),
_events = util.Emitter.new(),
_cache = {},
_fetch_epoch = 0,
_pending_invalidate = false,
_submodules = {},
}, Repo)
self._schedule_refresh, self._refresh_handle =
util.debounce(Repo._fetch_status, 50)
self:start_watcher()
self:refresh()
if vim.g.git_submodule_recursion ~= false then
self:_start_modules_watcher()
for _, name in ipairs(find_submodules(gitdir)) do
self:_register_submodule(name)
end
end
return self
end
---@generic T
---@param key string
---@param compute fun(self: ow.Git.Repo): T
---@return T
function Repo:get_cached(key, compute)
local hit = self._cache[key]
if hit ~= nil then
return hit
end
local value = compute(self)
self._cache[key] = value
return value
end
---@param path string
---@param on_event fun(filename: string?)
---@return uv.uv_fs_event_t?
local function start_fs_event(path, on_event)
local watcher = vim.uv.new_fs_event()
if not watcher then
return nil
end
local ok = watcher:start(path, {}, function(err, filename)
if err then
return
end
on_event(filename)
end)
if not ok then
watcher:close()
return nil
end
return watcher
end
---@private
---@param name string
function Repo:_unregister_submodule(name)
local entry = self._submodules[name]
if not entry then
return
end
self._submodules[name] = nil
if entry.unsub then
entry.unsub()
end
local child = repos[entry.worktree]
if child then
release_if_unused(child)
end
end
---@private
---@param name string
function Repo:_register_submodule(name)
local sub_gitdir = vim.fs.joinpath(self.gitdir, "modules", name)
local cfg = read_git_config(vim.fs.joinpath(sub_gitdir, "config"))
local raw = cfg and cfg.core and cfg.core.worktree
if not raw then
return
end
local wt = raw:match("^/") and raw or vim.fs.joinpath(sub_gitdir, raw)
wt = vim.fs.normalize(wt)
local existing = self._submodules[name]
if existing and existing.worktree == wt then
return
end
if existing then
self:_unregister_submodule(name)
end
local child = repos[wt] or M.resolve(wt)
if not child then
return
end
self._submodules[name] = {
worktree = wt,
unsub = child:on("change", function()
self:refresh()
end),
}
end
---@private
function Repo:_start_modules_watcher()
local dir = vim.fs.joinpath(self.gitdir, "modules")
if self._watchers[dir] then
return
end
if not vim.uv.fs_stat(dir) then
return
end
self._watchers[dir] = start_fs_event(dir, function(filename)
if not filename then
return
end
if vim.uv.fs_stat(vim.fs.joinpath(dir, filename)) then
self:_register_submodule(filename)
else
self:_unregister_submodule(filename)
end
end)
end
---@private
function Repo:_stop_modules_watcher()
local dir = vim.fs.joinpath(self.gitdir, "modules")
local w = self._watchers[dir]
if w then
w:stop()
w:close()
self._watchers[dir] = nil
end
for _, name in ipairs(vim.tbl_keys(self._submodules)) do
self:_unregister_submodule(name)
end
end
---@private
---@param relpath string
function Repo:_handle_fs_event(relpath)
if vim.startswith(relpath, "objects") then
return
end
self:_invalidate(relpath)
if relpath == "modules" and vim.g.git_submodule_recursion ~= false then
if vim.uv.fs_stat(vim.fs.joinpath(self.gitdir, "modules")) then
self:_start_modules_watcher()
for _, name in ipairs(find_submodules(self.gitdir)) do
self:_register_submodule(name)
end
else
self:_stop_modules_watcher()
end
end
if vim.startswith(relpath, "logs") then
return
end
self:refresh()
end
---@private
---@param relpath string gitdir-relative path of the directory to watch
function Repo:_watch_tree(relpath)
local path = vim.fs.joinpath(self.gitdir, relpath)
if self._watchers[path] then
return
end
local stat = vim.uv.fs_stat(path)
if not stat or stat.type ~= "directory" then
return
end
local watcher = start_fs_event(path, function(filename)
if not vim.uv.fs_stat(path) then
local w = self._watchers[path] --[[@as uv.uv_fs_event_t?]]
if w then
w:stop()
w:close()
self._watchers[path] = nil
end
return
end
if filename then
local child = vim.fs.joinpath(relpath, filename)
self:_handle_fs_event(child)
vim.schedule(function()
self:_watch_tree(child)
end)
else
self:refresh({ invalidate = true })
end
end)
if not watcher then
return
end
self._watchers[path] = watcher
local handle = vim.uv.fs_scandir(path)
if not handle then
return
end
while true do
local name, typ = vim.uv.fs_scandir_next(handle)
if not name then
break
end
if typ == "directory" then
self:_watch_tree(vim.fs.joinpath(relpath, name))
end
end
end
function Repo:start_watcher()
self._watchers = {}
local top = start_fs_event(self.gitdir, function(filename)
if not filename then
self:refresh({ invalidate = true })
return
end
self:_handle_fs_event(filename)
end)
if not top then
util.error("git: failed to watch %s", self.gitdir)
return
end
self._watchers[self.gitdir] = top
self:_watch_tree("refs")
end
function Repo:close()
for _, watcher in pairs(self._watchers) do
watcher:stop()
watcher:close()
end
self._watchers = {}
self:_stop_modules_watcher()
self._refresh_handle.close()
self._events:clear()
end
---@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
---@param buf? integer
---@return ow.Git.Repo.BufState?
function Repo:state(buf)
return self.buffers[expand_buf(buf)]
end
---@return string?
function Repo:head()
return self:get_cached("head", function(self)
local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r")
if not f then
return nil
end
local first = f:read("*l")
f:close()
if not first then
return nil
end
local branch = first:match("^ref:%s*refs/heads/(%S+)")
if branch then
return branch
end
local sha = first:match("^(%x+)")
if sha then
return sha:sub(1, 7)
end
return nil
end)
end
---@return string[]
function Repo:list_refs()
return self:get_cached("refs", function(self)
local out = util.git({
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
"refs/tags",
"refs/remotes",
}, { cwd = self.worktree, silent = true })
if not out then
return {}
end
return util.split_lines(out)
end)
end
---@return string[]
function Repo:list_pseudo_refs()
return self:get_cached("pseudo_refs", function(self)
local refs = {}
for _, name in ipairs(PSEUDO_REFS) do
if name == "HEAD" or vim.uv.fs_stat(self.gitdir .. "/" .. name) then
table.insert(refs, name)
end
end
return refs
end)
end
---@return string[]
function Repo:list_stash_refs()
return self:get_cached("stash_refs", function(self)
if not vim.uv.fs_stat(self.gitdir .. "/refs/stash") then
return {}
end
local refs = { "stash" }
local out = util.git(
{ "stash", "list", "--pretty=format:%gd" },
{ cwd = self.worktree, silent = true }
)
if out then
for _, entry in ipairs(util.split_lines(out)) do
table.insert(refs, entry)
end
end
return refs
end)
end
---@param rev string
---@param short boolean
---@return string?
function Repo:rev_parse(rev, short)
local args = { "rev-parse", "--verify", "--quiet" }
if short then
table.insert(args, "--short")
end
table.insert(args, rev)
local stdout = util.git(args, { cwd = self.worktree, silent = true })
local trimmed = stdout and vim.trim(stdout) or ""
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
---@return string? full_sha
---@return ow.Git.Repo.ResolveStatus
function Repo:resolve_sha(abbrev)
local result = self:get_cached("resolve:" .. abbrev, function(self)
local out = util.git(
{ "rev-parse", "--disambiguate=" .. abbrev },
{ cwd = self.worktree, silent = true }
)
local trimmed = out and vim.trim(out) or ""
if trimmed == "" then
return { nil, "missing" }
end
local lines = util.split_lines(trimmed)
if #lines == 1 then
return { lines[1], "ok" }
end
return { nil, "ambiguous" }
end)
return result[1], result[2]
end
---@private
---@return table<string, table<string, string>>
function Repo:_config()
return self:get_cached("config", function(self)
return read_git_config(vim.fs.joinpath(self.gitdir, "config")) or {}
end)
end
---@private
---@return boolean
function Repo:_ignorecase()
local cfg = self:_config()
return cfg.core and cfg.core.ignorecase == "true" or false
end
---@param rel string
---@return ow.Git.Status.Entry?
function Repo:status_entry_for(rel)
local direct = self.status.entries[rel]
if direct or not self:_ignorecase() then
return direct
end
local lower = rel:lower()
for path, entry in pairs(self.status.entries) do
if path:lower() == lower then
return entry
end
end
return nil
end
---@type table<string, true>
local no_repo_dirs = {}
---@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
---@param prefix string
---@param fn fun(buf: integer, r: ow.Git.Repo)
---@return fun() unsubscribe
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)
if name:sub(1, #prefix) == prefix then
fn(buf, r)
end
end
end
end)
end
---@return table<string, ow.Git.Repo>
function M.all()
return repos
end
---@param buf integer
---@return ow.Git.Repo?
local function find_by_buf(buf)
for _, r in pairs(repos) do
if r.buffers[buf] then
return r
end
end
return nil
end
---@param path string
---@return ow.Git.Repo?
local function find_by_path(path)
if path == "" then
return nil
end
if repos[path] then
return repos[path]
end
local best
for wt in pairs(repos) do
if path:sub(1, #wt + 1) == wt .. "/" then
if not best or #wt > #best then
best = wt
end
end
end
return best and repos[best] or nil
end
---@param buf integer
---@return string
local function path_for_buf(buf)
local path = vim.api.nvim_buf_get_name(buf)
if path == "" or util.is_uri(path) then
return vim.fn.getcwd()
end
return vim.fn.resolve(path)
end
---@param arg? integer | string bufnr (default current) or worktree path
---@return ow.Git.Repo?
function M.find(arg)
if type(arg) == "string" then
return find_by_path(arg)
end
local buf = expand_buf(arg)
return find_by_buf(buf) or find_by_path(path_for_buf(buf))
end
---@param arg? integer | string bufnr (default current) or worktree path
---@return ow.Git.Repo?
function M.resolve(arg)
if type(arg) ~= "string" then
local existing = find_by_buf(expand_buf(arg))
if existing then
return existing
end
end
local path
if type(arg) == "string" then
path = vim.fn.resolve(arg)
else
path = path_for_buf(expand_buf(arg))
end
local dir = vim.fs.dirname(path)
if no_repo_dirs[dir] then
return nil
end
local found = vim.fs.find(".git", { upward = true, path = path })[1]
if not found then
no_repo_dirs[dir] = true
return nil
end
local worktree = vim.fs.dirname(found)
if repos[worktree] then
return repos[worktree]
end
local stat = vim.uv.fs_stat(found)
if not stat then
return nil
end
local gitdir
if stat.type == "directory" then
gitdir = found
else
local f = io.open(found, "r")
if not f then
return nil
end
local content = f:read("*a")
f:close()
local rel = content:match("gitdir:%s*(%S+)")
if not rel then
util.error(".git file at %s has no `gitdir:` line", found)
return nil
end
gitdir = vim.fs.normalize(
rel:match("^/") and rel or vim.fs.joinpath(worktree, rel)
)
end
local r = Repo.new(gitdir, worktree)
repos[worktree] = r
for d in pairs(no_repo_dirs) do
if d == worktree or vim.startswith(d, worktree .. "/") then
no_repo_dirs[d] = nil
end
end
return r
end
---@param buf? integer
---@return ow.Git.Repo.BufState?
function M.state(buf)
buf = expand_buf(buf)
local r = find_by_buf(buf)
return r and r.buffers[buf]
end
---@param buf? integer
---@param r ow.Git.Repo
function M.bind(buf, r)
buf = expand_buf(buf)
local prev = find_by_buf(buf)
if prev == r then
return
end
if prev then
prev.buffers[buf] = nil
release_if_unused(prev)
end
r.buffers[buf] = { repo = r }
end
---@param buf? integer
function M.unbind(buf)
buf = expand_buf(buf)
local r = find_by_buf(buf)
if not r then
return
end
r.buffers[buf] = nil
release_if_unused(r)
end
---@param buf integer
---@return boolean
function M.is_worktree_buf(buf)
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
return false
end
local path = vim.api.nvim_buf_get_name(buf)
return path ~= "" and not util.is_uri(path)
end
---@param buf? integer
function M.track(buf)
buf = expand_buf(buf)
if not M.is_worktree_buf(buf) then
return
end
local r = M.resolve(buf)
if r and not r.buffers[buf] then
M.bind(buf, r)
end
end
---@param buf? integer
function M.refresh(buf)
local r = find_by_buf(expand_buf(buf))
if r then
r:refresh()
end
end
function M.refresh_all()
for _, r in pairs(repos) do
r:refresh()
end
end
function M.update_cwd_repo()
no_repo_dirs = {}
local tab = vim.api.nvim_get_current_tabpage()
local new = M.resolve(vim.fn.getcwd())
local old
for _, r in pairs(repos) do
if r.tabs[tab] then
old = r
break
end
end
if new == old then
return
end
if old then
old.tabs[tab] = nil
release_if_unused(old)
end
if new then
new.tabs[tab] = true
new:refresh()
end
end
---@param tab integer
function M.release_tab(tab)
for _, r in pairs(repos) do
if r.tabs[tab] then
r.tabs[tab] = nil
release_if_unused(r)
return
end
end
end
function M.stop_all()
for _, r in pairs(repos) do
r:close()
end
end
return M
-45
View File
@@ -1,45 +0,0 @@
---@class ow.Git.Revision
---@field stage 0|1|2|3?
---@field path string?
---@field base string?
local Revision = {}
Revision.__index = Revision
---@return string
function Revision:format()
if self.stage then
return ":" .. self.stage .. ":" .. self.path
elseif self.path then
return self.base .. ":" .. self.path
end
return self.base or error("Revision:format: empty Revision")
end
---@param parts { stage?: integer, base?: string, path?: string }
---@return ow.Git.Revision
function Revision.new(parts)
return setmetatable(parts, Revision)
end
---@param str string
---@return ow.Git.Revision
function Revision.parse(str)
local stage, path = str:match("^:([0123]):(.+)$")
if stage then
return Revision.new({
stage = tonumber(stage) --[[@as (0|1|2|3)?]],
path = path,
})
end
path = str:match("^:([^:]+)$")
if path then
return Revision.new({ stage = 0, path = path })
end
local base, p = str:match("^([^:]+):(.+)$")
if base then
return Revision.new({ base = base, path = p })
end
return Revision.new({ base = str })
end
return Revision
-383
View File
@@ -1,383 +0,0 @@
local M = {}
---@alias ow.Git.Status.Kind
---| "changed"
---| "unmerged"
---| "untracked"
---| "ignored"
---@class ow.Git.Status.Entry
---@field kind ow.Git.Status.Kind
---@field path string
---@alias ow.Git.Status.Change
---| "modified"
---| "added"
---| "deleted"
---| "renamed"
---| "copied"
---| "type_changed"
---@class ow.Git.Status.ChangedEntry: ow.Git.Status.Entry
---@field kind "changed"
---@field staged ow.Git.Status.Change?
---@field unstaged ow.Git.Status.Change?
---@field orig string?
---@alias ow.Git.Status.Conflict
---| "both_deleted"
---| "added_by_us"
---| "deleted_by_them"
---| "added_by_them"
---| "deleted_by_us"
---| "both_added"
---| "both_modified"
---@class ow.Git.Status.UnmergedEntry: ow.Git.Status.Entry
---@field kind "unmerged"
---@field conflict ow.Git.Status.Conflict
---@class ow.Git.Status.UntrackedEntry: ow.Git.Status.Entry
---@field kind "untracked"
---@class ow.Git.Status.IgnoredEntry: ow.Git.Status.Entry
---@field kind "ignored"
---@class ow.Git.Status.Mark
---@field char string
---@field hl string
---@alias ow.Git.Status.Section
--- "staged"|"unstaged"|"unmerged"|"untracked"|"ignored"
---@class ow.Git.Status.Row
---@field entry ow.Git.Status.Entry
---@field section ow.Git.Status.Section
---@field side ("staged"|"unstaged")?
---@class ow.Git.Status.Branch
---@field oid string?
---@field head string?
---@field upstream string?
---@field ahead integer
---@field behind integer
---@class ow.Git.Status
---@field branch ow.Git.Status.Branch
---@field entries table<string, ow.Git.Status.Entry>
local Status = {}
Status.__index = Status
local CHANGE_FROM_CHAR = {
M = "modified",
A = "added",
D = "deleted",
R = "renamed",
C = "copied",
T = "type_changed",
}
local CONFLICT_FROM_XY = {
DD = "both_deleted",
AU = "added_by_us",
UD = "deleted_by_them",
UA = "added_by_them",
DU = "deleted_by_us",
AA = "both_added",
UU = "both_modified",
}
local CHAR_FROM_CHANGE = {
modified = "M",
added = "A",
deleted = "D",
renamed = "R",
copied = "C",
type_changed = "T",
}
---@param s string
---@return string
local function pascal(s)
return (
s:sub(1, 1):upper()
.. s:sub(2):gsub("_(%a)", function(c)
return c:upper()
end)
)
end
---@param path string
---@param staged ow.Git.Status.Change?
---@param unstaged ow.Git.Status.Change?
---@param orig string?
---@return ow.Git.Status.ChangedEntry
local function changed(path, staged, unstaged, orig)
return {
kind = "changed",
path = path,
staged = staged,
unstaged = unstaged,
orig = orig,
}
end
---@param path string
---@param conflict ow.Git.Status.Conflict
---@return ow.Git.Status.UnmergedEntry
local function unmerged(path, conflict)
return { kind = "unmerged", path = path, conflict = conflict }
end
---@param path string
---@return ow.Git.Status.UntrackedEntry
local function untracked(path)
return { kind = "untracked", path = path }
end
---@param path string
---@return ow.Git.Status.IgnoredEntry
local function ignored(path)
return { kind = "ignored", path = path }
end
---@param entry ow.Git.Status.Entry
---@param side ("staged"|"unstaged")?
---@return ow.Git.Status.Mark
function M.mark_for(entry, side)
if entry.kind == "untracked" then
return { char = "?", hl = "GitUntracked" }
end
if entry.kind == "ignored" then
return { char = "i", hl = "GitIgnored" }
end
if entry.kind == "unmerged" then
---@cast entry ow.Git.Status.UnmergedEntry
return { char = "!", hl = "GitUnmerged" .. pascal(entry.conflict) }
end
---@cast entry ow.Git.Status.ChangedEntry
assert(side, "mark_for: side required for changed entry")
local change = side == "staged" and entry.staged or entry.unstaged
assert(change, "mark_for: changed entry has no change on side " .. side)
return {
char = CHAR_FROM_CHANGE[change],
hl = "Git" .. pascal(side) .. pascal(change),
}
end
---@param entry ow.Git.Status.Entry
---@return ow.Git.Status.Mark[]
function M.marks_for(entry)
if entry.kind ~= "changed" then
return { M.mark_for(entry) }
end
---@cast entry ow.Git.Status.ChangedEntry
local out = {}
if entry.staged then
table.insert(out, M.mark_for(entry, "staged"))
end
if entry.unstaged then
table.insert(out, M.mark_for(entry, "unstaged"))
end
return out
end
---@param section ow.Git.Status.Section
---@return ow.Git.Status.Row[]
function Status:rows(section)
local out = {}
if section == "staged" or section == "unstaged" then
for _, entry in pairs(self.entries) do
if entry.kind == "changed" then
---@cast entry ow.Git.Status.ChangedEntry
if entry[section] then
table.insert(
out,
{ entry = entry, section = section, side = section }
)
end
end
end
else
for _, entry in pairs(self.entries) do
if entry.kind == section then
table.insert(out, { entry = entry, section = section })
end
end
end
return out
end
---@param prefix string
---@return ow.Git.Status.Mark[]
function Status:aggregate_at(prefix)
local match = (prefix == "" or prefix == ".") and "" or prefix .. "/"
local seen = {}
local out = {}
for path, entry in pairs(self.entries) do
if path == prefix or vim.startswith(path, match) then
for _, mark in ipairs(M.marks_for(entry)) do
local key = mark.char .. "\0" .. mark.hl
if not seen[key] then
seen[key] = true
table.insert(out, mark)
end
end
end
end
table.sort(out, function(a, b)
return a.char < b.char
end)
return out
end
---@param line string
---@param branch ow.Git.Status.Branch
local function parse_branch_header(line, branch)
local oid = line:match("^# branch%.oid (.+)$")
if oid then
branch.oid = oid ~= "(initial)" and oid or nil
return
end
local head = line:match("^# branch%.head (.+)$")
if head then
branch.head = head ~= "(detached)" and head or nil
return
end
local up = line:match("^# branch%.upstream (.+)$")
if up then
branch.upstream = up
return
end
local a, b = line:match("^# branch%.ab %+(%d+) %-(%d+)$")
if a and b then
branch.ahead = tonumber(a) --[[@as integer]]
branch.behind = tonumber(b) --[[@as integer]]
end
end
---@param x string
---@param y string
---@return ow.Git.Status.Change?, ow.Git.Status.Change?
local function changes_from_xy(x, y)
local staged = x ~= "." and CHANGE_FROM_CHAR[x] or nil
local unstaged = y ~= "." and CHANGE_FROM_CHAR[y] or nil
return staged, unstaged
end
---@param path string
---@return string
local function strip_dir_slash(path)
if path:sub(-1) == "/" then
return path:sub(1, -2)
end
return path
end
---@param a ow.Git.Status.Entry?
---@param b ow.Git.Status.Entry?
---@return boolean
function M.entry_equal(a, b)
if a == nil or b == nil then
return a == b
end
if a.kind ~= b.kind or a.path ~= b.path then
return false
end
if a.kind == "changed" then
---@cast a ow.Git.Status.ChangedEntry
---@cast b ow.Git.Status.ChangedEntry
return a.staged == b.staged
and a.unstaged == b.unstaged
and a.orig == b.orig
end
if a.kind == "unmerged" then
---@cast a ow.Git.Status.UnmergedEntry
---@cast b ow.Git.Status.UnmergedEntry
return a.conflict == b.conflict
end
return true
end
---@param prior table<string, ow.Git.Status.Entry>
---@param next_ table<string, ow.Git.Status.Entry>
---@return table<string, true>
function M.diff_entries(prior, next_)
local paths = {}
for path, entry in pairs(next_) do
if not M.entry_equal(prior[path], entry) then
paths[path] = true
end
end
for path in pairs(prior) do
if next_[path] == nil then
paths[path] = true
end
end
return paths
end
---@param stdout string
---@return ow.Git.Status
function M.parse(stdout)
---@type ow.Git.Status.Branch
local branch = { ahead = 0, behind = 0 }
---@type table<string, ow.Git.Status.Entry>
local entries = {}
local tokens = vim.split(stdout, "\0", { plain = true })
while #tokens > 0 and tokens[#tokens] == "" do
tokens[#tokens] = nil
end
local i = 1
while i <= #tokens do
local line = tokens[i] --[[@as string]]
local tag = line:sub(1, 2)
if tag == "# " then
parse_branch_header(line, branch)
elseif tag == "1 " then
local xy, _, _, _, _, _, _, path =
line:match("^1 (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$")
if xy and path then
local key = strip_dir_slash(path)
local staged, unstaged =
changes_from_xy(xy:sub(1, 1), xy:sub(2, 2))
entries[key] = changed(key, staged, unstaged)
end
elseif tag == "2 " then
local xy, _, _, _, _, _, _, _, path = line:match(
"^2 (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$"
)
local orig = tokens[i + 1]
if xy and path and orig then
local key = strip_dir_slash(path)
local staged, unstaged =
changes_from_xy(xy:sub(1, 1), xy:sub(2, 2))
entries[key] = changed(key, staged, unstaged, orig)
i = i + 1
end
elseif tag == "u " then
local xy, _, _, _, _, _, _, _, _, path = line:match(
"^u (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$"
)
local conflict = xy and CONFLICT_FROM_XY[xy] --[[@as ow.Git.Status.Conflict?]]
or nil
if conflict and path then
local key = strip_dir_slash(path)
entries[key] = unmerged(key, conflict)
end
elseif tag == "? " then
local key = strip_dir_slash(line:sub(3))
entries[key] = untracked(key)
elseif tag == "! " then
local key = strip_dir_slash(line:sub(3))
entries[key] = ignored(key)
end
i = i + 1
end
return setmetatable({ branch = branch, entries = entries }, Status)
end
return M
-352
View File
@@ -1,352 +0,0 @@
local M = {}
---@class ow.Git.Util.ScratchOpts
---@field name string?
---@field bufhidden ("hide"|"wipe"|"delete")?
---@field buftype ("nofile"|"acwrite"|"nowrite")?
---@field modifiable boolean?
---@param buf integer
---@param opts ow.Git.Util.ScratchOpts
function M.setup_scratch(buf, opts)
vim.bo[buf].buftype = opts.buftype or "nofile"
vim.bo[buf].bufhidden = opts.bufhidden or "wipe"
vim.bo[buf].swapfile = false
vim.bo[buf].modifiable = opts.modifiable == true
vim.bo[buf].modified = false
vim.bo[buf].buflisted = false
if opts.name then
pcall(vim.api.nvim_buf_set_name, buf, opts.name)
end
end
---@param name string
---@return boolean
function M.is_uri(name)
return name:match("^%a+://") ~= nil
end
---@param sha string?
---@return boolean
function M.is_zero_sha(sha)
return sha == nil or sha:match("^0+$") ~= nil
end
---@param buf integer
---@param name string
function M.set_buf_name(buf, name)
pcall(vim.api.nvim_buf_set_name, buf, name)
local ft = vim.filetype.match({ buf = buf })
if ft then
vim.bo[buf].filetype = ft
end
end
---@param buf integer
---@param split (false|"above"|"below"|"left"|"right")?
---@return integer win
function M.place_buf(buf, split)
if split == false then
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(buf)
return vim.api.nvim_get_current_win()
end
local win = vim.api.nvim_open_win(buf, true, {
split = split or (vim.o.splitbelow and "below" or "above"),
})
vim.cmd.clearjumps()
return win
end
---@class ow.Git.Util.NewScratchOpts : ow.Git.Util.ScratchOpts
---@field split (false|"above"|"below"|"left"|"right")?
---@param opts ow.Git.Util.NewScratchOpts?
---@return integer buf
---@return integer win
function M.new_scratch(opts)
opts = opts or {}
local buf = vim.api.nvim_create_buf(false, true)
M.setup_scratch(buf, opts)
return buf, M.place_buf(buf, opts.split)
end
---@param fmt string
---@param ... any
function M.error(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.ERROR)
end
---@param fmt string
---@param ... any
function M.warning(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.WARN)
end
---@param fmt string
---@param ... any
function M.info(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.INFO)
end
---@param fmt string
---@param ... any
function M.debug(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.DEBUG)
end
---@param buf integer
---@param start integer
---@param end_ integer
---@param lines string[]
function M.set_buf_lines(buf, start, end_, lines)
if not vim.api.nvim_buf_is_loaded(buf) then
return
end
local was_modifiable = vim.bo[buf].modifiable
vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(buf, start, end_, true, lines)
vim.bo[buf].modifiable = was_modifiable
vim.bo[buf].modified = false
end
---@param content string
---@return string[]
function M.split_lines(content)
local lines = vim.split(content, "\n", { plain = true, trimempty = false })
if #lines > 0 and lines[#lines] == "" then
table.remove(lines)
end
return lines
end
---@class ow.Git.Util.DebounceHandle
---@field cancel fun()
---@field flush fun()
---@field pending fun(): boolean
---@field close fun()
---@generic F: fun(...)
---@param fn F
---@param delay integer
---@return F, ow.Git.Util.DebounceHandle
function M.debounce(fn, delay)
local timer, err = vim.uv.new_timer()
if not timer then
M.warning("git: failed to create timer: %s", err)
local noop = function() end
return fn,
{
cancel = noop,
flush = noop,
pending = function()
return false
end,
close = noop,
}
end
local args ---@type table?
local gen = 0
local fired_gen = 0
local cb_main = vim.schedule_wrap(function()
-- Identity check: the libuv fire may have been superseded by
-- a re-arm or a cancel between the timer firing and this
-- scheduled callback running.
if fired_gen ~= gen or args == nil then
return
end
local a = args
args = nil
fn(vim.F.unpack_len(a))
end)
local cb_uv = function()
fired_gen = gen
cb_main()
end
local function call(...)
args = vim.F.pack_len(...)
gen = gen + 1
timer:start(delay, 0, cb_uv)
end
return call,
{
cancel = function()
timer:stop()
args = nil
end,
flush = function()
if args == nil then
return
end
timer:stop()
local a = args
args = nil
fn(vim.F.unpack_len(a))
end,
pending = function()
return args ~= nil
end,
close = function()
timer:stop()
if not timer:is_closing() then
timer:close()
end
args = nil
end,
}
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?
---@field silent boolean?
---@field env table<string, string>?
---@field on_exit fun(result: vim.SystemCompleted)?
---@param cmd string[]
---@param opts ow.Git.Util.ExecOpts?
---@return string?
function M.exec(cmd, opts)
opts = opts or {}
local sys_opts = {
cwd = opts.cwd,
stdin = opts.stdin,
env = opts.env,
text = true,
}
if opts.on_exit then
vim.system(cmd, sys_opts, vim.schedule_wrap(opts.on_exit))
return nil
end
local result = vim.system(cmd, sys_opts):wait()
if result.code ~= 0 then
if not opts.silent then
local label = cmd[2] and (cmd[1] .. " " .. cmd[2]) or cmd[1] or "?"
M.error("%s failed: %s", label, vim.trim(result.stderr or ""))
end
return nil
end
return result.stdout or ""
end
M.DEFAULT_GIT_ENV = {
GIT_TERMINAL_PROMPT = "false",
}
---@param args string[]
---@param opts ow.Git.Util.ExecOpts?
---@return string?
function M.git(args, opts)
opts = opts or {}
opts.env = vim.tbl_extend("force", M.DEFAULT_GIT_ENV, opts.env or {})
local cmd = { "git" }
vim.list_extend(cmd, args)
return M.exec(cmd, opts)
end
---@class ow.Git.Util.Emitter<T>
---@field private _listeners table<T, (fun(...))[]>
local Emitter = {}
Emitter.__index = Emitter
---@return ow.Git.Util.Emitter<T>
function Emitter.new()
return setmetatable({ _listeners = {} }, Emitter)
end
---@param event T
---@param fn fun(...)
---@return fun() unsubscribe
function Emitter:on(event, fn)
local list = self._listeners[event] or {}
self._listeners[event] = list
table.insert(list, fn)
return function()
for i, f in ipairs(list) do
if f == fn then
table.remove(list, i)
return
end
end
end
end
---@param event T
function Emitter:emit(event, ...)
for _, fn in ipairs(self._listeners[event] or {}) do
fn(...)
end
end
function Emitter:clear()
self._listeners = {}
end
M.Emitter = Emitter
return M
-134
View File
@@ -1,134 +0,0 @@
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
-117
View File
@@ -1,117 +0,0 @@
local util = require("git.core.util")
local M = {}
local SENTINEL = "__NVIM_GIT_EDIT__"
local SCRIPT = string.format(
[=[set -eu
flag="${TMPDIR:-/tmp}/nvim-git-editor-$$.done"
trap 'rm -f "$flag"' EXIT
abs=$(realpath "$1")
printf '%s\t%%s\t%%s\n' "$flag" "$abs" >&2
while [ ! -e "$flag" ]; do
sleep 0.05
done
]=],
SENTINEL
)
---@param s string
---@return string
local function shq(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
local GIT_EDITOR = "sh -c " .. shq(SCRIPT) .. " --"
---@param on_open fun(file_path: string, done: fun())
---@return fun(err: string?, data: string?), fun(result: vim.SystemCompleted)
local function build_stderr_handler(on_open)
local pending = ""
local stderr_buf = {}
local function dispatch(flag_path, abs_path)
vim.schedule(function()
local fired = false
local function done()
if fired then
return
end
fired = true
local fw = io.open(flag_path, "w")
if fw then
fw:close()
end
end
local ok, err = pcall(on_open, abs_path, done)
if not ok then
util.error("git.editor on_open failed: %s", tostring(err))
done()
end
end)
end
local pattern = "^" .. SENTINEL .. "\t(.-)\t(.+)$"
local function on_stderr(_, data)
if not data or data == "" then
return
end
pending = pending .. data
while true do
local nl = pending:find("\n", 1, true)
if not nl then
break
end
local line = pending:sub(1, nl - 1)
pending = pending:sub(nl + 1)
local flag, abs = line:match(pattern)
if flag then
dispatch(flag, abs)
else
table.insert(stderr_buf, line)
table.insert(stderr_buf, "\n")
end
end
end
local function finalize(result)
if pending ~= "" then
table.insert(stderr_buf, pending)
end
result.stderr = table.concat(stderr_buf)
end
return on_stderr, finalize
end
---@param cmd string[]
---@param opts? { cwd?: string, env?: table<string,string> }
---@param on_open fun(file_path: string, done: fun())
---@param on_exit fun(result: vim.SystemCompleted)
function M.run(cmd, opts, on_open, on_exit)
opts = opts or {}
local on_stderr, finalize = build_stderr_handler(on_open)
local env = vim.tbl_extend("force", opts.env or {}, {
GIT_EDITOR = GIT_EDITOR,
GIT_SEQUENCE_EDITOR = GIT_EDITOR,
})
vim.system(
cmd,
{
cwd = opts.cwd,
text = true,
env = env,
stderr = on_stderr,
},
vim.schedule_wrap(function(result)
finalize(result)
on_exit(result)
end)
)
end
return M
-970
View File
@@ -1,970 +0,0 @@
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
-143
View File
@@ -1,143 +0,0 @@
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
local LOG_FORMAT = "%h %ad {%an}%d %s"
local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
---@param buf integer
---@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 not sha then
return false
end
---@cast r -nil
require("git.object").open(r, sha, { split = false })
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
---@param max_count integer?
---@return string?
local function fetch(worktree, max_count)
local args = {
"log",
"--graph",
"--all",
"--decorate",
"--date=short",
"--format=format:" .. LOG_FORMAT,
}
if max_count then
table.insert(args, "--max-count=" .. max_count)
end
return util.git(args, { cwd = worktree })
end
---@type table<string, integer> -- worktree -> max_count
local max_counts = {}
---@param buf integer
---@param r ow.Git.Repo
local function populate(buf, r)
local stdout = fetch(r.worktree, max_counts[r.worktree])
if not stdout then
return
end
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
end
---@class ow.Git.Log.OpenOpts
---@field max_count integer?
---@type table<string, fun(s: string): any>
M.opt_parsers = {
max_count = tonumber,
}
---@param opts ow.Git.Log.OpenOpts?
function M.open(opts)
opts = opts or {}
local r = repo.resolve()
if not r then
util.error("not in a git repository")
return
end
max_counts[r.worktree] = opts.max_count
local buf = vim.fn.bufadd(r.worktree .. "/GitLog")
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
function M.run_glog(cmd_opts)
local parsed = { max_count = 1000 }
for _, a in ipairs(cmd_opts.fargs) do
local k, v = a:match("^([%w_]+)=(.*)$")
if not k then
util.error("invalid argument: %s", a)
return
end
---@cast v -nil
local parser = M.opt_parsers[k]
if parser then
local value = parser(v)
if value ~= nil then
parsed[k] = value
end
end
end
M.open(parsed)
end
---@param arg_lead string
---@return string[]
function M.complete_glog(arg_lead)
local matches = {}
for k in pairs(M.opt_parsers) do
local prefix = k .. "="
if prefix:sub(1, #arg_lead) == arg_lead then
table.insert(matches, prefix)
end
end
table.sort(matches)
return matches
end
return M
-434
View File
@@ -1,434 +0,0 @@
local Revision = require("git.core.revision")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
M.URI_PREFIX = "git://"
---@param rev ow.Git.Revision
---@return string
function M.format_uri(rev)
return M.URI_PREFIX .. rev:format()
end
---@param str string
---@return ow.Git.Revision?
function M.parse_uri(str)
local raw = str:match("^" .. M.URI_PREFIX .. "(.+)$")
if raw then
return Revision.parse(raw)
end
end
---@class ow.Git.DiffSection
---@field path_a string
---@field path_b string
---@field blob_a string?
---@field blob_b string?
---@return ow.Git.DiffSection?
local function diff_section()
local diff_lnum = vim.fn.search("^diff --git ", "bcnW")
if diff_lnum == 0 then
return nil
end
local diff_line =
vim.api.nvim_buf_get_lines(0, diff_lnum - 1, diff_lnum, false)[1]
if not diff_line then
return nil
end
local path_a, path_b = diff_line:match("^diff %-%-git a/(.-) b/(.+)$")
if not path_a or not path_b then
return nil
end
local header =
vim.api.nvim_buf_get_lines(0, diff_lnum, diff_lnum + 20, false)
local blob_a, blob_b
for _, l in ipairs(header) do
if l:sub(1, 3) == "@@ " or l:match("^diff %-%-git ") then
break
end
local pre, post = l:match("^index (%x+)%.%.(%x+)")
if pre then
blob_a = pre
blob_b = post
break
end
end
return {
path_a = path_a,
path_b = path_b,
blob_a = blob_a,
blob_b = blob_b,
}
end
---@param rev ow.Git.Revision
---@return boolean
local function is_immutable_rev(rev)
if rev.stage ~= nil then
return false
end
local base = rev.base
if not base then
return false
end
local stripped = base:gsub("%^%b{}", ""):gsub("[%^~]%d*", "")
return stripped:match("^%x+$") ~= nil and #stripped >= 7
end
---@param buf integer
---@param r ow.Git.Repo
---@param path string
local function attach_index_writer(buf, r, path)
vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = buf,
callback = function()
local body = table.concat(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n"
) .. "\n"
local hash_stdout = util.git(
{ "hash-object", "-w", "--stdin" },
{ cwd = r.worktree, stdin = body }
)
if not hash_stdout then
return
end
local sha = vim.trim(hash_stdout)
local state = r:state(buf)
local mode = state and state.index_mode
if not mode then
mode = "100644"
local ls = util.git(
{ "ls-files", "-s", "--", path },
{ cwd = r.worktree, silent = true }
)
if ls then
local m = ls:match("^(%d+)")
if m then
mode = m
end
end
if state then
state.index_mode = mode
end
end
if
not util.git({
"update-index",
"--cacheinfo",
mode,
sha,
path,
}, { cwd = r.worktree })
then
return
end
if state then
state.sha = r:rev_parse(":" .. path, true)
end
vim.bo[buf].modified = false
end,
})
end
local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
---@param buf integer
function M.attach_dispatch(buf)
vim.keymap.set("n", "<CR>", function()
if not M.open_under_cursor() then
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
---@param rev ow.Git.Revision
---@return integer
function M.buf_for(r, rev)
local buf = vim.fn.bufadd(M.format_uri(rev))
repo.bind(buf, r)
vim.fn.bufload(buf)
return buf
end
---@param buf integer
---@param r ow.Git.Repo
---@param rev ow.Git.Revision
---@param state ow.Git.Repo.BufState
---@param rev_sha string
---@return boolean ok
local function populate(buf, r, rev, state, rev_sha)
local rev_str = rev:format()
local stdout = util.git({ "cat-file", "-p", rev_str }, { cwd = r.worktree })
if not stdout then
return false
end
if rev.path == nil then
local commit_sha = r:rev_parse(rev_str .. "^{commit}", true)
if commit_sha then
local patch = util.git({
"diff-tree",
"-p",
"--diff-merges=first-parent",
"--root",
"--no-commit-id",
commit_sha,
}, { cwd = r.worktree })
if patch then
stdout = (stdout:gsub("\n*$", "\n\n")) .. patch
end
end
end
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
state.sha = rev_sha
return true
end
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local rev = M.parse_uri(name)
if not rev then
return
end
local r = repo.resolve(buf)
if not r then
util.error("git BufReadCmd %s: cannot resolve worktree", name)
return
end
repo.bind(buf, r)
local state = r:state(buf) --[[@as -nil]]
local writable = rev.stage == 0 and rev.path ~= nil
util.setup_scratch(buf, {
bufhidden = "delete",
buftype = writable and "acwrite" or "nofile",
modifiable = writable,
})
local rev_sha = r:rev_parse(rev:format(), true)
if not rev_sha then
return
end
if not populate(buf, r, rev, state, rev_sha) then
return
end
state.immutable = is_immutable_rev(rev)
if writable and not state.index_writer then
attach_index_writer(buf, r, rev.path --[[@as string]])
state.index_writer = true
end
if rev.path then
local ft = vim.filetype.match({ filename = rev.path, buf = buf })
if ft then
vim.bo[buf].filetype = ft
end
else
vim.bo[buf].filetype = "git"
end
M.attach_dispatch(buf)
vim.api.nvim_buf_call(buf, function()
vim.api.nvim_exec_autocmds("BufReadPost", { buf = buf })
end)
end
---@param buf integer
---@param r ow.Git.Repo
local function refresh(buf, r)
local state = r:state(buf)
if not state or state.immutable or vim.bo[buf].modified then
return
end
local rev = M.parse_uri(vim.api.nvim_buf_get_name(buf))
if not rev then
return
end
local rev_sha = r:rev_parse(rev:format(), true)
if not rev_sha or rev_sha == state.sha then
return
end
if state.sha == nil then
M.read_uri(buf)
else
populate(buf, r, rev, state, rev_sha)
end
end
---@param buf integer
---@param path string
local function set_ft_from_path(buf, path)
local ft = vim.filetype.match({ filename = path, buf = buf })
if ft then
vim.bo[buf].filetype = ft
end
end
---@param r ow.Git.Repo
---@param blob string?
---@param path string
---@return integer?
local function side_buf(r, blob, path)
if not blob or util.is_zero_sha(blob) then
return nil
end
local full, status = r:resolve_sha(blob)
if status == "ambiguous" then
util.error("ambiguous blob abbreviation: %s", blob)
return nil
end
if full then
local buf = M.buf_for(r, Revision.new({ base = full }))
set_ft_from_path(buf, path)
return buf
end
local p = vim.fs.joinpath(r.worktree, path)
if vim.uv.fs_stat(p) then
local buf = vim.fn.bufadd(p)
vim.fn.bufload(buf)
return buf
end
return nil
end
---@param r ow.Git.Repo
---@param blob string?
---@param path string
local function load_side(r, blob, path)
local buf = side_buf(r, blob, path)
if not buf then
util.error("no content for %s", path)
return
end
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(buf)
end
---@param r ow.Git.Repo
---@param section ow.Git.DiffSection
local function open_section(r, section)
if not section.blob_a or not section.blob_b then
util.error("no index line, cannot determine blob SHAs")
return
end
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
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
util.error("no content for %s", section.path_b)
return
end
local buf = left or right
---@cast buf -nil
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(buf)
end
---@class ow.Git.Object.OpenOpts
---@field split (false|"above"|"below"|"left"|"right")?
---@param r ow.Git.Repo
---@param rev string
---@param opts ow.Git.Object.OpenOpts?
function M.open(r, rev, opts)
local parsed = Revision.parse(rev)
if parsed.base then
local sha = r:rev_parse(parsed.base, false)
if not sha then
util.error("not a git object: %s", rev)
return
end
parsed.base = sha
end
if parsed.path and not r:rev_parse(parsed:format(), false) then
util.error("not a git object: %s", rev)
return
end
local buf = M.buf_for(r, parsed)
util.place_buf(buf, opts and opts.split)
end
---@return boolean dispatched
function M.open_under_cursor()
local r = repo.resolve()
if not r then
return false
end
local line = vim.api.nvim_get_current_line()
local sha = line:match("^commit (%x+)$")
or line:match("^parent (%x+)$")
or line:match("^tree (%x+)$")
or line:match("^object (%x+)$")
if sha then
M.open(r, sha, { split = false })
return true
end
local entry_type, entry_sha, entry_name =
line:match("^%d+ (%w+) (%x+)\t(.+)$")
if entry_sha then
if entry_type == "blob" then
load_side(r, entry_sha, entry_name --[[@as string]])
else
M.open(r, entry_sha, { split = false })
end
return true
end
local section = diff_section()
if not section then
return false
end
if line:match("^diff %-%-git ") then
open_section(r, section)
return true
end
if line:match("^%-%-%- ") then
load_side(r, section.blob_a, section.path_a)
return true
end
if line:match("^%+%+%+ ") then
load_side(r, section.blob_b, section.path_b)
return true
end
local prefix = line:sub(1, 1)
if prefix == "+" then
load_side(r, section.blob_b, section.path_b)
return true
elseif prefix == "-" then
load_side(r, section.blob_a, section.path_a)
return true
end
return false
end
repo.on_uri_change(M.URI_PREFIX, refresh)
return M
-740
View File
@@ -1,740 +0,0 @@
local Revision = require("git.core.revision")
local diffsplit = require("git.diffsplit")
local object = require("git.object")
local repo = require("git.core.repo")
local status = require("git.core.status")
local util = require("git.core.util")
local M = {}
---@type ow.Git.StatusView.Placement[]
M.PLACEMENTS = { "sidebar", "split", "current" }
---@type ow.Git.Status.Section[]
local SECTIONS = { "untracked", "unstaged", "staged", "unmerged" }
local WINDOW_WIDTH = 50
---@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"
---@class ow.Git.StatusView.Header
---@field is_header true
---@field section ow.Git.Status.Section
---@alias ow.Git.StatusView.Item ow.Git.Status.Row | ow.Git.StatusView.Header
---@class ow.Git.StatusView.State
---@field repo ow.Git.Repo
---@field placement ow.Git.StatusView.Placement
---@field lines table<integer, ow.Git.StatusView.Item>
---@field win integer?
---@field unsubscribe fun()?
---@type table<integer, ow.Git.StatusView.State>
local state = {}
local group =
vim.api.nvim_create_augroup("ow.git.status_win", { clear = true })
local ns = vim.api.nvim_create_namespace("ow.git.status_win")
---@return integer? win
---@return integer? bufnr
local function find_view()
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
local buf = vim.api.nvim_win_get_buf(win)
if vim.bo[buf].filetype == "gitstatus" then
return win, buf
end
end
end
---@param win integer?
---@return boolean
local function valid_in_current_tab(win)
if not win or not vim.api.nvim_win_is_valid(win) then
return false
end
return vim.api.nvim_win_get_tabpage(win)
== vim.api.nvim_get_current_tabpage()
end
---@param s ow.Git.StatusView.State
---@return integer?
local function win_for(s)
if valid_in_current_tab(s.win) then
return s.win
end
local win = find_view()
s.win = win
return win
end
---@param row ow.Git.Status.Row
---@return string line
---@return string hl_group
---@return integer hl_len
local function format_row(row)
local entry = row.entry
local orig
if entry.kind == "changed" then
---@cast entry ow.Git.Status.ChangedEntry
orig = entry.orig
end
local label = orig and (orig .. " -> " .. entry.path) or entry.path
local mark = status.mark_for(entry, row.side)
return string.format(" %s %s", mark.char, label), mark.hl, #mark.char
end
---@param section ow.Git.Status.Section
---@return string
local function display_name(section)
return (section:gsub("^%l", string.upper))
end
---@param bufnr integer
---@param r ow.Git.Repo
local function render(bufnr, r)
local status = r.status
local branch = status.branch
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 up = "Upstream: " .. branch.upstream
local extras = {}
if branch.ahead > 0 then
local col = #up + 1
up = up .. " +" .. branch.ahead
table.insert(extras, {
col = col,
end_col = #up,
hl = "GitUnpushed",
})
end
if branch.behind > 0 then
local col = #up + 1
up = up .. " -" .. branch.behind
table.insert(extras, {
col = col,
end_col = #up,
hl = "GitUnpulled",
})
end
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, "")
for _, section in ipairs(SECTIONS) do
local rows = status:rows(section)
if #rows > 0 then
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)
meta[#lines] = row
table.insert(marks, {
row = #lines - 1,
col = 2,
end_col = 2 + hl_len,
hl = hl,
})
end
table.insert(lines, "")
end
end
util.set_buf_lines(bufnr, 0, -1, lines)
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
for _, m in ipairs(marks) do
vim.api.nvim_buf_set_extmark(bufnr, ns, m.row, m.col, {
end_col = m.end_col,
hl_group = m.hl,
})
end
state[bufnr].lines = meta
end
---@param bufnr integer
local function refresh(bufnr)
local s = state[bufnr]
if not s or not vim.api.nvim_buf_is_valid(bufnr) then
return
end
render(bufnr, s.repo)
end
---@param bufnr integer
---@return ow.Git.StatusView.State?
---@return ow.Git.StatusView.Item?
local function current_entry(bufnr)
local s = state[bufnr]
if not s then
return nil, nil
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
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.StatusView.Pane
local function head_pane(r, path)
local rev = Revision.new({ base = "HEAD", path = path })
return {
buf = object.buf_for(r, rev),
name = object.format_uri(rev),
}
end
---@param r ow.Git.Repo
---@param path string
---@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)
return { buf = buf, name = nil }
end
---@param s ow.Git.StatusView.State
---@param path string
---@return ow.Git.StatusView.Pane
local function index_pane(s, path)
local rev = Revision.new({ stage = 0, path = path })
return {
buf = object.buf_for(s.repo, rev),
name = object.format_uri(rev),
}
end
---@param s ow.Git.StatusView.State
---@param row ow.Git.Status.Row
---@return ow.Git.StatusView.Pane?
local function older_pane(s, row)
local entry = row.entry
if row.section == "staged" then
---@cast entry ow.Git.Status.ChangedEntry
if entry.staged == "added" then
return nil
end
return head_pane(s.repo, entry.orig or entry.path)
end
if row.section == "unstaged" then
return index_pane(s, entry.path)
end
return nil
end
---@param s ow.Git.StatusView.State
---@param row ow.Git.Status.Row
---@return ow.Git.StatusView.Pane?
local function newer_pane(s, row)
local entry = row.entry
if row.section == "staged" then
---@cast entry ow.Git.Status.ChangedEntry
if entry.staged == "deleted" then
return nil
end
return index_pane(s, entry.path)
end
if row.section == "unstaged" then
---@cast entry ow.Git.Status.ChangedEntry
if entry.unstaged == "deleted" then
return nil
end
return worktree_pane(s.repo, entry.path)
end
if row.section == "untracked" then
return worktree_pane(s.repo, entry.path)
end
return nil
end
---@param target_win integer
---@param dir "left"|"right"
---@return integer
local function vsplit_at(target_win, dir)
local win = vim.api.nvim_open_win(
vim.api.nvim_win_get_buf(target_win),
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 status_win integer
---@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
end
end
---@param s ow.Git.StatusView.State
---@param row ow.Git.Status.Row
---@param focus_left boolean
local function view_row(s, row, focus_left)
local status_win = win_for(s)
if not status_win then
return
end
local left = older_pane(s, row)
local right = newer_pane(s, row)
if not left and not right then
util.warning(
"no content for %s row: %s",
row.section,
row.entry.path
)
return
end
if s.placement ~= "sidebar" then
local pane = right or left
---@cast pane -nil
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_win_set_buf(status_win, pane.buf)
if pane.name then
util.set_buf_name(pane.buf, pane.name)
end
return
end
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 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
vim.api.nvim_set_current_win(focus_left and target or status_win)
return
end
---@cast left ow.Git.StatusView.Pane
---@cast right ow.Git.StatusView.Pane
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
vim.api.nvim_set_current_win(focus_left and left_win or status_win)
end
---@param focus_left boolean
local function preview_or_open(focus_left)
local s, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item or item.is_header then
return
end
---@cast item ow.Git.Status.Row
view_row(s, item, focus_left)
end
local function action_stage()
local s, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item then
return
end
local paths = {}
if item.is_header then
if item.section == "staged" or item.section == "ignored" then
return
end
for _, row in ipairs(s.repo.status:rows(item.section)) do
table.insert(paths, row.entry.path)
end
else
---@cast item ow.Git.Status.Row
if item.section == "staged" then
return
end
table.insert(paths, item.entry.path)
end
if #paths == 0 then
return
end
local args = { "add", "--" }
vim.list_extend(args, paths)
util.git(args, {
cwd = s.repo.worktree,
on_exit = function(result)
if result.code ~= 0 then
util.error("git add failed: %s", vim.trim(result.stderr or ""))
end
end,
})
end
local function action_unstage()
local s, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item then
return
end
local rows
if item.is_header then
if item.section ~= "staged" then
return
end
rows = s.repo.status:rows("staged")
else
---@cast item ow.Git.Status.Row
if item.section ~= "staged" then
return
end
rows = { item }
end
---@cast rows ow.Git.Status.Row[]
if #rows == 0 then
return
end
local args = { "restore", "--staged", "--" }
for _, row in ipairs(rows) do
local entry = row.entry
if entry.kind == "changed" then
---@cast entry ow.Git.Status.ChangedEntry
if entry.orig then
table.insert(args, entry.orig)
end
end
table.insert(args, entry.path)
end
util.git(args, {
cwd = s.repo.worktree,
on_exit = function(result)
if result.code ~= 0 then
util.error(
"git restore --staged failed: %s",
vim.trim(result.stderr or "")
)
end
end,
})
end
local function action_discard()
local s, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item or item.is_header then
return
end
---@cast item ow.Git.Status.Row
if item.section == "staged" then
util.warning("file has staged changes, unstage first with 'u'")
return
end
local entry = item.entry
local path = entry.path
local prompt, action
if item.section == "untracked" then
local is_dir = path:sub(-1) == "/"
prompt = string.format(
"Delete untracked %s %s?",
is_dir and "directory" or "file",
path
)
action = function()
local target = vim.fs.joinpath(s.repo.worktree, path)
local rc = vim.fn.delete(target, is_dir and "rf" or "")
if rc ~= 0 then
util.error("failed to delete %s", path)
end
refresh(vim.api.nvim_get_current_buf())
end
elseif item.section == "unstaged" then
prompt = string.format("Discard changes to %s?", path)
action = function()
util.git({ "checkout", "--", path }, {
cwd = s.repo.worktree,
on_exit = function(result)
if result.code ~= 0 then
util.error(
"git checkout failed: %s",
vim.trim(result.stderr or "")
)
end
end,
})
end
else
return
end
if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then
action()
end
end
---@param placement ow.Git.StatusView.Placement
local function action_help(placement)
local lines = { "git status view" }
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")
table.insert(
lines,
" X discard worktree changes (untracked: delete file)"
)
table.insert(lines, " R refresh")
table.insert(lines, " g? show this help")
print(table.concat(lines, "\n"))
end
---@param bufnr integer
---@param placement ow.Git.StatusView.Placement
---@return integer win
local function place(bufnr, placement)
local split
if placement == "sidebar" then
split = "left"
elseif placement == "current" then
split = false
end
local win = util.place_buf(bufnr, split)
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].wrap = false
vim.wo[win].signcolumn = "no"
vim.wo[win].cursorline = true
if placement == "sidebar" then
vim.wo[win].winfixwidth = true
vim.api.nvim_win_set_width(win, WINDOW_WIDTH)
end
return win
end
---@param bufnr integer
---@param r ow.Git.Repo
---@param placement ow.Git.StatusView.Placement
---@param win integer?
local function setup_buffer(bufnr, r, placement, win)
state[bufnr] = {
repo = r,
placement = placement,
lines = {},
win = win,
}
local function k(lhs, rhs, desc)
vim.keymap.set(
"n",
lhs,
rhs,
{ buffer = bufnr, silent = true, desc = desc }
)
end
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")
k("R", function()
r:refresh()
end, "Refresh")
k("g?", function()
action_help(state[bufnr].placement)
end, "Help")
state[bufnr].unsubscribe = r:on("change", function()
refresh(bufnr)
end)
vim.api.nvim_create_autocmd("BufEnter", {
buffer = bufnr,
group = group,
callback = function()
r:refresh()
end,
})
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
buffer = bufnr,
group = group,
callback = function()
local s = state[bufnr]
if not s then
return
end
if s.unsubscribe then
s.unsubscribe()
end
state[bufnr] = nil
end,
})
end
---@param bufnr integer
---@param placement ow.Git.StatusView.Placement
local function set_keymaps(bufnr, placement)
if placement == "sidebar" then
vim.keymap.set("n", "<Tab>", function()
preview_or_open(false)
end, { buffer = bufnr, silent = true, desc = "Preview diff" })
else
pcall(vim.keymap.del, "n", "<Tab>", { buffer = bufnr })
end
end
---@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"
if not vim.tbl_contains(M.PLACEMENTS, placement) then
util.error(
"invalid placement: %s (expected one of %s)",
placement,
table.concat(M.PLACEMENTS, ", ")
)
return
end
local r = repo.resolve()
if not r then
util.error("not in a git repository")
return
end
local previous_win = vim.api.nvim_get_current_win()
local buf = vim.fn.bufadd(buf_name_for(r))
local visible = vim.fn.bufwinid(buf)
if visible ~= -1 then
vim.api.nvim_set_current_win(visible)
r:refresh()
return
end
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
refresh(buf)
r:refresh()
end
---@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(opts)
end
return M
-104
View File
@@ -1,104 +0,0 @@
local repo = require("git.core.repo")
local status = require("git.core.status")
local util = require("git.core.util")
local M = {}
---@class ow.Git.Statusline.Status
---@field head string?
---@field entry ow.Git.Status.Entry?
---@param entry ow.Git.Status.Entry?
---@return string
local function render(entry)
if not entry then
return ""
end
local marks = status.marks_for(entry)
if #marks == 0 then
return ""
end
local parts = {}
for _, mark in ipairs(marks) do
table.insert(
parts,
string.format("%%#%s#%s%%*", mark.hl, mark.char)
)
end
return table.concat(parts, " ")
end
---@param buf integer
local function clear(buf)
vim.b[buf].git_status = nil
vim.b[buf].git_status_string = nil
end
---@param buf integer
---@param r ow.Git.Repo
---@param rel string
local function set_status(buf, r, rel)
local entry = r:status_entry_for(rel)
vim.b[buf].git_status = { head = r:head(), entry = entry }
vim.b[buf].git_status_string = render(entry)
end
---@param buf integer
---@param r ow.Git.Repo?
local function update_buf(buf, r)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
local name = vim.api.nvim_buf_get_name(buf)
if name == "" or util.is_uri(name) then
return clear(buf)
end
r = r or repo.find(buf)
if not r then
return clear(buf)
end
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
if not rel then
return clear(buf)
end
set_status(buf, r, rel)
end
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
local name = vim.api.nvim_buf_get_name(buf)
if name ~= "" and not util.is_uri(name) then
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
if rel then
set_status(buf, r, rel)
if
not any_visible
and #vim.fn.win_findbuf(buf) > 0
then
any_visible = true
end
end
end
end
end
if any_visible then
vim.cmd.redrawstatus({ bang = true })
end
end)
vim.api.nvim_create_autocmd("BufWinEnter", {
group = vim.api.nvim_create_augroup("ow.git.statusline", { clear = true }),
callback = function(args)
update_buf(args.buf, nil)
end,
})
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then
update_buf(buf, nil)
end
end
return M
+142 -11
View File
@@ -79,6 +79,77 @@ local function is_url(src)
return src:find("://") ~= nil return src:find("://") ~= nil
end end
---@param src string
---@return string?
local function file_url_to_path(src)
local path = src:match("^file://(/.+)$")
if not path then
return nil
end
return vim.fs.normalize(path)
end
---@param path string
---@return string?
local function plugin_name_from_path(path)
local name = vim.fs.basename(path)
if not name or name == "" or name == "." or name == ".." then
return nil
end
return name
end
local data_dir = vim.fn.stdpath("data")
if type(data_dir) == "table" then
data_dir = assert(data_dir[1])
end
local dev_opt_dir = vim.fs.joinpath(data_dir, "site", "pack", "dev", "opt")
---@param target string
---@param name string
---@return string? link
---@return boolean changed
local function ensure_dev_link(target, name)
if not vim.uv.fs_stat(target) then
log.error("pack: dev plugin path does not exist: %s", target)
return nil, false
end
local ok, info = pcall(vim.pack.get, { name })
if ok and info and #info > 0 then
pcall(vim.pack.del, { name }, { force = true })
end
vim.fn.mkdir(dev_opt_dir, "p")
local link = vim.fs.joinpath(dev_opt_dir, name)
local lstat = vim.uv.fs_lstat(link)
if lstat then
if lstat.type == "link" then
if vim.uv.fs_readlink(link) == target then
return link, false
end
local ok_unlink, err = vim.uv.fs_unlink(link)
if not ok_unlink then
log.error("pack: failed to unlink %s: %s", link, err)
return nil, false
end
else
log.error(
"pack: %s exists and is not a symlink; refusing to overwrite",
link
)
return nil, false
end
end
local ok_link, err = vim.uv.fs_symlink(target, link)
if not ok_link then
log.error("pack: failed to symlink %s -> %s: %s", link, target, err)
return nil, false
end
return link, true
end
---@param spec string | ow.Pack.Spec ---@param spec string | ow.Pack.Spec
---@return vim.pack.Spec ---@return vim.pack.Spec
local function to_pack_spec(spec) local function to_pack_spec(spec)
@@ -267,22 +338,59 @@ end
function M.setup(specs) function M.setup(specs)
local pack_specs = {} local pack_specs = {}
local order = {} local order = {}
local dev_changed = {}
for _, spec in ipairs(specs) do for _, spec in ipairs(specs) do
local spec_t = type(spec) == "table" and spec or nil
local src = type(spec) == "string" and spec or spec.src local src = type(spec) == "string" and spec or spec.src
table.insert(order, src) table.insert(order, src)
if is_url(src) then local dev_path = file_url_to_path(src)
if dev_path then
local name = (spec_t and spec_t.name)
or plugin_name_from_path(dev_path)
if not name then
log.error("pack: invalid plugin name derived from %s", src)
else
local link, did_change = ensure_dev_link(dev_path, name)
if link then
local ok_add, add_err = pcall(vim.cmd.packadd, name)
if not ok_add then
log.error(
"pack: failed to packadd %s: %s",
name,
add_err
)
else
---@type ow.Pack.Plugin
M.plugins[src] = {
src = src,
name = name,
version = spec_t and spec_t.version,
build = spec_t and spec_t.build,
path = link,
}
if did_change then
dev_changed[src] = { path = link }
end
end
end
end
elseif is_url(src) then
table.insert(pack_specs, to_pack_spec(spec)) table.insert(pack_specs, to_pack_spec(spec))
else else
vim.cmd.packadd(src) local ok_add, add_err = pcall(vim.cmd.packadd, src)
local runtime = if not ok_add then
vim.api.nvim_get_runtime_file("pack/*/opt/" .. src, false) log.error("pack: failed to packadd %s: %s", src, add_err)
---@type ow.Pack.Plugin else
local plugin = { local runtime =
src = src, vim.api.nvim_get_runtime_file("pack/*/opt/" .. src, false)
name = src, ---@type ow.Pack.Plugin
path = runtime[1] or "", local plugin = {
} src = src,
M.plugins[plugin.src] = plugin name = src,
path = runtime[1] or "",
}
M.plugins[plugin.src] = plugin
end
end end
end end
@@ -303,6 +411,9 @@ function M.setup(specs)
M.plugins[plugin.src] = plugin M.plugins[plugin.src] = plugin
vim.cmd.packadd(plugin.name) vim.cmd.packadd(plugin.name)
end) end)
for src, data in pairs(dev_changed) do
changed[src] = data
end
for _, src in ipairs(order) do for _, src in ipairs(order) do
local plugin = M.plugins[src] local plugin = M.plugins[src]
@@ -343,6 +454,26 @@ end
---@param names? string[] ---@param names? string[]
---@param opts? table ---@param opts? table
function M.update(names, opts) function M.update(names, opts)
if names then
local managed = {}
for _, plugin in pairs(M.plugins) do
if not file_url_to_path(plugin.src) and is_url(plugin.src) then
managed[plugin.name] = true
end
end
local filtered = {}
for _, name in ipairs(names) do
if managed[name] then
table.insert(filtered, name)
else
log.warning("pack: skipping %s (not managed by vim.pack)", name)
end
end
if #filtered == 0 then
return
end
names = filtered
end
vim.pack.update(names, opts) vim.pack.update(names, opts)
end end
+1 -4
View File
@@ -63,10 +63,7 @@ end
---@param filetypes? string[] ---@param filetypes? string[]
---@param symbol_name? string ---@param symbol_name? string
local function register_lang(lang, so, filetypes, symbol_name) local function register_lang(lang, so, filetypes, symbol_name)
vim.treesitter.language.add( vim.treesitter.language.add(lang, { path = so, symbol_name = symbol_name })
lang,
{ path = so, symbol_name = symbol_name }
)
local fts = { lang } local fts = { lang }
if filetypes then if filetypes then
vim.treesitter.language.register(lang, filetypes) vim.treesitter.language.register(lang, filetypes)
+3 -15
View File
@@ -13,9 +13,9 @@
"rev": "17e3507b788699776ba6d2f8dd101ec177f37a96", "rev": "17e3507b788699776ba6d2f8dd101ec177f37a96",
"src": "https://github.com/ibhagwan/fzf-lua" "src": "https://github.com/ibhagwan/fzf-lua"
}, },
"gitsigns.nvim": { "git.nvim": {
"rev": "dd3f588bacbeb041be6facf1742e42097f62165d", "rev": "c4585b77686b8134cb2b671d5e30ef4a773b5357",
"src": "https://github.com/lewis6991/gitsigns.nvim" "src": "https://git.owall.se/warg/git.nvim"
}, },
"grug-far.nvim": { "grug-far.nvim": {
"rev": "21790e59dd0109a92a70cb874dd002af186314f5", "rev": "21790e59dd0109a92a70cb874dd002af186314f5",
@@ -134,10 +134,6 @@
"rev": "ae5199db47757f785e43a14b332118a5474de1a2", "rev": "ae5199db47757f785e43a14b332118a5474de1a2",
"src": "https://github.com/tree-sitter-grammars/tree-sitter-svelte" "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": { "tree-sitter-typescript": {
"rev": "75b3874edb2dc714fb1fd77a32013d0f8699989f", "rev": "75b3874edb2dc714fb1fd77a32013d0f8699989f",
"src": "https://github.com/tree-sitter/tree-sitter-typescript" "src": "https://github.com/tree-sitter/tree-sitter-typescript"
@@ -153,14 +149,6 @@
"tree-sitter-zsh": { "tree-sitter-zsh": {
"rev": "86b37f8d515a529722411bc7bf3c9e993a4743bf", "rev": "86b37f8d515a529722411bc7bf3c9e993a4743bf",
"src": "https://github.com/georgeharker/tree-sitter-zsh" "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"
} }
} }
} }
-348
View File
@@ -1,348 +0,0 @@
if vim.g.loaded_git then
return
end
vim.g.loaded_git = 1
local DEFAULT_HIGHLIGHTS = {
GitIgnored = "Comment",
GitSha = "Identifier",
GitStaged = "Constant",
GitUnmerged = "Todo",
GitUnpulled = "Removed",
GitUnpushed = "Added",
GitUnstaged = "Changed",
GitUntracked = "Added",
GitStagedAdded = "GitStaged",
GitStagedCopied = "GitStaged",
GitStagedDeleted = "GitStaged",
GitStagedModified = "GitStaged",
GitStagedRenamed = "GitStaged",
GitStagedTypeChanged = "GitStaged",
GitUnstagedAdded = "GitUnstaged",
GitUnstagedCopied = "GitUnstaged",
GitUnstagedDeleted = "Removed",
GitUnstagedModified = "GitUnstaged",
GitUnstagedRenamed = "GitStaged",
GitUnstagedTypeChanged = "GitUnstaged",
GitUnmergedAddedByThem = "GitUnmerged",
GitUnmergedAddedByUs = "GitUnmerged",
GitUnmergedBothAdded = "GitUnmerged",
GitUnmergedBothDeleted = "GitUnmerged",
GitUnmergedBothModified = "GitUnmerged",
GitUnmergedDeletedByThem = "GitUnmerged",
GitUnmergedDeletedByUs = "GitUnmerged",
GitHunkAdded = "Added",
GitHunkChanged = "Changed",
GitHunkRemoved = "Removed",
GitHunkAddLine = "DiffAdd",
GitHunkDeleteLine = "DiffDelete",
GitBlame = "Comment",
GitBlameSha = "GitSha",
}
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" }, {
group = group,
callback = function(args)
require("git.core.repo").refresh(args.buf)
end,
})
vim.api.nvim_create_autocmd({ "ShellCmdPost", "TermLeave", "FocusGained" }, {
group = group,
callback = function()
for _, r in pairs(require("git.core.repo").all()) do
r:refresh({ invalidate = true })
end
end,
})
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
group = group,
callback = function(args)
require("git.hunks").detach(args.buf)
require("git.blame").detach(args.buf)
require("git.core.repo").unbind(args.buf)
end,
})
vim.api.nvim_create_autocmd("VimLeavePre", {
group = group,
callback = function()
require("git.core.repo").stop_all()
end,
})
vim.api.nvim_create_autocmd({ "VimEnter", "DirChanged", "TabEnter" }, {
group = group,
callback = function()
require("git.core.repo").update_cwd_repo()
end,
})
vim.api.nvim_create_autocmd("TabClosed", {
group = group,
callback = function(args)
local tab = tonumber(args.file) --[[@as integer?]]
if tab then
require("git.core.repo").release_tab(tab)
end
end,
})
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "git://*",
group = group,
callback = function(args)
require("git.object").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 })
end, {
nargs = "*",
bang = true,
complete = function(...)
return require("git.cmd").complete(...)
end,
})
vim.api.nvim_create_user_command("Grefresh", function()
require("git.core.repo").refresh_all()
end, { desc = "Refresh git status for all repos" })
vim.api.nvim_create_user_command("Glog", function(opts)
require("git.log_view").run_glog(opts)
end, {
nargs = "*",
complete = function(...)
return require("git.log_view").complete_glog(...)
end,
desc = "Show git log",
})
local function complete_rev(...)
return require("git.cmd").complete_rev(...)
end
local DIFF_DIRECTIONS = { "vertical", "horizontal" }
vim.api.nvim_create_user_command("Gdiffsplit", function(opts)
local fargs = opts.fargs
local mods = nil
local rev_idx = 1
if fargs[1] == "vertical" then
mods = { vertical = true }
rev_idx = 2
elseif fargs[1] == "horizontal" then
mods = { vertical = false }
rev_idx = 2
end
require("git.diffsplit").open({ target = fargs[rev_idx], mods = mods })
end, {
nargs = "*",
complete = function(arg_lead, cmd_line, _)
local rest = cmd_line:gsub("^%s*%S+%s*", "", 1)
local trailing_space = rest == "" or rest:sub(-1):match("%s") ~= nil
local tokens = vim.split(vim.trim(rest), "%s+", { trimempty = true })
local prior = trailing_space and tokens
or vim.list_slice(tokens, 1, #tokens - 1)
local results = {}
if #prior == 0 then
for _, d in ipairs(DIFF_DIRECTIONS) do
if vim.startswith(d, arg_lead) then
table.insert(results, d)
end
end
end
local first_is_direction = prior[1] == "vertical"
or prior[1] == "horizontal"
if #prior == 0 or (#prior == 1 and first_is_direction) then
vim.list_extend(results, complete_rev(arg_lead))
end
return results
end,
desc = "Open a diff split",
})
vim.api.nvim_create_user_command("Gedit", function(opts)
vim.cmd.edit({
args = { require("git.object").URI_PREFIX .. opts.args },
magic = { file = false },
})
end, {
nargs = 1,
complete = complete_rev,
desc = "Edit a git object (<rev>)",
})
vim.api.nvim_create_user_command("Gstatus", function(opts)
require("git.status_view").open({
placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]] or "split",
})
end, {
nargs = "?",
complete = function()
return require("git.status_view").PLACEMENTS
end,
desc = "Open git status view",
})
vim.keymap.set("n", "<Plug>(git-edit)", function()
local rev = vim.fn.input("Edit git object: ")
if rev == "" then
return
end
vim.cmd.edit({
args = { require("git.object").URI_PREFIX .. rev },
magic = { file = false },
})
end, { silent = true, desc = "Edit a git object" })
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" })
vim.keymap.set("n", "<Plug>(git-log)", function()
require("git.log_view").open({ max_count = 1000 })
end, { silent = true, desc = "Open git log" })
vim.keymap.set("n", "<Plug>(git-commit)", function()
require("git.commit").commit()
end, { silent = true, desc = "Start a git commit" })
vim.keymap.set("n", "<Plug>(git-commit-amend)", function()
require("git.commit").commit({ args = { "--amend" } })
end, { silent = true, desc = "Amend the last git commit" })
if vim.g.git_statusline ~= false then
vim.api.nvim_create_autocmd("BufWinEnter", {
group = group,
once = true,
callback = function()
require("git.statusline")
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" })
vim.keymap.set("n", "<Plug>(git-blame)", function()
require("git.blame").toggle_overlay()
end, { silent = true, desc = "Toggle the full-file git blame overlay" })
vim.keymap.set("n", "<Plug>(git-blame-line)", function()
require("git.blame").toggle_inline()
end, { silent = true, desc = "Toggle inline git blame" })
vim.keymap.set("n", "<Plug>(git-blame-popup)", function()
require("git.blame").line_popup()
end, { silent = true, desc = "Show git blame for the current line" })
vim.keymap.set("n", "<Plug>(git-blame-commit)", function()
require("git.blame").open_commit()
end, { silent = true, desc = "Open the commit that last touched this line" })
vim.keymap.set("n", "<Plug>(git-blame-file)", function()
require("git.blame").open_file()
end, { silent = true, desc = "Open this file at the line's commit" })
vim.keymap.set("n", "<Plug>(git-blame-file-parent)", function()
require("git.blame").open_file_parent()
end, {
silent = true,
desc = "Open this file at the parent of the line's commit",
})
vim.api.nvim_create_user_command("GitBlame", function()
require("git.blame").toggle_overlay()
end, { desc = "Toggle the full-file git blame overlay in the current buffer" })
vim.api.nvim_create_user_command("GitBlameLine", function()
require("git.blame").toggle_inline()
end, { desc = "Toggle inline git blame in the current buffer" })
+2 -2
View File
@@ -19,8 +19,8 @@ syntax match gitlogGraphLine /^[*|\\\/_ ]\+$/
highlight default link gitlogGraph Comment highlight default link gitlogGraph Comment
highlight default link gitlogHash GitSha highlight default link gitlogHash GitSha
highlight default link gitlogDate Number highlight default link gitlogDate GitDate
highlight default link gitlogAuthor String highlight default link gitlogAuthor GitAuthor
highlight default link gitlogRef Constant highlight default link gitlogRef Constant
let b:current_syntax = "gitlog" let b:current_syntax = "gitlog"
-572
View File
@@ -1,572 +0,0 @@
local blame = require("git.blame")
local h = require("test.git.helpers")
local t = require("test")
---@param sha string
---@return boolean
local function is_zero(sha)
return sha:match("^0+$") ~= nil
end
---@param committed string
---@param worktree string?
---@param file string?
---@return string dir
---@return integer buf
local function setup(committed, worktree, file)
file = file or "a.txt"
local dir = h.make_repo({ [file] = committed })
if worktree then
t.write(dir, file, worktree)
end
vim.cmd.edit(dir .. "/" .. file)
return dir, vim.api.nvim_get_current_buf()
end
---@param buf integer
---@return ow.Git.Blame.BufState
local function enable_blame(buf)
blame.toggle_inline(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.tick ~= nil
end, "blame to populate the buffer state")
local s = assert(blame.state(buf))
return s
end
---@param buf integer
---@param ns_name string
---@return vim.api.keyset.get_extmark_item[]
local function 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
---@param buf integer
---@return vim.api.keyset.get_extmark_item[]
local function inline_marks(buf)
return marks(buf, "ow.git.blame.inline")
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
---@return integer float
local function wait_float()
local float ---@type integer?
t.wait_for(function()
float = find_float()
return float ~= nil
end, "blame popup float to open")
local found = assert(float)
return found
end
---@param pat string
local function wait_buf_name(pat)
t.wait_for(function()
return vim.api.nvim_buf_get_name(0):match(pat) ~= nil
end, "current buffer name to match " .. pat)
end
t.test("relative_time buckets", function()
local now = os.time()
t.eq(blame.relative_time(now), "just now")
t.eq(blame.relative_time(now - 10), "just now")
t.eq(blame.relative_time(now - 60), "a minute ago")
t.eq(blame.relative_time(now - 5 * 60), "5 minutes ago")
t.eq(blame.relative_time(now - 60 * 60), "an hour ago")
t.eq(blame.relative_time(now - 3 * 3600), "3 hours ago")
t.eq(blame.relative_time(now - 26 * 3600), "a day ago")
t.eq(blame.relative_time(now - 3 * 86400), "3 days ago")
t.eq(blame.relative_time(now - 14 * 86400), "2 weeks ago")
t.eq(blame.relative_time(now - 60 * 86400), "2 months ago")
t.eq(blame.relative_time(now - 400 * 86400), "1 year ago")
end)
t.test("blame layout squeezes the author before date and sha", function()
local sha, author, date = blame._layout(40)
t.eq(sha, 8, "full budget: sha at its preference")
t.eq(author, 16, "full budget: author at its preference")
t.eq(date, 10, "full budget: date at its preference")
sha, author, date = blame._layout(32)
t.eq(sha, 8, "tight: the sha is untouched")
t.eq(date, 10, "tight: the date is untouched")
t.eq(author, 8, "tight: the author absorbs the squeeze first")
sha, author, date = blame._layout(20)
t.eq(sha, 8, "tighter: the sha is still untouched")
t.eq(author, 0, "tighter: the author is squeezed out")
t.eq(date, 6, "tighter: the date shrinks next")
sha, author, date = blame._layout(4)
t.eq(sha, 0, "degenerate: no room even for separators")
t.eq(author, 0)
t.eq(date, 0)
end)
t.test("native_width measures the gutter from window options", function()
local b = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(b, 0, -1, false, { "a", "b", "c" })
vim.api.nvim_set_current_buf(b)
local win = vim.api.nvim_get_current_win()
t.defer(function()
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].signcolumn = "auto"
vim.wo[win].foldcolumn = "0"
end
pcall(vim.api.nvim_buf_delete, b, { force = true })
end)
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].signcolumn = "no"
vim.wo[win].foldcolumn = "0"
t.eq(blame._native_width(win), 0, "no gutter columns")
vim.wo[win].number = true
vim.wo[win].numberwidth = 4
vim.wo[win].signcolumn = "yes:2"
t.eq(blame._native_width(win), 8, "number column plus signcolumn yes:2")
vim.wo[win].signcolumn = "auto:3"
vim.wo[win].foldcolumn = "2"
t.eq(
blame._native_width(win),
4 + 6 + 2,
"auto:3 and a numeric foldcolumn reserve their maximum"
)
end)
t.test("porcelain parse of a committed file", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
local state = enable_blame(buf)
t.eq(vim.tbl_count(state.commits), 1, "one commit")
local sha = state.line_sha[1]
t.eq(state.line_sha[2], sha, "line 2 shares the commit")
t.eq(state.line_sha[3], sha, "line 3 shares the commit")
local commit = state.commits[sha]
t.eq(commit.author, "t", "author parsed from the porcelain")
t.eq(commit.summary, "init", "summary parsed from the porcelain")
t.truthy(#sha >= 40, "the full sha is recorded")
t.truthy(commit.author_time > 0, "author time parsed")
end)
t.test("multiple line groups reuse one commit entry", function()
local dir = h.make_repo({ ["a.txt"] = "a\nb\nc\n" })
t.write(dir, "a.txt", "a\nB\nc\n")
h.git(dir, "add", "a.txt")
h.git(dir, "commit", "-q", "-m", "change middle")
vim.cmd.edit(dir .. "/a.txt")
local buf = vim.api.nvim_get_current_buf()
local state = enable_blame(buf)
t.eq(vim.tbl_count(state.commits), 2, "two distinct commits")
t.eq(
state.line_sha[1],
state.line_sha[3],
"lines 1 and 3 share the original commit"
)
t.truthy(
state.line_sha[1] ~= state.line_sha[2],
"line 2 is a different commit"
)
end)
t.test("an edited line blames as the zero sha", function()
local _, buf = setup("a\nb\nc\n")
local state = enable_blame(buf)
t.falsy(is_zero(state.line_sha[2]), "line 2 starts committed")
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "EDITED" })
vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf })
blame._flush(buf)
t.wait_for(function()
local s = assert(blame.state(buf))
return s.line_sha[2] ~= nil and is_zero(s.line_sha[2])
end, "the edited line to blame as uncommitted")
end)
t.test("blame refreshes after a git event", function()
local dir, buf = setup("original\n")
local state = enable_blame(buf)
local sha1 = state.line_sha[1]
h.git(dir, "commit", "--amend", "-m", "amended")
local sha2 = h.git(dir, "rev-parse", "HEAD").stdout
t.truthy(sha1 ~= sha2, "the amend produced a new commit")
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.line_sha[1] == sha2
end, "blame to pick up the amended commit", 2000)
end)
t.test("an untracked file blames every line as uncommitted", function()
local dir = h.make_repo({ ["tracked.txt"] = "x\n" })
t.write(dir, "new.txt", "one\ntwo\nthree\n")
vim.cmd.edit(dir .. "/new.txt")
local buf = vim.api.nvim_get_current_buf()
local state = enable_blame(buf)
for i = 1, 3 do
t.truthy(is_zero(state.line_sha[i]), "line " .. i .. " is uncommitted")
end
t.eq(vim.tbl_count(state.commits), 1, "one synthesized commit")
end)
t.test("blame actions are no-ops off a worktree", function()
local buf = vim.api.nvim_create_buf(true, false)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
t.quietly(function()
blame.line_popup(buf)
blame.toggle_inline(buf)
blame.toggle_overlay(buf)
end)
t.eq(blame.state(buf), nil, "no state created for a non-worktree buffer")
end)
t.test("line popup shows the commit for the cursor line", function()
local dir, buf = setup("alpha\nbeta\ngamma\n")
local sha = h.git(dir, "rev-parse", "HEAD").stdout
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.line_popup(buf)
local float = wait_float()
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 "", sha:sub(1, 8)),
"first line starts with the short sha"
)
t.truthy((lines[1] or ""):find("t", 1, true), "author shown")
end)
t.test("re-invoking the line popup focuses the open float", function()
local _, buf = setup("alpha\nbeta\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.line_popup(buf)
local float = wait_float()
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"
)
blame.line_popup(buf)
t.eq(
vim.api.nvim_get_current_win(),
float,
"re-invoking focuses the existing float"
)
end)
t.test("line popup works in a git:// object buffer", function()
local dir = h.make_repo({ ["a.txt"] = "alpha\nbeta\ngamma\n" })
local sha = h.git(dir, "rev-parse", "HEAD").stdout
local r = assert(require("git.core.repo").resolve(dir))
local gbuf = require("git.object").buf_for(
r,
require("git.core.revision").new({ base = sha, path = "a.txt" })
)
vim.api.nvim_set_current_buf(gbuf)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.line_popup(gbuf)
local float = wait_float()
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 "", sha:sub(1, 8)),
"the popup blames the commit even in a git:// buffer"
)
end)
t.test("inline toggle adds and removes the annotation", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
enable_blame(buf)
t.wait_for(function()
return #inline_marks(buf) == 1
end, "an inline annotation on the current line")
t.eq(assert(inline_marks(buf)[1])[2], 0, "annotation on line 1")
blame.toggle_inline(buf)
t.eq(#inline_marks(buf), 0, "annotation cleared when toggled off")
end)
t.test("inline annotation follows the cursor", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
enable_blame(buf)
t.wait_for(function()
return #inline_marks(buf) == 1
end, "the initial annotation")
t.eq(assert(inline_marks(buf)[1])[2], 0)
vim.api.nvim_win_set_cursor(0, { 3, 0 })
vim.api.nvim_exec_autocmds("CursorMoved", { buffer = buf })
t.eq(#inline_marks(buf), 1, "still one annotation")
t.eq(assert(inline_marks(buf)[1])[2], 2, "annotation moved to line 3")
end)
t.test("overlay toggle sets and clears the statuscolumn", function()
local _, buf = setup("a\nb\nc\nd\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
blame.toggle_overlay(buf)
t.truthy(
vim.wo[win].statuscolumn ~= "",
"the overlay sets the window statuscolumn"
)
blame.toggle_overlay(buf)
t.eq(vim.wo[win].statuscolumn, "", "toggling off clears it")
end)
t.test("overlay saves and restores the statuscolumn", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
t.defer(function()
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].statuscolumn = ""
vim.wo[win].signcolumn = "auto"
end
end)
vim.wo[win].statuscolumn = "%l custom"
vim.wo[win].signcolumn = "yes:2"
blame.toggle_overlay(buf)
t.truthy(
vim.wo[win].statuscolumn ~= "%l custom",
"the overlay overrides a custom statuscolumn"
)
t.eq(
vim.wo[win].signcolumn,
"yes:2",
"the overlay leaves signcolumn untouched"
)
blame.toggle_overlay(buf)
t.eq(vim.wo[win].statuscolumn, "%l custom", "statuscolumn restored")
end)
t.test("overlay gutter uses the full preferred width when it can", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
t.defer(function()
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].statuscolumn = ""
end
end)
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].signcolumn = "no"
vim.wo[win].foldcolumn = "0"
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.blame_width ~= nil
end, "the overlay blame to render")
t.eq(
assert(blame.state(buf)).blame_width,
40,
"with no native columns the blame takes its full preferred width"
)
end)
t.test("overlay gutter is budgeted under the 47-cell cap", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
t.defer(function()
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].statuscolumn = ""
vim.wo[win].signcolumn = "auto"
end
end)
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].foldcolumn = "0"
vim.wo[win].signcolumn = "yes:9"
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.blame_width ~= nil
end, "the overlay blame to render")
local native = blame._native_width(win)
local width = assert(assert(blame.state(buf)).blame_width)
t.eq(native, 18, "signcolumn=yes:9 reserves an 18-cell sign column")
t.eq(width, 47 - native, "the blame is budgeted into the cells left free")
t.truthy(width + native <= 47, "blame plus native columns fits the cap")
end)
t.test("overlay re-budgets when a gutter option changes", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
t.defer(function()
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].statuscolumn = ""
vim.wo[win].signcolumn = "auto"
end
end)
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].foldcolumn = "0"
vim.wo[win].signcolumn = "no"
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.blame_width ~= nil
end, "the overlay blame to render")
t.eq(
assert(blame.state(buf)).blame_width,
40,
"a clear gutter leaves the full preferred width"
)
vim.wo[win].signcolumn = "yes:9"
t.wait_for(function()
return assert(blame.state(buf)).blame_width == 47 - 18
end, "the blame to re-budget for the widened signcolumn")
end)
t.test("overlay gutter shows sha, author and an absolute date", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.tick ~= nil
end, "the overlay blame to populate")
local g = blame._gutter(win, 1, 0)
t.truthy(g:match("%x%x%x%x%x%x%x%x"), "the gutter shows a short sha")
t.truthy(g:find("t", 1, true), "the gutter shows the author")
t.truthy(
g:match("%d%d%d%d%-%d%d%-%d%d"),
"the gutter shows a YYYY-MM-DD date"
)
end)
t.test("overlay gutter is blank on virtual lines", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.tick ~= nil
end, "the overlay blame to populate")
local g = blame._gutter(win, 1, -1)
t.falsy(g:match("%x%x%x%x%x%x%x%x"), "no sha on a virtual line")
end)
t.test("the statuscolumn expression renders the blame gutter", function()
local dir, buf = setup("a\nb\nc\n")
local sha = h.git(dir, "rev-parse", "HEAD").stdout
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.tick ~= nil
end, "the overlay blame to populate")
local rendered = vim.api.nvim_eval_statusline(
"%{%v:lua.require('git.blame').statuscolumn()%}",
{ winid = win, use_statuscol_lnum = 1 }
)
t.truthy(
rendered.str:find(sha:sub(1, 8), 1, true),
"the statuscolumn renders the commit's short sha"
)
end)
t.test("open_commit opens the commit that last touched the line", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.open_commit()
wait_buf_name("^git://%x+$")
end)
t.test("open_file opens the file at the line's commit", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.open_file()
wait_buf_name("^git://%x+:a%.txt$")
end)
t.test("open_file_parent opens the file at the parent commit", function()
local dir = h.make_repo({ ["a.txt"] = "a\nb\nc\n" })
local root = h.git(dir, "rev-parse", "HEAD").stdout
t.write(dir, "a.txt", "a\nB\nc\n")
h.git(dir, "add", "a.txt")
h.git(dir, "commit", "-q", "-m", "change middle")
vim.cmd.edit(dir .. "/a.txt")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.open_file_parent()
t.wait_for(function()
return vim.api.nvim_buf_get_name(0) == "git://" .. root .. ":a.txt"
end, "the file at the parent commit to open")
end)
t.test("the drill actions refuse an uncommitted line", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "EDITED" })
vim.api.nvim_win_set_cursor(0, { 2, 0 })
t.quietly(function()
blame.open_commit()
vim.wait(200)
end)
t.eq(
vim.api.nvim_get_current_buf(),
buf,
"no commit opened for an uncommitted line"
)
end)
t.test("drilling chains through git:// buffers", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.open_file()
wait_buf_name("^git://%x+:a%.txt$")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.open_commit()
wait_buf_name("^git://%x+$")
end)
t.test("detach clears blame state and annotations", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
enable_blame(buf)
blame.toggle_overlay(buf)
t.truthy(vim.wo[win].statuscolumn ~= "", "the overlay statuscolumn set")
blame.detach(buf)
t.eq(blame.state(buf), nil, "state dropped on detach")
t.eq(#inline_marks(buf), 0, "inline annotation cleared")
t.eq(vim.wo[win].statuscolumn, "", "overlay statuscolumn cleared")
end)
-668
View File
@@ -1,668 +0,0 @@
local cmd = require("git.cmd")
local h = require("test.git.helpers")
local t = require("test")
---@param files table<string, string>?
---@return string dir
local function make_repo(files)
return h.make_repo(files, { cd = true })
end
---@param actual string[]
---@param expected string[]
local function eq_sorted(actual, expected, msg)
table.sort(actual)
table.sort(expected)
t.eq(actual, expected, msg)
end
t.test("parse_args splits on whitespace", function()
t.eq(
cmd.parse_args("config user.name value"),
{ "config", "user.name", "value" }
)
end)
t.test("parse_args preserves spaces inside double quotes", function()
t.eq(
cmd.parse_args([[config user.name "Oscar Wallberg"]]),
{ "config", "user.name", "Oscar Wallberg" }
)
end)
t.test("parse_args preserves spaces inside single quotes", function()
t.eq(
cmd.parse_args([[log --grep='bug fix' --author=Oscar]]),
{ "log", "--grep=bug fix", "--author=Oscar" }
)
end)
t.test("parse_args handles backslash-escaped space", function()
t.eq(cmd.parse_args([[a\ b c]]), { "a b", "c" })
end)
t.test("parse_args handles escaped quote inside double quotes", function()
t.eq(cmd.parse_args([["a\"b" c]]), { 'a"b', "c" })
end)
t.test("parse_args treats backslash literally inside single quotes", function()
t.eq(cmd.parse_args([['a\b' c]]), { "a\\b", "c" })
end)
t.test("parse_args concatenates adjacent quoted segments", function()
t.eq(cmd.parse_args([[foo"bar"baz]]), { "foobarbaz" })
end)
t.test("parse_args handles tabs as separators", function()
t.eq(cmd.parse_args("a\tb\tc"), { "a", "b", "c" })
end)
t.test("parse_args returns empty list for empty or whitespace input", function()
t.eq(cmd.parse_args(""), {})
t.eq(cmd.parse_args(" \t "), {})
end)
t.test("parse_args preserves empty quoted token", function()
t.eq(cmd.parse_args([[a "" b]]), { "a", "", "b" })
end)
t.test("parse_args expands %% on unquoted token", function()
local buf = vim.api.nvim_create_buf(false, false)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua")
vim.api.nvim_set_current_buf(buf)
t.eq(
cmd.parse_args("add %"),
{ "add", vim.fn.getcwd() .. "/some-file.lua" }
)
end)
t.test("parse_args does not expand %% inside double quotes", function()
local buf = vim.api.nvim_create_buf(false, false)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua")
vim.api.nvim_set_current_buf(buf)
t.eq(cmd.parse_args([[log -- "%"]]), { "log", "--", "%" })
end)
t.test("parse_args does not expand %% inside single quotes", function()
local buf = vim.api.nvim_create_buf(false, false)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua")
vim.api.nvim_set_current_buf(buf)
t.eq(cmd.parse_args([[log -- '%']]), { "log", "--", "%" })
end)
t.test("parse_args does not treat mid-token tilde as expansion", function()
t.eq(cmd.parse_args("checkout HEAD~3"), { "checkout", "HEAD~3" })
end)
t.test("parse_args expands leading ~/ to home", function()
t.eq(cmd.parse_args("add ~/foo"), { "add", vim.fn.expand("~/foo") })
end)
t.test("parse_complete_state with trailing space", function()
local s = cmd._parse_complete_state("G push origin ")
t.eq(s.prior, { "push", "origin" })
t.falsy(s.after_separator)
end)
t.test("parse_complete_state mid-token", function()
local s = cmd._parse_complete_state("G push or")
t.eq(s.prior, { "push" })
t.falsy(s.after_separator)
end)
t.test("parse_complete_state empty after command", function()
local s = cmd._parse_complete_state("G ")
t.eq(s.prior, {})
t.falsy(s.after_separator)
end)
t.test("parse_complete_state detects -- separator", function()
local s = cmd._parse_complete_state("G log -- foo")
t.eq(s.prior, { "log", "--" })
t.truthy(s.after_separator)
end)
t.test("positional_index ignores flags", function()
t.eq(cmd._positional_index({ "push" }), 1)
t.eq(cmd._positional_index({ "push", "origin" }), 2)
t.eq(cmd._positional_index({ "push", "--force" }), 1)
t.eq(cmd._positional_index({ "push", "--force", "origin" }), 2)
t.eq(cmd._positional_index({ "checkout", "-b", "feature" }), 2)
end)
t.test("complete returns subcommands at first position", function()
local matches = cmd.complete("ch", "G ch", 4)
t.truthy(vim.tbl_contains(matches, "checkout"))
t.truthy(vim.tbl_contains(matches, "cherry-pick"))
end)
t.test("complete returns flags when arg starts with -", function()
local matches = cmd.complete("--am", "G commit --am", 13)
t.eq(matches, { "--amend" })
end)
t.test("complete branch returns plain refs (no pseudo, no stash)", function()
local dir = make_repo({ a = "x" })
h.git(dir, "branch", "feature")
h.git(dir, "tag", "v1")
t.write(dir, "a", "modified")
h.git(dir, "stash")
local matches = cmd.complete("", "G branch ", 9)
eq_sorted(matches, { "feature", "main", "v1" })
end)
t.test("complete merge returns refs + pseudo + stash", function()
local dir = make_repo({ a = "x" })
h.git(dir, "branch", "feature")
t.write(dir, "a", "y")
h.git(dir, "stash")
local matches = cmd.complete("", "G merge ", 8)
eq_sorted(
matches,
{ "HEAD", "ORIG_HEAD", "feature", "main", "stash", "stash@{0}" }
)
end)
t.test("complete push first positional returns remotes", function()
local dir = make_repo({ a = "x" })
h.git(dir, "remote", "add", "origin", "/tmp/nope")
h.git(dir, "remote", "add", "upstream", "/tmp/nope")
local matches = cmd.complete("", "G push ", 7)
eq_sorted(matches, { "origin", "upstream" })
end)
t.test("complete push second positional returns refs", function()
local dir = make_repo({ a = "x" })
h.git(dir, "branch", "feature")
local matches = cmd.complete("", "G push origin ", 14)
eq_sorted(matches, { "HEAD", "feature", "main" })
end)
t.test("complete add returns only unstaged/untracked paths", function()
local dir = make_repo({ tracked = "x" })
t.write(dir, "tracked", "modified")
t.write(dir, "newfile", "new")
local r = assert(require("git.core.repo").resolve(dir))
r:refresh()
t.wait_for(function()
return r.status and #vim.tbl_keys(r.status.entries) > 0
end, "git status to report entries", 500)
local matches = cmd.complete("", "G add ", 6)
eq_sorted(matches, { "newfile", "tracked" })
end)
t.test("complete after `--` returns tracked paths only", function()
local dir = make_repo({ a = "x", b = "y" })
t.write(dir, "untracked", "z")
local matches = cmd.complete("", "G log -- ", 9)
eq_sorted(matches, { "a", "b" })
end)
t.test("complete stash returns subsubcommands", function()
make_repo({ a = "x" })
local matches = cmd.complete("p", "G stash p", 9)
eq_sorted(matches, { "pop", "push" })
end)
t.test("complete show with <rev>:<path> returns tree paths", function()
make_repo({ a = "x", ["sub/b"] = "y" })
local matches = cmd.complete("HEAD:", "G show HEAD:", 12)
eq_sorted(matches, { "HEAD:a", "HEAD:sub/" })
end)
t.test("complete unknown subcommand falls back to tracked paths", function()
make_repo({ a = "x", b = "y" })
local matches = cmd.complete("", "G nonexistent ", 14)
eq_sorted(matches, { "a", "b" })
end)
---@param name_pattern string
---@return integer count
local function count_bufs_named(name_pattern)
local n = 0
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match(name_pattern) then
n = n + 1
end
end
return n
end
---@param buf_name_pattern string
---@param timeout integer?
local function wait_buf_populated(buf_name_pattern, timeout)
t.wait_for(function()
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match(buf_name_pattern) then
return #vim.api.nvim_buf_get_lines(b, 0, -1, false) > 1
end
end
return false
end, "buffer matching " .. buf_name_pattern .. " to populate", timeout)
end
---Wait for a buffer matching `buf_name_pattern` to contain a line whose
---content equals `line`. Useful for asserting that re-running a :G
---command repopulated the buffer with new output.
---@param buf_name_pattern string
---@param line string
---@param timeout integer?
local function wait_buf_has_line(buf_name_pattern, line, timeout)
t.wait_for(function()
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match(buf_name_pattern) then
for _, l in ipairs(vim.api.nvim_buf_get_lines(b, 0, -1, false)) do
if l == line then
return true
end
end
end
end
return false
end, "buffer " .. buf_name_pattern .. " to contain " .. line, timeout)
end
t.test("run :G diff reuses the same buffer across invocations", function()
local dir = make_repo({ a = "v1\n" })
t.write(dir, "a", "v2\n")
cmd.run({ "diff" })
wait_buf_has_line("%[Git diff%]", "+v2")
t.eq(count_bufs_named("%[Git diff%]"), 1)
t.write(dir, "a", "v3\n")
cmd.run({ "diff" })
wait_buf_has_line("%[Git diff%]", "+v3")
t.eq(count_bufs_named("%[Git diff%]"), 1, "second :G diff should reuse")
t.write(dir, "a", "v4\n")
cmd.run({ "diff" })
wait_buf_has_line("%[Git diff%]", "+v4")
t.eq(count_bufs_named("%[Git diff%]"), 1, "third :G diff should reuse")
end)
---@param buf integer
---@param prefix string
---@return integer? lnum
local function find_line(buf, prefix)
for i, l in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
if l:sub(1, #prefix) == prefix then
return i
end
end
end
t.test(":G show <CR> on + line opens the blob URI", function()
local dir = make_repo({ a = "first\n" })
t.write(dir, "a", "second\n")
h.git(dir, "add", "a")
h.git(dir, "commit", "-q", "-m", "second")
assert(require("git.core.repo").resolve(dir))
local blob = h.git(dir, "rev-parse", "HEAD:a").stdout
cmd.run({ "show", "HEAD" })
wait_buf_populated("%[Git show HEAD%]")
---@type integer?
local diff_buf
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match("%[Git show HEAD%]") then
diff_buf = b
end
end
assert(diff_buf, "expected [Git show HEAD] buffer")
local win = vim.fn.bufwinid(diff_buf)
vim.api.nvim_set_current_win(win)
local lnum = assert(find_line(diff_buf, "+second"))
vim.api.nvim_win_set_cursor(win, { lnum, 0 })
t.truthy(require("git.object").open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. blob)
end)
t.test("<leader>gl log buffer refills after jumping back", function()
local dir = make_repo({ a = "v1\n" })
t.write(dir, "a", "v2\n")
h.git(dir, "add", "a")
h.git(dir, "commit", "-q", "-m", "second")
require("git.log_view").open({ max_count = 1000 })
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$"))
local initial_lines = #vim.api.nvim_buf_get_lines(log_buf, 0, -1, false)
t.truthy(initial_lines >= 2)
-- Step into a commit, then <C-o> back to the log.
vim.api.nvim_win_set_cursor(log_win, { 1, 0 })
t.press("<CR>")
t.truthy(vim.api.nvim_buf_get_name(0):match("^git://"))
t.press("<C-o>")
t.eq(vim.api.nvim_get_current_buf(), log_buf)
t.eq(
#vim.api.nvim_buf_get_lines(log_buf, 0, -1, false),
initial_lines,
"log buffer must repopulate on jump-back"
)
end)
t.test("<CR> still dispatches after navigating away and back", function()
local dir = make_repo({ a = "v1\n" })
t.write(dir, "a", "v2\n")
h.git(dir, "add", "a")
h.git(dir, "commit", "-q", "-m", "second")
-- Open the HEAD commit object buffer. Its cat-file output includes a
-- "parent <sha>" line we can navigate from.
local r = assert(require("git.core.repo").resolve(dir))
require("git.object").open(r, "HEAD", { split = false })
local first_obj_buf = vim.api.nvim_get_current_buf()
local first_obj_win = vim.api.nvim_get_current_win()
t.truthy(vim.api.nvim_buf_get_name(first_obj_buf):match("^git://"))
-- Step into the parent commit. This hides first_obj_buf which has
-- bufhidden=delete, so it gets unloaded.
local parent_lnum = assert(find_line(first_obj_buf, "parent "))
vim.api.nvim_win_set_cursor(first_obj_win, { parent_lnum, 0 })
t.truthy(require("git.object").open_under_cursor())
local parent_buf = vim.api.nvim_get_current_buf()
t.truthy(parent_buf ~= first_obj_buf)
-- <C-o> back to first_obj_buf. With bufhidden=delete, vim re-reads the
-- URI, which previously raced with BufDelete-driven unbind and left
-- state cleared, so open_under_cursor returned false.
t.press("<C-o>")
t.eq(vim.api.nvim_get_current_buf(), first_obj_buf)
local tree_lnum = assert(find_line(first_obj_buf, "tree "))
vim.api.nvim_win_set_cursor(first_obj_win, { tree_lnum, 0 })
t.truthy(
require("git.object").open_under_cursor(),
"<CR> must work after returning to the buffer"
)
end)
t.test(":G diff <CR> on + line falls back to worktree file", function()
local dir = make_repo({ a = "v1\n" })
t.write(dir, "a", "v2\n")
assert(require("git.core.repo").resolve(dir))
cmd.run({ "diff" })
wait_buf_populated("%[Git diff%]")
---@type integer?
local diff_buf
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match("%[Git diff%]") then
diff_buf = b
end
end
assert(diff_buf, "expected [Git diff] buffer")
local win = vim.fn.bufwinid(diff_buf)
vim.api.nvim_set_current_win(win)
local lnum = assert(find_line(diff_buf, "+v2"))
vim.api.nvim_win_set_cursor(win, { lnum, 0 })
t.truthy(require("git.object").open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), vim.fs.joinpath(dir, "a"))
end)
---Run cmd.run via :lua so vim.fn.execute captures any nvim_echo output
---and suppresses it from headless stdout.
---@param args string[]
---@return string
local function run_capturing(args)
return vim.trim(
vim.fn.execute(
string.format([[lua require("git.cmd").run(%s)]], vim.inspect(args))
)
)
end
---@return integer? pwin
local function find_preview_win()
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.wo[w].previewwindow then
return w
end
end
end
local function close_preview()
pcall(vim.cmd.pclose)
end
t.test("quiet :G echoes single-line stdout", function()
make_repo({ a = "x" })
local out = run_capturing({ "config", "user.email" })
t.truthy(
out:match("t@t%.com"),
"expected output to contain t@t.com, got: " .. out
)
end)
t.test("quiet :G is silent on empty-output success", function()
make_repo({ a = "x" })
t.eq(run_capturing({ "config", "user.email", "new@t.com" }), "")
end)
t.test("quiet :G echoes 'git exited N' on silent failure", function()
make_repo({ a = "x" })
local out = run_capturing({ "config", "--get", "nonexistent.foo.bar" })
t.truthy(
out:match("git exited 1"),
"expected output to contain 'git exited 1', got: " .. out
)
end)
t.test("quiet :G echoes stderr on failure with output", function()
make_repo({ a = "x" })
local out = run_capturing({ "branch", "-d", "nonexistent-branch" })
t.truthy(
out:match("nonexistent%-branch"),
"expected stderr mentioning the branch, got: " .. out
)
end)
---@param fn fun(calls: { chunks: table, history: boolean, opts: table }[])
local function with_echo_stub(fn)
---@type { chunks: table, history: boolean, opts: table }[]
local calls = {}
local original = vim.api.nvim_echo
vim.api.nvim_echo = function(chunks, history, opts)
table.insert(calls, {
chunks = chunks,
history = history,
opts = opts or {},
})
return -1
end
local ok, err = pcall(fn, calls)
vim.api.nvim_echo = original
if not ok then
error(err, 0)
end
end
---@param calls { opts: table }[]
---@param status string
---@return boolean
local function has_status(calls, status)
for _, c in ipairs(calls) do
if c.opts.status == status then
return true
end
end
return false
end
---@param calls { history: boolean }[]
---@return boolean
local function any_history(calls)
for _, c in ipairs(calls) do
if c.history then
return true
end
end
return false
end
t.test("quiet :G success does not add to :messages history", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "config", "user.email" })
t.falsy(any_history(calls), "success path must not write history")
end)
end)
t.test("quiet :G silent failure adds 'git exited N' to history", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "config", "--get", "nonexistent.foo.bar" })
t.truthy(any_history(calls), "failure path must write history")
end)
end)
t.test("quiet :G stderr failure adds error to history", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "branch", "-d", "nonexistent-branch" })
t.truthy(any_history(calls), "failure path must write history")
end)
end)
t.test("streaming :G fetch (no bang) does not open a window", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
local before = #vim.api.nvim_tabpage_list_wins(0)
cmd.run({ "fetch" })
t.wait_for(function()
return has_status(calls, "failed")
or has_status(calls, "success")
end, "streaming job to terminate", 5000)
t.eq(#vim.api.nvim_tabpage_list_wins(0), before, "no new window")
t.falsy(find_preview_win(), "no preview window")
end)
end)
t.test(
"streaming :G fetch (no bang) emits failed progress on bad remote",
function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "fetch", "nonexistent" })
t.wait_for(function()
return has_status(calls, "failed")
end, "failed progress notification", 5000)
---@type { chunks: table, history: boolean, opts: table }?
local final
for _, c in ipairs(calls) do
if c.opts.status == "failed" then
final = c
break
end
end
t.truthy(final)
---@cast final -nil
t.eq(final.opts.kind, "progress")
t.falsy(final.history, "transient progress, not history")
t.truthy(has_status(calls, "running"), "running progress emitted")
end)
end
)
t.test(
"streaming :G fetch (no bang) on failure highlights only fatal/error lines",
function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "fetch", "nonexistent" })
t.wait_for(function()
return has_status(calls, "failed")
end, "failed progress notification", 5000)
---@type table?
local dump
for _, c in ipairs(calls) do
if c.history == true then
dump = c.chunks
break
end
end
t.truthy(dump, "expected history dump")
---@cast dump -nil
local fatal_chunks_red, plain_continuation = 0, false
for _, chunk in ipairs(dump) do
local text, hl = chunk[1], chunk[2]
if text:match("^fatal:") and hl == "ErrorMsg" then
fatal_chunks_red = fatal_chunks_red + 1
end
if text:match("Please make sure") and hl ~= "ErrorMsg" then
plain_continuation = true
end
end
t.truthy(
fatal_chunks_red >= 1,
"expected at least one fatal: line highlighted as ErrorMsg"
)
t.truthy(
plain_continuation,
"expected continuation line to be plain"
)
end)
end
)
t.test(
"streaming :G fetch (no bang) on success does not dump to :messages",
function()
local remote = vim.fn.tempname()
vim.fn.mkdir(remote, "p")
h.git(remote, "init", "-q", "--bare")
t.defer(function()
vim.fn.delete(remote, "rf")
end)
local dir = make_repo({ a = "x" })
h.git(dir, "remote", "add", "origin", remote)
h.git(dir, "push", "-q", "origin", "main")
with_echo_stub(function(calls)
cmd.run({ "fetch", "origin" })
t.wait_for(function()
return has_status(calls, "success")
end, "success progress notification", 5000)
for _, c in ipairs(calls) do
t.falsy(
c.history,
"success path must not echo to message history"
)
end
end)
end
)
t.test(
"streaming :G! fetch (bang) opens preview window with terminal buffer",
function()
make_repo({ a = "x" })
t.defer(close_preview)
cmd.run({ "fetch" }, { bang = true })
local pwin = find_preview_win()
t.truthy(pwin, "expected preview window to exist")
---@cast pwin integer
local buf = vim.api.nvim_win_get_buf(pwin)
t.eq(vim.bo[buf].buftype, "terminal")
end
)
-97
View File
@@ -1,97 +0,0 @@
local t = require("test")
local M = {}
---@class test.git.SystemCompleted : vim.SystemCompleted
---@field stdout string
---@param dir string
---@return test.git.SystemCompleted
function M.git(dir, ...)
local r = vim.system({ "git", ... }, { cwd = dir, text = true }):wait()
if r.code ~= 0 then
error(
string.format(
"git %s failed: %s",
table.concat({ ... }, " "),
vim.trim(r.stderr or "")
),
2
)
end
if r.stdout then
r.stdout = vim.trim(r.stdout)
else
r.stdout = ""
end
return r
end
---Build a temporary git repo with the given committed contents and queue
---cleanup (stop fs watchers, drop test buffers, delete the dir). When
---`opts.cd` is true, also `cd` into the repo and restore the previous
---working directory on cleanup.
---@param files table<string, string>?
---@param opts { cd: boolean? }?
---@return string dir
function M.make_repo(files, opts)
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, "p")
M.git(dir, "init", "-q", "-b", "main")
M.git(dir, "config", "user.email", "t@t.com")
M.git(dir, "config", "user.name", "t")
if files and next(files) then
for path, content in pairs(files) do
t.write(dir, path, content)
end
M.git(dir, "add", ".")
M.git(dir, "commit", "-q", "-m", "init")
end
local prev_cwd
if opts and opts.cd then
prev_cwd = vim.fn.getcwd()
vim.cmd.cd(dir)
end
t.defer(function()
if prev_cwd then
pcall(vim.cmd.cd, prev_cwd)
else
pcall(vim.cmd.cd, "/tmp")
end
pcall(function()
require("git.core.repo").stop_all()
end)
vim.wait(60)
for _, b in ipairs(vim.api.nvim_list_bufs()) do
local name = vim.api.nvim_buf_get_name(b)
if name:find(dir, 1, true) or name:match("^git[a-z]*://") then
pcall(vim.api.nvim_buf_delete, b, { force = true })
end
end
vim.fn.delete(dir, "rf")
end)
return dir
end
---Build an outer repo with one nested submodule at `sub/`. Both the
---outer and inner repo are committed and registered for cleanup.
---@return string outer
---@return string inner
function M.make_submodule_repo()
local inner = M.make_repo({ a = "x\n" })
local outer = M.make_repo({ x = "x\n" })
vim.system({
"git",
"-c",
"protocol.file.allow=always",
"submodule",
"add",
"--quiet",
inner,
"sub",
}, { cwd = outer, text = true }):wait()
M.git(outer, "commit", "-q", "-m", "add submodule")
return outer, inner
end
return M
-634
View File
@@ -1,634 +0,0 @@
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)
-188
View File
@@ -1,188 +0,0 @@
local Revision = require("git.core.revision")
local h = require("test.git.helpers")
local object = require("git.object")
local t = require("test")
---@return integer? buf
local function find_git_buf()
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match("^git://") then
return b
end
end
end
---@param buf integer
---@param prefix string
---@return integer? lnum
local function find_line(buf, prefix)
for i, l in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
if l:sub(1, #prefix) == prefix then
return i
end
end
end
t.test("parse_uri / format_uri round-trip for base", function()
local uri = "git://HEAD"
local rev = assert(object.parse_uri(uri))
t.eq(object.format_uri(rev), uri)
end)
t.test("parse_uri / format_uri round-trip for base + path", function()
local uri = "git://HEAD:lua/foo.lua"
local rev = assert(object.parse_uri(uri))
t.eq(object.format_uri(rev), uri)
end)
t.test("parse_uri / format_uri round-trip for stage + path", function()
local uri = "git://:2:lua/foo.lua"
local rev = assert(object.parse_uri(uri))
t.eq(object.format_uri(rev), uri)
end)
t.test("parse_uri normalizes bare :path to stage 0", function()
local rev = assert(object.parse_uri("git://:foo"))
t.eq(rev.stage, 0)
t.eq(rev.path, "foo")
t.eq(object.format_uri(rev), "git://:0:foo")
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"))
end)
t.test("M.open(HEAD) names buffer with full sha", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = h.git(dir, "rev-parse", "HEAD").stdout
object.open(r, "HEAD", { split = false })
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha)
end)
t.test("M.open(<short sha>) canonicalizes to full sha", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = h.git(dir, "rev-parse", "HEAD").stdout
local short = h.git(dir, "rev-parse", "--short", "HEAD").stdout
t.truthy(#short < #sha, "short sha must be shorter than full")
object.open(r, short, { split = false })
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha)
end)
t.test("M.open(HEAD:<path>) loads file content at HEAD", function()
local dir = h.make_repo({ ["a.txt"] = "first\nsecond\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = h.git(dir, "rev-parse", "HEAD").stdout
object.open(r, "HEAD:a.txt", { split = false })
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha .. ":a.txt")
t.eq(
vim.api.nvim_buf_get_lines(0, 0, -1, false),
{ "first", "second" }
)
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))
t.quietly(function()
object.open(r, "deadbeefdeadbeef", { split = false })
end)
t.falsy(find_git_buf(), "no git:// buffer should exist")
end)
t.test("M.open errors on a missing path, no buffer is opened", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
t.quietly(function()
object.open(r, "HEAD:does-not-exist", { split = false })
end)
t.falsy(find_git_buf(), "no git:// buffer should exist")
end)
t.test("read_uri opens stage-0 entry as a writable index buffer", function()
local dir = h.make_repo({ ["a.txt"] = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local rev = Revision.new({ stage = 0, path = "a.txt" })
local buf = object.buf_for(r, rev)
t.eq(vim.bo[buf].buftype, "acwrite")
t.truthy(vim.bo[buf].modifiable)
t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "first" })
end)
t.test("open_under_cursor on a 'tree <sha>' line opens the tree", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local tree_sha = h.git(dir, "rev-parse", "HEAD^{tree}").stdout
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "tree "), "expected a tree line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.truthy(object.open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. tree_sha)
end)
t.test("open_under_cursor on a 'parent <sha>' line opens the parent", function()
local dir = h.make_repo({ a = "first\n" })
t.write(dir, "a", "second\n")
h.git(dir, "add", "a")
h.git(dir, "commit", "-q", "-m", "second")
local r = assert(require("git.core.repo").resolve(dir))
local parent_sha = h.git(dir, "rev-parse", "HEAD~").stdout
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "parent "), "expected a parent line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.truthy(object.open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. parent_sha)
end)
t.test("open_under_cursor on a '+++ b/<path>' line loads the blob", function()
local dir = h.make_repo({ ["a.txt"] = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local blob_sha = h.git(dir, "rev-parse", "HEAD:a.txt").stdout
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "+++ b/a.txt"), "expected a +++ line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.truthy(object.open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. blob_sha)
end)
t.test("open_under_cursor returns false on a non-dispatchable line", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "author "), "expected an author line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.falsy(object.open_under_cursor())
end)
-369
View File
@@ -1,369 +0,0 @@
---@diagnostic disable: access-invisible
local h = require("test.git.helpers")
local t = require("test")
---@param r ow.Git.Repo
---@param key string
---@param timeout integer?
local function wait_cleared(r, key, timeout)
t.wait_for(function()
return r._cache[key] == nil
end, key .. " cache to clear", timeout or 2000)
end
t.test("list_refs returns heads, tags, remotes (no HEAD)", function()
local dir = h.make_repo({ a = "x" })
h.git(dir, "branch", "feature")
h.git(dir, "tag", "v1")
local r = assert(require("git.core.repo").resolve(dir))
local refs = r:list_refs()
table.sort(refs)
t.eq(refs, { "feature", "main", "v1" })
end)
t.test("list_pseudo_refs always includes HEAD", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
t.eq(r:list_pseudo_refs(), { "HEAD" })
end)
t.test("list_pseudo_refs picks up MERGE_HEAD when present", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
t.write(dir .. "/.git", "MERGE_HEAD", "deadbeef\n")
-- Bypass cache (file appeared after first scan).
r._cache = {}
local refs = r:list_pseudo_refs()
table.sort(refs)
t.eq(refs, { "HEAD", "MERGE_HEAD" })
end)
t.test("list_stash_refs is empty when no stash", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
t.eq(r:list_stash_refs(), {})
end)
t.test("list_stash_refs lists stash + entries when stash exists", function()
local dir = h.make_repo({ a = "x" })
t.write(dir, "a", "modified")
h.git(dir, "stash")
local r = assert(require("git.core.repo").resolve(dir))
local refs = r:list_stash_refs()
t.eq(#refs, 2)
t.eq(refs[1], "stash")
t.eq(refs[2], "stash@{0}")
end)
t.test("get_cached memoizes by key", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local calls = 0
local v1 = r:get_cached("k", function()
calls = calls + 1
return { "first" }
end)
local v2 = r:get_cached("k", function()
calls = calls + 1
return { "second" }
end)
t.eq(calls, 1)
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))
local _ = r:list_refs()
t.truthy(r._cache.refs)
t.write(dir, "b", "y")
h.git(dir, "add", "b")
h.git(dir, "commit", "-q", "-m", "two")
wait_cleared(r, "refs")
t.falsy(r._cache.refs, "cache should be cleared after commit")
end)
t.test("cache clears after slash-branch creation (polyfill)", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local _ = r:list_refs()
t.truthy(r._cache.refs)
h.git(dir, "branch", "feat/foo")
wait_cleared(r, "refs")
t.falsy(r._cache.refs, "cache should clear via polyfilled subdir watcher")
local refs = r:list_refs()
table.sort(refs)
t.eq(refs, { "feat/foo", "main" })
end)
t.test("cache clears after deeply nested slash branch", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local _ = r:list_refs()
h.git(dir, "branch", "deep/a/b/c")
wait_cleared(r, "refs")
local refs = r:list_refs()
table.sort(refs)
t.eq(refs, { "deep/a/b/c", "main" })
end)
t.test("resolve_sha returns ok + full sha for a known blob", function()
local dir = h.make_repo({ a = "hello\n" })
local r = assert(require("git.core.repo").resolve(dir))
local blob = h.git(dir, "rev-parse", "HEAD:a").stdout
local short = blob:sub(1, 7)
local full, status = r:resolve_sha(short)
t.eq(status, "ok")
t.eq(full, blob)
end)
t.test("resolve_sha returns missing for an unknown prefix", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local full, status = r:resolve_sha("0000deadbeef")
t.eq(full, nil)
t.eq(status, "missing")
end)
t.test("resolve_sha caches by prefix", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local blob = h.git(dir, "rev-parse", "HEAD:a").stdout
local short = blob:sub(1, 7)
local _, _ = r:resolve_sha(short)
t.truthy(r._cache["resolve:" .. short], "result should be cached")
end)
---@param r ow.Git.Repo
local function wait_initial(r)
t.wait_for(function()
return r.status.branch.head ~= nil
end, "initial fetch to complete", 2000)
end
t.test("_invalidate clears only matching keys for HEAD", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.head = "h"
r._cache.refs = { "main" }
r._cache.pseudo_refs = { "HEAD" }
r._cache.stash_refs = {}
r._cache["resolve:abc"] = { "deadbeef", "ok" }
r:_invalidate("HEAD")
t.eq(r._cache.head, nil)
t.eq(r._cache.pseudo_refs, nil)
t.eq(r._cache["resolve:abc"], nil)
t.truthy(r._cache.refs)
t.truthy(r._cache.stash_refs)
end)
t.test("_invalidate clears refs/head/resolve for refs/heads/*", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.head = "h"
r._cache.refs = { "main" }
r._cache.pseudo_refs = { "HEAD" }
r._cache.stash_refs = {}
r._cache["resolve:abc"] = { "deadbeef", "ok" }
r:_invalidate("refs/heads/feature")
t.eq(r._cache.head, nil)
t.eq(r._cache.refs, nil)
t.eq(r._cache["resolve:abc"], nil)
t.truthy(r._cache.pseudo_refs)
t.truthy(r._cache.stash_refs)
end)
t.test("_invalidate clears config on .git/config change", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.config = { core = {} }
r:_invalidate("config")
t.eq(r._cache.config, nil)
end)
t.test("status_entry_for: exact match on case-sensitive repo", function()
local dir = h.make_repo({ Foo = "x" })
t.write(dir, "Foo", "modified")
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
t.truthy(r:status_entry_for("Foo"))
t.eq(r:status_entry_for("foo"), nil, "case mismatch returns nil")
end)
t.test("status_entry_for: case-insensitive fallback when core.ignorecase=true", function()
local dir = h.make_repo({ Foo = "x" })
h.git(dir, "config", "core.ignorecase", "true")
t.write(dir, "Foo", "modified")
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
t.truthy(r:status_entry_for("Foo"), "exact match")
t.truthy(r:status_entry_for("foo"), "lowercase finds Foo")
t.truthy(r:status_entry_for("FOO"), "uppercase finds Foo")
end)
t.test("_invalidate matches stash_refs on refs/stash and logs/refs/stash", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.stash_refs = {}
r:_invalidate("refs/stash")
t.eq(r._cache.stash_refs, nil)
r._cache.stash_refs = {}
r:_invalidate("logs/refs/stash")
t.eq(r._cache.stash_refs, nil)
end)
t.test("refresh with invalidate=true wipes cache on next fetch", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
r._cache.head = "stale"
r._cache["resolve:abc"] = { "x", "ok" }
r:refresh({ invalidate = true })
t.wait_for(function()
return r._cache.head == nil
end, "cache wiped after invalidating refresh completes", 2000)
t.eq(r._cache.head, nil)
t.eq(r._cache["resolve:abc"], nil)
end)
t.test("refresh emits change.paths listing structurally-changed paths", function()
local dir = h.make_repo({ a = "1", b = "1" })
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
t.write(dir, "a", "2")
---@type ow.Git.Repo.Change?
local change_seen
local unsub = r:on("change", function(change)
change_seen = change
end)
r:refresh()
t.wait_for(function()
return change_seen ~= nil
end, "refresh emit", 2000)
unsub()
local change = assert(change_seen)
t.truthy(change.paths["a"])
t.falsy(change.paths["b"], "b is unchanged structurally")
end)
t.test("submodule: parent enumerates initialized submodules by default", function()
local outer_path = h.make_submodule_repo()
local outer = assert(require("git.core.repo").resolve(outer_path))
t.truthy(outer._submodules["sub"], "sub recorded as submodule")
end)
t.test("submodule: eagerly creates child Repos and subscribes by default", function()
local outer_path = h.make_submodule_repo()
local outer = assert(require("git.core.repo").resolve(outer_path))
wait_initial(outer)
local inner = require("git.core.repo").all()[outer_path .. "/sub"]
t.truthy(inner, "inner Repo eagerly created")
t.truthy(outer._submodules["sub"] and outer._submodules["sub"].unsub, "inner subscribed by outer")
t.write(outer_path .. "/sub", "a", "modified\n")
---@type ow.Git.Repo.Change?
local outer_change
local unsub = outer:on("change", function(change)
outer_change = change
end)
inner:refresh()
t.wait_for(function()
return outer_change ~= nil
end, "outer notified by inner refresh", 2000)
unsub()
local entry = outer.status.entries["sub"]
t.truthy(entry, "outer sub entry now present")
t.eq(entry.kind, "changed")
end)
t.test("submodule: no eager creation when flag is explicitly disabled", function()
vim.g.git_submodule_recursion = false
t.defer(function()
vim.g.git_submodule_recursion = nil
end)
local outer_path = h.make_submodule_repo()
local outer = assert(require("git.core.repo").resolve(outer_path))
wait_initial(outer)
t.eq(
require("git.core.repo").all()[outer_path .. "/sub"],
nil,
"inner Repo not created when flag is false"
)
t.eq(next(outer._submodules), nil)
end)
t.test("submodule: outer created after inner picks up existing child", function()
local outer_path = h.make_submodule_repo()
local inner = assert(
require("git.core.repo").resolve(outer_path .. "/sub")
)
wait_initial(inner)
local outer = assert(require("git.core.repo").resolve(outer_path))
wait_initial(outer)
t.truthy(outer._submodules["sub"] and outer._submodules["sub"].unsub, "outer subscribed to pre-existing inner")
end)
t.test("watcher cleans up after a slash-branch dir is removed", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
h.git(dir, "branch", "feat/foo")
-- Wait for the dynamic watcher on .git/refs/heads/feat to be added.
local feat_path = dir .. "/.git/refs/heads/feat"
t.wait_for(function()
return r._watchers[feat_path] ~= nil
end, "watcher to be installed on feat/ subdir", 2000)
t.truthy(r._watchers[feat_path], "feat/ subdir should be watched")
-- Remove the branch; the feat/ directory becomes empty and is
-- pruned by git, triggering the deleted-self event.
h.git(dir, "branch", "-D", "feat/foo")
t.wait_for(function()
return r._watchers[feat_path] == nil
end, "watcher on feat/ subdir to close", 2000)
t.falsy(r._watchers[feat_path], "watcher should self-close")
end)
-387
View File
@@ -1,387 +0,0 @@
local t = require("test")
local status = require("git.core.status")
local NUL = "\0"
---@param parts string[]
---@return string
local function nul(parts)
return table.concat(parts, NUL) .. NUL
end
t.test("branch headers: initial repo, no commits", function()
local s = status.parse(nul({
"# branch.oid (initial)",
"# branch.head main",
}))
t.eq(s.branch.oid, nil)
t.eq(s.branch.head, "main")
t.eq(s.branch.upstream, nil)
t.eq(s.branch.ahead, 0)
t.eq(s.branch.behind, 0)
end)
t.test("branch headers: detached HEAD", function()
local s = status.parse(nul({
"# branch.oid 1234567890abcdef1234567890abcdef12345678",
"# branch.head (detached)",
}))
t.eq(s.branch.oid, "1234567890abcdef1234567890abcdef12345678")
t.eq(s.branch.head, nil)
end)
t.test("branch headers: with upstream and ahead/behind", function()
local s = status.parse(nul({
"# branch.oid abc123",
"# branch.head main",
"# branch.upstream origin/main",
"# branch.ab +3 -2",
}))
t.eq(s.branch.head, "main")
t.eq(s.branch.upstream, "origin/main")
t.eq(s.branch.ahead, 3)
t.eq(s.branch.behind, 2)
end)
t.test("type 1: staged-only modification", function()
local s = status.parse(nul({
"1 M. N... 100644 100644 100644 abc abc foo.lua",
}))
local e = s.entries["foo.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.kind, "changed")
t.eq(e.path, "foo.lua")
t.eq(e.staged, "modified")
t.eq(e.unstaged, nil)
t.eq(e.orig, nil)
end)
t.test("type 1: unstaged-only modification", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 abc abc foo.lua",
}))
local e = s.entries["foo.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.staged, nil)
t.eq(e.unstaged, "modified")
end)
t.test("type 1: both sides modified", function()
local s = status.parse(nul({
"1 MM N... 100644 100644 100644 abc abc foo.lua",
}))
local e = s.entries["foo.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.staged, "modified")
t.eq(e.unstaged, "modified")
end)
t.test("type 1: deleted (unstaged)", function()
local s = status.parse(nul({
"1 .D N... 100644 100644 000000 abc abc foo.lua",
}))
local e = s.entries["foo.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.unstaged, "deleted")
end)
t.test("type 1: added (staged)", function()
local s = status.parse(nul({
"1 A. N... 000000 100644 100644 abc abc new.lua",
}))
local e = s.entries["new.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.staged, "added")
end)
t.test("type 1: type-changed (unstaged)", function()
local s = status.parse(nul({
"1 .T N... 100644 100644 120000 abc abc foo.lua",
}))
local e = s.entries["foo.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.unstaged, "type_changed")
end)
t.test("type 2: renamed with orig", function()
local s = status.parse(nul({
"2 R. N... 100644 100644 100644 abc abc R100 new.lua",
"old.lua",
}))
local e = s.entries["new.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.kind, "changed")
t.eq(e.path, "new.lua")
t.eq(e.staged, "renamed")
t.eq(e.orig, "old.lua")
end)
t.test("type 2: copied with orig", function()
local s = status.parse(nul({
"2 C. N... 100644 100644 100644 abc abc C90 copy.lua",
"src.lua",
}))
local e = s.entries["copy.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.staged, "copied")
t.eq(e.orig, "src.lua")
end)
t.test("type u: all seven conflict types", function()
local cases = {
{ xy = "DD", expected = "both_deleted" },
{ xy = "AU", expected = "added_by_us" },
{ xy = "UD", expected = "deleted_by_them" },
{ xy = "UA", expected = "added_by_them" },
{ xy = "DU", expected = "deleted_by_us" },
{ xy = "AA", expected = "both_added" },
{ xy = "UU", expected = "both_modified" },
}
for _, c in ipairs(cases) do
local s = status.parse(nul({
string.format(
"u %s N... 100644 100644 100644 100644 abc abc abc conflict.lua",
c.xy
),
}))
local e = s.entries["conflict.lua"]
t.eq(e.kind, "unmerged", "kind for " .. c.xy)
t.eq(
(e --[[@as ow.Git.Status.UnmergedEntry]]).conflict,
c.expected,
"conflict for " .. c.xy
)
end
end)
t.test("type ?: untracked", function()
local s = status.parse(nul({ "? new.txt" }))
local e = s.entries["new.txt"]
t.eq(e.kind, "untracked")
t.eq(e.path, "new.txt")
end)
t.test("type !: ignored", function()
local s = status.parse(nul({ "! .secret" }))
local e = s.entries[".secret"]
t.eq(e.kind, "ignored")
end)
t.test("mixed: branch + multiple variants", function()
local s = status.parse(nul({
"# branch.oid abc",
"# branch.head main",
"# branch.upstream origin/main",
"# branch.ab +0 -0",
"1 M. N... 100644 100644 100644 a a staged.lua",
"1 .M N... 100644 100644 100644 a a unstaged.lua",
"1 MM N... 100644 100644 100644 a a both.lua",
"u UU N... 100644 100644 100644 100644 a a a conflict.lua",
"? untracked.txt",
"! ignored.txt",
}))
t.eq(s.branch.head, "main")
local staged = s.entries["staged.lua"] --[[@as ow.Git.Status.ChangedEntry]]
local unstaged = s.entries["unstaged.lua"] --[[@as ow.Git.Status.ChangedEntry]]
local both = s.entries["both.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(staged.staged, "modified")
t.eq(unstaged.unstaged, "modified")
t.eq(both.staged, "modified")
t.eq(both.unstaged, "modified")
t.eq(s.entries["conflict.lua"].kind, "unmerged")
t.eq(s.entries["untracked.txt"].kind, "untracked")
t.eq(s.entries["ignored.txt"].kind, "ignored")
end)
t.test("paths with spaces survive splitting", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 a a path with spaces.lua",
}))
local e = s.entries["path with spaces.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.unstaged, "modified")
end)
t.test("mark_for: changed staged modified", function()
local entry = {
kind = "changed",
path = "x",
staged = "modified",
}
t.eq(status.mark_for(entry, "staged"), { char = "M", hl = "GitStagedModified" })
end)
t.test("mark_for: changed unstaged deleted uses GitUnstagedDeleted", function()
local entry = {
kind = "changed",
path = "x",
unstaged = "deleted",
}
t.eq(status.mark_for(entry, "unstaged"), { char = "D", hl = "GitUnstagedDeleted" })
end)
t.test("mark_for: changed renamed uses per-side renamed hl", function()
local entry = {
kind = "changed",
path = "x",
staged = "renamed",
orig = "y",
}
t.eq(status.mark_for(entry, "staged"), { char = "R", hl = "GitStagedRenamed" })
end)
t.test("mark_for: untracked / ignored / unmerged ignore side", function()
t.eq(
status.mark_for({ kind = "untracked", path = "x" } --[[@as ow.Git.Status.Entry]]),
{ char = "?", hl = "GitUntracked" }
)
t.eq(
status.mark_for({ kind = "ignored", path = "x" } --[[@as ow.Git.Status.Entry]]),
{ char = "i", hl = "GitIgnored" }
)
t.eq(
status.mark_for({
kind = "unmerged",
path = "x",
conflict = "both_modified",
} --[[@as ow.Git.Status.Entry]]),
{ char = "!", hl = "GitUnmergedBothModified" }
)
end)
t.test("marks_for: changed with both sides yields two marks", function()
local entry = {
kind = "changed",
path = "x",
staged = "modified",
unstaged = "modified",
}
local marks = status.marks_for(entry)
t.eq(#marks, 2)
t.eq(marks[1], { char = "M", hl = "GitStagedModified" })
t.eq(marks[2], { char = "M", hl = "GitUnstagedModified" })
end)
t.test("marks_for: changed one-sided yields one mark", function()
local entry = { kind = "changed", path = "x", staged = "added" }
local marks = status.marks_for(entry)
t.eq(#marks, 1)
t.eq(marks[1], { char = "A", hl = "GitStagedAdded" })
end)
t.test("marks_for: untracked yields one mark", function()
local entry = { kind = "untracked", path = "x" } --[[@as ow.Git.Status.Entry]]
local marks = status.marks_for(entry)
t.eq(#marks, 1)
t.eq(marks[1], { char = "?", hl = "GitUntracked" })
end)
t.test("Status:rows buckets by section", function()
local s = status.parse(nul({
"1 M. N... 100644 100644 100644 a a staged.lua",
"1 .M N... 100644 100644 100644 a a unstaged.lua",
"1 MM N... 100644 100644 100644 a a both.lua",
"? untracked.txt",
}))
t.eq(#s:rows("staged"), 2, "staged section: staged.lua + both.lua")
t.eq(#s:rows("unstaged"), 2, "unstaged section: unstaged.lua + both.lua")
t.eq(#s:rows("untracked"), 1)
t.eq(#s:rows("unmerged"), 0)
t.eq(#s:rows("ignored"), 0)
end)
t.test("Status:rows for staged carries side='staged'", function()
local s = status.parse(nul({
"1 M. N... 100644 100644 100644 a a x.lua",
}))
local row = assert(s:rows("staged")[1])
t.eq(row.section, "staged")
t.eq(row.side, "staged")
t.eq(row.entry.kind, "changed")
end)
t.test("Status:rows for untracked has nil side", function()
local s = status.parse(nul({ "? x.txt" }))
local row = assert(s:rows("untracked")[1])
t.eq(row.section, "untracked")
t.eq(row.side, nil)
end)
t.test("Status:aggregate_at dedups marks under prefix", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 a a sub/a.lua",
"1 .M N... 100644 100644 100644 a a sub/b.lua",
"? sub/c.txt",
}))
local marks = s:aggregate_at("sub")
t.eq(#marks, 2, "modified ('M') and untracked ('?') deduped")
local m1 = assert(marks[1])
local m2 = assert(marks[2])
local hls = { m1.hl, m2.hl }
table.sort(hls)
t.eq(hls, { "GitUnstagedModified", "GitUntracked" })
end)
t.test("Status:aggregate_at with prefix '.' includes everything", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 a a a.lua",
"? b.txt",
}))
t.eq(#s:aggregate_at("."), 2)
end)
t.test("entry_equal: identical changed entries", function()
local a = { kind = "changed", path = "x", staged = "modified" }
local b = { kind = "changed", path = "x", staged = "modified" }
t.truthy(status.entry_equal(a, b))
end)
t.test("entry_equal: differing staged side returns false", function()
local a = { kind = "changed", path = "x", staged = "modified" }
local b = { kind = "changed", path = "x", staged = "added" }
t.falsy(status.entry_equal(a, b))
end)
t.test("entry_equal: differing orig returns false", function()
local a = { kind = "changed", path = "x", staged = "renamed", orig = "y" }
local b = { kind = "changed", path = "x", staged = "renamed", orig = "z" }
t.falsy(status.entry_equal(a, b))
end)
t.test("entry_equal: nil vs nil is true", function()
t.truthy(status.entry_equal(nil, nil))
end)
t.test("entry_equal: nil vs entry is false", function()
t.falsy(status.entry_equal(nil, { kind = "untracked", path = "x" }))
end)
t.test("entry_equal: different kinds returns false", function()
local a = { kind = "untracked", path = "x" }
local b = { kind = "ignored", path = "x" }
t.falsy(status.entry_equal(a, b))
end)
t.test("entry_equal: differing unmerged conflict returns false", function()
local a = { kind = "unmerged", path = "x", conflict = "both_added" }
local b = { kind = "unmerged", path = "x", conflict = "both_modified" }
t.falsy(status.entry_equal(a, b))
end)
t.test("diff_entries: detects additions, removals, and modifications", function()
local prior = {
a = { kind = "changed", path = "a", staged = "modified" },
b = { kind = "untracked", path = "b" },
}
local next_ = {
a = { kind = "changed", path = "a", staged = "added" },
c = { kind = "untracked", path = "c" },
}
local changed = status.diff_entries(prior, next_)
t.truthy(changed.a, "a modified")
t.truthy(changed.b, "b removed")
t.truthy(changed.c, "c added")
end)
t.test("diff_entries: empty when entries match", function()
local prior = { a = { kind = "untracked", path = "a" } }
local next_ = { a = { kind = "untracked", path = "a" } }
t.eq(status.diff_entries(prior, next_), {})
end)
-487
View File
@@ -1,487 +0,0 @@
local h = require("test.git.helpers")
local t = require("test")
---Replicate the user's global cursor-restore autocmd. Scoped to a
---named augroup + cleanup so it doesn't leak between tests.
local function install_cursor_restore_autocmd()
local group =
vim.api.nvim_create_augroup("test.cursor_restore", { clear = true })
vim.api.nvim_create_autocmd("BufReadPost", {
group = group,
pattern = "*",
command = 'silent! normal! g`"zv',
})
t.defer(function()
pcall(vim.api.nvim_del_augroup_by_name, "test.cursor_restore")
end)
end
---@param sidebar_buf integer
---@param needle string
---@return integer?
local function find_line(sidebar_buf, needle)
for i, l in ipairs(vim.api.nvim_buf_get_lines(sidebar_buf, 0, -1, false)) do
if l:match(needle) then
return i
end
end
end
---Find the gitstatus sidebar window in the current tabpage.
---@return integer? sidebar_buf
---@return integer? sidebar_win
local function find_sidebar()
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
local b = vim.api.nvim_win_get_buf(w)
if vim.bo[b].filetype == "gitstatus" then
return b, w
end
end
end
---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.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
---@param committed_content string
---@param worktree_content string
---@return integer sidebar_win
---@return integer entry_line
local function setup_sidebar_with_unstaged_file(
file_path,
committed_content,
worktree_content
)
local repo = h.make_repo({ [file_path] = committed_content })
t.write(repo, file_path, worktree_content)
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local sidebar_buf, sidebar_win = find_sidebar()
assert(sidebar_buf, "sidebar buffer should exist")
assert(sidebar_win, "sidebar window should exist")
local r = assert(
require("git.core.repo").find(vim.fn.getcwd()),
"repo should resolve for the test worktree"
)
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 = assert(
find_line(sidebar_buf, vim.pesc(file_path) .. "$"),
file_path .. " should appear in sidebar"
)
return sidebar_win, entry_line
end
t.test("stage with diff open: sidebar cursor stays put", function()
install_cursor_restore_autocmd()
local sidebar_win, line = setup_sidebar_with_unstaged_file(
"zsh/rc",
"ZSH=true\n",
"ZSH=true\nmodified\n"
)
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
end, "diff windows to appear")
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
vim.api.nvim_set_current_win(sidebar_win)
t.press("s")
t.wait_for(function()
return #r.status:rows("staged") > 0
end, "stage to propagate to repo state")
t.eq(
vim.api.nvim_win_get_cursor(sidebar_win),
{ line, 0 },
"sidebar cursor should remain at the entry's original line"
)
end)
t.test(
"stage with diff open: diff foldmethod is preserved on refresh",
function()
local sidebar_win, line = setup_sidebar_with_unstaged_file(
"zsh/rc",
"# vim: set ft=zsh nowrap:\nZSH=true\n",
"# vim: set ft=zsh nowrap:\nZSH=true\nmodified\n"
)
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
end, "diff windows to appear")
local left_win = assert(find_diff_win("left"))
t.eq(
vim.wo[left_win].foldmethod,
"diff",
"left diff foldmethod should be 'diff' after Tab"
)
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
vim.api.nvim_set_current_win(sidebar_win)
t.press("s")
t.wait_for(function()
return #r.status:rows("staged") > 0
end, "stage to propagate to repo state")
t.eq(
vim.wo[left_win].foldmethod,
"diff",
"left diff foldmethod should still be 'diff' after stage refresh"
)
end
)
t.test(
"<Tab> in a second tabpage opens the diff inside that tabpage",
function()
local sidebar_win, line =
setup_sidebar_with_unstaged_file("foo.txt", "v1\n", "v2\n")
local tab1 = vim.api.nvim_get_current_tabpage()
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", tab1) ~= nil
end, "diff windows in tab1 to appear")
vim.cmd("tabnew")
require("git.status_view").open({ placement = "sidebar" })
local tab2 = vim.api.nvim_get_current_tabpage()
t.truthy(tab2 ~= tab1, "tabnew should produce a distinct tabpage")
local _, sidebar_win2 = find_sidebar()
assert(sidebar_win2, "sidebar window should exist in tab2")
vim.api.nvim_set_current_win(sidebar_win2)
vim.api.nvim_win_set_cursor(sidebar_win2, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left", tab2) ~= nil
end, "diff windows in tab2 to appear")
t.truthy(
find_diff_win("right", tab2),
"right diff window should be in tab2"
)
end
)
t.test("refresh on stage updates the index URI buffer's content", function()
local sidebar_win, line =
setup_sidebar_with_unstaged_file("foo.txt", "v1\n", "v2\n")
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
end, "diff windows to appear")
local left_win = assert(find_diff_win("left"))
local index_buf = vim.api.nvim_win_get_buf(left_win)
t.eq(
vim.api.nvim_buf_get_lines(index_buf, 0, -1, false),
{ "v1" },
"index pane should initially show committed content"
)
vim.api.nvim_set_current_win(sidebar_win)
t.press("s")
t.wait_for(function()
local first = vim.api.nvim_buf_get_lines(index_buf, 0, -1, false)[1]
return first == "v2"
end, "index pane to refresh to staged content")
t.eq(
vim.api.nvim_buf_get_lines(index_buf, 0, -1, false),
{ "v2" },
"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
)
-49
View File
@@ -1,49 +0,0 @@
local t = require("test")
local util = require("git.core.util")
local function fresh_buf()
local buf = vim.api.nvim_create_buf(false, true)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
return buf
end
t.test("set_buf_lines preserves modifiable=false", function()
local buf = fresh_buf()
vim.bo[buf].modifiable = false
util.set_buf_lines(buf, 0, -1, { "a", "b", "c" })
t.eq(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
{ "a", "b", "c" },
"lines should be replaced"
)
t.falsy(vim.bo[buf].modifiable, "modifiable should stay false")
t.falsy(vim.bo[buf].modified, "modified should be cleared")
end)
t.test("set_buf_lines preserves modifiable=true", function()
local buf = fresh_buf()
vim.bo[buf].modifiable = true
util.set_buf_lines(buf, 0, -1, { "a", "b" })
t.truthy(vim.bo[buf].modifiable, "modifiable should stay true")
t.falsy(vim.bo[buf].modified, "modified should be cleared")
end)
t.test("set_buf_lines partial range update", function()
local buf = fresh_buf()
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "a", "b", "c", "d" })
util.set_buf_lines(buf, 1, 3, { "X", "Y", "Z" })
t.eq(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
{ "a", "X", "Y", "Z", "d" },
"lines [1, 3) should be replaced"
)
end)
t.test("set_buf_lines errors on out-of-bounds (strict_indexing)", function()
local buf = fresh_buf()
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "a", "b" })
local ok = pcall(util.set_buf_lines, buf, 100, 200, { "x" })
t.falsy(ok, "out-of-bounds index should error")
end)