Compare commits

...

42 Commits

Author SHA1 Message Date
warg 189bb8e5dc chore(pack): refresh lockfile 2026-05-28 00:08:12 +02:00
warg ca1e6e4f5c chore(pack): remove tree-sitter-tumblr 2026-05-28 00:06:55 +02:00
warg 496188dd95 fix: change git.nvim url 2026-05-28 00:01:50 +02:00
warg 5b69014c0c feat(pack): support file:// dev plugins via symlink 2026-05-26 20:35:58 +02:00
warg feb10f543c feat: move out git package to separate plugin 2026-05-26 17:07:32 +02:00
warg c0dcb14171 chore: format 2026-05-26 16:37:28 +02:00
warg bf6ec376c4 chore(make): format on check 2026-05-26 16:36:22 +02:00
warg 201c88d750 chore(git): remove comments 2026-05-26 16:06:40 +02:00
warg 9e8a838059 refactor(git): remove inline and side-window blame, keep popup + drill actions 2026-05-26 16:05:48 +02:00
warg db0b2d2527 feat(git): add blame side window with synced scroll 2026-05-26 15:35:11 +02:00
warg ebfa15c276 refactor(git): remove blame gutter 2026-05-26 15:28:22 +02:00
warg 27f77e4fb7 fix(git): separate blame gutter from buffer text 2026-05-26 15:17:26 +02:00
warg c560f62fb2 refactor(git): rework blame highlights and rename overlay to gutter 2026-05-26 14:52:59 +02:00
warg a0a8d723d6 fix(git): rename plug command 2026-05-26 14:51:30 +02:00
warg d81cf95b48 fix(git): stop the blame statuscolumn leaking into new windows 2026-05-26 14:02:37 +02:00
warg 872431be3c fix(fzf): don't include declaration and current line for references 2026-05-26 00:50:39 +02:00
warg df5e9d787b feat(lsp): change from pyright to pyrefly 2026-05-26 00:40:37 +02:00
warg 432682409e feat(git): add in-house git blame 2026-05-22 16:38:50 +02:00
warg a786d8458d refactor(git): move the zero-sha check into core/util 2026-05-22 16:38:49 +02:00
warg a401e0a12b fix(onedark): add back DiffDelete hl override 2026-05-20 14:33:37 +02:00
warg 942dbdcaa0 refactor(git): rename stage_hunk to toggle_stage 2026-05-20 13:19:05 +02:00
warg 1a582045f6 feat(git): show staged hunks in the gutter with a stage toggle 2026-05-20 12:46:45 +02:00
warg 7c92b5eff6 feat(git/repo): add Repo:head_sha for cached HEAD blob lookups 2026-05-20 12:46:05 +02:00
warg 6230c2663c fix(onedark): override GitUnstaged 2026-05-20 11:28:34 +02:00
warg 5b869334d6 fix(onedark): remove overridden diff/git highlights 2026-05-20 10:19:29 +02:00
warg 01ca0025dd fix(git): refresh the gutter after staging a hunk 2026-05-20 08:09:07 +02:00
warg b52f34ce9a fix(git): use GitHunkRemoved for the delete-hunk sign 2026-05-20 08:00:56 +02:00
warg e050896dc0 test(git): speed up the hunks test setup with a flush hook 2026-05-20 07:56:07 +02:00
warg 4c8b3f0d3e fix(git): align hunk signs with :diffsplit 2026-05-20 07:55:55 +02:00
warg 72ab9059fa fix(git): rename hunk highlights 2026-05-20 07:53:51 +02:00
warg 7c8975af10 fix(git): show merge commits diffed against the first parent 2026-05-20 07:14:37 +02:00
warg 2064c629ed feat(git): syntax-highlight deleted lines in the diff overlay 2026-05-20 06:42:42 +02:00
warg aaef6621dd fix(hunks): change delete sign 2026-05-20 06:27:52 +02:00
warg d629302625 perf(git): cache index blob sha, drop rev-parse from the edit path 2026-05-20 06:25:15 +02:00
warg f77d26db6b feat(git): trim hunk preview header, focus float on re-invoke 2026-05-20 06:17:58 +02:00
warg f4181b89fc feat(git): add in-house hunks module, replace gitsigns.nvim 2026-05-20 06:10:18 +02:00
warg d979c961a2 test(git): remove unused git uri 2026-05-19 20:42:06 +02:00
warg d132c00032 feat(git): bind gd to open-under-cursor in log and object views 2026-05-19 20:30:23 +02:00
warg 73fa92afc8 fix(core): reset foldlevel on entering a diff window 2026-05-19 20:26:59 +02:00
warg 3b8951758e fix(git): reset diff-pair cursor on sidebar selection 2026-05-19 19:55:03 +02:00
warg b692f23fe2 refactor(git): rework log_view, drop URI scheme 2026-05-19 19:40:13 +02:00
warg ffd5584a05 refactor(git): replace status_view URI scheme with path-style name 2026-05-19 16:29:10 +02:00
36 changed files with 201 additions and 6717 deletions
+1
View File
@@ -18,6 +18,7 @@
"~/.local/share/nvim/site/pack/core/opt/mason-auto-install.nvim",
"~/.local/share/nvim/site/pack/core/opt/nvim-dap",
"~/.local/share/nvim/site/pack/core/opt/Comment.nvim",
"~/.local/share/nvim/site/pack/core/opt/git.nvim",
"~/.local/share/nvim/site/pack/core/opt/gitsigns.nvim",
"~/.local/share/nvim/site/pack/core/opt/grug-far.nvim",
"~/.local/share/nvim/site/pack/core/opt/nvim-tree.lua",
+5 -4
View File
@@ -1,11 +1,12 @@
.PHONY: all check lint test
.PHONY: all check lint test format
all: check
check: lint test
check: format lint test
test:
@scripts/test
lint:
@scripts/lint
format:
@stylua .
+1 -1
View File
@@ -2,7 +2,7 @@ My Neovim config.
## System Requirements
Only supports Linux, macOS and other BSD variants. Requires neovim v0.12 and git v2.25 or newer (the git module uses `git restore`, which stabilized in 2.25).
Only supports Linux, macOS and other BSD variants. Requires neovim v0.12.
## License
+10
View File
@@ -0,0 +1,10 @@
---@type vim.lsp.Config
return {
settings = {
python = {
pyrefly = {
typeCheckingMode = "strict",
},
},
},
}
+1 -1
View File
@@ -30,11 +30,11 @@ require("pack").setup({
"https://github.com/owallb/mason-auto-install.nvim",
"https://github.com/mfussenegger/nvim-dap",
"https://github.com/numToStr/Comment.nvim",
"https://github.com/lewis6991/gitsigns.nvim",
"https://github.com/MagicDuck/grug-far.nvim",
"https://github.com/nvim-tree/nvim-tree.lua",
"https://github.com/stevearc/oil.nvim",
"https://github.com/hedyhli/outline.nvim",
"https://git.owall.se/warg/git.nvim",
"nvim.undotree",
{
src = "https://github.com/saghen/blink.cmp",
+12
View File
@@ -66,6 +66,18 @@ vim.api.nvim_create_autocmd({ "BufReadPost" }, {
command = 'silent! normal! g`"zv',
})
vim.api.nvim_create_autocmd("BufWinEnter", {
desc = "Reset foldlevel to 0 when entering a diff window."
.. " Vim's partial diff-state restoration on buffer re-entry"
.. " (e.g. via <C-o>) doesn't re-apply foldlevel=0, so"
.. " foldlevelstart leaks through and folds appear open.",
callback = function()
if vim.wo.diff then
vim.wo.foldlevel = 0
end
end,
})
vim.api.nvim_create_autocmd("FileType", {
pattern = { "c" },
callback = function()
+13 -4
View File
@@ -226,11 +226,20 @@ vim.keymap.set("n", "<leader>fD", vim.diagnostic.setqflist)
vim.keymap.set("n", "grt", vim.lsp.buf.type_definition)
vim.keymap.set("n", "gd", vim.lsp.buf.definition)
vim.keymap.set("n", "<leader>gd", "<Plug>(git-diff-vertical)")
vim.keymap.set("n", "<leader>gD", "<Plug>(git-diff-vertical-head)")
vim.keymap.set("n", "<leader>gh", "<Plug>(git-diff-horizontal)")
vim.keymap.set("n", "<leader>gH", "<Plug>(git-diff-horizontal-head)")
vim.keymap.set("n", "<leader>gd", "<Plug>(git-diffsplit-vertical)")
vim.keymap.set("n", "<leader>gD", "<Plug>(git-diffsplit-vertical-head)")
vim.keymap.set("n", "<leader>gh", "<Plug>(git-diffsplit-horizontal)")
vim.keymap.set("n", "<leader>gH", "<Plug>(git-diffsplit-horizontal-head)")
vim.keymap.set("n", "<leader>gg", "<Plug>(git-status-toggle)")
vim.keymap.set("n", "<leader>gc", "<Plug>(git-commit)")
vim.keymap.set("n", "<leader>ga", "<Plug>(git-commit-amend)")
vim.keymap.set("n", "<leader>gl", "<Plug>(git-log)")
vim.keymap.set("n", "<leader>gb", "<Plug>(git-blame-popup)")
vim.keymap.set("n", "<leader>gB", "<Plug>(git-blame-view)")
vim.keymap.set("n", "<leader>gv", "<Plug>(git-hunk-select)")
vim.keymap.set("n", "<leader>gs", "<Plug>(git-hunk-stage-toggle)")
vim.keymap.set("n", "<leader>gr", "<Plug>(git-hunk-reset)")
vim.keymap.set("n", "<C-w>g", "<Plug>(git-hunk-preview)")
vim.keymap.set("n", "<leader>go", "<Plug>(git-hunk-overlay-toggle)")
vim.keymap.set({ "n", "x" }, "]g", "<Plug>(git-hunk-next)")
vim.keymap.set({ "n", "x" }, "[g", "<Plug>(git-hunk-prev)")
-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
-897
View File
@@ -1,897 +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 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
for key in pairs(self._cache) do
if vim.startswith(key, "resolve:") then
self._cache[key] = nil
end
end
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
---@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
local function 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 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
-287
View File
@@ -1,287 +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 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.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.Diff.SplitOpts
---@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.Diff.SplitOpts
function M.split(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
-175
View File
@@ -1,175 +0,0 @@
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
M.URI_PREFIX = "gitlog://"
local LOG_FORMAT = "%h %ad {%an}%d %s"
local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
---@param buf integer
local function attach_dispatch(buf)
vim.keymap.set("n", "<CR>", function()
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 sha then
---@cast r -nil
require("git.object").open(r, sha, { split = false })
else
vim.api.nvim_feedkeys(cr, "n", false)
end
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
local new_lines = util.split_lines(stdout)
local old_str = table.concat(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n"
) .. "\n"
local new_str = table.concat(new_lines, "\n") .. "\n"
local hunks = vim.text.diff(old_str, new_str, {
result_type = "indices",
algorithm = "histogram",
})
---@cast hunks [integer, integer, integer, integer][]
if #hunks == 0 then
return
end
for i = #hunks, 1, -1 do
local sa, ca, sb, cb = unpack(hunks[i])
local start = ca == 0 and sa or sa - 1
util.set_buf_lines(
buf,
start,
start + ca,
vim.list_slice(new_lines, sb, sb + cb - 1)
)
end
end
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local worktree = name:sub(#M.URI_PREFIX + 1)
if worktree == "" then
return
end
local r = repo.resolve(worktree)
if not r then
return
end
repo.bind(buf, r)
util.setup_scratch(buf, { bufhidden = "hide" })
vim.bo[buf].filetype = "gitlog"
attach_dispatch(buf)
populate(buf, r)
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(M.URI_PREFIX .. r.worktree)
local was_loaded = vim.api.nvim_buf_is_loaded(buf)
local win = vim.fn.bufwinid(buf)
if win == -1 then
util.place_buf(buf, nil)
else
vim.api.nvim_set_current_win(win)
end
if was_loaded then
populate(buf, r)
end
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
repo.on_uri_change(M.URI_PREFIX, populate)
return M
-438
View File
@@ -1,438 +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 sha string?
---@return boolean
local function is_zero(sha)
return sha == nil or sha:match("^0+$") ~= nil
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" })
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",
"-m",
"--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 is_zero(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.diff").split({
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
-796
View File
@@ -1,796 +0,0 @@
local Revision = require("git.core.revision")
local diff = require("git.diff")
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 = {}
M.URI_PREFIX = "gitstatus://"
---@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 name string
---@return integer? bufnr
local function find_buf(name)
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b) == name then
return b
end
end
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()
diff.split({
target = older,
mods = { vertical = true },
})
left_win = vim.api.nvim_get_current_win()
end)
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(M.URI_PREFIX .. r.worktree)
local visible = vim.fn.bufwinid(buf)
if visible ~= -1 then
vim.api.nvim_set_current_win(visible)
r:refresh()
return
end
local was_loaded = vim.api.nvim_buf_is_loaded(buf)
local win = place(buf, placement)
vim.bo[buf].bufhidden = placement == "sidebar" and "wipe" or "hide"
local s = state[buf]
if s then
s.win = win
s.placement = placement
end
set_keymaps(buf, placement)
if placement == "sidebar" then
vim.api.nvim_set_current_win(previous_win)
end
if was_loaded then
refresh(buf)
end
r:refresh()
end
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local raw = name:sub(#M.URI_PREFIX + 1)
if raw == "" then
return
end
local worktree = vim.fs.abspath(raw)
local r = repo.resolve(worktree)
if not r then
util.error("not a git worktree: %s", raw)
return
end
if r.worktree ~= worktree then
util.warning("%s is not a worktree root, using %s", raw, r.worktree)
end
local canonical = M.URI_PREFIX .. r.worktree
if name ~= canonical then
local existing = find_buf(canonical)
if existing and existing ~= buf then
local win = vim.api.nvim_get_current_win()
if vim.api.nvim_win_get_buf(win) == buf then
vim.api.nvim_win_set_buf(win, existing)
end
vim.api.nvim_buf_delete(buf, { force = true })
local s = state[existing]
if s then
s.win = win
s.placement = "current"
end
refresh(existing)
r:refresh()
return
end
pcall(vim.api.nvim_buf_set_name, buf, canonical)
end
repo.bind(buf, r)
util.setup_scratch(buf, { bufhidden = "hide" })
vim.bo[buf].filetype = "gitstatus"
---@type integer?
local win = vim.fn.bufwinid(buf)
if win == -1 then
win = nil
end
if not state[buf] then
setup_buffer(buf, r, "current", win)
else
state[buf].win = 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
+2 -2
View File
@@ -91,8 +91,8 @@ function M.setup()
"oxfmt",
"oxlint",
-- "phpactor",
-- "pyrefly",
"pyright",
"pyrefly",
-- "pyright",
"ruff",
"rust_analyzer",
"svelte",
+142 -11
View File
@@ -79,6 +79,77 @@ local function is_url(src)
return src:find("://") ~= nil
end
---@param src string
---@return string?
local function file_url_to_path(src)
local path = src:match("^file://(/.+)$")
if not path then
return nil
end
return vim.fs.normalize(path)
end
---@param path string
---@return string?
local function plugin_name_from_path(path)
local name = vim.fs.basename(path)
if not name or name == "" or name == "." or name == ".." then
return nil
end
return name
end
local data_dir = vim.fn.stdpath("data")
if type(data_dir) == "table" then
data_dir = assert(data_dir[1])
end
local dev_opt_dir = vim.fs.joinpath(data_dir, "site", "pack", "dev", "opt")
---@param target string
---@param name string
---@return string? link
---@return boolean changed
local function ensure_dev_link(target, name)
if not vim.uv.fs_stat(target) then
log.error("pack: dev plugin path does not exist: %s", target)
return nil, false
end
local ok, info = pcall(vim.pack.get, { name })
if ok and info and #info > 0 then
pcall(vim.pack.del, { name }, { force = true })
end
vim.fn.mkdir(dev_opt_dir, "p")
local link = vim.fs.joinpath(dev_opt_dir, name)
local lstat = vim.uv.fs_lstat(link)
if lstat then
if lstat.type == "link" then
if vim.uv.fs_readlink(link) == target then
return link, false
end
local ok_unlink, err = vim.uv.fs_unlink(link)
if not ok_unlink then
log.error("pack: failed to unlink %s: %s", link, err)
return nil, false
end
else
log.error(
"pack: %s exists and is not a symlink; refusing to overwrite",
link
)
return nil, false
end
end
local ok_link, err = vim.uv.fs_symlink(target, link)
if not ok_link then
log.error("pack: failed to symlink %s -> %s: %s", link, target, err)
return nil, false
end
return link, true
end
---@param spec string | ow.Pack.Spec
---@return vim.pack.Spec
local function to_pack_spec(spec)
@@ -267,22 +338,59 @@ end
function M.setup(specs)
local pack_specs = {}
local order = {}
local dev_changed = {}
for _, spec in ipairs(specs) do
local spec_t = type(spec) == "table" and spec or nil
local src = type(spec) == "string" and spec or spec.src
table.insert(order, src)
if is_url(src) then
local dev_path = file_url_to_path(src)
if dev_path then
local name = (spec_t and spec_t.name)
or plugin_name_from_path(dev_path)
if not name then
log.error("pack: invalid plugin name derived from %s", src)
else
local link, did_change = ensure_dev_link(dev_path, name)
if link then
local ok_add, add_err = pcall(vim.cmd.packadd, name)
if not ok_add then
log.error(
"pack: failed to packadd %s: %s",
name,
add_err
)
else
---@type ow.Pack.Plugin
M.plugins[src] = {
src = src,
name = name,
version = spec_t and spec_t.version,
build = spec_t and spec_t.build,
path = link,
}
if did_change then
dev_changed[src] = { path = link }
end
end
end
end
elseif is_url(src) then
table.insert(pack_specs, to_pack_spec(spec))
else
vim.cmd.packadd(src)
local runtime =
vim.api.nvim_get_runtime_file("pack/*/opt/" .. src, false)
---@type ow.Pack.Plugin
local plugin = {
src = src,
name = src,
path = runtime[1] or "",
}
M.plugins[plugin.src] = plugin
local ok_add, add_err = pcall(vim.cmd.packadd, src)
if not ok_add then
log.error("pack: failed to packadd %s: %s", src, add_err)
else
local runtime =
vim.api.nvim_get_runtime_file("pack/*/opt/" .. src, false)
---@type ow.Pack.Plugin
local plugin = {
src = src,
name = src,
path = runtime[1] or "",
}
M.plugins[plugin.src] = plugin
end
end
end
@@ -303,6 +411,9 @@ function M.setup(specs)
M.plugins[plugin.src] = plugin
vim.cmd.packadd(plugin.name)
end)
for src, data in pairs(dev_changed) do
changed[src] = data
end
for _, src in ipairs(order) do
local plugin = M.plugins[src]
@@ -343,6 +454,26 @@ end
---@param names? string[]
---@param opts? table
function M.update(names, opts)
if names then
local managed = {}
for _, plugin in pairs(M.plugins) do
if not file_url_to_path(plugin.src) and is_url(plugin.src) then
managed[plugin.name] = true
end
end
local filtered = {}
for _, name in ipairs(names) do
if managed[name] then
table.insert(filtered, name)
else
log.warning("pack: skipping %s (not managed by vim.pack)", name)
end
end
if #filtered == 0 then
return
end
names = filtered
end
vim.pack.update(names, opts)
end
+1 -4
View File
@@ -63,10 +63,7 @@ end
---@param filetypes? string[]
---@param symbol_name? string
local function register_lang(lang, so, filetypes, symbol_name)
vim.treesitter.language.add(
lang,
{ path = so, symbol_name = symbol_name }
)
vim.treesitter.language.add(lang, { path = so, symbol_name = symbol_name })
local fts = { lang }
if filetypes then
vim.treesitter.language.register(lang, filetypes)
+3 -3
View File
@@ -13,9 +13,9 @@
"rev": "17e3507b788699776ba6d2f8dd101ec177f37a96",
"src": "https://github.com/ibhagwan/fzf-lua"
},
"gitsigns.nvim": {
"rev": "dd3f588bacbeb041be6facf1742e42097f62165d",
"src": "https://github.com/lewis6991/gitsigns.nvim"
"git.nvim": {
"rev": "c4585b77686b8134cb2b671d5e30ef4a773b5357",
"src": "https://git.owall.se/warg/git.nvim"
},
"grug-far.nvim": {
"rev": "21790e59dd0109a92a70cb874dd002af186314f5",
-259
View File
@@ -1,259 +0,0 @@
if vim.g.loaded_git then
return
end
vim.g.loaded_git = 1
local DEFAULT_HIGHLIGHTS = {
GitIgnored = "Comment",
GitSha = "Identifier",
GitStaged = "Constant",
GitUnmerged = "Todo",
GitUnpulled = "Removed",
GitUnpushed = "Added",
GitUnstaged = "Changed",
GitUntracked = "Added",
GitStagedAdded = "GitStaged",
GitStagedCopied = "GitStaged",
GitStagedDeleted = "GitStaged",
GitStagedModified = "GitStaged",
GitStagedRenamed = "GitStaged",
GitStagedTypeChanged = "GitStaged",
GitUnstagedAdded = "GitUnstaged",
GitUnstagedCopied = "GitUnstaged",
GitUnstagedDeleted = "Removed",
GitUnstagedModified = "GitUnstaged",
GitUnstagedRenamed = "GitStaged",
GitUnstagedTypeChanged = "GitUnstaged",
GitUnmergedAddedByThem = "GitUnmerged",
GitUnmergedAddedByUs = "GitUnmerged",
GitUnmergedBothAdded = "GitUnmerged",
GitUnmergedBothDeleted = "GitUnmerged",
GitUnmergedBothModified = "GitUnmerged",
GitUnmergedDeletedByThem = "GitUnmerged",
GitUnmergedDeletedByUs = "GitUnmerged",
}
for name, link in pairs(DEFAULT_HIGHLIGHTS) do
vim.api.nvim_set_hl(0, name, { link = link, default = true })
end
local group = vim.api.nvim_create_augroup("ow.git", { clear = true })
vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, {
group = group,
callback = function(args)
require("git.core.repo").track(args.buf)
end,
})
vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, {
group = group,
callback = function(args)
require("git.core.repo").refresh(args.buf)
end,
})
vim.api.nvim_create_autocmd({ "ShellCmdPost", "TermLeave", "FocusGained" }, {
group = group,
callback = function()
for _, r in pairs(require("git.core.repo").all()) do
r:refresh({ invalidate = true })
end
end,
})
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
group = group,
callback = function(args)
require("git.core.repo").unbind(args.buf)
end,
})
vim.api.nvim_create_autocmd("VimLeavePre", {
group = group,
callback = function()
require("git.core.repo").stop_all()
end,
})
vim.api.nvim_create_autocmd({ "VimEnter", "DirChanged", "TabEnter" }, {
group = group,
callback = function()
require("git.core.repo").update_cwd_repo()
end,
})
vim.api.nvim_create_autocmd("TabClosed", {
group = group,
callback = function(args)
local tab = tonumber(args.file) --[[@as integer?]]
if tab then
require("git.core.repo").release_tab(tab)
end
end,
})
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "git://*",
group = group,
callback = function(args)
require("git.object").read_uri(args.buf)
end,
})
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "gitlog://*",
group = group,
callback = function(args)
require("git.log_view").read_uri(args.buf)
end,
})
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "gitstatus://*",
group = group,
callback = function(args)
require("git.status_view").read_uri(args.buf)
end,
})
vim.api.nvim_create_user_command("G", function(opts)
local cmd = require("git.cmd")
cmd.run(cmd.parse_args(opts.args), { bang = opts.bang })
end, {
nargs = "*",
bang = true,
complete = function(...)
return require("git.cmd").complete(...)
end,
})
vim.api.nvim_create_user_command("Grefresh", function()
require("git.core.repo").refresh_all()
end, { desc = "Refresh git status for all repos" })
vim.api.nvim_create_user_command("Glog", function(opts)
require("git.log_view").run_glog(opts)
end, {
nargs = "*",
complete = function(...)
return require("git.log_view").complete_glog(...)
end,
desc = "Show git log",
})
local function complete_rev(...)
return require("git.cmd").complete_rev(...)
end
local DIFF_DIRECTIONS = { "vertical", "horizontal" }
vim.api.nvim_create_user_command("Gdiffsplit", function(opts)
local fargs = opts.fargs
local mods = nil
local rev_idx = 1
if fargs[1] == "vertical" then
mods = { vertical = true }
rev_idx = 2
elseif fargs[1] == "horizontal" then
mods = { vertical = false }
rev_idx = 2
end
require("git.diff").split({ target = fargs[rev_idx], mods = mods })
end, {
nargs = "*",
complete = function(arg_lead, 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 results = {}
if #prior == 0 then
for _, d in ipairs(DIFF_DIRECTIONS) do
if vim.startswith(d, arg_lead) then
table.insert(results, d)
end
end
end
local first_is_direction = prior[1] == "vertical"
or prior[1] == "horizontal"
if #prior == 0 or (#prior == 1 and first_is_direction) then
vim.list_extend(results, complete_rev(arg_lead))
end
return results
end,
desc = "Open a diff split",
})
vim.api.nvim_create_user_command("Gedit", function(opts)
vim.cmd.edit({
args = { require("git.object").URI_PREFIX .. opts.args },
magic = { file = false },
})
end, {
nargs = 1,
complete = complete_rev,
desc = "Edit a git object (<rev>)",
})
vim.api.nvim_create_user_command("Gstatus", function(opts)
require("git.status_view").open({
placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]]
or "split",
})
end, {
nargs = "?",
complete = function()
return require("git.status_view").PLACEMENTS
end,
desc = "Open git status view",
})
vim.keymap.set("n", "<Plug>(git-edit)", function()
local rev = vim.fn.input("Edit git object: ")
if rev == "" then
return
end
vim.cmd.edit({
args = { require("git.object").URI_PREFIX .. rev },
magic = { file = false },
})
end, { silent = true, desc = "Edit a git object" })
vim.keymap.set("n", "<Plug>(git-diff-vertical)", function()
require("git.diff").split({ mods = { vertical = true } })
end, { silent = true, desc = "Diff against index (vertical)" })
vim.keymap.set("n", "<Plug>(git-diff-horizontal)", function()
require("git.diff").split({ mods = { vertical = false } })
end, { silent = true, desc = "Diff against index (horizontal)" })
vim.keymap.set("n", "<Plug>(git-diff-vertical-head)", function()
require("git.diff").split({
target = "HEAD",
mods = { vertical = true },
})
end, { silent = true, desc = "Diff against HEAD (vertical)" })
vim.keymap.set("n", "<Plug>(git-diff-horizontal-head)", function()
require("git.diff").split({
target = "HEAD",
mods = { vertical = false },
})
end, { silent = true, desc = "Diff against HEAD (horizontal)" })
vim.keymap.set("n", "<Plug>(git-status-toggle)", function()
require("git.status_view").toggle()
end, { silent = true, desc = "Toggle git status sidebar" })
vim.keymap.set("n", "<Plug>(git-log)", function()
require("git.log_view").open({ max_count = 1000 })
end, { silent = true, desc = "Open git log" })
vim.keymap.set("n", "<Plug>(git-commit)", function()
require("git.commit").commit()
end, { silent = true, desc = "Start a git commit" })
vim.keymap.set("n", "<Plug>(git-commit-amend)", function()
require("git.commit").commit({ args = { "--amend" } })
end, { silent = true, desc = "Amend the last git commit" })
if vim.g.git_statusline ~= false then
vim.api.nvim_create_autocmd("BufWinEnter", {
group = group,
once = true,
callback = function()
require("git.statusline")
end,
})
end
+6 -1
View File
@@ -172,7 +172,12 @@ end)
vim.keymap.set("n", "<leader>fb", fzf.buffers)
vim.keymap.set("n", "<leader>fh", fzf.highlights)
vim.keymap.set("n", "gd", fzf.lsp_definitions)
vim.keymap.set("n", "grr", fzf.lsp_references)
vim.keymap.set("n", "grr", function()
fzf.lsp_references({
ignore_current_line = true,
includeDeclaration = false,
})
end)
vim.keymap.set("n", "gri", fzf.lsp_implementations)
vim.keymap.set("n", "grt", fzf.lsp_typedefs)
vim.keymap.set("n", "<leader>fd", fzf.diagnostics_document)
-48
View File
@@ -1,48 +0,0 @@
require("gitsigns").setup({
preview_config = {
border = "single",
},
on_attach = function(bufnr)
local gs = require("gitsigns")
vim.keymap.set("n", "<leader>gv", gs.select_hunk, { buffer = bufnr })
vim.keymap.set("n", "<leader>gs", gs.stage_hunk, { buffer = bufnr })
vim.keymap.set("x", "<leader>gs", function()
gs.stage_hunk({ vim.fn.line("."), vim.fn.line("v") })
end, { buffer = bufnr })
vim.keymap.set("n", "<leader>gr", gs.reset_hunk, { buffer = bufnr })
vim.keymap.set(
"x",
"<leader>gr",
":Gitsigns reset_hunk<CR>",
{ buffer = bufnr }
)
vim.keymap.set("n", "<leader>g?", gs.preview_hunk, { buffer = bufnr })
vim.keymap.set("n", "<leader>gb", function()
gs.blame_line({ full = true, ignore_whitespace = true })
end, { buffer = bufnr })
vim.keymap.set({ "n", "x" }, "]g", function()
gs.nav_hunk("next", {
wrap = true,
foldopen = true,
navigation_message = true,
greedy = true,
preview = true,
count = 1,
target = "all",
})
end)
vim.keymap.set({ "n", "x" }, "[g", function()
gs.nav_hunk("prev", {
wrap = true,
foldopen = true,
navigation_message = true,
greedy = true,
preview = true,
count = 1,
target = "all",
})
end)
end,
attach_to_untracked = false,
sign_priority = 100,
})
+1 -1
View File
@@ -27,7 +27,7 @@ require("mason-auto-install").setup({
"oxfmt",
"oxlint",
"ruff",
"pyright",
-- "pyright",
"pyrefly",
{
"prettier",
+1 -2
View File
@@ -45,10 +45,9 @@ local highlights = {
TabLineFill = { bg = c.bg1 },
EndOfBuffer = { fg = "NONE", bg = "NONE" },
DiffAdd = { bg = "#1a2f22" },
DiffChange = { bg = "#15304a" },
DiffDelete = { bg = "#311c1e" },
Changed = { fg = c.yellow },
NvimTreeIndentMarker = { fg = c.bg3 },
GitUnstaged = { fg = c.yellow },
}
for kind, color in pairs(completion_kind_colors) do
highlights["LspKind" .. kind] = { fg = color }
+2 -2
View File
@@ -19,8 +19,8 @@ syntax match gitlogGraphLine /^[*|\\\/_ ]\+$/
highlight default link gitlogGraph Comment
highlight default link gitlogHash GitSha
highlight default link gitlogDate Number
highlight default link gitlogAuthor String
highlight default link gitlogDate GitDate
highlight default link gitlogAuthor GitAuthor
highlight default link gitlogRef Constant
let b:current_syntax = "gitlog"
-668
View File
@@ -1,668 +0,0 @@
local cmd = require("git.cmd")
local h = require("test.git.helpers")
local t = require("test")
---@param files table<string, string>?
---@return string dir
local function make_repo(files)
return h.make_repo(files, { cd = true })
end
---@param actual string[]
---@param expected string[]
local function eq_sorted(actual, expected, msg)
table.sort(actual)
table.sort(expected)
t.eq(actual, expected, msg)
end
t.test("parse_args splits on whitespace", function()
t.eq(
cmd.parse_args("config user.name value"),
{ "config", "user.name", "value" }
)
end)
t.test("parse_args preserves spaces inside double quotes", function()
t.eq(
cmd.parse_args([[config user.name "Oscar Wallberg"]]),
{ "config", "user.name", "Oscar Wallberg" }
)
end)
t.test("parse_args preserves spaces inside single quotes", function()
t.eq(
cmd.parse_args([[log --grep='bug fix' --author=Oscar]]),
{ "log", "--grep=bug fix", "--author=Oscar" }
)
end)
t.test("parse_args handles backslash-escaped space", function()
t.eq(cmd.parse_args([[a\ b c]]), { "a b", "c" })
end)
t.test("parse_args handles escaped quote inside double quotes", function()
t.eq(cmd.parse_args([["a\"b" c]]), { 'a"b', "c" })
end)
t.test("parse_args treats backslash literally inside single quotes", function()
t.eq(cmd.parse_args([['a\b' c]]), { "a\\b", "c" })
end)
t.test("parse_args concatenates adjacent quoted segments", function()
t.eq(cmd.parse_args([[foo"bar"baz]]), { "foobarbaz" })
end)
t.test("parse_args handles tabs as separators", function()
t.eq(cmd.parse_args("a\tb\tc"), { "a", "b", "c" })
end)
t.test("parse_args returns empty list for empty or whitespace input", function()
t.eq(cmd.parse_args(""), {})
t.eq(cmd.parse_args(" \t "), {})
end)
t.test("parse_args preserves empty quoted token", function()
t.eq(cmd.parse_args([[a "" b]]), { "a", "", "b" })
end)
t.test("parse_args expands %% on unquoted token", function()
local buf = vim.api.nvim_create_buf(false, false)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua")
vim.api.nvim_set_current_buf(buf)
t.eq(
cmd.parse_args("add %"),
{ "add", vim.fn.getcwd() .. "/some-file.lua" }
)
end)
t.test("parse_args does not expand %% inside double quotes", function()
local buf = vim.api.nvim_create_buf(false, false)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua")
vim.api.nvim_set_current_buf(buf)
t.eq(cmd.parse_args([[log -- "%"]]), { "log", "--", "%" })
end)
t.test("parse_args does not expand %% inside single quotes", function()
local buf = vim.api.nvim_create_buf(false, false)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua")
vim.api.nvim_set_current_buf(buf)
t.eq(cmd.parse_args([[log -- '%']]), { "log", "--", "%" })
end)
t.test("parse_args does not treat mid-token tilde as expansion", function()
t.eq(cmd.parse_args("checkout HEAD~3"), { "checkout", "HEAD~3" })
end)
t.test("parse_args expands leading ~/ to home", function()
t.eq(cmd.parse_args("add ~/foo"), { "add", vim.fn.expand("~/foo") })
end)
t.test("parse_complete_state with trailing space", function()
local s = cmd._parse_complete_state("G push origin ")
t.eq(s.prior, { "push", "origin" })
t.falsy(s.after_separator)
end)
t.test("parse_complete_state mid-token", function()
local s = cmd._parse_complete_state("G push or")
t.eq(s.prior, { "push" })
t.falsy(s.after_separator)
end)
t.test("parse_complete_state empty after command", function()
local s = cmd._parse_complete_state("G ")
t.eq(s.prior, {})
t.falsy(s.after_separator)
end)
t.test("parse_complete_state detects -- separator", function()
local s = cmd._parse_complete_state("G log -- foo")
t.eq(s.prior, { "log", "--" })
t.truthy(s.after_separator)
end)
t.test("positional_index ignores flags", function()
t.eq(cmd._positional_index({ "push" }), 1)
t.eq(cmd._positional_index({ "push", "origin" }), 2)
t.eq(cmd._positional_index({ "push", "--force" }), 1)
t.eq(cmd._positional_index({ "push", "--force", "origin" }), 2)
t.eq(cmd._positional_index({ "checkout", "-b", "feature" }), 2)
end)
t.test("complete returns subcommands at first position", function()
local matches = cmd.complete("ch", "G ch", 4)
t.truthy(vim.tbl_contains(matches, "checkout"))
t.truthy(vim.tbl_contains(matches, "cherry-pick"))
end)
t.test("complete returns flags when arg starts with -", function()
local matches = cmd.complete("--am", "G commit --am", 13)
t.eq(matches, { "--amend" })
end)
t.test("complete branch returns plain refs (no pseudo, no stash)", function()
local dir = make_repo({ a = "x" })
h.git(dir, "branch", "feature")
h.git(dir, "tag", "v1")
t.write(dir, "a", "modified")
h.git(dir, "stash")
local matches = cmd.complete("", "G branch ", 9)
eq_sorted(matches, { "feature", "main", "v1" })
end)
t.test("complete merge returns refs + pseudo + stash", function()
local dir = make_repo({ a = "x" })
h.git(dir, "branch", "feature")
t.write(dir, "a", "y")
h.git(dir, "stash")
local matches = cmd.complete("", "G merge ", 8)
eq_sorted(
matches,
{ "HEAD", "ORIG_HEAD", "feature", "main", "stash", "stash@{0}" }
)
end)
t.test("complete push first positional returns remotes", function()
local dir = make_repo({ a = "x" })
h.git(dir, "remote", "add", "origin", "/tmp/nope")
h.git(dir, "remote", "add", "upstream", "/tmp/nope")
local matches = cmd.complete("", "G push ", 7)
eq_sorted(matches, { "origin", "upstream" })
end)
t.test("complete push second positional returns refs", function()
local dir = make_repo({ a = "x" })
h.git(dir, "branch", "feature")
local matches = cmd.complete("", "G push origin ", 14)
eq_sorted(matches, { "HEAD", "feature", "main" })
end)
t.test("complete add returns only unstaged/untracked paths", function()
local dir = make_repo({ tracked = "x" })
t.write(dir, "tracked", "modified")
t.write(dir, "newfile", "new")
local r = assert(require("git.core.repo").resolve(dir))
r:refresh()
t.wait_for(function()
return r.status and #vim.tbl_keys(r.status.entries) > 0
end, "git status to report entries", 500)
local matches = cmd.complete("", "G add ", 6)
eq_sorted(matches, { "newfile", "tracked" })
end)
t.test("complete after `--` returns tracked paths only", function()
local dir = make_repo({ a = "x", b = "y" })
t.write(dir, "untracked", "z")
local matches = cmd.complete("", "G log -- ", 9)
eq_sorted(matches, { "a", "b" })
end)
t.test("complete stash returns subsubcommands", function()
make_repo({ a = "x" })
local matches = cmd.complete("p", "G stash p", 9)
eq_sorted(matches, { "pop", "push" })
end)
t.test("complete show with <rev>:<path> returns tree paths", function()
make_repo({ a = "x", ["sub/b"] = "y" })
local matches = cmd.complete("HEAD:", "G show HEAD:", 12)
eq_sorted(matches, { "HEAD:a", "HEAD:sub/" })
end)
t.test("complete unknown subcommand falls back to tracked paths", function()
make_repo({ a = "x", b = "y" })
local matches = cmd.complete("", "G nonexistent ", 14)
eq_sorted(matches, { "a", "b" })
end)
---@param name_pattern string
---@return integer count
local function count_bufs_named(name_pattern)
local n = 0
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match(name_pattern) then
n = n + 1
end
end
return n
end
---@param buf_name_pattern string
---@param timeout integer?
local function wait_buf_populated(buf_name_pattern, timeout)
t.wait_for(function()
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match(buf_name_pattern) then
return #vim.api.nvim_buf_get_lines(b, 0, -1, false) > 1
end
end
return false
end, "buffer matching " .. buf_name_pattern .. " to populate", timeout)
end
---Wait for a buffer matching `buf_name_pattern` to contain a line whose
---content equals `line`. Useful for asserting that re-running a :G
---command repopulated the buffer with new output.
---@param buf_name_pattern string
---@param line string
---@param timeout integer?
local function wait_buf_has_line(buf_name_pattern, line, timeout)
t.wait_for(function()
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match(buf_name_pattern) then
for _, l in ipairs(vim.api.nvim_buf_get_lines(b, 0, -1, false)) do
if l == line then
return true
end
end
end
end
return false
end, "buffer " .. buf_name_pattern .. " to contain " .. line, timeout)
end
t.test("run :G diff reuses the same buffer across invocations", function()
local dir = make_repo({ a = "v1\n" })
t.write(dir, "a", "v2\n")
cmd.run({ "diff" })
wait_buf_has_line("%[Git diff%]", "+v2")
t.eq(count_bufs_named("%[Git diff%]"), 1)
t.write(dir, "a", "v3\n")
cmd.run({ "diff" })
wait_buf_has_line("%[Git diff%]", "+v3")
t.eq(count_bufs_named("%[Git diff%]"), 1, "second :G diff should reuse")
t.write(dir, "a", "v4\n")
cmd.run({ "diff" })
wait_buf_has_line("%[Git diff%]", "+v4")
t.eq(count_bufs_named("%[Git diff%]"), 1, "third :G diff should reuse")
end)
---@param buf integer
---@param prefix string
---@return integer? lnum
local function find_line(buf, prefix)
for i, l in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
if l:sub(1, #prefix) == prefix then
return i
end
end
end
t.test(":G show <CR> on + line opens the blob URI", function()
local dir = make_repo({ a = "first\n" })
t.write(dir, "a", "second\n")
h.git(dir, "add", "a")
h.git(dir, "commit", "-q", "-m", "second")
assert(require("git.core.repo").resolve(dir))
local blob = h.git(dir, "rev-parse", "HEAD:a").stdout
cmd.run({ "show", "HEAD" })
wait_buf_populated("%[Git show HEAD%]")
---@type integer?
local diff_buf
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match("%[Git show HEAD%]") then
diff_buf = b
end
end
assert(diff_buf, "expected [Git show HEAD] buffer")
local win = vim.fn.bufwinid(diff_buf)
vim.api.nvim_set_current_win(win)
local lnum = assert(find_line(diff_buf, "+second"))
vim.api.nvim_win_set_cursor(win, { lnum, 0 })
t.truthy(require("git.object").open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. blob)
end)
t.test("<leader>gl log buffer refills after jumping back", function()
local dir = make_repo({ a = "v1\n" })
t.write(dir, "a", "v2\n")
h.git(dir, "add", "a")
h.git(dir, "commit", "-q", "-m", "second")
require("git.log_view").open({ max_count = 1000 })
wait_buf_populated("^gitlog://")
local log_buf = vim.api.nvim_get_current_buf()
local log_win = vim.api.nvim_get_current_win()
t.truthy(vim.api.nvim_buf_get_name(log_buf):match("^gitlog://"))
local initial_lines = #vim.api.nvim_buf_get_lines(log_buf, 0, -1, false)
t.truthy(initial_lines >= 2)
-- Step into a commit, then <C-o> back to the log.
vim.api.nvim_win_set_cursor(log_win, { 1, 0 })
t.press("<CR>")
t.truthy(vim.api.nvim_buf_get_name(0):match("^git://"))
t.press("<C-o>")
t.eq(vim.api.nvim_get_current_buf(), log_buf)
t.eq(
#vim.api.nvim_buf_get_lines(log_buf, 0, -1, false),
initial_lines,
"log buffer must repopulate on jump-back"
)
end)
t.test("<CR> still dispatches after navigating away and back", function()
local dir = make_repo({ a = "v1\n" })
t.write(dir, "a", "v2\n")
h.git(dir, "add", "a")
h.git(dir, "commit", "-q", "-m", "second")
-- Open the HEAD commit object buffer. Its cat-file output includes a
-- "parent <sha>" line we can navigate from.
local r = assert(require("git.core.repo").resolve(dir))
require("git.object").open(r, "HEAD", { split = false })
local first_obj_buf = vim.api.nvim_get_current_buf()
local first_obj_win = vim.api.nvim_get_current_win()
t.truthy(vim.api.nvim_buf_get_name(first_obj_buf):match("^git://"))
-- Step into the parent commit. This hides first_obj_buf which has
-- bufhidden=delete, so it gets unloaded.
local parent_lnum = assert(find_line(first_obj_buf, "parent "))
vim.api.nvim_win_set_cursor(first_obj_win, { parent_lnum, 0 })
t.truthy(require("git.object").open_under_cursor())
local parent_buf = vim.api.nvim_get_current_buf()
t.truthy(parent_buf ~= first_obj_buf)
-- <C-o> back to first_obj_buf. With bufhidden=delete, vim re-reads the
-- URI, which previously raced with BufDelete-driven unbind and left
-- state cleared, so open_under_cursor returned false.
t.press("<C-o>")
t.eq(vim.api.nvim_get_current_buf(), first_obj_buf)
local tree_lnum = assert(find_line(first_obj_buf, "tree "))
vim.api.nvim_win_set_cursor(first_obj_win, { tree_lnum, 0 })
t.truthy(
require("git.object").open_under_cursor(),
"<CR> must work after returning to the buffer"
)
end)
t.test(":G diff <CR> on + line falls back to worktree file", function()
local dir = make_repo({ a = "v1\n" })
t.write(dir, "a", "v2\n")
assert(require("git.core.repo").resolve(dir))
cmd.run({ "diff" })
wait_buf_populated("%[Git diff%]")
---@type integer?
local diff_buf
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match("%[Git diff%]") then
diff_buf = b
end
end
assert(diff_buf, "expected [Git diff] buffer")
local win = vim.fn.bufwinid(diff_buf)
vim.api.nvim_set_current_win(win)
local lnum = assert(find_line(diff_buf, "+v2"))
vim.api.nvim_win_set_cursor(win, { lnum, 0 })
t.truthy(require("git.object").open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), vim.fs.joinpath(dir, "a"))
end)
---Run cmd.run via :lua so vim.fn.execute captures any nvim_echo output
---and suppresses it from headless stdout.
---@param args string[]
---@return string
local function run_capturing(args)
return vim.trim(
vim.fn.execute(
string.format([[lua require("git.cmd").run(%s)]], vim.inspect(args))
)
)
end
---@return integer? pwin
local function find_preview_win()
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.wo[w].previewwindow then
return w
end
end
end
local function close_preview()
pcall(vim.cmd.pclose)
end
t.test("quiet :G echoes single-line stdout", function()
make_repo({ a = "x" })
local out = run_capturing({ "config", "user.email" })
t.truthy(
out:match("t@t%.com"),
"expected output to contain t@t.com, got: " .. out
)
end)
t.test("quiet :G is silent on empty-output success", function()
make_repo({ a = "x" })
t.eq(run_capturing({ "config", "user.email", "new@t.com" }), "")
end)
t.test("quiet :G echoes 'git exited N' on silent failure", function()
make_repo({ a = "x" })
local out = run_capturing({ "config", "--get", "nonexistent.foo.bar" })
t.truthy(
out:match("git exited 1"),
"expected output to contain 'git exited 1', got: " .. out
)
end)
t.test("quiet :G echoes stderr on failure with output", function()
make_repo({ a = "x" })
local out = run_capturing({ "branch", "-d", "nonexistent-branch" })
t.truthy(
out:match("nonexistent%-branch"),
"expected stderr mentioning the branch, got: " .. out
)
end)
---@param fn fun(calls: { chunks: table, history: boolean, opts: table }[])
local function with_echo_stub(fn)
---@type { chunks: table, history: boolean, opts: table }[]
local calls = {}
local original = vim.api.nvim_echo
vim.api.nvim_echo = function(chunks, history, opts)
table.insert(calls, {
chunks = chunks,
history = history,
opts = opts or {},
})
return -1
end
local ok, err = pcall(fn, calls)
vim.api.nvim_echo = original
if not ok then
error(err, 0)
end
end
---@param calls { opts: table }[]
---@param status string
---@return boolean
local function has_status(calls, status)
for _, c in ipairs(calls) do
if c.opts.status == status then
return true
end
end
return false
end
---@param calls { history: boolean }[]
---@return boolean
local function any_history(calls)
for _, c in ipairs(calls) do
if c.history then
return true
end
end
return false
end
t.test("quiet :G success does not add to :messages history", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "config", "user.email" })
t.falsy(any_history(calls), "success path must not write history")
end)
end)
t.test("quiet :G silent failure adds 'git exited N' to history", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "config", "--get", "nonexistent.foo.bar" })
t.truthy(any_history(calls), "failure path must write history")
end)
end)
t.test("quiet :G stderr failure adds error to history", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "branch", "-d", "nonexistent-branch" })
t.truthy(any_history(calls), "failure path must write history")
end)
end)
t.test("streaming :G fetch (no bang) does not open a window", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
local before = #vim.api.nvim_tabpage_list_wins(0)
cmd.run({ "fetch" })
t.wait_for(function()
return has_status(calls, "failed")
or has_status(calls, "success")
end, "streaming job to terminate", 5000)
t.eq(#vim.api.nvim_tabpage_list_wins(0), before, "no new window")
t.falsy(find_preview_win(), "no preview window")
end)
end)
t.test(
"streaming :G fetch (no bang) emits failed progress on bad remote",
function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "fetch", "nonexistent" })
t.wait_for(function()
return has_status(calls, "failed")
end, "failed progress notification", 5000)
---@type { chunks: table, history: boolean, opts: table }?
local final
for _, c in ipairs(calls) do
if c.opts.status == "failed" then
final = c
break
end
end
t.truthy(final)
---@cast final -nil
t.eq(final.opts.kind, "progress")
t.falsy(final.history, "transient progress, not history")
t.truthy(has_status(calls, "running"), "running progress emitted")
end)
end
)
t.test(
"streaming :G fetch (no bang) on failure highlights only fatal/error lines",
function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "fetch", "nonexistent" })
t.wait_for(function()
return has_status(calls, "failed")
end, "failed progress notification", 5000)
---@type table?
local dump
for _, c in ipairs(calls) do
if c.history == true then
dump = c.chunks
break
end
end
t.truthy(dump, "expected history dump")
---@cast dump -nil
local fatal_chunks_red, plain_continuation = 0, false
for _, chunk in ipairs(dump) do
local text, hl = chunk[1], chunk[2]
if text:match("^fatal:") and hl == "ErrorMsg" then
fatal_chunks_red = fatal_chunks_red + 1
end
if text:match("Please make sure") and hl ~= "ErrorMsg" then
plain_continuation = true
end
end
t.truthy(
fatal_chunks_red >= 1,
"expected at least one fatal: line highlighted as ErrorMsg"
)
t.truthy(
plain_continuation,
"expected continuation line to be plain"
)
end)
end
)
t.test(
"streaming :G fetch (no bang) on success does not dump to :messages",
function()
local remote = vim.fn.tempname()
vim.fn.mkdir(remote, "p")
h.git(remote, "init", "-q", "--bare")
t.defer(function()
vim.fn.delete(remote, "rf")
end)
local dir = make_repo({ a = "x" })
h.git(dir, "remote", "add", "origin", remote)
h.git(dir, "push", "-q", "origin", "main")
with_echo_stub(function(calls)
cmd.run({ "fetch", "origin" })
t.wait_for(function()
return has_status(calls, "success")
end, "success progress notification", 5000)
for _, c in ipairs(calls) do
t.falsy(
c.history,
"success path must not echo to message history"
)
end
end)
end
)
t.test(
"streaming :G! fetch (bang) opens preview window with terminal buffer",
function()
make_repo({ a = "x" })
t.defer(close_preview)
cmd.run({ "fetch" }, { bang = true })
local pwin = find_preview_win()
t.truthy(pwin, "expected preview window to exist")
---@cast pwin integer
local buf = vim.api.nvim_win_get_buf(pwin)
t.eq(vim.bo[buf].buftype, "terminal")
end
)
-97
View File
@@ -1,97 +0,0 @@
local t = require("test")
local M = {}
---@class test.git.SystemCompleted : vim.SystemCompleted
---@field stdout string
---@param dir string
---@return test.git.SystemCompleted
function M.git(dir, ...)
local r = vim.system({ "git", ... }, { cwd = dir, text = true }):wait()
if r.code ~= 0 then
error(
string.format(
"git %s failed: %s",
table.concat({ ... }, " "),
vim.trim(r.stderr or "")
),
2
)
end
if r.stdout then
r.stdout = vim.trim(r.stdout)
else
r.stdout = ""
end
return r
end
---Build a temporary git repo with the given committed contents and queue
---cleanup (stop fs watchers, drop test buffers, delete the dir). When
---`opts.cd` is true, also `cd` into the repo and restore the previous
---working directory on cleanup.
---@param files table<string, string>?
---@param opts { cd: boolean? }?
---@return string dir
function M.make_repo(files, opts)
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, "p")
M.git(dir, "init", "-q", "-b", "main")
M.git(dir, "config", "user.email", "t@t.com")
M.git(dir, "config", "user.name", "t")
if files and next(files) then
for path, content in pairs(files) do
t.write(dir, path, content)
end
M.git(dir, "add", ".")
M.git(dir, "commit", "-q", "-m", "init")
end
local prev_cwd
if opts and opts.cd then
prev_cwd = vim.fn.getcwd()
vim.cmd.cd(dir)
end
t.defer(function()
if prev_cwd then
pcall(vim.cmd.cd, prev_cwd)
else
pcall(vim.cmd.cd, "/tmp")
end
pcall(function()
require("git.core.repo").stop_all()
end)
vim.wait(60)
for _, b in ipairs(vim.api.nvim_list_bufs()) do
local name = vim.api.nvim_buf_get_name(b)
if name:find(dir, 1, true) or name:match("^git[a-z]*://") then
pcall(vim.api.nvim_buf_delete, b, { force = true })
end
end
vim.fn.delete(dir, "rf")
end)
return dir
end
---Build an outer repo with one nested submodule at `sub/`. Both the
---outer and inner repo are committed and registered for cleanup.
---@return string outer
---@return string inner
function M.make_submodule_repo()
local inner = M.make_repo({ a = "x\n" })
local outer = M.make_repo({ x = "x\n" })
vim.system({
"git",
"-c",
"protocol.file.allow=always",
"submodule",
"add",
"--quiet",
inner,
"sub",
}, { cwd = outer, text = true }):wait()
M.git(outer, "commit", "-q", "-m", "add submodule")
return outer, inner
end
return M
-172
View File
@@ -1,172 +0,0 @@
local Revision = require("git.core.revision")
local h = require("test.git.helpers")
local object = require("git.object")
local t = require("test")
---@return integer? buf
local function find_git_buf()
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match("^git://") then
return b
end
end
end
---@param buf integer
---@param prefix string
---@return integer? lnum
local function find_line(buf, prefix)
for i, l in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
if l:sub(1, #prefix) == prefix then
return i
end
end
end
t.test("parse_uri / format_uri round-trip for base", function()
local uri = "git://HEAD"
local rev = assert(object.parse_uri(uri))
t.eq(object.format_uri(rev), uri)
end)
t.test("parse_uri / format_uri round-trip for base + path", function()
local uri = "git://HEAD:lua/foo.lua"
local rev = assert(object.parse_uri(uri))
t.eq(object.format_uri(rev), uri)
end)
t.test("parse_uri / format_uri round-trip for stage + path", function()
local uri = "git://:2:lua/foo.lua"
local rev = assert(object.parse_uri(uri))
t.eq(object.format_uri(rev), uri)
end)
t.test("parse_uri normalizes bare :path to stage 0", function()
local rev = assert(object.parse_uri("git://:foo"))
t.eq(rev.stage, 0)
t.eq(rev.path, "foo")
t.eq(object.format_uri(rev), "git://:0:foo")
end)
t.test("parse_uri returns nil for non-git URIs", function()
t.falsy(object.parse_uri("file:///tmp/x"))
t.falsy(object.parse_uri("/tmp/x"))
t.falsy(object.parse_uri("gitlog:///tmp/x"))
end)
t.test("M.open(HEAD) names buffer with full sha", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = h.git(dir, "rev-parse", "HEAD").stdout
object.open(r, "HEAD", { split = false })
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha)
end)
t.test("M.open(<short sha>) canonicalizes to full sha", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = h.git(dir, "rev-parse", "HEAD").stdout
local short = h.git(dir, "rev-parse", "--short", "HEAD").stdout
t.truthy(#short < #sha, "short sha must be shorter than full")
object.open(r, short, { split = false })
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha)
end)
t.test("M.open(HEAD:<path>) loads file content at HEAD", function()
local dir = h.make_repo({ ["a.txt"] = "first\nsecond\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = h.git(dir, "rev-parse", "HEAD").stdout
object.open(r, "HEAD:a.txt", { split = false })
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha .. ":a.txt")
t.eq(
vim.api.nvim_buf_get_lines(0, 0, -1, false),
{ "first", "second" }
)
end)
t.test("M.open errors on a bogus base, no buffer is opened", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
t.quietly(function()
object.open(r, "deadbeefdeadbeef", { split = false })
end)
t.falsy(find_git_buf(), "no git:// buffer should exist")
end)
t.test("M.open errors on a missing path, no buffer is opened", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
t.quietly(function()
object.open(r, "HEAD:does-not-exist", { split = false })
end)
t.falsy(find_git_buf(), "no git:// buffer should exist")
end)
t.test("read_uri opens stage-0 entry as a writable index buffer", function()
local dir = h.make_repo({ ["a.txt"] = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local rev = Revision.new({ stage = 0, path = "a.txt" })
local buf = object.buf_for(r, rev)
t.eq(vim.bo[buf].buftype, "acwrite")
t.truthy(vim.bo[buf].modifiable)
t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "first" })
end)
t.test("open_under_cursor on a 'tree <sha>' line opens the tree", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local tree_sha = h.git(dir, "rev-parse", "HEAD^{tree}").stdout
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "tree "), "expected a tree line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.truthy(object.open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. tree_sha)
end)
t.test("open_under_cursor on a 'parent <sha>' line opens the parent", function()
local dir = h.make_repo({ a = "first\n" })
t.write(dir, "a", "second\n")
h.git(dir, "add", "a")
h.git(dir, "commit", "-q", "-m", "second")
local r = assert(require("git.core.repo").resolve(dir))
local parent_sha = h.git(dir, "rev-parse", "HEAD~").stdout
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "parent "), "expected a parent line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.truthy(object.open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. parent_sha)
end)
t.test("open_under_cursor on a '+++ b/<path>' line loads the blob", function()
local dir = h.make_repo({ ["a.txt"] = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local blob_sha = h.git(dir, "rev-parse", "HEAD:a.txt").stdout
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "+++ b/a.txt"), "expected a +++ line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.truthy(object.open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. blob_sha)
end)
t.test("open_under_cursor returns false on a non-dispatchable line", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "author "), "expected an author line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.falsy(object.open_under_cursor())
end)
-324
View File
@@ -1,324 +0,0 @@
---@diagnostic disable: access-invisible
local h = require("test.git.helpers")
local t = require("test")
---@param r ow.Git.Repo
---@param key string
---@param timeout integer?
local function wait_cleared(r, key, timeout)
t.wait_for(function()
return r._cache[key] == nil
end, key .. " cache to clear", timeout or 2000)
end
t.test("list_refs returns heads, tags, remotes (no HEAD)", function()
local dir = h.make_repo({ a = "x" })
h.git(dir, "branch", "feature")
h.git(dir, "tag", "v1")
local r = assert(require("git.core.repo").resolve(dir))
local refs = r:list_refs()
table.sort(refs)
t.eq(refs, { "feature", "main", "v1" })
end)
t.test("list_pseudo_refs always includes HEAD", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
t.eq(r:list_pseudo_refs(), { "HEAD" })
end)
t.test("list_pseudo_refs picks up MERGE_HEAD when present", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
t.write(dir .. "/.git", "MERGE_HEAD", "deadbeef\n")
-- Bypass cache (file appeared after first scan).
r._cache = {}
local refs = r:list_pseudo_refs()
table.sort(refs)
t.eq(refs, { "HEAD", "MERGE_HEAD" })
end)
t.test("list_stash_refs is empty when no stash", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
t.eq(r:list_stash_refs(), {})
end)
t.test("list_stash_refs lists stash + entries when stash exists", function()
local dir = h.make_repo({ a = "x" })
t.write(dir, "a", "modified")
h.git(dir, "stash")
local r = assert(require("git.core.repo").resolve(dir))
local refs = r:list_stash_refs()
t.eq(#refs, 2)
t.eq(refs[1], "stash")
t.eq(refs[2], "stash@{0}")
end)
t.test("get_cached memoizes by key", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local calls = 0
local v1 = r:get_cached("k", function()
calls = calls + 1
return { "first" }
end)
local v2 = r:get_cached("k", function()
calls = calls + 1
return { "second" }
end)
t.eq(calls, 1)
t.truthy(v1 == v2, "second call should return cached table")
end)
t.test("cache clears after top-level .git change (commit)", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local _ = r:list_refs()
t.truthy(r._cache.refs)
t.write(dir, "b", "y")
h.git(dir, "add", "b")
h.git(dir, "commit", "-q", "-m", "two")
wait_cleared(r, "refs")
t.falsy(r._cache.refs, "cache should be cleared after commit")
end)
t.test("cache clears after slash-branch creation (polyfill)", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local _ = r:list_refs()
t.truthy(r._cache.refs)
h.git(dir, "branch", "feat/foo")
wait_cleared(r, "refs")
t.falsy(r._cache.refs, "cache should clear via polyfilled subdir watcher")
local refs = r:list_refs()
table.sort(refs)
t.eq(refs, { "feat/foo", "main" })
end)
t.test("cache clears after deeply nested slash branch", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local _ = r:list_refs()
h.git(dir, "branch", "deep/a/b/c")
wait_cleared(r, "refs")
local refs = r:list_refs()
table.sort(refs)
t.eq(refs, { "deep/a/b/c", "main" })
end)
t.test("resolve_sha returns ok + full sha for a known blob", function()
local dir = h.make_repo({ a = "hello\n" })
local r = assert(require("git.core.repo").resolve(dir))
local blob = h.git(dir, "rev-parse", "HEAD:a").stdout
local short = blob:sub(1, 7)
local full, status = r:resolve_sha(short)
t.eq(status, "ok")
t.eq(full, blob)
end)
t.test("resolve_sha returns missing for an unknown prefix", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local full, status = r:resolve_sha("0000deadbeef")
t.eq(full, nil)
t.eq(status, "missing")
end)
t.test("resolve_sha caches by prefix", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local blob = h.git(dir, "rev-parse", "HEAD:a").stdout
local short = blob:sub(1, 7)
local _, _ = r:resolve_sha(short)
t.truthy(r._cache["resolve:" .. short], "result should be cached")
end)
---@param r ow.Git.Repo
local function wait_initial(r)
t.wait_for(function()
return r.status.branch.head ~= nil
end, "initial fetch to complete", 2000)
end
t.test("_invalidate clears only matching keys for HEAD", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.head = "h"
r._cache.refs = { "main" }
r._cache.pseudo_refs = { "HEAD" }
r._cache.stash_refs = {}
r._cache["resolve:abc"] = { "deadbeef", "ok" }
r:_invalidate("HEAD")
t.eq(r._cache.head, nil)
t.eq(r._cache.pseudo_refs, nil)
t.eq(r._cache["resolve:abc"], nil)
t.truthy(r._cache.refs)
t.truthy(r._cache.stash_refs)
end)
t.test("_invalidate clears refs/head/resolve for refs/heads/*", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.head = "h"
r._cache.refs = { "main" }
r._cache.pseudo_refs = { "HEAD" }
r._cache.stash_refs = {}
r._cache["resolve:abc"] = { "deadbeef", "ok" }
r:_invalidate("refs/heads/feature")
t.eq(r._cache.head, nil)
t.eq(r._cache.refs, nil)
t.eq(r._cache["resolve:abc"], nil)
t.truthy(r._cache.pseudo_refs)
t.truthy(r._cache.stash_refs)
end)
t.test("_invalidate clears config on .git/config change", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.config = { core = {} }
r:_invalidate("config")
t.eq(r._cache.config, nil)
end)
t.test("status_entry_for: exact match on case-sensitive repo", function()
local dir = h.make_repo({ Foo = "x" })
t.write(dir, "Foo", "modified")
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
t.truthy(r:status_entry_for("Foo"))
t.eq(r:status_entry_for("foo"), nil, "case mismatch returns nil")
end)
t.test("status_entry_for: case-insensitive fallback when core.ignorecase=true", function()
local dir = h.make_repo({ Foo = "x" })
h.git(dir, "config", "core.ignorecase", "true")
t.write(dir, "Foo", "modified")
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
t.truthy(r:status_entry_for("Foo"), "exact match")
t.truthy(r:status_entry_for("foo"), "lowercase finds Foo")
t.truthy(r:status_entry_for("FOO"), "uppercase finds Foo")
end)
t.test("_invalidate matches stash_refs on refs/stash and logs/refs/stash", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.stash_refs = {}
r:_invalidate("refs/stash")
t.eq(r._cache.stash_refs, nil)
r._cache.stash_refs = {}
r:_invalidate("logs/refs/stash")
t.eq(r._cache.stash_refs, nil)
end)
t.test("refresh with invalidate=true wipes cache on next fetch", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
r._cache.head = "stale"
r._cache["resolve:abc"] = { "x", "ok" }
r:refresh({ invalidate = true })
t.wait_for(function()
return r._cache.head == nil
end, "cache wiped after invalidating refresh completes", 2000)
t.eq(r._cache.head, nil)
t.eq(r._cache["resolve:abc"], nil)
end)
t.test("refresh emits change.paths listing structurally-changed paths", function()
local dir = h.make_repo({ a = "1", b = "1" })
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
t.write(dir, "a", "2")
---@type ow.Git.Repo.Change?
local change_seen
local unsub = r:on("change", function(change)
change_seen = change
end)
r:refresh()
t.wait_for(function()
return change_seen ~= nil
end, "refresh emit", 2000)
unsub()
local change = assert(change_seen)
t.truthy(change.paths["a"])
t.falsy(change.paths["b"], "b is unchanged structurally")
end)
t.test("submodule: parent enumerates initialized submodules by default", function()
local outer_path = h.make_submodule_repo()
local outer = assert(require("git.core.repo").resolve(outer_path))
t.truthy(outer._submodules["sub"], "sub recorded as submodule")
end)
t.test("submodule: eagerly creates child Repos and subscribes by default", function()
local outer_path = h.make_submodule_repo()
local outer = assert(require("git.core.repo").resolve(outer_path))
wait_initial(outer)
local inner = require("git.core.repo").all()[outer_path .. "/sub"]
t.truthy(inner, "inner Repo eagerly created")
t.truthy(outer._submodules["sub"] and outer._submodules["sub"].unsub, "inner subscribed by outer")
t.write(outer_path .. "/sub", "a", "modified\n")
---@type ow.Git.Repo.Change?
local outer_change
local unsub = outer:on("change", function(change)
outer_change = change
end)
inner:refresh()
t.wait_for(function()
return outer_change ~= nil
end, "outer notified by inner refresh", 2000)
unsub()
local entry = outer.status.entries["sub"]
t.truthy(entry, "outer sub entry now present")
t.eq(entry.kind, "changed")
end)
t.test("submodule: no eager creation when flag is explicitly disabled", function()
vim.g.git_submodule_recursion = false
t.defer(function()
vim.g.git_submodule_recursion = nil
end)
local outer_path = h.make_submodule_repo()
local outer = assert(require("git.core.repo").resolve(outer_path))
wait_initial(outer)
t.eq(
require("git.core.repo").all()[outer_path .. "/sub"],
nil,
"inner Repo not created when flag is false"
)
t.eq(next(outer._submodules), nil)
end)
t.test("submodule: outer created after inner picks up existing child", function()
local outer_path = h.make_submodule_repo()
local inner = assert(
require("git.core.repo").resolve(outer_path .. "/sub")
)
wait_initial(inner)
local outer = assert(require("git.core.repo").resolve(outer_path))
wait_initial(outer)
t.truthy(outer._submodules["sub"] and outer._submodules["sub"].unsub, "outer subscribed to pre-existing inner")
end)
t.test("watcher cleans up after a slash-branch dir is removed", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
h.git(dir, "branch", "feat/foo")
-- Wait for the dynamic watcher on .git/refs/heads/feat to be added.
local feat_path = dir .. "/.git/refs/heads/feat"
t.wait_for(function()
return r._watchers[feat_path] ~= nil
end, "watcher to be installed on feat/ subdir", 2000)
t.truthy(r._watchers[feat_path], "feat/ subdir should be watched")
-- Remove the branch; the feat/ directory becomes empty and is
-- pruned by git, triggering the deleted-self event.
h.git(dir, "branch", "-D", "feat/foo")
t.wait_for(function()
return r._watchers[feat_path] == nil
end, "watcher on feat/ subdir to close", 2000)
t.falsy(r._watchers[feat_path], "watcher should self-close")
end)
-387
View File
@@ -1,387 +0,0 @@
local t = require("test")
local status = require("git.core.status")
local NUL = "\0"
---@param parts string[]
---@return string
local function nul(parts)
return table.concat(parts, NUL) .. NUL
end
t.test("branch headers: initial repo, no commits", function()
local s = status.parse(nul({
"# branch.oid (initial)",
"# branch.head main",
}))
t.eq(s.branch.oid, nil)
t.eq(s.branch.head, "main")
t.eq(s.branch.upstream, nil)
t.eq(s.branch.ahead, 0)
t.eq(s.branch.behind, 0)
end)
t.test("branch headers: detached HEAD", function()
local s = status.parse(nul({
"# branch.oid 1234567890abcdef1234567890abcdef12345678",
"# branch.head (detached)",
}))
t.eq(s.branch.oid, "1234567890abcdef1234567890abcdef12345678")
t.eq(s.branch.head, nil)
end)
t.test("branch headers: with upstream and ahead/behind", function()
local s = status.parse(nul({
"# branch.oid abc123",
"# branch.head main",
"# branch.upstream origin/main",
"# branch.ab +3 -2",
}))
t.eq(s.branch.head, "main")
t.eq(s.branch.upstream, "origin/main")
t.eq(s.branch.ahead, 3)
t.eq(s.branch.behind, 2)
end)
t.test("type 1: staged-only modification", function()
local s = status.parse(nul({
"1 M. N... 100644 100644 100644 abc abc foo.lua",
}))
local e = s.entries["foo.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.kind, "changed")
t.eq(e.path, "foo.lua")
t.eq(e.staged, "modified")
t.eq(e.unstaged, nil)
t.eq(e.orig, nil)
end)
t.test("type 1: unstaged-only modification", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 abc abc foo.lua",
}))
local e = s.entries["foo.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.staged, nil)
t.eq(e.unstaged, "modified")
end)
t.test("type 1: both sides modified", function()
local s = status.parse(nul({
"1 MM N... 100644 100644 100644 abc abc foo.lua",
}))
local e = s.entries["foo.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.staged, "modified")
t.eq(e.unstaged, "modified")
end)
t.test("type 1: deleted (unstaged)", function()
local s = status.parse(nul({
"1 .D N... 100644 100644 000000 abc abc foo.lua",
}))
local e = s.entries["foo.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.unstaged, "deleted")
end)
t.test("type 1: added (staged)", function()
local s = status.parse(nul({
"1 A. N... 000000 100644 100644 abc abc new.lua",
}))
local e = s.entries["new.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.staged, "added")
end)
t.test("type 1: type-changed (unstaged)", function()
local s = status.parse(nul({
"1 .T N... 100644 100644 120000 abc abc foo.lua",
}))
local e = s.entries["foo.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.unstaged, "type_changed")
end)
t.test("type 2: renamed with orig", function()
local s = status.parse(nul({
"2 R. N... 100644 100644 100644 abc abc R100 new.lua",
"old.lua",
}))
local e = s.entries["new.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.kind, "changed")
t.eq(e.path, "new.lua")
t.eq(e.staged, "renamed")
t.eq(e.orig, "old.lua")
end)
t.test("type 2: copied with orig", function()
local s = status.parse(nul({
"2 C. N... 100644 100644 100644 abc abc C90 copy.lua",
"src.lua",
}))
local e = s.entries["copy.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.staged, "copied")
t.eq(e.orig, "src.lua")
end)
t.test("type u: all seven conflict types", function()
local cases = {
{ xy = "DD", expected = "both_deleted" },
{ xy = "AU", expected = "added_by_us" },
{ xy = "UD", expected = "deleted_by_them" },
{ xy = "UA", expected = "added_by_them" },
{ xy = "DU", expected = "deleted_by_us" },
{ xy = "AA", expected = "both_added" },
{ xy = "UU", expected = "both_modified" },
}
for _, c in ipairs(cases) do
local s = status.parse(nul({
string.format(
"u %s N... 100644 100644 100644 100644 abc abc abc conflict.lua",
c.xy
),
}))
local e = s.entries["conflict.lua"]
t.eq(e.kind, "unmerged", "kind for " .. c.xy)
t.eq(
(e --[[@as ow.Git.Status.UnmergedEntry]]).conflict,
c.expected,
"conflict for " .. c.xy
)
end
end)
t.test("type ?: untracked", function()
local s = status.parse(nul({ "? new.txt" }))
local e = s.entries["new.txt"]
t.eq(e.kind, "untracked")
t.eq(e.path, "new.txt")
end)
t.test("type !: ignored", function()
local s = status.parse(nul({ "! .secret" }))
local e = s.entries[".secret"]
t.eq(e.kind, "ignored")
end)
t.test("mixed: branch + multiple variants", function()
local s = status.parse(nul({
"# branch.oid abc",
"# branch.head main",
"# branch.upstream origin/main",
"# branch.ab +0 -0",
"1 M. N... 100644 100644 100644 a a staged.lua",
"1 .M N... 100644 100644 100644 a a unstaged.lua",
"1 MM N... 100644 100644 100644 a a both.lua",
"u UU N... 100644 100644 100644 100644 a a a conflict.lua",
"? untracked.txt",
"! ignored.txt",
}))
t.eq(s.branch.head, "main")
local staged = s.entries["staged.lua"] --[[@as ow.Git.Status.ChangedEntry]]
local unstaged = s.entries["unstaged.lua"] --[[@as ow.Git.Status.ChangedEntry]]
local both = s.entries["both.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(staged.staged, "modified")
t.eq(unstaged.unstaged, "modified")
t.eq(both.staged, "modified")
t.eq(both.unstaged, "modified")
t.eq(s.entries["conflict.lua"].kind, "unmerged")
t.eq(s.entries["untracked.txt"].kind, "untracked")
t.eq(s.entries["ignored.txt"].kind, "ignored")
end)
t.test("paths with spaces survive splitting", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 a a path with spaces.lua",
}))
local e = s.entries["path with spaces.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.unstaged, "modified")
end)
t.test("mark_for: changed staged modified", function()
local entry = {
kind = "changed",
path = "x",
staged = "modified",
}
t.eq(status.mark_for(entry, "staged"), { char = "M", hl = "GitStagedModified" })
end)
t.test("mark_for: changed unstaged deleted uses GitUnstagedDeleted", function()
local entry = {
kind = "changed",
path = "x",
unstaged = "deleted",
}
t.eq(status.mark_for(entry, "unstaged"), { char = "D", hl = "GitUnstagedDeleted" })
end)
t.test("mark_for: changed renamed uses per-side renamed hl", function()
local entry = {
kind = "changed",
path = "x",
staged = "renamed",
orig = "y",
}
t.eq(status.mark_for(entry, "staged"), { char = "R", hl = "GitStagedRenamed" })
end)
t.test("mark_for: untracked / ignored / unmerged ignore side", function()
t.eq(
status.mark_for({ kind = "untracked", path = "x" } --[[@as ow.Git.Status.Entry]]),
{ char = "?", hl = "GitUntracked" }
)
t.eq(
status.mark_for({ kind = "ignored", path = "x" } --[[@as ow.Git.Status.Entry]]),
{ char = "i", hl = "GitIgnored" }
)
t.eq(
status.mark_for({
kind = "unmerged",
path = "x",
conflict = "both_modified",
} --[[@as ow.Git.Status.Entry]]),
{ char = "!", hl = "GitUnmergedBothModified" }
)
end)
t.test("marks_for: changed with both sides yields two marks", function()
local entry = {
kind = "changed",
path = "x",
staged = "modified",
unstaged = "modified",
}
local marks = status.marks_for(entry)
t.eq(#marks, 2)
t.eq(marks[1], { char = "M", hl = "GitStagedModified" })
t.eq(marks[2], { char = "M", hl = "GitUnstagedModified" })
end)
t.test("marks_for: changed one-sided yields one mark", function()
local entry = { kind = "changed", path = "x", staged = "added" }
local marks = status.marks_for(entry)
t.eq(#marks, 1)
t.eq(marks[1], { char = "A", hl = "GitStagedAdded" })
end)
t.test("marks_for: untracked yields one mark", function()
local entry = { kind = "untracked", path = "x" } --[[@as ow.Git.Status.Entry]]
local marks = status.marks_for(entry)
t.eq(#marks, 1)
t.eq(marks[1], { char = "?", hl = "GitUntracked" })
end)
t.test("Status:rows buckets by section", function()
local s = status.parse(nul({
"1 M. N... 100644 100644 100644 a a staged.lua",
"1 .M N... 100644 100644 100644 a a unstaged.lua",
"1 MM N... 100644 100644 100644 a a both.lua",
"? untracked.txt",
}))
t.eq(#s:rows("staged"), 2, "staged section: staged.lua + both.lua")
t.eq(#s:rows("unstaged"), 2, "unstaged section: unstaged.lua + both.lua")
t.eq(#s:rows("untracked"), 1)
t.eq(#s:rows("unmerged"), 0)
t.eq(#s:rows("ignored"), 0)
end)
t.test("Status:rows for staged carries side='staged'", function()
local s = status.parse(nul({
"1 M. N... 100644 100644 100644 a a x.lua",
}))
local row = assert(s:rows("staged")[1])
t.eq(row.section, "staged")
t.eq(row.side, "staged")
t.eq(row.entry.kind, "changed")
end)
t.test("Status:rows for untracked has nil side", function()
local s = status.parse(nul({ "? x.txt" }))
local row = assert(s:rows("untracked")[1])
t.eq(row.section, "untracked")
t.eq(row.side, nil)
end)
t.test("Status:aggregate_at dedups marks under prefix", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 a a sub/a.lua",
"1 .M N... 100644 100644 100644 a a sub/b.lua",
"? sub/c.txt",
}))
local marks = s:aggregate_at("sub")
t.eq(#marks, 2, "modified ('M') and untracked ('?') deduped")
local m1 = assert(marks[1])
local m2 = assert(marks[2])
local hls = { m1.hl, m2.hl }
table.sort(hls)
t.eq(hls, { "GitUnstagedModified", "GitUntracked" })
end)
t.test("Status:aggregate_at with prefix '.' includes everything", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 a a a.lua",
"? b.txt",
}))
t.eq(#s:aggregate_at("."), 2)
end)
t.test("entry_equal: identical changed entries", function()
local a = { kind = "changed", path = "x", staged = "modified" }
local b = { kind = "changed", path = "x", staged = "modified" }
t.truthy(status.entry_equal(a, b))
end)
t.test("entry_equal: differing staged side returns false", function()
local a = { kind = "changed", path = "x", staged = "modified" }
local b = { kind = "changed", path = "x", staged = "added" }
t.falsy(status.entry_equal(a, b))
end)
t.test("entry_equal: differing orig returns false", function()
local a = { kind = "changed", path = "x", staged = "renamed", orig = "y" }
local b = { kind = "changed", path = "x", staged = "renamed", orig = "z" }
t.falsy(status.entry_equal(a, b))
end)
t.test("entry_equal: nil vs nil is true", function()
t.truthy(status.entry_equal(nil, nil))
end)
t.test("entry_equal: nil vs entry is false", function()
t.falsy(status.entry_equal(nil, { kind = "untracked", path = "x" }))
end)
t.test("entry_equal: different kinds returns false", function()
local a = { kind = "untracked", path = "x" }
local b = { kind = "ignored", path = "x" }
t.falsy(status.entry_equal(a, b))
end)
t.test("entry_equal: differing unmerged conflict returns false", function()
local a = { kind = "unmerged", path = "x", conflict = "both_added" }
local b = { kind = "unmerged", path = "x", conflict = "both_modified" }
t.falsy(status.entry_equal(a, b))
end)
t.test("diff_entries: detects additions, removals, and modifications", function()
local prior = {
a = { kind = "changed", path = "a", staged = "modified" },
b = { kind = "untracked", path = "b" },
}
local next_ = {
a = { kind = "changed", path = "a", staged = "added" },
c = { kind = "untracked", path = "c" },
}
local changed = status.diff_entries(prior, next_)
t.truthy(changed.a, "a modified")
t.truthy(changed.b, "b removed")
t.truthy(changed.c, "c added")
end)
t.test("diff_entries: empty when entries match", function()
local prior = { a = { kind = "untracked", path = "a" } }
local next_ = { a = { kind = "untracked", path = "a" } }
t.eq(status.diff_entries(prior, next_), {})
end)
-317
View File
@@ -1,317 +0,0 @@
local h = require("test.git.helpers")
local t = require("test")
---Replicate the user's global cursor-restore autocmd. Scoped to a
---named augroup + cleanup so it doesn't leak between tests.
local function install_cursor_restore_autocmd()
local group =
vim.api.nvim_create_augroup("test.cursor_restore", { clear = true })
vim.api.nvim_create_autocmd("BufReadPost", {
group = group,
pattern = "*",
command = 'silent! normal! g`"zv',
})
t.defer(function()
pcall(vim.api.nvim_del_augroup_by_name, "test.cursor_restore")
end)
end
---@param sidebar_buf integer
---@param needle string
---@return integer?
local function find_line(sidebar_buf, needle)
for i, l in ipairs(vim.api.nvim_buf_get_lines(sidebar_buf, 0, -1, false)) do
if l:match(needle) then
return i
end
end
end
---Find the gitstatus sidebar window in the current tabpage.
---@return integer? sidebar_buf
---@return integer? sidebar_win
local function find_sidebar()
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
local b = vim.api.nvim_win_get_buf(w)
if vim.bo[b].filetype == "gitstatus" then
return b, w
end
end
end
---Find a diff window in the given tabpage (or current). "left" / "right"
---is determined by column position: the layout is [sidebar | left | right],
---so the leftmost &diff window is the left pane and the rightmost is the
---right pane.
---@param role "left"|"right"
---@param tab integer?
---@return integer?
local function find_diff_win(role, tab)
local diffs = {}
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(tab or 0)) do
if vim.wo[w].diff then
table.insert(diffs, w)
end
end
table.sort(diffs, function(a, b)
return vim.api.nvim_win_get_position(a)[2]
< vim.api.nvim_win_get_position(b)[2]
end)
if role == "left" then
return diffs[1]
end
return diffs[#diffs]
end
---@param file_path string
---@param committed_content string
---@param worktree_content string
---@return integer sidebar_win
---@return integer entry_line
local function setup_sidebar_with_unstaged_file(
file_path,
committed_content,
worktree_content
)
local repo = h.make_repo({ [file_path] = committed_content })
t.write(repo, file_path, worktree_content)
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local sidebar_buf, sidebar_win = find_sidebar()
assert(sidebar_buf, "sidebar buffer should exist")
assert(sidebar_win, "sidebar window should exist")
local r = assert(
require("git.core.repo").find(vim.fn.getcwd()),
"repo should resolve for the test worktree"
)
r:refresh()
t.wait_for(function()
return r.status and #r.status:rows("unstaged") > 0
end, "git status to report unstaged changes")
local entry_line = assert(
find_line(sidebar_buf, vim.pesc(file_path) .. "$"),
file_path .. " should appear in sidebar"
)
return sidebar_win, entry_line
end
t.test("stage with diff open: sidebar cursor stays put", function()
install_cursor_restore_autocmd()
local sidebar_win, line = setup_sidebar_with_unstaged_file(
"zsh/rc",
"ZSH=true\n",
"ZSH=true\nmodified\n"
)
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
end, "diff windows to appear")
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
vim.api.nvim_set_current_win(sidebar_win)
t.press("s")
t.wait_for(function()
return #r.status:rows("staged") > 0
end, "stage to propagate to repo state")
t.eq(
vim.api.nvim_win_get_cursor(sidebar_win),
{ line, 0 },
"sidebar cursor should remain at the entry's original line"
)
end)
t.test(
"stage with diff open: diff foldmethod is preserved on refresh",
function()
local sidebar_win, line = setup_sidebar_with_unstaged_file(
"zsh/rc",
"# vim: set ft=zsh nowrap:\nZSH=true\n",
"# vim: set ft=zsh nowrap:\nZSH=true\nmodified\n"
)
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
end, "diff windows to appear")
local left_win = assert(find_diff_win("left"))
t.eq(
vim.wo[left_win].foldmethod,
"diff",
"left diff foldmethod should be 'diff' after Tab"
)
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
vim.api.nvim_set_current_win(sidebar_win)
t.press("s")
t.wait_for(function()
return #r.status:rows("staged") > 0
end, "stage to propagate to repo state")
t.eq(
vim.wo[left_win].foldmethod,
"diff",
"left diff foldmethod should still be 'diff' after stage refresh"
)
end
)
t.test(
"<Tab> in a second tabpage opens the diff inside that tabpage",
function()
local sidebar_win, line =
setup_sidebar_with_unstaged_file("foo.txt", "v1\n", "v2\n")
local tab1 = vim.api.nvim_get_current_tabpage()
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left", tab1) ~= nil
end, "diff windows in tab1 to appear")
vim.cmd("tabnew")
require("git.status_view").open({ placement = "sidebar" })
local tab2 = vim.api.nvim_get_current_tabpage()
t.truthy(tab2 ~= tab1, "tabnew should produce a distinct tabpage")
local _, sidebar_win2 = find_sidebar()
assert(sidebar_win2, "sidebar window should exist in tab2")
vim.api.nvim_set_current_win(sidebar_win2)
vim.api.nvim_win_set_cursor(sidebar_win2, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left", tab2) ~= nil
end, "diff windows in tab2 to appear")
t.truthy(
find_diff_win("right", tab2),
"right diff window should be in tab2"
)
end
)
t.test("refresh on stage updates the index URI buffer's content", function()
local sidebar_win, line =
setup_sidebar_with_unstaged_file("foo.txt", "v1\n", "v2\n")
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
end, "diff windows to appear")
local left_win = assert(find_diff_win("left"))
local index_buf = vim.api.nvim_win_get_buf(left_win)
t.eq(
vim.api.nvim_buf_get_lines(index_buf, 0, -1, false),
{ "v1" },
"index pane should initially show committed content"
)
vim.api.nvim_set_current_win(sidebar_win)
t.press("s")
t.wait_for(function()
local first = vim.api.nvim_buf_get_lines(index_buf, 0, -1, false)[1]
return first == "v2"
end, "index pane to refresh to staged content")
t.eq(
vim.api.nvim_buf_get_lines(index_buf, 0, -1, false),
{ "v2" },
"index pane should reflect staged content after refresh"
)
end)
t.test(
"re-selecting same entry after close + diffsplit keeps fold state in sync",
function()
local committed, worktree = {}, {}
for i = 1, 30 do
committed[i] = "line " .. i
worktree[i] = i == 15 and "CHANGED" or ("line " .. i)
end
local sidebar_win, line = setup_sidebar_with_unstaged_file(
"foo.txt",
table.concat(committed, "\n") .. "\n",
table.concat(worktree, "\n") .. "\n"
)
local prev_foldlevel = vim.o.foldlevel
vim.o.foldlevel = 99
t.defer(function()
vim.o.foldlevel = prev_foldlevel
end)
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
and find_diff_win("right") ~= nil
end, "first diff pair to appear")
local first_left = assert(find_diff_win("left"))
vim.api.nvim_win_close(first_left, false)
local remaining
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if w ~= sidebar_win then
remaining = w
break
end
end
if not remaining then
error("a non-sidebar window should remain after close")
end
vim.api.nvim_set_current_win(remaining)
require("git.diff").split({ mods = { vertical = true } })
t.wait_for(function()
local count = 0
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.wo[w].diff then
count = count + 1
end
end
return count == 2
end, "diffsplit to produce a diff pair")
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
local count = 0
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.wo[w].diff then
count = count + 1
end
end
return count == 2
end, "diff pair after re-selecting entry")
local left_win = assert(find_diff_win("left"))
local right_win = assert(find_diff_win("right"))
t.eq(
vim.wo[left_win].foldlevel,
0,
"left pane foldlevel should be 0 after re-select"
)
t.eq(
vim.wo[right_win].foldlevel,
0,
"right pane foldlevel should be 0 after re-select"
)
end
)
-49
View File
@@ -1,49 +0,0 @@
local t = require("test")
local util = require("git.core.util")
local function fresh_buf()
local buf = vim.api.nvim_create_buf(false, true)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
return buf
end
t.test("set_buf_lines preserves modifiable=false", function()
local buf = fresh_buf()
vim.bo[buf].modifiable = false
util.set_buf_lines(buf, 0, -1, { "a", "b", "c" })
t.eq(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
{ "a", "b", "c" },
"lines should be replaced"
)
t.falsy(vim.bo[buf].modifiable, "modifiable should stay false")
t.falsy(vim.bo[buf].modified, "modified should be cleared")
end)
t.test("set_buf_lines preserves modifiable=true", function()
local buf = fresh_buf()
vim.bo[buf].modifiable = true
util.set_buf_lines(buf, 0, -1, { "a", "b" })
t.truthy(vim.bo[buf].modifiable, "modifiable should stay true")
t.falsy(vim.bo[buf].modified, "modified should be cleared")
end)
t.test("set_buf_lines partial range update", function()
local buf = fresh_buf()
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "a", "b", "c", "d" })
util.set_buf_lines(buf, 1, 3, { "X", "Y", "Z" })
t.eq(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
{ "a", "X", "Y", "Z", "d" },
"lines [1, 3) should be replaced"
)
end)
t.test("set_buf_lines errors on out-of-bounds (strict_indexing)", function()
local buf = fresh_buf()
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "a", "b" })
local ok = pcall(util.set_buf_lines, buf, 100, 200, { "x" })
t.falsy(ok, "out-of-bounds index should error")
end)