From 067594ef9e902a8629ed8b022b813aeeb5ed23d1 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Sat, 9 May 2026 22:59:07 +0200 Subject: [PATCH] refactor(git/status): rework entries into typed variants on porcelain v2 --- lua/git/cmd.lua | 19 +- lua/git/init.lua | 30 ++- lua/git/repo.lua | 3 +- lua/git/status.lua | 430 ++++++++++++++++++++++------------ lua/git/status_view.lua | 190 ++++++++------- lua/git/statusline.lua | 52 ++-- plugins/nvim-tree.lua | 28 ++- test/git/status_test.lua | 327 ++++++++++++++++++++++++++ test/git/status_view_test.lua | 6 +- 9 files changed, 793 insertions(+), 292 deletions(-) create mode 100644 test/git/status_test.lua diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua index 6ac16a5..3c73afd 100644 --- a/lua/git/cmd.lua +++ b/lua/git/cmd.lua @@ -651,17 +651,16 @@ end ---@return string[] local function complete_unstaged_paths(r, lead) local matches = {} - for path, entry_list in pairs(r.status.entries) do + for path, entry in pairs(r.status.entries) do if path:sub(1, #lead) == lead then - for _, e in ipairs(entry_list) do - if - e.kind == "unstaged" - or e.kind == "untracked" - or e.kind == "unmerged" - then - table.insert(matches, path) - break - end + local include = entry.kind == "untracked" + or entry.kind == "unmerged" + if not include and entry.kind == "changed" then + ---@cast entry ow.Git.Status.ChangedEntry + include = entry.unstaged ~= nil + end + if include then + table.insert(matches, path) end end end diff --git a/lua/git/init.lua b/lua/git/init.lua index 49a6a07..0cba633 100644 --- a/lua/git/init.lua +++ b/lua/git/init.lua @@ -1,20 +1,40 @@ -local HIGHLIGHTS = { - GitDeleted = "Removed", +local DEFAULT_HIGHLIGHTS = { GitIgnored = "Comment", - GitUnstaged = "Changed", - GitRenamed = "GitStaged", GitSha = "Identifier", GitStaged = "Constant", GitUnmerged = "Todo", GitUnpulled = "Removed", GitUnpushed = "Added", + GitUnstaged = "Changed", GitUntracked = "Added", + + GitStagedAdded = "GitStaged", + GitStagedCopied = "GitStaged", + GitStagedDeleted = "GitStaged", + GitStagedModified = "GitStaged", + GitStagedRenamed = "GitStaged", + GitStagedTypeChanged = "GitStaged", + + GitUnstagedAdded = "GitUnstaged", + GitUnstagedCopied = "GitUnstaged", + GitUnstagedDeleted = "Removed", + GitUnstagedModified = "GitUnstaged", + GitUnstagedRenamed = "GitStaged", + GitUnstagedTypeChanged = "GitUnstaged", + + GitUnmergedAddedByThem = "GitUnmerged", + GitUnmergedAddedByUs = "GitUnmerged", + GitUnmergedBothAdded = "GitUnmerged", + GitUnmergedBothDeleted = "GitUnmerged", + GitUnmergedBothModified = "GitUnmerged", + GitUnmergedDeletedByThem = "GitUnmerged", + GitUnmergedDeletedByUs = "GitUnmerged", } local M = {} function M.init() - for name, link in pairs(HIGHLIGHTS) do + for name, link in pairs(DEFAULT_HIGHLIGHTS) do vim.api.nvim_set_hl(0, name, { link = link, default = true }) end diff --git a/lua/git/repo.lua b/lua/git/repo.lua index da601f4..1061d9a 100644 --- a/lua/git/repo.lua +++ b/lua/git/repo.lua @@ -43,10 +43,11 @@ local STATUS_ARGS = { "-c", "core.quotePath=false", "status", - "--porcelain=v1", + "--porcelain=v2", "--branch", "--ignored", "--untracked-files=all", + "-z", } ---@private diff --git a/lua/git/status.lua b/lua/git/status.lua index 46e7089..376b3d7 100644 --- a/lua/git/status.lua +++ b/lua/git/status.lua @@ -1,44 +1,207 @@ local M = {} -local UNMERGED = { - DD = true, - AU = true, - UD = true, - UA = true, - DU = true, - AA = true, - UU = true, -} - ----@alias ow.Git.Status.EntryKind "untracked"|"unstaged"|"staged"|"unmerged"|"ignored" +---@alias ow.Git.Status.Kind +---| "changed" +---| "unmerged" +---| "untracked" +---| "ignored" ---@class ow.Git.Status.Entry +---@field kind ow.Git.Status.Kind ---@field path string ----@field kind ow.Git.Status.EntryKind ----@field char string ----@field hl 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? ----@class ow.Git.Status.BranchInfo +---@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.BranchInfo ----@field entries table +---@field branch ow.Git.Status.Branch +---@field entries table local Status = {} Status.__index = Status ----@param kind ow.Git.Status.EntryKind ----@return ow.Git.Status.Entry[] -function Status:by_kind(kind) +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 = {} - for _, list in pairs(self.entries) do - for _, e in ipairs(list) do - if e.kind == kind then - table.insert(out, e) + 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 @@ -46,18 +209,18 @@ function Status:by_kind(kind) end ---@param prefix string ----@return ow.Git.Status.Entry[] +---@return ow.Git.Status.Mark[] function Status:aggregate_at(prefix) local match = (prefix == "" or prefix == ".") and "" or prefix .. "/" local seen = {} local out = {} - for path, list in pairs(self.entries) do + for path, entry in pairs(self.entries) do if path == prefix or vim.startswith(path, match) then - for _, e in ipairs(list) do - local key = e.char .. "\0" .. e.hl + 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, e) + table.insert(out, mark) end end end @@ -68,138 +231,109 @@ function Status:aggregate_at(prefix) 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 ----@return string char, string hl -local function staged_attrs(x) - if x == "R" then - return "R", "GitRenamed" - end - 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 y, "GitUnstaged" +---@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 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 +---@param path string +---@return string +local function strip_dir_slash(path) + if path:sub(-1) == "/" then + return path:sub(1, -2) 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 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 ----@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 + 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 + ---@type table 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 + + 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 diff --git a/lua/git/status_view.lua b/lua/git/status_view.lua index fa8ed1d..783f388 100644 --- a/lua/git/status_view.lua +++ b/lua/git/status_view.lua @@ -2,6 +2,7 @@ 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 = {} @@ -11,8 +12,8 @@ M.URI_PREFIX = "gitstatus://" ---@type ow.Git.StatusView.Placement[] M.PLACEMENTS = { "sidebar", "split", "current" } ----@type ow.Git.Status.EntryKind[] -local KINDS = { "untracked", "unstaged", "staged", "unmerged" } +---@type ow.Git.Status.Section[] +local SECTIONS = { "untracked", "unstaged", "staged", "unmerged" } local WINDOW_WIDTH = 50 ---@param name string @@ -29,9 +30,9 @@ end ---@class ow.Git.StatusView.Header ---@field is_header true ----@field kind ow.Git.Status.EntryKind +---@field section ow.Git.Status.Section ----@alias ow.Git.StatusView.Item ow.Git.Status.Entry | ow.Git.StatusView.Header +---@alias ow.Git.StatusView.Item ow.Git.Status.Row | ow.Git.StatusView.Header ---@class ow.Git.StatusView.State ---@field repo ow.Git.Repo @@ -83,20 +84,26 @@ local function win_for(s) return win end ----@param entry ow.Git.Status.Entry +---@param row ow.Git.Status.Row ---@return string line ---@return string hl_group ---@return integer hl_len -local function format_entry(entry) - local label = entry.orig and (entry.orig .. " -> " .. entry.path) - or entry.path - return string.format(" %s %s", entry.char, label), entry.hl, #entry.char +local function format_row(row) + local entry = row.entry + local orig + if entry.kind == "changed" then + ---@cast entry ow.Git.Status.ChangedEntry + orig = entry.orig + end + local label = orig and (orig .. " -> " .. entry.path) or entry.path + local mark = status.mark_for(entry, row.side) + return string.format(" %s %s", mark.char, label), mark.hl, #mark.char end ----@param kind ow.Git.Status.EntryKind +---@param section ow.Git.Status.Section ---@return string -local function display_name(kind) - return (kind:gsub("^%l", string.upper)) +local function display_name(section) + return (section:gsub("^%l", string.upper)) end ---@param bufnr integer @@ -118,18 +125,18 @@ local function render(bufnr, status) local meta = {} local marks = {} - for _, kind in ipairs(KINDS) do - local entries = status:by_kind(kind) - if #entries > 0 then + for _, section in ipairs(SECTIONS) do + local rows = status:rows(section) + if #rows > 0 then table.insert( lines, - string.format("%s (%d)", display_name(kind), #entries) + string.format("%s (%d)", display_name(section), #rows) ) - meta[#lines] = { is_header = true, kind = kind } - for _, entry in ipairs(entries) do - local line, hl, hl_len = format_entry(entry) + meta[#lines] = { is_header = true, section = section } + for _, row in ipairs(rows) do + local line, hl, hl_len = format_row(row) table.insert(lines, line) - meta[#lines] = entry + meta[#lines] = row table.insert(marks, { row = #lines - 1, col = 2, @@ -195,10 +202,10 @@ local function worktree_pane(r, path) end ---@param s ow.Git.StatusView.State ----@param entry ow.Git.Status.Entry +---@param path string ---@return ow.Git.Diff.Side -local function index_pane(s, entry) - local rev = Revision.new({ stage = 0, path = entry.path }) +local function index_pane(s, path) + local rev = Revision.new({ stage = 0, path = path }) return { buf = object.buf_for(s.repo, rev), name = object.format_uri(rev), @@ -206,38 +213,43 @@ local function index_pane(s, entry) end ---@param s ow.Git.StatusView.State ----@param entry ow.Git.Status.Entry +---@param row ow.Git.Status.Row ---@return ow.Git.Diff.Side? -local function older_pane(s, entry) - if entry.kind == "staged" then - if entry.char == "A" then +local function older_pane(s, row) + local entry = row.entry + if row.section == "staged" then + ---@cast entry ow.Git.Status.ChangedEntry + if entry.staged == "added" then return nil end return head_pane(s.repo, entry.orig or entry.path) end - if entry.kind == "unstaged" then - return index_pane(s, entry) + if row.section == "unstaged" then + return index_pane(s, entry.path) end return nil end ---@param s ow.Git.StatusView.State ----@param entry ow.Git.Status.Entry +---@param row ow.Git.Status.Row ---@return ow.Git.Diff.Side? -local function newer_pane(s, entry) - if entry.kind == "staged" then - if entry.char == "D" then +local function newer_pane(s, row) + local entry = row.entry + if row.section == "staged" then + ---@cast entry ow.Git.Status.ChangedEntry + if entry.staged == "deleted" then return nil end - return index_pane(s, entry) + return index_pane(s, entry.path) end - if entry.kind == "unstaged" then - if entry.char == "D" then + if row.section == "unstaged" then + ---@cast entry ow.Git.Status.ChangedEntry + if entry.unstaged == "deleted" then return nil end return worktree_pane(s.repo, entry.path) end - if entry.kind == "untracked" then + if row.section == "untracked" then return worktree_pane(s.repo, entry.path) end return nil @@ -296,10 +308,16 @@ local function adopt_diff_wins(s, status_win) return left, right end ----@param entry ow.Git.Status.Entry +---@param row ow.Git.Status.Row ---@return string -local function entry_key(entry) - return entry.kind .. "|" .. entry.path .. "|" .. (entry.orig or "") +local function row_key(row) + local entry = row.entry + local orig + if entry.kind == "changed" then + ---@cast entry ow.Git.Status.ChangedEntry + orig = entry.orig + end + return row.section .. "|" .. entry.path .. "|" .. (orig or "") end ---@param target_win integer @@ -335,18 +353,22 @@ local function ensure_right_win(s, status_win, right_win) end ---@param s ow.Git.StatusView.State ----@param entry ow.Git.Status.Entry +---@param row ow.Git.Status.Row ---@param focus_left boolean -local function view_entry(s, entry, focus_left) +local function view_row(s, row, focus_left) 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) + local left = older_pane(s, row) + local right = newer_pane(s, row) if not left and not right then - util.warning("no content for %s entry: %s", entry.kind, entry.path) + util.warning( + "no content for %s row: %s", + row.section, + row.entry.path + ) return end @@ -362,7 +384,7 @@ local function view_entry(s, entry, focus_left) return end - local key = entry_key(entry) + local key = row_key(row) local left_win, right_win = adopt_diff_wins(s, status_win) local want_pair = left and right @@ -433,8 +455,8 @@ local function preview_or_open(focus_left) if not s or not item or item.is_header then return end - ---@cast item ow.Git.Status.Entry - view_entry(s, item, focus_left) + ---@cast item ow.Git.Status.Row + view_row(s, item, focus_left) end local function action_stage() @@ -444,18 +466,18 @@ local function action_stage() end local paths = {} if item.is_header then - if item.kind == "staged" or item.kind == "ignored" then + if item.section == "staged" or item.section == "ignored" then return end - for _, e in ipairs(s.repo.status:by_kind(item.kind)) do - table.insert(paths, e.path) + for _, row in ipairs(s.repo.status:rows(item.section)) do + table.insert(paths, row.entry.path) end else - ---@cast item ow.Git.Status.Entry - if item.kind == "staged" then + ---@cast item ow.Git.Status.Row + if item.section == "staged" then return end - table.insert(paths, item.path) + table.insert(paths, item.entry.path) end if #paths == 0 then return @@ -477,25 +499,33 @@ local function action_unstage() if not s or not item then return end - if item.kind ~= "staged" then + local rows + if item.is_header then + if item.section ~= "staged" then + return + end + rows = s.repo.status:rows("staged") + else + ---@cast item ow.Git.Status.Row + if item.section ~= "staged" then + return + end + rows = { item } + end + ---@cast rows ow.Git.Status.Row[] + if #rows == 0 then return end local args = { "restore", "--staged", "--" } - local entries - if item.is_header then - entries = s.repo.status:by_kind("staged") - else - ---@cast item ow.Git.Status.Entry - entries = { item } - end - if #entries == 0 then - return - end - for _, e in ipairs(entries) do - if e.orig then - table.insert(args, e.orig) + for _, row in ipairs(rows) do + local entry = row.entry + if entry.kind == "changed" then + ---@cast entry ow.Git.Status.ChangedEntry + if entry.orig then + table.insert(args, entry.orig) + end end - table.insert(args, e.path) + table.insert(args, entry.path) end util.git(args, { cwd = s.repo.worktree, @@ -515,32 +545,34 @@ local function action_discard() if not s or not item or item.is_header then return end - ---@cast item ow.Git.Status.Entry - if item.kind == "staged" then + ---@cast item ow.Git.Status.Row + if item.section == "staged" then util.warning("file has staged changes, unstage first with 'u'") return end + local entry = item.entry + local path = entry.path local prompt, action - if item.kind == "untracked" then - local is_dir = item.path:sub(-1) == "/" + if item.section == "untracked" then + local is_dir = path:sub(-1) == "/" prompt = string.format( "Delete untracked %s %s?", is_dir and "directory" or "file", - item.path + path ) action = function() - local target = vim.fs.joinpath(s.repo.worktree, item.path) + local target = vim.fs.joinpath(s.repo.worktree, path) local rc = vim.fn.delete(target, is_dir and "rf" or "") if rc ~= 0 then - util.error("failed to delete %s", item.path) + util.error("failed to delete %s", path) end refresh(vim.api.nvim_get_current_buf()) end - elseif item.kind == "unstaged" then - prompt = string.format("Discard changes to %s?", item.path) + elseif item.section == "unstaged" then + prompt = string.format("Discard changes to %s?", path) action = function() - util.git({ "checkout", "--", item.path }, { + util.git({ "checkout", "--", path }, { cwd = s.repo.worktree, on_exit = function(result) if result.code ~= 0 then diff --git a/lua/git/statusline.lua b/lua/git/statusline.lua index c680ba4..0c14567 100644 --- a/lua/git/statusline.lua +++ b/lua/git/statusline.lua @@ -1,47 +1,29 @@ local repo = require("git.repo") +local status = require("git.status") local util = require("git.util") local M = {} ---@class ow.Git.Statusline.Status ---@field head string? ----@field entries ow.Git.Status.Entry[] ----@field unstaged boolean ----@field staged boolean ----@field conflict boolean +---@field entry ow.Git.Status.Entry? ----@param entries ow.Git.Status.Entry[] ----@param head string? ----@return ow.Git.Statusline.Status -local function build(entries, head) - local out = { - head = head, - entries = entries, - unstaged = false, - staged = false, - conflict = false, - } - for _, e in ipairs(entries) do - if e.kind == "unstaged" or e.kind == "untracked" then - out.unstaged = true - elseif e.kind == "staged" then - out.staged = true - elseif e.kind == "unmerged" then - out.conflict = true - end - end - return out -end - ----@param entries ow.Git.Status.Entry[] +---@param entry ow.Git.Status.Entry? ---@return string -local function render(entries) - if #entries == 0 then +local function render(entry) + if not entry then + return "" + end + local marks = status.marks_for(entry) + if #marks == 0 then return "" end local parts = {} - for _, e in ipairs(entries) do - table.insert(parts, string.format("%%#%s#%s%%*", e.hl, e.char)) + for _, mark in ipairs(marks) do + table.insert( + parts, + string.format("%%#%s#%s%%*", mark.hl, mark.char) + ) end return table.concat(parts, " ") end @@ -70,9 +52,9 @@ local function update_buf(buf, r) if not rel then return clear(buf) end - local entries = r.status.entries[rel] or {} - vim.b[buf].git_status = build(entries, r:head()) - vim.b[buf].git_status_string = render(entries) + local entry = r.status.entries[rel] + vim.b[buf].git_status = { head = r:head(), entry = entry } + vim.b[buf].git_status_string = render(entry) end local enabled = false diff --git a/plugins/nvim-tree.lua b/plugins/nvim-tree.lua index 408bf67..ade5dcd 100644 --- a/plugins/nvim-tree.lua +++ b/plugins/nvim-tree.lua @@ -49,9 +49,11 @@ function GitDecorator:new() self.highlight_range = "name" end +local status = require("git.status") + ---@param node Node ----@return ow.Git.Status.Entry[]? -local function entries_for(node) +---@return ow.Git.Status.Mark[]? +local function marks_for(node) local r = repo.find(node.absolute_path) if not r then return @@ -64,20 +66,24 @@ local function entries_for(node) local list = r.status:aggregate_at(rel) return #list > 0 and list or nil end - return r.status.entries[rel] + local entry = r.status.entries[rel] + if not entry then + return + end + return status.marks_for(entry) end ---@param node Node ---@return { str: string, hl: string[] }[]? function GitDecorator.icons(_, node) - local list = entries_for(node) + local list = marks_for(node) if not list then return end local out = {} - for _, entry in ipairs(list) do - if entry.kind ~= "ignored" then - table.insert(out, { str = entry.char, hl = { entry.hl } }) + for _, mark in ipairs(list) do + if mark.hl ~= "GitIgnored" then + table.insert(out, { str = mark.char, hl = { mark.hl } }) end end return out @@ -86,16 +92,16 @@ end ---@param node Node ---@return string? function GitDecorator.highlight_group(_, node) - local list = entries_for(node) + local list = marks_for(node) if not list then return end local hl - for _, entry in ipairs(list) do - if entry.kind ~= "ignored" then + for _, mark in ipairs(list) do + if mark.hl ~= "GitIgnored" then return end - hl = hl or entry.hl + hl = hl or mark.hl end return hl end diff --git a/test/git/status_test.lua b/test/git/status_test.lua new file mode 100644 index 0000000..45d3525 --- /dev/null +++ b/test/git/status_test.lua @@ -0,0 +1,327 @@ +local t = require("test") +local status = require("git.status") + +local NUL = "\0" + +---@param parts string[] +---@return string +local function nul(parts) + return table.concat(parts, NUL) .. NUL +end + +t.test("branch headers: initial repo, no commits", function() + local s = status.parse(nul({ + "# branch.oid (initial)", + "# branch.head main", + })) + t.eq(s.branch.oid, nil) + t.eq(s.branch.head, "main") + t.eq(s.branch.upstream, nil) + t.eq(s.branch.ahead, 0) + t.eq(s.branch.behind, 0) +end) + +t.test("branch headers: detached HEAD", function() + local s = status.parse(nul({ + "# branch.oid 1234567890abcdef1234567890abcdef12345678", + "# branch.head (detached)", + })) + t.eq(s.branch.oid, "1234567890abcdef1234567890abcdef12345678") + t.eq(s.branch.head, nil) +end) + +t.test("branch headers: with upstream and ahead/behind", function() + local s = status.parse(nul({ + "# branch.oid abc123", + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +3 -2", + })) + t.eq(s.branch.head, "main") + t.eq(s.branch.upstream, "origin/main") + t.eq(s.branch.ahead, 3) + t.eq(s.branch.behind, 2) +end) + +t.test("type 1: staged-only modification", function() + local s = status.parse(nul({ + "1 M. N... 100644 100644 100644 abc abc foo.lua", + })) + local e = s.entries["foo.lua"] + ---@cast e ow.Git.Status.ChangedEntry + t.eq(e.kind, "changed") + t.eq(e.path, "foo.lua") + t.eq(e.staged, "modified") + t.eq(e.unstaged, nil) + t.eq(e.orig, nil) +end) + +t.test("type 1: unstaged-only modification", function() + local s = status.parse(nul({ + "1 .M N... 100644 100644 100644 abc abc foo.lua", + })) + local e = s.entries["foo.lua"] + ---@cast e ow.Git.Status.ChangedEntry + t.eq(e.staged, nil) + t.eq(e.unstaged, "modified") +end) + +t.test("type 1: both sides modified", function() + local s = status.parse(nul({ + "1 MM N... 100644 100644 100644 abc abc foo.lua", + })) + local e = s.entries["foo.lua"] + ---@cast e ow.Git.Status.ChangedEntry + t.eq(e.staged, "modified") + t.eq(e.unstaged, "modified") +end) + +t.test("type 1: deleted (unstaged)", function() + local s = status.parse(nul({ + "1 .D N... 100644 100644 000000 abc abc foo.lua", + })) + local e = s.entries["foo.lua"] --[[@as ow.Git.Status.ChangedEntry]] + t.eq(e.unstaged, "deleted") +end) + +t.test("type 1: added (staged)", function() + local s = status.parse(nul({ + "1 A. N... 000000 100644 100644 abc abc new.lua", + })) + local e = s.entries["new.lua"] --[[@as ow.Git.Status.ChangedEntry]] + t.eq(e.staged, "added") +end) + +t.test("type 1: type-changed (unstaged)", function() + local s = status.parse(nul({ + "1 .T N... 100644 100644 120000 abc abc foo.lua", + })) + local e = s.entries["foo.lua"] --[[@as ow.Git.Status.ChangedEntry]] + t.eq(e.unstaged, "type_changed") +end) + +t.test("type 2: renamed with orig", function() + local s = status.parse(nul({ + "2 R. N... 100644 100644 100644 abc abc R100 new.lua", + "old.lua", + })) + local e = s.entries["new.lua"] + ---@cast e ow.Git.Status.ChangedEntry + t.eq(e.kind, "changed") + t.eq(e.path, "new.lua") + t.eq(e.staged, "renamed") + t.eq(e.orig, "old.lua") +end) + +t.test("type 2: copied with orig", function() + local s = status.parse(nul({ + "2 C. N... 100644 100644 100644 abc abc C90 copy.lua", + "src.lua", + })) + local e = s.entries["copy.lua"] + ---@cast e ow.Git.Status.ChangedEntry + t.eq(e.staged, "copied") + t.eq(e.orig, "src.lua") +end) + +t.test("type u: all seven conflict types", function() + local cases = { + { xy = "DD", expected = "both_deleted" }, + { xy = "AU", expected = "added_by_us" }, + { xy = "UD", expected = "deleted_by_them" }, + { xy = "UA", expected = "added_by_them" }, + { xy = "DU", expected = "deleted_by_us" }, + { xy = "AA", expected = "both_added" }, + { xy = "UU", expected = "both_modified" }, + } + for _, c in ipairs(cases) do + local s = status.parse(nul({ + string.format( + "u %s N... 100644 100644 100644 100644 abc abc abc conflict.lua", + c.xy + ), + })) + local e = s.entries["conflict.lua"] + t.eq(e.kind, "unmerged", "kind for " .. c.xy) + t.eq( + (e --[[@as ow.Git.Status.UnmergedEntry]]).conflict, + c.expected, + "conflict for " .. c.xy + ) + end +end) + +t.test("type ?: untracked", function() + local s = status.parse(nul({ "? new.txt" })) + local e = s.entries["new.txt"] + t.eq(e.kind, "untracked") + t.eq(e.path, "new.txt") +end) + +t.test("type !: ignored", function() + local s = status.parse(nul({ "! .secret" })) + local e = s.entries[".secret"] + t.eq(e.kind, "ignored") +end) + +t.test("mixed: branch + multiple variants", function() + local s = status.parse(nul({ + "# branch.oid abc", + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +0 -0", + "1 M. N... 100644 100644 100644 a a staged.lua", + "1 .M N... 100644 100644 100644 a a unstaged.lua", + "1 MM N... 100644 100644 100644 a a both.lua", + "u UU N... 100644 100644 100644 100644 a a a conflict.lua", + "? untracked.txt", + "! ignored.txt", + })) + t.eq(s.branch.head, "main") + local staged = s.entries["staged.lua"] --[[@as ow.Git.Status.ChangedEntry]] + local unstaged = s.entries["unstaged.lua"] --[[@as ow.Git.Status.ChangedEntry]] + local both = s.entries["both.lua"] --[[@as ow.Git.Status.ChangedEntry]] + t.eq(staged.staged, "modified") + t.eq(unstaged.unstaged, "modified") + t.eq(both.staged, "modified") + t.eq(both.unstaged, "modified") + t.eq(s.entries["conflict.lua"].kind, "unmerged") + t.eq(s.entries["untracked.txt"].kind, "untracked") + t.eq(s.entries["ignored.txt"].kind, "ignored") +end) + +t.test("paths with spaces survive splitting", function() + local s = status.parse(nul({ + "1 .M N... 100644 100644 100644 a a path with spaces.lua", + })) + local e = s.entries["path with spaces.lua"] --[[@as ow.Git.Status.ChangedEntry]] + t.eq(e.unstaged, "modified") +end) + +t.test("mark_for: changed staged modified", function() + local entry = { + kind = "changed", + path = "x", + staged = "modified", + } + t.eq(status.mark_for(entry, "staged"), { char = "M", hl = "GitStagedModified" }) +end) + +t.test("mark_for: changed unstaged deleted uses GitUnstagedDeleted", function() + local entry = { + kind = "changed", + path = "x", + unstaged = "deleted", + } + t.eq(status.mark_for(entry, "unstaged"), { char = "D", hl = "GitUnstagedDeleted" }) +end) + +t.test("mark_for: changed renamed uses per-side renamed hl", function() + local entry = { + kind = "changed", + path = "x", + staged = "renamed", + orig = "y", + } + t.eq(status.mark_for(entry, "staged"), { char = "R", hl = "GitStagedRenamed" }) +end) + +t.test("mark_for: untracked / ignored / unmerged ignore side", function() + t.eq( + status.mark_for({ kind = "untracked", path = "x" } --[[@as ow.Git.Status.Entry]]), + { char = "?", hl = "GitUntracked" } + ) + t.eq( + status.mark_for({ kind = "ignored", path = "x" } --[[@as ow.Git.Status.Entry]]), + { char = "i", hl = "GitIgnored" } + ) + t.eq( + status.mark_for({ + kind = "unmerged", + path = "x", + conflict = "both_modified", + } --[[@as ow.Git.Status.Entry]]), + { char = "!", hl = "GitUnmergedBothModified" } + ) +end) + +t.test("marks_for: changed with both sides yields two marks", function() + local entry = { + kind = "changed", + path = "x", + staged = "modified", + unstaged = "modified", + } + local marks = status.marks_for(entry) + t.eq(#marks, 2) + t.eq(marks[1], { char = "M", hl = "GitStagedModified" }) + t.eq(marks[2], { char = "M", hl = "GitUnstagedModified" }) +end) + +t.test("marks_for: changed one-sided yields one mark", function() + local entry = { kind = "changed", path = "x", staged = "added" } + local marks = status.marks_for(entry) + t.eq(#marks, 1) + t.eq(marks[1], { char = "A", hl = "GitStagedAdded" }) +end) + +t.test("marks_for: untracked yields one mark", function() + local entry = { kind = "untracked", path = "x" } --[[@as ow.Git.Status.Entry]] + local marks = status.marks_for(entry) + t.eq(#marks, 1) + t.eq(marks[1], { char = "?", hl = "GitUntracked" }) +end) + +t.test("Status:rows buckets by section", function() + local s = status.parse(nul({ + "1 M. N... 100644 100644 100644 a a staged.lua", + "1 .M N... 100644 100644 100644 a a unstaged.lua", + "1 MM N... 100644 100644 100644 a a both.lua", + "? untracked.txt", + })) + t.eq(#s:rows("staged"), 2, "staged section: staged.lua + both.lua") + t.eq(#s:rows("unstaged"), 2, "unstaged section: unstaged.lua + both.lua") + t.eq(#s:rows("untracked"), 1) + t.eq(#s:rows("unmerged"), 0) + t.eq(#s:rows("ignored"), 0) +end) + +t.test("Status:rows for staged carries side='staged'", function() + local s = status.parse(nul({ + "1 M. N... 100644 100644 100644 a a x.lua", + })) + local row = assert(s:rows("staged")[1]) + t.eq(row.section, "staged") + t.eq(row.side, "staged") + t.eq(row.entry.kind, "changed") +end) + +t.test("Status:rows for untracked has nil side", function() + local s = status.parse(nul({ "? x.txt" })) + local row = assert(s:rows("untracked")[1]) + t.eq(row.section, "untracked") + t.eq(row.side, nil) +end) + +t.test("Status:aggregate_at dedups marks under prefix", function() + local s = status.parse(nul({ + "1 .M N... 100644 100644 100644 a a sub/a.lua", + "1 .M N... 100644 100644 100644 a a sub/b.lua", + "? sub/c.txt", + })) + local marks = s:aggregate_at("sub") + t.eq(#marks, 2, "modified ('M') and untracked ('?') deduped") + local m1 = assert(marks[1]) + local m2 = assert(marks[2]) + local hls = { m1.hl, m2.hl } + table.sort(hls) + t.eq(hls, { "GitUnstagedModified", "GitUntracked" }) +end) + +t.test("Status:aggregate_at with prefix '.' includes everything", function() + local s = status.parse(nul({ + "1 .M N... 100644 100644 100644 a a a.lua", + "? b.txt", + })) + t.eq(#s:aggregate_at("."), 2) +end) diff --git a/test/git/status_view_test.lua b/test/git/status_view_test.lua index 7e7ecd1..f517fe3 100644 --- a/test/git/status_view_test.lua +++ b/test/git/status_view_test.lua @@ -78,7 +78,7 @@ local function setup_sidebar_with_unstaged_file( ) r:refresh() t.wait_for(function() - return r.status and #r.status:by_kind("unstaged") > 0 + return r.status and #r.status:rows("unstaged") > 0 end, "git status to report unstaged changes") local entry_line = assert( @@ -108,7 +108,7 @@ t.test("stage with diff open: sidebar cursor stays put", function() vim.api.nvim_set_current_win(sidebar_win) t.press("s") t.wait_for(function() - return #r.status:by_kind("staged") > 0 + return #r.status:rows("staged") > 0 end, "stage to propagate to repo state") t.eq( @@ -145,7 +145,7 @@ t.test( vim.api.nvim_set_current_win(sidebar_win) t.press("s") t.wait_for(function() - return #r.status:by_kind("staged") > 0 + return #r.status:rows("staged") > 0 end, "stage to propagate to repo state") t.eq(