-- pmacs-magit/status.lua --- Git status fetching + parsing (T M8.6). -- -- The magit-class layer that turns a Git repo into a section spec -- ready for the M8.5 fold module. Runs `git` via -- `pmacs.process.spawn`, parses stdout into structured sections, -- assembles the canonical 5-section layout (working tree, staged, -- recent commits, branches, stashes). -- -- This module is split out so the parsers are unit-testable without -- spawning git: each `parse_*` function takes a string and returns -- a Lua table describing the parsed result. `fetch_sections` -- orchestrates the four `git` invocations sequentially and assembles -- a section spec the caller hands to the fold module. -- -- Public surface: -- -- run_git(args, opts) -> { ok, stdout, stderr, exit_code } -- parse_porcelain_v2(text) -> { unstaged, staged, untracked, branch } -- parse_log(text) -> array of { hash, subject } -- parse_branches(text) -> { current, all = [...] } -- parse_stashes(text) -> array of { ref, subject } -- build_spec(parsed) -> section spec table -- fetch_sections(repo_root) -> section spec table (calls run_git x4) -- -- "Sequentially" rather than in parallel: in v0.1, four sequential -- git invocations on a typical repo total ~50-100ms. Polling cadence -- is 250 ms (see init.lua), so the worst-case latency to reflect an -- external change is ~350 ms --- under the M8.6 acceptance bullet's -- 500 ms budget. Parallel invocation is a v0.2 optimization that -- requires futures-style joining (see `V0.2-PREREQUISITES.md SP-2`). local M = {} -- --------------------------------------------------------------------------- -- run_git: spawn `git`, drain stdout/stderr, await exit -- --------------------------------------------------------------------------- -- -- Must be called inside a `pmacs.async` coroutine because it yields -- via `pmacs.workers.sleep(...):await()` between event polls. -- Returns a table with: -- ok boolean (exit_code == 0) -- exit_code integer -- stdout string (concatenated stdout bytes) -- stderr string (concatenated stderr bytes) -- -- The 10ms event-poll cadence is fast enough that git's output is -- captured in chunks of a few-hundred bytes per tick on typical -- repos. A more event-driven shape (an "events_wait" primitive that -- blocks the coroutine until events arrive) would be cleaner; this -- is what `V0.2-PREREQUISITES.md SP-2` (futures with `:await`) would -- enable. v0.1 polls. function M.run_git(args, opts) opts = opts or {} local id = pmacs.process.spawn { label = "git " .. (args[1] or ""), -- Worker identity Stage 1: `purpose` is required. The full argument -- vector, not just the subcommand the label carries -- "git log" and -- "git log --oneline -20" are the same label and different work. purpose = "git " .. table.concat(args, " "), command = "git", args = args, cwd = opts.cwd, } local stdout_chunks, stderr_chunks = {}, {} local exit_code = nil while exit_code == nil do for _, ev in ipairs(pmacs.process.events_take(id)) do if ev.kind == "stdout" then stdout_chunks[#stdout_chunks + 1] = ev.bytes elseif ev.kind == "stderr" then stderr_chunks[#stderr_chunks + 1] = ev.bytes elseif ev.kind == "exited" then exit_code = ev.code end -- "started" / "ansi" events are ignored: ansi=false on the -- spec means we only get raw bytes; "started" carries no info -- run_git needs. end if exit_code == nil then pmacs.workers.sleep(10):await() end end -- After the exit event we may still have a tail of stdout buffered. -- Drain it once more so partial last-line output isn't lost. for _, ev in ipairs(pmacs.process.events_take(id)) do if ev.kind == "stdout" then stdout_chunks[#stdout_chunks + 1] = ev.bytes elseif ev.kind == "stderr" then stderr_chunks[#stderr_chunks + 1] = ev.bytes end end pmacs.process.forget(id) return { ok = (exit_code == 0), exit_code = exit_code, stdout = table.concat(stdout_chunks), stderr = table.concat(stderr_chunks), } end -- --------------------------------------------------------------------------- -- Parsers (pure-Lua, no side effects) -- --------------------------------------------------------------------------- local function split_lines(text) local out = {} if text == nil or text == "" then return out end local i = 1 while i <= #text + 1 do local nl = text:find("\n", i, true) if nl then out[#out + 1] = text:sub(i, nl - 1) i = nl + 1 else if i <= #text then out[#out + 1] = text:sub(i) end break end end return out end -- Parse `git status --porcelain=v2 --branch` output. -- Reference: https://git-scm.com/docs/git-status#_porcelain_format_version_2 -- -- Returns: -- { -- branch = "main" or nil (from "# branch.head " header), -- staged = array of " " (X column non-space and non-?), -- unstaged = array of " " (Y column non-space and non-?), -- untracked = array of "", (lines starting with "?") -- } -- -- The status prefix is the porcelain-v2 XY pair as a single 2-char -- string. For renames/copies, the path field shows " -> ". function M.parse_porcelain_v2(text) local out = { branch = nil, staged = {}, unstaged = {}, untracked = {}, } for _, line in ipairs(split_lines(text)) do if line:sub(1, 1) == "#" then -- Header line. We only care about "# branch.head ". local head = line:match("^# branch%.head (.+)$") if head then out.branch = head end elseif line:sub(1, 1) == "?" then -- "? " --- untracked. local path = line:match("^%? (.+)$") if path then out.untracked[#out.untracked + 1] = path end elseif line:sub(1, 1) == "1" or line:sub(1, 1) == "2" then -- "1 XY ..." (ordinary) or "2 XY ..." (rename/copy). -- Field 2 is the XY pair, field 9 (ordinary) or 10 (rename) -- is the path. We split by space and take the first two -- fields plus the trailing path (everything after the 8th / -- 9th space). local kind = line:sub(1, 1) local xy = line:sub(3, 4) local path if kind == "1" then -- "1 XY sub mH mI mW hH hI path" --- 5 fields between sub -- and path (mH, mI, mW, hH, hI). path = line:match("^1 .. .... %S+ %S+ %S+ %S+ %S+ (.+)$") else -- kind == "2" -- "2 XY sub mH mI mW hH hI Xscore pathorig" --- 6 -- fields between sub and path (the previous 5 + Xscore). path = line:match("^2 .. .... %S+ %S+ %S+ %S+ %S+ %S+ (.+)$") end if path then local x = xy:sub(1, 1) local y = xy:sub(2, 2) if x ~= "." and x ~= " " then out.staged[#out.staged + 1] = x .. " " .. path end if y ~= "." and y ~= " " then out.unstaged[#out.unstaged + 1] = y .. " " .. path end end elseif line:sub(1, 1) == "u" then -- "u XY ..." --- unmerged. Treat as both staged and unstaged. local xy = line:sub(3, 4) local path = line:match("^u .. .... .+ (%S+)$") if path then out.staged[#out.staged + 1] = xy .. " " .. path end end end return out end -- Parse `git log --oneline -n N` output. -- Each line: " ". function M.parse_log(text) local out = {} for _, line in ipairs(split_lines(text)) do local hash, subject = line:match("^(%S+) (.*)$") if hash then out[#out + 1] = { hash = hash, subject = subject } end end return out end -- Parse `git branch --list` output. -- Lines: "* " or " ". function M.parse_branches(text) local out = { current = nil, all = {} } for _, line in ipairs(split_lines(text)) do local current = line:match("^%* (.+)$") if current then out.current = current out.all[#out.all + 1] = current else local name = line:match("^ (.+)$") if name then out.all[#out.all + 1] = name end end end return out end -- Parse `git stash list` output. -- Lines: "stash@{N}: : ". function M.parse_stashes(text) local out = {} for _, line in ipairs(split_lines(text)) do local ref, subject = line:match("^(stash@{%d+}): (.*)$") if ref then out[#out + 1] = { ref = ref, subject = subject } end end return out end -- --------------------------------------------------------------------------- -- Section-spec assembly -- --------------------------------------------------------------------------- local function placeholder_or_join(items, format_fn, empty_message) if #items == 0 then return empty_message end local lines = {} for i, item in ipairs(items) do lines[i] = format_fn(item) end return table.concat(lines, "\n") end -- Build a section spec from parsed git output. The spec follows the -- M8.6 canonical 5-section layout: working tree, staged, recent -- commits, branches, stashes. Section IDs are stable across -- refreshes so fold-state survives. -- -- `parsed` is a table with fields: -- status = parse_porcelain_v2 result -- log = parse_log result -- branches = parse_branches result -- stashes = parse_stashes result -- -- Empty sections render as a one-line placeholder body -- ("(none)" / "(no stashes)" / etc.) per the M8.6 acceptance -- bullet "Empty sections render as a one-line placeholder rather -- than disappearing." function M.build_spec(parsed) local status = parsed.status or { staged = {}, unstaged = {}, untracked = {} } local log = parsed.log or {} local branches = parsed.branches or { current = nil, all = {} } local stashes = parsed.stashes or {} -- "Working tree" combines unstaged-modified and untracked, mirroring -- magit's grouping. (Magit's actual UX has them as separate sections -- but the M8.6 spec lists "working-tree changes" as a single -- section; we follow the spec.) local working_items = {} for _, e in ipairs(status.unstaged) do working_items[#working_items + 1] = e end for _, p in ipairs(status.untracked) do working_items[#working_items + 1] = "?? " .. p end local staged_count = #status.staged local working_count = #working_items local commits_count = #log local branches_count = #branches.all local stashes_count = #stashes return { { id = "working", title = "Working tree changes (" .. working_count .. ")", body = placeholder_or_join( working_items, function(item) return item end, "(no working-tree changes)" ), }, { id = "staged", title = "Staged changes (" .. staged_count .. ")", body = placeholder_or_join( status.staged, function(item) return item end, "(nothing staged)" ), }, { id = "log", title = "Recent commits (" .. commits_count .. ")", body = placeholder_or_join( log, function(c) return c.hash .. " " .. c.subject end, "(no commits yet)" ), }, { id = "branches", title = "Branches (" .. branches_count .. ")", body = placeholder_or_join( branches.all, function(name) if name == branches.current then return "* " .. name end return " " .. name end, "(no branches)" ), }, { id = "stashes", title = "Stashes (" .. stashes_count .. ")", body = placeholder_or_join( stashes, function(s) return s.ref .. ": " .. s.subject end, "(no stashes)" ), }, } end -- --------------------------------------------------------------------------- -- fetch_sections: orchestrate the 4 git invocations -- --------------------------------------------------------------------------- -- -- Sequential: status, log, branches, stashes. Each runs to -- completion before the next starts. On a typical repo, this totals -- well under 100 ms; on a pathologically large repo (tens of -- thousands of dirty files), the working-tree section may dominate -- but that's a `git status` problem, not a pmacs-magit problem. -- -- A non-zero exit on any single invocation collapses the -- corresponding section to its placeholder; we don't fail the whole -- refresh. This means a transient git error (lockfile contention, -- repo corruption mid-refresh) shows up as a reduced display, not a -- crashed package. Stderr from failed invocations is captured but -- not surfaced to the visible buffer in v0.1; future revisions -- might add an "errors" section. -- -- Must be called inside `pmacs.async`. function M.fetch_sections(repo_root) local opts = { cwd = repo_root } local function safe_parse(parser, result) if not result.ok then return nil end return parser(result.stdout) end local status_res = M.run_git({ "status", "--porcelain=v2", "--branch" }, opts) local log_res = M.run_git({ "log", "--oneline", "-n", "10" }, opts) local branches_res = M.run_git({ "branch", "--list" }, opts) local stashes_res = M.run_git({ "stash", "list" }, opts) local parsed = { status = safe_parse(M.parse_porcelain_v2, status_res), log = safe_parse(M.parse_log, log_res), branches = safe_parse(M.parse_branches, branches_res), stashes = safe_parse(M.parse_stashes, stashes_res), } return { spec = M.build_spec(parsed), parsed = parsed, } end return M