refactor(git): introduce Revision class, normalize naming, slim docs

This commit is contained in:
2026-04-30 09:44:24 +02:00
parent 775add9b15
commit d95de4bc1d
10 changed files with 244 additions and 428 deletions
+14 -26
View File
@@ -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<string, ow.Git.SplitHandler>
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 <Tab>` 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 <args>` 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 <ref>:<path>` 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 <sha>` 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
+41 -79
View File
@@ -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://<revspec>`
---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:<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 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 <ref>")
if rev.stage == 1 then
util.warning("gD on merge base is ambiguous, use :Gdiffsplit <rev>")
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 `<ref>:<rel>` 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
-22
View File
@@ -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<string,string> }
---@param on_open fun(file_path: string, done: fun())
+19 -19
View File
@@ -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",
"<leader>gg",
sidebar.toggle,
require("git.sidebar").toggle,
{ desc = "Toggle git status sidebar" }
)
vim.keymap.set("n", "<leader>gl", log.open, { desc = "Show git log" })
vim.keymap.set("n", "<leader>gl", function()
log.open({ max_count = 1000 })
end, { desc = "Show git log" })
vim.keymap.set("n", "<leader>gd", function()
diff.split({ revspec = "", vertical = true })
diff.split({ vertical = true })
end, { desc = "Diff index vs worktree (vsplit)" })
vim.keymap.set("n", "<leader>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", "<leader>gh", function()
diff.split({ revspec = "", vertical = false })
diff.split({ vertical = false })
end, { desc = "Diff index vs worktree (split)" })
vim.keymap.set("n", "<leader>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", "<leader>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 <revspec> (vsplit)" }
{ nargs = "?", desc = "Diff against <rev> (vsplit)" }
)
vim.api.nvim_create_user_command(
"Ghdiffsplit",
diff_split_cmd(false),
{ nargs = "?", desc = "Diff against <revspec> (split)" }
{ nargs = "?", desc = "Diff against <rev> (split)" }
)
cmd.setup()
+5 -12
View File
@@ -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://<worktree>` 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
+77 -126
View File
@@ -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 <merge>` buffer doesn't pay an O(cursor_lnum)
---array allocation on every <CR>.
---@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 (`:<path>`) index entries are writable through the buffer:
---`:w` rewrites the entry via `hash-object` + `update-index`. All other
---revspecs (HEAD:, <sha>:, :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<integer, string>
local pending_content = {}
---Return a `git://<revspec>` 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`, `<sha>`, `<sha>: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://<revspec>` URIs. Worktree comes from
---`b:git_worktree` if set, else from cwd. Stage-0 index entries
---(`:<path>`) 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
-- `<CR>` 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`, `<C-o>` 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 `<CR>`-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 `<ref>:<path>`. 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. `<sha>` or `<sha>^`)
---@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 `<commit-ref>:<path>`.
---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
+7 -25
View File
@@ -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<integer, true> set of registered buffer numbers
---@field buffers table<integer, true>
---@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
+61
View File
@@ -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
+13 -47
View File
@@ -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<integer, ow.Git.SidebarEntry>
---@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<string, ow.Git.SidebarEntry[]>
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<string, ow.Git.SidebarEntry[]>
@@ -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<string, ow.Git.SidebarEntry[]>)
@@ -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<string, ow.Git.SidebarEntry[]>
---@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?",
+7 -72
View File
@@ -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://<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"
---@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?