Skip to content

fix(slash): /rollback stops accepting a count it never used, and stops lying about HEAD [SC-A3.1] - #74

Merged
AetherAI3 merged 3 commits into
mainfrom
supercluster/a3-review-ship
Aug 19, 2026
Merged

fix(slash): /rollback stops accepting a count it never used, and stops lying about HEAD [SC-A3.1]#74
AetherAI3 merged 3 commits into
mainfrom
supercluster/a3-review-ship

Conversation

@AetherAI3

Copy link
Copy Markdown
Owner

Problem

Three defects in two commands that discard the user's uncommitted work, and which shipped with no tests at all (grep -rln 'rollbackSlash|revertSlash' test/ → zero files).

1. /rollback [n] accepted a count that did nothing

const n = parseInt(arg.trim()) || 1;                 // :16
if (n < 1 || n > 50) { …usage…; return; }            // :17

execFileSync("git", [..., "checkout", "--", "."])    // :53  — reverts everything

n appears at :16 and :17 and nowhere else in the file. Every invocation reverted the whole working tree, whatever number was passed. The usage line advertised "revert last n filesystem changes" — a capability that does not exist. Nothing in this codebase records per-step filesystem state.

2. Both commands claimed "restored to last commit". They were not.

git checkout -- <path> restores from the index, not HEAD. With anything staged, the content that comes back is the staged content — so the message was false exactly when the user had staged work they might have been counting on.

3. Neither command worked from a subdirectory

Detection was existsSync(join(cwd, ".git")), which reports "not a git repository" for every subdirectory of one — where a REPL usually sits.

Contract

On [n]: the spec allows either a real checkpoint journal or dropping the count. There is no journal to hang [n] on, so this drops it. Passing an argument now prints what it used to do and refuses, rather than silently ignoring it. /revert step-3 likewise stops saying "coming soon" about something no code is working toward.

On the restore target: the destructive scope is deliberately not widened. It would have been easy to make the message true by restoring from HEAD instead — but that destroys staged work the old command preserved. Making a destructive command destroy more is not a bug fix. So the behaviour stands and the message tells the truth:

↩  Ready to rollback  3 files changed
  src/a.ts
  src/b.ts

  ! you have staged changes, so these files restore to their STAGED state,
    not to the last commit. To go all the way back to HEAD:
      git restore --source=HEAD --staged --worktree -- .

  untracked files are not touched.

The confirmation prompt names the same target as the completion line, so neither can drift from the other.

Implementation

  • git checkout --git restore --. Both restore from the index, but restore only ever touches files, so it cannot be talked into switching branches by a crafted pathspec.
  • Repo detection → git rev-parse --show-toplevel.
  • Testability seam: both functions take an optional GitToolDeps { cwd, git } defaulting to the real runner. It reuses the existing GitRunner from git_commit_guard.ts rather than adding a third runner abstraction — this repo already has two, flagged as a drift hazard during recon. The three call sites in slash.ts are unchanged.

Tests

12 tests, previously zero. All drive a scripted GitRunner; none touches a real repository.

test proves
the count is refused, and refusing mutates nothing no checkout/restore in argv
usage text no longer promises per-change undo the old claim is gone
"last commit" is never claimed while the index is populated the core lie
"last commit" is claimed only when nothing is staged the message is not merely vaguer
untracked files are never removed no clean in any argv
declining the prompt issues no mutation consent is load-bearing
a clean tree issues no mutation
a subdirectory of a repo is still in the repo rev-parse, not .git probing
outside a repo it refuses and mutates nothing
/revert passes the path after -- proven with a leading-dash filename
/revert on an untracked file refuses

Mutation-checked. Forcing restoreTargetLabel to always answer "last commit" fails both truthfulness tests — 10 pass / 2 fail. Restoring gives 12 / 12.

Gates at 8e15476:

command result
npm run typecheck exit 0
npm test 934 pass / 0 fail

Baseline on clean 41a7e261, measured in the same session: 922 / 0.

Security notes

  • Destructive scope is unchanged — this PR does not make either command destroy anything it did not destroy before.
  • Every path is passed as an argv element after a -- separator, with core.literalPathspecs=true (inherited from SpawnGitRunner). A leading-dash filename is used in a test to prove it.
  • No shell is involved on any path.
  • Untracked files remain untouched; asserted, not assumed.
  • Reflog and commits are untouched.

