From d95de4bc1da475e7f55f921197052c1a954916ca Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Thu, 30 Apr 2026 09:44:24 +0200 Subject: [PATCH] refactor(git): introduce Revision class, normalize naming, slim docs --- lua/git/cmd.lua | 40 +++------ lua/git/diff.lua | 120 +++++++++---------------- lua/git/editor.lua | 22 ----- lua/git/init.lua | 38 ++++---- lua/git/log.lua | 17 ++-- lua/git/object.lua | 203 ++++++++++++++++--------------------------- lua/git/repo.lua | 32 ++----- lua/git/revision.lua | 61 +++++++++++++ lua/git/sidebar.lua | 60 +++---------- lua/git/util.lua | 79 ++--------------- 10 files changed, 244 insertions(+), 428 deletions(-) create mode 100644 lua/git/revision.lua diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua index 2f47345..700553f 100644 --- a/lua/git/cmd.lua +++ b/lua/git/cmd.lua @@ -7,9 +7,8 @@ local M = {} ---@class ow.Git.SplitHandler ---@field ft string ----@field needs_ref boolean? +---@field needs_rev boolean? ----Subcommands whose output goes to a buffer. ---@type table local SPLIT_HANDLERS = { log = { ft = "git" }, @@ -34,7 +33,6 @@ local function populate_cached_cmds(result) table.sort(cached_cmds) end ----Prime `cached_cmds` asynchronously so the first `:G ` doesn't block. local function prefetch_cmds() vim.system( { "git", "--list-cmds=main,others,alias" }, @@ -85,10 +83,6 @@ local function place_split(name) return buf end ----Run `git ` async. On success, drop the output into a named ----scratch split (creating or reusing as needed). On failure, `util.exec` ----notifies and the split is never opened, so a bad ref doesn't leave a ----stray buffer behind. ---@param worktree string ---@param args string[] ---@param conf ow.Git.SplitHandler @@ -104,15 +98,15 @@ local function run_in_split(worktree, args, conf) local name = "[git " .. table.concat(args, " ") .. "]" local buf = place_split(name) vim.b[buf].git_worktree = worktree - vim.b[buf].git_ref = nil - vim.b[buf].git_parent_ref = nil - if conf.needs_ref then - local user_ref = first_positional(args, 2) or "HEAD" - local sha = repo.rev_parse(worktree, user_ref, true) + vim.b[buf].git_sha = nil + vim.b[buf].git_parent_sha = nil + if conf.needs_rev then + local user_rev = first_positional(args, 2) or "HEAD" + local sha = repo.rev_parse(worktree, user_rev, true) if sha then - vim.b[buf].git_ref = sha - vim.b[buf].git_parent_ref = - repo.rev_parse(worktree, user_ref .. "^", true) + vim.b[buf].git_sha = sha + vim.b[buf].git_parent_sha = + repo.rev_parse(worktree, user_rev .. "^", true) end end vim.bo[buf].filetype = conf.ft @@ -206,31 +200,25 @@ function M.run(args) return end - -- `:G show :` opens the blob via the BufReadCmd loader - -- so the URI carries the path and filetype detection has something - -- to match against. Other show invocations dump output to a buffer. if sub == "show" then local arg = first_positional(args, 2) if arg and arg:find(":", 1, true) then object.open_object(worktree, arg) return end - run_in_split(worktree, args, { ft = "git", needs_ref = true }) + run_in_split(worktree, args, { ft = "git", needs_rev = true }) return end - -- `:G cat-file -p ` routes to the gitobject viewer so commits - -- get the full message + diff view and other types render via - -- cat-file. Other modes (-t, -s, -e) dump to a buffer. if sub == "cat-file" then if vim.list_contains(args, "-p") then - local ref = first_positional(args, 2) - if ref then - object.open_object(worktree, ref) + local rev = first_positional(args, 2) + if rev then + object.open_object(worktree, rev) return end end - run_in_split(worktree, args, { ft = "git", needs_ref = true }) + run_in_split(worktree, args, { ft = "git", needs_rev = true }) return end diff --git a/lua/git/diff.lua b/lua/git/diff.lua index ec41b88..bec9b4f 100644 --- a/lua/git/diff.lua +++ b/lua/git/diff.lua @@ -1,15 +1,9 @@ +local Revision = require("git.revision") local repo = require("git.repo") local util = require("git.util") local M = {} ----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 function M.set_diff(win, enabled) @@ -18,16 +12,10 @@ function M.set_diff(win, enabled) 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) @@ -38,13 +26,6 @@ function M.open(left, right, vertical) }) 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 @@ -65,8 +46,6 @@ function M.update_pair(left_win, right_win, pair) vim.cmd.syncbind() 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 @@ -79,97 +58,82 @@ local function place_pair(buf_a, buf_b, a_left, vertical) end end ----Dispatch for `M.split` when the current buffer is a `git://` ----URI. Placement follows the older-on-left convention. ---- ----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:

` ---- * stage 2 (ours) <-> stage 3 (theirs) ---- * stage 1 (base) -> bail (ambiguous, suggest `:Gdiffsplit `) ---- * any other ref -> `:0:

` ---- ----A `` 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()) +---@param buf integer +---@param rev ow.Git.Revision +local function uri_split(opts, buf, rev) + local worktree = vim.b[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 + if not rev.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") - if opts.revspec ~= "" and opts.revspec:find(":", 1, true) then + if opts.rev and opts.rev:find(":", 1, true) then local content = util.exec( - { "git", "cat-file", "-p", opts.revspec }, + { "git", "cat-file", "-p", opts.rev }, { cwd = worktree, silent = true } ) if not content then - util.warning("invalid revspec: %s", opts.revspec) + util.warning("invalid rev: %s", opts.rev) return end place_pair( - cur_buf, - object.buf_for(worktree, opts.revspec, content), + buf, + object.buf_for(worktree, Revision.parse(opts.rev), content), false, opts.vertical ) return end - if opts.revspec == "" then - local worktree_path = vim.fs.joinpath(worktree, cur.path) + if not opts.rev then + local worktree_path = vim.fs.joinpath(worktree, rev.path) if not vim.uv.fs_stat(worktree_path) then - util.warning("worktree file does not exist: %s", cur.path) + util.warning("worktree file does not exist: %s", rev.path) return end local worktree_buf = vim.fn.bufadd(worktree_path) vim.fn.bufload(worktree_buf) - place_pair(cur_buf, worktree_buf, true, opts.vertical) + place_pair(buf, worktree_buf, true, opts.vertical) return end - if cur.stage == 1 then - util.warning("gD on merge base is ambiguous, use :Gdiffsplit ") + if rev.stage == 1 then + util.warning("gD on merge base is ambiguous, use :Gdiffsplit ") return end local mapping = { - [2] = { ":3:" .. cur.path, true }, - [3] = { ":2:" .. cur.path, false }, - [0] = { "HEAD:" .. cur.path, false }, + [2] = { Revision.new({ stage = 3, path = rev.path }), true }, + [3] = { Revision.new({ stage = 2, path = rev.path }), false }, + [0] = { Revision.new({ base = "HEAD", path = rev.path }), false }, } - local m = mapping[cur.stage] or { ":0:" .. cur.path, true } - local other_revspec, cur_left = m[1], m[2] + local m = mapping[rev.stage] + or { Revision.new({ stage = 0, path = rev.path }), true } + local other_rev, left = m[1], m[2] local content = util.exec( - { "git", "cat-file", "-p", other_revspec }, + { "git", "cat-file", "-p", other_rev:format() }, { cwd = worktree, silent = true } ) if not content then - util.warning("invalid revspec: %s", other_revspec) + util.warning("invalid rev: %s", other_rev:format()) return end place_pair( - cur_buf, - object.buf_for(worktree, other_revspec, content), - cur_left, + buf, + object.buf_for(worktree, other_rev, content), + left, opts.vertical ) end ---@class ow.Git.SplitOpts ----@field revspec string `''` for smart-default routing (index vs worktree). A plain ref like `'HEAD'` compares `:` against the current path. A full revspec containing `:` (e.g. `':2:foo'`, `'HEAD~1:other.lua'`) is used as-is. +---@field rev string? ---@field vertical boolean ---@param opts ow.Git.SplitOpts @@ -177,9 +141,9 @@ 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) + local cur_rev = Revision.from_uri(cur_path) + if cur_rev then + return uri_split(opts, cur_buf, cur_rev) end if cur_path == "" then @@ -201,25 +165,23 @@ function M.split(opts) return end - local revspec - if opts.revspec == "" then - revspec = ":0:" .. rel - elseif opts.revspec:find(":", 1, true) then - revspec = opts.revspec + local rev + if not opts.rev then + rev = Revision.new({ stage = 0, path = rel }) + elseif opts.rev:find(":", 1, true) then + rev = Revision.parse(opts.rev) else - revspec = opts.revspec .. ":" .. rel + rev = Revision.new({ base = opts.rev, path = rel }) end local content = util.exec( - { "git", "cat-file", "-p", revspec }, + { "git", "cat-file", "-p", rev:format() }, { cwd = worktree, silent = true } ) if not content then - util.warning("invalid revspec: %s", revspec) + util.warning("invalid rev: %s", rev:format()) return end - local object = require("git.object") - local buf = object.buf_for(worktree, revspec, content) - -- Revspec snapshot is older than the worktree, so it goes left. + local buf = require("git.object").buf_for(worktree, rev, content) place_pair(buf, cur_buf, true, opts.vertical) end diff --git a/lua/git/editor.lua b/lua/git/editor.lua index 1ce857e..0cff3a7 100644 --- a/lua/git/editor.lua +++ b/lua/git/editor.lua @@ -4,11 +4,6 @@ local M = {} local SENTINEL = "__NVIM_GIT_EDIT__" --- Per-invocation: each `sh -c` body picks a flag file via `$$`, --- prints sentinel + flag-path + abs-path on stderr, then polls the --- flag until Neovim writes it. `rebase -i` fires the editor many --- times (todo + each reword). Each call is a fresh shell with a --- fresh `$$`, so flags don't collide. local SCRIPT = string.format( [=[set -eu flag="${TMPDIR:-/tmp}/nvim-git-editor-$$.done" @@ -22,7 +17,6 @@ done SENTINEL ) ----POSIX shell single-quote escape: foo'bar -> 'foo'\''bar'. ---@param s string ---@return string local function shq(s) @@ -31,11 +25,6 @@ end local GIT_EDITOR = "sh -c " .. shq(SCRIPT) .. " --" ----Build a stderr callback that strips our sentinel lines, accumulates ----the rest, and dispatches `on_open(abs, done)` for each sentinel seen. ----The `finalize(result)` helper drains any trailing partial line into ----`result.stderr` so the caller can treat `result` like a plain ----`vim.system` result. ---@param on_open fun(file_path: string, done: fun()) ---@return fun(err: string?, data: string?), fun(result: vim.SystemCompleted) local function build_stderr_handler(on_open) @@ -97,17 +86,6 @@ local function build_stderr_handler(on_open) return on_stderr, finalize end ----Run a git command with an editor proxy active. When git invokes the ----editor, `on_open` fires with the absolute file path git wants edited ----plus a `done` callback. The caller opens the file in a buffer and ----invokes `done()` once the user is finished (typically from a ----`BufWipeout` autocmd). For commands that fire the editor more than ----once in a single git invocation (`git rebase -i`, with one call for ----the todo and one per `reword`), `on_open` is invoked once per ----editor handoff. ---- ----`on_exit` is called on the main loop with a `vim.SystemCompleted`- ----shaped result. `result.stderr` has our protocol sentinels stripped. ---@param cmd string[] ---@param opts? { cwd?: string, env?: table } ---@param on_open fun(file_path: string, done: fun()) diff --git a/lua/git/init.lua b/lua/git/init.lua index 8c93b56..0d7b835 100644 --- a/lua/git/init.lua +++ b/lua/git/init.lua @@ -1,11 +1,3 @@ -local cmd = require("git.cmd") -local commit = require("git.commit") -local diff = require("git.diff") -local log = require("git.log") -local object = require("git.object") -local repo = require("git.repo") -local sidebar = require("git.sidebar") - local HIGHLIGHTS = { GitDeleted = "Removed", GitIgnored = "Comment", @@ -28,10 +20,16 @@ end ---@param path string ---@return string? function M.head(path) - return repo.head(path) + return require("git.repo").head(path) end function M.setup() + local cmd = require("git.cmd") + local commit = require("git.commit") + local diff = require("git.diff") + local log = require("git.log") + local repo = require("git.repo") + for name, link in pairs(HIGHLIGHTS) do vim.api.nvim_set_hl(0, name, { link = link, default = true }) end @@ -40,7 +38,7 @@ function M.setup() pattern = "git://*", group = group, callback = function(args) - object.read_uri(args.buf) + require("git.object").read_uri(args.buf) end, }) vim.api.nvim_create_autocmd("BufReadCmd", { @@ -81,21 +79,23 @@ function M.setup() vim.keymap.set( "n", "gg", - sidebar.toggle, + require("git.sidebar").toggle, { desc = "Toggle git status sidebar" } ) - vim.keymap.set("n", "gl", log.open, { desc = "Show git log" }) + vim.keymap.set("n", "gl", function() + log.open({ max_count = 1000 }) + end, { desc = "Show git log" }) vim.keymap.set("n", "gd", function() - diff.split({ revspec = "", vertical = true }) + diff.split({ vertical = true }) end, { desc = "Diff index vs worktree (vsplit)" }) vim.keymap.set("n", "gD", function() - diff.split({ revspec = "HEAD", vertical = true }) + diff.split({ rev = "HEAD", vertical = true }) end, { desc = "Diff HEAD vs worktree (vsplit)" }) vim.keymap.set("n", "gh", function() - diff.split({ revspec = "", vertical = false }) + diff.split({ vertical = false }) end, { desc = "Diff index vs worktree (split)" }) vim.keymap.set("n", "gH", function() - diff.split({ revspec = "HEAD", vertical = false }) + diff.split({ rev = "HEAD", vertical = false }) end, { desc = "Diff HEAD vs worktree (split)" }) vim.keymap.set("n", "gc", function() commit.commit() @@ -110,7 +110,7 @@ function M.setup() local function diff_split_cmd(vertical) return function(opts) diff.split({ - revspec = opts.args, + rev = opts.args ~= "" and opts.args or nil, vertical = vertical, }) end @@ -118,12 +118,12 @@ function M.setup() vim.api.nvim_create_user_command( "Gdiffsplit", diff_split_cmd(true), - { nargs = "?", desc = "Diff against (vsplit)" } + { nargs = "?", desc = "Diff against (vsplit)" } ) vim.api.nvim_create_user_command( "Ghdiffsplit", diff_split_cmd(false), - { nargs = "?", desc = "Diff against (split)" } + { nargs = "?", desc = "Diff against (split)" } ) cmd.setup() diff --git a/lua/git/log.lua b/lua/git/log.lua index 91f1c31..97f1bf1 100644 --- a/lua/git/log.lua +++ b/lua/git/log.lua @@ -4,11 +4,10 @@ local util = require("git.util") local M = {} local LOG_FORMAT = "%h %ad {%an}%d %s" -local DEFAULT_MAX_COUNT = 1000 local URI_PREFIX = "gitlog://" ---@param worktree string ----@param max_count integer +---@param max_count integer? ---@return string? local function fetch(worktree, max_count) local cmd = { @@ -20,7 +19,7 @@ local function fetch(worktree, max_count) "--date=short", "--format=format:" .. LOG_FORMAT, } - if max_count > 0 then + if max_count then table.insert(cmd, "--max-count=" .. max_count) end return util.exec(cmd, { cwd = worktree }) @@ -29,8 +28,7 @@ end ---@param buf integer local function populate(buf) local worktree = vim.b[buf].git_worktree - local max_count = vim.b[buf].git_log_max_count or DEFAULT_MAX_COUNT - local stdout = fetch(worktree, max_count) + local stdout = fetch(worktree, vim.b[buf].git_log_max_count) if not stdout then return end @@ -64,7 +62,6 @@ local function populate(buf) vim.bo[buf].modified = false end ----BufReadCmd handler for `gitlog://` URIs. ---@param buf integer function M.read_uri(buf) local name = vim.api.nvim_buf_get_name(buf) @@ -77,8 +74,6 @@ function M.read_uri(buf) vim.bo[buf].swapfile = false vim.bo[buf].bufhidden = "hide" vim.bo[buf].buftype = "nofile" - -- Skip the assignment when ft is already set so re-runs don't - -- re-fire `FileType` autocmds (ftplugin reload, treesitter attach). if vim.bo[buf].filetype ~= "gitlog" then vim.bo[buf].filetype = "gitlog" end @@ -87,7 +82,7 @@ function M.read_uri(buf) end ---@class ow.Git.LogOpts ----@field max_count integer? cap on commits to show. Nil uses the default, <= 0 means "all" +---@field max_count integer? ---@param opts ow.Git.LogOpts? function M.open(opts) @@ -100,7 +95,7 @@ function M.open(opts) local buf = vim.fn.bufadd(URI_PREFIX .. worktree) vim.b[buf].git_worktree = worktree - vim.b[buf].git_log_max_count = opts.max_count or DEFAULT_MAX_COUNT + vim.b[buf].git_log_max_count = opts.max_count local was_loaded = vim.api.nvim_buf_is_loaded(buf) local win = vim.fn.bufwinid(buf) @@ -110,8 +105,6 @@ function M.open(opts) vim.api.nvim_set_current_win(win) end - -- `place_buf` triggers `bufload` -> `read_uri` for a fresh buffer. - -- An already-loaded buffer needs an explicit refresh. if was_loaded then populate(buf) end diff --git a/lua/git/object.lua b/lua/git/object.lua index 2ff9cc3..6e18c1b 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -1,36 +1,30 @@ -local diff = require("git.diff") +local Revision = require("git.revision") local repo = require("git.repo") local util = require("git.util") local M = {} ---@class ow.Git.DiffSection ----@field pre_path string path on the parent side (`a/...`) ----@field post_path string path on the current side (`b/...`) ----@field pre_blob string? ----@field post_blob string? +---@field path_a string +---@field path_b string +---@field blob_a string? +---@field blob_b string? ---@class ow.Git.BufContext ---@field worktree string ----@field ref string resolved commit SHA of the gitobject buffer ----@field parent_ref string? resolved parent commit SHA, nil for root commits +---@field sha string +---@field parent_sha string? ---@return ow.Git.BufContext? local function context() local worktree = vim.b.git_worktree - local ref = vim.b.git_ref - if not worktree or not ref then + local sha = vim.b.git_sha + if not worktree or not sha then return nil end - return { worktree = worktree, ref = ref, parent_ref = vim.b.git_parent_ref } + return { worktree = worktree, sha = sha, parent_sha = vim.b.git_parent_sha } end ----Find the enclosing `diff --git` line and parse the section's pre/post ----paths plus the pre/post blob SHAs from the `index` line. ---- ----Uses `vim.fn.search('bcnW')` (backward, accept cursor pos, no move, no ----wrap) so a giant `git show ` buffer doesn't pay an O(cursor_lnum) ----array allocation on every . ---@return ow.Git.DiffSection? local function diff_section() local diff_lnum = vim.fn.search("^diff --git ", "bcnW") @@ -42,33 +36,30 @@ local function diff_section() if not diff_line then return nil end - local pre_path, post_path = diff_line:match("^diff %-%-git a/(.-) b/(.+)$") - if not pre_path or not post_path then + local path_a, path_b = diff_line:match("^diff %-%-git a/(.-) b/(.+)$") + if not path_a or not path_b then return nil end - -- Header lines (mode/index/oldfile/newfile/etc) sit between the - -- `diff --git` line and the first `@@` hunk. Cap the read at 20 to - -- bound work even for unusual diff headers. local header = vim.api.nvim_buf_get_lines(0, diff_lnum, diff_lnum + 20, false) - local pre_blob, post_blob + local blob_a, blob_b for _, l in ipairs(header) do if l:sub(1, 3) == "@@ " or l:match("^diff %-%-git ") then break end local pre, post = l:match("^index (%x+)%.%.(%x+)") if pre then - pre_blob = pre - post_blob = post + blob_a = pre + blob_b = post break end end return { - pre_path = pre_path, - post_path = post_path, - pre_blob = pre_blob, - post_blob = post_blob, + path_a = path_a, + path_b = path_b, + blob_a = blob_a, + blob_b = blob_b, } end @@ -78,9 +69,6 @@ local function is_zero(sha) return sha == nil or sha:match("^0+$") ~= nil end ----Stage-0 (`:`) index entries are writable through the buffer: ----`:w` rewrites the entry via `hash-object` + `update-index`. All other ----revspecs (HEAD:, :, :1:, bare object refs) stay read-only. ---@param buf integer ---@param worktree string ---@param path string @@ -115,9 +103,9 @@ local function attach_index_writer(buf, worktree, path) end vim.b[buf].git_index_mode = mode end - -- Use the 3-arg form (mode sha path) instead of the comma form - -- (mode,sha,path), which doesn't survive paths containing a - -- comma. + -- Use the 3-arg form (mode sha path) instead of the comma + -- form (mode,sha,path), which doesn't survive paths + -- containing a comma. if not util.exec({ "git", @@ -135,18 +123,15 @@ local function attach_index_writer(buf, worktree, path) }) end ----Pre-fetched content keyed by bufnr, consumed once by `read_uri`. ---@type table local pending_content = {} ----Return a `git://` URI buffer. Pass `content` to prime ----`read_uri`'s cache and skip its `cat-file -p` fetch. ---@param worktree string ----@param revspec string any revspec git understands (e.g. `HEAD:foo`, `:foo`, `:1:foo`, ``, `:foo`) +---@param rev ow.Git.Revision ---@param content string? ---@return integer -function M.buf_for(worktree, revspec, content) - local buf = vim.fn.bufadd(util.uri(revspec)) +function M.buf_for(worktree, rev, content) + local buf = vim.fn.bufadd(rev:uri()) vim.b[buf].git_worktree = worktree if content then pending_content[buf] = content @@ -155,17 +140,14 @@ function M.buf_for(worktree, revspec, content) return buf end ----BufReadCmd handler for `git://` URIs. Worktree comes from ----`b:git_worktree` if set, else from cwd. Stage-0 index entries ----(`:`) are made writable so `:w` updates the index. Other ----revspecs are read-only. ---@param buf integer function M.read_uri(buf) local name = vim.api.nvim_buf_get_name(buf) - local revspec = util.parse_uri(name) - if not revspec then + local rev = Revision.from_uri(name) + if not rev then return end + local rev_str = rev:format() local worktree = vim.b[buf].git_worktree or select(2, repo.resolve_cwd()) if not worktree then @@ -182,25 +164,14 @@ function M.read_uri(buf) pending_content[buf] = nil if stdout == nil then stdout = util.exec( - { "git", "cat-file", "-p", revspec }, + { "git", "cat-file", "-p", rev_str }, { cwd = worktree } ) end - local parsed = util.parse_revspec(revspec) - - -- Bare-ref objects that dereference to a commit (commits, stashes, - -- annotated tags pointing at a commit, lightweight tags) get their - -- `diff-tree -p` patch appended so the buffer is navigable. The - -- `` parser walks `diff --git` blocks. `^{commit}` is git's - -- standard "deref to commit" suffix. rev-parse fails for non-commit - -- objects (trees, blobs, tags pointing at non-commits) so they - -- naturally skip the append. `-m --first-parent` collapses merges - -- and stashes into one diff per file (vs `diff --cc` combined - -- diffs, which the parser can't follow). - if stdout and parsed.path == nil then + if stdout and rev.path == nil then local commit_sha = - repo.rev_parse(worktree, revspec .. "^{commit}", true) + repo.rev_parse(worktree, rev_str .. "^{commit}", true) if commit_sha then local patch = util.exec({ "git", @@ -215,31 +186,25 @@ function M.read_uri(buf) if patch then stdout = (stdout:gsub("\n*$", "\n\n")) .. patch end - vim.b[buf].git_parent_ref = + vim.b[buf].git_parent_sha = repo.rev_parse(worktree, commit_sha .. "^", true) end end if stdout then - -- Reload paths (`:e`, `` to an unloaded buf) re-enter - -- with `modifiable = false` from the prior load. vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout)) end - -- `b:git_ref` anchors ``-driven navigation in this buffer. - local ref_sha = repo.rev_parse(worktree, revspec, true) - if ref_sha then - vim.b[buf].git_ref = ref_sha + local rev_sha = repo.rev_parse(worktree, rev_str, true) + if rev_sha then + vim.b[buf].git_sha = rev_sha end - if parsed.stage == 0 and parsed.path then + if rev.stage == 0 and rev.path then vim.bo[buf].buftype = "acwrite" - -- Re-running BufReadCmd (e.g. on `:edit`) would otherwise stack - -- another BufWriteCmd on the same buffer, so each `:w` runs - -- hash-object + update-index N times. if not vim.b[buf].git_index_writer then - attach_index_writer(buf, worktree, parsed.path) + attach_index_writer(buf, worktree, rev.path) vim.b[buf].git_index_writer = true end else @@ -248,15 +213,12 @@ function M.read_uri(buf) end vim.bo[buf].modified = false - -- Filetype from the inner path. We can't lean on `vim.filetype.add` - -- because Vim normalises `git://` filenames (cwd-prefix + collapses - -- `://` to `:/`) before matching, breaking any pattern keyed on the - -- raw scheme as well as any built-in pattern that doesn't catch a - -- recognisable extension on the mangled form (.Xresources is the - -- canonical example). Bare-ref content (commit/tag headers + tree - -- listings) uses the built-in `git` filetype. - if parsed.path then - local ft = vim.filetype.match({ filename = parsed.path, buf = buf }) + -- Match on the inner path directly. `vim.filetype.add` patterns + -- don't work because Vim normalises `git://` filenames (cwd-prefix + -- + `://` -> `:/`) before matching, breaking any pattern keyed on + -- the raw scheme. + if rev.path then + local ft = vim.filetype.match({ filename = rev.path, buf = buf }) if ft then vim.bo[buf].filetype = ft end @@ -264,34 +226,29 @@ function M.read_uri(buf) vim.bo[buf].filetype = "git" end - -- BufReadCmd suppresses the normal BufReadPost dispatch, so - -- modeline parsing doesn't run unless we fire it ourselves. The - -- modeline can still override the filetype set above (standard Vim - -- precedence). vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) end ----Buffer for the file at `:`. Returns nil for a zero/nil blob. ---@param worktree string ---@param blob string? ---@param path string ----@param ref string the commit ref the blob represents (e.g. `` or `^`) +---@param sha string ---@return integer? -local function blob_buf(worktree, blob, path, ref) +local function blob_buf(worktree, blob, path, sha) if is_zero(blob) then return nil end - return M.buf_for(worktree, ref .. ":" .. path) + return M.buf_for(worktree, Revision.new({ base = sha, path = path })) end ---@param worktree string ---@param blob string? ---@param path string ----@param ref string -local function load_blob(worktree, blob, path, ref) - local buf = blob_buf(worktree, blob, path, ref) +---@param sha string +local function load_blob(worktree, blob, path, sha) + local buf = blob_buf(worktree, blob, path, sha) if not buf then - util.warning("no content for %s at %s", path, ref) + util.warning("no content for %s at %s", path, sha) return end vim.cmd.normal({ "m'", bang = true }) @@ -301,21 +258,20 @@ end ---@param ctx ow.Git.BufContext ---@param section ow.Git.DiffSection local function open_section(ctx, section) - if not section.pre_blob or not section.post_blob then + if not section.blob_a or not section.blob_b then util.warning("no index line, cannot determine blob SHAs") return end - local parent = ctx.parent_ref or "0" - local left = - blob_buf(ctx.worktree, section.pre_blob, section.pre_path, parent) + local parent = ctx.parent_sha or "0" + local left = blob_buf(ctx.worktree, section.blob_a, section.path_a, parent) local right = - blob_buf(ctx.worktree, section.post_blob, section.post_path, ctx.ref) + blob_buf(ctx.worktree, section.blob_b, section.path_b, ctx.sha) if left and right then - diff.open(left, right, true) + require("git.diff").open(left, right, true) return end if not left and not right then - util.warning("no content for %s", section.post_path) + util.warning("no content for %s", section.path_b) return end local buf = left or right @@ -325,36 +281,32 @@ local function open_section(ctx, section) end ---@class ow.Git.OpenObjectOpts ----@field split (false|"above"|"below"|"left"|"right")? forwarded to `util.place_buf`. Default opens a new horizontal split. +---@field split (false|"above"|"below"|"left"|"right")? ----Open any git object. Accepts a bare ref (commit / tree / blob / tag ----sha, branch, tag name, `stash@{N}`, etc.) or `:`. ----Resolves to a sha so the URI stays stable if the ref later moves. ---@param worktree string ----@param ref string +---@param rev string ---@param opts ow.Git.OpenObjectOpts? -function M.open_object(worktree, ref, opts) - local commit_ref, path = ref:match("^(.-):(.+)$") - local revspec - if commit_ref then - local sha = repo.rev_parse(worktree, commit_ref, true) or commit_ref - revspec = sha .. ":" .. path - else - revspec = repo.rev_parse(worktree, ref, true) or ref +function M.open_object(worktree, rev, opts) + local parsed = Revision.parse(rev) + if parsed.base then + local sha = repo.rev_parse(worktree, parsed.base, true) + if sha then + parsed.base = sha + end end local content = util.exec( - { "git", "cat-file", "-p", revspec }, + { "git", "cat-file", "-p", parsed:format() }, { cwd = worktree, silent = true } ) if not content then - util.warning("not a git object: %s", ref) + util.warning("not a git object: %s", rev) return end - local buf = M.buf_for(worktree, revspec, content) + local buf = M.buf_for(worktree, parsed, content) util.place_buf(buf, opts and opts.split) end ----@return boolean dispatched true if the cursor was on an actionable line +---@return boolean dispatched function M.open_under_cursor() local ctx = context() if not ctx then @@ -372,14 +324,13 @@ function M.open_under_cursor() return true end - -- Blobs navigate by path so the URI carries the entry name (filetype - -- detection wants the extension). Other types navigate by sha. local entry_type, entry_sha, entry_name = line:match("^%d+ (%w+) (%x+)\t(.+)$") if entry_sha then - local nav_ref = entry_type == "blob" and (ctx.ref .. ":" .. entry_name) + local nav_rev = entry_type == "blob" + and Revision.new({ base = ctx.sha, path = entry_name }):format() or entry_sha - M.open_object(ctx.worktree, nav_ref, { split = false }) + M.open_object(ctx.worktree, nav_rev, { split = false }) return true end @@ -387,26 +338,26 @@ function M.open_under_cursor() if not section then return false end - local parent = ctx.parent_ref or "0" + local parent = ctx.parent_sha or "0" if line:match("^diff %-%-git ") then open_section(ctx, section) return true end if line:match("^%-%-%- ") then - load_blob(ctx.worktree, section.pre_blob, section.pre_path, parent) + load_blob(ctx.worktree, section.blob_a, section.path_a, parent) return true end if line:match("^%+%+%+ ") then - load_blob(ctx.worktree, section.post_blob, section.post_path, ctx.ref) + load_blob(ctx.worktree, section.blob_b, section.path_b, ctx.sha) return true end local prefix = line:sub(1, 1) if prefix == "+" then - load_blob(ctx.worktree, section.post_blob, section.post_path, ctx.ref) + load_blob(ctx.worktree, section.blob_b, section.path_b, ctx.sha) return true elseif prefix == "-" then - load_blob(ctx.worktree, section.pre_blob, section.pre_path, parent) + load_blob(ctx.worktree, section.blob_a, section.path_a, parent) return true end return false diff --git a/lua/git/repo.lua b/lua/git/repo.lua index a7a8111..8aeec61 100644 --- a/lua/git/repo.lua +++ b/lua/git/repo.lua @@ -12,7 +12,7 @@ M.UNMERGED = { UU = true, } ----@param code string porcelain v1 XY code +---@param code string ---@return string? char ---@return string? hl_group function M.indicator(code) @@ -84,10 +84,6 @@ function M.resolve(path) return vim.fs.normalize(gitdir), worktree end ----Resolve the gitdir/worktree from the current buffer's file path, ----falling back to `vim.fn.getcwd()` when the buffer is unnamed or ----carries a synthetic URI (`git://`, `gitlog://`) that isn't a real ----filesystem path. Returns nil for both when not inside a git repo. ---@return string? gitdir ---@return string? worktree function M.resolve_cwd() @@ -101,7 +97,7 @@ end ---@class ow.Git.Repo ---@field gitdir string ---@field worktree string ----@field buffers table set of registered buffer numbers +---@field buffers table ---@field watcher? uv.uv_fs_event_t ---@field refresh fun(self: ow.Git.Repo) ---@field refresh_handle ow.Git.Util.DebounceHandle @@ -121,9 +117,8 @@ end function Repo:stop_watcher() -- Stop the libuv watcher first so no further fs-events can trigger - -- self:refresh(). Only then tear down the debounce handle. The reverse - -- order leaves a window where an in-flight watcher callback would call - -- a closed debounce timer. + -- self:refresh(). Tearing down the debounce handle first leaves a + -- window where an in-flight watcher callback hits a closed timer. if self.watcher then self.watcher:stop() self.watcher:close() @@ -149,11 +144,6 @@ end ---@param repo ow.Git.Repo local function do_refresh(repo) - -- `--branch` adds the `## branch...upstream [ahead/behind]` line that - -- the sidebar parses. The per-buffer indicator only needs the XY + - -- path lines, so it ignores `##` lines below. Running with `--branch` - -- lets the sidebar reuse this single subprocess via the GitRefresh - -- data payload instead of spawning its own. vim.system( { "git", @@ -173,10 +163,6 @@ local function do_refresh(repo) local x = code:sub(1, 1) local y = code:sub(2, 2) local path_part = line:sub(4) - -- ` -> ` only appears in renames/copies. Without - -- this guard, a literal filename containing the - -- arrow (rare with `core.quotePath=false`) would - -- be mis-parsed. if x == "R" or x == "C" or y == "R" or y == "C" then local arrow = path_part:find(" -> ", 1, true) if arrow then @@ -325,20 +311,16 @@ function M.head(path) return nil end ----Resolve a git revision to its object SHA. Returns nil if the ref can't ----be parsed (root-commit's `^`, blob's `^`, malformed ref, etc.). When ----`short` is true, the result is abbreviated via `core.abbrev` ----(auto-extended by git to keep the prefix unique in the current repo). ---@param worktree string ----@param ref string +---@param rev string ---@param short boolean ---@return string? -function M.rev_parse(worktree, ref, short) +function M.rev_parse(worktree, rev, short) local cmd = { "git", "rev-parse", "--verify", "--quiet" } if short then table.insert(cmd, "--short") end - table.insert(cmd, ref) + table.insert(cmd, rev) local stdout = util.exec(cmd, { cwd = worktree, silent = true }) local trimmed = stdout and vim.trim(stdout) or "" return trimmed ~= "" and trimmed or nil diff --git a/lua/git/revision.lua b/lua/git/revision.lua new file mode 100644 index 0000000..529e03e --- /dev/null +++ b/lua/git/revision.lua @@ -0,0 +1,61 @@ +---@class ow.Git.Revision +---@field stage 0|1|2|3? +---@field path string? +---@field base string? +local Revision = {} +Revision.__index = Revision + +local URI_PREFIX = "git://" + +---@return string +function Revision:format() + if self.stage then + return ":" .. self.stage .. ":" .. self.path + elseif self.path then + return self.base .. ":" .. self.path + end + return self.base or error("Revision:format: empty Revision") +end + +---@return string +function Revision:uri() + return URI_PREFIX .. self:format() +end + +---@param parts { stage?: integer, base?: string, path?: string } +---@return ow.Git.Revision +function Revision.new(parts) + return setmetatable(parts, Revision) +end + +---@param str string +---@return ow.Git.Revision +function Revision.parse(str) + local stage, path = str:match("^:([0123]):(.+)$") + if stage then + return Revision.new({ + stage = tonumber(stage) --[[@as (0|1|2|3)?]], + path = path, + }) + end + path = str:match("^:([^:]+)$") + if path then + return Revision.new({ stage = 0, path = path }) + end + local base, p = str:match("^([^:]+):(.+)$") + if base then + return Revision.new({ base = base, path = p }) + end + return Revision.new({ base = str }) +end + +---@param str string +---@return ow.Git.Revision? +function Revision.from_uri(str) + local raw = str:match("^" .. URI_PREFIX .. "(.+)$") + if raw then + return Revision.parse(raw) + end +end + +return Revision diff --git a/lua/git/sidebar.lua b/lua/git/sidebar.lua index 1cadfda..44ab973 100644 --- a/lua/git/sidebar.lua +++ b/lua/git/sidebar.lua @@ -1,3 +1,4 @@ +local Revision = require("git.revision") local diff = require("git.diff") local object = require("git.object") local repo = require("git.repo") @@ -19,8 +20,8 @@ local SIDEBAR_WIDTH = 50 ---@field section string ---@field path string ---@field orig string? ----@field x string porcelain v1 column 1 (always set, may be a literal space) ----@field y string porcelain v1 column 2 (always set, may be a literal space) +---@field x string +---@field y string ---@class ow.Git.CommitEntry ---@field section string @@ -34,7 +35,7 @@ local SIDEBAR_WIDTH = 50 ---@field worktree string ---@field lines table ---@field sidebar_win integer? ----@field invocation_win integer? window focused when the sidebar opened. The first diff repurposes it as the right pane +---@field invocation_win integer? ---@field diff_left_win integer? ---@field diff_right_win integer? ---@field user_aucmd integer? @@ -47,7 +48,6 @@ local state = {} local group = vim.api.nvim_create_augroup("ow.git.sidebar", { clear = false }) local ns = vim.api.nvim_create_namespace("ow.git.sidebar") ----Find the sidebar window in the current tabpage by filetype. ---@return integer? win ---@return integer? bufnr local function find_sidebar() @@ -59,8 +59,6 @@ local function find_sidebar() end end ----Return the sidebar window stashed on `s`, validating that it's still ----live. Falls back to `find_sidebar` if the stashed handle is gone. ---@param s ow.Git.SidebarState ---@return integer? local function sidebar_win_for(s) @@ -90,7 +88,7 @@ end ---@param entry ow.Git.SidebarEntry ---@return string? line ---@return string? hl_group ----@return integer? hl_len byte length of the symbol portion at column 2 +---@return integer? hl_len local function format_entry(entry) if entry.sha then return string.format(" %s %s", entry.sha, entry.subject or ""), @@ -116,7 +114,7 @@ end ---@field ahead integer ---@field behind integer ----@param line string '## branch.line' from porcelain v1 +---@param line string ---@return ow.Git.BranchInfo local function parse_branch_line(line) local info = { ahead = 0, behind = 0 } @@ -142,9 +140,6 @@ local function parse_branch_line(line) return info end ----Parse `git status --porcelain=v1 --branch` output into a (branch, groups) ----pair. `Unpushed` and `Unpulled` start empty here. Ahead/behind commits are ----filled in by a follow-up `git log` once we know the upstream is set. ---@param stdout string ---@return ow.Git.BranchInfo, table local function parse_porcelain(stdout) @@ -165,9 +160,6 @@ local function parse_porcelain(stdout) local y = line:sub(2, 2) local rest = line:sub(4) local orig - -- ` -> ` only appears in renames/copies (R/C codes). Without - -- this guard, a literal filename containing the arrow would - -- be mis-parsed. if x == "R" or x == "C" or y == "R" or y == "C" then local arrow = rest:find(" -> ", 1, true) if arrow then @@ -213,9 +205,6 @@ local function parse_porcelain(stdout) return branch, groups end ----Fill in the Unpushed/Unpulled groups from `git log` for any non-zero ----ahead/behind counter. Capped at 200 commits per range so a wildly ----divergent branch can't blow the sidebar's render budget. ---@param worktree string ---@param branch ow.Git.BranchInfo ---@param groups table @@ -233,8 +222,6 @@ local function enrich_with_log(worktree, branch, groups) { section = "Unpulled", range = "HEAD..@{upstream}" } ) end - -- Submit both subprocesses before waiting so they run concurrently - -- rather than sequentially. Total time = max, not sum. local pending = {} for _, f in ipairs(fetches) do table.insert(pending, { @@ -271,10 +258,6 @@ local function enrich_with_log(worktree, branch, groups) end end ----Build the (branch, groups) tuple for the sidebar. When `prefetched_stdout` ----is provided (typical case: dispatched via the `User GitRefresh` autocmd ----that already ran `git status --porcelain=v1 --branch` for the indicator), ----we skip the duplicate subprocess. Otherwise the sidebar fetches its own. ---@param worktree string ---@param prefetched_stdout string? ---@param callback fun(branch: ow.Git.BranchInfo, groups: table) @@ -372,9 +355,6 @@ local function render(bufnr, branch, groups) state[bufnr].lines = meta end ----Build a stable fingerprint of the parsed branch + groups so refresh can ----short-circuit when the porcelain state is byte-identical to the last ----successful render. ---@param branch ow.Git.BranchInfo ---@param groups table ---@return string @@ -408,7 +388,7 @@ local function fingerprint(branch, groups) end ---@param bufnr integer ----@param prefetched_stdout string? porcelain output from a piggybacked GitRefresh +---@param prefetched_stdout string? local function refresh(bufnr, prefetched_stdout) local s = state[bufnr] if not s then @@ -430,9 +410,6 @@ local function refresh(bufnr, prefetched_stdout) if not vim.api.nvim_buf_is_valid(bufnr) then return end - -- Any fs-event that triggered this refresh might have changed the - -- worktree under the diff buffers we last opened. Invalidate the - -- cache so the next view_entry recomputes panes. s.last_shown_key = nil local fp = fingerprint(branch, groups) if fp == s.last_render_key then @@ -482,9 +459,10 @@ end ---@param path string ---@return ow.Git.DiffSide local function head_pane(worktree, path) + local rev = Revision.new({ base = "HEAD", path = path }) return { - buf = object.buf_for(worktree, "HEAD:" .. path), - name = util.uri("HEAD:" .. path), + buf = object.buf_for(worktree, rev), + name = rev:uri(), } end @@ -501,9 +479,10 @@ end ---@param entry ow.Git.FileEntry ---@return ow.Git.DiffSide local function index_pane(s, entry) + local rev = Revision.new({ stage = 0, path = entry.path }) return { - buf = object.buf_for(s.worktree, ":0:" .. entry.path), - name = util.uri(":0:" .. entry.path), + buf = object.buf_for(s.worktree, rev), + name = rev:uri(), } end @@ -515,7 +494,6 @@ local function older_pane(s, entry) if entry.x == "A" then return nil end - -- HEAD holds the pre-rename path return head_pane(s.worktree, entry.orig or entry.path) end if entry.section == "Unstaged" then @@ -555,10 +533,6 @@ local function reset_diff_win(win) end) end ----Validate the window the user was in when the sidebar opened. The first ----diff repurposes it as the right pane, regardless of whether it holds an ----empty buffer or a real file. Returns nil if the user closed it, moved ----to another tabpage, or it's somehow the sidebar itself. ---@param s ow.Git.SidebarState ---@return integer? local function invocation_win_for(s) @@ -613,8 +587,6 @@ local function entry_key(entry) return entry.section .. "|" .. entry.path .. "|" .. (entry.orig or "") end ----Split `target_win` to the given side. The new window inherits ----`target_win`'s buffer, which the caller swaps afterwards. ---@param target_win integer ---@param dir "left"|"right" ---@return integer @@ -628,8 +600,6 @@ local function vsplit_at(target_win, dir) return win end ----Make sure `right_win` exists, repurposing the invocation window or ----splitting the sidebar. Returns the right window. ---@param s ow.Git.SidebarState ---@param sidebar_win integer ---@param right_win integer? @@ -642,7 +612,6 @@ local function ensure_right_win(s, sidebar_win, right_win) if target then right_win = target else - -- Sidebar-only case: split steals from sidebar, restore width. right_win = vsplit_at(sidebar_win, "right") vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH) end @@ -804,9 +773,6 @@ local function action_discard() local prompt, action if entry.section == "Untracked" then - -- Porcelain v1 collapses untracked directories into a single - -- entry with a trailing slash, so plain `os.remove` (which only - -- deletes files / empty dirs) won't do. local is_dir = entry.path:sub(-1) == "/" prompt = string.format( "Delete untracked %s %s?", diff --git a/lua/git/util.lua b/lua/git/util.lua index 4f5eb0d..4aabeb6 100644 --- a/lua/git/util.lua +++ b/lua/git/util.lua @@ -1,54 +1,9 @@ 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" +---@field bufhidden ("hide"|"wipe")? ----Configure a fresh buffer as a read-only scratch and optionally name it. ---@param buf integer ---@param opts ow.Git.ScratchOpts local function setup_scratch(buf, opts) @@ -62,8 +17,6 @@ local function setup_scratch(buf, opts) end end ----Set a buffer's name and re-run filetype detection from it. Wrapped ----in `pcall` because a buffer with that name may already exist (E95). ---@param buf integer ---@param name string function M.set_buf_name(buf, name) @@ -74,10 +27,6 @@ function M.set_buf_name(buf, name) end end ----Place a buffer in the current window or a new split per `split`. ----`false` replaces the current buffer, dropping a `'` mark first so ----`''` jumps back. A direction string opens a split on that side. Nil ----falls back to `'splitbelow'` for the direction. ---@param buf integer ---@param split (false|"above"|"below"|"left"|"right")? ---@return integer win @@ -95,11 +44,8 @@ function M.place_buf(buf, split) 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). +---@field split (false|"above"|"below"|"left"|"right")? ----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 @@ -134,9 +80,6 @@ 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) @@ -164,9 +107,9 @@ function M.debounce(fn, delay) 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. + -- 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 @@ -217,17 +160,9 @@ 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 +---@field silent boolean? +---@field on_done fun(stdout: string?)? ----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?