mirror of
https://github.com/folke/snacks.nvim
synced 2025-07-13 08:05:08 +00:00
437 lines
12 KiB
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
|