Known limits

  • Hunk-level staging still does not exist. No git add -p, no git apply --cached. That is the next slice.
  • The commit and PR rail is untouched. gh pr create is still only a printed hint string (repo.ts:91), never executed.
  • stage_diff.ts still regexes human git diff --stat output and still uses execSync with a shell string. Untouched here to keep this diff to the destructive commands; it is the one remaining shell-string git caller in the tree.
  • /stage-diff remains uncovered — this PR tests only the two destructive commands.

Dependency and merge order

Independent of SC-A0 (#72) and SC-A2 (#73) — branched from origin/main, no shared files. Any order.

Per the integration order this lands after SC-A2 and before SC-A4.

…s lying about HEAD

Lane SC-A3, slice 1 of the review-and-ship rail.

Three defects in two commands that discard the user's uncommitted work, and
which shipped with no tests at all.

1. `/rollback [n]` accepted a count that did nothing.

   The count was parsed, range-checked to 1..50, and then never referenced
   again — every invocation reverted the entire working tree, whatever number
   was passed. The usage line advertised "revert last n filesystem changes",
   a capability that does not exist: nothing in this codebase records per-step
   filesystem state.

   There is no checkpoint journal to hang a real `[n]` on, so this takes the
   other option the contract allows and drops the argument. Passing one now
   prints what it used to do and refuses, rather than silently ignoring it.
   `/revert step-3` likewise stops saying "coming soon" for something no code
   is working toward.

2. Both commands claimed changes were "restored to last commit". They were not.

   `git checkout -- <path>` restores from the INDEX. With anything staged, the
   content that comes back is the staged content — so the message was false
   exactly when the user had staged work they might have been counting on.

   Both commands now check the index and say which it was, and `/rollback`
   prints the command that does go all the way back to HEAD. The confirmation
   prompt names the same target as the completion line.

3. Neither command worked from a subdirectory.

   Detection was `existsSync(join(cwd, ".git"))`, which reports "not a git
   repository" for every subdirectory of one — where a REPL usually sits.
   Now `git rev-parse --show-toplevel`.

Also: `git checkout --` becomes `git restore --`. Both restore from the index,
but restore only ever touches files, so it cannot be talked into switching
branches by a crafted pathspec.

Adds a testability seam. Both functions take an optional GitToolDeps
{ cwd, git } defaulting to the real runner. It reuses the existing GitRunner
from git_commit_guard.ts rather than introducing a third runner abstraction —
this repo already has two, which recon flagged as a hazard. The three call
sites in slash.ts are unchanged.

First behavioural coverage for these commands — 12 tests, previously zero:

  the count is refused, and refusing mutates nothing
  usage text no longer promises per-change undo
  "last commit" is never claimed while the index is populated
  "last commit" is claimed only when nothing is staged
  untracked files are never removed (no `clean` in any argv)
  declining the prompt issues no mutation
  a clean tree issues no mutation
  a subdirectory of a repo is still in the repo
  outside a repo it refuses and mutates nothing
  /revert passes the path after `--`, proven with a leading-dash filename
  /revert on an untracked file refuses and mutates nothing

Mutation-checked: forcing restoreTargetLabel to always answer "last commit"
fails both truthfulness tests (10 pass / 2 fail); restoring gives 12 / 12.

Registry and COMMANDS.md updated to match — `/rollback` no longer documents an
argument, and both entries state the index-versus-HEAD behaviour rather than
leaving the reader to discover it.

Gates at this commit:
  npm run typecheck   exit 0
  npm test            934 pass / 0 fail  (922 on clean 41a7e26)

Not addressed here, and still open in this lane: hunk-level staging, the
commit and PR rail, and stage_diff.ts still parsing human `git diff --stat`
output with a regex.
@AetherAI3
AetherAI3 marked this pull request as ready for review August 19, 2026 12:51
@AetherAI3
AetherAI3 merged commit ae15c9d into main Aug 19, 2026
5 checks passed
@AetherAI3
AetherAI3 deleted the supercluster/a3-review-ship branch August 19, 2026 12:54
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