feat: move out git package to separate plugin

This commit is contained in:
2026-05-26 17:07:32 +02:00
parent c0dcb14171
commit feb10f543c
28 changed files with 7 additions and 9393 deletions
-531
View File
@@ -1,531 +0,0 @@
local object = require("git.object")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
local NS_POPUP = vim.api.nvim_create_namespace("ow.git.blame.popup")
local ZERO_SHA = string.rep("0", 40)
---@class ow.Git.Blame.Commit
---@field sha string
---@field author string
---@field author_mail string
---@field author_time integer
---@field author_tz string
---@field summary string
---@class ow.Git.Blame.Result
---@field commits table<string, ow.Git.Blame.Commit>
---@field line_sha table<integer, string>
---@class ow.Git.Blame.Source
---@field repo ow.Git.Repo
---@field rel string
---@field revision string?
---@class ow.Git.Blame.BufState
---@field repo ow.Git.Repo
---@field rel string
---@field revision string?
---@field commits table<string, ow.Git.Blame.Commit>
---@field line_sha table<integer, string>
---@field tick integer?
---@field epoch integer
---@field pending fun()[]
---@type table<integer, ow.Git.Blame.BufState>
local states = {}
---@param buf integer?
---@return integer
local function resolve_buf(buf)
return buf and buf ~= 0 and buf or vim.api.nvim_get_current_buf()
end
---@param buf integer
---@return ow.Git.Blame.BufState?
function M.state(buf)
return states[buf]
end
---@param ts integer
---@param tz string
---@return string
local function format_author_time(ts, tz)
local sign, hh, mm = tz:match("^([+-])(%d%d)(%d%d)$")
---@type number
local offset = 0
if sign then
local h = tonumber(hh) or 0
local m = tonumber(mm) or 0
offset = (h * 3600 + m * 60) * (sign == "-" and -1 or 1)
end
return os.date("!%Y-%m-%d %T ", ts + offset) .. tz
end
---@param stdout string
---@return ow.Git.Blame.Result
local function parse_porcelain(stdout)
---@type table<string, ow.Git.Blame.Commit>
local commits = {}
---@type table<integer, string>
local line_sha = {}
local cur_sha ---@type string?
local cur_lnum ---@type integer?
for _, line in ipairs(util.split_lines(stdout)) do
if line:sub(1, 1) == "\t" then
if cur_sha and cur_lnum then
line_sha[cur_lnum] = cur_sha
end
cur_sha = nil
cur_lnum = nil
else
local sha, final = line:match("^(%x+) %d+ (%d+)")
if sha and #sha >= 40 then
cur_sha = sha
cur_lnum = tonumber(final) --[[@as integer?]]
if not commits[sha] then
commits[sha] = {
sha = sha,
author = "",
author_mail = "",
author_time = 0,
author_tz = "",
summary = "",
}
end
else
local key, value = line:match("^(%S+) (.*)$")
local commit = cur_sha and commits[cur_sha]
if commit and key then
if key == "author" then
commit.author = value
elseif key == "author-mail" then
commit.author_mail = value
elseif key == "author-time" then
commit.author_time = math.floor(tonumber(value) or 0)
elseif key == "author-tz" then
commit.author_tz = value
elseif key == "summary" then
commit.summary = value
end
end
end
end
end
return { commits = commits, line_sha = line_sha }
end
---@param line_count integer
---@return ow.Git.Blame.Result
local function synth_uncommitted(line_count)
---@type table<integer, string>
local line_sha = {}
for i = 1, line_count do
line_sha[i] = ZERO_SHA
end
return {
commits = {
[ZERO_SHA] = {
sha = ZERO_SHA,
author = "Not Committed Yet",
author_mail = "",
author_time = os.time() --[[@as integer]],
author_tz = "",
summary = "",
},
},
line_sha = line_sha,
}
end
---@param r ow.Git.Repo
---@param rel string
---@param opts { rev: string?, contents: string? }
---@param done fun(result: ow.Git.Blame.Result?)
local function fetch_blame(r, rel, opts, done)
local args = { "--no-pager", "blame", "--porcelain" }
if opts.contents then
table.insert(args, "--contents")
table.insert(args, "-")
end
if opts.rev then
table.insert(args, opts.rev)
end
table.insert(args, "--")
table.insert(args, rel)
util.git(args, {
cwd = r.worktree,
stdin = opts.contents,
silent = true,
on_exit = function(res)
if res.code ~= 0 then
done(nil)
else
done(parse_porcelain(res.stdout or ""))
end
end,
})
end
---@param buf integer
---@return ow.Git.Blame.Source?
local function resolve_source(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return nil
end
local name = vim.api.nvim_buf_get_name(buf)
if util.is_uri(name) then
local rev = object.parse_uri(name)
if not rev or not rev.base or not rev.path then
return nil
end
local r = repo.find(buf)
if not r then
return nil
end
return { repo = r, rel = rev.path, revision = rev.base }
end
if not repo.is_worktree_buf(buf) then
return nil
end
local r = repo.find(buf)
if not r then
return nil
end
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
if not rel then
return nil
end
return { repo = r, rel = rel }
end
---@param buf integer
---@return ow.Git.Blame.BufState?
local function ensure_state(buf)
if states[buf] then
return states[buf]
end
local src = resolve_source(buf)
if not src then
return nil
end
---@type ow.Git.Blame.BufState
local state = {
repo = src.repo,
rel = src.rel,
revision = src.revision,
commits = {},
line_sha = {},
tick = nil,
epoch = 0,
pending = {},
}
states[buf] = state
return state
end
---@param state ow.Git.Blame.BufState
---@param buf integer
---@param done fun()?
local function run_blame(state, buf, done)
local tick = vim.api.nvim_buf_get_changedtick(buf)
if state.tick == tick then
if done then
done()
end
return
end
if done then
table.insert(state.pending, done)
end
state.epoch = state.epoch + 1
local epoch = state.epoch
local opts ---@type { rev: string?, contents: string? }
if state.revision then
opts = { rev = state.revision }
else
opts = {
contents = table.concat(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n"
) .. "\n",
}
end
fetch_blame(state.repo, state.rel, opts, function(result)
if
states[buf] ~= state
or epoch ~= state.epoch
or not vim.api.nvim_buf_is_valid(buf)
then
return
end
local data = result
or synth_uncommitted(vim.api.nvim_buf_line_count(buf))
state.commits = data.commits
state.line_sha = data.line_sha
state.tick = tick
local pending = state.pending
state.pending = {}
for _, fn in ipairs(pending) do
fn()
end
end)
end
---@param lines string[]
---@return integer width
---@return integer height
local function size_for(lines)
local width = 1
for _, l in ipairs(lines) do
local w = vim.api.nvim_strwidth(l)
if w > width then
width = w
end
end
width = math.min(math.max(width + 1, 30), vim.o.columns - 4)
local height = math.min(math.max(#lines, 1), math.floor(vim.o.lines / 2))
return width, height
end
local popup_win ---@type integer?
local function close_popup()
if popup_win and vim.api.nvim_win_is_valid(popup_win) then
vim.api.nvim_win_close(popup_win, true)
end
popup_win = nil
end
---@param pbuf integer
---@param win integer
---@param head string[]
---@param body string[]?
---@param sha_len integer?
---@param date_col integer?
local function apply_popup(pbuf, win, head, body, sha_len, date_col)
local lines = {}
vim.list_extend(lines, head)
if body then
vim.list_extend(lines, body)
end
vim.bo[pbuf].modifiable = true
vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines)
vim.bo[pbuf].modifiable = false
vim.api.nvim_buf_clear_namespace(pbuf, NS_POPUP, 0, -1)
if sha_len then
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, 0, {
end_col = sha_len,
hl_group = "GitBlameSha",
})
end
if sha_len and date_col then
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, sha_len + 2, {
end_col = date_col - 2,
hl_group = "GitBlameAuthor",
})
end
if date_col then
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, date_col, {
end_col = #(head[1] or ""),
hl_group = "GitBlameDate",
})
end
local width, height = size_for(lines)
pcall(vim.api.nvim_win_set_width, win, width)
pcall(vim.api.nvim_win_set_height, win, height)
end
---@param watch_buf integer
---@param pbuf integer
---@param win integer
local function setup_popup_autocmds(watch_buf, pbuf, win)
local group =
vim.api.nvim_create_augroup("ow.git.blame.popup", { clear = true })
vim.api.nvim_create_autocmd(
{ "CursorMoved", "CursorMovedI", "InsertEnter" },
{ group = group, buffer = watch_buf, callback = close_popup }
)
vim.api.nvim_create_autocmd("WinLeave", {
group = group,
buffer = pbuf,
callback = close_popup,
})
vim.api.nvim_create_autocmd("WinClosed", {
group = group,
pattern = tostring(win),
callback = function()
popup_win = nil
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame.popup")
end,
})
vim.keymap.set("n", "q", close_popup, { buffer = pbuf, nowait = true })
end
---@param r ow.Git.Repo
---@param commits table<string, ow.Git.Blame.Commit>
---@param line_sha table<integer, string>
---@param lnum integer
---@param watch_buf integer
local function open_popup(r, commits, line_sha, lnum, watch_buf)
close_popup()
local sha = line_sha[lnum]
local commit = sha and commits[sha]
if not commit then
util.warning("git blame: no blame information for line %d", lnum)
return
end
local head ---@type string[]
local sha_len ---@type integer?
local date_col ---@type integer?
if util.is_zero_sha(sha) then
head = { "Not Committed Yet" }
else
local short = sha:sub(1, 8)
local date = format_author_time(commit.author_time, commit.author_tz)
sha_len = #short
date_col = sha_len + 2 + #commit.author + 2
head = {
short .. " " .. commit.author .. " " .. date,
"",
}
end
local body = sha_len and { commit.summary } or nil
local lines = {}
vim.list_extend(lines, head)
if body then
vim.list_extend(lines, body)
end
local width, height = size_for(lines)
local pbuf = vim.api.nvim_create_buf(false, true)
vim.bo[pbuf].bufhidden = "wipe"
local win = vim.api.nvim_open_win(pbuf, false, {
relative = "cursor",
row = 1,
col = 0,
width = width,
height = height,
style = "minimal",
})
popup_win = win
apply_popup(pbuf, win, head, body, sha_len, date_col)
setup_popup_autocmds(watch_buf, pbuf, win)
if not sha_len then
return
end
util.git({ "show", "-s", "--format=%B", sha }, {
cwd = r.worktree,
silent = true,
on_exit = function(res)
if
popup_win ~= win
or not vim.api.nvim_win_is_valid(win)
or not vim.api.nvim_buf_is_valid(pbuf)
or res.code ~= 0
then
return
end
local msg = util.split_lines(res.stdout or "")
if #msg > 0 then
apply_popup(pbuf, win, head, msg, sha_len, date_col)
end
end,
})
end
---@param buf integer?
function M.line_popup(buf)
buf = resolve_buf(buf)
if popup_win and vim.api.nvim_win_is_valid(popup_win) then
vim.api.nvim_set_current_win(popup_win)
return
end
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
run_blame(state, buf, function()
if
not vim.api.nvim_buf_is_valid(buf)
or vim.api.nvim_get_current_buf() ~= buf
or vim.api.nvim_win_get_cursor(0)[1] ~= lnum
then
return
end
open_popup(state.repo, state.commits, state.line_sha, lnum, buf)
end)
end
---@param done fun(state: ow.Git.Blame.BufState, sha: string)
local function blame_line(done)
local buf = vim.api.nvim_get_current_buf()
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
run_blame(state, buf, function()
if not vim.api.nvim_buf_is_valid(buf) then
return
end
local sha = state.line_sha[lnum]
if not sha or util.is_zero_sha(sha) then
util.warning("git blame: line is not committed yet")
return
end
done(state, sha)
end)
end
function M.open_commit()
blame_line(function(state, sha)
object.open(state.repo, sha, { split = false })
end)
end
function M.open_file()
blame_line(function(state, sha)
object.open(state.repo, sha .. ":" .. state.rel, { split = false })
end)
end
function M.open_file_parent()
blame_line(function(state, sha)
local parent = state.repo:rev_parse(sha .. "^", false)
if not parent then
util.warning("git blame: %s has no parent commit", sha:sub(1, 8))
return
end
object.open(state.repo, parent .. ":" .. state.rel, { split = false })
end)
end
---@param buf integer
function M.detach(buf)
local state = states[buf]
if not state then
return
end
state.epoch = state.epoch + 1
states[buf] = nil
end
repo.on("change", function(r, change)
for _, state in pairs(states) do
if
state.repo == r
and not state.revision
and (change.paths[state.rel] or change.branch_changed)
then
state.tick = nil
end
end
end)
return M
-904
View File
@@ -1,904 +0,0 @@
local commit = require("git.commit")
local object = require("git.object")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
---@alias ow.Git.Cmd.Run fun(r: ow.Git.Repo, args: string[])
---@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 \\<BS>\\<Esc>"')
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",
"<C-r>",
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, failed or err ~= "", {})
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 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,
})
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", "<cmd>pclose<cr>", {
buffer = buf,
nowait = true,
desc = "Close preview",
})
vim.keymap.set("n", "<C-c>", 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)
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 lines string[]
---@param fallback string
---@return [string, string?][]
local function format_error_dump(lines, fallback)
if #lines == 0 then
return { { fallback, "ErrorMsg" } }
end
local chunks = {}
local matched = false
for i, line in ipairs(lines) do
if i > 1 then
table.insert(chunks, { "\n" })
end
if line:match("^fatal:") or line:match("^error:") then
table.insert(chunks, { line, "ErrorMsg" })
matched = true
else
table.insert(chunks, { line })
end
end
if not matched then
return { { table.concat(lines, "\n"), "ErrorMsg" } }
end
return chunks
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"
)
else
emit_progress(("exit %d"):format(code), "failed")
local fallback = ("%s failed: exit %d"):format(title, code)
vim.api.nvim_echo(format_error_dump(accum, fallback), true, {})
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
---@type table<string, ow.Git.Cmd.Run>
local HANDLERS = {
log = in_split("git"),
diff = in_split("git"),
push = run_streaming,
fetch = run_streaming,
pull = run_streaming,
clone = run_streaming,
am = run_streaming,
["cherry-pick"] = run_streaming,
revert = run_streaming,
}
---@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
if bang then
run_in_preview(r, args)
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, "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)
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<string, string[]>
local SUBSUB_FALLBACK = {
submodule = {
"add",
"status",
"init",
"deinit",
"update",
"summary",
"foreach",
"sync",
"absorbgitdirs",
},
}
---@type table<string, string[]>
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 in pairs(r.status.entries) do
if path:sub(1, #lead) == lead then
local include = entry.kind == "untracked"
or entry.kind == "unmerged"
if not include and entry.kind == "changed" then
---@cast entry ow.Git.Status.ChangedEntry
include = entry.unstaged ~= nil
end
if include then
table.insert(matches, path)
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<string, ow.Git.Cmd.Slot[]>
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
-80
View File
@@ -1,80 +0,0 @@
local editor = require("git.editor")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
---@param opts { args: string[]? }?
function M.commit(opts)
local r = repo.resolve()
if not r then
util.error("not in a git repository")
return
end
local cmd = { "git", "commit" }
if opts and opts.args then
vim.list_extend(cmd, opts.args)
end
local proxy_buf, proxy_win
editor.run(cmd, { cwd = r.worktree }, function(file_path, done)
local lines = {}
local f = io.open(file_path, "r")
if f then
for line in f:lines() do
table.insert(lines, line)
end
f:close()
end
local buf, win = util.new_scratch({
name = file_path,
buftype = "acwrite",
modifiable = true,
})
proxy_buf = buf
proxy_win = win
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modified = false
vim.bo[buf].filetype = "gitcommit"
vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = buf,
callback = function()
local out = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
local fw, werr = io.open(file_path, "w")
if not fw then
util.error("failed to write %s: %s", file_path, werr or "")
return
end
fw:write(table.concat(out, "\n"))
fw:close()
vim.bo[buf].modified = false
end,
})
vim.api.nvim_create_autocmd("BufWipeout", {
buffer = buf,
once = true,
callback = done,
})
end, function(result)
if proxy_win and vim.api.nvim_win_is_valid(proxy_win) then
pcall(vim.api.nvim_win_close, proxy_win, true)
end
if proxy_buf and vim.api.nvim_buf_is_valid(proxy_buf) then
vim.api.nvim_buf_delete(proxy_buf, { force = true })
end
if result.code ~= 0 then
util.error("git commit failed: %s", vim.trim(result.stderr or ""))
return
end
local out = vim.trim(result.stdout or "")
if out ~= "" then
vim.api.nvim_echo({ { out } }, false, {})
end
end)
end
return M
-925
View File
@@ -1,925 +0,0 @@
local status = require("git.core.status")
local util = require("git.core.util")
local M = {}
---@param buf? integer
---@return integer
local function expand_buf(buf)
if not buf or buf == 0 then
return vim.api.nvim_get_current_buf()
end
return buf
end
---@class ow.Git.Repo.BufState
---@field repo ow.Git.Repo
---@field sha string?
---@field initialized boolean?
---@field immutable boolean?
---@field index_writer boolean?
---@field index_mode string?
---@alias ow.Git.Repo.Event
---| "change"
local global = util.Emitter.new()
---@type table<string, ow.Git.Repo> keyed by worktree
local repos = {}
---@param r ow.Git.Repo
local function release_if_unused(r)
if repos[r.worktree] ~= r then
return
end
if next(r.buffers) ~= nil or next(r.tabs) ~= nil then
return
end
r:close()
repos[r.worktree] = nil
end
---@class ow.Git.Repo.Change
---@field paths table<string, true>
---@field branch_changed boolean
---@class ow.Git.Repo.RefreshOpts
---@field invalidate boolean?
---@class ow.Git.Repo.SubmoduleEntry
---@field worktree string
---@field unsub fun()?
---@class ow.Git.Repo
---@field gitdir string
---@field worktree string
---@field buffers table<integer, ow.Git.Repo.BufState>
---@field tabs table<integer, true>
---@field status ow.Git.Status
---@field private _events ow.Git.Util.Emitter<ow.Git.Repo.Event>
---@field private _watchers table<string, uv.uv_fs_event_t>
---@field private _schedule_refresh fun(self: ow.Git.Repo)
---@field private _refresh_handle ow.Git.Util.DebounceHandle
---@field private _cache table<string, any>
---@field private _fetch_epoch integer
---@field private _pending_invalidate boolean
---@field package _submodules table<string, ow.Git.Repo.SubmoduleEntry>
local Repo = {}
Repo.__index = Repo
local STATUS_ARGS = {
"--no-optional-locks",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v2",
"--branch",
"--ignored",
"--untracked-files=all",
"-z",
}
local PSEUDO_REFS = {
"HEAD",
"FETCH_HEAD",
"ORIG_HEAD",
"MERGE_HEAD",
"REBASE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
}
---@type table<string, fun(relpath: string): boolean>
local INVALIDATION_RULES = {
head = function(relpath)
return relpath == "HEAD"
or vim.startswith(relpath, "refs/heads/")
or relpath == "packed-refs"
end,
refs = function(relpath)
return vim.startswith(relpath, "refs/heads/")
or vim.startswith(relpath, "refs/tags/")
or vim.startswith(relpath, "refs/remotes/")
or relpath == "packed-refs"
end,
pseudo_refs = function(relpath)
return vim.tbl_contains(PSEUDO_REFS, relpath)
end,
stash_refs = function(relpath)
return relpath == "refs/stash" or relpath == "logs/refs/stash"
end,
config = function(relpath)
return relpath == "config"
end,
}
---@param relpath string
---@return boolean
local function affects_resolve(relpath)
return vim.startswith(relpath, "refs/")
or relpath == "packed-refs"
or relpath == "HEAD"
or relpath == "FETCH_HEAD"
end
---@private
---@param prefix string
function Repo:_clear_cache_prefix(prefix)
for key in pairs(self._cache) do
if vim.startswith(key, prefix) then
self._cache[key] = nil
end
end
end
---@private
---@param relpath string
function Repo:_invalidate(relpath)
for key, affects in pairs(INVALIDATION_RULES) do
if self._cache[key] ~= nil and affects(relpath) then
self._cache[key] = nil
end
end
if affects_resolve(relpath) then
self:_clear_cache_prefix("resolve:")
self:_clear_cache_prefix("head_blob:")
end
if relpath == "index" then
self:_clear_cache_prefix("index:")
end
end
---@param path string
---@return table<string, table<string, string>>?
local function read_git_config(path)
local f = io.open(path, "r")
if not f then
return nil
end
local content = f:read("*a")
f:close()
local out = {}
local section
for line in content:gmatch("[^\n]+") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and not trimmed:match("^[#;]") then
local s = trimmed:match("^%[(.-)%]$")
if s then
section = s
out[section] = out[section] or {}
elseif section then
local key, value =
trimmed:match("^(%S+)%s*=%s*(.-)$")
if key then
out[section][key] = value
end
end
end
end
return out
end
---@param gitdir string
---@return string[]
local function find_submodules(gitdir)
local handle = vim.uv.fs_scandir(vim.fs.joinpath(gitdir, "modules"))
if not handle then
return {}
end
local out = {}
while true do
local name, typ = vim.uv.fs_scandir_next(handle)
if not name then
break
end
if typ == "directory" then
table.insert(out, name)
end
end
return out
end
---@private
function Repo:_fetch_status()
if self._pending_invalidate then
self._cache = {}
self._pending_invalidate = false
end
local prior_entries = self.status.entries
local prior_branch = self.status.branch
self._fetch_epoch = self._fetch_epoch + 1
local epoch = self._fetch_epoch
util.git(STATUS_ARGS, {
cwd = self.worktree,
on_exit = function(result)
if epoch ~= self._fetch_epoch then
return
end
if result.code ~= 0 then
util.error(
"git status failed: %s",
vim.trim(result.stderr or "")
)
return
end
self.status = status.parse(result.stdout or "")
local change = {
paths = status.diff_entries(
prior_entries,
self.status.entries
),
branch_changed = not vim.deep_equal(
prior_branch,
self.status.branch
),
}
if next(change.paths) == nil and not change.branch_changed then
return
end
self._events:emit("change", change, self.status)
global:emit("change", self, change, self.status)
end,
})
end
---@param opts ow.Git.Repo.RefreshOpts?
function Repo:refresh(opts)
if opts and opts.invalidate then
self._pending_invalidate = true
end
self:_schedule_refresh()
end
---@param gitdir string
---@param worktree string
---@return ow.Git.Repo
function Repo.new(gitdir, worktree)
local self = setmetatable({
gitdir = gitdir,
worktree = worktree,
buffers = {},
tabs = {},
status = status.parse(""),
_events = util.Emitter.new(),
_cache = {},
_fetch_epoch = 0,
_pending_invalidate = false,
_submodules = {},
}, Repo)
self._schedule_refresh, self._refresh_handle =
util.debounce(Repo._fetch_status, 50)
self:start_watcher()
self:refresh()
if vim.g.git_submodule_recursion ~= false then
self:_start_modules_watcher()
for _, name in ipairs(find_submodules(gitdir)) do
self:_register_submodule(name)
end
end
return self
end
---@generic T
---@param key string
---@param compute fun(self: ow.Git.Repo): T
---@return T
function Repo:get_cached(key, compute)
local hit = self._cache[key]
if hit ~= nil then
return hit
end
local value = compute(self)
self._cache[key] = value
return value
end
---@param path string
---@param on_event fun(filename: string?)
---@return uv.uv_fs_event_t?
local function start_fs_event(path, on_event)
local watcher = vim.uv.new_fs_event()
if not watcher then
return nil
end
local ok = watcher:start(path, {}, function(err, filename)
if err then
return
end
on_event(filename)
end)
if not ok then
watcher:close()
return nil
end
return watcher
end
---@private
---@param name string
function Repo:_unregister_submodule(name)
local entry = self._submodules[name]
if not entry then
return
end
self._submodules[name] = nil
if entry.unsub then
entry.unsub()
end
local child = repos[entry.worktree]
if child then
release_if_unused(child)
end
end
---@private
---@param name string
function Repo:_register_submodule(name)
local sub_gitdir = vim.fs.joinpath(self.gitdir, "modules", name)
local cfg = read_git_config(vim.fs.joinpath(sub_gitdir, "config"))
local raw = cfg and cfg.core and cfg.core.worktree
if not raw then
return
end
local wt = raw:match("^/") and raw or vim.fs.joinpath(sub_gitdir, raw)
wt = vim.fs.normalize(wt)
local existing = self._submodules[name]
if existing and existing.worktree == wt then
return
end
if existing then
self:_unregister_submodule(name)
end
local child = repos[wt] or M.resolve(wt)
if not child then
return
end
self._submodules[name] = {
worktree = wt,
unsub = child:on("change", function()
self:refresh()
end),
}
end
---@private
function Repo:_start_modules_watcher()
local dir = vim.fs.joinpath(self.gitdir, "modules")
if self._watchers[dir] then
return
end
if not vim.uv.fs_stat(dir) then
return
end
self._watchers[dir] = start_fs_event(dir, function(filename)
if not filename then
return
end
if vim.uv.fs_stat(vim.fs.joinpath(dir, filename)) then
self:_register_submodule(filename)
else
self:_unregister_submodule(filename)
end
end)
end
---@private
function Repo:_stop_modules_watcher()
local dir = vim.fs.joinpath(self.gitdir, "modules")
local w = self._watchers[dir]
if w then
w:stop()
w:close()
self._watchers[dir] = nil
end
for _, name in ipairs(vim.tbl_keys(self._submodules)) do
self:_unregister_submodule(name)
end
end
---@private
---@param relpath string
function Repo:_handle_fs_event(relpath)
if vim.startswith(relpath, "objects") then
return
end
self:_invalidate(relpath)
if relpath == "modules" and vim.g.git_submodule_recursion ~= false then
if vim.uv.fs_stat(vim.fs.joinpath(self.gitdir, "modules")) then
self:_start_modules_watcher()
for _, name in ipairs(find_submodules(self.gitdir)) do
self:_register_submodule(name)
end
else
self:_stop_modules_watcher()
end
end
if vim.startswith(relpath, "logs") then
return
end
self:refresh()
end
---@private
---@param relpath string gitdir-relative path of the directory to watch
function Repo:_watch_tree(relpath)
local path = vim.fs.joinpath(self.gitdir, relpath)
if self._watchers[path] then
return
end
local stat = vim.uv.fs_stat(path)
if not stat or stat.type ~= "directory" then
return
end
local watcher = start_fs_event(path, function(filename)
if not vim.uv.fs_stat(path) then
local w = self._watchers[path] --[[@as uv.uv_fs_event_t?]]
if w then
w:stop()
w:close()
self._watchers[path] = nil
end
return
end
if filename then
local child = vim.fs.joinpath(relpath, filename)
self:_handle_fs_event(child)
vim.schedule(function()
self:_watch_tree(child)
end)
else
self:refresh({ invalidate = true })
end
end)
if not watcher then
return
end
self._watchers[path] = watcher
local handle = vim.uv.fs_scandir(path)
if not handle then
return
end
while true do
local name, typ = vim.uv.fs_scandir_next(handle)
if not name then
break
end
if typ == "directory" then
self:_watch_tree(vim.fs.joinpath(relpath, name))
end
end
end
function Repo:start_watcher()
self._watchers = {}
local top = start_fs_event(self.gitdir, function(filename)
if not filename then
self:refresh({ invalidate = true })
return
end
self:_handle_fs_event(filename)
end)
if not top then
util.error("git: failed to watch %s", self.gitdir)
return
end
self._watchers[self.gitdir] = top
self:_watch_tree("refs")
end
function Repo:close()
for _, watcher in pairs(self._watchers) do
watcher:stop()
watcher:close()
end
self._watchers = {}
self:_stop_modules_watcher()
self._refresh_handle.close()
self._events:clear()
end
---@overload fun(event: "change", fn: fun(change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
function Repo:on(event, fn)
return self._events:on(event, fn)
end
---@param buf? integer
---@return ow.Git.Repo.BufState?
function Repo:state(buf)
return self.buffers[expand_buf(buf)]
end
---@return string?
function Repo:head()
return self:get_cached("head", function(self)
local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r")
if not f then
return nil
end
local first = f:read("*l")
f:close()
if not first then
return nil
end
local branch = first:match("^ref:%s*refs/heads/(%S+)")
if branch then
return branch
end
local sha = first:match("^(%x+)")
if sha then
return sha:sub(1, 7)
end
return nil
end)
end
---@return string[]
function Repo:list_refs()
return self:get_cached("refs", function(self)
local out = util.git({
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
"refs/tags",
"refs/remotes",
}, { cwd = self.worktree, silent = true })
if not out then
return {}
end
return util.split_lines(out)
end)
end
---@return string[]
function Repo:list_pseudo_refs()
return self:get_cached("pseudo_refs", function(self)
local refs = {}
for _, name in ipairs(PSEUDO_REFS) do
if name == "HEAD" or vim.uv.fs_stat(self.gitdir .. "/" .. name) then
table.insert(refs, name)
end
end
return refs
end)
end
---@return string[]
function Repo:list_stash_refs()
return self:get_cached("stash_refs", function(self)
if not vim.uv.fs_stat(self.gitdir .. "/refs/stash") then
return {}
end
local refs = { "stash" }
local out = util.git(
{ "stash", "list", "--pretty=format:%gd" },
{ cwd = self.worktree, silent = true }
)
if out then
for _, entry in ipairs(util.split_lines(out)) do
table.insert(refs, entry)
end
end
return refs
end)
end
---@param rev string
---@param short boolean
---@return string?
function Repo:rev_parse(rev, short)
local args = { "rev-parse", "--verify", "--quiet" }
if short then
table.insert(args, "--short")
end
table.insert(args, rev)
local stdout = util.git(args, { cwd = self.worktree, silent = true })
local trimmed = stdout and vim.trim(stdout) or ""
return trimmed ~= "" and trimmed or nil
end
---@param rel string worktree-relative path
---@return string?
function Repo:index_sha(rel)
local sha = self:get_cached("index:" .. rel, function(self)
return self:rev_parse(":" .. rel, false) or false
end)
return sha or nil
end
---@param rel string worktree-relative path
---@return string?
function Repo:head_sha(rel)
local sha = self:get_cached("head_blob:" .. rel, function(self)
return self:rev_parse("HEAD:" .. rel, false) or false
end)
return sha or nil
end
---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing"
---@param abbrev string
---@return string? full_sha
---@return ow.Git.Repo.ResolveStatus
function Repo:resolve_sha(abbrev)
local result = self:get_cached("resolve:" .. abbrev, function(self)
local out = util.git(
{ "rev-parse", "--disambiguate=" .. abbrev },
{ cwd = self.worktree, silent = true }
)
local trimmed = out and vim.trim(out) or ""
if trimmed == "" then
return { nil, "missing" }
end
local lines = util.split_lines(trimmed)
if #lines == 1 then
return { lines[1], "ok" }
end
return { nil, "ambiguous" }
end)
return result[1], result[2]
end
---@private
---@return table<string, table<string, string>>
function Repo:_config()
return self:get_cached("config", function(self)
return read_git_config(vim.fs.joinpath(self.gitdir, "config")) or {}
end)
end
---@private
---@return boolean
function Repo:_ignorecase()
local cfg = self:_config()
return cfg.core and cfg.core.ignorecase == "true" or false
end
---@param rel string
---@return ow.Git.Status.Entry?
function Repo:status_entry_for(rel)
local direct = self.status.entries[rel]
if direct or not self:_ignorecase() then
return direct
end
local lower = rel:lower()
for path, entry in pairs(self.status.entries) do
if path:lower() == lower then
return entry
end
end
return nil
end
---@type table<string, true>
local no_repo_dirs = {}
---@overload fun(event: "change", fn: fun(r: ow.Git.Repo, change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
function M.on(event, fn)
return global:on(event, fn)
end
---@param prefix string
---@param fn fun(buf: integer, r: ow.Git.Repo)
---@return fun() unsubscribe
function M.on_uri_change(prefix, fn)
return M.on("change", function(r)
for buf in pairs(r.buffers) do
if vim.api.nvim_buf_is_loaded(buf) then
local name = vim.api.nvim_buf_get_name(buf)
if name:sub(1, #prefix) == prefix then
fn(buf, r)
end
end
end
end)
end
---@return table<string, ow.Git.Repo>
function M.all()
return repos
end
---@param buf integer
---@return ow.Git.Repo?
local function find_by_buf(buf)
for _, r in pairs(repos) do
if r.buffers[buf] then
return r
end
end
return nil
end
---@param path string
---@return ow.Git.Repo?
local function find_by_path(path)
if path == "" then
return nil
end
if repos[path] then
return repos[path]
end
local best
for wt in pairs(repos) do
if path:sub(1, #wt + 1) == wt .. "/" then
if not best or #wt > #best then
best = wt
end
end
end
return best and repos[best] or nil
end
---@param buf integer
---@return string
local function path_for_buf(buf)
local path = vim.api.nvim_buf_get_name(buf)
if path == "" or util.is_uri(path) then
return vim.fn.getcwd()
end
return vim.fn.resolve(path)
end
---@param arg? integer | string bufnr (default current) or worktree path
---@return ow.Git.Repo?
function M.find(arg)
if type(arg) == "string" then
return find_by_path(arg)
end
local buf = expand_buf(arg)
return find_by_buf(buf) or find_by_path(path_for_buf(buf))
end
---@param arg? integer | string bufnr (default current) or worktree path
---@return ow.Git.Repo?
function M.resolve(arg)
if type(arg) ~= "string" then
local existing = find_by_buf(expand_buf(arg))
if existing then
return existing
end
end
local path
if type(arg) == "string" then
path = vim.fn.resolve(arg)
else
path = path_for_buf(expand_buf(arg))
end
local dir = vim.fs.dirname(path)
if no_repo_dirs[dir] then
return nil
end
local found = vim.fs.find(".git", { upward = true, path = path })[1]
if not found then
no_repo_dirs[dir] = true
return nil
end
local worktree = vim.fs.dirname(found)
if repos[worktree] then
return repos[worktree]
end
local stat = vim.uv.fs_stat(found)
if not stat then
return nil
end
local gitdir
if stat.type == "directory" then
gitdir = found
else
local f = io.open(found, "r")
if not f then
return nil
end
local content = f:read("*a")
f:close()
local rel = content:match("gitdir:%s*(%S+)")
if not rel then
util.error(".git file at %s has no `gitdir:` line", found)
return nil
end
gitdir = vim.fs.normalize(
rel:match("^/") and rel or vim.fs.joinpath(worktree, rel)
)
end
local r = Repo.new(gitdir, worktree)
repos[worktree] = r
for d in pairs(no_repo_dirs) do
if d == worktree or vim.startswith(d, worktree .. "/") then
no_repo_dirs[d] = nil
end
end
return r
end
---@param buf? integer
---@return ow.Git.Repo.BufState?
function M.state(buf)
buf = expand_buf(buf)
local r = find_by_buf(buf)
return r and r.buffers[buf]
end
---@param buf? integer
---@param r ow.Git.Repo
function M.bind(buf, r)
buf = expand_buf(buf)
local prev = find_by_buf(buf)
if prev == r then
return
end
if prev then
prev.buffers[buf] = nil
release_if_unused(prev)
end
r.buffers[buf] = { repo = r }
end
---@param buf? integer
function M.unbind(buf)
buf = expand_buf(buf)
local r = find_by_buf(buf)
if not r then
return
end
r.buffers[buf] = nil
release_if_unused(r)
end
---@param buf integer
---@return boolean
function M.is_worktree_buf(buf)
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
return false
end
local path = vim.api.nvim_buf_get_name(buf)
return path ~= "" and not util.is_uri(path)
end
---@param buf? integer
function M.track(buf)
buf = expand_buf(buf)
if not M.is_worktree_buf(buf) then
return
end
local r = M.resolve(buf)
if r and not r.buffers[buf] then
M.bind(buf, r)
end
end
---@param buf? integer
function M.refresh(buf)
local r = find_by_buf(expand_buf(buf))
if r then
r:refresh()
end
end
function M.refresh_all()
for _, r in pairs(repos) do
r:refresh()
end
end
function M.update_cwd_repo()
no_repo_dirs = {}
local tab = vim.api.nvim_get_current_tabpage()
local new = M.resolve(vim.fn.getcwd())
local old
for _, r in pairs(repos) do
if r.tabs[tab] then
old = r
break
end
end
if new == old then
return
end
if old then
old.tabs[tab] = nil
release_if_unused(old)
end
if new then
new.tabs[tab] = true
new:refresh()
end
end
---@param tab integer
function M.release_tab(tab)
for _, r in pairs(repos) do
if r.tabs[tab] then
r.tabs[tab] = nil
release_if_unused(r)
return
end
end
end
function M.stop_all()
for _, r in pairs(repos) do
r:close()
end
end
return M
-45
View File
@@ -1,45 +0,0 @@
---@class ow.Git.Revision
---@field stage 0|1|2|3?
---@field path string?
---@field base string?
local Revision = {}
Revision.__index = Revision
---@return string
function Revision:format()
if self.stage then
return ":" .. self.stage .. ":" .. self.path
elseif self.path then
return self.base .. ":" .. self.path
end
return self.base or error("Revision:format: empty Revision")
end
---@param parts { stage?: integer, base?: string, path?: string }
---@return ow.Git.Revision
function Revision.new(parts)
return setmetatable(parts, Revision)
end
---@param str string
---@return ow.Git.Revision
function Revision.parse(str)
local stage, path = str:match("^:([0123]):(.+)$")
if stage then
return Revision.new({
stage = tonumber(stage) --[[@as (0|1|2|3)?]],
path = path,
})
end
path = str:match("^:([^:]+)$")
if path then
return Revision.new({ stage = 0, path = path })
end
local base, p = str:match("^([^:]+):(.+)$")
if base then
return Revision.new({ base = base, path = p })
end
return Revision.new({ base = str })
end
return Revision
-383
View File
@@ -1,383 +0,0 @@
local M = {}
---@alias ow.Git.Status.Kind
---| "changed"
---| "unmerged"
---| "untracked"
---| "ignored"
---@class ow.Git.Status.Entry
---@field kind ow.Git.Status.Kind
---@field path string
---@alias ow.Git.Status.Change
---| "modified"
---| "added"
---| "deleted"
---| "renamed"
---| "copied"
---| "type_changed"
---@class ow.Git.Status.ChangedEntry: ow.Git.Status.Entry
---@field kind "changed"
---@field staged ow.Git.Status.Change?
---@field unstaged ow.Git.Status.Change?
---@field orig string?
---@alias ow.Git.Status.Conflict
---| "both_deleted"
---| "added_by_us"
---| "deleted_by_them"
---| "added_by_them"
---| "deleted_by_us"
---| "both_added"
---| "both_modified"
---@class ow.Git.Status.UnmergedEntry: ow.Git.Status.Entry
---@field kind "unmerged"
---@field conflict ow.Git.Status.Conflict
---@class ow.Git.Status.UntrackedEntry: ow.Git.Status.Entry
---@field kind "untracked"
---@class ow.Git.Status.IgnoredEntry: ow.Git.Status.Entry
---@field kind "ignored"
---@class ow.Git.Status.Mark
---@field char string
---@field hl string
---@alias ow.Git.Status.Section
--- "staged"|"unstaged"|"unmerged"|"untracked"|"ignored"
---@class ow.Git.Status.Row
---@field entry ow.Git.Status.Entry
---@field section ow.Git.Status.Section
---@field side ("staged"|"unstaged")?
---@class ow.Git.Status.Branch
---@field oid string?
---@field head string?
---@field upstream string?
---@field ahead integer
---@field behind integer
---@class ow.Git.Status
---@field branch ow.Git.Status.Branch
---@field entries table<string, ow.Git.Status.Entry>
local Status = {}
Status.__index = Status
local CHANGE_FROM_CHAR = {
M = "modified",
A = "added",
D = "deleted",
R = "renamed",
C = "copied",
T = "type_changed",
}
local CONFLICT_FROM_XY = {
DD = "both_deleted",
AU = "added_by_us",
UD = "deleted_by_them",
UA = "added_by_them",
DU = "deleted_by_us",
AA = "both_added",
UU = "both_modified",
}
local CHAR_FROM_CHANGE = {
modified = "M",
added = "A",
deleted = "D",
renamed = "R",
copied = "C",
type_changed = "T",
}
---@param s string
---@return string
local function pascal(s)
return (
s:sub(1, 1):upper()
.. s:sub(2):gsub("_(%a)", function(c)
return c:upper()
end)
)
end
---@param path string
---@param staged ow.Git.Status.Change?
---@param unstaged ow.Git.Status.Change?
---@param orig string?
---@return ow.Git.Status.ChangedEntry
local function changed(path, staged, unstaged, orig)
return {
kind = "changed",
path = path,
staged = staged,
unstaged = unstaged,
orig = orig,
}
end
---@param path string
---@param conflict ow.Git.Status.Conflict
---@return ow.Git.Status.UnmergedEntry
local function unmerged(path, conflict)
return { kind = "unmerged", path = path, conflict = conflict }
end
---@param path string
---@return ow.Git.Status.UntrackedEntry
local function untracked(path)
return { kind = "untracked", path = path }
end
---@param path string
---@return ow.Git.Status.IgnoredEntry
local function ignored(path)
return { kind = "ignored", path = path }
end
---@param entry ow.Git.Status.Entry
---@param side ("staged"|"unstaged")?
---@return ow.Git.Status.Mark
function M.mark_for(entry, side)
if entry.kind == "untracked" then
return { char = "?", hl = "GitUntracked" }
end
if entry.kind == "ignored" then
return { char = "i", hl = "GitIgnored" }
end
if entry.kind == "unmerged" then
---@cast entry ow.Git.Status.UnmergedEntry
return { char = "!", hl = "GitUnmerged" .. pascal(entry.conflict) }
end
---@cast entry ow.Git.Status.ChangedEntry
assert(side, "mark_for: side required for changed entry")
local change = side == "staged" and entry.staged or entry.unstaged
assert(change, "mark_for: changed entry has no change on side " .. side)
return {
char = CHAR_FROM_CHANGE[change],
hl = "Git" .. pascal(side) .. pascal(change),
}
end
---@param entry ow.Git.Status.Entry
---@return ow.Git.Status.Mark[]
function M.marks_for(entry)
if entry.kind ~= "changed" then
return { M.mark_for(entry) }
end
---@cast entry ow.Git.Status.ChangedEntry
local out = {}
if entry.staged then
table.insert(out, M.mark_for(entry, "staged"))
end
if entry.unstaged then
table.insert(out, M.mark_for(entry, "unstaged"))
end
return out
end
---@param section ow.Git.Status.Section
---@return ow.Git.Status.Row[]
function Status:rows(section)
local out = {}
if section == "staged" or section == "unstaged" then
for _, entry in pairs(self.entries) do
if entry.kind == "changed" then
---@cast entry ow.Git.Status.ChangedEntry
if entry[section] then
table.insert(
out,
{ entry = entry, section = section, side = section }
)
end
end
end
else
for _, entry in pairs(self.entries) do
if entry.kind == section then
table.insert(out, { entry = entry, section = section })
end
end
end
return out
end
---@param prefix string
---@return ow.Git.Status.Mark[]
function Status:aggregate_at(prefix)
local match = (prefix == "" or prefix == ".") and "" or prefix .. "/"
local seen = {}
local out = {}
for path, entry in pairs(self.entries) do
if path == prefix or vim.startswith(path, match) then
for _, mark in ipairs(M.marks_for(entry)) do
local key = mark.char .. "\0" .. mark.hl
if not seen[key] then
seen[key] = true
table.insert(out, mark)
end
end
end
end
table.sort(out, function(a, b)
return a.char < b.char
end)
return out
end
---@param line string
---@param branch ow.Git.Status.Branch
local function parse_branch_header(line, branch)
local oid = line:match("^# branch%.oid (.+)$")
if oid then
branch.oid = oid ~= "(initial)" and oid or nil
return
end
local head = line:match("^# branch%.head (.+)$")
if head then
branch.head = head ~= "(detached)" and head or nil
return
end
local up = line:match("^# branch%.upstream (.+)$")
if up then
branch.upstream = up
return
end
local a, b = line:match("^# branch%.ab %+(%d+) %-(%d+)$")
if a and b then
branch.ahead = tonumber(a) --[[@as integer]]
branch.behind = tonumber(b) --[[@as integer]]
end
end
---@param x string
---@param y string
---@return ow.Git.Status.Change?, ow.Git.Status.Change?
local function changes_from_xy(x, y)
local staged = x ~= "." and CHANGE_FROM_CHAR[x] or nil
local unstaged = y ~= "." and CHANGE_FROM_CHAR[y] or nil
return staged, unstaged
end
---@param path string
---@return string
local function strip_dir_slash(path)
if path:sub(-1) == "/" then
return path:sub(1, -2)
end
return path
end
---@param a ow.Git.Status.Entry?
---@param b ow.Git.Status.Entry?
---@return boolean
function M.entry_equal(a, b)
if a == nil or b == nil then
return a == b
end
if a.kind ~= b.kind or a.path ~= b.path then
return false
end
if a.kind == "changed" then
---@cast a ow.Git.Status.ChangedEntry
---@cast b ow.Git.Status.ChangedEntry
return a.staged == b.staged
and a.unstaged == b.unstaged
and a.orig == b.orig
end
if a.kind == "unmerged" then
---@cast a ow.Git.Status.UnmergedEntry
---@cast b ow.Git.Status.UnmergedEntry
return a.conflict == b.conflict
end
return true
end
---@param prior table<string, ow.Git.Status.Entry>
---@param next_ table<string, ow.Git.Status.Entry>
---@return table<string, true>
function M.diff_entries(prior, next_)
local paths = {}
for path, entry in pairs(next_) do
if not M.entry_equal(prior[path], entry) then
paths[path] = true
end
end
for path in pairs(prior) do
if next_[path] == nil then
paths[path] = true
end
end
return paths
end
---@param stdout string
---@return ow.Git.Status
function M.parse(stdout)
---@type ow.Git.Status.Branch
local branch = { ahead = 0, behind = 0 }
---@type table<string, ow.Git.Status.Entry>
local entries = {}
local tokens = vim.split(stdout, "\0", { plain = true })
while #tokens > 0 and tokens[#tokens] == "" do
tokens[#tokens] = nil
end
local i = 1
while i <= #tokens do
local line = tokens[i] --[[@as string]]
local tag = line:sub(1, 2)
if tag == "# " then
parse_branch_header(line, branch)
elseif tag == "1 " then
local xy, _, _, _, _, _, _, path =
line:match("^1 (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$")
if xy and path then
local key = strip_dir_slash(path)
local staged, unstaged =
changes_from_xy(xy:sub(1, 1), xy:sub(2, 2))
entries[key] = changed(key, staged, unstaged)
end
elseif tag == "2 " then
local xy, _, _, _, _, _, _, _, path = line:match(
"^2 (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$"
)
local orig = tokens[i + 1]
if xy and path and orig then
local key = strip_dir_slash(path)
local staged, unstaged =
changes_from_xy(xy:sub(1, 1), xy:sub(2, 2))
entries[key] = changed(key, staged, unstaged, orig)
i = i + 1
end
elseif tag == "u " then
local xy, _, _, _, _, _, _, _, _, path = line:match(
"^u (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$"
)
local conflict = xy and CONFLICT_FROM_XY[xy] --[[@as ow.Git.Status.Conflict?]]
or nil
if conflict and path then
local key = strip_dir_slash(path)
entries[key] = unmerged(key, conflict)
end
elseif tag == "? " then
local key = strip_dir_slash(line:sub(3))
entries[key] = untracked(key)
elseif tag == "! " then
local key = strip_dir_slash(line:sub(3))
entries[key] = ignored(key)
end
i = i + 1
end
return setmetatable({ branch = branch, entries = entries }, Status)
end
return M
-352
View File
@@ -1,352 +0,0 @@
local M = {}
---@class ow.Git.Util.ScratchOpts
---@field name string?
---@field bufhidden ("hide"|"wipe"|"delete")?
---@field buftype ("nofile"|"acwrite"|"nowrite")?
---@field modifiable boolean?
---@param buf integer
---@param opts ow.Git.Util.ScratchOpts
function M.setup_scratch(buf, opts)
vim.bo[buf].buftype = opts.buftype or "nofile"
vim.bo[buf].bufhidden = opts.bufhidden or "wipe"
vim.bo[buf].swapfile = false
vim.bo[buf].modifiable = opts.modifiable == true
vim.bo[buf].modified = false
vim.bo[buf].buflisted = false
if opts.name then
pcall(vim.api.nvim_buf_set_name, buf, opts.name)
end
end
---@param name string
---@return boolean
function M.is_uri(name)
return name:match("^%a+://") ~= nil
end
---@param sha string?
---@return boolean
function M.is_zero_sha(sha)
return sha == nil or sha:match("^0+$") ~= nil
end
---@param buf integer
---@param name string
function M.set_buf_name(buf, name)
pcall(vim.api.nvim_buf_set_name, buf, name)
local ft = vim.filetype.match({ buf = buf })
if ft then
vim.bo[buf].filetype = ft
end
end
---@param buf integer
---@param split (false|"above"|"below"|"left"|"right")?
---@return integer win
function M.place_buf(buf, split)
if split == false then
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(buf)
return vim.api.nvim_get_current_win()
end
local win = vim.api.nvim_open_win(buf, true, {
split = split or (vim.o.splitbelow and "below" or "above"),
})
vim.cmd.clearjumps()
return win
end
---@class ow.Git.Util.NewScratchOpts : ow.Git.Util.ScratchOpts
---@field split (false|"above"|"below"|"left"|"right")?
---@param opts ow.Git.Util.NewScratchOpts?
---@return integer buf
---@return integer win
function M.new_scratch(opts)
opts = opts or {}
local buf = vim.api.nvim_create_buf(false, true)
M.setup_scratch(buf, opts)
return buf, M.place_buf(buf, opts.split)
end
---@param fmt string
---@param ... any
function M.error(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.ERROR)
end
---@param fmt string
---@param ... any
function M.warning(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.WARN)
end
---@param fmt string
---@param ... any
function M.info(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.INFO)
end
---@param fmt string
---@param ... any
function M.debug(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.DEBUG)
end
---@param buf integer
---@param start integer
---@param end_ integer
---@param lines string[]
function M.set_buf_lines(buf, start, end_, lines)
if not vim.api.nvim_buf_is_loaded(buf) then
return
end
local was_modifiable = vim.bo[buf].modifiable
vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(buf, start, end_, true, lines)
vim.bo[buf].modifiable = was_modifiable
vim.bo[buf].modified = false
end
---@param content string
---@return string[]
function M.split_lines(content)
local lines = vim.split(content, "\n", { plain = true, trimempty = false })
if #lines > 0 and lines[#lines] == "" then
table.remove(lines)
end
return lines
end
---@class ow.Git.Util.DebounceHandle
---@field cancel fun()
---@field flush fun()
---@field pending fun(): boolean
---@field close fun()
---@generic F: fun(...)
---@param fn F
---@param delay integer
---@return F, ow.Git.Util.DebounceHandle
function M.debounce(fn, delay)
local timer, err = vim.uv.new_timer()
if not timer then
M.warning("git: failed to create timer: %s", err)
local noop = function() end
return fn,
{
cancel = noop,
flush = noop,
pending = function()
return false
end,
close = noop,
}
end
local args ---@type table?
local gen = 0
local fired_gen = 0
local cb_main = vim.schedule_wrap(function()
-- Identity check: the libuv fire may have been superseded by
-- a re-arm or a cancel between the timer firing and this
-- scheduled callback running.
if fired_gen ~= gen or args == nil then
return
end
local a = args
args = nil
fn(vim.F.unpack_len(a))
end)
local cb_uv = function()
fired_gen = gen
cb_main()
end
local function call(...)
args = vim.F.pack_len(...)
gen = gen + 1
timer:start(delay, 0, cb_uv)
end
return call,
{
cancel = function()
timer:stop()
args = nil
end,
flush = function()
if args == nil then
return
end
timer:stop()
local a = args
args = nil
fn(vim.F.unpack_len(a))
end,
pending = function()
return args ~= nil
end,
close = function()
timer:stop()
if not timer:is_closing() then
timer:close()
end
args = nil
end,
}
end
---@class ow.Git.Util.KeyedDebounceHandle<K>
---@field cancel fun(key: K)
---@field flush fun(key: K)
---@field pending fun(key: K): boolean
---@field close fun()
---@generic K, F: fun(key: K, ...)
---@param fn F
---@param delay integer
---@return F, ow.Git.Util.KeyedDebounceHandle<K>
function M.keyed_debounce(fn, delay)
---@type table<any, { call: fun(...), handle: ow.Git.Util.DebounceHandle }>
local slots = {}
local function call(key, ...)
local t = type(key)
assert(
t == "string" or t == "number" or t == "boolean",
"key must be a primitive (string, number, boolean)"
)
local slot = slots[key]
if not slot then
local c, h = M.debounce(function(...)
fn(key, ...)
end, delay)
slot = { call = c, handle = h }
slots[key] = slot
end
slot.call(...)
end
return call,
{
cancel = function(key)
local slot = slots[key]
if slot then
slot.handle.close()
slots[key] = nil
end
end,
flush = function(key)
local slot = slots[key]
if slot then
slot.handle.flush()
end
end,
pending = function(key)
local slot = slots[key]
return slot ~= nil and slot.handle.pending()
end,
close = function()
for _, slot in pairs(slots) do
slot.handle.close()
end
slots = {}
end,
}
end
---@class ow.Git.Util.ExecOpts
---@field cwd string?
---@field stdin string?
---@field silent boolean?
---@field env table<string, string>?
---@field on_exit fun(result: vim.SystemCompleted)?
---@param cmd string[]
---@param opts ow.Git.Util.ExecOpts?
---@return string?
function M.exec(cmd, opts)
opts = opts or {}
local sys_opts = {
cwd = opts.cwd,
stdin = opts.stdin,
env = opts.env,
text = true,
}
if opts.on_exit then
vim.system(cmd, sys_opts, vim.schedule_wrap(opts.on_exit))
return nil
end
local result = vim.system(cmd, sys_opts):wait()
if result.code ~= 0 then
if not opts.silent then
local label = cmd[2] and (cmd[1] .. " " .. cmd[2]) or cmd[1] or "?"
M.error("%s failed: %s", label, vim.trim(result.stderr or ""))
end
return nil
end
return result.stdout or ""
end
M.DEFAULT_GIT_ENV = {
GIT_TERMINAL_PROMPT = "false",
}
---@param args string[]
---@param opts ow.Git.Util.ExecOpts?
---@return string?
function M.git(args, opts)
opts = opts or {}
opts.env = vim.tbl_extend("force", M.DEFAULT_GIT_ENV, opts.env or {})
local cmd = { "git" }
vim.list_extend(cmd, args)
return M.exec(cmd, opts)
end
---@class ow.Git.Util.Emitter<T>
---@field private _listeners table<T, (fun(...))[]>
local Emitter = {}
Emitter.__index = Emitter
---@return ow.Git.Util.Emitter<T>
function Emitter.new()
return setmetatable({ _listeners = {} }, Emitter)
end
---@param event T
---@param fn fun(...)
---@return fun() unsubscribe
function Emitter:on(event, fn)
local list = self._listeners[event] or {}
self._listeners[event] = list
table.insert(list, fn)
return function()
for i, f in ipairs(list) do
if f == fn then
table.remove(list, i)
return
end
end
end
end
---@param event T
function Emitter:emit(event, ...)
for _, fn in ipairs(self._listeners[event] or {}) do
fn(...)
end
end
function Emitter:clear()
self._listeners = {}
end
M.Emitter = Emitter
return M
-134
View File
@@ -1,134 +0,0 @@
local Revision = require("git.core.revision")
local object = require("git.object")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
---@class ow.Git.Diffsplit.OpenOpts
---@field target string?
---@field mods vim.api.keyset.cmd.mods?
---@param cur_buf integer
---@return string? target
---@return string? err
local function infer_target(cur_buf)
local cur_name = vim.api.nvim_buf_get_name(cur_buf)
local cur_rev = object.parse_uri(cur_name)
if cur_rev then
local r = repo.resolve(cur_buf)
if not r then
return nil, "git URI buffer has no worktree"
end
if not cur_rev.path then
return nil, "git URI has no path, cannot diff against worktree"
end
local worktree_path = vim.fs.joinpath(r.worktree, cur_rev.path)
if not vim.uv.fs_stat(worktree_path) then
return nil, "worktree file does not exist: " .. cur_rev.path
end
return worktree_path, nil
end
if cur_name == "" then
return nil, "no file in current buffer"
end
if vim.bo[cur_buf].buftype ~= "" then
return nil, "cannot diff this buffer (not a worktree file)"
end
local resolved = vim.fn.resolve(cur_name)
local r = repo.resolve(resolved)
if not r then
return nil, "not in a git repository"
end
local rel = vim.fs.relpath(r.worktree, resolved)
if not rel then
return nil, "current buffer is outside the worktree"
end
return object.format_uri(Revision.new({ stage = 0, path = rel })), nil
end
---@param target string
---@param cur_buf integer
---@return string? resolved
---@return string? err
local function resolve_target(target, cur_buf)
if vim.startswith(target, object.URI_PREFIX) then
return target, nil
end
if vim.fn.filereadable(target) == 1 then
return target, nil
end
local cur_name = vim.api.nvim_buf_get_name(cur_buf)
local cur_rev = object.parse_uri(cur_name)
local r, rel
if cur_rev and cur_rev.path then
r = repo.resolve(cur_buf)
rel = cur_rev.path
elseif cur_name ~= "" then
local resolved = vim.fn.resolve(cur_name)
r = repo.resolve(resolved)
if r then
rel = vim.fs.relpath(r.worktree, resolved)
end
end
if not r then
return nil, "not in a git repository"
end
if not rel then
return nil, "current buffer has no path"
end
if not r:rev_parse(target, true) then
return nil, "invalid rev: " .. target
end
return object.format_uri(Revision.new({ base = target, path = rel })), nil
end
---@param cur_buf integer
---@param target string
---@return 'aboveleft'|'belowright'|nil
local function default_split(cur_buf, target)
local cur_rev = object.parse_uri(vim.api.nvim_buf_get_name(cur_buf))
local target_rev = object.parse_uri(target)
if not cur_rev and target_rev then
return "aboveleft"
end
if cur_rev and not target_rev then
return "belowright"
end
if cur_rev and target_rev then
if cur_rev.stage == 0 and target_rev.base then
return "aboveleft"
end
if cur_rev.base and target_rev.stage == 0 then
return "belowright"
end
end
return nil
end
---@param opts? ow.Git.Diffsplit.OpenOpts
function M.open(opts)
opts = opts or {}
local cur_buf = vim.api.nvim_get_current_buf()
local target, err
if opts.target then
target, err = resolve_target(opts.target, cur_buf)
else
target, err = infer_target(cur_buf)
end
if not target then
util.error("%s", err or "no diff target")
return
end
local mods = opts.mods
if not mods or mods.split == nil then
local placement = default_split(cur_buf, target)
if placement then
mods = vim.tbl_extend("force", mods or {}, { split = placement })
end
end
vim.cmd.diffsplit({ args = { target }, mods = mods })
end
return M
-117
View File
@@ -1,117 +0,0 @@
local util = require("git.core.util")
local M = {}
local SENTINEL = "__NVIM_GIT_EDIT__"
local SCRIPT = string.format(
[=[set -eu
flag="${TMPDIR:-/tmp}/nvim-git-editor-$$.done"
trap 'rm -f "$flag"' EXIT
abs=$(realpath "$1")
printf '%s\t%%s\t%%s\n' "$flag" "$abs" >&2
while [ ! -e "$flag" ]; do
sleep 0.05
done
]=],
SENTINEL
)
---@param s string
---@return string
local function shq(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
local GIT_EDITOR = "sh -c " .. shq(SCRIPT) .. " --"
---@param on_open fun(file_path: string, done: fun())
---@return fun(err: string?, data: string?), fun(result: vim.SystemCompleted)
local function build_stderr_handler(on_open)
local pending = ""
local stderr_buf = {}
local function dispatch(flag_path, abs_path)
vim.schedule(function()
local fired = false
local function done()
if fired then
return
end
fired = true
local fw = io.open(flag_path, "w")
if fw then
fw:close()
end
end
local ok, err = pcall(on_open, abs_path, done)
if not ok then
util.error("git.editor on_open failed: %s", tostring(err))
done()
end
end)
end
local pattern = "^" .. SENTINEL .. "\t(.-)\t(.+)$"
local function on_stderr(_, data)
if not data or data == "" then
return
end
pending = pending .. data
while true do
local nl = pending:find("\n", 1, true)
if not nl then
break
end
local line = pending:sub(1, nl - 1)
pending = pending:sub(nl + 1)
local flag, abs = line:match(pattern)
if flag then
dispatch(flag, abs)
else
table.insert(stderr_buf, line)
table.insert(stderr_buf, "\n")
end
end
end
local function finalize(result)
if pending ~= "" then
table.insert(stderr_buf, pending)
end
result.stderr = table.concat(stderr_buf)
end
return on_stderr, finalize
end
---@param cmd string[]
---@param opts? { cwd?: string, env?: table<string,string> }
---@param on_open fun(file_path: string, done: fun())
---@param on_exit fun(result: vim.SystemCompleted)
function M.run(cmd, opts, on_open, on_exit)
opts = opts or {}
local on_stderr, finalize = build_stderr_handler(on_open)
local env = vim.tbl_extend("force", opts.env or {}, {
GIT_EDITOR = GIT_EDITOR,
GIT_SEQUENCE_EDITOR = GIT_EDITOR,
})
vim.system(
cmd,
{
cwd = opts.cwd,
text = true,
env = env,
stderr = on_stderr,
},
vim.schedule_wrap(function(result)
finalize(result)
on_exit(result)
end)
)
end
return M
-970
View File
@@ -1,970 +0,0 @@
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
local NS_SIGNS = vim.api.nvim_create_namespace("ow.git.hunks")
local NS_OVERLAY = vim.api.nvim_create_namespace("ow.git.hunks.overlay")
---@alias ow.Git.Hunks.HunkType "add"|"change"|"delete"
---@class ow.Git.Hunks.Hunk
---@field old_start integer 1-indexed first old line
---@field old_count integer
---@field new_start integer 1-indexed first new line
---@field new_count integer
---@field type ow.Git.Hunks.HunkType
---@field old_lines string[]
---@field new_lines string[]
---@class ow.Git.Hunks.BufState
---@field repo ow.Git.Repo
---@field rel string
---@field index string[]?
---@field index_sha string?
---@field head string[]?
---@field head_sha string?
---@field index_hl { src: string[], lines: table[][]? }?
---@field hunks ow.Git.Hunks.Hunk[]
---@field staged ow.Git.Hunks.Hunk[]
---@field overlay boolean
---@field autocmds integer[]
---@type table<integer, ow.Git.Hunks.BufState>
local states = {}
---@param buf integer
---@return ow.Git.Hunks.BufState?
function M.state(buf)
return states[buf]
end
---@param buf integer?
---@return integer
local function resolve_buf(buf)
return buf and buf ~= 0 and buf or vim.api.nvim_get_current_buf()
end
---Mirror the hunk-affecting parts of the user's 'diffopt' so the gutter
---lines up with what `:diffsplit` shows.
---@return table
local function diff_opts()
local opts = { result_type = "indices", algorithm = "myers" }
for _, item in ipairs(vim.split(vim.o.diffopt, ",", { plain = true })) do
if item == "indent-heuristic" then
opts.indent_heuristic = true
else
local algorithm = item:match("^algorithm:(.+)$")
if algorithm then
opts.algorithm = algorithm
end
local linematch = item:match("^linematch:(%d+)$")
if linematch then
opts.linematch = tonumber(linematch)
end
end
end
return opts
end
---@param old_lines string[]
---@param new_lines string[]
---@return ow.Git.Hunks.Hunk[]
local function compute_hunks(old_lines, new_lines)
local raw = vim.text.diff(
table.concat(old_lines, "\n"),
table.concat(new_lines, "\n"),
diff_opts()
)
---@type ow.Git.Hunks.Hunk[]
local hunks = {}
if type(raw) ~= "table" then
return hunks
end
for _, h in ipairs(raw) do
local os_ = h[1] --[[@as integer]]
local oc = h[2] --[[@as integer]]
local ns_ = h[3] --[[@as integer]]
local nc = h[4] --[[@as integer]]
local typ ---@type ow.Git.Hunks.HunkType
if oc == 0 then
typ = "add"
elseif nc == 0 then
typ = "delete"
else
typ = "change"
end
local old = {}
if typ ~= "add" then
for i = os_, os_ + oc - 1 do
table.insert(old, old_lines[i] or "")
end
end
local new = {}
if typ ~= "delete" then
for i = ns_, ns_ + nc - 1 do
table.insert(new, new_lines[i] or "")
end
end
table.insert(hunks, {
old_start = os_,
old_count = oc,
new_start = ns_,
new_count = nc,
type = typ,
old_lines = old,
new_lines = new,
})
end
return hunks
end
---@type table<ow.Git.Hunks.HunkType, string>
local DEFAULT_SIGNS = { add = "", change = "", delete = "" }
---@return table<ow.Git.Hunks.HunkType, string>
local function resolve_signs()
local cfg = vim.g.git_hunk_signs
if type(cfg) ~= "table" then
return DEFAULT_SIGNS
end
return vim.tbl_extend("force", DEFAULT_SIGNS, cfg)
end
---@type table<ow.Git.Hunks.HunkType, string>
local SIGN_HL = {
add = "GitHunkAdded",
change = "GitHunkChanged",
delete = "GitHunkRemoved",
}
---@type table<ow.Git.Hunks.HunkType, string>
local STAGED_SIGN_HL = {
add = "GitHunkStagedAdded",
change = "GitHunkStagedChanged",
delete = "GitHunkStagedRemoved",
}
---@param h ow.Git.Hunks.Hunk
---@param line_count integer
---@return integer[] 0-indexed buffer rows for the hunk
local function hunk_rows(h, line_count)
if h.type == "delete" then
local row = math.max(h.new_start, 1) - 1
if row >= line_count then
row = math.max(line_count - 1, 0)
end
return { row }
end
local rows = {}
for r = h.new_start, h.new_start + h.new_count - 1 do
local row = r - 1
if row >= 0 and row < line_count then
table.insert(rows, row)
end
end
return rows
end
---@param h ow.Git.Hunks.Hunk
---@return integer 1-indexed last index line the hunk occupies
local function index_end(h)
if h.old_count == 0 then
return h.old_start
end
return h.old_start + h.old_count - 1
end
---@param unstaged ow.Git.Hunks.Hunk[]
---@param iline integer 1-indexed index line
---@return integer? 1-indexed buffer line
local function index_to_buffer(unstaged, iline)
local delta = 0
for _, h in ipairs(unstaged) do
if
h.old_count > 0
and iline >= h.old_start
and iline <= index_end(h)
then
return nil
end
if iline > index_end(h) then
delta = delta + h.new_count - h.old_count
end
end
return iline + delta
end
---@param state ow.Git.Hunks.BufState
---@param line_count integer
---@return { row: integer, hunk: ow.Git.Hunks.Hunk }[] row is a 0-indexed buffer row
local function staged_signs(state, line_count)
local out = {}
for _, h in ipairs(state.staged) do
local index_lines = {}
if h.type == "delete" then
table.insert(index_lines, math.max(h.new_start, 1))
else
for i = h.new_start, h.new_start + h.new_count - 1 do
table.insert(index_lines, i)
end
end
for _, iline in ipairs(index_lines) do
local bline = index_to_buffer(state.hunks, iline)
if bline then
local row = math.min(math.max(bline - 1, 0), line_count - 1)
table.insert(out, { row = row, hunk = h })
end
end
end
return out
end
---@param buf integer
local function render_signs(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1)
local state = states[buf]
if not state or state.overlay then
return
end
local signs = resolve_signs()
local line_count = vim.api.nvim_buf_line_count(buf)
local signed = {}
for _, h in ipairs(state.hunks) do
for _, row in ipairs(hunk_rows(h, line_count)) do
signed[row] = true
pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, row, 0, {
sign_text = signs[h.type],
sign_hl_group = SIGN_HL[h.type],
priority = 100,
})
end
end
for _, s in ipairs(staged_signs(state, line_count)) do
if not signed[s.row] then
pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, s.row, 0, {
sign_text = signs[s.hunk.type],
sign_hl_group = STAGED_SIGN_HL[s.hunk.type],
priority = 100,
})
end
end
end
local SKIP_CAPTURES = { spell = true, nospell = true, conceal = true }
---@param buf integer
---@param lines string[]
---@return table[][]?
local function highlight_index(buf, lines)
if not vim.treesitter.highlighter.active[buf] then
return nil
end
local got, parser = pcall(vim.treesitter.get_parser, buf)
if not got or not parser then
return nil
end
local lang = parser:lang()
local query = vim.treesitter.query.get(lang, "highlights")
if not query then
return nil
end
local source = table.concat(lines, "\n")
local got_root, root = pcall(function()
local trees = vim.treesitter.get_string_parser(source, lang):parse()
local tree = trees and trees[1]
return tree and tree:root()
end)
if not got_root or not root then
return nil
end
---@type table<integer, table<integer, string>>
local groups = {}
for id, node in query:iter_captures(root, source) do
local name = query.captures[id]
if name and name:sub(1, 1) ~= "_" and not SKIP_CAPTURES[name] then
local sr, sc, er, ec = node:range()
for row = sr, math.min(er, #lines - 1) do
local row_groups = groups[row] or {}
groups[row] = row_groups
local from = row == sr and sc or 0
local to = row == er and ec or #(lines[row + 1] or "")
for col = from, to - 1 do
row_groups[col] = name
end
end
end
end
local out = {}
for row = 0, #lines - 1 do
local line = lines[row + 1] or ""
local row_groups = groups[row] or {}
local chunks = {}
local col = 0
while col < #line do
local name = row_groups[col]
local stop = col + 1
while stop < #line and row_groups[stop] == name do
stop = stop + 1
end
local hl ---@type string|string[]
if name then
hl = { "GitHunkDeleteLine", "@" .. name }
else
hl = "GitHunkDeleteLine"
end
table.insert(chunks, { line:sub(col + 1, stop), hl })
col = stop
end
out[row + 1] = chunks
end
return out
end
---@param h ow.Git.Hunks.Hunk
---@param hl_lines table[][]? per-index-line syntax chunks, or nil
---@return table[]
local function delete_virt_lines(h, hl_lines)
local width = vim.o.columns
local virt = {}
for i, line in ipairs(h.old_lines) do
local pad = math.max(width - vim.api.nvim_strwidth(line), 0)
local cached = hl_lines and hl_lines[h.old_start + i - 1]
if cached then
local chunks = vim.list_extend({}, cached)
table.insert(chunks, {
string.rep(" ", pad),
"GitHunkDeleteLine",
})
table.insert(virt, chunks)
else
table.insert(virt, {
{ line .. string.rep(" ", pad), "GitHunkDeleteLine" },
})
end
end
return virt
end
---@param state ow.Git.Hunks.BufState
---@param buf integer
---@return table[][]?
local function index_spans(state, buf)
if not state.index then
return nil
end
local cache = state.index_hl
if cache and cache.src == state.index then
return cache.lines
end
local lines = highlight_index(buf, state.index)
state.index_hl = { src = state.index, lines = lines }
return lines
end
---@param buf integer
local function render_overlay(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1)
local state = states[buf]
if not state or not state.overlay then
return
end
local line_count = vim.api.nvim_buf_line_count(buf)
local hl_lines = index_spans(state, buf)
for _, h in ipairs(state.hunks) do
if h.type ~= "delete" then
for r = h.new_start, h.new_start + h.new_count - 1 do
local row = r - 1
if row >= 0 and row < line_count then
pcall(
vim.api.nvim_buf_set_extmark,
buf,
NS_OVERLAY,
row,
0,
{
line_hl_group = "GitHunkAddLine",
priority = 100,
}
)
end
end
end
if h.type ~= "add" then
local row, above
if h.type == "delete" then
if h.new_start <= 0 then
row, above = 0, true
elseif h.new_start >= line_count then
row, above = math.max(line_count - 1, 0), false
else
row, above = h.new_start, true
end
else
row, above = math.max(h.new_start - 1, 0), true
end
pcall(vim.api.nvim_buf_set_extmark, buf, NS_OVERLAY, row, 0, {
virt_lines = delete_virt_lines(h, hl_lines),
virt_lines_above = above,
right_gravity = false,
invalidate = true,
})
end
end
end
---@param buf integer
local function render(buf)
render_signs(buf)
render_overlay(buf)
end
---@param state ow.Git.Hunks.BufState
---@param buf integer
---@param rev string
---@param want string? the wanted blob sha
---@param have string? the currently-loaded blob sha
---@param apply fun(lines: string[]?, sha: string?)
---@param after fun()
local function ensure_content(state, buf, rev, want, have, apply, after)
if not want then
apply(nil, nil)
return after()
end
if want == have then
return after()
end
util.git({ "cat-file", "-p", rev }, {
cwd = state.repo.worktree,
silent = true,
on_exit = function(res)
if states[buf] ~= state or not vim.api.nvim_buf_is_valid(buf) then
return
end
if res.code == 0 then
apply(util.split_lines(res.stdout or ""), want)
else
apply(nil, nil)
end
after()
end,
})
end
---@param buf integer
local function recompute(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
local state = states[buf]
if not state then
return
end
local r = state.repo
ensure_content(
state,
buf,
":0:" .. state.rel,
r:index_sha(state.rel),
state.index_sha,
function(lines, sha)
state.index = lines
state.index_sha = sha
end,
function()
ensure_content(
state,
buf,
"HEAD:" .. state.rel,
r:head_sha(state.rel),
state.head_sha,
function(lines, sha)
state.head = lines
state.head_sha = sha
end,
function()
local new =
vim.api.nvim_buf_get_lines(buf, 0, -1, false)
state.hunks = state.index
and compute_hunks(state.index, new)
or {}
state.staged = state.head
and state.index
and compute_hunks(state.head, state.index)
or {}
render(buf)
end
)
end
)
end
local schedule, sched_handle = util.keyed_debounce(recompute, 100)
---@param buf integer
function M._flush(buf)
sched_handle.flush(buf)
end
---@param buf integer
function M.attach(buf)
if states[buf] then
return
end
if not repo.is_worktree_buf(buf) then
return
end
local r = repo.find(buf)
if not r then
return
end
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(vim.api.nvim_buf_get_name(buf)))
if not rel then
return
end
---@type ow.Git.Hunks.BufState
local state = {
repo = r,
rel = rel,
index = nil,
index_sha = nil,
head = nil,
head_sha = nil,
hunks = {},
staged = {},
overlay = vim.g.git_hunk_overlay_default == true,
autocmds = {},
}
states[buf] = state
local group =
vim.api.nvim_create_augroup("ow.git.hunks." .. buf, { clear = true })
table.insert(
state.autocmds,
vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, {
group = group,
buffer = buf,
callback = function()
schedule(buf)
end,
})
)
table.insert(
state.autocmds,
vim.api.nvim_create_autocmd("BufWritePost", {
group = group,
buffer = buf,
callback = function()
schedule(buf)
end,
})
)
schedule(buf)
end
---@param buf integer
function M.detach(buf)
local state = states[buf]
if not state then
return
end
if vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1)
end
for _, id in ipairs(state.autocmds) do
pcall(vim.api.nvim_del_autocmd, id)
end
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks." .. buf)
sched_handle.cancel(buf)
states[buf] = nil
end
---@param buf integer?
function M.toggle_overlay(buf)
buf = resolve_buf(buf)
local state = states[buf]
if not state then
util.warning("git hunks: buffer not attached")
return
end
state.overlay = not state.overlay
render(buf)
end
---@param hunks ow.Git.Hunks.Hunk[]
---@param row integer 1-indexed cursor line
---@return ow.Git.Hunks.Hunk?
local function hunk_at(hunks, row)
for _, h in ipairs(hunks) do
if h.type == "delete" then
if math.max(h.new_start, 1) == row then
return h
end
elseif row >= h.new_start and row <= h.new_start + h.new_count - 1 then
return h
end
end
return nil
end
---@param state ow.Git.Hunks.BufState
---@param buf integer
---@param row integer 1-indexed cursor line
---@return ow.Git.Hunks.Hunk?
local function staged_hunk_at(state, buf, row)
local line_count = vim.api.nvim_buf_line_count(buf)
for _, s in ipairs(staged_signs(state, line_count)) do
if s.row == row - 1 then
return s.hunk
end
end
return nil
end
---@param buf integer?
---@return integer buf
---@return ow.Git.Hunks.BufState? state
---@return ow.Git.Hunks.Hunk? hunk
local function cursor_hunk(buf)
buf = resolve_buf(buf)
local state = states[buf]
if not state then
return buf, nil, nil
end
return buf, state, hunk_at(state.hunks, vim.api.nvim_win_get_cursor(0)[1])
end
---@param h ow.Git.Hunks.Hunk
---@return integer 1-indexed buffer line to anchor the cursor on
local function anchor_line(h)
if h.type == "delete" then
return math.max(h.new_start, 1)
end
return h.new_start
end
---@param direction "next"|"prev"
function M.nav(direction)
local buf = vim.api.nvim_get_current_buf()
local state = states[buf]
if not state or #state.hunks == 0 then
return
end
local cur = vim.api.nvim_win_get_cursor(0)[1]
local hunks = state.hunks
local target = direction == "next" and hunks[1] or hunks[#hunks]
if direction == "next" then
for _, h in ipairs(hunks) do
if anchor_line(h) > cur then
target = h
break
end
end
else
for i = #hunks, 1, -1 do
if anchor_line(hunks[i]) < cur then
target = hunks[i]
break
end
end
end
if not target then
return
end
vim.api.nvim_win_set_cursor(0, { anchor_line(target), 0 })
end
---@param h ow.Git.Hunks.Hunk
---@return string[]
local function hunk_body(h)
local lines = {
string.format(
"@@ -%d,%d +%d,%d @@",
h.old_start,
h.old_count,
h.new_start,
h.new_count
),
}
for _, l in ipairs(h.old_lines) do
table.insert(lines, "-" .. l)
end
for _, l in ipairs(h.new_lines) do
table.insert(lines, "+" .. l)
end
return lines
end
local PATCH_CONTEXT = 3
---@param h ow.Git.Hunks.Hunk
---@return integer old_before count of old lines before the hunk's changed content
---@return integer new_before count of new lines before the hunk's changed content
local function hunk_offsets(h)
if h.type == "add" then
return h.old_start, h.new_start - 1
elseif h.type == "delete" then
return h.old_start - 1, h.new_start
end
return h.old_start - 1, h.new_start - 1
end
---@param h ow.Git.Hunks.Hunk
---@return ow.Git.Hunks.Hunk
local function invert(h)
local typ ---@type ow.Git.Hunks.HunkType
if h.type == "add" then
typ = "delete"
elseif h.type == "delete" then
typ = "add"
else
typ = "change"
end
return {
old_start = h.new_start,
old_count = h.new_count,
new_start = h.old_start,
new_count = h.old_count,
type = typ,
old_lines = h.new_lines,
new_lines = h.old_lines,
}
end
---@param h ow.Git.Hunks.Hunk
---@param old_lines string[]
---@param rel string
---@return string patch
---@return boolean zero_context
local function build_patch(h, old_lines, rel)
local old_before, new_before = hunk_offsets(h)
local pre = {}
for i = math.max(old_before - PATCH_CONTEXT + 1, 1), old_before do
pre[#pre + 1] = old_lines[i] or ""
end
local post = {}
local after = old_before + h.old_count
for i = after + 1, math.min(after + PATCH_CONTEXT, #old_lines) do
post[#post + 1] = old_lines[i] or ""
end
local old_n = #pre + h.old_count + #post
local new_n = #pre + h.new_count + #post
local old_start = old_n > 0 and old_before - #pre + 1 or old_before
local new_start = new_n > 0 and new_before - #pre + 1 or new_before
local body = {
string.format(
"@@ -%d,%d +%d,%d @@",
old_start,
old_n,
new_start,
new_n
),
}
for _, l in ipairs(pre) do
body[#body + 1] = " " .. l
end
for _, l in ipairs(h.old_lines) do
body[#body + 1] = "-" .. l
end
for _, l in ipairs(h.new_lines) do
body[#body + 1] = "+" .. l
end
for _, l in ipairs(post) do
body[#body + 1] = " " .. l
end
local lines = { "--- a/" .. rel, "+++ b/" .. rel }
vim.list_extend(lines, body)
return table.concat(lines, "\n") .. "\n", #pre == 0 and #post == 0
end
---@param state ow.Git.Hunks.BufState
---@param buf integer
---@param patch string
---@param zero_context boolean
local function apply_patch(state, buf, patch, zero_context)
local args = { "apply", "--cached" }
if zero_context then
table.insert(args, "--unidiff-zero")
end
table.insert(args, "-")
util.git(args, {
cwd = state.repo.worktree,
stdin = patch,
on_exit = function(res)
if res.code ~= 0 then
util.error("git apply failed: %s", vim.trim(res.stderr or ""))
return
end
local s = states[buf]
if s then
s.index_sha = nil
schedule(buf)
end
end,
})
end
---@param buf? integer
function M.toggle_stage(buf)
buf = resolve_buf(buf)
local state = states[buf]
if not state then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local unstaged = hunk_at(state.hunks, row)
if unstaged and state.index then
local patch, zero = build_patch(unstaged, state.index, state.rel)
apply_patch(state, buf, patch, zero)
return
end
local staged = staged_hunk_at(state, buf, row)
if staged and state.index then
local patch, zero = build_patch(invert(staged), state.index, state.rel)
apply_patch(state, buf, patch, zero)
return
end
util.warning("git hunks: no hunk at cursor")
end
---@param buf? integer
function M.reset_hunk(buf)
local target, state, h = cursor_hunk(buf)
if not state then
return
end
if not h then
util.warning("git hunks: no hunk at cursor")
return
end
if h.type == "add" then
vim.api.nvim_buf_set_lines(
target,
h.new_start - 1,
h.new_start - 1 + h.new_count,
false,
{}
)
elseif h.type == "delete" then
vim.api.nvim_buf_set_lines(
target,
h.new_start,
h.new_start,
false,
h.old_lines
)
else
vim.api.nvim_buf_set_lines(
target,
h.new_start - 1,
h.new_start - 1 + h.new_count,
false,
h.old_lines
)
end
end
---@param buf? integer
function M.select_hunk(buf)
local _, _, h = cursor_hunk(buf)
if not h or h.type == "delete" then
return
end
local first = h.new_start
local last = h.new_start + math.max(h.new_count, 1) - 1
vim.api.nvim_win_set_cursor(0, { first, 0 })
vim.cmd("normal! V")
vim.api.nvim_win_set_cursor(0, { last, 0 })
end
local preview_win ---@type integer?
---@param buf? integer
function M.preview_hunk(buf)
if preview_win and vim.api.nvim_win_is_valid(preview_win) then
vim.api.nvim_set_current_win(preview_win)
return
end
local target, state, h = cursor_hunk(buf)
if not state then
return
end
if not h then
return
end
local lines = hunk_body(h)
local pbuf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines)
vim.bo[pbuf].filetype = "diff"
vim.bo[pbuf].bufhidden = "wipe"
local width = 0
for _, l in ipairs(lines) do
if #l > width then
width = #l
end
end
width = math.min(math.max(width + 2, 40), vim.o.columns - 4)
local height = math.min(#lines, math.floor(vim.o.lines / 2))
local win = vim.api.nvim_open_win(pbuf, false, {
relative = "cursor",
row = 1,
col = 0,
width = width,
height = height,
style = "minimal",
})
preview_win = win
local function close()
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
end
end
local group =
vim.api.nvim_create_augroup("ow.git.hunks.preview", { clear = true })
vim.api.nvim_create_autocmd(
{ "CursorMoved", "CursorMovedI", "InsertEnter" },
{ group = group, buffer = target, callback = close }
)
vim.api.nvim_create_autocmd("WinLeave", {
group = group,
buffer = pbuf,
callback = close,
})
vim.api.nvim_create_autocmd("WinClosed", {
group = group,
pattern = tostring(win),
callback = function()
preview_win = nil
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks.preview")
end,
})
vim.keymap.set("n", "q", close, { buffer = pbuf, nowait = true })
end
repo.on("change", function(r, change)
for buf, state in pairs(states) do
if
state.repo == r
and (change.paths[state.rel] or change.branch_changed)
then
schedule(buf)
end
end
end)
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then
M.attach(buf)
end
end
return M
-143
View File
@@ -1,143 +0,0 @@
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
local LOG_FORMAT = "%h %ad {%an}%d %s"
local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
---@param buf integer
---@return boolean opened
local function open_under_cursor(buf)
local r = repo.resolve(buf)
-- Anchor past the leading graph chars (matches the leading sha column,
-- not any hex word that happens to appear later in the subject).
local sha = r
and vim.api.nvim_get_current_line():match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)")
if not sha then
return false
end
---@cast r -nil
require("git.object").open(r, sha, { split = false })
return true
end
---@param buf integer
local function attach_dispatch(buf)
vim.keymap.set("n", "<CR>", function()
if not open_under_cursor(buf) then
vim.api.nvim_feedkeys(cr, "n", false)
end
end, { buffer = buf, silent = true, desc = "Open commit" })
vim.keymap.set("n", "gd", function()
open_under_cursor(buf)
end, { buffer = buf, silent = true, desc = "Open commit" })
end
---@param worktree string
---@param max_count integer?
---@return string?
local function fetch(worktree, max_count)
local args = {
"log",
"--graph",
"--all",
"--decorate",
"--date=short",
"--format=format:" .. LOG_FORMAT,
}
if max_count then
table.insert(args, "--max-count=" .. max_count)
end
return util.git(args, { cwd = worktree })
end
---@type table<string, integer> -- worktree -> max_count
local max_counts = {}
---@param buf integer
---@param r ow.Git.Repo
local function populate(buf, r)
local stdout = fetch(r.worktree, max_counts[r.worktree])
if not stdout then
return
end
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
end
---@class ow.Git.Log.OpenOpts
---@field max_count integer?
---@type table<string, fun(s: string): any>
M.opt_parsers = {
max_count = tonumber,
}
---@param opts ow.Git.Log.OpenOpts?
function M.open(opts)
opts = opts or {}
local r = repo.resolve()
if not r then
util.error("not in a git repository")
return
end
max_counts[r.worktree] = opts.max_count
local buf = vim.fn.bufadd(r.worktree .. "/GitLog")
local visible = vim.fn.bufwinid(buf)
if visible ~= -1 then
vim.api.nvim_set_current_win(visible)
populate(buf, r)
vim.api.nvim_win_set_cursor(visible, { 1, 0 })
return
end
vim.fn.bufload(buf)
repo.bind(buf, r)
util.setup_scratch(buf, { bufhidden = "hide" })
vim.bo[buf].filetype = "gitlog"
attach_dispatch(buf)
local win = util.place_buf(buf, nil)
vim.api.nvim_win_set_cursor(win, { 1, 0 })
populate(buf, r)
end
---@param cmd_opts table
function M.run_glog(cmd_opts)
local parsed = { max_count = 1000 }
for _, a in ipairs(cmd_opts.fargs) do
local k, v = a:match("^([%w_]+)=(.*)$")
if not k then
util.error("invalid argument: %s", a)
return
end
---@cast v -nil
local parser = M.opt_parsers[k]
if parser then
local value = parser(v)
if value ~= nil then
parsed[k] = value
end
end
end
M.open(parsed)
end
---@param arg_lead string
---@return string[]
function M.complete_glog(arg_lead)
local matches = {}
for k in pairs(M.opt_parsers) do
local prefix = k .. "="
if prefix:sub(1, #arg_lead) == arg_lead then
table.insert(matches, prefix)
end
end
table.sort(matches)
return matches
end
return M
-434
View File
@@ -1,434 +0,0 @@
local Revision = require("git.core.revision")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
M.URI_PREFIX = "git://"
---@param rev ow.Git.Revision
---@return string
function M.format_uri(rev)
return M.URI_PREFIX .. rev:format()
end
---@param str string
---@return ow.Git.Revision?
function M.parse_uri(str)
local raw = str:match("^" .. M.URI_PREFIX .. "(.+)$")
if raw then
return Revision.parse(raw)
end
end
---@class ow.Git.DiffSection
---@field path_a string
---@field path_b string
---@field blob_a string?
---@field blob_b string?
---@return ow.Git.DiffSection?
local function diff_section()
local diff_lnum = vim.fn.search("^diff --git ", "bcnW")
if diff_lnum == 0 then
return nil
end
local diff_line =
vim.api.nvim_buf_get_lines(0, diff_lnum - 1, diff_lnum, false)[1]
if not diff_line then
return nil
end
local path_a, path_b = diff_line:match("^diff %-%-git a/(.-) b/(.+)$")
if not path_a or not path_b then
return nil
end
local header =
vim.api.nvim_buf_get_lines(0, diff_lnum, diff_lnum + 20, false)
local blob_a, blob_b
for _, l in ipairs(header) do
if l:sub(1, 3) == "@@ " or l:match("^diff %-%-git ") then
break
end
local pre, post = l:match("^index (%x+)%.%.(%x+)")
if pre then
blob_a = pre
blob_b = post
break
end
end
return {
path_a = path_a,
path_b = path_b,
blob_a = blob_a,
blob_b = blob_b,
}
end
---@param rev ow.Git.Revision
---@return boolean
local function is_immutable_rev(rev)
if rev.stage ~= nil then
return false
end
local base = rev.base
if not base then
return false
end
local stripped = base:gsub("%^%b{}", ""):gsub("[%^~]%d*", "")
return stripped:match("^%x+$") ~= nil and #stripped >= 7
end
---@param buf integer
---@param r ow.Git.Repo
---@param path string
local function attach_index_writer(buf, r, path)
vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = buf,
callback = function()
local body = table.concat(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n"
) .. "\n"
local hash_stdout = util.git(
{ "hash-object", "-w", "--stdin" },
{ cwd = r.worktree, stdin = body }
)
if not hash_stdout then
return
end
local sha = vim.trim(hash_stdout)
local state = r:state(buf)
local mode = state and state.index_mode
if not mode then
mode = "100644"
local ls = util.git(
{ "ls-files", "-s", "--", path },
{ cwd = r.worktree, silent = true }
)
if ls then
local m = ls:match("^(%d+)")
if m then
mode = m
end
end
if state then
state.index_mode = mode
end
end
if
not util.git({
"update-index",
"--cacheinfo",
mode,
sha,
path,
}, { cwd = r.worktree })
then
return
end
if state then
state.sha = r:rev_parse(":" .. path, true)
end
vim.bo[buf].modified = false
end,
})
end
local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
---@param buf integer
function M.attach_dispatch(buf)
vim.keymap.set("n", "<CR>", function()
if not M.open_under_cursor() then
vim.api.nvim_feedkeys(cr, "n", false)
end
end, { buffer = buf, silent = true, desc = "Open file at commit" })
vim.keymap.set("n", "gd", function()
M.open_under_cursor()
end, { buffer = buf, silent = true, desc = "Open file at commit" })
end
---@param r ow.Git.Repo
---@param rev ow.Git.Revision
---@return integer
function M.buf_for(r, rev)
local buf = vim.fn.bufadd(M.format_uri(rev))
repo.bind(buf, r)
vim.fn.bufload(buf)
return buf
end
---@param buf integer
---@param r ow.Git.Repo
---@param rev ow.Git.Revision
---@param state ow.Git.Repo.BufState
---@param rev_sha string
---@return boolean ok
local function populate(buf, r, rev, state, rev_sha)
local rev_str = rev:format()
local stdout = util.git({ "cat-file", "-p", rev_str }, { cwd = r.worktree })
if not stdout then
return false
end
if rev.path == nil then
local commit_sha = r:rev_parse(rev_str .. "^{commit}", true)
if commit_sha then
local patch = util.git({
"diff-tree",
"-p",
"--diff-merges=first-parent",
"--root",
"--no-commit-id",
commit_sha,
}, { cwd = r.worktree })
if patch then
stdout = (stdout:gsub("\n*$", "\n\n")) .. patch
end
end
end
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
state.sha = rev_sha
return true
end
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local rev = M.parse_uri(name)
if not rev then
return
end
local r = repo.resolve(buf)
if not r then
util.error("git BufReadCmd %s: cannot resolve worktree", name)
return
end
repo.bind(buf, r)
local state = r:state(buf) --[[@as -nil]]
local writable = rev.stage == 0 and rev.path ~= nil
util.setup_scratch(buf, {
bufhidden = "delete",
buftype = writable and "acwrite" or "nofile",
modifiable = writable,
})
local rev_sha = r:rev_parse(rev:format(), true)
if not rev_sha then
return
end
if not populate(buf, r, rev, state, rev_sha) then
return
end
state.immutable = is_immutable_rev(rev)
if writable and not state.index_writer then
attach_index_writer(buf, r, rev.path --[[@as string]])
state.index_writer = true
end
if rev.path then
local ft = vim.filetype.match({ filename = rev.path, buf = buf })
if ft then
vim.bo[buf].filetype = ft
end
else
vim.bo[buf].filetype = "git"
end
M.attach_dispatch(buf)
vim.api.nvim_buf_call(buf, function()
vim.api.nvim_exec_autocmds("BufReadPost", { buf = buf })
end)
end
---@param buf integer
---@param r ow.Git.Repo
local function refresh(buf, r)
local state = r:state(buf)
if not state or state.immutable or vim.bo[buf].modified then
return
end
local rev = M.parse_uri(vim.api.nvim_buf_get_name(buf))
if not rev then
return
end
local rev_sha = r:rev_parse(rev:format(), true)
if not rev_sha or rev_sha == state.sha then
return
end
if state.sha == nil then
M.read_uri(buf)
else
populate(buf, r, rev, state, rev_sha)
end
end
---@param buf integer
---@param path string
local function set_ft_from_path(buf, path)
local ft = vim.filetype.match({ filename = path, buf = buf })
if ft then
vim.bo[buf].filetype = ft
end
end
---@param r ow.Git.Repo
---@param blob string?
---@param path string
---@return integer?
local function side_buf(r, blob, path)
if not blob or util.is_zero_sha(blob) then
return nil
end
local full, status = r:resolve_sha(blob)
if status == "ambiguous" then
util.error("ambiguous blob abbreviation: %s", blob)
return nil
end
if full then
local buf = M.buf_for(r, Revision.new({ base = full }))
set_ft_from_path(buf, path)
return buf
end
local p = vim.fs.joinpath(r.worktree, path)
if vim.uv.fs_stat(p) then
local buf = vim.fn.bufadd(p)
vim.fn.bufload(buf)
return buf
end
return nil
end
---@param r ow.Git.Repo
---@param blob string?
---@param path string
local function load_side(r, blob, path)
local buf = side_buf(r, blob, path)
if not buf then
util.error("no content for %s", path)
return
end
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(buf)
end
---@param r ow.Git.Repo
---@param section ow.Git.DiffSection
local function open_section(r, section)
if not section.blob_a or not section.blob_b then
util.error("no index line, cannot determine blob SHAs")
return
end
local left = side_buf(r, section.blob_a, section.path_a)
local right = side_buf(r, section.blob_b, section.path_b)
if left and right then
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(right)
require("git.diffsplit").open({
target = vim.api.nvim_buf_get_name(left),
mods = { vertical = true },
})
return
end
if not left and not right then
util.error("no content for %s", section.path_b)
return
end
local buf = left or right
---@cast buf -nil
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(buf)
end
---@class ow.Git.Object.OpenOpts
---@field split (false|"above"|"below"|"left"|"right")?
---@param r ow.Git.Repo
---@param rev string
---@param opts ow.Git.Object.OpenOpts?
function M.open(r, rev, opts)
local parsed = Revision.parse(rev)
if parsed.base then
local sha = r:rev_parse(parsed.base, false)
if not sha then
util.error("not a git object: %s", rev)
return
end
parsed.base = sha
end
if parsed.path and not r:rev_parse(parsed:format(), false) then
util.error("not a git object: %s", rev)
return
end
local buf = M.buf_for(r, parsed)
util.place_buf(buf, opts and opts.split)
end
---@return boolean dispatched
function M.open_under_cursor()
local r = repo.resolve()
if not r then
return false
end
local line = vim.api.nvim_get_current_line()
local sha = line:match("^commit (%x+)$")
or line:match("^parent (%x+)$")
or line:match("^tree (%x+)$")
or line:match("^object (%x+)$")
if sha then
M.open(r, sha, { split = false })
return true
end
local entry_type, entry_sha, entry_name =
line:match("^%d+ (%w+) (%x+)\t(.+)$")
if entry_sha then
if entry_type == "blob" then
load_side(r, entry_sha, entry_name --[[@as string]])
else
M.open(r, entry_sha, { split = false })
end
return true
end
local section = diff_section()
if not section then
return false
end
if line:match("^diff %-%-git ") then
open_section(r, section)
return true
end
if line:match("^%-%-%- ") then
load_side(r, section.blob_a, section.path_a)
return true
end
if line:match("^%+%+%+ ") then
load_side(r, section.blob_b, section.path_b)
return true
end
local prefix = line:sub(1, 1)
if prefix == "+" then
load_side(r, section.blob_b, section.path_b)
return true
elseif prefix == "-" then
load_side(r, section.blob_a, section.path_a)
return true
end
return false
end
repo.on_uri_change(M.URI_PREFIX, refresh)
return M
-740
View File
@@ -1,740 +0,0 @@
local Revision = require("git.core.revision")
local diffsplit = require("git.diffsplit")
local object = require("git.object")
local repo = require("git.core.repo")
local status = require("git.core.status")
local util = require("git.core.util")
local M = {}
---@type ow.Git.StatusView.Placement[]
M.PLACEMENTS = { "sidebar", "split", "current" }
---@type ow.Git.Status.Section[]
local SECTIONS = { "untracked", "unstaged", "staged", "unmerged" }
local WINDOW_WIDTH = 50
---@param r ow.Git.Repo
---@return string
local function buf_name_for(r)
return r.worktree .. "/GitStatus"
end
---@alias ow.Git.StatusView.Placement "sidebar"|"split"|"current"
---@class ow.Git.StatusView.Header
---@field is_header true
---@field section ow.Git.Status.Section
---@alias ow.Git.StatusView.Item ow.Git.Status.Row | ow.Git.StatusView.Header
---@class ow.Git.StatusView.State
---@field repo ow.Git.Repo
---@field placement ow.Git.StatusView.Placement
---@field lines table<integer, ow.Git.StatusView.Item>
---@field win integer?
---@field unsubscribe fun()?
---@type table<integer, ow.Git.StatusView.State>
local state = {}
local group =
vim.api.nvim_create_augroup("ow.git.status_win", { clear = true })
local ns = vim.api.nvim_create_namespace("ow.git.status_win")
---@return integer? win
---@return integer? bufnr
local function find_view()
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
local buf = vim.api.nvim_win_get_buf(win)
if vim.bo[buf].filetype == "gitstatus" then
return win, buf
end
end
end
---@param win integer?
---@return boolean
local function valid_in_current_tab(win)
if not win or not vim.api.nvim_win_is_valid(win) then
return false
end
return vim.api.nvim_win_get_tabpage(win)
== vim.api.nvim_get_current_tabpage()
end
---@param s ow.Git.StatusView.State
---@return integer?
local function win_for(s)
if valid_in_current_tab(s.win) then
return s.win
end
local win = find_view()
s.win = win
return win
end
---@param row ow.Git.Status.Row
---@return string line
---@return string hl_group
---@return integer hl_len
local function format_row(row)
local entry = row.entry
local orig
if entry.kind == "changed" then
---@cast entry ow.Git.Status.ChangedEntry
orig = entry.orig
end
local label = orig and (orig .. " -> " .. entry.path) or entry.path
local mark = status.mark_for(entry, row.side)
return string.format(" %s %s", mark.char, label), mark.hl, #mark.char
end
---@param section ow.Git.Status.Section
---@return string
local function display_name(section)
return (section:gsub("^%l", string.upper))
end
---@param bufnr integer
---@param r ow.Git.Repo
local function render(bufnr, r)
local status = r.status
local branch = status.branch
local lines = {}
local marks = {}
local meta = {}
local function label(row, len)
table.insert(marks, { row = row, col = 0, end_col = len, hl = "Label" })
end
local repo_line = vim.fn.fnamemodify(r.worktree, ":t")
table.insert(lines, repo_line)
table.insert(marks, {
row = #lines - 1,
col = 0,
end_col = #repo_line,
hl = "Directory",
})
table.insert(lines, "Branch: " .. (branch.head or "?"))
label(#lines - 1, 6)
if branch.upstream then
local up = "Upstream: " .. branch.upstream
local extras = {}
if branch.ahead > 0 then
local col = #up + 1
up = up .. " +" .. branch.ahead
table.insert(extras, {
col = col,
end_col = #up,
hl = "GitUnpushed",
})
end
if branch.behind > 0 then
local col = #up + 1
up = up .. " -" .. branch.behind
table.insert(extras, {
col = col,
end_col = #up,
hl = "GitUnpulled",
})
end
table.insert(lines, up)
local row = #lines - 1
label(row, 8)
for _, e in ipairs(extras) do
e.row = row
table.insert(marks, e)
end
end
table.insert(lines, "")
for _, section in ipairs(SECTIONS) do
local rows = status:rows(section)
if #rows > 0 then
local name = display_name(section)
local header = string.format("%s (%d)", name, #rows)
table.insert(lines, header)
local header_row = #lines - 1
meta[#lines] = { is_header = true, section = section }
label(header_row, #name)
table.insert(marks, {
row = header_row,
col = #name + 2,
end_col = #header - 1,
hl = "Number",
})
for _, row in ipairs(rows) do
local line, hl, hl_len = format_row(row)
table.insert(lines, line)
meta[#lines] = row
table.insert(marks, {
row = #lines - 1,
col = 2,
end_col = 2 + hl_len,
hl = hl,
})
end
table.insert(lines, "")
end
end
util.set_buf_lines(bufnr, 0, -1, lines)
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
for _, m in ipairs(marks) do
vim.api.nvim_buf_set_extmark(bufnr, ns, m.row, m.col, {
end_col = m.end_col,
hl_group = m.hl,
})
end
state[bufnr].lines = meta
end
---@param bufnr integer
local function refresh(bufnr)
local s = state[bufnr]
if not s or not vim.api.nvim_buf_is_valid(bufnr) then
return
end
render(bufnr, s.repo)
end
---@param bufnr integer
---@return ow.Git.StatusView.State?
---@return ow.Git.StatusView.Item?
local function current_entry(bufnr)
local s = state[bufnr]
if not s then
return nil, nil
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
return s, s.lines[lnum]
end
---@class ow.Git.StatusView.Pane
---@field buf integer
---@field name string?
---@param r ow.Git.Repo
---@param path string
---@return ow.Git.StatusView.Pane
local function head_pane(r, path)
local rev = Revision.new({ base = "HEAD", path = path })
return {
buf = object.buf_for(r, rev),
name = object.format_uri(rev),
}
end
---@param r ow.Git.Repo
---@param path string
---@return ow.Git.StatusView.Pane
local function worktree_pane(r, path)
local buf = vim.fn.bufadd(vim.fs.joinpath(r.worktree, path))
vim.fn.bufload(buf)
return { buf = buf, name = nil }
end
---@param s ow.Git.StatusView.State
---@param path string
---@return ow.Git.StatusView.Pane
local function index_pane(s, path)
local rev = Revision.new({ stage = 0, path = path })
return {
buf = object.buf_for(s.repo, rev),
name = object.format_uri(rev),
}
end
---@param s ow.Git.StatusView.State
---@param row ow.Git.Status.Row
---@return ow.Git.StatusView.Pane?
local function older_pane(s, row)
local entry = row.entry
if row.section == "staged" then
---@cast entry ow.Git.Status.ChangedEntry
if entry.staged == "added" then
return nil
end
return head_pane(s.repo, entry.orig or entry.path)
end
if row.section == "unstaged" then
return index_pane(s, entry.path)
end
return nil
end
---@param s ow.Git.StatusView.State
---@param row ow.Git.Status.Row
---@return ow.Git.StatusView.Pane?
local function newer_pane(s, row)
local entry = row.entry
if row.section == "staged" then
---@cast entry ow.Git.Status.ChangedEntry
if entry.staged == "deleted" then
return nil
end
return index_pane(s, entry.path)
end
if row.section == "unstaged" then
---@cast entry ow.Git.Status.ChangedEntry
if entry.unstaged == "deleted" then
return nil
end
return worktree_pane(s.repo, entry.path)
end
if row.section == "untracked" then
return worktree_pane(s.repo, entry.path)
end
return nil
end
---@param target_win integer
---@param dir "left"|"right"
---@return integer
local function vsplit_at(target_win, dir)
local win = vim.api.nvim_open_win(
vim.api.nvim_win_get_buf(target_win),
true,
{ split = dir, win = target_win }
)
vim.api.nvim_win_call(win, function()
vim.cmd("setlocal winfixwidth<")
end)
vim.cmd.clearjumps()
return win
end
---@param status_win integer
---@return integer?
local function previous_target_win(status_win)
local n = vim.fn.winnr("#")
if n == 0 then
return nil
end
local win = vim.fn.win_getid(n)
if win == 0 or win == status_win or not valid_in_current_tab(win) then
return nil
end
local cfg = vim.api.nvim_win_get_config(win)
if cfg.relative and cfg.relative ~= "" then
return nil
end
return win
end
---@param status_win integer
---@param keep integer
local function close_other_diff_wins(status_win, keep)
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if win ~= status_win and win ~= keep and vim.wo[win].diff then
pcall(vim.api.nvim_win_close, win, false)
end
end
end
---@param s ow.Git.StatusView.State
---@param row ow.Git.Status.Row
---@param focus_left boolean
local function view_row(s, row, focus_left)
local status_win = win_for(s)
if not status_win then
return
end
local left = older_pane(s, row)
local right = newer_pane(s, row)
if not left and not right then
util.warning(
"no content for %s row: %s",
row.section,
row.entry.path
)
return
end
if s.placement ~= "sidebar" then
local pane = right or left
---@cast pane -nil
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_win_set_buf(status_win, pane.buf)
if pane.name then
util.set_buf_name(pane.buf, pane.name)
end
return
end
local target = previous_target_win(status_win)
if not target then
target = vsplit_at(status_win, "right")
end
close_other_diff_wins(status_win, target)
vim.api.nvim_win_set_width(status_win, WINDOW_WIDTH)
vim.api.nvim_win_call(target, function()
vim.cmd.diffoff()
end)
if not (left and right) then
local side = right or left
---@cast side ow.Git.StatusView.Pane
vim.api.nvim_win_set_buf(target, side.buf)
if side.name then
util.set_buf_name(side.buf, side.name)
end
vim.api.nvim_set_current_win(focus_left and target or status_win)
return
end
---@cast left ow.Git.StatusView.Pane
---@cast right ow.Git.StatusView.Pane
vim.api.nvim_win_set_buf(target, right.buf)
if right.name then
util.set_buf_name(right.buf, right.name)
end
local older = left.name or vim.api.nvim_buf_get_name(left.buf)
local left_win
vim.api.nvim_win_call(target, function()
diffsplit.open({
target = older,
mods = { vertical = true },
})
left_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_cursor(left_win, { 1, 0 })
vim.api.nvim_win_set_cursor(target, { 1, 0 })
end)
---@cast left_win -nil
vim.api.nvim_set_current_win(focus_left and left_win or status_win)
end
---@param focus_left boolean
local function preview_or_open(focus_left)
local s, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item or item.is_header then
return
end
---@cast item ow.Git.Status.Row
view_row(s, item, focus_left)
end
local function action_stage()
local s, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item then
return
end
local paths = {}
if item.is_header then
if item.section == "staged" or item.section == "ignored" then
return
end
for _, row in ipairs(s.repo.status:rows(item.section)) do
table.insert(paths, row.entry.path)
end
else
---@cast item ow.Git.Status.Row
if item.section == "staged" then
return
end
table.insert(paths, item.entry.path)
end
if #paths == 0 then
return
end
local args = { "add", "--" }
vim.list_extend(args, paths)
util.git(args, {
cwd = s.repo.worktree,
on_exit = function(result)
if result.code ~= 0 then
util.error("git add failed: %s", vim.trim(result.stderr or ""))
end
end,
})
end
local function action_unstage()
local s, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item then
return
end
local rows
if item.is_header then
if item.section ~= "staged" then
return
end
rows = s.repo.status:rows("staged")
else
---@cast item ow.Git.Status.Row
if item.section ~= "staged" then
return
end
rows = { item }
end
---@cast rows ow.Git.Status.Row[]
if #rows == 0 then
return
end
local args = { "restore", "--staged", "--" }
for _, row in ipairs(rows) do
local entry = row.entry
if entry.kind == "changed" then
---@cast entry ow.Git.Status.ChangedEntry
if entry.orig then
table.insert(args, entry.orig)
end
end
table.insert(args, entry.path)
end
util.git(args, {
cwd = s.repo.worktree,
on_exit = function(result)
if result.code ~= 0 then
util.error(
"git restore --staged failed: %s",
vim.trim(result.stderr or "")
)
end
end,
})
end
local function action_discard()
local s, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item or item.is_header then
return
end
---@cast item ow.Git.Status.Row
if item.section == "staged" then
util.warning("file has staged changes, unstage first with 'u'")
return
end
local entry = item.entry
local path = entry.path
local prompt, action
if item.section == "untracked" then
local is_dir = path:sub(-1) == "/"
prompt = string.format(
"Delete untracked %s %s?",
is_dir and "directory" or "file",
path
)
action = function()
local target = vim.fs.joinpath(s.repo.worktree, path)
local rc = vim.fn.delete(target, is_dir and "rf" or "")
if rc ~= 0 then
util.error("failed to delete %s", path)
end
refresh(vim.api.nvim_get_current_buf())
end
elseif item.section == "unstaged" then
prompt = string.format("Discard changes to %s?", path)
action = function()
util.git({ "checkout", "--", path }, {
cwd = s.repo.worktree,
on_exit = function(result)
if result.code ~= 0 then
util.error(
"git checkout failed: %s",
vim.trim(result.stderr or "")
)
end
end,
})
end
else
return
end
if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then
action()
end
end
---@param placement ow.Git.StatusView.Placement
local function action_help(placement)
local lines = { "git status view" }
if placement == "sidebar" then
table.insert(lines, " <Tab> preview diff (keep focus)")
table.insert(lines, " <CR> open diff (focus left pane)")
table.insert(lines, " <2-LeftMouse> open diff (focus left pane)")
else
table.insert(lines, " <CR> open file")
table.insert(lines, " <2-LeftMouse> open file")
end
table.insert(lines, " s stage file")
table.insert(lines, " u unstage file")
table.insert(
lines,
" X discard worktree changes (untracked: delete file)"
)
table.insert(lines, " R refresh")
table.insert(lines, " g? show this help")
print(table.concat(lines, "\n"))
end
---@param bufnr integer
---@param placement ow.Git.StatusView.Placement
---@return integer win
local function place(bufnr, placement)
local split
if placement == "sidebar" then
split = "left"
elseif placement == "current" then
split = false
end
local win = util.place_buf(bufnr, split)
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].wrap = false
vim.wo[win].signcolumn = "no"
vim.wo[win].cursorline = true
if placement == "sidebar" then
vim.wo[win].winfixwidth = true
vim.api.nvim_win_set_width(win, WINDOW_WIDTH)
end
return win
end
---@param bufnr integer
---@param r ow.Git.Repo
---@param placement ow.Git.StatusView.Placement
---@param win integer?
local function setup_buffer(bufnr, r, placement, win)
state[bufnr] = {
repo = r,
placement = placement,
lines = {},
win = win,
}
local function k(lhs, rhs, desc)
vim.keymap.set(
"n",
lhs,
rhs,
{ buffer = bufnr, silent = true, desc = desc }
)
end
k("<CR>", function()
preview_or_open(true)
end, "Open")
k("<2-LeftMouse>", function()
preview_or_open(true)
end, "Open")
k("s", action_stage, "Stage file")
k("u", action_unstage, "Unstage file")
k("X", action_discard, "Discard worktree changes")
k("R", function()
r:refresh()
end, "Refresh")
k("g?", function()
action_help(state[bufnr].placement)
end, "Help")
state[bufnr].unsubscribe = r:on("change", function()
refresh(bufnr)
end)
vim.api.nvim_create_autocmd("BufEnter", {
buffer = bufnr,
group = group,
callback = function()
r:refresh()
end,
})
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
buffer = bufnr,
group = group,
callback = function()
local s = state[bufnr]
if not s then
return
end
if s.unsubscribe then
s.unsubscribe()
end
state[bufnr] = nil
end,
})
end
---@param bufnr integer
---@param placement ow.Git.StatusView.Placement
local function set_keymaps(bufnr, placement)
if placement == "sidebar" then
vim.keymap.set("n", "<Tab>", function()
preview_or_open(false)
end, { buffer = bufnr, silent = true, desc = "Preview diff" })
else
pcall(vim.keymap.del, "n", "<Tab>", { buffer = bufnr })
end
end
---@class ow.Git.StatusView.OpenOpts
---@field placement ow.Git.StatusView.Placement?
---@param opts? ow.Git.StatusView.OpenOpts
function M.open(opts)
opts = opts or {}
local placement = opts.placement or "sidebar"
if not vim.tbl_contains(M.PLACEMENTS, placement) then
util.error(
"invalid placement: %s (expected one of %s)",
placement,
table.concat(M.PLACEMENTS, ", ")
)
return
end
local r = repo.resolve()
if not r then
util.error("not in a git repository")
return
end
local previous_win = vim.api.nvim_get_current_win()
local buf = vim.fn.bufadd(buf_name_for(r))
local visible = vim.fn.bufwinid(buf)
if visible ~= -1 then
vim.api.nvim_set_current_win(visible)
r:refresh()
return
end
if not state[buf] then
vim.fn.bufload(buf)
repo.bind(buf, r)
util.setup_scratch(buf, {})
vim.bo[buf].filetype = "gitstatus"
setup_buffer(buf, r, placement)
end
vim.bo[buf].bufhidden = placement == "sidebar" and "wipe" or "hide"
local win = place(buf, placement)
state[buf].win = win
state[buf].placement = placement
set_keymaps(buf, placement)
if placement == "sidebar" then
vim.api.nvim_set_current_win(previous_win)
end
refresh(buf)
r:refresh()
end
---@param opts? ow.Git.StatusView.OpenOpts
function M.toggle(opts)
local existing = find_view()
if existing then
vim.api.nvim_win_close(existing, false)
return
end
M.open(opts)
end
return M
-104
View File
@@ -1,104 +0,0 @@
local repo = require("git.core.repo")
local status = require("git.core.status")
local util = require("git.core.util")
local M = {}
---@class ow.Git.Statusline.Status
---@field head string?
---@field entry ow.Git.Status.Entry?
---@param entry ow.Git.Status.Entry?
---@return string
local function render(entry)
if not entry then
return ""
end
local marks = status.marks_for(entry)
if #marks == 0 then
return ""
end
local parts = {}
for _, mark in ipairs(marks) do
table.insert(
parts,
string.format("%%#%s#%s%%*", mark.hl, mark.char)
)
end
return table.concat(parts, " ")
end
---@param buf integer
local function clear(buf)
vim.b[buf].git_status = nil
vim.b[buf].git_status_string = nil
end
---@param buf integer
---@param r ow.Git.Repo
---@param rel string
local function set_status(buf, r, rel)
local entry = r:status_entry_for(rel)
vim.b[buf].git_status = { head = r:head(), entry = entry }
vim.b[buf].git_status_string = render(entry)
end
---@param buf integer
---@param r ow.Git.Repo?
local function update_buf(buf, r)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
local name = vim.api.nvim_buf_get_name(buf)
if name == "" or util.is_uri(name) then
return clear(buf)
end
r = r or repo.find(buf)
if not r then
return clear(buf)
end
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
if not rel then
return clear(buf)
end
set_status(buf, r, rel)
end
repo.on("change", function(r)
local any_visible = false
for buf in pairs(r.buffers) do
if vim.api.nvim_buf_is_loaded(buf) then
local name = vim.api.nvim_buf_get_name(buf)
if name ~= "" and not util.is_uri(name) then
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
if rel then
set_status(buf, r, rel)
if
not any_visible
and #vim.fn.win_findbuf(buf) > 0
then
any_visible = true
end
end
end
end
end
if any_visible then
vim.cmd.redrawstatus({ bang = true })
end
end)
vim.api.nvim_create_autocmd("BufWinEnter", {
group = vim.api.nvim_create_augroup("ow.git.statusline", { clear = true }),
callback = function(args)
update_buf(args.buf, nil)
end,
})
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then
update_buf(buf, nil)
end
end
return M