snacks.nvim/lua/snacks/picker/source/git.lua

437 lines
12 KiB
Lua

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