Merge pull request #227 from levineuwirth/git-status-stage1

feat(git): Stage 1 — *git-status* and *git-diff*, no wire change
This commit is contained in:
Levi Neuwirth 2026-08-11 07:50:42 +00:00 committed by GitHub
commit b867f642b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 5383 additions and 35 deletions

View File

@ -1306,6 +1306,17 @@ Primitive-by-primitive against the list above:
that found it (§25). All four remain in `lsp.lua`; per §25 the that found it (§25). All four remain in `lsp.lua`; per §25 the
symbols are authoritative and the `ad41cf1` line numbers have drifted. symbols are authoritative and the `ad41cf1` line numbers have drifted.
**Updated again: FIVE, and the fifth is the first outside
`lsp.lua`.** Git Stage 1's `*git-status*`
(`builtin/runtime/git.lua`) is the concrete evidence P5 asked for
that the primitive generalizes past its first consumer — the
remediation here was always adoption, not construction. It also
added the primitive's one extension: an optional **`keys`** table on
the open spec, installed once with the panel's buffer and compared
(not re-bound) on reopen, because `Keymap::bind` refuses duplicates
and an async consumer re-opens on every refresh. `*buffer-list*` and
project-search remain the un-migrated hand-rolled pair.
**`*lsp*` is the only one of the four with a working refresh** — it is **`*lsp*` is the only one of the four with a working refresh** — it is
the only one supplying `on_refresh`. `g` is bound on all four the only one supplying `on_refresh`. `g` is bound on all four
unconditionally by `bind_local_keymap`, so the other three carry a unconditionally by `bind_local_keymap`, so the other three carry a
@ -1435,10 +1446,25 @@ What does not:
- **Code actions apply the first action blindly** — no picker (a - **Code actions apply the first action blindly** — no picker (a
roadmap "dark matter" item still true at audit). roadmap "dark matter" item still true at audit).
- **There is no Git integration at all** — no status, stage, diff, - **Git integration reaches status and diff, and no further.** Stage 1
blame, or gutter markers anywhere in the tree (gutter git riders and (`docs/git-integration-framing.md`) ships `*git-status*` — a
the `ResourceOffer` diff/blame family are named deferrals). The Git `listview` panel over `git status --porcelain=v2 --branch -z`, with
affordance list above has nothing to attach to yet. RET visiting the file and `d` showing its file-level diff. There is
still **no stage, revert, blame, or gutter marker** anywhere in the
tree; gutter git riders need new `DecorationKind` variants (Stage 2,
which must be scheduled alone), and the `ResourceOffer` diff/blame
family remains a named deferral. The Git affordance list above now has
something to attach to; the affordances themselves are unbuilt, and
the menu's context vocabulary (`src/menu.rs`) has no `git` context to
host them.
The original audit said "there is no Git integration at all … anywhere
in the tree", and that was **literally false when it was written**:
`tests/fixtures/pmacs-magit/` is a tracked, installable package that
spawns git and parses porcelain v2, with a 32-test acceptance suite
(`tests/m8_6_acceptance.rs`). The **product** gap it described was
real; the sentence overstated it, and the framing that found the
overstatement is the one that closed the gap.
- No test run/debug affordances (DAP is a future arc, - No test run/debug affordances (DAP is a future arc,
`docs/dap-debugging-framing.md`). `docs/dap-debugging-framing.md`).
- No missing-tool guidance affordances (§1.2 — the diagnostic that - No missing-tool guidance affordances (§1.2 — the diagnostic that

1234
builtin/runtime/git.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,7 @@
-- rows = { { text = "src/foo.rs:12:4", item = <any> }, ... }, -- rows = { { text = "src/foo.rs:12:4", item = <any> }, ... },
-- on_visit = function(item) ... end, -- RET/SPC (optional) -- on_visit = function(item) ... end, -- RET/SPC (optional)
-- on_refresh = function() return rows end, -- g (optional) -- on_refresh = function() return rows end, -- g (optional)
-- keys = { d = "git.diff-file" }, -- extra buffer-local keys
-- } -- }
pmacs.listview = pmacs.listview or {} pmacs.listview = pmacs.listview or {}
@ -263,19 +264,193 @@ local function seat_cursor(p, line)
end end
end end
-- The primitive's own key surface, named ONCE so the binder below and
-- the `keys` validator consult the same list. Previously this was a
-- sequence of `bind(...)` calls and the set existed nowhere as data,
-- which is why the git framing had to quote it from the source
-- (docs/git-integration-framing.md Q#G-7).
local FIXED_KEYS = {
{ "RET", "listview.visit" },
{ "SPC", "listview.visit" },
{ "n", "cursor.down" },
{ "<down>", "cursor.down" },
{ "p", "cursor.up" },
{ "<up>", "cursor.up" },
{ "TAB", "listview.toggle" },
{ "g", "listview.refresh" },
{ "q", "listview.quit" },
}
local function bind_local_keymap(buf) local function bind_local_keymap(buf)
local function bind(seq, command) for _, entry in ipairs(FIXED_KEYS) do
pmacs.keymap.bind { scope = "buffer", buffer = buf, sequence = seq, command = command } pmacs.keymap.bind {
scope = "buffer", buffer = buf, sequence = entry[1], command = entry[2],
}
end end
bind("RET", "listview.visit") end
bind("SPC", "listview.visit")
bind("n", "cursor.down") -- ---------------------------------------------------------------------
bind("<down>", "cursor.down") -- Consumer-supplied keys (Q#G-7)
bind("p", "cursor.up") -- ---------------------------------------------------------------------
bind("<up>", "cursor.up") --
bind("TAB", "listview.toggle") -- An optional `keys = { <sequence> = <command name> }` on the open
bind("g", "listview.refresh") -- spec, bound through the SAME `pmacs.keymap.bind { scope = "buffer" }`
bind("q", "listview.quit") -- path as the fixed set above. It exists because a consumer cannot
-- safely bind its own key from outside: `open` disambiguates a name
-- collision to `<2>`, so the name a consumer passed is not necessarily
-- the buffer it got, and this module is the only place the handle is
-- known. No key is intercepted anywhere — COHERENCE.md §6's shadow
-- count is unchanged by this.
--
-- INSTALL-ONCE, MATCH-ON-REOPEN. `Keymap::bind` refuses duplicates
-- (`KeymapError::DuplicateBinding`, "Refuse rather than silently
-- overwrite", src/keymap_tree.rs), and a consumer built on the async
-- completion model calls `open` again on EVERY refresh. So keys are
-- installed when the buffer is created and a later `open` for a live
-- panel does not re-bind — it COMPARES, and errors on divergence.
-- Silently keeping the old binding would hand the consumer a key that
-- does something other than what it just asked for, which is the dead-
-- or-lying-key defect this module already condemns for `g`.
-- A key sequence's whitespace-separated chord tokens. That is exactly
-- how `parse_sequence` (src/key.rs) splits one, so a prefix relation
-- computed here is the same relation the trie would find.
local function chords_of(sequence)
local out = {}
for token in sequence:gmatch("%S+") do out[#out + 1] = token end
return out
end
-- True when one chord list is a STRICT prefix of the other. Either
-- direction is a conflict: `Keymap` refuses both turning a leaf into a
-- submap (`WouldExtendLeaf`) and shadowing a submap with a leaf
-- (`WouldShadowSubmap`), and a `keys` table must not be able to reach
-- either.
local function prefix_conflict(a, b)
local short, long = a, b
if #a > #b then short, long = b, a end
if #short == 0 or #short == #long then return false end
for i = 1, #short do
if short[i] ~= long[i] then return false end
end
return true
end
-- Normalize `keys` into a sorted array of `{ sequence, command }`.
-- Sorted so the comparison on reopen and every error message are
-- deterministic (`pairs` order is not).
local function normalized_keys(keys)
if keys == nil then return {} end
if type(keys) ~= "table" then
error(string.format(
"listview: `keys` must be a table of sequence -> command name; got %s",
type(keys)))
end
local out = {}
for sequence, command in pairs(keys) do
if type(sequence) ~= "string" or sequence == "" then
error("listview: every `keys` entry must be keyed by a non-empty key sequence")
end
if type(command) ~= "string" or command == "" then
error(string.format(
"listview: `keys[%q]` must be a command NAME (a non-empty string); got %s",
sequence, type(command)))
end
out[#out + 1] = { sequence = sequence, command = command }
end
table.sort(out, function(a, b) return a.sequence < b.sequence end)
return out
end
-- A FIRST-PASS collision check, for a better message than the keymap's.
--
-- It compares RAW TOKENS, and that is deliberately not sufficient: the
-- key parser canonicalizes aliases before it ever reaches the trie
-- (`parse_key_code`, src/key.rs, uppercases and folds `RET`/`RETURN`/
-- `ENTER`, `SPC`/`SPACE`, `ESC`/`ESCAPE`, `BS`/`BACKSPACE`,
-- `DEL`/`DELETE`), so `keys = { RETURN = ... }` is a collision this
-- function cannot see.
--
-- **`Keymap::bind` is the authority, and `ensure_panel` tears the panel
-- down when it refuses.** That is not a fallback for a check that
-- happens to be weak --- it is the only version that cannot go stale. A
-- Lua-side canonicalizer would be a second copy of `parse_key_code`'s
-- alias table, and the day the Rust one gains a name the Lua one would
-- silently stop seeing that alias, reintroducing exactly this bug for
-- it. (There is also no way to canonicalize an arbitrary sequence from
-- Lua today: `display_sequence` is reachable only through
-- `describe.key` and `keymap.list`, which both require the sequence to
-- be BOUND already.)
--
-- So what this buys is diagnosis, not safety: a named "that is the
-- panel's own `g`" instead of a raw `DuplicateBinding`.
local function check_key_collisions(entries)
for i, entry in ipairs(entries) do
local mine = chords_of(entry.sequence)
for _, fixed in ipairs(FIXED_KEYS) do
if entry.sequence == fixed[1] then
error(string.format(
"listview: `keys` may not rebind %q --- it is part of the panel's "
.. "own key surface (RET SPC n <down> p <up> TAB g q), bound to %q",
entry.sequence, fixed[2]))
end
if prefix_conflict(mine, chords_of(fixed[1])) then
error(string.format(
"listview: `keys` entry %q conflicts with the panel's own %q --- "
.. "one is a prefix of the other, which the keymap refuses rather "
.. "than turning a binding into a submap",
entry.sequence, fixed[1]))
end
end
for j = i + 1, #entries do
if prefix_conflict(mine, chords_of(entries[j].sequence)) then
error(string.format(
"listview: `keys` entries %q and %q conflict --- one is a prefix "
.. "of the other", entry.sequence, entries[j].sequence))
end
end
end
end
-- Bind the entries, naming which one the keymap refused.
--
-- It does NOT roll back the keys it already bound: its caller owns
-- teardown, and the caller's teardown is killing the whole buffer,
-- which takes the buffer's entire keymap scope with it
-- (`after_buffer_removed` -> `KeymapStack::remove_buffer`). Unbinding
-- here as well would be a second, weaker cleanup mechanism for the same
-- failure --- and the weaker one is what let a half-built panel survive.
local function install_keys(buf, entries)
for _, entry in ipairs(entries) do
local ok, err = pcall(pmacs.keymap.bind, {
scope = "buffer", buffer = buf,
sequence = entry.sequence, command = entry.command,
})
if not ok then
error(string.format(
"listview: cannot bind %q to %q: %s",
entry.sequence, entry.command, tostring(err)))
end
end
end
local function keys_match(a, b)
if #a ~= #b then return false end
for i = 1, #a do
if a[i].sequence ~= b[i].sequence or a[i].command ~= b[i].command then
return false
end
end
return true
end
local function render_keys(entries)
if #entries == 0 then return "none" end
local parts = {}
for i, entry in ipairs(entries) do
parts[i] = string.format("%s=%s", entry.sequence, entry.command)
end
return table.concat(parts, " ")
end end
-- Build the persistent panel record for `name`. A user-killed panel -- Build the persistent panel record for `name`. A user-killed panel
@ -293,9 +468,23 @@ end
-- collision disambiguates `<2>`..`<99>`, and exhausting the limit raises -- collision disambiguates `<2>`..`<99>`, and exhausting the limit raises
-- rather than adopting --- the rule terminal.lua:300-305 states and -- rather than adopting --- the rule terminal.lua:300-305 states and
-- dired.lua:476-504 already implements. -- dired.lua:476-504 already implements.
local function ensure_panel(name) local function ensure_panel(name, key_entries)
local p = panel_for_requested_name(name) local p = panel_for_requested_name(name)
if p then return p end if p then
-- Match-on-reopen (Q#G-7). A live panel keeps the keys it was
-- created with; a DIFFERENT table is a consumer asking for
-- something it will not get, so it is an error rather than a
-- silently ignored request.
if not keys_match(p.keys, key_entries) then
error(string.format(
"listview: %s is already open with keys [%s]; this open asks for "
.. "[%s]. Keys are installed once with the panel's buffer, so the "
.. "second table would be silently ignored --- close the panel "
.. "first, or pass the same keys",
name, render_keys(p.keys), render_keys(key_entries)))
end
return p
end
local actual = name local actual = name
if find_buffer_by_name(actual) then if find_buffer_by_name(actual) then
@ -315,29 +504,64 @@ local function ensure_panel(name)
local buf = pmacs.buffer.create(actual) local buf = pmacs.buffer.create(actual)
p = { requested_name = name, buffer = buf, line_to_item = {}, p = { requested_name = name, buffer = buf, line_to_item = {},
line_to_row = {}, collapsed = {}, rows = {}, visible = 0 } line_to_row = {}, collapsed = {}, rows = {}, visible = 0,
panels[#panels + 1] = p keys = key_entries }
-- Read-only (Q#P3): every non-bypass edit is rejected, with a NAMED -- ALL-OR-NOTHING from here. Everything below mutates a buffer that
-- error. Kept beside the rope lock, not replaced by it: the layering -- does not yet belong to a panel, and `install_keys` can genuinely
-- at terminal.lua:351-366 --- the rope lock protects the daemon copy, -- fail: the raw-token preflight cannot see an alias spelling of a
-- this and the round-trip mark protect a semantic frontend's own -- fixed key (`RETURN` for `RET`), so `Keymap::bind` is the first thing
-- mirror, and neither substitutes for the other. The intercept lives -- to notice, and by then the buffer exists, carries a read-only
-- as long as the buffer; no teardown (the buffer-list precedent for -- intercept and a round-trip mark, and holds the fixed keymap.
-- its keymap). --
pmacs.buffer.add_intercept(buf, function() -- Leaving it behind is worse than it sounds: it is read-only, it is in
error(actual .. " is read-only") -- no `panels` record so nothing owns or can reach it, and the next
-- `open` for the same name finds it by name and disambiguates itself
-- to `<2>` --- so a rejected `keys` table silently renames the panel.
local built, err = pcall(function()
-- Read-only (Q#P3): every non-bypass edit is rejected, with a NAMED
-- error. Kept beside the rope lock, not replaced by it: the layering
-- at terminal.lua:351-366 --- the rope lock protects the daemon copy,
-- this and the round-trip mark protect a semantic frontend's own
-- mirror, and neither substitutes for the other. The intercept lives
-- as long as the buffer; no teardown (the buffer-list precedent for
-- its keymap).
pmacs.buffer.add_intercept(buf, function()
error(actual .. " is read-only")
end)
-- Q#P6: semantic frontends must round-trip keys while this panel
-- is focused (RET = visit, not an optimistic newline).
pmacs.buffer.set_round_trip_input(buf, true)
bind_local_keymap(buf)
install_keys(buf, key_entries)
end) end)
-- Q#P6: semantic frontends must round-trip keys while this panel if not built then
-- is focused (RET = visit, not an optimistic newline). -- `kill` is the whole teardown, not a convenience: it removes the
pmacs.buffer.set_round_trip_input(buf, true) -- buffer AND, through `after_buffer_removed`, prunes the buffer's
bind_local_keymap(buf) -- keymap scope, its config locals and its folds. Unbinding key by
-- key would leave the buffer itself --- which is the defect.
pcall(pmacs.buffer.kill, buf)
-- Level 0: re-raise the inner message verbatim rather than stacking
-- this line's position onto it.
error(err, 0)
end
-- Registered LAST, deliberately: a failure above must leave no record
-- claiming keys it did not bind. Nothing above needs the panel to be
-- in `panels` --- the intercept, the round-trip mark and the keymap
-- all address the buffer directly.
panels[#panels + 1] = p
return p return p
end end
function pmacs.listview.open(spec) function pmacs.listview.open(spec)
assert(type(spec) == "table" and type(spec.name) == "string", assert(type(spec) == "table" and type(spec.name) == "string",
"listview.open: spec.name (string) required") "listview.open: spec.name (string) required")
local p = ensure_panel(spec.name) -- The cheap checks first, so the common mistakes are named before
-- anything is created. The ones this pass cannot see --- alias
-- spellings --- are caught by `Keymap::bind` inside `ensure_panel`,
-- which tears the panel down rather than leaving it half-built.
local key_entries = normalized_keys(spec.keys)
check_key_collisions(key_entries)
local p = ensure_panel(spec.name, key_entries)
p.header = spec.header or spec.name p.header = spec.header or spec.name
p.on_visit = spec.on_visit p.on_visit = spec.on_visit
p.on_refresh = spec.on_refresh p.on_refresh = spec.on_refresh

View File

@ -367,6 +367,444 @@ also removed: this branch's "R8 NEEDS A LANE" investigation block, and
durable facts are in the retired registry row and the handoff §6 durable facts are in the retired registry row and the handoff §6
census. census.
## Git integration Stage 1 — PR #227 OPEN
**PR #227** — https://github.com/levineuwirth/pmacs/pull/227. Opened
2026-08-09 at `4002734`, after the framing was approved at revision 5
and the full gate suite went green. **UNBLOCKED 2026-08-10**: `main` was
merged in (72 commits) and the destination capture #231 provides is
adopted below. Re-gated 12/12 green on the merged tree.
**One cross-lane break the merge surfaced**, recorded because it was
invisible until the suites ran: #232 made `purpose` **required** on
`pmacs.process.spawn`, and this module's spawn lives on this branch, so
it was never among the 11 call sites #232 updated — every git test
failed at once. Each of the three spawns now carries its own purpose,
and deliberately **not** the label, which is the copy #232's ruling was
made against: all three are labelled `git`, and only the purpose
distinguishes "resolving which repository contains `<dir>`" from
"reading the working tree status of `<root>`" from "diffing `<path>`
against HEAD".
**Review round 1 found three blockers. Two are fixed; the third is why
this lane is blocked.**
- **P1 fixed (`ffe5ae2`) — concurrent `status` opens were ordered by
`rev-parse` completion, not by invocation.** The generation was
minted on arrival, inside `start_status`, so the **slowest root
lookup won** rather than the newest invocation. It is now reserved at
the command and carried through the root-resolution callback, which
drops a superseded result **before any effect** — no spawn, no
`state.root` write, and no status message either, since a message
from a replaced invocation is as wrong as a panel from one.
- **P2 fixed (`6c1631e`) — a refused `keys` bind left a live, unowned
buffer.** The preflight compared **raw tokens** while `parse_key_code`
folds `RET`/`RETURN`/`ENTER` (and `SPC`/`SPACE`, `ESC`/`ESCAPE`,
`BS`/`BACKSPACE`, `DEL`/`DELETE`, case-insensitively) onto one chord,
so an alias spelling passed preflight and failed at bind time —
*after* buffer creation and intercept install. The partial rollback
removed only new keys, so later opens silently got `<2>`.
**Fixed by full teardown, not by canonicalizing the preflight**, and
the reasoning is worth keeping: a Lua canonicalizer would be a second
copy of the Rust alias table and would go stale the day that table
gains a name, reintroducing this exact bug for the new alias.
`Keymap::bind` is the authority because it *is* what decides. There
is also **no Lua-reachable canonicalization** to build on —
`display_sequence` escapes only through `describe.key` and
`keymap.list`, both of which require the sequence to be bound
already. Verified; no binding was added for this.
- **P1a FIXED — the block is lifted.** The async completions displayed
UI without capturing the initiating frontend, so a result surfaced in
whichever frontend was active when git exited. `commit_to` was the
right mechanism and was **not Lua-reachable** outside a directory
open, so the capture half shipped as **#231** (`0e4c58d`) and this
lane adopts it now that `main` carries it:
- **Captured at invocation** in all four entrances — `git.status()`,
`_on_refresh` (the `g` keypress), `_deliver_root`'s hand-off, and
the `git.diff-file` command — and threaded on the request table
exactly as the generation and root already were. The ambient
frontend was the one input still being read late.
- **Committed under the profile the surface actually takes**:
`"panel"` for `*git-status*`, `"document"` for `*git-diff*` (Q#DC-2).
- **`set_status` moved INSIDE the status commit.** A failure message
announcing a panel that the commit then refuses is the same
misrouting in its most confusing form, so rows and message are now
computed first and emitted together.
- **Witnessed by `g6_25`**, and the first version of that test was
**worthless**: `panel_text` finds `*git-status*` by NAME, which is
global, so a render into the wrong frontend satisfied it and the
test passed its own mutation. Rewritten per frontend
(`side_window_for`, and each view's active window) it fails both
bites — removing the status commit grows a `*git-status*` panel in
the competitor, removing the diff commit hands it the document
window.
**The second citation written here in round 1 was wrong**`:854`
pointed at `local unstaged = …` inside `diff_plan`, not at a display
call. The site was always `show_diff_buffer`'s `pmacs.window.display`.
Corrected rather than silently re-numbered, because a stale pointer in
a block whose whole purpose is "do not touch these two lines" is worse
than none.
**Re-gated at `6c1631e`:** all 11 steps green, acceptance now 27 tests.
Both fixes mutation-verified.
**Review round 2 found three more P2s, all the SAME SHAPE as the round-1
P1: module-level mutable state read or written at CONTINUATION time
instead of captured at INVOCATION time.** All three fixed here; the P1a
block above is unchanged and still the reason this lane cannot merge.
The fourth recurrence is why the last fix generalizes the rule instead
of adding another counter, and why the census below exists.
- **P2 fixed (`3eca5e8`) — an unborn-repository diff could switch
repositories mid-plan.** `run_diff_plan`'s `next_step` read
`state.root` each time it started a step, and an unborn `AM`/`AD` row
produces a **two-step** plan. A concurrent `git.status` for another
repository reassigns `state.root` from its own root-resolution
callback, so a diff started in A ran its second step with B as cwd and
A's path — git there matches nothing, so the unstaged half silently
rendered `(no changes)` instead of the worktree delta it exists to
show. The root is now captured at the keypress and threaded through as
a parameter; `state.root` is not read inside the plan at all.
- **P2 fixed (`842ec61`) — a repository root containing a newline was
truncated.** `rev-parse --show-toplevel` was parsed with `first_line`,
so a root at `/tmp/a\nb` became `/tmp/a` and every command after it ran
with a nonexistent cwd. Fixed with a **separate** helper,
`strip_output_terminator`, at that one call site. `first_line` is
deliberately untouched: its other three callers all feed the
**single-line status band**, where truncating is right, so folding the
two together would fix one caller and break three.
**A third instance of the shape was found in the same pass and reported
rather than fixed silently:** the diff path had no generation counter at
all. Review independently raised it as **P2 #3**, and it is fixed below.
- **P2 fixed (`723afa7`) — concurrent `d` requests were
last-writer-wins.** `git.diff-file` started a plan with **no request
generation** while every plan writes the **singleton** `*git-diff*`
buffer through `show_diff_buffer`. `d` on A, then `d` on B before A
finishes: if A completed last, A's diff replaced B's. Reachable
without contrivance — `d` renders into the document window only at
completion, so the panel is still focused for a second press.
**Fixed by giving the rule ONE implementation instead of a fourth
hand-rolled counter.** `new_channel()` hands out a ticket at the
command and answers "is this still the request in force?" at the
completion; `state.generation` and `reserve_generation` are gone.
**Two channels, not one, and that is a design decision.** A single
module-wide counter would make `d` cancel an in-flight `g` and vice
versa. The status panel and the diff view are independent things a
user asks for, so each gets its own ordering; what is shared is the
**mechanism**, not the counter. A channel spans a whole **request**
rather than one process — a status open is `rev-parse` then `status`
so `_deliver_root` and `_deliver_status` correctly share one ticket
while a diff plan gets its own. `g6_23` asserts the separation: two
`d` presses leave `_generation()` untouched.
The plan is restructured into the request shape the other two
continuations already use: `step_done`'s closure becomes
`pmacs.git._deliver_diff(request, step, res)`, exposed for the same
reason `_deliver_status` and `_deliver_root` are.
**The async-continuation census, so the next round is not another
instance hunt.** `git.lua` has **exactly three** async continuations,
plus one dispatcher and one synchronous impostor:
| continuation | invocation-time ticket? | needs one? | shared state written |
|---|---|---|---|
| `_deliver_root` (`rev-parse`) | yes, `status_requests`, reserved in `git.status()` | yes | `state.root`, `state.buffer`, spawns the status |
| `_deliver_status` (`git status`) | yes, `status_requests`, reserved at the command or the `g` keypress | yes | `state.branch`, `.rows`, `.display`, `.failure`, `.buffer`, the panel, the cursor |
| `_deliver_diff` (`git diff`, 12 steps) | **now yes**, `diff_requests`, reserved at the `d` keypress | yes | `state.diff_buffer`, the `*git-diff*` contents, the status band |
| `process.after-tick` pump | no | **no** — it is the dispatcher, not a request; it owns only the module-local `pump` table and calls each `on_done` once | `pump` |
| `run_git`'s spawn-failure path | n/a | **no** — it calls `on_done` **synchronously**, at invocation time, and that `on_done` carries the ticket anyway | none |
Everything else that looks like a callback is synchronous at the
keypress: `on_visit`/`on_refresh` on the listview spec, the `*git-diff*`
read-only intercept, and the two `pmacs.command.define` bodies.
`state.diff_buffer` is deliberately still read at continuation time and
that is correct: "do I already have a live diff buffer?" is a question
about *now*, not about the invocation. It is the state `_deliver_diff`
guards, not a second instance of the bug.
**Re-gated at `723afa7`:** all steps green, acceptance now 30 tests. All
three round-2 fixes mutation-verified — `g6_22` fails on the second
spawned diff argv when the `state.root` read is restored; `g6_14c`
resolves `<tmp>/nl` instead of `<tmp>/nl\nroot` when `first_line` is;
and `g6_23` fails when the ticket check is removed, at its **real**
half, the older plan having overwritten the newer one's patch before the
driven delivery was reached.
**Review round 3 found a FOURTH instance of the byte/lifetime shape, and
it was one byte inside round 2's own fix.**
- **P2 fixed (`39ad43d`) — a repository root ENDING IN A CARRIAGE RETURN
was truncated.** `strip_output_terminator` stripped `\r?\n$`, and `\r`
is as legal a byte in a POSIX directory name as `\n` is. For a root
named `trailing\r`, `git rev-parse --show-toplevel` prints
`…/trailing` `0d` `0a` — the path's own CR, then git's LF terminator —
and a pattern tolerant of an optional preceding carriage return cannot
tell those apart, so it took both. The root resolved as `…/trailing`
and every command after it ran with that as its `-C` and cwd: a
directory that does not exist. Now **exactly one trailing `\n`** is
removed, by an explicit last-byte test rather than an anchored pattern
— both of this function's bugs lived in a pattern.
**`-z` was CHECKED against the installed git, not assumed, and must
NOT be used.** `git rev-parse` has no `-z` option at all on git 2.55:
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 — exit code 0, corrupted output, silent.
`--show-toplevel` applies no C quoting either, not even under
`core.quotePath=true`. So there is no unambiguous representation to
prefer over a correct strip, and removing the one byte git appended is
the whole of the right answer.
`first_line` is untouched again, for the reason `842ec61` recorded: its
three callers all feed the single-line status band.
**Re-gated at `39ad43d`:** all steps green, acceptance now 31 tests.
`g6_14d` is end to end — real directories, the real `git`, asserted on
the cwd of the spawn the module actually made — and covers both
`trailing\r` and `nl\nand-trailing\r`, the second because the two hazards
compose and neither fix may mask the other. `g6_14c` now shares that
chain through `assert_root_resolves_whole` rather than keeping a second
copy of it. Mutation-verified: restoring `\r?\n$` fails `g6_14d` at
`<tmp>/trailing` against `<tmp>/trailing\r` while `g6_14c` still passes,
which is exactly the byte separating the two fixes.
**CI round: a DETERMINISTIC macOS failure, in the FIXTURE rather than in
the product.** Both macOS legs of the matrix (LuaJIT and Lua 5.4) failed
`g6_2` identically at
https://github.com/levineuwirth/pmacs/actions/runs/31324683235 while
Linux stayed green.
- **DURABLE PORTABILITY FACT, and this project will hit it again:
macOS cannot hold a non-UTF-8 filename.** APFS and HFS+ validate
pathnames as UTF-8 and reject an invalid one at the syscall with
**errno 92, `EILSEQ`, "Illegal byte sequence"**. Linux's VFS treats a
filename as opaque bytes and accepts it. So `std::fs::write` on
`bad\xFF.txt` is a Linux-only fixture, and any test that builds one is
red on macOS by construction, not by flake.
It goes further than creation: the name cannot be reached *around* the
filesystem either. Putting it only in the index (`update-index
--index-info` plus `write-tree`, never touching the worktree) does not
help, because `git status` lstats every index entry and on macOS that
lstat fails with `EILSEQ` rather than `ENOENT` — which git reports on
stderr and **skips**, so the row would be absent rather than
unrepresentable. **There is no macOS arrangement in which real `git
status` names a non-UTF-8 path at all.**
- **Fixed (`4b82d1e`) by splitting the coverage along the line the
platform actually draws, NOT by `#[cfg]`-skipping the behaviour.**
A behaviour that vanishes on one platform is how a boundary stops
being tested; the behaviour now runs everywhere and only the
*provenance* is gated.
| test | what it witnesses | where it runs |
|---|---|---|
| `g6_2` | parse + display, driven from the **payload bytes** — no repository, no filesystem | every platform |
| `g6_2b` | RET and `d` **refusing with a message**, over a real repository, with the row delivered through `_deliver_status` | every platform |
| `g6_2c` | that real `git` emits those bytes at all | **Linux only**, loudly named and commented |
The gestures are exercised against a **real** repository, panel,
keymap and dispatch; only the row bytes are supplied, through the same
`_deliver_status` seam `g6_17` and `g6_21` already use because a
chosen completion is not otherwise expressible. `g6_2b` also gained
the **rename-ORIGIN** case, which the old single test never had: `d`
passes the origin to `git diff` as an argument too, so a check written
on `row.path` alone would let it through.
The one link no payload can witness — that the spawn pipe carries
bytes rather than text — is **structural**: `event_to_lua` in
`src/lua_bindings/mod.rs` builds the stdout chunk with
`lua.create_string(bytes)`, and `git.lua` only concatenates chunks.
- **New fixture mechanism: `lua_bytes` / `z_payload_bytes`.** A `-z`
payload whose paths are not UTF-8 **cannot be spelled as a Rust
`&str`**, so it is assembled as raw bytes and handed to Lua as one
literal, with every non-printable byte spelled as a **three-digit**
decimal escape. Three digits always: Lua's decimal escape consumes up
to three, so a shorter one swallows the digit after it — the same
hazard the `{:?}`-on-NUL note above records, removed rather than
worked around.
- **Verified here vs. reasoned about.** Verified locally: the full gate
suite green; 33/33 under **both** LuaJIT and Lua 5.4; the two portable
tests still green with `g6_2c` compiled out (a stand-in for the macOS
build, with no dead-code warnings left behind); three mutations each
caught by `g6_2b` — removing the RET check, removing the `d` check,
and removing only the origin clause. Reasoned about, not executed: the
macOS `EILSEQ` behaviour itself and the `lstat`-vs-`ENOENT` argument
above. What is *no longer* reasoned about is the important part —
after this change nothing macOS runs depends on a filesystem accepting
such a name.
- **CONFIRMED ON THE REAL MATRIX.** Run
https://github.com/levineuwirth/pmacs/actions/runs/31330601204 at
`e816812`: **all 14 jobs green**, including
`Test (macos-latest / luajit)` and `Test (macos-latest / lua54)` — the
two that were red. So the macOS half is now OBSERVED rather than
reasoned about; what stays reasoned about is only the *explanation*
(`EILSEQ`, and the `lstat`-vs-`ENOENT` argument for why `g6_2c` cannot
be made portable), which nothing in CI can confirm or refute.
- **LATENT SIBLING, out of scope and NOT red today:**
`tests/gpu_invocation_acceptance.rs:621` writes
`OsString::from_vec(vec![b'r', b'a', b'w', 0xff])` to disk. It sits
inside `#[cfg(feature = "crdt")] mod crdt`, and the `crdt-test` job is
**ubuntu-only**, so it never runs on macOS. It would fail the same way
the day that job gains a macOS leg. No other test in the tree builds a
platform-hostile path: `g6_14c`/`g6_14d`'s `nl\nroot` and `trailing\r`
are valid UTF-8 and legal on APFS, which is why they were green on
macOS all along.
**Review round 4: a COPY was being reported as a RENAME.**
- **Fixed at presentation, not in `kind`.** Porcelain v2's `2` record
covers renames **and** copies — `<Xscore>` leads with `R` or `C`
and the parser already retained `score`. The diff header now reads
that byte and says `copied from` or `renamed from`; nothing new is
parsed.
**`kind` stays `"rename"` for both, deliberately.** Every *behaviour*
keyed on it is identical, including the two-path
`git diff HEAD -- <orig> <current>`, which is correct for a copy as
much as for a rename. Splitting the kind would force every consumer
present and future to spell `kind == "rename" or kind == "copy"`, and
an arm forgotten anywhere silently drops copies back to the one-path
diff — the exact regression the fix exists to avoid. Consumers
checked, and there are few: `diff_plan` (the only `kind == "rename"`
branch in the tree), `status_line_text` (keys off `row.orig`, not
`kind`), `g6_1`'s corpus assertion and `g6_8`'s unborn-unreachability
assertion. `score` has no other reader anywhere.
- **Read from `score`, not from `row.x`.** The score field names
rename-vs-copy whichever side detected the change; `X` carries the
letter only for an index-side one, a worktree-side detection leaving
`X` a `.`.
- **The status ROW is unchanged, and that is a decision.** Its `XY`
prefix already reads `R.` against `C.`, out of the same byte, in the
porcelain vocabulary every other row is read in — so the distinction
is already on screen and a second vocabulary beside it would be the
wider surface for no new fact. `g6_4b` asserts both prefixes, so the
claim is checked rather than asserted here.
- **Parser-level coverage, stated plainly rather than implied.** The
copy ROW is supplied through `_deliver_status`, the seam
`g6_2b`/`g6_17`/`g6_21` already use.
**Scope corrected in review round 5.** This entry claimed real `git`
emits no `2 C` record at all, measured under
`-c status.renames=copies`. **The measurement was real; the claim
drawn from it was too broad.** `git-status(1)` documents `C` as
"copied (if config option status.renames is set to `copies`)", so
git does emit it. What the test establishes is that **this fixture**
— whose copy source is unchanged — yields `1 A.`. That is enough to
justify crafting the row and nothing more. Everything downstream is real: repository,
panel, `d` dispatch, spawned `git diff`, rendered buffer. Both
crafted rows name paths that exist in the fixture, so each drives a
real two-path diff.
- **Unborn `HEAD` needed nothing, confirmed rather than assumed.**
`diff_plan`'s rename branch is inside `if not unborn`, and `g6_8`
already pins that no `2` record can occur there — over
`kind == 'rename'`, which under this choice covers copies too.
**Re-gated:** all steps green, acceptance now **34 tests**. Three
mutations, each caught: header always `renamed` fails only the copy
half; header always `copied` fails only the rename half; dropping
`row.orig` from the steps fails the argv equality. The two `--lib`
failures seen on an earlier run (`composition_overhead_under_ten_percent`,
`setsid_escapee_…`, plus two perf tests) were **machine load from
sibling worktrees** — load average 1527 — and pass in isolation and on
a re-run; nothing in this change touches `src/`.
**Written with the lane's first commit, before the PR exists** — the
standing correction from #171 and #215. This session it was missed on
#224 and again on #225, both caught by review; writing it now is the
only thing that stops a third.
**Branch `git-status-stage1`**, base `githubsucks/main` @ `4bc55e8`
(the #225 merge). **`githubsucks/git-status-stage1` is the
authoritative tip** — the ref, not a SHA. Recover with
`git fetch githubsucks && git checkout git-status-stage1`.
- **Framing `docs/git-integration-framing.md`, revision 5, APPROVED
2026-08-09** after four review rounds. Every round found something
the previous one had asserted without reading; the doc records which.
- **Scope:** `*git-status*` (a `listview` panel over
`git --no-optional-locks -C <dir> status --porcelain=v2 --branch -z`)
and `*git-diff*` (plain generated text, file-level, no hunk model).
Plus **one additive `listview` change**: an optional `keys` table,
install-once with match-on-reopen, because `Keymap::bind` refuses
duplicates and the refresh path re-opens.
- **NO WIRE CHANGE**, and that is load-bearing for scheduling:
`PROTOCOL_VERSION` is a strict serialization point, so this lane can
run concurrently with other work. **Stage 2 (gutter markers) needs
new `DecorationKind` variants and must be scheduled alone.**
- **Known negative coherence impact (§9):** git runs as a spawned
process, and spawned processes do not appear in `*workers*` — that is
`async.lua`'s job list. This adds a fifth unattributable background
thing. Labelled honestly; a label is not attribution.
- **Verification plan** in framing §6. The load-bearing cases: an `AM`
unborn fixture (two labelled patches), untracked diff rendering on
**exit 1** (`--no-index` implies `--exit-code`), two successive
refreshes not raising `DuplicateBinding`, and a non-UTF-8 path that
parses and displays but **refuses** its gestures at the
`String`-typed binding boundary.
- **Gates:** `scripts/gate --acceptance git_status_stage1_acceptance`.
**Implemented.** `builtin/runtime/git.lua` (new, loaded after
`linewrap.lua`), the `keys` extension in `builtin/runtime/listview.lua`,
one chunk-load line in `src/editor.rs`, and
`tests/git_status_stage1_acceptance.rs` (25 tests, one per §6 bullet).
No `pmacs-protocol` change, no `PROTOCOL_VERSION` change, no
`DecorationKind` change — the no-wire property held.
Five things worth carrying, all found by biting the suite rather than by
reading:
- **`listview.open`'s `seat_cursor` walks DOWN from wherever the cursor
is**, on the premise that a fresh `switch_active_buffer` zeroed it.
Re-opening an already-displayed panel — which is exactly what the
async completion model does — zeroes nothing, so the walk lands one
row *below* the previous cursor. The completion handler seats
unconditionally from line 0 instead of trusting `open`.
- **A selection test that inserts ONE row above the selection is
vacuous**, because that accidental off-by-one lands on the right row.
The fixture inserts two.
- **`{:?}` on a Rust string containing NUL cannot build a `-z` fixture.**
Debug renders NUL as `\0`, and Lua's decimal escape swallows the
digits after it — so `\0` before a `1` record becomes
`string.char(1)` and the record merges into its predecessor. One test
passed while parsing nothing: the merged text landed in
`# branch.head`, the panel header rendered it, and a `contains`
assertion on the panel text was satisfied by the header. Payloads are
joined in Lua with `string.char(0)`.
- **A path may contain a newline, so a panel row must escape it.**
Parsing the bytes correctly and then writing them raw into a
one-row-per-line buffer desynchronizes every line-to-row map — and the
rope is UTF-8 by project invariant, so non-UTF-8 path bytes cannot go
in at all. Rows render `\xNN` escapes; the raw bytes stay on the
record, where the refusal check reads them.
- **Untracked rows sort AFTER every tracked row** in porcelain v2, so an
untracked file cannot be used to reorder a list above a selection.
Two deliberate deviations from the framing's letter, both narrow:
`--no-color` on every diff invocation (a user with `color.ui = always`
would otherwise get escape sequences in a buffer with no ANSI parser
behind it), and `pmacs.git._program`, a module-local that the
missing-binary witness points at a name not on `PATH`. There is no other
in-process route to that branch: 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.
## Destination capture (Q#JR14 generalization) — PR #231 OPEN, revision 9, cleared to merge ## Destination capture (Q#JR14 generalization) — PR #231 OPEN, revision 9, cleared to merge
**PR #231** — https://github.com/levineuwirth/pmacs/pull/231. #227 **PR #231** — https://github.com/levineuwirth/pmacs/pull/231. #227
@ -1499,6 +1937,7 @@ authoritative tip** — the ref, not a SHA. Recover with
— added in the second round — a **rename of either** the build or the — added in the second round — a **rename of either** the build or the
sweep step each fail the suite. sweep step each fail the suite.
## QoL arc retirement — PR #224 OPEN (docs only) ## QoL arc retirement — PR #224 OPEN (docs only)
**PR #224** — https://github.com/levineuwirth/pmacs/pull/224. Written **PR #224** — https://github.com/levineuwirth/pmacs/pull/224. Written

View File

@ -0,0 +1,708 @@
# Git integration — Stage 1: seeing what changed
**Status: revision 5, APPROVED 2026-08-09. Implementation may
proceed.**
**Revision 5 completes the unborn-repository policy, which revision 4
wrote as three disjoint rows when a single file can be in two states at
once.** `AM` — staged, then edited again — is not exotic; it is what a
first commit looks like halfway through. The states below were
**enumerated from a real unborn repository**, not reasoned about, and
one of them settles a case by ruling it out entirely.
**Revision 4 fixes two contracts that would have failed in ordinary
use, both verified against real behaviour rather than reasoned about:**
re-binding `d` on every refresh (keymap binds refuse duplicates, so
*every successful refresh* would have errored), and two git exit states
the failure predicate got wrong. Measured, not assumed — the exit codes
below were produced in a scratch repository.
**Revision 3 pins four Stage 1 contracts revision 2 left loose, and two
of those were again claims I made without reading the code I was
crediting.** I attributed selection preservation to `listview` and
`d` to its key surface; neither is true, and both were checkable in the
file I had already cited. The pattern is worth naming since it has now
recurred across three revisions: **I cite a file, then describe what I
expect it to contain.**
**Revision 2 answers four blockers, two of which were factual errors in
revision 1 that scouting should have caught and did not.** I read
`ProjectKind::Git`'s name instead of its doc comment, and I quoted
`COHERENCE.md` §15's "no Git integration anywhere in the tree" without
checking whether it was still true of the tree. It is not.
---
## 1. Why this, and why now
`COHERENCE.md` §15 is blunt about it:
> **There is no Git integration at all** — no status, stage, diff,
> blame, or gutter markers anywhere in the tree (gutter git riders and
> the `ResourceOffer` diff/blame family are named deferrals). The Git
> affordance list above has nothing to attach to yet.
**That sentence is literally false about the tree, and revision 1
repeated it without checking.** `tests/fixtures/pmacs-magit/` is a
tracked, installable package — 1,914 lines across four modules, with
`status.lua` spawning git through `pmacs.process.spawn` and parsing
**`--porcelain=v2 --branch`** into structured sections, plus a 662-line
acceptance suite (`tests/m8_6_acceptance.rs`, 32 tests) covering
status, refresh, staging, commit, push and branch behaviour.
**The PRODUCT gap is real and unchanged** — none of that is bundled
runtime, so a user who installs pmacs gets no git integration. But
"nothing to attach to" understates what exists to *learn from*, and
§15's wording should be corrected when this lands.
For a **daily driver**, this is the largest remaining gap. Not because
git is the most architecturally interesting thing missing — §7
workspaces and §9 worker identity are both deeper — but because it is
the one a user touches *every working hour*, and pmacs currently makes
them leave the editor to answer "what have I changed?".
That is the criterion this lane is chosen against: **frequency of use
per day**, not depth of model.
## 2. Ground truth — what already exists
Scouted, not assumed:
- **`ProjectKind::Git` is NOT general repository detection**, and
revision 1 said it was. Its doc comment is explicit: *"A bare git
repository (no language marker found inside)"* (`src/project.rs:89`).
Markers are ordered and a language marker beside `.git` **wins**
(`src/project.rs:10`), so a normal Rust repository reports
`kind = "rust"` and would have been invisible to a lane that gated on
`kind == "git"`. That gate would have failed on this very repository.
**The rule this lane uses instead: never ask pmacs whether it is a
git repo.** Run git in the **active file's directory** and let git
resolve its own worktree — `git -C <dir> rev-parse --show-toplevel`
establishes the root, and a non-zero exit *is* the "not a repository"
answer. Git's own resolution handles submodules, worktrees, `GIT_DIR`
and `.git` files; a marker walk reimplements a subset of that and
gets it subtly wrong.
- **`pmacs.process.spawn` / `events_take` / `terminate` / `forget`**
is the working model for running an external tool asynchronously;
`builtin/runtime/compile.lua` is a full worked example, including
spawn-failure handling and exit markers.
- **`pmacs.listview.open`** is a real primitive with existing adopters
(`*references*`, `*lsp*`), carrying optional `depth`/`id`,
primitive-owned collapse, and selection re-seated by id. `COHERENCE.md`
P5 says the remaining work there is **adoption, not construction**
a `*git-status*` panel is exactly that.
- **Gutter signs exist in both frontends** — the TUI's leading-column
glyph (`src/diag.rs`) and the GPU's `GUTTER_SIGN_X` bars
(`pmacs-gpu/src/main.rs:420`).
- **A tested porcelain-v2 parser exists as a package fixture** (above).
Its `status.lua` deliberately separates **pure `parse_*` functions
that take a string and return structure** from the spawning around
them — which is the shape that makes a parser testable without a
repository, and it is already proven by 32 tests.
And the constraint that shapes the staging:
- **`DecorationKind` is a CLOSED enum on the wire**
(`pmacs-protocol/src/message.rs:1472`): four diagnostic severities,
`Selection`, `SearchMatch`, `SearchMatchActive`, `CurrentLine`.
**Gutter markers for git hunks therefore require new variants, which
is a protocol version bump.** The gutter signs that exist are keyed
on `diagnostic_severity_rank` and have no notion of anything else.
## 3. The staging, and why the line falls where it does
**Stage 1 (this lane): read-only, panel-based, NO WIRE CHANGE.**
- `*git-status*` — a `listview` panel over
`git status --porcelain=v2 --branch -z` (Q#G-6), rows visiting the
file at RET, refreshed by `g` under the completion model in Q#G-1.
- `*git-diff*` — the diff for the **file** under point (Q#G-7), in a
generated buffer rendered as **plain text** (no `diff` grammar
exists). **No hunk model** — hunks are Stage 2's concern.
**Stage 2 (separate lane): gutter markers.** Needs new
`DecorationKind` variants and a `PROTOCOL_VERSION` bump, plus both
frontends' gutter renderers learning a second rider family.
**Stage 3+ (unscheduled): staging, commit, blame.** Staging and commit
are where an editor becomes a git *client*; blame is a lower-frequency
read. Neither belongs in front of the two above.
**The line is drawn at the wire on purpose, and it is a scheduling
decision as much as a design one.** Parallel lanes are about to start,
and `PROTOCOL_VERSION` is a strict serialization point — two lanes
bumping it collide, and this session already recorded what that costs
(eight broken version assertions on CI from a single bump). Stage 1
touching no wire is what lets it run **concurrently** with other work.
Stage 2 must be scheduled alone.
## 4. Coherence impact (§20)
Required by `CLAUDE.md` for coherence-affecting work, and this is
coherence-affecting — it is §15's named gap.
- **Journey steps touched:** none directly. Git is not currently a
journey step; the golden journey runs open → edit → build → test →
navigate. This lane does **not** add a step, and I would rather say
so than inflate the claim.
- **§15 contextual affordances — the direct target.** The audit's git
affordance list ("a Git change stage/revert/diff") has *nothing to
attach to*. Stage 1 creates the thing to attach to; the affordances
themselves follow it, and the menu's context vocabulary
(`src/menu.rs:44`) would need a `git` context to host them — **out of
scope here**, named so it is not forgotten.
- **§14 workbench primitives — adoption, which is the stated need.**
`*git-status*` becomes the **fifth** `listview` call site and the
first outside the LSP panels, which is the concrete evidence P5 asks
for that the primitive generalizes past its first consumer.
- **Interaction islands (§6): none added, and this is a real
constraint.** The panel gets no hardcoded key interception; it uses
`listview`'s existing key handling. §6 records six such shadows and
calls them "weak, and growing" — this lane must not make it seven.
- **Config registry adoption:** at least one setting
(`git.enabled`, Q#G-4), defined through `pmacs.config.define` like
`ui.line-wrap` and the zoom settings, not a bare Lua global.
- **Background-work attribution (§9): NEGATIVE, and named as such.**
Git runs as a spawned process, and spawned processes do **not** appear
in `*workers*` — that view is `async.lua`'s job list; processes live
under `pmacs.process.list` (Q#G-5). This lane therefore adds a fifth
thing running in the background with no single place to see it. The
process is labelled honestly, which is better than anonymous, but
**a label is not attribution and this document does not pretend
otherwise.** Accepted because these are short-lived reads; it would
not be acceptable for Stage 3's push/pull.
## 5. Open questions
### Q#G-1 — is the status panel a snapshot or a live view?
A snapshot is a command that opens a panel; a live view refreshes on
buffer save, on focus, or on a filesystem watch.
*My vote: **snapshot, refreshed explicitly***, with `g` re-running
inside the panel. Live refresh needs a watch mechanism, an invalidation
rule, and a §9 story for the recurring work — all real arcs. A snapshot
is honest, useful the first day, and does not pretend to a currency it
cannot maintain.
**But "explicit refresh" does not fit `listview` unmodified, and
revision 1 missed that.** `listview.refresh` is synchronous:
```lua
local rows = check_ids(p.on_refresh() or {}) -- listview.lua:402
```
The result is consumed immediately. `pmacs.process.spawn` cannot return
rows there — it returns a process id whose output is drained later. So
revision 1's "adopt `listview`" would have produced exactly one of the
two failures the reviewer named: a reimplemented list, or a `g` that
silently does nothing. The primitive's own docs already call a dead `g`
out as a defect it must not repeat (`listview.lua:416`).
**The completion model, specified.** `on_refresh` stays synchronous and
honest:
1. **`on_refresh` returns the CURRENT rows immediately**, with a
`refreshing…` marker row appended, and *kicks off* the spawn. `g` is
therefore never a no-op — it always re-renders and always shows that
work started.
2. **On exit, the completion handler re-opens the panel** via
`listview.open` with the same `name` — **and re-seats the selection
itself.**
Revision 2 credited that to the primitive and was wrong.
`listview.open` **resets collapse** (`p.collapsed = {}`) and
**always seats line 1** (`seat_cursor(p, 1)`,
`builtin/runtime/listview.lua:337-378`). The `listview.lua:82` note
I cited is about **name** disambiguation to `<2>`, not selection.
Only `listview.refresh` preserves a selection, and that is the
synchronous path this model cannot use.
So the contract is explicit and owned here: **capture the selected
row's git id (its current path) before re-opening, and after
re-opening move to the line whose row carries that id**, computed
from the handler's own rows array via `pmacs.editor.move_to_line`.
If the id is gone from the new status — the commonest case, since a
file that stopped being modified drops out — seat line 1 and say
nothing; that is the correct answer, not a failure.
**Collapse state is moot in Stage 1** because the rows are flat: no
`depth`, so nothing to collapse. Stage 2 or a sectioned view would
have to revisit this, and would then face the same reset.
3. **Concurrent refresh is suppressed by a generation counter.** A
second `g` while one is in flight bumps the generation; the older
completion sees a stale generation and **discards its rows** rather
than racing. It does not terminate the first process — reaping is
`pmacs.process.forget`'s job and killing git mid-read buys nothing.
4. **Failure is a row, not a silence.** Non-zero exit or spawn failure
renders a row carrying the exit code and the first stderr line, plus
a status message. §1.2's silence asymmetry.
5. **Panel lifetime.** If the panel's buffer is gone when the process
exits, the handler drops the result. `compile.lua:252` already
handles the buffer-killed case for its own slot; the same shape.
**The alternative — extending `listview` with an async contract — is
the more correct long-term answer** and is deliberately not taken here:
it changes a primitive with four existing adopters, and doing that from
inside its fifth adopter's lane is how a primitive acquires a consumer's
idiosyncrasies. **If review prefers it, it belongs in its own lane
before this one.**
### Q#G-0 — what is the relationship to `pmacs-magit`? **(new in rev 2)**
The reviewer's framing of the choice is right: adopt, replace, or
declare it out-of-product precedent. Doing none of those and quietly
writing a second parser is the option that must not happen.
*My vote: **port its pure `parse_*` functions and its test corpus into
the bundled runtime; leave the fixture itself untouched.***
- **The record TOKENIZER is deliberately rewritten, not ported.** The
fixture parses **newline-delimited** v2; Stage 1 reads **`-z`**, and
those are different grammars — under `-z` a record's fields are
NUL-terminated and a rename carries its two paths as separate fields
rather than tab-joined. Saying "port the parser" would have been
wrong; what ports is the **separation** (pure `parse_*` functions
over a string, testable with no repository) and the **case coverage**
its 32 tests encode. The tokenizer underneath is new, and its
correctness rests on this lane's own corpus.
- **Port, not import.** The fixture's purpose is to prove the *package
system* can host this. If bundled code became its dependency, it
would stop demonstrating an independent package and `m8_6` would test
less than it claims.
- **The duplication is therefore deliberate**, and it is the one place
this framing accepts two copies of a rule after a session spent
removing them. The justification is that they answer different
questions — one is product behaviour, one is package-system
capability — and coupling them weakens the second. **If review
prefers the coupling, that is a defensible call and I will take it**;
what I will not do is leave the duplication unstated.
- **It also settles Q#G-2's format**: the existing, tested parser is
**porcelain v2**, so Stage 1 is v2. Revision 1 said v1 for no reason
beyond familiarity.
### Q#G-2 — `git` the binary, or a library?
*My vote: **the binary**, via `pmacs.process.spawn`. `compile.lua` is
the worked precedent, the daemon already spawns external tools, and a
git library is a dependency with a much larger surface than "run one
command and parse porcelain". `--porcelain=v2` is explicitly a stable
machine format; that is what it is for.
**Named risk:** no `git` on `PATH`. §1.2's *silence asymmetry* says the
failure must be **surfaced with guidance**, not swallowed — the same
lesson #204 landed for a missing language server.
### Q#G-6 — the status data contract **(new in rev 2)**
Revision 1 said "`--porcelain=v1`" and proposed "a path with a space"
as the parsing witness. **Both were inadequate.** Porcelain without
`-z` emits paths in git's **C quoting** for anything non-ASCII or
containing special characters, and rename/copy records carry *two*
paths whose separation is positional. A single space-in-path fixture
proves none of that.
*My vote: the exact invocation*
```
git --no-optional-locks -C <dir> status --porcelain=v2 --branch -z
```
**`--no-optional-locks` is part of the contract, not a nicety.**
`git status` is **not strictly read-only**: it may refresh and write
the index, and git's own documentation recommends this flag for
background scripts precisely so a background reader does not contend
for `index.lock` with the user's real git commands
(<https://git-scm.com/docs/git-status>). This lane runs status
*asynchronously, from an editor, while the user may be running git in a
terminal* — the exact scenario the flag exists for. Revision 2 called
the lane "read-only" and that was wrong about the mechanism.
It is **witnessed structurally** — the assembled argv is asserted to
carry the flag — because observing a lock that was *not* taken is not
something a test can do directly. Verified accepted by the git in use
here.
The rest: `--porcelain=v2 --branch -z`, also verified accepted. NUL delimiting removes C quoting from
the problem **entirely** rather than obliging a hand-written unquoter,
and it makes the two-path rename record unambiguous: the paths are
separate NUL-terminated fields rather than tab-joined inside one.
The rename/copy identity rule to pin: a `2` record carries the current
path **and** its origin, and the panel must show which file it is now
while remembering where it came from — a row whose id is the current
path, since that is what RET visits.
**Witness corpus, not one case:** modified, added, deleted, untracked,
**renamed (both paths)**, **copied**, a path with a space, a path with
a newline, and a non-UTF-8 path. The last two are exactly what `-z`
buys and what a quoted parser gets wrong.
### Q#G-7 — the diff gesture **(new in rev 2)**
Revision 1 wrote "the diff for the file or hunk under point" while also
committing RET to visiting the file. **RET cannot do both, there is no
second binding proposed, and no hunk model exists anywhere in the
tree.**
*My vote:*
- **RET visits the file** — unchanged, and the behaviour a list of
files should have.
- **A named command, `git.diff-file`, bound to `d` inside the panel.**
**`d` is not on `listview`'s key surface**, and revision 2 said it
was. The bound set is exactly `RET SPC n <down> p <up> TAB g q`
(`builtin/runtime/listview.lua:266-279`), bound buffer-locally inside
the primitive, which is the only place the panel's buffer handle is
known. **Looking the buffer up by name from outside is unsafe**
`listview` deliberately disambiguates a collision to `<2>`, so the
name a consumer passed is not necessarily the buffer it got.
*My vote: **a `keys` table on the open spec***, e.g.
`keys = { d = "git.diff-file" }`, bound through the same
`bind_local_keymap` that already binds the fixed set. It is additive,
general to any adopter, keeps binding where the buffer is known, and
adds **no** interception — the §6 constraint holds.
**The registration lifecycle, which revision 3 omitted and which
would have broken the refresh path it depends on.** `Keymap::bind`
**refuses duplicates**`KeymapError::DuplicateBinding`, *"Refuse
rather than silently overwrite"* (`src/keymap_tree.rs:75`) — and the
completion model calls `listview.open` again on **every** refresh. A
naive `keys` implementation therefore errors on the second open, so
**every successful refresh would have failed while re-binding `d`.**
The contract:
1. **Keys are installed once, when the panel's buffer is created**,
and stored on the panel.
2. **A later `open` for a live panel does not re-bind.** It
**compares** the supplied `keys` against the stored table and
**errors on divergence** rather than ignoring it. Silently keeping
the old binding would give the consumer a key that does something
other than what it just asked for — a dead or lying key, which is
the defect `listview` already condemns for `g`.
3. **Collisions are rejected at install time**, against both the
fixed set (`RET SPC n <down> p <up> TAB g q`) and any
**prefix conflict**`Keymap` has a separate error for turning a
leaf into a submap, and a `keys` table must not be able to reach
it.
(The alternative, idempotent re-registration, is tolerable but
strictly weaker: it makes a consumer that changes its keys mid-session
silently wrong instead of loudly wrong.)
**This IS a `listview` modification, and revision 2's "no listview
modification" was false.** I distinguish it from the async-contract
change I deferred: that one alters *when* an existing callback's
result is consumed for four existing adopters; this adds an optional
field that changes nothing for a spec that omits it. **If review
judges any primitive change out of an adopter's lane, the alternative
is `listview.open` returning the panel buffer** so the consumer binds
its own key — smaller still, but it pushes binding to every adopter.
- **No hunk model in Stage 1.** Hunks are precisely what gutter markers
need, and that is Stage 2's protocol work. Introducing a half hunk
model here to serve one gesture would prejudge Stage 2's design from
the wrong side.
**And what `d` actually SHOWS, which revision 2 left unstated.** "File,
not hunk" is a scope, not a contract. A porcelain-v2 row carries an
**XY** pair — X staged (index vs HEAD), Y unstaged (worktree vs index)
— 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.
*My vote: **`d` answers the lane's own question — "what have I
changed?" — against `HEAD`:***
| row | `d` runs | why |
|---|---|---|
| staged, unstaged, or both | `git diff HEAD -- <path>` | one view of the total change; splitting X from Y is a staging UI, which is Stage 3 |
| deleted | `git diff HEAD -- <path>` | shows the deletion; no special case needed |
| renamed / copied | `git diff HEAD -- <orig> <current>` | v2 gives both paths; passing both is what lets rename detection render it as a rename rather than an unrelated add+delete |
| **untracked** | `git diff --no-index -- /dev/null <path>` | **a normal diff shows nothing at all** for an untracked file. Without this case `d` is silently dead on the rows a user is most likely to press it on |
| non-UTF-8 path | *refuses, with a message* | see Q#G-8 |
The `HEAD` choice is deliberate and is the one thing here I would most
expect review to push back on: it is the right default for *reading*
what changed, and the wrong one for *staging*, which is why it is
correct for Stage 1 and will need revisiting when Stage 3 arrives.
**The exit-state contract, which revision 3 got wrong in two ways.**
"Non-zero exit renders a failure row" is not correct for `git diff`.
Both cases below were measured in a scratch repository, not inferred:
**(a) `--no-index` implies `--exit-code`.** It exits **1 when it
successfully finds differences** — measured: `exit=1` for an untracked
file against `/dev/null`. Under revision 3's predicate, *every*
untracked diff — the case `--no-index` exists to serve — would have
rendered a failure row instead of the diff it just produced.
So for the untracked path the success predicate is **exit ∈ {0, 1}**,
rendering whatever came out; **exit ≥ 2 is a real failure**. That
asymmetry is confined to the `--no-index` invocation and does not leak
to the others, where non-zero still means failure.
**(b) An unborn repository has no `HEAD`.** Measured:
`git diff HEAD -- <path>` exits **128** with `fatal: bad revision
'HEAD'`. This is not an edge case — it is a freshly `git init`-ed
repository with the first files staged, which is exactly when someone
opens a status panel to see what they are about to commit.
*Policy: **detect once, then split**.*
**Detection needs no extra subprocess.** `--branch` already reports
`# branch.oid (initial)` when `HEAD` is unborn — observed in the
output this lane already parses. Revision 4 proposed a separate
`git rev-parse --verify --quiet HEAD`; that is a second process for a
fact the first one hands over.
**The reachable states, enumerated from a real unborn repository** —
`git init`, stage three files, then edit one, delete one, and `git mv`
one:
```
# branch.oid (initial)
1 AD ... ad.txt
1 AM ... am.txt
1 A. ... r_new.txt <- the `git mv`
? untracked.txt
```
Two findings fall straight out:
- **`AM` and `AD` are ordinary and carry BOTH states**, which is
exactly the gap: `--cached` alone loses the worktree delta, plain
`git diff` alone loses the staged base.
- **Rename and copy CANNOT occur under an unborn `HEAD`.** The
`git mv` produced `1 A. … r_new.txt` — an ordinary add of the new
path, **not** a `2` record. With no `HEAD` there is nothing to
rename *from*, so the rename/copy row class is unreachable here and
needs no unborn policy. That is a case closed by evidence rather than
handled speculatively.
| unborn row | `d` renders |
|---|---|
| `A.` staged only | one patch: `git diff --cached -- <path>` |
| **`AM` staged + edited** | **two labelled patches***staged* `git diff --cached -- <path>`, then *unstaged* `git diff -- <path>` |
| **`AD` staged + deleted** | **two labelled patches**, same pair; the second renders the deletion |
| `.M` / `.D` unstaged only | one patch: `git diff -- <path>` |
| `?` untracked | `git diff --no-index -- /dev/null <path>` (exit ∈ {0,1}) |
| rename / copy | **unreachable** — see above |
All four `--cached` / plain invocations above were run against that
repository and render the expected patches.
**The split is unborn-only, and that asymmetry is deliberate.** Once
`HEAD` exists, `git diff HEAD` gives one total — which is the lane's
question — and splitting it would be a staging UI (Stage 3). The split
appears here only because there is no `HEAD` to total *against*.
The generated buffer carries a **header naming what it is showing**:
*"no commits yet — split view: staged (index) above, unstaged
(worktree) below"*. Revision 4's wording ("showing staged changes")
would have described a single total-against-`HEAD` diff, which is
precisely what this is not. A diff that silently answers a different
question than the one asked is worse than one that says so — and a
header that misdescribes a split view is the same failure in smaller
type.
### Q#G-8 — non-UTF-8 paths: an honest boundary **(new in rev 3)**
Revision 2 listed a non-UTF-8 path in the witness corpus as though it
were an end-to-end case. **It cannot be**, and the boundary is in the
bindings: `pmacs.process.spawn` takes `args: Vec<String>`
(`src/lua_bindings/mod.rs:8683`) and `pmacs.buffer.find_or_open` takes
`path: String` (`:3564`). Both are Rust `String`, i.e. UTF-8 by
construction. A path that is valid bytes but not valid UTF-8 can be
*read* from git's `-z` output and *displayed*, but it cannot be passed
back to `spawn` for a diff, nor opened.
*My vote: **parse it, show it, and refuse the gesture with a
message***:
- the row **appears** in the panel, so the user is not lied to about
what is modified;
- **RET and `d` on that row report** that the path is not representable
and do nothing else — a witnessed refusal, not a stack trace or a
silent no-op;
- **it is removed from the end-to-end promise.** The witness is
parser-and-display **plus the refusal**, and the framing does not
claim visiting works.
Making it work end-to-end means `OsString`/bytes through two binding
boundaries — a real change to the Lua API surface, and not this lane's.
### Q#G-3 — what does the diff view render into?
*My vote: **a generated buffer**, reusing the generated-buffer
immutability work (Stage 1 merged; that lane's Stage 2 is queued).
Diff output is read-only text and that machinery exists.
**RESOLVED in rev 2 — there is no bundled `diff` grammar.**
`BUILTIN_LANGUAGES` (`src/syntax.rs`) has no `diff` entry; checked, not
assumed. **Stage 1 renders plain generated text**, and diff
highlighting is later work needing a grammar first.
### Q#G-4 — what is configurable?
*My vote: **one setting to start**`git.enabled` (boolean, default
`true`), through the config registry. Resist more until there is use
evidence; §11's grade is "partial (foundation only)" and adding five
speculative settings is how a registry becomes noise.
### Q#G-5 — §9 attribution — **RESOLVED, and the answer is negative**
Revision 1 deferred this to implementation. That was wrong: it is
answerable by reading, and deferring it would have meant discovering a
known coherence cost *after* committing to the design.
**A spawned git process does not appear in `*workers*` at all.** That
buffer is `builtin/runtime/async.lua`'s (`:490`) and lists **async
jobs**; spawned processes live separately under `pmacs.process.list`.
They are two of the four disjoint activity views §9 grades as
"mechanism without identity".
So, stated plainly rather than dressed up:
- **This lane adds a fifth thing that runs in the background and is not
attributable from one place.** That is a **negative** coherence impact
against §9, and it is the honest cost of shipping git status before
worker identity exists.
- **Labelling the process is still required** — a clear label under
`pmacs.process.list` is strictly better than an anonymous `git`. But
**a label does not solve attribution**, and this document does not
claim it does. The claim is only: do not make it worse than it has to
be.
- **The mitigation is bounded in time, not in kind.** These are
short-lived reads, not long-running jobs; a `git status` that has not
finished is a bug, not a background task a user needs to supervise.
That is why the cost is acceptable *now* and would not be for
Stage 3's push/pull.
## 6. Verification
- **Parsing, against a corpus rather than a case (Q#G-6):** modified,
added, deleted, untracked, **renamed with both paths**, **copied**, a
path with a space, and **a path with a newline** — the last is what
`-z` buys, and a parser that passes only the space case is the one
that ships broken.
- **A non-UTF-8 path is parsed and displayed, and its gestures refuse
with a message** (Q#G-8) — a witnessed refusal at the binding
boundary, **not** an end-to-end visit.
- **The argv carries `--no-optional-locks`** (Q#G-6), asserted
structurally. A lock not taken cannot be observed directly, so the
invocation is what gets pinned.
- **`d` is witnessed on every row class** (Q#G-7): staged, unstaged,
both, deleted, renamed, and **untracked** — the last because a normal
`git diff` shows nothing there, so a missing `--no-index` case makes
`d` silently dead exactly where it is most used.
- **A copy is reported as a COPY, not a rename** (Q#G-7). Porcelain v2
folds both into the one `2` record, so `kind` stays `"rename"` for
both — every *behaviour* keyed on it is the same — and the
distinction is made where it is a distinction: the diff header reads
the `<Xscore>` field's leading `R`/`C` and says which one happened.
The status row is left alone, because its `XY` prefix already reads
`R.` against `C.`. Both classes are asserted, and so is the **argv**:
the two-path `git diff HEAD -- <orig> <current>` is right for a copy
and a rename alike, so a fix to what the user is *told* must not
reach what runs. **Parser-level, deliberately** — the copy ROW is
supplied through `_deliver_status` while the repository, the panel,
the `d` dispatch and the spawned diff around it are real.
**The reason, narrowed after review.** This bullet used to say real
`git` emits no `2 C` record "even under `status.renames=copies`".
**That is too strong, and git's own documentation contradicts it**
`git-status(1)` lists `C` as *"copied (if config option
status.renames is set to `copies`)"*. What the test measures is
narrower: **for its fixture, whose copy source is left unchanged**,
git reports `1 A.`. That is a fact about the fixture, and it is
sufficient reason to craft the row — a weaker and true justification
in place of a stronger false one. No mechanism is claimed for why an
unchanged source is not offered as a candidate; that was never
established.
- **The untracked diff renders on exit 1**, not a failure row (Q#G-7a)
— the case `--exit-code` semantics would otherwise break, and the
one most likely to be "fixed" later by someone who reads exit 1 as an
error.
- **An unborn repository is witnessed end to end**, and the fixture is
**`AM`** specifically — staged then edited again, the shape a first
commit actually has partway through. `git init`, stage, edit, open
the panel, press `d`, and get **two labelled patches** with the
split-view header — not `fatal: bad revision 'HEAD'`, and not a
single `--cached` patch that silently drops the worktree edit.
**`AD` rides the same fixture**, since one repository can hold both.
- **Unborn detection reads `# branch.oid (initial)`** from the status
output already being parsed — asserted, so nobody later reintroduces
a second `rev-parse` process for a fact already in hand.
- **Rename/copy under an unborn `HEAD` is asserted UNREACHABLE**: the
fixture `git mv`s a staged-but-uncommitted file and the parser sees a
`1 A.` record, never a `2`. Pinned so a future reader does not
"fix" the missing unborn rename policy by inventing one.
- **Re-binding across a refresh does not error** (Q#G-7): two
successive refreshes on a live panel, asserting `d` still works and
no `DuplicateBinding` surfaced. This is the one that would have
broken on every refresh.
- **A `keys` table colliding with the fixed set is rejected at install
time**, as is a prefix conflict.
- **Selection is re-seated by the completion handler** (Q#G-1), across
a refresh that reorders rows, and **falls back to line 1 without
complaint when the selected path drops out of status** — the common
case, not an error.
- **The pure `parse_*` functions are tested without a repository**,
which is the shape `pmacs-magit/status.lua` already proves works and
the reason to port that separation rather than invent one.
- **A repository fixture built with real `git`**, in a tempdir, and
**bounded with `set_search_boundary`** — R8 was retired two commits
ago and is precisely what happens when a fixture lets project
detection escape into the developer's environment.
- **The root rule is witnessed on a repository whose `ProjectKind` is
NOT `Git`** — i.e. an ordinary language project with a `.git` beside
its manifest. That is the case revision 1's `kind == "git"` gate
would have failed, and this repository is one.
- **Missing `git` on `PATH` is witnessed**, not assumed (Q#G-2), and
surfaces guidance rather than silence.
- **`g` is never a no-op** (Q#G-1): it re-renders and marks that work
started, even mid-flight. A dead `g` is a defect `listview` already
names.
- **Concurrent refresh discards the stale generation** rather than
racing — asserted by driving two refreshes and completing them out of
order.
- **Failure renders a row**, carrying exit code and stderr.
- **The panel is a `listview` adopter**, asserted structurally, so a
future re-implementation of list behaviour inside git code fails the
test rather than passing review.
- **No new interaction island**`d` is bound buffer-locally through
`listview`'s own binding path (Q#G-7), not a hardcoded interception.
§6 stays at six shadows.
Gates via `scripts/gate --acceptance <the new suite>`.
**What this will NOT prove:** that background git work is attributable
(Q#G-5 — it is not, by construction), or that the parser handles
porcelain versions other than v2.
## 7. Not in scope
Gutter markers and any `DecorationKind`/`PROTOCOL_VERSION` change
(Stage 2 — must be scheduled alone). Staging, commit, push, pull,
branch operations, merge-conflict resolution. Blame. A `git` context in
the menu vocabulary. Any git *library* dependency. Live refresh
(Q#G-1). Fixing §9's worker identity — this lane makes it marginally
worse and says so (Q#G-5). Any hunk model (Q#G-7). Modifying the
`listview` primitive to carry an async contract — the better long-term
answer, but it belongs in its own lane before this one, not inside its
fifth adopter (Q#G-1). Changing `tests/fixtures/pmacs-magit/` or
`tests/m8_6_acceptance.rs` (Q#G-0).
**A `listview` change IS in scope after all** (Q#G-7): an optional
`keys` table on the open spec. Revision 2 said no primitive
modification; that was false, because `d` cannot be bound from outside
the primitive safely. The async-contract change stays out.
**One correction this lane should carry when it lands:** `COHERENCE.md`
§15's "no Git integration anywhere in the tree" is literally false —
`tests/fixtures/pmacs-magit/` exists. The *product* gap it describes is
real; the sentence needs narrowing to say so.

View File

@ -231,12 +231,33 @@ all share one keymap:
| `RET` / `SPC` | `listview.visit` — act on the item under the cursor | | `RET` / `SPC` | `listview.visit` — act on the item under the cursor |
| `n` / `<down>` | `cursor.down` | | `n` / `<down>` | `cursor.down` |
| `p` / `<up>` | `cursor.up` | | `p` / `<up>` | `cursor.up` |
| `TAB` | `listview.toggle` — collapse/expand the tree node under the cursor; a panel with no tree rows delegates to `buffer.tab` |
| `g` | `listview.refresh` — re-run the data source and re-render | | `g` | `listview.refresh` — re-run the data source and re-render |
| `q` | `listview.quit` — restore the buffer that was active before the panel opened | | `q` | `listview.quit` — restore the buffer that was active before the panel opened |
(`TAB` arrived with the tree primitive and this table had not recorded
it. Noted rather than quietly added: the omission predates the git lane
that found it.)
Panels currently built on this: `*references*`, `*outline*`, Panels currently built on this: `*references*`, `*outline*`,
`*lsp-help*` (hover docs). Header text always spells out the same `*lsp-help*` (hover docs), `*lsp*` (`lsp.status`), and `*git-status*`
`RET`/`n`/`p`/`g`/`q` legend inline. (`git.status`). Header text always spells out the panel's own legend
inline.
A panel may add keys of its own through an optional `keys` table on the
open spec, bound through the same buffer-local path — so they are
inspectable by `describe-key` and rebindable from `init.lua`, exactly
like the fixed set. They are installed once with the panel's buffer and
may not collide with the fixed set, nor prefix it. One panel uses this
today:
| Buffer | Key | Command |
|---|---|---|
| `*git-status*` (`git.status`) | `d` | `git.diff-file` — the diff for the file under the cursor, into `*git-diff*` |
`git.status` gets **no global chord**: an opening key is a
command-surface decision the Stage 1 framing did not make, so the entry
point is `M-x git.status`.
`*buffer-list*` (`editor.list-buffers`, `C-x C-b`) uses its own `*buffer-list*` (`editor.list-buffers`, `C-x C-b`) uses its own
keymap, layered on the same idiom, in `builtin/commands/default.lua`: keymap, layered on the same idiom, in `builtin/commands/default.lua`:

View File

@ -797,6 +797,20 @@ impl EditorState {
include_str!("../builtin/runtime/linewrap.lua"), include_str!("../builtin/runtime/linewrap.lua"),
) )
.expect("load linewrap builtin chunk"); .expect("load linewrap builtin chunk");
// Git integration Stage 1 (docs/git-integration-framing.md):
// `*git-status*` and `*git-diff*`. Loaded after `listview.lua`,
// whose `open` (and whose new optional `keys` table) it drives,
// and after `window.lua`, which owns `window.panel-height` — the
// setting a `display = "panel"` listview resolves. It binds no
// global key: an opening chord is a command-surface decision and
// the framing did not make one, so the entry point is
// `M-x git.status`.
lua_host
.eval(
Some("@pmacs/builtin/runtime/git.lua"),
include_str!("../builtin/runtime/git.lua"),
)
.expect("load git builtin chunk");
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL // T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the // was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it // M7.11 deliverable migrates it to the package system so it

File diff suppressed because it is too large Load Diff