Skip to content

Offer a git staging control only where the server proved a slot - #162

Merged
benfavre merged 4 commits into
mainfrom
feat/review-staging-controls-128
Aug 29, 2026
Merged

benfavre merged 4 commits into
mainfrom
feat/review-staging-controls-128

Conversation

@benfavre

Copy link
Copy Markdown
Owner

Closes the last open item of #128: the combined review can now stage, unstage, commit and resolve conflicts, at file level.

The read side has shipped since PR #159 — ReviewFile already carried worktree, conflict, the hunks and the preview metadata, so a partially staged file appeared on both sides and an unresolved conflict was listed only as a conflict. Only the mutations were missing, and #159 and #160 reached the same conclusion independently from opposite directions: ReviewCapabilities advertised nothing that could fence a staging write, so a stage could only have gone out unconfirmed and uncorrelated, and an ambiguous outcome would have been reconcilable by nothing but an uncorrelated key search. That is precisely what the confirmed lane exists to avoid.

bext-stack/automonique PR #225 built the missing fence, and this consumes it.

What arrives on the wire

Two lists on ReviewCapabilities. Each entry names the commit HEAD the server's preflight resolved to and a digest over the whole index it read, because a worktree is shared substrate: another process running as the daemon uid can move either between the advertisement and the action, and the snapshot revision only tracks what the projection observed. The four actions moved into requires_confirmation, so their unconfirmed spelling no longer encodes on either side.

Absence is the withholding

The three staging kinds share one capability type but not one grant — an operator installs index writes, committing and conflict resolution independently. A deployment that grants stage and unstage but not commit therefore advertises two entries, and the commit proposal keeps its observation badge and grows no button. Not a disabled one with a tooltip: a disabled control claims the grant is present and merely unavailable, which is the opposite of what the list says.

SDTEST-1867 demonstrates that rather than asserting it. One run: the withheld commit answers false at the projection, refuses at the preview constructor, and stays visible as an observation; both granted siblings go end to end to a confirmed preview carrying the server's own digests verbatim, still admitted by a re-read of the capability. Then the commit grant is installed and the third control appears from unchanged projection code. Mutation-checked — forcing advertises_staging to true fails it on the intended line.

conflict_resolutions lists one entry per side git actually recorded, because the side decides which blob lands and is therefore inside the confirmation digest. A file holding both stage 2 and stage 3 renders two buttons; a delete/modify pair renders one (SDTEST-1869, which proves the two previews carry different confirmations — that is what makes them unswappable).

Withdrawing locally rather than being refused

The server mints the confirmation over the commit HEAD, the whole index and every named path, so comparing that digest against a fresher capability document is the head-and-index comparison; restating a subset of it separately would add nothing. Every staging preview therefore reports requires_capability_revalidation, and a moved worktree stops matching before anything reaches the daemon (SDTEST-1868). The observation is exposed on the projection and rendered, so a reader can see the fence rather than trust that one exists.

Receipts are polled through GetReviewReceipt with the correlation digest — lookup_review_action already routes that for any confirmed preview. Re-sending the action would be pointless anyway: the write invalidated the confirmation it was minted with.

Two things this had to touch beyond the obvious

The confirmation guard was a local restatement of the contract. review_action tested matches!(action, RerunCheck { .. }) rather than asking requires_confirmation(). The confirmed set grew from one member to eight with this protocol revision, and the stale spelling had already gone wrong for merge_pull_request, which was minting previews the transport could no longer encode. That constructor now takes its capability the way the rerun does, so it refuses honestly instead of failing at the wire. The server still ships the merge slot empty, so nothing changes in production.

custody.rs did not understand these four families. prepare would have returned UnsupportedAction and the dispatch would have died at the fence — the same defect PR #161 found for the batch delivery after PR #160 asserted the format already knew "these actions". It knew the two it named. The disk enum now spells each staging kind separately, because a recovered plan re-readable as another kind would let a narrower grant replay as a wider one, and a conflict resolution persists its side because the side is part of the effect's identity. SDTEST-1870/1871 drive the whole store rather than only the enum.

Hunk granularity: not built, not faked

ReviewProposal lists file ids, a staging action names a proposal, and ReviewAnchor's hunk id exists for comments only. File-level staging is all the contract can express, so that is what ships. The #128 item ticks for the file half; the hunk half needs a protocol addition — a hunk-scoped proposal or action, plus a hunk identity stable enough to fence a write on — and should be tracked separately.

Coverage

SDTEST-1867..1873, rows in docs/testing/tests-core.md and tests-ui-and-app.md, plus a ## Change log entry amending SDUC-495. SDTEST-1848 is retired rather than deleted: it pinned the reason staging had no control, and that reason is gone.

Verification

cargo test --workspace --exclude shelldeck-ssh, cargo clippy --workspace --no-deps -- -D warnings, cargo fmt --all -- --check. The lockfile diff is the two automonique-* source lines and nothing else.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nf2PAqdKZxRuZSYGq3NE6o

benfavre and others added 4 commits August 29, 2026 22:02
PR #221 shipped no staging capability on purpose: a proposal id plus a
snapshot revision does not pin a worktree, so a staging confirmation
digest would have had to commit to the HEAD object id and the index
state, and no projection carried either. That was the single contract
gap behind the last open item of #128, and both halves
of the ticket reached it independently.

