refactor(git): rework module around clearer Status and Repo split

This commit is contained in:
2026-05-06 00:45:56 +02:00
parent 1b43fa6a1c
commit 80d6d465cf
17 changed files with 821 additions and 775 deletions
+1 -1
View File
@@ -242,7 +242,7 @@ vim.keymap.set("n", "<leader>gH", function()
require("git.diff").split({ rev = "HEAD", vertical = false })
end)
vim.keymap.set("n", "<leader>gg", function()
require("git.sidebar").toggle()
require("git.status_view").toggle()
end)
vim.keymap.set("n", "<leader>gc", function()
require("git.commit").commit()
+1 -1
View File
@@ -77,7 +77,7 @@ vim.opt.inccommand = "split"
vim.opt.winborder = "rounded"
vim.opt.confirm = true
vim.opt.statusline = "%{expand('%:.')} %{%v:lua.require('git.status').statusline()%} %3(%m%)"
vim.opt.statusline = "%{expand('%:.')} %{%v:lua.require('git.statusline').render()%} %3(%m%)"
.. " %="
.. " %{%v:lua.vim.diagnostic.status()%}"
.. " %{&filetype} %{&fileencoding} %{&fileformat}"
+8 -8
View File
@@ -5,11 +5,11 @@ local util = require("git.util")
local M = {}
---@class ow.Git.SplitHandler
---@class ow.Git.Cmd.SplitHandler
---@field ft string
---@field needs_rev boolean?
---@type table<string, ow.Git.SplitHandler>
---@type table<string, ow.Git.Cmd.SplitHandler>
local SPLIT_HANDLERS = {
log = { ft = "git" },
diff = { ft = "diff" },
@@ -74,7 +74,7 @@ end
---@param r ow.Git.Repo
---@param args string[]
---@param conf ow.Git.SplitHandler
---@param conf ow.Git.Cmd.SplitHandler
local function run_in_split(r, args, conf)
local cmd = { "git" }
vim.list_extend(cmd, args)
@@ -86,7 +86,7 @@ local function run_in_split(r, args, conf)
end
local name = "[git " .. table.concat(args, " ") .. "]"
local buf = place_split(name)
repo.attach(buf, r)
repo.bind(buf, r)
object.attach_dispatch(buf)
local state = r:state(buf) --[[@as -nil]]
state.sha = nil
@@ -178,7 +178,7 @@ end
---@param args string[]
function M.run(args)
local r = repo.find()
local r = repo.resolve()
if not r then
util.warning("not in a git repository")
return
@@ -193,7 +193,7 @@ function M.run(args)
if sub == "show" then
local arg = first_positional(args, 2)
if arg and arg:find(":", 1, true) then
object.open_object(r, arg)
object.open(r, arg)
return
end
run_in_split(r, args, { ft = "git", needs_rev = true })
@@ -204,7 +204,7 @@ function M.run(args)
if vim.list_contains(args, "-p") then
local rev = first_positional(args, 2)
if rev then
object.open_object(r, rev)
object.open(r, rev)
return
end
end
@@ -223,7 +223,7 @@ end
---@param arg_lead string
---@return string[]
function M.complete_rev(arg_lead)
local r = repo.find()
local r = repo.resolve()
if not r then
return {}
end
+1 -1
View File
@@ -7,7 +7,7 @@ local M = {}
---@param opts { amend: boolean? }?
function M.commit(opts)
local amend = opts and opts.amend or false
local r = repo.find()
local r = repo.resolve()
if not r then
util.warning("not in a git repository")
return
+13 -5
View File
@@ -4,6 +4,14 @@ local util = require("git.util")
local M = {}
---@class ow.Git.Diff.Side
---@field buf integer
---@field name string?
---@class ow.Git.Diff.Pair
---@field left ow.Git.Diff.Side
---@field right ow.Git.Diff.Side
---@param win integer
---@param enabled boolean
function M.set_diff(win, enabled)
@@ -28,7 +36,7 @@ end
---@param left_win integer
---@param right_win integer
---@param pair ow.Git.DiffPair
---@param pair ow.Git.Diff.Pair
function M.update_pair(left_win, right_win, pair)
M.set_diff(left_win, false)
M.set_diff(right_win, false)
@@ -58,11 +66,11 @@ local function place_pair(buf_a, buf_b, a_left, vertical)
end
end
---@param opts ow.Git.SplitOpts
---@param opts ow.Git.Diff.SplitOpts
---@param buf integer
---@param rev ow.Git.Revision
local function uri_split(opts, buf, rev)
local r = repo.find(buf)
local r = repo.resolve(buf)
if not r then
util.warning("git URI buffer has no worktree")
return
@@ -127,11 +135,11 @@ local function uri_split(opts, buf, rev)
place_pair(buf, object.buf_for(r, other_rev, content), left, opts.vertical)
end
---@class ow.Git.SplitOpts
---@class ow.Git.Diff.SplitOpts
---@field rev string?
---@field vertical boolean
---@param opts ow.Git.SplitOpts
---@param opts ow.Git.Diff.SplitOpts
function M.split(opts)
local cur_buf = vim.api.nvim_get_current_buf()
local cur_path = vim.api.nvim_buf_get_name(cur_buf)
+31 -8
View File
@@ -20,25 +20,28 @@ function M.init()
local group = vim.api.nvim_create_augroup("ow.git", { clear = true })
vim.api.nvim_create_autocmd(
{ "BufReadPost", "BufNewFile", "BufWritePost", "FileChangedShellPost" },
{
vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, {
group = group,
callback = function(args)
require("git.repo").track(args.buf)
end,
})
vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, {
group = group,
callback = function(args)
require("git.repo").refresh(args.buf)
end,
}
)
})
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
group = group,
callback = function(args)
require("git.repo").unregister(args.buf)
require("git.repo").unbind(args.buf)
end,
})
vim.api.nvim_create_autocmd("FocusGained", {
group = group,
callback = function(args)
require("git.repo").refresh(args.buf)
callback = function()
require("git.repo").refresh_all()
end,
})
vim.api.nvim_create_autocmd("VimLeavePre", {
@@ -48,6 +51,22 @@ function M.init()
end,
})
vim.api.nvim_create_autocmd({ "VimEnter", "DirChanged", "TabEnter" }, {
group = group,
callback = function()
require("git.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.repo").release_tab(tab)
end
end,
})
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "git://*",
group = group,
@@ -118,6 +137,10 @@ function M.init()
return require("git.cmd").complete(...)
end,
})
vim.api.nvim_create_user_command("Grefresh", function()
require("git.repo").refresh_all()
end, { desc = "Refresh git status for all repos" })
end
return M
+10 -8
View File
@@ -12,7 +12,7 @@ local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
---@param buf integer
local function attach_dispatch(buf)
vim.keymap.set("n", "<CR>", function()
local r = repo.find(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).
@@ -22,7 +22,7 @@ local function attach_dispatch(buf)
:match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)")
if sha then
---@cast r -nil
require("git.object").open_object(r, sha, { split = false })
require("git.object").open(r, sha, { split = false })
else
vim.api.nvim_feedkeys(cr, "n", false)
end
@@ -50,7 +50,7 @@ end
---@param buf integer
local function populate(buf)
local r = repo.find(buf)
local r = repo.resolve(buf)
local state = r and r:state(buf)
if not r or not state then
return
@@ -100,7 +100,7 @@ function M.read_uri(buf)
if not r then
return
end
repo.attach(buf, r)
repo.bind(buf, r)
vim.bo[buf].swapfile = false
vim.bo[buf].bufhidden = "hide"
@@ -113,7 +113,7 @@ function M.read_uri(buf)
populate(buf)
end
---@class ow.Git.LogOpts
---@class ow.Git.Log.OpenOpts
---@field max_count integer?
---@type table<string, fun(s: string): any>
@@ -121,17 +121,17 @@ M.opt_parsers = {
max_count = tonumber,
}
---@param opts ow.Git.LogOpts?
---@param opts ow.Git.Log.OpenOpts?
function M.open(opts)
opts = opts or {}
local r = repo.find()
local r = repo.resolve()
if not r then
util.warning("not in a git repository")
return
end
local buf = vim.fn.bufadd(M.URI_PREFIX .. r.worktree)
repo.attach(buf, r)
repo.bind(buf, r)
local state = r:state(buf) --[[@as -nil]]
state.log_max_count = opts.max_count
local was_loaded = vim.api.nvim_buf_is_loaded(buf)
@@ -183,4 +183,6 @@ function M.complete_glog(arg_lead)
return matches
end
repo.on_uri_refresh(M.URI_PREFIX, M.read_uri)
return M
+11 -9
View File
@@ -145,7 +145,7 @@ end
---@return integer
function M.buf_for(r, rev, content)
local buf = vim.fn.bufadd(M.format_uri(rev))
repo.attach(buf, r)
repo.bind(buf, r)
if content then
local state = r:state(buf) --[[@as -nil]]
state.pending_content = content
@@ -163,12 +163,12 @@ function M.read_uri(buf)
end
local rev_str = rev:format()
local r = repo.find(buf)
local r = repo.resolve(buf)
if not r then
util.error("git BufReadCmd %s: cannot resolve worktree", name)
return
end
repo.attach(buf, r)
repo.bind(buf, r)
local state = r:state(buf) --[[@as -nil]]
vim.bo[buf].swapfile = false
@@ -275,7 +275,7 @@ local function load_blob(r, blob, path, sha)
vim.api.nvim_set_current_buf(buf)
end
---@param s ow.Git.BufState
---@param s ow.Git.Repo.BufState
---@param section ow.Git.DiffSection
local function open_section(s, section)
if not section.blob_a or not section.blob_b then
@@ -300,13 +300,13 @@ local function open_section(s, section)
vim.api.nvim_set_current_buf(buf)
end
---@class ow.Git.OpenObjectOpts
---@class ow.Git.Object.OpenOpts
---@field split (false|"above"|"below"|"left"|"right")?
---@param r ow.Git.Repo
---@param rev string
---@param opts ow.Git.OpenObjectOpts?
function M.open_object(r, rev, opts)
---@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, true)
@@ -341,7 +341,7 @@ function M.open_under_cursor()
or line:match("^tree (%x+)$")
or line:match("^object (%x+)$")
if sha then
M.open_object(r, sha, { split = false })
M.open(r, sha, { split = false })
return true
end
@@ -351,7 +351,7 @@ function M.open_under_cursor()
local nav_rev = entry_type == "blob"
and Revision.new({ base = s.sha, path = entry_name }):format()
or entry_sha
M.open_object(r, nav_rev, { split = false })
M.open(r, nav_rev, { split = false })
return true
end
@@ -384,4 +384,6 @@ function M.open_under_cursor()
return false
end
repo.on_uri_refresh(M.URI_PREFIX, M.read_uri)
return M
+235 -292
View File
@@ -1,7 +1,9 @@
local status = require("git.status")
local util = require("git.util")
---@param buf integer?
local M = {}
---@param buf? integer
---@return integer
local function expand_buf(buf)
if not buf or buf == 0 then
@@ -10,7 +12,7 @@ local function expand_buf(buf)
return buf
end
---@class ow.Git.BufState
---@class ow.Git.Repo.BufState
---@field repo ow.Git.Repo
---@field sha string?
---@field parent_sha string?
@@ -19,110 +21,58 @@ end
---@field log_max_count integer?
---@field pending_content string?
---@class ow.Git.StatusEntry
---@field section "Untracked"|"Unstaged"|"Staged"|"Unmerged"
---@field x string
---@field y string
---@field path string
---@field orig string?
---@alias ow.Git.Repo.Event "refresh"
---@class ow.Git.BranchInfo
---@field head string?
---@field upstream string?
---@field ahead integer
---@field behind integer
---@class ow.Git.PorcelainGroups
---@field Untracked ow.Git.StatusEntry[]
---@field Unstaged ow.Git.StatusEntry[]
---@field Staged ow.Git.StatusEntry[]
---@field Unmerged ow.Git.StatusEntry[]
local global = util.Emitter.new()
---@class ow.Git.Repo
---@field gitdir string
---@field worktree string
---@field buffers table<integer, ow.Git.BufState>
---@field watcher? uv.uv_fs_event_t
---@field refresh fun(self: ow.Git.Repo)
---@field refresh_handle ow.Git.Util.DebounceHandle
---@field private refresh_listeners (fun(r: ow.Git.Repo, porcelain_stdout: 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 _watcher? uv.uv_fs_event_t
---@field private _schedule_refresh fun(self: ow.Git.Repo)
---@field private _refresh_handle ow.Git.Util.DebounceHandle
local Repo = {}
Repo.__index = Repo
---@param line string
---@return string x, string y, string path, string? orig
local function parse_porcelain_line(line)
local x = line:sub(1, 1)
local y = line:sub(2, 2)
local rest = line:sub(4)
local orig
if x == "R" or x == "C" or y == "R" or y == "C" then
local arrow = rest:find(" -> ", 1, true)
if arrow then
orig = rest:sub(1, arrow - 1)
rest = rest:sub(arrow + 4)
end
end
return x, y, rest, orig
end
---@param r ow.Git.Repo
local function do_refresh(r)
vim.system(
{
local STATUS_CMD = {
"git",
"--no-optional-locks",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
},
{ cwd = r.worktree, text = true },
"--ignored",
}
---@private
function Repo:_fetch_status()
vim.system(
STATUS_CMD,
{ cwd = self.worktree, text = true },
vim.schedule_wrap(function(obj)
local statuses = {}
if obj.code == 0 then
for line in (obj.stdout or ""):gmatch("[^\r\n]+") do
if line:sub(1, 2) ~= "##" then
local x, y, path = parse_porcelain_line(line)
statuses[vim.fs.joinpath(r.worktree, path)] =
status.format(x .. y)
end
end
else
if obj.code ~= 0 then
util.warning(
"git status failed: %s",
vim.trim(obj.stderr or "")
)
return
end
local dirty = false
for buf in pairs(r.buffers) do
if not vim.api.nvim_buf_is_valid(buf) then
r.buffers[buf] = nil
else
local name = vim.api.nvim_buf_get_name(buf)
local object = require("git.object")
local log = require("git.log")
if name:sub(1, #object.URI_PREFIX) == object.URI_PREFIX then
object.read_uri(buf)
elseif name:sub(1, #log.URI_PREFIX) == log.URI_PREFIX then
log.read_uri(buf)
else
local s = statuses[vim.fn.resolve(name)]
if vim.b[buf].git_status ~= s then
vim.b[buf].git_status = s
dirty = true
end
end
end
end
if dirty then
vim.cmd.redrawstatus({ bang = true })
end
r:notify_refresh(obj.code == 0 and obj.stdout or nil)
self.status = status.parse(obj.stdout or "")
self._events:emit("refresh", self.status)
global:emit("refresh", self, self.status)
end)
)
end
function Repo:refresh()
self:_schedule_refresh()
end
---@param gitdir string
---@param worktree string
---@return ow.Git.Repo
@@ -131,10 +81,14 @@ function Repo.new(gitdir, worktree)
gitdir = gitdir,
worktree = worktree,
buffers = {},
refresh_listeners = {},
tabs = {},
status = status.parse(""),
_events = util.Emitter.new(),
}, Repo)
self.refresh, self.refresh_handle = util.debounce(do_refresh, 50)
self._schedule_refresh, self._refresh_handle =
util.debounce(Repo._fetch_status, 50)
self:start_watcher()
self:refresh()
return self
end
@@ -148,7 +102,7 @@ function Repo:start_watcher()
)
return
end
local ok, err = watcher:start(
local ok, start_err = watcher:start(
self.gitdir,
{ recursive = true },
function(err_, filename)
@@ -163,67 +117,35 @@ function Repo:start_watcher()
end
)
if not ok then
util.warning("failed to watch %s: %s", self.gitdir, tostring(err))
util.warning("failed to watch %s: %s", self.gitdir, tostring(start_err))
watcher:close()
return
end
self.watcher = watcher
self._watcher = watcher
end
function Repo:stop_watcher()
if self.watcher then
self.watcher:stop()
self.watcher:close()
self.watcher = nil
function Repo:close()
if self._watcher then
self._watcher:stop()
self._watcher:close()
self._watcher = nil
end
self.refresh_handle.close()
self._refresh_handle.close()
end
---@param buf integer
function Repo:add_buffer(buf)
buf = expand_buf(buf)
if not self.buffers[buf] then
self.buffers[buf] = { repo = self }
end
---@param event ow.Git.Repo.Event
---@param fn fun(...)
---@return fun() unsubscribe
function Repo:on(event, fn)
return self._events:on(event, fn)
end
---@param buf integer
function Repo:remove_buffer(buf)
self.buffers[expand_buf(buf)] = nil
end
---@param buf integer
---@return ow.Git.BufState?
---@param buf? integer
---@return ow.Git.Repo.BufState?
function Repo:state(buf)
return self.buffers[expand_buf(buf)]
end
---@return boolean
function Repo:has_buffers()
return next(self.buffers) ~= nil
end
---@param fn fun(r: ow.Git.Repo, porcelain_stdout: string?)
---@return fun() unsubscribe
function Repo:on_refresh(fn)
table.insert(self.refresh_listeners, fn)
return function()
for i, f in ipairs(self.refresh_listeners) do
if f == fn then
table.remove(self.refresh_listeners, i)
return
end
end
end
end
---@param porcelain_stdout string?
function Repo:notify_refresh(porcelain_stdout)
for _, fn in ipairs(self.refresh_listeners) do
fn(self, porcelain_stdout)
end
end
---@return string?
function Repo:head()
local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r")
@@ -278,27 +200,117 @@ function Repo:rev_parse(rev, short)
return trimmed ~= "" and trimmed or nil
end
local M = {}
---@type table<string, ow.Git.Repo> keyed by worktree
local repos = {}
---@type table<string, ow.Git.Repo>
local repo_by_gitdir = {}
---@param event ow.Git.Repo.Event
---@param fn fun(...)
---@return fun() unsubscribe
function M.on(event, fn)
return global:on(event, fn)
end
---@type table<integer, ow.Git.Repo>
local repo_by_buf = {}
---@param prefix string
---@param fn fun(buf: integer)
---@return fun() unsubscribe
function M.on_uri_refresh(prefix, fn)
return M.on("refresh", function(r)
for buf in pairs(r.buffers) do
if vim.api.nvim_buf_is_valid(buf) then
local name = vim.api.nvim_buf_get_name(buf)
if name:sub(1, #prefix) == prefix then
fn(buf)
end
end
end
end)
end
---@param r ow.Git.Repo
local function release(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
---@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?
function M.resolve(path)
path = vim.fn.resolve(path)
local function find_by_path(path)
if path == "" then
return nil
end
if repos[path] then
return repos[path]
end
for wt, r in pairs(repos) do
if path:sub(1, #wt + 1) == wt .. "/" then
return r
end
end
return 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 path:match("^%a+://") 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)
local existing = M.find(arg)
if existing then
return existing
end
local path
if type(arg) == "string" then
path = vim.fn.resolve(arg)
else
path = path_for_buf(expand_buf(arg))
end
local found = vim.fs.find(".git", { upward = true, path = path })[1]
if not found then
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 worktree = vim.fs.dirname(found)
local gitdir
if stat.type == "directory" then
gitdir = found
@@ -314,192 +326,123 @@ function M.resolve(path)
util.warning(".git file at %s has no `gitdir:` line", found)
return nil
end
if rel:match("^/") then
gitdir = rel
else
gitdir = vim.fs.joinpath(worktree, rel)
end
gitdir = vim.fs.normalize(gitdir)
end
local r = repo_by_gitdir[gitdir]
if not r then
r = Repo.new(gitdir, worktree)
repo_by_gitdir[gitdir] = r
gitdir = vim.fs.normalize(
rel:match("^/") and rel or vim.fs.joinpath(worktree, rel)
)
end
local r = Repo.new(gitdir, worktree)
repos[worktree] = r
return r
end
---@param buf integer?
---@return ow.Git.Repo?
function M.find(buf)
buf = expand_buf(buf)
local existing = repo_by_buf[buf]
if existing then
return existing
end
local path = vim.api.nvim_buf_get_name(buf)
if path == "" or path:match("^%a+://") then
path = vim.fn.getcwd()
end
return M.resolve(path)
end
---@param buf integer?
---@return ow.Git.BufState?
---@param buf? integer
---@return ow.Git.Repo.BufState?
function M.state(buf)
buf = expand_buf(buf)
local r = repo_by_buf[buf]
local r = find_by_buf(buf)
return r and r.buffers[buf]
end
---@param buf integer
---@param buf? integer
---@param r ow.Git.Repo
function M.attach(buf, r)
function M.bind(buf, r)
buf = expand_buf(buf)
if repo_by_buf[buf] == r then
local prev = find_by_buf(buf)
if prev == r then
return
end
if repo_by_buf[buf] then
repo_by_buf[buf]:remove_buffer(buf)
if prev then
prev.buffers[buf] = nil
release(prev)
end
r:add_buffer(buf)
repo_by_buf[buf] = r
r.buffers[buf] = { repo = r }
end
---@param buf integer
---@return ow.Git.Repo?
function M.register(buf)
---@param buf? integer
function M.unbind(buf)
buf = expand_buf(buf)
local r = M.find(buf)
if not r then
return nil
end
if repo_by_buf[buf] ~= r then
M.attach(buf, r)
end
return r
end
---@param buf integer
function M.unregister(buf)
buf = expand_buf(buf)
local r = repo_by_buf[buf]
local r = find_by_buf(buf)
if not r then
return
end
repo_by_buf[buf] = nil
r:remove_buffer(buf)
if not r:has_buffers() then
r:stop_watcher()
repo_by_gitdir[r.gitdir] = nil
end
r.buffers[buf] = nil
release(r)
end
---@param buf integer
function M.refresh(buf)
buf = expand_buf(buf)
---@return boolean
local function is_worktree_buf(buf)
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
return
return false
end
local path = vim.api.nvim_buf_get_name(buf)
if path == "" or path:match("^%a+://") then
return path ~= "" and not path:match("^%a+://")
end
---@param buf? integer
function M.track(buf)
buf = expand_buf(buf)
if not is_worktree_buf(buf) then
return
end
local r = M.register(buf)
if not r then
vim.b[buf].git_status = nil
return
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()
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(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(r)
return
end
end
end
function M.stop_all()
for _, r in pairs(repo_by_gitdir) do
r:stop_watcher()
for _, r in pairs(repos) do
r:close()
end
end
---@param line string
---@return ow.Git.BranchInfo
function M.parse_branch_line(line)
local info = { ahead = 0, behind = 0 }
local content = line:sub(4)
local arrow = content:find("...", 1, true)
if not arrow then
info.head = content
return info
end
info.head = content:sub(1, arrow - 1)
local rest = content:sub(arrow + 3)
local bracket = rest:find(" %[")
if not bracket then
info.upstream = rest
return info
end
info.upstream = rest:sub(1, bracket - 1)
local inside = rest:match("%[([^%]]+)%]")
if inside then
info.ahead = (tonumber(inside:match("ahead (%d+)")) or 0) --[[@as integer]]
info.behind = (tonumber(inside:match("behind (%d+)")) or 0) --[[@as integer]]
end
return info
end
---@param stdout string
---@return ow.Git.BranchInfo, ow.Git.PorcelainGroups
function M.parse_porcelain(stdout)
local branch = { ahead = 0, behind = 0 }
---@type ow.Git.PorcelainGroups
local groups = {
Untracked = {},
Unstaged = {},
Staged = {},
Unmerged = {},
}
for line in stdout:gmatch("[^\r\n]+") do
if line:sub(1, 2) == "##" then
branch = M.parse_branch_line(line)
else
local x, y, path, orig = parse_porcelain_line(line)
if x == "?" and y == "?" then
table.insert(groups.Untracked, {
section = "Untracked",
x = x,
y = y,
path = path,
orig = orig,
})
elseif status.UNMERGED[x .. y] then
table.insert(groups.Unmerged, {
section = "Unmerged",
x = x,
y = y,
path = path,
orig = orig,
})
else
if x ~= " " then
table.insert(groups.Staged, {
section = "Staged",
x = x,
y = y,
path = path,
orig = orig,
})
end
if y ~= " " then
table.insert(groups.Unstaged, {
section = "Unstaged",
x = x,
y = y,
path = path,
orig = orig,
})
end
end
end
end
return branch, groups
end
return M
+153 -27
View File
@@ -1,6 +1,6 @@
local M = {}
M.UNMERGED = {
local UNMERGED = {
DD = true,
AU = true,
UD = true,
@@ -10,48 +10,174 @@ M.UNMERGED = {
UU = true,
}
---@param code string
---@return string? char
---@return string? hl_group
function M.indicator(code)
if code == "" then
return nil
---@alias ow.Git.Status.EntryKind "untracked"|"unstaged"|"staged"|"unmerged"|"ignored"
---@class ow.Git.Status.Entry
---@field path string
---@field kind ow.Git.Status.EntryKind
---@field char string
---@field hl string
---@field orig string?
---@class ow.Git.Status.BranchInfo
---@field head string?
---@field upstream string?
---@field ahead integer
---@field behind integer
---@class ow.Git.Status
---@field branch ow.Git.Status.BranchInfo
---@field entries table<string, ow.Git.Status.Entry[]>
local Status = {}
Status.__index = Status
---@param kind ow.Git.Status.EntryKind
---@return ow.Git.Status.Entry[]
function Status:by_kind(kind)
local out = {}
for _, list in pairs(self.entries) do
for _, e in ipairs(list) do
if e.kind == kind then
table.insert(out, e)
end
if code == "??" then
return "?", "GitUntracked"
end
if code == "!!" then
return "!", "GitIgnored"
end
if M.UNMERGED[code] then
return "U", "GitUnmerged"
return out
end
local x, y = code:sub(1, 1), code:sub(2, 2)
if x == "R" or y == "R" then
---@param x string
---@return string char, string hl
local function staged_attrs(x)
if x == "R" then
return "R", "GitRenamed"
end
if y == " " and x ~= " " then
return x, "GitStaged"
end
---@param y string
---@return string char, string hl
local function unstaged_attrs(y)
if y == "R" then
return "R", "GitRenamed"
end
if y == "D" then
return "D", "GitDeleted"
end
return "M", "GitUnstaged"
return y, "GitUnstaged"
end
---@param code string
---@return string?
function M.format(code)
local char, hl = M.indicator(code)
if not char then
return nil
---@param line string
---@return ow.Git.Status.BranchInfo
local function parse_branch_line(line)
local info = { ahead = 0, behind = 0 }
local content = line:sub(4)
local arrow = content:find("...", 1, true)
if not arrow then
info.head = content
return info
end
return string.format("%%#%s#%s%%*", hl, char)
info.head = content:sub(1, arrow - 1)
local rest = content:sub(arrow + 3)
local bracket = rest:find(" %[")
if not bracket then
info.upstream = rest
return info
end
info.upstream = rest:sub(1, bracket - 1)
local inside = rest:match("%[([^%]]+)%]")
if inside then
info.ahead = (tonumber(inside:match("ahead (%d+)")) or 0) --[[@as integer]]
info.behind = (tonumber(inside:match("behind (%d+)")) or 0) --[[@as integer]]
end
return info
end
---@return string
function M.statusline()
return vim.b.git_status or ""
---@param line string
---@return string x, string y, string path, string? orig
local function parse_status_line(line)
local x = line:sub(1, 1)
local y = line:sub(2, 2)
local rest = line:sub(4)
local orig
if x == "R" or x == "C" or y == "R" or y == "C" then
local arrow = rest:find(" -> ", 1, true)
if arrow then
orig = rest:sub(1, arrow - 1)
rest = rest:sub(arrow + 4)
end
end
return x, y, rest, orig
end
---@param entries table<string, ow.Git.Status.Entry[]>
---@param entry ow.Git.Status.Entry
local function add(entries, entry)
local key = entry.path
if key:sub(-1) == "/" then
key = key:sub(1, -2)
end
local list = entries[key] or {}
table.insert(list, entry)
entries[key] = list
end
---@param stdout string
---@return ow.Git.Status
function M.parse(stdout)
local branch = { ahead = 0, behind = 0 }
---@type table<string, ow.Git.Status.Entry[]>
local entries = {}
for line in stdout:gmatch("[^\r\n]+") do
if line:sub(1, 2) == "##" then
branch = parse_branch_line(line)
else
local x, y, path, orig = parse_status_line(line)
if x == "?" and y == "?" then
add(entries, {
path = path,
kind = "untracked",
char = "?",
hl = "GitUntracked",
})
elseif x == "!" and y == "!" then
add(entries, {
path = path,
kind = "ignored",
char = "i",
hl = "GitIgnored",
})
elseif UNMERGED[x .. y] then
add(entries, {
path = path,
kind = "unmerged",
char = "!",
hl = "GitUnmerged",
})
else
if x ~= " " then
local char, hl = staged_attrs(x)
add(entries, {
path = path,
kind = "staged",
char = char,
hl = hl,
orig = orig,
})
end
if y ~= " " then
local char, hl = unstaged_attrs(y)
add(entries, {
path = path,
kind = "unstaged",
char = char,
hl = hl,
orig = orig,
})
end
end
end
end
return setmetatable({ branch = branch, entries = entries }, Status)
end
return M
+122 -292
View File
@@ -2,32 +2,18 @@ local Revision = require("git.revision")
local diff = require("git.diff")
local object = require("git.object")
local repo = require("git.repo")
local status = require("git.status")
local util = require("git.util")
local M = {}
local SECTIONS = {
"Untracked",
"Unstaged",
"Staged",
"Unmerged",
"Unpushed",
"Unpulled",
}
local SIDEBAR_WIDTH = 50
---@type ow.Git.Status.EntryKind[]
local KINDS = { "untracked", "unstaged", "staged", "unmerged" }
local WINDOW_WIDTH = 50
---@class ow.Git.CommitEntry
---@field section "Unpushed"|"Unpulled"
---@field sha string
---@field subject string?
---@alias ow.Git.SidebarEntry ow.Git.StatusEntry | ow.Git.CommitEntry
---@class ow.Git.SidebarState
---@class ow.Git.StatusView.State
---@field repo ow.Git.Repo
---@field lines table<integer, ow.Git.SidebarEntry>
---@field sidebar_win integer?
---@field lines table<integer, ow.Git.Status.Entry>
---@field win integer?
---@field invocation_win integer?
---@field diff_left_win integer?
---@field diff_right_win integer?
@@ -35,181 +21,56 @@ local SIDEBAR_WIDTH = 50
---@field last_shown_key string?
---@field last_render_key string?
---@type table<integer, ow.Git.SidebarState>
---@type table<integer, ow.Git.StatusView.State>
local state = {}
local group = vim.api.nvim_create_augroup("ow.git.sidebar", { clear = false })
local ns = vim.api.nvim_create_namespace("ow.git.sidebar")
local group =
vim.api.nvim_create_augroup("ow.git.status_win", { clear = false })
local ns = vim.api.nvim_create_namespace("ow.git.status_win")
---@return integer? win
---@return integer? bufnr
local function find_sidebar()
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 == "gitsidebar" then
if vim.bo[buf].filetype == "gitstatus" then
return win, buf
end
end
end
---@param s ow.Git.SidebarState
---@param s ow.Git.StatusView.State
---@return integer?
local function sidebar_win_for(s)
local win = s.sidebar_win
local function win_for(s)
local win = s.win
if win and vim.api.nvim_win_is_valid(win) then
return win
end
win = find_sidebar()
s.sidebar_win = win
win = find_view()
s.win = win
return win
end
---@param entry ow.Git.SidebarEntry
---@return string?
local function entry_code(entry)
if entry.section == "Untracked" then
return "??"
elseif entry.section == "Unmerged" then
return entry.x .. entry.y
elseif entry.section == "Staged" then
return entry.x .. " "
elseif entry.section == "Unstaged" then
return " " .. entry.y
end
end
---@param entry ow.Git.SidebarEntry
---@return string? line
---@return string? hl_group
---@return integer? hl_len
---@param entry ow.Git.Status.Entry
---@return string line
---@return string hl_group
---@return integer hl_len
local function format_entry(entry)
if entry.sha then
return string.format(" %s %s", entry.sha, entry.subject or ""),
"GitSha",
#entry.sha
end
local code = entry_code(entry)
if not code then
return nil
end
local char, hl = status.indicator(code)
if not char then
return nil
end
local label = entry.orig and (entry.orig .. " -> " .. entry.path)
or entry.path
return string.format(" %s %s", char, label), hl, #char
return string.format(" %s %s", entry.char, label), entry.hl, #entry.char
end
---@param stdout string
---@return ow.Git.BranchInfo, table<string, ow.Git.SidebarEntry[]>
local function parse_porcelain(stdout)
local branch, groups = repo.parse_porcelain(stdout)
---@cast groups table<string, ow.Git.SidebarEntry[]>
groups.Unpushed = {}
groups.Unpulled = {}
return branch, groups
end
---@param worktree string
---@param branch ow.Git.BranchInfo
---@param groups table<string, ow.Git.SidebarEntry[]>
local function enrich_with_log(worktree, branch, groups)
local fetches = {}
if branch.upstream and branch.ahead > 0 then
table.insert(
fetches,
{ section = "Unpushed", range = "@{upstream}..HEAD" }
)
end
if branch.upstream and branch.behind > 0 then
table.insert(
fetches,
{ section = "Unpulled", range = "HEAD..@{upstream}" }
)
end
local pending = {}
for _, f in ipairs(fetches) do
table.insert(pending, {
f = f,
sys = vim.system({
"git",
"log",
"--max-count=200",
"--format=%h %s",
f.range,
}, { cwd = worktree, text = true }),
})
end
for _, p in ipairs(pending) do
local result = p.sys:wait()
if result.code == 0 then
for line in (result.stdout or ""):gmatch("[^\r\n]+") do
local sha, subject = line:match("^(%S+)%s+(.+)$")
if sha then
table.insert(groups[p.f.section], {
section = p.f.section,
sha = sha,
subject = subject,
})
end
end
else
util.error(
"git log %s failed: %s",
p.f.range,
vim.trim(result.stderr or "")
)
end
end
end
---@param worktree string
---@param prefetched_stdout string?
---@param callback fun(branch: ow.Git.BranchInfo, groups: table<string, ow.Git.SidebarEntry[]>)
local function fetch_status(worktree, prefetched_stdout, callback)
if prefetched_stdout then
local branch, groups = parse_porcelain(prefetched_stdout)
enrich_with_log(worktree, branch, groups)
callback(branch, groups)
return
end
vim.system(
{
"git",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
},
{ cwd = worktree, text = true },
vim.schedule_wrap(function(obj)
if obj.code ~= 0 then
util.error("git status failed: %s", vim.trim(obj.stderr or ""))
local branch = { ahead = 0, behind = 0 }
local groups = {
Untracked = {},
Unstaged = {},
Staged = {},
Unmerged = {},
Unpushed = {},
Unpulled = {},
}
callback(branch, groups)
return
end
local branch, groups = parse_porcelain(obj.stdout or "")
enrich_with_log(worktree, branch, groups)
callback(branch, groups)
end)
)
---@param kind ow.Git.Status.EntryKind
---@return string
local function display_name(kind)
return (kind:gsub("^%l", string.upper))
end
---@param bufnr integer
---@param branch ow.Git.BranchInfo
---@param groups table<string, ow.Git.SidebarEntry[]>
local function render(bufnr, branch, groups)
---@param status ow.Git.Status
local function render(bufnr, status)
local branch = status.branch
local lines = { "Head: " .. (branch.head or "?") }
if branch.upstream then
local push = "Push: " .. branch.upstream
@@ -225,16 +86,17 @@ local function render(bufnr, branch, groups)
local meta = {}
local marks = {}
for _, section in ipairs(SECTIONS) do
local entries = groups[section]
if entries and #entries > 0 then
table.insert(lines, string.format("%s (%d)", section, #entries))
for _, kind in ipairs(KINDS) do
local entries = status:by_kind(kind)
if #entries > 0 then
table.insert(
lines,
string.format("%s (%d)", display_name(kind), #entries)
)
for _, entry in ipairs(entries) do
local line, hl, hl_len = format_entry(entry)
if line then
table.insert(lines, line)
meta[#lines] = entry
if hl and hl_len then
table.insert(marks, {
row = #lines - 1,
col = 2,
@@ -242,8 +104,6 @@ local function render(bufnr, branch, groups)
hl = hl,
})
end
end
end
table.insert(lines, "")
end
end
@@ -261,89 +121,78 @@ local function render(bufnr, branch, groups)
state[bufnr].lines = meta
end
---@param branch ow.Git.BranchInfo
---@param groups table<string, ow.Git.SidebarEntry[]>
---@param status ow.Git.Status
---@return string
local function fingerprint(branch, groups)
local function fingerprint(status)
local branch = status.branch
local parts = {
branch.head or "",
branch.upstream or "",
tostring(branch.ahead),
tostring(branch.behind),
}
for _, section in ipairs(SECTIONS) do
local entries = groups[section]
if entries then
for _, e in ipairs(entries) do
for _, kind in ipairs(KINDS) do
for _, e in ipairs(status:by_kind(kind)) do
table.insert(
parts,
e.section
e.kind
.. ":"
.. (e.path or e.sha or "")
.. e.path
.. ":"
.. (e.orig or "")
.. ":"
.. (e.x or "")
.. ":"
.. (e.y or "")
.. e.char
)
end
end
end
return table.concat(parts, "\0")
end
---@param bufnr integer
---@param prefetched_stdout string?
local function refresh(bufnr, prefetched_stdout)
local function refresh(bufnr)
local s = state[bufnr]
if not s then
return
end
local saved_path, saved_sha
local sidebar_win = sidebar_win_for(s)
if sidebar_win then
local lnum = vim.api.nvim_win_get_cursor(sidebar_win)[1]
local saved_path
local status_win = win_for(s)
if status_win then
local lnum = vim.api.nvim_win_get_cursor(status_win)[1]
local entry = s.lines[lnum]
if entry then
saved_path = entry.path
saved_sha = entry.sha
end
end
fetch_status(s.repo.worktree, prefetched_stdout, function(branch, groups)
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
local status = s.repo.status
s.last_shown_key = nil
local fp = fingerprint(branch, groups)
local fp = fingerprint(status)
if fp == s.last_render_key then
return
end
s.last_render_key = fp
render(bufnr, branch, groups)
if not saved_path and not saved_sha then
render(bufnr, status)
if not saved_path then
return
end
for lnum, entry in pairs(s.lines) do
if
(saved_path and entry.path == saved_path)
or (saved_sha and entry.sha == saved_sha)
then
local win = sidebar_win_for(s)
if entry.path == saved_path then
local win = win_for(s)
if win then
pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 })
end
break
end
end
end)
end
---@param bufnr integer
---@return ow.Git.SidebarState?
---@return ow.Git.SidebarEntry?
---@return ow.Git.StatusView.State?
---@return ow.Git.Status.Entry?
local function current_entry(bufnr)
local s = state[bufnr]
if not s then
@@ -353,17 +202,9 @@ local function current_entry(bufnr)
return s, s.lines[lnum]
end
---@class ow.Git.DiffSide
---@field buf integer
---@field name string?
---@class ow.Git.DiffPair
---@field left ow.Git.DiffSide
---@field right ow.Git.DiffSide
---@param r ow.Git.Repo
---@param path string
---@return ow.Git.DiffSide
---@return ow.Git.Diff.Side
local function head_pane(r, path)
local rev = Revision.new({ base = "HEAD", path = path })
return {
@@ -374,16 +215,16 @@ end
---@param r ow.Git.Repo
---@param path string
---@return ow.Git.DiffSide
---@return ow.Git.Diff.Side
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.SidebarState
---@param entry ow.Git.StatusEntry
---@return ow.Git.DiffSide
---@param s ow.Git.StatusView.State
---@param entry ow.Git.Status.Entry
---@return ow.Git.Diff.Side
local function index_pane(s, entry)
local rev = Revision.new({ stage = 0, path = entry.path })
return {
@@ -392,39 +233,39 @@ local function index_pane(s, entry)
}
end
---@param s ow.Git.SidebarState
---@param entry ow.Git.StatusEntry
---@return ow.Git.DiffSide?
---@param s ow.Git.StatusView.State
---@param entry ow.Git.Status.Entry
---@return ow.Git.Diff.Side?
local function older_pane(s, entry)
if entry.section == "Staged" then
if entry.x == "A" then
if entry.kind == "staged" then
if entry.char == "A" then
return nil
end
return head_pane(s.repo, entry.orig or entry.path)
end
if entry.section == "Unstaged" then
if entry.kind == "unstaged" then
return index_pane(s, entry)
end
return nil
end
---@param s ow.Git.SidebarState
---@param entry ow.Git.StatusEntry
---@return ow.Git.DiffSide?
---@param s ow.Git.StatusView.State
---@param entry ow.Git.Status.Entry
---@return ow.Git.Diff.Side?
local function newer_pane(s, entry)
if entry.section == "Staged" then
if entry.x == "D" then
if entry.kind == "staged" then
if entry.char == "D" then
return nil
end
return index_pane(s, entry)
end
if entry.section == "Unstaged" then
if entry.y == "D" then
if entry.kind == "unstaged" then
if entry.char == "D" then
return nil
end
return worktree_pane(s.repo, entry.path)
end
if entry.section == "Untracked" then
if entry.kind == "untracked" then
return worktree_pane(s.repo, entry.path)
end
return nil
@@ -439,14 +280,14 @@ local function reset_diff_win(win)
end)
end
---@param s ow.Git.SidebarState
---@param s ow.Git.StatusView.State
---@return integer?
local function invocation_win_for(s)
local win = s.invocation_win
if not win or not vim.api.nvim_win_is_valid(win) then
return nil
end
if win == s.sidebar_win then
if win == s.win then
return nil
end
if
@@ -458,11 +299,11 @@ local function invocation_win_for(s)
return win
end
---@param s ow.Git.SidebarState
---@param sidebar_win integer
---@param s ow.Git.StatusView.State
---@param status_win integer
---@return integer? left
---@return integer? right
local function adopt_diff_wins(s, sidebar_win)
local function adopt_diff_wins(s, status_win)
local left = s.diff_left_win
local right = s.diff_right_win
if left and not vim.api.nvim_win_is_valid(left) then
@@ -475,7 +316,7 @@ local function adopt_diff_wins(s, sidebar_win)
return left, right
end
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if win ~= sidebar_win then
if win ~= status_win then
local role = vim.w[win].git_diff_role
if role == "left" and not left then
left = win
@@ -487,10 +328,10 @@ local function adopt_diff_wins(s, sidebar_win)
return left, right
end
---@param entry ow.Git.StatusEntry
---@param entry ow.Git.Status.Entry
---@return string
local function entry_key(entry)
return entry.section .. "|" .. entry.path .. "|" .. (entry.orig or "")
return entry.kind .. "|" .. entry.path .. "|" .. (entry.orig or "")
end
---@param target_win integer
@@ -506,11 +347,11 @@ local function vsplit_at(target_win, dir)
return win
end
---@param s ow.Git.SidebarState
---@param sidebar_win integer
---@param s ow.Git.StatusView.State
---@param status_win integer
---@param right_win integer?
---@return integer
local function ensure_right_win(s, sidebar_win, right_win)
local function ensure_right_win(s, status_win, right_win)
if right_win then
return right_win
end
@@ -518,46 +359,38 @@ local function ensure_right_win(s, sidebar_win, right_win)
if target then
right_win = target
else
right_win = vsplit_at(sidebar_win, "right")
vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH)
right_win = vsplit_at(status_win, "right")
vim.api.nvim_win_set_width(status_win, WINDOW_WIDTH)
end
reset_diff_win(right_win)
return right_win
end
---@param s ow.Git.SidebarState
---@param entry ow.Git.SidebarEntry
---@param s ow.Git.StatusView.State
---@param entry ow.Git.Status.Entry
---@param focus_left boolean
local function view_entry(s, entry, focus_left)
if not entry.path then
return
end
---@cast entry ow.Git.StatusEntry
local sidebar_win = sidebar_win_for(s)
if not sidebar_win then
local status_win = win_for(s)
if not status_win then
return
end
local left = older_pane(s, entry)
local right = newer_pane(s, entry)
if not left and not right then
util.warning(
"no content for %s entry: %s",
string.lower(entry.section),
entry.path
)
util.warning("no content for %s entry: %s", entry.kind, entry.path)
return
end
local key = entry_key(entry)
local left_win, right_win = adopt_diff_wins(s, sidebar_win)
local left_win, right_win = adopt_diff_wins(s, status_win)
local want_pair = left and right
if s.last_shown_key == key then
local intact = (want_pair and left_win and right_win)
or (not want_pair and right_win and not left_win)
if intact then
local target = focus_left and (left_win or right_win) or sidebar_win
local target = focus_left and (left_win or right_win) or status_win
vim.api.nvim_set_current_win(target)
return
end
@@ -569,22 +402,22 @@ local function view_entry(s, entry, focus_left)
left_win = nil
s.diff_left_win = nil
end
right_win = ensure_right_win(s, sidebar_win, right_win)
right_win = ensure_right_win(s, status_win, right_win)
s.diff_right_win = right_win
vim.w[right_win].git_diff_role = "right"
local side = left or right
---@cast side ow.Git.DiffSide
---@cast side ow.Git.Diff.Side
diff.set_diff(right_win, false)
vim.api.nvim_win_set_buf(right_win, side.buf)
if side.name then
util.set_buf_name(side.buf, side.name)
end
s.last_shown_key = key
vim.api.nvim_set_current_win(focus_left and right_win or sidebar_win)
vim.api.nvim_set_current_win(focus_left and right_win or status_win)
return
end
---@cast left ow.Git.DiffSide
---@cast right ow.Git.DiffSide
---@cast left ow.Git.Diff.Side
---@cast right ow.Git.Diff.Side
if left_win and not right_win then
right_win = vsplit_at(left_win, "right")
@@ -593,7 +426,7 @@ local function view_entry(s, entry, focus_left)
left_win = vsplit_at(right_win, "left")
reset_diff_win(left_win)
elseif not (left_win or right_win) then
right_win = ensure_right_win(s, sidebar_win, nil)
right_win = ensure_right_win(s, status_win, nil)
left_win = vsplit_at(right_win, "left")
reset_diff_win(left_win)
local combined = vim.api.nvim_win_get_width(left_win)
@@ -611,7 +444,7 @@ local function view_entry(s, entry, focus_left)
diff.update_pair(left_win, right_win, { left = left, right = right })
s.last_shown_key = key
vim.api.nvim_set_current_win(focus_left and left_win or sidebar_win)
vim.api.nvim_set_current_win(focus_left and left_win or status_win)
end
---@param focus_left boolean
@@ -625,11 +458,10 @@ end
local function action_stage()
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry or not entry.path then
if not s or not entry then
return
end
---@cast entry ow.Git.StatusEntry
if entry.section == "Staged" then
if entry.kind == "staged" then
return
end
vim.system(
@@ -645,11 +477,10 @@ end
local function action_unstage()
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry or not entry.path then
if not s or not entry then
return
end
---@cast entry ow.Git.StatusEntry
if entry.section ~= "Staged" then
if entry.kind ~= "staged" then
return
end
local cmd = { "git", "restore", "--staged", "--" }
@@ -673,17 +504,16 @@ end
local function action_discard()
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry or not entry.path then
if not s or not entry then
return
end
---@cast entry ow.Git.StatusEntry
if entry.section == "Staged" then
if entry.kind == "staged" then
util.warning("file has staged changes, unstage first with 'u'")
return
end
local prompt, action
if entry.section == "Untracked" then
if entry.kind == "untracked" then
local is_dir = entry.path:sub(-1) == "/"
prompt = string.format(
"Delete untracked %s %s?",
@@ -698,7 +528,7 @@ local function action_discard()
end
refresh(vim.api.nvim_get_current_buf())
end
elseif entry.section == "Unstaged" then
elseif entry.kind == "unstaged" then
prompt = string.format("Discard changes to %s?", entry.path)
action = function()
vim.system(
@@ -725,7 +555,7 @@ end
local function action_help()
print(table.concat({
"git status sidebar",
"git status window",
" <Tab> preview diff (keep focus)",
" <CR> open diff (focus left pane)",
" s stage file",
@@ -737,7 +567,7 @@ end
---@param r ow.Git.Repo
local function open(r)
local existing = find_sidebar()
local existing = find_view()
if existing then
vim.api.nvim_set_current_win(existing)
return
@@ -745,7 +575,7 @@ local function open(r)
local previous_win = vim.api.nvim_get_current_win()
local bufnr, win = util.new_scratch({ split = "left" })
vim.bo[bufnr].filetype = "gitsidebar"
vim.bo[bufnr].filetype = "gitstatus"
vim.wo[win].number = false
vim.wo[win].relativenumber = false
@@ -753,12 +583,12 @@ local function open(r)
vim.wo[win].signcolumn = "no"
vim.wo[win].cursorline = true
vim.wo[win].winfixwidth = true
vim.api.nvim_win_set_width(win, SIDEBAR_WIDTH)
vim.api.nvim_win_set_width(win, WINDOW_WIDTH)
state[bufnr] = {
repo = r,
lines = {},
sidebar_win = win,
win = win,
invocation_win = previous_win,
}
@@ -781,8 +611,8 @@ local function open(r)
k("X", action_discard, "Discard worktree changes")
k("g?", action_help, "Help")
state[bufnr].unsubscribe = r:on_refresh(function(_, porcelain_stdout)
refresh(bufnr, porcelain_stdout)
state[bufnr].unsubscribe = r:on("refresh", function()
refresh(bufnr)
end)
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
buffer = bufnr,
@@ -804,12 +634,12 @@ local function open(r)
end
function M.toggle()
local sidebar_win = find_sidebar()
if sidebar_win then
vim.api.nvim_win_close(sidebar_win, false)
local status_win = find_view()
if status_win then
vim.api.nvim_win_close(status_win, false)
return
end
local r = repo.find()
local r = repo.resolve()
if not r then
util.warning("not in a git repository")
return
+31
View File
@@ -0,0 +1,31 @@
local repo = require("git.repo")
local M = {}
---@return string
function M.render()
local r = repo.find()
if not r then
return ""
end
local name = vim.api.nvim_buf_get_name(0)
if name == "" then
return ""
end
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
local list = rel and r.status.entries[rel]
if not list then
return ""
end
local parts = {}
for _, e in ipairs(list) do
table.insert(parts, string.format("%%#%s#%s%%*", e.hl, e.char))
end
return table.concat(parts, " ")
end
repo.on("refresh", function()
vim.cmd.redrawstatus({ bang = true })
end)
return M
+42 -6
View File
@@ -1,11 +1,11 @@
local M = {}
---@class ow.Git.ScratchOpts
---@class ow.Git.Util.ScratchOpts
---@field name string?
---@field bufhidden ("hide"|"wipe")?
---@param buf integer
---@param opts ow.Git.ScratchOpts
---@param opts ow.Git.Util.ScratchOpts
local function setup_scratch(buf, opts)
vim.bo[buf].buftype = "nofile"
vim.bo[buf].bufhidden = opts.bufhidden or "wipe"
@@ -43,10 +43,10 @@ function M.place_buf(buf, split)
return win
end
---@class ow.Git.NewScratchOpts : ow.Git.ScratchOpts
---@class ow.Git.Util.NewScratchOpts : ow.Git.Util.ScratchOpts
---@field split (false|"above"|"below"|"left"|"right")?
---@param opts ow.Git.NewScratchOpts?
---@param opts ow.Git.Util.NewScratchOpts?
---@return integer buf
---@return integer win
function M.new_scratch(opts)
@@ -170,14 +170,14 @@ function M.debounce(fn, delay)
}
end
---@class ow.Git.ExecOpts
---@class ow.Git.Util.ExecOpts
---@field cwd string?
---@field stdin string?
---@field silent boolean?
---@field on_done fun(stdout: string?)?
---@param cmd string[]
---@param opts ow.Git.ExecOpts?
---@param opts ow.Git.Util.ExecOpts?
---@return string?
function M.exec(cmd, opts)
opts = opts or {}
@@ -209,4 +209,40 @@ function M.exec(cmd, opts)
return handle(vim.system(cmd, sys_opts):wait())
end
---@class ow.Git.Util.Emitter<T>
---@field private _listeners table<T, (fun(...))[]>
local Emitter = {}
Emitter.__index = Emitter
---@return ow.Git.Util.Emitter
function Emitter.new()
return setmetatable({ _listeners = {} }, Emitter)
end
---@param event T
---@param fn fun(...)
---@return fun() unsubscribe
function Emitter:on(event, fn)
local list = self._listeners[event] or {}
self._listeners[event] = list
table.insert(list, fn)
return function()
for i, f in ipairs(list) do
if f == fn then
table.remove(list, i)
return
end
end
end
end
---@param event T
function Emitter:emit(event, ...)
for _, fn in ipairs(self._listeners[event] or {}) do
fn(...)
end
end
M.Emitter = Emitter
return M
+75 -28
View File
@@ -41,29 +41,88 @@ end)
---@class nvim_tree.api.decorator.UserDecorator
local Decorator = require("nvim-tree.api").Decorator
---@class GitIgnoreDecorator: nvim_tree.api.decorator.UserDecorator
local GitIgnoreDecorator = Decorator:extend()
local repo = require("git.repo")
function GitIgnoreDecorator:new()
repo.on("refresh", function()
require("nvim-tree.api").tree.reload()
end)
do
local events = require("nvim-tree.api").events
local function on_change(data)
if not data then
return
end
local seen = {}
local function fire(path)
if not path then
return
end
local r = repo.find(path)
if r and not seen[r] then
seen[r] = true
r:refresh()
end
end
fire(data.fname)
fire(data.folder_name)
fire(data.old_name)
fire(data.new_name)
end
events.subscribe(events.Event.FileCreated, on_change)
events.subscribe(events.Event.FileRemoved, on_change)
events.subscribe(events.Event.FolderCreated, on_change)
events.subscribe(events.Event.FolderRemoved, on_change)
events.subscribe(events.Event.NodeRenamed, on_change)
end
---@class ow.GitDecorator: nvim_tree.api.Decorator
local GitDecorator = Decorator:extend()
function GitDecorator:new()
self.enabled = true
self.icon_placement = "after"
self.highlight_range = "name"
self.icon_placement = "none"
self.file_hl = "NvimTreeGitFileIgnoredHL"
self.folder_hl = "NvimTreeGitFolderIgnoredHL"
end
---@param node Node
---@return string? highlight_group
function GitIgnoreDecorator:highlight_group(node)
local status = node.git_status
if not status then
---@return ow.Git.Status.Entry[]?
local function entries_for(node)
local r = repo.find(node.absolute_path)
if not r then
return
end
local rel = vim.fs.relpath(r.worktree, node.absolute_path)
return rel and r.status.entries[rel]
end
if status.file == "!!" then
return self.file_hl
elseif status.dir and status.dir.direct == "!!" then
return self.folder_hl
---@param node Node
---@return { str: string, hl: string[] }[]?
function GitDecorator.icons(_, node)
local list = entries_for(node)
if not list then
return
end
local out = {}
for _, entry in ipairs(list) do
if entry.kind ~= "ignored" then
table.insert(out, { str = entry.char, hl = { entry.hl } })
end
end
return out
end
---@param node Node
---@return string?
function GitDecorator.highlight_group(_, node)
local list = entries_for(node)
if not list then
return
end
for _, entry in ipairs(list) do
if entry.kind == "ignored" then
return entry.hl
end
end
end
@@ -120,8 +179,7 @@ require("nvim-tree").setup({
end,
special_files = {},
decorators = {
"Git",
GitIgnoreDecorator,
GitDecorator,
"Open",
"Modified",
"Bookmark",
@@ -140,7 +198,6 @@ require("nvim-tree").setup({
},
},
icons = {
git_placement = "after",
diagnostics_placement = "signcolumn",
bookmarks_placement = "after",
symlink_arrow = " -> ",
@@ -157,15 +214,6 @@ require("nvim-tree").setup({
},
glyphs = {
modified = "*",
git = {
unstaged = "M",
staged = "M",
unmerged = "!",
renamed = "R",
untracked = "?",
deleted = "D",
ignored = " ",
},
},
},
},
@@ -186,7 +234,6 @@ require("nvim-tree").setup({
enable = true,
},
filters = {
git_ignored = false,
custom = { "^\\.git$" },
},
live_filter = {
@@ -219,7 +266,7 @@ require("nvim-tree").setup({
},
sync_root_with_cwd = true,
git = {
show_on_open_dirs = false,
enable = false,
},
})
+1 -3
View File
@@ -53,9 +53,7 @@ local highlights = {
DiffAdd = { bg = "#1a2f22" },
DiffChange = { bg = "#15304a" },
DiffDelete = { bg = "#311c1e" },
-- GitDeleted = { fg = c.red },
-- GitUnstaged = { fg = c.yellow },
-- GitUntracked = { fg = c.green },
Changed = { fg = c.yellow },
}
for kind, color in pairs(completion_kind_colors) do
highlights["LspKind" .. kind] = { fg = color }
-44
View File
@@ -1,44 +0,0 @@
if exists("b:current_syntax")
finish
endif
syntax match gitSidebarLabel /\v^(Head|Push)\ze:/
syntax match gitSidebarBranch /\v(^(Head|Push):\s+)@<=\S+/
syntax match gitSidebarAhead /\v\+\d+/
syntax match gitSidebarBehind /\v-\d+/
syntax region gitSidebarUntrackedHeader start=/\v^Untracked>/ end=/\v^$/
syntax region gitSidebarUnstagedHeader start=/\v^Unstaged>/ end=/\v^$/
syntax region gitSidebarStagedHeader start=/\v^Staged>/ end=/\v^$/
syntax region gitSidebarUnmergedHeader start=/\v^Unmerged>/ end=/\v^$/
syntax region gitSidebarUnpushedHeader start=/\v^Unpushed>/ end=/\v^$/
syntax region gitSidebarUnpulledHeader start=/\v^Unpulled>/ end=/\v^$/
syntax match gitSidebarUntrackedLabel /\v^Untracked/ contained containedin=gitSidebarUntrackedHeader
syntax match gitSidebarUnstagedLabel /\v^Unstaged/ contained containedin=gitSidebarUnstagedHeader
syntax match gitSidebarStagedLabel /\v^Staged/ contained containedin=gitSidebarStagedHeader
syntax match gitSidebarUnmergedLabel /\v^Unmerged/ contained containedin=gitSidebarUnmergedHeader
syntax match gitSidebarUnpushedLabel /\v^Unpushed/ contained containedin=gitSidebarUnpushedHeader
syntax match gitSidebarUnpulledLabel /\v^Unpulled/ contained containedin=gitSidebarUnpulledHeader
syntax match gitSidebarHeaderCount /\v\(\zs\d+\ze\)/ contained containedin=gitSidebarUntrackedHeader,
\ gitSidebarUnstagedHeader,
\ gitSidebarStagedHeader,
\ gitSidebarUnmergedHeader,
\ gitSidebarUnpushedHeader,
\ gitSidebarUnpulledHeader
highlight default link gitSidebarLabel Label
highlight default link gitSidebarBranch None
highlight default link gitSidebarAhead GitUnpushed
highlight default link gitSidebarBehind GitUnpulled
highlight default link gitSidebarHeaderCount Number
highlight default link gitSidebarUntrackedLabel gitSidebarLabel
highlight default link gitSidebarUnstagedLabel gitSidebarLabel
highlight default link gitSidebarStagedLabel gitSidebarLabel
highlight default link gitSidebarUnmergedLabel gitSidebarLabel
highlight default link gitSidebarUnpushedLabel gitSidebarLabel
highlight default link gitSidebarUnpulledLabel gitSidebarLabel
let b:current_syntax = "gitSidebar"
+44
View File
@@ -0,0 +1,44 @@
if exists("b:current_syntax")
finish
endif
syntax match gitStatusLabel /\v^(Head|Push)\ze:/
syntax match gitStatusBranch /\v(^(Head|Push):\s+)@<=\S+/
syntax match gitStatusAhead /\v\+\d+/
syntax match gitStatusBehind /\v-\d+/
syntax region gitStatusUntrackedHeader start=/\v^Untracked>/ end=/\v^$/
syntax region gitStatusUnstagedHeader start=/\v^Unstaged>/ end=/\v^$/
syntax region gitStatusStagedHeader start=/\v^Staged>/ end=/\v^$/
syntax region gitStatusUnmergedHeader start=/\v^Unmerged>/ end=/\v^$/
syntax region gitStatusUnpushedHeader start=/\v^Unpushed>/ end=/\v^$/
syntax region gitStatusUnpulledHeader start=/\v^Unpulled>/ end=/\v^$/
syntax match gitStatusUntrackedLabel /\v^Untracked/ contained containedin=gitStatusUntrackedHeader
syntax match gitStatusUnstagedLabel /\v^Unstaged/ contained containedin=gitStatusUnstagedHeader
syntax match gitStatusStagedLabel /\v^Staged/ contained containedin=gitStatusStagedHeader
syntax match gitStatusUnmergedLabel /\v^Unmerged/ contained containedin=gitStatusUnmergedHeader
syntax match gitStatusUnpushedLabel /\v^Unpushed/ contained containedin=gitStatusUnpushedHeader
syntax match gitStatusUnpulledLabel /\v^Unpulled/ contained containedin=gitStatusUnpulledHeader
syntax match gitStatusHeaderCount /\v\(\zs\d+\ze\)/ contained containedin=gitStatusUntrackedHeader,
\ gitStatusUnstagedHeader,
\ gitStatusStagedHeader,
\ gitStatusUnmergedHeader,
\ gitStatusUnpushedHeader,
\ gitStatusUnpulledHeader
highlight default link gitStatusLabel Label
highlight default link gitStatusBranch None
highlight default link gitStatusAhead GitUnpushed
highlight default link gitStatusBehind GitUnpulled
highlight default link gitStatusHeaderCount Number
highlight default link gitStatusUntrackedLabel gitStatusLabel
highlight default link gitStatusUnstagedLabel gitStatusLabel
highlight default link gitStatusStagedLabel gitStatusLabel
highlight default link gitStatusUnmergedLabel gitStatusLabel
highlight default link gitStatusUnpushedLabel gitStatusLabel
highlight default link gitStatusUnpulledLabel gitStatusLabel
let b:current_syntax = "gitStatus"