refactor(git): make the git module self-contained under git.util

This commit is contained in:
2026-04-28 09:05:01 +02:00
parent 4390b55dfe
commit 37e5582795
10 changed files with 195 additions and 75 deletions
+4 -5
View File
@@ -1,7 +1,6 @@
local git = require("git") local git = require("git")
local log = require("log")
local repo = require("git.repo") local repo = require("git.repo")
local util = require("util") local util = require("git.util")
local M = {} local M = {}
@@ -23,7 +22,7 @@ local cached_cmds
---@param result vim.SystemCompleted ---@param result vim.SystemCompleted
local function populate_cached_cmds(result) local function populate_cached_cmds(result)
if result.code ~= 0 then if result.code ~= 0 then
log.error("git --list-cmds failed: %s", vim.trim(result.stderr or "")) util.error("git --list-cmds failed: %s", vim.trim(result.stderr or ""))
return return
end end
cached_cmds = {} cached_cmds = {}
@@ -140,7 +139,7 @@ local function run_in_split(worktree, args, conf)
vim.bo[buf].modifiable = false vim.bo[buf].modifiable = false
vim.bo[buf].modified = false vim.bo[buf].modified = false
if obj.code ~= 0 then if obj.code ~= 0 then
log.error( util.error(
"git %s failed: %s", "git %s failed: %s",
args[1] or "", args[1] or "",
vim.trim(obj.stderr or "") vim.trim(obj.stderr or "")
@@ -216,7 +215,7 @@ end
function M.run(args) function M.run(args)
local _, worktree = repo.resolve_cwd() local _, worktree = repo.resolve_cwd()
if not worktree then if not worktree then
log.warning("not in a git repository") util.warning("not in a git repository")
return return
end end
+4 -5
View File
@@ -1,6 +1,5 @@
local editor = require("git.editor") local editor = require("git.editor")
local git = require("git") local git = require("git")
local log = require("log")
local repo = require("git.repo") local repo = require("git.repo")
local M = {} local M = {}
@@ -10,7 +9,7 @@ function M.commit(opts)
local amend = opts and opts.amend or false local amend = opts and opts.amend or false
local _, worktree = repo.resolve_cwd() local _, worktree = repo.resolve_cwd()
if not worktree then if not worktree then
log.warning("not in a git repository") util.warning("not in a git repository")
return return
end end
@@ -46,7 +45,7 @@ function M.commit(opts)
local out = vim.api.nvim_buf_get_lines(buf, 0, -1, false) local out = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
local fw, werr = io.open(file_path, "w") local fw, werr = io.open(file_path, "w")
if not fw then if not fw then
log.error("failed to write %s: %s", file_path, werr or "") util.error("failed to write %s: %s", file_path, werr or "")
return return
end end
fw:write(table.concat(out, "\n")) fw:write(table.concat(out, "\n"))
@@ -68,12 +67,12 @@ function M.commit(opts)
vim.api.nvim_buf_delete(proxy_buf, { force = true }) vim.api.nvim_buf_delete(proxy_buf, { force = true })
end end
if result.code ~= 0 then if result.code ~= 0 then
log.error("git commit failed: %s", vim.trim(result.stderr or "")) util.error("git commit failed: %s", vim.trim(result.stderr or ""))
return return
end end
local out = vim.trim(result.stdout or "") local out = vim.trim(result.stdout or "")
if out ~= "" then if out ~= "" then
log.info("%s", out) util.info("%s", out)
end end
end) end)
end end
+10 -14
View File
@@ -1,6 +1,5 @@
local log = require("log")
local repo = require("git.repo") local repo = require("git.repo")
local util = require("util") local util = require("git.util")
local M = {} local M = {}
@@ -15,7 +14,7 @@ local function attach_index_writer(buf, worktree, path)
vim.api.nvim_buf_get_lines(buf, 0, -1, false), vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n" "\n"
) .. "\n" ) .. "\n"
local hash_stdout = util.system_sync( local hash_stdout = util.exec(
{ "git", "hash-object", "-w", "--stdin" }, { "git", "hash-object", "-w", "--stdin" },
{ cwd = worktree, stdin = body } { cwd = worktree, stdin = body }
) )
@@ -26,7 +25,7 @@ local function attach_index_writer(buf, worktree, path)
local mode = vim.b[buf].git_index_mode local mode = vim.b[buf].git_index_mode
if not mode then if not mode then
mode = "100644" mode = "100644"
local ls = util.system_sync( local ls = util.exec(
{ "git", "ls-files", "-s", "--", path }, { "git", "ls-files", "-s", "--", path },
{ cwd = worktree, silent = true } { cwd = worktree, silent = true }
) )
@@ -42,7 +41,7 @@ local function attach_index_writer(buf, worktree, path)
-- (mode,sha,path), which doesn't survive paths containing a -- (mode,sha,path), which doesn't survive paths containing a
-- comma. -- comma.
if if
not util.system_sync({ not util.exec({
"git", "git",
"update-index", "update-index",
"--cacheinfo", "--cacheinfo",
@@ -87,7 +86,7 @@ function M.read_uri(buf)
local worktree = vim.b[buf].git_worktree or select(2, repo.resolve_cwd()) local worktree = vim.b[buf].git_worktree or select(2, repo.resolve_cwd())
if not worktree then if not worktree then
log.error("git BufReadCmd %s: cannot resolve worktree", name) util.error("git BufReadCmd %s: cannot resolve worktree", name)
return return
end end
vim.b[buf].git_worktree = worktree vim.b[buf].git_worktree = worktree
@@ -104,10 +103,7 @@ function M.read_uri(buf)
end end
local revspec = ref == "index" and (":" .. path) or (ref .. ":" .. path) local revspec = ref == "index" and (":" .. path) or (ref .. ":" .. path)
local stdout = util.system_sync( local stdout = util.exec({ "git", "show", revspec }, { cwd = worktree })
{ "git", "show", revspec },
{ cwd = worktree }
)
if stdout then if stdout then
vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout)) vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout))
end end
@@ -207,21 +203,21 @@ function M.split(opts)
local cur_buf = vim.api.nvim_get_current_buf() local cur_buf = vim.api.nvim_get_current_buf()
local cur_path = vim.api.nvim_buf_get_name(cur_buf) local cur_path = vim.api.nvim_buf_get_name(cur_buf)
if cur_path == "" then if cur_path == "" then
log.warning("no file in current buffer") util.warning("no file in current buffer")
return return
end end
if vim.bo[cur_buf].buftype ~= "" then if vim.bo[cur_buf].buftype ~= "" then
log.warning("cannot diff this buffer (not a worktree file)") util.warning("cannot diff this buffer (not a worktree file)")
return return
end end
local _, worktree = repo.resolve(cur_path) local _, worktree = repo.resolve(cur_path)
if not worktree then if not worktree then
log.warning("not in a git repository") util.warning("not in a git repository")
return return
end end
local rel = vim.fs.relpath(worktree, cur_path) local rel = vim.fs.relpath(worktree, cur_path)
if not rel then if not rel then
log.warning("file is outside the worktree") util.warning("file is outside the worktree")
return return
end end
+1 -3
View File
@@ -1,5 +1,3 @@
local log = require("log")
local M = {} local M = {}
local SENTINEL = "__NVIM_GIT_EDIT__" local SENTINEL = "__NVIM_GIT_EDIT__"
@@ -59,7 +57,7 @@ local function build_stderr_handler(on_open)
end end
local ok, err = pcall(on_open, abs_path, done) local ok, err = pcall(on_open, abs_path, done)
if not ok then if not ok then
log.error("git.editor on_open failed: %s", tostring(err)) util.error("git.editor on_open failed: %s", tostring(err))
done() done()
end end
end) end)
+3 -4
View File
@@ -1,7 +1,6 @@
local git = require("git") local git = require("git")
local log = require("log")
local repo = require("git.repo") local repo = require("git.repo")
local util = require("util") local util = require("git.util")
local M = {} local M = {}
@@ -21,7 +20,7 @@ function M.show(opts)
local _, worktree = repo.resolve_cwd() local _, worktree = repo.resolve_cwd()
if not worktree then if not worktree then
log.warning("not in a git repository") util.warning("not in a git repository")
return return
end end
@@ -38,7 +37,7 @@ function M.show(opts)
table.insert(cmd, "--max-count=" .. max_count) table.insert(cmd, "--max-count=" .. max_count)
end end
local stdout = util.system_sync(cmd, { cwd = worktree }) local stdout = util.exec(cmd, { cwd = worktree })
if not stdout then if not stdout then
return return
end end
+8 -6
View File
@@ -1,5 +1,4 @@
local log = require("log") local util = require("git.util")
local util = require("util")
local M = {} local M = {}
@@ -76,7 +75,7 @@ function M.resolve(path)
f:close() f:close()
local gitdir = content:match("gitdir:%s*(%S+)") local gitdir = content:match("gitdir:%s*(%S+)")
if not gitdir then if not gitdir then
log.warning(".git file at %s has no `gitdir:` line", found) util.warning(".git file at %s has no `gitdir:` line", found)
return nil return nil
end end
if not gitdir:match("^/") then if not gitdir:match("^/") then
@@ -104,7 +103,7 @@ end
---@field buffers table<integer, true> set of registered buffer numbers ---@field buffers table<integer, true> set of registered buffer numbers
---@field watcher? uv.uv_fs_event_t ---@field watcher? uv.uv_fs_event_t
---@field refresh fun(self: ow.Git.Repo) ---@field refresh fun(self: ow.Git.Repo)
---@field refresh_handle ow.Util.DebounceHandle ---@field refresh_handle ow.Git.Util.DebounceHandle
local Repo = {} local Repo = {}
Repo.__index = Repo Repo.__index = Repo
@@ -188,7 +187,10 @@ local function do_refresh(repo)
end end
end end
else else
log.warning("git status failed: %s", vim.trim(obj.stderr or "")) util.warning(
"git status failed: %s",
vim.trim(obj.stderr or "")
)
end end
local dirty = false local dirty = false
for buf in pairs(repo.buffers) do for buf in pairs(repo.buffers) do
@@ -336,7 +338,7 @@ function M.rev_parse(worktree, ref, short)
table.insert(cmd, "--short") table.insert(cmd, "--short")
end end
table.insert(cmd, ref) table.insert(cmd, ref)
local stdout = util.system_sync(cmd, { cwd = worktree, silent = true }) local stdout = util.exec(cmd, { cwd = worktree, silent = true })
local trimmed = stdout and vim.trim(stdout) or "" local trimmed = stdout and vim.trim(stdout) or ""
return trimmed ~= "" and trimmed or nil return trimmed ~= "" and trimmed or nil
end end
+3 -4
View File
@@ -1,8 +1,7 @@
local diff = require("git.diff") local diff = require("git.diff")
local git = require("git") local git = require("git")
local log = require("log")
local repo = require("git.repo") local repo = require("git.repo")
local util = require("util") local util = require("git.util")
local M = {} local M = {}
@@ -109,7 +108,7 @@ end
---@param section ow.Git.DiffSection ---@param section ow.Git.DiffSection
local function show_diff(ctx, section) local function show_diff(ctx, section)
if not section.pre_blob or not section.post_blob then if not section.pre_blob or not section.post_blob then
log.warning("no index line; cannot determine blob SHAs") util.warning("no index line; cannot determine blob SHAs")
return return
end end
local parent = ctx.parent_ref or "0" local parent = ctx.parent_ref or "0"
@@ -152,7 +151,7 @@ function M.open_commit(worktree, ref, opts)
return return
end end
local stdout = util.system_sync({ "git", "show", ref }, { cwd = worktree }) local stdout = util.exec({ "git", "show", ref }, { cwd = worktree })
if not stdout then if not stdout then
return return
end end
+8 -9
View File
@@ -1,6 +1,5 @@
local diff = require("git.diff") local diff = require("git.diff")
local git = require("git") local git = require("git")
local log = require("log")
local repo = require("git.repo") local repo = require("git.repo")
local M = {} local M = {}
@@ -263,7 +262,7 @@ local function enrich_with_log(worktree, branch, groups)
end end
end end
else else
log.error( util.error(
"git log %s failed: %s", "git log %s failed: %s",
p.f.range, p.f.range,
vim.trim(result.stderr or "") vim.trim(result.stderr or "")
@@ -298,7 +297,7 @@ local function fetch_status(worktree, prefetched_stdout, callback)
{ cwd = worktree, text = true }, { cwd = worktree, text = true },
vim.schedule_wrap(function(obj) vim.schedule_wrap(function(obj)
if obj.code ~= 0 then if obj.code ~= 0 then
log.error("git status failed: %s", vim.trim(obj.stderr or "")) util.error("git status failed: %s", vim.trim(obj.stderr or ""))
local branch = { ahead = 0, behind = 0 } local branch = { ahead = 0, behind = 0 }
local groups = { local groups = {
Untracked = {}, Untracked = {},
@@ -761,7 +760,7 @@ local function action_stage()
{ cwd = s.worktree }, { cwd = s.worktree },
vim.schedule_wrap(function(obj) vim.schedule_wrap(function(obj)
if obj.code ~= 0 then if obj.code ~= 0 then
log.error("git add failed: %s", vim.trim(obj.stderr or "")) util.error("git add failed: %s", vim.trim(obj.stderr or ""))
end end
end) end)
) )
@@ -786,7 +785,7 @@ local function action_unstage()
{ cwd = s.worktree }, { cwd = s.worktree },
vim.schedule_wrap(function(obj) vim.schedule_wrap(function(obj)
if obj.code ~= 0 then if obj.code ~= 0 then
log.error( util.error(
"git restore --staged failed: %s", "git restore --staged failed: %s",
vim.trim(obj.stderr or "") vim.trim(obj.stderr or "")
) )
@@ -802,7 +801,7 @@ local function action_discard()
end end
---@cast entry ow.Git.FileEntry ---@cast entry ow.Git.FileEntry
if entry.section == "Staged" then if entry.section == "Staged" then
log.warning("file has staged changes; unstage first with 'u'") util.warning("file has staged changes; unstage first with 'u'")
return return
end end
@@ -821,7 +820,7 @@ local function action_discard()
local target = vim.fs.joinpath(s.worktree, entry.path) local target = vim.fs.joinpath(s.worktree, entry.path)
local rc = vim.fn.delete(target, is_dir and "rf" or "") local rc = vim.fn.delete(target, is_dir and "rf" or "")
if rc ~= 0 then if rc ~= 0 then
log.error("failed to delete %s", entry.path) util.error("failed to delete %s", entry.path)
end end
refresh(vim.api.nvim_get_current_buf()) refresh(vim.api.nvim_get_current_buf())
end end
@@ -833,7 +832,7 @@ local function action_discard()
{ cwd = s.worktree }, { cwd = s.worktree },
vim.schedule_wrap(function(obj) vim.schedule_wrap(function(obj)
if obj.code ~= 0 then if obj.code ~= 0 then
log.error( util.error(
"git checkout failed: %s", "git checkout failed: %s",
vim.trim(obj.stderr or "") vim.trim(obj.stderr or "")
) )
@@ -950,7 +949,7 @@ function M.toggle()
end end
local _, worktree = repo.resolve_cwd() local _, worktree = repo.resolve_cwd()
if not worktree then if not worktree then
log.warning("not in a git repository") util.warning("not in a git repository")
return return
end end
open(worktree) open(worktree)
+154
View File
@@ -0,0 +1,154 @@
local M = {}
---@param fmt string
---@param ... any
function M.error(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.ERROR)
end
---@param fmt string
---@param ... any
function M.warning(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.WARN)
end
---@param fmt string
---@param ... any
function M.info(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.INFO)
end
---@param fmt string
---@param ... any
function M.debug(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.DEBUG)
end
---Split a string on newlines, dropping the trailing empty element that an
---input ending in `\n` produces. Convenient for slicing subprocess stdout
---into a list of lines without a phantom blank at the end.
---@param content string
---@return string[]
function M.split_lines(content)
local lines = vim.split(content, "\n", { plain = true, trimempty = false })
if #lines > 0 and lines[#lines] == "" then
table.remove(lines)
end
return lines
end
---@class ow.Git.Util.DebounceHandle
---@field cancel fun()
---@field flush fun()
---@field pending fun(): boolean
---@field close fun()
---@generic F: fun(...)
---@param fn F
---@param delay integer
---@return F, ow.Git.Util.DebounceHandle
function M.debounce(fn, delay)
local timer = assert(vim.uv.new_timer())
local args ---@type table?
local gen = 0
local fired_gen = 0
local cb_main = vim.schedule_wrap(function()
-- Identity check: the libuv fire may have been superseded by a
-- re-arm or a cancel between the timer firing and this scheduled
-- callback running.
if fired_gen ~= gen or args == nil then
return
end
local a = args
args = nil
fn(vim.F.unpack_len(a))
end)
local cb_uv = function()
fired_gen = gen
cb_main()
end
local function call(...)
args = vim.F.pack_len(...)
gen = gen + 1
timer:start(delay, 0, cb_uv)
end
return call,
{
cancel = function()
timer:stop()
args = nil
end,
flush = function()
if args == nil then
return
end
timer:stop()
local a = args
args = nil
fn(vim.F.unpack_len(a))
end,
pending = function()
return args ~= nil
end,
close = function()
timer:stop()
if not timer:is_closing() then
timer:close()
end
args = nil
end,
}
end
---@class ow.Git.ExecOpts
---@field cwd string?
---@field stdin string?
---@field silent boolean? suppress the auto-log on non-zero exit
---@field on_done fun(stdout: string?)? if set, run async and deliver stdout (or nil on failure) here on the main loop instead of returning sync
---Run a system command. Default is sync: returns stdout on success or
---nil on failure (logging stderr unless `opts.silent`). When
---`opts.on_done` is set, runs async via `vim.schedule_wrap` and
---delivers the same stdout-or-nil value to that callback instead.
---
---Async mode returns nil immediately. Callers that need access to the
---raw stderr / exit code in the failure path should opt out of this
---helper and use `vim.system` directly.
---@param cmd string[]
---@param opts ow.Git.ExecOpts?
---@return string?
function M.exec(cmd, opts)
opts = opts or {}
local sys_opts = { cwd = opts.cwd, stdin = opts.stdin, text = true }
local function handle(result)
if result.code ~= 0 then
if not opts.silent then
local label = cmd[2] and (cmd[1] .. " " .. cmd[2])
or cmd[1]
or "?"
M.error("%s failed: %s", label, vim.trim(result.stderr or ""))
end
return nil
end
return result.stdout or ""
end
if opts.on_done then
vim.system(
cmd,
sys_opts,
vim.schedule_wrap(function(result)
opts.on_done(handle(result))
end)
)
return nil
end
return handle(vim.system(cmd, sys_opts):wait())
end
return M
-25
View File
@@ -441,29 +441,4 @@ function M.split_lines(content)
return lines return lines
end end
---Run a system command synchronously and return stdout on success. On
---non-zero exit, logs stderr via `log.error` and returns nil. Pass
---`opts.silent` to suppress the auto-log when failure is expected (e.g.
---probe-style commands like `git rev-parse` against a possibly-missing
---ref).
---@param cmd string[]
---@param opts { cwd: string?, stdin: string?, silent: boolean? }?
---@return string?
function M.system_sync(cmd, opts)
opts = opts or {}
local result = vim.system(cmd, {
cwd = opts.cwd,
stdin = opts.stdin,
text = true,
}):wait()
if result.code ~= 0 then
if not opts.silent then
local label = cmd[2] and (cmd[1] .. " " .. cmd[2]) or cmd[1] or "?"
log.error("%s failed: %s", label, vim.trim(result.stderr or ""))
end
return nil
end
return result.stdout or ""
end
return M return M