bext-stack/automonique PR #225 earned that field set from a mutation-free
preflight of the repository. `ReviewCapabilities` now carries `staging`
and `conflict_resolutions`, each entry naming the commit HEAD resolved to
and a digest over the whole index, plus the two digests every confirmed
lane needs. The four staging actions moved into `requires_confirmation`,
so their unconfirmed spelling no longer encodes on either side.

This commit is the bump and the mechanical repair that keeps the tree
green: `ReviewCapabilities::new` grew an eighth argument, so every test
helper that mints a capability response now passes an explicitly empty
`ReviewGitStagingCapabilities`. Empty is the honest value for a helper
that is exercising another family — it says the server proved no staging,
which is exactly what those tests assume.

The lockfile diff is limited to the two `automonique-*` source lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nf2PAqdKZxRuZSYGq3NE6o
The combined staged/unstaged/untracked/conflicted view has shipped since
PR #159. Only the mutations were missing, and for one structural reason:
`ReviewCapabilities` carried no staging capability, so a stage could
only have gone out unconfirmed and uncorrelated, and an ambiguous
outcome would have been reconcilable by nothing but an uncorrelated key
search. That is exactly what the confirmed lane exists to avoid.

The capability landed, so this consumes it, following `delivery.rs`
rather than inventing a second shape: the two are the same problem, one
read-side projection reporting what the server proved plus which fence
is missing when it proved nothing.

Absence is the withholding. The three staging kinds share one capability
type but not one grant: an operator installs `index_write`, `commit` and
`conflict_resolution` independently. A deployment granting stage and
unstage but not commit therefore advertises two entries, and the commit
proposal keeps its observation badge and grows no button. A disabled
button with a tooltip would claim the grant is present and merely
unavailable, which is the opposite of what the list says.

`conflict_resolutions` lists one entry per admissible side, because the
side decides which blob lands and a digest cannot commit to a choice not
yet made. A file with both stage 2 and stage 3 recorded renders two
controls; a delete/modify pair renders one.

Three things this had to touch beyond the obvious:

`review_action`'s confirmation guard now asks `requires_confirmation()`
instead of restating it as a local `matches!` on the rerun. The confirmed
set grew from one member to eight with this protocol revision, and the
local spelling had already gone stale for `merge_pull_request`, which
was minting previews the transport could no longer encode. That
constructor now takes its capability like the rerun does, so it refuses
honestly instead of failing at the wire.

`custody.rs` did not understand these four families. It would have
returned `UnsupportedAction` at `prepare` and the dispatch would have
died at the fence, which is precisely what PR #161 found for the batch
delivery after PR #160 asserted the format already knew "these actions".
It knew the two it named. The disk enum now spells each staging kind
separately, because a recovered plan that could be re-read as another
kind would let a narrower grant replay as a wider one, and a conflict
resolution persists its side because the side is part of the effect's
identity.

Receipts are polled through `GetReviewReceipt` with the correlation
digest, which `lookup_review_action` already routes for any confirmed
preview: re-sending the action would be pointless anyway, since the
write invalidated the confirmation it was minted with.

A stale worktree withdraws the control locally rather than reaching the
daemon. The server mints the confirmation over the commit HEAD resolved
to, the whole index and every named path, so comparing the digest
against a fresher capability document *is* the head-and-index
comparison; the observation is exposed on the projection so a reader can
see the fence rather than trust that one exists.

Hunk granularity is not built and not faked. Proposals list file ids and
`ReviewAnchor`'s hunk id serves comments only, so file-level staging is
all the contract can express.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nf2PAqdKZxRuZSYGq3NE6o
Amends SDUC-495 for capability-fenced file-level staging, unstaging,
committing and conflict resolution, and adds SDTEST-1867..1873 across
the core and UI inventories.

The use case now states the two things a reader has to know to review
this surface: a control exists only where a slot exists for that exact
proposal, so a withheld grant is an absent control rather than a
disabled one; and `conflict_resolutions` lists one entry per side git
recorded, so a file holding both renders two.

SDTEST-1848 is retired rather than deleted. It pinned the reason staging
had no control at all, and that reason is gone: PR #225 closed the
contract gap it named. SDTEST-1867 replaces it and fails closed the
other way round, demonstrating that a deployment granting stage and
unstage but not commit renders no commit control while both siblings go
end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nf2PAqdKZxRuZSYGq3NE6o
A staging proposal names files, so its control belongs on the file card
next to the observation badge, not in the footer row where the check
rerun and the batch send live. Those two act on the review as a whole;
these do not. Same widgets, same sizing, same disabled gate as both
siblings, so the placement is the only divergence and it is recorded
where the next reader will look for it.

Two smaller corrections in the same pass: the control row claims its
bottom margin only when it holds a control, so a card with none does not
push its preview down by an empty row's spacing; and the lane-level
"is there anything a control could apply to" check now walks proposals
once instead of every file against every proposal on a render path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nf2PAqdKZxRuZSYGq3NE6o
@benfavre
benfavre merged commit e6bb7ed into main Aug 29, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant