feat(git/cmd): make :G diff output navigable
This commit is contained in:
+132
-9
@@ -8,13 +8,89 @@ local M = {}
|
|||||||
---@class ow.Git.Cmd.SplitHandler
|
---@class ow.Git.Cmd.SplitHandler
|
||||||
---@field ft string
|
---@field ft string
|
||||||
---@field needs_rev boolean?
|
---@field needs_rev boolean?
|
||||||
|
---@field on_state? fun(state: ow.Git.Repo.BufState, r: ow.Git.Repo, args: string[])
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param args string[] -- diff args including leading "diff"
|
||||||
|
---@return string left_ref
|
||||||
|
---@return string? right_ref -- nil means worktree
|
||||||
|
local function compute_diff_refs(r, args)
|
||||||
|
local cached = false
|
||||||
|
local positional = {} ---@type string[]
|
||||||
|
local saw_separator = false
|
||||||
|
for i = 2, #args do
|
||||||
|
local a = args[i]
|
||||||
|
if saw_separator then
|
||||||
|
break
|
||||||
|
elseif a == "--" then
|
||||||
|
saw_separator = true
|
||||||
|
elseif a == "--cached" or a == "--staged" then
|
||||||
|
cached = true
|
||||||
|
elseif a:sub(1, 1) ~= "-" then
|
||||||
|
table.insert(positional, a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function defaults()
|
||||||
|
if cached then
|
||||||
|
return "HEAD", ":"
|
||||||
|
end
|
||||||
|
return ":", nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if #positional == 0 then
|
||||||
|
return defaults()
|
||||||
|
end
|
||||||
|
|
||||||
|
local first = positional[1] --[[@as string]]
|
||||||
|
if #positional == 1 then
|
||||||
|
local lhs, rhs = first:match("^(.-)%.%.%.(.+)$")
|
||||||
|
if lhs then
|
||||||
|
return (lhs ~= "" and lhs or "HEAD"), rhs
|
||||||
|
end
|
||||||
|
lhs, rhs = first:match("^(.-)%.%.(.+)$")
|
||||||
|
if lhs then
|
||||||
|
return (lhs ~= "" and lhs or "HEAD"),
|
||||||
|
(rhs ~= "" and rhs or "HEAD")
|
||||||
|
end
|
||||||
|
if r:rev_parse(first, true) then
|
||||||
|
if cached then
|
||||||
|
return first, ":"
|
||||||
|
end
|
||||||
|
return first, nil
|
||||||
|
end
|
||||||
|
return defaults()
|
||||||
|
end
|
||||||
|
|
||||||
|
local second = positional[2] --[[@as string]]
|
||||||
|
local first_ok = r:rev_parse(first, true) ~= nil
|
||||||
|
if first_ok and r:rev_parse(second, true) then
|
||||||
|
return first, second
|
||||||
|
end
|
||||||
|
if first_ok then
|
||||||
|
if cached then
|
||||||
|
return first, ":"
|
||||||
|
end
|
||||||
|
return first, nil
|
||||||
|
end
|
||||||
|
return defaults()
|
||||||
|
end
|
||||||
|
|
||||||
---@type table<string, ow.Git.Cmd.SplitHandler>
|
---@type table<string, ow.Git.Cmd.SplitHandler>
|
||||||
local SPLIT_HANDLERS = {
|
local SPLIT_HANDLERS = {
|
||||||
log = { ft = "git" },
|
log = { ft = "git" },
|
||||||
diff = { ft = "git" },
|
diff = {
|
||||||
|
ft = "gitdiff",
|
||||||
|
on_state = function(state, r, args)
|
||||||
|
local left, right = compute_diff_refs(r, args)
|
||||||
|
state.left_ref = left
|
||||||
|
state.right_ref = right
|
||||||
|
end,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
M._compute_diff_refs = compute_diff_refs
|
||||||
|
|
||||||
---@type string[]?
|
---@type string[]?
|
||||||
local cached_cmds
|
local cached_cmds
|
||||||
|
|
||||||
@@ -147,20 +223,57 @@ end
|
|||||||
---@return integer buf
|
---@return integer buf
|
||||||
local function place_split(name)
|
local function place_split(name)
|
||||||
local buf = vim.fn.bufnr("\\V" .. name)
|
local buf = vim.fn.bufnr("\\V" .. name)
|
||||||
if buf == -1 or not vim.api.nvim_buf_is_loaded(buf) then
|
if buf == -1 then
|
||||||
buf = util.new_scratch()
|
buf = util.new_scratch({ name = name, bufhidden = "hide" })
|
||||||
pcall(vim.api.nvim_buf_set_name, buf, name)
|
|
||||||
return buf
|
return buf
|
||||||
end
|
end
|
||||||
local win_id = vim.fn.bufwinid(buf)
|
if not vim.api.nvim_buf_is_loaded(buf) then
|
||||||
if win_id ~= -1 then
|
vim.fn.bufload(buf)
|
||||||
vim.api.nvim_set_current_win(win_id)
|
end
|
||||||
|
local win = vim.fn.bufwinid(buf)
|
||||||
|
if win ~= -1 then
|
||||||
|
vim.api.nvim_set_current_win(win)
|
||||||
else
|
else
|
||||||
util.place_buf(buf, nil)
|
util.place_buf(buf, nil)
|
||||||
end
|
end
|
||||||
return buf
|
return buf
|
||||||
end
|
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 r ow.Git.Repo
|
||||||
---@param args string[]
|
---@param args string[]
|
||||||
---@param conf ow.Git.Cmd.SplitHandler
|
---@param conf ow.Git.Cmd.SplitHandler
|
||||||
@@ -173,10 +286,10 @@ local function run_in_split(r, args, conf)
|
|||||||
if not stdout then
|
if not stdout then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local name = "[git " .. table.concat(args, " ") .. "]"
|
local buf = place_split("[Git " .. table.concat(args, " ") .. "]")
|
||||||
local buf = place_split(name)
|
|
||||||
repo.bind(buf, r)
|
repo.bind(buf, r)
|
||||||
object.attach_dispatch(buf)
|
object.attach_dispatch(buf)
|
||||||
|
attach_history_keys(buf)
|
||||||
local state = r:state(buf) --[[@as -nil]]
|
local state = r:state(buf) --[[@as -nil]]
|
||||||
state.sha = nil
|
state.sha = nil
|
||||||
state.parent_sha = nil
|
state.parent_sha = nil
|
||||||
@@ -188,8 +301,18 @@ local function run_in_split(r, args, conf)
|
|||||||
state.parent_sha = r:rev_parse(user_rev .. "^", true)
|
state.parent_sha = r:rev_parse(user_rev .. "^", true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if conf.on_state then
|
||||||
|
conf.on_state(state, r, args)
|
||||||
|
end
|
||||||
vim.bo[buf].filetype = conf.ft
|
vim.bo[buf].filetype = conf.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))
|
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
|
||||||
|
if first_run then
|
||||||
|
clear_undo(buf)
|
||||||
|
state.initialized = true
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|||||||
+47
-24
@@ -221,7 +221,7 @@ function M.read_uri(buf)
|
|||||||
local state = r:state(buf) --[[@as -nil]]
|
local state = r:state(buf) --[[@as -nil]]
|
||||||
|
|
||||||
vim.bo[buf].swapfile = false
|
vim.bo[buf].swapfile = false
|
||||||
vim.bo[buf].bufhidden = "hide"
|
vim.bo[buf].bufhidden = "delete"
|
||||||
|
|
||||||
local rev_sha = r:rev_parse(rev:format(), true)
|
local rev_sha = r:rev_parse(rev:format(), true)
|
||||||
if not rev_sha then
|
if not rev_sha then
|
||||||
@@ -284,42 +284,53 @@ local function refresh(buf, r)
|
|||||||
end
|
end
|
||||||
|
|
||||||
---@param r ow.Git.Repo
|
---@param r ow.Git.Repo
|
||||||
---@param blob string?
|
---@param ref string? -- nil = worktree, ":" = index, else commit/sha
|
||||||
---@param path string
|
---@param path string
|
||||||
---@param sha string
|
---@param blob string? -- diff section blob hash; if zero, side has no content
|
||||||
---@return integer?
|
---@return integer?
|
||||||
local function blob_buf(r, blob, path, sha)
|
local function side_buf(r, ref, path, blob)
|
||||||
if is_zero(blob) then
|
if blob and is_zero(blob) then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
return M.buf_for(r, Revision.new({ base = sha, path = path }))
|
if ref == nil then
|
||||||
|
local p = vim.fs.joinpath(r.worktree, path)
|
||||||
|
if not vim.uv.fs_stat(p) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local buf = vim.fn.bufadd(p)
|
||||||
|
vim.fn.bufload(buf)
|
||||||
|
return buf
|
||||||
|
end
|
||||||
|
local rev = ref == ":" and Revision.new({ stage = 0, path = path })
|
||||||
|
or Revision.new({ base = ref, path = path })
|
||||||
|
return M.buf_for(r, rev)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param r ow.Git.Repo
|
---@param r ow.Git.Repo
|
||||||
---@param blob string?
|
---@param ref string?
|
||||||
---@param path string
|
---@param path string
|
||||||
---@param sha string
|
---@param blob string?
|
||||||
local function load_blob(r, blob, path, sha)
|
local function load_side(r, ref, path, blob)
|
||||||
local buf = blob_buf(r, blob, path, sha)
|
local buf = side_buf(r, ref, path, blob)
|
||||||
if not buf then
|
if not buf then
|
||||||
util.error("no content for %s at %s", path, sha)
|
util.error("no content for %s", path)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
vim.cmd.normal({ "m'", bang = true })
|
vim.cmd.normal({ "m'", bang = true })
|
||||||
vim.api.nvim_set_current_buf(buf)
|
vim.api.nvim_set_current_buf(buf)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param s ow.Git.Repo.BufState
|
---@param r ow.Git.Repo
|
||||||
|
---@param left_ref string?
|
||||||
|
---@param right_ref string?
|
||||||
---@param section ow.Git.DiffSection
|
---@param section ow.Git.DiffSection
|
||||||
local function open_section(s, section)
|
local function open_section(r, left_ref, right_ref, section)
|
||||||
if not section.blob_a or not section.blob_b then
|
if not section.blob_a or not section.blob_b then
|
||||||
util.error("no index line, cannot determine blob SHAs")
|
util.error("no index line, cannot determine blob SHAs")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local parent = s.parent_sha or "0"
|
local left = side_buf(r, left_ref, section.path_a, section.blob_a)
|
||||||
local left = blob_buf(s.repo, section.blob_a, section.path_a, parent)
|
local right = side_buf(r, right_ref, section.path_b, section.blob_b)
|
||||||
local right =
|
|
||||||
blob_buf(s.repo, section.blob_b, section.path_b, s.sha --[[@as string]])
|
|
||||||
if left and right then
|
if left and right then
|
||||||
require("git.diff").open(left, right, true)
|
require("git.diff").open(left, right, true)
|
||||||
return
|
return
|
||||||
@@ -359,13 +370,14 @@ end
|
|||||||
---@return boolean dispatched
|
---@return boolean dispatched
|
||||||
function M.open_under_cursor()
|
function M.open_under_cursor()
|
||||||
local s = repo.state()
|
local s = repo.state()
|
||||||
if not s or not s.sha then
|
if not s then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local line = vim.api.nvim_get_current_line()
|
local line = vim.api.nvim_get_current_line()
|
||||||
local r = s.repo
|
local r = s.repo
|
||||||
|
|
||||||
|
if s.sha and not s.left_ref then
|
||||||
local sha = line:match("^commit (%x+)$")
|
local sha = line:match("^commit (%x+)$")
|
||||||
or line:match("^parent (%x+)$")
|
or line:match("^parent (%x+)$")
|
||||||
or line:match("^tree (%x+)$")
|
or line:match("^tree (%x+)$")
|
||||||
@@ -384,31 +396,42 @@ function M.open_under_cursor()
|
|||||||
M.open(r, nav_rev, { split = false })
|
M.open(r, nav_rev, { split = false })
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local left_ref, right_ref
|
||||||
|
if s.left_ref then
|
||||||
|
left_ref = s.left_ref
|
||||||
|
right_ref = s.right_ref
|
||||||
|
elseif s.sha then
|
||||||
|
left_ref = s.parent_sha or "0"
|
||||||
|
right_ref = s.sha
|
||||||
|
else
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
local section = diff_section()
|
local section = diff_section()
|
||||||
if not section then
|
if not section then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local parent = s.parent_sha or "0"
|
|
||||||
|
|
||||||
if line:match("^diff %-%-git ") then
|
if line:match("^diff %-%-git ") then
|
||||||
open_section(s, section)
|
open_section(r, left_ref, right_ref, section)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if line:match("^%-%-%- ") then
|
if line:match("^%-%-%- ") then
|
||||||
load_blob(r, section.blob_a, section.path_a, parent)
|
load_side(r, left_ref, section.path_a, section.blob_a)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if line:match("^%+%+%+ ") then
|
if line:match("^%+%+%+ ") then
|
||||||
load_blob(r, section.blob_b, section.path_b, s.sha --[[@as string]])
|
load_side(r, right_ref, section.path_b, section.blob_b)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
local prefix = line:sub(1, 1)
|
local prefix = line:sub(1, 1)
|
||||||
if prefix == "+" then
|
if prefix == "+" then
|
||||||
load_blob(r, section.blob_b, section.path_b, s.sha --[[@as string]])
|
load_side(r, right_ref, section.path_b, section.blob_b)
|
||||||
return true
|
return true
|
||||||
elseif prefix == "-" then
|
elseif prefix == "-" then
|
||||||
load_blob(r, section.blob_a, section.path_a, parent)
|
load_side(r, left_ref, section.path_a, section.blob_a)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ end
|
|||||||
---@field repo ow.Git.Repo
|
---@field repo ow.Git.Repo
|
||||||
---@field sha string?
|
---@field sha string?
|
||||||
---@field parent_sha string?
|
---@field parent_sha string?
|
||||||
|
---@field left_ref string?
|
||||||
|
---@field right_ref string?
|
||||||
|
---@field initialized boolean?
|
||||||
---@field immutable boolean?
|
---@field immutable boolean?
|
||||||
---@field index_writer boolean?
|
---@field index_writer boolean?
|
||||||
---@field index_mode string?
|
---@field index_mode string?
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
if exists("b:current_syntax")
|
||||||
|
finish
|
||||||
|
endif
|
||||||
|
|
||||||
|
runtime! syntax/git.vim
|
||||||
|
|
||||||
|
let b:current_syntax = "gitdiff"
|
||||||
@@ -234,3 +234,90 @@ t.test("complete unknown subcommand falls back to tracked paths", function()
|
|||||||
local matches = cmd.complete("", "G nonexistent ", 14)
|
local matches = cmd.complete("", "G nonexistent ", 14)
|
||||||
eq_sorted(matches, { "a", "b" })
|
eq_sorted(matches, { "a", "b" })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
t.test("compute_diff_refs default is index vs worktree", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.repo").resolve(dir))
|
||||||
|
local left, right = cmd._compute_diff_refs(r, { "diff" })
|
||||||
|
t.eq(left, ":")
|
||||||
|
t.eq(right, nil)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("compute_diff_refs --cached is HEAD vs index", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.repo").resolve(dir))
|
||||||
|
local left, right = cmd._compute_diff_refs(r, { "diff", "--cached" })
|
||||||
|
t.eq(left, "HEAD")
|
||||||
|
t.eq(right, ":")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("compute_diff_refs --staged is HEAD vs index", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.repo").resolve(dir))
|
||||||
|
local left, right = cmd._compute_diff_refs(r, { "diff", "--staged" })
|
||||||
|
t.eq(left, "HEAD")
|
||||||
|
t.eq(right, ":")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("compute_diff_refs single rev is rev vs worktree", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.repo").resolve(dir))
|
||||||
|
local left, right = cmd._compute_diff_refs(r, { "diff", "HEAD" })
|
||||||
|
t.eq(left, "HEAD")
|
||||||
|
t.eq(right, nil)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("compute_diff_refs single rev with --cached is rev vs index", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.repo").resolve(dir))
|
||||||
|
local left, right =
|
||||||
|
cmd._compute_diff_refs(r, { "diff", "--cached", "HEAD" })
|
||||||
|
t.eq(left, "HEAD")
|
||||||
|
t.eq(right, ":")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("compute_diff_refs two revs", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
git(dir, "commit", "--allow-empty", "-m", "second")
|
||||||
|
local r = assert(require("git.repo").resolve(dir))
|
||||||
|
local left, right =
|
||||||
|
cmd._compute_diff_refs(r, { "diff", "HEAD~1", "HEAD" })
|
||||||
|
t.eq(left, "HEAD~1")
|
||||||
|
t.eq(right, "HEAD")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("compute_diff_refs double-dot range", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
git(dir, "commit", "--allow-empty", "-m", "second")
|
||||||
|
local r = assert(require("git.repo").resolve(dir))
|
||||||
|
local left, right = cmd._compute_diff_refs(r, { "diff", "HEAD~1..HEAD" })
|
||||||
|
t.eq(left, "HEAD~1")
|
||||||
|
t.eq(right, "HEAD")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("compute_diff_refs triple-dot range", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
git(dir, "commit", "--allow-empty", "-m", "second")
|
||||||
|
local r = assert(require("git.repo").resolve(dir))
|
||||||
|
local left, right =
|
||||||
|
cmd._compute_diff_refs(r, { "diff", "HEAD~1...HEAD" })
|
||||||
|
t.eq(left, "HEAD~1")
|
||||||
|
t.eq(right, "HEAD")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("compute_diff_refs path-only falls back to defaults", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.repo").resolve(dir))
|
||||||
|
local left, right = cmd._compute_diff_refs(r, { "diff", "a" })
|
||||||
|
t.eq(left, ":")
|
||||||
|
t.eq(right, nil)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("compute_diff_refs ignores args after --", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.repo").resolve(dir))
|
||||||
|
local left, right =
|
||||||
|
cmd._compute_diff_refs(r, { "diff", "--", "HEAD" })
|
||||||
|
t.eq(left, ":")
|
||||||
|
t.eq(right, nil)
|
||||||
|
end)
|
||||||
|
|||||||
Reference in New Issue
Block a user