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://` 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 `:` / `:0:` / `:N:`; nil otherwise ---@field path string? path component when the revspec carries one; nil for bare object refs ---Classify a `git://` revspec into its stage / path components. ---Recognised forms: --- * `:` and `:0:` -> stage 0 (the resolved index entry) --- * `:1:` / `:2:` / `:3:` -> merge stages base / ours / theirs --- * `:` -> 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