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 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 stdout string ---@return ow.Git.Status function M.parse(stdout) ---@type ow.Git.Status.Branch local branch = { ahead = 0, behind = 0 } ---@type table 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