Commit Graph

8 Commits

Author SHA1 Message Date
Levi Neuwirth 4903c7cfb6
feat(git): adopt the destination capture --- P1a fixed, lane unblocked
The async completions rendered through whichever frontend was ambient
when git exited. Run `M-x git.status` in frontend A, let B become
active while `git status` runs, and A's panel opened in B. The
generation and the root were already captured at the keypress; the
frontend was the one input still read late.

Captured at invocation in all four entrances --- `git.status()`,
`_on_refresh`, `_deliver_root`'s hand-off, and the `git.diff-file`
command --- and threaded on the request table exactly as this module
already threads the generation and root. Committed under the profile
each surface actually takes: `"panel"` for `*git-status*`, `"document"`
for `*git-diff*`.

`set_status` moved INSIDE the status commit. Rows and message are now
computed first and emitted together, because a failure message
announcing a panel that the commit then refuses is the same misrouting
in its most confusing form.

A refused commit DROPS the render, which is the answer the
`expect_buffer` rule already gives when the panel a refresh belongs to
was killed. `commit_to` refuses before the body runs, so there is no
partial render to undo.

THE FIRST VERSION OF `g6_25` WAS WORTHLESS AND PASSED ITS OWN MUTATION.
`panel_text` finds `*git-status*` by NAME, which is global --- it
answers "does this buffer exist", not "which frontend is showing it"
--- so a render into the competitor satisfied it. Rewritten against
`side_window_for` and each view's active window, it now fails both
bites: removing the status commit grows a `*git-status*` panel in the
competing frontend; removing the diff commit hands it the document
window.

The merge also surfaced a cross-lane break invisible until the suites
ran: #232 made `purpose` required on `pmacs.process.spawn`, and this
module's spawn is on this branch, so it was never among the 11 sites
#232 updated. Each spawn now carries its own purpose and NOT the label
--- all three are labelled `git`, and only the purpose separates
resolving a repository from reading its status from diffing one file.

Existing tests that drive `_deliver_status` / `_deliver_diff` through
the internal seam now supply a captured destination. That includes the
one place it is load-bearing rather than uniform: `g6_23` reuses its
"stale" request table at the CURRENT ticket as its positive control,
which is the only one of those three deliveries that reaches a render.

Gates: 12/12 green --- git_status_stage1, destination_capture, journey
and worker_identity acceptance, plus the full sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 22:17:37 +02:00
Levi Neuwirth e94b256cc6
fix(git): say "copied" when a 2 record is a copy, not "renamed"
Porcelain v2's `2` record covers renames AND copies --- the `<Xscore>`
field leads with `R` or `C` --- and the diff header said "renamed from"
for either. A copied file was therefore reported to the user as a
rename, which is a different fact about their tree.

The information was already retained: `parse_status` captures `score`.
Nothing new is parsed; the header reads the byte it already has.

`kind` stays `"rename"` for both, deliberately. Every BEHAVIOUR keyed on
it is identical --- including the two-path `git diff HEAD -- <orig>
<current>`, which is right for a copy as much as for a rename. Splitting
the kind would oblige 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
this fix exists to avoid. The consumers are few and all were checked ---
`diff_plan` is the tree's only `kind == "rename"` branch,
`status_line_text` keys off `row.orig`, and the two tests that name the
kind are `g6_1`'s corpus and `g6_8`'s unborn-unreachability assertion.
`score` has no other reader anywhere.

Read from `score` rather than from `row.x`: the score names
rename-vs-copy whichever side detected the change, while `X` carries the
letter only for an index-side one.

The status ROW is UNCHANGED, and that is a decision rather than an
omission. Its `XY` prefix already reads `R.` against `C.`, out of the
same byte, in the porcelain vocabulary every other row in the panel is
read in --- so the distinction is already on screen, and a second
vocabulary beside it would be a wider surface for no new fact. `g6_4b`
asserts both prefixes so the claim is checked.

Unborn `HEAD` needs nothing, confirmed rather than assumed: `diff_plan`'s
rename branch sits inside `if not unborn`, and `g6_8` already pins that
no `2` record can occur there.

`g6_4b` is a parser/presentation test and says so. Real `git` emits no
`2 C` record --- the test MEASURES that under `-c status.renames=copies`
rather than recalling it --- so the copy row is supplied through
`_deliver_status`, the seam `g6_2b`/`g6_17`/`g6_21` already use.
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. Both classes are
asserted, and so is the argv --- a fix to what the user is TOLD must not
reach what the module DOES.

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.

