Files
nvim/lua/git/cmd.lua
T

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