271 lines
8.3 KiB
Lua
271 lines
8.3 KiB
Lua
local M = {}
|
|
|
|
local URI_PREFIX = "git://"
|
|
|
|
---@param revspec string
|
|
---@return string
|
|
function M.uri(revspec)
|
|
return URI_PREFIX .. revspec
|
|
end
|
|
|
|
---Extract the revspec from a `git://<revspec>` buffer name. Returns
|
|
---nil if the name doesn't carry the scheme.
|
|
---@param name string
|
|
---@return string?
|
|
function M.parse_uri(name)
|
|
return name:match("^" .. URI_PREFIX .. "(.+)$")
|
|
end
|
|
|
|
---@class ow.Git.ParsedRevspec
|
|
---@field stage 0|1|2|3? index stage when the revspec is `:<path>` / `:0:<path>` / `:N:<path>`; nil otherwise
|
|
---@field path string? path component when the revspec carries one; nil for bare object refs
|
|
|
|
---Classify a `git://<revspec>` revspec into its stage / path components.
|
|
---Recognised forms:
|
|
--- * `:<path>` and `:0:<path>` -> stage 0 (the resolved index entry)
|
|
--- * `:1:<path>` / `:2:<path>` / `:3:<path>` -> merge stages base / ours / theirs
|
|
--- * `<commit-ref>:<path>` -> stage = nil, path set
|
|
--- * bare object ref (no `:`) -> stage = nil, path = nil
|
|
---@param revspec string
|
|
---@return ow.Git.ParsedRevspec
|
|
function M.parse_revspec(revspec)
|
|
local stage, path = revspec:match("^:([0123]):(.+)$")
|
|
if stage then
|
|
return {
|
|
stage = tonumber(stage) --[[@as (0|1|2|3)?]],
|
|
path = path,
|
|
}
|
|
end
|
|
path = revspec:match("^:([^:]+)$")
|
|
if path then
|
|
return { stage = 0, path = path }
|
|
end
|
|
path = (revspec:match("^[^:]+:(.+)$"))
|
|
return { stage = nil, path = path }
|
|
end
|
|
|
|
---@class ow.Git.ScratchOpts
|
|
---@field name string?
|
|
---@field bufhidden ("hide"|"wipe")? defaults to "wipe"
|
|
|
|
---Configure a fresh buffer as a read-only scratch and optionally name
|
|
---it. Shared by `empty_buf` and `new_scratch`; the public functions
|
|
---differ in whether they dedup-by-name or place the buffer in a window.
|
|
---@param buf integer
|
|
---@param opts ow.Git.ScratchOpts
|
|
local function setup_scratch(buf, opts)
|
|
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
|
|
end
|
|
|
|
---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.ScratchOpts?
|
|
---@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)
|
|
setup_scratch(buf, opts)
|
|
return buf
|
|
end
|
|
|
|
---Place a buffer in the current window or a new split per `split`.
|
|
---`false` replaces the current buffer (drops a `'` mark first so `''`
|
|
---jumps back); a direction string opens a leftabove split; nil falls
|
|
---back to a `splitbelow`-aware horizontal split.
|
|
---@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
|
|
return vim.api.nvim_open_win(buf, true, {
|
|
split = split or (vim.o.splitbelow and "below" or "above"),
|
|
})
|
|
end
|
|
|
|
---@class ow.Git.NewScratchOpts : ow.Git.ScratchOpts
|
|
---@field split (false|"above"|"below"|"left"|"right")? defaults to splitbelow-aware horizontal. `false` places the buffer in the current window (drops a `'` mark first so the user can jump back).
|
|
|
|
---Create a fresh read-only scratch buffer and place it. Default split
|
|
---direction is horizontal, honouring `splitbelow`. Caller flips
|
|
---`modifiable`, fills the buffer, and sets `filetype` once content lands.
|
|
---@param opts ow.Git.NewScratchOpts?
|
|
---@return integer buf
|
|
---@return integer win
|
|
function M.new_scratch(opts)
|
|
opts = opts or {}
|
|
local buf = vim.api.nvim_create_buf(false, true)
|
|
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
|
|
|
|
---Split a string on newlines, dropping the trailing empty element that an
|
|
---input ending in `\n` produces. Convenient for slicing subprocess stdout
|
|
---into a list of lines without a phantom blank at the 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 = assert(vim.uv.new_timer())
|
|
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.ExecOpts
|
|
---@field cwd string?
|
|
---@field stdin string?
|
|
---@field silent boolean? suppress the auto-log on non-zero exit
|
|
---@field on_done fun(stdout: string?)? if set, run async and deliver stdout (or nil on failure) here on the main loop instead of returning sync
|
|
|
|
---Run a system command. Default is sync: returns stdout on success or
|
|
---nil on failure (logging stderr unless `opts.silent`). When
|
|
---`opts.on_done` is set, runs async via `vim.schedule_wrap` and
|
|
---delivers the same stdout-or-nil value to that callback instead.
|
|
---
|
|
---Async mode returns nil immediately. Callers that need access to the
|
|
---raw stderr / exit code in the failure path should opt out of this
|
|
---helper and use `vim.system` directly.
|
|
---@param cmd string[]
|
|
---@param opts ow.Git.ExecOpts?
|
|
---@return string?
|
|
function M.exec(cmd, opts)
|
|
opts = opts or {}
|
|
local sys_opts = { cwd = opts.cwd, stdin = opts.stdin, text = true }
|
|
|
|
local function handle(result)
|
|
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
|
|
|
|
if opts.on_done then
|
|
vim.system(
|
|
cmd,
|
|
sys_opts,
|
|
vim.schedule_wrap(function(result)
|
|
opts.on_done(handle(result))
|
|
end)
|
|
)
|
|
return nil
|
|
end
|
|
return handle(vim.system(cmd, sys_opts):wait())
|
|
end
|
|
|
|
return M
|