feat(git/cmd): improved completion for :G

This commit is contained in:
2026-05-07 15:28:26 +02:00
parent 1b0315750d
commit 2abd1d653d
3 changed files with 381 additions and 34 deletions
+310 -32
View File
@@ -284,6 +284,158 @@ function M.run(args)
end end
end end
---@param items string[]
---@param lead string
---@return string[]
local function prefix_filter(items, lead)
return vim.tbl_filter(function(it)
return vim.startswith(it, lead)
end, items)
end
---@param prefix string
---@param dir string
---@param name_lead string
---@param entries string[]
---@return string[]
local function path_segments(prefix, dir, name_lead, entries)
local matches = {}
local seen = {}
for _, full_path in ipairs(entries) 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, prefix .. dir .. segment)
end
end
return matches
end
---@param r ow.Git.Repo
---@param dir string
---@return string[]
local function list_files(r, dir)
local cmd = { "git", "ls-files" }
if dir ~= "" then
table.insert(cmd, dir)
end
local out = util.exec(cmd, { cwd = r.worktree, silent = true })
return out and util.split_lines(out) or {}
end
---@param r ow.Git.Repo
---@return string[]
local function list_remotes(r)
local out = util.exec(
{ "git", "remote" },
{ cwd = r.worktree, silent = true }
)
return out and util.split_lines(out) or {}
end
---@type table<string, string[]>
local SUBSUB_FALLBACK = {
submodule = {
"add",
"status",
"init",
"deinit",
"update",
"summary",
"foreach",
"sync",
"absorbgitdirs",
},
}
---@type table<string, string[]>
local cached_completions = {}
---@param sub string
---@return string[]
local function fetch_completions(sub)
if cached_completions[sub] then
return cached_completions[sub]
end
local out = util.exec(
{ "git", sub, "--git-completion-helper-all" },
{ silent = true }
) or util.exec(
{ "git", sub, "--git-completion-helper" },
{ silent = true }
)
local items = {}
if out then
for tok in out:gmatch("%S+") do
table.insert(items, tok)
end
end
cached_completions[sub] = items
return items
end
---@param sub string
---@return string[]
local function fetch_subsubcommands(sub)
local subs = {}
for _, it in ipairs(fetch_completions(sub)) do
if it:sub(1, 1) ~= "-" and it ~= "--" then
table.insert(subs, it)
end
end
if #subs == 0 and SUBSUB_FALLBACK[sub] then
return SUBSUB_FALLBACK[sub]
end
return subs
end
---@param sub string
---@return string[]
local function fetch_flags(sub)
local flags = {}
for _, it in ipairs(fetch_completions(sub)) do
if it:sub(1, 1) == "-" and it ~= "--" then
table.insert(flags, it)
end
end
return flags
end
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_tracked_paths(r, lead)
local dir, name_lead = lead:match("^(.*/)([^/]*)$")
dir = dir or ""
name_lead = name_lead or lead
return path_segments("", dir, name_lead, list_files(r, dir))
end
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_unstaged_paths(r, lead)
local matches = {}
for path, entry_list in pairs(r.status.entries) do
if path:sub(1, #lead) == lead then
for _, e in ipairs(entry_list) do
if
e.kind == "unstaged"
or e.kind == "untracked"
or e.kind == "unmerged"
then
table.insert(matches, path)
break
end
end
end
end
table.sort(matches)
return matches
end
---@param arg_lead string ---@param arg_lead string
---@return string[] ---@return string[]
function M.complete_rev(arg_lead) function M.complete_rev(arg_lead)
@@ -317,13 +469,10 @@ function M.complete_rev(arg_lead)
local colon = arg_lead:find(":", 1, true) local colon = arg_lead:find(":", 1, true)
if not colon then if not colon then
local matches = {} local refs = r:list_refs()
for _, ref in ipairs(r:list_refs()) do vim.list_extend(refs, r:list_pseudo_refs())
if ref:sub(1, #arg_lead) == arg_lead then vim.list_extend(refs, r:list_stash_refs())
table.insert(matches, ref) return prefix_filter(refs, arg_lead)
end
end
return matches
end end
local rev = arg_lead:sub(1, colon - 1) local rev = arg_lead:sub(1, colon - 1)
@@ -358,44 +507,173 @@ function M.complete_rev(arg_lead)
return matches return matches
end end
local cmd = { "git", "ls-files" } return path_segments(":", dir, name_lead, list_files(r, dir))
if dir ~= "" then end
table.insert(cmd, dir)
end ---@alias ow.Git.Cmd.Handler fun(r: ow.Git.Repo, lead: string, sub: string, idx: integer): string[]
local out = util.exec(cmd, { cwd = r.worktree, silent = true }) ---@alias ow.Git.Cmd.Slot ow.Git.Cmd.Handler | ow.Git.Cmd.Handler[]
if not out then
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_remote(r, lead)
return prefix_filter(list_remotes(r), lead)
end
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_ref(r, lead)
return prefix_filter(r:list_refs(), lead)
end
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_pseudo_ref(r, lead)
return prefix_filter(r:list_pseudo_refs(), lead)
end
---@param r ow.Git.Repo
---@param lead string
---@return string[]
local function complete_stash_ref(r, lead)
return prefix_filter(r:list_stash_refs(), lead)
end
---@param _ ow.Git.Repo
---@param lead string
---@return string[]
local function complete_rev(_, lead)
return M.complete_rev(lead)
end
---@param _ ow.Git.Repo
---@param lead string
---@param sub string
---@param idx integer
---@return string[]
local function complete_subsubcmd(_, lead, sub, idx)
if idx ~= 1 then
return {} return {}
end end
local matches = {} return prefix_filter(fetch_subsubcommands(sub), lead)
local seen = {} end
for _, full_path in ipairs(util.split_lines(out)) do
local rel = dir == "" and full_path or full_path:sub(#dir + 1) local ALL_REFS = { complete_ref, complete_pseudo_ref, complete_stash_ref }
local slash = rel:find("/", 1, true) local REV_OR_PATH = { complete_rev, complete_tracked_paths }
local segment = slash and rel:sub(1, slash) or rel
if not seen[segment] and segment:sub(1, #name_lead) == name_lead then ---@type table<string, ow.Git.Cmd.Slot[]>
seen[segment] = true local POSITIONAL_HANDLER = {
table.insert(matches, ":" .. dir .. segment) push = { complete_remote, ALL_REFS },
pull = { complete_remote, ALL_REFS },
fetch = { complete_remote, ALL_REFS },
checkout = { REV_OR_PATH },
reset = { REV_OR_PATH },
restore = { complete_tracked_paths },
add = { complete_unstaged_paths },
rm = { complete_tracked_paths },
mv = { complete_tracked_paths },
blame = { complete_tracked_paths },
branch = { complete_ref },
switch = { complete_ref },
merge = { ALL_REFS },
rebase = { ALL_REFS },
["cherry-pick"] = { ALL_REFS },
revert = { ALL_REFS },
tag = { ALL_REFS },
log = { REV_OR_PATH },
diff = { REV_OR_PATH },
show = { complete_rev },
["cat-file"] = { complete_rev },
stash = { complete_subsubcmd },
remote = { complete_subsubcmd },
worktree = { complete_subsubcmd },
bisect = { complete_subsubcmd },
submodule = { complete_subsubcmd },
}
---@class ow.Git.Cmd.CompleteState
---@field prior string[] -- positional and flag tokens before the current arg_lead
---@field after_separator boolean -- whether `--` appeared in prior
---@param cmd_line string
---@return ow.Git.Cmd.CompleteState
local function parse_complete_state(cmd_line)
local rest = cmd_line:gsub("^%s*%S+%s*", "", 1)
local trailing_space = rest == "" or rest:sub(-1):match("%s") ~= nil
local tokens = vim.split(vim.trim(rest), "%s+", { trimempty = true })
local prior = trailing_space and tokens
or vim.list_slice(tokens, 1, #tokens - 1)
local after_separator = false
for _, t in ipairs(prior) do
if t == "--" then
after_separator = true
break
end end
end end
return matches return { prior = prior, after_separator = after_separator }
end
---@param prior string[] -- includes the subcommand at index 1
---@return integer
local function positional_index(prior)
local pos = 0
for i = 2, #prior do
if prior[i]:sub(1, 1) ~= "-" then
pos = pos + 1
end
end
return pos + 1
end end
---@param arg_lead string ---@param arg_lead string
---@param cmd_line string ---@param cmd_line string
---@return string[] ---@return string[]
function M.complete(arg_lead, cmd_line, _) function M.complete(arg_lead, cmd_line, _)
local rest = cmd_line:gsub("^%s*%S+%s*", "", 1) local state = parse_complete_state(cmd_line)
local words = vim.split(rest, "%s+", { trimempty = false }) local prior = state.prior
if #words > 1 then
if #prior == 0 then
return prefix_filter(git_cmds(), arg_lead)
end
local sub = prior[1] --[[@as string]]
if arg_lead:sub(1, 1) == "-" then
return prefix_filter(fetch_flags(sub), arg_lead)
end
local r = repo.resolve()
if not r then
return {} return {}
end end
local matches = {}
for _, c in ipairs(git_cmds()) do if state.after_separator then
if c:sub(1, #arg_lead) == arg_lead then return complete_tracked_paths(r, arg_lead)
table.insert(matches, c)
end
end end
return matches
local handlers = POSITIONAL_HANDLER[sub]
if not handlers then
return complete_tracked_paths(r, arg_lead)
end
local idx = positional_index(prior)
local slot = handlers[idx] or handlers[#handlers]
if not slot then
return {}
end
if type(slot) == "function" then
return slot(r, arg_lead, sub, idx)
end
local result = {}
for _, fn in ipairs(slot) do
vim.list_extend(result, fn(r, arg_lead, sub, idx))
end
return result
end end
M._parse_complete_state = parse_complete_state
M._positional_index = positional_index
return M return M
+39 -2
View File
@@ -179,8 +179,45 @@ function Repo:list_refs()
if not out then if not out then
return {} return {}
end end
local refs = util.split_lines(out) return util.split_lines(out)
table.insert(refs, 1, "HEAD") end
local PSEUDO_REFS = {
"HEAD",
"FETCH_HEAD",
"ORIG_HEAD",
"MERGE_HEAD",
"REBASE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
}
---@return string[]
function Repo:list_pseudo_refs()
local refs = {}
for _, name in ipairs(PSEUDO_REFS) do
if name == "HEAD" or vim.uv.fs_stat(self.gitdir .. "/" .. name) then
table.insert(refs, name)
end
end
return refs
end
---@return string[]
function Repo:list_stash_refs()
if not vim.uv.fs_stat(self.gitdir .. "/refs/stash") then
return {}
end
local refs = { "stash" }
local out = util.exec(
{ "git", "stash", "list", "--pretty=format:%gd" },
{ cwd = self.worktree, silent = true }
)
if out then
for _, entry in ipairs(util.split_lines(out)) do
table.insert(refs, entry)
end
end
return refs return refs
end end
+32
View File
@@ -97,3 +97,35 @@ end)
t.test("parse_args expands leading ~/ to home", function() t.test("parse_args expands leading ~/ to home", function()
t.eq(cmd.parse_args("add ~/foo"), { "add", vim.fn.expand("~/foo") }) t.eq(cmd.parse_args("add ~/foo"), { "add", vim.fn.expand("~/foo") })
end) end)
t.test("parse_complete_state with trailing space", function()
local s = cmd._parse_complete_state("G push origin ")
t.eq(s.prior, { "push", "origin" })
t.falsy(s.after_separator)
end)
t.test("parse_complete_state mid-token", function()
local s = cmd._parse_complete_state("G push or")
t.eq(s.prior, { "push" })
t.falsy(s.after_separator)
end)
t.test("parse_complete_state empty after command", function()
local s = cmd._parse_complete_state("G ")
t.eq(s.prior, {})
t.falsy(s.after_separator)
end)
t.test("parse_complete_state detects -- separator", function()
local s = cmd._parse_complete_state("G log -- foo")
t.eq(s.prior, { "log", "--" })
t.truthy(s.after_separator)
end)
t.test("positional_index ignores flags", function()
t.eq(cmd._positional_index({ "push" }), 1)
t.eq(cmd._positional_index({ "push", "origin" }), 2)
t.eq(cmd._positional_index({ "push", "--force" }), 1)
t.eq(cmd._positional_index({ "push", "--force", "origin" }), 2)
t.eq(cmd._positional_index({ "checkout", "-b", "feature" }), 2)
end)