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)
+35 -12
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" },
{
group = group,
callback = function(args)
require("git.repo").refresh(args.buf)
end,
}
)
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
+239 -296
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
local STATUS_CMD = {
"git",
"--no-optional-locks",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
"--ignored",
}
---@param r ow.Git.Repo
local function do_refresh(r)
---@private
function Repo:_fetch_status()
vim.system(
{
"git",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
},
{ cwd = r.worktree, text = true },
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
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
r:refresh()
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
+157 -31
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
end
end
if code == "??" then
return "?", "GitUntracked"
end
if code == "!!" then
return "!", "GitIgnored"
end
if M.UNMERGED[code] then
return "U", "GitUnmerged"
end
local x, y = code:sub(1, 1), code:sub(2, 2)
if x == "R" or y == "R" then
return out
end
---@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"
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
+152 -322
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,24 +86,23 @@ 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,
end_col = 2 + hl_len,
hl = hl,
})
end
end
table.insert(lines, line)
meta[#lines] = entry
table.insert(marks, {
row = #lines - 1,
col = 2,
end_col = 2 + hl_len,
hl = hl,
})
end
table.insert(lines, "")
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
table.insert(
parts,
e.section
.. ":"
.. (e.path or e.sha or "")
.. ":"
.. (e.orig or "")
.. ":"
.. (e.x or "")
.. ":"
.. (e.y or "")
)
end
for _, kind in ipairs(KINDS) do
for _, e in ipairs(status:by_kind(kind)) do
table.insert(
parts,
e.kind
.. ":"
.. e.path
.. ":"
.. (e.orig or "")
.. ":"
.. e.char
)
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
s.last_shown_key = nil
local fp = fingerprint(branch, groups)
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
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 win then
pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 })
end
break
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(status)
if fp == s.last_render_key then
return
end
s.last_render_key = fp
render(bufnr, status)
if not saved_path then
return
end
for lnum, entry in pairs(s.lines) do
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