local M = {} local uv = vim.uv or vim.loop local commit_pat = ("[a-z0-9]"):rep(7) ---@param ... (string|string[]|nil) local function git_args(...) local ret = { "-c", "core.quotepath=false" } ---@type string[] for i = 1, select("#", ...) do local arg = select(i, ...) vim.list_extend(ret, type(arg) == "table" and arg or { arg }) end return ret end ---@param opts snacks.picker.git.files.Config ---@type snacks.picker.finder function M.files(opts, ctx) local args = git_args(opts.args, "ls-files", "--exclude-standard", "--cached") if opts.untracked then table.insert(args, "--others") elseif opts.submodules then table.insert(args, "--recurse-submodules") end if not opts.cwd then opts.cwd = Snacks.git.get_root() or uv.cwd() or "." ctx.picker:set_cwd(opts.cwd) end local cwd = svim.fs.normalize(opts.cwd) or nil return require("snacks.picker.source.proc").proc({ opts, { cmd = "git", args = args, ---@param item snacks.picker.finder.Item transform = function(item) item.cwd = cwd item.file = item.text end, }, }, ctx) end ---@param opts snacks.picker.git.grep.Config ---@type snacks.picker.finder function M.grep(opts, ctx) if opts.need_search ~= false and ctx.filter.search == "" then return function() end end local args = git_args(opts.args, "grep", "--line-number", "--column", "--no-color", "-I") if opts.untracked then table.insert(args, "--untracked") elseif opts.submodules then table.insert(args, "--recurse-submodules") end table.insert(args, ctx.filter.search) if not opts.cwd then opts.cwd = Snacks.git.get_root() or uv.cwd() or "." ctx.picker:set_cwd(opts.cwd) end local cwd = svim.fs.normalize(opts.cwd) or nil return require("snacks.picker.source.proc").proc({ opts, { cmd = "git", args = args, notify = false, ---@param item snacks.picker.finder.Item transform = function(item) item.cwd = cwd local file, line, col, text = item.text:match("^(.+):(%d+):(%d+):(.*)$") if not file then if not item.text:match("WARNING") then Snacks.notify.error("invalid grep output:\n" .. item.text) end return false else item.line = text item.file = file item.pos = { tonumber(line), tonumber(col) - 1 } end end, }, }, ctx) end ---@param opts snacks.picker.git.log.Config ---@type snacks.picker.finder function M.log(opts, ctx) local args = git_args( opts.args, "log", "--pretty=format:%h %s (%ch)", "--abbrev-commit", "--decorate", "--date=short", "--color=never", "--no-show-signature", "--no-patch" ) if opts.author then table.insert(args, "--author=" .. opts.author) end local file ---@type string? if opts.current_line then local cursor = vim.api.nvim_win_get_cursor(ctx.filter.current_win) file = vim.api.nvim_buf_get_name(ctx.filter.current_buf) local line = cursor[1] args[#args + 1] = "-L" args[#args + 1] = line .. ",+1:" .. file elseif opts.current_file then file = vim.api.nvim_buf_get_name(ctx.filter.current_buf) if opts.follow then args[#args + 1] = "--follow" end args[#args + 1] = "--" args[#args + 1] = file end local Proc = require("snacks.picker.source.proc") file = file and svim.fs.normalize(file) or nil local cwd = svim.fs.normalize(file and vim.fn.fnamemodify(file, ":h") or opts and opts.cwd or uv.cwd() or ".") or nil cwd = Snacks.git.get_root(cwd) or cwd local renames = { file } ---@type string[] return function(cb) if file then -- detect renames local is_rename = false Proc.proc({ cmd = "git", cwd = cwd, args = { "log", "-z", "--follow", "--name-status", "--pretty=format:''", "--diff-filter=R", "--", file }, }, ctx)(function(item) for _, text in ipairs(vim.split(item.text, "\0")) do if text:find("^R%d%d%d$") then is_rename = true elseif is_rename then is_rename = false renames[#renames + 1] = text end end end) end Proc.proc({ opts, { cwd = cwd, cmd = "git", args = args, ---@param item snacks.picker.finder.Item transform = function(item) local commit, msg, date = item.text:match("^(%S+) (.*) %((.*)%)$") if not commit then Snacks.notify.error(("failed to parse log item:\n%q"):format(item.text)) return false end item.cwd = cwd item.commit = commit item.msg = msg item.date = date item.file = file item.files = renames end, }, }, ctx)(cb) end end ---@param opts snacks.picker.git.status.Config ---@type snacks.picker.finder function M.status(opts, ctx) local args = git_args(opts.args, "--no-pager", "status", "-uall", "--porcelain=v1", "-z") if opts.ignored then table.insert(args, "--ignored=matching") end local cwd = svim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil cwd = Snacks.git.get_root(cwd) local prev ---@type snacks.picker.finder.Item? return require("snacks.picker.source.proc").proc({ opts, { sep = "\0", cwd = cwd, cmd = "git", args = args, ---@param item snacks.picker.finder.Item transform = function(item) local status, file = item.text:match("^(..) (.+)$") if status then item.cwd = cwd item.status = status item.file = file prev = item elseif prev and prev.status:find("R") then prev.rename = item.text return false else return false end end, }, }, ctx) end ---@param opts snacks.picker.git.Config ---@type snacks.picker.finder function M.diff(opts, ctx) local args = git_args(opts.args, "--no-pager", "diff", "--no-color", "--no-ext-diff") local file, line ---@type string?, number? local header, hunk = {}, {} ---@type string[], string[] local header_len = 4 local finder = require("snacks.picker.source.proc").proc({ opts, { cmd = "git", args = args }, }, ctx) return function(cb) local function add() if file and line and #hunk > 0 then local diff = table.concat(header, "\n") .. "\n" .. table.concat(hunk, "\n") cb({ text = file .. ":" .. line, diff = diff, file = file, pos = { line, 0 }, preview = { text = diff, ft = "diff", loc = false }, }) end hunk = {} end finder(function(proc_item) local text = proc_item.text if text:find("diff", 1, true) == 1 then add() file = text:match("^diff .* a/(.*) b/.*$") header = { text } header_len = 4 elseif file and #header < header_len then if text:find("^deleted file") then header_len = 5 end header[#header + 1] = text elseif text:find("@", 1, true) == 1 then add() -- Hunk header -- @example "@@ -157,20 +157,6 @@ some content" line = tonumber(string.match(text, "@@ %-.*,.* %+(.*),.* @@")) hunk = { text } elseif #hunk > 0 then hunk[#hunk + 1] = text else error("unexpected line: " .. text) end end) add() end end ---@param opts snacks.picker.git.branches.Config ---@type snacks.picker.finder function M.branches(opts, ctx) local args = git_args(opts.args, "--no-pager", "branch", "--no-color", "-vvl") if opts.all then table.insert(args, "--all") end local cwd = svim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil cwd = Snacks.git.get_root(cwd) local patterns = { -- stylua: ignore start --- e.g. "* (HEAD detached at f65a2c8) f65a2c8 chore(build): auto-generate docs" "^(.)%s(%b())%s+(" .. commit_pat .. ")%s*(.*)$", --- e.g. " main d2b2b7b [origin/main: behind 276] chore(build): auto-generate docs" "^(.)%s(%S+)%s+(".. commit_pat .. ")%s*(.*)$", -- stylua: ignore end } ---@type string[] return require("snacks.picker.source.proc").proc({ opts, { cwd = cwd, cmd = "git", args = args, ---@param item snacks.picker.finder.Item transform = function(item) item.cwd = cwd if item.text:find("HEAD.*%->") then return false end for p, pattern in ipairs(patterns) do local status, branch, commit, msg = item.text:match(pattern) if status then local detached = p == 1 item.current = status == "*" item.branch = not detached and branch or nil item.commit = commit item.msg = msg item.detached = detached return end end Snacks.notify.warn("failed to parse branch: " .. item.text) return false -- skip items we could not parse end, }, }, ctx) end ---@param opts snacks.picker.git.Config ---@type snacks.picker.finder function M.stash(opts, ctx) local args = git_args(opts.args, "--no-pager", "stash", "list") local cwd = svim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil cwd = Snacks.git.get_root(cwd) return require("snacks.picker.source.proc").proc({ opts, { cwd = cwd, cmd = "git", args = args, ---@param item snacks.picker.finder.Item transform = function(item) if item.text:find("autostash", 1, true) then return false end local stash, branch, msg = item.text:gsub(": On (%S+):", ": WIP on %1:"):match("^(%S+): WIP on (%S+): (.*)$") if stash then local commit, m = msg:match("^(" .. commit_pat .. ") (.*)") item.cwd = cwd item.stash = stash item.branch = branch item.commit = commit item.msg = m or msg return end Snacks.notify.warn("failed to parse stash:\n```git\n" .. item.text .. "\n```") return false -- skip items we could not parse end, }, }, ctx) end ---@class snacks.picker.git.Status ---@field xy string ---@field status "modified" | "deleted" | "added" | "untracked" | "renamed" | "copied" | "ignored" ---@field unmerged? boolean ---@field staged? boolean ---@field priority? number ---@param xy string ---@return snacks.picker.git.Status function M.git_status(xy) local ss = { A = "added", D = "deleted", M = "modified", R = "renamed", C = "copied", ["?"] = "untracked", ["!"] = "ignored", } local prios = "!?CRDAM" ---@param status string ---@param unmerged? boolean ---@param staged? boolean local function s(status, unmerged, staged) local prio = (prios:find(status, 1, true) or 0) + (unmerged and 20 or 0) if not staged and not status:find("[!]") then prio = prio + 10 end return { xy = xy, status = ss[status], unmerged = unmerged, staged = staged, priority = prio, } end ---@param c string local function f(c) return xy:gsub("T", "M"):match(c) --[[@as string?]] end if f("%?%?") then return s("?") elseif f("!!") then return s("!") elseif f("UU") then return s("M", true) elseif f("DD") then return s("D", true) elseif f("AA") then return s("A", true) elseif f("U") then return s(f("A") and "A" or "D", true) end local m = f("^([MADRC])") if m then return s(m, nil, true) end m = f("([MADRC])$") if m then return s(m) end error("unknown status: " .. xy) end ---@param a string ---@param b string function M.merge_status(a, b) if a == b then return a end local as = M.git_status(a) local bs = M.git_status(b) if as.unmerged or bs.unmerged then return as.priority > bs.priority and as.xy or bs.xy end if not as.staged or not bs.staged then if as.status == bs.status then return as.staged and b or a end return " M" end return "M " end return M