chore: initial commit
This commit is contained in:
@@ -0,0 +1,925 @@
|
||||
local status = require("git.core.status")
|
||||
local util = require("git.core.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
---@param buf? integer
|
||||
---@return integer
|
||||
local function expand_buf(buf)
|
||||
if not buf or buf == 0 then
|
||||
return vim.api.nvim_get_current_buf()
|
||||
end
|
||||
return buf
|
||||
end
|
||||
|
||||
---@class ow.Git.Repo.BufState
|
||||
---@field repo ow.Git.Repo
|
||||
---@field sha string?
|
||||
---@field initialized boolean?
|
||||
---@field immutable boolean?
|
||||
---@field index_writer boolean?
|
||||
---@field index_mode string?
|
||||
|
||||
---@alias ow.Git.Repo.Event
|
||||
---| "change"
|
||||
|
||||
local global = util.Emitter.new()
|
||||
|
||||
---@type table<string, ow.Git.Repo> keyed by worktree
|
||||
local repos = {}
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
local function release_if_unused(r)
|
||||
if repos[r.worktree] ~= r then
|
||||
return
|
||||
end
|
||||
if next(r.buffers) ~= nil or next(r.tabs) ~= nil then
|
||||
return
|
||||
end
|
||||
r:close()
|
||||
repos[r.worktree] = nil
|
||||
end
|
||||
|
||||
---@class ow.Git.Repo.Change
|
||||
---@field paths table<string, true>
|
||||
---@field branch_changed boolean
|
||||
|
||||
---@class ow.Git.Repo.RefreshOpts
|
||||
---@field invalidate boolean?
|
||||
|
||||
---@class ow.Git.Repo.SubmoduleEntry
|
||||
---@field worktree string
|
||||
---@field unsub fun()?
|
||||
|
||||
---@class ow.Git.Repo
|
||||
---@field gitdir string
|
||||
---@field worktree string
|
||||
---@field buffers table<integer, ow.Git.Repo.BufState>
|
||||
---@field tabs table<integer, true>
|
||||
---@field status ow.Git.Status
|
||||
---@field private _events ow.Git.Util.Emitter<ow.Git.Repo.Event>
|
||||
---@field private _watchers table<string, uv.uv_fs_event_t>
|
||||
---@field private _schedule_refresh fun(self: ow.Git.Repo)
|
||||
---@field private _refresh_handle ow.Git.Util.DebounceHandle
|
||||
---@field private _cache table<string, any>
|
||||
---@field private _fetch_epoch integer
|
||||
---@field private _pending_invalidate boolean
|
||||
---@field package _submodules table<string, ow.Git.Repo.SubmoduleEntry>
|
||||
local Repo = {}
|
||||
Repo.__index = Repo
|
||||
|
||||
local STATUS_ARGS = {
|
||||
"--no-optional-locks",
|
||||
"-c",
|
||||
"core.quotePath=false",
|
||||
"status",
|
||||
"--porcelain=v2",
|
||||
"--branch",
|
||||
"--ignored",
|
||||
"--untracked-files=all",
|
||||
"-z",
|
||||
}
|
||||
|
||||
local PSEUDO_REFS = {
|
||||
"HEAD",
|
||||
"FETCH_HEAD",
|
||||
"ORIG_HEAD",
|
||||
"MERGE_HEAD",
|
||||
"REBASE_HEAD",
|
||||
"CHERRY_PICK_HEAD",
|
||||
"REVERT_HEAD",
|
||||
}
|
||||
|
||||
---@type table<string, fun(relpath: string): boolean>
|
||||
local INVALIDATION_RULES = {
|
||||
head = function(relpath)
|
||||
return relpath == "HEAD"
|
||||
or vim.startswith(relpath, "refs/heads/")
|
||||
or relpath == "packed-refs"
|
||||
end,
|
||||
refs = function(relpath)
|
||||
return vim.startswith(relpath, "refs/heads/")
|
||||
or vim.startswith(relpath, "refs/tags/")
|
||||
or vim.startswith(relpath, "refs/remotes/")
|
||||
or relpath == "packed-refs"
|
||||
end,
|
||||
pseudo_refs = function(relpath)
|
||||
return vim.tbl_contains(PSEUDO_REFS, relpath)
|
||||
end,
|
||||
stash_refs = function(relpath)
|
||||
return relpath == "refs/stash" or relpath == "logs/refs/stash"
|
||||
end,
|
||||
config = function(relpath)
|
||||
return relpath == "config"
|
||||
end,
|
||||
}
|
||||
|
||||
---@param relpath string
|
||||
---@return boolean
|
||||
local function affects_resolve(relpath)
|
||||
return vim.startswith(relpath, "refs/")
|
||||
or relpath == "packed-refs"
|
||||
or relpath == "HEAD"
|
||||
or relpath == "FETCH_HEAD"
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param prefix string
|
||||
function Repo:_clear_cache_prefix(prefix)
|
||||
for key in pairs(self._cache) do
|
||||
if vim.startswith(key, prefix) then
|
||||
self._cache[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param relpath string
|
||||
function Repo:_invalidate(relpath)
|
||||
for key, affects in pairs(INVALIDATION_RULES) do
|
||||
if self._cache[key] ~= nil and affects(relpath) then
|
||||
self._cache[key] = nil
|
||||
end
|
||||
end
|
||||
if affects_resolve(relpath) then
|
||||
self:_clear_cache_prefix("resolve:")
|
||||
self:_clear_cache_prefix("head_blob:")
|
||||
end
|
||||
if relpath == "index" then
|
||||
self:_clear_cache_prefix("index:")
|
||||
end
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return table<string, table<string, string>>?
|
||||
local function read_git_config(path)
|
||||
local f = io.open(path, "r")
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local content = f:read("*a")
|
||||
f:close()
|
||||
local out = {}
|
||||
local section
|
||||
for line in content:gmatch("[^\n]+") do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
if trimmed ~= "" and not trimmed:match("^[#;]") then
|
||||
local s = trimmed:match("^%[(.-)%]$")
|
||||
if s then
|
||||
section = s
|
||||
out[section] = out[section] or {}
|
||||
elseif section then
|
||||
local key, value =
|
||||
trimmed:match("^(%S+)%s*=%s*(.-)$")
|
||||
if key then
|
||||
out[section][key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
---@param gitdir string
|
||||
---@return string[]
|
||||
local function find_submodules(gitdir)
|
||||
local handle = vim.uv.fs_scandir(vim.fs.joinpath(gitdir, "modules"))
|
||||
if not handle then
|
||||
return {}
|
||||
end
|
||||
local out = {}
|
||||
while true do
|
||||
local name, typ = vim.uv.fs_scandir_next(handle)
|
||||
if not name then
|
||||
break
|
||||
end
|
||||
if typ == "directory" then
|
||||
table.insert(out, name)
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
---@private
|
||||
function Repo:_fetch_status()
|
||||
if self._pending_invalidate then
|
||||
self._cache = {}
|
||||
self._pending_invalidate = false
|
||||
end
|
||||
local prior_entries = self.status.entries
|
||||
local prior_branch = self.status.branch
|
||||
self._fetch_epoch = self._fetch_epoch + 1
|
||||
local epoch = self._fetch_epoch
|
||||
util.git(STATUS_ARGS, {
|
||||
cwd = self.worktree,
|
||||
on_exit = function(result)
|
||||
if epoch ~= self._fetch_epoch then
|
||||
return
|
||||
end
|
||||
if result.code ~= 0 then
|
||||
util.error(
|
||||
"git status failed: %s",
|
||||
vim.trim(result.stderr or "")
|
||||
)
|
||||
return
|
||||
end
|
||||
self.status = status.parse(result.stdout or "")
|
||||
local change = {
|
||||
paths = status.diff_entries(
|
||||
prior_entries,
|
||||
self.status.entries
|
||||
),
|
||||
branch_changed = not vim.deep_equal(
|
||||
prior_branch,
|
||||
self.status.branch
|
||||
),
|
||||
}
|
||||
if next(change.paths) == nil and not change.branch_changed then
|
||||
return
|
||||
end
|
||||
self._events:emit("change", change, self.status)
|
||||
global:emit("change", self, change, self.status)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
---@param opts ow.Git.Repo.RefreshOpts?
|
||||
function Repo:refresh(opts)
|
||||
if opts and opts.invalidate then
|
||||
self._pending_invalidate = true
|
||||
end
|
||||
self:_schedule_refresh()
|
||||
end
|
||||
|
||||
---@param gitdir string
|
||||
---@param worktree string
|
||||
---@return ow.Git.Repo
|
||||
function Repo.new(gitdir, worktree)
|
||||
local self = setmetatable({
|
||||
gitdir = gitdir,
|
||||
worktree = worktree,
|
||||
buffers = {},
|
||||
tabs = {},
|
||||
status = status.parse(""),
|
||||
_events = util.Emitter.new(),
|
||||
_cache = {},
|
||||
_fetch_epoch = 0,
|
||||
_pending_invalidate = false,
|
||||
_submodules = {},
|
||||
}, Repo)
|
||||
self._schedule_refresh, self._refresh_handle =
|
||||
util.debounce(Repo._fetch_status, 50)
|
||||
self:start_watcher()
|
||||
self:refresh()
|
||||
if vim.g.git_submodule_recursion ~= false then
|
||||
self:_start_modules_watcher()
|
||||
for _, name in ipairs(find_submodules(gitdir)) do
|
||||
self:_register_submodule(name)
|
||||
end
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
---@generic T
|
||||
---@param key string
|
||||
---@param compute fun(self: ow.Git.Repo): T
|
||||
---@return T
|
||||
function Repo:get_cached(key, compute)
|
||||
local hit = self._cache[key]
|
||||
if hit ~= nil then
|
||||
return hit
|
||||
end
|
||||
local value = compute(self)
|
||||
self._cache[key] = value
|
||||
return value
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param on_event fun(filename: string?)
|
||||
---@return uv.uv_fs_event_t?
|
||||
local function start_fs_event(path, on_event)
|
||||
local watcher = vim.uv.new_fs_event()
|
||||
if not watcher then
|
||||
return nil
|
||||
end
|
||||
local ok = watcher:start(path, {}, function(err, filename)
|
||||
if err then
|
||||
return
|
||||
end
|
||||
on_event(filename)
|
||||
end)
|
||||
if not ok then
|
||||
watcher:close()
|
||||
return nil
|
||||
end
|
||||
return watcher
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param name string
|
||||
function Repo:_unregister_submodule(name)
|
||||
local entry = self._submodules[name]
|
||||
if not entry then
|
||||
return
|
||||
end
|
||||
self._submodules[name] = nil
|
||||
if entry.unsub then
|
||||
entry.unsub()
|
||||
end
|
||||
local child = repos[entry.worktree]
|
||||
if child then
|
||||
release_if_unused(child)
|
||||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param name string
|
||||
function Repo:_register_submodule(name)
|
||||
local sub_gitdir = vim.fs.joinpath(self.gitdir, "modules", name)
|
||||
local cfg = read_git_config(vim.fs.joinpath(sub_gitdir, "config"))
|
||||
local raw = cfg and cfg.core and cfg.core.worktree
|
||||
if not raw then
|
||||
return
|
||||
end
|
||||
local wt = raw:match("^/") and raw or vim.fs.joinpath(sub_gitdir, raw)
|
||||
wt = vim.fs.normalize(wt)
|
||||
local existing = self._submodules[name]
|
||||
if existing and existing.worktree == wt then
|
||||
return
|
||||
end
|
||||
if existing then
|
||||
self:_unregister_submodule(name)
|
||||
end
|
||||
local child = repos[wt] or M.resolve(wt)
|
||||
if not child then
|
||||
return
|
||||
end
|
||||
self._submodules[name] = {
|
||||
worktree = wt,
|
||||
unsub = child:on("change", function()
|
||||
self:refresh()
|
||||
end),
|
||||
}
|
||||
end
|
||||
|
||||
---@private
|
||||
function Repo:_start_modules_watcher()
|
||||
local dir = vim.fs.joinpath(self.gitdir, "modules")
|
||||
if self._watchers[dir] then
|
||||
return
|
||||
end
|
||||
if not vim.uv.fs_stat(dir) then
|
||||
return
|
||||
end
|
||||
self._watchers[dir] = start_fs_event(dir, function(filename)
|
||||
if not filename then
|
||||
return
|
||||
end
|
||||
if vim.uv.fs_stat(vim.fs.joinpath(dir, filename)) then
|
||||
self:_register_submodule(filename)
|
||||
else
|
||||
self:_unregister_submodule(filename)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@private
|
||||
function Repo:_stop_modules_watcher()
|
||||
local dir = vim.fs.joinpath(self.gitdir, "modules")
|
||||
local w = self._watchers[dir]
|
||||
if w then
|
||||
w:stop()
|
||||
w:close()
|
||||
self._watchers[dir] = nil
|
||||
end
|
||||
for _, name in ipairs(vim.tbl_keys(self._submodules)) do
|
||||
self:_unregister_submodule(name)
|
||||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param relpath string
|
||||
function Repo:_handle_fs_event(relpath)
|
||||
if vim.startswith(relpath, "objects") then
|
||||
return
|
||||
end
|
||||
self:_invalidate(relpath)
|
||||
if relpath == "modules" and vim.g.git_submodule_recursion ~= false then
|
||||
if vim.uv.fs_stat(vim.fs.joinpath(self.gitdir, "modules")) then
|
||||
self:_start_modules_watcher()
|
||||
for _, name in ipairs(find_submodules(self.gitdir)) do
|
||||
self:_register_submodule(name)
|
||||
end
|
||||
else
|
||||
self:_stop_modules_watcher()
|
||||
end
|
||||
end
|
||||
if vim.startswith(relpath, "logs") then
|
||||
return
|
||||
end
|
||||
self:refresh()
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param relpath string gitdir-relative path of the directory to watch
|
||||
function Repo:_watch_tree(relpath)
|
||||
local path = vim.fs.joinpath(self.gitdir, relpath)
|
||||
if self._watchers[path] then
|
||||
return
|
||||
end
|
||||
local stat = vim.uv.fs_stat(path)
|
||||
if not stat or stat.type ~= "directory" then
|
||||
return
|
||||
end
|
||||
local watcher = start_fs_event(path, function(filename)
|
||||
if not vim.uv.fs_stat(path) then
|
||||
local w = self._watchers[path] --[[@as uv.uv_fs_event_t?]]
|
||||
if w then
|
||||
w:stop()
|
||||
w:close()
|
||||
self._watchers[path] = nil
|
||||
end
|
||||
return
|
||||
end
|
||||
if filename then
|
||||
local child = vim.fs.joinpath(relpath, filename)
|
||||
self:_handle_fs_event(child)
|
||||
vim.schedule(function()
|
||||
self:_watch_tree(child)
|
||||
end)
|
||||
else
|
||||
self:refresh({ invalidate = true })
|
||||
end
|
||||
end)
|
||||
if not watcher then
|
||||
return
|
||||
end
|
||||
self._watchers[path] = watcher
|
||||
local handle = vim.uv.fs_scandir(path)
|
||||
if not handle then
|
||||
return
|
||||
end
|
||||
while true do
|
||||
local name, typ = vim.uv.fs_scandir_next(handle)
|
||||
if not name then
|
||||
break
|
||||
end
|
||||
if typ == "directory" then
|
||||
self:_watch_tree(vim.fs.joinpath(relpath, name))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Repo:start_watcher()
|
||||
self._watchers = {}
|
||||
local top = start_fs_event(self.gitdir, function(filename)
|
||||
if not filename then
|
||||
self:refresh({ invalidate = true })
|
||||
return
|
||||
end
|
||||
self:_handle_fs_event(filename)
|
||||
end)
|
||||
if not top then
|
||||
util.error("git: failed to watch %s", self.gitdir)
|
||||
return
|
||||
end
|
||||
self._watchers[self.gitdir] = top
|
||||
self:_watch_tree("refs")
|
||||
end
|
||||
|
||||
function Repo:close()
|
||||
for _, watcher in pairs(self._watchers) do
|
||||
watcher:stop()
|
||||
watcher:close()
|
||||
end
|
||||
self._watchers = {}
|
||||
self:_stop_modules_watcher()
|
||||
self._refresh_handle.close()
|
||||
self._events:clear()
|
||||
end
|
||||
|
||||
---@overload fun(event: "change", fn: fun(change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
|
||||
function Repo:on(event, fn)
|
||||
return self._events:on(event, fn)
|
||||
end
|
||||
|
||||
---@param buf? integer
|
||||
---@return ow.Git.Repo.BufState?
|
||||
function Repo:state(buf)
|
||||
return self.buffers[expand_buf(buf)]
|
||||
end
|
||||
|
||||
---@return string?
|
||||
function Repo:head()
|
||||
return self:get_cached("head", function(self)
|
||||
local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r")
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local first = f:read("*l")
|
||||
f:close()
|
||||
if not first then
|
||||
return nil
|
||||
end
|
||||
local branch = first:match("^ref:%s*refs/heads/(%S+)")
|
||||
if branch then
|
||||
return branch
|
||||
end
|
||||
local sha = first:match("^(%x+)")
|
||||
if sha then
|
||||
return sha:sub(1, 7)
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
end
|
||||
|
||||
---@return string[]
|
||||
function Repo:list_refs()
|
||||
return self:get_cached("refs", function(self)
|
||||
local out = util.git({
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"refs/heads",
|
||||
"refs/tags",
|
||||
"refs/remotes",
|
||||
}, { cwd = self.worktree, silent = true })
|
||||
if not out then
|
||||
return {}
|
||||
end
|
||||
return util.split_lines(out)
|
||||
end)
|
||||
end
|
||||
|
||||
---@return string[]
|
||||
function Repo:list_pseudo_refs()
|
||||
return self:get_cached("pseudo_refs", function(self)
|
||||
local refs = {}
|
||||
for _, name in ipairs(PSEUDO_REFS) do
|
||||
if name == "HEAD" or vim.uv.fs_stat(self.gitdir .. "/" .. name) then
|
||||
table.insert(refs, name)
|
||||
end
|
||||
end
|
||||
return refs
|
||||
end)
|
||||
end
|
||||
|
||||
---@return string[]
|
||||
function Repo:list_stash_refs()
|
||||
return self:get_cached("stash_refs", function(self)
|
||||
if not vim.uv.fs_stat(self.gitdir .. "/refs/stash") then
|
||||
return {}
|
||||
end
|
||||
local refs = { "stash" }
|
||||
local out = util.git(
|
||||
{ "stash", "list", "--pretty=format:%gd" },
|
||||
{ cwd = self.worktree, silent = true }
|
||||
)
|
||||
if out then
|
||||
for _, entry in ipairs(util.split_lines(out)) do
|
||||
table.insert(refs, entry)
|
||||
end
|
||||
end
|
||||
return refs
|
||||
end)
|
||||
end
|
||||
|
||||
---@param rev string
|
||||
---@param short boolean
|
||||
---@return string?
|
||||
function Repo:rev_parse(rev, short)
|
||||
local args = { "rev-parse", "--verify", "--quiet" }
|
||||
if short then
|
||||
table.insert(args, "--short")
|
||||
end
|
||||
table.insert(args, rev)
|
||||
local stdout = util.git(args, { cwd = self.worktree, silent = true })
|
||||
local trimmed = stdout and vim.trim(stdout) or ""
|
||||
return trimmed ~= "" and trimmed or nil
|
||||
end
|
||||
|
||||
---@param rel string worktree-relative path
|
||||
---@return string?
|
||||
function Repo:index_sha(rel)
|
||||
local sha = self:get_cached("index:" .. rel, function(self)
|
||||
return self:rev_parse(":" .. rel, false) or false
|
||||
end)
|
||||
return sha or nil
|
||||
end
|
||||
|
||||
---@param rel string worktree-relative path
|
||||
---@return string?
|
||||
function Repo:head_sha(rel)
|
||||
local sha = self:get_cached("head_blob:" .. rel, function(self)
|
||||
return self:rev_parse("HEAD:" .. rel, false) or false
|
||||
end)
|
||||
return sha or nil
|
||||
end
|
||||
|
||||
---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing"
|
||||
|
||||
---@param abbrev string
|
||||
---@return string? full_sha
|
||||
---@return ow.Git.Repo.ResolveStatus
|
||||
function Repo:resolve_sha(abbrev)
|
||||
local result = self:get_cached("resolve:" .. abbrev, function(self)
|
||||
local out = util.git(
|
||||
{ "rev-parse", "--disambiguate=" .. abbrev },
|
||||
{ cwd = self.worktree, silent = true }
|
||||
)
|
||||
local trimmed = out and vim.trim(out) or ""
|
||||
if trimmed == "" then
|
||||
return { nil, "missing" }
|
||||
end
|
||||
local lines = util.split_lines(trimmed)
|
||||
if #lines == 1 then
|
||||
return { lines[1], "ok" }
|
||||
end
|
||||
return { nil, "ambiguous" }
|
||||
end)
|
||||
return result[1], result[2]
|
||||
end
|
||||
|
||||
---@private
|
||||
---@return table<string, table<string, string>>
|
||||
function Repo:_config()
|
||||
return self:get_cached("config", function(self)
|
||||
return read_git_config(vim.fs.joinpath(self.gitdir, "config")) or {}
|
||||
end)
|
||||
end
|
||||
|
||||
---@private
|
||||
---@return boolean
|
||||
function Repo:_ignorecase()
|
||||
local cfg = self:_config()
|
||||
return cfg.core and cfg.core.ignorecase == "true" or false
|
||||
end
|
||||
|
||||
---@param rel string
|
||||
---@return ow.Git.Status.Entry?
|
||||
function Repo:status_entry_for(rel)
|
||||
local direct = self.status.entries[rel]
|
||||
if direct or not self:_ignorecase() then
|
||||
return direct
|
||||
end
|
||||
local lower = rel:lower()
|
||||
for path, entry in pairs(self.status.entries) do
|
||||
if path:lower() == lower then
|
||||
return entry
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
---@type table<string, true>
|
||||
local no_repo_dirs = {}
|
||||
|
||||
---@overload fun(event: "change", fn: fun(r: ow.Git.Repo, change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
|
||||
function M.on(event, fn)
|
||||
return global:on(event, fn)
|
||||
end
|
||||
|
||||
---@param prefix string
|
||||
---@param fn fun(buf: integer, r: ow.Git.Repo)
|
||||
---@return fun() unsubscribe
|
||||
function M.on_uri_change(prefix, fn)
|
||||
return M.on("change", function(r)
|
||||
for buf in pairs(r.buffers) do
|
||||
if vim.api.nvim_buf_is_loaded(buf) then
|
||||
local name = vim.api.nvim_buf_get_name(buf)
|
||||
if name:sub(1, #prefix) == prefix then
|
||||
fn(buf, r)
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@return table<string, ow.Git.Repo>
|
||||
function M.all()
|
||||
return repos
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@return ow.Git.Repo?
|
||||
local function find_by_buf(buf)
|
||||
for _, r in pairs(repos) do
|
||||
if r.buffers[buf] then
|
||||
return r
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return ow.Git.Repo?
|
||||
local function find_by_path(path)
|
||||
if path == "" then
|
||||
return nil
|
||||
end
|
||||
if repos[path] then
|
||||
return repos[path]
|
||||
end
|
||||
local best
|
||||
for wt in pairs(repos) do
|
||||
if path:sub(1, #wt + 1) == wt .. "/" then
|
||||
if not best or #wt > #best then
|
||||
best = wt
|
||||
end
|
||||
end
|
||||
end
|
||||
return best and repos[best] or nil
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@return string
|
||||
local function path_for_buf(buf)
|
||||
local path = vim.api.nvim_buf_get_name(buf)
|
||||
if path == "" or util.is_uri(path) then
|
||||
return vim.fn.getcwd()
|
||||
end
|
||||
return vim.fn.resolve(path)
|
||||
end
|
||||
|
||||
---@param arg? integer | string bufnr (default current) or worktree path
|
||||
---@return ow.Git.Repo?
|
||||
function M.find(arg)
|
||||
if type(arg) == "string" then
|
||||
return find_by_path(arg)
|
||||
end
|
||||
local buf = expand_buf(arg)
|
||||
return find_by_buf(buf) or find_by_path(path_for_buf(buf))
|
||||
end
|
||||
|
||||
---@param arg? integer | string bufnr (default current) or worktree path
|
||||
---@return ow.Git.Repo?
|
||||
function M.resolve(arg)
|
||||
if type(arg) ~= "string" then
|
||||
local existing = find_by_buf(expand_buf(arg))
|
||||
if existing then
|
||||
return existing
|
||||
end
|
||||
end
|
||||
local path
|
||||
if type(arg) == "string" then
|
||||
path = vim.fn.resolve(arg)
|
||||
else
|
||||
path = path_for_buf(expand_buf(arg))
|
||||
end
|
||||
local dir = vim.fs.dirname(path)
|
||||
if no_repo_dirs[dir] then
|
||||
return nil
|
||||
end
|
||||
local found = vim.fs.find(".git", { upward = true, path = path })[1]
|
||||
if not found then
|
||||
no_repo_dirs[dir] = true
|
||||
return nil
|
||||
end
|
||||
local worktree = vim.fs.dirname(found)
|
||||
if repos[worktree] then
|
||||
return repos[worktree]
|
||||
end
|
||||
local stat = vim.uv.fs_stat(found)
|
||||
if not stat then
|
||||
return nil
|
||||
end
|
||||
local gitdir
|
||||
if stat.type == "directory" then
|
||||
gitdir = found
|
||||
else
|
||||
local f = io.open(found, "r")
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local content = f:read("*a")
|
||||
f:close()
|
||||
local rel = content:match("gitdir:%s*(%S+)")
|
||||
if not rel then
|
||||
util.error(".git file at %s has no `gitdir:` line", found)
|
||||
return nil
|
||||
end
|
||||
gitdir = vim.fs.normalize(
|
||||
rel:match("^/") and rel or vim.fs.joinpath(worktree, rel)
|
||||
)
|
||||
end
|
||||
local r = Repo.new(gitdir, worktree)
|
||||
repos[worktree] = r
|
||||
for d in pairs(no_repo_dirs) do
|
||||
if d == worktree or vim.startswith(d, worktree .. "/") then
|
||||
no_repo_dirs[d] = nil
|
||||
end
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
---@param buf? integer
|
||||
---@return ow.Git.Repo.BufState?
|
||||
function M.state(buf)
|
||||
buf = expand_buf(buf)
|
||||
local r = find_by_buf(buf)
|
||||
return r and r.buffers[buf]
|
||||
end
|
||||
|
||||
---@param buf? integer
|
||||
---@param r ow.Git.Repo
|
||||
function M.bind(buf, r)
|
||||
buf = expand_buf(buf)
|
||||
local prev = find_by_buf(buf)
|
||||
if prev == r then
|
||||
return
|
||||
end
|
||||
if prev then
|
||||
prev.buffers[buf] = nil
|
||||
release_if_unused(prev)
|
||||
end
|
||||
r.buffers[buf] = { repo = r }
|
||||
end
|
||||
|
||||
---@param buf? integer
|
||||
function M.unbind(buf)
|
||||
buf = expand_buf(buf)
|
||||
local r = find_by_buf(buf)
|
||||
if not r then
|
||||
return
|
||||
end
|
||||
r.buffers[buf] = nil
|
||||
release_if_unused(r)
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@return boolean
|
||||
function M.is_worktree_buf(buf)
|
||||
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
|
||||
return false
|
||||
end
|
||||
local path = vim.api.nvim_buf_get_name(buf)
|
||||
return path ~= "" and not util.is_uri(path)
|
||||
end
|
||||
|
||||
---@param buf? integer
|
||||
function M.track(buf)
|
||||
buf = expand_buf(buf)
|
||||
if not M.is_worktree_buf(buf) then
|
||||
return
|
||||
end
|
||||
local r = M.resolve(buf)
|
||||
if r and not r.buffers[buf] then
|
||||
M.bind(buf, r)
|
||||
end
|
||||
end
|
||||
|
||||
---@param buf? integer
|
||||
function M.refresh(buf)
|
||||
local r = find_by_buf(expand_buf(buf))
|
||||
if r then
|
||||
r:refresh()
|
||||
end
|
||||
end
|
||||
|
||||
function M.refresh_all()
|
||||
for _, r in pairs(repos) do
|
||||
r:refresh()
|
||||
end
|
||||
end
|
||||
|
||||
function M.update_cwd_repo()
|
||||
no_repo_dirs = {}
|
||||
local tab = vim.api.nvim_get_current_tabpage()
|
||||
local new = M.resolve(vim.fn.getcwd())
|
||||
local old
|
||||
for _, r in pairs(repos) do
|
||||
if r.tabs[tab] then
|
||||
old = r
|
||||
break
|
||||
end
|
||||
end
|
||||
if new == old then
|
||||
return
|
||||
end
|
||||
if old then
|
||||
old.tabs[tab] = nil
|
||||
release_if_unused(old)
|
||||
end
|
||||
if new then
|
||||
new.tabs[tab] = true
|
||||
new:refresh()
|
||||
end
|
||||
end
|
||||
|
||||
---@param tab integer
|
||||
function M.release_tab(tab)
|
||||
for _, r in pairs(repos) do
|
||||
if r.tabs[tab] then
|
||||
r.tabs[tab] = nil
|
||||
release_if_unused(r)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M.stop_all()
|
||||
for _, r in pairs(repos) do
|
||||
r:close()
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,45 @@
|
||||
---@class ow.Git.Revision
|
||||
---@field stage 0|1|2|3?
|
||||
---@field path string?
|
||||
---@field base string?
|
||||
local Revision = {}
|
||||
Revision.__index = Revision
|
||||
|
||||
---@return string
|
||||
function Revision:format()
|
||||
if self.stage then
|
||||
return ":" .. self.stage .. ":" .. self.path
|
||||
elseif self.path then
|
||||
return self.base .. ":" .. self.path
|
||||
end
|
||||
return self.base or error("Revision:format: empty Revision")
|
||||
end
|
||||
|
||||
---@param parts { stage?: integer, base?: string, path?: string }
|
||||
---@return ow.Git.Revision
|
||||
function Revision.new(parts)
|
||||
return setmetatable(parts, Revision)
|
||||
end
|
||||
|
||||
---@param str string
|
||||
---@return ow.Git.Revision
|
||||
function Revision.parse(str)
|
||||
local stage, path = str:match("^:([0123]):(.+)$")
|
||||
if stage then
|
||||
return Revision.new({
|
||||
stage = tonumber(stage) --[[@as (0|1|2|3)?]],
|
||||
path = path,
|
||||
})
|
||||
end
|
||||
path = str:match("^:([^:]+)$")
|
||||
if path then
|
||||
return Revision.new({ stage = 0, path = path })
|
||||
end
|
||||
local base, p = str:match("^([^:]+):(.+)$")
|
||||
if base then
|
||||
return Revision.new({ base = base, path = p })
|
||||
end
|
||||
return Revision.new({ base = str })
|
||||
end
|
||||
|
||||
return Revision
|
||||
@@ -0,0 +1,383 @@
|
||||
local M = {}
|
||||
|
||||
---@alias ow.Git.Status.Kind
|
||||
---| "changed"
|
||||
---| "unmerged"
|
||||
---| "untracked"
|
||||
---| "ignored"
|
||||
|
||||
---@class ow.Git.Status.Entry
|
||||
---@field kind ow.Git.Status.Kind
|
||||
---@field path string
|
||||
|
||||
---@alias ow.Git.Status.Change
|
||||
---| "modified"
|
||||
---| "added"
|
||||
---| "deleted"
|
||||
---| "renamed"
|
||||
---| "copied"
|
||||
---| "type_changed"
|
||||
|
||||
---@class ow.Git.Status.ChangedEntry: ow.Git.Status.Entry
|
||||
---@field kind "changed"
|
||||
---@field staged ow.Git.Status.Change?
|
||||
---@field unstaged ow.Git.Status.Change?
|
||||
---@field orig string?
|
||||
|
||||
---@alias ow.Git.Status.Conflict
|
||||
---| "both_deleted"
|
||||
---| "added_by_us"
|
||||
---| "deleted_by_them"
|
||||
---| "added_by_them"
|
||||
---| "deleted_by_us"
|
||||
---| "both_added"
|
||||
---| "both_modified"
|
||||
|
||||
---@class ow.Git.Status.UnmergedEntry: ow.Git.Status.Entry
|
||||
---@field kind "unmerged"
|
||||
---@field conflict ow.Git.Status.Conflict
|
||||
|
||||
---@class ow.Git.Status.UntrackedEntry: ow.Git.Status.Entry
|
||||
---@field kind "untracked"
|
||||
|
||||
---@class ow.Git.Status.IgnoredEntry: ow.Git.Status.Entry
|
||||
---@field kind "ignored"
|
||||
|
||||
---@class ow.Git.Status.Mark
|
||||
---@field char string
|
||||
---@field hl string
|
||||
|
||||
---@alias ow.Git.Status.Section
|
||||
--- "staged"|"unstaged"|"unmerged"|"untracked"|"ignored"
|
||||
|
||||
---@class ow.Git.Status.Row
|
||||
---@field entry ow.Git.Status.Entry
|
||||
---@field section ow.Git.Status.Section
|
||||
---@field side ("staged"|"unstaged")?
|
||||
|
||||
---@class ow.Git.Status.Branch
|
||||
---@field oid string?
|
||||
---@field head string?
|
||||
---@field upstream string?
|
||||
---@field ahead integer
|
||||
---@field behind integer
|
||||
|
||||
---@class ow.Git.Status
|
||||
---@field branch ow.Git.Status.Branch
|
||||
---@field entries table<string, ow.Git.Status.Entry>
|
||||
local Status = {}
|
||||
Status.__index = Status
|
||||
|
||||
local CHANGE_FROM_CHAR = {
|
||||
M = "modified",
|
||||
A = "added",
|
||||
D = "deleted",
|
||||
R = "renamed",
|
||||
C = "copied",
|
||||
T = "type_changed",
|
||||
}
|
||||
|
||||
local CONFLICT_FROM_XY = {
|
||||
DD = "both_deleted",
|
||||
AU = "added_by_us",
|
||||
UD = "deleted_by_them",
|
||||
UA = "added_by_them",
|
||||
DU = "deleted_by_us",
|
||||
AA = "both_added",
|
||||
UU = "both_modified",
|
||||
}
|
||||
|
||||
local CHAR_FROM_CHANGE = {
|
||||
modified = "M",
|
||||
added = "A",
|
||||
deleted = "D",
|
||||
renamed = "R",
|
||||
copied = "C",
|
||||
type_changed = "T",
|
||||
}
|
||||
|
||||
---@param s string
|
||||
---@return string
|
||||
local function pascal(s)
|
||||
return (
|
||||
s:sub(1, 1):upper()
|
||||
.. s:sub(2):gsub("_(%a)", function(c)
|
||||
return c:upper()
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param staged ow.Git.Status.Change?
|
||||
---@param unstaged ow.Git.Status.Change?
|
||||
---@param orig string?
|
||||
---@return ow.Git.Status.ChangedEntry
|
||||
local function changed(path, staged, unstaged, orig)
|
||||
return {
|
||||
kind = "changed",
|
||||
path = path,
|
||||
staged = staged,
|
||||
unstaged = unstaged,
|
||||
orig = orig,
|
||||
}
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param conflict ow.Git.Status.Conflict
|
||||
---@return ow.Git.Status.UnmergedEntry
|
||||
local function unmerged(path, conflict)
|
||||
return { kind = "unmerged", path = path, conflict = conflict }
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return ow.Git.Status.UntrackedEntry
|
||||
local function untracked(path)
|
||||
return { kind = "untracked", path = path }
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return ow.Git.Status.IgnoredEntry
|
||||
local function ignored(path)
|
||||
return { kind = "ignored", path = path }
|
||||
end
|
||||
|
||||
---@param entry ow.Git.Status.Entry
|
||||
---@param side ("staged"|"unstaged")?
|
||||
---@return ow.Git.Status.Mark
|
||||
function M.mark_for(entry, side)
|
||||
if entry.kind == "untracked" then
|
||||
return { char = "?", hl = "GitUntracked" }
|
||||
end
|
||||
if entry.kind == "ignored" then
|
||||
return { char = "i", hl = "GitIgnored" }
|
||||
end
|
||||
if entry.kind == "unmerged" then
|
||||
---@cast entry ow.Git.Status.UnmergedEntry
|
||||
return { char = "!", hl = "GitUnmerged" .. pascal(entry.conflict) }
|
||||
end
|
||||
---@cast entry ow.Git.Status.ChangedEntry
|
||||
assert(side, "mark_for: side required for changed entry")
|
||||
local change = side == "staged" and entry.staged or entry.unstaged
|
||||
assert(change, "mark_for: changed entry has no change on side " .. side)
|
||||
return {
|
||||
char = CHAR_FROM_CHANGE[change],
|
||||
hl = "Git" .. pascal(side) .. pascal(change),
|
||||
}
|
||||
end
|
||||
|
||||
---@param entry ow.Git.Status.Entry
|
||||
---@return ow.Git.Status.Mark[]
|
||||
function M.marks_for(entry)
|
||||
if entry.kind ~= "changed" then
|
||||
return { M.mark_for(entry) }
|
||||
end
|
||||
---@cast entry ow.Git.Status.ChangedEntry
|
||||
local out = {}
|
||||
if entry.staged then
|
||||
table.insert(out, M.mark_for(entry, "staged"))
|
||||
end
|
||||
if entry.unstaged then
|
||||
table.insert(out, M.mark_for(entry, "unstaged"))
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
---@param section ow.Git.Status.Section
|
||||
---@return ow.Git.Status.Row[]
|
||||
function Status:rows(section)
|
||||
local out = {}
|
||||
if section == "staged" or section == "unstaged" then
|
||||
for _, entry in pairs(self.entries) do
|
||||
if entry.kind == "changed" then
|
||||
---@cast entry ow.Git.Status.ChangedEntry
|
||||
if entry[section] then
|
||||
table.insert(
|
||||
out,
|
||||
{ entry = entry, section = section, side = section }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
for _, entry in pairs(self.entries) do
|
||||
if entry.kind == section then
|
||||
table.insert(out, { entry = entry, section = section })
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
---@param prefix string
|
||||
---@return ow.Git.Status.Mark[]
|
||||
function Status:aggregate_at(prefix)
|
||||
local match = (prefix == "" or prefix == ".") and "" or prefix .. "/"
|
||||
local seen = {}
|
||||
local out = {}
|
||||
for path, entry in pairs(self.entries) do
|
||||
if path == prefix or vim.startswith(path, match) then
|
||||
for _, mark in ipairs(M.marks_for(entry)) do
|
||||
local key = mark.char .. "\0" .. mark.hl
|
||||
if not seen[key] then
|
||||
seen[key] = true
|
||||
table.insert(out, mark)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
return a.char < b.char
|
||||
end)
|
||||
return out
|
||||
end
|
||||
|
||||
---@param line string
|
||||
---@param branch ow.Git.Status.Branch
|
||||
local function parse_branch_header(line, branch)
|
||||
local oid = line:match("^# branch%.oid (.+)$")
|
||||
if oid then
|
||||
branch.oid = oid ~= "(initial)" and oid or nil
|
||||
return
|
||||
end
|
||||
local head = line:match("^# branch%.head (.+)$")
|
||||
if head then
|
||||
branch.head = head ~= "(detached)" and head or nil
|
||||
return
|
||||
end
|
||||
local up = line:match("^# branch%.upstream (.+)$")
|
||||
if up then
|
||||
branch.upstream = up
|
||||
return
|
||||
end
|
||||
local a, b = line:match("^# branch%.ab %+(%d+) %-(%d+)$")
|
||||
if a and b then
|
||||
branch.ahead = tonumber(a) --[[@as integer]]
|
||||
branch.behind = tonumber(b) --[[@as integer]]
|
||||
end
|
||||
end
|
||||
|
||||
---@param x string
|
||||
---@param y string
|
||||
---@return ow.Git.Status.Change?, ow.Git.Status.Change?
|
||||
local function changes_from_xy(x, y)
|
||||
local staged = x ~= "." and CHANGE_FROM_CHAR[x] or nil
|
||||
local unstaged = y ~= "." and CHANGE_FROM_CHAR[y] or nil
|
||||
return staged, unstaged
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return string
|
||||
local function strip_dir_slash(path)
|
||||
if path:sub(-1) == "/" then
|
||||
return path:sub(1, -2)
|
||||
end
|
||||
return path
|
||||
end
|
||||
|
||||
---@param a ow.Git.Status.Entry?
|
||||
---@param b ow.Git.Status.Entry?
|
||||
---@return boolean
|
||||
function M.entry_equal(a, b)
|
||||
if a == nil or b == nil then
|
||||
return a == b
|
||||
end
|
||||
if a.kind ~= b.kind or a.path ~= b.path then
|
||||
return false
|
||||
end
|
||||
if a.kind == "changed" then
|
||||
---@cast a ow.Git.Status.ChangedEntry
|
||||
---@cast b ow.Git.Status.ChangedEntry
|
||||
return a.staged == b.staged
|
||||
and a.unstaged == b.unstaged
|
||||
and a.orig == b.orig
|
||||
end
|
||||
if a.kind == "unmerged" then
|
||||
---@cast a ow.Git.Status.UnmergedEntry
|
||||
---@cast b ow.Git.Status.UnmergedEntry
|
||||
return a.conflict == b.conflict
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
---@param prior table<string, ow.Git.Status.Entry>
|
||||
---@param next_ table<string, ow.Git.Status.Entry>
|
||||
---@return table<string, true>
|
||||
function M.diff_entries(prior, next_)
|
||||
local paths = {}
|
||||
for path, entry in pairs(next_) do
|
||||
if not M.entry_equal(prior[path], entry) then
|
||||
paths[path] = true
|
||||
end
|
||||
end
|
||||
for path in pairs(prior) do
|
||||
if next_[path] == nil then
|
||||
paths[path] = true
|
||||
end
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
---@param stdout string
|
||||
---@return ow.Git.Status
|
||||
function M.parse(stdout)
|
||||
---@type ow.Git.Status.Branch
|
||||
local branch = { ahead = 0, behind = 0 }
|
||||
---@type table<string, ow.Git.Status.Entry>
|
||||
local entries = {}
|
||||
|
||||
local tokens = vim.split(stdout, "\0", { plain = true })
|
||||
while #tokens > 0 and tokens[#tokens] == "" do
|
||||
tokens[#tokens] = nil
|
||||
end
|
||||
|
||||
local i = 1
|
||||
while i <= #tokens do
|
||||
local line = tokens[i] --[[@as string]]
|
||||
local tag = line:sub(1, 2)
|
||||
if tag == "# " then
|
||||
parse_branch_header(line, branch)
|
||||
elseif tag == "1 " then
|
||||
local xy, _, _, _, _, _, _, path =
|
||||
line:match("^1 (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$")
|
||||
if xy and path then
|
||||
local key = strip_dir_slash(path)
|
||||
local staged, unstaged =
|
||||
changes_from_xy(xy:sub(1, 1), xy:sub(2, 2))
|
||||
entries[key] = changed(key, staged, unstaged)
|
||||
end
|
||||
elseif tag == "2 " then
|
||||
local xy, _, _, _, _, _, _, _, path = line:match(
|
||||
"^2 (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$"
|
||||
)
|
||||
local orig = tokens[i + 1]
|
||||
if xy and path and orig then
|
||||
local key = strip_dir_slash(path)
|
||||
local staged, unstaged =
|
||||
changes_from_xy(xy:sub(1, 1), xy:sub(2, 2))
|
||||
entries[key] = changed(key, staged, unstaged, orig)
|
||||
i = i + 1
|
||||
end
|
||||
elseif tag == "u " then
|
||||
local xy, _, _, _, _, _, _, _, _, path = line:match(
|
||||
"^u (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (%S+) (.+)$"
|
||||
)
|
||||
local conflict = xy and CONFLICT_FROM_XY[xy] --[[@as ow.Git.Status.Conflict?]]
|
||||
or nil
|
||||
if conflict and path then
|
||||
local key = strip_dir_slash(path)
|
||||
entries[key] = unmerged(key, conflict)
|
||||
end
|
||||
elseif tag == "? " then
|
||||
local key = strip_dir_slash(line:sub(3))
|
||||
entries[key] = untracked(key)
|
||||
elseif tag == "! " then
|
||||
local key = strip_dir_slash(line:sub(3))
|
||||
entries[key] = ignored(key)
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
return setmetatable({ branch = branch, entries = entries }, Status)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,352 @@
|
||||
local M = {}
|
||||
|
||||
---@class ow.Git.Util.ScratchOpts
|
||||
---@field name string?
|
||||
---@field bufhidden ("hide"|"wipe"|"delete")?
|
||||
---@field buftype ("nofile"|"acwrite"|"nowrite")?
|
||||
---@field modifiable boolean?
|
||||
|
||||
---@param buf integer
|
||||
---@param opts ow.Git.Util.ScratchOpts
|
||||
function M.setup_scratch(buf, opts)
|
||||
vim.bo[buf].buftype = opts.buftype or "nofile"
|
||||
vim.bo[buf].bufhidden = opts.bufhidden or "wipe"
|
||||
vim.bo[buf].swapfile = false
|
||||
vim.bo[buf].modifiable = opts.modifiable == true
|
||||
vim.bo[buf].modified = false
|
||||
vim.bo[buf].buflisted = false
|
||||
if opts.name then
|
||||
pcall(vim.api.nvim_buf_set_name, buf, opts.name)
|
||||
end
|
||||
end
|
||||
|
||||
---@param name string
|
||||
---@return boolean
|
||||
function M.is_uri(name)
|
||||
return name:match("^%a+://") ~= nil
|
||||
end
|
||||
|
||||
---@param sha string?
|
||||
---@return boolean
|
||||
function M.is_zero_sha(sha)
|
||||
return sha == nil or sha:match("^0+$") ~= nil
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@param name string
|
||||
function M.set_buf_name(buf, name)
|
||||
pcall(vim.api.nvim_buf_set_name, buf, name)
|
||||
local ft = vim.filetype.match({ buf = buf })
|
||||
if ft then
|
||||
vim.bo[buf].filetype = ft
|
||||
end
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@param split (false|"above"|"below"|"left"|"right")?
|
||||
---@return integer win
|
||||
function M.place_buf(buf, split)
|
||||
if split == false then
|
||||
vim.cmd.normal({ "m'", bang = true })
|
||||
vim.api.nvim_set_current_buf(buf)
|
||||
return vim.api.nvim_get_current_win()
|
||||
end
|
||||
local win = vim.api.nvim_open_win(buf, true, {
|
||||
split = split or (vim.o.splitbelow and "below" or "above"),
|
||||
})
|
||||
vim.cmd.clearjumps()
|
||||
return win
|
||||
end
|
||||
|
||||
---@class ow.Git.Util.NewScratchOpts : ow.Git.Util.ScratchOpts
|
||||
---@field split (false|"above"|"below"|"left"|"right")?
|
||||
|
||||
---@param opts ow.Git.Util.NewScratchOpts?
|
||||
---@return integer buf
|
||||
---@return integer win
|
||||
function M.new_scratch(opts)
|
||||
opts = opts or {}
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
M.setup_scratch(buf, opts)
|
||||
return buf, M.place_buf(buf, opts.split)
|
||||
end
|
||||
|
||||
---@param fmt string
|
||||
---@param ... any
|
||||
function M.error(fmt, ...)
|
||||
vim.notify(fmt:format(...), vim.log.levels.ERROR)
|
||||
end
|
||||
|
||||
---@param fmt string
|
||||
---@param ... any
|
||||
function M.warning(fmt, ...)
|
||||
vim.notify(fmt:format(...), vim.log.levels.WARN)
|
||||
end
|
||||
|
||||
---@param fmt string
|
||||
---@param ... any
|
||||
function M.info(fmt, ...)
|
||||
vim.notify(fmt:format(...), vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
---@param fmt string
|
||||
---@param ... any
|
||||
function M.debug(fmt, ...)
|
||||
vim.notify(fmt:format(...), vim.log.levels.DEBUG)
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@param start integer
|
||||
---@param end_ integer
|
||||
---@param lines string[]
|
||||
function M.set_buf_lines(buf, start, end_, lines)
|
||||
if not vim.api.nvim_buf_is_loaded(buf) then
|
||||
return
|
||||
end
|
||||
local was_modifiable = vim.bo[buf].modifiable
|
||||
vim.bo[buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(buf, start, end_, true, lines)
|
||||
vim.bo[buf].modifiable = was_modifiable
|
||||
vim.bo[buf].modified = false
|
||||
end
|
||||
|
||||
---@param content string
|
||||
---@return string[]
|
||||
function M.split_lines(content)
|
||||
local lines = vim.split(content, "\n", { plain = true, trimempty = false })
|
||||
if #lines > 0 and lines[#lines] == "" then
|
||||
table.remove(lines)
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
---@class ow.Git.Util.DebounceHandle
|
||||
---@field cancel fun()
|
||||
---@field flush fun()
|
||||
---@field pending fun(): boolean
|
||||
---@field close fun()
|
||||
|
||||
---@generic F: fun(...)
|
||||
---@param fn F
|
||||
---@param delay integer
|
||||
---@return F, ow.Git.Util.DebounceHandle
|
||||
function M.debounce(fn, delay)
|
||||
local timer, err = vim.uv.new_timer()
|
||||
if not timer then
|
||||
M.warning("git: failed to create timer: %s", err)
|
||||
local noop = function() end
|
||||
return fn,
|
||||
{
|
||||
cancel = noop,
|
||||
flush = noop,
|
||||
pending = function()
|
||||
return false
|
||||
end,
|
||||
close = noop,
|
||||
}
|
||||
end
|
||||
local args ---@type table?
|
||||
local gen = 0
|
||||
local fired_gen = 0
|
||||
|
||||
local cb_main = vim.schedule_wrap(function()
|
||||
-- Identity check: the libuv fire may have been superseded by
|
||||
-- a re-arm or a cancel between the timer firing and this
|
||||
-- scheduled callback running.
|
||||
if fired_gen ~= gen or args == nil then
|
||||
return
|
||||
end
|
||||
local a = args
|
||||
args = nil
|
||||
fn(vim.F.unpack_len(a))
|
||||
end)
|
||||
|
||||
local cb_uv = function()
|
||||
fired_gen = gen
|
||||
cb_main()
|
||||
end
|
||||
|
||||
local function call(...)
|
||||
args = vim.F.pack_len(...)
|
||||
gen = gen + 1
|
||||
timer:start(delay, 0, cb_uv)
|
||||
end
|
||||
|
||||
return call,
|
||||
{
|
||||
cancel = function()
|
||||
timer:stop()
|
||||
args = nil
|
||||
end,
|
||||
flush = function()
|
||||
if args == nil then
|
||||
return
|
||||
end
|
||||
timer:stop()
|
||||
local a = args
|
||||
args = nil
|
||||
fn(vim.F.unpack_len(a))
|
||||
end,
|
||||
pending = function()
|
||||
return args ~= nil
|
||||
end,
|
||||
close = function()
|
||||
timer:stop()
|
||||
if not timer:is_closing() then
|
||||
timer:close()
|
||||
end
|
||||
args = nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
---@class ow.Git.Util.KeyedDebounceHandle<K>
|
||||
---@field cancel fun(key: K)
|
||||
---@field flush fun(key: K)
|
||||
---@field pending fun(key: K): boolean
|
||||
---@field close fun()
|
||||
|
||||
---@generic K, F: fun(key: K, ...)
|
||||
---@param fn F
|
||||
---@param delay integer
|
||||
---@return F, ow.Git.Util.KeyedDebounceHandle<K>
|
||||
function M.keyed_debounce(fn, delay)
|
||||
---@type table<any, { call: fun(...), handle: ow.Git.Util.DebounceHandle }>
|
||||
local slots = {}
|
||||
|
||||
local function call(key, ...)
|
||||
local t = type(key)
|
||||
assert(
|
||||
t == "string" or t == "number" or t == "boolean",
|
||||
"key must be a primitive (string, number, boolean)"
|
||||
)
|
||||
local slot = slots[key]
|
||||
if not slot then
|
||||
local c, h = M.debounce(function(...)
|
||||
fn(key, ...)
|
||||
end, delay)
|
||||
slot = { call = c, handle = h }
|
||||
slots[key] = slot
|
||||
end
|
||||
slot.call(...)
|
||||
end
|
||||
|
||||
return call,
|
||||
{
|
||||
cancel = function(key)
|
||||
local slot = slots[key]
|
||||
if slot then
|
||||
slot.handle.close()
|
||||
slots[key] = nil
|
||||
end
|
||||
end,
|
||||
flush = function(key)
|
||||
local slot = slots[key]
|
||||
if slot then
|
||||
slot.handle.flush()
|
||||
end
|
||||
end,
|
||||
pending = function(key)
|
||||
local slot = slots[key]
|
||||
return slot ~= nil and slot.handle.pending()
|
||||
end,
|
||||
close = function()
|
||||
for _, slot in pairs(slots) do
|
||||
slot.handle.close()
|
||||
end
|
||||
slots = {}
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
---@class ow.Git.Util.ExecOpts
|
||||
---@field cwd string?
|
||||
---@field stdin string?
|
||||
---@field silent boolean?
|
||||
---@field env table<string, string>?
|
||||
---@field on_exit fun(result: vim.SystemCompleted)?
|
||||
|
||||
---@param cmd string[]
|
||||
---@param opts ow.Git.Util.ExecOpts?
|
||||
---@return string?
|
||||
function M.exec(cmd, opts)
|
||||
opts = opts or {}
|
||||
local sys_opts = {
|
||||
cwd = opts.cwd,
|
||||
stdin = opts.stdin,
|
||||
env = opts.env,
|
||||
text = true,
|
||||
}
|
||||
|
||||
if opts.on_exit then
|
||||
vim.system(cmd, sys_opts, vim.schedule_wrap(opts.on_exit))
|
||||
return nil
|
||||
end
|
||||
|
||||
local result = vim.system(cmd, sys_opts):wait()
|
||||
if result.code ~= 0 then
|
||||
if not opts.silent then
|
||||
local label = cmd[2] and (cmd[1] .. " " .. cmd[2]) or cmd[1] or "?"
|
||||
M.error("%s failed: %s", label, vim.trim(result.stderr or ""))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
return result.stdout or ""
|
||||
end
|
||||
|
||||
M.DEFAULT_GIT_ENV = {
|
||||
GIT_TERMINAL_PROMPT = "false",
|
||||
}
|
||||
|
||||
---@param args string[]
|
||||
---@param opts ow.Git.Util.ExecOpts?
|
||||
---@return string?
|
||||
function M.git(args, opts)
|
||||
opts = opts or {}
|
||||
opts.env = vim.tbl_extend("force", M.DEFAULT_GIT_ENV, opts.env or {})
|
||||
local cmd = { "git" }
|
||||
vim.list_extend(cmd, args)
|
||||
return M.exec(cmd, opts)
|
||||
end
|
||||
|
||||
---@class ow.Git.Util.Emitter<T>
|
||||
---@field private _listeners table<T, (fun(...))[]>
|
||||
local Emitter = {}
|
||||
Emitter.__index = Emitter
|
||||
|
||||
---@return ow.Git.Util.Emitter<T>
|
||||
function Emitter.new()
|
||||
return setmetatable({ _listeners = {} }, Emitter)
|
||||
end
|
||||
|
||||
---@param event T
|
||||
---@param fn fun(...)
|
||||
---@return fun() unsubscribe
|
||||
function Emitter:on(event, fn)
|
||||
local list = self._listeners[event] or {}
|
||||
self._listeners[event] = list
|
||||
table.insert(list, fn)
|
||||
return function()
|
||||
for i, f in ipairs(list) do
|
||||
if f == fn then
|
||||
table.remove(list, i)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param event T
|
||||
function Emitter:emit(event, ...)
|
||||
for _, fn in ipairs(self._listeners[event] or {}) do
|
||||
fn(...)
|
||||
end
|
||||
end
|
||||
|
||||
function Emitter:clear()
|
||||
self._listeners = {}
|
||||
end
|
||||
|
||||
M.Emitter = Emitter
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user