commit 914aa493fffe5c7d0e1312b655a581032624d624 Author: Oscar Wallberg Date: Tue May 26 17:01:44 2026 +0200 chore: initial commit diff --git a/.emmyrc.json b/.emmyrc.json new file mode 100644 index 0000000..73c0480 --- /dev/null +++ b/.emmyrc.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json", + "runtime": { + "version": "LuaJIT", + "requirePattern": ["?.lua", "?/init.lua", "lua/?.lua", "lua/?/init.lua"] + }, + "diagnostics": { + "disable": ["unnecessary-if", "preferred-local-alias", "redefined-local"] + }, + "workspace": { + "library": ["/usr/share/nvim/runtime"] + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..724a262 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: all check lint test format + +check: format lint test + +test: + @scripts/test + +lint: + @scripts/lint + +format: + @stylua . diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbf03c8 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# git.nvim + +A git workflow integration for Neovim. + +Features: + +- Status sidebar with stage/unstage/discard actions +- Log viewer +- Diff splits against any revision, the index, or the worktree +- Gutter signs + optional diff overlay +- Per-hunk stage / reset / preview / select +- Blame: cursor-line popup, inline annotation, full-file gutter +- Commit proxy (compose in a Neovim buffer) +- Generic `:G ` runner +- `git://:` URIs (worktree, index, stages, blobs, bare objects) + +Requires Neovim v0.12+ and git v2.25+. + +## Install + +The plugin auto-initializes from its `plugin/git.lua` and exposes `lua/git/`. No `setup()` call is required. + +With `vim.pack`: + +```lua +vim.pack.add({ "https://git.owall.dev/warg/git.nvim" }) +``` + +## Commands + +| Command | Action | +| -------------------------------------------- | ---------------------------------------------- | +| `:G[!] [args...]` | Generic git runner | +| `:Grefresh` | Refresh git status for all repos | +| `:Glog [args...]` | Open the log viewer | +| `:Gdiffsplit [vertical\|horizontal] []` | Open a diff split (default vertical, vs index) | +| `:Gedit [:path]` | Open an object as a buffer (`git://` URI) | +| `:Gstatus [sidebar\|split\|current]` | Open the status view (default `split`) | +| `:GitDiffOverlay` | Toggle the in-buffer diff overlay | + +## `` mappings + +| Plug map | Action | +| --------------------------------------- | ------------------------------------- | +| `(git-status-toggle)` | Toggle status sidebar | +| `(git-log)` | Open log viewer | +| `(git-commit)` | Compose a commit | +| `(git-commit-amend)` | Amend the last commit | +| `(git-diffsplit-vertical)` | Vertical diff vs index | +| `(git-diffsplit-vertical-head)` | Vertical diff vs HEAD | +| `(git-diffsplit-horizontal)` | Horizontal diff vs index | +| `(git-diffsplit-horizontal-head)` | Horizontal diff vs HEAD | +| `(git-blame-popup)` | Cursor-line blame popup | +| `(git-blame-view)` | Toggle full-file blame overlay | +| `(git-hunk-select)` | Visually select the hunk under cursor | +| `(git-hunk-stage-toggle)` | Stage / unstage hunk | +| `(git-hunk-reset)` | Reset hunk | +| `(git-hunk-overlay-toggle)` | Toggle the in-buffer diff overlay | +| `(git-hunk-next)` | Jump to next hunk | +| `(git-hunk-prev)` | Jump to previous hunk | + +Bind one with e.g. + +```lua +vim.keymap.set("n", "gg", "(git-status-toggle)") +``` + +## Globals + +- `vim.g.loaded_git = 1`: skip auto-initialization (set before the plugin loads). +- `vim.g.git_hunk_signs = { add?, change?, delete? }`: override the gutter glyphs (default `┃`). + +## Highlight groups + +See the `DEFAULT_HIGHLIGHTS` table for the full list. diff --git a/lua/git/blame.lua b/lua/git/blame.lua new file mode 100644 index 0000000..663af28 --- /dev/null +++ b/lua/git/blame.lua @@ -0,0 +1,531 @@ +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 new file mode 100644 index 0000000..e18ca3b --- /dev/null +++ b/lua/git/cmd.lua @@ -0,0 +1,904 @@ +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 new file mode 100644 index 0000000..79ace26 --- /dev/null +++ b/lua/git/commit.lua @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000..ce65f08 --- /dev/null +++ b/lua/git/core/repo.lua @@ -0,0 +1,925 @@ +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 new file mode 100644 index 0000000..55b3ca8 --- /dev/null +++ b/lua/git/core/revision.lua @@ -0,0 +1,45 @@ +---@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 new file mode 100644 index 0000000..7e626ec --- /dev/null +++ b/lua/git/core/status.lua @@ -0,0 +1,383 @@ +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 new file mode 100644 index 0000000..f29cfed --- /dev/null +++ b/lua/git/core/util.lua @@ -0,0 +1,352 @@ +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 new file mode 100644 index 0000000..d16e562 --- /dev/null +++ b/lua/git/diffsplit.lua @@ -0,0 +1,134 @@ +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 new file mode 100644 index 0000000..29eab55 --- /dev/null +++ b/lua/git/editor.lua @@ -0,0 +1,117 @@ +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 new file mode 100644 index 0000000..094a0af --- /dev/null +++ b/lua/git/hunks.lua @@ -0,0 +1,970 @@ +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 = false, + 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 new file mode 100644 index 0000000..99f2a35 --- /dev/null +++ b/lua/git/log_view.lua @@ -0,0 +1,143 @@ +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 new file mode 100644 index 0000000..8898832 --- /dev/null +++ b/lua/git/object.lua @@ -0,0 +1,434 @@ +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 new file mode 100644 index 0000000..cf951a5 --- /dev/null +++ b/lua/git/status_view.lua @@ -0,0 +1,740 @@ +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 new file mode 100644 index 0000000..d779c53 --- /dev/null +++ b/lua/git/statusline.lua @@ -0,0 +1,104 @@ +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/plugin/git.lua b/plugin/git.lua new file mode 100644 index 0000000..e393909 --- /dev/null +++ b/plugin/git.lua @@ -0,0 +1,339 @@ +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/scripts/lint b/scripts/lint new file mode 100755 index 0000000..ee5646b --- /dev/null +++ b/scripts/lint @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -uo pipefail + +cd "$(dirname "$0")/.." || exit 1 + +emmylua_check --warnings-as-errors --output-format=json . 2> >(grep -v '^Check finished$' >&2) \ + | jq -r ' + .[] + | .file as $f + | .diagnostics[] + | "\($f):\(.range.start.line + 1):\(.range.start.character + 1)" + + ": \(["error","warning","info","hint"][.severity-1])" + + ": \(.message | rtrimstr(" ")) [\(.code)]" + ' \ + | sed "s|^$PWD/||" + +exit "${PIPESTATUS[0]}" diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..1d75628 --- /dev/null +++ b/scripts/test @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -u + +usage() { + cat <= 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 new file mode 100644 index 0000000..4474588 --- /dev/null +++ b/test/git/cmd_test.lua @@ -0,0 +1,668 @@ +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 new file mode 100644 index 0000000..e916502 --- /dev/null +++ b/test/git/helpers.lua @@ -0,0 +1,97 @@ +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 new file mode 100644 index 0000000..e3d71e8 --- /dev/null +++ b/test/git/hunks_test.lua @@ -0,0 +1,634 @@ +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 new file mode 100644 index 0000000..3aa79b4 --- /dev/null +++ b/test/git/object_test.lua @@ -0,0 +1,188 @@ +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 new file mode 100644 index 0000000..e418551 --- /dev/null +++ b/test/git/repo_test.lua @@ -0,0 +1,369 @@ +---@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 new file mode 100644 index 0000000..fab1ab9 --- /dev/null +++ b/test/git/status_test.lua @@ -0,0 +1,387 @@ +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 new file mode 100644 index 0000000..371780d --- /dev/null +++ b/test/git/status_view_test.lua @@ -0,0 +1,487 @@ +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 new file mode 100644 index 0000000..2b5e890 --- /dev/null +++ b/test/git/util_test.lua @@ -0,0 +1,49 @@ +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) diff --git a/test/init.lua b/test/init.lua new file mode 100644 index 0000000..517e353 --- /dev/null +++ b/test/init.lua @@ -0,0 +1,177 @@ +local M = {} + +local stats = { passed = 0, failed = 0, errors = {} } +local defers = {} +local label = "?" +local started = false + +local color_on = vim.env.TEST_COLOR == "1" + +local function color(code, str) + if color_on then + return string.format("\27[%sm%s\27[0m", code, str) + end + return str +end +local function red(s) + return color("31", s) +end +local function green(s) + return color("32", s) +end + +local function ensure_started() + if started then + return + end + started = true + io.stdout:write(string.format("-> %s ", label)) + io.stdout:flush() +end + +---Begin a new test file. Resets per-file stats and the cleanup queue +---and stages the per-file header for the next `M.test` call. +---@param path string +function M.start_file(path) + label = path + started = false + stats = { passed = 0, failed = 0, errors = {} } + defers = {} +end + +---Print the per-file summary and return the counts so the runner can +---accumulate totals across files. +---@return integer passed +---@return integer failed +function M.report() + ensure_started() + if stats.failed > 0 then + io.stdout:write(" " .. red("FAIL") .. "\n") + for _, e in ipairs(stats.errors) do + io.stdout:write( + string.format( + " %s %s\n %s\n", + red("FAIL"), + e.name, + e.err + ) + ) + end + else + io.stdout:write(" " .. green("OK") .. "\n") + end + return stats.passed, stats.failed +end + +---Queue a function to run when the current `M.test` completes (whether +---it passed or failed). Deferred calls run in LIFO order. +---@param fn fun() +function M.defer(fn) + table.insert(defers, fn) +end + +---@param name string +---@param fn fun() +function M.test(name, fn) + ensure_started() + local saved_cwd = vim.fn.getcwd() + local ok, err = pcall(fn) + while #defers > 0 do + pcall(table.remove(defers)) + end + if vim.fn.getcwd() ~= saved_cwd then + pcall(vim.cmd.cd, saved_cwd) + end + if ok then + stats.passed = stats.passed + 1 + io.stdout:write(".") + else + stats.failed = stats.failed + 1 + table.insert(stats.errors, { name = name, err = err }) + io.stdout:write(red("F")) + end + io.stdout:flush() +end + +local function fmt_value(v) + if type(v) == "string" then + return string.format("%q", v) + end + return vim.inspect(v) +end + +---@param actual any +---@param expected any +---@param msg string? +function M.eq(actual, expected, msg) + if not vim.deep_equal(actual, expected) then + error( + string.format( + "%s\n expected: %s\n actual: %s", + msg or "values differ", + fmt_value(expected), + fmt_value(actual) + ), + 2 + ) + end +end + +---@param val any +---@param msg string? +function M.truthy(val, msg) + if not val then + error(msg or ("expected truthy, got " .. tostring(val)), 2) + end +end + +---@param val any +---@param msg string? +function M.falsy(val, msg) + if val then + error(msg or ("expected falsy, got " .. tostring(val)), 2) + end +end + +---@param dir string +---@param path string +---@param content string +function M.write(dir, path, content) + local full = vim.fs.joinpath(dir, path) + local parent = vim.fs.dirname(full) + vim.fn.mkdir(parent, "p") + local f, err = io.open(full, "w") + if not f then + error(err or "io.open failed: " .. full) + end + f:write(content) + f:close() +end + +---@param keys string +function M.press(keys) + local rhs = vim.api.nvim_replace_termcodes(keys, true, false, true) + vim.api.nvim_feedkeys(rhs, "x", false) +end + +---@param cond fun(): boolean +---@param msg string +---@param timeout integer? +function M.wait_for(cond, msg, timeout) + M.truthy(vim.wait(timeout or 1000, cond), "timed out waiting for: " .. msg) +end + +---Run `fn` with `vim.notify` stubbed to a no-op so error/warning paths +---don't bleed onto the test runner's stdout. +---@param fn fun() +function M.quietly(fn) + local orig = vim.notify + vim.notify = function() end + local ok, err = pcall(fn) + vim.notify = orig + if not ok then + error(err, 0) + end +end + +return M diff --git a/test/runner.lua b/test/runner.lua new file mode 100644 index 0000000..b29c6de --- /dev/null +++ b/test/runner.lua @@ -0,0 +1,82 @@ +local root = vim.fn.getcwd() +package.path = package.path + .. (";" .. root .. "/lua/?.lua") + .. (";" .. root .. "/lua/?/init.lua") + .. (";" .. root .. "/?.lua") + .. (";" .. root .. "/?/init.lua") + +for _, f in ipairs(vim.fn.glob(root .. "/plugin/*.lua", false, true)) do + dofile(f) +end + +local t = require("test") + +---@param target string +---@return string[]? +local function gather(target) + local abs = vim.fn.fnamemodify(target, ":p") + if vim.fn.isdirectory(abs) == 1 then + return vim.fn.globpath(abs, "**/*_test.lua", false, true) + end + if vim.fn.filereadable(abs) == 1 then + return { abs } + end +end + +local targets = (arg and #arg > 0) and arg or { root .. "/test" } +---@type string[] +local files = {} +local resolve_failed = false +for _, target in ipairs(targets) do + local matched = gather(target) + if matched then + for _, f in ipairs(matched) do + table.insert(files, f) + end + else + io.stderr:write("no such test target: " .. target .. "\n") + resolve_failed = true + end +end +table.sort(files) + +local total_passed, total_failed = 0, 0 +for _, f in ipairs(files) do + local label = f + if f:sub(1, #root + 1) == root .. "/" then + label = f:sub(#root + 2) + end + t.start_file(label) + local ok, err = pcall(dofile, f) + if not ok then + t.test("(load)", function() + error(err, 0) + end) + end + local p, fl = t.report() + total_passed = total_passed + p + total_failed = total_failed + fl +end + +local function color(code, str) + if vim.env.TEST_COLOR == "1" then + return string.format("\27[%sm%s\27[0m", code, str) + end + return str +end + +io.stdout:write("\n") +if total_failed > 0 then + io.stdout:write( + string.format( + "%s, %s\n", + color("32", total_passed .. " passed"), + color("31", total_failed .. " failed") + ) + ) + os.exit(1) +end +io.stdout:write(color("32", total_passed .. " passed") .. "\n") +if resolve_failed then + os.exit(2) +end