local commit = require("git.commit") local object = require("git.object") local repo = require("git.repo") local util = require("git.util") local M = {} ---@class ow.Git.Cmd.SplitHandler ---@field ft string ---@field needs_rev boolean? ---@type table local SPLIT_HANDLERS = { log = { ft = "git" }, diff = { ft = "diff" }, } ---@type string[]? local cached_cmds ---@return string[] local function git_cmds() if cached_cmds then return cached_cmds end 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 "")) return {} end cached_cmds = {} for line in (result.stdout or ""):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 args string[] ---@param start integer ---@return string? local function first_positional(args, start) for i = start, #args do local a = args[i] if a:sub(1, 1) ~= "-" then return a end end end ---@param name string ---@return integer buf local function place_split(name) local buf = vim.fn.bufnr("\\V" .. name) if buf == -1 or not vim.api.nvim_buf_is_loaded(buf) then buf = util.new_scratch() pcall(vim.api.nvim_buf_set_name, buf, name) return buf end local win_id = vim.fn.bufwinid(buf) if win_id ~= -1 then vim.api.nvim_set_current_win(win_id) else util.place_buf(buf, nil) end return buf end ---@param r ow.Git.Repo ---@param args string[] ---@param conf ow.Git.Cmd.SplitHandler local function run_in_split(r, args, conf) local cmd = { "git" } vim.list_extend(cmd, args) util.exec(cmd, { cwd = r.worktree, on_done = function(stdout) if not stdout then return end local name = "[git " .. table.concat(args, " ") .. "]" local buf = place_split(name) repo.bind(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 = r:rev_parse(user_rev, true) if sha then state.sha = sha state.parent_sha = r:rev_parse(user_rev .. "^", true) end end vim.bo[buf].filetype = conf.ft util.set_buf_lines(buf, 0, -1, util.split_lines(stdout)) 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) vim.system( cmd, { cwd = r.worktree, text = true }, vim.schedule_wrap(function(obj) local out = vim.trim(obj.stdout or "") local err = vim.trim(obj.stderr or "") 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, "ErrorMsg" }) end if #chunks == 0 and obj.code ~= 0 then table.insert( chunks, { "git exited " .. tostring(obj.code), "ErrorMsg" } ) end if #chunks > 0 then vim.api.nvim_echo(chunks, true, {}) end end) ) end ---@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[] function M.run(args) local r = repo.resolve() if not r then util.error("not in a git repository") return end 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, { ft = "git", needs_rev = true }) 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, { ft = "git", needs_rev = true }) return end local conf = sub and SPLIT_HANDLERS[sub] if conf then run_in_split(r, args, conf) 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) 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 local SUBSUB_FALLBACK = { submodule = { "add", "status", "init", "deinit", "update", "summary", "foreach", "sync", "absorbgitdirs", }, } ---@type table 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 ---@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.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 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 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 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 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