feat: resolve a task's change set and add 'bernstein undo --dry-run' (#2919) - #5160
Conversation
Six tests over a real repo with a real linked worktree: the resolved set is exactly the task branch's paths with their pre/post blob hashes, a path changed only on the integration branch is absent, an unresolvable task is refused rather than answered with an empty set, and the dry run leaves the tree byte-identical. The integration-only case is load-bearing. A two-dot diff reports that path as a deletion the task never made, so any later revert built on such a set would restore a file the task never removed.
Neither existing source can say what one task changed. The commit-subject scan in undo matches 'task:<id>', which nothing in the tree writes - agent commits read '[WIP] <title>' and 'feat: <summary>'. A lineage spine entry carries artifact_path, content_hash, actor and step_id and no task id, and CLI-adapter subprocess writes never reach that boundary. The per-task worktree does know: the session-to-task binding is already in .sdd/runtime/pids/<session>.json and surfaced by classify_worktrees, and the task's commits live on agent/<session>. resolve_task_change_set maps task to session, then diffs the branch three-dot against the integration branch so the set holds only what the task itself changed, each path with its pre- and post-change blob hash. Unresolvable cases raise instead of returning an empty set: an empty set is a real answer, so 'we could not look' must not look like 'nothing is there'.
Prints one line per changed path - kind, path, abbreviated pre -> post blob - and returns before any git write, so status, index and HEAD are untouched. --dry-run requires a task id: a change set belongs to one task and --all names none, so the combination is a usage error rather than an empty report that would read as 'this session changed nothing'. An unresolvable task id exits non-zero with the reason.
Covers why commit subjects and the lineage spine cannot name a task's files, what the resolver returns, why the diff is three-dot, and the refusal cases. States plainly that the reversal itself is not implemented yet and that undo without --dry-run still uses the subject scan.
The git_ops facade re-exports it but also pulls in git_pr, and undo_cmd is imported eagerly by cli/main - so the facade would put the GitHub PR module on every CLI startup for a read-only diff. git_pr itself imports run_git from git_basic for the same reason.
|
VERDICT: request-changes Missing release-notes fragment for a user-visible CLI change.
The new Nits (non-blocking): verify_cli formatting churn.
Scope review — all hunks trace to the PR's stated intent:
Logic review — the three-dot diff is correct and load-bearing:
Tests as evidence — all 6 tests pass. The fixture deliberately avoids writing Security — read-only git operations only. No new network calls, no credential access, no permission widening. Hygiene — clean. Release notes — blocking finding #1. The fragment must use the PR's closing keyword to an open issue. Per the fragment convention (see bernstein v3.19.0 - unattended review run Signed review receipt - verify with
|
|
VERDICT: approve Well-scoped feature: reads a task's worktree branch and prints its changed paths as Review dimensions examined
Verification run Nits (non-blocking)
bernstein v3.19.0 - unattended review run Signed review receipt - verify with
|
What
Resolves, read-only, the exact set of paths one task changed, and surfaces it as
bernstein undo <task_id> --dry-run.New module
src/bernstein/core/worktrees/change_set.py:resolve_task_change_set(repo_root, task_id) -> TaskChangeSetTaskChangePath— one path with itschange_typeand its pre-/post-change blob hashTaskChangeSetUnresolved— raised when the set cannot be determinedNew flag on
bernstein undo:--dry-runprints that set and returns before any git write.This PR does not revert anything.
_find_commits_to_revertand_execute_revertsare untouched, andbernstein undowithout--dry-runbehaves exactly as before. No fresh-worktree restore, no later-task conflict detection, no signed reversal receipt, nobernstein task revertcommand.Why
Reverting an agent's work needs a precise, reproducible answer to what did this task change. An agent task is one logical change spread over several files, so reverting it by picking files or reverting a merge routinely misses part of the change or reverts unrelated work that landed nearby.
Neither existing source can supply that answer:
_find_commits_to_revert(src/bernstein/cli/commands/undo_cmd.py) matchestask:<task_id>across the last 50 subjects. Nothing in the tree writes that string — the agent commit prompts emit[WIP] <title>(core/agents/spawner_core.py:589) andfeat: <summary>(:1110). The scan finds nothing for a real task.LineageSpineentry (core/lineage/spine.py) carriesartifact_path,content_hash,actor,step_id,model,timestamp— and no task id.record_artifact_write's own docstring notes that CLI-adapter subprocess file writes never cross that boundary.The per-task worktree does know. Each task runs on its own
agent/<session_id>branch, and the session-to-task binding is already recorded in.sdd/runtime/pids/<session_id>.jsonand surfaced byclassify_worktrees.How
classify_worktrees(repo_root)mapstask_idtosession_id; the branch isagent/<session_id>.git merge-base main agent/<session_id>fixes where the task forked.git diff --raw --no-renames -z --abbrev=64 <merge_base>..<branch>yields the changed paths in git path order, each with its pre- and post-change blob hash.--no-renamesbecause rename detection reports only a rename's destination and a reversal has to restore the source path too.--abbrev=64asks for more hexdigits than any hash git uses, which git clamps to the full object name, so the hashes are never abbreviated prefixes.None, so an added path's missing pre-image cannot be mistaken for a content hash.--dry-runrenders that set and returns before_find_commits_to_revertis reached.Three dots, not two.
main..agent/<sid>reports every path that landed onmainafter the task forked as a deletion the task never made. Diffing from the merge base excludes it. Test 3 pins exactly this.Refusals, not empty answers. A task with no worktree, a task claimed by two worktrees, a missing branch, and a failed or timed-out git call all raise
TaskChangeSetUnresolved. An empty set is a real answer — a task that touched no files — so "we could not look" must not be returned in its shape. This mirrorsIncomingChangeUnreadableincore/agents/spawner_merge.py.undo_cmdis imported eagerly bycli/main.py, so the new import is kept cheap:change_setpulls onlyclassifier(stdlib only) andgit_basic, not thegit_opsfacade, which would draggit_prand the GitHub module onto every CLI startup. Importingchange_setcosts 0.05s and loads neither.Open decision, decided here. The issue left
--dry-run's relationship to--allopen.--dry-runnow requires a task id, and--all --dry-runis aclick.UsageError. A change set is a property of one task;--allnames none, so the alternative would be an empty report that reads as "this session changed nothing" — the failure mode the refusals above exist to prevent. An unresolvable task id likewise exits non-zero with the reason rather than printing an empty panel.Tests
tests/unit/test_task_change_set.py— a real repo with a real linked worktree: the task branch addsadded.txt, modifiesmod.txt, deletesdel.txt;mainseparately gainsinteg.txt. The task's commit subject isfeat: add feature, carrying notask:<id>marker, so a subject-based resolver would return nothing here.All six failed before the change (
ModuleNotFoundError: No module named 'bernstein.core.worktrees.change_set'at import; the two CLI tests additionally had no--dry-runflag to invoke).test_change_set_names_exactly_the_paths_the_task_changed— the set is the task's three paths, in path order, with the right change kinds; asserts first that the fixture hands the resolver no subject marker.test_change_set_records_pre_and_post_blob_hashes_for_each_path— hashes matchgit rev-parse <rev>:<path>; an added path'spre_hashand a deleted path'spost_hashareNone.test_integration_only_path_is_absent_from_the_change_set— asserts the two-dot trap reproduces (integ.txtappears inmain..agent/<sid>), then that the resolved set excludes it. Every later conflict check compares this set against what other work touched, so an integration-only path leaking in would make a reversal restore a file the task never removed and still report a clean run.test_unknown_task_id_is_refused_not_answered_with_an_empty_set— raises, naming the task id.test_dry_run_prints_the_change_set_and_leaves_the_tree_byte_identical—git status --porcelainbyte-identical before and after, HEAD unmoved, the three paths printed andinteg.txtnot.test_dry_run_without_a_task_id_is_refused—--all --dry-runexits non-zero.Also run green:
tests/unit/cli/test_undo_cmd.py(existing undo behaviour unchanged) andtests/unit/test_worktree_classifier.py.Checklist
uv run ruff check src/— cleanuv run ruff format --check src/— cleanuv run pyright src/bernstein/core/worktrees/change_set.py src/bernstein/cli/commands/undo_cmd.py— no new errors;mypyclean on bothuv run python scripts/run_tests.py -x tests/unit/test_task_change_set.py tests/unit/cli/test_undo_cmd.py tests/unit/test_worktree_classifier.py— 20 passeduv run python scripts/run_tests.py tests/unit/cli/ tests/unit/test_task_change_set.py tests/unit/test_worktree_classifier.py tests/unit/test_worktrees_cmd.py tests/unit/test_tui_worktree_status.py tests/unit/test_cli_command_registration.py tests/unit/scripts/test_run_tests_affected_gate.py tests/unit/test_sonar_s3358_nested_ternary.py— 61 of 62 files pass.tests/unit/cli/test_run_banner.pyfails with a subprocessTimeoutErroron this machine, and fails identically on an unmodifiedorigin/maincheckout — environmental, not from this change. The full--affected origin/mainset is 1533 files (cli/main.pyimportsundo_cmdeagerly, so the reverse-dependency closure is nearly the whole suite); it did not finish locally, so CI runs it.docs/reference/cli-reference.mdrow forbernstein undoupdated (its source path was stale too)docs/operations/task-revert.md, wired intomkdocs.ymlnavdocs/api/: no public API schema changeduv run bernstein agents-md syncrun, produced no changestests/unit/Part of #2919
Remaining
{reverted_task_id, reverted_change_set_hash, pre_task_content_hashes, revert_commit}, anchored in the audit chain and verifiable offline, plus its tamper test.bernstein task revert <task_id>command surface.