274 lines
9.4 KiB
Lua
274 lines
9.4 KiB
Lua
local repo = require("git.repo")
|
|
local util = require("git.util")
|
|
|
|
local M = {}
|
|
|
|
---@class ow.Git.EmptyBufOpts
|
|
---@field name string?
|
|
---@field bufhidden ("hide"|"wipe")? defaults to "wipe"
|
|
|
|
---Build a read-only scratch buffer, optionally naming it. When `opts.name`
|
|
---is set and a loaded buffer with that name already exists, returns it
|
|
---instead of creating a duplicate.
|
|
---@param opts ow.Git.EmptyBufOpts?
|
|
---@return integer
|
|
function M.empty_buf(opts)
|
|
opts = opts or {}
|
|
if opts.name then
|
|
local existing = vim.fn.bufnr(opts.name)
|
|
if existing ~= -1 and vim.api.nvim_buf_is_loaded(existing) then
|
|
return existing
|
|
end
|
|
end
|
|
local buf = vim.api.nvim_create_buf(false, true)
|
|
vim.bo[buf].buftype = "nofile"
|
|
vim.bo[buf].bufhidden = opts.bufhidden or "wipe"
|
|
vim.bo[buf].swapfile = false
|
|
vim.bo[buf].modifiable = false
|
|
vim.bo[buf].modified = false
|
|
if opts.name then
|
|
pcall(vim.api.nvim_buf_set_name, buf, opts.name)
|
|
end
|
|
return buf
|
|
end
|
|
|
|
---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 (oil, fzf-lua, etc) 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`). Both windows enter Vim's diff mode via `:diffsplit`'s
|
|
---built-in setup. Drops a `'` jumplist mark before reassigning the
|
|
---current window.
|
|
---@param left integer
|
|
---@param right integer
|
|
---@param vertical boolean
|
|
function M.open(left, right, vertical)
|
|
-- Read the name first: if `left` is the current window's buffer
|
|
-- and has `bufhidden=wipe` (a freshly-loaded `git://` URI), the
|
|
-- `nvim_set_current_buf(right)` below wipes it, and a later name
|
|
-- lookup would fail. `:diffsplit` re-bufadds + reloads from the
|
|
-- name, so the wipe-then-recreate sequence is fine.
|
|
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 get renamed +
|
|
---filetype-refreshed (used to relabel a fresh `empty_buf` placeholder
|
|
---as `[absent] <abs>`, or to re-run ft detection on a `git://` buffer).
|
|
---@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` decides which one goes in the
|
|
---leftabove slot (where `M.open` parks the cursor). Caller picks per
|
|
---its own rule (writable-on-left for routine flows, anchor-on-left
|
|
---when neither side is writable, etc).
|
|
---@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 all the
|
|
---above and pairs cur with that revspec literally. This is the escape
|
|
---hatch the merge-base warning points at.
|
|
---@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
|
|
-- `git.object` is lazy-required to break the load-time cycle.
|
|
-- It requires `git.diff` itself for `empty_buf`.
|
|
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 the smart-default routing (index vs worktree); a plain ref like `'HEAD'` to compare `<ref>:<rel>` against the current path; or a full revspec containing `:` (e.g. `':2:foo'`, `'HEAD~1:other.lua'`) 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 = cur_path:match("^git://(.+)$")
|
|
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
|
|
|
|
-- A `<revspec>` containing `:` is treated as a full revspec;
|
|
-- otherwise the worktree-relative path is appended (the common
|
|
-- keymap form).
|
|
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
|