refactor(git): unify around the Repo abstraction

This commit is contained in:
2026-05-02 22:45:44 +02:00
parent 8bd674622e
commit be1d7ace50
14 changed files with 671 additions and 586 deletions
-17
View File
@@ -1,17 +0,0 @@
-- The built-in `git` filetype is reused for our `:G show` / `:G cat-file -p`
-- output buffers and for commits opened from the log window. We set
-- `vim.b.git_worktree` before assigning the filetype on those buffers; the
-- guard below keeps the <CR> dispatcher off any unrelated git buffer (a
-- real `.git/HEAD` file, etc.) so the default normal-mode <CR> still works.
if not vim.b.git_worktree then
return
end
local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
vim.keymap.set("n", "<CR>", function()
if not require("git.object").open_under_cursor() then
-- "n" mode = no remap, so this doesn't recurse into our mapping.
vim.api.nvim_feedkeys(cr, "n", false)
end
end, { buffer = 0, silent = true, desc = "Open file at commit" })
-17
View File
@@ -1,17 +0,0 @@
local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
vim.keymap.set("n", "<CR>", function()
local worktree = vim.b.git_worktree
-- Anchor past the leading graph chars (matches the leading sha column,
-- not any hex word that happens to appear later in the subject).
local sha = worktree
and vim.api
.nvim_get_current_line()
:match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)")
if sha then
require("git.object").open_object(worktree, sha, { split = false })
else
-- "n" mode = no remap, so this doesn't recurse into our mapping.
vim.api.nvim_feedkeys(cr, "n", false)
end
end, { buffer = 0, silent = true, desc = "Open commit" })
+122 -23
View File
@@ -23,8 +23,10 @@ local function git_cmds()
if cached_cmds then if cached_cmds then
return cached_cmds return cached_cmds
end end
local result = vim local result = vim.system(
.system({ "git", "--list-cmds=main,others,alias" }, { text = true }) { "git", "--list-cmds=main,others,alias" },
{ text = true }
)
:wait() :wait()
if result.code ~= 0 then if result.code ~= 0 then
util.error("git --list-cmds failed: %s", vim.trim(result.stderr or "")) util.error("git --list-cmds failed: %s", vim.trim(result.stderr or ""))
@@ -70,30 +72,31 @@ local function place_split(name)
return buf return buf
end end
---@param worktree string ---@param r ow.Git.Repo
---@param args string[] ---@param args string[]
---@param conf ow.Git.SplitHandler ---@param conf ow.Git.SplitHandler
local function run_in_split(worktree, args, conf) local function run_in_split(r, args, conf)
local cmd = { "git" } local cmd = { "git" }
vim.list_extend(cmd, args) vim.list_extend(cmd, args)
util.exec(cmd, { util.exec(cmd, {
cwd = worktree, cwd = r.worktree,
on_done = function(stdout) on_done = function(stdout)
if not stdout then if not stdout then
return return
end end
local name = "[git " .. table.concat(args, " ") .. "]" local name = "[git " .. table.concat(args, " ") .. "]"
local buf = place_split(name) local buf = place_split(name)
vim.b[buf].git_worktree = worktree repo.attach(buf, r)
vim.b[buf].git_sha = nil object.attach_dispatch(buf)
vim.b[buf].git_parent_sha = nil local state = r:state(buf) --[[@as -nil]]
state.sha = nil
state.parent_sha = nil
if conf.needs_rev then if conf.needs_rev then
local user_rev = first_positional(args, 2) or "HEAD" local user_rev = first_positional(args, 2) or "HEAD"
local sha = repo.rev_parse(worktree, user_rev, true) local sha = r:rev_parse(user_rev, true)
if sha then if sha then
vim.b[buf].git_sha = sha state.sha = sha
vim.b[buf].git_parent_sha = state.parent_sha = r:rev_parse(user_rev .. "^", true)
repo.rev_parse(worktree, user_rev .. "^", true)
end end
end end
vim.bo[buf].filetype = conf.ft vim.bo[buf].filetype = conf.ft
@@ -111,14 +114,14 @@ local function run_in_split(worktree, args, conf)
}) })
end end
---@param worktree string ---@param r ow.Git.Repo
---@param args string[] ---@param args string[]
local function run_to_messages(worktree, args) local function run_to_messages(r, args)
local cmd = { "git" } local cmd = { "git" }
vim.list_extend(cmd, args) vim.list_extend(cmd, args)
vim.system( vim.system(
cmd, cmd,
{ cwd = worktree, text = true }, { cwd = r.worktree, text = true },
vim.schedule_wrap(function(obj) vim.schedule_wrap(function(obj)
local out = vim.trim(obj.stdout or "") local out = vim.trim(obj.stdout or "")
local err = vim.trim(obj.stderr or "") local err = vim.trim(obj.stderr or "")
@@ -175,8 +178,8 @@ end
---@param args string[] ---@param args string[]
function M.run(args) function M.run(args)
local _, worktree = repo.current_repo() local r = repo.find()
if not worktree then if not r then
util.warning("not in a git repository") util.warning("not in a git repository")
return return
end end
@@ -190,10 +193,10 @@ function M.run(args)
if sub == "show" then if sub == "show" then
local arg = first_positional(args, 2) local arg = first_positional(args, 2)
if arg and arg:find(":", 1, true) then if arg and arg:find(":", 1, true) then
object.open_object(worktree, arg) object.open_object(r, arg)
return return
end end
run_in_split(worktree, args, { ft = "git", needs_rev = true }) run_in_split(r, args, { ft = "git", needs_rev = true })
return return
end end
@@ -201,22 +204,118 @@ function M.run(args)
if vim.list_contains(args, "-p") then if vim.list_contains(args, "-p") then
local rev = first_positional(args, 2) local rev = first_positional(args, 2)
if rev then if rev then
object.open_object(worktree, rev) object.open_object(r, rev)
return return
end end
end end
run_in_split(worktree, args, { ft = "git", needs_rev = true }) run_in_split(r, args, { ft = "git", needs_rev = true })
return return
end end
local conf = sub and SPLIT_HANDLERS[sub] local conf = sub and SPLIT_HANDLERS[sub]
if conf then if conf then
run_in_split(worktree, args, conf) run_in_split(r, args, conf)
else else
run_to_messages(worktree, args) run_to_messages(r, args)
end end
end end
---@param arg_lead string
---@return string[]
function M.complete_rev(arg_lead)
local r = repo.find()
if not r then
return {}
end
local stage, stage_path_lead = arg_lead:match("^:([0-3]):(.*)$")
if stage then
local out = util.exec(
{ "git", "ls-files", "--stage" },
{ cwd = r.worktree, silent = true }
)
if not out then
return {}
end
local matches = {}
for _, line in ipairs(util.split_lines(out)) do
local row_stage, row_path = line:match("^%S+ %S+ (%d)\t(.*)$")
if
row_stage == stage
and row_path
and row_path:sub(1, #stage_path_lead) == stage_path_lead
then
table.insert(matches, ":" .. stage .. ":" .. row_path)
end
end
return matches
end
local colon = arg_lead:find(":", 1, true)
if not colon then
local matches = {}
for _, ref in ipairs(r:list_refs()) do
if ref:sub(1, #arg_lead) == arg_lead then
table.insert(matches, ref)
end
end
return matches
end
local rev = arg_lead:sub(1, colon - 1)
local path_lead = arg_lead:sub(colon + 1)
local dir, name_lead = path_lead:match("^(.*/)([^/]*)$")
dir = dir or ""
name_lead = name_lead or path_lead
if rev ~= "" then
local cmd = { "git", "ls-tree", rev }
if dir ~= "" then
table.insert(cmd, dir)
end
local out = util.exec(cmd, { cwd = r.worktree, silent = true })
if not out then
return {}
end
local matches = {}
for _, line in ipairs(util.split_lines(out)) do
local typ, full_path = line:match("^%S+ (%S+) %S+\t(.*)$")
if typ and full_path then
local basename = dir == "" and full_path
or full_path:sub(#dir + 1)
if typ == "tree" then
basename = basename .. "/"
end
if basename:sub(1, #name_lead) == name_lead then
table.insert(matches, rev .. ":" .. dir .. basename)
end
end
end
return matches
end
local cmd = { "git", "ls-files" }
if dir ~= "" then
table.insert(cmd, dir)
end
local out = util.exec(cmd, { cwd = r.worktree, silent = true })
if not out then
return {}
end
local matches = {}
local seen = {}
for _, full_path in ipairs(util.split_lines(out)) do
local rel = dir == "" and full_path or full_path:sub(#dir + 1)
local slash = rel:find("/", 1, true)
local segment = slash and rel:sub(1, slash) or rel
if not seen[segment] and segment:sub(1, #name_lead) == name_lead then
seen[segment] = true
table.insert(matches, ":" .. dir .. segment)
end
end
return matches
end
---@param arg_lead string ---@param arg_lead string
---@param cmd_line string ---@param cmd_line string
---@return string[] ---@return string[]
+3 -3
View File
@@ -7,8 +7,8 @@ local M = {}
---@param opts { amend: boolean? }? ---@param opts { amend: boolean? }?
function M.commit(opts) function M.commit(opts)
local amend = opts and opts.amend or false local amend = opts and opts.amend or false
local _, worktree = repo.current_repo() local r = repo.find()
if not worktree then if not r then
util.warning("not in a git repository") util.warning("not in a git repository")
return return
end end
@@ -19,7 +19,7 @@ function M.commit(opts)
end end
local proxy_buf, proxy_win local proxy_buf, proxy_win
editor.run(cmd, { cwd = worktree }, function(file_path, done) editor.run(cmd, { cwd = r.worktree }, function(file_path, done)
local lines = {} local lines = {}
local f = io.open(file_path, "r") local f = io.open(file_path, "r")
if f then if f then
+14 -19
View File
@@ -62,8 +62,8 @@ end
---@param buf integer ---@param buf integer
---@param rev ow.Git.Revision ---@param rev ow.Git.Revision
local function uri_split(opts, buf, rev) local function uri_split(opts, buf, rev)
local worktree = vim.b[buf].git_worktree or select(2, repo.current_repo()) local r = repo.find(buf)
if not worktree then if not r then
util.warning("git URI buffer has no worktree") util.warning("git URI buffer has no worktree")
return return
end end
@@ -76,7 +76,7 @@ local function uri_split(opts, buf, rev)
if opts.rev and opts.rev:find(":", 1, true) then if opts.rev and opts.rev:find(":", 1, true) then
local content = util.exec( local content = util.exec(
{ "git", "cat-file", "-p", opts.rev }, { "git", "cat-file", "-p", opts.rev },
{ cwd = worktree, silent = true } { cwd = r.worktree, silent = true }
) )
if not content then if not content then
util.warning("invalid rev: %s", opts.rev) util.warning("invalid rev: %s", opts.rev)
@@ -84,7 +84,7 @@ local function uri_split(opts, buf, rev)
end end
place_pair( place_pair(
buf, buf,
object.buf_for(worktree, Revision.parse(opts.rev), content), object.buf_for(r, Revision.parse(opts.rev), content),
false, false,
opts.vertical opts.vertical
) )
@@ -92,7 +92,7 @@ local function uri_split(opts, buf, rev)
end end
if not opts.rev then if not opts.rev then
local worktree_path = vim.fs.joinpath(worktree, rev.path) local worktree_path = vim.fs.joinpath(r.worktree, rev.path)
if not vim.uv.fs_stat(worktree_path) then if not vim.uv.fs_stat(worktree_path) then
util.warning("worktree file does not exist: %s", rev.path) util.warning("worktree file does not exist: %s", rev.path)
return return
@@ -118,18 +118,13 @@ local function uri_split(opts, buf, rev)
local other_rev, left = m[1], m[2] local other_rev, left = m[1], m[2]
local content = util.exec( local content = util.exec(
{ "git", "cat-file", "-p", other_rev:format() }, { "git", "cat-file", "-p", other_rev:format() },
{ cwd = worktree, silent = true } { cwd = r.worktree, silent = true }
) )
if not content then if not content then
util.warning("invalid rev: %s", other_rev:format()) util.warning("invalid rev: %s", other_rev:format())
return return
end end
place_pair( place_pair(buf, object.buf_for(r, other_rev, content), left, opts.vertical)
buf,
object.buf_for(worktree, other_rev, content),
left,
opts.vertical
)
end end
---@class ow.Git.SplitOpts ---@class ow.Git.SplitOpts
@@ -141,7 +136,7 @@ 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)
local cur_rev = Revision.from_uri(cur_path) local cur_rev = require("git.object").parse_uri(cur_path)
if cur_rev then if cur_rev then
return uri_split(opts, cur_buf, cur_rev) return uri_split(opts, cur_buf, cur_rev)
end end
@@ -154,13 +149,13 @@ function M.split(opts)
util.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, cur_path = repo.resolve(cur_path) cur_path = vim.fn.resolve(cur_path)
if not worktree then local r = repo.resolve(cur_path)
if not r then
util.warning("not in a git repository") util.warning("not in a git repository")
return return
end end
---@cast cur_path -nil local rel = vim.fs.relpath(r.worktree, cur_path)
local rel = vim.fs.relpath(worktree, cur_path)
local rev local rev
if not opts.rev then if not opts.rev then
@@ -172,13 +167,13 @@ function M.split(opts)
end end
local content = util.exec( local content = util.exec(
{ "git", "cat-file", "-p", rev:format() }, { "git", "cat-file", "-p", rev:format() },
{ cwd = worktree, silent = true } { cwd = r.worktree, silent = true }
) )
if not content then if not content then
util.warning("invalid rev: %s", rev:format()) util.warning("invalid rev: %s", rev:format())
return return
end end
local buf = require("git.object").buf_for(worktree, rev, content) local buf = require("git.object").buf_for(r, rev, content)
place_pair(buf, cur_buf, true, opts.vertical) place_pair(buf, cur_buf, true, opts.vertical)
end end
+6 -6
View File
@@ -25,26 +25,26 @@ function M.init()
{ {
group = group, group = group,
callback = function(args) callback = function(args)
require("git.watcher").refresh_buf(args.buf) require("git.repo").refresh(args.buf)
end, end,
} }
) )
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
group = group, group = group,
callback = function(args) callback = function(args)
require("git.watcher").unregister(args.buf) require("git.repo").unregister(args.buf)
end, end,
}) })
vim.api.nvim_create_autocmd("FocusGained", { vim.api.nvim_create_autocmd("FocusGained", {
group = group, group = group,
callback = function(args) callback = function(args)
require("git.watcher").refresh_buf(args.buf) require("git.repo").refresh(args.buf)
end, end,
}) })
vim.api.nvim_create_autocmd("VimLeavePre", { vim.api.nvim_create_autocmd("VimLeavePre", {
group = group, group = group,
callback = function() callback = function()
require("git.watcher").stop_all() require("git.repo").stop_all()
end, end,
}) })
@@ -82,7 +82,7 @@ function M.init()
end end
end end
local function complete_rev(...) local function complete_rev(...)
return require("git.repo").complete_rev(...) return require("git.cmd").complete_rev(...)
end end
vim.api.nvim_create_user_command("Gdiffsplit", diff_split_cmd(true), { vim.api.nvim_create_user_command("Gdiffsplit", diff_split_cmd(true), {
nargs = "?", nargs = "?",
@@ -101,7 +101,7 @@ function M.init()
}) })
vim.api.nvim_create_user_command("Gedit", function(opts) vim.api.nvim_create_user_command("Gedit", function(opts)
vim.cmd.edit({ vim.cmd.edit({
args = { "git://" .. opts.args }, args = { require("git.object").URI_PREFIX .. opts.args },
magic = { file = false }, magic = { file = false },
}) })
end, { end, {
+43 -10
View File
@@ -3,8 +3,31 @@ local util = require("git.util")
local M = {} local M = {}
M.URI_PREFIX = "gitlog://"
local LOG_FORMAT = "%h %ad {%an}%d %s" local LOG_FORMAT = "%h %ad {%an}%d %s"
local URI_PREFIX = "gitlog://"
local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
---@param buf integer
local function attach_dispatch(buf)
vim.keymap.set("n", "<CR>", function()
local r = repo.find(buf)
-- Anchor past the leading graph chars (matches the leading sha
-- column, not any hex word that happens to appear later in the
-- subject).
local sha = r
and vim.api
.nvim_get_current_line()
:match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)")
if sha then
---@cast r -nil
require("git.object").open_object(r, sha, { split = false })
else
vim.api.nvim_feedkeys(cr, "n", false)
end
end, { buffer = buf, silent = true, desc = "Open commit" })
end
---@param worktree string ---@param worktree string
---@param max_count integer? ---@param max_count integer?
@@ -27,8 +50,12 @@ end
---@param buf integer ---@param buf integer
local function populate(buf) local function populate(buf)
local worktree = vim.b[buf].git_worktree local r = repo.find(buf)
local stdout = fetch(worktree, vim.b[buf].git_log_max_count) local state = r and r:state(buf)
if not r or not state then
return
end
local stdout = fetch(r.worktree, state.log_max_count)
if not stdout then if not stdout then
return return
end end
@@ -65,12 +92,16 @@ end
---@param buf integer ---@param buf integer
function M.read_uri(buf) function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf) local name = vim.api.nvim_buf_get_name(buf)
local worktree = name:sub(#URI_PREFIX + 1) local worktree = name:sub(#M.URI_PREFIX + 1)
if worktree == "" then if worktree == "" then
return return
end end
local r = repo.resolve(worktree)
if not r then
return
end
repo.attach(buf, r)
vim.b[buf].git_worktree = worktree
vim.bo[buf].swapfile = false vim.bo[buf].swapfile = false
vim.bo[buf].bufhidden = "hide" vim.bo[buf].bufhidden = "hide"
vim.bo[buf].buftype = "nofile" vim.bo[buf].buftype = "nofile"
@@ -78,6 +109,7 @@ function M.read_uri(buf)
vim.bo[buf].filetype = "gitlog" vim.bo[buf].filetype = "gitlog"
end end
attach_dispatch(buf)
populate(buf) populate(buf)
end end
@@ -92,15 +124,16 @@ M.opt_parsers = {
---@param opts ow.Git.LogOpts? ---@param opts ow.Git.LogOpts?
function M.open(opts) function M.open(opts)
opts = opts or {} opts = opts or {}
local _, worktree = repo.current_repo() local r = repo.find()
if not worktree then if not r then
util.warning("not in a git repository") util.warning("not in a git repository")
return return
end end
local buf = vim.fn.bufadd(URI_PREFIX .. worktree) local buf = vim.fn.bufadd(M.URI_PREFIX .. r.worktree)
vim.b[buf].git_worktree = worktree repo.attach(buf, r)
vim.b[buf].git_log_max_count = opts.max_count local state = r:state(buf) --[[@as -nil]]
state.log_max_count = opts.max_count
local was_loaded = vim.api.nvim_buf_is_loaded(buf) local was_loaded = vim.api.nvim_buf_is_loaded(buf)
local win = vim.fn.bufwinid(buf) local win = vim.fn.bufwinid(buf)
+96 -75
View File
@@ -4,27 +4,29 @@ local util = require("git.util")
local M = {} local M = {}
M.URI_PREFIX = "git://"
---@param rev ow.Git.Revision
---@return string
function M.format_uri(rev)
return M.URI_PREFIX .. rev:format()
end
---@param str string
---@return ow.Git.Revision?
function M.parse_uri(str)
local raw = str:match("^" .. M.URI_PREFIX .. "(.+)$")
if raw then
return Revision.parse(raw)
end
end
---@class ow.Git.DiffSection ---@class ow.Git.DiffSection
---@field path_a string ---@field path_a string
---@field path_b string ---@field path_b string
---@field blob_a string? ---@field blob_a string?
---@field blob_b string? ---@field blob_b string?
---@class ow.Git.BufContext
---@field worktree string
---@field sha string
---@field parent_sha string?
---@return ow.Git.BufContext?
local function context()
local worktree = vim.b.git_worktree
local sha = vim.b.git_sha
if not worktree or not sha then
return nil
end
return { worktree = worktree, sha = sha, parent_sha = vim.b.git_parent_sha }
end
---@return ow.Git.DiffSection? ---@return ow.Git.DiffSection?
local function diff_section() local function diff_section()
local diff_lnum = vim.fn.search("^diff --git ", "bcnW") local diff_lnum = vim.fn.search("^diff --git ", "bcnW")
@@ -70,9 +72,9 @@ local function is_zero(sha)
end end
---@param buf integer ---@param buf integer
---@param worktree string ---@param r ow.Git.Repo
---@param path string ---@param path string
local function attach_index_writer(buf, worktree, path) local function attach_index_writer(buf, r, path)
vim.api.nvim_create_autocmd("BufWriteCmd", { vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = buf, buffer = buf,
callback = function() callback = function()
@@ -82,18 +84,19 @@ local function attach_index_writer(buf, worktree, path)
) .. "\n" ) .. "\n"
local hash_stdout = util.exec( local hash_stdout = util.exec(
{ "git", "hash-object", "-w", "--stdin" }, { "git", "hash-object", "-w", "--stdin" },
{ cwd = worktree, stdin = body } { cwd = r.worktree, stdin = body }
) )
if not hash_stdout then if not hash_stdout then
return return
end end
local sha = vim.trim(hash_stdout) local sha = vim.trim(hash_stdout)
local mode = vim.b[buf].git_index_mode local state = r:state(buf)
local mode = state and state.index_mode
if not mode then if not mode then
mode = "100644" mode = "100644"
local ls = util.exec( local ls = util.exec(
{ "git", "ls-files", "-s", "--", path }, { "git", "ls-files", "-s", "--", path },
{ cwd = worktree, silent = true } { cwd = r.worktree, silent = true }
) )
if ls then if ls then
local m = ls:match("^(%d+)") local m = ls:match("^(%d+)")
@@ -101,7 +104,9 @@ local function attach_index_writer(buf, worktree, path)
mode = m mode = m
end end
end end
vim.b[buf].git_index_mode = mode if state then
state.index_mode = mode
end
end end
-- Use the 3-arg form (mode sha path) instead of the comma -- Use the 3-arg form (mode sha path) instead of the comma
-- form (mode,sha,path), which doesn't survive paths -- form (mode,sha,path), which doesn't survive paths
@@ -114,7 +119,7 @@ local function attach_index_writer(buf, worktree, path)
mode, mode,
sha, sha,
path, path,
}, { cwd = worktree }) }, { cwd = r.worktree })
then then
return return
end end
@@ -123,18 +128,27 @@ local function attach_index_writer(buf, worktree, path)
}) })
end end
---@type table<integer, string> local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
local pending_content = {}
---@param worktree string ---@param buf integer
function M.attach_dispatch(buf)
vim.keymap.set("n", "<CR>", function()
if not M.open_under_cursor() then
vim.api.nvim_feedkeys(cr, "n", false)
end
end, { buffer = buf, silent = true, desc = "Open file at commit" })
end
---@param r ow.Git.Repo
---@param rev ow.Git.Revision ---@param rev ow.Git.Revision
---@param content string? ---@param content string?
---@return integer ---@return integer
function M.buf_for(worktree, rev, content) function M.buf_for(r, rev, content)
local buf = vim.fn.bufadd(rev:uri()) local buf = vim.fn.bufadd(M.format_uri(rev))
vim.b[buf].git_worktree = worktree repo.attach(buf, r)
if content then if content then
pending_content[buf] = content local state = r:state(buf) --[[@as -nil]]
state.pending_content = content
end end
vim.fn.bufload(buf) vim.fn.bufload(buf)
return buf return buf
@@ -143,35 +157,43 @@ end
---@param buf integer ---@param buf integer
function M.read_uri(buf) function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf) local name = vim.api.nvim_buf_get_name(buf)
local rev = Revision.from_uri(name) local rev = M.parse_uri(name)
if not rev then if not rev then
return return
end end
local rev_str = rev:format() local rev_str = rev:format()
local worktree = vim.b[buf].git_worktree or select(2, repo.current_repo()) local r = repo.find(buf)
if not worktree then if not r then
util.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 repo.attach(buf, r)
local state = r:state(buf) --[[@as -nil]]
vim.bo[buf].swapfile = false vim.bo[buf].swapfile = false
vim.bo[buf].bufhidden = "hide" vim.bo[buf].bufhidden = "hide"
---@type string? ---@type string?
local stdout = pending_content[buf] local stdout = state.pending_content
pending_content[buf] = nil state.pending_content = nil
-- On a refresh tick (no caller-provided content), skip the re-read
-- when the rev still resolves to the same sha. Avoids re-firing
-- BufReadPost (and the LSP/treesitter re-attach storm) on every
-- fs-event for buffers whose content can't have changed.
if stdout == nil then if stdout == nil then
local rev_sha = r:rev_parse(rev_str, true)
if rev_sha and rev_sha == state.sha then
return
end
stdout = util.exec( stdout = util.exec(
{ "git", "cat-file", "-p", rev_str }, { "git", "cat-file", "-p", rev_str },
{ cwd = worktree } { cwd = r.worktree }
) )
end end
if stdout and rev.path == nil then if stdout and rev.path == nil then
local commit_sha = local commit_sha = r:rev_parse(rev_str .. "^{commit}", true)
repo.rev_parse(worktree, rev_str .. "^{commit}", true)
if commit_sha then if commit_sha then
local patch = util.exec({ local patch = util.exec({
"git", "git",
@@ -182,12 +204,11 @@ function M.read_uri(buf)
"--root", "--root",
"--no-commit-id", "--no-commit-id",
commit_sha, commit_sha,
}, { cwd = worktree }) }, { cwd = r.worktree })
if patch then if patch then
stdout = (stdout:gsub("\n*$", "\n\n")) .. patch stdout = (stdout:gsub("\n*$", "\n\n")) .. patch
end end
vim.b[buf].git_parent_sha = state.parent_sha = r:rev_parse(commit_sha .. "^", true)
repo.rev_parse(worktree, commit_sha .. "^", true)
end end
end end
@@ -196,16 +217,13 @@ function M.read_uri(buf)
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
local rev_sha = repo.rev_parse(worktree, rev_str, true) state.sha = r:rev_parse(rev_str, true)
if rev_sha then
vim.b[buf].git_sha = rev_sha
end
if rev.stage == 0 and rev.path then if rev.stage == 0 and rev.path then
vim.bo[buf].buftype = "acwrite" vim.bo[buf].buftype = "acwrite"
if not vim.b[buf].git_index_writer then if not state.index_writer then
attach_index_writer(buf, worktree, rev.path) attach_index_writer(buf, r, rev.path)
vim.b[buf].git_index_writer = true state.index_writer = true
end end
else else
vim.bo[buf].buftype = "nofile" vim.bo[buf].buftype = "nofile"
@@ -226,27 +244,29 @@ function M.read_uri(buf)
vim.bo[buf].filetype = "git" vim.bo[buf].filetype = "git"
end end
M.attach_dispatch(buf)
vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf })
end end
---@param worktree string ---@param r ow.Git.Repo
---@param blob string? ---@param blob string?
---@param path string ---@param path string
---@param sha string ---@param sha string
---@return integer? ---@return integer?
local function blob_buf(worktree, blob, path, sha) local function blob_buf(r, blob, path, sha)
if is_zero(blob) then if is_zero(blob) then
return nil return nil
end end
return M.buf_for(worktree, Revision.new({ base = sha, path = path })) return M.buf_for(r, Revision.new({ base = sha, path = path }))
end end
---@param worktree string ---@param r ow.Git.Repo
---@param blob string? ---@param blob string?
---@param path string ---@param path string
---@param sha string ---@param sha string
local function load_blob(worktree, blob, path, sha) local function load_blob(r, blob, path, sha)
local buf = blob_buf(worktree, blob, path, sha) local buf = blob_buf(r, blob, path, sha)
if not buf then if not buf then
util.warning("no content for %s at %s", path, sha) util.warning("no content for %s at %s", path, sha)
return return
@@ -255,17 +275,17 @@ local function load_blob(worktree, blob, path, sha)
vim.api.nvim_set_current_buf(buf) vim.api.nvim_set_current_buf(buf)
end end
---@param ctx ow.Git.BufContext ---@param s ow.Git.BufState
---@param section ow.Git.DiffSection ---@param section ow.Git.DiffSection
local function open_section(ctx, section) local function open_section(s, section)
if not section.blob_a or not section.blob_b then if not section.blob_a or not section.blob_b then
util.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_sha or "0" local parent = s.parent_sha or "0"
local left = blob_buf(ctx.worktree, section.blob_a, section.path_a, parent) local left = blob_buf(s.repo, section.blob_a, section.path_a, parent)
local right = local right =
blob_buf(ctx.worktree, section.blob_b, section.path_b, ctx.sha) blob_buf(s.repo, section.blob_b, section.path_b, s.sha --[[@as string]])
if left and right then if left and right then
require("git.diff").open(left, right, true) require("git.diff").open(left, right, true)
return return
@@ -283,44 +303,45 @@ end
---@class ow.Git.OpenObjectOpts ---@class ow.Git.OpenObjectOpts
---@field split (false|"above"|"below"|"left"|"right")? ---@field split (false|"above"|"below"|"left"|"right")?
---@param worktree string ---@param r ow.Git.Repo
---@param rev string ---@param rev string
---@param opts ow.Git.OpenObjectOpts? ---@param opts ow.Git.OpenObjectOpts?
function M.open_object(worktree, rev, opts) function M.open_object(r, rev, opts)
local parsed = Revision.parse(rev) local parsed = Revision.parse(rev)
if parsed.base then if parsed.base then
local sha = repo.rev_parse(worktree, parsed.base, true) local sha = r:rev_parse(parsed.base, true)
if sha then if sha then
parsed.base = sha parsed.base = sha
end end
end end
local content = util.exec( local content = util.exec(
{ "git", "cat-file", "-p", parsed:format() }, { "git", "cat-file", "-p", parsed:format() },
{ cwd = worktree, silent = true } { cwd = r.worktree, silent = true }
) )
if not content then if not content then
util.warning("not a git object: %s", rev) util.warning("not a git object: %s", rev)
return return
end end
local buf = M.buf_for(worktree, parsed, content) local buf = M.buf_for(r, parsed, content)
util.place_buf(buf, opts and opts.split) util.place_buf(buf, opts and opts.split)
end end
---@return boolean dispatched ---@return boolean dispatched
function M.open_under_cursor() function M.open_under_cursor()
local ctx = context() local s = repo.state()
if not ctx then if not s or not s.sha then
return false return false
end end
local line = vim.api.nvim_get_current_line() local line = vim.api.nvim_get_current_line()
local r = s.repo
local sha = line:match("^commit (%x+)$") local sha = line:match("^commit (%x+)$")
or line:match("^parent (%x+)$") or line:match("^parent (%x+)$")
or line:match("^tree (%x+)$") or line:match("^tree (%x+)$")
or line:match("^object (%x+)$") or line:match("^object (%x+)$")
if sha then if sha then
M.open_object(ctx.worktree, sha, { split = false }) M.open_object(r, sha, { split = false })
return true return true
end end
@@ -328,9 +349,9 @@ function M.open_under_cursor()
line:match("^%d+ (%w+) (%x+)\t(.+)$") line:match("^%d+ (%w+) (%x+)\t(.+)$")
if entry_sha then if entry_sha then
local nav_rev = entry_type == "blob" local nav_rev = entry_type == "blob"
and Revision.new({ base = ctx.sha, path = entry_name }):format() and Revision.new({ base = s.sha, path = entry_name }):format()
or entry_sha or entry_sha
M.open_object(ctx.worktree, nav_rev, { split = false }) M.open_object(r, nav_rev, { split = false })
return true return true
end end
@@ -338,26 +359,26 @@ function M.open_under_cursor()
if not section then if not section then
return false return false
end end
local parent = ctx.parent_sha or "0" local parent = s.parent_sha or "0"
if line:match("^diff %-%-git ") then if line:match("^diff %-%-git ") then
open_section(ctx, section) open_section(s, section)
return true return true
end end
if line:match("^%-%-%- ") then if line:match("^%-%-%- ") then
load_blob(ctx.worktree, section.blob_a, section.path_a, parent) load_blob(r, section.blob_a, section.path_a, parent)
return true return true
end end
if line:match("^%+%+%+ ") then if line:match("^%+%+%+ ") then
load_blob(ctx.worktree, section.blob_b, section.path_b, ctx.sha) load_blob(r, section.blob_b, section.path_b, s.sha --[[@as string]])
return true return true
end end
local prefix = line:sub(1, 1) local prefix = line:sub(1, 1)
if prefix == "+" then if prefix == "+" then
load_blob(ctx.worktree, section.blob_b, section.path_b, ctx.sha) load_blob(r, section.blob_b, section.path_b, s.sha --[[@as string]])
return true return true
elseif prefix == "-" then elseif prefix == "-" then
load_blob(ctx.worktree, section.blob_a, section.path_a, parent) load_blob(r, section.blob_a, section.path_a, parent)
return true return true
end end
return false return false
+338 -154
View File
@@ -1,61 +1,205 @@
local status = require("git.status")
local util = require("git.util") local util = require("git.util")
local M = {} ---@param buf integer?
---@return integer
---@param path string local function expand_buf(buf)
---@return string? gitdir if not buf or buf == 0 then
---@return string? worktree return vim.api.nvim_get_current_buf()
---@return string? path
function M.resolve(path)
path = vim.fn.resolve(path)
local found = vim.fs.find(".git", { upward = true, path = path })[1]
if not found then
return nil
end end
local worktree = vim.fs.dirname(found) return buf
local stat = vim.uv.fs_stat(found)
if not stat then
return nil
end
if stat.type == "directory" then
return found, worktree, path
end
local f = io.open(found, "r")
if not f then
return nil
end
local content = f:read("*a")
f:close()
local gitdir = content:match("gitdir:%s*(%S+)")
if not gitdir then
util.warning(".git file at %s has no `gitdir:` line", found)
return nil
end
if not gitdir:match("^/") then
gitdir = vim.fs.joinpath(worktree, gitdir)
end
return vim.fs.normalize(gitdir), worktree, path
end end
---@return string? gitdir ---@class ow.Git.BufState
---@return string? worktree ---@field repo ow.Git.Repo
function M.current_repo() ---@field sha string?
local path = vim.api.nvim_buf_get_name(0) ---@field parent_sha string?
if path == "" or path:match("^%a+://") then ---@field index_writer boolean?
path = vim.fn.getcwd() ---@field index_mode string?
---@field log_max_count integer?
---@field pending_content string?
---@class ow.Git.Repo
---@field gitdir string
---@field worktree string
---@field buffers table<integer, ow.Git.BufState>
---@field watcher? uv.uv_fs_event_t
---@field refresh fun(self: ow.Git.Repo)
---@field refresh_handle ow.Git.Util.DebounceHandle
---@field private refresh_listeners (fun(r: ow.Git.Repo, porcelain_stdout: string?))[]
local Repo = {}
Repo.__index = Repo
---@param r ow.Git.Repo
local function do_refresh(r)
vim.system(
{
"git",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
},
{ cwd = r.worktree, text = true },
vim.schedule_wrap(function(obj)
local statuses = {}
if obj.code == 0 then
for line in (obj.stdout or ""):gmatch("[^\r\n]+") do
if line:sub(1, 2) ~= "##" then
local code = line:sub(1, 2)
local x = code:sub(1, 1)
local y = code:sub(2, 2)
local path_part = line:sub(4)
if x == "R" or x == "C" or y == "R" or y == "C" then
local arrow = path_part:find(" -> ", 1, true)
if arrow then
path_part = path_part:sub(arrow + 4)
end
end
statuses[vim.fs.joinpath(r.worktree, path_part)] =
status.format(code)
end
end
else
util.warning(
"git status failed: %s",
vim.trim(obj.stderr or "")
)
end
local dirty = false
for buf in pairs(r.buffers) do
if not vim.api.nvim_buf_is_valid(buf) then
r.buffers[buf] = nil
else
local name = vim.api.nvim_buf_get_name(buf)
local object = require("git.object")
local log = require("git.log")
if name:sub(1, #object.URI_PREFIX) == object.URI_PREFIX then
object.read_uri(buf)
elseif name:sub(1, #log.URI_PREFIX) == log.URI_PREFIX then
log.read_uri(buf)
else
local s = statuses[vim.fn.resolve(name)]
if vim.b[buf].git_status ~= s then
vim.b[buf].git_status = s
dirty = true
end
end
end
end
if dirty then
vim.cmd.redrawstatus({ bang = true })
end
r:notify_refresh(obj.code == 0 and obj.stdout or nil)
end)
)
end
---@param gitdir string
---@param worktree string
---@return ow.Git.Repo
function Repo.new(gitdir, worktree)
local self = setmetatable({
gitdir = gitdir,
worktree = worktree,
buffers = {},
refresh_listeners = {},
}, Repo)
self.refresh, self.refresh_handle = util.debounce(do_refresh, 50)
self:start_watcher()
return self
end
function Repo:start_watcher()
local watcher, err = vim.uv.new_fs_event()
if not watcher then
util.warning(
"git: failed to create fs_event for %s: %s",
self.gitdir,
err
)
return
end
local ok, err = watcher:start(
self.gitdir,
{ recursive = true },
function(err_, filename)
if
err_
or filename:match("^objects/")
or filename:match("^logs/")
then
return
end
self:refresh()
end
)
if not ok then
util.warning("failed to watch %s: %s", self.gitdir, tostring(err))
watcher:close()
return
end
self.watcher = watcher
end
function Repo:stop_watcher()
if self.watcher then
self.watcher:stop()
self.watcher:close()
self.watcher = nil
end
self.refresh_handle.close()
end
---@param buf integer
function Repo:add_buffer(buf)
buf = expand_buf(buf)
if not self.buffers[buf] then
self.buffers[buf] = { repo = self }
end
end
---@param buf integer
function Repo:remove_buffer(buf)
self.buffers[expand_buf(buf)] = nil
end
---@param buf integer
---@return ow.Git.BufState?
function Repo:state(buf)
return self.buffers[expand_buf(buf)]
end
---@return boolean
function Repo:has_buffers()
return next(self.buffers) ~= nil
end
---@param fn fun(r: ow.Git.Repo, porcelain_stdout: string?)
---@return fun() unsubscribe
function Repo:on_refresh(fn)
table.insert(self.refresh_listeners, fn)
return function()
for i, f in ipairs(self.refresh_listeners) do
if f == fn then
table.remove(self.refresh_listeners, i)
return
end
end
end
end
---@param porcelain_stdout string?
function Repo:notify_refresh(porcelain_stdout)
for _, fn in ipairs(self.refresh_listeners) do
fn(self, porcelain_stdout)
end end
local gitdir, worktree, _ = M.resolve(path)
return gitdir, worktree
end end
---@param path string
---@return string? ---@return string?
function M.head(path) function Repo:head()
local gitdir = M.resolve(path) local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r")
if not gitdir then
return nil
end
local f = io.open(vim.fs.joinpath(gitdir, "HEAD"), "r")
if not f then if not f then
return nil return nil
end end
@@ -75,9 +219,8 @@ function M.head(path)
return nil return nil
end end
---@param worktree string
---@return string[] ---@return string[]
function M.list_refs(worktree) function Repo:list_refs()
local out = util.exec({ local out = util.exec({
"git", "git",
"for-each-ref", "for-each-ref",
@@ -85,7 +228,7 @@ function M.list_refs(worktree)
"refs/heads", "refs/heads",
"refs/tags", "refs/tags",
"refs/remotes", "refs/remotes",
}, { cwd = worktree, silent = true }) }, { cwd = self.worktree, silent = true })
if not out then if not out then
return {} return {}
end end
@@ -94,118 +237,159 @@ function M.list_refs(worktree)
return refs return refs
end end
---@param arg_lead string
---@return string[]
function M.complete_rev(arg_lead)
local _, worktree = M.current_repo()
if not worktree then
return {}
end
local stage, stage_path_lead = arg_lead:match("^:([0-3]):(.*)$")
if stage then
local out = util.exec(
{ "git", "ls-files", "--stage" },
{ cwd = worktree, silent = true }
)
if not out then
return {}
end
local matches = {}
for _, line in ipairs(util.split_lines(out)) do
local row_stage, row_path = line:match("^%S+ %S+ (%d)\t(.*)$")
if
row_stage == stage
and row_path
and row_path:sub(1, #stage_path_lead) == stage_path_lead
then
table.insert(matches, ":" .. stage .. ":" .. row_path)
end
end
return matches
end
local colon = arg_lead:find(":", 1, true)
if not colon then
local matches = {}
for _, ref in ipairs(M.list_refs(worktree)) do
if ref:sub(1, #arg_lead) == arg_lead then
table.insert(matches, ref)
end
end
return matches
end
local rev = arg_lead:sub(1, colon - 1)
local path_lead = arg_lead:sub(colon + 1)
local dir, name_lead = path_lead:match("^(.*/)([^/]*)$")
dir = dir or ""
name_lead = name_lead or path_lead
if rev ~= "" then
local cmd = { "git", "ls-tree", rev }
if dir ~= "" then
table.insert(cmd, dir)
end
local out = util.exec(cmd, { cwd = worktree, silent = true })
if not out then
return {}
end
local matches = {}
for _, line in ipairs(util.split_lines(out)) do
local typ, full_path = line:match("^%S+ (%S+) %S+\t(.*)$")
if typ and full_path then
local basename = dir == "" and full_path
or full_path:sub(#dir + 1)
if typ == "tree" then
basename = basename .. "/"
end
if basename:sub(1, #name_lead) == name_lead then
table.insert(matches, rev .. ":" .. dir .. basename)
end
end
end
return matches
end
local cmd = { "git", "ls-files" }
if dir ~= "" then
table.insert(cmd, dir)
end
local out = util.exec(cmd, { cwd = worktree, silent = true })
if not out then
return {}
end
local matches = {}
local seen = {}
for _, full_path in ipairs(util.split_lines(out)) do
local rel = dir == "" and full_path or full_path:sub(#dir + 1)
local slash = rel:find("/", 1, true)
local segment = slash and rel:sub(1, slash) or rel
if
not seen[segment]
and segment:sub(1, #name_lead) == name_lead
then
seen[segment] = true
table.insert(matches, ":" .. dir .. segment)
end
end
return matches
end
---@param worktree string
---@param rev string ---@param rev string
---@param short boolean ---@param short boolean
---@return string? ---@return string?
function M.rev_parse(worktree, rev, short) function Repo:rev_parse(rev, short)
local cmd = { "git", "rev-parse", "--verify", "--quiet" } local cmd = { "git", "rev-parse", "--verify", "--quiet" }
if short then if short then
table.insert(cmd, "--short") table.insert(cmd, "--short")
end end
table.insert(cmd, rev) table.insert(cmd, rev)
local stdout = util.exec(cmd, { cwd = worktree, silent = true }) local stdout = util.exec(cmd, { cwd = self.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
local M = {}
---@type table<string, ow.Git.Repo>
local repo_by_gitdir = {}
---@type table<integer, ow.Git.Repo>
local repo_by_buf = {}
---@param path string
---@return ow.Git.Repo?
function M.resolve(path)
path = vim.fn.resolve(path)
local found = vim.fs.find(".git", { upward = true, path = path })[1]
if not found then
return nil
end
local stat = vim.uv.fs_stat(found)
if not stat then
return nil
end
local worktree = vim.fs.dirname(found)
local gitdir
if stat.type == "directory" then
gitdir = found
else
local f = io.open(found, "r")
if not f then
return nil
end
local content = f:read("*a")
f:close()
local rel = content:match("gitdir:%s*(%S+)")
if not rel then
util.warning(".git file at %s has no `gitdir:` line", found)
return nil
end
if rel:match("^/") then
gitdir = rel
else
gitdir = vim.fs.joinpath(worktree, rel)
end
gitdir = vim.fs.normalize(gitdir)
end
local r = repo_by_gitdir[gitdir]
if not r then
r = Repo.new(gitdir, worktree)
repo_by_gitdir[gitdir] = r
end
return r
end
---@param buf integer?
---@return ow.Git.Repo?
function M.find(buf)
buf = expand_buf(buf)
local existing = repo_by_buf[buf]
if existing then
return existing
end
local path = vim.api.nvim_buf_get_name(buf)
if path == "" or path:match("^%a+://") then
path = vim.fn.getcwd()
end
return M.resolve(path)
end
---@param buf integer?
---@return ow.Git.BufState?
function M.state(buf)
buf = expand_buf(buf)
local r = repo_by_buf[buf]
return r and r.buffers[buf]
end
---@param buf integer
---@param r ow.Git.Repo
function M.attach(buf, r)
buf = expand_buf(buf)
if repo_by_buf[buf] == r then
return
end
if repo_by_buf[buf] then
repo_by_buf[buf]:remove_buffer(buf)
end
r:add_buffer(buf)
repo_by_buf[buf] = r
end
---@param buf integer
---@return ow.Git.Repo?
function M.register(buf)
buf = expand_buf(buf)
local r = M.find(buf)
if not r then
return nil
end
if repo_by_buf[buf] ~= r then
M.attach(buf, r)
end
return r
end
---@param buf integer
function M.unregister(buf)
buf = expand_buf(buf)
local r = repo_by_buf[buf]
if not r then
return
end
repo_by_buf[buf] = nil
r:remove_buffer(buf)
if not r:has_buffers() then
r:stop_watcher()
repo_by_gitdir[r.gitdir] = nil
end
end
---@param buf integer
function M.refresh(buf)
buf = expand_buf(buf)
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
return
end
local path = vim.api.nvim_buf_get_name(buf)
if path == "" or path:match("^%a+://") then
return
end
local r = M.register(buf)
if not r then
vim.b[buf].git_status = nil
return
end
r:refresh()
end
function M.stop_all()
for _, r in pairs(repo_by_gitdir) do
r:stop_watcher()
end
end
return M return M
-16
View File
@@ -5,8 +5,6 @@
local Revision = {} local Revision = {}
Revision.__index = Revision Revision.__index = Revision
local URI_PREFIX = "git://"
---@return string ---@return string
function Revision:format() function Revision:format()
if self.stage then if self.stage then
@@ -17,11 +15,6 @@ function Revision:format()
return self.base or error("Revision:format: empty Revision") return self.base or error("Revision:format: empty Revision")
end end
---@return string
function Revision:uri()
return URI_PREFIX .. self:format()
end
---@param parts { stage?: integer, base?: string, path?: string } ---@param parts { stage?: integer, base?: string, path?: string }
---@return ow.Git.Revision ---@return ow.Git.Revision
function Revision.new(parts) function Revision.new(parts)
@@ -49,13 +42,4 @@ function Revision.parse(str)
return Revision.new({ base = str }) return Revision.new({ base = str })
end 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 return Revision
+32 -45
View File
@@ -32,14 +32,13 @@ local SIDEBAR_WIDTH = 50
---@alias ow.Git.SidebarEntry ow.Git.FileEntry | ow.Git.CommitEntry ---@alias ow.Git.SidebarEntry ow.Git.FileEntry | ow.Git.CommitEntry
---@class ow.Git.SidebarState ---@class ow.Git.SidebarState
---@field gitdir string ---@field repo ow.Git.Repo
---@field worktree string
---@field lines table<integer, ow.Git.SidebarEntry> ---@field lines table<integer, ow.Git.SidebarEntry>
---@field sidebar_win integer? ---@field sidebar_win integer?
---@field invocation_win integer? ---@field invocation_win integer?
---@field diff_left_win integer? ---@field diff_left_win integer?
---@field diff_right_win integer? ---@field diff_right_win integer?
---@field user_aucmd integer? ---@field unsubscribe fun()?
---@field last_shown_key string? ---@field last_shown_key string?
---@field last_render_key string? ---@field last_render_key string?
@@ -407,7 +406,7 @@ local function refresh(bufnr, prefetched_stdout)
end end
end end
fetch_status(s.worktree, prefetched_stdout, function(branch, groups) fetch_status(s.repo.worktree, prefetched_stdout, function(branch, groups)
if not vim.api.nvim_buf_is_valid(bufnr) then if not vim.api.nvim_buf_is_valid(bufnr) then
return return
end end
@@ -456,22 +455,22 @@ end
---@field left ow.Git.DiffSide ---@field left ow.Git.DiffSide
---@field right ow.Git.DiffSide ---@field right ow.Git.DiffSide
---@param worktree string ---@param r ow.Git.Repo
---@param path string ---@param path string
---@return ow.Git.DiffSide ---@return ow.Git.DiffSide
local function head_pane(worktree, path) local function head_pane(r, path)
local rev = Revision.new({ base = "HEAD", path = path }) local rev = Revision.new({ base = "HEAD", path = path })
return { return {
buf = object.buf_for(worktree, rev), buf = object.buf_for(r, rev),
name = rev:uri(), name = object.format_uri(rev),
} }
end end
---@param worktree string ---@param r ow.Git.Repo
---@param path string ---@param path string
---@return ow.Git.DiffSide ---@return ow.Git.DiffSide
local function worktree_pane(worktree, path) local function worktree_pane(r, path)
local buf = vim.fn.bufadd(vim.fs.joinpath(worktree, path)) local buf = vim.fn.bufadd(vim.fs.joinpath(r.worktree, path))
vim.fn.bufload(buf) vim.fn.bufload(buf)
return { buf = buf, name = nil } return { buf = buf, name = nil }
end end
@@ -482,8 +481,8 @@ end
local function index_pane(s, entry) local function index_pane(s, entry)
local rev = Revision.new({ stage = 0, path = entry.path }) local rev = Revision.new({ stage = 0, path = entry.path })
return { return {
buf = object.buf_for(s.worktree, rev), buf = object.buf_for(s.repo, rev),
name = rev:uri(), name = object.format_uri(rev),
} }
end end
@@ -495,7 +494,7 @@ local function older_pane(s, entry)
if entry.x == "A" then if entry.x == "A" then
return nil return nil
end end
return head_pane(s.worktree, entry.orig or entry.path) return head_pane(s.repo, entry.orig or entry.path)
end end
if entry.section == "Unstaged" then if entry.section == "Unstaged" then
return index_pane(s, entry) return index_pane(s, entry)
@@ -517,10 +516,10 @@ local function newer_pane(s, entry)
if entry.y == "D" then if entry.y == "D" then
return nil return nil
end end
return worktree_pane(s.worktree, entry.path) return worktree_pane(s.repo, entry.path)
end end
if entry.section == "Untracked" then if entry.section == "Untracked" then
return worktree_pane(s.worktree, entry.path) return worktree_pane(s.repo, entry.path)
end end
return nil return nil
end end
@@ -695,8 +694,9 @@ local function view_entry(s, entry, focus_left)
+ vim.api.nvim_win_get_width(right_win) + vim.api.nvim_win_get_width(right_win)
vim.api.nvim_win_set_width(left_win, math.floor(combined / 2)) vim.api.nvim_win_set_width(left_win, math.floor(combined / 2))
end end
---@cast left_win -nil
---@cast right_win -nil
assert(left_win and right_win, "diff windows must be set")
vim.w[left_win].git_diff_role = "left" vim.w[left_win].git_diff_role = "left"
vim.w[right_win].git_diff_role = "right" vim.w[right_win].git_diff_role = "right"
s.diff_left_win = left_win s.diff_left_win = left_win
@@ -728,7 +728,7 @@ local function action_stage()
end end
vim.system( vim.system(
{ "git", "add", "--", entry.path }, { "git", "add", "--", entry.path },
{ cwd = s.worktree }, { cwd = s.repo.worktree },
vim.schedule_wrap(function(obj) vim.schedule_wrap(function(obj)
if obj.code ~= 0 then if obj.code ~= 0 then
util.error("git add failed: %s", vim.trim(obj.stderr or "")) util.error("git add failed: %s", vim.trim(obj.stderr or ""))
@@ -753,7 +753,7 @@ local function action_unstage()
table.insert(cmd, entry.path) table.insert(cmd, entry.path)
vim.system( vim.system(
cmd, cmd,
{ cwd = s.worktree }, { cwd = s.repo.worktree },
vim.schedule_wrap(function(obj) vim.schedule_wrap(function(obj)
if obj.code ~= 0 then if obj.code ~= 0 then
util.error( util.error(
@@ -785,7 +785,7 @@ local function action_discard()
entry.path entry.path
) )
action = function() action = function()
local target = vim.fs.joinpath(s.worktree, entry.path) local target = vim.fs.joinpath(s.repo.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
util.error("failed to delete %s", entry.path) util.error("failed to delete %s", entry.path)
@@ -797,7 +797,7 @@ local function action_discard()
action = function() action = function()
vim.system( vim.system(
{ "git", "checkout", "--", entry.path }, { "git", "checkout", "--", entry.path },
{ cwd = s.worktree }, { cwd = s.repo.worktree },
vim.schedule_wrap(function(obj) vim.schedule_wrap(function(obj)
if obj.code ~= 0 then if obj.code ~= 0 then
util.error( util.error(
@@ -829,20 +829,14 @@ local function action_help()
}, "\n")) }, "\n"))
end end
---@param worktree string ---@param r ow.Git.Repo
local function open(worktree) local function open(r)
local existing = find_sidebar() local existing = find_sidebar()
if existing then if existing then
vim.api.nvim_set_current_win(existing) vim.api.nvim_set_current_win(existing)
return return
end end
local gitdir, worktree = repo.resolve(worktree)
if not gitdir then
return
end
---@cast worktree -nil
local previous_win = vim.api.nvim_get_current_win() local previous_win = vim.api.nvim_get_current_win()
local bufnr, win = util.new_scratch({ split = "left" }) local bufnr, win = util.new_scratch({ split = "left" })
vim.bo[bufnr].filetype = "gitsidebar" vim.bo[bufnr].filetype = "gitsidebar"
@@ -856,8 +850,7 @@ local function open(worktree)
vim.api.nvim_win_set_width(win, SIDEBAR_WIDTH) vim.api.nvim_win_set_width(win, SIDEBAR_WIDTH)
state[bufnr] = { state[bufnr] = {
gitdir = gitdir, repo = r,
worktree = worktree,
lines = {}, lines = {},
sidebar_win = win, sidebar_win = win,
invocation_win = previous_win, invocation_win = previous_win,
@@ -882,15 +875,9 @@ local function open(worktree)
k("X", action_discard, "Discard worktree changes") k("X", action_discard, "Discard worktree changes")
k("g?", action_help, "Help") k("g?", action_help, "Help")
state[bufnr].user_aucmd = vim.api.nvim_create_autocmd("User", { state[bufnr].unsubscribe = r:on_refresh(function(_, porcelain_stdout)
pattern = "GitRefresh", refresh(bufnr, porcelain_stdout)
group = group, end)
callback = function(args)
if args.data and args.data.gitdir == gitdir then
refresh(bufnr, args.data.porcelain_stdout)
end
end,
})
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
buffer = bufnr, buffer = bufnr,
group = group, group = group,
@@ -899,8 +886,8 @@ local function open(worktree)
if not s then if not s then
return return
end end
if s.user_aucmd then if s.unsubscribe then
pcall(vim.api.nvim_del_autocmd, s.user_aucmd) s.unsubscribe()
end end
state[bufnr] = nil state[bufnr] = nil
end, end,
@@ -916,12 +903,12 @@ function M.toggle()
vim.api.nvim_win_close(sidebar_win, false) vim.api.nvim_win_close(sidebar_win, false)
return return
end end
local _, worktree = repo.current_repo() local r = repo.find()
if not worktree then if not r then
util.warning("not in a git repository") util.warning("not in a git repository")
return return
end end
open(worktree) open(r)
end end
return M return M
+14 -1
View File
@@ -101,7 +101,20 @@ end
---@param delay integer ---@param delay integer
---@return F, ow.Git.Util.DebounceHandle ---@return F, ow.Git.Util.DebounceHandle
function M.debounce(fn, delay) function M.debounce(fn, delay)
local timer = assert(vim.uv.new_timer()) local timer, err = vim.uv.new_timer()
if not timer then
M.warning("git: failed to create timer: %s", err)
local noop = function() end
return fn,
{
cancel = noop,
flush = noop,
pending = function()
return false
end,
close = noop,
}
end
local args ---@type table? local args ---@type table?
local gen = 0 local gen = 0
local fired_gen = 0 local fired_gen = 0
-198
View File
@@ -1,198 +0,0 @@
local repo = require("git.repo")
local status = require("git.status")
local util = require("git.util")
local M = {}
---@class ow.Git.Repo
---@field gitdir string
---@field worktree string
---@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
local Repo = {}
Repo.__index = Repo
function Repo:start_watcher()
local watcher = assert(vim.uv.new_fs_event())
assert(watcher:start(self.gitdir, {}, function(err, filename)
if err or (filename ~= "index" and filename ~= "HEAD") then
return
end
self:refresh()
end))
self.watcher = watcher
end
function Repo:stop_watcher()
-- Stop the libuv watcher first so no further fs-events can trigger
-- 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()
self.watcher = nil
end
self.refresh_handle.close()
end
---@param buf integer
function Repo:add_buffer(buf)
self.buffers[buf] = true
end
---@param buf integer
function Repo:remove_buffer(buf)
self.buffers[buf] = nil
end
---@return boolean
function Repo:has_buffers()
return next(self.buffers) ~= nil
end
---@param r ow.Git.Repo
local function do_refresh(r)
vim.system(
{
"git",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
},
{ cwd = r.worktree, text = true },
vim.schedule_wrap(function(obj)
local statuses = {}
if obj.code == 0 then
for line in (obj.stdout or ""):gmatch("[^\r\n]+") do
if line:sub(1, 2) ~= "##" then
local code = line:sub(1, 2)
local x = code:sub(1, 1)
local y = code:sub(2, 2)
local path_part = line:sub(4)
if x == "R" or x == "C" or y == "R" or y == "C" then
local arrow = path_part:find(" -> ", 1, true)
if arrow then
path_part = path_part:sub(arrow + 4)
end
end
statuses[vim.fs.joinpath(r.worktree, path_part)] =
status.format(code)
end
end
else
util.warning(
"git status failed: %s",
vim.trim(obj.stderr or "")
)
end
local dirty = false
for buf in pairs(r.buffers) do
if not vim.api.nvim_buf_is_valid(buf) then
r.buffers[buf] = nil
else
local status =
statuses[vim.fn.resolve(vim.api.nvim_buf_get_name(buf))]
if vim.b[buf].git_status ~= status then
vim.b[buf].git_status = status
dirty = true
end
end
end
if dirty then
vim.cmd.redrawstatus({ bang = true })
end
vim.api.nvim_exec_autocmds("User", {
pattern = "GitRefresh",
data = {
gitdir = r.gitdir,
worktree = r.worktree,
porcelain_stdout = obj.code == 0 and obj.stdout or nil,
},
})
end)
)
end
---@param gitdir string
---@param worktree string
---@return ow.Git.Repo
function Repo.new(gitdir, worktree)
local self = setmetatable({
gitdir = gitdir,
worktree = worktree,
buffers = {},
}, Repo)
self.refresh, self.refresh_handle = util.debounce(do_refresh, 50)
self:start_watcher()
return self
end
---@type table<string, ow.Git.Repo>
local repo_by_gitdir = {}
---@type table<integer, ow.Git.Repo>
local repo_by_buf = {}
---@param buf integer
---@return ow.Git.Repo?
local function register(buf)
local existing = repo_by_buf[buf]
if existing then
return existing
end
local path = vim.api.nvim_buf_get_name(buf)
if path == "" then
return nil
end
local gitdir, worktree = repo.resolve(path)
if not gitdir or not worktree then
return nil
end
local r = repo_by_gitdir[gitdir]
if not r then
r = Repo.new(gitdir, worktree)
repo_by_gitdir[gitdir] = r
end
r:add_buffer(buf)
repo_by_buf[buf] = r
return r
end
---@param buf integer
function M.unregister(buf)
local r = repo_by_buf[buf]
if not r then
return
end
repo_by_buf[buf] = nil
r:remove_buffer(buf)
if not r:has_buffers() then
r:stop_watcher()
repo_by_gitdir[r.gitdir] = nil
end
end
---@param buf integer
function M.refresh_buf(buf)
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
return
end
local r = register(buf)
if not r then
vim.b[buf].git_status = nil
return
end
r:refresh()
end
function M.stop_all()
for _, r in pairs(repo_by_gitdir) do
r:stop_watcher()
end
end
return M
+2 -1
View File
@@ -111,7 +111,8 @@ require("nvim-tree").setup({
full_name = true, full_name = true,
root_folder_label = function(path) root_folder_label = function(path)
local label = vim.fn.fnamemodify(path, ":~") local label = vim.fn.fnamemodify(path, ":~")
local git_head = require("git.repo").head(path) local r = require("git.repo").resolve(path)
local git_head = r and r:head()
if git_head then if git_head then
label = label .. ("  %s"):format(git_head) label = label .. ("  %s"):format(git_head)
end end