Gate: `scripts/gate --acceptance git_status_stage1_acceptance
--acceptance listview_acceptance --acceptance config_registry_acceptance`
--- all eleven steps green, acceptance 34/34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 22:12:56 +02:00
Levi Neuwirth 39ad43db5a
fix(git): keep a repository root that ends in a carriage return whole
`strip_output_terminator` stripped `\r?\n$`, and a carriage return is as
legal a byte in a POSIX directory name as a newline is. For a repository
rooted at a directory named `trailing\r`, `rev-parse --show-toplevel`
prints the path's own `0d` and then its own `0a` terminator, and a strip
tolerant of an optional preceding carriage return cannot tell those two
bytes apart --- so it took both. The root resolved as `trailing`, and
every command afterwards ran with a `-C` and a cwd naming a directory
that does not exist: the same defect the previous commit at this call
site fixed, one byte over.

Exactly one trailing `\n` is now removed, by an explicit last-byte test
rather than an anchored pattern. Both of this function's bugs lived in a
pattern, and the third answer to the same question should not be a
cleverer one.

`-z` was CHECKED against the installed git rather than 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`. There is
therefore no unambiguous output representation to prefer over removing
the one byte git appended.

`first_line` is untouched, for the reason the previous commit recorded:
its three callers all feed the single-line status band, where taking the
first line is right.

Witnessed by `g6_14d` end to end, not at the parser: the fixture really
creates directories named `trailing\r` and `nl\nand-trailing\r`, the
real `git` resolves them, and the assertion is on the cwd of the status
spawn the module actually made, plus real rows in the panel and a RET
that opens the file the row names. The second case sends both hazards in
together because a root may hold both and neither fix may mask the
other. `g6_14c` now reaches that chain through the shared
`assert_root_resolves_whole` rather than keeping a second copy of it,
and the helper binds `pmacs.project.set_search_boundary` to the fixture
through `open_panel` --- R8's lesson, and a root fixture is exactly that
hazard's shape.

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 that separates the two fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 18:41:22 +02:00
Levi Neuwirth 723afa717f
fix(git): give diff requests a ticket, and share one channel mechanism
`git.diff-file` started a plan with no request generation while every
plan writes the SINGLETON `*git-diff*` buffer through
`show_diff_buffer`. Press `d` on A, then `d` on B before A finishes: if
A completes last, A's diff replaces B's. The newest invocation loses to
the slowest subprocess --- the same defect the status channel had before
`ffe5ae2`, on the one surface that had never been given the fix.

Every finding on this lane has now been one shape: module-level mutable
state read or written at CONTINUATION time without an invocation-time
ticket. So the rule gets ONE implementation rather than 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, and that is a design decision rather than an oversight: 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 "newest wins" 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`, and a diff is one or two `git diff` runs --- so `_deliver_root`
and `_deliver_status` correctly share one ticket while the diff plan
gets its own. `g6_23` asserts the separation directly: 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 exactly the
reason `_deliver_status` and `_deliver_root` are: no arrangement of real
subprocess timing can make two `git diff` runs finish in a chosen order,
and the contract is about the order the caller did NOT choose. The
ticket check sits at the single point a plan re-enters from a
continuation, so one check covers everything downstream --- no further
spawn, no buffer write, and no status message, since a status line from
a replaced invocation is as wrong as a buffer from one.

Witnessed by `g6_23` in two halves. The real half presses `d` twice with
nothing pumped between, so two plans are genuinely in flight and each
really reserved its own ticket. The driven half then completes the OLDER
request after the newer one has rendered --- the ordering that is the
whole contract, and the one real timing will not produce on demand,
since the first plan spawned normally finishes first and that order
passes on the broken code. A superseded FAILURE is asserted too, since a
buffer-only check would miss the status-message half. The positive
control at the current ticket makes the discards attributable to the
ticket rather than the payload.

Mutation-verified: removing the ticket check fails `g6_23` --- and in
that run it failed at the REAL half, the older plan having overwritten
the newer one's patch before the fabricated delivery was ever reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 16:48:54 +02:00
Levi Neuwirth 842ec61f6f
fix(git): keep a repository root that contains a newline whole
`rev-parse --show-toplevel`'s output was parsed with `first_line`, which
takes `^[^\r\n]*`. A newline is a legal byte in a POSIX path, so a
repository rooted at `/tmp/a\nb` resolved to `/tmp/a` --- and every
command afterwards ran with a `-C` and a cwd naming a directory that
does not exist, turning a working repository into a wall of exit-128
failure rows.

Fixed with a SEPARATE helper, `strip_output_terminator`, used at that
one call site. `first_line` is deliberately left alone: its other three
callers --- the spawn-error text, the stderr detail, and the
`display_file` error string --- all feed the single-line status band,
where a multi-line message corrupts the row layout, so taking the first
line is exactly right for them. Folding the two together would fix one
caller and break three. Both functions now say at their definition which
kind of text they are for and why the other exists.

Exactly one trailing newline is stripped, with an optional preceding
carriage return, because that is what git emits as a terminator; a
second newline would be output rather than a terminator. The trailing
whitespace trim `first_line` also did is NOT carried over --- a path may
legally end in a space.

Witnessed end to end by `g6_14c`, not at the parser: the fixture really
creates `<tmp>/nl\nroot`, the real `git` resolves it, and the assertion
is on the cwd of the status spawn the module actually made, plus real
rows in the panel and a RET that opens the file the row names.
Mutation-verified --- restoring `first_line` there resolves the root to
`<tmp>/nl` and fails the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 16:18:41 +02:00
Levi Neuwirth 3eca5e8f60
fix(git): capture the diff plan's root at `d`, not per step
`run_diff_plan`'s `next_step` read the module-level `state.root` each
time it started a step, so a multi-step plan could change repositories
halfway through.

An unborn `AM`/`AD` row produces a TWO-STEP plan --- staged patch, then
unstaged patch --- and the second step is spawned from the first one's
completion callback. `state.root` is reassigned by `_deliver_root`,
which runs whenever a concurrent `git.status` for another repository
finishes resolving its worktree. Start a diff in A, run `git.status` in
B before the first patch lands, and the plan's second step runs with B
as its cwd and A's path: git there matches nothing, so the unstaged half
silently renders "(no changes)" instead of the worktree delta it exists
to show.

The root is now captured at the keypress and threaded through the plan
as a parameter; `state.root` is not read inside the plan at all. Same
shape as the generation counter fixed in `ffe5ae2` --- capture at the
INVOCATION, never at the continuation --- and the third instance of it,
`state.branch`, is already read at the keypress on the same line.

Witnessed by `g6_22`: an unborn `AM` row's two-step plan with
`state.root` reassigned between the keypress and the first step's
completion, asserting BOTH spawned diff argvs carry the originally
captured root and neither carries the other repository's. Driven through
`_deliver_root` because no arrangement of real subprocess timing can
guarantee the interleaving, and nothing is pumped between the keypress
and the reassignment, so step 1 is genuinely in flight.

The argv assertion is the load-bearing half. A test that checked only
the first step, or only that a diff rendered, passes on the broken code:
step 1 is spawned synchronously from the keypress, and step 2 against
the wrong repository exits 0 with empty output rather than failing.
Mutation-verified --- restoring the `state.root` read fails the test on
the second argv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 16:16:52 +02:00
Levi Neuwirth ffe5ae2d8d
fix(git): reserve the refresh generation at the command, not on arrival
PR #227 review, P1. Two `git.status` invocations against different
repositories were ordered by which `rev-parse` returned first, not by
which the user asked for last.

`git.status` started an UNVERSIONED `rev-parse`, and the generation was
minted later, inside `start_status`, which runs from that lookup's
completion callback. So invoke status in repo A, then repo B: if B's
root resolved first and A's resolved second, A claimed the NEWER
generation and replaced B. The counter that exists to make the newest
INVOCATION win instead made the slowest SUBPROCESS win --- and it did it
silently, since both requests looked well-formed.

The fix is the ordering, not a new check:

* `reserve_generation()` is called at the point the user ASKS ---
  `git.status` after its early returns and before the spawn, `g` at the
  keypress. An invocation that starts no work reserves nothing, so it
  cannot invalidate one already in flight.
* `start_status` takes the reserved generation as a PARAMETER instead of
  minting its own, so the value survives the round trip through the root
  lookup.
* The root-lookup completion is now `pmacs.git._deliver_root`, and it
  drops a superseded result before any effect: no status spawn, no
  `state.root` write, and no status-line message. A message from an
  invocation the user has already replaced is as wrong as a panel from
  one, and the previous shape would have written both.

Exposed for the same reason `_deliver_status` is exposed: the contract
is about completions arriving in an order the caller did not choose, and
no arrangement of real subprocess timing can guarantee two `rev-parse`
runs finish in a chosen order.

Witness: `g6_21_a_superseded_root_lookup_does_not_spawn_its_status`
drives two real invocations, then completes their ROOT LOOKUPS out of
order --- newer first, older second --- and asserts the superseded one
spawns nothing at all, comparing the status-spawn count before and
after. `g6_17` cannot see this: it drives the STATUS completions out of
order, by which point the generation each carries is already fixed.

Bite: restoring the old ordering (mint on arrival, no staleness check)
fails `g6_21` and nothing else. A test that merely hoped for the bad
subprocess order would have passed on the broken code about half the
time, which is why this one drives the completion directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 15:52:17 +02:00
Levi Neuwirth 40027340df
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
2026-08-09 14:43:21 +02:00