Files
nvim/lua/git/diff.lua
T

233 lines
7.7 KiB
Lua

local repo = require("git.repo")
local util = require("git.util")
local M = {}
---Name a buffer and re-run filetype detection from the (re-)set name.
---Wrapped in `pcall` because a buffer with that name may already exist
---(E95).
---@param buf integer
---@param name string
local function set_buf_name_and_filetype(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
---Toggle a window into or out of Vim's diff mode. Goes through the
---`:diffthis` / `:diffoff` commands rather than `vim.wo[win].diff = X`.
---The command path runs Vim's full `diff_win_options` setup, which sets
---an internal flag that prevents subsequently-created floats from
---inheriting `'diff' = 1` when opened from a focused diff pane. The raw
---option setter skips that setup, so floats end up joining the
---tabpage's diff group and corrupting its render.
---@param win integer
---@param enabled boolean
local function set_diff(win, enabled)
vim.api.nvim_win_call(win, function()
vim.cmd(enabled and "diffthis" or "diffoff")
end)
end
---Render two buffers as a diff pair. The right buffer takes the current
---window. The left opens in a leftabove split (vertical or horizontal
---per `vertical`). Drops a `'` jumplist mark first so `''` jumps back.
---@param left integer
---@param right integer
---@param vertical boolean
function M.open(left, right, vertical)
-- Read the name first: an empty placeholder has `bufhidden=wipe`,
-- so `nvim_set_current_buf(right)` below wipes `left` before a
-- name lookup could see it. `:diffsplit` re-creates from the name.
local left_name = vim.api.nvim_buf_get_name(left)
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(right)
local prefix = vertical and "leftabove vert " or "leftabove "
vim.cmd(prefix .. "diffsplit " .. vim.fn.fnameescape(left_name))
end
---Repoint two existing diff windows at a new pair of buffers.
---Toggles diff mode off around the swap so Vim tears down the old diff
---group and re-establishes a fresh one. `nvim_win_set_buf` swaps the
---buffer pointer without invalidating cached diff state, and
---`:diffupdate` alone doesn't reliably force a recompute when no buffer
---contents have actually changed. Sides with `name` set are renamed
---and re-run filetype detection.
---@param left_win integer
---@param right_win integer
---@param pair ow.Git.DiffPair
function M.update_pair(left_win, right_win, pair)
set_diff(left_win, false)
set_diff(right_win, false)
vim.api.nvim_win_set_buf(left_win, pair.left.buf)
vim.api.nvim_win_set_buf(right_win, pair.right.buf)
for _, side in ipairs({ pair.left, pair.right }) do
if side.name then
set_buf_name_and_filetype(side.buf, side.name)
end
end
set_diff(left_win, true)
set_diff(right_win, true)
end
---Open two buffers as a diff. `a_left` puts `buf_a` in the leftabove
---slot (where `M.open` parks the cursor).
---@param buf_a integer
---@param buf_b integer
---@param a_left boolean
---@param vertical boolean
local function place_pair(buf_a, buf_b, a_left, vertical)
if a_left then
M.open(buf_a, buf_b, vertical)
else
M.open(buf_b, buf_a, vertical)
end
end
---Dispatch for `M.split` when the current buffer is a `git://<revspec>`
---URI. Placement is writable-on-the-left via `place_pair`.
---
---gd/gh: pair cur with the worktree file at the URI's path.
---
---gD/gH: pair cur with the next layer toward HEAD.
--- * stage 0 -> `HEAD:<p>`
--- * stage 2 (ours) <-> stage 3 (theirs)
--- * stage 1 (base) -> bail (ambiguous, suggest `:Gdiffsplit <ref>`)
--- * any other ref -> `:0:<p>`
---
---A `<ref>` containing `:` (from `:Gdiffsplit`) short-circuits the
---above and pairs cur with that revspec literally.
---@param opts ow.Git.SplitOpts
---@param cur_buf integer
---@param cur_revspec string
local function uri_split(opts, cur_buf, cur_revspec)
local worktree = vim.b[cur_buf].git_worktree
or select(2, repo.resolve_cwd())
if not worktree then
util.warning("git URI buffer has no worktree")
return
end
local cur = util.parse_revspec(cur_revspec)
if not cur.path then
util.warning("git URI has no path, cannot diff against worktree")
return
end
-- Lazy-required to break the load-time cycle with `git.object`.
local object = require("git.object")
local cur_writable = cur.stage == 0
if opts.revspec ~= "" and opts.revspec:find(":", 1, true) then
local content = util.exec(
{ "git", "cat-file", "-p", opts.revspec },
{ cwd = worktree, silent = true }
)
if not content then
util.warning("invalid revspec: %s", opts.revspec)
return
end
place_pair(
cur_buf,
object.buf_for(worktree, opts.revspec, content),
cur_writable,
opts.vertical
)
return
end
if opts.revspec == "" then
local worktree_buf = vim.fn.bufadd(vim.fs.joinpath(worktree, cur.path))
vim.fn.bufload(worktree_buf)
place_pair(cur_buf, worktree_buf, cur_writable, opts.vertical)
return
end
if cur.stage == 1 then
util.warning("gD on merge base is ambiguous, use :Gdiffsplit <ref>")
return
end
local other_revspec
if cur.stage == 2 then
other_revspec = ":3:" .. cur.path
elseif cur.stage == 3 then
other_revspec = ":2:" .. cur.path
elseif cur.stage == 0 then
other_revspec = "HEAD:" .. cur.path
else
other_revspec = ":0:" .. cur.path
end
local content = util.exec(
{ "git", "cat-file", "-p", other_revspec },
{ cwd = worktree, silent = true }
)
if not content then
util.warning("invalid revspec: %s", other_revspec)
return
end
place_pair(
cur_buf,
object.buf_for(worktree, other_revspec, content),
cur.stage ~= nil,
opts.vertical
)
end
---@class ow.Git.SplitOpts
---@field revspec string `''` for smart-default routing (index vs worktree). A plain ref like `'HEAD'` compares `<ref>:<rel>` against the current path. A full revspec containing `:` (e.g. `':2:foo'`, `'HEAD~1:other.lua'`) is used as-is.
---@field vertical boolean
---@param opts ow.Git.SplitOpts
function M.split(opts)
local cur_buf = vim.api.nvim_get_current_buf()
local cur_path = vim.api.nvim_buf_get_name(cur_buf)
local cur_revspec = util.parse_uri(cur_path)
if cur_revspec then
return uri_split(opts, cur_buf, cur_revspec)
end
if cur_path == "" then
util.warning("no file in current buffer")
return
end
if vim.bo[cur_buf].buftype ~= "" then
util.warning("cannot diff this buffer (not a worktree file)")
return
end
local _, worktree = repo.resolve(cur_path)
if not worktree then
util.warning("not in a git repository")
return
end
local rel = vim.fs.relpath(worktree, cur_path)
if not rel then
util.warning("file is outside the worktree")
return
end
local revspec
if opts.revspec == "" then
revspec = ":0:" .. rel
elseif opts.revspec:find(":", 1, true) then
revspec = opts.revspec
else
revspec = opts.revspec .. ":" .. rel
end
local content = util.exec(
{ "git", "cat-file", "-p", revspec },
{ cwd = worktree, silent = true }
)
if not content then
util.warning("invalid revspec: %s", revspec)
return
end
local object = require("git.object")
local buf = object.buf_for(worktree, revspec, content)
local other_writable = util.parse_revspec(revspec).stage == 0
place_pair(buf, cur_buf, other_writable, opts.vertical)
end
return M