Compare commits

..

7 Commits

12 changed files with 917 additions and 108 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ The plugin auto-initializes from its `plugin/git.lua` and exposes `lua/git/`. No
With `vim.pack`:
```lua
vim.pack.add({ "https://git.owall.dev/warg/git.nvim" })
vim.pack.add({ "https://git.owall.se/warg/git.nvim" })
```
## Commands
+229 -74
View File
@@ -1,10 +1,12 @@
local object = require("git.object")
local popup = require("git.core.popup")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
local NS_POPUP = vim.api.nvim_create_namespace("ow.git.blame.popup")
local NS_OVERFLOW = vim.api.nvim_create_namespace("ow.git.blame.popup.overflow")
local ZERO_SHA = string.rep("0", 40)
@@ -19,6 +21,8 @@ local ZERO_SHA = string.rep("0", 40)
---@class ow.Git.Blame.Result
---@field commits table<string, ow.Git.Blame.Commit>
---@field line_sha table<integer, string>
---@field line_orig table<integer, integer>
---@field line_file table<integer, string>
---@class ow.Git.Blame.Source
---@field repo ow.Git.Repo
@@ -31,6 +35,8 @@ local ZERO_SHA = string.rep("0", 40)
---@field revision string?
---@field commits table<string, ow.Git.Blame.Commit>
---@field line_sha table<integer, string>
---@field line_orig table<integer, integer>
---@field line_file table<integer, string>
---@field tick integer?
---@field epoch integer
---@field pending fun()[]
@@ -50,21 +56,6 @@ function M.state(buf)
return states[buf]
end
---@param ts integer
---@param tz string
---@return string
local function format_author_time(ts, tz)
local sign, hh, mm = tz:match("^([+-])(%d%d)(%d%d)$")
---@type number
local offset = 0
if sign then
local h = tonumber(hh) or 0
local m = tonumber(mm) or 0
offset = (h * 3600 + m * 60) * (sign == "-" and -1 or 1)
end
return os.date("!%Y-%m-%d %T ", ts + offset) .. tz
end
---@param stdout string
---@return ow.Git.Blame.Result
local function parse_porcelain(stdout)
@@ -72,20 +63,33 @@ local function parse_porcelain(stdout)
local commits = {}
---@type table<integer, string>
local line_sha = {}
---@type table<integer, integer>
local line_orig = {}
---@type table<integer, string>
local line_file = {}
local cur_sha ---@type string?
local cur_lnum ---@type integer?
local cur_orig ---@type integer?
local cur_file ---@type string?
for _, line in ipairs(util.split_lines(stdout)) do
if line:sub(1, 1) == "\t" then
if cur_sha and cur_lnum then
if cur_sha and cur_lnum and cur_orig then
line_sha[cur_lnum] = cur_sha
line_orig[cur_lnum] = cur_orig
if cur_file then
line_file[cur_lnum] = cur_file
end
end
cur_sha = nil
cur_lnum = nil
cur_orig = nil
cur_file = nil
else
local sha, final = line:match("^(%x+) %d+ (%d+)")
local sha, orig, final = line:match("^(%x+) (%d+) (%d+)")
if sha and #sha >= 40 then
cur_sha = sha
cur_lnum = tonumber(final) --[[@as integer?]]
cur_orig = tonumber(orig) --[[@as integer]]
cur_lnum = tonumber(final) --[[@as integer]]
if not commits[sha] then
commits[sha] = {
sha = sha,
@@ -99,7 +103,9 @@ local function parse_porcelain(stdout)
else
local key, value = line:match("^(%S+) (.*)$")
local commit = cur_sha and commits[cur_sha]
if commit and key then
if key == "filename" and value then
cur_file = value
elseif commit and key then
if key == "author" then
commit.author = value
elseif key == "author-mail" then
@@ -115,7 +121,12 @@ local function parse_porcelain(stdout)
end
end
end
return { commits = commits, line_sha = line_sha }
return {
commits = commits,
line_sha = line_sha,
line_orig = line_orig,
line_file = line_file,
}
end
---@param line_count integer
@@ -138,6 +149,8 @@ local function synth_uncommitted(line_count)
},
},
line_sha = line_sha,
line_orig = {},
line_file = {},
}
end
@@ -179,14 +192,26 @@ local function resolve_source(buf)
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
if not rev 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 }
local rel = rev.path
if not rel then
local state = r:state(buf)
rel = state and state.blob_path or nil
if not rel then
return nil
end
end
local revision = nil
if rev.path and rev.base and rev.stage == nil then
revision = rev.base
end
return { repo = r, rel = rel, revision = revision }
end
if not repo.is_worktree_buf(buf) then
return nil
@@ -219,6 +244,8 @@ local function ensure_state(buf)
revision = src.revision,
commits = {},
line_sha = {},
line_orig = {},
line_file = {},
tick = nil,
epoch = 0,
pending = {},
@@ -266,6 +293,8 @@ local function run_blame(state, buf, done)
or synth_uncommitted(vim.api.nvim_buf_line_count(buf))
state.commits = data.commits
state.line_sha = data.line_sha
state.line_orig = data.line_orig
state.line_file = data.line_file
state.tick = tick
local pending = state.pending
state.pending = {}
@@ -275,20 +304,100 @@ local function run_blame(state, buf, done)
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
local NEW_FILE_MARKER = "(new file)"
---@param diff_text string
---@param orig_line integer
---@return string[]?
local function extract_commit_hunk(diff_text, orig_line)
local lines = util.split_lines(diff_text)
for _, line in ipairs(lines) do
if line == "--- /dev/null" then
return { NEW_FILE_MARKER }
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
local hunk_starts = {} ---@type integer[]
local target ---@type integer?
for i, line in ipairs(lines) do
local c, d = line:match("^@@ %-%d+,?%d* %+(%d+),?(%d*) @@")
if c then
table.insert(hunk_starts, i)
if not target then
local ns = tonumber(c) --[[@as integer]]
local nc = (d == "" or d == nil) and 1 or tonumber(d) --[[@as integer]]
if orig_line >= ns and orig_line < ns + nc then
target = #hunk_starts
end
end
end
end
if not target then
return nil
end
local out = {} ---@type string[]
table.insert(out, ("Hunk %d of %d"):format(target, #hunk_starts))
local start_idx = hunk_starts[target]
local end_idx = hunk_starts[target + 1] and (hunk_starts[target + 1] - 1)
or #lines
for j = start_idx, end_idx do
local line = lines[j] --[[@as string]]
if line:match("^diff %-%-git") then
break
end
table.insert(out, line)
end
return out
end
local POPUP_SEP = "GIT_BLAME_POPUP_SEP"
---@param r ow.Git.Repo
---@param sha string
---@param rel string
---@param orig_line integer
---@param done fun(message: string[]?, hunk: string[]?)
local function fetch_show(r, sha, rel, orig_line, done)
util.git({
"show",
"--diff-merges=first-parent",
"--format=%B%n" .. POPUP_SEP .. "%n",
sha,
"--",
rel,
}, {
cwd = r.worktree,
silent = true,
on_exit = function(res)
if res.code ~= 0 then
done(nil, nil)
return
end
local lines = util.split_lines(res.stdout or "")
local sep_idx ---@type integer?
for i, line in ipairs(lines) do
if line == POPUP_SEP then
sep_idx = i
break
end
end
if not sep_idx then
done(nil, nil)
return
end
local message = {} ---@type string[]
for i = 1, sep_idx - 1 do
table.insert(message, lines[i])
end
while #message > 0 and message[#message] == "" do
table.remove(message)
end
local diff_text = table.concat(lines, "\n", sep_idx + 1) ---@type string
done(
#message > 0 and message or nil,
extract_commit_hunk(diff_text, orig_line)
)
end,
})
end
local popup_win ---@type integer?
@@ -303,14 +412,23 @@ end
---@param pbuf integer
---@param win integer
---@param head string[]
---@param body string[]?
---@param message string[]?
---@param diff string[]?
---@param sha_len integer?
---@param date_col integer?
local function apply_popup(pbuf, win, head, body, sha_len, date_col)
local lines = {}
local function apply_popup(pbuf, win, head, message, diff, sha_len, date_col)
local lines = {} ---@type string[]
vim.list_extend(lines, head)
if body then
vim.list_extend(lines, body)
if message then
vim.list_extend(lines, message)
end
local diff_start ---@type integer?
if diff then
if #lines > #head then
table.insert(lines, "")
end
diff_start = #lines
vim.list_extend(lines, diff)
end
vim.bo[pbuf].modifiable = true
vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines)
@@ -334,9 +452,32 @@ local function apply_popup(pbuf, win, head, body, sha_len, date_col)
hl_group = "GitBlameDate",
})
end
local width, height = size_for(lines)
if diff_start and diff then
if #diff == 1 and diff[1] == NEW_FILE_MARKER then
pcall(
vim.api.nvim_buf_set_extmark,
pbuf,
NS_POPUP,
diff_start,
0,
{ line_hl_group = "GitHunkAdded" }
)
else
util.paint_diff_lines(pbuf, NS_POPUP, diff, diff_start)
pcall(
vim.api.nvim_buf_set_extmark,
pbuf,
NS_POPUP,
diff_start,
0,
{ line_hl_group = "GitHunkAnnotation" }
)
end
end
local width, height = popup.size_for(lines)
pcall(vim.api.nvim_win_set_width, win, width)
pcall(vim.api.nvim_win_set_height, win, height)
popup.paint_overflow(pbuf, win, NS_OVERFLOW)
end
---@param watch_buf integer
@@ -362,18 +503,23 @@ local function setup_popup_autocmds(watch_buf, pbuf, win)
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame.popup")
end,
})
vim.api.nvim_create_autocmd("WinScrolled", {
group = group,
pattern = tostring(win),
callback = function()
popup.paint_overflow(pbuf, win, NS_OVERFLOW)
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 state ow.Git.Blame.BufState
---@param lnum integer
---@param watch_buf integer
local function open_popup(r, commits, line_sha, lnum, watch_buf)
local function open_popup(state, lnum, watch_buf)
close_popup()
local sha = line_sha[lnum]
local commit = sha and commits[sha]
local sha = state.line_sha[lnum]
local commit = sha and state.commits[sha]
if not commit then
util.warning("git blame: no blame information for line %d", lnum)
return
@@ -385,7 +531,7 @@ local function open_popup(r, commits, line_sha, lnum, watch_buf)
head = { "Not Committed Yet" }
else
local short = sha:sub(1, 8)
local date = format_author_time(commit.author_time, commit.author_tz)
local date = util.format_git_time(commit.author_time, commit.author_tz)
sha_len = #short
date_col = sha_len + 2 + #commit.author + 2
head = {
@@ -393,15 +539,16 @@ local function open_popup(r, commits, line_sha, lnum, watch_buf)
"",
}
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 message = sha_len and { commit.summary } or nil ---@type string[]?
local diff ---@type string[]?
local pbuf = vim.api.nvim_create_buf(false, true)
vim.bo[pbuf].bufhidden = "wipe"
local initial = {} ---@type string[]
vim.list_extend(initial, head)
if message then
vim.list_extend(initial, message)
end
local width, height = popup.size_for(initial)
local win = vim.api.nvim_open_win(pbuf, false, {
relative = "cursor",
row = 1,
@@ -411,29 +558,37 @@ local function open_popup(r, commits, line_sha, lnum, watch_buf)
style = "minimal",
})
popup_win = win
apply_popup(pbuf, win, head, body, sha_len, date_col)
local function redraw()
apply_popup(pbuf, win, head, message, diff, sha_len, date_col)
end
redraw()
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, date_col)
end
end,
})
local orig = state.line_orig[lnum]
local rel = state.line_file[lnum] or state.rel
if not orig then
return
end
fetch_show(state.repo, sha, rel, orig, function(msg, hunk)
if
popup_win ~= win
or not vim.api.nvim_win_is_valid(win)
or not vim.api.nvim_buf_is_valid(pbuf)
then
return
end
if msg then
message = msg
end
if hunk then
diff = hunk
end
redraw()
end)
end
---@param buf integer?
@@ -457,7 +612,7 @@ function M.line_popup(buf)
then
return
end
open_popup(state.repo, state.commits, state.line_sha, lnum, buf)
open_popup(state, lnum, buf)
end)
end
+82
View File
@@ -0,0 +1,82 @@
local M = {}
---@param win integer
---@return boolean
local function has_border(win)
local b = vim.api.nvim_win_get_config(win).border
if not b or b == "none" or b == "" then
return false
end
if type(b) == "table" and #b == 0 then
return false
end
return true
end
---@param lines string[]
---@param opts? { min_width?: integer, padding?: integer }
---@return integer width
---@return integer height
function M.size_for(lines, opts)
opts = opts or {}
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 + (opts.padding or 1), opts.min_width or 30),
100,
vim.o.columns - 4
)
local height =
math.min(math.max(#lines, 1), 20, math.floor(vim.o.lines / 2))
return width, height
end
---@param pbuf integer
---@param win integer
---@param ns integer
function M.paint_overflow(pbuf, win, ns)
vim.schedule(function()
if
not vim.api.nvim_buf_is_valid(pbuf)
or not vim.api.nvim_win_is_valid(win)
then
return
end
vim.api.nvim_buf_clear_namespace(pbuf, ns, 0, -1)
local total = vim.api.nvim_buf_line_count(pbuf)
local top, bottom = unpack(vim.api.nvim_win_call(win, function()
return { vim.fn.line("w0"), vim.fn.line("w$") }
end)) ---@cast top integer
---@cast bottom integer
local needs_top = top > 1
local needs_bottom = bottom < total
if has_border(win) then
pcall(vim.api.nvim_win_set_config, win, {
title = needs_top and { { "", "FloatBorder" } } or "",
title_pos = needs_top and "right" or nil,
footer = needs_bottom and { { "", "FloatBorder" } } or "",
footer_pos = needs_bottom and "right" or nil,
})
return
end
if needs_top then
pcall(vim.api.nvim_buf_set_extmark, pbuf, ns, top - 1, 0, {
virt_text = { { "", "GitPopupEnd" } },
virt_text_pos = "right_align",
})
end
if needs_bottom then
pcall(vim.api.nvim_buf_set_extmark, pbuf, ns, bottom - 1, 0, {
virt_text = { { "", "GitPopupEnd" } },
virt_text_pos = "right_align",
})
end
end)
end
return M
+1
View File
@@ -19,6 +19,7 @@ end
---@field immutable boolean?
---@field index_writer boolean?
---@field index_mode string?
---@field blob_path string?
---@alias ow.Git.Repo.Event
---| "change"
+45
View File
@@ -120,6 +120,51 @@ function M.split_lines(content)
return lines
end
---@param ts integer
---@param tz string
---@return string
function M.format_git_time(ts, tz)
local sign, hh, mm = tz:match("^([+-])(%d%d)(%d%d)$")
local offset = 0
if sign then
local h = tonumber(hh) or 0
local m = tonumber(mm) or 0
offset = math.floor(h * 3600 + m * 60)
if sign == "-" then
offset = -offset
end
end
return os.date("!%a %b %e %T %Y ", ts + offset) .. tz
end
---@param buf integer
---@param ns integer
---@param lines string[]
---@param start_row integer 0-indexed row of the first line in `lines`
function M.paint_diff_lines(buf, ns, lines, start_row)
for i, line in ipairs(lines) do
local hl ---@type string?
local prefix = line:sub(1, 1)
if prefix == "+" then
hl = "GitHunkAdded"
elseif prefix == "-" then
hl = "GitHunkRemoved"
elseif vim.startswith(line, "@@") then
hl = "GitHunkHeader"
end
if hl then
pcall(
vim.api.nvim_buf_set_extmark,
buf,
ns,
start_row + i - 1,
0,
{ line_hl_group = hl }
)
end
end
end
---@class ow.Git.Util.DebounceHandle
---@field cancel fun()
---@field flush fun()
+14 -9
View File
@@ -1,3 +1,4 @@
local popup = require("git.core.popup")
local repo = require("git.core.repo")
local util = require("git.core.util")
@@ -5,6 +6,9 @@ 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")
local NS_PREVIEW = vim.api.nvim_create_namespace("ow.git.hunks.preview")
local NS_PREVIEW_OVERFLOW =
vim.api.nvim_create_namespace("ow.git.hunks.preview.overflow")
---@alias ow.Git.Hunks.HunkType "add"|"change"|"delete"
@@ -905,16 +909,9 @@ function M.preview_hunk(buf)
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))
util.paint_diff_lines(pbuf, NS_PREVIEW, lines, 0)
local width, height = popup.size_for(lines, { min_width = 40, padding = 2 })
local win = vim.api.nvim_open_win(pbuf, false, {
relative = "cursor",
row = 1,
@@ -924,6 +921,7 @@ function M.preview_hunk(buf)
style = "minimal",
})
preview_win = win
popup.paint_overflow(pbuf, win, NS_PREVIEW_OVERFLOW)
local function close()
if vim.api.nvim_win_is_valid(win) then
@@ -949,6 +947,13 @@ function M.preview_hunk(buf)
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks.preview")
end,
})
vim.api.nvim_create_autocmd("WinScrolled", {
group = group,
pattern = tostring(win),
callback = function()
popup.paint_overflow(pbuf, win, NS_PREVIEW_OVERFLOW)
end,
})
vim.keymap.set("n", "q", close, { buffer = pbuf, nowait = true })
end
+94 -12
View File
@@ -159,6 +159,29 @@ function M.buf_for(r, rev)
return buf
end
---@param lines string[]
local function format_header_dates(lines)
for i, line in ipairs(lines) do
if line == "" then
return
end
local prefix, ts, tz = line:match("^(author .-) (%d+) ([+-]%d%d%d%d)$")
if not prefix then
prefix, ts, tz = line:match("^(committer .-) (%d+) ([+-]%d%d%d%d)$")
end
if not prefix then
prefix, ts, tz = line:match("^(tagger .-) (%d+) ([+-]%d%d%d%d)$")
end
if prefix then
local n = math.floor(assert(tonumber(ts)))
lines[i] = ("%s %s"):format(
prefix,
util.format_git_time(n, tz --[[@as string]])
)
end
end
end
---@param buf integer
---@param r ow.Git.Repo
---@param rev ow.Git.Revision
@@ -189,7 +212,9 @@ local function populate(buf, r, rev, state, rev_sha)
end
end
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
local lines = util.split_lines(stdout)
format_header_dates(lines)
util.set_buf_lines(buf, 0, -1, lines)
state.sha = rev_sha
return true
end
@@ -296,6 +321,10 @@ local function side_buf(r, blob, path)
if full then
local buf = M.buf_for(r, Revision.new({ base = full }))
set_ft_from_path(buf, path)
local state = r:state(buf)
if state then
state.blob_path = path
end
return buf
end
local p = vim.fs.joinpath(r.worktree, path)
@@ -308,10 +337,45 @@ local function side_buf(r, blob, path)
end
---@param r ow.Git.Repo
---@param commit string
---@param parent boolean
---@param path string
---@return integer?
local function commit_side_buf(r, commit, parent, path)
local rev = parent and (commit .. "^") or commit
local sha = r:rev_parse(rev, false)
if not sha then
return nil
end
if not r:rev_parse(sha .. ":" .. path, false) then
return nil
end
return M.buf_for(r, Revision.new({ base = sha, path = path }))
end
---@param r ow.Git.Repo
---@param commit string?
---@param parent boolean
---@param blob string?
---@param path string
local function load_side(r, blob, path)
local buf = side_buf(r, blob, path)
---@return integer?
local function side(r, commit, parent, blob, path)
if commit then
local buf = commit_side_buf(r, commit, parent, path)
if buf then
return buf
end
end
return side_buf(r, blob, path)
end
---@param r ow.Git.Repo
---@param commit string?
---@param parent boolean
---@param blob string?
---@param path string
local function load_side(r, commit, parent, blob, path)
local buf = side(r, commit, parent, blob, path)
if not buf then
util.error("no content for %s", path)
return
@@ -321,14 +385,15 @@ local function load_side(r, blob, path)
end
---@param r ow.Git.Repo
---@param commit string?
---@param section ow.Git.DiffSection
local function open_section(r, section)
local function open_section(r, commit, 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)
local left = side(r, commit, true, section.blob_a, section.path_a)
local right = side(r, commit, false, section.blob_b, section.path_b)
if left and right then
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(right)
@@ -348,6 +413,21 @@ local function open_section(r, section)
vim.api.nvim_set_current_buf(buf)
end
---@param r ow.Git.Repo
---@param buf integer
---@return string?
local function commit_in_buf(r, buf)
local name = vim.api.nvim_buf_get_name(buf)
if not vim.startswith(name, M.URI_PREFIX) then
return nil
end
local rev = M.parse_uri(name)
if not rev or not rev.base or rev.path then
return nil
end
return r:rev_parse(rev.base .. "^{commit}", false)
end
---@class ow.Git.Object.OpenOpts
---@field split (false|"above"|"below"|"left"|"right")?
@@ -394,7 +474,7 @@ function M.open_under_cursor()
line:match("^%d+ (%w+) (%x+)\t(.+)$")
if entry_sha then
if entry_type == "blob" then
load_side(r, entry_sha, entry_name --[[@as string]])
load_side(r, nil, false, entry_sha, entry_name --[[@as string]])
else
M.open(r, entry_sha, { split = false })
end
@@ -406,24 +486,26 @@ function M.open_under_cursor()
return false
end
local commit = commit_in_buf(r, vim.api.nvim_get_current_buf())
if line:match("^diff %-%-git ") then
open_section(r, section)
open_section(r, commit, section)
return true
end
if line:match("^%-%-%- ") then
load_side(r, section.blob_a, section.path_a)
load_side(r, commit, true, section.blob_a, section.path_a)
return true
end
if line:match("^%+%+%+ ") then
load_side(r, section.blob_b, section.path_b)
load_side(r, commit, false, 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)
load_side(r, commit, false, section.blob_b, section.path_b)
return true
elseif prefix == "-" then
load_side(r, section.blob_a, section.path_a)
load_side(r, commit, true, section.blob_a, section.path_a)
return true
end
return false
+4
View File
@@ -40,12 +40,16 @@ local DEFAULT_HIGHLIGHTS = {
GitHunkAdded = "Added",
GitHunkChanged = "Changed",
GitHunkRemoved = "Removed",
GitHunkHeader = "Statement",
GitHunkAnnotation = "Title",
GitHunkAddLine = "DiffAdd",
GitHunkDeleteLine = "DiffDelete",
GitBlameAuthor = "GitAuthor",
GitBlameDate = "GitDate",
GitBlameSha = "GitSha",
GitPopupEnd = "NonText",
}
local STAGED_HUNK_HL = {
GitHunkStagedAdded = "GitHunkAdded",
+26
View File
@@ -0,0 +1,26 @@
if exists("b:current_syntax")
finish
endif
syntax match gitlogGraph contained /^[*|\\\/_ ]*/
\ nextgroup=gitlogHash
syntax match gitlogHash contained /\<\x\{7,64\}\>/
\ nextgroup=gitlogDate skipwhite
syntax match gitlogDate contained /\<\d\{4}-\d\{2}-\d\{2}\>/
\ nextgroup=gitlogAuthor skipwhite
syntax match gitlogAuthor contained /{[^}]\+}/
\ nextgroup=gitlogRef skipwhite
syntax match gitlogRef contained /([^)]\+)/
syntax match gitlogLine
\ /^[*|\\\/_ ]*\x\{7,64}\s\+\d\{4}-\d\{2}-\d\{2}\s\+{[^}]\+}.*/
\ contains=gitlogGraph
syntax match gitlogGraphLine /^[*|\\\/_ ]\+$/
\ contains=gitlogGraph
highlight default link gitlogGraph Comment
highlight default link gitlogHash GitSha
highlight default link gitlogDate GitDate
highlight default link gitlogAuthor GitAuthor
highlight default link gitlogRef Constant
let b:current_syntax = "gitlog"
+306 -2
View File
@@ -86,9 +86,9 @@ t.test("line popup formats the datetime in the author timezone", function()
)
t.truthy(
(lines[1] or ""):match(
"%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d [+%-]%d%d%d%d$"
"%u%l%l %u%l%l +%d+ %d%d:%d%d:%d%d %d%d%d%d [+%-]%d%d%d%d$"
),
"the head line ends with an ISO datetime and numeric timezone"
"the head line ends with a human datetime and numeric timezone"
)
end)
@@ -199,6 +199,226 @@ t.test("line popup shows the commit for the cursor line", function()
t.truthy((lines[1] or ""):find("t", 1, true), "author shown")
end)
t.test("line popup appends the commit's hunk for the cursor line", function()
local dir = h.make_repo({ ["a.txt"] = "alpha\nbeta\ngamma\n" })
t.write(dir, "a.txt", "alpha\nBETA\ngamma\n")
h.git(dir, "add", "a.txt")
h.git(dir, "commit", "-q", "-m", "change beta")
vim.cmd.edit(dir .. "/a.txt")
local buf = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.line_popup(buf)
local float = wait_float()
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
t.wait_for(function()
local lines = vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
for _, l in ipairs(lines) do
if l:match("^@@") then
return true
end
end
return false
end, "hunk header to appear in the blame popup")
local lines = vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
local has = {} ---@type table<string, boolean>
for _, l in ipairs(lines) do
has[l] = true
end
t.truthy(has["-beta"], "popup includes the pre-image of the changed line")
t.truthy(has["+BETA"], "popup includes the post-image of the changed line")
t.truthy(
has["Hunk 1 of 1"],
"popup annotates the hunk index even when the commit only has one"
)
local pbuf = vim.api.nvim_win_get_buf(float)
local seen = {} ---@type table<string, boolean>
for _, m in
ipairs(vim.api.nvim_buf_get_extmarks(pbuf, -1, 0, -1, {
details = true,
}))
do
local hl = m[4] and m[4].line_hl_group
if hl then
seen[hl] = true
end
end
t.truthy(seen["GitHunkAdded"], "+ line gets GitHunkAdded")
t.truthy(seen["GitHunkRemoved"], "- line gets GitHunkRemoved")
t.truthy(seen["GitHunkHeader"], "@@ header gets GitHunkHeader")
t.truthy(
seen["GitHunkAnnotation"],
"hunk-index annotation gets GitHunkAnnotation"
)
end)
t.test(
"line popup picks the hunk that contains the cursor, not the first one",
function()
local lines_initial = {}
local lines_changed = {}
for i = 1, 12 do
table.insert(lines_initial, "line" .. i)
table.insert(lines_changed, "line" .. i)
end
lines_changed[2] = "FIRST"
lines_changed[10] = "SECOND"
local initial = table.concat(lines_initial, "\n") .. "\n"
local changed = table.concat(lines_changed, "\n") .. "\n"
local dir = h.make_repo({ ["a.txt"] = initial })
t.write(dir, "a.txt", changed)
h.git(dir, "add", "a.txt")
h.git(dir, "commit", "-q", "-m", "change line 2 and line 10")
vim.cmd.edit(dir .. "/a.txt")
local buf = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_cursor(0, { 10, 0 })
blame.line_popup(buf)
local float = wait_float()
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
t.wait_for(function()
for _, l in
ipairs(
vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
)
do
if l == "+SECOND" then
return true
end
end
return false
end, "popup to include the second hunk's added line")
local lines = vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
local has = {} ---@type table<string, boolean>
for _, l in ipairs(lines) do
has[l] = true
end
t.truthy(has["-line10"], "popup includes second hunk pre-image")
t.truthy(has["+SECOND"], "popup includes second hunk post-image")
t.falsy(has["-line2"], "popup omits the first hunk's pre-image")
t.falsy(has["+FIRST"], "popup omits the first hunk's post-image")
t.truthy(
has["Hunk 2 of 2"],
"popup annotates which hunk is selected and how many total"
)
end
)
t.test(
"line popup finds the diff via the historical filename after a rename",
function()
local dir = h.make_repo({ ["old.txt"] = "alpha\nbeta\n" })
t.write(dir, "old.txt", "ALPHA\nbeta\n")
h.git(dir, "add", "old.txt")
h.git(dir, "commit", "-q", "-m", "change line 1")
h.git(dir, "mv", "old.txt", "new.txt")
h.git(dir, "commit", "-q", "-m", "rename")
vim.cmd.edit(dir .. "/new.txt")
local buf = vim.api.nvim_get_current_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.wait_for(function()
for _, l in
ipairs(
vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
)
do
if l == "+ALPHA" then
return true
end
end
return false
end, "popup to include the pre-rename commit's diff via old.txt")
end
)
t.test("line popup collapses a new-file diff to a single marker", function()
local dir = h.make_repo({ ["a.txt"] = "alpha\nbeta\ngamma\n" })
vim.cmd.edit(dir .. "/a.txt")
local buf = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.line_popup(buf)
local float = wait_float()
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
t.wait_for(function()
for _, l in
ipairs(
vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
)
do
if l == "(new file)" then
return true
end
end
return false
end, "popup to show the new-file marker")
local lines = vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
for _, l in ipairs(lines) do
t.falsy(
vim.startswith(l, "+alpha")
or vim.startswith(l, "+beta")
or vim.startswith(l, "+gamma"),
"popup does not dump the file content as + lines"
)
end
local pbuf = vim.api.nvim_win_get_buf(float)
local seen = {} ---@type table<string, boolean>
for _, m in
ipairs(vim.api.nvim_buf_get_extmarks(pbuf, -1, 0, -1, {
details = true,
}))
do
local hl = m[4] and m[4].line_hl_group
if hl then
seen[hl] = true
end
end
t.truthy(seen["GitHunkAdded"], "new-file marker gets GitHunkAdded")
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)
@@ -247,6 +467,90 @@ t.test("line popup works in a git:// object buffer", function()
)
end)
t.test(
"line popup works in a git://<blob-sha> buffer with a recovered path",
function()
local object = require("git.object")
local dir = h.make_repo({ ["a.txt"] = "alpha\nbeta\ngamma\n" })
local sha = h.git(dir, "rev-parse", "HEAD").stdout
local tree = h.git(dir, "rev-parse", "HEAD^{tree}").stdout
local blob = h.git(dir, "rev-parse", "HEAD:a.txt").stdout
local r = assert(require("git.core.repo").resolve(dir))
object.open(r, tree, { split = false })
local tree_buf = vim.api.nvim_get_current_buf()
local found ---@type integer?
for i, l in ipairs(vim.api.nvim_buf_get_lines(tree_buf, 0, -1, false)) do
if l:match("^%d+ blob %x+\ta%.txt$") then
found = i
break
end
end
local lnum = assert(found, "expected an a.txt tree entry")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.truthy(object.open_under_cursor())
local gbuf = vim.api.nvim_get_current_buf()
t.eq(vim.api.nvim_buf_get_name(gbuf), "git://" .. blob)
vim.api.nvim_win_set_cursor(0, { 1, 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 bare-blob git:// buffer"
)
end
)
t.test("line popup works in a git://:0: index buffer", function()
local object = require("git.object")
local Revision = require("git.core.revision")
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 ibuf = object.buf_for(r, Revision.new({ stage = 0, path = "a.txt" }))
vim.api.nvim_set_current_buf(ibuf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.line_popup(ibuf)
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 for an index entry"
)
end)
t.test("line popup skips a bare git://<commit-sha> object dump", function()
local object = require("git.object")
local dir = h.make_repo({ ["a.txt"] = "alpha\n" })
local r = assert(require("git.core.repo").resolve(dir))
object.open(r, "HEAD", { split = false })
local cbuf = vim.api.nvim_get_current_buf()
t.quietly(function()
blame.line_popup(cbuf)
end)
t.eq(blame.state(cbuf), nil, "no blame state on a commit dump")
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)
+26
View File
@@ -608,6 +608,32 @@ t.test("preview_hunk shows the hunk body without file headers", function()
end
end)
t.test("preview_hunk highlights diff lines via per-line extmarks", 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 pbuf = vim.api.nvim_win_get_buf(float)
local seen = {} ---@type table<string, boolean>
for _, m in
ipairs(vim.api.nvim_buf_get_extmarks(pbuf, -1, 0, -1, {
details = true,
}))
do
local hl = m[4] and m[4].line_hl_group
if hl then
seen[hl] = true
end
end
t.truthy(seen["GitHunkHeader"], "@@ header gets GitHunkHeader")
t.truthy(seen["GitHunkAdded"], "+ line gets GitHunkAdded")
t.truthy(seen["GitHunkRemoved"], "- line gets GitHunkRemoved")
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)
+89 -10
View File
@@ -134,6 +134,45 @@ t.test("read_uri opens stage-0 entry as a writable index buffer", function()
t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "first" })
end)
t.test("M.open renders author/committer dates, not unix timestamps", 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 author = assert(find_line(0, "author "), "expected an author line")
local committer =
assert(find_line(0, "committer "), "expected a committer line")
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
for _, lnum in ipairs({ author, committer }) do
local line = assert(lines[lnum])
t.truthy(
line:match(
" %u%l%l %u%l%l +%d+ %d%d:%d%d:%d%d %d%d%d%d [+-]%d%d%d%d$"
),
"expected formatted date on: " .. line
)
t.falsy(
line:match(" %d%d%d%d%d%d%d%d%d%d? [+-]%d%d%d%d$"),
"expected no unix timestamp on: " .. line
)
end
end)
t.test("M.open renders tagger dates on annotated tags", function()
local dir = h.make_repo({ a = "first\n" })
h.git(dir, "tag", "-m", "rel", "v1")
local r = assert(require("git.core.repo").resolve(dir))
object.open(r, "v1", { split = false })
local tagger = assert(find_line(0, "tagger "), "expected a tagger line")
local line =
assert(vim.api.nvim_buf_get_lines(0, tagger - 1, tagger, false)[1])
t.truthy(
line:match(" %u%l%l %u%l%l +%d+ %d%d:%d%d:%d%d %d%d%d%d [+-]%d%d%d%d$"),
"expected formatted date on: " .. line
)
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))
@@ -163,18 +202,58 @@ t.test("open_under_cursor on a 'parent <sha>' line opens the parent", function()
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
t.test(
"open_under_cursor on a '+++ b/<path>' line opens the file at the commit",
function()
local dir = h.make_repo({ ["a.txt"] = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local commit_sha = h.git(dir, "rev-parse", "HEAD").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 })
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.truthy(object.open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. commit_sha .. ":a.txt")
end
)
t.test(
"open_under_cursor on a '--- a/<path>' line opens the parent at that path",
function()
local dir = h.make_repo({ ["a.txt"] = "first\n" })
t.write(dir, "a.txt", "second\n")
h.git(dir, "add", "a.txt")
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, "--- a/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://" .. parent_sha .. ":a.txt")
end
)
t.test(
"open_under_cursor on a tree-entry blob still uses the bare blob URI",
function()
local dir = h.make_repo({ ["a.txt"] = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local tree_sha = h.git(dir, "rev-parse", "HEAD^{tree}").stdout
local blob_sha = h.git(dir, "rev-parse", "HEAD:a.txt").stdout
object.open(r, tree_sha, { split = false })
local lnum =
assert(find_line(0, "100644 blob "), "expected a tree entry 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" })