feat(git/cmd): improved completion for :G
This commit is contained in:
+312
-34
@@ -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
|
|
||||||
table.insert(cmd, dir)
|
|
||||||
end
|
end
|
||||||
local out = util.exec(cmd, { cwd = r.worktree, silent = true })
|
|
||||||
if not out then
|
---@alias ow.Git.Cmd.Handler fun(r: ow.Git.Repo, lead: string, sub: string, idx: integer): string[]
|
||||||
|
---@alias ow.Git.Cmd.Slot ow.Git.Cmd.Handler | ow.Git.Cmd.Handler[]
|
||||||
|
|
||||||
|
---@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
|
|
||||||
return {}
|
if #prior == 0 then
|
||||||
end
|
return prefix_filter(git_cmds(), arg_lead)
|
||||||
local matches = {}
|
|
||||||
for _, c in ipairs(git_cmds()) do
|
|
||||||
if c:sub(1, #arg_lead) == arg_lead then
|
|
||||||
table.insert(matches, c)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return matches
|
|
||||||
end
|
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 {}
|
||||||
|
end
|
||||||
|
|
||||||
|
if state.after_separator then
|
||||||
|
return complete_tracked_paths(r, arg_lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
M._parse_complete_state = parse_complete_state
|
||||||
|
M._positional_index = positional_index
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
+39
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user