local commit = require("git.commit") local history = require("git.history") 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 \\\\"') 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", "", 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, true, {}) 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", "pclose", { buffer = buf, nowait = true, desc = "Close preview", }) vim.keymap.set("n", "", 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 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" ) history.append(args, accum) else emit_progress(("exit %d"):format(code), "failed") local body = #accum > 0 and table.concat(accum, "\n") or ("%s failed: exit %d"):format(title, code) vim.api.nvim_echo({ { body, "ErrorMsg" } }, true, {}) history.append(args, accum) 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 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 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.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_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.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 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