chore: initial commit
This commit is contained in:
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.PHONY: all check lint test format
|
||||||
|
|
||||||
|
check: format lint test
|
||||||
|
|
||||||
|
test:
|
||||||
|
@scripts/test
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@scripts/lint
|
||||||
|
|
||||||
|
format:
|
||||||
|
@stylua .
|
||||||
@@ -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 <subcommand>` runner
|
||||||
|
- `git://<rev>:<path>` 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] [<rev>]` | Open a diff split (default vertical, vs index) |
|
||||||
|
| `:Gedit <rev>[: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 |
|
||||||
|
|
||||||
|
## `<Plug>` mappings
|
||||||
|
|
||||||
|
| Plug map | Action |
|
||||||
|
| --------------------------------------- | ------------------------------------- |
|
||||||
|
| `<Plug>(git-status-toggle)` | Toggle status sidebar |
|
||||||
|
| `<Plug>(git-log)` | Open log viewer |
|
||||||
|
| `<Plug>(git-commit)` | Compose a commit |
|
||||||
|
| `<Plug>(git-commit-amend)` | Amend the last commit |
|
||||||
|
| `<Plug>(git-diffsplit-vertical)` | Vertical diff vs index |
|
||||||
|
| `<Plug>(git-diffsplit-vertical-head)` | Vertical diff vs HEAD |
|
||||||
|
| `<Plug>(git-diffsplit-horizontal)` | Horizontal diff vs index |
|
||||||
|
| `<Plug>(git-diffsplit-horizontal-head)` | Horizontal diff vs HEAD |
|
||||||
|
| `<Plug>(git-blame-popup)` | Cursor-line blame popup |
|
||||||
|
| `<Plug>(git-blame-view)` | Toggle full-file blame overlay |
|
||||||
|
| `<Plug>(git-hunk-select)` | Visually select the hunk under cursor |
|
||||||
|
| `<Plug>(git-hunk-stage-toggle)` | Stage / unstage hunk |
|
||||||
|
| `<Plug>(git-hunk-reset)` | Reset hunk |
|
||||||
|
| `<Plug>(git-hunk-overlay-toggle)` | Toggle the in-buffer diff overlay |
|
||||||
|
| `<Plug>(git-hunk-next)` | Jump to next hunk |
|
||||||
|
| `<Plug>(git-hunk-prev)` | Jump to previous hunk |
|
||||||
|
|
||||||
|
Bind one with e.g.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
vim.keymap.set("n", "<leader>gg", "<Plug>(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.
|
||||||
@@ -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<string, ow.Git.Blame.Commit>
|
||||||
|
---@field line_sha table<integer, string>
|
||||||
|
|
||||||
|
---@class ow.Git.Blame.Source
|
||||||
|
---@field repo ow.Git.Repo
|
||||||
|
---@field rel string
|
||||||
|
---@field revision string?
|
||||||
|
|
||||||
|
---@class ow.Git.Blame.BufState
|
||||||
|
---@field repo ow.Git.Repo
|
||||||
|
---@field rel string
|
||||||
|
---@field revision string?
|
||||||
|
---@field commits table<string, ow.Git.Blame.Commit>
|
||||||
|
---@field line_sha table<integer, string>
|
||||||
|
---@field tick integer?
|
||||||
|
---@field epoch integer
|
||||||
|
---@field pending fun()[]
|
||||||
|
|
||||||
|
---@type table<integer, ow.Git.Blame.BufState>
|
||||||
|
local states = {}
|
||||||
|
|
||||||
|
---@param buf integer?
|
||||||
|
---@return integer
|
||||||
|
local function resolve_buf(buf)
|
||||||
|
return buf and buf ~= 0 and buf or vim.api.nvim_get_current_buf()
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@return ow.Git.Blame.BufState?
|
||||||
|
function M.state(buf)
|
||||||
|
return states[buf]
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param ts integer
|
||||||
|
---@param tz string
|
||||||
|
---@return string
|
||||||
|
local function format_author_time(ts, tz)
|
||||||
|
local sign, hh, mm = tz:match("^([+-])(%d%d)(%d%d)$")
|
||||||
|
---@type number
|
||||||
|
local offset = 0
|
||||||
|
if sign then
|
||||||
|
local h = tonumber(hh) or 0
|
||||||
|
local m = tonumber(mm) or 0
|
||||||
|
offset = (h * 3600 + m * 60) * (sign == "-" and -1 or 1)
|
||||||
|
end
|
||||||
|
return os.date("!%Y-%m-%d %T ", ts + offset) .. tz
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param stdout string
|
||||||
|
---@return ow.Git.Blame.Result
|
||||||
|
local function parse_porcelain(stdout)
|
||||||
|
---@type table<string, ow.Git.Blame.Commit>
|
||||||
|
local commits = {}
|
||||||
|
---@type table<integer, string>
|
||||||
|
local line_sha = {}
|
||||||
|
local cur_sha ---@type string?
|
||||||
|
local cur_lnum ---@type integer?
|
||||||
|
for _, line in ipairs(util.split_lines(stdout)) do
|
||||||
|
if line:sub(1, 1) == "\t" then
|
||||||
|
if cur_sha and cur_lnum then
|
||||||
|
line_sha[cur_lnum] = cur_sha
|
||||||
|
end
|
||||||
|
cur_sha = nil
|
||||||
|
cur_lnum = nil
|
||||||
|
else
|
||||||
|
local sha, final = line:match("^(%x+) %d+ (%d+)")
|
||||||
|
if sha and #sha >= 40 then
|
||||||
|
cur_sha = sha
|
||||||
|
cur_lnum = tonumber(final) --[[@as integer?]]
|
||||||
|
if not commits[sha] then
|
||||||
|
commits[sha] = {
|
||||||
|
sha = sha,
|
||||||
|
author = "",
|
||||||
|
author_mail = "",
|
||||||
|
author_time = 0,
|
||||||
|
author_tz = "",
|
||||||
|
summary = "",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local key, value = line:match("^(%S+) (.*)$")
|
||||||
|
local commit = cur_sha and commits[cur_sha]
|
||||||
|
if commit and key then
|
||||||
|
if key == "author" then
|
||||||
|
commit.author = value
|
||||||
|
elseif key == "author-mail" then
|
||||||
|
commit.author_mail = value
|
||||||
|
elseif key == "author-time" then
|
||||||
|
commit.author_time = math.floor(tonumber(value) or 0)
|
||||||
|
elseif key == "author-tz" then
|
||||||
|
commit.author_tz = value
|
||||||
|
elseif key == "summary" then
|
||||||
|
commit.summary = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return { commits = commits, line_sha = line_sha }
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param line_count integer
|
||||||
|
---@return ow.Git.Blame.Result
|
||||||
|
local function synth_uncommitted(line_count)
|
||||||
|
---@type table<integer, string>
|
||||||
|
local line_sha = {}
|
||||||
|
for i = 1, line_count do
|
||||||
|
line_sha[i] = ZERO_SHA
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
commits = {
|
||||||
|
[ZERO_SHA] = {
|
||||||
|
sha = ZERO_SHA,
|
||||||
|
author = "Not Committed Yet",
|
||||||
|
author_mail = "",
|
||||||
|
author_time = os.time() --[[@as integer]],
|
||||||
|
author_tz = "",
|
||||||
|
summary = "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
line_sha = line_sha,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param rel string
|
||||||
|
---@param opts { rev: string?, contents: string? }
|
||||||
|
---@param done fun(result: ow.Git.Blame.Result?)
|
||||||
|
local function fetch_blame(r, rel, opts, done)
|
||||||
|
local args = { "--no-pager", "blame", "--porcelain" }
|
||||||
|
if opts.contents then
|
||||||
|
table.insert(args, "--contents")
|
||||||
|
table.insert(args, "-")
|
||||||
|
end
|
||||||
|
if opts.rev then
|
||||||
|
table.insert(args, opts.rev)
|
||||||
|
end
|
||||||
|
table.insert(args, "--")
|
||||||
|
table.insert(args, rel)
|
||||||
|
util.git(args, {
|
||||||
|
cwd = r.worktree,
|
||||||
|
stdin = opts.contents,
|
||||||
|
silent = true,
|
||||||
|
on_exit = function(res)
|
||||||
|
if res.code ~= 0 then
|
||||||
|
done(nil)
|
||||||
|
else
|
||||||
|
done(parse_porcelain(res.stdout or ""))
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@return ow.Git.Blame.Source?
|
||||||
|
local function resolve_source(buf)
|
||||||
|
if not vim.api.nvim_buf_is_valid(buf) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local name = vim.api.nvim_buf_get_name(buf)
|
||||||
|
if util.is_uri(name) then
|
||||||
|
local rev = object.parse_uri(name)
|
||||||
|
if not rev or not rev.base or not rev.path then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local r = repo.find(buf)
|
||||||
|
if not r then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return { repo = r, rel = rev.path, revision = rev.base }
|
||||||
|
end
|
||||||
|
if not repo.is_worktree_buf(buf) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local r = repo.find(buf)
|
||||||
|
if not r then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
|
||||||
|
if not rel then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return { repo = r, rel = rel }
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@return ow.Git.Blame.BufState?
|
||||||
|
local function ensure_state(buf)
|
||||||
|
if states[buf] then
|
||||||
|
return states[buf]
|
||||||
|
end
|
||||||
|
local src = resolve_source(buf)
|
||||||
|
if not src then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
---@type ow.Git.Blame.BufState
|
||||||
|
local state = {
|
||||||
|
repo = src.repo,
|
||||||
|
rel = src.rel,
|
||||||
|
revision = src.revision,
|
||||||
|
commits = {},
|
||||||
|
line_sha = {},
|
||||||
|
tick = nil,
|
||||||
|
epoch = 0,
|
||||||
|
pending = {},
|
||||||
|
}
|
||||||
|
states[buf] = state
|
||||||
|
return state
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param state ow.Git.Blame.BufState
|
||||||
|
---@param buf integer
|
||||||
|
---@param done fun()?
|
||||||
|
local function run_blame(state, buf, done)
|
||||||
|
local tick = vim.api.nvim_buf_get_changedtick(buf)
|
||||||
|
if state.tick == tick then
|
||||||
|
if done then
|
||||||
|
done()
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if done then
|
||||||
|
table.insert(state.pending, done)
|
||||||
|
end
|
||||||
|
state.epoch = state.epoch + 1
|
||||||
|
local epoch = state.epoch
|
||||||
|
local opts ---@type { rev: string?, contents: string? }
|
||||||
|
if state.revision then
|
||||||
|
opts = { rev = state.revision }
|
||||||
|
else
|
||||||
|
opts = {
|
||||||
|
contents = table.concat(
|
||||||
|
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
|
||||||
|
"\n"
|
||||||
|
) .. "\n",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
fetch_blame(state.repo, state.rel, opts, function(result)
|
||||||
|
if
|
||||||
|
states[buf] ~= state
|
||||||
|
or epoch ~= state.epoch
|
||||||
|
or not vim.api.nvim_buf_is_valid(buf)
|
||||||
|
then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = result
|
||||||
|
or synth_uncommitted(vim.api.nvim_buf_line_count(buf))
|
||||||
|
state.commits = data.commits
|
||||||
|
state.line_sha = data.line_sha
|
||||||
|
state.tick = tick
|
||||||
|
local pending = state.pending
|
||||||
|
state.pending = {}
|
||||||
|
for _, fn in ipairs(pending) do
|
||||||
|
fn()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param lines string[]
|
||||||
|
---@return integer width
|
||||||
|
---@return integer height
|
||||||
|
local function size_for(lines)
|
||||||
|
local width = 1
|
||||||
|
for _, l in ipairs(lines) do
|
||||||
|
local w = vim.api.nvim_strwidth(l)
|
||||||
|
if w > width then
|
||||||
|
width = w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
width = math.min(math.max(width + 1, 30), vim.o.columns - 4)
|
||||||
|
local height = math.min(math.max(#lines, 1), math.floor(vim.o.lines / 2))
|
||||||
|
return width, height
|
||||||
|
end
|
||||||
|
|
||||||
|
local popup_win ---@type integer?
|
||||||
|
|
||||||
|
local function close_popup()
|
||||||
|
if popup_win and vim.api.nvim_win_is_valid(popup_win) then
|
||||||
|
vim.api.nvim_win_close(popup_win, true)
|
||||||
|
end
|
||||||
|
popup_win = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param pbuf integer
|
||||||
|
---@param win integer
|
||||||
|
---@param head string[]
|
||||||
|
---@param body string[]?
|
||||||
|
---@param sha_len integer?
|
||||||
|
---@param date_col integer?
|
||||||
|
local function apply_popup(pbuf, win, head, body, sha_len, date_col)
|
||||||
|
local lines = {}
|
||||||
|
vim.list_extend(lines, head)
|
||||||
|
if body then
|
||||||
|
vim.list_extend(lines, body)
|
||||||
|
end
|
||||||
|
vim.bo[pbuf].modifiable = true
|
||||||
|
vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines)
|
||||||
|
vim.bo[pbuf].modifiable = false
|
||||||
|
vim.api.nvim_buf_clear_namespace(pbuf, NS_POPUP, 0, -1)
|
||||||
|
if sha_len then
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, 0, {
|
||||||
|
end_col = sha_len,
|
||||||
|
hl_group = "GitBlameSha",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
if sha_len and date_col then
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, sha_len + 2, {
|
||||||
|
end_col = date_col - 2,
|
||||||
|
hl_group = "GitBlameAuthor",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
if date_col then
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, date_col, {
|
||||||
|
end_col = #(head[1] or ""),
|
||||||
|
hl_group = "GitBlameDate",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
local width, height = size_for(lines)
|
||||||
|
pcall(vim.api.nvim_win_set_width, win, width)
|
||||||
|
pcall(vim.api.nvim_win_set_height, win, height)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param watch_buf integer
|
||||||
|
---@param pbuf integer
|
||||||
|
---@param win integer
|
||||||
|
local function setup_popup_autocmds(watch_buf, pbuf, win)
|
||||||
|
local group =
|
||||||
|
vim.api.nvim_create_augroup("ow.git.blame.popup", { clear = true })
|
||||||
|
vim.api.nvim_create_autocmd(
|
||||||
|
{ "CursorMoved", "CursorMovedI", "InsertEnter" },
|
||||||
|
{ group = group, buffer = watch_buf, callback = close_popup }
|
||||||
|
)
|
||||||
|
vim.api.nvim_create_autocmd("WinLeave", {
|
||||||
|
group = group,
|
||||||
|
buffer = pbuf,
|
||||||
|
callback = close_popup,
|
||||||
|
})
|
||||||
|
vim.api.nvim_create_autocmd("WinClosed", {
|
||||||
|
group = group,
|
||||||
|
pattern = tostring(win),
|
||||||
|
callback = function()
|
||||||
|
popup_win = nil
|
||||||
|
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame.popup")
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
vim.keymap.set("n", "q", close_popup, { buffer = pbuf, nowait = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param commits table<string, ow.Git.Blame.Commit>
|
||||||
|
---@param line_sha table<integer, string>
|
||||||
|
---@param lnum integer
|
||||||
|
---@param watch_buf integer
|
||||||
|
local function open_popup(r, commits, line_sha, lnum, watch_buf)
|
||||||
|
close_popup()
|
||||||
|
local sha = line_sha[lnum]
|
||||||
|
local commit = sha and commits[sha]
|
||||||
|
if not commit then
|
||||||
|
util.warning("git blame: no blame information for line %d", lnum)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local head ---@type string[]
|
||||||
|
local sha_len ---@type integer?
|
||||||
|
local date_col ---@type integer?
|
||||||
|
if util.is_zero_sha(sha) then
|
||||||
|
head = { "Not Committed Yet" }
|
||||||
|
else
|
||||||
|
local short = sha:sub(1, 8)
|
||||||
|
local date = format_author_time(commit.author_time, commit.author_tz)
|
||||||
|
sha_len = #short
|
||||||
|
date_col = sha_len + 2 + #commit.author + 2
|
||||||
|
head = {
|
||||||
|
short .. " " .. commit.author .. " " .. date,
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
local body = sha_len and { commit.summary } or nil
|
||||||
|
local lines = {}
|
||||||
|
vim.list_extend(lines, head)
|
||||||
|
if body then
|
||||||
|
vim.list_extend(lines, body)
|
||||||
|
end
|
||||||
|
local width, height = size_for(lines)
|
||||||
|
local pbuf = vim.api.nvim_create_buf(false, true)
|
||||||
|
vim.bo[pbuf].bufhidden = "wipe"
|
||||||
|
local win = vim.api.nvim_open_win(pbuf, false, {
|
||||||
|
relative = "cursor",
|
||||||
|
row = 1,
|
||||||
|
col = 0,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
style = "minimal",
|
||||||
|
})
|
||||||
|
popup_win = win
|
||||||
|
apply_popup(pbuf, win, head, body, sha_len, date_col)
|
||||||
|
setup_popup_autocmds(watch_buf, pbuf, win)
|
||||||
|
if not sha_len then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
util.git({ "show", "-s", "--format=%B", sha }, {
|
||||||
|
cwd = r.worktree,
|
||||||
|
silent = true,
|
||||||
|
on_exit = function(res)
|
||||||
|
if
|
||||||
|
popup_win ~= win
|
||||||
|
or not vim.api.nvim_win_is_valid(win)
|
||||||
|
or not vim.api.nvim_buf_is_valid(pbuf)
|
||||||
|
or res.code ~= 0
|
||||||
|
then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local msg = util.split_lines(res.stdout or "")
|
||||||
|
if #msg > 0 then
|
||||||
|
apply_popup(pbuf, win, head, msg, sha_len, date_col)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer?
|
||||||
|
function M.line_popup(buf)
|
||||||
|
buf = resolve_buf(buf)
|
||||||
|
if popup_win and vim.api.nvim_win_is_valid(popup_win) then
|
||||||
|
vim.api.nvim_set_current_win(popup_win)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local state = ensure_state(buf)
|
||||||
|
if not state then
|
||||||
|
util.warning("git blame: nothing to blame in this buffer")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
run_blame(state, buf, function()
|
||||||
|
if
|
||||||
|
not vim.api.nvim_buf_is_valid(buf)
|
||||||
|
or vim.api.nvim_get_current_buf() ~= buf
|
||||||
|
or vim.api.nvim_win_get_cursor(0)[1] ~= lnum
|
||||||
|
then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
open_popup(state.repo, state.commits, state.line_sha, lnum, buf)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param done fun(state: ow.Git.Blame.BufState, sha: string)
|
||||||
|
local function blame_line(done)
|
||||||
|
local buf = vim.api.nvim_get_current_buf()
|
||||||
|
local state = ensure_state(buf)
|
||||||
|
if not state then
|
||||||
|
util.warning("git blame: nothing to blame in this buffer")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
run_blame(state, buf, function()
|
||||||
|
if not vim.api.nvim_buf_is_valid(buf) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local sha = state.line_sha[lnum]
|
||||||
|
if not sha or util.is_zero_sha(sha) then
|
||||||
|
util.warning("git blame: line is not committed yet")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
done(state, sha)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.open_commit()
|
||||||
|
blame_line(function(state, sha)
|
||||||
|
object.open(state.repo, sha, { split = false })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.open_file()
|
||||||
|
blame_line(function(state, sha)
|
||||||
|
object.open(state.repo, sha .. ":" .. state.rel, { split = false })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.open_file_parent()
|
||||||
|
blame_line(function(state, sha)
|
||||||
|
local parent = state.repo:rev_parse(sha .. "^", false)
|
||||||
|
if not parent then
|
||||||
|
util.warning("git blame: %s has no parent commit", sha:sub(1, 8))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
object.open(state.repo, parent .. ":" .. state.rel, { split = false })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
function M.detach(buf)
|
||||||
|
local state = states[buf]
|
||||||
|
if not state then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.epoch = state.epoch + 1
|
||||||
|
states[buf] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
repo.on("change", function(r, change)
|
||||||
|
for _, state in pairs(states) do
|
||||||
|
if
|
||||||
|
state.repo == r
|
||||||
|
and not state.revision
|
||||||
|
and (change.paths[state.rel] or change.branch_changed)
|
||||||
|
then
|
||||||
|
state.tick = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
return M
|
||||||
+904
@@ -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 \\<BS>\\<Esc>"')
|
||||||
|
end)
|
||||||
|
vim.bo[buf].modifiable = false
|
||||||
|
vim.bo[buf].undolevels = saved
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
local function attach_history_keys(buf)
|
||||||
|
local function bypass(fn)
|
||||||
|
return function()
|
||||||
|
vim.bo[buf].modifiable = true
|
||||||
|
pcall(fn)
|
||||||
|
vim.bo[buf].modifiable = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vim.keymap.set(
|
||||||
|
"n",
|
||||||
|
"u",
|
||||||
|
bypass(vim.cmd.undo),
|
||||||
|
{ buffer = buf, desc = "Undo" }
|
||||||
|
)
|
||||||
|
vim.keymap.set(
|
||||||
|
"n",
|
||||||
|
"<C-r>",
|
||||||
|
bypass(vim.cmd.redo),
|
||||||
|
{ buffer = buf, desc = "Redo" }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param args string[]
|
||||||
|
---@param ft string
|
||||||
|
local function run_in_split(r, args, ft)
|
||||||
|
util.git(args, {
|
||||||
|
cwd = r.worktree,
|
||||||
|
on_exit = function(result)
|
||||||
|
if result.code ~= 0 then
|
||||||
|
util.error(
|
||||||
|
"git %s failed: %s",
|
||||||
|
args[1] or "?",
|
||||||
|
vim.trim(result.stderr or "")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local stdout = result.stdout or ""
|
||||||
|
local buf = place_split("[Git " .. table.concat(args, " ") .. "]")
|
||||||
|
repo.bind(buf, r)
|
||||||
|
object.attach_dispatch(buf)
|
||||||
|
attach_history_keys(buf)
|
||||||
|
local state = r:state(buf) --[[@as -nil]]
|
||||||
|
vim.bo[buf].filetype = ft
|
||||||
|
-- Force a new undo block so each rerun is its own undo step.
|
||||||
|
vim.bo[buf].undolevels = vim.bo[buf].undolevels
|
||||||
|
local first_run = not state.initialized
|
||||||
|
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
|
||||||
|
if first_run then
|
||||||
|
clear_undo(buf)
|
||||||
|
state.initialized = true
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param args string[]
|
||||||
|
local function run_to_messages(r, args)
|
||||||
|
local cmd = { "git" }
|
||||||
|
vim.list_extend(cmd, args)
|
||||||
|
local result = vim.system(cmd, {
|
||||||
|
cwd = r.worktree,
|
||||||
|
text = true,
|
||||||
|
env = util.DEFAULT_GIT_ENV,
|
||||||
|
}):wait()
|
||||||
|
local out = vim.trim(result.stdout or "")
|
||||||
|
local err = vim.trim(result.stderr or "")
|
||||||
|
local failed = result.code ~= 0
|
||||||
|
|
||||||
|
local chunks = {}
|
||||||
|
if out ~= "" then
|
||||||
|
table.insert(chunks, { out })
|
||||||
|
end
|
||||||
|
if err ~= "" then
|
||||||
|
if #chunks > 0 then
|
||||||
|
table.insert(chunks, { "\n" })
|
||||||
|
end
|
||||||
|
table.insert(chunks, { err, failed and "ErrorMsg" or nil })
|
||||||
|
end
|
||||||
|
if #chunks == 0 and failed then
|
||||||
|
table.insert(
|
||||||
|
chunks,
|
||||||
|
{ "git exited " .. tostring(result.code), "ErrorMsg" }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
if #chunks > 0 then
|
||||||
|
vim.api.nvim_echo(chunks, failed or err ~= "", {})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return integer
|
||||||
|
local function find_or_create_preview_win()
|
||||||
|
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||||
|
if vim.wo[w].previewwindow then
|
||||||
|
return w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vim.cmd(("botright %dnew"):format(vim.o.previewheight))
|
||||||
|
local w = vim.api.nvim_get_current_win()
|
||||||
|
vim.wo[w].previewwindow = true
|
||||||
|
return w
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param args string[]
|
||||||
|
local function run_in_preview(r, args)
|
||||||
|
local pwin = find_or_create_preview_win()
|
||||||
|
|
||||||
|
local buf = vim.api.nvim_create_buf(false, true)
|
||||||
|
vim.bo[buf].bufhidden = "wipe"
|
||||||
|
vim.api.nvim_win_set_buf(pwin, buf)
|
||||||
|
|
||||||
|
vim.api.nvim_set_current_win(pwin)
|
||||||
|
local cmd = { "git" }
|
||||||
|
vim.list_extend(cmd, args)
|
||||||
|
local job = vim.fn.jobstart(cmd, {
|
||||||
|
cwd = r.worktree,
|
||||||
|
term = true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if job <= 0 then
|
||||||
|
util.error("failed to start git job")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd("BufWipeout", {
|
||||||
|
buffer = buf,
|
||||||
|
once = true,
|
||||||
|
callback = function()
|
||||||
|
pcall(vim.fn.jobstop, job)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.keymap.set("n", "q", "<cmd>pclose<cr>", {
|
||||||
|
buffer = buf,
|
||||||
|
nowait = true,
|
||||||
|
desc = "Close preview",
|
||||||
|
})
|
||||||
|
vim.keymap.set("n", "<C-c>", function()
|
||||||
|
pcall(vim.fn.jobstop, job)
|
||||||
|
end, { buffer = buf, nowait = true, desc = "Cancel git job" })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param ft string
|
||||||
|
---@return ow.Git.Cmd.Run
|
||||||
|
local function in_split(ft)
|
||||||
|
return function(r, args)
|
||||||
|
run_in_split(r, args, ft)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param line string
|
||||||
|
---@return string
|
||||||
|
local function clean_progress_line(line)
|
||||||
|
if not (line:find("\27", 1, true) or line:find("\r", 1, true)) then
|
||||||
|
return line
|
||||||
|
end
|
||||||
|
line = line:gsub("\27%[[%d;?]*[%a]", "")
|
||||||
|
local parts = vim.split(line, "\r", { plain = true })
|
||||||
|
for i = #parts, 1, -1 do
|
||||||
|
if parts[i] ~= "" then
|
||||||
|
return parts[i]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param lines string[]
|
||||||
|
---@param fallback string
|
||||||
|
---@return [string, string?][]
|
||||||
|
local function format_error_dump(lines, fallback)
|
||||||
|
if #lines == 0 then
|
||||||
|
return { { fallback, "ErrorMsg" } }
|
||||||
|
end
|
||||||
|
local chunks = {}
|
||||||
|
local matched = false
|
||||||
|
for i, line in ipairs(lines) do
|
||||||
|
if i > 1 then
|
||||||
|
table.insert(chunks, { "\n" })
|
||||||
|
end
|
||||||
|
if line:match("^fatal:") or line:match("^error:") then
|
||||||
|
table.insert(chunks, { line, "ErrorMsg" })
|
||||||
|
matched = true
|
||||||
|
else
|
||||||
|
table.insert(chunks, { line })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not matched then
|
||||||
|
return { { table.concat(lines, "\n"), "ErrorMsg" } }
|
||||||
|
end
|
||||||
|
return chunks
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param args string[]
|
||||||
|
local function run_streaming(r, args)
|
||||||
|
local title = "git " .. (args[1] or "")
|
||||||
|
local id = "git." .. tostring(vim.uv.hrtime())
|
||||||
|
---@type string[]
|
||||||
|
local accum = {}
|
||||||
|
local partial = ""
|
||||||
|
local last_progress = ""
|
||||||
|
|
||||||
|
---@param text string
|
||||||
|
---@param status "running"|"success"|"failed"
|
||||||
|
local function emit_progress(text, status)
|
||||||
|
vim.api.nvim_echo({ { text } }, false, {
|
||||||
|
id = id,
|
||||||
|
kind = "progress",
|
||||||
|
status = status,
|
||||||
|
title = title,
|
||||||
|
source = "git",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_data(_, data, _)
|
||||||
|
if not data or #data == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if #data == 1 and data[1] == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
partial = partial .. data[1]
|
||||||
|
local prev = last_progress
|
||||||
|
for i = 2, #data do
|
||||||
|
local cleaned = clean_progress_line(partial)
|
||||||
|
if cleaned ~= "" then
|
||||||
|
table.insert(accum, cleaned)
|
||||||
|
last_progress = cleaned
|
||||||
|
end
|
||||||
|
partial = data[i]
|
||||||
|
end
|
||||||
|
if partial ~= "" then
|
||||||
|
local cleaned = clean_progress_line(partial)
|
||||||
|
if cleaned ~= "" then
|
||||||
|
last_progress = cleaned
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if last_progress ~= prev then
|
||||||
|
emit_progress(last_progress, "running")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_exit(_, code)
|
||||||
|
if partial ~= "" then
|
||||||
|
local cleaned = clean_progress_line(partial)
|
||||||
|
if cleaned ~= "" then
|
||||||
|
table.insert(accum, cleaned)
|
||||||
|
last_progress = cleaned
|
||||||
|
end
|
||||||
|
partial = ""
|
||||||
|
end
|
||||||
|
if code == 0 then
|
||||||
|
emit_progress(
|
||||||
|
last_progress ~= "" and last_progress or "done",
|
||||||
|
"success"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
emit_progress(("exit %d"):format(code), "failed")
|
||||||
|
local fallback = ("%s failed: exit %d"):format(title, code)
|
||||||
|
vim.api.nvim_echo(format_error_dump(accum, fallback), true, {})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local cmd = { "git" }
|
||||||
|
vim.list_extend(cmd, args)
|
||||||
|
local job = vim.fn.jobstart(cmd, {
|
||||||
|
cwd = r.worktree,
|
||||||
|
pty = true,
|
||||||
|
env = util.DEFAULT_GIT_ENV,
|
||||||
|
on_stdout = on_data,
|
||||||
|
on_stderr = on_data,
|
||||||
|
on_exit = on_exit,
|
||||||
|
})
|
||||||
|
if job <= 0 then
|
||||||
|
util.error("failed to start git job")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@type table<string, ow.Git.Cmd.Run>
|
||||||
|
local HANDLERS = {
|
||||||
|
log = in_split("git"),
|
||||||
|
diff = in_split("git"),
|
||||||
|
push = run_streaming,
|
||||||
|
fetch = run_streaming,
|
||||||
|
pull = run_streaming,
|
||||||
|
clone = run_streaming,
|
||||||
|
am = run_streaming,
|
||||||
|
["cherry-pick"] = run_streaming,
|
||||||
|
revert = run_streaming,
|
||||||
|
}
|
||||||
|
|
||||||
|
---@param args string[]
|
||||||
|
---@return boolean
|
||||||
|
local function has_message(args)
|
||||||
|
for _, a in ipairs(args) do
|
||||||
|
if
|
||||||
|
a == "-m"
|
||||||
|
or a == "--message"
|
||||||
|
or a:match("^%-%-message=")
|
||||||
|
or a:match("^%-m")
|
||||||
|
then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param args string[]
|
||||||
|
---@param opts { bang: boolean? }?
|
||||||
|
function M.run(args, opts)
|
||||||
|
local r = repo.resolve()
|
||||||
|
if not r then
|
||||||
|
util.error("not in a git repository")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local bang = opts and opts.bang or false
|
||||||
|
|
||||||
|
if bang then
|
||||||
|
run_in_preview(r, args)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local sub = args[1]
|
||||||
|
if sub == "commit" and not has_message(args) then
|
||||||
|
commit.commit({ args = vim.list_slice(args, 2) })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if sub == "show" then
|
||||||
|
if #args == 2 and args[2]:find(":", 1, true) then
|
||||||
|
object.open(r, args[2])
|
||||||
|
return
|
||||||
|
end
|
||||||
|
run_in_split(r, args, "git")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if sub == "cat-file" then
|
||||||
|
if #args == 3 and args[2] == "-p" then
|
||||||
|
object.open(r, args[3])
|
||||||
|
return
|
||||||
|
end
|
||||||
|
run_in_split(r, args, "git")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local handler = sub and HANDLERS[sub]
|
||||||
|
if handler then
|
||||||
|
handler(r, args)
|
||||||
|
else
|
||||||
|
run_to_messages(r, args)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param items string[]
|
||||||
|
---@param lead string
|
||||||
|
---@return string[]
|
||||||
|
local function prefix_filter(items, lead)
|
||||||
|
return vim.tbl_filter(function(it)
|
||||||
|
return vim.startswith(it, lead)
|
||||||
|
end, items)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param prefix string
|
||||||
|
---@param dir string
|
||||||
|
---@param name_lead string
|
||||||
|
---@param entries string[]
|
||||||
|
---@return string[]
|
||||||
|
local function path_segments(prefix, dir, name_lead, entries)
|
||||||
|
local matches = {}
|
||||||
|
local seen = {}
|
||||||
|
for _, full_path in ipairs(entries) do
|
||||||
|
local rel = dir == "" and full_path or full_path:sub(#dir + 1)
|
||||||
|
local slash = rel:find("/", 1, true)
|
||||||
|
local segment = slash and rel:sub(1, slash) or rel
|
||||||
|
if not seen[segment] and segment:sub(1, #name_lead) == name_lead then
|
||||||
|
seen[segment] = true
|
||||||
|
table.insert(matches, prefix .. dir .. segment)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return matches
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param dir string
|
||||||
|
---@return string[]
|
||||||
|
local function list_files(r, dir)
|
||||||
|
return r:get_cached("files:" .. dir, function(self)
|
||||||
|
local args = { "ls-files" }
|
||||||
|
if dir ~= "" then
|
||||||
|
table.insert(args, dir)
|
||||||
|
end
|
||||||
|
local out = util.git(args, { cwd = self.worktree, silent = true })
|
||||||
|
return out and util.split_lines(out) or {}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@return string[]
|
||||||
|
local function list_remotes(r)
|
||||||
|
return r:get_cached("remotes", function(self)
|
||||||
|
local out = util.git(
|
||||||
|
{ "remote" },
|
||||||
|
{ cwd = self.worktree, silent = true }
|
||||||
|
)
|
||||||
|
return out and util.split_lines(out) or {}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@type table<string, string[]>
|
||||||
|
local SUBSUB_FALLBACK = {
|
||||||
|
submodule = {
|
||||||
|
"add",
|
||||||
|
"status",
|
||||||
|
"init",
|
||||||
|
"deinit",
|
||||||
|
"update",
|
||||||
|
"summary",
|
||||||
|
"foreach",
|
||||||
|
"sync",
|
||||||
|
"absorbgitdirs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
---@type table<string, string[]>
|
||||||
|
local cached_completions = {}
|
||||||
|
|
||||||
|
---@param sub string
|
||||||
|
---@return string[]
|
||||||
|
local function fetch_completions(sub)
|
||||||
|
if cached_completions[sub] then
|
||||||
|
return cached_completions[sub]
|
||||||
|
end
|
||||||
|
local out = util.git(
|
||||||
|
{ sub, "--git-completion-helper-all" },
|
||||||
|
{ silent = true }
|
||||||
|
) or util.git({ sub, "--git-completion-helper" }, { silent = true })
|
||||||
|
local items = {}
|
||||||
|
if out then
|
||||||
|
for tok in out:gmatch("%S+") do
|
||||||
|
table.insert(items, tok)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
cached_completions[sub] = items
|
||||||
|
return items
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param sub string
|
||||||
|
---@return string[]
|
||||||
|
local function fetch_subsubcommands(sub)
|
||||||
|
local subs = {}
|
||||||
|
for _, it in ipairs(fetch_completions(sub)) do
|
||||||
|
if it:sub(1, 1) ~= "-" and it ~= "--" then
|
||||||
|
table.insert(subs, it)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #subs == 0 and SUBSUB_FALLBACK[sub] then
|
||||||
|
return SUBSUB_FALLBACK[sub]
|
||||||
|
end
|
||||||
|
return subs
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param sub string
|
||||||
|
---@return string[]
|
||||||
|
local function fetch_flags(sub)
|
||||||
|
local flags = {}
|
||||||
|
for _, it in ipairs(fetch_completions(sub)) do
|
||||||
|
if it:sub(1, 1) == "-" and it ~= "--" then
|
||||||
|
table.insert(flags, it)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return flags
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param lead string
|
||||||
|
---@return string[]
|
||||||
|
local function complete_tracked_paths(r, lead)
|
||||||
|
local dir, name_lead = lead:match("^(.*/)([^/]*)$")
|
||||||
|
dir = dir or ""
|
||||||
|
name_lead = name_lead or lead
|
||||||
|
return path_segments("", dir, name_lead, list_files(r, dir))
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param lead string
|
||||||
|
---@return string[]
|
||||||
|
local function complete_unstaged_paths(r, lead)
|
||||||
|
local matches = {}
|
||||||
|
for path, entry in pairs(r.status.entries) do
|
||||||
|
if path:sub(1, #lead) == lead then
|
||||||
|
local include = entry.kind == "untracked"
|
||||||
|
or entry.kind == "unmerged"
|
||||||
|
if not include and entry.kind == "changed" then
|
||||||
|
---@cast entry ow.Git.Status.ChangedEntry
|
||||||
|
include = entry.unstaged ~= nil
|
||||||
|
end
|
||||||
|
if include then
|
||||||
|
table.insert(matches, path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(matches)
|
||||||
|
return matches
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param arg_lead string
|
||||||
|
---@return string[]
|
||||||
|
function M.complete_rev(arg_lead)
|
||||||
|
local r = repo.resolve()
|
||||||
|
if not r then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local stage, stage_path_lead = arg_lead:match("^:([0-3]):(.*)$")
|
||||||
|
if stage then
|
||||||
|
local out = util.git(
|
||||||
|
{ "ls-files", "--stage" },
|
||||||
|
{ cwd = r.worktree, silent = true }
|
||||||
|
)
|
||||||
|
if not out then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local matches = {}
|
||||||
|
for _, line in ipairs(util.split_lines(out)) do
|
||||||
|
local row_stage, row_path = line:match("^%S+ %S+ (%d)\t(.*)$")
|
||||||
|
if
|
||||||
|
row_stage == stage
|
||||||
|
and row_path
|
||||||
|
and row_path:sub(1, #stage_path_lead) == stage_path_lead
|
||||||
|
then
|
||||||
|
table.insert(matches, ":" .. stage .. ":" .. row_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return matches
|
||||||
|
end
|
||||||
|
|
||||||
|
local colon = arg_lead:find(":", 1, true)
|
||||||
|
if not colon then
|
||||||
|
local refs = {}
|
||||||
|
vim.list_extend(refs, r:list_refs())
|
||||||
|
vim.list_extend(refs, r:list_pseudo_refs())
|
||||||
|
vim.list_extend(refs, r:list_stash_refs())
|
||||||
|
return prefix_filter(refs, arg_lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
local rev = arg_lead:sub(1, colon - 1)
|
||||||
|
local path_lead = arg_lead:sub(colon + 1)
|
||||||
|
local dir, name_lead = path_lead:match("^(.*/)([^/]*)$")
|
||||||
|
dir = dir or ""
|
||||||
|
name_lead = name_lead or path_lead
|
||||||
|
|
||||||
|
if rev ~= "" then
|
||||||
|
local args = { "ls-tree", rev }
|
||||||
|
if dir ~= "" then
|
||||||
|
table.insert(args, dir)
|
||||||
|
end
|
||||||
|
local out = util.git(args, { cwd = r.worktree, silent = true })
|
||||||
|
if not out then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local matches = {}
|
||||||
|
for _, line in ipairs(util.split_lines(out)) do
|
||||||
|
local typ, full_path = line:match("^%S+ (%S+) %S+\t(.*)$")
|
||||||
|
if typ and full_path then
|
||||||
|
local basename = dir == "" and full_path
|
||||||
|
or full_path:sub(#dir + 1)
|
||||||
|
if typ == "tree" then
|
||||||
|
basename = basename .. "/"
|
||||||
|
end
|
||||||
|
if basename:sub(1, #name_lead) == name_lead then
|
||||||
|
table.insert(matches, rev .. ":" .. dir .. basename)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return matches
|
||||||
|
end
|
||||||
|
|
||||||
|
return path_segments(":", dir, name_lead, list_files(r, dir))
|
||||||
|
end
|
||||||
|
|
||||||
|
---@alias ow.Git.Cmd.Handler fun(r: ow.Git.Repo, lead: string, sub: string, idx: integer): string[]
|
||||||
|
---@alias ow.Git.Cmd.Slot ow.Git.Cmd.Handler | ow.Git.Cmd.Handler[]
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param lead string
|
||||||
|
---@return string[]
|
||||||
|
local function complete_remote(r, lead)
|
||||||
|
return prefix_filter(list_remotes(r), lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param lead string
|
||||||
|
---@return string[]
|
||||||
|
local function complete_ref(r, lead)
|
||||||
|
return prefix_filter(r:list_refs(), lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param lead string
|
||||||
|
---@return string[]
|
||||||
|
local function complete_pseudo_ref(r, lead)
|
||||||
|
return prefix_filter(r:list_pseudo_refs(), lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param lead string
|
||||||
|
---@return string[]
|
||||||
|
local function complete_stash_ref(r, lead)
|
||||||
|
return prefix_filter(r:list_stash_refs(), lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param _ ow.Git.Repo
|
||||||
|
---@param lead string
|
||||||
|
---@return string[]
|
||||||
|
local function complete_rev(_, lead)
|
||||||
|
return M.complete_rev(lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param _ ow.Git.Repo
|
||||||
|
---@param lead string
|
||||||
|
---@param sub string
|
||||||
|
---@param idx integer
|
||||||
|
---@return string[]
|
||||||
|
local function complete_subsubcmd(_, lead, sub, idx)
|
||||||
|
if idx ~= 1 then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
return prefix_filter(fetch_subsubcommands(sub), lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
local ALL_REFS = { complete_ref, complete_pseudo_ref, complete_stash_ref }
|
||||||
|
local REV_OR_PATH = { complete_rev, complete_tracked_paths }
|
||||||
|
|
||||||
|
---@type table<string, ow.Git.Cmd.Slot[]>
|
||||||
|
local POSITIONAL_HANDLER = {
|
||||||
|
push = { complete_remote, ALL_REFS },
|
||||||
|
pull = { complete_remote, ALL_REFS },
|
||||||
|
fetch = { complete_remote, ALL_REFS },
|
||||||
|
checkout = { REV_OR_PATH },
|
||||||
|
reset = { REV_OR_PATH },
|
||||||
|
restore = { complete_tracked_paths },
|
||||||
|
add = { complete_unstaged_paths },
|
||||||
|
rm = { complete_tracked_paths },
|
||||||
|
mv = { complete_tracked_paths },
|
||||||
|
blame = { complete_tracked_paths },
|
||||||
|
branch = { complete_ref },
|
||||||
|
switch = { complete_ref },
|
||||||
|
merge = { ALL_REFS },
|
||||||
|
rebase = { ALL_REFS },
|
||||||
|
["cherry-pick"] = { ALL_REFS },
|
||||||
|
revert = { ALL_REFS },
|
||||||
|
tag = { ALL_REFS },
|
||||||
|
log = { REV_OR_PATH },
|
||||||
|
diff = { REV_OR_PATH },
|
||||||
|
show = { complete_rev },
|
||||||
|
["cat-file"] = { complete_rev },
|
||||||
|
stash = { complete_subsubcmd },
|
||||||
|
remote = { complete_subsubcmd },
|
||||||
|
worktree = { complete_subsubcmd },
|
||||||
|
bisect = { complete_subsubcmd },
|
||||||
|
submodule = { complete_subsubcmd },
|
||||||
|
}
|
||||||
|
|
||||||
|
---@class ow.Git.Cmd.CompleteState
|
||||||
|
---@field prior string[] -- positional and flag tokens before the current arg_lead
|
||||||
|
---@field after_separator boolean -- whether `--` appeared in prior
|
||||||
|
|
||||||
|
---@param cmd_line string
|
||||||
|
---@return ow.Git.Cmd.CompleteState
|
||||||
|
local function parse_complete_state(cmd_line)
|
||||||
|
local rest = cmd_line:gsub("^%s*%S+%s*", "", 1)
|
||||||
|
local trailing_space = rest == "" or rest:sub(-1):match("%s") ~= nil
|
||||||
|
local tokens = vim.split(vim.trim(rest), "%s+", { trimempty = true })
|
||||||
|
local prior = trailing_space and tokens
|
||||||
|
or vim.list_slice(tokens, 1, #tokens - 1)
|
||||||
|
local after_separator = false
|
||||||
|
for _, t in ipairs(prior) do
|
||||||
|
if t == "--" then
|
||||||
|
after_separator = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return { prior = prior, after_separator = after_separator }
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param prior string[] -- includes the subcommand at index 1
|
||||||
|
---@return integer
|
||||||
|
local function positional_index(prior)
|
||||||
|
local pos = 0
|
||||||
|
for i = 2, #prior do
|
||||||
|
if prior[i]:sub(1, 1) ~= "-" then
|
||||||
|
pos = pos + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param arg_lead string
|
||||||
|
---@param cmd_line string
|
||||||
|
---@return string[]
|
||||||
|
function M.complete(arg_lead, cmd_line, _)
|
||||||
|
local state = parse_complete_state(cmd_line)
|
||||||
|
local prior = state.prior
|
||||||
|
|
||||||
|
if #prior == 0 then
|
||||||
|
return prefix_filter(git_cmds(), arg_lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
local sub = prior[1] --[[@as string]]
|
||||||
|
|
||||||
|
if arg_lead:sub(1, 1) == "-" then
|
||||||
|
return prefix_filter(fetch_flags(sub), arg_lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
local r = repo.resolve()
|
||||||
|
if not r then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
if state.after_separator then
|
||||||
|
return complete_tracked_paths(r, arg_lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
local handlers = POSITIONAL_HANDLER[sub]
|
||||||
|
if not handlers then
|
||||||
|
return complete_tracked_paths(r, arg_lead)
|
||||||
|
end
|
||||||
|
|
||||||
|
local idx = positional_index(prior)
|
||||||
|
local slot = handlers[idx] or handlers[#handlers]
|
||||||
|
if not slot then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
if type(slot) == "function" then
|
||||||
|
return slot(r, arg_lead, sub, idx)
|
||||||
|
end
|
||||||
|
local result = {}
|
||||||
|
for _, fn in ipairs(slot) do
|
||||||
|
vim.list_extend(result, fn(r, arg_lead, sub, idx))
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
M._parse_complete_state = parse_complete_state
|
||||||
|
M._positional_index = positional_index
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -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
|
||||||
@@ -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<string, ow.Git.Repo> keyed by worktree
|
||||||
|
local repos = {}
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
local function release_if_unused(r)
|
||||||
|
if repos[r.worktree] ~= r then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if next(r.buffers) ~= nil or next(r.tabs) ~= nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
r:close()
|
||||||
|
repos[r.worktree] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class ow.Git.Repo.Change
|
||||||
|
---@field paths table<string, true>
|
||||||
|
---@field branch_changed boolean
|
||||||
|
|
||||||
|
---@class ow.Git.Repo.RefreshOpts
|
||||||
|
---@field invalidate boolean?
|
||||||
|
|
||||||
|
---@class ow.Git.Repo.SubmoduleEntry
|
||||||
|
---@field worktree string
|
||||||
|
---@field unsub fun()?
|
||||||
|
|
||||||
|
---@class ow.Git.Repo
|
||||||
|
---@field gitdir string
|
||||||
|
---@field worktree string
|
||||||
|
---@field buffers table<integer, ow.Git.Repo.BufState>
|
||||||
|
---@field tabs table<integer, true>
|
||||||
|
---@field status ow.Git.Status
|
||||||
|
---@field private _events ow.Git.Util.Emitter<ow.Git.Repo.Event>
|
||||||
|
---@field private _watchers table<string, uv.uv_fs_event_t>
|
||||||
|
---@field private _schedule_refresh fun(self: ow.Git.Repo)
|
||||||
|
---@field private _refresh_handle ow.Git.Util.DebounceHandle
|
||||||
|
---@field private _cache table<string, any>
|
||||||
|
---@field private _fetch_epoch integer
|
||||||
|
---@field private _pending_invalidate boolean
|
||||||
|
---@field package _submodules table<string, ow.Git.Repo.SubmoduleEntry>
|
||||||
|
local Repo = {}
|
||||||
|
Repo.__index = Repo
|
||||||
|
|
||||||
|
local STATUS_ARGS = {
|
||||||
|
"--no-optional-locks",
|
||||||
|
"-c",
|
||||||
|
"core.quotePath=false",
|
||||||
|
"status",
|
||||||
|
"--porcelain=v2",
|
||||||
|
"--branch",
|
||||||
|
"--ignored",
|
||||||
|
"--untracked-files=all",
|
||||||
|
"-z",
|
||||||
|
}
|
||||||
|
|
||||||
|
local PSEUDO_REFS = {
|
||||||
|
"HEAD",
|
||||||
|
"FETCH_HEAD",
|
||||||
|
"ORIG_HEAD",
|
||||||
|
"MERGE_HEAD",
|
||||||
|
"REBASE_HEAD",
|
||||||
|
"CHERRY_PICK_HEAD",
|
||||||
|
"REVERT_HEAD",
|
||||||
|
}
|
||||||
|
|
||||||
|
---@type table<string, fun(relpath: string): boolean>
|
||||||
|
local INVALIDATION_RULES = {
|
||||||
|
head = function(relpath)
|
||||||
|
return relpath == "HEAD"
|
||||||
|
or vim.startswith(relpath, "refs/heads/")
|
||||||
|
or relpath == "packed-refs"
|
||||||
|
end,
|
||||||
|
refs = function(relpath)
|
||||||
|
return vim.startswith(relpath, "refs/heads/")
|
||||||
|
or vim.startswith(relpath, "refs/tags/")
|
||||||
|
or vim.startswith(relpath, "refs/remotes/")
|
||||||
|
or relpath == "packed-refs"
|
||||||
|
end,
|
||||||
|
pseudo_refs = function(relpath)
|
||||||
|
return vim.tbl_contains(PSEUDO_REFS, relpath)
|
||||||
|
end,
|
||||||
|
stash_refs = function(relpath)
|
||||||
|
return relpath == "refs/stash" or relpath == "logs/refs/stash"
|
||||||
|
end,
|
||||||
|
config = function(relpath)
|
||||||
|
return relpath == "config"
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
---@param relpath string
|
||||||
|
---@return boolean
|
||||||
|
local function affects_resolve(relpath)
|
||||||
|
return vim.startswith(relpath, "refs/")
|
||||||
|
or relpath == "packed-refs"
|
||||||
|
or relpath == "HEAD"
|
||||||
|
or relpath == "FETCH_HEAD"
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
---@param prefix string
|
||||||
|
function Repo:_clear_cache_prefix(prefix)
|
||||||
|
for key in pairs(self._cache) do
|
||||||
|
if vim.startswith(key, prefix) then
|
||||||
|
self._cache[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
---@param relpath string
|
||||||
|
function Repo:_invalidate(relpath)
|
||||||
|
for key, affects in pairs(INVALIDATION_RULES) do
|
||||||
|
if self._cache[key] ~= nil and affects(relpath) then
|
||||||
|
self._cache[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if affects_resolve(relpath) then
|
||||||
|
self:_clear_cache_prefix("resolve:")
|
||||||
|
self:_clear_cache_prefix("head_blob:")
|
||||||
|
end
|
||||||
|
if relpath == "index" then
|
||||||
|
self:_clear_cache_prefix("index:")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param path string
|
||||||
|
---@return table<string, table<string, string>>?
|
||||||
|
local function read_git_config(path)
|
||||||
|
local f = io.open(path, "r")
|
||||||
|
if not f then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local content = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
local out = {}
|
||||||
|
local section
|
||||||
|
for line in content:gmatch("[^\n]+") do
|
||||||
|
local trimmed = line:match("^%s*(.-)%s*$")
|
||||||
|
if trimmed ~= "" and not trimmed:match("^[#;]") then
|
||||||
|
local s = trimmed:match("^%[(.-)%]$")
|
||||||
|
if s then
|
||||||
|
section = s
|
||||||
|
out[section] = out[section] or {}
|
||||||
|
elseif section then
|
||||||
|
local key, value =
|
||||||
|
trimmed:match("^(%S+)%s*=%s*(.-)$")
|
||||||
|
if key then
|
||||||
|
out[section][key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param gitdir string
|
||||||
|
---@return string[]
|
||||||
|
local function find_submodules(gitdir)
|
||||||
|
local handle = vim.uv.fs_scandir(vim.fs.joinpath(gitdir, "modules"))
|
||||||
|
if not handle then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
while true do
|
||||||
|
local name, typ = vim.uv.fs_scandir_next(handle)
|
||||||
|
if not name then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
if typ == "directory" then
|
||||||
|
table.insert(out, name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
function Repo:_fetch_status()
|
||||||
|
if self._pending_invalidate then
|
||||||
|
self._cache = {}
|
||||||
|
self._pending_invalidate = false
|
||||||
|
end
|
||||||
|
local prior_entries = self.status.entries
|
||||||
|
local prior_branch = self.status.branch
|
||||||
|
self._fetch_epoch = self._fetch_epoch + 1
|
||||||
|
local epoch = self._fetch_epoch
|
||||||
|
util.git(STATUS_ARGS, {
|
||||||
|
cwd = self.worktree,
|
||||||
|
on_exit = function(result)
|
||||||
|
if epoch ~= self._fetch_epoch then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if result.code ~= 0 then
|
||||||
|
util.error(
|
||||||
|
"git status failed: %s",
|
||||||
|
vim.trim(result.stderr or "")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.status = status.parse(result.stdout or "")
|
||||||
|
local change = {
|
||||||
|
paths = status.diff_entries(
|
||||||
|
prior_entries,
|
||||||
|
self.status.entries
|
||||||
|
),
|
||||||
|
branch_changed = not vim.deep_equal(
|
||||||
|
prior_branch,
|
||||||
|
self.status.branch
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if next(change.paths) == nil and not change.branch_changed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._events:emit("change", change, self.status)
|
||||||
|
global:emit("change", self, change, self.status)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param opts ow.Git.Repo.RefreshOpts?
|
||||||
|
function Repo:refresh(opts)
|
||||||
|
if opts and opts.invalidate then
|
||||||
|
self._pending_invalidate = true
|
||||||
|
end
|
||||||
|
self:_schedule_refresh()
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param gitdir string
|
||||||
|
---@param worktree string
|
||||||
|
---@return ow.Git.Repo
|
||||||
|
function Repo.new(gitdir, worktree)
|
||||||
|
local self = setmetatable({
|
||||||
|
gitdir = gitdir,
|
||||||
|
worktree = worktree,
|
||||||
|
buffers = {},
|
||||||
|
tabs = {},
|
||||||
|
status = status.parse(""),
|
||||||
|
_events = util.Emitter.new(),
|
||||||
|
_cache = {},
|
||||||
|
_fetch_epoch = 0,
|
||||||
|
_pending_invalidate = false,
|
||||||
|
_submodules = {},
|
||||||
|
}, Repo)
|
||||||
|
self._schedule_refresh, self._refresh_handle =
|
||||||
|
util.debounce(Repo._fetch_status, 50)
|
||||||
|
self:start_watcher()
|
||||||
|
self:refresh()
|
||||||
|
if vim.g.git_submodule_recursion ~= false then
|
||||||
|
self:_start_modules_watcher()
|
||||||
|
for _, name in ipairs(find_submodules(gitdir)) do
|
||||||
|
self:_register_submodule(name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
---@generic T
|
||||||
|
---@param key string
|
||||||
|
---@param compute fun(self: ow.Git.Repo): T
|
||||||
|
---@return T
|
||||||
|
function Repo:get_cached(key, compute)
|
||||||
|
local hit = self._cache[key]
|
||||||
|
if hit ~= nil then
|
||||||
|
return hit
|
||||||
|
end
|
||||||
|
local value = compute(self)
|
||||||
|
self._cache[key] = value
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param path string
|
||||||
|
---@param on_event fun(filename: string?)
|
||||||
|
---@return uv.uv_fs_event_t?
|
||||||
|
local function start_fs_event(path, on_event)
|
||||||
|
local watcher = vim.uv.new_fs_event()
|
||||||
|
if not watcher then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local ok = watcher:start(path, {}, function(err, filename)
|
||||||
|
if err then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
on_event(filename)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
watcher:close()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return watcher
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
---@param name string
|
||||||
|
function Repo:_unregister_submodule(name)
|
||||||
|
local entry = self._submodules[name]
|
||||||
|
if not entry then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._submodules[name] = nil
|
||||||
|
if entry.unsub then
|
||||||
|
entry.unsub()
|
||||||
|
end
|
||||||
|
local child = repos[entry.worktree]
|
||||||
|
if child then
|
||||||
|
release_if_unused(child)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
---@param name string
|
||||||
|
function Repo:_register_submodule(name)
|
||||||
|
local sub_gitdir = vim.fs.joinpath(self.gitdir, "modules", name)
|
||||||
|
local cfg = read_git_config(vim.fs.joinpath(sub_gitdir, "config"))
|
||||||
|
local raw = cfg and cfg.core and cfg.core.worktree
|
||||||
|
if not raw then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local wt = raw:match("^/") and raw or vim.fs.joinpath(sub_gitdir, raw)
|
||||||
|
wt = vim.fs.normalize(wt)
|
||||||
|
local existing = self._submodules[name]
|
||||||
|
if existing and existing.worktree == wt then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if existing then
|
||||||
|
self:_unregister_submodule(name)
|
||||||
|
end
|
||||||
|
local child = repos[wt] or M.resolve(wt)
|
||||||
|
if not child then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._submodules[name] = {
|
||||||
|
worktree = wt,
|
||||||
|
unsub = child:on("change", function()
|
||||||
|
self:refresh()
|
||||||
|
end),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
function Repo:_start_modules_watcher()
|
||||||
|
local dir = vim.fs.joinpath(self.gitdir, "modules")
|
||||||
|
if self._watchers[dir] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not vim.uv.fs_stat(dir) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._watchers[dir] = start_fs_event(dir, function(filename)
|
||||||
|
if not filename then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if vim.uv.fs_stat(vim.fs.joinpath(dir, filename)) then
|
||||||
|
self:_register_submodule(filename)
|
||||||
|
else
|
||||||
|
self:_unregister_submodule(filename)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
function Repo:_stop_modules_watcher()
|
||||||
|
local dir = vim.fs.joinpath(self.gitdir, "modules")
|
||||||
|
local w = self._watchers[dir]
|
||||||
|
if w then
|
||||||
|
w:stop()
|
||||||
|
w:close()
|
||||||
|
self._watchers[dir] = nil
|
||||||
|
end
|
||||||
|
for _, name in ipairs(vim.tbl_keys(self._submodules)) do
|
||||||
|
self:_unregister_submodule(name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
---@param relpath string
|
||||||
|
function Repo:_handle_fs_event(relpath)
|
||||||
|
if vim.startswith(relpath, "objects") then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self:_invalidate(relpath)
|
||||||
|
if relpath == "modules" and vim.g.git_submodule_recursion ~= false then
|
||||||
|
if vim.uv.fs_stat(vim.fs.joinpath(self.gitdir, "modules")) then
|
||||||
|
self:_start_modules_watcher()
|
||||||
|
for _, name in ipairs(find_submodules(self.gitdir)) do
|
||||||
|
self:_register_submodule(name)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self:_stop_modules_watcher()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if vim.startswith(relpath, "logs") then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self:refresh()
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
---@param relpath string gitdir-relative path of the directory to watch
|
||||||
|
function Repo:_watch_tree(relpath)
|
||||||
|
local path = vim.fs.joinpath(self.gitdir, relpath)
|
||||||
|
if self._watchers[path] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local stat = vim.uv.fs_stat(path)
|
||||||
|
if not stat or stat.type ~= "directory" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local watcher = start_fs_event(path, function(filename)
|
||||||
|
if not vim.uv.fs_stat(path) then
|
||||||
|
local w = self._watchers[path] --[[@as uv.uv_fs_event_t?]]
|
||||||
|
if w then
|
||||||
|
w:stop()
|
||||||
|
w:close()
|
||||||
|
self._watchers[path] = nil
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if filename then
|
||||||
|
local child = vim.fs.joinpath(relpath, filename)
|
||||||
|
self:_handle_fs_event(child)
|
||||||
|
vim.schedule(function()
|
||||||
|
self:_watch_tree(child)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
self:refresh({ invalidate = true })
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not watcher then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._watchers[path] = watcher
|
||||||
|
local handle = vim.uv.fs_scandir(path)
|
||||||
|
if not handle then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
while true do
|
||||||
|
local name, typ = vim.uv.fs_scandir_next(handle)
|
||||||
|
if not name then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
if typ == "directory" then
|
||||||
|
self:_watch_tree(vim.fs.joinpath(relpath, name))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Repo:start_watcher()
|
||||||
|
self._watchers = {}
|
||||||
|
local top = start_fs_event(self.gitdir, function(filename)
|
||||||
|
if not filename then
|
||||||
|
self:refresh({ invalidate = true })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self:_handle_fs_event(filename)
|
||||||
|
end)
|
||||||
|
if not top then
|
||||||
|
util.error("git: failed to watch %s", self.gitdir)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._watchers[self.gitdir] = top
|
||||||
|
self:_watch_tree("refs")
|
||||||
|
end
|
||||||
|
|
||||||
|
function Repo:close()
|
||||||
|
for _, watcher in pairs(self._watchers) do
|
||||||
|
watcher:stop()
|
||||||
|
watcher:close()
|
||||||
|
end
|
||||||
|
self._watchers = {}
|
||||||
|
self:_stop_modules_watcher()
|
||||||
|
self._refresh_handle.close()
|
||||||
|
self._events:clear()
|
||||||
|
end
|
||||||
|
|
||||||
|
---@overload fun(event: "change", fn: fun(change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
|
||||||
|
function Repo:on(event, fn)
|
||||||
|
return self._events:on(event, fn)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf? integer
|
||||||
|
---@return ow.Git.Repo.BufState?
|
||||||
|
function Repo:state(buf)
|
||||||
|
return self.buffers[expand_buf(buf)]
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string?
|
||||||
|
function Repo:head()
|
||||||
|
return self:get_cached("head", function(self)
|
||||||
|
local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r")
|
||||||
|
if not f then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local first = f:read("*l")
|
||||||
|
f:close()
|
||||||
|
if not first then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local branch = first:match("^ref:%s*refs/heads/(%S+)")
|
||||||
|
if branch then
|
||||||
|
return branch
|
||||||
|
end
|
||||||
|
local sha = first:match("^(%x+)")
|
||||||
|
if sha then
|
||||||
|
return sha:sub(1, 7)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string[]
|
||||||
|
function Repo:list_refs()
|
||||||
|
return self:get_cached("refs", function(self)
|
||||||
|
local out = util.git({
|
||||||
|
"for-each-ref",
|
||||||
|
"--format=%(refname:short)",
|
||||||
|
"refs/heads",
|
||||||
|
"refs/tags",
|
||||||
|
"refs/remotes",
|
||||||
|
}, { cwd = self.worktree, silent = true })
|
||||||
|
if not out then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
return util.split_lines(out)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string[]
|
||||||
|
function Repo:list_pseudo_refs()
|
||||||
|
return self:get_cached("pseudo_refs", function(self)
|
||||||
|
local refs = {}
|
||||||
|
for _, name in ipairs(PSEUDO_REFS) do
|
||||||
|
if name == "HEAD" or vim.uv.fs_stat(self.gitdir .. "/" .. name) then
|
||||||
|
table.insert(refs, name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return refs
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string[]
|
||||||
|
function Repo:list_stash_refs()
|
||||||
|
return self:get_cached("stash_refs", function(self)
|
||||||
|
if not vim.uv.fs_stat(self.gitdir .. "/refs/stash") then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local refs = { "stash" }
|
||||||
|
local out = util.git(
|
||||||
|
{ "stash", "list", "--pretty=format:%gd" },
|
||||||
|
{ cwd = self.worktree, silent = true }
|
||||||
|
)
|
||||||
|
if out then
|
||||||
|
for _, entry in ipairs(util.split_lines(out)) do
|
||||||
|
table.insert(refs, entry)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return refs
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param rev string
|
||||||
|
---@param short boolean
|
||||||
|
---@return string?
|
||||||
|
function Repo:rev_parse(rev, short)
|
||||||
|
local args = { "rev-parse", "--verify", "--quiet" }
|
||||||
|
if short then
|
||||||
|
table.insert(args, "--short")
|
||||||
|
end
|
||||||
|
table.insert(args, rev)
|
||||||
|
local stdout = util.git(args, { cwd = self.worktree, silent = true })
|
||||||
|
local trimmed = stdout and vim.trim(stdout) or ""
|
||||||
|
return trimmed ~= "" and trimmed or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param rel string worktree-relative path
|
||||||
|
---@return string?
|
||||||
|
function Repo:index_sha(rel)
|
||||||
|
local sha = self:get_cached("index:" .. rel, function(self)
|
||||||
|
return self:rev_parse(":" .. rel, false) or false
|
||||||
|
end)
|
||||||
|
return sha or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param rel string worktree-relative path
|
||||||
|
---@return string?
|
||||||
|
function Repo:head_sha(rel)
|
||||||
|
local sha = self:get_cached("head_blob:" .. rel, function(self)
|
||||||
|
return self:rev_parse("HEAD:" .. rel, false) or false
|
||||||
|
end)
|
||||||
|
return sha or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing"
|
||||||
|
|
||||||
|
---@param abbrev string
|
||||||
|
---@return string? full_sha
|
||||||
|
---@return ow.Git.Repo.ResolveStatus
|
||||||
|
function Repo:resolve_sha(abbrev)
|
||||||
|
local result = self:get_cached("resolve:" .. abbrev, function(self)
|
||||||
|
local out = util.git(
|
||||||
|
{ "rev-parse", "--disambiguate=" .. abbrev },
|
||||||
|
{ cwd = self.worktree, silent = true }
|
||||||
|
)
|
||||||
|
local trimmed = out and vim.trim(out) or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return { nil, "missing" }
|
||||||
|
end
|
||||||
|
local lines = util.split_lines(trimmed)
|
||||||
|
if #lines == 1 then
|
||||||
|
return { lines[1], "ok" }
|
||||||
|
end
|
||||||
|
return { nil, "ambiguous" }
|
||||||
|
end)
|
||||||
|
return result[1], result[2]
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
---@return table<string, table<string, string>>
|
||||||
|
function Repo:_config()
|
||||||
|
return self:get_cached("config", function(self)
|
||||||
|
return read_git_config(vim.fs.joinpath(self.gitdir, "config")) or {}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@private
|
||||||
|
---@return boolean
|
||||||
|
function Repo:_ignorecase()
|
||||||
|
local cfg = self:_config()
|
||||||
|
return cfg.core and cfg.core.ignorecase == "true" or false
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param rel string
|
||||||
|
---@return ow.Git.Status.Entry?
|
||||||
|
function Repo:status_entry_for(rel)
|
||||||
|
local direct = self.status.entries[rel]
|
||||||
|
if direct or not self:_ignorecase() then
|
||||||
|
return direct
|
||||||
|
end
|
||||||
|
local lower = rel:lower()
|
||||||
|
for path, entry in pairs(self.status.entries) do
|
||||||
|
if path:lower() == lower then
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@type table<string, true>
|
||||||
|
local no_repo_dirs = {}
|
||||||
|
|
||||||
|
---@overload fun(event: "change", fn: fun(r: ow.Git.Repo, change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
|
||||||
|
function M.on(event, fn)
|
||||||
|
return global:on(event, fn)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param prefix string
|
||||||
|
---@param fn fun(buf: integer, r: ow.Git.Repo)
|
||||||
|
---@return fun() unsubscribe
|
||||||
|
function M.on_uri_change(prefix, fn)
|
||||||
|
return M.on("change", function(r)
|
||||||
|
for buf in pairs(r.buffers) do
|
||||||
|
if vim.api.nvim_buf_is_loaded(buf) then
|
||||||
|
local name = vim.api.nvim_buf_get_name(buf)
|
||||||
|
if name:sub(1, #prefix) == prefix then
|
||||||
|
fn(buf, r)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return table<string, ow.Git.Repo>
|
||||||
|
function M.all()
|
||||||
|
return repos
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@return ow.Git.Repo?
|
||||||
|
local function find_by_buf(buf)
|
||||||
|
for _, r in pairs(repos) do
|
||||||
|
if r.buffers[buf] then
|
||||||
|
return r
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param path string
|
||||||
|
---@return ow.Git.Repo?
|
||||||
|
local function find_by_path(path)
|
||||||
|
if path == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if repos[path] then
|
||||||
|
return repos[path]
|
||||||
|
end
|
||||||
|
local best
|
||||||
|
for wt in pairs(repos) do
|
||||||
|
if path:sub(1, #wt + 1) == wt .. "/" then
|
||||||
|
if not best or #wt > #best then
|
||||||
|
best = wt
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best and repos[best] or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@return string
|
||||||
|
local function path_for_buf(buf)
|
||||||
|
local path = vim.api.nvim_buf_get_name(buf)
|
||||||
|
if path == "" or util.is_uri(path) then
|
||||||
|
return vim.fn.getcwd()
|
||||||
|
end
|
||||||
|
return vim.fn.resolve(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param arg? integer | string bufnr (default current) or worktree path
|
||||||
|
---@return ow.Git.Repo?
|
||||||
|
function M.find(arg)
|
||||||
|
if type(arg) == "string" then
|
||||||
|
return find_by_path(arg)
|
||||||
|
end
|
||||||
|
local buf = expand_buf(arg)
|
||||||
|
return find_by_buf(buf) or find_by_path(path_for_buf(buf))
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param arg? integer | string bufnr (default current) or worktree path
|
||||||
|
---@return ow.Git.Repo?
|
||||||
|
function M.resolve(arg)
|
||||||
|
if type(arg) ~= "string" then
|
||||||
|
local existing = find_by_buf(expand_buf(arg))
|
||||||
|
if existing then
|
||||||
|
return existing
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local path
|
||||||
|
if type(arg) == "string" then
|
||||||
|
path = vim.fn.resolve(arg)
|
||||||
|
else
|
||||||
|
path = path_for_buf(expand_buf(arg))
|
||||||
|
end
|
||||||
|
local dir = vim.fs.dirname(path)
|
||||||
|
if no_repo_dirs[dir] then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local found = vim.fs.find(".git", { upward = true, path = path })[1]
|
||||||
|
if not found then
|
||||||
|
no_repo_dirs[dir] = true
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local worktree = vim.fs.dirname(found)
|
||||||
|
if repos[worktree] then
|
||||||
|
return repos[worktree]
|
||||||
|
end
|
||||||
|
local stat = vim.uv.fs_stat(found)
|
||||||
|
if not stat then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local gitdir
|
||||||
|
if stat.type == "directory" then
|
||||||
|
gitdir = found
|
||||||
|
else
|
||||||
|
local f = io.open(found, "r")
|
||||||
|
if not f then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local content = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
local rel = content:match("gitdir:%s*(%S+)")
|
||||||
|
if not rel then
|
||||||
|
util.error(".git file at %s has no `gitdir:` line", found)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
gitdir = vim.fs.normalize(
|
||||||
|
rel:match("^/") and rel or vim.fs.joinpath(worktree, rel)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
local r = Repo.new(gitdir, worktree)
|
||||||
|
repos[worktree] = r
|
||||||
|
for d in pairs(no_repo_dirs) do
|
||||||
|
if d == worktree or vim.startswith(d, worktree .. "/") then
|
||||||
|
no_repo_dirs[d] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return r
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf? integer
|
||||||
|
---@return ow.Git.Repo.BufState?
|
||||||
|
function M.state(buf)
|
||||||
|
buf = expand_buf(buf)
|
||||||
|
local r = find_by_buf(buf)
|
||||||
|
return r and r.buffers[buf]
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf? integer
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
function M.bind(buf, r)
|
||||||
|
buf = expand_buf(buf)
|
||||||
|
local prev = find_by_buf(buf)
|
||||||
|
if prev == r then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if prev then
|
||||||
|
prev.buffers[buf] = nil
|
||||||
|
release_if_unused(prev)
|
||||||
|
end
|
||||||
|
r.buffers[buf] = { repo = r }
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf? integer
|
||||||
|
function M.unbind(buf)
|
||||||
|
buf = expand_buf(buf)
|
||||||
|
local r = find_by_buf(buf)
|
||||||
|
if not r then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
r.buffers[buf] = nil
|
||||||
|
release_if_unused(r)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@return boolean
|
||||||
|
function M.is_worktree_buf(buf)
|
||||||
|
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local path = vim.api.nvim_buf_get_name(buf)
|
||||||
|
return path ~= "" and not util.is_uri(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf? integer
|
||||||
|
function M.track(buf)
|
||||||
|
buf = expand_buf(buf)
|
||||||
|
if not M.is_worktree_buf(buf) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local r = M.resolve(buf)
|
||||||
|
if r and not r.buffers[buf] then
|
||||||
|
M.bind(buf, r)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf? integer
|
||||||
|
function M.refresh(buf)
|
||||||
|
local r = find_by_buf(expand_buf(buf))
|
||||||
|
if r then
|
||||||
|
r:refresh()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.refresh_all()
|
||||||
|
for _, r in pairs(repos) do
|
||||||
|
r:refresh()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.update_cwd_repo()
|
||||||
|
no_repo_dirs = {}
|
||||||
|
local tab = vim.api.nvim_get_current_tabpage()
|
||||||
|
local new = M.resolve(vim.fn.getcwd())
|
||||||
|
local old
|
||||||
|
for _, r in pairs(repos) do
|
||||||
|
if r.tabs[tab] then
|
||||||
|
old = r
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if new == old then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if old then
|
||||||
|
old.tabs[tab] = nil
|
||||||
|
release_if_unused(old)
|
||||||
|
end
|
||||||
|
if new then
|
||||||
|
new.tabs[tab] = true
|
||||||
|
new:refresh()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param tab integer
|
||||||
|
function M.release_tab(tab)
|
||||||
|
for _, r in pairs(repos) do
|
||||||
|
if r.tabs[tab] then
|
||||||
|
r.tabs[tab] = nil
|
||||||
|
release_if_unused(r)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.stop_all()
|
||||||
|
for _, r in pairs(repos) do
|
||||||
|
r:close()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -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
|
||||||
@@ -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<string, ow.Git.Status.Entry>
|
||||||
|
local Status = {}
|
||||||
|
Status.__index = Status
|
||||||
|
|
||||||
|
local CHANGE_FROM_CHAR = {
|
||||||
|
M = "modified",
|
||||||
|
A = "added",
|
||||||
|
D = "deleted",
|
||||||
|
R = "renamed",
|
||||||
|
C = "copied",
|
||||||
|
T = "type_changed",
|
||||||
|
}
|
||||||
|
|
||||||
|
local CONFLICT_FROM_XY = {
|
||||||
|
DD = "both_deleted",
|
||||||
|
AU = "added_by_us",
|
||||||
|
UD = "deleted_by_them",
|
||||||
|
UA = "added_by_them",
|
||||||
|
DU = "deleted_by_us",
|
||||||
|
AA = "both_added",
|
||||||
|
UU = "both_modified",
|
||||||
|
}
|
||||||
|
|
||||||
|
local CHAR_FROM_CHANGE = {
|
||||||
|
modified = "M",
|
||||||
|
added = "A",
|
||||||
|
deleted = "D",
|
||||||
|
renamed = "R",
|
||||||
|
copied = "C",
|
||||||
|
type_changed = "T",
|
||||||
|
}
|
||||||
|
|
||||||
|
---@param s string
|
||||||
|
---@return string
|
||||||
|
local function pascal(s)
|
||||||
|
return (
|
||||||
|
s:sub(1, 1):upper()
|
||||||
|
.. s:sub(2):gsub("_(%a)", function(c)
|
||||||
|
return c:upper()
|
||||||
|
end)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param path string
|
||||||
|
---@param staged ow.Git.Status.Change?
|
||||||
|
---@param unstaged ow.Git.Status.Change?
|
||||||
|
---@param orig string?
|
||||||
|
---@return ow.Git.Status.ChangedEntry
|
||||||
|
local function changed(path, staged, unstaged, orig)
|
||||||
|
return {
|
||||||
|
kind = "changed",
|
||||||
|
path = path,
|
||||||
|
staged = staged,
|
||||||
|
unstaged = unstaged,
|
||||||
|
orig = orig,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param path string
|
||||||
|
---@param conflict ow.Git.Status.Conflict
|
||||||
|
---@return ow.Git.Status.UnmergedEntry
|
||||||
|
local function unmerged(path, conflict)
|
||||||
|
return { kind = "unmerged", path = path, conflict = conflict }
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param path string
|
||||||
|
---@return ow.Git.Status.UntrackedEntry
|
||||||
|
local function untracked(path)
|
||||||
|
return { kind = "untracked", path = path }
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param path string
|
||||||
|
---@return ow.Git.Status.IgnoredEntry
|
||||||
|
local function ignored(path)
|
||||||
|
return { kind = "ignored", path = path }
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param entry ow.Git.Status.Entry
|
||||||
|
---@param side ("staged"|"unstaged")?
|
||||||
|
---@return ow.Git.Status.Mark
|
||||||
|
function M.mark_for(entry, side)
|
||||||
|
if entry.kind == "untracked" then
|
||||||
|
return { char = "?", hl = "GitUntracked" }
|
||||||
|
end
|
||||||
|
if entry.kind == "ignored" then
|
||||||
|
return { char = "i", hl = "GitIgnored" }
|
||||||
|
end
|
||||||
|
if entry.kind == "unmerged" then
|
||||||
|
---@cast entry ow.Git.Status.UnmergedEntry
|
||||||
|
return { char = "!", hl = "GitUnmerged" .. pascal(entry.conflict) }
|
||||||
|
end
|
||||||
|
---@cast entry ow.Git.Status.ChangedEntry
|
||||||
|
assert(side, "mark_for: side required for changed entry")
|
||||||
|
local change = side == "staged" and entry.staged or entry.unstaged
|
||||||
|
assert(change, "mark_for: changed entry has no change on side " .. side)
|
||||||
|
return {
|
||||||
|
char = CHAR_FROM_CHANGE[change],
|
||||||
|
hl = "Git" .. pascal(side) .. pascal(change),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param entry ow.Git.Status.Entry
|
||||||
|
---@return ow.Git.Status.Mark[]
|
||||||
|
function M.marks_for(entry)
|
||||||
|
if entry.kind ~= "changed" then
|
||||||
|
return { M.mark_for(entry) }
|
||||||
|
end
|
||||||
|
---@cast entry ow.Git.Status.ChangedEntry
|
||||||
|
local out = {}
|
||||||
|
if entry.staged then
|
||||||
|
table.insert(out, M.mark_for(entry, "staged"))
|
||||||
|
end
|
||||||
|
if entry.unstaged then
|
||||||
|
table.insert(out, M.mark_for(entry, "unstaged"))
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param section ow.Git.Status.Section
|
||||||
|
---@return ow.Git.Status.Row[]
|
||||||
|
function Status:rows(section)
|
||||||
|
local out = {}
|
||||||
|
if section == "staged" or section == "unstaged" then
|
||||||
|
for _, entry in pairs(self.entries) do
|
||||||
|
if entry.kind == "changed" then
|
||||||
|
---@cast entry ow.Git.Status.ChangedEntry
|
||||||
|
if entry[section] then
|
||||||
|
table.insert(
|
||||||
|
out,
|
||||||
|
{ entry = entry, section = section, side = section }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for _, entry in pairs(self.entries) do
|
||||||
|
if entry.kind == section then
|
||||||
|
table.insert(out, { entry = entry, section = section })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param prefix string
|
||||||
|
---@return ow.Git.Status.Mark[]
|
||||||
|
function Status:aggregate_at(prefix)
|
||||||
|
local match = (prefix == "" or prefix == ".") and "" or prefix .. "/"
|
||||||
|
local seen = {}
|
||||||
|
local out = {}
|
||||||
|
for path, entry in pairs(self.entries) do
|
||||||
|
if path == prefix or vim.startswith(path, match) then
|
||||||
|
for _, mark in ipairs(M.marks_for(entry)) do
|
||||||
|
local key = mark.char .. "\0" .. mark.hl
|
||||||
|
if not seen[key] then
|
||||||
|
seen[key] = true
|
||||||
|
table.insert(out, mark)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(out, function(a, b)
|
||||||
|
return a.char < b.char
|
||||||
|
end)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param line string
|
||||||
|
---@param branch ow.Git.Status.Branch
|
||||||
|
local function parse_branch_header(line, branch)
|
||||||
|
local oid = line:match("^# branch%.oid (.+)$")
|
||||||
|
if oid then
|
||||||
|
branch.oid = oid ~= "(initial)" and oid or nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local head = line:match("^# branch%.head (.+)$")
|
||||||
|
if head then
|
||||||
|
branch.head = head ~= "(detached)" and head or nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local up = line:match("^# branch%.upstream (.+)$")
|
||||||
|
if up then
|
||||||
|
branch.upstream = up
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local a, b = line:match("^# branch%.ab %+(%d+) %-(%d+)$")
|
||||||
|
if a and b then
|
||||||
|
branch.ahead = tonumber(a) --[[@as integer]]
|
||||||
|
branch.behind = tonumber(b) --[[@as integer]]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param x string
|
||||||
|
---@param y string
|
||||||
|
---@return ow.Git.Status.Change?, ow.Git.Status.Change?
|
||||||
|
local function changes_from_xy(x, y)
|
||||||
|
local staged = x ~= "." and CHANGE_FROM_CHAR[x] or nil
|
||||||
|
local unstaged = y ~= "." and CHANGE_FROM_CHAR[y] or nil
|
||||||
|
return staged, unstaged
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param path string
|
||||||
|
---@return string
|
||||||
|
local function strip_dir_slash(path)
|
||||||
|
if path:sub(-1) == "/" then
|
||||||
|
return path:sub(1, -2)
|
||||||
|
end
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param a ow.Git.Status.Entry?
|
||||||
|
---@param b ow.Git.Status.Entry?
|
||||||
|
---@return boolean
|
||||||
|
function M.entry_equal(a, b)
|
||||||
|
if a == nil or b == nil then
|
||||||
|
return a == b
|
||||||
|
end
|
||||||
|
if a.kind ~= b.kind or a.path ~= b.path then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if a.kind == "changed" then
|
||||||
|
---@cast a ow.Git.Status.ChangedEntry
|
||||||
|
---@cast b ow.Git.Status.ChangedEntry
|
||||||
|
return a.staged == b.staged
|
||||||
|
and a.unstaged == b.unstaged
|
||||||
|
and a.orig == b.orig
|
||||||
|
end
|
||||||
|
if a.kind == "unmerged" then
|
||||||
|
---@cast a ow.Git.Status.UnmergedEntry
|
||||||
|
---@cast b ow.Git.Status.UnmergedEntry
|
||||||
|
return a.conflict == b.conflict
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param prior table<string, ow.Git.Status.Entry>
|
||||||
|
---@param next_ table<string, ow.Git.Status.Entry>
|
||||||
|
---@return table<string, true>
|
||||||
|
function M.diff_entries(prior, next_)
|
||||||
|
local paths = {}
|
||||||
|
for path, entry in pairs(next_) do
|
||||||
|
if not M.entry_equal(prior[path], entry) then
|
||||||
|
paths[path] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for path in pairs(prior) do
|
||||||
|
if next_[path] == nil then
|
||||||
|
paths[path] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return paths
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param stdout string
|
||||||
|
---@return ow.Git.Status
|
||||||
|
function M.parse(stdout)
|
||||||
|
---@type ow.Git.Status.Branch
|
||||||
|
local branch = { ahead = 0, behind = 0 }
|
||||||
|
---@type table<string, ow.Git.Status.Entry>
|
||||||
|
local entries = {}
|
||||||
|
|
||||||
|
local tokens = vim.split(stdout, "\0", { plain = true })
|
||||||
|
while #tokens > 0 and tokens[#tokens] == "" do
|
||||||
|
tokens[#tokens] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local i = 1
|
||||||
|
while i <= #tokens do
|
||||||
|
local line = tokens[i] --[[@as string]]
|
||||||
|
local tag = line:sub(1, 2)
|
||||||
|
if tag == "# " then
|
||||||
|
parse_branch_header(line, branch)
|
||||||
|
elseif tag == "1 " then
|
||||||
|
local xy, _, _, _, _, _, _, path =
|
||||||
|
line:match("^1 (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$")
|
||||||
|
if xy and path then
|
||||||
|
local key = strip_dir_slash(path)
|
||||||
|
local staged, unstaged =
|
||||||
|
changes_from_xy(xy:sub(1, 1), xy:sub(2, 2))
|
||||||
|
entries[key] = changed(key, staged, unstaged)
|
||||||
|
end
|
||||||
|
elseif tag == "2 " then
|
||||||
|
local xy, _, _, _, _, _, _, _, path = line:match(
|
||||||
|
"^2 (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$"
|
||||||
|
)
|
||||||
|
local orig = tokens[i + 1]
|
||||||
|
if xy and path and orig then
|
||||||
|
local key = strip_dir_slash(path)
|
||||||
|
local staged, unstaged =
|
||||||
|
changes_from_xy(xy:sub(1, 1), xy:sub(2, 2))
|
||||||
|
entries[key] = changed(key, staged, unstaged, orig)
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
elseif tag == "u " then
|
||||||
|
local xy, _, _, _, _, _, _, _, _, path = line:match(
|
||||||
|
"^u (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$"
|
||||||
|
)
|
||||||
|
local conflict = xy and CONFLICT_FROM_XY[xy] --[[@as ow.Git.Status.Conflict?]]
|
||||||
|
or nil
|
||||||
|
if conflict and path then
|
||||||
|
local key = strip_dir_slash(path)
|
||||||
|
entries[key] = unmerged(key, conflict)
|
||||||
|
end
|
||||||
|
elseif tag == "? " then
|
||||||
|
local key = strip_dir_slash(line:sub(3))
|
||||||
|
entries[key] = untracked(key)
|
||||||
|
elseif tag == "! " then
|
||||||
|
local key = strip_dir_slash(line:sub(3))
|
||||||
|
entries[key] = ignored(key)
|
||||||
|
end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
return setmetatable({ branch = branch, entries = entries }, Status)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -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<K>
|
||||||
|
---@field cancel fun(key: K)
|
||||||
|
---@field flush fun(key: K)
|
||||||
|
---@field pending fun(key: K): boolean
|
||||||
|
---@field close fun()
|
||||||
|
|
||||||
|
---@generic K, F: fun(key: K, ...)
|
||||||
|
---@param fn F
|
||||||
|
---@param delay integer
|
||||||
|
---@return F, ow.Git.Util.KeyedDebounceHandle<K>
|
||||||
|
function M.keyed_debounce(fn, delay)
|
||||||
|
---@type table<any, { call: fun(...), handle: ow.Git.Util.DebounceHandle }>
|
||||||
|
local slots = {}
|
||||||
|
|
||||||
|
local function call(key, ...)
|
||||||
|
local t = type(key)
|
||||||
|
assert(
|
||||||
|
t == "string" or t == "number" or t == "boolean",
|
||||||
|
"key must be a primitive (string, number, boolean)"
|
||||||
|
)
|
||||||
|
local slot = slots[key]
|
||||||
|
if not slot then
|
||||||
|
local c, h = M.debounce(function(...)
|
||||||
|
fn(key, ...)
|
||||||
|
end, delay)
|
||||||
|
slot = { call = c, handle = h }
|
||||||
|
slots[key] = slot
|
||||||
|
end
|
||||||
|
slot.call(...)
|
||||||
|
end
|
||||||
|
|
||||||
|
return call,
|
||||||
|
{
|
||||||
|
cancel = function(key)
|
||||||
|
local slot = slots[key]
|
||||||
|
if slot then
|
||||||
|
slot.handle.close()
|
||||||
|
slots[key] = nil
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
flush = function(key)
|
||||||
|
local slot = slots[key]
|
||||||
|
if slot then
|
||||||
|
slot.handle.flush()
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
pending = function(key)
|
||||||
|
local slot = slots[key]
|
||||||
|
return slot ~= nil and slot.handle.pending()
|
||||||
|
end,
|
||||||
|
close = function()
|
||||||
|
for _, slot in pairs(slots) do
|
||||||
|
slot.handle.close()
|
||||||
|
end
|
||||||
|
slots = {}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class ow.Git.Util.ExecOpts
|
||||||
|
---@field cwd string?
|
||||||
|
---@field stdin string?
|
||||||
|
---@field silent boolean?
|
||||||
|
---@field env table<string, string>?
|
||||||
|
---@field on_exit fun(result: vim.SystemCompleted)?
|
||||||
|
|
||||||
|
---@param cmd string[]
|
||||||
|
---@param opts ow.Git.Util.ExecOpts?
|
||||||
|
---@return string?
|
||||||
|
function M.exec(cmd, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local sys_opts = {
|
||||||
|
cwd = opts.cwd,
|
||||||
|
stdin = opts.stdin,
|
||||||
|
env = opts.env,
|
||||||
|
text = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.on_exit then
|
||||||
|
vim.system(cmd, sys_opts, vim.schedule_wrap(opts.on_exit))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = vim.system(cmd, sys_opts):wait()
|
||||||
|
if result.code ~= 0 then
|
||||||
|
if not opts.silent then
|
||||||
|
local label = cmd[2] and (cmd[1] .. " " .. cmd[2]) or cmd[1] or "?"
|
||||||
|
M.error("%s failed: %s", label, vim.trim(result.stderr or ""))
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return result.stdout or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
M.DEFAULT_GIT_ENV = {
|
||||||
|
GIT_TERMINAL_PROMPT = "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
---@param args string[]
|
||||||
|
---@param opts ow.Git.Util.ExecOpts?
|
||||||
|
---@return string?
|
||||||
|
function M.git(args, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
opts.env = vim.tbl_extend("force", M.DEFAULT_GIT_ENV, opts.env or {})
|
||||||
|
local cmd = { "git" }
|
||||||
|
vim.list_extend(cmd, args)
|
||||||
|
return M.exec(cmd, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class ow.Git.Util.Emitter<T>
|
||||||
|
---@field private _listeners table<T, (fun(...))[]>
|
||||||
|
local Emitter = {}
|
||||||
|
Emitter.__index = Emitter
|
||||||
|
|
||||||
|
---@return ow.Git.Util.Emitter<T>
|
||||||
|
function Emitter.new()
|
||||||
|
return setmetatable({ _listeners = {} }, Emitter)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param event T
|
||||||
|
---@param fn fun(...)
|
||||||
|
---@return fun() unsubscribe
|
||||||
|
function Emitter:on(event, fn)
|
||||||
|
local list = self._listeners[event] or {}
|
||||||
|
self._listeners[event] = list
|
||||||
|
table.insert(list, fn)
|
||||||
|
return function()
|
||||||
|
for i, f in ipairs(list) do
|
||||||
|
if f == fn then
|
||||||
|
table.remove(list, i)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param event T
|
||||||
|
function Emitter:emit(event, ...)
|
||||||
|
for _, fn in ipairs(self._listeners[event] or {}) do
|
||||||
|
fn(...)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Emitter:clear()
|
||||||
|
self._listeners = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
M.Emitter = Emitter
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -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
|
||||||
@@ -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<string,string> }
|
||||||
|
---@param on_open fun(file_path: string, done: fun())
|
||||||
|
---@param on_exit fun(result: vim.SystemCompleted)
|
||||||
|
function M.run(cmd, opts, on_open, on_exit)
|
||||||
|
opts = opts or {}
|
||||||
|
local on_stderr, finalize = build_stderr_handler(on_open)
|
||||||
|
|
||||||
|
local env = vim.tbl_extend("force", opts.env or {}, {
|
||||||
|
GIT_EDITOR = GIT_EDITOR,
|
||||||
|
GIT_SEQUENCE_EDITOR = GIT_EDITOR,
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.system(
|
||||||
|
cmd,
|
||||||
|
{
|
||||||
|
cwd = opts.cwd,
|
||||||
|
text = true,
|
||||||
|
env = env,
|
||||||
|
stderr = on_stderr,
|
||||||
|
},
|
||||||
|
vim.schedule_wrap(function(result)
|
||||||
|
finalize(result)
|
||||||
|
on_exit(result)
|
||||||
|
end)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -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<integer, ow.Git.Hunks.BufState>
|
||||||
|
local states = {}
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@return ow.Git.Hunks.BufState?
|
||||||
|
function M.state(buf)
|
||||||
|
return states[buf]
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer?
|
||||||
|
---@return integer
|
||||||
|
local function resolve_buf(buf)
|
||||||
|
return buf and buf ~= 0 and buf or vim.api.nvim_get_current_buf()
|
||||||
|
end
|
||||||
|
|
||||||
|
---Mirror the hunk-affecting parts of the user's 'diffopt' so the gutter
|
||||||
|
---lines up with what `:diffsplit` shows.
|
||||||
|
---@return table
|
||||||
|
local function diff_opts()
|
||||||
|
local opts = { result_type = "indices", algorithm = "myers" }
|
||||||
|
for _, item in ipairs(vim.split(vim.o.diffopt, ",", { plain = true })) do
|
||||||
|
if item == "indent-heuristic" then
|
||||||
|
opts.indent_heuristic = true
|
||||||
|
else
|
||||||
|
local algorithm = item:match("^algorithm:(.+)$")
|
||||||
|
if algorithm then
|
||||||
|
opts.algorithm = algorithm
|
||||||
|
end
|
||||||
|
local linematch = item:match("^linematch:(%d+)$")
|
||||||
|
if linematch then
|
||||||
|
opts.linematch = tonumber(linematch)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return opts
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param old_lines string[]
|
||||||
|
---@param new_lines string[]
|
||||||
|
---@return ow.Git.Hunks.Hunk[]
|
||||||
|
local function compute_hunks(old_lines, new_lines)
|
||||||
|
local raw = vim.text.diff(
|
||||||
|
table.concat(old_lines, "\n"),
|
||||||
|
table.concat(new_lines, "\n"),
|
||||||
|
diff_opts()
|
||||||
|
)
|
||||||
|
---@type ow.Git.Hunks.Hunk[]
|
||||||
|
local hunks = {}
|
||||||
|
if type(raw) ~= "table" then
|
||||||
|
return hunks
|
||||||
|
end
|
||||||
|
for _, h in ipairs(raw) do
|
||||||
|
local os_ = h[1] --[[@as integer]]
|
||||||
|
local oc = h[2] --[[@as integer]]
|
||||||
|
local ns_ = h[3] --[[@as integer]]
|
||||||
|
local nc = h[4] --[[@as integer]]
|
||||||
|
local typ ---@type ow.Git.Hunks.HunkType
|
||||||
|
if oc == 0 then
|
||||||
|
typ = "add"
|
||||||
|
elseif nc == 0 then
|
||||||
|
typ = "delete"
|
||||||
|
else
|
||||||
|
typ = "change"
|
||||||
|
end
|
||||||
|
local old = {}
|
||||||
|
if typ ~= "add" then
|
||||||
|
for i = os_, os_ + oc - 1 do
|
||||||
|
table.insert(old, old_lines[i] or "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local new = {}
|
||||||
|
if typ ~= "delete" then
|
||||||
|
for i = ns_, ns_ + nc - 1 do
|
||||||
|
table.insert(new, new_lines[i] or "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(hunks, {
|
||||||
|
old_start = os_,
|
||||||
|
old_count = oc,
|
||||||
|
new_start = ns_,
|
||||||
|
new_count = nc,
|
||||||
|
type = typ,
|
||||||
|
old_lines = old,
|
||||||
|
new_lines = new,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return hunks
|
||||||
|
end
|
||||||
|
|
||||||
|
---@type table<ow.Git.Hunks.HunkType, string>
|
||||||
|
local DEFAULT_SIGNS = { add = "┃", change = "┃", delete = "▁" }
|
||||||
|
|
||||||
|
---@return table<ow.Git.Hunks.HunkType, string>
|
||||||
|
local function resolve_signs()
|
||||||
|
local cfg = vim.g.git_hunk_signs
|
||||||
|
if type(cfg) ~= "table" then
|
||||||
|
return DEFAULT_SIGNS
|
||||||
|
end
|
||||||
|
return vim.tbl_extend("force", DEFAULT_SIGNS, cfg)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@type table<ow.Git.Hunks.HunkType, string>
|
||||||
|
local SIGN_HL = {
|
||||||
|
add = "GitHunkAdded",
|
||||||
|
change = "GitHunkChanged",
|
||||||
|
delete = "GitHunkRemoved",
|
||||||
|
}
|
||||||
|
|
||||||
|
---@type table<ow.Git.Hunks.HunkType, string>
|
||||||
|
local STAGED_SIGN_HL = {
|
||||||
|
add = "GitHunkStagedAdded",
|
||||||
|
change = "GitHunkStagedChanged",
|
||||||
|
delete = "GitHunkStagedRemoved",
|
||||||
|
}
|
||||||
|
|
||||||
|
---@param h ow.Git.Hunks.Hunk
|
||||||
|
---@param line_count integer
|
||||||
|
---@return integer[] 0-indexed buffer rows for the hunk
|
||||||
|
local function hunk_rows(h, line_count)
|
||||||
|
if h.type == "delete" then
|
||||||
|
local row = math.max(h.new_start, 1) - 1
|
||||||
|
if row >= line_count then
|
||||||
|
row = math.max(line_count - 1, 0)
|
||||||
|
end
|
||||||
|
return { row }
|
||||||
|
end
|
||||||
|
local rows = {}
|
||||||
|
for r = h.new_start, h.new_start + h.new_count - 1 do
|
||||||
|
local row = r - 1
|
||||||
|
if row >= 0 and row < line_count then
|
||||||
|
table.insert(rows, row)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param h ow.Git.Hunks.Hunk
|
||||||
|
---@return integer 1-indexed last index line the hunk occupies
|
||||||
|
local function index_end(h)
|
||||||
|
if h.old_count == 0 then
|
||||||
|
return h.old_start
|
||||||
|
end
|
||||||
|
return h.old_start + h.old_count - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param unstaged ow.Git.Hunks.Hunk[]
|
||||||
|
---@param iline integer 1-indexed index line
|
||||||
|
---@return integer? 1-indexed buffer line
|
||||||
|
local function index_to_buffer(unstaged, iline)
|
||||||
|
local delta = 0
|
||||||
|
for _, h in ipairs(unstaged) do
|
||||||
|
if
|
||||||
|
h.old_count > 0
|
||||||
|
and iline >= h.old_start
|
||||||
|
and iline <= index_end(h)
|
||||||
|
then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if iline > index_end(h) then
|
||||||
|
delta = delta + h.new_count - h.old_count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return iline + delta
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param state ow.Git.Hunks.BufState
|
||||||
|
---@param line_count integer
|
||||||
|
---@return { row: integer, hunk: ow.Git.Hunks.Hunk }[] row is a 0-indexed buffer row
|
||||||
|
local function staged_signs(state, line_count)
|
||||||
|
local out = {}
|
||||||
|
for _, h in ipairs(state.staged) do
|
||||||
|
local index_lines = {}
|
||||||
|
if h.type == "delete" then
|
||||||
|
table.insert(index_lines, math.max(h.new_start, 1))
|
||||||
|
else
|
||||||
|
for i = h.new_start, h.new_start + h.new_count - 1 do
|
||||||
|
table.insert(index_lines, i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, iline in ipairs(index_lines) do
|
||||||
|
local bline = index_to_buffer(state.hunks, iline)
|
||||||
|
if bline then
|
||||||
|
local row = math.min(math.max(bline - 1, 0), line_count - 1)
|
||||||
|
table.insert(out, { row = row, hunk = h })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
local function render_signs(buf)
|
||||||
|
if not vim.api.nvim_buf_is_valid(buf) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1)
|
||||||
|
local state = states[buf]
|
||||||
|
if not state or state.overlay then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local signs = resolve_signs()
|
||||||
|
local line_count = vim.api.nvim_buf_line_count(buf)
|
||||||
|
local signed = {}
|
||||||
|
for _, h in ipairs(state.hunks) do
|
||||||
|
for _, row in ipairs(hunk_rows(h, line_count)) do
|
||||||
|
signed[row] = true
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, row, 0, {
|
||||||
|
sign_text = signs[h.type],
|
||||||
|
sign_hl_group = SIGN_HL[h.type],
|
||||||
|
priority = 100,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, s in ipairs(staged_signs(state, line_count)) do
|
||||||
|
if not signed[s.row] then
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, s.row, 0, {
|
||||||
|
sign_text = signs[s.hunk.type],
|
||||||
|
sign_hl_group = STAGED_SIGN_HL[s.hunk.type],
|
||||||
|
priority = 100,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local SKIP_CAPTURES = { spell = true, nospell = true, conceal = true }
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@param lines string[]
|
||||||
|
---@return table[][]?
|
||||||
|
local function highlight_index(buf, lines)
|
||||||
|
if not vim.treesitter.highlighter.active[buf] then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local got, parser = pcall(vim.treesitter.get_parser, buf)
|
||||||
|
if not got or not parser then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local lang = parser:lang()
|
||||||
|
local query = vim.treesitter.query.get(lang, "highlights")
|
||||||
|
if not query then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local source = table.concat(lines, "\n")
|
||||||
|
local got_root, root = pcall(function()
|
||||||
|
local trees = vim.treesitter.get_string_parser(source, lang):parse()
|
||||||
|
local tree = trees and trees[1]
|
||||||
|
return tree and tree:root()
|
||||||
|
end)
|
||||||
|
if not got_root or not root then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
---@type table<integer, table<integer, string>>
|
||||||
|
local groups = {}
|
||||||
|
for id, node in query:iter_captures(root, source) do
|
||||||
|
local name = query.captures[id]
|
||||||
|
if name and name:sub(1, 1) ~= "_" and not SKIP_CAPTURES[name] then
|
||||||
|
local sr, sc, er, ec = node:range()
|
||||||
|
for row = sr, math.min(er, #lines - 1) do
|
||||||
|
local row_groups = groups[row] or {}
|
||||||
|
groups[row] = row_groups
|
||||||
|
local from = row == sr and sc or 0
|
||||||
|
local to = row == er and ec or #(lines[row + 1] or "")
|
||||||
|
for col = from, to - 1 do
|
||||||
|
row_groups[col] = name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
for row = 0, #lines - 1 do
|
||||||
|
local line = lines[row + 1] or ""
|
||||||
|
local row_groups = groups[row] or {}
|
||||||
|
local chunks = {}
|
||||||
|
local col = 0
|
||||||
|
while col < #line do
|
||||||
|
local name = row_groups[col]
|
||||||
|
local stop = col + 1
|
||||||
|
while stop < #line and row_groups[stop] == name do
|
||||||
|
stop = stop + 1
|
||||||
|
end
|
||||||
|
local hl ---@type string|string[]
|
||||||
|
if name then
|
||||||
|
hl = { "GitHunkDeleteLine", "@" .. name }
|
||||||
|
else
|
||||||
|
hl = "GitHunkDeleteLine"
|
||||||
|
end
|
||||||
|
table.insert(chunks, { line:sub(col + 1, stop), hl })
|
||||||
|
col = stop
|
||||||
|
end
|
||||||
|
out[row + 1] = chunks
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param h ow.Git.Hunks.Hunk
|
||||||
|
---@param hl_lines table[][]? per-index-line syntax chunks, or nil
|
||||||
|
---@return table[]
|
||||||
|
local function delete_virt_lines(h, hl_lines)
|
||||||
|
local width = vim.o.columns
|
||||||
|
local virt = {}
|
||||||
|
for i, line in ipairs(h.old_lines) do
|
||||||
|
local pad = math.max(width - vim.api.nvim_strwidth(line), 0)
|
||||||
|
local cached = hl_lines and hl_lines[h.old_start + i - 1]
|
||||||
|
if cached then
|
||||||
|
local chunks = vim.list_extend({}, cached)
|
||||||
|
table.insert(chunks, {
|
||||||
|
string.rep(" ", pad),
|
||||||
|
"GitHunkDeleteLine",
|
||||||
|
})
|
||||||
|
table.insert(virt, chunks)
|
||||||
|
else
|
||||||
|
table.insert(virt, {
|
||||||
|
{ line .. string.rep(" ", pad), "GitHunkDeleteLine" },
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return virt
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param state ow.Git.Hunks.BufState
|
||||||
|
---@param buf integer
|
||||||
|
---@return table[][]?
|
||||||
|
local function index_spans(state, buf)
|
||||||
|
if not state.index then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local cache = state.index_hl
|
||||||
|
if cache and cache.src == state.index then
|
||||||
|
return cache.lines
|
||||||
|
end
|
||||||
|
local lines = highlight_index(buf, state.index)
|
||||||
|
state.index_hl = { src = state.index, lines = lines }
|
||||||
|
return lines
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
local function render_overlay(buf)
|
||||||
|
if not vim.api.nvim_buf_is_valid(buf) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1)
|
||||||
|
local state = states[buf]
|
||||||
|
if not state or not state.overlay then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local line_count = vim.api.nvim_buf_line_count(buf)
|
||||||
|
local hl_lines = index_spans(state, buf)
|
||||||
|
for _, h in ipairs(state.hunks) do
|
||||||
|
if h.type ~= "delete" then
|
||||||
|
for r = h.new_start, h.new_start + h.new_count - 1 do
|
||||||
|
local row = r - 1
|
||||||
|
if row >= 0 and row < line_count then
|
||||||
|
pcall(
|
||||||
|
vim.api.nvim_buf_set_extmark,
|
||||||
|
buf,
|
||||||
|
NS_OVERLAY,
|
||||||
|
row,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
line_hl_group = "GitHunkAddLine",
|
||||||
|
priority = 100,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if h.type ~= "add" then
|
||||||
|
local row, above
|
||||||
|
if h.type == "delete" then
|
||||||
|
if h.new_start <= 0 then
|
||||||
|
row, above = 0, true
|
||||||
|
elseif h.new_start >= line_count then
|
||||||
|
row, above = math.max(line_count - 1, 0), false
|
||||||
|
else
|
||||||
|
row, above = h.new_start, true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
row, above = math.max(h.new_start - 1, 0), true
|
||||||
|
end
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, buf, NS_OVERLAY, row, 0, {
|
||||||
|
virt_lines = delete_virt_lines(h, hl_lines),
|
||||||
|
virt_lines_above = above,
|
||||||
|
right_gravity = false,
|
||||||
|
invalidate = true,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
local function render(buf)
|
||||||
|
render_signs(buf)
|
||||||
|
render_overlay(buf)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param state ow.Git.Hunks.BufState
|
||||||
|
---@param buf integer
|
||||||
|
---@param rev string
|
||||||
|
---@param want string? the wanted blob sha
|
||||||
|
---@param have string? the currently-loaded blob sha
|
||||||
|
---@param apply fun(lines: string[]?, sha: string?)
|
||||||
|
---@param after fun()
|
||||||
|
local function ensure_content(state, buf, rev, want, have, apply, after)
|
||||||
|
if not want then
|
||||||
|
apply(nil, nil)
|
||||||
|
return after()
|
||||||
|
end
|
||||||
|
if want == have then
|
||||||
|
return after()
|
||||||
|
end
|
||||||
|
util.git({ "cat-file", "-p", rev }, {
|
||||||
|
cwd = state.repo.worktree,
|
||||||
|
silent = true,
|
||||||
|
on_exit = function(res)
|
||||||
|
if states[buf] ~= state or not vim.api.nvim_buf_is_valid(buf) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if res.code == 0 then
|
||||||
|
apply(util.split_lines(res.stdout or ""), want)
|
||||||
|
else
|
||||||
|
apply(nil, nil)
|
||||||
|
end
|
||||||
|
after()
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
local function recompute(buf)
|
||||||
|
if not vim.api.nvim_buf_is_valid(buf) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local state = states[buf]
|
||||||
|
if not state then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local r = state.repo
|
||||||
|
ensure_content(
|
||||||
|
state,
|
||||||
|
buf,
|
||||||
|
":0:" .. state.rel,
|
||||||
|
r:index_sha(state.rel),
|
||||||
|
state.index_sha,
|
||||||
|
function(lines, sha)
|
||||||
|
state.index = lines
|
||||||
|
state.index_sha = sha
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
ensure_content(
|
||||||
|
state,
|
||||||
|
buf,
|
||||||
|
"HEAD:" .. state.rel,
|
||||||
|
r:head_sha(state.rel),
|
||||||
|
state.head_sha,
|
||||||
|
function(lines, sha)
|
||||||
|
state.head = lines
|
||||||
|
state.head_sha = sha
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
local new =
|
||||||
|
vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||||
|
state.hunks = state.index
|
||||||
|
and compute_hunks(state.index, new)
|
||||||
|
or {}
|
||||||
|
state.staged = state.head
|
||||||
|
and state.index
|
||||||
|
and compute_hunks(state.head, state.index)
|
||||||
|
or {}
|
||||||
|
render(buf)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local schedule, sched_handle = util.keyed_debounce(recompute, 100)
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
function M._flush(buf)
|
||||||
|
sched_handle.flush(buf)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
function M.attach(buf)
|
||||||
|
if states[buf] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not repo.is_worktree_buf(buf) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local r = repo.find(buf)
|
||||||
|
if not r then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(vim.api.nvim_buf_get_name(buf)))
|
||||||
|
if not rel then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
---@type ow.Git.Hunks.BufState
|
||||||
|
local state = {
|
||||||
|
repo = r,
|
||||||
|
rel = rel,
|
||||||
|
index = nil,
|
||||||
|
index_sha = nil,
|
||||||
|
head = nil,
|
||||||
|
head_sha = nil,
|
||||||
|
hunks = {},
|
||||||
|
staged = {},
|
||||||
|
overlay = 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
|
||||||
@@ -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("<CR>", true, false, true)
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@return boolean opened
|
||||||
|
local function open_under_cursor(buf)
|
||||||
|
local r = repo.resolve(buf)
|
||||||
|
-- Anchor past the leading graph chars (matches the leading sha column,
|
||||||
|
-- not any hex word that happens to appear later in the subject).
|
||||||
|
local sha = r
|
||||||
|
and vim.api.nvim_get_current_line():match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)")
|
||||||
|
if not sha then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
---@cast r -nil
|
||||||
|
require("git.object").open(r, sha, { split = false })
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
local function attach_dispatch(buf)
|
||||||
|
vim.keymap.set("n", "<CR>", function()
|
||||||
|
if not open_under_cursor(buf) then
|
||||||
|
vim.api.nvim_feedkeys(cr, "n", false)
|
||||||
|
end
|
||||||
|
end, { buffer = buf, silent = true, desc = "Open commit" })
|
||||||
|
vim.keymap.set("n", "gd", function()
|
||||||
|
open_under_cursor(buf)
|
||||||
|
end, { buffer = buf, silent = true, desc = "Open commit" })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param worktree string
|
||||||
|
---@param max_count integer?
|
||||||
|
---@return string?
|
||||||
|
local function fetch(worktree, max_count)
|
||||||
|
local args = {
|
||||||
|
"log",
|
||||||
|
"--graph",
|
||||||
|
"--all",
|
||||||
|
"--decorate",
|
||||||
|
"--date=short",
|
||||||
|
"--format=format:" .. LOG_FORMAT,
|
||||||
|
}
|
||||||
|
if max_count then
|
||||||
|
table.insert(args, "--max-count=" .. max_count)
|
||||||
|
end
|
||||||
|
return util.git(args, { cwd = worktree })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@type table<string, integer> -- worktree -> max_count
|
||||||
|
local max_counts = {}
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
local function populate(buf, r)
|
||||||
|
local stdout = fetch(r.worktree, max_counts[r.worktree])
|
||||||
|
if not stdout then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class ow.Git.Log.OpenOpts
|
||||||
|
---@field max_count integer?
|
||||||
|
|
||||||
|
---@type table<string, fun(s: string): any>
|
||||||
|
M.opt_parsers = {
|
||||||
|
max_count = tonumber,
|
||||||
|
}
|
||||||
|
|
||||||
|
---@param opts ow.Git.Log.OpenOpts?
|
||||||
|
function M.open(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local r = repo.resolve()
|
||||||
|
if not r then
|
||||||
|
util.error("not in a git repository")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
max_counts[r.worktree] = opts.max_count
|
||||||
|
local buf = vim.fn.bufadd(r.worktree .. "/GitLog")
|
||||||
|
|
||||||
|
local visible = vim.fn.bufwinid(buf)
|
||||||
|
if visible ~= -1 then
|
||||||
|
vim.api.nvim_set_current_win(visible)
|
||||||
|
populate(buf, r)
|
||||||
|
vim.api.nvim_win_set_cursor(visible, { 1, 0 })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.fn.bufload(buf)
|
||||||
|
repo.bind(buf, r)
|
||||||
|
util.setup_scratch(buf, { bufhidden = "hide" })
|
||||||
|
vim.bo[buf].filetype = "gitlog"
|
||||||
|
attach_dispatch(buf)
|
||||||
|
|
||||||
|
local win = util.place_buf(buf, nil)
|
||||||
|
vim.api.nvim_win_set_cursor(win, { 1, 0 })
|
||||||
|
populate(buf, r)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param cmd_opts table
|
||||||
|
function M.run_glog(cmd_opts)
|
||||||
|
local parsed = { max_count = 1000 }
|
||||||
|
for _, a in ipairs(cmd_opts.fargs) do
|
||||||
|
local k, v = a:match("^([%w_]+)=(.*)$")
|
||||||
|
if not k then
|
||||||
|
util.error("invalid argument: %s", a)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
---@cast v -nil
|
||||||
|
local parser = M.opt_parsers[k]
|
||||||
|
if parser then
|
||||||
|
local value = parser(v)
|
||||||
|
if value ~= nil then
|
||||||
|
parsed[k] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
M.open(parsed)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param arg_lead string
|
||||||
|
---@return string[]
|
||||||
|
function M.complete_glog(arg_lead)
|
||||||
|
local matches = {}
|
||||||
|
for k in pairs(M.opt_parsers) do
|
||||||
|
local prefix = k .. "="
|
||||||
|
if prefix:sub(1, #arg_lead) == arg_lead then
|
||||||
|
table.insert(matches, prefix)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(matches)
|
||||||
|
return matches
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -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("<CR>", true, false, true)
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
function M.attach_dispatch(buf)
|
||||||
|
vim.keymap.set("n", "<CR>", function()
|
||||||
|
if not M.open_under_cursor() then
|
||||||
|
vim.api.nvim_feedkeys(cr, "n", false)
|
||||||
|
end
|
||||||
|
end, { buffer = buf, silent = true, desc = "Open file at commit" })
|
||||||
|
vim.keymap.set("n", "gd", function()
|
||||||
|
M.open_under_cursor()
|
||||||
|
end, { buffer = buf, silent = true, desc = "Open file at commit" })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param rev ow.Git.Revision
|
||||||
|
---@return integer
|
||||||
|
function M.buf_for(r, rev)
|
||||||
|
local buf = vim.fn.bufadd(M.format_uri(rev))
|
||||||
|
repo.bind(buf, r)
|
||||||
|
vim.fn.bufload(buf)
|
||||||
|
return buf
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param rev ow.Git.Revision
|
||||||
|
---@param state ow.Git.Repo.BufState
|
||||||
|
---@param rev_sha string
|
||||||
|
---@return boolean ok
|
||||||
|
local function populate(buf, r, rev, state, rev_sha)
|
||||||
|
local rev_str = rev:format()
|
||||||
|
local stdout = util.git({ "cat-file", "-p", rev_str }, { cwd = r.worktree })
|
||||||
|
if not stdout then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if rev.path == nil then
|
||||||
|
local commit_sha = r:rev_parse(rev_str .. "^{commit}", true)
|
||||||
|
if commit_sha then
|
||||||
|
local patch = util.git({
|
||||||
|
"diff-tree",
|
||||||
|
"-p",
|
||||||
|
"--diff-merges=first-parent",
|
||||||
|
"--root",
|
||||||
|
"--no-commit-id",
|
||||||
|
commit_sha,
|
||||||
|
}, { cwd = r.worktree })
|
||||||
|
if patch then
|
||||||
|
stdout = (stdout:gsub("\n*$", "\n\n")) .. patch
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
|
||||||
|
state.sha = rev_sha
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
function M.read_uri(buf)
|
||||||
|
local name = vim.api.nvim_buf_get_name(buf)
|
||||||
|
local rev = M.parse_uri(name)
|
||||||
|
if not rev then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local r = repo.resolve(buf)
|
||||||
|
if not r then
|
||||||
|
util.error("git BufReadCmd %s: cannot resolve worktree", name)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
repo.bind(buf, r)
|
||||||
|
local state = r:state(buf) --[[@as -nil]]
|
||||||
|
|
||||||
|
local writable = rev.stage == 0 and rev.path ~= nil
|
||||||
|
util.setup_scratch(buf, {
|
||||||
|
bufhidden = "delete",
|
||||||
|
buftype = writable and "acwrite" or "nofile",
|
||||||
|
modifiable = writable,
|
||||||
|
})
|
||||||
|
|
||||||
|
local rev_sha = r:rev_parse(rev:format(), true)
|
||||||
|
if not rev_sha then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not populate(buf, r, rev, state, rev_sha) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
state.immutable = is_immutable_rev(rev)
|
||||||
|
|
||||||
|
if writable and not state.index_writer then
|
||||||
|
attach_index_writer(buf, r, rev.path --[[@as string]])
|
||||||
|
state.index_writer = true
|
||||||
|
end
|
||||||
|
|
||||||
|
if rev.path then
|
||||||
|
local ft = vim.filetype.match({ filename = rev.path, buf = buf })
|
||||||
|
if ft then
|
||||||
|
vim.bo[buf].filetype = ft
|
||||||
|
end
|
||||||
|
else
|
||||||
|
vim.bo[buf].filetype = "git"
|
||||||
|
end
|
||||||
|
|
||||||
|
M.attach_dispatch(buf)
|
||||||
|
|
||||||
|
vim.api.nvim_buf_call(buf, function()
|
||||||
|
vim.api.nvim_exec_autocmds("BufReadPost", { buf = buf })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
local function refresh(buf, r)
|
||||||
|
local state = r:state(buf)
|
||||||
|
if not state or state.immutable or vim.bo[buf].modified then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local rev = M.parse_uri(vim.api.nvim_buf_get_name(buf))
|
||||||
|
if not rev then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local rev_sha = r:rev_parse(rev:format(), true)
|
||||||
|
if not rev_sha or rev_sha == state.sha then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if state.sha == nil then
|
||||||
|
M.read_uri(buf)
|
||||||
|
else
|
||||||
|
populate(buf, r, rev, state, rev_sha)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@param path string
|
||||||
|
local function set_ft_from_path(buf, path)
|
||||||
|
local ft = vim.filetype.match({ filename = path, buf = buf })
|
||||||
|
if ft then
|
||||||
|
vim.bo[buf].filetype = ft
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param blob string?
|
||||||
|
---@param path string
|
||||||
|
---@return integer?
|
||||||
|
local function side_buf(r, blob, path)
|
||||||
|
if not blob or util.is_zero_sha(blob) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local full, status = r:resolve_sha(blob)
|
||||||
|
if status == "ambiguous" then
|
||||||
|
util.error("ambiguous blob abbreviation: %s", blob)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if full then
|
||||||
|
local buf = M.buf_for(r, Revision.new({ base = full }))
|
||||||
|
set_ft_from_path(buf, path)
|
||||||
|
return buf
|
||||||
|
end
|
||||||
|
local p = vim.fs.joinpath(r.worktree, path)
|
||||||
|
if vim.uv.fs_stat(p) then
|
||||||
|
local buf = vim.fn.bufadd(p)
|
||||||
|
vim.fn.bufload(buf)
|
||||||
|
return buf
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param blob string?
|
||||||
|
---@param path string
|
||||||
|
local function load_side(r, blob, path)
|
||||||
|
local buf = side_buf(r, blob, path)
|
||||||
|
if not buf then
|
||||||
|
util.error("no content for %s", path)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
vim.cmd.normal({ "m'", bang = true })
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param section ow.Git.DiffSection
|
||||||
|
local function open_section(r, section)
|
||||||
|
if not section.blob_a or not section.blob_b then
|
||||||
|
util.error("no index line, cannot determine blob SHAs")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local left = side_buf(r, section.blob_a, section.path_a)
|
||||||
|
local right = side_buf(r, section.blob_b, section.path_b)
|
||||||
|
if left and right then
|
||||||
|
vim.cmd.normal({ "m'", bang = true })
|
||||||
|
vim.api.nvim_set_current_buf(right)
|
||||||
|
require("git.diffsplit").open({
|
||||||
|
target = vim.api.nvim_buf_get_name(left),
|
||||||
|
mods = { vertical = true },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not left and not right then
|
||||||
|
util.error("no content for %s", section.path_b)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local buf = left or right
|
||||||
|
---@cast buf -nil
|
||||||
|
vim.cmd.normal({ "m'", bang = true })
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class ow.Git.Object.OpenOpts
|
||||||
|
---@field split (false|"above"|"below"|"left"|"right")?
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param rev string
|
||||||
|
---@param opts ow.Git.Object.OpenOpts?
|
||||||
|
function M.open(r, rev, opts)
|
||||||
|
local parsed = Revision.parse(rev)
|
||||||
|
if parsed.base then
|
||||||
|
local sha = r:rev_parse(parsed.base, false)
|
||||||
|
if not sha then
|
||||||
|
util.error("not a git object: %s", rev)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
parsed.base = sha
|
||||||
|
end
|
||||||
|
if parsed.path and not r:rev_parse(parsed:format(), false) then
|
||||||
|
util.error("not a git object: %s", rev)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local buf = M.buf_for(r, parsed)
|
||||||
|
util.place_buf(buf, opts and opts.split)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return boolean dispatched
|
||||||
|
function M.open_under_cursor()
|
||||||
|
local r = repo.resolve()
|
||||||
|
if not r then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local line = vim.api.nvim_get_current_line()
|
||||||
|
|
||||||
|
local sha = line:match("^commit (%x+)$")
|
||||||
|
or line:match("^parent (%x+)$")
|
||||||
|
or line:match("^tree (%x+)$")
|
||||||
|
or line:match("^object (%x+)$")
|
||||||
|
if sha then
|
||||||
|
M.open(r, sha, { split = false })
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local entry_type, entry_sha, entry_name =
|
||||||
|
line:match("^%d+ (%w+) (%x+)\t(.+)$")
|
||||||
|
if entry_sha then
|
||||||
|
if entry_type == "blob" then
|
||||||
|
load_side(r, entry_sha, entry_name --[[@as string]])
|
||||||
|
else
|
||||||
|
M.open(r, entry_sha, { split = false })
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local section = diff_section()
|
||||||
|
if not section then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if line:match("^diff %-%-git ") then
|
||||||
|
open_section(r, section)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if line:match("^%-%-%- ") then
|
||||||
|
load_side(r, section.blob_a, section.path_a)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if line:match("^%+%+%+ ") then
|
||||||
|
load_side(r, section.blob_b, section.path_b)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local prefix = line:sub(1, 1)
|
||||||
|
if prefix == "+" then
|
||||||
|
load_side(r, section.blob_b, section.path_b)
|
||||||
|
return true
|
||||||
|
elseif prefix == "-" then
|
||||||
|
load_side(r, section.blob_a, section.path_a)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
repo.on_uri_change(M.URI_PREFIX, refresh)
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -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<integer, ow.Git.StatusView.Item>
|
||||||
|
---@field win integer?
|
||||||
|
---@field unsubscribe fun()?
|
||||||
|
|
||||||
|
---@type table<integer, ow.Git.StatusView.State>
|
||||||
|
local state = {}
|
||||||
|
|
||||||
|
local group =
|
||||||
|
vim.api.nvim_create_augroup("ow.git.status_win", { clear = true })
|
||||||
|
local ns = vim.api.nvim_create_namespace("ow.git.status_win")
|
||||||
|
|
||||||
|
---@return integer? win
|
||||||
|
---@return integer? bufnr
|
||||||
|
local function find_view()
|
||||||
|
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||||
|
local buf = vim.api.nvim_win_get_buf(win)
|
||||||
|
if vim.bo[buf].filetype == "gitstatus" then
|
||||||
|
return win, buf
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param win integer?
|
||||||
|
---@return boolean
|
||||||
|
local function valid_in_current_tab(win)
|
||||||
|
if not win or not vim.api.nvim_win_is_valid(win) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return vim.api.nvim_win_get_tabpage(win)
|
||||||
|
== vim.api.nvim_get_current_tabpage()
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param s ow.Git.StatusView.State
|
||||||
|
---@return integer?
|
||||||
|
local function win_for(s)
|
||||||
|
if valid_in_current_tab(s.win) then
|
||||||
|
return s.win
|
||||||
|
end
|
||||||
|
local win = find_view()
|
||||||
|
s.win = win
|
||||||
|
return win
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param row ow.Git.Status.Row
|
||||||
|
---@return string line
|
||||||
|
---@return string hl_group
|
||||||
|
---@return integer hl_len
|
||||||
|
local function format_row(row)
|
||||||
|
local entry = row.entry
|
||||||
|
local orig
|
||||||
|
if entry.kind == "changed" then
|
||||||
|
---@cast entry ow.Git.Status.ChangedEntry
|
||||||
|
orig = entry.orig
|
||||||
|
end
|
||||||
|
local label = orig and (orig .. " -> " .. entry.path) or entry.path
|
||||||
|
local mark = status.mark_for(entry, row.side)
|
||||||
|
return string.format(" %s %s", mark.char, label), mark.hl, #mark.char
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param section ow.Git.Status.Section
|
||||||
|
---@return string
|
||||||
|
local function display_name(section)
|
||||||
|
return (section:gsub("^%l", string.upper))
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
local function render(bufnr, r)
|
||||||
|
local status = r.status
|
||||||
|
local branch = status.branch
|
||||||
|
local lines = {}
|
||||||
|
local marks = {}
|
||||||
|
local meta = {}
|
||||||
|
|
||||||
|
local function label(row, len)
|
||||||
|
table.insert(marks, { row = row, col = 0, end_col = len, hl = "Label" })
|
||||||
|
end
|
||||||
|
|
||||||
|
local repo_line = vim.fn.fnamemodify(r.worktree, ":t")
|
||||||
|
table.insert(lines, repo_line)
|
||||||
|
table.insert(marks, {
|
||||||
|
row = #lines - 1,
|
||||||
|
col = 0,
|
||||||
|
end_col = #repo_line,
|
||||||
|
hl = "Directory",
|
||||||
|
})
|
||||||
|
|
||||||
|
table.insert(lines, "Branch: " .. (branch.head or "?"))
|
||||||
|
label(#lines - 1, 6)
|
||||||
|
|
||||||
|
if branch.upstream then
|
||||||
|
local up = "Upstream: " .. branch.upstream
|
||||||
|
local extras = {}
|
||||||
|
if branch.ahead > 0 then
|
||||||
|
local col = #up + 1
|
||||||
|
up = up .. " +" .. branch.ahead
|
||||||
|
table.insert(extras, {
|
||||||
|
col = col,
|
||||||
|
end_col = #up,
|
||||||
|
hl = "GitUnpushed",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
if branch.behind > 0 then
|
||||||
|
local col = #up + 1
|
||||||
|
up = up .. " -" .. branch.behind
|
||||||
|
table.insert(extras, {
|
||||||
|
col = col,
|
||||||
|
end_col = #up,
|
||||||
|
hl = "GitUnpulled",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
table.insert(lines, up)
|
||||||
|
local row = #lines - 1
|
||||||
|
label(row, 8)
|
||||||
|
for _, e in ipairs(extras) do
|
||||||
|
e.row = row
|
||||||
|
table.insert(marks, e)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(lines, "")
|
||||||
|
|
||||||
|
for _, section in ipairs(SECTIONS) do
|
||||||
|
local rows = status:rows(section)
|
||||||
|
if #rows > 0 then
|
||||||
|
local name = display_name(section)
|
||||||
|
local header = string.format("%s (%d)", name, #rows)
|
||||||
|
table.insert(lines, header)
|
||||||
|
local header_row = #lines - 1
|
||||||
|
meta[#lines] = { is_header = true, section = section }
|
||||||
|
label(header_row, #name)
|
||||||
|
table.insert(marks, {
|
||||||
|
row = header_row,
|
||||||
|
col = #name + 2,
|
||||||
|
end_col = #header - 1,
|
||||||
|
hl = "Number",
|
||||||
|
})
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
local line, hl, hl_len = format_row(row)
|
||||||
|
table.insert(lines, line)
|
||||||
|
meta[#lines] = row
|
||||||
|
table.insert(marks, {
|
||||||
|
row = #lines - 1,
|
||||||
|
col = 2,
|
||||||
|
end_col = 2 + hl_len,
|
||||||
|
hl = hl,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
table.insert(lines, "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
util.set_buf_lines(bufnr, 0, -1, lines)
|
||||||
|
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
|
||||||
|
for _, m in ipairs(marks) do
|
||||||
|
vim.api.nvim_buf_set_extmark(bufnr, ns, m.row, m.col, {
|
||||||
|
end_col = m.end_col,
|
||||||
|
hl_group = m.hl,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
state[bufnr].lines = meta
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
local function refresh(bufnr)
|
||||||
|
local s = state[bufnr]
|
||||||
|
if not s or not vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
render(bufnr, s.repo)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@return ow.Git.StatusView.State?
|
||||||
|
---@return ow.Git.StatusView.Item?
|
||||||
|
local function current_entry(bufnr)
|
||||||
|
local s = state[bufnr]
|
||||||
|
if not s then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
return s, s.lines[lnum]
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class ow.Git.StatusView.Pane
|
||||||
|
---@field buf integer
|
||||||
|
---@field name string?
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param path string
|
||||||
|
---@return ow.Git.StatusView.Pane
|
||||||
|
local function head_pane(r, path)
|
||||||
|
local rev = Revision.new({ base = "HEAD", path = path })
|
||||||
|
return {
|
||||||
|
buf = object.buf_for(r, rev),
|
||||||
|
name = object.format_uri(rev),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param path string
|
||||||
|
---@return ow.Git.StatusView.Pane
|
||||||
|
local function worktree_pane(r, path)
|
||||||
|
local buf = vim.fn.bufadd(vim.fs.joinpath(r.worktree, path))
|
||||||
|
vim.fn.bufload(buf)
|
||||||
|
return { buf = buf, name = nil }
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param s ow.Git.StatusView.State
|
||||||
|
---@param path string
|
||||||
|
---@return ow.Git.StatusView.Pane
|
||||||
|
local function index_pane(s, path)
|
||||||
|
local rev = Revision.new({ stage = 0, path = path })
|
||||||
|
return {
|
||||||
|
buf = object.buf_for(s.repo, rev),
|
||||||
|
name = object.format_uri(rev),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param s ow.Git.StatusView.State
|
||||||
|
---@param row ow.Git.Status.Row
|
||||||
|
---@return ow.Git.StatusView.Pane?
|
||||||
|
local function older_pane(s, row)
|
||||||
|
local entry = row.entry
|
||||||
|
if row.section == "staged" then
|
||||||
|
---@cast entry ow.Git.Status.ChangedEntry
|
||||||
|
if entry.staged == "added" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return head_pane(s.repo, entry.orig or entry.path)
|
||||||
|
end
|
||||||
|
if row.section == "unstaged" then
|
||||||
|
return index_pane(s, entry.path)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param s ow.Git.StatusView.State
|
||||||
|
---@param row ow.Git.Status.Row
|
||||||
|
---@return ow.Git.StatusView.Pane?
|
||||||
|
local function newer_pane(s, row)
|
||||||
|
local entry = row.entry
|
||||||
|
if row.section == "staged" then
|
||||||
|
---@cast entry ow.Git.Status.ChangedEntry
|
||||||
|
if entry.staged == "deleted" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return index_pane(s, entry.path)
|
||||||
|
end
|
||||||
|
if row.section == "unstaged" then
|
||||||
|
---@cast entry ow.Git.Status.ChangedEntry
|
||||||
|
if entry.unstaged == "deleted" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return worktree_pane(s.repo, entry.path)
|
||||||
|
end
|
||||||
|
if row.section == "untracked" then
|
||||||
|
return worktree_pane(s.repo, entry.path)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param target_win integer
|
||||||
|
---@param dir "left"|"right"
|
||||||
|
---@return integer
|
||||||
|
local function vsplit_at(target_win, dir)
|
||||||
|
local win = vim.api.nvim_open_win(
|
||||||
|
vim.api.nvim_win_get_buf(target_win),
|
||||||
|
true,
|
||||||
|
{ split = dir, win = target_win }
|
||||||
|
)
|
||||||
|
vim.api.nvim_win_call(win, function()
|
||||||
|
vim.cmd("setlocal winfixwidth<")
|
||||||
|
end)
|
||||||
|
vim.cmd.clearjumps()
|
||||||
|
return win
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param status_win integer
|
||||||
|
---@return integer?
|
||||||
|
local function previous_target_win(status_win)
|
||||||
|
local n = vim.fn.winnr("#")
|
||||||
|
if n == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local win = vim.fn.win_getid(n)
|
||||||
|
if win == 0 or win == status_win or not valid_in_current_tab(win) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local cfg = vim.api.nvim_win_get_config(win)
|
||||||
|
if cfg.relative and cfg.relative ~= "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return win
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param status_win integer
|
||||||
|
---@param keep integer
|
||||||
|
local function close_other_diff_wins(status_win, keep)
|
||||||
|
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||||
|
if win ~= status_win and win ~= keep and vim.wo[win].diff then
|
||||||
|
pcall(vim.api.nvim_win_close, win, false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param s ow.Git.StatusView.State
|
||||||
|
---@param row ow.Git.Status.Row
|
||||||
|
---@param focus_left boolean
|
||||||
|
local function view_row(s, row, focus_left)
|
||||||
|
local status_win = win_for(s)
|
||||||
|
if not status_win then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local left = older_pane(s, row)
|
||||||
|
local right = newer_pane(s, row)
|
||||||
|
if not left and not right then
|
||||||
|
util.warning(
|
||||||
|
"no content for %s row: %s",
|
||||||
|
row.section,
|
||||||
|
row.entry.path
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if s.placement ~= "sidebar" then
|
||||||
|
local pane = right or left
|
||||||
|
---@cast pane -nil
|
||||||
|
vim.cmd.normal({ "m'", bang = true })
|
||||||
|
vim.api.nvim_win_set_buf(status_win, pane.buf)
|
||||||
|
if pane.name then
|
||||||
|
util.set_buf_name(pane.buf, pane.name)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local target = previous_target_win(status_win)
|
||||||
|
if not target then
|
||||||
|
target = vsplit_at(status_win, "right")
|
||||||
|
end
|
||||||
|
close_other_diff_wins(status_win, target)
|
||||||
|
vim.api.nvim_win_set_width(status_win, WINDOW_WIDTH)
|
||||||
|
vim.api.nvim_win_call(target, function()
|
||||||
|
vim.cmd.diffoff()
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not (left and right) then
|
||||||
|
local side = right or left
|
||||||
|
---@cast side ow.Git.StatusView.Pane
|
||||||
|
vim.api.nvim_win_set_buf(target, side.buf)
|
||||||
|
if side.name then
|
||||||
|
util.set_buf_name(side.buf, side.name)
|
||||||
|
end
|
||||||
|
vim.api.nvim_set_current_win(focus_left and target or status_win)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
---@cast left ow.Git.StatusView.Pane
|
||||||
|
---@cast right ow.Git.StatusView.Pane
|
||||||
|
|
||||||
|
vim.api.nvim_win_set_buf(target, right.buf)
|
||||||
|
if right.name then
|
||||||
|
util.set_buf_name(right.buf, right.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
local older = left.name or vim.api.nvim_buf_get_name(left.buf)
|
||||||
|
local left_win
|
||||||
|
vim.api.nvim_win_call(target, function()
|
||||||
|
diffsplit.open({
|
||||||
|
target = older,
|
||||||
|
mods = { vertical = true },
|
||||||
|
})
|
||||||
|
left_win = vim.api.nvim_get_current_win()
|
||||||
|
vim.api.nvim_win_set_cursor(left_win, { 1, 0 })
|
||||||
|
vim.api.nvim_win_set_cursor(target, { 1, 0 })
|
||||||
|
end)
|
||||||
|
---@cast left_win -nil
|
||||||
|
vim.api.nvim_set_current_win(focus_left and left_win or status_win)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param focus_left boolean
|
||||||
|
local function preview_or_open(focus_left)
|
||||||
|
local s, item = current_entry(vim.api.nvim_get_current_buf())
|
||||||
|
if not s or not item or item.is_header then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
---@cast item ow.Git.Status.Row
|
||||||
|
view_row(s, item, focus_left)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function action_stage()
|
||||||
|
local s, item = current_entry(vim.api.nvim_get_current_buf())
|
||||||
|
if not s or not item then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local paths = {}
|
||||||
|
if item.is_header then
|
||||||
|
if item.section == "staged" or item.section == "ignored" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, row in ipairs(s.repo.status:rows(item.section)) do
|
||||||
|
table.insert(paths, row.entry.path)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
---@cast item ow.Git.Status.Row
|
||||||
|
if item.section == "staged" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
table.insert(paths, item.entry.path)
|
||||||
|
end
|
||||||
|
if #paths == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local args = { "add", "--" }
|
||||||
|
vim.list_extend(args, paths)
|
||||||
|
util.git(args, {
|
||||||
|
cwd = s.repo.worktree,
|
||||||
|
on_exit = function(result)
|
||||||
|
if result.code ~= 0 then
|
||||||
|
util.error("git add failed: %s", vim.trim(result.stderr or ""))
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function action_unstage()
|
||||||
|
local s, item = current_entry(vim.api.nvim_get_current_buf())
|
||||||
|
if not s or not item then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local rows
|
||||||
|
if item.is_header then
|
||||||
|
if item.section ~= "staged" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
rows = s.repo.status:rows("staged")
|
||||||
|
else
|
||||||
|
---@cast item ow.Git.Status.Row
|
||||||
|
if item.section ~= "staged" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
rows = { item }
|
||||||
|
end
|
||||||
|
---@cast rows ow.Git.Status.Row[]
|
||||||
|
if #rows == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local args = { "restore", "--staged", "--" }
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
local entry = row.entry
|
||||||
|
if entry.kind == "changed" then
|
||||||
|
---@cast entry ow.Git.Status.ChangedEntry
|
||||||
|
if entry.orig then
|
||||||
|
table.insert(args, entry.orig)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(args, entry.path)
|
||||||
|
end
|
||||||
|
util.git(args, {
|
||||||
|
cwd = s.repo.worktree,
|
||||||
|
on_exit = function(result)
|
||||||
|
if result.code ~= 0 then
|
||||||
|
util.error(
|
||||||
|
"git restore --staged failed: %s",
|
||||||
|
vim.trim(result.stderr or "")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function action_discard()
|
||||||
|
local s, item = current_entry(vim.api.nvim_get_current_buf())
|
||||||
|
if not s or not item or item.is_header then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
---@cast item ow.Git.Status.Row
|
||||||
|
if item.section == "staged" then
|
||||||
|
util.warning("file has staged changes, unstage first with 'u'")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local entry = item.entry
|
||||||
|
local path = entry.path
|
||||||
|
|
||||||
|
local prompt, action
|
||||||
|
if item.section == "untracked" then
|
||||||
|
local is_dir = path:sub(-1) == "/"
|
||||||
|
prompt = string.format(
|
||||||
|
"Delete untracked %s %s?",
|
||||||
|
is_dir and "directory" or "file",
|
||||||
|
path
|
||||||
|
)
|
||||||
|
action = function()
|
||||||
|
local target = vim.fs.joinpath(s.repo.worktree, path)
|
||||||
|
local rc = vim.fn.delete(target, is_dir and "rf" or "")
|
||||||
|
if rc ~= 0 then
|
||||||
|
util.error("failed to delete %s", path)
|
||||||
|
end
|
||||||
|
refresh(vim.api.nvim_get_current_buf())
|
||||||
|
end
|
||||||
|
elseif item.section == "unstaged" then
|
||||||
|
prompt = string.format("Discard changes to %s?", path)
|
||||||
|
action = function()
|
||||||
|
util.git({ "checkout", "--", path }, {
|
||||||
|
cwd = s.repo.worktree,
|
||||||
|
on_exit = function(result)
|
||||||
|
if result.code ~= 0 then
|
||||||
|
util.error(
|
||||||
|
"git checkout failed: %s",
|
||||||
|
vim.trim(result.stderr or "")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then
|
||||||
|
action()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param placement ow.Git.StatusView.Placement
|
||||||
|
local function action_help(placement)
|
||||||
|
local lines = { "git status view" }
|
||||||
|
if placement == "sidebar" then
|
||||||
|
table.insert(lines, " <Tab> preview diff (keep focus)")
|
||||||
|
table.insert(lines, " <CR> open diff (focus left pane)")
|
||||||
|
table.insert(lines, " <2-LeftMouse> open diff (focus left pane)")
|
||||||
|
else
|
||||||
|
table.insert(lines, " <CR> open file")
|
||||||
|
table.insert(lines, " <2-LeftMouse> open file")
|
||||||
|
end
|
||||||
|
table.insert(lines, " s stage file")
|
||||||
|
table.insert(lines, " u unstage file")
|
||||||
|
table.insert(
|
||||||
|
lines,
|
||||||
|
" X discard worktree changes (untracked: delete file)"
|
||||||
|
)
|
||||||
|
table.insert(lines, " R refresh")
|
||||||
|
table.insert(lines, " g? show this help")
|
||||||
|
print(table.concat(lines, "\n"))
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param placement ow.Git.StatusView.Placement
|
||||||
|
---@return integer win
|
||||||
|
local function place(bufnr, placement)
|
||||||
|
local split
|
||||||
|
if placement == "sidebar" then
|
||||||
|
split = "left"
|
||||||
|
elseif placement == "current" then
|
||||||
|
split = false
|
||||||
|
end
|
||||||
|
local win = util.place_buf(bufnr, split)
|
||||||
|
vim.wo[win].number = false
|
||||||
|
vim.wo[win].relativenumber = false
|
||||||
|
vim.wo[win].wrap = false
|
||||||
|
vim.wo[win].signcolumn = "no"
|
||||||
|
vim.wo[win].cursorline = true
|
||||||
|
if placement == "sidebar" then
|
||||||
|
vim.wo[win].winfixwidth = true
|
||||||
|
vim.api.nvim_win_set_width(win, WINDOW_WIDTH)
|
||||||
|
end
|
||||||
|
return win
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param placement ow.Git.StatusView.Placement
|
||||||
|
---@param win integer?
|
||||||
|
local function setup_buffer(bufnr, r, placement, win)
|
||||||
|
state[bufnr] = {
|
||||||
|
repo = r,
|
||||||
|
placement = placement,
|
||||||
|
lines = {},
|
||||||
|
win = win,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function k(lhs, rhs, desc)
|
||||||
|
vim.keymap.set(
|
||||||
|
"n",
|
||||||
|
lhs,
|
||||||
|
rhs,
|
||||||
|
{ buffer = bufnr, silent = true, desc = desc }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
k("<CR>", function()
|
||||||
|
preview_or_open(true)
|
||||||
|
end, "Open")
|
||||||
|
k("<2-LeftMouse>", function()
|
||||||
|
preview_or_open(true)
|
||||||
|
end, "Open")
|
||||||
|
k("s", action_stage, "Stage file")
|
||||||
|
k("u", action_unstage, "Unstage file")
|
||||||
|
k("X", action_discard, "Discard worktree changes")
|
||||||
|
k("R", function()
|
||||||
|
r:refresh()
|
||||||
|
end, "Refresh")
|
||||||
|
k("g?", function()
|
||||||
|
action_help(state[bufnr].placement)
|
||||||
|
end, "Help")
|
||||||
|
|
||||||
|
state[bufnr].unsubscribe = r:on("change", function()
|
||||||
|
refresh(bufnr)
|
||||||
|
end)
|
||||||
|
vim.api.nvim_create_autocmd("BufEnter", {
|
||||||
|
buffer = bufnr,
|
||||||
|
group = group,
|
||||||
|
callback = function()
|
||||||
|
r:refresh()
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
|
||||||
|
buffer = bufnr,
|
||||||
|
group = group,
|
||||||
|
callback = function()
|
||||||
|
local s = state[bufnr]
|
||||||
|
if not s then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if s.unsubscribe then
|
||||||
|
s.unsubscribe()
|
||||||
|
end
|
||||||
|
state[bufnr] = nil
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param placement ow.Git.StatusView.Placement
|
||||||
|
local function set_keymaps(bufnr, placement)
|
||||||
|
if placement == "sidebar" then
|
||||||
|
vim.keymap.set("n", "<Tab>", function()
|
||||||
|
preview_or_open(false)
|
||||||
|
end, { buffer = bufnr, silent = true, desc = "Preview diff" })
|
||||||
|
else
|
||||||
|
pcall(vim.keymap.del, "n", "<Tab>", { buffer = bufnr })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class ow.Git.StatusView.OpenOpts
|
||||||
|
---@field placement ow.Git.StatusView.Placement?
|
||||||
|
|
||||||
|
---@param opts? ow.Git.StatusView.OpenOpts
|
||||||
|
function M.open(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local placement = opts.placement or "sidebar"
|
||||||
|
if not vim.tbl_contains(M.PLACEMENTS, placement) then
|
||||||
|
util.error(
|
||||||
|
"invalid placement: %s (expected one of %s)",
|
||||||
|
placement,
|
||||||
|
table.concat(M.PLACEMENTS, ", ")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local r = repo.resolve()
|
||||||
|
if not r then
|
||||||
|
util.error("not in a git repository")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local previous_win = vim.api.nvim_get_current_win()
|
||||||
|
local buf = vim.fn.bufadd(buf_name_for(r))
|
||||||
|
|
||||||
|
local visible = vim.fn.bufwinid(buf)
|
||||||
|
if visible ~= -1 then
|
||||||
|
vim.api.nvim_set_current_win(visible)
|
||||||
|
r:refresh()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not state[buf] then
|
||||||
|
vim.fn.bufload(buf)
|
||||||
|
repo.bind(buf, r)
|
||||||
|
util.setup_scratch(buf, {})
|
||||||
|
vim.bo[buf].filetype = "gitstatus"
|
||||||
|
setup_buffer(buf, r, placement)
|
||||||
|
end
|
||||||
|
vim.bo[buf].bufhidden = placement == "sidebar" and "wipe" or "hide"
|
||||||
|
|
||||||
|
local win = place(buf, placement)
|
||||||
|
state[buf].win = win
|
||||||
|
state[buf].placement = placement
|
||||||
|
set_keymaps(buf, placement)
|
||||||
|
|
||||||
|
if placement == "sidebar" then
|
||||||
|
vim.api.nvim_set_current_win(previous_win)
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh(buf)
|
||||||
|
r:refresh()
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param opts? ow.Git.StatusView.OpenOpts
|
||||||
|
function M.toggle(opts)
|
||||||
|
local existing = find_view()
|
||||||
|
if existing then
|
||||||
|
vim.api.nvim_win_close(existing, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
M.open(opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -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
|
||||||
+339
@@ -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 (<rev>)",
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.api.nvim_create_user_command("Gstatus", function(opts)
|
||||||
|
require("git.status_view").open({
|
||||||
|
placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]] or "split",
|
||||||
|
})
|
||||||
|
end, {
|
||||||
|
nargs = "?",
|
||||||
|
complete = function()
|
||||||
|
return require("git.status_view").PLACEMENTS
|
||||||
|
end,
|
||||||
|
desc = "Open git status view",
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.keymap.set("n", "<Plug>(git-edit)", function()
|
||||||
|
local rev = vim.fn.input("Edit git object: ")
|
||||||
|
if rev == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
vim.cmd.edit({
|
||||||
|
args = { require("git.object").URI_PREFIX .. rev },
|
||||||
|
magic = { file = false },
|
||||||
|
})
|
||||||
|
end, { silent = true, desc = "Edit a git object" })
|
||||||
|
|
||||||
|
vim.keymap.set("n", "<Plug>(git-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", "<Plug>(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", "<Plug>(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", "<Plug>(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", "<Plug>(git-status-open)", function()
|
||||||
|
require("git.status_view").open()
|
||||||
|
end, { silent = true, desc = "Open git status sidebar" })
|
||||||
|
vim.keymap.set("n", "<Plug>(git-status-toggle)", function()
|
||||||
|
require("git.status_view").toggle()
|
||||||
|
end, { silent = true, desc = "Toggle git status sidebar" })
|
||||||
|
|
||||||
|
vim.keymap.set("n", "<Plug>(git-log)", function()
|
||||||
|
require("git.log_view").open({ max_count = 1000 })
|
||||||
|
end, { silent = true, desc = "Open git log" })
|
||||||
|
|
||||||
|
vim.keymap.set("n", "<Plug>(git-commit)", function()
|
||||||
|
require("git.commit").commit()
|
||||||
|
end, { silent = true, desc = "Start a git commit" })
|
||||||
|
vim.keymap.set("n", "<Plug>(git-commit-amend)", function()
|
||||||
|
require("git.commit").commit({ args = { "--amend" } })
|
||||||
|
end, { silent = true, desc = "Amend the last git commit" })
|
||||||
|
|
||||||
|
if vim.g.git_statusline ~= false then
|
||||||
|
vim.api.nvim_create_autocmd("BufWinEnter", {
|
||||||
|
group = group,
|
||||||
|
once = true,
|
||||||
|
callback = function()
|
||||||
|
require("git.statusline")
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.keymap.set({ "n", "x" }, "<Plug>(git-hunk-next)", function()
|
||||||
|
require("git.hunks").nav("next")
|
||||||
|
end, { silent = true, desc = "Jump to next git hunk" })
|
||||||
|
vim.keymap.set({ "n", "x" }, "<Plug>(git-hunk-prev)", function()
|
||||||
|
require("git.hunks").nav("prev")
|
||||||
|
end, { silent = true, desc = "Jump to previous git hunk" })
|
||||||
|
vim.keymap.set("n", "<Plug>(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", "<Plug>(git-hunk-reset)", function()
|
||||||
|
require("git.hunks").reset_hunk()
|
||||||
|
end, { silent = true, desc = "Reset hunk under cursor" })
|
||||||
|
vim.keymap.set("n", "<Plug>(git-hunk-preview)", function()
|
||||||
|
require("git.hunks").preview_hunk()
|
||||||
|
end, { silent = true, desc = "Preview hunk under cursor" })
|
||||||
|
vim.keymap.set("n", "<Plug>(git-hunk-select)", function()
|
||||||
|
require("git.hunks").select_hunk()
|
||||||
|
end, { silent = true, desc = "Select hunk under cursor" })
|
||||||
|
vim.keymap.set("n", "<Plug>(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", "<Plug>(git-blame-popup)", function()
|
||||||
|
require("git.blame").line_popup()
|
||||||
|
end, { silent = true, desc = "Show git blame for the current line" })
|
||||||
|
vim.keymap.set("n", "<Plug>(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", "<Plug>(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", "<Plug>(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",
|
||||||
|
})
|
||||||
|
|
||||||
Executable
+17
@@ -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]}"
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -u
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: ${0##*/} [--help] [TARGET ...]
|
||||||
|
|
||||||
|
Run Neovim integration tests in a single 'nvim --headless' instance.
|
||||||
|
|
||||||
|
With no targets, runs every test/**/*_test.lua. Each TARGET may be a
|
||||||
|
test file or a directory; directories expand to all _test.lua files
|
||||||
|
beneath them.
|
||||||
|
|
||||||
|
Exit status is 0 if all tests passed, non-zero otherwise.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ ${1:-} == --help || ${1:-} == -h ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
export TEST_COLOR=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec nvim --headless --clean -l test/runner.lua "$@"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
column_width = 80
|
||||||
|
line_endings = "Unix"
|
||||||
|
indent_type = "Spaces"
|
||||||
|
indent_width = 4
|
||||||
|
quote_style = "AutoPreferDouble"
|
||||||
|
call_parentheses = "Always"
|
||||||
|
collapse_simple_statement = "Never"
|
||||||
|
|
||||||
|
[sort_requires]
|
||||||
|
enabled = true
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
local blame = require("git.blame")
|
||||||
|
local h = require("test.git.helpers")
|
||||||
|
local t = require("test")
|
||||||
|
|
||||||
|
---@param sha string
|
||||||
|
---@return boolean
|
||||||
|
local function is_zero(sha)
|
||||||
|
return sha:match("^0+$") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param committed string
|
||||||
|
---@param worktree string?
|
||||||
|
---@param file string?
|
||||||
|
---@return string dir
|
||||||
|
---@return integer buf
|
||||||
|
local function setup(committed, worktree, file)
|
||||||
|
file = file or "a.txt"
|
||||||
|
local dir = h.make_repo({ [file] = committed })
|
||||||
|
if worktree then
|
||||||
|
t.write(dir, file, worktree)
|
||||||
|
end
|
||||||
|
vim.cmd.edit(dir .. "/" .. file)
|
||||||
|
return dir, vim.api.nvim_get_current_buf()
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return integer?
|
||||||
|
local function find_float()
|
||||||
|
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||||
|
if vim.api.nvim_win_get_config(w).relative ~= "" then
|
||||||
|
return w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return integer float
|
||||||
|
local function wait_float()
|
||||||
|
local float ---@type integer?
|
||||||
|
t.wait_for(function()
|
||||||
|
float = find_float()
|
||||||
|
return float ~= nil
|
||||||
|
end, "blame popup float to open")
|
||||||
|
local found = assert(float)
|
||||||
|
return found
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param pat string
|
||||||
|
local function wait_buf_name(pat)
|
||||||
|
t.wait_for(function()
|
||||||
|
return vim.api.nvim_buf_get_name(0):match(pat) ~= nil
|
||||||
|
end, "current buffer name to match " .. pat)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@return ow.Git.Blame.BufState
|
||||||
|
local function populate_blame(buf)
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
local tick = vim.api.nvim_buf_get_changedtick(buf)
|
||||||
|
blame.line_popup(buf)
|
||||||
|
t.wait_for(function()
|
||||||
|
local s = blame.state(buf)
|
||||||
|
return s ~= nil and s.tick == tick
|
||||||
|
end, "blame to populate the buffer state")
|
||||||
|
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||||
|
if vim.api.nvim_win_get_config(w).relative ~= "" then
|
||||||
|
pcall(vim.api.nvim_win_close, w, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return (assert(blame.state(buf)))
|
||||||
|
end
|
||||||
|
|
||||||
|
t.test("line popup formats the datetime in the author timezone", function()
|
||||||
|
local _, buf = setup("alpha\nbeta\n")
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
blame.line_popup(buf)
|
||||||
|
local float = wait_float()
|
||||||
|
t.defer(function()
|
||||||
|
pcall(vim.api.nvim_win_close, float, true)
|
||||||
|
end)
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(
|
||||||
|
vim.api.nvim_win_get_buf(float),
|
||||||
|
0,
|
||||||
|
-1,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
t.truthy(
|
||||||
|
(lines[1] or ""):match(
|
||||||
|
"%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d [+%-]%d%d%d%d$"
|
||||||
|
),
|
||||||
|
"the head line ends with an ISO datetime and numeric timezone"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("porcelain parse of a committed file", function()
|
||||||
|
local _, buf = setup("alpha\nbeta\ngamma\n")
|
||||||
|
local state = populate_blame(buf)
|
||||||
|
t.eq(vim.tbl_count(state.commits), 1, "one commit")
|
||||||
|
local sha = state.line_sha[1]
|
||||||
|
t.eq(state.line_sha[2], sha, "line 2 shares the commit")
|
||||||
|
t.eq(state.line_sha[3], sha, "line 3 shares the commit")
|
||||||
|
local commit = state.commits[sha]
|
||||||
|
t.eq(commit.author, "t", "author parsed from the porcelain")
|
||||||
|
t.eq(commit.summary, "init", "summary parsed from the porcelain")
|
||||||
|
t.truthy(#sha >= 40, "the full sha is recorded")
|
||||||
|
t.truthy(commit.author_time > 0, "author time parsed")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("multiple line groups reuse one commit entry", function()
|
||||||
|
local dir = h.make_repo({ ["a.txt"] = "a\nb\nc\n" })
|
||||||
|
t.write(dir, "a.txt", "a\nB\nc\n")
|
||||||
|
h.git(dir, "add", "a.txt")
|
||||||
|
h.git(dir, "commit", "-q", "-m", "change middle")
|
||||||
|
vim.cmd.edit(dir .. "/a.txt")
|
||||||
|
local buf = vim.api.nvim_get_current_buf()
|
||||||
|
local state = populate_blame(buf)
|
||||||
|
t.eq(vim.tbl_count(state.commits), 2, "two distinct commits")
|
||||||
|
t.eq(
|
||||||
|
state.line_sha[1],
|
||||||
|
state.line_sha[3],
|
||||||
|
"lines 1 and 3 share the original commit"
|
||||||
|
)
|
||||||
|
t.truthy(
|
||||||
|
state.line_sha[1] ~= state.line_sha[2],
|
||||||
|
"line 2 is a different commit"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("an edited line blames as the zero sha", function()
|
||||||
|
local _, buf = setup("a\nb\nc\n")
|
||||||
|
local state = populate_blame(buf)
|
||||||
|
t.falsy(is_zero(state.line_sha[2]), "line 2 starts committed")
|
||||||
|
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "EDITED" })
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
local refreshed = populate_blame(buf)
|
||||||
|
t.truthy(
|
||||||
|
is_zero(refreshed.line_sha[2]),
|
||||||
|
"the edited line blames as uncommitted on the next fetch"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("blame picks up a commit amend on the next fetch", function()
|
||||||
|
local dir, buf = setup("original\n")
|
||||||
|
local state = populate_blame(buf)
|
||||||
|
local sha1 = state.line_sha[1]
|
||||||
|
h.git(dir, "commit", "--amend", "-m", "amended")
|
||||||
|
local sha2 = h.git(dir, "rev-parse", "HEAD").stdout
|
||||||
|
t.truthy(sha1 ~= sha2, "the amend produced a new commit")
|
||||||
|
t.wait_for(function()
|
||||||
|
return assert(blame.state(buf)).tick == nil
|
||||||
|
end, "the cache to be invalidated by the repo change")
|
||||||
|
local refreshed = populate_blame(buf)
|
||||||
|
t.eq(refreshed.line_sha[1], sha2, "blame picks up the amended sha")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("an untracked file blames every line as uncommitted", function()
|
||||||
|
local dir = h.make_repo({ ["tracked.txt"] = "x\n" })
|
||||||
|
t.write(dir, "new.txt", "one\ntwo\nthree\n")
|
||||||
|
vim.cmd.edit(dir .. "/new.txt")
|
||||||
|
local buf = vim.api.nvim_get_current_buf()
|
||||||
|
local state = populate_blame(buf)
|
||||||
|
for i = 1, 3 do
|
||||||
|
t.truthy(is_zero(state.line_sha[i]), "line " .. i .. " is uncommitted")
|
||||||
|
end
|
||||||
|
t.eq(vim.tbl_count(state.commits), 1, "one synthesized commit")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("blame actions are no-ops off a worktree", function()
|
||||||
|
local buf = vim.api.nvim_create_buf(true, false)
|
||||||
|
t.defer(function()
|
||||||
|
pcall(vim.api.nvim_buf_delete, buf, { force = true })
|
||||||
|
end)
|
||||||
|
t.quietly(function()
|
||||||
|
blame.line_popup(buf)
|
||||||
|
end)
|
||||||
|
t.eq(blame.state(buf), nil, "no state created for a non-worktree buffer")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("line popup shows the commit for the cursor line", function()
|
||||||
|
local dir, buf = setup("alpha\nbeta\ngamma\n")
|
||||||
|
local sha = h.git(dir, "rev-parse", "HEAD").stdout
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
blame.line_popup(buf)
|
||||||
|
local float = wait_float()
|
||||||
|
t.defer(function()
|
||||||
|
pcall(vim.api.nvim_win_close, float, true)
|
||||||
|
end)
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(
|
||||||
|
vim.api.nvim_win_get_buf(float),
|
||||||
|
0,
|
||||||
|
-1,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
t.truthy(
|
||||||
|
vim.startswith(lines[1] or "", sha:sub(1, 8)),
|
||||||
|
"first line starts with the short sha"
|
||||||
|
)
|
||||||
|
t.truthy((lines[1] or ""):find("t", 1, true), "author shown")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("re-invoking the line popup focuses the open float", function()
|
||||||
|
local _, buf = setup("alpha\nbeta\n")
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
blame.line_popup(buf)
|
||||||
|
local float = wait_float()
|
||||||
|
t.defer(function()
|
||||||
|
pcall(vim.api.nvim_win_close, float, true)
|
||||||
|
end)
|
||||||
|
t.truthy(
|
||||||
|
vim.api.nvim_get_current_win() ~= float,
|
||||||
|
"the float opens unfocused"
|
||||||
|
)
|
||||||
|
blame.line_popup(buf)
|
||||||
|
t.eq(
|
||||||
|
vim.api.nvim_get_current_win(),
|
||||||
|
float,
|
||||||
|
"re-invoking focuses the existing float"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("line popup works in a git:// object buffer", function()
|
||||||
|
local dir = h.make_repo({ ["a.txt"] = "alpha\nbeta\ngamma\n" })
|
||||||
|
local sha = h.git(dir, "rev-parse", "HEAD").stdout
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
local gbuf = require("git.object").buf_for(
|
||||||
|
r,
|
||||||
|
require("git.core.revision").new({ base = sha, path = "a.txt" })
|
||||||
|
)
|
||||||
|
vim.api.nvim_set_current_buf(gbuf)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
blame.line_popup(gbuf)
|
||||||
|
local float = wait_float()
|
||||||
|
t.defer(function()
|
||||||
|
pcall(vim.api.nvim_win_close, float, true)
|
||||||
|
end)
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(
|
||||||
|
vim.api.nvim_win_get_buf(float),
|
||||||
|
0,
|
||||||
|
-1,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
t.truthy(
|
||||||
|
vim.startswith(lines[1] or "", sha:sub(1, 8)),
|
||||||
|
"the popup blames the commit even in a git:// buffer"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("open_commit opens the commit that last touched the line", function()
|
||||||
|
local _, buf = setup("alpha\nbeta\ngamma\n")
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
blame.open_commit()
|
||||||
|
wait_buf_name("^git://%x+$")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("open_file opens the file at the line's commit", function()
|
||||||
|
local _, buf = setup("alpha\nbeta\ngamma\n")
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
blame.open_file()
|
||||||
|
wait_buf_name("^git://%x+:a%.txt$")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("open_file_parent opens the file at the parent commit", function()
|
||||||
|
local dir = h.make_repo({ ["a.txt"] = "a\nb\nc\n" })
|
||||||
|
local root = h.git(dir, "rev-parse", "HEAD").stdout
|
||||||
|
t.write(dir, "a.txt", "a\nB\nc\n")
|
||||||
|
h.git(dir, "add", "a.txt")
|
||||||
|
h.git(dir, "commit", "-q", "-m", "change middle")
|
||||||
|
vim.cmd.edit(dir .. "/a.txt")
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
blame.open_file_parent()
|
||||||
|
t.wait_for(function()
|
||||||
|
return vim.api.nvim_buf_get_name(0) == "git://" .. root .. ":a.txt"
|
||||||
|
end, "the file at the parent commit to open")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("the drill actions refuse an uncommitted line", function()
|
||||||
|
local _, buf = setup("a\nb\nc\n")
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "EDITED" })
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
t.quietly(function()
|
||||||
|
blame.open_commit()
|
||||||
|
vim.wait(200)
|
||||||
|
end)
|
||||||
|
t.eq(
|
||||||
|
vim.api.nvim_get_current_buf(),
|
||||||
|
buf,
|
||||||
|
"no commit opened for an uncommitted line"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("drilling chains through git:// buffers", function()
|
||||||
|
local _, buf = setup("alpha\nbeta\ngamma\n")
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
blame.open_file()
|
||||||
|
wait_buf_name("^git://%x+:a%.txt$")
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
blame.open_commit()
|
||||||
|
wait_buf_name("^git://%x+$")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("detach drops the blame state", function()
|
||||||
|
local _, buf = setup("a\nb\nc\n")
|
||||||
|
populate_blame(buf)
|
||||||
|
blame.detach(buf)
|
||||||
|
t.eq(blame.state(buf), nil, "state dropped on detach")
|
||||||
|
end)
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
local cmd = require("git.cmd")
|
||||||
|
local h = require("test.git.helpers")
|
||||||
|
local t = require("test")
|
||||||
|
|
||||||
|
---@param files table<string, string>?
|
||||||
|
---@return string dir
|
||||||
|
local function make_repo(files)
|
||||||
|
return h.make_repo(files, { cd = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param actual string[]
|
||||||
|
---@param expected string[]
|
||||||
|
local function eq_sorted(actual, expected, msg)
|
||||||
|
table.sort(actual)
|
||||||
|
table.sort(expected)
|
||||||
|
t.eq(actual, expected, msg)
|
||||||
|
end
|
||||||
|
|
||||||
|
t.test("parse_args splits on whitespace", function()
|
||||||
|
t.eq(
|
||||||
|
cmd.parse_args("config user.name value"),
|
||||||
|
{ "config", "user.name", "value" }
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args preserves spaces inside double quotes", function()
|
||||||
|
t.eq(
|
||||||
|
cmd.parse_args([[config user.name "Oscar Wallberg"]]),
|
||||||
|
{ "config", "user.name", "Oscar Wallberg" }
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args preserves spaces inside single quotes", function()
|
||||||
|
t.eq(
|
||||||
|
cmd.parse_args([[log --grep='bug fix' --author=Oscar]]),
|
||||||
|
{ "log", "--grep=bug fix", "--author=Oscar" }
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args handles backslash-escaped space", function()
|
||||||
|
t.eq(cmd.parse_args([[a\ b c]]), { "a b", "c" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args handles escaped quote inside double quotes", function()
|
||||||
|
t.eq(cmd.parse_args([["a\"b" c]]), { 'a"b', "c" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args treats backslash literally inside single quotes", function()
|
||||||
|
t.eq(cmd.parse_args([['a\b' c]]), { "a\\b", "c" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args concatenates adjacent quoted segments", function()
|
||||||
|
t.eq(cmd.parse_args([[foo"bar"baz]]), { "foobarbaz" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args handles tabs as separators", function()
|
||||||
|
t.eq(cmd.parse_args("a\tb\tc"), { "a", "b", "c" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args returns empty list for empty or whitespace input", function()
|
||||||
|
t.eq(cmd.parse_args(""), {})
|
||||||
|
t.eq(cmd.parse_args(" \t "), {})
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args preserves empty quoted token", function()
|
||||||
|
t.eq(cmd.parse_args([[a "" b]]), { "a", "", "b" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args expands %% on unquoted token", function()
|
||||||
|
local buf = vim.api.nvim_create_buf(false, false)
|
||||||
|
t.defer(function()
|
||||||
|
pcall(vim.api.nvim_buf_delete, buf, { force = true })
|
||||||
|
end)
|
||||||
|
vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua")
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
|
||||||
|
t.eq(
|
||||||
|
cmd.parse_args("add %"),
|
||||||
|
{ "add", vim.fn.getcwd() .. "/some-file.lua" }
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args does not expand %% inside double quotes", function()
|
||||||
|
local buf = vim.api.nvim_create_buf(false, false)
|
||||||
|
t.defer(function()
|
||||||
|
pcall(vim.api.nvim_buf_delete, buf, { force = true })
|
||||||
|
end)
|
||||||
|
vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua")
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
|
||||||
|
t.eq(cmd.parse_args([[log -- "%"]]), { "log", "--", "%" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args does not expand %% inside single quotes", function()
|
||||||
|
local buf = vim.api.nvim_create_buf(false, false)
|
||||||
|
t.defer(function()
|
||||||
|
pcall(vim.api.nvim_buf_delete, buf, { force = true })
|
||||||
|
end)
|
||||||
|
vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua")
|
||||||
|
vim.api.nvim_set_current_buf(buf)
|
||||||
|
|
||||||
|
t.eq(cmd.parse_args([[log -- '%']]), { "log", "--", "%" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args does not treat mid-token tilde as expansion", function()
|
||||||
|
t.eq(cmd.parse_args("checkout HEAD~3"), { "checkout", "HEAD~3" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_args expands leading ~/ to home", function()
|
||||||
|
t.eq(cmd.parse_args("add ~/foo"), { "add", vim.fn.expand("~/foo") })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_complete_state with trailing space", function()
|
||||||
|
local s = cmd._parse_complete_state("G push origin ")
|
||||||
|
t.eq(s.prior, { "push", "origin" })
|
||||||
|
t.falsy(s.after_separator)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_complete_state mid-token", function()
|
||||||
|
local s = cmd._parse_complete_state("G push or")
|
||||||
|
t.eq(s.prior, { "push" })
|
||||||
|
t.falsy(s.after_separator)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_complete_state empty after command", function()
|
||||||
|
local s = cmd._parse_complete_state("G ")
|
||||||
|
t.eq(s.prior, {})
|
||||||
|
t.falsy(s.after_separator)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("parse_complete_state detects -- separator", function()
|
||||||
|
local s = cmd._parse_complete_state("G log -- foo")
|
||||||
|
t.eq(s.prior, { "log", "--" })
|
||||||
|
t.truthy(s.after_separator)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("positional_index ignores flags", function()
|
||||||
|
t.eq(cmd._positional_index({ "push" }), 1)
|
||||||
|
t.eq(cmd._positional_index({ "push", "origin" }), 2)
|
||||||
|
t.eq(cmd._positional_index({ "push", "--force" }), 1)
|
||||||
|
t.eq(cmd._positional_index({ "push", "--force", "origin" }), 2)
|
||||||
|
t.eq(cmd._positional_index({ "checkout", "-b", "feature" }), 2)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete returns subcommands at first position", function()
|
||||||
|
local matches = cmd.complete("ch", "G ch", 4)
|
||||||
|
t.truthy(vim.tbl_contains(matches, "checkout"))
|
||||||
|
t.truthy(vim.tbl_contains(matches, "cherry-pick"))
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete returns flags when arg starts with -", function()
|
||||||
|
local matches = cmd.complete("--am", "G commit --am", 13)
|
||||||
|
t.eq(matches, { "--amend" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete branch returns plain refs (no pseudo, no stash)", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
h.git(dir, "branch", "feature")
|
||||||
|
h.git(dir, "tag", "v1")
|
||||||
|
t.write(dir, "a", "modified")
|
||||||
|
h.git(dir, "stash")
|
||||||
|
local matches = cmd.complete("", "G branch ", 9)
|
||||||
|
eq_sorted(matches, { "feature", "main", "v1" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete merge returns refs + pseudo + stash", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
h.git(dir, "branch", "feature")
|
||||||
|
t.write(dir, "a", "y")
|
||||||
|
h.git(dir, "stash")
|
||||||
|
local matches = cmd.complete("", "G merge ", 8)
|
||||||
|
eq_sorted(
|
||||||
|
matches,
|
||||||
|
{ "HEAD", "ORIG_HEAD", "feature", "main", "stash", "stash@{0}" }
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete push first positional returns remotes", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
h.git(dir, "remote", "add", "origin", "/tmp/nope")
|
||||||
|
h.git(dir, "remote", "add", "upstream", "/tmp/nope")
|
||||||
|
local matches = cmd.complete("", "G push ", 7)
|
||||||
|
eq_sorted(matches, { "origin", "upstream" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete push second positional returns refs", function()
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
h.git(dir, "branch", "feature")
|
||||||
|
local matches = cmd.complete("", "G push origin ", 14)
|
||||||
|
eq_sorted(matches, { "HEAD", "feature", "main" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete add returns only unstaged/untracked paths", function()
|
||||||
|
local dir = make_repo({ tracked = "x" })
|
||||||
|
t.write(dir, "tracked", "modified")
|
||||||
|
t.write(dir, "newfile", "new")
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
r:refresh()
|
||||||
|
t.wait_for(function()
|
||||||
|
return r.status and #vim.tbl_keys(r.status.entries) > 0
|
||||||
|
end, "git status to report entries", 500)
|
||||||
|
local matches = cmd.complete("", "G add ", 6)
|
||||||
|
eq_sorted(matches, { "newfile", "tracked" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete after `--` returns tracked paths only", function()
|
||||||
|
local dir = make_repo({ a = "x", b = "y" })
|
||||||
|
t.write(dir, "untracked", "z")
|
||||||
|
local matches = cmd.complete("", "G log -- ", 9)
|
||||||
|
eq_sorted(matches, { "a", "b" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete stash returns subsubcommands", function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
local matches = cmd.complete("p", "G stash p", 9)
|
||||||
|
eq_sorted(matches, { "pop", "push" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete show with <rev>:<path> returns tree paths", function()
|
||||||
|
make_repo({ a = "x", ["sub/b"] = "y" })
|
||||||
|
local matches = cmd.complete("HEAD:", "G show HEAD:", 12)
|
||||||
|
eq_sorted(matches, { "HEAD:a", "HEAD:sub/" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("complete unknown subcommand falls back to tracked paths", function()
|
||||||
|
make_repo({ a = "x", b = "y" })
|
||||||
|
local matches = cmd.complete("", "G nonexistent ", 14)
|
||||||
|
eq_sorted(matches, { "a", "b" })
|
||||||
|
end)
|
||||||
|
|
||||||
|
---@param name_pattern string
|
||||||
|
---@return integer count
|
||||||
|
local function count_bufs_named(name_pattern)
|
||||||
|
local n = 0
|
||||||
|
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
||||||
|
if vim.api.nvim_buf_get_name(b):match(name_pattern) then
|
||||||
|
n = n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param buf_name_pattern string
|
||||||
|
---@param timeout integer?
|
||||||
|
local function wait_buf_populated(buf_name_pattern, timeout)
|
||||||
|
t.wait_for(function()
|
||||||
|
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
||||||
|
if vim.api.nvim_buf_get_name(b):match(buf_name_pattern) then
|
||||||
|
return #vim.api.nvim_buf_get_lines(b, 0, -1, false) > 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end, "buffer matching " .. buf_name_pattern .. " to populate", timeout)
|
||||||
|
end
|
||||||
|
|
||||||
|
---Wait for a buffer matching `buf_name_pattern` to contain a line whose
|
||||||
|
---content equals `line`. Useful for asserting that re-running a :G
|
||||||
|
---command repopulated the buffer with new output.
|
||||||
|
---@param buf_name_pattern string
|
||||||
|
---@param line string
|
||||||
|
---@param timeout integer?
|
||||||
|
local function wait_buf_has_line(buf_name_pattern, line, timeout)
|
||||||
|
t.wait_for(function()
|
||||||
|
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
||||||
|
if vim.api.nvim_buf_get_name(b):match(buf_name_pattern) then
|
||||||
|
for _, l in ipairs(vim.api.nvim_buf_get_lines(b, 0, -1, false)) do
|
||||||
|
if l == line then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end, "buffer " .. buf_name_pattern .. " to contain " .. line, timeout)
|
||||||
|
end
|
||||||
|
|
||||||
|
t.test("run :G diff reuses the same buffer across invocations", function()
|
||||||
|
local dir = make_repo({ a = "v1\n" })
|
||||||
|
t.write(dir, "a", "v2\n")
|
||||||
|
|
||||||
|
cmd.run({ "diff" })
|
||||||
|
wait_buf_has_line("%[Git diff%]", "+v2")
|
||||||
|
t.eq(count_bufs_named("%[Git diff%]"), 1)
|
||||||
|
|
||||||
|
t.write(dir, "a", "v3\n")
|
||||||
|
cmd.run({ "diff" })
|
||||||
|
wait_buf_has_line("%[Git diff%]", "+v3")
|
||||||
|
t.eq(count_bufs_named("%[Git diff%]"), 1, "second :G diff should reuse")
|
||||||
|
|
||||||
|
t.write(dir, "a", "v4\n")
|
||||||
|
cmd.run({ "diff" })
|
||||||
|
wait_buf_has_line("%[Git diff%]", "+v4")
|
||||||
|
t.eq(count_bufs_named("%[Git diff%]"), 1, "third :G diff should reuse")
|
||||||
|
end)
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@param prefix string
|
||||||
|
---@return integer? lnum
|
||||||
|
local function find_line(buf, prefix)
|
||||||
|
for i, l in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
|
||||||
|
if l:sub(1, #prefix) == prefix then
|
||||||
|
return i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
t.test(":G show <CR> on + line opens the blob URI", function()
|
||||||
|
local dir = make_repo({ a = "first\n" })
|
||||||
|
t.write(dir, "a", "second\n")
|
||||||
|
h.git(dir, "add", "a")
|
||||||
|
h.git(dir, "commit", "-q", "-m", "second")
|
||||||
|
assert(require("git.core.repo").resolve(dir))
|
||||||
|
local blob = h.git(dir, "rev-parse", "HEAD:a").stdout
|
||||||
|
|
||||||
|
cmd.run({ "show", "HEAD" })
|
||||||
|
wait_buf_populated("%[Git show HEAD%]")
|
||||||
|
---@type integer?
|
||||||
|
local diff_buf
|
||||||
|
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
||||||
|
if vim.api.nvim_buf_get_name(b):match("%[Git show HEAD%]") then
|
||||||
|
diff_buf = b
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert(diff_buf, "expected [Git show HEAD] buffer")
|
||||||
|
local win = vim.fn.bufwinid(diff_buf)
|
||||||
|
vim.api.nvim_set_current_win(win)
|
||||||
|
local lnum = assert(find_line(diff_buf, "+second"))
|
||||||
|
vim.api.nvim_win_set_cursor(win, { lnum, 0 })
|
||||||
|
|
||||||
|
t.truthy(require("git.object").open_under_cursor())
|
||||||
|
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. blob)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("<leader>gl log buffer refills after jumping back", function()
|
||||||
|
local dir = make_repo({ a = "v1\n" })
|
||||||
|
t.write(dir, "a", "v2\n")
|
||||||
|
h.git(dir, "add", "a")
|
||||||
|
h.git(dir, "commit", "-q", "-m", "second")
|
||||||
|
|
||||||
|
require("git.log_view").open({ max_count = 1000 })
|
||||||
|
wait_buf_populated("/GitLog$")
|
||||||
|
local log_buf = vim.api.nvim_get_current_buf()
|
||||||
|
local log_win = vim.api.nvim_get_current_win()
|
||||||
|
t.truthy(vim.api.nvim_buf_get_name(log_buf):match("/GitLog$"))
|
||||||
|
local initial_lines = #vim.api.nvim_buf_get_lines(log_buf, 0, -1, false)
|
||||||
|
t.truthy(initial_lines >= 2)
|
||||||
|
|
||||||
|
-- Step into a commit, then <C-o> back to the log.
|
||||||
|
vim.api.nvim_win_set_cursor(log_win, { 1, 0 })
|
||||||
|
t.press("<CR>")
|
||||||
|
t.truthy(vim.api.nvim_buf_get_name(0):match("^git://"))
|
||||||
|
|
||||||
|
t.press("<C-o>")
|
||||||
|
t.eq(vim.api.nvim_get_current_buf(), log_buf)
|
||||||
|
t.eq(
|
||||||
|
#vim.api.nvim_buf_get_lines(log_buf, 0, -1, false),
|
||||||
|
initial_lines,
|
||||||
|
"log buffer must repopulate on jump-back"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("<CR> still dispatches after navigating away and back", function()
|
||||||
|
local dir = make_repo({ a = "v1\n" })
|
||||||
|
t.write(dir, "a", "v2\n")
|
||||||
|
h.git(dir, "add", "a")
|
||||||
|
h.git(dir, "commit", "-q", "-m", "second")
|
||||||
|
|
||||||
|
-- Open the HEAD commit object buffer. Its cat-file output includes a
|
||||||
|
-- "parent <sha>" line we can navigate from.
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
require("git.object").open(r, "HEAD", { split = false })
|
||||||
|
local first_obj_buf = vim.api.nvim_get_current_buf()
|
||||||
|
local first_obj_win = vim.api.nvim_get_current_win()
|
||||||
|
t.truthy(vim.api.nvim_buf_get_name(first_obj_buf):match("^git://"))
|
||||||
|
|
||||||
|
-- Step into the parent commit. This hides first_obj_buf which has
|
||||||
|
-- bufhidden=delete, so it gets unloaded.
|
||||||
|
local parent_lnum = assert(find_line(first_obj_buf, "parent "))
|
||||||
|
vim.api.nvim_win_set_cursor(first_obj_win, { parent_lnum, 0 })
|
||||||
|
t.truthy(require("git.object").open_under_cursor())
|
||||||
|
local parent_buf = vim.api.nvim_get_current_buf()
|
||||||
|
t.truthy(parent_buf ~= first_obj_buf)
|
||||||
|
|
||||||
|
-- <C-o> back to first_obj_buf. With bufhidden=delete, vim re-reads the
|
||||||
|
-- URI, which previously raced with BufDelete-driven unbind and left
|
||||||
|
-- state cleared, so open_under_cursor returned false.
|
||||||
|
t.press("<C-o>")
|
||||||
|
t.eq(vim.api.nvim_get_current_buf(), first_obj_buf)
|
||||||
|
local tree_lnum = assert(find_line(first_obj_buf, "tree "))
|
||||||
|
vim.api.nvim_win_set_cursor(first_obj_win, { tree_lnum, 0 })
|
||||||
|
t.truthy(
|
||||||
|
require("git.object").open_under_cursor(),
|
||||||
|
"<CR> must work after returning to the buffer"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test(":G diff <CR> on + line falls back to worktree file", function()
|
||||||
|
local dir = make_repo({ a = "v1\n" })
|
||||||
|
t.write(dir, "a", "v2\n")
|
||||||
|
assert(require("git.core.repo").resolve(dir))
|
||||||
|
|
||||||
|
cmd.run({ "diff" })
|
||||||
|
wait_buf_populated("%[Git diff%]")
|
||||||
|
---@type integer?
|
||||||
|
local diff_buf
|
||||||
|
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
||||||
|
if vim.api.nvim_buf_get_name(b):match("%[Git diff%]") then
|
||||||
|
diff_buf = b
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert(diff_buf, "expected [Git diff] buffer")
|
||||||
|
local win = vim.fn.bufwinid(diff_buf)
|
||||||
|
vim.api.nvim_set_current_win(win)
|
||||||
|
local lnum = assert(find_line(diff_buf, "+v2"))
|
||||||
|
vim.api.nvim_win_set_cursor(win, { lnum, 0 })
|
||||||
|
|
||||||
|
t.truthy(require("git.object").open_under_cursor())
|
||||||
|
t.eq(vim.api.nvim_buf_get_name(0), vim.fs.joinpath(dir, "a"))
|
||||||
|
end)
|
||||||
|
|
||||||
|
---Run cmd.run via :lua so vim.fn.execute captures any nvim_echo output
|
||||||
|
---and suppresses it from headless stdout.
|
||||||
|
---@param args string[]
|
||||||
|
---@return string
|
||||||
|
local function run_capturing(args)
|
||||||
|
return vim.trim(
|
||||||
|
vim.fn.execute(
|
||||||
|
string.format([[lua require("git.cmd").run(%s)]], vim.inspect(args))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return integer? pwin
|
||||||
|
local function find_preview_win()
|
||||||
|
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||||
|
if vim.wo[w].previewwindow then
|
||||||
|
return w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function close_preview()
|
||||||
|
pcall(vim.cmd.pclose)
|
||||||
|
end
|
||||||
|
|
||||||
|
t.test("quiet :G echoes single-line stdout", function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
local out = run_capturing({ "config", "user.email" })
|
||||||
|
t.truthy(
|
||||||
|
out:match("t@t%.com"),
|
||||||
|
"expected output to contain t@t.com, got: " .. out
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("quiet :G is silent on empty-output success", function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
t.eq(run_capturing({ "config", "user.email", "new@t.com" }), "")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("quiet :G echoes 'git exited N' on silent failure", function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
local out = run_capturing({ "config", "--get", "nonexistent.foo.bar" })
|
||||||
|
t.truthy(
|
||||||
|
out:match("git exited 1"),
|
||||||
|
"expected output to contain 'git exited 1', got: " .. out
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("quiet :G echoes stderr on failure with output", function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
local out = run_capturing({ "branch", "-d", "nonexistent-branch" })
|
||||||
|
t.truthy(
|
||||||
|
out:match("nonexistent%-branch"),
|
||||||
|
"expected stderr mentioning the branch, got: " .. out
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
---@param fn fun(calls: { chunks: table, history: boolean, opts: table }[])
|
||||||
|
local function with_echo_stub(fn)
|
||||||
|
---@type { chunks: table, history: boolean, opts: table }[]
|
||||||
|
local calls = {}
|
||||||
|
local original = vim.api.nvim_echo
|
||||||
|
vim.api.nvim_echo = function(chunks, history, opts)
|
||||||
|
table.insert(calls, {
|
||||||
|
chunks = chunks,
|
||||||
|
history = history,
|
||||||
|
opts = opts or {},
|
||||||
|
})
|
||||||
|
return -1
|
||||||
|
end
|
||||||
|
local ok, err = pcall(fn, calls)
|
||||||
|
vim.api.nvim_echo = original
|
||||||
|
if not ok then
|
||||||
|
error(err, 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param calls { opts: table }[]
|
||||||
|
---@param status string
|
||||||
|
---@return boolean
|
||||||
|
local function has_status(calls, status)
|
||||||
|
for _, c in ipairs(calls) do
|
||||||
|
if c.opts.status == status then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param calls { history: boolean }[]
|
||||||
|
---@return boolean
|
||||||
|
local function any_history(calls)
|
||||||
|
for _, c in ipairs(calls) do
|
||||||
|
if c.history then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.test("quiet :G success does not add to :messages history", function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
with_echo_stub(function(calls)
|
||||||
|
cmd.run({ "config", "user.email" })
|
||||||
|
t.falsy(any_history(calls), "success path must not write history")
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("quiet :G silent failure adds 'git exited N' to history", function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
with_echo_stub(function(calls)
|
||||||
|
cmd.run({ "config", "--get", "nonexistent.foo.bar" })
|
||||||
|
t.truthy(any_history(calls), "failure path must write history")
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("quiet :G stderr failure adds error to history", function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
with_echo_stub(function(calls)
|
||||||
|
cmd.run({ "branch", "-d", "nonexistent-branch" })
|
||||||
|
t.truthy(any_history(calls), "failure path must write history")
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("streaming :G fetch (no bang) does not open a window", function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
with_echo_stub(function(calls)
|
||||||
|
local before = #vim.api.nvim_tabpage_list_wins(0)
|
||||||
|
cmd.run({ "fetch" })
|
||||||
|
t.wait_for(function()
|
||||||
|
return has_status(calls, "failed")
|
||||||
|
or has_status(calls, "success")
|
||||||
|
end, "streaming job to terminate", 5000)
|
||||||
|
t.eq(#vim.api.nvim_tabpage_list_wins(0), before, "no new window")
|
||||||
|
t.falsy(find_preview_win(), "no preview window")
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test(
|
||||||
|
"streaming :G fetch (no bang) emits failed progress on bad remote",
|
||||||
|
function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
with_echo_stub(function(calls)
|
||||||
|
cmd.run({ "fetch", "nonexistent" })
|
||||||
|
t.wait_for(function()
|
||||||
|
return has_status(calls, "failed")
|
||||||
|
end, "failed progress notification", 5000)
|
||||||
|
---@type { chunks: table, history: boolean, opts: table }?
|
||||||
|
local final
|
||||||
|
for _, c in ipairs(calls) do
|
||||||
|
if c.opts.status == "failed" then
|
||||||
|
final = c
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
t.truthy(final)
|
||||||
|
---@cast final -nil
|
||||||
|
t.eq(final.opts.kind, "progress")
|
||||||
|
t.falsy(final.history, "transient progress, not history")
|
||||||
|
t.truthy(has_status(calls, "running"), "running progress emitted")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
t.test(
|
||||||
|
"streaming :G fetch (no bang) on failure highlights only fatal/error lines",
|
||||||
|
function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
with_echo_stub(function(calls)
|
||||||
|
cmd.run({ "fetch", "nonexistent" })
|
||||||
|
t.wait_for(function()
|
||||||
|
return has_status(calls, "failed")
|
||||||
|
end, "failed progress notification", 5000)
|
||||||
|
---@type table?
|
||||||
|
local dump
|
||||||
|
for _, c in ipairs(calls) do
|
||||||
|
if c.history == true then
|
||||||
|
dump = c.chunks
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
t.truthy(dump, "expected history dump")
|
||||||
|
---@cast dump -nil
|
||||||
|
local fatal_chunks_red, plain_continuation = 0, false
|
||||||
|
for _, chunk in ipairs(dump) do
|
||||||
|
local text, hl = chunk[1], chunk[2]
|
||||||
|
if text:match("^fatal:") and hl == "ErrorMsg" then
|
||||||
|
fatal_chunks_red = fatal_chunks_red + 1
|
||||||
|
end
|
||||||
|
if text:match("Please make sure") and hl ~= "ErrorMsg" then
|
||||||
|
plain_continuation = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
t.truthy(
|
||||||
|
fatal_chunks_red >= 1,
|
||||||
|
"expected at least one fatal: line highlighted as ErrorMsg"
|
||||||
|
)
|
||||||
|
t.truthy(
|
||||||
|
plain_continuation,
|
||||||
|
"expected continuation line to be plain"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
t.test(
|
||||||
|
"streaming :G fetch (no bang) on success does not dump to :messages",
|
||||||
|
function()
|
||||||
|
local remote = vim.fn.tempname()
|
||||||
|
vim.fn.mkdir(remote, "p")
|
||||||
|
h.git(remote, "init", "-q", "--bare")
|
||||||
|
t.defer(function()
|
||||||
|
vim.fn.delete(remote, "rf")
|
||||||
|
end)
|
||||||
|
|
||||||
|
local dir = make_repo({ a = "x" })
|
||||||
|
h.git(dir, "remote", "add", "origin", remote)
|
||||||
|
h.git(dir, "push", "-q", "origin", "main")
|
||||||
|
|
||||||
|
with_echo_stub(function(calls)
|
||||||
|
cmd.run({ "fetch", "origin" })
|
||||||
|
t.wait_for(function()
|
||||||
|
return has_status(calls, "success")
|
||||||
|
end, "success progress notification", 5000)
|
||||||
|
for _, c in ipairs(calls) do
|
||||||
|
t.falsy(
|
||||||
|
c.history,
|
||||||
|
"success path must not echo to message history"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
t.test(
|
||||||
|
"streaming :G! fetch (bang) opens preview window with terminal buffer",
|
||||||
|
function()
|
||||||
|
make_repo({ a = "x" })
|
||||||
|
t.defer(close_preview)
|
||||||
|
|
||||||
|
cmd.run({ "fetch" }, { bang = true })
|
||||||
|
local pwin = find_preview_win()
|
||||||
|
t.truthy(pwin, "expected preview window to exist")
|
||||||
|
---@cast pwin integer
|
||||||
|
local buf = vim.api.nvim_win_get_buf(pwin)
|
||||||
|
t.eq(vim.bo[buf].buftype, "terminal")
|
||||||
|
end
|
||||||
|
)
|
||||||
@@ -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<string, string>?
|
||||||
|
---@param opts { cd: boolean? }?
|
||||||
|
---@return string dir
|
||||||
|
function M.make_repo(files, opts)
|
||||||
|
local dir = vim.fn.tempname()
|
||||||
|
vim.fn.mkdir(dir, "p")
|
||||||
|
M.git(dir, "init", "-q", "-b", "main")
|
||||||
|
M.git(dir, "config", "user.email", "t@t.com")
|
||||||
|
M.git(dir, "config", "user.name", "t")
|
||||||
|
if files and next(files) then
|
||||||
|
for path, content in pairs(files) do
|
||||||
|
t.write(dir, path, content)
|
||||||
|
end
|
||||||
|
M.git(dir, "add", ".")
|
||||||
|
M.git(dir, "commit", "-q", "-m", "init")
|
||||||
|
end
|
||||||
|
local prev_cwd
|
||||||
|
if opts and opts.cd then
|
||||||
|
prev_cwd = vim.fn.getcwd()
|
||||||
|
vim.cmd.cd(dir)
|
||||||
|
end
|
||||||
|
t.defer(function()
|
||||||
|
if prev_cwd then
|
||||||
|
pcall(vim.cmd.cd, prev_cwd)
|
||||||
|
else
|
||||||
|
pcall(vim.cmd.cd, "/tmp")
|
||||||
|
end
|
||||||
|
pcall(function()
|
||||||
|
require("git.core.repo").stop_all()
|
||||||
|
end)
|
||||||
|
vim.wait(60)
|
||||||
|
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
||||||
|
local name = vim.api.nvim_buf_get_name(b)
|
||||||
|
if name:find(dir, 1, true) or name:match("^git[a-z]*://") then
|
||||||
|
pcall(vim.api.nvim_buf_delete, b, { force = true })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vim.fn.delete(dir, "rf")
|
||||||
|
end)
|
||||||
|
return dir
|
||||||
|
end
|
||||||
|
|
||||||
|
---Build an outer repo with one nested submodule at `sub/`. Both the
|
||||||
|
---outer and inner repo are committed and registered for cleanup.
|
||||||
|
---@return string outer
|
||||||
|
---@return string inner
|
||||||
|
function M.make_submodule_repo()
|
||||||
|
local inner = M.make_repo({ a = "x\n" })
|
||||||
|
local outer = M.make_repo({ x = "x\n" })
|
||||||
|
vim.system({
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"protocol.file.allow=always",
|
||||||
|
"submodule",
|
||||||
|
"add",
|
||||||
|
"--quiet",
|
||||||
|
inner,
|
||||||
|
"sub",
|
||||||
|
}, { cwd = outer, text = true }):wait()
|
||||||
|
M.git(outer, "commit", "-q", "-m", "add submodule")
|
||||||
|
return outer, inner
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -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<string, boolean>
|
||||||
|
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)
|
||||||
@@ -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(<short sha>) canonicalizes to full sha", function()
|
||||||
|
local dir = h.make_repo({ a = "first\n" })
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
local sha = h.git(dir, "rev-parse", "HEAD").stdout
|
||||||
|
local short = h.git(dir, "rev-parse", "--short", "HEAD").stdout
|
||||||
|
t.truthy(#short < #sha, "short sha must be shorter than full")
|
||||||
|
|
||||||
|
object.open(r, short, { split = false })
|
||||||
|
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("M.open(HEAD:<path>) loads file content at HEAD", function()
|
||||||
|
local dir = h.make_repo({ ["a.txt"] = "first\nsecond\n" })
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
local sha = h.git(dir, "rev-parse", "HEAD").stdout
|
||||||
|
|
||||||
|
object.open(r, "HEAD:a.txt", { split = false })
|
||||||
|
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha .. ":a.txt")
|
||||||
|
t.eq(
|
||||||
|
vim.api.nvim_buf_get_lines(0, 0, -1, false),
|
||||||
|
{ "first", "second" }
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("M.open 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 <sha>' line opens the tree", function()
|
||||||
|
local dir = h.make_repo({ a = "first\n" })
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
local tree_sha = h.git(dir, "rev-parse", "HEAD^{tree}").stdout
|
||||||
|
|
||||||
|
object.open(r, "HEAD", { split = false })
|
||||||
|
local lnum = assert(find_line(0, "tree "), "expected a tree line")
|
||||||
|
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
||||||
|
|
||||||
|
t.truthy(object.open_under_cursor())
|
||||||
|
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. tree_sha)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("open_under_cursor on a 'parent <sha>' line opens the parent", function()
|
||||||
|
local dir = h.make_repo({ a = "first\n" })
|
||||||
|
t.write(dir, "a", "second\n")
|
||||||
|
h.git(dir, "add", "a")
|
||||||
|
h.git(dir, "commit", "-q", "-m", "second")
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
local parent_sha = h.git(dir, "rev-parse", "HEAD~").stdout
|
||||||
|
|
||||||
|
object.open(r, "HEAD", { split = false })
|
||||||
|
local lnum = assert(find_line(0, "parent "), "expected a parent line")
|
||||||
|
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
||||||
|
|
||||||
|
t.truthy(object.open_under_cursor())
|
||||||
|
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. parent_sha)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("open_under_cursor on a '+++ b/<path>' line loads the blob", function()
|
||||||
|
local dir = h.make_repo({ ["a.txt"] = "first\n" })
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
local blob_sha = h.git(dir, "rev-parse", "HEAD:a.txt").stdout
|
||||||
|
|
||||||
|
object.open(r, "HEAD", { split = false })
|
||||||
|
local lnum = assert(find_line(0, "+++ b/a.txt"), "expected a +++ line")
|
||||||
|
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
||||||
|
|
||||||
|
t.truthy(object.open_under_cursor())
|
||||||
|
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. blob_sha)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("open_under_cursor returns false on a non-dispatchable line", function()
|
||||||
|
local dir = h.make_repo({ a = "first\n" })
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
|
||||||
|
object.open(r, "HEAD", { split = false })
|
||||||
|
local lnum = assert(find_line(0, "author "), "expected an author line")
|
||||||
|
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
||||||
|
|
||||||
|
t.falsy(object.open_under_cursor())
|
||||||
|
end)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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("<Tab>")
|
||||||
|
t.wait_for(function()
|
||||||
|
return find_diff_win("left") ~= nil
|
||||||
|
end, "diff windows to appear")
|
||||||
|
|
||||||
|
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
|
||||||
|
vim.api.nvim_set_current_win(sidebar_win)
|
||||||
|
t.press("s")
|
||||||
|
t.wait_for(function()
|
||||||
|
return #r.status:rows("staged") > 0
|
||||||
|
end, "stage to propagate to repo state")
|
||||||
|
|
||||||
|
t.eq(
|
||||||
|
vim.api.nvim_win_get_cursor(sidebar_win),
|
||||||
|
{ line, 0 },
|
||||||
|
"sidebar cursor should remain at the entry's original line"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test(
|
||||||
|
"stage with diff open: diff foldmethod is preserved on refresh",
|
||||||
|
function()
|
||||||
|
local sidebar_win, line = setup_sidebar_with_unstaged_file(
|
||||||
|
"zsh/rc",
|
||||||
|
"# vim: set ft=zsh nowrap:\nZSH=true\n",
|
||||||
|
"# vim: set ft=zsh nowrap:\nZSH=true\nmodified\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
vim.api.nvim_set_current_win(sidebar_win)
|
||||||
|
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
|
||||||
|
|
||||||
|
t.press("<Tab>")
|
||||||
|
t.wait_for(function()
|
||||||
|
return find_diff_win("left") ~= nil
|
||||||
|
end, "diff windows to appear")
|
||||||
|
local left_win = assert(find_diff_win("left"))
|
||||||
|
t.eq(
|
||||||
|
vim.wo[left_win].foldmethod,
|
||||||
|
"diff",
|
||||||
|
"left diff foldmethod should be 'diff' after Tab"
|
||||||
|
)
|
||||||
|
|
||||||
|
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
|
||||||
|
vim.api.nvim_set_current_win(sidebar_win)
|
||||||
|
t.press("s")
|
||||||
|
t.wait_for(function()
|
||||||
|
return #r.status:rows("staged") > 0
|
||||||
|
end, "stage to propagate to repo state")
|
||||||
|
|
||||||
|
t.eq(
|
||||||
|
vim.wo[left_win].foldmethod,
|
||||||
|
"diff",
|
||||||
|
"left diff foldmethod should still be 'diff' after stage refresh"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
t.test(
|
||||||
|
"<Tab> in a second tabpage opens the diff inside that tabpage",
|
||||||
|
function()
|
||||||
|
local sidebar_win, line =
|
||||||
|
setup_sidebar_with_unstaged_file("foo.txt", "v1\n", "v2\n")
|
||||||
|
local tab1 = vim.api.nvim_get_current_tabpage()
|
||||||
|
|
||||||
|
vim.api.nvim_set_current_win(sidebar_win)
|
||||||
|
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
|
||||||
|
t.press("<Tab>")
|
||||||
|
t.wait_for(function()
|
||||||
|
return find_diff_win("left", tab1) ~= nil
|
||||||
|
end, "diff windows in tab1 to appear")
|
||||||
|
|
||||||
|
vim.cmd("tabnew")
|
||||||
|
require("git.status_view").open({ placement = "sidebar" })
|
||||||
|
local tab2 = vim.api.nvim_get_current_tabpage()
|
||||||
|
t.truthy(tab2 ~= tab1, "tabnew should produce a distinct tabpage")
|
||||||
|
|
||||||
|
local _, sidebar_win2 = find_sidebar()
|
||||||
|
assert(sidebar_win2, "sidebar window should exist in tab2")
|
||||||
|
vim.api.nvim_set_current_win(sidebar_win2)
|
||||||
|
vim.api.nvim_win_set_cursor(sidebar_win2, { line, 0 })
|
||||||
|
|
||||||
|
t.press("<Tab>")
|
||||||
|
t.wait_for(function()
|
||||||
|
return find_diff_win("left", tab2) ~= nil
|
||||||
|
end, "diff windows in tab2 to appear")
|
||||||
|
|
||||||
|
t.truthy(
|
||||||
|
find_diff_win("right", tab2),
|
||||||
|
"right diff window should be in tab2"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
t.test("refresh on stage updates the index URI buffer's content", function()
|
||||||
|
local sidebar_win, line =
|
||||||
|
setup_sidebar_with_unstaged_file("foo.txt", "v1\n", "v2\n")
|
||||||
|
|
||||||
|
vim.api.nvim_set_current_win(sidebar_win)
|
||||||
|
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
|
||||||
|
t.press("<Tab>")
|
||||||
|
t.wait_for(function()
|
||||||
|
return find_diff_win("left") ~= nil
|
||||||
|
end, "diff windows to appear")
|
||||||
|
|
||||||
|
local left_win = assert(find_diff_win("left"))
|
||||||
|
local index_buf = vim.api.nvim_win_get_buf(left_win)
|
||||||
|
t.eq(
|
||||||
|
vim.api.nvim_buf_get_lines(index_buf, 0, -1, false),
|
||||||
|
{ "v1" },
|
||||||
|
"index pane should initially show committed content"
|
||||||
|
)
|
||||||
|
|
||||||
|
vim.api.nvim_set_current_win(sidebar_win)
|
||||||
|
t.press("s")
|
||||||
|
t.wait_for(function()
|
||||||
|
local first = vim.api.nvim_buf_get_lines(index_buf, 0, -1, false)[1]
|
||||||
|
return first == "v2"
|
||||||
|
end, "index pane to refresh to staged content")
|
||||||
|
|
||||||
|
t.eq(
|
||||||
|
vim.api.nvim_buf_get_lines(index_buf, 0, -1, false),
|
||||||
|
{ "v2" },
|
||||||
|
"index pane should reflect staged content after refresh"
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test(
|
||||||
|
"re-selecting same entry after close + diffsplit keeps fold state in sync",
|
||||||
|
function()
|
||||||
|
local committed, worktree = {}, {}
|
||||||
|
for i = 1, 30 do
|
||||||
|
committed[i] = "line " .. i
|
||||||
|
worktree[i] = i == 15 and "CHANGED" or ("line " .. i)
|
||||||
|
end
|
||||||
|
local sidebar_win, line = setup_sidebar_with_unstaged_file(
|
||||||
|
"foo.txt",
|
||||||
|
table.concat(committed, "\n") .. "\n",
|
||||||
|
table.concat(worktree, "\n") .. "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
local prev_foldlevel = vim.o.foldlevel
|
||||||
|
vim.o.foldlevel = 99
|
||||||
|
t.defer(function()
|
||||||
|
vim.o.foldlevel = prev_foldlevel
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.api.nvim_set_current_win(sidebar_win)
|
||||||
|
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
|
||||||
|
t.press("<Tab>")
|
||||||
|
t.wait_for(function()
|
||||||
|
return find_diff_win("left") ~= nil
|
||||||
|
and find_diff_win("right") ~= nil
|
||||||
|
end, "first diff pair to appear")
|
||||||
|
|
||||||
|
local first_left = assert(find_diff_win("left"))
|
||||||
|
vim.api.nvim_win_close(first_left, false)
|
||||||
|
|
||||||
|
local remaining
|
||||||
|
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||||
|
if w ~= sidebar_win then
|
||||||
|
remaining = w
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not remaining then
|
||||||
|
error("a non-sidebar window should remain after close")
|
||||||
|
end
|
||||||
|
vim.api.nvim_set_current_win(remaining)
|
||||||
|
require("git.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("<Tab>")
|
||||||
|
t.wait_for(function()
|
||||||
|
local count = 0
|
||||||
|
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||||
|
if vim.wo[w].diff then
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return count == 2
|
||||||
|
end, "diff pair after re-selecting entry")
|
||||||
|
|
||||||
|
local left_win = assert(find_diff_win("left"))
|
||||||
|
local right_win = assert(find_diff_win("right"))
|
||||||
|
t.eq(
|
||||||
|
vim.wo[left_win].foldlevel,
|
||||||
|
0,
|
||||||
|
"left pane foldlevel should be 0 after re-select"
|
||||||
|
)
|
||||||
|
t.eq(
|
||||||
|
vim.wo[right_win].foldlevel,
|
||||||
|
0,
|
||||||
|
"right pane foldlevel should be 0 after re-select"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
t.test("sidebar buffer is named <worktree>/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 <worktree>/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("<Tab>")
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
+177
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user