From feb10f543c5ded72465126aaeeb1c256231f4dbf Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Tue, 26 May 2026 17:07:32 +0200 Subject: [PATCH] feat: move out git package to separate plugin --- .emmyrc.json | 1 + README.md | 2 +- init.lua | 1 + lua/git/blame.lua | 531 ------------------- lua/git/cmd.lua | 904 ------------------------------- lua/git/commit.lua | 80 --- lua/git/core/repo.lua | 925 -------------------------------- lua/git/core/revision.lua | 45 -- lua/git/core/status.lua | 383 -------------- lua/git/core/util.lua | 352 ------------ lua/git/diffsplit.lua | 134 ----- lua/git/editor.lua | 117 ---- lua/git/hunks.lua | 970 ---------------------------------- lua/git/log_view.lua | 143 ----- lua/git/object.lua | 434 --------------- lua/git/status_view.lua | 740 -------------------------- lua/git/statusline.lua | 104 ---- nvim-pack-lock.json | 4 + plugin/git.lua | 339 ------------ test/git/blame_test.lua | 312 ----------- test/git/cmd_test.lua | 668 ----------------------- test/git/helpers.lua | 97 ---- test/git/hunks_test.lua | 634 ---------------------- test/git/object_test.lua | 188 ------- test/git/repo_test.lua | 369 ------------- test/git/status_test.lua | 387 -------------- test/git/status_view_test.lua | 487 ----------------- test/git/util_test.lua | 49 -- 28 files changed, 7 insertions(+), 9393 deletions(-) delete mode 100644 lua/git/blame.lua delete mode 100644 lua/git/cmd.lua delete mode 100644 lua/git/commit.lua delete mode 100644 lua/git/core/repo.lua delete mode 100644 lua/git/core/revision.lua delete mode 100644 lua/git/core/status.lua delete mode 100644 lua/git/core/util.lua delete mode 100644 lua/git/diffsplit.lua delete mode 100644 lua/git/editor.lua delete mode 100644 lua/git/hunks.lua delete mode 100644 lua/git/log_view.lua delete mode 100644 lua/git/object.lua delete mode 100644 lua/git/status_view.lua delete mode 100644 lua/git/statusline.lua delete mode 100644 plugin/git.lua delete mode 100644 test/git/blame_test.lua delete mode 100644 test/git/cmd_test.lua delete mode 100644 test/git/helpers.lua delete mode 100644 test/git/hunks_test.lua delete mode 100644 test/git/object_test.lua delete mode 100644 test/git/repo_test.lua delete mode 100644 test/git/status_test.lua delete mode 100644 test/git/status_view_test.lua delete mode 100644 test/git/util_test.lua diff --git a/.emmyrc.json b/.emmyrc.json index 4a7ba3e..14d1c6e 100644 --- a/.emmyrc.json +++ b/.emmyrc.json @@ -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", diff --git a/README.md b/README.md index da4f9ba..08a5785 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/init.lua b/init.lua index e7b9ea2..3f85263 100644 --- a/init.lua +++ b/init.lua @@ -34,6 +34,7 @@ require("pack").setup({ "https://github.com/nvim-tree/nvim-tree.lua", "https://github.com/stevearc/oil.nvim", "https://github.com/hedyhli/outline.nvim", + "https://git.owall.dev/warg/git.nvim", "nvim.undotree", { src = "https://github.com/saghen/blink.cmp", diff --git a/lua/git/blame.lua b/lua/git/blame.lua deleted file mode 100644 index 663af28..0000000 --- a/lua/git/blame.lua +++ /dev/null @@ -1,531 +0,0 @@ -local object = require("git.object") -local repo = require("git.core.repo") -local util = require("git.core.util") - -local M = {} - -local NS_POPUP = vim.api.nvim_create_namespace("ow.git.blame.popup") - -local ZERO_SHA = string.rep("0", 40) - ----@class ow.Git.Blame.Commit ----@field sha string ----@field author string ----@field author_mail string ----@field author_time integer ----@field author_tz string ----@field summary string - ----@class ow.Git.Blame.Result ----@field commits table ----@field line_sha table - ----@class ow.Git.Blame.Source ----@field repo ow.Git.Repo ----@field rel string ----@field revision string? - ----@class ow.Git.Blame.BufState ----@field repo ow.Git.Repo ----@field rel string ----@field revision string? ----@field commits table ----@field line_sha table ----@field tick integer? ----@field epoch integer ----@field pending fun()[] - ----@type table -local states = {} - ----@param buf integer? ----@return integer -local function resolve_buf(buf) - return buf and buf ~= 0 and buf or vim.api.nvim_get_current_buf() -end - ----@param buf integer ----@return ow.Git.Blame.BufState? -function M.state(buf) - return states[buf] -end - ----@param ts integer ----@param tz string ----@return string -local function format_author_time(ts, tz) - local sign, hh, mm = tz:match("^([+-])(%d%d)(%d%d)$") - ---@type number - local offset = 0 - if sign then - local h = tonumber(hh) or 0 - local m = tonumber(mm) or 0 - offset = (h * 3600 + m * 60) * (sign == "-" and -1 or 1) - end - return os.date("!%Y-%m-%d %T ", ts + offset) .. tz -end - ----@param stdout string ----@return ow.Git.Blame.Result -local function parse_porcelain(stdout) - ---@type table - local commits = {} - ---@type table - local line_sha = {} - local cur_sha ---@type string? - local cur_lnum ---@type integer? - for _, line in ipairs(util.split_lines(stdout)) do - if line:sub(1, 1) == "\t" then - if cur_sha and cur_lnum then - line_sha[cur_lnum] = cur_sha - end - cur_sha = nil - cur_lnum = nil - else - local sha, final = line:match("^(%x+) %d+ (%d+)") - if sha and #sha >= 40 then - cur_sha = sha - cur_lnum = tonumber(final) --[[@as integer?]] - if not commits[sha] then - commits[sha] = { - sha = sha, - author = "", - author_mail = "", - author_time = 0, - author_tz = "", - summary = "", - } - end - else - local key, value = line:match("^(%S+) (.*)$") - local commit = cur_sha and commits[cur_sha] - if commit and key then - if key == "author" then - commit.author = value - elseif key == "author-mail" then - commit.author_mail = value - elseif key == "author-time" then - commit.author_time = math.floor(tonumber(value) or 0) - elseif key == "author-tz" then - commit.author_tz = value - elseif key == "summary" then - commit.summary = value - end - end - end - end - end - return { commits = commits, line_sha = line_sha } -end - ----@param line_count integer ----@return ow.Git.Blame.Result -local function synth_uncommitted(line_count) - ---@type table - local line_sha = {} - for i = 1, line_count do - line_sha[i] = ZERO_SHA - end - return { - commits = { - [ZERO_SHA] = { - sha = ZERO_SHA, - author = "Not Committed Yet", - author_mail = "", - author_time = os.time() --[[@as integer]], - author_tz = "", - summary = "", - }, - }, - line_sha = line_sha, - } -end - ----@param r ow.Git.Repo ----@param rel string ----@param opts { rev: string?, contents: string? } ----@param done fun(result: ow.Git.Blame.Result?) -local function fetch_blame(r, rel, opts, done) - local args = { "--no-pager", "blame", "--porcelain" } - if opts.contents then - table.insert(args, "--contents") - table.insert(args, "-") - end - if opts.rev then - table.insert(args, opts.rev) - end - table.insert(args, "--") - table.insert(args, rel) - util.git(args, { - cwd = r.worktree, - stdin = opts.contents, - silent = true, - on_exit = function(res) - if res.code ~= 0 then - done(nil) - else - done(parse_porcelain(res.stdout or "")) - end - end, - }) -end - ----@param buf integer ----@return ow.Git.Blame.Source? -local function resolve_source(buf) - if not vim.api.nvim_buf_is_valid(buf) then - return nil - end - local name = vim.api.nvim_buf_get_name(buf) - if util.is_uri(name) then - local rev = object.parse_uri(name) - if not rev or not rev.base or not rev.path then - return nil - end - local r = repo.find(buf) - if not r then - return nil - end - return { repo = r, rel = rev.path, revision = rev.base } - end - if not repo.is_worktree_buf(buf) then - return nil - end - local r = repo.find(buf) - if not r then - return nil - end - local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name)) - if not rel then - return nil - end - return { repo = r, rel = rel } -end - ----@param buf integer ----@return ow.Git.Blame.BufState? -local function ensure_state(buf) - if states[buf] then - return states[buf] - end - local src = resolve_source(buf) - if not src then - return nil - end - ---@type ow.Git.Blame.BufState - local state = { - repo = src.repo, - rel = src.rel, - revision = src.revision, - commits = {}, - line_sha = {}, - tick = nil, - epoch = 0, - pending = {}, - } - states[buf] = state - return state -end - ----@param state ow.Git.Blame.BufState ----@param buf integer ----@param done fun()? -local function run_blame(state, buf, done) - local tick = vim.api.nvim_buf_get_changedtick(buf) - if state.tick == tick then - if done then - done() - end - return - end - if done then - table.insert(state.pending, done) - end - state.epoch = state.epoch + 1 - local epoch = state.epoch - local opts ---@type { rev: string?, contents: string? } - if state.revision then - opts = { rev = state.revision } - else - opts = { - contents = table.concat( - vim.api.nvim_buf_get_lines(buf, 0, -1, false), - "\n" - ) .. "\n", - } - end - fetch_blame(state.repo, state.rel, opts, function(result) - if - states[buf] ~= state - or epoch ~= state.epoch - or not vim.api.nvim_buf_is_valid(buf) - then - return - end - local data = result - or synth_uncommitted(vim.api.nvim_buf_line_count(buf)) - state.commits = data.commits - state.line_sha = data.line_sha - state.tick = tick - local pending = state.pending - state.pending = {} - for _, fn in ipairs(pending) do - fn() - end - end) -end - ----@param lines string[] ----@return integer width ----@return integer height -local function size_for(lines) - local width = 1 - for _, l in ipairs(lines) do - local w = vim.api.nvim_strwidth(l) - if w > width then - width = w - end - end - width = math.min(math.max(width + 1, 30), vim.o.columns - 4) - local height = math.min(math.max(#lines, 1), math.floor(vim.o.lines / 2)) - return width, height -end - -local popup_win ---@type integer? - -local function close_popup() - if popup_win and vim.api.nvim_win_is_valid(popup_win) then - vim.api.nvim_win_close(popup_win, true) - end - popup_win = nil -end - ----@param pbuf integer ----@param win integer ----@param head string[] ----@param body string[]? ----@param sha_len integer? ----@param date_col integer? -local function apply_popup(pbuf, win, head, body, sha_len, date_col) - local lines = {} - vim.list_extend(lines, head) - if body then - vim.list_extend(lines, body) - end - vim.bo[pbuf].modifiable = true - vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines) - vim.bo[pbuf].modifiable = false - vim.api.nvim_buf_clear_namespace(pbuf, NS_POPUP, 0, -1) - if sha_len then - pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, 0, { - end_col = sha_len, - hl_group = "GitBlameSha", - }) - end - if sha_len and date_col then - pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, sha_len + 2, { - end_col = date_col - 2, - hl_group = "GitBlameAuthor", - }) - end - if date_col then - pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, date_col, { - end_col = #(head[1] or ""), - hl_group = "GitBlameDate", - }) - end - local width, height = size_for(lines) - pcall(vim.api.nvim_win_set_width, win, width) - pcall(vim.api.nvim_win_set_height, win, height) -end - ----@param watch_buf integer ----@param pbuf integer ----@param win integer -local function setup_popup_autocmds(watch_buf, pbuf, win) - local group = - vim.api.nvim_create_augroup("ow.git.blame.popup", { clear = true }) - vim.api.nvim_create_autocmd( - { "CursorMoved", "CursorMovedI", "InsertEnter" }, - { group = group, buffer = watch_buf, callback = close_popup } - ) - vim.api.nvim_create_autocmd("WinLeave", { - group = group, - buffer = pbuf, - callback = close_popup, - }) - vim.api.nvim_create_autocmd("WinClosed", { - group = group, - pattern = tostring(win), - callback = function() - popup_win = nil - pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame.popup") - end, - }) - vim.keymap.set("n", "q", close_popup, { buffer = pbuf, nowait = true }) -end - ----@param r ow.Git.Repo ----@param commits table ----@param line_sha table ----@param lnum integer ----@param watch_buf integer -local function open_popup(r, commits, line_sha, lnum, watch_buf) - close_popup() - local sha = line_sha[lnum] - local commit = sha and commits[sha] - if not commit then - util.warning("git blame: no blame information for line %d", lnum) - return - end - local head ---@type string[] - local sha_len ---@type integer? - local date_col ---@type integer? - if util.is_zero_sha(sha) then - head = { "Not Committed Yet" } - else - local short = sha:sub(1, 8) - local date = format_author_time(commit.author_time, commit.author_tz) - sha_len = #short - date_col = sha_len + 2 + #commit.author + 2 - head = { - short .. " " .. commit.author .. " " .. date, - "", - } - end - local body = sha_len and { commit.summary } or nil - local lines = {} - vim.list_extend(lines, head) - if body then - vim.list_extend(lines, body) - end - local width, height = size_for(lines) - local pbuf = vim.api.nvim_create_buf(false, true) - vim.bo[pbuf].bufhidden = "wipe" - local win = vim.api.nvim_open_win(pbuf, false, { - relative = "cursor", - row = 1, - col = 0, - width = width, - height = height, - style = "minimal", - }) - popup_win = win - apply_popup(pbuf, win, head, body, sha_len, date_col) - setup_popup_autocmds(watch_buf, pbuf, win) - if not sha_len then - return - end - util.git({ "show", "-s", "--format=%B", sha }, { - cwd = r.worktree, - silent = true, - on_exit = function(res) - if - popup_win ~= win - or not vim.api.nvim_win_is_valid(win) - or not vim.api.nvim_buf_is_valid(pbuf) - or res.code ~= 0 - then - return - end - local msg = util.split_lines(res.stdout or "") - if #msg > 0 then - apply_popup(pbuf, win, head, msg, sha_len, date_col) - end - end, - }) -end - ----@param buf integer? -function M.line_popup(buf) - buf = resolve_buf(buf) - if popup_win and vim.api.nvim_win_is_valid(popup_win) then - vim.api.nvim_set_current_win(popup_win) - return - end - local state = ensure_state(buf) - if not state then - util.warning("git blame: nothing to blame in this buffer") - return - end - local lnum = vim.api.nvim_win_get_cursor(0)[1] - run_blame(state, buf, function() - if - not vim.api.nvim_buf_is_valid(buf) - or vim.api.nvim_get_current_buf() ~= buf - or vim.api.nvim_win_get_cursor(0)[1] ~= lnum - then - return - end - open_popup(state.repo, state.commits, state.line_sha, lnum, buf) - end) -end - ----@param done fun(state: ow.Git.Blame.BufState, sha: string) -local function blame_line(done) - local buf = vim.api.nvim_get_current_buf() - local state = ensure_state(buf) - if not state then - util.warning("git blame: nothing to blame in this buffer") - return - end - local lnum = vim.api.nvim_win_get_cursor(0)[1] - run_blame(state, buf, function() - if not vim.api.nvim_buf_is_valid(buf) then - return - end - local sha = state.line_sha[lnum] - if not sha or util.is_zero_sha(sha) then - util.warning("git blame: line is not committed yet") - return - end - done(state, sha) - end) -end - -function M.open_commit() - blame_line(function(state, sha) - object.open(state.repo, sha, { split = false }) - end) -end - -function M.open_file() - blame_line(function(state, sha) - object.open(state.repo, sha .. ":" .. state.rel, { split = false }) - end) -end - -function M.open_file_parent() - blame_line(function(state, sha) - local parent = state.repo:rev_parse(sha .. "^", false) - if not parent then - util.warning("git blame: %s has no parent commit", sha:sub(1, 8)) - return - end - object.open(state.repo, parent .. ":" .. state.rel, { split = false }) - end) -end - ----@param buf integer -function M.detach(buf) - local state = states[buf] - if not state then - return - end - state.epoch = state.epoch + 1 - states[buf] = nil -end - -repo.on("change", function(r, change) - for _, state in pairs(states) do - if - state.repo == r - and not state.revision - and (change.paths[state.rel] or change.branch_changed) - then - state.tick = nil - end - end -end) - -return M diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua deleted file mode 100644 index e18ca3b..0000000 --- a/lua/git/cmd.lua +++ /dev/null @@ -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 \\\\"') - 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", - "", - 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", "pclose", { - buffer = buf, - nowait = true, - desc = "Close preview", - }) - vim.keymap.set("n", "", 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 -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 -local SUBSUB_FALLBACK = { - submodule = { - "add", - "status", - "init", - "deinit", - "update", - "summary", - "foreach", - "sync", - "absorbgitdirs", - }, -} - ----@type table -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 -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 diff --git a/lua/git/commit.lua b/lua/git/commit.lua deleted file mode 100644 index 79ace26..0000000 --- a/lua/git/commit.lua +++ /dev/null @@ -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 diff --git a/lua/git/core/repo.lua b/lua/git/core/repo.lua deleted file mode 100644 index ce65f08..0000000 --- a/lua/git/core/repo.lua +++ /dev/null @@ -1,925 +0,0 @@ -local status = require("git.core.status") -local util = require("git.core.util") - -local M = {} - ----@param buf? integer ----@return integer -local function expand_buf(buf) - if not buf or buf == 0 then - return vim.api.nvim_get_current_buf() - end - return buf -end - ----@class ow.Git.Repo.BufState ----@field repo ow.Git.Repo ----@field sha string? ----@field initialized boolean? ----@field immutable boolean? ----@field index_writer boolean? ----@field index_mode string? - ----@alias ow.Git.Repo.Event ----| "change" - -local global = util.Emitter.new() - ----@type table 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 ----@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 ----@field tabs table ----@field status ow.Git.Status ----@field private _events ow.Git.Util.Emitter ----@field private _watchers table ----@field private _schedule_refresh fun(self: ow.Git.Repo) ----@field private _refresh_handle ow.Git.Util.DebounceHandle ----@field private _cache table ----@field private _fetch_epoch integer ----@field private _pending_invalidate boolean ----@field package _submodules table -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 -local INVALIDATION_RULES = { - head = function(relpath) - return relpath == "HEAD" - or vim.startswith(relpath, "refs/heads/") - or relpath == "packed-refs" - end, - refs = function(relpath) - return vim.startswith(relpath, "refs/heads/") - or vim.startswith(relpath, "refs/tags/") - or vim.startswith(relpath, "refs/remotes/") - or relpath == "packed-refs" - end, - pseudo_refs = function(relpath) - return vim.tbl_contains(PSEUDO_REFS, relpath) - end, - stash_refs = function(relpath) - return relpath == "refs/stash" or relpath == "logs/refs/stash" - end, - config = function(relpath) - return relpath == "config" - end, -} - ----@param relpath string ----@return boolean -local function affects_resolve(relpath) - return vim.startswith(relpath, "refs/") - or relpath == "packed-refs" - or relpath == "HEAD" - or relpath == "FETCH_HEAD" -end - ----@private ----@param prefix string -function Repo:_clear_cache_prefix(prefix) - for key in pairs(self._cache) do - if vim.startswith(key, prefix) then - self._cache[key] = nil - end - end -end - ----@private ----@param relpath string -function Repo:_invalidate(relpath) - for key, affects in pairs(INVALIDATION_RULES) do - if self._cache[key] ~= nil and affects(relpath) then - self._cache[key] = nil - end - end - if affects_resolve(relpath) then - self:_clear_cache_prefix("resolve:") - self:_clear_cache_prefix("head_blob:") - end - if relpath == "index" then - self:_clear_cache_prefix("index:") - end -end - ----@param path string ----@return table>? -local function read_git_config(path) - local f = io.open(path, "r") - if not f then - return nil - end - local content = f:read("*a") - f:close() - local out = {} - local section - for line in content:gmatch("[^\n]+") do - local trimmed = line:match("^%s*(.-)%s*$") - if trimmed ~= "" and not trimmed:match("^[#;]") then - local s = trimmed:match("^%[(.-)%]$") - if s then - section = s - out[section] = out[section] or {} - elseif section then - local key, value = - trimmed:match("^(%S+)%s*=%s*(.-)$") - if key then - out[section][key] = value - end - end - end - end - return out -end - ----@param gitdir string ----@return string[] -local function find_submodules(gitdir) - local handle = vim.uv.fs_scandir(vim.fs.joinpath(gitdir, "modules")) - if not handle then - return {} - end - local out = {} - while true do - local name, typ = vim.uv.fs_scandir_next(handle) - if not name then - break - end - if typ == "directory" then - table.insert(out, name) - end - end - return out -end - ----@private -function Repo:_fetch_status() - if self._pending_invalidate then - self._cache = {} - self._pending_invalidate = false - end - local prior_entries = self.status.entries - local prior_branch = self.status.branch - self._fetch_epoch = self._fetch_epoch + 1 - local epoch = self._fetch_epoch - util.git(STATUS_ARGS, { - cwd = self.worktree, - on_exit = function(result) - if epoch ~= self._fetch_epoch then - return - end - if result.code ~= 0 then - util.error( - "git status failed: %s", - vim.trim(result.stderr or "") - ) - return - end - self.status = status.parse(result.stdout or "") - local change = { - paths = status.diff_entries( - prior_entries, - self.status.entries - ), - branch_changed = not vim.deep_equal( - prior_branch, - self.status.branch - ), - } - if next(change.paths) == nil and not change.branch_changed then - return - end - self._events:emit("change", change, self.status) - global:emit("change", self, change, self.status) - end, - }) -end - ----@param opts ow.Git.Repo.RefreshOpts? -function Repo:refresh(opts) - if opts and opts.invalidate then - self._pending_invalidate = true - end - self:_schedule_refresh() -end - ----@param gitdir string ----@param worktree string ----@return ow.Git.Repo -function Repo.new(gitdir, worktree) - local self = setmetatable({ - gitdir = gitdir, - worktree = worktree, - buffers = {}, - tabs = {}, - status = status.parse(""), - _events = util.Emitter.new(), - _cache = {}, - _fetch_epoch = 0, - _pending_invalidate = false, - _submodules = {}, - }, Repo) - self._schedule_refresh, self._refresh_handle = - util.debounce(Repo._fetch_status, 50) - self:start_watcher() - self:refresh() - if vim.g.git_submodule_recursion ~= false then - self:_start_modules_watcher() - for _, name in ipairs(find_submodules(gitdir)) do - self:_register_submodule(name) - end - end - return self -end - ----@generic T ----@param key string ----@param compute fun(self: ow.Git.Repo): T ----@return T -function Repo:get_cached(key, compute) - local hit = self._cache[key] - if hit ~= nil then - return hit - end - local value = compute(self) - self._cache[key] = value - return value -end - ----@param path string ----@param on_event fun(filename: string?) ----@return uv.uv_fs_event_t? -local function start_fs_event(path, on_event) - local watcher = vim.uv.new_fs_event() - if not watcher then - return nil - end - local ok = watcher:start(path, {}, function(err, filename) - if err then - return - end - on_event(filename) - end) - if not ok then - watcher:close() - return nil - end - return watcher -end - ----@private ----@param name string -function Repo:_unregister_submodule(name) - local entry = self._submodules[name] - if not entry then - return - end - self._submodules[name] = nil - if entry.unsub then - entry.unsub() - end - local child = repos[entry.worktree] - if child then - release_if_unused(child) - end -end - ----@private ----@param name string -function Repo:_register_submodule(name) - local sub_gitdir = vim.fs.joinpath(self.gitdir, "modules", name) - local cfg = read_git_config(vim.fs.joinpath(sub_gitdir, "config")) - local raw = cfg and cfg.core and cfg.core.worktree - if not raw then - return - end - local wt = raw:match("^/") and raw or vim.fs.joinpath(sub_gitdir, raw) - wt = vim.fs.normalize(wt) - local existing = self._submodules[name] - if existing and existing.worktree == wt then - return - end - if existing then - self:_unregister_submodule(name) - end - local child = repos[wt] or M.resolve(wt) - if not child then - return - end - self._submodules[name] = { - worktree = wt, - unsub = child:on("change", function() - self:refresh() - end), - } -end - ----@private -function Repo:_start_modules_watcher() - local dir = vim.fs.joinpath(self.gitdir, "modules") - if self._watchers[dir] then - return - end - if not vim.uv.fs_stat(dir) then - return - end - self._watchers[dir] = start_fs_event(dir, function(filename) - if not filename then - return - end - if vim.uv.fs_stat(vim.fs.joinpath(dir, filename)) then - self:_register_submodule(filename) - else - self:_unregister_submodule(filename) - end - end) -end - ----@private -function Repo:_stop_modules_watcher() - local dir = vim.fs.joinpath(self.gitdir, "modules") - local w = self._watchers[dir] - if w then - w:stop() - w:close() - self._watchers[dir] = nil - end - for _, name in ipairs(vim.tbl_keys(self._submodules)) do - self:_unregister_submodule(name) - end -end - ----@private ----@param relpath string -function Repo:_handle_fs_event(relpath) - if vim.startswith(relpath, "objects") then - return - end - self:_invalidate(relpath) - if relpath == "modules" and vim.g.git_submodule_recursion ~= false then - if vim.uv.fs_stat(vim.fs.joinpath(self.gitdir, "modules")) then - self:_start_modules_watcher() - for _, name in ipairs(find_submodules(self.gitdir)) do - self:_register_submodule(name) - end - else - self:_stop_modules_watcher() - end - end - if vim.startswith(relpath, "logs") then - return - end - self:refresh() -end - ----@private ----@param relpath string gitdir-relative path of the directory to watch -function Repo:_watch_tree(relpath) - local path = vim.fs.joinpath(self.gitdir, relpath) - if self._watchers[path] then - return - end - local stat = vim.uv.fs_stat(path) - if not stat or stat.type ~= "directory" then - return - end - local watcher = start_fs_event(path, function(filename) - if not vim.uv.fs_stat(path) then - local w = self._watchers[path] --[[@as uv.uv_fs_event_t?]] - if w then - w:stop() - w:close() - self._watchers[path] = nil - end - return - end - if filename then - local child = vim.fs.joinpath(relpath, filename) - self:_handle_fs_event(child) - vim.schedule(function() - self:_watch_tree(child) - end) - else - self:refresh({ invalidate = true }) - end - end) - if not watcher then - return - end - self._watchers[path] = watcher - local handle = vim.uv.fs_scandir(path) - if not handle then - return - end - while true do - local name, typ = vim.uv.fs_scandir_next(handle) - if not name then - break - end - if typ == "directory" then - self:_watch_tree(vim.fs.joinpath(relpath, name)) - end - end -end - -function Repo:start_watcher() - self._watchers = {} - local top = start_fs_event(self.gitdir, function(filename) - if not filename then - self:refresh({ invalidate = true }) - return - end - self:_handle_fs_event(filename) - end) - if not top then - util.error("git: failed to watch %s", self.gitdir) - return - end - self._watchers[self.gitdir] = top - self:_watch_tree("refs") -end - -function Repo:close() - for _, watcher in pairs(self._watchers) do - watcher:stop() - watcher:close() - end - self._watchers = {} - self:_stop_modules_watcher() - self._refresh_handle.close() - self._events:clear() -end - ----@overload fun(event: "change", fn: fun(change: ow.Git.Repo.Change, status: ow.Git.Status)): fun() -function Repo:on(event, fn) - return self._events:on(event, fn) -end - ----@param buf? integer ----@return ow.Git.Repo.BufState? -function Repo:state(buf) - return self.buffers[expand_buf(buf)] -end - ----@return string? -function Repo:head() - return self:get_cached("head", function(self) - local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r") - if not f then - return nil - end - local first = f:read("*l") - f:close() - if not first then - return nil - end - local branch = first:match("^ref:%s*refs/heads/(%S+)") - if branch then - return branch - end - local sha = first:match("^(%x+)") - if sha then - return sha:sub(1, 7) - end - return nil - end) -end - ----@return string[] -function Repo:list_refs() - return self:get_cached("refs", function(self) - local out = util.git({ - "for-each-ref", - "--format=%(refname:short)", - "refs/heads", - "refs/tags", - "refs/remotes", - }, { cwd = self.worktree, silent = true }) - if not out then - return {} - end - return util.split_lines(out) - end) -end - ----@return string[] -function Repo:list_pseudo_refs() - return self:get_cached("pseudo_refs", function(self) - local refs = {} - for _, name in ipairs(PSEUDO_REFS) do - if name == "HEAD" or vim.uv.fs_stat(self.gitdir .. "/" .. name) then - table.insert(refs, name) - end - end - return refs - end) -end - ----@return string[] -function Repo:list_stash_refs() - return self:get_cached("stash_refs", function(self) - if not vim.uv.fs_stat(self.gitdir .. "/refs/stash") then - return {} - end - local refs = { "stash" } - local out = util.git( - { "stash", "list", "--pretty=format:%gd" }, - { cwd = self.worktree, silent = true } - ) - if out then - for _, entry in ipairs(util.split_lines(out)) do - table.insert(refs, entry) - end - end - return refs - end) -end - ----@param rev string ----@param short boolean ----@return string? -function Repo:rev_parse(rev, short) - local args = { "rev-parse", "--verify", "--quiet" } - if short then - table.insert(args, "--short") - end - table.insert(args, rev) - local stdout = util.git(args, { cwd = self.worktree, silent = true }) - local trimmed = stdout and vim.trim(stdout) or "" - return trimmed ~= "" and trimmed or nil -end - ----@param rel string worktree-relative path ----@return string? -function Repo:index_sha(rel) - local sha = self:get_cached("index:" .. rel, function(self) - return self:rev_parse(":" .. rel, false) or false - end) - return sha or nil -end - ----@param rel string worktree-relative path ----@return string? -function Repo:head_sha(rel) - local sha = self:get_cached("head_blob:" .. rel, function(self) - return self:rev_parse("HEAD:" .. rel, false) or false - end) - return sha or nil -end - ----@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing" - ----@param abbrev string ----@return string? full_sha ----@return ow.Git.Repo.ResolveStatus -function Repo:resolve_sha(abbrev) - local result = self:get_cached("resolve:" .. abbrev, function(self) - local out = util.git( - { "rev-parse", "--disambiguate=" .. abbrev }, - { cwd = self.worktree, silent = true } - ) - local trimmed = out and vim.trim(out) or "" - if trimmed == "" then - return { nil, "missing" } - end - local lines = util.split_lines(trimmed) - if #lines == 1 then - return { lines[1], "ok" } - end - return { nil, "ambiguous" } - end) - return result[1], result[2] -end - ----@private ----@return table> -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 -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 -function M.all() - return repos -end - ----@param buf integer ----@return ow.Git.Repo? -local function find_by_buf(buf) - for _, r in pairs(repos) do - if r.buffers[buf] then - return r - end - end - return nil -end - ----@param path string ----@return ow.Git.Repo? -local function find_by_path(path) - if path == "" then - return nil - end - if repos[path] then - return repos[path] - end - local best - for wt in pairs(repos) do - if path:sub(1, #wt + 1) == wt .. "/" then - if not best or #wt > #best then - best = wt - end - end - end - return best and repos[best] or nil -end - ----@param buf integer ----@return string -local function path_for_buf(buf) - local path = vim.api.nvim_buf_get_name(buf) - if path == "" or util.is_uri(path) then - return vim.fn.getcwd() - end - return vim.fn.resolve(path) -end - ----@param arg? integer | string bufnr (default current) or worktree path ----@return ow.Git.Repo? -function M.find(arg) - if type(arg) == "string" then - return find_by_path(arg) - end - local buf = expand_buf(arg) - return find_by_buf(buf) or find_by_path(path_for_buf(buf)) -end - ----@param arg? integer | string bufnr (default current) or worktree path ----@return ow.Git.Repo? -function M.resolve(arg) - if type(arg) ~= "string" then - local existing = find_by_buf(expand_buf(arg)) - if existing then - return existing - end - end - local path - if type(arg) == "string" then - path = vim.fn.resolve(arg) - else - path = path_for_buf(expand_buf(arg)) - end - local dir = vim.fs.dirname(path) - if no_repo_dirs[dir] then - return nil - end - local found = vim.fs.find(".git", { upward = true, path = path })[1] - if not found then - no_repo_dirs[dir] = true - return nil - end - local worktree = vim.fs.dirname(found) - if repos[worktree] then - return repos[worktree] - end - local stat = vim.uv.fs_stat(found) - if not stat then - return nil - end - local gitdir - if stat.type == "directory" then - gitdir = found - else - local f = io.open(found, "r") - if not f then - return nil - end - local content = f:read("*a") - f:close() - local rel = content:match("gitdir:%s*(%S+)") - if not rel then - util.error(".git file at %s has no `gitdir:` line", found) - return nil - end - gitdir = vim.fs.normalize( - rel:match("^/") and rel or vim.fs.joinpath(worktree, rel) - ) - end - local r = Repo.new(gitdir, worktree) - repos[worktree] = r - for d in pairs(no_repo_dirs) do - if d == worktree or vim.startswith(d, worktree .. "/") then - no_repo_dirs[d] = nil - end - end - return r -end - ----@param buf? integer ----@return ow.Git.Repo.BufState? -function M.state(buf) - buf = expand_buf(buf) - local r = find_by_buf(buf) - return r and r.buffers[buf] -end - ----@param buf? integer ----@param r ow.Git.Repo -function M.bind(buf, r) - buf = expand_buf(buf) - local prev = find_by_buf(buf) - if prev == r then - return - end - if prev then - prev.buffers[buf] = nil - release_if_unused(prev) - end - r.buffers[buf] = { repo = r } -end - ----@param buf? integer -function M.unbind(buf) - buf = expand_buf(buf) - local r = find_by_buf(buf) - if not r then - return - end - r.buffers[buf] = nil - release_if_unused(r) -end - ----@param buf integer ----@return boolean -function M.is_worktree_buf(buf) - if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then - return false - end - local path = vim.api.nvim_buf_get_name(buf) - return path ~= "" and not util.is_uri(path) -end - ----@param buf? integer -function M.track(buf) - buf = expand_buf(buf) - if not M.is_worktree_buf(buf) then - return - end - local r = M.resolve(buf) - if r and not r.buffers[buf] then - M.bind(buf, r) - end -end - ----@param buf? integer -function M.refresh(buf) - local r = find_by_buf(expand_buf(buf)) - if r then - r:refresh() - end -end - -function M.refresh_all() - for _, r in pairs(repos) do - r:refresh() - end -end - -function M.update_cwd_repo() - no_repo_dirs = {} - local tab = vim.api.nvim_get_current_tabpage() - local new = M.resolve(vim.fn.getcwd()) - local old - for _, r in pairs(repos) do - if r.tabs[tab] then - old = r - break - end - end - if new == old then - return - end - if old then - old.tabs[tab] = nil - release_if_unused(old) - end - if new then - new.tabs[tab] = true - new:refresh() - end -end - ----@param tab integer -function M.release_tab(tab) - for _, r in pairs(repos) do - if r.tabs[tab] then - r.tabs[tab] = nil - release_if_unused(r) - return - end - end -end - -function M.stop_all() - for _, r in pairs(repos) do - r:close() - end -end - -return M diff --git a/lua/git/core/revision.lua b/lua/git/core/revision.lua deleted file mode 100644 index 55b3ca8..0000000 --- a/lua/git/core/revision.lua +++ /dev/null @@ -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 diff --git a/lua/git/core/status.lua b/lua/git/core/status.lua deleted file mode 100644 index 7e626ec..0000000 --- a/lua/git/core/status.lua +++ /dev/null @@ -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 -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 ----@param next_ table ----@return table -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 - 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 diff --git a/lua/git/core/util.lua b/lua/git/core/util.lua deleted file mode 100644 index f29cfed..0000000 --- a/lua/git/core/util.lua +++ /dev/null @@ -1,352 +0,0 @@ -local M = {} - ----@class ow.Git.Util.ScratchOpts ----@field name string? ----@field bufhidden ("hide"|"wipe"|"delete")? ----@field buftype ("nofile"|"acwrite"|"nowrite")? ----@field modifiable boolean? - ----@param buf integer ----@param opts ow.Git.Util.ScratchOpts -function M.setup_scratch(buf, opts) - vim.bo[buf].buftype = opts.buftype or "nofile" - vim.bo[buf].bufhidden = opts.bufhidden or "wipe" - vim.bo[buf].swapfile = false - vim.bo[buf].modifiable = opts.modifiable == true - vim.bo[buf].modified = false - vim.bo[buf].buflisted = false - if opts.name then - pcall(vim.api.nvim_buf_set_name, buf, opts.name) - end -end - ----@param name string ----@return boolean -function M.is_uri(name) - return name:match("^%a+://") ~= nil -end - ----@param sha string? ----@return boolean -function M.is_zero_sha(sha) - return sha == nil or sha:match("^0+$") ~= nil -end - ----@param buf integer ----@param name string -function M.set_buf_name(buf, name) - pcall(vim.api.nvim_buf_set_name, buf, name) - local ft = vim.filetype.match({ buf = buf }) - if ft then - vim.bo[buf].filetype = ft - end -end - ----@param buf integer ----@param split (false|"above"|"below"|"left"|"right")? ----@return integer win -function M.place_buf(buf, split) - if split == false then - vim.cmd.normal({ "m'", bang = true }) - vim.api.nvim_set_current_buf(buf) - return vim.api.nvim_get_current_win() - end - local win = vim.api.nvim_open_win(buf, true, { - split = split or (vim.o.splitbelow and "below" or "above"), - }) - vim.cmd.clearjumps() - return win -end - ----@class ow.Git.Util.NewScratchOpts : ow.Git.Util.ScratchOpts ----@field split (false|"above"|"below"|"left"|"right")? - ----@param opts ow.Git.Util.NewScratchOpts? ----@return integer buf ----@return integer win -function M.new_scratch(opts) - opts = opts or {} - local buf = vim.api.nvim_create_buf(false, true) - M.setup_scratch(buf, opts) - return buf, M.place_buf(buf, opts.split) -end - ----@param fmt string ----@param ... any -function M.error(fmt, ...) - vim.notify(fmt:format(...), vim.log.levels.ERROR) -end - ----@param fmt string ----@param ... any -function M.warning(fmt, ...) - vim.notify(fmt:format(...), vim.log.levels.WARN) -end - ----@param fmt string ----@param ... any -function M.info(fmt, ...) - vim.notify(fmt:format(...), vim.log.levels.INFO) -end - ----@param fmt string ----@param ... any -function M.debug(fmt, ...) - vim.notify(fmt:format(...), vim.log.levels.DEBUG) -end - ----@param buf integer ----@param start integer ----@param end_ integer ----@param lines string[] -function M.set_buf_lines(buf, start, end_, lines) - if not vim.api.nvim_buf_is_loaded(buf) then - return - end - local was_modifiable = vim.bo[buf].modifiable - vim.bo[buf].modifiable = true - vim.api.nvim_buf_set_lines(buf, start, end_, true, lines) - vim.bo[buf].modifiable = was_modifiable - vim.bo[buf].modified = false -end - ----@param content string ----@return string[] -function M.split_lines(content) - local lines = vim.split(content, "\n", { plain = true, trimempty = false }) - if #lines > 0 and lines[#lines] == "" then - table.remove(lines) - end - return lines -end - ----@class ow.Git.Util.DebounceHandle ----@field cancel fun() ----@field flush fun() ----@field pending fun(): boolean ----@field close fun() - ----@generic F: fun(...) ----@param fn F ----@param delay integer ----@return F, ow.Git.Util.DebounceHandle -function M.debounce(fn, delay) - local timer, err = vim.uv.new_timer() - if not timer then - M.warning("git: failed to create timer: %s", err) - local noop = function() end - return fn, - { - cancel = noop, - flush = noop, - pending = function() - return false - end, - close = noop, - } - end - local args ---@type table? - local gen = 0 - local fired_gen = 0 - - local cb_main = vim.schedule_wrap(function() - -- Identity check: the libuv fire may have been superseded by - -- a re-arm or a cancel between the timer firing and this - -- scheduled callback running. - if fired_gen ~= gen or args == nil then - return - end - local a = args - args = nil - fn(vim.F.unpack_len(a)) - end) - - local cb_uv = function() - fired_gen = gen - cb_main() - end - - local function call(...) - args = vim.F.pack_len(...) - gen = gen + 1 - timer:start(delay, 0, cb_uv) - end - - return call, - { - cancel = function() - timer:stop() - args = nil - end, - flush = function() - if args == nil then - return - end - timer:stop() - local a = args - args = nil - fn(vim.F.unpack_len(a)) - end, - pending = function() - return args ~= nil - end, - close = function() - timer:stop() - if not timer:is_closing() then - timer:close() - end - args = nil - end, - } -end - ----@class ow.Git.Util.KeyedDebounceHandle ----@field cancel fun(key: K) ----@field flush fun(key: K) ----@field pending fun(key: K): boolean ----@field close fun() - ----@generic K, F: fun(key: K, ...) ----@param fn F ----@param delay integer ----@return F, ow.Git.Util.KeyedDebounceHandle -function M.keyed_debounce(fn, delay) - ---@type table - local slots = {} - - local function call(key, ...) - local t = type(key) - assert( - t == "string" or t == "number" or t == "boolean", - "key must be a primitive (string, number, boolean)" - ) - local slot = slots[key] - if not slot then - local c, h = M.debounce(function(...) - fn(key, ...) - end, delay) - slot = { call = c, handle = h } - slots[key] = slot - end - slot.call(...) - end - - return call, - { - cancel = function(key) - local slot = slots[key] - if slot then - slot.handle.close() - slots[key] = nil - end - end, - flush = function(key) - local slot = slots[key] - if slot then - slot.handle.flush() - end - end, - pending = function(key) - local slot = slots[key] - return slot ~= nil and slot.handle.pending() - end, - close = function() - for _, slot in pairs(slots) do - slot.handle.close() - end - slots = {} - end, - } -end - ----@class ow.Git.Util.ExecOpts ----@field cwd string? ----@field stdin string? ----@field silent boolean? ----@field env table? ----@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 ----@field private _listeners table -local Emitter = {} -Emitter.__index = Emitter - ----@return ow.Git.Util.Emitter -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 diff --git a/lua/git/diffsplit.lua b/lua/git/diffsplit.lua deleted file mode 100644 index d16e562..0000000 --- a/lua/git/diffsplit.lua +++ /dev/null @@ -1,134 +0,0 @@ -local Revision = require("git.core.revision") -local object = require("git.object") -local repo = require("git.core.repo") -local util = require("git.core.util") - -local M = {} - ----@class ow.Git.Diffsplit.OpenOpts ----@field target string? ----@field mods vim.api.keyset.cmd.mods? - ----@param cur_buf integer ----@return string? target ----@return string? err -local function infer_target(cur_buf) - local cur_name = vim.api.nvim_buf_get_name(cur_buf) - local cur_rev = object.parse_uri(cur_name) - if cur_rev then - local r = repo.resolve(cur_buf) - if not r then - return nil, "git URI buffer has no worktree" - end - if not cur_rev.path then - return nil, "git URI has no path, cannot diff against worktree" - end - local worktree_path = vim.fs.joinpath(r.worktree, cur_rev.path) - if not vim.uv.fs_stat(worktree_path) then - return nil, "worktree file does not exist: " .. cur_rev.path - end - return worktree_path, nil - end - - if cur_name == "" then - return nil, "no file in current buffer" - end - if vim.bo[cur_buf].buftype ~= "" then - return nil, "cannot diff this buffer (not a worktree file)" - end - local resolved = vim.fn.resolve(cur_name) - local r = repo.resolve(resolved) - if not r then - return nil, "not in a git repository" - end - local rel = vim.fs.relpath(r.worktree, resolved) - if not rel then - return nil, "current buffer is outside the worktree" - end - return object.format_uri(Revision.new({ stage = 0, path = rel })), nil -end - ----@param target string ----@param cur_buf integer ----@return string? resolved ----@return string? err -local function resolve_target(target, cur_buf) - if vim.startswith(target, object.URI_PREFIX) then - return target, nil - end - if vim.fn.filereadable(target) == 1 then - return target, nil - end - local cur_name = vim.api.nvim_buf_get_name(cur_buf) - local cur_rev = object.parse_uri(cur_name) - local r, rel - if cur_rev and cur_rev.path then - r = repo.resolve(cur_buf) - rel = cur_rev.path - elseif cur_name ~= "" then - local resolved = vim.fn.resolve(cur_name) - r = repo.resolve(resolved) - if r then - rel = vim.fs.relpath(r.worktree, resolved) - end - end - if not r then - return nil, "not in a git repository" - end - if not rel then - return nil, "current buffer has no path" - end - if not r:rev_parse(target, true) then - return nil, "invalid rev: " .. target - end - return object.format_uri(Revision.new({ base = target, path = rel })), nil -end - ----@param cur_buf integer ----@param target string ----@return 'aboveleft'|'belowright'|nil -local function default_split(cur_buf, target) - local cur_rev = object.parse_uri(vim.api.nvim_buf_get_name(cur_buf)) - local target_rev = object.parse_uri(target) - if not cur_rev and target_rev then - return "aboveleft" - end - if cur_rev and not target_rev then - return "belowright" - end - if cur_rev and target_rev then - if cur_rev.stage == 0 and target_rev.base then - return "aboveleft" - end - if cur_rev.base and target_rev.stage == 0 then - return "belowright" - end - end - return nil -end - ----@param opts? ow.Git.Diffsplit.OpenOpts -function M.open(opts) - opts = opts or {} - local cur_buf = vim.api.nvim_get_current_buf() - local target, err - if opts.target then - target, err = resolve_target(opts.target, cur_buf) - else - target, err = infer_target(cur_buf) - end - if not target then - util.error("%s", err or "no diff target") - return - end - local mods = opts.mods - if not mods or mods.split == nil then - local placement = default_split(cur_buf, target) - if placement then - mods = vim.tbl_extend("force", mods or {}, { split = placement }) - end - end - vim.cmd.diffsplit({ args = { target }, mods = mods }) -end - -return M diff --git a/lua/git/editor.lua b/lua/git/editor.lua deleted file mode 100644 index 29eab55..0000000 --- a/lua/git/editor.lua +++ /dev/null @@ -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 } ----@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 diff --git a/lua/git/hunks.lua b/lua/git/hunks.lua deleted file mode 100644 index badc1c1..0000000 --- a/lua/git/hunks.lua +++ /dev/null @@ -1,970 +0,0 @@ -local repo = require("git.core.repo") -local util = require("git.core.util") - -local M = {} - -local NS_SIGNS = vim.api.nvim_create_namespace("ow.git.hunks") -local NS_OVERLAY = vim.api.nvim_create_namespace("ow.git.hunks.overlay") - ----@alias ow.Git.Hunks.HunkType "add"|"change"|"delete" - ----@class ow.Git.Hunks.Hunk ----@field old_start integer 1-indexed first old line ----@field old_count integer ----@field new_start integer 1-indexed first new line ----@field new_count integer ----@field type ow.Git.Hunks.HunkType ----@field old_lines string[] ----@field new_lines string[] - ----@class ow.Git.Hunks.BufState ----@field repo ow.Git.Repo ----@field rel string ----@field index string[]? ----@field index_sha string? ----@field head string[]? ----@field head_sha string? ----@field index_hl { src: string[], lines: table[][]? }? ----@field hunks ow.Git.Hunks.Hunk[] ----@field staged ow.Git.Hunks.Hunk[] ----@field overlay boolean ----@field autocmds integer[] - ----@type table -local states = {} - ----@param buf integer ----@return ow.Git.Hunks.BufState? -function M.state(buf) - return states[buf] -end - ----@param buf integer? ----@return integer -local function resolve_buf(buf) - return buf and buf ~= 0 and buf or vim.api.nvim_get_current_buf() -end - ----Mirror the hunk-affecting parts of the user's 'diffopt' so the gutter ----lines up with what `:diffsplit` shows. ----@return table -local function diff_opts() - local opts = { result_type = "indices", algorithm = "myers" } - for _, item in ipairs(vim.split(vim.o.diffopt, ",", { plain = true })) do - if item == "indent-heuristic" then - opts.indent_heuristic = true - else - local algorithm = item:match("^algorithm:(.+)$") - if algorithm then - opts.algorithm = algorithm - end - local linematch = item:match("^linematch:(%d+)$") - if linematch then - opts.linematch = tonumber(linematch) - end - end - end - return opts -end - ----@param old_lines string[] ----@param new_lines string[] ----@return ow.Git.Hunks.Hunk[] -local function compute_hunks(old_lines, new_lines) - local raw = vim.text.diff( - table.concat(old_lines, "\n"), - table.concat(new_lines, "\n"), - diff_opts() - ) - ---@type ow.Git.Hunks.Hunk[] - local hunks = {} - if type(raw) ~= "table" then - return hunks - end - for _, h in ipairs(raw) do - local os_ = h[1] --[[@as integer]] - local oc = h[2] --[[@as integer]] - local ns_ = h[3] --[[@as integer]] - local nc = h[4] --[[@as integer]] - local typ ---@type ow.Git.Hunks.HunkType - if oc == 0 then - typ = "add" - elseif nc == 0 then - typ = "delete" - else - typ = "change" - end - local old = {} - if typ ~= "add" then - for i = os_, os_ + oc - 1 do - table.insert(old, old_lines[i] or "") - end - end - local new = {} - if typ ~= "delete" then - for i = ns_, ns_ + nc - 1 do - table.insert(new, new_lines[i] or "") - end - end - table.insert(hunks, { - old_start = os_, - old_count = oc, - new_start = ns_, - new_count = nc, - type = typ, - old_lines = old, - new_lines = new, - }) - end - return hunks -end - ----@type table -local DEFAULT_SIGNS = { add = "┃", change = "┃", delete = "▁" } - ----@return table -local function resolve_signs() - local cfg = vim.g.git_hunk_signs - if type(cfg) ~= "table" then - return DEFAULT_SIGNS - end - return vim.tbl_extend("force", DEFAULT_SIGNS, cfg) -end - ----@type table -local SIGN_HL = { - add = "GitHunkAdded", - change = "GitHunkChanged", - delete = "GitHunkRemoved", -} - ----@type table -local STAGED_SIGN_HL = { - add = "GitHunkStagedAdded", - change = "GitHunkStagedChanged", - delete = "GitHunkStagedRemoved", -} - ----@param h ow.Git.Hunks.Hunk ----@param line_count integer ----@return integer[] 0-indexed buffer rows for the hunk -local function hunk_rows(h, line_count) - if h.type == "delete" then - local row = math.max(h.new_start, 1) - 1 - if row >= line_count then - row = math.max(line_count - 1, 0) - end - return { row } - end - local rows = {} - for r = h.new_start, h.new_start + h.new_count - 1 do - local row = r - 1 - if row >= 0 and row < line_count then - table.insert(rows, row) - end - end - return rows -end - ----@param h ow.Git.Hunks.Hunk ----@return integer 1-indexed last index line the hunk occupies -local function index_end(h) - if h.old_count == 0 then - return h.old_start - end - return h.old_start + h.old_count - 1 -end - ----@param unstaged ow.Git.Hunks.Hunk[] ----@param iline integer 1-indexed index line ----@return integer? 1-indexed buffer line -local function index_to_buffer(unstaged, iline) - local delta = 0 - for _, h in ipairs(unstaged) do - if - h.old_count > 0 - and iline >= h.old_start - and iline <= index_end(h) - then - return nil - end - if iline > index_end(h) then - delta = delta + h.new_count - h.old_count - end - end - return iline + delta -end - ----@param state ow.Git.Hunks.BufState ----@param line_count integer ----@return { row: integer, hunk: ow.Git.Hunks.Hunk }[] row is a 0-indexed buffer row -local function staged_signs(state, line_count) - local out = {} - for _, h in ipairs(state.staged) do - local index_lines = {} - if h.type == "delete" then - table.insert(index_lines, math.max(h.new_start, 1)) - else - for i = h.new_start, h.new_start + h.new_count - 1 do - table.insert(index_lines, i) - end - end - for _, iline in ipairs(index_lines) do - local bline = index_to_buffer(state.hunks, iline) - if bline then - local row = math.min(math.max(bline - 1, 0), line_count - 1) - table.insert(out, { row = row, hunk = h }) - end - end - end - return out -end - ----@param buf integer -local function render_signs(buf) - if not vim.api.nvim_buf_is_valid(buf) then - return - end - vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1) - local state = states[buf] - if not state or state.overlay then - return - end - local signs = resolve_signs() - local line_count = vim.api.nvim_buf_line_count(buf) - local signed = {} - for _, h in ipairs(state.hunks) do - for _, row in ipairs(hunk_rows(h, line_count)) do - signed[row] = true - pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, row, 0, { - sign_text = signs[h.type], - sign_hl_group = SIGN_HL[h.type], - priority = 100, - }) - end - end - for _, s in ipairs(staged_signs(state, line_count)) do - if not signed[s.row] then - pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, s.row, 0, { - sign_text = signs[s.hunk.type], - sign_hl_group = STAGED_SIGN_HL[s.hunk.type], - priority = 100, - }) - end - end -end - -local SKIP_CAPTURES = { spell = true, nospell = true, conceal = true } - ----@param buf integer ----@param lines string[] ----@return table[][]? -local function highlight_index(buf, lines) - if not vim.treesitter.highlighter.active[buf] then - return nil - end - local got, parser = pcall(vim.treesitter.get_parser, buf) - if not got or not parser then - return nil - end - local lang = parser:lang() - local query = vim.treesitter.query.get(lang, "highlights") - if not query then - return nil - end - local source = table.concat(lines, "\n") - local got_root, root = pcall(function() - local trees = vim.treesitter.get_string_parser(source, lang):parse() - local tree = trees and trees[1] - return tree and tree:root() - end) - if not got_root or not root then - return nil - end - ---@type table> - local groups = {} - for id, node in query:iter_captures(root, source) do - local name = query.captures[id] - if name and name:sub(1, 1) ~= "_" and not SKIP_CAPTURES[name] then - local sr, sc, er, ec = node:range() - for row = sr, math.min(er, #lines - 1) do - local row_groups = groups[row] or {} - groups[row] = row_groups - local from = row == sr and sc or 0 - local to = row == er and ec or #(lines[row + 1] or "") - for col = from, to - 1 do - row_groups[col] = name - end - end - end - end - local out = {} - for row = 0, #lines - 1 do - local line = lines[row + 1] or "" - local row_groups = groups[row] or {} - local chunks = {} - local col = 0 - while col < #line do - local name = row_groups[col] - local stop = col + 1 - while stop < #line and row_groups[stop] == name do - stop = stop + 1 - end - local hl ---@type string|string[] - if name then - hl = { "GitHunkDeleteLine", "@" .. name } - else - hl = "GitHunkDeleteLine" - end - table.insert(chunks, { line:sub(col + 1, stop), hl }) - col = stop - end - out[row + 1] = chunks - end - return out -end - ----@param h ow.Git.Hunks.Hunk ----@param hl_lines table[][]? per-index-line syntax chunks, or nil ----@return table[] -local function delete_virt_lines(h, hl_lines) - local width = vim.o.columns - local virt = {} - for i, line in ipairs(h.old_lines) do - local pad = math.max(width - vim.api.nvim_strwidth(line), 0) - local cached = hl_lines and hl_lines[h.old_start + i - 1] - if cached then - local chunks = vim.list_extend({}, cached) - table.insert(chunks, { - string.rep(" ", pad), - "GitHunkDeleteLine", - }) - table.insert(virt, chunks) - else - table.insert(virt, { - { line .. string.rep(" ", pad), "GitHunkDeleteLine" }, - }) - end - end - return virt -end - ----@param state ow.Git.Hunks.BufState ----@param buf integer ----@return table[][]? -local function index_spans(state, buf) - if not state.index then - return nil - end - local cache = state.index_hl - if cache and cache.src == state.index then - return cache.lines - end - local lines = highlight_index(buf, state.index) - state.index_hl = { src = state.index, lines = lines } - return lines -end - ----@param buf integer -local function render_overlay(buf) - if not vim.api.nvim_buf_is_valid(buf) then - return - end - vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1) - local state = states[buf] - if not state or not state.overlay then - return - end - local line_count = vim.api.nvim_buf_line_count(buf) - local hl_lines = index_spans(state, buf) - for _, h in ipairs(state.hunks) do - if h.type ~= "delete" then - for r = h.new_start, h.new_start + h.new_count - 1 do - local row = r - 1 - if row >= 0 and row < line_count then - pcall( - vim.api.nvim_buf_set_extmark, - buf, - NS_OVERLAY, - row, - 0, - { - line_hl_group = "GitHunkAddLine", - priority = 100, - } - ) - end - end - end - if h.type ~= "add" then - local row, above - if h.type == "delete" then - if h.new_start <= 0 then - row, above = 0, true - elseif h.new_start >= line_count then - row, above = math.max(line_count - 1, 0), false - else - row, above = h.new_start, true - end - else - row, above = math.max(h.new_start - 1, 0), true - end - pcall(vim.api.nvim_buf_set_extmark, buf, NS_OVERLAY, row, 0, { - virt_lines = delete_virt_lines(h, hl_lines), - virt_lines_above = above, - right_gravity = false, - invalidate = true, - }) - end - end -end - ----@param buf integer -local function render(buf) - render_signs(buf) - render_overlay(buf) -end - ----@param state ow.Git.Hunks.BufState ----@param buf integer ----@param rev string ----@param want string? the wanted blob sha ----@param have string? the currently-loaded blob sha ----@param apply fun(lines: string[]?, sha: string?) ----@param after fun() -local function ensure_content(state, buf, rev, want, have, apply, after) - if not want then - apply(nil, nil) - return after() - end - if want == have then - return after() - end - util.git({ "cat-file", "-p", rev }, { - cwd = state.repo.worktree, - silent = true, - on_exit = function(res) - if states[buf] ~= state or not vim.api.nvim_buf_is_valid(buf) then - return - end - if res.code == 0 then - apply(util.split_lines(res.stdout or ""), want) - else - apply(nil, nil) - end - after() - end, - }) -end - ----@param buf integer -local function recompute(buf) - if not vim.api.nvim_buf_is_valid(buf) then - return - end - local state = states[buf] - if not state then - return - end - local r = state.repo - ensure_content( - state, - buf, - ":0:" .. state.rel, - r:index_sha(state.rel), - state.index_sha, - function(lines, sha) - state.index = lines - state.index_sha = sha - end, - function() - ensure_content( - state, - buf, - "HEAD:" .. state.rel, - r:head_sha(state.rel), - state.head_sha, - function(lines, sha) - state.head = lines - state.head_sha = sha - end, - function() - local new = - vim.api.nvim_buf_get_lines(buf, 0, -1, false) - state.hunks = state.index - and compute_hunks(state.index, new) - or {} - state.staged = state.head - and state.index - and compute_hunks(state.head, state.index) - or {} - render(buf) - end - ) - end - ) -end - -local schedule, sched_handle = util.keyed_debounce(recompute, 100) - ----@param buf integer -function M._flush(buf) - sched_handle.flush(buf) -end - ----@param buf integer -function M.attach(buf) - if states[buf] then - return - end - if not repo.is_worktree_buf(buf) then - return - end - local r = repo.find(buf) - if not r then - return - end - local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(vim.api.nvim_buf_get_name(buf))) - if not rel then - return - end - ---@type ow.Git.Hunks.BufState - local state = { - repo = r, - rel = rel, - index = nil, - index_sha = nil, - head = nil, - head_sha = nil, - hunks = {}, - staged = {}, - overlay = vim.g.git_hunk_overlay_default == true, - autocmds = {}, - } - states[buf] = state - - local group = - vim.api.nvim_create_augroup("ow.git.hunks." .. buf, { clear = true }) - table.insert( - state.autocmds, - vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { - group = group, - buffer = buf, - callback = function() - schedule(buf) - end, - }) - ) - table.insert( - state.autocmds, - vim.api.nvim_create_autocmd("BufWritePost", { - group = group, - buffer = buf, - callback = function() - schedule(buf) - end, - }) - ) - - schedule(buf) -end - ----@param buf integer -function M.detach(buf) - local state = states[buf] - if not state then - return - end - if vim.api.nvim_buf_is_valid(buf) then - vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1) - vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1) - end - for _, id in ipairs(state.autocmds) do - pcall(vim.api.nvim_del_autocmd, id) - end - pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks." .. buf) - sched_handle.cancel(buf) - states[buf] = nil -end - ----@param buf integer? -function M.toggle_overlay(buf) - buf = resolve_buf(buf) - local state = states[buf] - if not state then - util.warning("git hunks: buffer not attached") - return - end - state.overlay = not state.overlay - render(buf) -end - ----@param hunks ow.Git.Hunks.Hunk[] ----@param row integer 1-indexed cursor line ----@return ow.Git.Hunks.Hunk? -local function hunk_at(hunks, row) - for _, h in ipairs(hunks) do - if h.type == "delete" then - if math.max(h.new_start, 1) == row then - return h - end - elseif row >= h.new_start and row <= h.new_start + h.new_count - 1 then - return h - end - end - return nil -end - ----@param state ow.Git.Hunks.BufState ----@param buf integer ----@param row integer 1-indexed cursor line ----@return ow.Git.Hunks.Hunk? -local function staged_hunk_at(state, buf, row) - local line_count = vim.api.nvim_buf_line_count(buf) - for _, s in ipairs(staged_signs(state, line_count)) do - if s.row == row - 1 then - return s.hunk - end - end - return nil -end - ----@param buf integer? ----@return integer buf ----@return ow.Git.Hunks.BufState? state ----@return ow.Git.Hunks.Hunk? hunk -local function cursor_hunk(buf) - buf = resolve_buf(buf) - local state = states[buf] - if not state then - return buf, nil, nil - end - return buf, state, hunk_at(state.hunks, vim.api.nvim_win_get_cursor(0)[1]) -end - ----@param h ow.Git.Hunks.Hunk ----@return integer 1-indexed buffer line to anchor the cursor on -local function anchor_line(h) - if h.type == "delete" then - return math.max(h.new_start, 1) - end - return h.new_start -end - ----@param direction "next"|"prev" -function M.nav(direction) - local buf = vim.api.nvim_get_current_buf() - local state = states[buf] - if not state or #state.hunks == 0 then - return - end - local cur = vim.api.nvim_win_get_cursor(0)[1] - local hunks = state.hunks - local target = direction == "next" and hunks[1] or hunks[#hunks] - if direction == "next" then - for _, h in ipairs(hunks) do - if anchor_line(h) > cur then - target = h - break - end - end - else - for i = #hunks, 1, -1 do - if anchor_line(hunks[i]) < cur then - target = hunks[i] - break - end - end - end - if not target then - return - end - vim.api.nvim_win_set_cursor(0, { anchor_line(target), 0 }) -end - ----@param h ow.Git.Hunks.Hunk ----@return string[] -local function hunk_body(h) - local lines = { - string.format( - "@@ -%d,%d +%d,%d @@", - h.old_start, - h.old_count, - h.new_start, - h.new_count - ), - } - for _, l in ipairs(h.old_lines) do - table.insert(lines, "-" .. l) - end - for _, l in ipairs(h.new_lines) do - table.insert(lines, "+" .. l) - end - return lines -end - -local PATCH_CONTEXT = 3 - ----@param h ow.Git.Hunks.Hunk ----@return integer old_before count of old lines before the hunk's changed content ----@return integer new_before count of new lines before the hunk's changed content -local function hunk_offsets(h) - if h.type == "add" then - return h.old_start, h.new_start - 1 - elseif h.type == "delete" then - return h.old_start - 1, h.new_start - end - return h.old_start - 1, h.new_start - 1 -end - ----@param h ow.Git.Hunks.Hunk ----@return ow.Git.Hunks.Hunk -local function invert(h) - local typ ---@type ow.Git.Hunks.HunkType - if h.type == "add" then - typ = "delete" - elseif h.type == "delete" then - typ = "add" - else - typ = "change" - end - return { - old_start = h.new_start, - old_count = h.new_count, - new_start = h.old_start, - new_count = h.old_count, - type = typ, - old_lines = h.new_lines, - new_lines = h.old_lines, - } -end - ----@param h ow.Git.Hunks.Hunk ----@param old_lines string[] ----@param rel string ----@return string patch ----@return boolean zero_context -local function build_patch(h, old_lines, rel) - local old_before, new_before = hunk_offsets(h) - local pre = {} - for i = math.max(old_before - PATCH_CONTEXT + 1, 1), old_before do - pre[#pre + 1] = old_lines[i] or "" - end - local post = {} - local after = old_before + h.old_count - for i = after + 1, math.min(after + PATCH_CONTEXT, #old_lines) do - post[#post + 1] = old_lines[i] or "" - end - local old_n = #pre + h.old_count + #post - local new_n = #pre + h.new_count + #post - local old_start = old_n > 0 and old_before - #pre + 1 or old_before - local new_start = new_n > 0 and new_before - #pre + 1 or new_before - local body = { - string.format( - "@@ -%d,%d +%d,%d @@", - old_start, - old_n, - new_start, - new_n - ), - } - for _, l in ipairs(pre) do - body[#body + 1] = " " .. l - end - for _, l in ipairs(h.old_lines) do - body[#body + 1] = "-" .. l - end - for _, l in ipairs(h.new_lines) do - body[#body + 1] = "+" .. l - end - for _, l in ipairs(post) do - body[#body + 1] = " " .. l - end - local lines = { "--- a/" .. rel, "+++ b/" .. rel } - vim.list_extend(lines, body) - return table.concat(lines, "\n") .. "\n", #pre == 0 and #post == 0 -end - ----@param state ow.Git.Hunks.BufState ----@param buf integer ----@param patch string ----@param zero_context boolean -local function apply_patch(state, buf, patch, zero_context) - local args = { "apply", "--cached" } - if zero_context then - table.insert(args, "--unidiff-zero") - end - table.insert(args, "-") - util.git(args, { - cwd = state.repo.worktree, - stdin = patch, - on_exit = function(res) - if res.code ~= 0 then - util.error("git apply failed: %s", vim.trim(res.stderr or "")) - return - end - local s = states[buf] - if s then - s.index_sha = nil - schedule(buf) - end - end, - }) -end - ----@param buf? integer -function M.toggle_stage(buf) - buf = resolve_buf(buf) - local state = states[buf] - if not state then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local unstaged = hunk_at(state.hunks, row) - if unstaged and state.index then - local patch, zero = build_patch(unstaged, state.index, state.rel) - apply_patch(state, buf, patch, zero) - return - end - local staged = staged_hunk_at(state, buf, row) - if staged and state.index then - local patch, zero = build_patch(invert(staged), state.index, state.rel) - apply_patch(state, buf, patch, zero) - return - end - util.warning("git hunks: no hunk at cursor") -end - ----@param buf? integer -function M.reset_hunk(buf) - local target, state, h = cursor_hunk(buf) - if not state then - return - end - if not h then - util.warning("git hunks: no hunk at cursor") - return - end - if h.type == "add" then - vim.api.nvim_buf_set_lines( - target, - h.new_start - 1, - h.new_start - 1 + h.new_count, - false, - {} - ) - elseif h.type == "delete" then - vim.api.nvim_buf_set_lines( - target, - h.new_start, - h.new_start, - false, - h.old_lines - ) - else - vim.api.nvim_buf_set_lines( - target, - h.new_start - 1, - h.new_start - 1 + h.new_count, - false, - h.old_lines - ) - end -end - ----@param buf? integer -function M.select_hunk(buf) - local _, _, h = cursor_hunk(buf) - if not h or h.type == "delete" then - return - end - local first = h.new_start - local last = h.new_start + math.max(h.new_count, 1) - 1 - vim.api.nvim_win_set_cursor(0, { first, 0 }) - vim.cmd("normal! V") - vim.api.nvim_win_set_cursor(0, { last, 0 }) -end - -local preview_win ---@type integer? - ----@param buf? integer -function M.preview_hunk(buf) - if preview_win and vim.api.nvim_win_is_valid(preview_win) then - vim.api.nvim_set_current_win(preview_win) - return - end - local target, state, h = cursor_hunk(buf) - if not state then - return - end - if not h then - return - end - local lines = hunk_body(h) - local pbuf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines) - vim.bo[pbuf].filetype = "diff" - vim.bo[pbuf].bufhidden = "wipe" - local width = 0 - for _, l in ipairs(lines) do - if #l > width then - width = #l - end - end - width = math.min(math.max(width + 2, 40), vim.o.columns - 4) - local height = math.min(#lines, math.floor(vim.o.lines / 2)) - local win = vim.api.nvim_open_win(pbuf, false, { - relative = "cursor", - row = 1, - col = 0, - width = width, - height = height, - style = "minimal", - }) - preview_win = win - - local function close() - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - end - end - local group = - vim.api.nvim_create_augroup("ow.git.hunks.preview", { clear = true }) - vim.api.nvim_create_autocmd( - { "CursorMoved", "CursorMovedI", "InsertEnter" }, - { group = group, buffer = target, callback = close } - ) - vim.api.nvim_create_autocmd("WinLeave", { - group = group, - buffer = pbuf, - callback = close, - }) - vim.api.nvim_create_autocmd("WinClosed", { - group = group, - pattern = tostring(win), - callback = function() - preview_win = nil - pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks.preview") - end, - }) - vim.keymap.set("n", "q", close, { buffer = pbuf, nowait = true }) -end - -repo.on("change", function(r, change) - for buf, state in pairs(states) do - if - state.repo == r - and (change.paths[state.rel] or change.branch_changed) - then - schedule(buf) - end - end -end) - -for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) then - M.attach(buf) - end -end - -return M diff --git a/lua/git/log_view.lua b/lua/git/log_view.lua deleted file mode 100644 index 99f2a35..0000000 --- a/lua/git/log_view.lua +++ /dev/null @@ -1,143 +0,0 @@ -local repo = require("git.core.repo") -local util = require("git.core.util") - -local M = {} - -local LOG_FORMAT = "%h %ad {%an}%d %s" - -local cr = vim.api.nvim_replace_termcodes("", true, false, true) - ----@param buf integer ----@return boolean opened -local function open_under_cursor(buf) - local r = repo.resolve(buf) - -- Anchor past the leading graph chars (matches the leading sha column, - -- not any hex word that happens to appear later in the subject). - local sha = r - and vim.api.nvim_get_current_line():match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)") - if not sha then - return false - end - ---@cast r -nil - require("git.object").open(r, sha, { split = false }) - return true -end - ----@param buf integer -local function attach_dispatch(buf) - vim.keymap.set("n", "", function() - if not open_under_cursor(buf) then - vim.api.nvim_feedkeys(cr, "n", false) - end - end, { buffer = buf, silent = true, desc = "Open commit" }) - vim.keymap.set("n", "gd", function() - open_under_cursor(buf) - end, { buffer = buf, silent = true, desc = "Open commit" }) -end - ----@param worktree string ----@param max_count integer? ----@return string? -local function fetch(worktree, max_count) - local args = { - "log", - "--graph", - "--all", - "--decorate", - "--date=short", - "--format=format:" .. LOG_FORMAT, - } - if max_count then - table.insert(args, "--max-count=" .. max_count) - end - return util.git(args, { cwd = worktree }) -end - ----@type table -- worktree -> max_count -local max_counts = {} - ----@param buf integer ----@param r ow.Git.Repo -local function populate(buf, r) - local stdout = fetch(r.worktree, max_counts[r.worktree]) - if not stdout then - return - end - util.set_buf_lines(buf, 0, -1, util.split_lines(stdout)) -end - ----@class ow.Git.Log.OpenOpts ----@field max_count integer? - ----@type table -M.opt_parsers = { - max_count = tonumber, -} - ----@param opts ow.Git.Log.OpenOpts? -function M.open(opts) - opts = opts or {} - local r = repo.resolve() - if not r then - util.error("not in a git repository") - return - end - - max_counts[r.worktree] = opts.max_count - local buf = vim.fn.bufadd(r.worktree .. "/GitLog") - - local visible = vim.fn.bufwinid(buf) - if visible ~= -1 then - vim.api.nvim_set_current_win(visible) - populate(buf, r) - vim.api.nvim_win_set_cursor(visible, { 1, 0 }) - return - end - - vim.fn.bufload(buf) - repo.bind(buf, r) - util.setup_scratch(buf, { bufhidden = "hide" }) - vim.bo[buf].filetype = "gitlog" - attach_dispatch(buf) - - local win = util.place_buf(buf, nil) - vim.api.nvim_win_set_cursor(win, { 1, 0 }) - populate(buf, r) -end - ----@param cmd_opts table -function M.run_glog(cmd_opts) - local parsed = { max_count = 1000 } - for _, a in ipairs(cmd_opts.fargs) do - local k, v = a:match("^([%w_]+)=(.*)$") - if not k then - util.error("invalid argument: %s", a) - return - end - ---@cast v -nil - local parser = M.opt_parsers[k] - if parser then - local value = parser(v) - if value ~= nil then - parsed[k] = value - end - end - end - M.open(parsed) -end - ----@param arg_lead string ----@return string[] -function M.complete_glog(arg_lead) - local matches = {} - for k in pairs(M.opt_parsers) do - local prefix = k .. "=" - if prefix:sub(1, #arg_lead) == arg_lead then - table.insert(matches, prefix) - end - end - table.sort(matches) - return matches -end - -return M diff --git a/lua/git/object.lua b/lua/git/object.lua deleted file mode 100644 index 8898832..0000000 --- a/lua/git/object.lua +++ /dev/null @@ -1,434 +0,0 @@ -local Revision = require("git.core.revision") -local repo = require("git.core.repo") -local util = require("git.core.util") - -local M = {} - -M.URI_PREFIX = "git://" - ----@param rev ow.Git.Revision ----@return string -function M.format_uri(rev) - return M.URI_PREFIX .. rev:format() -end - ----@param str string ----@return ow.Git.Revision? -function M.parse_uri(str) - local raw = str:match("^" .. M.URI_PREFIX .. "(.+)$") - if raw then - return Revision.parse(raw) - end -end - ----@class ow.Git.DiffSection ----@field path_a string ----@field path_b string ----@field blob_a string? ----@field blob_b string? - ----@return ow.Git.DiffSection? -local function diff_section() - local diff_lnum = vim.fn.search("^diff --git ", "bcnW") - if diff_lnum == 0 then - return nil - end - local diff_line = - vim.api.nvim_buf_get_lines(0, diff_lnum - 1, diff_lnum, false)[1] - if not diff_line then - return nil - end - local path_a, path_b = diff_line:match("^diff %-%-git a/(.-) b/(.+)$") - if not path_a or not path_b then - return nil - end - - local header = - vim.api.nvim_buf_get_lines(0, diff_lnum, diff_lnum + 20, false) - local blob_a, blob_b - for _, l in ipairs(header) do - if l:sub(1, 3) == "@@ " or l:match("^diff %-%-git ") then - break - end - local pre, post = l:match("^index (%x+)%.%.(%x+)") - if pre then - blob_a = pre - blob_b = post - break - end - end - return { - path_a = path_a, - path_b = path_b, - blob_a = blob_a, - blob_b = blob_b, - } -end - ----@param rev ow.Git.Revision ----@return boolean -local function is_immutable_rev(rev) - if rev.stage ~= nil then - return false - end - local base = rev.base - if not base then - return false - end - local stripped = base:gsub("%^%b{}", ""):gsub("[%^~]%d*", "") - return stripped:match("^%x+$") ~= nil and #stripped >= 7 -end - ----@param buf integer ----@param r ow.Git.Repo ----@param path string -local function attach_index_writer(buf, r, path) - vim.api.nvim_create_autocmd("BufWriteCmd", { - buffer = buf, - callback = function() - local body = table.concat( - vim.api.nvim_buf_get_lines(buf, 0, -1, false), - "\n" - ) .. "\n" - local hash_stdout = util.git( - { "hash-object", "-w", "--stdin" }, - { cwd = r.worktree, stdin = body } - ) - if not hash_stdout then - return - end - local sha = vim.trim(hash_stdout) - local state = r:state(buf) - local mode = state and state.index_mode - if not mode then - mode = "100644" - local ls = util.git( - { "ls-files", "-s", "--", path }, - { cwd = r.worktree, silent = true } - ) - if ls then - local m = ls:match("^(%d+)") - if m then - mode = m - end - end - if state then - state.index_mode = mode - end - end - if - not util.git({ - "update-index", - "--cacheinfo", - mode, - sha, - path, - }, { cwd = r.worktree }) - then - return - end - if state then - state.sha = r:rev_parse(":" .. path, true) - end - vim.bo[buf].modified = false - end, - }) -end - -local cr = vim.api.nvim_replace_termcodes("", true, false, true) - ----@param buf integer -function M.attach_dispatch(buf) - vim.keymap.set("n", "", function() - if not M.open_under_cursor() then - vim.api.nvim_feedkeys(cr, "n", false) - end - end, { buffer = buf, silent = true, desc = "Open file at commit" }) - vim.keymap.set("n", "gd", function() - M.open_under_cursor() - end, { buffer = buf, silent = true, desc = "Open file at commit" }) -end - ----@param r ow.Git.Repo ----@param rev ow.Git.Revision ----@return integer -function M.buf_for(r, rev) - local buf = vim.fn.bufadd(M.format_uri(rev)) - repo.bind(buf, r) - vim.fn.bufload(buf) - return buf -end - ----@param buf integer ----@param r ow.Git.Repo ----@param rev ow.Git.Revision ----@param state ow.Git.Repo.BufState ----@param rev_sha string ----@return boolean ok -local function populate(buf, r, rev, state, rev_sha) - local rev_str = rev:format() - local stdout = util.git({ "cat-file", "-p", rev_str }, { cwd = r.worktree }) - if not stdout then - return false - end - - if rev.path == nil then - local commit_sha = r:rev_parse(rev_str .. "^{commit}", true) - if commit_sha then - local patch = util.git({ - "diff-tree", - "-p", - "--diff-merges=first-parent", - "--root", - "--no-commit-id", - commit_sha, - }, { cwd = r.worktree }) - if patch then - stdout = (stdout:gsub("\n*$", "\n\n")) .. patch - end - end - end - - util.set_buf_lines(buf, 0, -1, util.split_lines(stdout)) - state.sha = rev_sha - return true -end - ----@param buf integer -function M.read_uri(buf) - local name = vim.api.nvim_buf_get_name(buf) - local rev = M.parse_uri(name) - if not rev then - return - end - - local r = repo.resolve(buf) - if not r then - util.error("git BufReadCmd %s: cannot resolve worktree", name) - return - end - repo.bind(buf, r) - local state = r:state(buf) --[[@as -nil]] - - local writable = rev.stage == 0 and rev.path ~= nil - util.setup_scratch(buf, { - bufhidden = "delete", - buftype = writable and "acwrite" or "nofile", - modifiable = writable, - }) - - local rev_sha = r:rev_parse(rev:format(), true) - if not rev_sha then - return - end - - if not populate(buf, r, rev, state, rev_sha) then - return - end - - state.immutable = is_immutable_rev(rev) - - if writable and not state.index_writer then - attach_index_writer(buf, r, rev.path --[[@as string]]) - state.index_writer = true - end - - if rev.path then - local ft = vim.filetype.match({ filename = rev.path, buf = buf }) - if ft then - vim.bo[buf].filetype = ft - end - else - vim.bo[buf].filetype = "git" - end - - M.attach_dispatch(buf) - - vim.api.nvim_buf_call(buf, function() - vim.api.nvim_exec_autocmds("BufReadPost", { buf = buf }) - end) -end - ----@param buf integer ----@param r ow.Git.Repo -local function refresh(buf, r) - local state = r:state(buf) - if not state or state.immutable or vim.bo[buf].modified then - return - end - local rev = M.parse_uri(vim.api.nvim_buf_get_name(buf)) - if not rev then - return - end - local rev_sha = r:rev_parse(rev:format(), true) - if not rev_sha or rev_sha == state.sha then - return - end - if state.sha == nil then - M.read_uri(buf) - else - populate(buf, r, rev, state, rev_sha) - end -end - ----@param buf integer ----@param path string -local function set_ft_from_path(buf, path) - local ft = vim.filetype.match({ filename = path, buf = buf }) - if ft then - vim.bo[buf].filetype = ft - end -end - ----@param r ow.Git.Repo ----@param blob string? ----@param path string ----@return integer? -local function side_buf(r, blob, path) - if not blob or util.is_zero_sha(blob) then - return nil - end - local full, status = r:resolve_sha(blob) - if status == "ambiguous" then - util.error("ambiguous blob abbreviation: %s", blob) - return nil - end - if full then - local buf = M.buf_for(r, Revision.new({ base = full })) - set_ft_from_path(buf, path) - return buf - end - local p = vim.fs.joinpath(r.worktree, path) - if vim.uv.fs_stat(p) then - local buf = vim.fn.bufadd(p) - vim.fn.bufload(buf) - return buf - end - return nil -end - ----@param r ow.Git.Repo ----@param blob string? ----@param path string -local function load_side(r, blob, path) - local buf = side_buf(r, blob, path) - if not buf then - util.error("no content for %s", path) - return - end - vim.cmd.normal({ "m'", bang = true }) - vim.api.nvim_set_current_buf(buf) -end - ----@param r ow.Git.Repo ----@param section ow.Git.DiffSection -local function open_section(r, section) - if not section.blob_a or not section.blob_b then - util.error("no index line, cannot determine blob SHAs") - return - end - local left = side_buf(r, section.blob_a, section.path_a) - local right = side_buf(r, section.blob_b, section.path_b) - if left and right then - vim.cmd.normal({ "m'", bang = true }) - vim.api.nvim_set_current_buf(right) - require("git.diffsplit").open({ - target = vim.api.nvim_buf_get_name(left), - mods = { vertical = true }, - }) - return - end - if not left and not right then - util.error("no content for %s", section.path_b) - return - end - local buf = left or right - ---@cast buf -nil - vim.cmd.normal({ "m'", bang = true }) - vim.api.nvim_set_current_buf(buf) -end - ----@class ow.Git.Object.OpenOpts ----@field split (false|"above"|"below"|"left"|"right")? - ----@param r ow.Git.Repo ----@param rev string ----@param opts ow.Git.Object.OpenOpts? -function M.open(r, rev, opts) - local parsed = Revision.parse(rev) - if parsed.base then - local sha = r:rev_parse(parsed.base, false) - if not sha then - util.error("not a git object: %s", rev) - return - end - parsed.base = sha - end - if parsed.path and not r:rev_parse(parsed:format(), false) then - util.error("not a git object: %s", rev) - return - end - local buf = M.buf_for(r, parsed) - util.place_buf(buf, opts and opts.split) -end - ----@return boolean dispatched -function M.open_under_cursor() - local r = repo.resolve() - if not r then - return false - end - - local line = vim.api.nvim_get_current_line() - - local sha = line:match("^commit (%x+)$") - or line:match("^parent (%x+)$") - or line:match("^tree (%x+)$") - or line:match("^object (%x+)$") - if sha then - M.open(r, sha, { split = false }) - return true - end - - local entry_type, entry_sha, entry_name = - line:match("^%d+ (%w+) (%x+)\t(.+)$") - if entry_sha then - if entry_type == "blob" then - load_side(r, entry_sha, entry_name --[[@as string]]) - else - M.open(r, entry_sha, { split = false }) - end - return true - end - - local section = diff_section() - if not section then - return false - end - - if line:match("^diff %-%-git ") then - open_section(r, section) - return true - end - if line:match("^%-%-%- ") then - load_side(r, section.blob_a, section.path_a) - return true - end - if line:match("^%+%+%+ ") then - load_side(r, section.blob_b, section.path_b) - return true - end - local prefix = line:sub(1, 1) - if prefix == "+" then - load_side(r, section.blob_b, section.path_b) - return true - elseif prefix == "-" then - load_side(r, section.blob_a, section.path_a) - return true - end - return false -end - -repo.on_uri_change(M.URI_PREFIX, refresh) - -return M diff --git a/lua/git/status_view.lua b/lua/git/status_view.lua deleted file mode 100644 index cf951a5..0000000 --- a/lua/git/status_view.lua +++ /dev/null @@ -1,740 +0,0 @@ -local Revision = require("git.core.revision") -local diffsplit = require("git.diffsplit") -local object = require("git.object") -local repo = require("git.core.repo") -local status = require("git.core.status") -local util = require("git.core.util") - -local M = {} - ----@type ow.Git.StatusView.Placement[] -M.PLACEMENTS = { "sidebar", "split", "current" } - ----@type ow.Git.Status.Section[] -local SECTIONS = { "untracked", "unstaged", "staged", "unmerged" } -local WINDOW_WIDTH = 50 - ----@param r ow.Git.Repo ----@return string -local function buf_name_for(r) - return r.worktree .. "/GitStatus" -end - ----@alias ow.Git.StatusView.Placement "sidebar"|"split"|"current" - ----@class ow.Git.StatusView.Header ----@field is_header true ----@field section ow.Git.Status.Section - ----@alias ow.Git.StatusView.Item ow.Git.Status.Row | ow.Git.StatusView.Header - ----@class ow.Git.StatusView.State ----@field repo ow.Git.Repo ----@field placement ow.Git.StatusView.Placement ----@field lines table ----@field win integer? ----@field unsubscribe fun()? - ----@type table -local state = {} - -local group = - vim.api.nvim_create_augroup("ow.git.status_win", { clear = true }) -local ns = vim.api.nvim_create_namespace("ow.git.status_win") - ----@return integer? win ----@return integer? bufnr -local function find_view() - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local buf = vim.api.nvim_win_get_buf(win) - if vim.bo[buf].filetype == "gitstatus" then - return win, buf - end - end -end - ----@param win integer? ----@return boolean -local function valid_in_current_tab(win) - if not win or not vim.api.nvim_win_is_valid(win) then - return false - end - return vim.api.nvim_win_get_tabpage(win) - == vim.api.nvim_get_current_tabpage() -end - ----@param s ow.Git.StatusView.State ----@return integer? -local function win_for(s) - if valid_in_current_tab(s.win) then - return s.win - end - local win = find_view() - s.win = win - return win -end - ----@param row ow.Git.Status.Row ----@return string line ----@return string hl_group ----@return integer hl_len -local function format_row(row) - local entry = row.entry - local orig - if entry.kind == "changed" then - ---@cast entry ow.Git.Status.ChangedEntry - orig = entry.orig - end - local label = orig and (orig .. " -> " .. entry.path) or entry.path - local mark = status.mark_for(entry, row.side) - return string.format(" %s %s", mark.char, label), mark.hl, #mark.char -end - ----@param section ow.Git.Status.Section ----@return string -local function display_name(section) - return (section:gsub("^%l", string.upper)) -end - ----@param bufnr integer ----@param r ow.Git.Repo -local function render(bufnr, r) - local status = r.status - local branch = status.branch - local lines = {} - local marks = {} - local meta = {} - - local function label(row, len) - table.insert(marks, { row = row, col = 0, end_col = len, hl = "Label" }) - end - - local repo_line = vim.fn.fnamemodify(r.worktree, ":t") - table.insert(lines, repo_line) - table.insert(marks, { - row = #lines - 1, - col = 0, - end_col = #repo_line, - hl = "Directory", - }) - - table.insert(lines, "Branch: " .. (branch.head or "?")) - label(#lines - 1, 6) - - if branch.upstream then - local up = "Upstream: " .. branch.upstream - local extras = {} - if branch.ahead > 0 then - local col = #up + 1 - up = up .. " +" .. branch.ahead - table.insert(extras, { - col = col, - end_col = #up, - hl = "GitUnpushed", - }) - end - if branch.behind > 0 then - local col = #up + 1 - up = up .. " -" .. branch.behind - table.insert(extras, { - col = col, - end_col = #up, - hl = "GitUnpulled", - }) - end - table.insert(lines, up) - local row = #lines - 1 - label(row, 8) - for _, e in ipairs(extras) do - e.row = row - table.insert(marks, e) - end - end - - table.insert(lines, "") - - for _, section in ipairs(SECTIONS) do - local rows = status:rows(section) - if #rows > 0 then - local name = display_name(section) - local header = string.format("%s (%d)", name, #rows) - table.insert(lines, header) - local header_row = #lines - 1 - meta[#lines] = { is_header = true, section = section } - label(header_row, #name) - table.insert(marks, { - row = header_row, - col = #name + 2, - end_col = #header - 1, - hl = "Number", - }) - for _, row in ipairs(rows) do - local line, hl, hl_len = format_row(row) - table.insert(lines, line) - meta[#lines] = row - table.insert(marks, { - row = #lines - 1, - col = 2, - end_col = 2 + hl_len, - hl = hl, - }) - end - table.insert(lines, "") - end - end - - util.set_buf_lines(bufnr, 0, -1, lines) - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - for _, m in ipairs(marks) do - vim.api.nvim_buf_set_extmark(bufnr, ns, m.row, m.col, { - end_col = m.end_col, - hl_group = m.hl, - }) - end - state[bufnr].lines = meta -end - ----@param bufnr integer -local function refresh(bufnr) - local s = state[bufnr] - if not s or not vim.api.nvim_buf_is_valid(bufnr) then - return - end - render(bufnr, s.repo) -end - ----@param bufnr integer ----@return ow.Git.StatusView.State? ----@return ow.Git.StatusView.Item? -local function current_entry(bufnr) - local s = state[bufnr] - if not s then - return nil, nil - end - local lnum = vim.api.nvim_win_get_cursor(0)[1] - return s, s.lines[lnum] -end - ----@class ow.Git.StatusView.Pane ----@field buf integer ----@field name string? - ----@param r ow.Git.Repo ----@param path string ----@return ow.Git.StatusView.Pane -local function head_pane(r, path) - local rev = Revision.new({ base = "HEAD", path = path }) - return { - buf = object.buf_for(r, rev), - name = object.format_uri(rev), - } -end - ----@param r ow.Git.Repo ----@param path string ----@return ow.Git.StatusView.Pane -local function worktree_pane(r, path) - local buf = vim.fn.bufadd(vim.fs.joinpath(r.worktree, path)) - vim.fn.bufload(buf) - return { buf = buf, name = nil } -end - ----@param s ow.Git.StatusView.State ----@param path string ----@return ow.Git.StatusView.Pane -local function index_pane(s, path) - local rev = Revision.new({ stage = 0, path = path }) - return { - buf = object.buf_for(s.repo, rev), - name = object.format_uri(rev), - } -end - ----@param s ow.Git.StatusView.State ----@param row ow.Git.Status.Row ----@return ow.Git.StatusView.Pane? -local function older_pane(s, row) - local entry = row.entry - if row.section == "staged" then - ---@cast entry ow.Git.Status.ChangedEntry - if entry.staged == "added" then - return nil - end - return head_pane(s.repo, entry.orig or entry.path) - end - if row.section == "unstaged" then - return index_pane(s, entry.path) - end - return nil -end - ----@param s ow.Git.StatusView.State ----@param row ow.Git.Status.Row ----@return ow.Git.StatusView.Pane? -local function newer_pane(s, row) - local entry = row.entry - if row.section == "staged" then - ---@cast entry ow.Git.Status.ChangedEntry - if entry.staged == "deleted" then - return nil - end - return index_pane(s, entry.path) - end - if row.section == "unstaged" then - ---@cast entry ow.Git.Status.ChangedEntry - if entry.unstaged == "deleted" then - return nil - end - return worktree_pane(s.repo, entry.path) - end - if row.section == "untracked" then - return worktree_pane(s.repo, entry.path) - end - return nil -end - ----@param target_win integer ----@param dir "left"|"right" ----@return integer -local function vsplit_at(target_win, dir) - local win = vim.api.nvim_open_win( - vim.api.nvim_win_get_buf(target_win), - true, - { split = dir, win = target_win } - ) - vim.api.nvim_win_call(win, function() - vim.cmd("setlocal winfixwidth<") - end) - vim.cmd.clearjumps() - return win -end - ----@param status_win integer ----@return integer? -local function previous_target_win(status_win) - local n = vim.fn.winnr("#") - if n == 0 then - return nil - end - local win = vim.fn.win_getid(n) - if win == 0 or win == status_win or not valid_in_current_tab(win) then - return nil - end - local cfg = vim.api.nvim_win_get_config(win) - if cfg.relative and cfg.relative ~= "" then - return nil - end - return win -end - ----@param status_win integer ----@param keep integer -local function close_other_diff_wins(status_win, keep) - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if win ~= status_win and win ~= keep and vim.wo[win].diff then - pcall(vim.api.nvim_win_close, win, false) - end - end -end - ----@param s ow.Git.StatusView.State ----@param row ow.Git.Status.Row ----@param focus_left boolean -local function view_row(s, row, focus_left) - local status_win = win_for(s) - if not status_win then - return - end - - local left = older_pane(s, row) - local right = newer_pane(s, row) - if not left and not right then - util.warning( - "no content for %s row: %s", - row.section, - row.entry.path - ) - return - end - - if s.placement ~= "sidebar" then - local pane = right or left - ---@cast pane -nil - vim.cmd.normal({ "m'", bang = true }) - vim.api.nvim_win_set_buf(status_win, pane.buf) - if pane.name then - util.set_buf_name(pane.buf, pane.name) - end - return - end - - local target = previous_target_win(status_win) - if not target then - target = vsplit_at(status_win, "right") - end - close_other_diff_wins(status_win, target) - vim.api.nvim_win_set_width(status_win, WINDOW_WIDTH) - vim.api.nvim_win_call(target, function() - vim.cmd.diffoff() - end) - - if not (left and right) then - local side = right or left - ---@cast side ow.Git.StatusView.Pane - vim.api.nvim_win_set_buf(target, side.buf) - if side.name then - util.set_buf_name(side.buf, side.name) - end - vim.api.nvim_set_current_win(focus_left and target or status_win) - return - end - ---@cast left ow.Git.StatusView.Pane - ---@cast right ow.Git.StatusView.Pane - - vim.api.nvim_win_set_buf(target, right.buf) - if right.name then - util.set_buf_name(right.buf, right.name) - end - - local older = left.name or vim.api.nvim_buf_get_name(left.buf) - local left_win - vim.api.nvim_win_call(target, function() - diffsplit.open({ - target = older, - mods = { vertical = true }, - }) - left_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_cursor(left_win, { 1, 0 }) - vim.api.nvim_win_set_cursor(target, { 1, 0 }) - end) - ---@cast left_win -nil - vim.api.nvim_set_current_win(focus_left and left_win or status_win) -end - ----@param focus_left boolean -local function preview_or_open(focus_left) - local s, item = current_entry(vim.api.nvim_get_current_buf()) - if not s or not item or item.is_header then - return - end - ---@cast item ow.Git.Status.Row - view_row(s, item, focus_left) -end - -local function action_stage() - local s, item = current_entry(vim.api.nvim_get_current_buf()) - if not s or not item then - return - end - local paths = {} - if item.is_header then - if item.section == "staged" or item.section == "ignored" then - return - end - for _, row in ipairs(s.repo.status:rows(item.section)) do - table.insert(paths, row.entry.path) - end - else - ---@cast item ow.Git.Status.Row - if item.section == "staged" then - return - end - table.insert(paths, item.entry.path) - end - if #paths == 0 then - return - end - local args = { "add", "--" } - vim.list_extend(args, paths) - util.git(args, { - cwd = s.repo.worktree, - on_exit = function(result) - if result.code ~= 0 then - util.error("git add failed: %s", vim.trim(result.stderr or "")) - end - end, - }) -end - -local function action_unstage() - local s, item = current_entry(vim.api.nvim_get_current_buf()) - if not s or not item then - return - end - local rows - if item.is_header then - if item.section ~= "staged" then - return - end - rows = s.repo.status:rows("staged") - else - ---@cast item ow.Git.Status.Row - if item.section ~= "staged" then - return - end - rows = { item } - end - ---@cast rows ow.Git.Status.Row[] - if #rows == 0 then - return - end - local args = { "restore", "--staged", "--" } - for _, row in ipairs(rows) do - local entry = row.entry - if entry.kind == "changed" then - ---@cast entry ow.Git.Status.ChangedEntry - if entry.orig then - table.insert(args, entry.orig) - end - end - table.insert(args, entry.path) - end - util.git(args, { - cwd = s.repo.worktree, - on_exit = function(result) - if result.code ~= 0 then - util.error( - "git restore --staged failed: %s", - vim.trim(result.stderr or "") - ) - end - end, - }) -end - -local function action_discard() - local s, item = current_entry(vim.api.nvim_get_current_buf()) - if not s or not item or item.is_header then - return - end - ---@cast item ow.Git.Status.Row - if item.section == "staged" then - util.warning("file has staged changes, unstage first with 'u'") - return - end - local entry = item.entry - local path = entry.path - - local prompt, action - if item.section == "untracked" then - local is_dir = path:sub(-1) == "/" - prompt = string.format( - "Delete untracked %s %s?", - is_dir and "directory" or "file", - path - ) - action = function() - local target = vim.fs.joinpath(s.repo.worktree, path) - local rc = vim.fn.delete(target, is_dir and "rf" or "") - if rc ~= 0 then - util.error("failed to delete %s", path) - end - refresh(vim.api.nvim_get_current_buf()) - end - elseif item.section == "unstaged" then - prompt = string.format("Discard changes to %s?", path) - action = function() - util.git({ "checkout", "--", path }, { - cwd = s.repo.worktree, - on_exit = function(result) - if result.code ~= 0 then - util.error( - "git checkout failed: %s", - vim.trim(result.stderr or "") - ) - end - end, - }) - end - else - return - end - - if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then - action() - end -end - ----@param placement ow.Git.StatusView.Placement -local function action_help(placement) - local lines = { "git status view" } - if placement == "sidebar" then - table.insert(lines, " preview diff (keep focus)") - table.insert(lines, " open diff (focus left pane)") - table.insert(lines, " <2-LeftMouse> open diff (focus left pane)") - else - table.insert(lines, " 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("", 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", "", function() - preview_or_open(false) - end, { buffer = bufnr, silent = true, desc = "Preview diff" }) - else - pcall(vim.keymap.del, "n", "", { buffer = bufnr }) - end -end - ----@class ow.Git.StatusView.OpenOpts ----@field placement ow.Git.StatusView.Placement? - ----@param opts? ow.Git.StatusView.OpenOpts -function M.open(opts) - opts = opts or {} - local placement = opts.placement or "sidebar" - if not vim.tbl_contains(M.PLACEMENTS, placement) then - util.error( - "invalid placement: %s (expected one of %s)", - placement, - table.concat(M.PLACEMENTS, ", ") - ) - return - end - local r = repo.resolve() - if not r then - util.error("not in a git repository") - return - end - - local previous_win = vim.api.nvim_get_current_win() - local buf = vim.fn.bufadd(buf_name_for(r)) - - local visible = vim.fn.bufwinid(buf) - if visible ~= -1 then - vim.api.nvim_set_current_win(visible) - r:refresh() - return - end - - if not state[buf] then - vim.fn.bufload(buf) - repo.bind(buf, r) - util.setup_scratch(buf, {}) - vim.bo[buf].filetype = "gitstatus" - setup_buffer(buf, r, placement) - end - vim.bo[buf].bufhidden = placement == "sidebar" and "wipe" or "hide" - - local win = place(buf, placement) - state[buf].win = win - state[buf].placement = placement - set_keymaps(buf, placement) - - if placement == "sidebar" then - vim.api.nvim_set_current_win(previous_win) - end - - refresh(buf) - r:refresh() -end - ----@param opts? ow.Git.StatusView.OpenOpts -function M.toggle(opts) - local existing = find_view() - if existing then - vim.api.nvim_win_close(existing, false) - return - end - M.open(opts) -end - -return M diff --git a/lua/git/statusline.lua b/lua/git/statusline.lua deleted file mode 100644 index d779c53..0000000 --- a/lua/git/statusline.lua +++ /dev/null @@ -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 diff --git a/nvim-pack-lock.json b/nvim-pack-lock.json index d439fda..054a023 100644 --- a/nvim-pack-lock.json +++ b/nvim-pack-lock.json @@ -13,6 +13,10 @@ "rev": "17e3507b788699776ba6d2f8dd101ec177f37a96", "src": "https://github.com/ibhagwan/fzf-lua" }, + "git.nvim": { + "rev": "914aa493fffe5c7d0e1312b655a581032624d624", + "src": "https://git.owall.dev/warg/git.nvim" + }, "gitsigns.nvim": { "rev": "dd3f588bacbeb041be6facf1742e42097f62165d", "src": "https://github.com/lewis6991/gitsigns.nvim" diff --git a/plugin/git.lua b/plugin/git.lua deleted file mode 100644 index e393909..0000000 --- a/plugin/git.lua +++ /dev/null @@ -1,339 +0,0 @@ -if vim.g.loaded_git then - return -end -vim.g.loaded_git = 1 - -local DEFAULT_HIGHLIGHTS = { - GitAuthor = "String", - GitDate = "Number", - 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", - - GitHunkAdded = "Added", - GitHunkChanged = "Changed", - GitHunkRemoved = "Removed", - GitHunkAddLine = "DiffAdd", - GitHunkDeleteLine = "DiffDelete", - - GitBlameAuthor = "GitAuthor", - GitBlameDate = "GitDate", - GitBlameSha = "GitSha", -} -local STAGED_HUNK_HL = { - GitHunkStagedAdded = "GitHunkAdded", - GitHunkStagedChanged = "GitHunkChanged", - GitHunkStagedRemoved = "GitHunkRemoved", -} - -local function blend(a, b, t) - local function mix(shift) - local x = bit.band(bit.rshift(a, shift), 0xff) - local y = bit.band(bit.rshift(b, shift), 0xff) - return bit.lshift(math.floor(x + (y - x) * t + 0.5), shift) - end - return mix(16) + mix(8) + mix(0) -end - -local function apply_highlights() - for name, link in pairs(DEFAULT_HIGHLIGHTS) do - vim.api.nvim_set_hl(0, name, { link = link, default = true }) - end - local bg = vim.api.nvim_get_hl(0, { name = "Normal" }).bg or 0x000000 - for name, base in pairs(STAGED_HUNK_HL) do - local src = vim.api.nvim_get_hl(0, { name = base, link = false }) - local hl = {} - if src.fg then - hl.fg = blend(src.fg, bg, 0.45) - end - if src.bg then - hl.bg = blend(src.bg, bg, 0.45) - end - vim.api.nvim_set_hl(0, name, hl) - end -end -apply_highlights() - -local group = vim.api.nvim_create_augroup("ow.git", { clear = true }) - -vim.api.nvim_create_autocmd("ColorScheme", { - group = group, - callback = apply_highlights, -}) - -vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, { - group = group, - callback = function(args) - require("git.core.repo").track(args.buf) - require("git.hunks").attach(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.hunks").detach(args.buf) - require("git.blame").detach(args.buf) - 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_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.diffsplit").open({ 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 ()", -}) - -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", "(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", "(git-diffsplit-vertical)", function() - require("git.diffsplit").open({ mods = { vertical = true } }) -end, { silent = true, desc = "Open a diff split against index (vertical)" }) -vim.keymap.set("n", "(git-diffsplit-horizontal)", function() - require("git.diffsplit").open({ mods = { vertical = false } }) -end, { silent = true, desc = "Open a diff split against index (horizontal)" }) -vim.keymap.set("n", "(git-diffsplit-vertical-head)", function() - require("git.diffsplit").open({ - target = "HEAD", - mods = { vertical = true }, - }) -end, { silent = true, desc = "Open a diff split against HEAD (vertical)" }) -vim.keymap.set("n", "(git-diffsplit-horizontal-head)", function() - require("git.diffsplit").open({ - target = "HEAD", - mods = { vertical = false }, - }) -end, { silent = true, desc = "Open a diff split against HEAD (horizontal)" }) - -vim.keymap.set("n", "(git-status-open)", function() - require("git.status_view").open() -end, { silent = true, desc = "Open git status sidebar" }) -vim.keymap.set("n", "(git-status-toggle)", function() - require("git.status_view").toggle() -end, { silent = true, desc = "Toggle git status sidebar" }) - -vim.keymap.set("n", "(git-log)", function() - require("git.log_view").open({ max_count = 1000 }) -end, { silent = true, desc = "Open git log" }) - -vim.keymap.set("n", "(git-commit)", function() - require("git.commit").commit() -end, { silent = true, desc = "Start a git commit" }) -vim.keymap.set("n", "(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 - -vim.keymap.set({ "n", "x" }, "(git-hunk-next)", function() - require("git.hunks").nav("next") -end, { silent = true, desc = "Jump to next git hunk" }) -vim.keymap.set({ "n", "x" }, "(git-hunk-prev)", function() - require("git.hunks").nav("prev") -end, { silent = true, desc = "Jump to previous git hunk" }) -vim.keymap.set("n", "(git-hunk-stage-toggle)", function() - require("git.hunks").toggle_stage() -end, { silent = true, desc = "Stage or unstage the hunk under cursor" }) -vim.keymap.set("n", "(git-hunk-reset)", function() - require("git.hunks").reset_hunk() -end, { silent = true, desc = "Reset hunk under cursor" }) -vim.keymap.set("n", "(git-hunk-preview)", function() - require("git.hunks").preview_hunk() -end, { silent = true, desc = "Preview hunk under cursor" }) -vim.keymap.set("n", "(git-hunk-select)", function() - require("git.hunks").select_hunk() -end, { silent = true, desc = "Select hunk under cursor" }) -vim.keymap.set("n", "(git-diff-overlay)", function() - require("git.hunks").toggle_overlay() -end, { silent = true, desc = "Toggle the git diff overlay" }) - -vim.api.nvim_create_user_command("GitDiffOverlay", function() - require("git.hunks").toggle_overlay() -end, { desc = "Toggle the git diff overlay in the current buffer" }) - -vim.keymap.set("n", "(git-blame-popup)", function() - require("git.blame").line_popup() -end, { silent = true, desc = "Show git blame for the current line" }) -vim.keymap.set("n", "(git-blame-commit)", function() - require("git.blame").open_commit() -end, { silent = true, desc = "Open the commit that last touched this line" }) -vim.keymap.set("n", "(git-blame-file)", function() - require("git.blame").open_file() -end, { silent = true, desc = "Open this file at the line's commit" }) -vim.keymap.set("n", "(git-blame-file-parent)", function() - require("git.blame").open_file_parent() -end, { - silent = true, - desc = "Open this file at the parent of the line's commit", -}) - diff --git a/test/git/blame_test.lua b/test/git/blame_test.lua deleted file mode 100644 index 598b8d5..0000000 --- a/test/git/blame_test.lua +++ /dev/null @@ -1,312 +0,0 @@ -local blame = require("git.blame") -local h = require("test.git.helpers") -local t = require("test") - ----@param sha string ----@return boolean -local function is_zero(sha) - return sha:match("^0+$") ~= nil -end - ----@param committed string ----@param worktree string? ----@param file string? ----@return string dir ----@return integer buf -local function setup(committed, worktree, file) - file = file or "a.txt" - local dir = h.make_repo({ [file] = committed }) - if worktree then - t.write(dir, file, worktree) - end - vim.cmd.edit(dir .. "/" .. file) - return dir, vim.api.nvim_get_current_buf() -end - ----@return integer? -local function find_float() - for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_get_config(w).relative ~= "" then - return w - end - end -end - ----@return integer float -local function wait_float() - local float ---@type integer? - t.wait_for(function() - float = find_float() - return float ~= nil - end, "blame popup float to open") - local found = assert(float) - return found -end - ----@param pat string -local function wait_buf_name(pat) - t.wait_for(function() - return vim.api.nvim_buf_get_name(0):match(pat) ~= nil - end, "current buffer name to match " .. pat) -end - ----@param buf integer ----@return ow.Git.Blame.BufState -local function populate_blame(buf) - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - local tick = vim.api.nvim_buf_get_changedtick(buf) - blame.line_popup(buf) - t.wait_for(function() - local s = blame.state(buf) - return s ~= nil and s.tick == tick - end, "blame to populate the buffer state") - for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_get_config(w).relative ~= "" then - pcall(vim.api.nvim_win_close, w, true) - end - end - return (assert(blame.state(buf))) -end - -t.test("line popup formats the datetime in the author timezone", function() - local _, buf = setup("alpha\nbeta\n") - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - blame.line_popup(buf) - local float = wait_float() - t.defer(function() - pcall(vim.api.nvim_win_close, float, true) - end) - local lines = vim.api.nvim_buf_get_lines( - vim.api.nvim_win_get_buf(float), - 0, - -1, - false - ) - t.truthy( - (lines[1] or ""):match( - "%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d [+%-]%d%d%d%d$" - ), - "the head line ends with an ISO datetime and numeric timezone" - ) -end) - -t.test("porcelain parse of a committed file", function() - local _, buf = setup("alpha\nbeta\ngamma\n") - local state = populate_blame(buf) - t.eq(vim.tbl_count(state.commits), 1, "one commit") - local sha = state.line_sha[1] - t.eq(state.line_sha[2], sha, "line 2 shares the commit") - t.eq(state.line_sha[3], sha, "line 3 shares the commit") - local commit = state.commits[sha] - t.eq(commit.author, "t", "author parsed from the porcelain") - t.eq(commit.summary, "init", "summary parsed from the porcelain") - t.truthy(#sha >= 40, "the full sha is recorded") - t.truthy(commit.author_time > 0, "author time parsed") -end) - -t.test("multiple line groups reuse one commit entry", function() - local dir = h.make_repo({ ["a.txt"] = "a\nb\nc\n" }) - t.write(dir, "a.txt", "a\nB\nc\n") - h.git(dir, "add", "a.txt") - h.git(dir, "commit", "-q", "-m", "change middle") - vim.cmd.edit(dir .. "/a.txt") - local buf = vim.api.nvim_get_current_buf() - local state = populate_blame(buf) - t.eq(vim.tbl_count(state.commits), 2, "two distinct commits") - t.eq( - state.line_sha[1], - state.line_sha[3], - "lines 1 and 3 share the original commit" - ) - t.truthy( - state.line_sha[1] ~= state.line_sha[2], - "line 2 is a different commit" - ) -end) - -t.test("an edited line blames as the zero sha", function() - local _, buf = setup("a\nb\nc\n") - local state = populate_blame(buf) - t.falsy(is_zero(state.line_sha[2]), "line 2 starts committed") - vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "EDITED" }) - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - local refreshed = populate_blame(buf) - t.truthy( - is_zero(refreshed.line_sha[2]), - "the edited line blames as uncommitted on the next fetch" - ) -end) - -t.test("blame picks up a commit amend on the next fetch", function() - local dir, buf = setup("original\n") - local state = populate_blame(buf) - local sha1 = state.line_sha[1] - h.git(dir, "commit", "--amend", "-m", "amended") - local sha2 = h.git(dir, "rev-parse", "HEAD").stdout - t.truthy(sha1 ~= sha2, "the amend produced a new commit") - t.wait_for(function() - return assert(blame.state(buf)).tick == nil - end, "the cache to be invalidated by the repo change") - local refreshed = populate_blame(buf) - t.eq(refreshed.line_sha[1], sha2, "blame picks up the amended sha") -end) - -t.test("an untracked file blames every line as uncommitted", function() - local dir = h.make_repo({ ["tracked.txt"] = "x\n" }) - t.write(dir, "new.txt", "one\ntwo\nthree\n") - vim.cmd.edit(dir .. "/new.txt") - local buf = vim.api.nvim_get_current_buf() - local state = populate_blame(buf) - for i = 1, 3 do - t.truthy(is_zero(state.line_sha[i]), "line " .. i .. " is uncommitted") - end - t.eq(vim.tbl_count(state.commits), 1, "one synthesized commit") -end) - -t.test("blame actions are no-ops off a worktree", function() - local buf = vim.api.nvim_create_buf(true, false) - t.defer(function() - pcall(vim.api.nvim_buf_delete, buf, { force = true }) - end) - t.quietly(function() - blame.line_popup(buf) - end) - t.eq(blame.state(buf), nil, "no state created for a non-worktree buffer") -end) - -t.test("line popup shows the commit for the cursor line", function() - local dir, buf = setup("alpha\nbeta\ngamma\n") - local sha = h.git(dir, "rev-parse", "HEAD").stdout - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - blame.line_popup(buf) - local float = wait_float() - t.defer(function() - pcall(vim.api.nvim_win_close, float, true) - end) - local lines = vim.api.nvim_buf_get_lines( - vim.api.nvim_win_get_buf(float), - 0, - -1, - false - ) - t.truthy( - vim.startswith(lines[1] or "", sha:sub(1, 8)), - "first line starts with the short sha" - ) - t.truthy((lines[1] or ""):find("t", 1, true), "author shown") -end) - -t.test("re-invoking the line popup focuses the open float", function() - local _, buf = setup("alpha\nbeta\n") - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - blame.line_popup(buf) - local float = wait_float() - t.defer(function() - pcall(vim.api.nvim_win_close, float, true) - end) - t.truthy( - vim.api.nvim_get_current_win() ~= float, - "the float opens unfocused" - ) - blame.line_popup(buf) - t.eq( - vim.api.nvim_get_current_win(), - float, - "re-invoking focuses the existing float" - ) -end) - -t.test("line popup works in a git:// object buffer", function() - local dir = h.make_repo({ ["a.txt"] = "alpha\nbeta\ngamma\n" }) - local sha = h.git(dir, "rev-parse", "HEAD").stdout - local r = assert(require("git.core.repo").resolve(dir)) - local gbuf = require("git.object").buf_for( - r, - require("git.core.revision").new({ base = sha, path = "a.txt" }) - ) - vim.api.nvim_set_current_buf(gbuf) - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - blame.line_popup(gbuf) - local float = wait_float() - t.defer(function() - pcall(vim.api.nvim_win_close, float, true) - end) - local lines = vim.api.nvim_buf_get_lines( - vim.api.nvim_win_get_buf(float), - 0, - -1, - false - ) - t.truthy( - vim.startswith(lines[1] or "", sha:sub(1, 8)), - "the popup blames the commit even in a git:// buffer" - ) -end) - -t.test("open_commit opens the commit that last touched the line", function() - local _, buf = setup("alpha\nbeta\ngamma\n") - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - blame.open_commit() - wait_buf_name("^git://%x+$") -end) - -t.test("open_file opens the file at the line's commit", function() - local _, buf = setup("alpha\nbeta\ngamma\n") - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - blame.open_file() - wait_buf_name("^git://%x+:a%.txt$") -end) - -t.test("open_file_parent opens the file at the parent commit", function() - local dir = h.make_repo({ ["a.txt"] = "a\nb\nc\n" }) - local root = h.git(dir, "rev-parse", "HEAD").stdout - t.write(dir, "a.txt", "a\nB\nc\n") - h.git(dir, "add", "a.txt") - h.git(dir, "commit", "-q", "-m", "change middle") - vim.cmd.edit(dir .. "/a.txt") - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - blame.open_file_parent() - t.wait_for(function() - return vim.api.nvim_buf_get_name(0) == "git://" .. root .. ":a.txt" - end, "the file at the parent commit to open") -end) - -t.test("the drill actions refuse an uncommitted line", function() - local _, buf = setup("a\nb\nc\n") - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "EDITED" }) - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - t.quietly(function() - blame.open_commit() - vim.wait(200) - end) - t.eq( - vim.api.nvim_get_current_buf(), - buf, - "no commit opened for an uncommitted line" - ) -end) - -t.test("drilling chains through git:// buffers", function() - local _, buf = setup("alpha\nbeta\ngamma\n") - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - blame.open_file() - wait_buf_name("^git://%x+:a%.txt$") - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - blame.open_commit() - wait_buf_name("^git://%x+$") -end) - -t.test("detach drops the blame state", function() - local _, buf = setup("a\nb\nc\n") - populate_blame(buf) - blame.detach(buf) - t.eq(blame.state(buf), nil, "state dropped on detach") -end) diff --git a/test/git/cmd_test.lua b/test/git/cmd_test.lua deleted file mode 100644 index 4474588..0000000 --- a/test/git/cmd_test.lua +++ /dev/null @@ -1,668 +0,0 @@ -local cmd = require("git.cmd") -local h = require("test.git.helpers") -local t = require("test") - ----@param files table? ----@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 : 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 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("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 back to the log. - vim.api.nvim_win_set_cursor(log_win, { 1, 0 }) - t.press("") - t.truthy(vim.api.nvim_buf_get_name(0):match("^git://")) - - t.press("") - 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(" 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 " 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) - - -- 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("") - 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(), - " must work after returning to the buffer" - ) -end) - -t.test(":G diff 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 -) diff --git a/test/git/helpers.lua b/test/git/helpers.lua deleted file mode 100644 index e916502..0000000 --- a/test/git/helpers.lua +++ /dev/null @@ -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? ----@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 diff --git a/test/git/hunks_test.lua b/test/git/hunks_test.lua deleted file mode 100644 index e3d71e8..0000000 --- a/test/git/hunks_test.lua +++ /dev/null @@ -1,634 +0,0 @@ -local h = require("test.git.helpers") -local hunks = require("git.hunks") -local t = require("test") - ----@param committed string ----@param worktree string ----@param file string? ----@return string dir ----@return integer buf ----@return ow.Git.Hunks.BufState state -local function setup(committed, worktree, file) - file = file or "a.txt" - local dir = h.make_repo({ [file] = committed }) - t.write(dir, file, worktree) - vim.cmd.edit(dir .. "/" .. file) - local buf = vim.api.nvim_get_current_buf() - hunks.attach(buf) - hunks._flush(buf) - t.wait_for(function() - local s = hunks.state(buf) - return s ~= nil and s.index ~= nil and s.head ~= nil - end, "hunks to load the index and HEAD snapshots") - local state = assert(hunks.state(buf), "buffer state should exist") - return dir, buf, state -end - ----@param buf integer ----@return { row: integer, sign: string, hl: string }[] -local function sign_marks(buf) - local ns = vim.api.nvim_get_namespaces()["ow.git.hunks"] - local out = {} - for _, m in ipairs(vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { - details = true, - })) do - local d = assert(m[4]) - table.insert(out, { - row = m[2], - sign = vim.trim(d.sign_text or ""), - hl = d.sign_hl_group, - }) - end - table.sort(out, function(a, b) - return a.row < b.row - end) - return out -end - ----@param buf integer ----@param ns_name string ----@return vim.api.keyset.get_extmark_item[] -local function detailed_marks(buf, ns_name) - local ns = vim.api.nvim_get_namespaces()[ns_name] - return vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) -end - ----@return integer? -local function find_float() - for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_get_config(w).relative ~= "" then - return w - end - end -end - -t.test("pure add: hunk shape and add signs", function() - local _, buf, state = setup("a\nd\n", "a\nb\nc\nd\n") - t.eq(#state.hunks, 1, "one hunk for a pure addition") - local hk = assert(state.hunks[1]) - t.eq(hk.type, "add") - t.eq(hk.new_start, 2) - t.eq(hk.new_count, 2) - t.eq(sign_marks(buf), { - { row = 1, sign = "┃", hl = "GitHunkAdded" }, - { row = 2, sign = "┃", hl = "GitHunkAdded" }, - }) -end) - -t.test("pure delete (middle): hunk shape and delete sign", function() - local _, buf, state = setup("a\nb\nc\n", "a\nc\n") - t.eq(#state.hunks, 1) - local hk = assert(state.hunks[1]) - t.eq(hk.type, "delete") - t.eq(hk.new_count, 0) - t.eq(hk.old_lines, { "b" }) - t.eq(sign_marks(buf), { - { row = 0, sign = "▁", hl = "GitHunkRemoved" }, - }) -end) - -t.test("top-of-file delete: sign anchors on line 1", function() - local _, buf, state = setup("a\nb\nc\n", "b\nc\n") - t.eq(#state.hunks, 1) - local hk = assert(state.hunks[1]) - t.eq(hk.type, "delete") - t.eq(hk.new_start, 0) - t.eq(hk.old_lines, { "a" }) - t.eq(sign_marks(buf), { - { row = 0, sign = "▁", hl = "GitHunkRemoved" }, - }) -end) - -t.test("change of N lines: hunk shape and change signs", function() - local _, buf, state = setup("a\nb\nc\nd\n", "a\nB\nC\nd\n") - t.eq(#state.hunks, 1) - local hk = assert(state.hunks[1]) - t.eq(hk.type, "change") - t.eq(hk.old_start, 2) - t.eq(hk.old_count, 2) - t.eq(hk.new_start, 2) - t.eq(hk.new_count, 2) - t.eq(hk.old_lines, { "b", "c" }) - t.eq(hk.new_lines, { "B", "C" }) - t.eq(sign_marks(buf), { - { row = 1, sign = "┃", hl = "GitHunkChanged" }, - { row = 2, sign = "┃", hl = "GitHunkChanged" }, - }) -end) - -t.test("multi-hunk file: two separate change hunks", function() - local _, buf, state = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\nE\n") - t.eq(#state.hunks, 2, "two hunks for two disjoint changes") - t.eq(sign_marks(buf), { - { row = 0, sign = "┃", hl = "GitHunkChanged" }, - { row = 4, sign = "┃", hl = "GitHunkChanged" }, - }) -end) - -t.test("clean file produces no hunks or signs", function() - local _, buf, state = setup("a\nb\nc\n", "a\nb\nc\n") - t.eq(#state.hunks, 0) - t.eq(sign_marks(buf), {}) -end) - -t.test("editing the buffer refreshes signs", function() - local _, buf, state = setup("a\nb\nc\n", "a\nb\nc\n") - t.eq(#state.hunks, 0) - vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "CHANGED" }) - vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf }) - hunks._flush(buf) - t.wait_for(function() - local s = assert(hunks.state(buf)) - return #s.hunks == 1 - end, "hunks to pick up the in-buffer edit") - local hk = assert(assert(hunks.state(buf)).hunks[1]) - t.eq(hk.type, "change") -end) - -t.test("overlay: change hunk shows deletion and addition", function() - local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") - hunks.toggle_overlay(buf) - ---@type integer? - local add_row - ---@type vim.api.keyset.extmark_details? - local add_d - ---@type vim.api.keyset.extmark_details? - local virt_d - for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do - local d = assert(m[4]) - if d.line_hl_group then - add_row, add_d = m[2], d - elseif d.virt_lines then - virt_d = d - end - end - add_d = assert(add_d, "the added line should get a line highlight") - t.eq(add_row, 1, "addition highlighted on the changed line") - t.eq(add_d.line_hl_group, "GitHunkAddLine") - virt_d = assert(virt_d, "the deletion should render as virtual lines") - local piece = assert(assert(assert(virt_d.virt_lines)[1])[1]) - t.truthy(vim.startswith(piece[1], "b"), "deleted line shows the old content") - t.eq(piece[2], "GitHunkDeleteLine") -end) - -t.test("overlay: delete hunk shows only deletion lines", function() - local _, buf = setup("a\nb\nc\n", "a\nc\n") - hunks.toggle_overlay(buf) - local marks = detailed_marks(buf, "ow.git.hunks.overlay") - t.eq(#marks, 1, "a pure delete has no addition highlight") - local d = assert(assert(marks[1])[4]) - local piece = assert(assert(assert(d.virt_lines)[1])[1]) - t.truthy(vim.startswith(piece[1], "b")) - t.eq(piece[2], "GitHunkDeleteLine") -end) - -t.test("overlay: add hunk highlights the added lines", function() - local _, buf = setup("a\nd\n", "a\nb\nc\nd\n") - hunks.toggle_overlay(buf) - local rows = {} - for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do - local d = assert(m[4]) - t.falsy(d.virt_lines, "a pure add has no deletion virtual lines") - t.eq(d.line_hl_group, "GitHunkAddLine") - table.insert(rows, m[2]) - end - table.sort(rows) - t.eq(rows, { 1, 2 }, "both added lines highlighted") -end) - -t.test("overlay: deleted lines are treesitter-highlighted", function() - local _, buf = setup( - "-- a note\nlocal x = 1\nlocal y = 2\n", - "local y = 2\n", - "a.lua" - ) - t.truthy( - pcall(vim.treesitter.start, buf, "lua"), - "the lua parser should be available" - ) - hunks.toggle_overlay(buf) - ---@type table[]? - local virt - for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do - local d = assert(m[4]) - if d.virt_lines then - virt = d.virt_lines - end - end - virt = assert(virt, "a deletion virtual line should render") - ---@type table - local seen = {} - for _, line in ipairs(virt) do - for _, c in ipairs(line) do - local hl = c[2] - if - type(hl) == "table" - and hl[1] == "GitHunkDeleteLine" - and hl[2] - then - seen[hl[2]] = true - end - end - end - t.truthy(seen["@comment"], "the deleted comment keeps its @comment group") - t.truthy(seen["@keyword"], "deleted code keeps its syntax groups") -end) - -t.test("overlay: toggling swaps gutter signs for the overlay", function() - local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") - t.truthy( - #detailed_marks(buf, "ow.git.hunks") > 0, - "gutter signs present while the overlay is off" - ) - t.eq(#detailed_marks(buf, "ow.git.hunks.overlay"), 0) - - hunks.toggle_overlay(buf) - t.truthy( - #detailed_marks(buf, "ow.git.hunks.overlay") > 0, - "overlay present once it is on" - ) - t.eq( - #detailed_marks(buf, "ow.git.hunks"), - 0, - "gutter signs replaced while the overlay is on" - ) - - hunks.toggle_overlay(buf) - t.eq(#detailed_marks(buf, "ow.git.hunks.overlay"), 0) - t.truthy( - #detailed_marks(buf, "ow.git.hunks") > 0, - "gutter signs restored after toggling the overlay off" - ) -end) - -t.test("toggle_stage stages the change into the index", function() - local dir, buf = setup("a\nb\nc\n", "a\nB\nc\n") - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return h.git(dir, "diff", "--cached", "--name-only").stdout ~= "" - end, "stage to land in the index") - t.eq(h.git(dir, "diff", "--cached", "--name-only").stdout, "a.txt") - t.eq( - h.git(dir, "show", ":0:a.txt").stdout, - "a\nB\nc", - "index blob reflects the staged change" - ) -end) - -t.test("toggle_stage stages a pure addition", function() - local dir, buf = setup("a\nb\n", "a\nb\nc\n") - vim.api.nvim_win_set_cursor(0, { 3, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return h.git(dir, "diff", "--cached", "--name-only").stdout ~= "" - end, "stage to land in the index") - t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nb\nc") -end) - -t.test("toggle_stage stages a deletion", function() - local dir, buf = setup("a\nb\nc\n", "a\nc\n") - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return h.git(dir, "diff", "--cached", "--name-only").stdout ~= "" - end, "stage to land in the index") - t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nc") -end) - -t.test("toggle_stage stages only the hunk under the cursor", function() - local committed = table.concat({ - "local M = {}", - "", - "function M.first()", - " return 1", - "end", - "", - "function M.last()", - " return 9", - "end", - "", - "return M", - }, "\n") .. "\n" - local worktree = table.concat({ - "local M = {}", - "", - "-- helpers", - "function M.first()", - " return 1", - "end", - "", - "function M.mid()", - " return 5", - "end", - "", - "function M.last()", - " return 9", - "end", - "", - "return M", - }, "\n") .. "\n" - local dir, buf = setup(committed, worktree) - vim.api.nvim_win_set_cursor(0, { 9, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return h.git(dir, "diff", "--cached", "--name-only").stdout ~= "" - end, "the mid hunk to land in the index") - t.eq( - h.git(dir, "show", ":0:a.txt").stdout, - table.concat({ - "local M = {}", - "", - "function M.first()", - " return 1", - "end", - "", - "function M.mid()", - " return 5", - "end", - "", - "function M.last()", - " return 9", - "end", - "", - "return M", - }, "\n"), - "only the cursor's hunk is staged, placed at the right line" - ) -end) - -t.test("toggle_stage stages a whole-file change with no context", function() - local dir, buf = setup("a\nb\nc\n", "x\ny\nz\n") - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return h.git(dir, "diff", "--cached", "--name-only").stdout ~= "" - end, "the change to land in the index") - t.eq(h.git(dir, "show", ":0:a.txt").stdout, "x\ny\nz") -end) - -t.test("toggle_stage stages a change at the start of the file", function() - local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\ne\n") - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return h.git(dir, "diff", "--cached", "--name-only").stdout ~= "" - end, "the change to land in the index") - t.eq(h.git(dir, "show", ":0:a.txt").stdout, "A\nb\nc\nd\ne") -end) - -t.test("toggle_stage stages a change at the end of the file", function() - local dir, buf = setup("a\nb\nc\nd\ne\n", "a\nb\nc\nd\nE\n") - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return h.git(dir, "diff", "--cached", "--name-only").stdout ~= "" - end, "the change to land in the index") - t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nb\nc\nd\nE") -end) - -t.test("toggle_stage stages a deletion at the start of the file", function() - local dir, buf = setup("a\nb\nc\nd\n", "b\nc\nd\n") - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return h.git(dir, "diff", "--cached", "--name-only").stdout ~= "" - end, "the deletion to land in the index") - t.eq(h.git(dir, "show", ":0:a.txt").stdout, "b\nc\nd") -end) - -t.test("toggle_stage leaves an adjacent unstaged hunk in place", function() - local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\ne\n") - vim.api.nvim_win_set_cursor(0, { 3, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return h.git(dir, "diff", "--cached", "--name-only").stdout ~= "" - end, "the line-3 hunk to land in the index") - t.eq( - h.git(dir, "show", ":0:a.txt").stdout, - "a\nb\nC\nd\ne", - "only line 3 is staged; the adjacent line-1 hunk is untouched" - ) -end) - -t.test("toggle_stage unstages one of two adjacent staged hunks", function() - local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\ne\n") - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return #assert(hunks.state(buf)).staged == 1 - end, "the line-1 hunk to be staged") - vim.api.nvim_win_set_cursor(0, { 3, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - local s = assert(hunks.state(buf)) - return #s.hunks == 0 and #s.staged == 2 - end, "both hunks to be staged") - - vim.api.nvim_win_set_cursor(0, { 3, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return #assert(hunks.state(buf)).staged == 1 - end, "the line-3 hunk to be unstaged again") - t.eq( - h.git(dir, "show", ":0:a.txt").stdout, - "A\nb\nc\nd\ne", - "line 3 reverts to HEAD while the staged line-1 change remains" - ) -end) - -t.test("toggle_stage refreshes the gutter when status stays modified", function() - local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n") - t.eq(#assert(hunks.state(buf)).hunks, 3) - - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return #assert(hunks.state(buf)).hunks == 2 - end, "gutter to drop the first staged hunk") - - vim.api.nvim_win_set_cursor(0, { 3, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return #assert(hunks.state(buf)).hunks == 1 - end, "gutter to drop the middle staged hunk") -end) - -t.test("staged hunks show with the staged highlight", function() - local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - local s = assert(hunks.state(buf)) - return #s.hunks == 0 and #s.staged == 1 - end, "the hunk to move from unstaged to staged") - t.eq(sign_marks(buf), { - { row = 1, sign = "┃", hl = "GitHunkStagedChanged" }, - }) -end) - -t.test("the gutter shows staged and unstaged hunks together", function() - local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n") - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return #assert(hunks.state(buf)).hunks == 2 - end, "the first hunk to leave the unstaged set") - t.eq(sign_marks(buf), { - { row = 0, sign = "┃", hl = "GitHunkStagedChanged" }, - { row = 2, sign = "┃", hl = "GitHunkChanged" }, - { row = 4, sign = "┃", hl = "GitHunkChanged" }, - }) -end) - -t.test("toggle_stage toggles a staged hunk back to unstaged", function() - local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - local s = assert(hunks.state(buf)) - return #s.hunks == 0 and #s.staged == 1 - end, "the hunk to become staged") - hunks.toggle_stage(buf) - t.wait_for(function() - local s = assert(hunks.state(buf)) - return #s.hunks == 1 and #s.staged == 0 - end, "the hunk to return to unstaged") - t.eq(sign_marks(buf), { - { row = 1, sign = "┃", hl = "GitHunkChanged" }, - }) -end) - -t.test("toggle_stage unstages correctly when buffer lines are shifted", function() - local dir, buf = setup("a\nb\nc\n", "a\nb\nC\n") - vim.api.nvim_win_set_cursor(0, { 3, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return #assert(hunks.state(buf)).staged == 1 - end, "the line-3 change to be staged") - - vim.api.nvim_buf_set_lines(buf, 0, 0, false, { "NEW" }) - vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf }) - hunks._flush(buf) - t.wait_for(function() - return #assert(hunks.state(buf)).hunks == 1 - end, "the unstaged add at the top to register") - - vim.api.nvim_win_set_cursor(0, { 4, 0 }) - hunks.toggle_stage(buf) - t.wait_for(function() - return #assert(hunks.state(buf)).staged == 0 - end, "the shifted staged hunk to be unstaged") - t.eq( - h.git(dir, "show", ":0:a.txt").stdout, - "a\nb\nc", - "the index reverts to HEAD content for the unstaged hunk" - ) -end) - -t.test("reset_hunk restores the index content for a change", function() - local _, buf, state = setup("a\nb\nc\n", "a\nB\nc\n") - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - hunks.reset_hunk(buf) - t.eq( - vim.api.nvim_buf_get_lines(buf, 0, -1, false), - state.index, - "buffer matches the index after reset" - ) -end) - -t.test("reset_hunk re-inserts deleted lines", function() - local _, buf = setup("a\nb\nc\n", "a\nc\n") - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - hunks.reset_hunk(buf) - t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "a", "b", "c" }) -end) - -t.test("reset_hunk removes a pure addition", function() - local _, buf = setup("a\nd\n", "a\nb\nc\nd\n") - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - hunks.reset_hunk(buf) - t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "a", "d" }) -end) - -t.test("git_hunk_signs overrides the sign character per kind", function() - local prev = vim.g.git_hunk_signs - vim.g.git_hunk_signs = { change = "C" } - t.defer(function() - vim.g.git_hunk_signs = prev - end) - local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") - t.eq(sign_marks(buf), { - { row = 1, sign = "C", hl = "GitHunkChanged" }, - }) -end) - -t.test("git_hunk_signs falls back to the default for unset kinds", function() - local prev = vim.g.git_hunk_signs - vim.g.git_hunk_signs = { add = "A" } - t.defer(function() - vim.g.git_hunk_signs = prev - end) - local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") - t.eq(sign_marks(buf), { - { row = 1, sign = "┃", hl = "GitHunkChanged" }, - }) -end) - -t.test("preview_hunk shows the hunk body without file headers", function() - local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - hunks.preview_hunk(buf) - local float = assert(find_float(), "preview float should open") - t.defer(function() - pcall(vim.api.nvim_win_close, float, true) - end) - local lines = vim.api.nvim_buf_get_lines( - vim.api.nvim_win_get_buf(float), - 0, - -1, - false - ) - t.truthy( - vim.startswith(lines[1] or "", "@@ "), - "first line is the @@ header" - ) - for _, l in ipairs(lines) do - t.falsy(vim.startswith(l, "--- "), "no --- file header line") - t.falsy(vim.startswith(l, "+++ "), "no +++ file header line") - end -end) - -t.test("preview_hunk re-invocation focuses the open float", function() - local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - hunks.preview_hunk(buf) - local float = assert(find_float(), "preview float should open") - t.defer(function() - pcall(vim.api.nvim_win_close, float, true) - end) - t.truthy( - vim.api.nvim_get_current_win() ~= float, - "the float opens unfocused" - ) - hunks.preview_hunk(buf) - t.eq( - vim.api.nvim_get_current_win(), - float, - "re-invoking focuses the existing float" - ) -end) - -t.test("nav jumps to next and previous hunks with wrap", function() - local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\nE\n") - vim.api.nvim_set_current_buf(buf) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - hunks.nav("next") - t.eq(vim.api.nvim_win_get_cursor(0)[1], 5, "next hunk is line 5") - hunks.nav("next") - t.eq(vim.api.nvim_win_get_cursor(0)[1], 1, "next wraps back to line 1") - hunks.nav("prev") - t.eq(vim.api.nvim_win_get_cursor(0)[1], 5, "prev wraps back to line 5") -end) diff --git a/test/git/object_test.lua b/test/git/object_test.lua deleted file mode 100644 index 3aa79b4..0000000 --- a/test/git/object_test.lua +++ /dev/null @@ -1,188 +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")) -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() 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:) 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 on a merge commit diffs against the first parent only", function() - local dir = h.make_repo({ ["a.txt"] = "one\n" }) - t.write(dir, "a.txt", "two\n") - h.git(dir, "stash") - local stash = h.git(dir, "rev-parse", "stash@{0}").stdout - local r = assert(require("git.core.repo").resolve(dir)) - - object.open(r, stash, { split = false }) - local count = 0 - for _, l in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do - if l:match("^diff %-%-git ") then - count = count + 1 - end - end - t.eq(count, 1, "the stashed file's diff appears once, not per-parent") -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 ' 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 ' 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/' 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) diff --git a/test/git/repo_test.lua b/test/git/repo_test.lua deleted file mode 100644 index e418551..0000000 --- a/test/git/repo_test.lua +++ /dev/null @@ -1,369 +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("index_sha returns the blob sha and caches it", function() - local dir = h.make_repo({ a = "x\n" }) - local r = assert(require("git.core.repo").resolve(dir)) - local sha = r:index_sha("a") - t.truthy(sha and #sha > 0, "index_sha returns the stage-0 blob sha") - t.truthy(r._cache["index:a"] ~= nil, "the result is cached") - t.eq(r:index_sha("a"), sha, "a cached call returns the same sha") -end) - -t.test("index_sha caches a negative result for an untracked path", function() - local dir = h.make_repo({ a = "x\n" }) - local r = assert(require("git.core.repo").resolve(dir)) - t.eq(r:index_sha("nope"), nil, "an untracked path has no index sha") - t.eq(r._cache["index:nope"], false, "the negative result is cached") -end) - -t.test("index_sha cache clears when the index is written", function() - local dir = h.make_repo({ a = "x\n" }) - local r = assert(require("git.core.repo").resolve(dir)) - r:index_sha("a") - t.truthy(r._cache["index:a"] ~= nil, "sha is cached before the stage") - t.write(dir, "a", "y\n") - h.git(dir, "add", "a") - wait_cleared(r, "index:a", 2000) -end) - -t.test("head_sha returns the blob sha and caches it", function() - local dir = h.make_repo({ a = "x\n" }) - local r = assert(require("git.core.repo").resolve(dir)) - local sha = r:head_sha("a") - t.truthy(sha and #sha > 0, "head_sha returns the HEAD blob sha") - t.truthy(r._cache["head_blob:a"] ~= nil, "the result is cached") - t.eq(r:head_sha("a"), sha, "a cached call returns the same sha") -end) - -t.test("head_sha cache clears when HEAD moves", function() - local dir = h.make_repo({ a = "x\n" }) - local r = assert(require("git.core.repo").resolve(dir)) - r:head_sha("a") - t.truthy(r._cache["head_blob:a"] ~= nil, "sha is cached before the commit") - t.write(dir, "a", "y\n") - h.git(dir, "commit", "-aqm", "change") - wait_cleared(r, "head_blob:a", 2000) -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) diff --git a/test/git/status_test.lua b/test/git/status_test.lua deleted file mode 100644 index fab1ab9..0000000 --- a/test/git/status_test.lua +++ /dev/null @@ -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) - diff --git a/test/git/status_view_test.lua b/test/git/status_view_test.lua deleted file mode 100644 index 371780d..0000000 --- a/test/git/status_view_test.lua +++ /dev/null @@ -1,487 +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("") - 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("") - 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( - " 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("") - 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("") - 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("") - 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("") - 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.diffsplit").open({ 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("") - 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 -) - -t.test("sidebar buffer is named /GitStatus", function() - local repo = h.make_repo({ ["foo.txt"] = "x\n" }) - vim.cmd("cd " .. repo) - require("git.status_view").open({ placement = "sidebar" }) - local r = assert(require("git.core.repo").find(vim.fn.getcwd())) - local buf = find_sidebar() - assert(buf, "sidebar buffer should exist") - t.eq( - vim.api.nvim_buf_get_name(buf), - r.worktree .. "/GitStatus", - "buffer name should be /GitStatus" - ) -end) - -t.test( - "calling open twice without closing focuses the existing sidebar", - function() - local repo = h.make_repo({ ["foo.txt"] = "x\n" }) - vim.cmd("cd " .. repo) - require("git.status_view").open({ placement = "sidebar" }) - local first = find_sidebar() - assert(first, "first sidebar buffer should exist") - - require("git.status_view").open({ placement = "sidebar" }) - local second = find_sidebar() - assert(second, "second sidebar buffer should exist") - t.eq( - first, - second, - "consecutive opens should reuse the visible sidebar" - ) - local count = 0 - for _, b in ipairs(vim.api.nvim_list_bufs()) do - if vim.bo[b].filetype == "gitstatus" then - count = count + 1 - end - end - t.eq(count, 1, "only one gitstatus buffer should exist") - end -) - -t.test("opening for different worktrees creates separate buffers", function() - local repo_a = h.make_repo({ ["a.txt"] = "x\n" }) - local repo_b = h.make_repo({ ["b.txt"] = "y\n" }) - - vim.cmd("cd " .. repo_a) - require("git.status_view").open({ placement = "sidebar" }) - local buf_a = find_sidebar() - require("git.status_view").toggle() - - vim.cmd("cd " .. repo_b) - require("git.status_view").open({ placement = "sidebar" }) - local buf_b = find_sidebar() - - assert(buf_a and buf_b) - t.truthy( - buf_a ~= buf_b, - "different worktrees should produce different buffers" - ) -end) - -t.test("sidebar buffer is buftype=nofile and not buflisted", function() - local repo = h.make_repo({ ["foo.txt"] = "x\n" }) - vim.cmd("cd " .. repo) - require("git.status_view").open({ placement = "sidebar" }) - local buf = find_sidebar() - assert(buf, "sidebar buffer should exist") - t.eq(vim.bo[buf].buftype, "nofile", "buftype should be nofile") - t.eq(vim.bo[buf].buflisted, false, "buflisted should be false") -end) - -t.test("sidebar buffer name does not get written to disk", function() - local repo = h.make_repo({ ["foo.txt"] = "x\n" }) - vim.cmd("cd " .. repo) - require("git.status_view").open({ placement = "sidebar" }) - local buf = find_sidebar() - assert(buf, "sidebar buffer should exist") - local name = vim.api.nvim_buf_get_name(buf) - vim.api.nvim_buf_call(buf, function() - pcall(function() - vim.cmd("silent! write") - end) - end) - t.eq( - vim.uv.fs_stat(name), - nil, - "no real file should be created at the sidebar buffer's path" - ) -end) - -t.test( - "diffsplit from sidebar resets cursor so panes stay in sync", - function() - local committed, worktree = {}, {} - for i = 1, 100 do - committed[i] = "line " .. i - worktree[i] = i == 10 - and "CHANGED " .. i - or i == 40 and "CHANGED " .. i - or i == 70 and "CHANGED " .. i - or i == 90 and "CHANGED " .. i - or ("line " .. i) - end - local repo = h.make_repo({ - ["file.txt"] = table.concat(committed, "\n") .. "\n", - }) - t.write(repo, "file.txt", table.concat(worktree, "\n") .. "\n") - vim.cmd("cd " .. repo) - - -- Open the worktree file in a normal window and position cursor in - -- what becomes a folded section after diff is set up. - vim.cmd("edit file.txt") - vim.api.nvim_win_set_cursor(0, { 50, 0 }) - - require("git.status_view").open({ placement = "sidebar" }) - local sidebar_buf, sidebar_win = find_sidebar() - assert(sidebar_buf and sidebar_win) - local r = assert(require("git.core.repo").find(vim.fn.getcwd())) - 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 - for i, l in - ipairs(vim.api.nvim_buf_get_lines(sidebar_buf, 0, -1, false)) - do - if l:match("file.txt$") then - entry_line = i - break - end - end - if not entry_line then - error("entry line should exist") - end - - vim.api.nvim_set_current_win(sidebar_win) - vim.api.nvim_win_set_cursor(sidebar_win, { entry_line, 0 }) - t.press("") - t.wait_for(function() - return find_diff_win("left") ~= nil - and find_diff_win("right") ~= nil - end, "diff pair to appear") - - local left_win = assert(find_diff_win("left")) - local right_win = assert(find_diff_win("right")) - local left_top = - vim.api.nvim_win_call(left_win, function() return vim.fn.line("w0") end) - local right_top = vim.api.nvim_win_call( - right_win, - function() return vim.fn.line("w0") end - ) - t.eq( - left_top, - right_top, - "left and right panes should have the same topline after diffsplit" - ) - t.eq( - vim.api.nvim_win_get_cursor(left_win), - { 1, 0 }, - "left pane should start at line 1" - ) - t.eq( - vim.api.nvim_win_get_cursor(right_win), - { 1, 0 }, - "right pane should start at line 1" - ) - end -) diff --git a/test/git/util_test.lua b/test/git/util_test.lua deleted file mode 100644 index 2b5e890..0000000 --- a/test/git/util_test.lua +++ /dev/null @@ -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)