Skip to content

feat(cli): add hydrate stage discard - #90

Merged
rennehan merged 2 commits into
mainfrom
feat/stage-discard
Aug 1, 2026
Merged

feat(cli): add hydrate stage discard#90
rennehan merged 2 commits into
mainfrom
feat/stage-discard

Conversation

@rennehan

@rennehan rennehan commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Abandoning a staged changeset meant deleting .hydrate/stage.json by hand. clear is not that verb — it stages removal of every top-level node, which is nearly the opposite and a dangerous thing to reach for by mistake.

$ hydrate stage discard
+ boundary Disc1
    description: authored prose that exists nowhere else
+ behavior Disc1.Disc2 (out: x:T)
Discarded 2 staged operations on branch 'sweep-1': 2 nodes, 0 edges.
Recoverable from .hydrate/stage.discarded.json until the next discard.

$ hydrate stage discard
Nothing staged on branch 'sweep-1'; nothing to discard.        # exit 0

Why it prints the operations

The discarded work exists nowhere else — nothing was committed, so there is no server copy. A node's description is the user's authored prompt for code generation, and 2 nodes does not tell you what you lost. The listing goes through the renderer diff already uses, so what scrolls past is a real record.

Why there is no prompt

This CLI is driven non-interactively and a prompt would break piping. So the mitigation is recoverability, not friction: the outgoing stage is parked in .hydrate/stage.discarded.json (single slot, overwritten each time), and --json echoes the deltas so an agent can re-stage without reading the file. The park happens before the delete, and a park failure aborts it.

Blast radius

Exactly one file. .hydrate/ also holds the binding and a 100KB+ pulled index, so a wider reach would unbind the working copy or throw away a snapshot the user would have to re-fetch. write_state_file takes a file name, not a path, so a caller cannot escape the state directory. Pinned by a test that plants a binding, an index, and a sibling file and asserts all three survive.

A test that didn't test anything

The unit test proving park() works did not prove discard() calls it — deleting the park call left every unit test passing, because the test invoked park directly. The end-to-end test that runs the real binary in a real working copy catches it; verified by re-applying the mutation.

status's noun renderer is now shared rather than copied, so the three verbs describing one stage can't drift on what it contains.

415 unit + 9 integration tests. fmt, clippy -D warnings clean. Driven against a real bound project, both the populated and empty paths.

Abandoning a staged changeset meant deleting .hydrate/stage.json by hand. clear
is not that verb — it STAGES removal of every top-level node, which is nearly
the opposite and a dangerous thing to reach for by mistake.

The discarded work exists nowhere else: nothing was committed, so there is no
server copy. Two things follow. The full operation list prints before the
delete, through the renderer diff already uses, because counts are not a record
— a node's description is the user's authored prompt, and '2 nodes' does not
tell you what you lost. And the outgoing stage is parked in
.hydrate/stage.discarded.json, a single slot, with --json echoing the deltas so
an agent can re-stage without reading the file.

No confirmation prompt: this CLI is driven non-interactively and a prompt would
break piping, so the mitigation is recoverability rather than friction. An empty
stage is not an error — it exits 0 and says so, matching status, diff and
commit.

The verb takes exactly one file. .hydrate/ also holds the binding and a large
pulled index, so a wider blast radius would unbind the working copy or throw
away a snapshot the user would have to re-fetch; write_state_file takes a file
NAME rather than a path so a caller cannot reach outside the state directory.

status's noun renderer is now shared rather than copied, so the three verbs that
describe one stage cannot drift on what it contains.

A unit test proving park() works did NOT prove discard() calls it — deleting the
call left every unit test passing. The end-to-end test that runs the real binary
catches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rennehan rennehan added the enhancement New feature or request label Aug 1, 2026
@rennehan rennehan self-assigned this Aug 1, 2026
@rennehan rennehan added the enhancement New feature or request label Aug 1, 2026
A review found the report was printed before the work. With a read-only
.hydrate, stdout carried 'Discarded 1 staged operation' and 'Recoverable from
...' while the stage was untouched and no recovery file existed — a fabricated
past-tense success on the documented machine channel. An agent reading it would
author on top of a stage it believed empty and commit both.

Now: the op listing goes to stderr FIRST, because it is the record of what is
about to be destroyed and has to survive a failure part-way through. Then the
park and the clear. Only then the verdict. An integration test drives a
read-only .hydrate and asserts stdout claims nothing and the stage survives.

The JSON also echoed the raw deltas, putting node UUIDs into output an author
consumes — the one thing every sibling verb avoids, and something this change
set argues for elsewhere. It now reports ops by dotted path through the same
projection diff uses; the recovery FILE remains the verbatim source for
re-staging, which is its job.

write_state_file's doc claimed a caller could not reach outside the state
directory. Path::join provides no such thing: the review demonstrated
'../escaped.json' landing beside .hydrate and an absolute path replacing the
base outright. The check is now implemented and tested rather than asserted.
Its doc block had also been inserted between atomic_write's doc and
atomic_write, describing a function it was no longer attached to; both are back
where they belong.

stage discard is now listed in the guide, next to clear, saying which is which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rennehan
rennehan merged commit fce5bdb into main Aug 1, 2026
3 checks passed
@rennehan
rennehan deleted the feat/stage-discard branch August 1, 2026 18:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant