From 4903c7cfb62422710abc8aa95edbc0dbd33be7d9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 10 Aug 2026 22:17:37 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- builtin/runtime/git.lua | 209 ++++++++++++++++++------- docs/active-work.md | 52 +++++-- tests/git_status_stage1_acceptance.rs | 212 +++++++++++++++++++++++++- 3 files changed, 401 insertions(+), 72 deletions(-) diff --git a/builtin/runtime/git.lua b/builtin/runtime/git.lua index 4b99f6f..fcefabd 100644 --- a/builtin/runtime/git.lua +++ b/builtin/runtime/git.lua @@ -231,9 +231,17 @@ local pump = {} -- 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 function run_git(label, purpose, root, rest, on_done) local spec = { label = label, + -- Required since worker identity Stage 1 (#232), and deliberately + -- NOT the label. `label` identifies the process --- "git status" --- + -- while this says what the run is FOR, which is what someone reading + -- `*workers*` or the statusline activity indicator needs: three of + -- this module's spawns are all "git", and only the purpose tells + -- them apart. Each call site writes its own; copying the label + -- across is the failure that ruling was made against. + purpose = purpose, command = pmacs.git._program, args = pmacs.git.argv(root, rest), stdin = "null", @@ -241,6 +249,7 @@ local function run_git(label, root, rest, on_done) if root then spec.cwd = root end pmacs.git._last_spawn = { command = spec.command, args = spec.args, cwd = spec.cwd, label = spec.label, + purpose = spec.purpose, } local log = pmacs.git._spawn_log log[#log + 1] = spec.args @@ -670,6 +679,44 @@ local function open_status_panel(rows) state.buffer = pmacs.window.buffer() end +-- --------------------------------------------------------------------- +-- The destination boundary (Q#JR14, Q#DC-1) +-- --------------------------------------------------------------------- +-- +-- Every continuation in this module renders a tick or more after the +-- keypress that asked for it, and until #231 there was nothing it could +-- render *to* except ambient state: whichever frontend happened to be +-- active when git exited. Run `M-x git.status` in frontend A, let B +-- become active while `git status` runs, and A's panel opened in B. +-- +-- So the destination is CAPTURED AT INVOCATION and threaded through, +-- exactly as this module already threads the generation, the root and +-- the row --- and for the same reason. `state.root` has a comment +-- explaining why reading module-level state per step is wrong; the +-- ambient frontend is that same argument one layer down, and it was the +-- one thing still being read late. + +--- Run `body` against the destination captured at invocation. +--- +--- Returns false when the commit is REFUSED --- the captured window or +--- buffer is gone, or the frontend can no longer satisfy `profile`. +--- A refusal drops the render, which is the same answer the +--- `expect_buffer` rule already gives when the panel a refresh belongs +--- to has been killed: a result whose destination no longer exists is +--- not a result to force somewhere else. `commit_to` refuses BEFORE the +--- body runs, so a refusal is mutation-free and there is no partial +--- render to undo. +--- +--- Deliberately not `pcall`-wrapped. A refusal returns `(false, reason)` +--- and is handled here; anything that RAISES is a defect in this module +--- (a fabricated destination, an unknown profile) and must not be +--- swallowed into a silent no-op. +local function commit_ui(dest, profile, body) + local ok, reason = pmacs.window.commit_to(dest, body, profile) + if ok == false then return false, reason end + return true +end + --- The status-refresh ticket currently in force. --- --- Exposed alongside `_deliver_status` below, and for the same reason: @@ -704,7 +751,12 @@ function pmacs.git._deliver_status(request, res) if not (live and valid) then return end end - local rows + -- Rows and the status line are COMPUTED here and EMITTED inside the + -- commit below. Splitting them is the point: `set_status` is a UI + -- mutation, and a failure message announcing a panel that the commit + -- then refuses to open is the misrouting this boundary exists to + -- prevent, in its most confusing form. + local rows, message if res.ok and res.code == 0 then local parsed = pmacs.git.parse_status(res.stdout) state.branch = parsed.branch @@ -714,41 +766,50 @@ function pmacs.git._deliver_status(request, res) local reason = failure_reason(res) state.branch = state.branch or {} rows = failure_rows(reason) - pmacs.editor.set_status("git status: " .. reason) + message = "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 + -- The PANEL profile: `*git-status*` is a bottom-panel surface + -- (`listview.open` defaults `display` to `"panel"`), so the + -- stale-intent checks that guard replacing a document window do not + -- apply --- but only while the placement really is a panel, which is + -- what the profile's own refusal keeps true for the extent of this + -- body (Q#DC-2). + commit_ui(request.dest, "panel", function() + if message then pmacs.editor.set_status(message) end + open_status_panel(rows) + -- `listview.open` resets collapse and does NOT preserve selection --- + -- only `listview.refresh` does, and that is the synchronous path this + -- model cannot use. So re-seating is owned here. + -- + -- And `open`'s own `seat_cursor(p, 1)` cannot be relied on either: it + -- walks DOWN from wherever the cursor is, on the premise that a fresh + -- `switch_active_buffer` zeroed it. Re-opening a panel that is + -- already displayed does not zero anything, so that walk would land + -- one row below the previous cursor instead of on row 1. The handler + -- therefore seats unconditionally, from line 0. + local target = 1 + if request.selected_path then + for i, row in ipairs(state.rows) do + if row.path == request.selected_path then + target = i + break + end end end - 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 + -- If the captured path is gone --- the commonest case, since a file + -- that stopped being modified drops out of status --- `target` stays + -- 1 and nothing is said about it. That is the correct answer, not a + -- failure. + pmacs.editor.clear_selection() + pmacs.editor.set_view_top(0) + pmacs.editor.move_to_line(0) + -- Walked with `move_down` rather than a single `move_to_line(target)` + -- because motion is what drags the viewport along; a bare cursor set + -- would leave a long status list scrolled to the top with the cursor + -- off screen. This is `listview.refresh`'s own idiom. + for _ = 1, target do pmacs.editor.move_down() end + end) end -- The path of the row under the cursor right now, or nil. @@ -767,13 +828,19 @@ end --- generation and replace the newer request. Reserving at the command --- and carrying it through is what makes the ordering the user's, not --- the filesystem's. -local function start_status(root, expect_buffer, want_selection, generation) +local function start_status(root, expect_buffer, want_selection, generation, dest) local request = { generation = generation, expect_buffer = expect_buffer, selected_path = want_selection and selected_path() or nil, + -- A parameter for the same reason `generation` is: it belongs to + -- the invocation, and this function can run from a root-resolution + -- callback that is already a tick removed from it. + dest = dest, } - run_git("git status", root, + run_git("git status", + "reading the working tree status of " .. root .. " for the *git-status* panel", + root, { "status", "--porcelain=v2", "--branch", "-z" }, function(res) pmacs.git._deliver_status(request, res) end) end @@ -793,8 +860,12 @@ function pmacs.git._on_refresh() end -- Reserved HERE, at the keypress, for the same reason `git.status` -- reserves at the command: `g` needs no root lookup, so this is - -- already the moment of invocation. - start_status(state.root, state.buffer, true, status_requests.reserve()) + -- already the moment of invocation. The destination is captured on + -- the same line of reasoning --- `g` is pressed IN the panel, so the + -- frontend showing it is the one this refresh belongs to, and that is + -- true now and possibly not true when git exits. + start_status(state.root, state.buffer, true, status_requests.reserve(), + pmacs.window.capture_destination()) return listview_rows("(refreshing...)") end @@ -865,8 +936,12 @@ function pmacs.git._deliver_root(request, res) -- A fresh open carries no buffer expectation, so a panel the user -- killed earlier does not make this run drop its own first result. state.buffer = nil - -- The generation reserved at the command, NOT a fresh one. - start_status(root, nil, false, request.generation) + -- The generation reserved at the command, NOT a fresh one --- and the + -- destination captured there, for the same reason. This callback runs + -- after `git rev-parse` has exited, so capturing here would read the + -- ambient frontend a round trip late and reintroduce exactly the + -- misrouting the capture exists to close. + start_status(root, nil, false, request.generation, request.dest) end --- Open (or re-open) `*git-status*` for the repository containing the @@ -885,10 +960,19 @@ function pmacs.git.status() -- invocation that starts no work must not invalidate one that is -- already in flight, and an invocation that does start work must own -- the newest generation from that moment on. - local request = { generation = status_requests.reserve(), dir = dir } + -- Captured alongside the generation, at the same moment and for the + -- same reason: this is the last instant at which "the frontend the + -- user asked from" is knowable without guessing. + local request = { + generation = status_requests.reserve(), + dir = dir, + dest = pmacs.window.capture_destination(), + } -- The root rule (Q#G-2): ask git, and let a non-zero exit BE the -- "not a repository" answer. `-C ` with no root of our own. - run_git("git rev-parse", nil, { "-C", dir, "rev-parse", "--show-toplevel" }, + run_git("git rev-parse", + "resolving which Git repository contains " .. dir, + nil, { "-C", dir, "rev-parse", "--show-toplevel" }, function(res) pmacs.git._deliver_root(request, res) end) end @@ -1038,11 +1122,21 @@ local function advance_diff(request) if body:gsub("%s", "") == "" then body = "(no differences)" end - show_diff_buffer(string.format("git diff --- %s\n%s", - pmacs.git.display_path(request.row.path), request.plan.header), body) + -- The DOCUMENT profile, and the contrast with the status channel is + -- the whole of Q#DC-2: `*git-diff*` REPLACES a document window, so + -- every stale-intent check applies. If the window the `d` was + -- pressed from now holds a different buffer, this diff is answering + -- a question about a view the user has already left, and the commit + -- is refused rather than allowed to overwrite it. + commit_ui(request.dest, "document", function() + show_diff_buffer(string.format("git diff --- %s\n%s", + pmacs.git.display_path(request.row.path), request.plan.header), body) + end) return end - run_git("git diff", request.root, step.args, + run_git("git diff", + "diffing " .. pmacs.git.display_path(request.row.path) .. " against HEAD for *git-diff*", + request.root, step.args, function(res) pmacs.git._deliver_diff(request, step, res) end) end @@ -1067,9 +1161,15 @@ function pmacs.git._deliver_diff(request, step, res) if not diff_requests.is_current(request.generation) then return end 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(request.row.path)), reason) - pmacs.editor.set_status("git diff: " .. reason) + -- The failure render is a render: same destination, same profile, + -- same refusal. Announcing a diff failure into whatever frontend + -- happens to be active would be the identical misrouting as + -- announcing a success there. + commit_ui(request.dest, "document", function() + show_diff_buffer(string.format("git diff --- %s", + pmacs.git.display_path(request.row.path)), reason) + pmacs.editor.set_status("git diff: " .. reason) + end) return end local text = utf8_clean(res.stdout) @@ -1085,10 +1185,10 @@ end -- Run `plan`'s steps in order under an ALREADY-RESERVED diff ticket, -- then render. `generation` is a parameter for the same reason -- `start_status`'s is: it belongs to the keypress, not to this call. -local function run_diff_plan(row, plan, root, generation) +local function run_diff_plan(row, plan, root, generation, dest) advance_diff { generation = generation, row = row, plan = plan, root = root, - pieces = {}, index = 0, + pieces = {}, index = 0, dest = dest, } end @@ -1122,10 +1222,13 @@ pmacs.command.define { -- Everything the plan runs on is captured HERE, at the keypress, and -- threaded through every step: the ticket, reserved AFTER the early -- returns above so a `d` that starts no work cannot supersede one - -- that is already in flight; the root; and the unborn flag. All three - -- describe the repository the user is looking at right now, and all - -- three are replaced wholesale by a `git.status` against another one. + -- that is already in flight; the root; the unborn flag; and the + -- DESTINATION. The first three describe the repository the user is + -- looking at right now and are replaced wholesale by a `git.status` + -- against another one; the fourth describes the window they are + -- looking at it IN, which nothing in this module replaces and + -- nothing outside it announces. run_diff_plan(row, diff_plan(row, (state.branch or {}).unborn == true), - state.root, diff_requests.reserve()) + state.root, diff_requests.reserve(), pmacs.window.capture_destination()) end, } diff --git a/docs/active-work.md b/docs/active-work.md index 0312ffe..f667aa4 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -269,7 +269,20 @@ census. **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. **Now at `4b82d1e`, MERGE-BLOCKED.** +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 ``" from +"reading the working tree status of ``" from "diffing `` +against HEAD". **Review round 1 found three blockers. Two are fixed; the third is why this lane is blocked.** @@ -299,17 +312,32 @@ this lane is blocked.** `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 NOT fixed, and deliberately — it blocks this PR.** The async - completions display UI without capturing the initiating frontend - (`builtin/runtime/git.lua:684` and `:965` at `723afa7`), so a result - surfaces in whichever frontend is active when git exits. `commit_to` - is the right mechanism and is **not Lua-reachable** outside a - directory open, so the fix lives in the **`destination-capture`** - lane — approved and in implementation. This lane adopts it after that - lands. Verified untouched: `show_diff_buffer`'s body and - `open_status_panel`'s `listview.open` are byte-identical to `4002734`, - and no commit on this branch adds a `commit_to` call or a frontend - capture anywhere. +- **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 diff --git a/tests/git_status_stage1_acceptance.rs b/tests/git_status_stage1_acceptance.rs index f3e71ca..aeea222 100644 --- a/tests/git_status_stage1_acceptance.rs +++ b/tests/git_status_stage1_acceptance.rs @@ -688,7 +688,8 @@ fn g6_2b_a_non_utf8_row_refuses_both_gestures_with_a_message() { &s, &format!( "pmacs.git._deliver_status(\n\ - {{ generation = pmacs.git._generation() }},\n\ + {{ generation = pmacs.git._generation(),\n\ + dest = pmacs.window.capture_destination() }},\n\ {{ ok = true, code = 0, stdout = {payload}, stderr = '' }})" ), ); @@ -1034,7 +1035,8 @@ fn g6_4b_a_copy_says_copied_and_a_rename_says_renamed() { &s, &format!( "pmacs.git._deliver_status(\n\ - {{ generation = pmacs.git._generation() }},\n\ + {{ generation = pmacs.git._generation(),\n\ + dest = pmacs.window.capture_destination() }},\n\ {{ ok = true, code = 0, stdout = {}, stderr = '' }})", z_payload(&refs) ), @@ -1323,7 +1325,8 @@ fn g6_22_a_two_step_plan_keeps_the_root_it_started_with() { &s, &format!( "pmacs.git._deliver_root(\n\ - {{ generation = pmacs.git._generation(), dir = {b:?} }},\n\ + {{ generation = pmacs.git._generation(), dir = {b:?},\n\ + dest = pmacs.window.capture_destination() }},\n\ {{ ok = true, code = 0, stdout = {b:?}, stderr = '' }})" ), ); @@ -1722,7 +1725,8 @@ fn g6_17_a_stale_completion_discards_its_rows() { &format!( "local current = pmacs.git._generation()\n\ pmacs.git._deliver_status(\n\ - {{ generation = current - 1 }},\n\ + {{ generation = current - 1,\n\ + dest = pmacs.window.capture_destination() }},\n\ {{ ok = true, code = 0, stdout = {payload}, stderr = '' }})" ), ); @@ -1743,7 +1747,8 @@ fn g6_17_a_stale_completion_discards_its_rows() { &s, &format!( "pmacs.git._deliver_status(\n\ - {{ generation = pmacs.git._generation() }},\n\ + {{ generation = pmacs.git._generation(),\n\ + dest = pmacs.window.capture_destination() }},\n\ {{ ok = true, code = 0, stdout = {payload}, stderr = '' }})" ), ); @@ -1958,7 +1963,8 @@ fn g6_23_a_superseded_diff_does_not_replace_the_newer_one() { "pmacs.git._deliver_diff(\n\ {{ generation = {gen_a}, row = {{ path = 'staged.txt' }},\n\ plan = {{ header = 'against HEAD', steps = {{}} }},\n\ - root = '/', pieces = {{}}, index = 0 }},\n\ + root = '/', pieces = {{}}, index = 0,\n\ + dest = pmacs.window.capture_destination() }},\n\ {{}},\n\ {{ ok = true, code = 0, stdout = 'STALE-DIFF-SENTINEL\\n', stderr = '' }})" ); @@ -1978,7 +1984,8 @@ fn g6_23_a_superseded_diff_does_not_replace_the_newer_one() { "pmacs.git._deliver_diff(\n\ {{ generation = {gen_a}, row = {{ path = 'staged.txt' }},\n\ plan = {{ header = 'against HEAD', steps = {{}} }},\n\ - root = '/', pieces = {{}}, index = 0 }},\n\ + root = '/', pieces = {{}}, index = 0,\n\ + dest = pmacs.window.capture_destination() }},\n\ {{}},\n\ {{ ok = true, code = 128, stdout = '',\n\ stderr = 'fatal: STALE-FAILURE' }})" @@ -2477,6 +2484,197 @@ fn g6_config_git_enabled_is_registry_defined_and_honoured() { assert!(panel_text(&s).is_empty(), "and no panel opened"); } +// --------------------------------------------------------------------------- +// Q#G-9 — the continuation lands where it was ASKED FROM +// --------------------------------------------------------------------------- + +/// The frontend that competes for ambient authority while git runs. +/// +/// Same shape and same id as `destination_capture_acceptance`'s, so the +/// two suites describe one mechanism rather than two conventions. +const COMPETITOR: FrontendId = FrontendId(7); + +/// The buffer name in `fid`'s own side (panel) slot, or `None` when that +/// frontend has no panel. +/// +/// **Per-frontend on purpose.** `panel_text` finds `*git-status*` by +/// NAME, which is global — it answers "does this buffer exist and what +/// is in it", not "which frontend is showing it". A name lookup is +/// therefore satisfied by a render into the wrong frontend, and an +/// earlier draft of `g6_25` passed its own mutation for exactly that +/// reason. +fn panel_buffer_name(s: &EditorState, fid: FrontendId) -> Option { + let core = s.core.borrow(); + let side = core.side_window_for(fid)?; + let buffer_id = core.windows.get(&side)?.buffer_id; + let reg = core.registry.borrow(); + Some(reg.get(buffer_id).ok()?.name().to_string()) +} + +/// The buffer name in `fid`'s active window. +fn active_name_in(s: &EditorState, fid: FrontendId) -> Option { + let core = s.core.borrow(); + let win = core.views.get(&fid)?.active; + let buffer_id = core.windows.get(&win)?.buffer_id; + let reg = core.registry.borrow(); + Some(reg.get(buffer_id).ok()?.name().to_string()) +} + +/// Register a second frontend with its own single-window layout. +fn attach_frontend(s: &EditorState) -> pmacs::window::WindowId { + use pmacs::window::{FrontendView, Layout, Window, WindowId}; + let win = WindowId::next(); + let mut core = s.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + core.windows + .insert(win, Window::new(win, buffer_id, text_view)); + core.register_frontend_view( + COMPETITOR, + FrontendView { + layout: Layout::single(win), + active: win, + fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + win +} + +/// **The defect this lane's review found, pinned on both channels.** +/// +/// `git status` and `git diff` settle a tick or more after the keypress. +/// Before the destination capture, both rendered through whichever +/// frontend was ambient *at completion* — so running `M-x git.status` in +/// frontend A and letting B become active while git ran opened A's panel +/// in B. The generation and root were already captured at invocation; +/// the frontend was the one thing still read late. +/// +/// Driven through `_deliver_status` / `_deliver_diff` — the seams the +/// concurrency tests already use — because the switch has to happen +/// *while the work is in flight*, and no arrangement of real subprocess +/// timing can produce that moment on demand. +/// +/// **Falsified by dropping either `commit_ui` call**: the render then +/// follows the ambient frontend and the competitor's window changes. +/// Asserting only that the capturing frontend got the buffer would pass +/// on a commit that wrote to *both*, so the competitor is asserted +/// unchanged as well. +#[test] +fn g6_25_a_completion_lands_in_the_frontend_that_asked() { + let (_td, root) = tempdir(); + init_repo(&root); + mixed_repo(&root); + let mut s = editor(); + open_panel(&mut s, &root, "staged.txt"); + + let other_win = attach_frontend(&s); + let other_before = { + let core = s.core.borrow(); + core.windows.get(&other_win).map(|w| w.buffer_id) + }; + + // Captured while LOCAL is the acting frontend — the invocation. + exec(&s, "saved_dest = pmacs.window.capture_destination()"); + + // The competitor takes ambient authority while git is "running". + s.core.borrow_mut().active_frontend = COMPETITOR; + + let row = format!("1 .M N... 100644 100644 100644 {H} {H} SENTINEL.txt"); + let payload = z_payload(&["# branch.oid deadbeef", "# branch.head main", &row]); + exec( + &s, + &format!( + "pmacs.git._deliver_status(\n\ + {{ generation = pmacs.git._generation(), dest = saved_dest }},\n\ + {{ ok = true, code = 0, stdout = {payload}, stderr = '' }})" + ), + ); + + // Asserted PER FRONTEND. `panel_text` alone would pass on a render + // into the competitor, because it finds the buffer by name. + assert_eq!( + panel_buffer_name(&s, FrontendId::LOCAL).as_deref(), + Some("*git-status*"), + "the panel must be in the capturing frontend's own side slot" + ); + assert_eq!( + panel_buffer_name(&s, COMPETITOR), + None, + "the competing frontend must not have grown a panel" + ); + assert_eq!( + { + let core = s.core.borrow(); + core.windows.get(&other_win).map(|w| w.buffer_id) + }, + other_before, + "…and its document window is untouched too" + ); + + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert!( + panel_text(&s).contains("SENTINEL.txt"), + "the rows must have rendered:\n{}", + panel_text(&s) + ); + + // The diff channel, same shape, DOCUMENT profile. An empty remaining + // plan makes `advance_diff` render on this very delivery. + let root_str = root.display().to_string(); + exec(&s, "saved_dest = pmacs.window.capture_destination()"); + s.core.borrow_mut().active_frontend = COMPETITOR; + let other_before_diff = { + let core = s.core.borrow(); + core.windows.get(&other_win).map(|w| w.buffer_id) + }; + exec( + &s, + &format!( + "pmacs.git._deliver_diff(\n\ + {{ generation = pmacs.git._diff_generation(),\n\ + row = {{ path = 'SENTINEL.txt' }},\n\ + plan = {{ steps = {{}}, header = 'hdr' }},\n\ + pieces = {{}}, index = 0, root = {root_str:?},\n\ + dest = saved_dest }},\n\ + {{ }},\n\ + {{ ok = true, code = 0, stdout = 'diff body', stderr = '' }})" + ), + ); + + // The diff takes a DOCUMENT window, so the discriminating question + // is which frontend's active window now holds `*git-diff*`. + assert_ne!( + active_name_in(&s, COMPETITOR).as_deref(), + Some("*git-diff*"), + "the diff must not have taken the competing frontend's window" + ); + assert_eq!( + { + let core = s.core.borrow(); + core.windows.get(&other_win).map(|w| w.buffer_id) + }, + other_before_diff, + "…and that window's buffer is unchanged" + ); + assert_eq!( + active_name_in(&s, FrontendId::LOCAL).as_deref(), + Some("*git-diff*"), + "the diff must land in the capturing frontend's own window" + ); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert!( + diff_text(&s).contains("diff body"), + "…carrying the body it was given:\n{}", + diff_text(&s) + ); +} + // Isolated bootstrap storage roots: an integration test is compiled // without `cfg(test)`, so a raw `EditorState::new()` would read the // developer's real `init.lua` and write into their real data root.