384 lines
10 KiB
Lua
384 lines
10 KiB
Lua
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
|