-- git.lua --- Git integration Stage 1: read-only status and diff. -- Framing: docs/git-integration-framing.md (revision 5). -- -- Two surfaces and nothing else: -- -- *git-status* a `pmacs.listview` panel over -- `git --no-optional-locks -C status -- --porcelain=v2 --branch -z`. RET visits the file, -- `d` shows its diff, `g` refreshes. -- *git-diff* the diff for the FILE under point, in a generated -- buffer rendered as plain text. There is no bundled -- `diff` grammar (checked: `BUILTIN_LANGUAGES` in -- src/syntax.rs has no entry), and there is no hunk -- model anywhere in the tree --- hunks are what gutter -- markers need, and that is Stage 2's protocol work. -- -- NO WIRE CHANGE. Stage 2 (gutter markers) needs new `DecorationKind` -- variants and a `PROTOCOL_VERSION` bump, and must be scheduled alone. -- -- Three facts this module is built on, each measured rather than -- reasoned about: -- -- * `ProjectKind::Git` means a BARE repository ("no language marker -- found inside", src/project.rs), and a language marker beside -- `.git` WINS --- so pmacs reports `kind = "rust"` for its own -- repository. This module therefore never asks pmacs whether -- something is a git repo: it runs `git -C rev-parse -- --show-toplevel` and lets a non-zero exit be the answer. Git's own -- resolution handles submodules, worktrees, `GIT_DIR` and `.git` -- files; a marker walk reimplements a subset and gets it wrong. -- * `git diff --no-index` implies `--exit-code`: it exits 1 when it -- SUCCESSFULLY finds differences. For the untracked path the success -- predicate is therefore exit in {0, 1}; only >= 2 is a failure. -- That asymmetry is confined to `--no-index`. -- * An unborn `HEAD` makes `git diff HEAD` exit 128 with -- `fatal: bad revision 'HEAD'`. It is detected from -- `# branch.oid (initial)` in the `--branch` output this module -- already parses --- NOT from a second `rev-parse` process for a -- fact the first one hands over. -- -- Background-work attribution is a NEGATIVE (COHERENCE.md §9): a -- spawned process does not appear in `*workers*` at all --- that buffer -- is `async.lua`'s job list, while processes live under -- `pmacs.process.list`. Every spawn here is labelled, which is strictly -- better than an anonymous `git`, but a label is not attribution and -- this module does not pretend otherwise. Accepted because these are -- short-lived reads. pmacs.git = pmacs.git or {} local STATUS_PANEL = "*git-status*" local DIFF_BUFFER = "*git-diff*" --- The git program name. --- --- A module-local rather than a setting: Q#G-4 defines exactly one --- setting and says to resist more until there is use evidence. It is --- reachable from Lua because the missing-binary path (Q#G-2's named --- risk) has to be WITNESSED, and there is no other way to reach it in --- process: Rust's `Command` resolves the program against the PARENT --- process's `PATH`, so a child `env` cannot hide git, and --- `std::env::set_var` is `unsafe` in edition 2024, which this project --- forbids. Pointing this at a name that is not on `PATH` produces --- exactly the ENOENT a missing git produces. pmacs.git._program = "git" --- The most recent spawn's `{ command, args, cwd, label }`. --- --- Public because the `--no-optional-locks` contract is witnessed --- STRUCTURALLY: a lock that was not taken cannot be observed directly, --- so the assembled invocation is what gets pinned (Q#G-6). pmacs.git._last_spawn = nil --- The last few spawns' argv, oldest first. --- --- A bounded diagnostic ring rather than an unbounded log. It exists --- because one of this module's contracts is about a process that must --- NOT be spawned: unborn `HEAD` is detected from `# branch.oid --- (initial)` in output already in hand, and nobody should later --- reintroduce a `rev-parse --verify HEAD` for it. "Which processes did --- that open run?" is not answerable from `_last_spawn` alone. pmacs.git._spawn_log = {} local SPAWN_LOG_LIMIT = 16 -- --------------------------------------------------------------------- -- Configuration (Q#G-4) -- --------------------------------------------------------------------- pmacs.config.define { name = "git.enabled", description = "Whether the Git commands (*git-status*, *git-diff*) run git at all. Turning this off makes them report that they are disabled rather than spawning anything.", type = "boolean", default = true, mutability = "live", } local function git_enabled() local ok, value = pcall(pmacs.config.get, "git.enabled") if not ok then return true end return value ~= false end -- --------------------------------------------------------------------- -- Text safety at the binding boundary (Q#G-8) -- --------------------------------------------------------------------- -- -- Git hands back PATH BYTES. `pmacs.process.spawn` takes -- `args: Vec` and `pmacs.buffer.find_or_open` takes -- `path: String` --- both Rust `String`, i.e. UTF-8 by construction --- -- and the rope is UTF-8 by project invariant, so a path that is valid -- bytes but not valid UTF-8 can be READ and DISPLAYED and cannot be -- passed back for a diff, nor opened. The honest boundary is: parse it, -- show it (escaped, since the raw bytes cannot enter a rope), and -- REFUSE the gesture with a message. -- Length of the valid UTF-8 sequence starting at byte `i`, or nil. -- Rejects overlongs, surrogates and anything past U+10FFFF, so -- "displayable" means the same thing here as it does to Rust. local function utf8_seq_len(s, i) local b1 = s:byte(i) if not b1 then return nil end if b1 < 0x80 then return 1 end local n, cp if b1 >= 0xC2 and b1 <= 0xDF then n, cp = 2, b1 - 0xC0 elseif b1 >= 0xE0 and b1 <= 0xEF then n, cp = 3, b1 - 0xE0 elseif b1 >= 0xF0 and b1 <= 0xF4 then n, cp = 4, b1 - 0xF0 else return nil end if i + n - 1 > #s then return nil end for k = 1, n - 1 do local b = s:byte(i + k) if b < 0x80 or b > 0xBF then return nil end cp = cp * 64 + (b - 0x80) end if n == 3 and cp < 0x800 then return nil end if n == 4 and cp < 0x10000 then return nil end if cp >= 0xD800 and cp <= 0xDFFF then return nil end if cp > 0x10FFFF then return nil end return n end --- True when `s` is valid UTF-8, i.e. when it can cross the binding --- boundary at all. function pmacs.git.is_text(s) if type(s) ~= "string" then return false end local i, n = 1, #s while i <= n do local len = utf8_seq_len(s, i) if not len then return false end i = i + len end return true end --- `s` rendered for a ONE-LINE panel row: invalid UTF-8 bytes and every --- control byte become `\xNN`. --- --- Escaping controls is not cosmetic. A path may contain a newline --- --- that is exactly what `-z` buys and what a quoted parser gets wrong --- --- and a raw newline in a row would split it across two lines and --- desynchronize every line-to-row mapping in the panel. function pmacs.git.display_path(s) if type(s) ~= "string" then return "" end local out = {} local i, n = 1, #s while i <= n do local len = utf8_seq_len(s, i) local b = s:byte(i) if len and not (len == 1 and (b < 0x20 or b == 0x7F)) then out[#out + 1] = s:sub(i, i + len - 1) i = i + len else out[#out + 1] = string.format("\\x%02X", b) i = i + 1 end end return table.concat(out) end --- `s` with invalid UTF-8 bytes replaced by U+FFFD, controls left --- alone. For MULTI-LINE bodies (a patch), where newlines and tabs are --- content rather than a hazard. local function utf8_clean(s) if pmacs.git.is_text(s) then return s end local out = {} local i, n = 1, #s while i <= n do local len = utf8_seq_len(s, i) if len then out[#out + 1] = s:sub(i, i + len - 1) i = i + len else out[#out + 1] = "\239\191\189" -- U+FFFD i = i + 1 end end return table.concat(out) end -- --------------------------------------------------------------------- -- Running git (Q#G-2) -- --------------------------------------------------------------------- --- The argv for a git invocation rooted at `root`. --- --- Every call site goes through here, so `--no-optional-locks` cannot --- be dropped by one of them. The flag is part of the contract, not a --- nicety (Q#G-6): `git status` is not strictly read-only --- it may --- refresh and write the index --- and this module runs it --- asynchronously from an editor while the user may be running git in a --- terminal, which is the exact scenario the flag exists for. function pmacs.git.argv(root, rest) local args = { "--no-optional-locks" } if root then args[#args + 1] = "-C" args[#args + 1] = root end for _, a in ipairs(rest) do args[#args + 1] = a end return args end -- proc raw id -> { procid, out, err, on_done } local pump = {} -- Spawn git and call `on_done { ok, code, kind, stdout, stderr }` once -- it terminates. A spawn failure calls `on_done` too, with -- `spawn_error` set --- §1.2's silence asymmetry: the failure must be -- surfaced with guidance, never swallowed. local function run_git(label, purpose, root, rest, on_done) local spec = { label = label, -- Required since worker identity Stage 1 (#232), and deliberately -- NOT the label. `label` identifies the process --- "git status" --- -- while this says what the run is FOR, which is what someone reading -- `*workers*` or the statusline activity indicator needs: three of -- this module's spawns are all "git", and only the purpose tells -- them apart. Each call site writes its own; copying the label -- across is the failure that ruling was made against. purpose = purpose, command = pmacs.git._program, args = pmacs.git.argv(root, rest), stdin = "null", } if root then spec.cwd = root end pmacs.git._last_spawn = { command = spec.command, args = spec.args, cwd = spec.cwd, label = spec.label, purpose = spec.purpose, } local log = pmacs.git._spawn_log log[#log + 1] = spec.args while #log > SPAWN_LOG_LIMIT do table.remove(log, 1) end local ok, proc = pcall(pmacs.process.spawn, spec) if not ok then on_done { ok = false, kind = "spawn_failed", spawn_error = tostring(proc), stdout = "", stderr = "", } return nil end pump[proc:raw()] = { procid = proc, out = {}, err = {}, on_done = on_done } return proc end pmacs.hook.add("process.after-tick", function() for raw, entry in pairs(pump) do for _, ev in ipairs(pmacs.process.events_take(entry.procid)) do local kind = ev.kind if kind == "stdout" then entry.out[#entry.out + 1] = ev.bytes elseif kind == "stderr" then entry.err[#entry.err + 1] = ev.bytes elseif kind == "exited" or kind == "signaled" or kind == "crashed" then -- The supervisor drains all remaining output BEFORE pushing the -- terminal event (`final_drain_runtime`, src/process.rs), so one -- pass in event order captures everything. pump[raw] = nil pcall(pmacs.process.forget, entry.procid) local result = { ok = (kind == "exited"), code = (kind == "exited") and (ev.code or 0) or nil, kind = kind, signal = ev.signal, error = ev.error, stdout = table.concat(entry.out), stderr = table.concat(entry.err), } local called, err = pcall(entry.on_done, result) if not called then pmacs.editor.set_status("git: " .. tostring(err)) end end end end end) --- The first line of `text`, trimmed, or `""`. --- --- For text bound for the ONE-LINE STATUS BAND, and only for that: a --- spawn error, a stderr detail, an error string. A status message that --- carried a newline would corrupt the row layout of whatever is --- rendering it, so truncating to the first line is the right answer --- there. local function first_line(text) local line = (text or ""):match("^[^\r\n]*") or "" return (line:gsub("%s+$", "")) end --- `text` with git's final output terminator removed, and NOTHING else. --- --- The counterpart to `first_line`, deliberately a SECOND function --- rather than a change to it, because the two answer opposite --- questions and each has callers that the other's answer would break. --- This one is for COMMAND OUTPUT that must survive whole: a POSIX path --- may legally contain a newline, so `git rev-parse --show-toplevel` --- prints one for a repository rooted at `/tmp/a\nb`, and taking the --- first line there truncates the root to `/tmp/a` --- after which every --- command this module runs has a wrong or nonexistent cwd. `first_line` --- has three other callers, all of them status-band text, and folding --- the two together would fix this one and break those. --- --- Exactly ONE trailing `\n` is stripped, and NOTHING may ride along --- with it --- not a carriage return, not a second newline. A carriage --- return is as legal a POSIX path byte as a newline is, so a repository --- rooted at `/tmp/a\r` makes git print `/tmp/a` `0d` `0a`: the path's --- own CR, then git's LF terminator. A strip tolerant of `\r?\n$` cannot --- tell those two bytes apart and takes both, resolving the root as --- `/tmp/a` --- the same defect this function was written to fix, one --- byte over. A second newline is output, not a terminator, for the same --- reason. --- --- There is no unambiguous output representation to prefer instead, --- which was CHECKED against git 2.55 rather than assumed: `-z` is not --- an option of `git rev-parse` at all. It is absent from the manual, --- `--parseopt -z` errors with "unknown switch", and in ordinary mode --- `rev-parse` treats `-z` as an unrecognized FLAG ARGUMENT and echoes a --- literal `-z\n` onto stdout AHEAD of the toplevel --- so asking for it --- would corrupt the very output it was meant to disambiguate, silently --- and with exit code 0. `--show-toplevel` applies no C quoting either, --- not even under `core.quotePath=true`. Removing the one byte git --- appended is therefore the whole of the correct answer. --- --- Written as an explicit last-byte test rather than a pattern: an --- anchored Lua pattern is where both of this function's bugs lived. local function strip_output_terminator(text) text = text or "" if text:sub(-1) == "\n" then return text:sub(1, -2) end return text end --- A one-line description of why a git invocation failed. local function failure_reason(res) if res.spawn_error then return string.format( "cannot run %q (%s) --- is git installed and on PATH?", pmacs.git._program, first_line(res.spawn_error)) end local detail = first_line(utf8_clean(res.stderr)) if res.kind == "signaled" then return string.format("git was killed by %s%s", res.signal or "a signal", detail ~= "" and (": " .. detail) or "") end if res.kind == "crashed" then return string.format("git crashed: %s", res.error or detail) end return string.format("git exited with code %d%s", res.code or -1, detail ~= "" and (": " .. detail) or "") end -- --------------------------------------------------------------------- -- Porcelain v2 parsing (Q#G-6) -- --------------------------------------------------------------------- -- -- The SEPARATION here --- pure `parse_*` functions that take a string -- and return structure, testable with no repository --- is ported from -- `tests/fixtures/pmacs-magit/status.lua`, whose 32-test suite proves -- the shape works. The record TOKENIZER is deliberately NOT ported: the -- fixture reads newline-delimited v2 and this reads `-z`, and those are -- different grammars. Under `-z` a record's fields are NUL-terminated, -- so a rename carries its two paths as SEPARATE fields rather than -- tab-joined inside one, and C quoting is removed from the problem -- entirely rather than obliging a hand-written unquoter. -- -- The fixture is left untouched (Q#G-0): its purpose is to prove the -- PACKAGE SYSTEM can host this, and bundled code becoming its -- dependency would make `tests/m8_6_acceptance.rs` test less than it -- claims. The duplication is deliberate and stated rather than quiet. -- Split NUL-terminated fields. A trailing empty fragment after the last -- NUL is dropped; an empty payload yields no fields. local function nul_fields(text) local out = {} if type(text) ~= "string" or text == "" then return out end local i = 1 while true do local nul = text:find("\0", i, true) if not nul then if i <= #text then out[#out + 1] = text:sub(i) end break end out[#out + 1] = text:sub(i, nul - 1) i = nul + 1 end return out end --- Parse `git status --porcelain=v2 --branch -z` output. --- --- Returns `{ branch = {...}, rows = {...} }` where each row is --- --- { kind = "ordinary"|"rename"|"unmerged"|"untracked"|"ignored", --- xy, x, y, path, orig, score } --- --- `path` is the CURRENT path --- what the panel shows and what RET --- visits --- and `orig` remembers where a rename or copy came from. --- Both are raw bytes; nothing here assumes they are text. --- --- **`kind = "rename"` covers copies too, deliberately.** A `2` record --- is porcelain-v2's ONE two-path record and every behaviour keyed on --- it is the same for both classes --- notably the two-path --- `git diff HEAD -- `. What differs is only what the --- user is TOLD, and that fact is already carried: `score` leads with --- `R` or `C` (and `xy` carries the same letter on whichever side --- detected it). See `is_copy` for where the two are told apart. function pmacs.git.parse_status(text) local branch = { unborn = false } local rows = {} local fields = nul_fields(text) local i = 1 while i <= #fields do local field = fields[i] local tag = field:sub(1, 1) if tag == "#" then local key, value = field:match("^# (%S+) (.*)$") if key == "branch.oid" then branch.oid = value -- Unborn HEAD, from the output already being parsed. A second -- `rev-parse --verify HEAD` would be a whole extra process for -- a fact this line hands over. branch.unborn = (value == "(initial)") elseif key == "branch.head" then branch.head = value elseif key == "branch.upstream" then branch.upstream = value elseif key == "branch.ab" then branch.ahead = tonumber(value:match("^%+(%d+)") or "") branch.behind = tonumber(value:match("%-(%d+)$") or "") end elseif tag == "1" then -- 1 local xy, path = field:match("^1 (%S%S) %S+ %S+ %S+ %S+ %S+ %S+ (.+)$") if xy then rows[#rows + 1] = { kind = "ordinary", xy = xy, x = xy:sub(1, 1), y = xy:sub(2, 2), path = path, } end elseif tag == "2" then -- 2 \0 -- -- The origin is the NEXT field, not a tab-joined suffix. That is -- the whole reason this tokenizer is not the fixture's. local xy, score, path = field:match("^2 (%S%S) %S+ %S+ %S+ %S+ %S+ %S+ (%S+) (.+)$") if xy then i = i + 1 rows[#rows + 1] = { kind = "rename", xy = xy, x = xy:sub(1, 1), y = xy:sub(2, 2), path = path, orig = fields[i], score = score, } end elseif tag == "u" then -- u

local xy, path = field:match("^u (%S%S) %S+ %S+ %S+ %S+ %S+ %S+ %S+ %S+ (.+)$") if xy then rows[#rows + 1] = { kind = "unmerged", xy = xy, x = xy:sub(1, 1), y = xy:sub(2, 2), path = path, } end elseif tag == "?" then local path = field:match("^%? (.+)$") if path then rows[#rows + 1] = { kind = "untracked", xy = "??", x = "?", y = "?", path = path } end elseif tag == "!" then local path = field:match("^! (.+)$") if path then rows[#rows + 1] = { kind = "ignored", xy = "!!", x = "!", y = "!", path = path } end end i = i + 1 end return { branch = branch, rows = rows } end -- --------------------------------------------------------------------- -- Request channels: "the newest INVOCATION wins" -- --------------------------------------------------------------------- -- -- EVERY defect review found on this module was one shape: module-level -- mutable state read or written at CONTINUATION time without an -- invocation-time ticket. So the rule gets one implementation instead of -- a bespoke counter per call site. -- -- A channel hands out a ticket when the user ASKS for something and -- answers "is this still the request in force?" when a subprocess -- finally replies. A continuation holding a stale ticket must discard -- BEFORE ANY EFFECT --- no spawn, no shared-state write, and no status -- message either, since a message from a replaced invocation is as wrong -- as a panel from one. -- -- There are TWO channels, and that is deliberate rather than an -- oversight: a single module-wide counter would make pressing `d` cancel -- an in-flight `g`, and vice versa. The status panel and the diff view -- are independent things a user can ask for, so each gets its own "newest -- wins" ordering. What is shared is the MECHANISM, not the counter. -- -- A channel spans a whole request, not one process: a status open is -- `rev-parse` then `status`, and a diff is one or two `git diff` runs. -- Every stage of one request carries the ticket reserved at the command. local function new_channel() local ch = { current = 0 } --- Claim the newest ticket, and return it. Called at the point a user --- ASKS for something, never at the point some subprocess answers --- --- minting on arrival makes the SLOWEST subprocess win instead of the --- newest invocation, which is exactly the bug this exists to stop. function ch.reserve() ch.current = ch.current + 1 return ch.current end --- True while `ticket` is still the request in force. function ch.is_current(ticket) return ticket == ch.current end return ch end local status_requests = new_channel() local diff_requests = new_channel() -- --------------------------------------------------------------------- -- Panel state -- --------------------------------------------------------------------- -- `display` maps a panel DATA LINE (1-based; the header is line 0) to -- the git row rendered there. It is this module's own copy because -- listview's line map is private, and `d` needs the row under the -- cursor. `rows` is the same information as an array, for re-seating. local state = { root = nil, branch = nil, rows = {}, display = {}, buffer = nil, diff_buffer = nil, failure = nil, } -- A copy and a rename are already TOLD APART here, and by the field -- that tells every other row class apart: the `XY` prefix reads `R.` -- for one and `C.` for the other, out of the same byte `score` leads -- with. So this renders both the same way on purpose --- `<-` reads -- "came from", which is true of a copy --- rather than growing a second -- vocabulary beside the porcelain codes the whole panel is built on. local function status_line_text(row) local shown = pmacs.git.display_path(row.path) if row.orig then shown = string.format("%s <- %s", shown, pmacs.git.display_path(row.orig)) end return string.format("%s %s", row.xy, shown) end local function status_header() -- A failed run has no branch and no rows, and rendering "0 changes" -- above a failure row would be a small lie in the one place the panel -- most needs to be honest. if state.failure then return "git: status failed g retry q quit" end local branch = state.branch or {} local where if branch.unborn then where = string.format("%s (no commits yet)", branch.head or "HEAD") elseif branch.head == "(detached)" or branch.head == nil then where = string.format("detached at %s", (branch.oid or "?"):sub(1, 8)) else where = branch.head end local n = #state.rows return string.format( "git: %s --- %d change%s RET visit d diff n/p move g refresh q quit", where, n, n == 1 and "" or "s") end -- Build the listview rows and (re)build `state.display` alongside them, -- so the two can never drift. local function listview_rows(extra_text) state.failure = nil state.display = {} local out = {} for _, row in ipairs(state.rows) do out[#out + 1] = { text = status_line_text(row), item = row } state.display[#out] = row end if extra_text then out[#out + 1] = { text = extra_text } end return out end -- Row-level failure (Q#G-1 item 4): a failure is a ROW, not a silence. local function failure_rows(reason) state.failure = reason state.rows = {} state.display = {} return { { text = "! " .. reason } } end -- --------------------------------------------------------------------- -- Visiting (RET) -- --------------------------------------------------------------------- local function join_root(path) if path:sub(1, 1) == "/" then return path end return (state.root or ".") .. "/" .. path end local function refuse_unrepresentable(row) pmacs.editor.set_status(string.format( "git: %s is not valid UTF-8, so pmacs cannot open or diff it", pmacs.git.display_path(row.path))) end local function visit_row(row) if type(row) ~= "table" or not row.path then return end if not pmacs.git.is_text(row.path) then refuse_unrepresentable(row) return end local target = join_root(row.path) pmacs.editor.push_jump() -- A visit FROM a panel lands in the DOCUMENT target and leaves the -- panel where it is (Q#BP11b) --- `display_file`, never the raw -- switch, which would clobber the panel with the source. local ok, err = pcall(pmacs.window.display_file, target, { select = true }) if not ok then pmacs.editor.jump_back() pmacs.editor.set_status(string.format("git: cannot open %s: %s", pmacs.git.display_path(row.path), first_line(tostring(err)))) end end -- --------------------------------------------------------------------- -- The status refresh (Q#G-1) -- --------------------------------------------------------------------- local function open_status_panel(rows) pmacs.listview.open { name = STATUS_PANEL, header = status_header(), rows = rows, -- `d` is not on listview's key surface (RET SPC n p TAB -- g q), and it cannot be bound from outside the primitive safely: -- a name collision disambiguates to `<2>`, so the name passed here -- is not necessarily the buffer that came back. The `keys` table -- binds it through the primitive's own buffer-local path, so no key -- is intercepted and COHERENCE.md §6 stays at six shadows. keys = { d = "git.diff-file" }, on_visit = visit_row, on_refresh = function() return pmacs.git._on_refresh() end, } -- `listview.open` takes `select = true`, so the panel is the active -- buffer here. Captured rather than looked up by name, because the -- name may have been disambiguated. state.buffer = pmacs.window.buffer() end -- --------------------------------------------------------------------- -- The destination boundary (Q#JR14, Q#DC-1) -- --------------------------------------------------------------------- -- -- Every continuation in this module renders a tick or more after the -- keypress that asked for it, and until #231 there was nothing it could -- render *to* except ambient state: whichever frontend happened to be -- active when git exited. Run `M-x git.status` in frontend A, let B -- become active while `git status` runs, and A's panel opened in B. -- -- So the destination is CAPTURED AT INVOCATION and threaded through, -- exactly as this module already threads the generation, the root and -- the row --- and for the same reason. `state.root` has a comment -- explaining why reading module-level state per step is wrong; the -- ambient frontend is that same argument one layer down, and it was the -- one thing still being read late. --- Run `body` against the destination captured at invocation. --- --- Returns false when the commit is REFUSED --- the captured window or --- buffer is gone, or the frontend can no longer satisfy `profile`. --- A refusal drops the render, which is the same answer the --- `expect_buffer` rule already gives when the panel a refresh belongs --- to has been killed: a result whose destination no longer exists is --- not a result to force somewhere else. `commit_to` refuses BEFORE the --- body runs, so a refusal is mutation-free and there is no partial --- render to undo. --- --- Deliberately not `pcall`-wrapped. A refusal returns `(false, reason)` --- and is handled here; anything that RAISES is a defect in this module --- (a fabricated destination, an unknown profile) and must not be --- swallowed into a silent no-op. local function commit_ui(dest, profile, body) local ok, reason = pmacs.window.commit_to(dest, body, profile) if ok == false then return false, reason end return true end --- The status-refresh ticket currently in force. --- --- Exposed alongside `_deliver_status` below, and for the same reason: --- the discard rule is about a completion arriving LATE, and a caller --- cannot construct a stale request without knowing what "current" --- means. function pmacs.git._generation() return status_requests.current end --- The diff ticket currently in force. `_generation`'s counterpart, on --- the other channel; see `new_channel` for why they are two. function pmacs.git._diff_generation() return diff_requests.current end --- Deliver a completed `git status`. --- --- Exposed because concurrent refresh is asserted by DRIVING two --- refreshes and completing them out of order, which no arrangement of --- real subprocess timing can guarantee. function pmacs.git._deliver_status(request, res) -- Generation (Q#G-1 item 3): a second `g` while one is in flight -- bumps the ticket, and the older completion DISCARDS its rows rather -- than racing. It does not terminate the first process --- reaping is -- `process.forget`'s job and killing git mid-read buys nothing. if not status_requests.is_current(request.generation) then return end -- Panel lifetime (Q#G-1 item 5): if the buffer this refresh belongs -- to is gone, drop the result. A FIRST open carries no expectation. if request.expect_buffer ~= nil then local live, valid = pcall(request.expect_buffer.is_valid, request.expect_buffer) if not (live and valid) then return end end -- Rows and the status line are COMPUTED here and EMITTED inside the -- commit below. Splitting them is the point: `set_status` is a UI -- mutation, and a failure message announcing a panel that the commit -- then refuses to open is the misrouting this boundary exists to -- prevent, in its most confusing form. local rows, message if res.ok and res.code == 0 then local parsed = pmacs.git.parse_status(res.stdout) state.branch = parsed.branch state.rows = parsed.rows rows = listview_rows(nil) else local reason = failure_reason(res) state.branch = state.branch or {} rows = failure_rows(reason) message = "git status: " .. reason end -- The PANEL profile: `*git-status*` is a bottom-panel surface -- (`listview.open` defaults `display` to `"panel"`), so the -- stale-intent checks that guard replacing a document window do not -- apply --- but only while the placement really is a panel, which is -- what the profile's own refusal keeps true for the extent of this -- body (Q#DC-2). commit_ui(request.dest, "panel", function() if message then pmacs.editor.set_status(message) end open_status_panel(rows) -- `listview.open` resets collapse and does NOT preserve selection --- -- only `listview.refresh` does, and that is the synchronous path this -- model cannot use. So re-seating is owned here. -- -- And `open`'s own `seat_cursor(p, 1)` cannot be relied on either: it -- walks DOWN from wherever the cursor is, on the premise that a fresh -- `switch_active_buffer` zeroed it. Re-opening a panel that is -- already displayed does not zero anything, so that walk would land -- one row below the previous cursor instead of on row 1. The handler -- therefore seats unconditionally, from line 0. local target = 1 if request.selected_path then for i, row in ipairs(state.rows) do if row.path == request.selected_path then target = i break end end end -- If the captured path is gone --- the commonest case, since a file -- that stopped being modified drops out of status --- `target` stays -- 1 and nothing is said about it. That is the correct answer, not a -- failure. pmacs.editor.clear_selection() pmacs.editor.set_view_top(0) pmacs.editor.move_to_line(0) -- Walked with `move_down` rather than a single `move_to_line(target)` -- because motion is what drags the viewport along; a bare cursor set -- would leave a long status list scrolled to the top with the cursor -- off screen. This is `listview.refresh`'s own idiom. for _ = 1, target do pmacs.editor.move_down() end end) end -- The path of the row under the cursor right now, or nil. local function selected_path() local row = state.display[pmacs.editor.cursor_line()] return row and row.path or nil end --- Spawn `git status` under an ALREADY-RESERVED generation. --- --- The generation is a parameter rather than something minted here, --- because this runs from the root-resolution callback: two `git.status` --- invocations against different repositories resolve their roots --- concurrently, and if the generation were minted on arrival then the --- invocation whose `rev-parse` returned LAST would claim the newest --- generation and replace the newer request. Reserving at the command --- and carrying it through is what makes the ordering the user's, not --- the filesystem's. local function start_status(root, expect_buffer, want_selection, generation, dest) local request = { generation = generation, expect_buffer = expect_buffer, selected_path = want_selection and selected_path() or nil, -- A parameter for the same reason `generation` is: it belongs to -- the invocation, and this function can run from a root-resolution -- callback that is already a tick removed from it. dest = dest, } run_git("git status", "reading the working tree status of " .. root .. " for the *git-status* panel", root, { "status", "--porcelain=v2", "--branch", "-z" }, function(res) pmacs.git._deliver_status(request, res) end) end --- `g` inside the panel. --- --- `on_refresh` stays SYNCHRONOUS and honest: it returns the current --- rows immediately with a marker appended and KICKS OFF the spawn, so --- `g` always re-renders and always shows that work started. A `g` that --- silently does nothing is a defect this primitive already names. function pmacs.git._on_refresh() if not git_enabled() then return listview_rows("(git.enabled is false --- nothing was run)") end if not state.root then return listview_rows("(no repository --- run M-x git.status)") end -- Reserved HERE, at the keypress, for the same reason `git.status` -- reserves at the command: `g` needs no root lookup, so this is -- already the moment of invocation. The destination is captured on -- the same line of reasoning --- `g` is pressed IN the panel, so the -- frontend showing it is the one this refresh belongs to, and that is -- true now and possibly not true when git exits. start_status(state.root, state.buffer, true, status_requests.reserve(), pmacs.window.capture_destination()) return listview_rows("(refreshing...)") end -- --------------------------------------------------------------------- -- Entry point -- --------------------------------------------------------------------- local function directory_of(path) local dir = path:match("^(.*)/[^/]*$") if dir == nil or dir == "" then return "/" end return dir end --- The directory a status run should resolve its repository from. --- --- The ACTIVE FILE's directory, falling back to the daemon's working --- directory when the active buffer is pathless (a dired listing, the --- scratch buffer). Deliberately not a project-marker walk: git resolves --- its own worktree, and `ProjectKind` cannot answer the question at all. local function active_directory() local buf = pmacs.window.buffer() if buf then local ok, path = pcall(function() return buf:path() end) if ok and type(path) == "string" and path ~= "" then return directory_of(path) end end local ok, id = pcall(pmacs.instance.identity) if ok and type(id) == "table" and type(id.working_directory) == "string" then return id.working_directory end return nil end --- Deliver a completed root lookup for the invocation in `request`. --- --- Exposed for the same reason `_deliver_status` is: the contract is --- about completions arriving in an order the CALLER did not choose, and --- no arrangement of real subprocess timing can guarantee that two --- `rev-parse` runs finish in a chosen order. function pmacs.git._deliver_root(request, res) -- A root lookup that returns after a newer invocation has superseded -- it must not proceed: not to a status spawn, not to `state.root`, and -- not even to a status-line message. Everything below this line is an -- effect belonging to an invocation the user has already replaced. if not status_requests.is_current(request.generation) then return end if not (res.ok and res.code == 0) then if res.spawn_error then pmacs.editor.set_status("git: " .. failure_reason(res)) else pmacs.editor.set_status( string.format("git: %s is not inside a repository", request.dir)) end return end -- The WHOLE output, minus its terminator --- never the first line, and -- never a byte more than git appended. A repository root may contain a -- newline and may END in a carriage return, and losing either here -- would point every command that follows at a directory that does not -- exist. local root = strip_output_terminator(res.stdout) if root == "" then pmacs.editor.set_status("git: rev-parse returned no worktree root") return end state.root = root -- A fresh open carries no buffer expectation, so a panel the user -- killed earlier does not make this run drop its own first result. state.buffer = nil -- The generation reserved at the command, NOT a fresh one --- and the -- destination captured there, for the same reason. This callback runs -- after `git rev-parse` has exited, so capturing here would read the -- ambient frontend a round trip late and reintroduce exactly the -- misrouting the capture exists to close. start_status(root, nil, false, request.generation, request.dest) end --- Open (or re-open) `*git-status*` for the repository containing the --- active file. function pmacs.git.status() if not git_enabled() then pmacs.editor.set_status("git: disabled by the `git.enabled` setting") return end local dir = active_directory() if not dir then pmacs.editor.set_status("git: no directory to resolve a repository from") return end -- Reserved AFTER the early returns and BEFORE the spawn: an -- invocation that starts no work must not invalidate one that is -- already in flight, and an invocation that does start work must own -- the newest generation from that moment on. -- Captured alongside the generation, at the same moment and for the -- same reason: this is the last instant at which "the frontend the -- user asked from" is knowable without guessing. local request = { generation = status_requests.reserve(), dir = dir, dest = pmacs.window.capture_destination(), } -- The root rule (Q#G-2): ask git, and let a non-zero exit BE the -- "not a repository" answer. `-C ` with no root of our own. run_git("git rev-parse", "resolving which Git repository contains " .. dir, nil, { "-C", dir, "rev-parse", "--show-toplevel" }, function(res) pmacs.git._deliver_root(request, res) end) end pmacs.command.define { name = "git.status", description = "Show the working tree's Git status in a *git-status* panel.", fn = pmacs.git.status, } -- --------------------------------------------------------------------- -- The diff gesture (Q#G-7) -- --------------------------------------------------------------------- -- -- RET visits the file --- the behaviour a list of files should have --- -- so the diff needs its own key, and `d` is it. -- -- What `d` shows answers the lane's own question, "what have I -- changed?", against HEAD. A porcelain-v2 row carries an XY pair (X -- staged, Y unstaged) and the three plausible diffs answer three -- different questions: `git diff` shows only Y, `--cached` only X, and -- neither shows an untracked file at all. One view of the TOTAL change -- is right for reading; splitting X from Y is a staging UI, which is -- Stage 3. -- -- `--no-color` because this renders as plain text and a user with -- `color.ui = always` would otherwise get escape sequences in a buffer -- with no ANSI parser behind it. local SPLIT_HEADER = "no commits yet --- split view: staged (index) above, unstaged (worktree) below" -- True when a `2` record is a COPY rather than a rename. -- -- Read from `score`, not from `row.x`. The `` field names -- rename-vs-copy whichever side detected the change, while `X` carries -- the letter only for an index-side one --- a worktree-side detection -- puts it in `Y` and leaves `X` a `.`. Absent or malformed, this says -- "not a copy", so the header falls back to the commoner of the two -- rather than to a claim it cannot support. local function is_copy(row) return (row.score or ""):sub(1, 1) == "C" end -- The invocations `d` runs for `row`, as -- `{ { label = string|nil, args = {...}, no_index = bool }, ... }`, -- plus the header describing what the result shows. local function diff_plan(row, unborn) local path = row.path if row.kind == "untracked" or row.kind == "ignored" then -- A normal diff shows NOTHING for an untracked file. Without this -- case `d` is silently dead on the rows a user is most likely to -- press it on. return { header = "untracked --- shown against /dev/null", steps = { { args = { "diff", "--no-color", "--no-index", "--", "/dev/null", path }, no_index = true } }, } end if not unborn then if row.kind == "rename" and row.orig then -- Both paths, which is what lets rename detection render this as -- a rename rather than an unrelated add plus delete. Identical for -- a copy, which is why the two share a `kind` --- only the WORD -- differs, because a copy left the origin where it was and saying -- "renamed" of it states a different fact about the user's tree. return { header = string.format("against HEAD (%s from %s)", is_copy(row) and "copied" or "renamed", pmacs.git.display_path(row.orig)), steps = { { args = { "diff", "--no-color", "HEAD", "--", row.orig, path } } }, } end return { header = "against HEAD", steps = { { args = { "diff", "--no-color", "HEAD", "--", path } } }, } end -- Unborn HEAD: there is nothing to total AGAINST, so the split -- appears here and only here. `AM` and `AD` carry BOTH states at -- once, which is exactly the gap: `--cached` alone loses the worktree -- delta and plain `git diff` alone loses the staged base. local staged = row.x ~= "." and row.x ~= " " local unstaged = row.y ~= "." and row.y ~= " " local cached = { label = "staged (index)", args = { "diff", "--no-color", "--cached", "--", path } } local worktree = { label = "unstaged (worktree)", args = { "diff", "--no-color", "--", path } } if staged and unstaged then return { header = SPLIT_HEADER, steps = { cached, worktree } } end if staged then return { header = "no commits yet --- staged (index) only", steps = { cached } } end return { header = "no commits yet --- unstaged (worktree) only", steps = { worktree } } end local function diff_step_ok(step, res) if not res.ok then return false end -- `--no-index` implies `--exit-code`: exit 1 means it SUCCESSFULLY -- found differences, which is the whole point of running it. Under a -- plain "non-zero is failure" predicate every untracked diff would -- render a failure row instead of the diff it just produced. The -- asymmetry is confined to this invocation. if step.no_index then return (res.code or 0) <= 1 end return (res.code or 0) == 0 end -- Ownership is the HANDLE this module holds, never a name match --- -- listview's Q#GB13 rule and dired's F7 rule, for the same reason. -- `pmacs.buffer.create` takes any caller-chosen name, so a user may -- already have a buffer called `*git-diff*`; adopting it would clobber -- their bytes and then lock the rope. A fresh create leaves theirs -- untouched, and this module writes only to the handle it made. local function show_diff_buffer(title, body) local buf = state.diff_buffer local live = buf ~= nil and select(2, pcall(buf.is_valid, buf)) == true if not live then buf = pmacs.buffer.create(DIFF_BUFFER) pmacs.buffer.add_intercept(buf, function() error(DIFF_BUFFER .. " is read-only") end) pmacs.buffer.set_round_trip_input(buf, true) state.diff_buffer = buf end pmacs.buffer.set_generated_contents(buf, title .. "\n\n" .. body) -- The DOCUMENT target, so the status panel it was invoked from stays -- visible beside it. pcall(pmacs.window.display, buf, { select = true }) end -- Start the request's next step, or render what the finished ones -- produced. -- -- Everything this needs lives on `request`, captured at the keypress: -- the diff ticket, the row, the plan, and the ROOT. `state.root` is -- deliberately not read anywhere in here. An unborn `AM`/`AD` row -- produces a TWO-STEP plan, and `state.root` is module-level mutable -- state that a concurrent `git.status` against another repository -- reassigns from its own root-resolution callback --- so reading it per -- step would let one plan's second step run in a different repository -- than its first, carrying the first repository's path. local function advance_diff(request) request.index = request.index + 1 local step = request.plan.steps[request.index] if not step then local body = table.concat(request.pieces, "\n") if body:gsub("%s", "") == "" then body = "(no differences)" end -- The DOCUMENT profile, and the contrast with the status channel is -- the whole of Q#DC-2: `*git-diff*` REPLACES a document window, so -- every stale-intent check applies. If the window the `d` was -- pressed from now holds a different buffer, this diff is answering -- a question about a view the user has already left, and the commit -- is refused rather than allowed to overwrite it. commit_ui(request.dest, "document", function() show_diff_buffer(string.format("git diff --- %s\n%s", pmacs.git.display_path(request.row.path), request.plan.header), body) end) return end run_git("git diff", "diffing " .. pmacs.git.display_path(request.row.path) .. " against HEAD for *git-diff*", request.root, step.args, function(res) pmacs.git._deliver_diff(request, step, res) end) end --- Deliver a completed diff STEP for the plan in `request`. --- --- Exposed for the same reason `_deliver_status` and `_deliver_root` --- are: the contract is about completions arriving in an order the --- CALLER did not choose, and no arrangement of real subprocess timing --- can guarantee that two `git diff` runs finish in a chosen order. function pmacs.git._deliver_diff(request, step, res) -- Superseded by a newer `d`: discard BEFORE ANY EFFECT. `*git-diff*` -- is a singleton buffer, so without this a slow first request -- overwrites a fast second one --- the newest invocation loses to the -- slowest subprocess, which is the same defect the status channel had. -- -- This is the ONE place a diff plan re-enters from a continuation, so -- one check covers all of it: no further spawn (`advance_diff` is -- never reached), no buffer write, and no status message either, since -- a status line from a replaced invocation is as wrong as a buffer -- from one. The in-flight process is not terminated --- reaping is -- `process.forget`'s job, exactly as on the status channel. if not diff_requests.is_current(request.generation) then return end if not diff_step_ok(step, res) then local reason = failure_reason(res) -- The failure render is a render: same destination, same profile, -- same refusal. Announcing a diff failure into whatever frontend -- happens to be active would be the identical misrouting as -- announcing a success there. commit_ui(request.dest, "document", function() show_diff_buffer(string.format("git diff --- %s", pmacs.git.display_path(request.row.path)), reason) pmacs.editor.set_status("git diff: " .. reason) end) return end local text = utf8_clean(res.stdout) if step.label then request.pieces[#request.pieces + 1] = string.format("=== %s ===\n%s", step.label, text ~= "" and text or "(no changes)\n") else request.pieces[#request.pieces + 1] = text end advance_diff(request) end -- Run `plan`'s steps in order under an ALREADY-RESERVED diff ticket, -- then render. `generation` is a parameter for the same reason -- `start_status`'s is: it belongs to the keypress, not to this call. local function run_diff_plan(row, plan, root, generation, dest) advance_diff { generation = generation, row = row, plan = plan, root = root, pieces = {}, index = 0, dest = dest, } end pmacs.command.define { name = "git.diff-file", description = "Show the diff for the file under the cursor in *git-status*.", fn = function() -- Bound buffer-locally on the panel, so `d` can only reach this -- there; the identity check is for the `M-x` path. Compared against -- the CAPTURED handle, never a name lookup: listview disambiguates -- a collision to `<2>`, so the name is not the identity. local active = pmacs.window.buffer() if not (state.buffer and active and active == state.buffer) then pmacs.editor.set_status("git: no *git-status* row here") return end local row = state.display[pmacs.editor.cursor_line()] if not (type(row) == "table" and row.path) then pmacs.editor.set_status("git: no file on this line") return end if not pmacs.git.is_text(row.path) or (row.orig and not pmacs.git.is_text(row.orig)) then refuse_unrepresentable(row) return end if not git_enabled() then pmacs.editor.set_status("git: disabled by the `git.enabled` setting") return end -- Everything the plan runs on is captured HERE, at the keypress, and -- threaded through every step: the ticket, reserved AFTER the early -- returns above so a `d` that starts no work cannot supersede one -- that is already in flight; the root; the unborn flag; and the -- DESTINATION. The first three describe the repository the user is -- looking at right now and are replaced wholesale by a `git.status` -- against another one; the fourth describes the window they are -- looking at it IN, which nothing in this module replaces and -- nothing outside it announces. run_diff_plan(row, diff_plan(row, (state.branch or {}).unborn == true), state.root, diff_requests.reserve(), pmacs.window.capture_destination()) end, }