Skip to content

fix: make the non-merge path in Execute() exception-safe [patch] - #72

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/undoredo-70-execute-preserves-redo-on-throw
Sep 23, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/undoredo-70-execute-preserves-redo-on-throw

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #70.

UndoRedoService.Execute(ICommand) cleared forward history and cleaned up save boundaries before calling command.Execute():

_stackManager.ClearForward();
_saveBoundaryManager.CleanupInvalidBoundaries(_stackManager.CurrentPosition);

command.Execute();

_stackManager.AddCommand(command);

When command.Execute() threw, the exception propagated correctly — but the pre-existing redo history, and any save boundaries inside it, had already been permanently discarded for a command that was never applied.

The comment on the auto-merge branch just above claimed this path already mirrored its exception safety ("This mirrors the non-merge path below"). It didn't; the non-merge path was evidently missed when the merge path and CompositeCommand were hardened.

The change

command.Execute() moves ahead of the two destructive calls. Nothing else moves.

Behaviour on success is identical, for two reasons worth stating because they are what makes the reorder safe rather than merely later:

  • CurrentPosition is not touched by command.Execute(), so CleanupInvalidBoundaries() still receives exactly the position it received before.
  • StackManager.AddCommand() already removes anything after CurrentPosition itself when branching, so the explicit ClearForward() was never what made the stack correct — it only mattered for the boundary cleanup's position argument, which is unchanged.

Tests

Two tests added next to the existing exception-safety tests, both verified to fail before the change and pass after it (by stashing the UndoRedoService.cs change and re-running):

Test Fails without the fix with
Execute_CommandThrowsException_PreservesRedoHistory CommandCount is 1, not 3 — B and C silently lost
Execute_CommandThrowsException_PreservesSaveBoundariesInForwardHistory SaveBoundaries.Count is 0, not 1

The first also asserts the preserved history is still replayable — both Redo() calls reapply B and C — rather than only that the count survived. The existing Execute_CommandThrowsException_DoesNotCorruptStack missed this bug because it exercises a stack with no redo history to lose.

Full suite: 48 passed, 0 failed, 0 skipped.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T1ntzPwg732TrH7D5vWpSV


Generated by Claude Code

Execute() cleared forward history and cleaned up save boundaries before
calling command.Execute(). When Execute() threw, the exception propagated
but the redo history and any save boundaries inside it had already been
discarded, even though the new command was never applied.

Move command.Execute() ahead of ClearForward() and CleanupInvalidBoundaries(),
mirroring the auto-merge path above and CompositeCommand.Execute(). Behaviour
on success is unchanged: CurrentPosition is untouched by Execute(), so
CleanupInvalidBoundaries() still receives the same position, and
AddCommand() already clears forward history itself when branching.

Adds two tests, each failing before the change:
- Execute_CommandThrowsException_PreservesRedoHistory
- Execute_CommandThrowsException_PreservesSaveBoundariesInForwardHistory

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1ntzPwg732TrH7D5vWpSV
MSTEST0037, reported by SonarQube Cloud on this PR: use Assert.HasCount
rather than Assert.AreEqual on a collection's Count. The rest of the suite
already asserts save boundaries this way.

Assertion strength is unchanged: both new tests still fail against the
pre-fix UndoRedoService with the same messages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1ntzPwg732TrH7D5vWpSV
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 136570e into main Sep 23, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/undoredo-70-execute-preserves-redo-on-throw branch September 23, 2026 23:51
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.

Execute() destroys redo history before executing the new command, losing it irrecoverably if execution throws

2 participants