Skip to content

feat(cli): add hydrate stage restore - #97

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

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

Conversation

@rennehan

@rennehan rennehan commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

`stage discard` has always parked the outgoing stage at `.hydrate/stage.discarded.json` and told the user it was recoverable there, but nothing ever read that file back — recovery meant hand-copying JSON into a directory the CLI owns, the exact thing `discard` exists to replace.

`stage restore` puts it back, purely locally (no network call):

  • Non-empty live stage: refuses rather than merge or overwrite. A silent merge could interleave two unrelated batches of deltas; there's no prompt to ask which the user meant. Clear the live stage (`commit` or `discard`) first.
  • No recovery file: not an error — same posture as an empty stage everywhere else in this CLI (a normal state, exit 0).
  • Recovery file parked from a different branch: refused. The recovery file now records the branch id (and cached name) it was parked from, since a workdir can be re-bound to a different branch between a `discard` and a `restore` (`fork` rewrites `config.toml` in place) — the parked deltas' alias table only resolves against the branch they were staged on.
  • Consumption: a successful restore deletes the recovery file. Its contents are now live in `stage.json`; leaving a stale duplicate around invites a later restore to replay the same batch again. A fresh `discard` re-populates the slot, exactly as `discard`'s own message already promises.

Along the way, fixed `discard` to thread the pulled index through its op summary (`summarize_workdir`, matching `status`/`diff`) instead of `summarize(&stage, None)`, which failed loud on any staged delta referencing a committed node (a cross-commit edge, an update to an earlier commit) — a real discard could get stuck unable to complete, and unable to report what it destroyed, on exactly the batch it was trying to throw away.

`hydrate guide`'s editing-in-place section now documents `stage restore` alongside `stage discard`.

Test plan

  • `cargo test` — full suite green (one pre-existing flaky network test, confirmed unrelated and passes in isolation/reruns)
  • `cargo fmt -p hydrate --check`
  • `cargo clippy --workspace --all-targets -- -D warnings`
  • Every new test mutation-tested: broke the covered behavior, confirmed the test failed, restored it
  • Hand-drove the release binary in a scratch dir: staged a node with a real description, discarded, restored, confirmed the stage came back semantically identical (JSON pretty-printing differs, content is byte-for-byte the same fields/values) including the description; hand-verified both refusal paths (non-empty stage, branch mismatch) and the "nothing to restore" case

🤖 Generated with Claude Code

`stage discard` has always parked the outgoing stage at
.hydrate/stage.discarded.json and told the user it was recoverable
there, but nothing ever read that file back — recovery meant
hand-copying JSON into a directory the CLI owns, the exact thing
`discard` exists to replace.

`stage restore` puts it back:
  - refuses over a non-empty live stage (no silent merge or overwrite)
  - a missing recovery file is a normal state, not an error
  - the recovery file now records which branch it was parked from, and
    restore refuses across a mismatch — a workdir can be re-bound to a
    different branch between a discard and a restore, and the parked
    deltas' alias table only resolves against the branch it was staged on
  - a successful restore consumes the recovery file

Also fixes `discard` to thread the pulled index through its summary
(summarize_workdir, matching status/diff) instead of summarize(&stage,
None), which failed loud on any staged delta referencing a committed
node — a real discard could get stuck unable to complete.
@rennehan rennehan added the enhancement New feature or request label Aug 1, 2026
@rennehan rennehan self-assigned this Aug 1, 2026
…uards

Fresh-context review of #97 found four issues, all landed here:

- The branch-mismatch guard only compared when BOTH the parked record and
  the current binding had a branch id, so it silently no-op'd when the
  workdir went unbound between discard and restore — the parked deltas'
  alias table would then resolve against nothing. Split into its own
  refusal (`CliError::BranchContextMissing`), distinct from a same-branch
  mismatch.
- A cleanup failure after a successful restore (removing the now-stale
  recovery file) was reported as if the whole restore had failed, inviting
  a retry that immediately hit the already-staged refusal without the user
  ever learning the first attempt worked. The wrapped error now says both:
  the restore landed, and which file needs manual removal.
- The no-lose-both-copies invariant (a cleanup failure after a successful
  save must leave BOTH the restored stage and the recovery file intact)
  was documented at length but never pinned by a test.
- The two ad hoc refusals (`live stage non-empty`, `branch mismatch`) used
  generic `Other`/`State` error kinds, indistinguishable from each other
  and from actual file corruption over `--json`. Added typed variants
  (`RestoreBlocked`, `BranchMismatch`, `BranchContextMissing`) with their
  own stable `error.kind` tokens so a driving agent can branch on the
  correct remediation.
- Extracted the triplicated bare-file-name containment check in
  `state::mod` into one `validate_bare_name` helper shared by
  write/read/remove_state_file.

The symlink-follow behavior on `remove_state_file`/`atomic_write` (noted by
review as a judgement call, not a mandated fix) is left as-is with a
comment recording why: it requires local write access to the working copy,
which is outside this CLI's threat model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rennehan
rennehan merged commit 8f3d3f4 into main Aug 2, 2026
3 checks passed
@rennehan
rennehan deleted the feat/stage-restore branch August 2, 2026 00:59
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