913 lines
24 KiB
Lua
913 lines
24 KiB
Lua
local commit = require("git.commit")
|
|
local object = require("git.object")
|
|
local repo = require("git.repo")
|
|
local util = require("git.util")
|
|
|
|
local M = {}
|
|
|
|
---@alias ow.Git.Cmd.Run fun(r: ow.Git.Repo, args: string[], bang: boolean)
|
|
|
|
---@type string[]?
|
|
local cached_cmds
|
|
|
|
---@return string[]
|
|
local function git_cmds()
|
|
if cached_cmds then
|
|
return cached_cmds
|
|
end
|
|
local out = util.git({ "--list-cmds=main,others,alias" })
|
|
if not out then
|
|
return {}
|
|
end
|
|
cached_cmds = {}
|
|
for line in out:gmatch("[^\r\n]+") do
|
|
if line ~= "" then
|
|
table.insert(cached_cmds, line)
|
|
end
|
|
end
|
|
table.sort(cached_cmds)
|
|
return cached_cmds
|
|
end
|
|
|
|
---@param tok string
|
|
---@return boolean
|
|
local function is_expansion_target(tok)
|
|
local first = tok:sub(1, 1)
|
|
if first == "%" or first == "#" then
|
|
return true
|
|
end
|
|
if tok:match("^<%w+>") then
|
|
return true
|
|
end
|
|
if tok == "~" or tok:sub(1, 2) == "~/" then
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
---@param line string
|
|
---@param i integer
|
|
---@param buf string[]
|
|
---@param escapes string?
|
|
---@return integer
|
|
local function parse_quoted(line, i, buf, escapes)
|
|
local quote = line:sub(i, i)
|
|
local n = #line
|
|
i = i + 1
|
|
while i <= n do
|
|
local c = line:sub(i, i)
|
|
if c == quote then
|
|
return i + 1
|
|
elseif escapes and c == "\\" and i < n then
|
|
local nxt = line:sub(i + 1, i + 1)
|
|
if escapes:find(nxt, 1, true) then
|
|
table.insert(buf, nxt)
|
|
i = i + 2
|
|
else
|
|
table.insert(buf, c)
|
|
i = i + 1
|
|
end
|
|
else
|
|
table.insert(buf, c)
|
|
i = i + 1
|
|
end
|
|
end
|
|
return i
|
|
end
|
|
|
|
---@param line string
|
|
---@return string[]
|
|
function M.parse_args(line)
|
|
local args = {}
|
|
local i, n = 1, #line
|
|
while i <= n do
|
|
local c = line:sub(i, i)
|
|
if c == " " or c == "\t" then
|
|
i = i + 1
|
|
else
|
|
local buf = {}
|
|
local quoted = false
|
|
while i <= n do
|
|
c = line:sub(i, i)
|
|
if c == " " or c == "\t" then
|
|
break
|
|
elseif c == "\\" and i < n then
|
|
table.insert(buf, line:sub(i + 1, i + 1))
|
|
i = i + 2
|
|
elseif c == '"' then
|
|
quoted = true
|
|
i = parse_quoted(line, i, buf, '"\\$`')
|
|
elseif c == "'" then
|
|
quoted = true
|
|
i = parse_quoted(line, i, buf, nil)
|
|
else
|
|
table.insert(buf, c)
|
|
i = i + 1
|
|
end
|
|
end
|
|
local tok = table.concat(buf)
|
|
if not quoted and is_expansion_target(tok) then
|
|
local expanded = vim.fn.expand(tok) --[[@as string]]
|
|
if expanded ~= "" then
|
|
tok = expanded
|
|
end
|
|
end
|
|
table.insert(args, tok)
|
|
end
|
|
end
|
|
return args
|
|
end
|
|
|
|
---@param name string
|
|
---@return integer buf
|
|
local function place_split(name)
|
|
-- bufadd resolves the name the same way nvim_buf_set_name does
|
|
-- (cwd-prefixing for non-absolute names), so calling it twice with
|
|
-- the same name returns the same buffer.
|
|
local buf = vim.fn.bufadd(name)
|
|
if not vim.api.nvim_buf_is_loaded(buf) then
|
|
vim.fn.bufload(buf)
|
|
util.setup_scratch(buf, { bufhidden = "hide" })
|
|
end
|
|
local win = vim.fn.bufwinid(buf)
|
|
if win ~= -1 then
|
|
vim.api.nvim_set_current_win(win)
|
|
else
|
|
util.place_buf(buf, nil)
|
|
end
|
|
return buf
|
|
end
|
|
|
|
---@param buf integer
|
|
local function clear_undo(buf)
|
|
local saved = vim.bo[buf].undolevels
|
|
vim.bo[buf].undolevels = -1
|
|
vim.bo[buf].modifiable = true
|
|
vim.api.nvim_buf_call(buf, function()
|
|
vim.cmd('silent! exe "normal! a \\<BS>\\<Esc>"')
|
|
end)
|
|
vim.bo[buf].modifiable = false
|
|
vim.bo[buf].undolevels = saved
|
|
end
|
|
|
|
---@param buf integer
|
|
local function attach_history_keys(buf)
|
|
local function bypass(fn)
|
|
return function()
|
|
vim.bo[buf].modifiable = true
|
|
pcall(fn)
|
|
vim.bo[buf].modifiable = false
|
|
end
|
|
end
|
|
vim.keymap.set(
|
|
"n",
|
|
"u",
|
|
bypass(vim.cmd.undo),
|
|
{ buffer = buf, desc = "Undo" }
|
|
)
|
|
vim.keymap.set(
|
|
"n",
|
|
"<C-r>",
|
|
bypass(vim.cmd.redo),
|
|
{ buffer = buf, desc = "Redo" }
|
|
)
|
|
end
|
|
|
|
---@param r ow.Git.Repo
|
|
---@param args string[]
|
|
---@param ft string
|
|
local function run_in_split(r, args, ft)
|
|
util.git(args, {
|
|
cwd = r.worktree,
|
|
on_exit = function(result)
|
|
if result.code ~= 0 then
|
|
util.error(
|
|
"git %s failed: %s",
|
|
args[1] or "?",
|
|
vim.trim(result.stderr or "")
|
|
)
|
|
return
|
|
end
|
|
local stdout = result.stdout or ""
|
|
local buf = place_split("[Git " .. table.concat(args, " ") .. "]")
|
|
repo.bind(buf, r)
|
|
object.attach_dispatch(buf)
|
|
attach_history_keys(buf)
|
|
local state = r:state(buf) --[[@as -nil]]
|
|
vim.bo[buf].filetype = ft
|
|
-- Force a new undo block so each rerun is its own undo step.
|
|
vim.bo[buf].undolevels = vim.bo[buf].undolevels
|
|
local first_run = not state.initialized
|
|
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
|
|
if first_run then
|
|
clear_undo(buf)
|
|
state.initialized = true
|
|
end
|
|
end,
|
|
})
|
|
end
|
|
|
|
---@param r ow.Git.Repo
|
|
---@param args string[]
|
|
local function run_to_messages(r, args)
|
|
local cmd = { "git" }
|
|
vim.list_extend(cmd, args)
|
|
local result = vim.system(cmd, {
|
|
cwd = r.worktree,
|
|
text = true,
|
|
env = util.DEFAULT_GIT_ENV,
|
|
}):wait()
|
|
local out = vim.trim(result.stdout or "")
|
|
local err = vim.trim(result.stderr or "")
|
|
local failed = result.code ~= 0
|
|
|
|
local chunks = {}
|
|
if out ~= "" then
|
|
table.insert(chunks, { out })
|
|
end
|
|
if err ~= "" then
|
|
if #chunks > 0 then
|
|
table.insert(chunks, { "\n" })
|
|
end
|
|
table.insert(chunks, { err, failed and "ErrorMsg" or nil })
|
|
end
|
|
if #chunks == 0 and failed then
|
|
table.insert(
|
|
chunks,
|
|
{ "git exited " .. tostring(result.code), "ErrorMsg" }
|
|
)
|
|
end
|
|
if #chunks > 0 then
|
|
vim.api.nvim_echo(chunks, failed or err ~= "", {})
|
|
end
|
|
end
|
|
|
|
---@return integer
|
|
local function find_or_create_preview_win()
|
|
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
|
if vim.wo[w].previewwindow then
|
|
return w
|
|
end
|
|
end
|
|
vim.cmd(("botright %dnew"):format(vim.o.previewheight))
|
|
local w = vim.api.nvim_get_current_win()
|
|
vim.wo[w].previewwindow = true
|
|
return w
|
|
end
|
|
|
|
---@param r ow.Git.Repo
|
|
---@param args string[]
|
|
local function run_in_preview(r, args)
|
|
local prev_win = vim.api.nvim_get_current_win()
|
|
local pwin = find_or_create_preview_win()
|
|
|
|
local buf = vim.api.nvim_create_buf(false, true)
|
|
vim.bo[buf].bufhidden = "wipe"
|
|
vim.api.nvim_win_set_buf(pwin, buf)
|
|
|
|
vim.api.nvim_set_current_win(pwin)
|
|
local cmd = { "git" }
|
|
vim.list_extend(cmd, args)
|
|
local job = vim.fn.jobstart(cmd, {
|
|
cwd = r.worktree,
|
|
term = true,
|
|
})
|
|
vim.api.nvim_set_current_win(prev_win)
|
|
|
|
if job <= 0 then
|
|
util.error("failed to start git job")
|
|
return
|
|
end
|
|
|
|
vim.api.nvim_create_autocmd("BufWipeout", {
|
|
buffer = buf,
|
|
once = true,
|
|
callback = function()
|
|
pcall(vim.fn.jobstop, job)
|
|
end,
|
|
})
|
|
|
|
vim.keymap.set("n", "q", "<cmd>pclose<cr>", {
|
|
buffer = buf,
|
|
nowait = true,
|
|
desc = "Close preview",
|
|
})
|
|
vim.keymap.set("n", "<C-c>", function()
|
|
pcall(vim.fn.jobstop, job)
|
|
end, { buffer = buf, nowait = true, desc = "Cancel git job" })
|
|
end
|
|
|
|
---@param ft string
|
|
---@return ow.Git.Cmd.Run
|
|
local function in_split(ft)
|
|
return function(r, args, _bang)
|
|
run_in_split(r, args, ft)
|
|
end
|
|
end
|
|
|
|
---@param line string
|
|
---@return string
|
|
local function clean_progress_line(line)
|
|
if not (line:find("\27", 1, true) or line:find("\r", 1, true)) then
|
|
return line
|
|
end
|
|
line = line:gsub("\27%[[%d;?]*[%a]", "")
|
|
local parts = vim.split(line, "\r", { plain = true })
|
|
for i = #parts, 1, -1 do
|
|
if parts[i] ~= "" then
|
|
return parts[i]
|
|
end
|
|
end
|
|
return ""
|
|
end
|
|
|
|
---@param lines string[]
|
|
---@param fallback string
|
|
---@return [string, string?][]
|
|
local function format_error_dump(lines, fallback)
|
|
if #lines == 0 then
|
|
return { { fallback, "ErrorMsg" } }
|
|
end
|
|
local chunks = {}
|
|
local matched = false
|
|
for i, line in ipairs(lines) do
|
|
if i > 1 then
|
|
table.insert(chunks, { "\n" })
|
|
end
|
|
if line:match("^fatal:") or line:match("^error:") then
|
|
table.insert(chunks, { line, "ErrorMsg" })
|
|
matched = true
|
|
else
|
|
table.insert(chunks, { line })
|
|
end
|
|
end
|
|
if not matched then
|
|
return { { table.concat(lines, "\n"), "ErrorMsg" } }
|
|
end
|
|
return chunks
|
|
end
|
|
|
|
---@param r ow.Git.Repo
|
|
---@param args string[]
|
|
local function run_streaming(r, args)
|
|
local title = "git " .. (args[1] or "")
|
|
local id = "git." .. tostring(vim.uv.hrtime())
|
|
---@type string[]
|
|
local accum = {}
|
|
local partial = ""
|
|
local last_progress = ""
|
|
|
|
---@param text string
|
|
---@param status "running"|"success"|"failed"
|
|
local function emit_progress(text, status)
|
|
vim.api.nvim_echo({ { text } }, false, {
|
|
id = id,
|
|
kind = "progress",
|
|
status = status,
|
|
title = title,
|
|
source = "git",
|
|
})
|
|
end
|
|
|
|
local function on_data(_, data, _)
|
|
if not data or #data == 0 then
|
|
return
|
|
end
|
|
if #data == 1 and data[1] == "" then
|
|
return
|
|
end
|
|
partial = partial .. data[1]
|
|
local prev = last_progress
|
|
for i = 2, #data do
|
|
local cleaned = clean_progress_line(partial)
|
|
if cleaned ~= "" then
|
|
table.insert(accum, cleaned)
|
|
last_progress = cleaned
|
|
end
|
|
partial = data[i]
|
|
end
|
|
if partial ~= "" then
|
|
local cleaned = clean_progress_line(partial)
|
|
if cleaned ~= "" then
|
|
last_progress = cleaned
|
|
end
|
|
end
|
|
if last_progress ~= prev then
|
|
emit_progress(last_progress, "running")
|
|
end
|
|
end
|
|
|
|
local function on_exit(_, code)
|
|
if partial ~= "" then
|
|
local cleaned = clean_progress_line(partial)
|
|
if cleaned ~= "" then
|
|
table.insert(accum, cleaned)
|
|
last_progress = cleaned
|
|
end
|
|
partial = ""
|
|
end
|
|
if code == 0 then
|
|
emit_progress(
|
|
last_progress ~= "" and last_progress or "done",
|
|
"success"
|
|
)
|
|
else
|
|
emit_progress(("exit %d"):format(code), "failed")
|
|
local fallback = ("%s failed: exit %d"):format(title, code)
|
|
vim.api.nvim_echo(format_error_dump(accum, fallback), true, {})
|
|
end
|
|
end
|
|
|
|
local cmd = { "git" }
|
|
vim.list_extend(cmd, args)
|
|
local job = vim.fn.jobstart(cmd, {
|
|
cwd = r.worktree,
|
|
pty = true,
|
|
env = util.DEFAULT_GIT_ENV,
|
|
on_stdout = on_data,
|
|
on_stderr = on_data,
|
|
on_exit = on_exit,
|
|
})
|
|
if job <= 0 then
|
|
util.error("failed to start git job")
|
|
end
|
|
end
|
|
|
|
---@param r ow.Git.Repo
|
|
---@param args string[]
|
|
---@param bang boolean
|
|
local function streaming_dispatch(r, args, bang)
|
|
if bang then
|
|
run_in_preview(r, args)
|
|
else
|
|
run_streaming(r, args)
|
|
end
|
|
end
|
|
|
|
---@type table<string, ow.Git.Cmd.Run>
|
|
local HANDLERS = {
|
|
log = in_split("git"),
|
|
diff = in_split("git"),
|
|
push = streaming_dispatch,
|
|
fetch = streaming_dispatch,
|
|
pull = streaming_dispatch,
|
|
clone = streaming_dispatch,
|
|
am = streaming_dispatch,
|
|
["cherry-pick"] = streaming_dispatch,
|
|
revert = streaming_dispatch,
|
|
}
|
|
|
|
---@param args string[]
|
|
---@return boolean
|
|
local function has_message(args)
|
|
for _, a in ipairs(args) do
|
|
if
|
|
a == "-m"
|
|
or a == "--message"
|
|
or a:match("^%-%-message=")
|
|
or a:match("^%-m")
|
|
then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
---@param args string[]
|
|
---@param opts { bang: boolean? }?
|
|
function M.run(args, opts)
|
|
local r = repo.resolve()
|
|
if not r then
|
|
util.error("not in a git repository")
|
|
return
|
|
end
|
|
|
|
local bang = opts and opts.bang or false
|
|
|
|
local sub = args[1]
|
|
if sub == "commit" and not has_message(args) then
|
|
commit.commit({ args = vim.list_slice(args, 2) })
|
|
return
|
|
end
|
|
|
|
if sub == "show" then
|
|
if #args == 2 and args[2]:find(":", 1, true) then
|
|
object.open(r, args[2])
|
|
return
|
|
end
|
|
run_in_split(r, args, "git")
|
|
return
|
|
end
|
|
|
|
if sub == "cat-file" then
|
|
if #args == 3 and args[2] == "-p" then
|
|
object.open(r, args[3])
|
|
return
|
|
end
|
|
run_in_split(r, args, "git")
|
|
return
|
|
end
|
|
|
|
local handler = sub and HANDLERS[sub]
|
|
if handler then
|
|
handler(r, args, bang)
|
|
else
|
|
run_to_messages(r, args)
|
|
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)
|
|
return r:get_cached("files:" .. dir, function(self)
|
|
local args = { "ls-files" }
|
|
if dir ~= "" then
|
|
table.insert(args, dir)
|
|
end
|
|
local out = util.git(args, { cwd = self.worktree, silent = true })
|
|
return out and util.split_lines(out) or {}
|
|
end)
|
|
end
|
|
|
|
---@param r ow.Git.Repo
|
|
---@return string[]
|
|
local function list_remotes(r)
|
|
return r:get_cached("remotes", function(self)
|
|
local out = util.git(
|
|
{ "remote" },
|
|
{ cwd = self.worktree, silent = true }
|
|
)
|
|
return out and util.split_lines(out) or {}
|
|
end)
|
|
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.git(
|
|
{ sub, "--git-completion-helper-all" },
|
|
{ silent = true }
|
|
) or util.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 in pairs(r.status.entries) do
|
|
if path:sub(1, #lead) == lead then
|
|
local include = entry.kind == "untracked"
|
|
or entry.kind == "unmerged"
|
|
if not include and entry.kind == "changed" then
|
|
---@cast entry ow.Git.Status.ChangedEntry
|
|
include = entry.unstaged ~= nil
|
|
end
|
|
if include then
|
|
table.insert(matches, path)
|
|
end
|
|
end
|
|
end
|
|
table.sort(matches)
|
|
return matches
|
|
end
|
|
|
|
---@param arg_lead string
|
|
---@return string[]
|
|
function M.complete_rev(arg_lead)
|
|
local r = repo.resolve()
|
|
if not r then
|
|
return {}
|
|
end
|
|
|
|
local stage, stage_path_lead = arg_lead:match("^:([0-3]):(.*)$")
|
|
if stage then
|
|
local out = util.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 refs = {}
|
|
vim.list_extend(refs, r:list_refs())
|
|
vim.list_extend(refs, r:list_pseudo_refs())
|
|
vim.list_extend(refs, r:list_stash_refs())
|
|
return prefix_filter(refs, arg_lead)
|
|
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 args = { "ls-tree", rev }
|
|
if dir ~= "" then
|
|
table.insert(args, dir)
|
|
end
|
|
local out = util.git(args, { 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
|
|
|
|
return path_segments(":", dir, name_lead, list_files(r, dir))
|
|
end
|
|
|
|
---@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 {}
|
|
end
|
|
return prefix_filter(fetch_subsubcommands(sub), lead)
|
|
end
|
|
|
|
local ALL_REFS = { complete_ref, complete_pseudo_ref, complete_stash_ref }
|
|
local REV_OR_PATH = { complete_rev, complete_tracked_paths }
|
|
|
|
---@type table<string, ow.Git.Cmd.Slot[]>
|
|
local POSITIONAL_HANDLER = {
|
|
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
|
|
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
|
|
|
|
---@param arg_lead string
|
|
---@param cmd_line string
|
|
---@return string[]
|
|
function M.complete(arg_lead, cmd_line, _)
|
|
local state = parse_complete_state(cmd_line)
|
|
local prior = state.prior
|
|
|
|
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 {}
|
|
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
|