diff --git a/after/ftplugin/git.lua b/after/ftplugin/git.lua deleted file mode 100644 index f6d04c1..0000000 --- a/after/ftplugin/git.lua +++ /dev/null @@ -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 dispatcher off any unrelated git buffer (a --- real `.git/HEAD` file, etc.) so the default normal-mode still works. -if not vim.b.git_worktree then - return -end - -local cr = vim.api.nvim_replace_termcodes("", true, false, true) - -vim.keymap.set("n", "", 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" }) diff --git a/ftplugin/gitlog.lua b/ftplugin/gitlog.lua deleted file mode 100644 index 07c3a80..0000000 --- a/ftplugin/gitlog.lua +++ /dev/null @@ -1,17 +0,0 @@ -local cr = vim.api.nvim_replace_termcodes("", true, false, true) - -vim.keymap.set("n", "", 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" }) diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua index 3b3fd68..b7a3548 100644 --- a/lua/git/cmd.lua +++ b/lua/git/cmd.lua @@ -23,8 +23,10 @@ local function git_cmds() if cached_cmds then return cached_cmds end - local result = vim - .system({ "git", "--list-cmds=main,others,alias" }, { text = true }) + local result = vim.system( + { "git", "--list-cmds=main,others,alias" }, + { text = true } + ) :wait() if result.code ~= 0 then util.error("git --list-cmds failed: %s", vim.trim(result.stderr or "")) @@ -70,30 +72,31 @@ local function place_split(name) return buf end ----@param worktree string +---@param r ow.Git.Repo ---@param args string[] ---@param conf ow.Git.SplitHandler -local function run_in_split(worktree, args, conf) +local function run_in_split(r, args, conf) local cmd = { "git" } vim.list_extend(cmd, args) util.exec(cmd, { - cwd = worktree, + cwd = r.worktree, on_done = function(stdout) if not stdout then return end local name = "[git " .. table.concat(args, " ") .. "]" local buf = place_split(name) - vim.b[buf].git_worktree = worktree - vim.b[buf].git_sha = nil - vim.b[buf].git_parent_sha = nil + repo.attach(buf, r) + object.attach_dispatch(buf) + local state = r:state(buf) --[[@as -nil]] + state.sha = nil + state.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) + local sha = r:rev_parse(user_rev, true) if sha then - vim.b[buf].git_sha = sha - vim.b[buf].git_parent_sha = - repo.rev_parse(worktree, user_rev .. "^", true) + state.sha = sha + state.parent_sha = r:rev_parse(user_rev .. "^", true) end end vim.bo[buf].filetype = conf.ft @@ -111,14 +114,14 @@ local function run_in_split(worktree, args, conf) }) end ----@param worktree string +---@param r ow.Git.Repo ---@param args string[] -local function run_to_messages(worktree, args) +local function run_to_messages(r, args) local cmd = { "git" } vim.list_extend(cmd, args) vim.system( cmd, - { cwd = worktree, text = true }, + { cwd = r.worktree, text = true }, vim.schedule_wrap(function(obj) local out = vim.trim(obj.stdout or "") local err = vim.trim(obj.stderr or "") @@ -175,8 +178,8 @@ end ---@param args string[] function M.run(args) - local _, worktree = repo.current_repo() - if not worktree then + local r = repo.find() + if not r then util.warning("not in a git repository") return end @@ -190,10 +193,10 @@ function M.run(args) if sub == "show" then local arg = first_positional(args, 2) if arg and arg:find(":", 1, true) then - object.open_object(worktree, arg) + object.open_object(r, arg) return end - run_in_split(worktree, args, { ft = "git", needs_rev = true }) + run_in_split(r, args, { ft = "git", needs_rev = true }) return end @@ -201,22 +204,118 @@ function M.run(args) if vim.list_contains(args, "-p") then local rev = first_positional(args, 2) if rev then - object.open_object(worktree, rev) + object.open_object(r, rev) return end end - run_in_split(worktree, args, { ft = "git", needs_rev = true }) + run_in_split(r, args, { ft = "git", needs_rev = true }) return end local conf = sub and SPLIT_HANDLERS[sub] if conf then - run_in_split(worktree, args, conf) + run_in_split(r, args, conf) else - run_to_messages(worktree, args) + run_to_messages(r, args) 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 cmd_line string ---@return string[] diff --git a/lua/git/commit.lua b/lua/git/commit.lua index ea88b06..b2b119d 100644 --- a/lua/git/commit.lua +++ b/lua/git/commit.lua @@ -7,8 +7,8 @@ local M = {} ---@param opts { amend: boolean? }? function M.commit(opts) local amend = opts and opts.amend or false - local _, worktree = repo.current_repo() - if not worktree then + local r = repo.find() + if not r then util.warning("not in a git repository") return end @@ -19,7 +19,7 @@ function M.commit(opts) end 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 f = io.open(file_path, "r") if f then diff --git a/lua/git/diff.lua b/lua/git/diff.lua index 77e355f..7904ce8 100644 --- a/lua/git/diff.lua +++ b/lua/git/diff.lua @@ -62,8 +62,8 @@ end ---@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.current_repo()) - if not worktree then + local r = repo.find(buf) + if not r then util.warning("git URI buffer has no worktree") return end @@ -76,7 +76,7 @@ local function uri_split(opts, buf, rev) if opts.rev and opts.rev:find(":", 1, true) then local content = util.exec( { "git", "cat-file", "-p", opts.rev }, - { cwd = worktree, silent = true } + { cwd = r.worktree, silent = true } ) if not content then util.warning("invalid rev: %s", opts.rev) @@ -84,7 +84,7 @@ local function uri_split(opts, buf, rev) end place_pair( buf, - object.buf_for(worktree, Revision.parse(opts.rev), content), + object.buf_for(r, Revision.parse(opts.rev), content), false, opts.vertical ) @@ -92,7 +92,7 @@ local function uri_split(opts, buf, rev) end 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 util.warning("worktree file does not exist: %s", rev.path) return @@ -118,18 +118,13 @@ local function uri_split(opts, buf, rev) local other_rev, left = m[1], m[2] local content = util.exec( { "git", "cat-file", "-p", other_rev:format() }, - { cwd = worktree, silent = true } + { cwd = r.worktree, silent = true } ) if not content then util.warning("invalid rev: %s", other_rev:format()) return end - place_pair( - buf, - object.buf_for(worktree, other_rev, content), - left, - opts.vertical - ) + place_pair(buf, object.buf_for(r, other_rev, content), left, opts.vertical) end ---@class ow.Git.SplitOpts @@ -141,7 +136,7 @@ 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_rev = Revision.from_uri(cur_path) + local cur_rev = require("git.object").parse_uri(cur_path) if cur_rev then return uri_split(opts, cur_buf, cur_rev) end @@ -154,13 +149,13 @@ function M.split(opts) util.warning("cannot diff this buffer (not a worktree file)") return end - local _, worktree, cur_path = repo.resolve(cur_path) - if not worktree then + cur_path = vim.fn.resolve(cur_path) + local r = repo.resolve(cur_path) + if not r then util.warning("not in a git repository") return end - ---@cast cur_path -nil - local rel = vim.fs.relpath(worktree, cur_path) + local rel = vim.fs.relpath(r.worktree, cur_path) local rev if not opts.rev then @@ -172,13 +167,13 @@ function M.split(opts) end local content = util.exec( { "git", "cat-file", "-p", rev:format() }, - { cwd = worktree, silent = true } + { cwd = r.worktree, silent = true } ) if not content then util.warning("invalid rev: %s", rev:format()) return 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) end diff --git a/lua/git/init.lua b/lua/git/init.lua index 5b96cf5..c362171 100644 --- a/lua/git/init.lua +++ b/lua/git/init.lua @@ -25,26 +25,26 @@ function M.init() { group = group, callback = function(args) - require("git.watcher").refresh_buf(args.buf) + require("git.repo").refresh(args.buf) end, } ) vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { group = group, callback = function(args) - require("git.watcher").unregister(args.buf) + require("git.repo").unregister(args.buf) end, }) vim.api.nvim_create_autocmd("FocusGained", { group = group, callback = function(args) - require("git.watcher").refresh_buf(args.buf) + require("git.repo").refresh(args.buf) end, }) vim.api.nvim_create_autocmd("VimLeavePre", { group = group, callback = function() - require("git.watcher").stop_all() + require("git.repo").stop_all() end, }) @@ -82,7 +82,7 @@ function M.init() end end local function complete_rev(...) - return require("git.repo").complete_rev(...) + return require("git.cmd").complete_rev(...) end vim.api.nvim_create_user_command("Gdiffsplit", diff_split_cmd(true), { nargs = "?", @@ -101,7 +101,7 @@ function M.init() }) vim.api.nvim_create_user_command("Gedit", function(opts) vim.cmd.edit({ - args = { "git://" .. opts.args }, + args = { require("git.object").URI_PREFIX .. opts.args }, magic = { file = false }, }) end, { diff --git a/lua/git/log.lua b/lua/git/log.lua index 7200094..ab592d8 100644 --- a/lua/git/log.lua +++ b/lua/git/log.lua @@ -3,8 +3,31 @@ local util = require("git.util") local M = {} +M.URI_PREFIX = "gitlog://" + local LOG_FORMAT = "%h %ad {%an}%d %s" -local URI_PREFIX = "gitlog://" + +local cr = vim.api.nvim_replace_termcodes("", true, false, true) + +---@param buf integer +local function attach_dispatch(buf) + vim.keymap.set("n", "", 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 max_count integer? @@ -27,8 +50,12 @@ end ---@param buf integer local function populate(buf) - local worktree = vim.b[buf].git_worktree - local stdout = fetch(worktree, vim.b[buf].git_log_max_count) + local r = repo.find(buf) + 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 return end @@ -65,12 +92,16 @@ end ---@param buf integer function M.read_uri(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 return 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].bufhidden = "hide" vim.bo[buf].buftype = "nofile" @@ -78,6 +109,7 @@ function M.read_uri(buf) vim.bo[buf].filetype = "gitlog" end + attach_dispatch(buf) populate(buf) end @@ -92,15 +124,16 @@ M.opt_parsers = { ---@param opts ow.Git.LogOpts? function M.open(opts) opts = opts or {} - local _, worktree = repo.current_repo() - if not worktree then + local r = repo.find() + if not r then util.warning("not in a git repository") return end - local buf = vim.fn.bufadd(URI_PREFIX .. worktree) - vim.b[buf].git_worktree = worktree - vim.b[buf].git_log_max_count = opts.max_count + local buf = vim.fn.bufadd(M.URI_PREFIX .. r.worktree) + repo.attach(buf, r) + 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 win = vim.fn.bufwinid(buf) diff --git a/lua/git/object.lua b/lua/git/object.lua index 7c14c56..c5e05f2 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -4,27 +4,29 @@ local util = require("git.util") 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 ---@field path_a string ---@field path_b string ---@field blob_a 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? local function diff_section() local diff_lnum = vim.fn.search("^diff --git ", "bcnW") @@ -70,9 +72,9 @@ local function is_zero(sha) end ---@param buf integer ----@param worktree string +---@param r ow.Git.Repo ---@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", { buffer = buf, callback = function() @@ -82,18 +84,19 @@ local function attach_index_writer(buf, worktree, path) ) .. "\n" local hash_stdout = util.exec( { "git", "hash-object", "-w", "--stdin" }, - { cwd = worktree, stdin = body } + { cwd = r.worktree, stdin = body } ) if not hash_stdout then return end 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 mode = "100644" local ls = util.exec( { "git", "ls-files", "-s", "--", path }, - { cwd = worktree, silent = true } + { cwd = r.worktree, silent = true } ) if ls then local m = ls:match("^(%d+)") @@ -101,7 +104,9 @@ local function attach_index_writer(buf, worktree, path) mode = m end end - vim.b[buf].git_index_mode = mode + if state then + state.index_mode = mode + end end -- Use the 3-arg form (mode sha path) instead of the comma -- form (mode,sha,path), which doesn't survive paths @@ -114,7 +119,7 @@ local function attach_index_writer(buf, worktree, path) mode, sha, path, - }, { cwd = worktree }) + }, { cwd = r.worktree }) then return end @@ -123,18 +128,27 @@ local function attach_index_writer(buf, worktree, path) }) end ----@type table -local pending_content = {} +local cr = vim.api.nvim_replace_termcodes("", true, false, true) ----@param worktree string +---@param buf integer +function M.attach_dispatch(buf) + vim.keymap.set("n", "", 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 content string? ---@return integer -function M.buf_for(worktree, rev, content) - local buf = vim.fn.bufadd(rev:uri()) - vim.b[buf].git_worktree = worktree +function M.buf_for(r, rev, content) + local buf = vim.fn.bufadd(M.format_uri(rev)) + repo.attach(buf, r) if content then - pending_content[buf] = content + local state = r:state(buf) --[[@as -nil]] + state.pending_content = content end vim.fn.bufload(buf) return buf @@ -143,35 +157,43 @@ end ---@param buf integer function M.read_uri(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 return end local rev_str = rev:format() - local worktree = vim.b[buf].git_worktree or select(2, repo.current_repo()) - if not worktree then + local r = repo.find(buf) + if not r then util.error("git BufReadCmd %s: cannot resolve worktree", name) return 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].bufhidden = "hide" ---@type string? - local stdout = pending_content[buf] - pending_content[buf] = nil + local stdout = state.pending_content + 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 + local rev_sha = r:rev_parse(rev_str, true) + if rev_sha and rev_sha == state.sha then + return + end stdout = util.exec( { "git", "cat-file", "-p", rev_str }, - { cwd = worktree } + { cwd = r.worktree } ) end if stdout and rev.path == nil then - local commit_sha = - repo.rev_parse(worktree, rev_str .. "^{commit}", true) + local commit_sha = r:rev_parse(rev_str .. "^{commit}", true) if commit_sha then local patch = util.exec({ "git", @@ -182,12 +204,11 @@ function M.read_uri(buf) "--root", "--no-commit-id", commit_sha, - }, { cwd = worktree }) + }, { cwd = r.worktree }) if patch then stdout = (stdout:gsub("\n*$", "\n\n")) .. patch end - vim.b[buf].git_parent_sha = - repo.rev_parse(worktree, commit_sha .. "^", true) + state.parent_sha = r:rev_parse(commit_sha .. "^", true) 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)) end - local rev_sha = repo.rev_parse(worktree, rev_str, true) - if rev_sha then - vim.b[buf].git_sha = rev_sha - end + state.sha = r:rev_parse(rev_str, true) if rev.stage == 0 and rev.path then vim.bo[buf].buftype = "acwrite" - if not vim.b[buf].git_index_writer then - attach_index_writer(buf, worktree, rev.path) - vim.b[buf].git_index_writer = true + if not state.index_writer then + attach_index_writer(buf, r, rev.path) + state.index_writer = true end else vim.bo[buf].buftype = "nofile" @@ -226,27 +244,29 @@ function M.read_uri(buf) vim.bo[buf].filetype = "git" end + M.attach_dispatch(buf) + vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) end ----@param worktree string +---@param r ow.Git.Repo ---@param blob string? ---@param path string ---@param sha string ---@return integer? -local function blob_buf(worktree, blob, path, sha) +local function blob_buf(r, blob, path, sha) if is_zero(blob) then return nil end - return M.buf_for(worktree, Revision.new({ base = sha, path = path })) + return M.buf_for(r, Revision.new({ base = sha, path = path })) end ----@param worktree string +---@param r ow.Git.Repo ---@param blob string? ---@param path string ---@param sha string -local function load_blob(worktree, blob, path, sha) - local buf = blob_buf(worktree, blob, path, sha) +local function load_blob(r, blob, path, sha) + local buf = blob_buf(r, blob, path, sha) if not buf then util.warning("no content for %s at %s", path, sha) return @@ -255,17 +275,17 @@ local function load_blob(worktree, blob, path, sha) vim.api.nvim_set_current_buf(buf) end ----@param ctx ow.Git.BufContext +---@param s ow.Git.BufState ---@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 util.warning("no index line, cannot determine blob SHAs") return end - local parent = ctx.parent_sha or "0" - local left = blob_buf(ctx.worktree, section.blob_a, section.path_a, parent) + local parent = s.parent_sha or "0" + local left = blob_buf(s.repo, section.blob_a, section.path_a, parent) 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 require("git.diff").open(left, right, true) return @@ -283,44 +303,45 @@ end ---@class ow.Git.OpenObjectOpts ---@field split (false|"above"|"below"|"left"|"right")? ----@param worktree string +---@param r ow.Git.Repo ---@param rev string ---@param opts ow.Git.OpenObjectOpts? -function M.open_object(worktree, rev, opts) +function M.open_object(r, rev, opts) local parsed = Revision.parse(rev) if parsed.base then - local sha = repo.rev_parse(worktree, parsed.base, true) + local sha = r:rev_parse(parsed.base, true) if sha then parsed.base = sha end end local content = util.exec( { "git", "cat-file", "-p", parsed:format() }, - { cwd = worktree, silent = true } + { cwd = r.worktree, silent = true } ) if not content then util.warning("not a git object: %s", rev) return 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) end ---@return boolean dispatched function M.open_under_cursor() - local ctx = context() - if not ctx then + local s = repo.state() + if not s or not s.sha then return false end local line = vim.api.nvim_get_current_line() + local r = s.repo local sha = line:match("^commit (%x+)$") or line:match("^parent (%x+)$") or line:match("^tree (%x+)$") or line:match("^object (%x+)$") if sha then - M.open_object(ctx.worktree, sha, { split = false }) + M.open_object(r, sha, { split = false }) return true end @@ -328,9 +349,9 @@ function M.open_under_cursor() line:match("^%d+ (%w+) (%x+)\t(.+)$") if entry_sha then 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 - M.open_object(ctx.worktree, nav_rev, { split = false }) + M.open_object(r, nav_rev, { split = false }) return true end @@ -338,26 +359,26 @@ function M.open_under_cursor() if not section then return false end - local parent = ctx.parent_sha or "0" + local parent = s.parent_sha or "0" if line:match("^diff %-%-git ") then - open_section(ctx, section) + open_section(s, section) return true end 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 end 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 end local prefix = line:sub(1, 1) 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 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 end return false diff --git a/lua/git/repo.lua b/lua/git/repo.lua index ae3900c..3cf9224 100644 --- a/lua/git/repo.lua +++ b/lua/git/repo.lua @@ -1,61 +1,205 @@ +local status = require("git.status") local util = require("git.util") -local M = {} - ----@param path string ----@return string? gitdir ----@return string? worktree ----@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 +---@param buf integer? +---@return integer +local function expand_buf(buf) + if not buf or buf == 0 then + return vim.api.nvim_get_current_buf() end - local worktree = vim.fs.dirname(found) - 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 + return buf end ----@return string? gitdir ----@return string? worktree -function M.current_repo() - local path = vim.api.nvim_buf_get_name(0) - if path == "" or path:match("^%a+://") then - path = vim.fn.getcwd() - end - local gitdir, worktree, _ = M.resolve(path) - return gitdir, worktree +---@class ow.Git.BufState +---@field repo ow.Git.Repo +---@field sha string? +---@field parent_sha string? +---@field index_writer boolean? +---@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 +---@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 ----@param path string ---@return string? -function M.head(path) - local gitdir = M.resolve(path) - if not gitdir then - return nil - end - local f = io.open(vim.fs.joinpath(gitdir, "HEAD"), "r") +function Repo:head() + local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r") if not f then return nil end @@ -75,9 +219,8 @@ function M.head(path) return nil end ----@param worktree string ---@return string[] -function M.list_refs(worktree) +function Repo:list_refs() local out = util.exec({ "git", "for-each-ref", @@ -85,7 +228,7 @@ function M.list_refs(worktree) "refs/heads", "refs/tags", "refs/remotes", - }, { cwd = worktree, silent = true }) + }, { cwd = self.worktree, silent = true }) if not out then return {} end @@ -94,118 +237,159 @@ function M.list_refs(worktree) return refs 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 short boolean ---@return string? -function M.rev_parse(worktree, rev, short) +function Repo:rev_parse(rev, short) local cmd = { "git", "rev-parse", "--verify", "--quiet" } if short then table.insert(cmd, "--short") end 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 "" return trimmed ~= "" and trimmed or nil end +local M = {} + +---@type table +local repo_by_gitdir = {} + +---@type table +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 diff --git a/lua/git/revision.lua b/lua/git/revision.lua index 529e03e..55b3ca8 100644 --- a/lua/git/revision.lua +++ b/lua/git/revision.lua @@ -5,8 +5,6 @@ local Revision = {} Revision.__index = Revision -local URI_PREFIX = "git://" - ---@return string function Revision:format() if self.stage then @@ -17,11 +15,6 @@ function Revision:format() 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) @@ -49,13 +42,4 @@ function Revision.parse(str) return Revision.new({ base = str }) end ----@param str string ----@return ow.Git.Revision? -function Revision.from_uri(str) - local raw = str:match("^" .. URI_PREFIX .. "(.+)$") - if raw then - return Revision.parse(raw) - end -end - return Revision diff --git a/lua/git/sidebar.lua b/lua/git/sidebar.lua index 9aa7d30..f837747 100644 --- a/lua/git/sidebar.lua +++ b/lua/git/sidebar.lua @@ -32,14 +32,13 @@ local SIDEBAR_WIDTH = 50 ---@alias ow.Git.SidebarEntry ow.Git.FileEntry | ow.Git.CommitEntry ---@class ow.Git.SidebarState ----@field gitdir string ----@field worktree string +---@field repo ow.Git.Repo ---@field lines table ---@field sidebar_win integer? ---@field invocation_win integer? ---@field diff_left_win integer? ---@field diff_right_win integer? ----@field user_aucmd integer? +---@field unsubscribe fun()? ---@field last_shown_key string? ---@field last_render_key string? @@ -407,7 +406,7 @@ local function refresh(bufnr, prefetched_stdout) 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 return end @@ -456,22 +455,22 @@ end ---@field left ow.Git.DiffSide ---@field right ow.Git.DiffSide ----@param worktree string +---@param r ow.Git.Repo ---@param path string ---@return ow.Git.DiffSide -local function head_pane(worktree, path) +local function head_pane(r, path) local rev = Revision.new({ base = "HEAD", path = path }) return { - buf = object.buf_for(worktree, rev), - name = rev:uri(), + buf = object.buf_for(r, rev), + name = object.format_uri(rev), } end ----@param worktree string +---@param r ow.Git.Repo ---@param path string ---@return ow.Git.DiffSide -local function worktree_pane(worktree, path) - local buf = vim.fn.bufadd(vim.fs.joinpath(worktree, path)) +local function worktree_pane(r, path) + local buf = vim.fn.bufadd(vim.fs.joinpath(r.worktree, path)) vim.fn.bufload(buf) return { buf = buf, name = nil } end @@ -482,8 +481,8 @@ end local function index_pane(s, entry) local rev = Revision.new({ stage = 0, path = entry.path }) return { - buf = object.buf_for(s.worktree, rev), - name = rev:uri(), + buf = object.buf_for(s.repo, rev), + name = object.format_uri(rev), } end @@ -495,7 +494,7 @@ local function older_pane(s, entry) if entry.x == "A" then return nil end - return head_pane(s.worktree, entry.orig or entry.path) + return head_pane(s.repo, entry.orig or entry.path) end if entry.section == "Unstaged" then return index_pane(s, entry) @@ -517,10 +516,10 @@ local function newer_pane(s, entry) if entry.y == "D" then return nil end - return worktree_pane(s.worktree, entry.path) + return worktree_pane(s.repo, entry.path) end if entry.section == "Untracked" then - return worktree_pane(s.worktree, entry.path) + return worktree_pane(s.repo, entry.path) end return nil 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_set_width(left_win, math.floor(combined / 2)) 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[right_win].git_diff_role = "right" s.diff_left_win = left_win @@ -728,7 +728,7 @@ local function action_stage() end vim.system( { "git", "add", "--", entry.path }, - { cwd = s.worktree }, + { cwd = s.repo.worktree }, vim.schedule_wrap(function(obj) if obj.code ~= 0 then util.error("git add failed: %s", vim.trim(obj.stderr or "")) @@ -753,7 +753,7 @@ local function action_unstage() table.insert(cmd, entry.path) vim.system( cmd, - { cwd = s.worktree }, + { cwd = s.repo.worktree }, vim.schedule_wrap(function(obj) if obj.code ~= 0 then util.error( @@ -785,7 +785,7 @@ local function action_discard() entry.path ) 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 "") if rc ~= 0 then util.error("failed to delete %s", entry.path) @@ -797,7 +797,7 @@ local function action_discard() action = function() vim.system( { "git", "checkout", "--", entry.path }, - { cwd = s.worktree }, + { cwd = s.repo.worktree }, vim.schedule_wrap(function(obj) if obj.code ~= 0 then util.error( @@ -829,20 +829,14 @@ local function action_help() }, "\n")) end ----@param worktree string -local function open(worktree) +---@param r ow.Git.Repo +local function open(r) local existing = find_sidebar() if existing then vim.api.nvim_set_current_win(existing) return 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 bufnr, win = util.new_scratch({ split = "left" }) vim.bo[bufnr].filetype = "gitsidebar" @@ -856,8 +850,7 @@ local function open(worktree) vim.api.nvim_win_set_width(win, SIDEBAR_WIDTH) state[bufnr] = { - gitdir = gitdir, - worktree = worktree, + repo = r, lines = {}, sidebar_win = win, invocation_win = previous_win, @@ -882,15 +875,9 @@ local function open(worktree) k("X", action_discard, "Discard worktree changes") k("g?", action_help, "Help") - state[bufnr].user_aucmd = vim.api.nvim_create_autocmd("User", { - pattern = "GitRefresh", - group = group, - callback = function(args) - if args.data and args.data.gitdir == gitdir then - refresh(bufnr, args.data.porcelain_stdout) - end - end, - }) + state[bufnr].unsubscribe = r:on_refresh(function(_, porcelain_stdout) + refresh(bufnr, porcelain_stdout) + end) vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { buffer = bufnr, group = group, @@ -899,8 +886,8 @@ local function open(worktree) if not s then return end - if s.user_aucmd then - pcall(vim.api.nvim_del_autocmd, s.user_aucmd) + if s.unsubscribe then + s.unsubscribe() end state[bufnr] = nil end, @@ -916,12 +903,12 @@ function M.toggle() vim.api.nvim_win_close(sidebar_win, false) return end - local _, worktree = repo.current_repo() - if not worktree then + local r = repo.find() + if not r then util.warning("not in a git repository") return end - open(worktree) + open(r) end return M diff --git a/lua/git/util.lua b/lua/git/util.lua index 4aabeb6..c59a6fa 100644 --- a/lua/git/util.lua +++ b/lua/git/util.lua @@ -101,7 +101,20 @@ end ---@param delay integer ---@return F, ow.Git.Util.DebounceHandle 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 gen = 0 local fired_gen = 0 diff --git a/lua/git/watcher.lua b/lua/git/watcher.lua deleted file mode 100644 index b77a5bd..0000000 --- a/lua/git/watcher.lua +++ /dev/null @@ -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 ----@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 -local repo_by_gitdir = {} - ----@type table -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 diff --git a/plugins/nvim-tree.lua b/plugins/nvim-tree.lua index d0b2e9e..ac6d14a 100644 --- a/plugins/nvim-tree.lua +++ b/plugins/nvim-tree.lua @@ -111,7 +111,8 @@ require("nvim-tree").setup({ full_name = true, root_folder_label = function(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 label = label .. ("  %s"):format(git_head) end