feat(git): Stage 1 --- *git-status* and *git-diff*, no wire change
Implements docs/git-integration-framing.md revision 5 (approved
2026-08-09). COHERENCE.md section 15's largest named gap gets something
to attach to: a user can now answer "what have I changed?" without
leaving the editor.
*git-status* a `pmacs.listview` panel over
`git --no-optional-locks -C <root> status
--porcelain=v2 --branch -z`. RET visits the file, `d`
shows its diff, `g` refreshes.
*git-diff* the file-level diff, in a generated buffer rendered as
plain text --- there is no bundled `diff` grammar and no
hunk model anywhere in the tree.
NO WIRE CHANGE: no pmacs-protocol edit, no PROTOCOL_VERSION bump, no
DecorationKind variant. That is load-bearing for scheduling, not a
coincidence --- gutter markers (Stage 2) need all three and must be
scheduled alone, while this lane could run beside another.
One additive `listview` change, and the framing was wrong to say there
would be none: an optional `keys` table on the open spec. `d` cannot be
bound from outside the primitive safely, because a name collision
disambiguates to `<2>` and the name a consumer passed is not necessarily
the buffer it got. Keys are INSTALLED ONCE with the panel's buffer and
COMPARED on reopen: `Keymap::bind` refuses duplicates, and the async
completion model re-opens on every refresh, so a naive implementation
would have errored on every successful refresh.
One config-registry setting, `git.enabled`, through `pmacs.config.define`.
Facts measured against real git rather than reasoned about, each pinned
by a test:
* `ProjectKind::Git` means a BARE repository, and a language marker
beside `.git` wins --- so pmacs reports `kind = "rust"` for its own
repository. This module never asks pmacs whether something is a repo;
it runs `rev-parse --show-toplevel` and lets a non-zero exit answer.
* `git diff --no-index` implies `--exit-code`: exit 1 means it
SUCCESSFULLY found differences. The untracked predicate is exit in
{0,1}; only >= 2 is failure. Under the naive predicate every untracked
diff --- the case `--no-index` exists for --- would render a failure.
* An unborn HEAD makes `git diff HEAD` exit 128. Detected from
`# branch.oid (initial)` in output already being parsed, never from a
second `rev-parse`. `AM`/`AD` carry both states and get two labelled
patches; rename/copy is asserted UNREACHABLE, because `git mv` on a
staged-but-uncommitted file yields `1 A.`, not a `2` record.
* Under `-z` a rename's origin is the NEXT NUL-terminated field, not a
tab-joined suffix, so the record tokenizer is new rather than ported
from `tests/fixtures/pmacs-magit/`. What ports is that fixture's
SEPARATION --- pure `parse_*` over a string --- and its case coverage.
The fixture is untouched: it exists to prove the package system can
host this, and bundled code becoming its dependency would make
`m8_6_acceptance` test less than it claims.
Coherence impact, stated per CLAUDE.md:
* Section 14: `*git-status*` is the FIFTH `listview` call site and the
first outside `lsp.lua` --- the evidence P5 asked for that the
primitive generalizes past its first consumer.
* Section 6: no new interaction island. `d` is an ordinary buffer-local
binding through the primitive's own path, so `describe-key` reports
the truth and `init.lua` can rebind it. The count stays at six.
* Section 9: NEGATIVE, and named as such. A spawned process does not
appear in `*workers*` --- that view is `async.lua`'s job list. This
adds a fifth background thing with no single place to see it. Every
spawn is labelled, which is better than anonymous, but a label is not
attribution. Accepted only because these are short-lived reads.
* Journey: no step added. Git is not a journey step and this does not
make it one.
Section 15's "no Git integration at all ... anywhere in the tree" is
narrowed here. It was literally false when written ---
`tests/fixtures/pmacs-magit/` is a tracked, installable package that
spawns git and parses porcelain v2 --- and the product gap it described
is what this closes.
Five things found by biting the suite rather than by reading, recorded
in docs/active-work.md: `listview.open`'s `seat_cursor` walks DOWN from
wherever the cursor is (so a re-opened panel lands one row low, and the
completion handler seats unconditionally from line 0); a selection test
that inserts ONE row above the selection is vacuous against exactly that
off-by-one; `{:?}` on a Rust string cannot build a `-z` fixture, because
Lua's decimal escape swallows the digit after `\0` --- which made one
test pass while parsing nothing; a path may contain a newline, so rows
escape it; and untracked rows sort after every tracked row.
Gates: scripts/gate --acceptance git_status_stage1_acceptance
--acceptance listview_acceptance --acceptance config_registry_acceptance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
2d2d63abfc
commit
40027340df
34
COHERENCE.md
34
COHERENCE.md
|
|
@ -1293,6 +1293,17 @@ Primitive-by-primitive against the list above:
|
|||
that found it (§25). All four remain in `lsp.lua`; per §25 the
|
||||
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
|
||||
the only one supplying `on_refresh`. `g` is bound on all four
|
||||
unconditionally by `bind_local_keymap`, so the other three carry a
|
||||
|
|
@ -1422,10 +1433,25 @@ What does not:
|
|||
|
||||
- **Code actions apply the first action blindly** — no picker (a
|
||||
roadmap "dark matter" item still true at audit).
|
||||
- **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.
|
||||
- **Git integration reaches status and diff, and no further.** Stage 1
|
||||
(`docs/git-integration-framing.md`) ships `*git-status*` — a
|
||||
`listview` panel over `git status --porcelain=v2 --branch -z`, with
|
||||
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,
|
||||
`docs/dap-debugging-framing.md`).
|
||||
- No missing-tool guidance affordances (§1.2 — the diagnostic that
|
||||
|
|
|
|||
|
|
@ -0,0 +1,925 @@
|
|||
-- 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 <root> 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 <dir> 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<String>` 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, root, rest, on_done)
|
||||
local spec = {
|
||||
label = label,
|
||||
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,
|
||||
}
|
||||
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 `""`.
|
||||
local function first_line(text)
|
||||
local line = (text or ""):match("^[^\r\n]*") or ""
|
||||
return (line:gsub("%s+$", ""))
|
||||
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.
|
||||
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 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
|
||||
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 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <Xscore> <path>\0<origPath>
|
||||
--
|
||||
-- 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 <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
|
||||
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
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 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,
|
||||
generation = 0,
|
||||
}
|
||||
|
||||
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 <down> p <up> 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 refresh generation 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 state.generation
|
||||
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 generation, 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 request.generation ~= state.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
|
||||
|
||||
local rows
|
||||
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)
|
||||
pmacs.editor.set_status("git status: " .. reason)
|
||||
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
|
||||
|
||||
-- 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
|
||||
|
||||
local function start_status(root, expect_buffer, want_selection)
|
||||
state.generation = state.generation + 1
|
||||
local request = {
|
||||
generation = state.generation,
|
||||
expect_buffer = expect_buffer,
|
||||
selected_path = want_selection and selected_path() or nil,
|
||||
}
|
||||
run_git("git status", 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
|
||||
start_status(state.root, state.buffer, true)
|
||||
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
|
||||
|
||||
--- 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
|
||||
-- The root rule (Q#G-2): ask git, and let a non-zero exit BE the
|
||||
-- "not a repository" answer. `-C <dir>` with no root of our own.
|
||||
run_git("git rev-parse", nil, { "-C", dir, "rev-parse", "--show-toplevel" },
|
||||
function(res)
|
||||
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", dir))
|
||||
end
|
||||
return
|
||||
end
|
||||
local root = first_line(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
|
||||
start_status(root, nil, false)
|
||||
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"
|
||||
|
||||
-- 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.
|
||||
return {
|
||||
header = string.format("against HEAD (renamed from %s)",
|
||||
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
|
||||
|
||||
-- Run the plan's steps in order, then render.
|
||||
local function run_diff_plan(row, plan)
|
||||
local pieces = {}
|
||||
local index = 0
|
||||
local step_done
|
||||
local function next_step()
|
||||
index = index + 1
|
||||
local step = plan.steps[index]
|
||||
if not step then
|
||||
local body = table.concat(pieces, "\n")
|
||||
if body:gsub("%s", "") == "" then
|
||||
body = "(no differences)"
|
||||
end
|
||||
show_diff_buffer(string.format("git diff --- %s\n%s",
|
||||
pmacs.git.display_path(row.path), plan.header), body)
|
||||
return
|
||||
end
|
||||
run_git("git diff", state.root, step.args, function(res) step_done(step, res) end)
|
||||
end
|
||||
step_done = function(step, res)
|
||||
if not diff_step_ok(step, res) then
|
||||
local reason = failure_reason(res)
|
||||
show_diff_buffer(string.format("git diff --- %s",
|
||||
pmacs.git.display_path(row.path)), reason)
|
||||
pmacs.editor.set_status("git diff: " .. reason)
|
||||
return
|
||||
end
|
||||
local text = utf8_clean(res.stdout)
|
||||
if step.label then
|
||||
pieces[#pieces + 1] = string.format("=== %s ===\n%s", step.label,
|
||||
text ~= "" and text or "(no changes)\n")
|
||||
else
|
||||
pieces[#pieces + 1] = text
|
||||
end
|
||||
next_step()
|
||||
end
|
||||
next_step()
|
||||
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
|
||||
run_diff_plan(row, diff_plan(row, (state.branch or {}).unborn == true))
|
||||
end,
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
-- rows = { { text = "src/foo.rs:12:4", item = <any> }, ... },
|
||||
-- on_visit = function(item) ... end, -- RET/SPC (optional)
|
||||
-- on_refresh = function() return rows end, -- g (optional)
|
||||
-- keys = { d = "git.diff-file" }, -- extra buffer-local keys
|
||||
-- }
|
||||
|
||||
pmacs.listview = pmacs.listview or {}
|
||||
|
|
@ -263,19 +264,173 @@ local function seat_cursor(p, line)
|
|||
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(seq, command)
|
||||
pmacs.keymap.bind { scope = "buffer", buffer = buf, sequence = seq, command = command }
|
||||
for _, entry in ipairs(FIXED_KEYS) do
|
||||
pmacs.keymap.bind {
|
||||
scope = "buffer", buffer = buf, sequence = entry[1], command = entry[2],
|
||||
}
|
||||
end
|
||||
bind("RET", "listview.visit")
|
||||
bind("SPC", "listview.visit")
|
||||
bind("n", "cursor.down")
|
||||
bind("<down>", "cursor.down")
|
||||
bind("p", "cursor.up")
|
||||
bind("<up>", "cursor.up")
|
||||
bind("TAB", "listview.toggle")
|
||||
bind("g", "listview.refresh")
|
||||
bind("q", "listview.quit")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Consumer-supplied keys (Q#G-7)
|
||||
-- ---------------------------------------------------------------------
|
||||
--
|
||||
-- An optional `keys = { <sequence> = <command name> }` on the open
|
||||
-- spec, bound through the SAME `pmacs.keymap.bind { scope = "buffer" }`
|
||||
-- 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
|
||||
|
||||
-- Reject collisions BEFORE anything is created or bound, so a bad
|
||||
-- `keys` table leaves no half-built panel behind.
|
||||
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 validated entries, rolling back on failure so a refusal the
|
||||
-- structural check above could not predict (an alias spelling of a
|
||||
-- chord, say) still leaves nothing half-installed.
|
||||
local function install_keys(buf, entries)
|
||||
local bound = {}
|
||||
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
|
||||
for _, sequence in ipairs(bound) do
|
||||
pcall(pmacs.keymap.unbind, { scope = "buffer", buffer = buf, sequence = sequence })
|
||||
end
|
||||
error(string.format(
|
||||
"listview: cannot bind %q to %q: %s",
|
||||
entry.sequence, entry.command, tostring(err)))
|
||||
end
|
||||
bound[#bound + 1] = entry.sequence
|
||||
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
|
||||
|
||||
-- Build the persistent panel record for `name`. A user-killed panel
|
||||
|
|
@ -293,9 +448,23 @@ end
|
|||
-- collision disambiguates `<2>`..`<99>`, and exhausting the limit raises
|
||||
-- rather than adopting --- the rule terminal.lua:300-305 states and
|
||||
-- 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)
|
||||
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
|
||||
if find_buffer_by_name(actual) then
|
||||
|
|
@ -315,8 +484,8 @@ local function ensure_panel(name)
|
|||
|
||||
local buf = pmacs.buffer.create(actual)
|
||||
p = { requested_name = name, buffer = buf, line_to_item = {},
|
||||
line_to_row = {}, collapsed = {}, rows = {}, visible = 0 }
|
||||
panels[#panels + 1] = p
|
||||
line_to_row = {}, collapsed = {}, rows = {}, visible = 0,
|
||||
keys = key_entries }
|
||||
-- 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,
|
||||
|
|
@ -331,13 +500,23 @@ local function ensure_panel(name)
|
|||
-- 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)
|
||||
-- Registered LAST, deliberately: an `install_keys` failure 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
|
||||
end
|
||||
|
||||
function pmacs.listview.open(spec)
|
||||
assert(type(spec) == "table" and type(spec.name) == "string",
|
||||
"listview.open: spec.name (string) required")
|
||||
local p = ensure_panel(spec.name)
|
||||
-- Validated BEFORE `ensure_panel`, so a colliding `keys` table never
|
||||
-- reaches buffer creation (Q#G-7: rejected at install time).
|
||||
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.on_visit = spec.on_visit
|
||||
p.on_refresh = spec.on_refresh
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ also removed: this branch's "R8 NEEDS A LANE" investigation block, and
|
|||
durable facts are in the retired registry row and the handoff §6
|
||||
census.
|
||||
|
||||
## Git integration Stage 1 — BRANCHED, pre-implementation
|
||||
## Git integration Stage 1 — IMPLEMENTED, gates green, PR not opened
|
||||
|
||||
**Written with the lane's first commit, before the PR exists** — the
|
||||
standing correction from #171 and #215. This session it was missed on
|
||||
|
|
@ -300,7 +300,52 @@ authoritative tip** — the ref, not a SHA. Recover with
|
|||
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 <the new suite>`.
|
||||
- **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.
|
||||
|
||||
## QoL arc retirement — PR #224 OPEN (docs only)
|
||||
|
||||
|
|
|
|||
|
|
@ -231,12 +231,33 @@ all share one keymap:
|
|||
| `RET` / `SPC` | `listview.visit` — act on the item under the cursor |
|
||||
| `n` / `<down>` | `cursor.down` |
|
||||
| `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 |
|
||||
| `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*`,
|
||||
`*lsp-help*` (hover docs). Header text always spells out the same
|
||||
`RET`/`n`/`p`/`g`/`q` legend inline.
|
||||
`*lsp-help*` (hover docs), `*lsp*` (`lsp.status`), and `*git-status*`
|
||||
(`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
|
||||
keymap, layered on the same idiom, in `builtin/commands/default.lua`:
|
||||
|
|
|
|||
|
|
@ -769,6 +769,20 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/linewrap.lua"),
|
||||
)
|
||||
.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
|
||||
// was loaded directly via `eval(include_str!(...))`; the
|
||||
// M7.11 deliverable migrates it to the package system so it
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue