local M = {} ---@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), 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.NewScratchOpts ---@field name string? ---@field bufhidden ("hide"|"wipe")? defaults to "hide" ---@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 non-modifiable 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) vim.bo[buf].buftype = "nofile" vim.bo[buf].bufhidden = opts.bufhidden or "hide" 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 if opts.split == false then vim.cmd.normal({ "m'", bang = true }) vim.api.nvim_set_current_buf(buf) return buf, vim.api.nvim_get_current_win() end local split = opts.split or (vim.o.splitbelow and "below" or "above") local win = vim.api.nvim_open_win(buf, true, { split = split }) return buf, win 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