Repository: git-town/git-town

Issue:
Syncing parent (other than default branch) results in duplicated commits
### Steps to reproduce

I've noticed for the past months that syncing with git town isn't what it used to be.

Yesterday it happened to me twice.

I was working on a feature, and at some point, I decided to prepend and beam some commits. 

This worked great. 

But then I went back to that newly created parent branch (which got the beamed commits) and modified those. There were some mistakes, I amended the commits, and synced. All good.

I went back to the feature branch and ran sync there and then I noticed something weird.

Before, the history was like this:

commit1-from-parent
commit2-from-parent
feature-commit1
feature-commit2

But after the syncing, it was like this:

commit1-from-parent
commit2-from-parent
commit2-from-parent
feature-commit1
feature-commit2

You can see that the second commit from parent is duplicated. And when I inspected the diff of that commit, it did the opposite as what I did in the parent branch. Basically, it brought back the change how it initially was. In my case, it reintroduced the back I solved in the parent.  

Having a hard time reproducing this properly. Maybe you can help me how to do it.

I have the feeling this is related to the way syncing happens. Where it applies the commits on top of it and then force pushes it, in the hope it resolves. But in cases where the original commit changed, it leaves a weird state.



### Command output

```shell
.
```

### Additional information

_No response_

Discussion:
@kevgo: Thanks for reaching out about this. This behavior might be a side effect of the way Git Town currently beams commits when prepending branches. The end-to-end test that demonstrates this in action is [here](https://github.com/git-town/git-town/blob/main/features/prepend/beam/rebase.feature).

- prepend an empty branch
- cherry-pick the commits to beam into the new branch
- rebase the old branch against its new parent

The point is, Git Town doesn't remove the beamed commits from the old branch. They disappear from the old branch on their own after the rebase. I was under the assumption that the rebase indeed removes them. Based on your feedback, it appears that's not really the case. The commits seem to remain in the old branch, and Git is simply hiding them to avoid displaying the same commit twice in a branch. 

So, when somebody amends the commits in new branch, they are now different from their counterparts in the old branch, and Git displays the commits in the old branch again. 

Since Git commits aren't diffs but full copies of the affected file before and after (the diff is calculated on demand if needed), the commits in the old branch appear as undoing changes from the earlier commits in the new branch.

A possible solution is to delete the beamed commits from the original branch.@kevgo: I haven't been able to re-create the problem on my end, so a reproduction would help.@kevgo: I've tried this workflow and it didn't do much:

- prepend an empty branch
- cherry-pick the commits to beam into the new branch
- rebase the old branch against its new parent
- remove the beamed commits from the old branch

Maybe this order works better:

- prepend an empty branch
- cherry-pick the commits to beam into the new branch
- remove the beamed commits from the old branch
  - the beamed commits on the new branch are not visible on the old branch yet, so removing them from the old branch will for sure remove the old commits
- rebase the old branch against its new parent
@ruudk: FWIW: I'm using the rebase sync strategy. 

I'll try a reproducer in the coming days.
@kevgo: I have managed to create an end-to-end test where a beamed commit [resurfaces on the old branch](https://github.com/git-town/git-town/blob/f3203d9323b3eaea4acf83f1db0678a090194f41/features/prepend/beam/rebase_same_file.feature#L125) after amending it on the new branch.@kevgo: Okay, I just merged a PR that physically removes the beamed commits from the old branch. This prevents them from re-appearing in my end-to-end tests, this should also work in real life.

This is additional commit surgery, so there is possibility for additional merge commits. There might be some opportunities to auto-resolve them, but probably not always.@ruudk: Who's awesome? You're awesome!

Thanks!😊 @kevgo: This is now available as part of [Git Town 21](https://github.com/git-town/git-town/releases/tag/v21.0.0).@ruudk: I know this is going to be a lousy report, but for the last weeks, I noticed more issues with sync rebase.

For example, I'm sync with main, on a feature branch, and there is some code there. Then time passes, and somebody removes code from main. Then time passes, and I run `sync` on my feature branch, and suddenly that code is now back into my branch. Sometimes without me noticing. 

Today somebody told me: "why are you adding this file? is that part of this PR?" and it was something that another person removed. 

This is frightening because I really on Git Town and somehow expect it to work (no blame, but just how I trust it after all this time).

Maybe you've heard more of this. The next time it happens I will try to see if I can somehow reproduce it.@ruudk: I think I know what happened. 

* Create feature branch from main
* Do changes to files A B C
* On main, file B gets removed
* On feature branch, do a sync.
* Because I did changes to B, it now added file B back.

This might be that I was just too fast with resolving a merge conflict. Or, the merge conflict didn't happen, and it just blindly added B back.

@kevgo: If `main` has a commit that deletes a file while your branch modifies that same file, you should indeed hit a merge conflict.

We recently fixed some bugs where Git Town was mislabeling real merge conflicts as phantom ones and then auto-resolved them the wrong way. Could it be that you ran into that? Are you on v21.4.3?

Solution patch:
diff --git a/features/append/beam/select_commits.feature b/features/append/beam/select_commits.feature
index 16eee6d49..913af49a5 100644
--- a/features/append/beam/select_commits.feature
+++ b/features/append/beam/select_commits.feature
@@ -52,3 +52,28 @@ Feature: beam multiple commits onto a new child branch
       |          | git branch -D new                                                      |
     And the initial commits exist now
     And the initial branches and lineage exist now
+
+  Scenario: amend the beamed commit
+    And I amend this commit
+      | BRANCH | LOCATION | MESSAGE   | FILE NAME | FILE CONTENT    |
+      | new    | local    | commit 4b | file_4    | amended content |
+    And the current branch is "new"
+    When I run "git town sync"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND                                                                                         |
+      | new      | git fetch --prune --tags                                                                        |
+      |          | git checkout main                                                                               |
+      | main     | git -c rebase.updateRefs=false rebase origin/main                                               |
+      |          | git checkout existing                                                                           |
+      | existing | git -c rebase.updateRefs=false rebase --onto main {{ sha 'initial commit' }}                    |
+      |          | git push --force-with-lease --force-if-includes                                                 |
+      |          | git checkout new                                                                                |
+      | new      | git -c rebase.updateRefs=false rebase --onto existing {{ sha-in-origin-before-run 'commit 3' }} |
+      |          | git push -u origin new                                                                          |
+    And these commits exist now
+      | BRANCH   | LOCATION      | MESSAGE     |
+      | main     | local, origin | main commit |
+      | existing | local, origin | commit 1    |
+      |          |               | commit 3    |
+      | new      | local, origin | commit 2    |
+      |          |               | commit 4b   |
diff --git a/features/hack/give_non_existing_branch/beam/select_commits.feature b/features/hack/give_non_existing_branch/beam/select_commits.feature
index 9d4c70096..0a041ac51 100644
--- a/features/hack/give_non_existing_branch/beam/select_commits.feature
+++ b/features/hack/give_non_existing_branch/beam/select_commits.feature
@@ -52,3 +52,26 @@ Feature: beam multiple commits onto a new feature branch
       |          | git branch -D new                                                      |
     And the initial commits exist now
     And the initial branches and lineage exist now
+
+  Scenario: amend the beamed commit
+    And I amend this commit
+      | BRANCH | LOCATION | MESSAGE   | FILE NAME | FILE CONTENT    |
+      | new    | local    | commit 4b | file_4    | amended content |
+    And the current branch is "new"
+    When I run "git town sync"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                           |
+      | new    | git fetch --prune --tags                          |
+      |        | git checkout main                                 |
+      | main   | git -c rebase.updateRefs=false rebase origin/main |
+      |        | git checkout new                                  |
+      | new    | git merge --no-edit --ff main                     |
+      |        | git push -u origin new                            |
+    And these commits exist now
+      | BRANCH   | LOCATION      | MESSAGE                      |
+      | main     | local, origin | main commit                  |
+      | existing | local, origin | commit 1                     |
+      |          |               | commit 3                     |
+      | new      | local, origin | commit 2                     |
+      |          |               | commit 4b                    |
+      |          |               | Merge branch 'main' into new |
diff --git a/features/prepend/beam/compress.feature b/features/prepend/beam/compress.feature
index d976fb5ad..321f42bac 100644
--- a/features/prepend/beam/compress.feature
+++ b/features/prepend/beam/compress.feature
@@ -7,11 +7,11 @@ Feature: prepend a branch to a feature branch using the "compress" sync strategy
       | NAME | TYPE    | PARENT | LOCATIONS     |
       | old  | feature | main   | local, origin |
     And the commits
-      | BRANCH | LOCATION      | MESSAGE  |
-      | old    | local, origin | commit 1 |
-      |        |               | commit 2 |
-      |        |               | commit 3 |
-      |        |               | commit 4 |
+      | BRANCH | LOCATION      | MESSAGE  | FILE NAME | FILE CONTENT |
+      | old    | local, origin | commit 1 | file 1    | content 1    |
+      | old    | local, origin | commit 2 | file 2    | content 2    |
+      | old    | local, origin | commit 3 | file 3    | content 3    |
+      | old    | local, origin | commit 4 | file 4    | content 4    |
     And the current branch is "old"
     And Git setting "git-town.sync-feature-strategy" is "compress"
     When I run "git-town prepend parent --beam" and enter into the dialog:
@@ -71,3 +71,26 @@ Feature: prepend a branch to a feature branch using the "compress" sync strategy
       |        | git branch -D parent                            |
     And the initial commits exist now
     And the initial lineage exists now
+
+  Scenario: amend the beamed commit
+    And I amend this commit
+      | BRANCH | LOCATION | MESSAGE   | FILE NAME | FILE CONTENT    |
+      | parent | local    | commit 4b | file_4    | amended content |
+    And the current branch is "old"
+    When I run "git town sync"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                         |
+      | old    | git fetch --prune --tags        |
+      |        | git checkout parent             |
+      | parent | git reset --soft main           |
+      |        | git commit -m "commit 2"        |
+      |        | git push -u origin parent       |
+      |        | git checkout old                |
+      | old    | git merge --no-edit --ff parent |
+      |        | git reset --soft parent         |
+      |        | git commit -m "commit 1"        |
+      |        | git push --force-with-lease     |
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE  |
+      | old    | local, origin | commit 1 |
+      | parent | local, origin | commit 2 |
diff --git a/features/prepend/beam/merge.feature b/features/prepend/beam/merge.feature
index 0835cb4e3..8800a0918 100644
--- a/features/prepend/beam/merge.feature
+++ b/features/prepend/beam/merge.feature
@@ -7,11 +7,11 @@ Feature: prepend a branch to a feature branch using the "merge" sync strategy
       | NAME | TYPE    | PARENT | LOCATIONS     |
       | old  | feature | main   | local, origin |
     And the commits
-      | BRANCH | LOCATION      | MESSAGE  |
-      | old    | local, origin | commit 1 |
-      | old    | local, origin | commit 2 |
-      | old    | local, origin | commit 3 |
-      | old    | local, origin | commit 4 |
+      | BRANCH | LOCATION      | MESSAGE  | FILE NAME | FILE CONTENT |
+      | old    | local, origin | commit 1 | file 1    | content 1    |
+      | old    | local, origin | commit 2 | file 2    | content 2    |
+      | old    | local, origin | commit 3 | file 3    | content 3    |
+      | old    | local, origin | commit 4 | file 4    | content 4    |
     And the current branch is "old"
     And Git setting "git-town.sync-feature-strategy" is "merge"
     When I run "git-town prepend parent --beam" and enter into the dialog:
@@ -68,3 +68,29 @@ Feature: prepend a branch to a feature branch using the "merge" sync strategy
       |        | git branch -D parent                            |
     And the initial commits exist now
     And the initial lineage exists now
+
+  Scenario: amend the beamed commit
+    And I amend this commit
+      | BRANCH | LOCATION | MESSAGE   | FILE NAME | FILE CONTENT    |
+      | parent | local    | commit 4b | file_4    | amended content |
+    And the current branch is "old"
+    When I run "git town sync"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                         |
+      | old    | git fetch --prune --tags        |
+      |        | git checkout parent             |
+      | parent | git push -u origin parent       |
+      |        | git checkout old                |
+      | old    | git merge --no-edit --ff parent |
+      |        | git push                        |
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE                        |
+      | old    | local, origin | commit 1                       |
+      |        |               | commit 2                       |
+      |        |               | commit 3                       |
+      |        |               | commit 4                       |
+      |        |               | commit 4                       |
+      |        |               | Merge branch 'parent' into old |
+      |        |               | Merge branch 'parent' into old |
+      | parent | local, origin | commit 2                       |
+      |        |               | commit 4b                      |
diff --git a/features/prepend/beam/rebase.feature b/features/prepend/beam/rebase_different_files.feature
similarity index 69%
rename from features/prepend/beam/rebase.feature
rename to features/prepend/beam/rebase_different_files.feature
index 79e3f5b88..39cd6c529 100644
--- a/features/prepend/beam/rebase.feature
+++ b/features/prepend/beam/rebase_different_files.feature
@@ -63,3 +63,25 @@ Feature: prepend a branch to a feature branch using the "rebase" sync strategy
       |        | git branch -D parent                            |
     And the initial commits exist now
     And the initial lineage exists now
+
+  Scenario: amend the beamed commit
+    And I amend this commit
+      | BRANCH | LOCATION | MESSAGE   | FILE NAME | FILE CONTENT    |
+      | parent | local    | commit 4b | file_4    | amended content |
+    And the current branch is "old"
+    When I run "git town sync"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                                                      |
+      | old    | git fetch --prune --tags                                                     |
+      |        | git checkout parent                                                          |
+      | parent | git -c rebase.updateRefs=false rebase --onto main {{ sha 'initial commit' }} |
+      |        | git push -u origin parent                                                    |
+      |        | git checkout old                                                             |
+      | old    | git -c rebase.updateRefs=false rebase --onto parent {{ sha 'commit 4' }}     |
+      |        | git push --force-with-lease --force-if-includes                              |
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE   |
+      | old    | local, origin | commit 1  |
+      |        |               | commit 3  |
+      | parent | local, origin | commit 2  |
+      |        |               | commit 4b |
diff --git a/features/prepend/beam/rebase_same_file.feature b/features/prepend/beam/rebase_same_file.feature
new file mode 100644
index 000000000..f30ce0c3e
--- /dev/null
+++ b/features/prepend/beam/rebase_same_file.feature
@@ -0,0 +1,127 @@
+@messyoutput
+Feature: prepend a branch to a feature branch using the "rebase" sync strategy
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME | TYPE    | PARENT | LOCATIONS     |
+      | old  | feature | main   | local, origin |
+    And the commits
+      | BRANCH | LOCATION      | MESSAGE  | FILE NAME | FILE CONTENT |
+      | old    | local, origin | commit 1 | file      | content 1    |
+      | old    | local, origin | commit 2 | file      | content 2    |
+      | old    | local, origin | commit 3 | file      | content 3    |
+    And the current branch is "old"
+    And Git setting "git-town.sync-feature-strategy" is "rebase"
+    When I run "git-town prepend parent --beam" and enter into the dialog:
+      | DIALOG          | KEYS             |
+      | select commit 2 | down space enter |
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                      |
+      | old    | git checkout -b parent main                  |
+      | parent | git cherry-pick {{ sha-initial 'commit 2' }} |
+    And Git Town prints the error:
+      """
+      CONFLICT (modify/delete): file deleted in HEAD and modified in
+      """
+    And file "file" now has content:
+      """
+      content 2
+      """
+    And wait 1 second to ensure new Git timestamps
+    When I run "git add file"
+    And I run "git town continue"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                      |
+      | parent | GIT_EDITOR=true git cherry-pick --continue   |
+      |        | git checkout old                             |
+      | old    | git -c rebase.updateRefs=false rebase parent |
+    And Git Town prints the error:
+      """
+      CONFLICT (add/add): Merge conflict in file
+      """
+    And Git Town prints something like:
+      """
+      error: could not apply .* commit 1
+      """
+    And file "file" now has content:
+      """
+      <<<<<<< HEAD
+      content 2
+      =======
+      content 1
+      >>>>>>> {{ sha-short "commit 1" }} (commit 1)
+      """
+    When I resolve the conflict in "file" with "content 1"
+    And I run "git town continue"
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                         |
+      | old    | GIT_EDITOR=true git rebase --continue           |
+      |        | git push --force-with-lease --force-if-includes |
+      |        | git checkout parent                             |
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE  | FILE NAME | FILE CONTENT |
+      | old    | local, origin | commit 1 | file      | content 1    |
+      |        |               | commit 2 | file      | content 2    |
+      |        |               | commit 3 | file      | content 3    |
+      |        | origin        | commit 2 | file      | content 2    |
+      | parent | local         | commit 2 | file      | content 2    |
+    And this lineage exists now
+      | BRANCH | PARENT |
+      | old    | parent |
+      | parent | main   |
+
+  Scenario: first sync after prepend
+    When I run "git town sync"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                                                      |
+      | parent | git fetch --prune --tags                                                     |
+      |        | git -c rebase.updateRefs=false rebase --onto main {{ sha 'initial commit' }} |
+      |        | git push -u origin parent                                                    |
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE  |
+      | old    | local, origin | commit 1 |
+      |        |               | commit 2 |
+      |        |               | commit 3 |
+      | parent | local, origin | commit 2 |
+    And no uncommitted files exist now
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                         |
+      | parent | git checkout old                                |
+      | old    | git reset --hard {{ sha 'commit 3' }}           |
+      |        | git push --force-with-lease --force-if-includes |
+      |        | git branch -D parent                            |
+    And the initial commits exist now
+    And the initial lineage exists now
+
+  Scenario: sync and amend the beamed commit
+    When wait 1 second to ensure new Git timestamps
+    And I run "git town sync"
+    And I amend this commit
+      | BRANCH | LOCATION | MESSAGE   | FILE NAME | FILE CONTENT    |
+      | parent | local    | commit 2b | file      | amended content |
+    And the current branch is "old"
+    And I run "git town sync"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                                                                       |
+      | old    | git fetch --prune --tags                                                                      |
+      |        | git checkout parent                                                                           |
+      | parent | git push --force-with-lease --force-if-includes                                               |
+      |        | git -c rebase.updateRefs=false rebase --onto main {{ sha 'initial commit' }}                  |
+      |        | git checkout old                                                                              |
+      | old    | git -c rebase.updateRefs=false rebase --onto parent {{ sha-in-origin-before-run 'commit 2' }} |
+      |        | git checkout --theirs file                                                                    |
+      |        | git add file                                                                                  |
+      |        | GIT_EDITOR=true git rebase --continue                                                         |
+      |        | git push --force-with-lease --force-if-includes                                               |
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE   | FILE NAME | FILE CONTENT    |
+      | old    | local, origin | commit 1  | file      | content 1       |
+      |        |               | commit 2  | file      | content 2       |
+      |        |               | commit 3  | file      | content 3       |
+      | parent | local, origin | commit 2b | file      | amended content |
diff --git a/internal/cmd/continue.go b/internal/cmd/continue.go
index c15f6879e..a958e274a 100644
--- a/internal/cmd/continue.go
+++ b/internal/cmd/continue.go
@@ -59,11 +59,11 @@ func executeContinue(verbose configdomain.Verbose) error {
 	if err != nil {
 		return err
 	}
-	data, exit, err := determineContinueData(repo, verbose)
+	runState, exit, err := determineContinueRunstate(repo)
 	if err != nil || exit {
 		return err
 	}
-	runState, exit, err := determineContinueRunstate(repo)
+	data, exit, err := determineContinueData(repo, verbose)
 	if err != nil || exit {
 		return err
 	}
diff --git a/internal/git/commands.go b/internal/git/commands.go
index a8826ab2b..ae6a15e02 100644
--- a/internal/git/commands.go
+++ b/internal/git/commands.go
@@ -213,6 +213,14 @@ func (self *Commands) CherryPick(runner gitdomain.Runner, sha gitdomain.SHA) err
 	return runner.Run("git", "cherry-pick", sha.String())
 }
 
+func (self *Commands) CherryPickAbort(runner gitdomain.Runner) error {
+	return runner.Run("git", "cherry-pick", "--abort")
+}
+
+func (self *Commands) CherryPickContinue(runner gitdomain.Runner) error {
+	return runner.RunWithEnv([]string{"GIT_EDITOR=true"}, "git", "cherry-pick", "--continue")
+}
+
 // CommentOutSquashCommitMessage comments out the message for the current squash merge
 // If the given prefix has content, adds it together with a newline.
 func (self *Commands) CommentOutSquashCommitMessage(prefix Option[string]) error {
diff --git a/internal/vm/opcodes/all.go b/internal/vm/opcodes/all.go
index b9c518e0e..0f5c71cf0 100644
--- a/internal/vm/opcodes/all.go
+++ b/internal/vm/opcodes/all.go
@@ -40,6 +40,8 @@ func All() []shared.Opcode {
 		&CheckoutParentOrMain{},
 		&CheckoutUncached{},
 		&Checkout{},
+		&CherryPickAbort{},
+		&CherryPickContinue{},
 		&CherryPick{},
 		&CommitAutoUndo{},
 		&CommitMessageCommentOut{},
diff --git a/internal/vm/opcodes/cherry_pick.go b/internal/vm/opcodes/cherry_pick.go
index 0ddcaac78..3e5d7a42d 100644
--- a/internal/vm/opcodes/cherry_pick.go
+++ b/internal/vm/opcodes/cherry_pick.go
@@ -11,6 +11,18 @@ type CherryPick struct {
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
+func (self *CherryPick) AbortProgram() []shared.Opcode {
+	return []shared.Opcode{
+		&CherryPickAbort{},
+	}
+}
+
+func (self *CherryPick) ContinueProgram() []shared.Opcode {
+	return []shared.Opcode{
+		&CherryPickContinue{},
+	}
+}
+
 func (self *CherryPick) Run(args shared.RunArgs) error {
 	return args.Git.CherryPick(args.Frontend, self.SHA)
 }
diff --git a/internal/vm/opcodes/cherry_pick_abort.go b/internal/vm/opcodes/cherry_pick_abort.go
new file mode 100644
index 000000000..5e6534c42
--- /dev/null
+++ b/internal/vm/opcodes/cherry_pick_abort.go
@@ -0,0 +1,14 @@
+package opcodes
+
+import (
+	"github.com/git-town/git-town/v20/internal/vm/shared"
+)
+
+// Checkout checks out the given existing branch.
+type CherryPickAbort struct {
+	undeclaredOpcodeMethods `exhaustruct:"optional"`
+}
+
+func (self *CherryPickAbort) Run(args shared.RunArgs) error {
+	return args.Git.CherryPickAbort(args.Frontend)
+}
diff --git a/internal/vm/opcodes/cherry_pick_continue.go b/internal/vm/opcodes/cherry_pick_continue.go
new file mode 100644
index 000000000..01013e6b4
--- /dev/null
+++ b/internal/vm/opcodes/cherry_pick_continue.go
@@ -0,0 +1,14 @@
+package opcodes
+
+import (
+	"github.com/git-town/git-town/v20/internal/vm/shared"
+)
+
+// Checkout checks out the given existing branch.
+type CherryPickContinue struct {
+	undeclaredOpcodeMethods `exhaustruct:"optional"`
+}
+
+func (self *CherryPickContinue) Run(args shared.RunArgs) error {
+	return args.Git.CherryPickContinue(args.Frontend)
+}



Test patch:
diff --git a/internal/test/cucumber/steps.go b/internal/test/cucumber/steps.go
index 5ad62a5b0..f3e3fee0f 100644
--- a/internal/test/cucumber/steps.go
+++ b/internal/test/cucumber/steps.go
@@ -335,6 +335,8 @@ func defineSteps(sc *godog.ScenarioContext) {
 		devRepo := state.fixture.DevRepo.GetOrPanic()
 		actualContent := strings.TrimSpace(devRepo.FileContent(file))
 		expectedText := handlebars.Expand(expectedContent.Content, handlebars.ExpandArgs{
+			BeforeRunDevSHAs:       state.beforeRunDevSHAs.GetOrPanic(),
+			BeforeRunOriginSHAsOpt: state.beforeRunOriginSHAs,
 			InitialDevCommits:      state.initialDevSHAs.GetOrPanic(),
 			InitialOriginCommits:   state.initialOriginSHAs,
 			InitialWorktreeCommits: state.initialWorktreeSHAs,
@@ -487,6 +489,8 @@ func defineSteps(sc *godog.ScenarioContext) {
 		table := output.RenderExecutedGitCommands(commands, input)
 		dataTable := datatable.FromGherkin(input)
 		expanded := dataTable.Expand(handlebars.ExpandArgs{
+			BeforeRunDevSHAs:       state.beforeRunDevSHAs.GetOrPanic(),
+			BeforeRunOriginSHAsOpt: state.beforeRunOriginSHAs,
 			InitialDevCommits:      state.initialDevSHAs.GetOrPanic(),
 			InitialOriginCommits:   state.initialOriginSHAs,
 			InitialWorktreeCommits: state.initialWorktreeSHAs,
@@ -650,13 +654,51 @@ func defineSteps(sc *godog.ScenarioContext) {
 		devRepo.CheckoutBranch(existingBranch)
 	})
 
-	sc.Step(`^inspect the commits$`, func(ctx context.Context) error {
+	sc.Step(`^inspect the commits$`, func(ctx context.Context) {
 		state := ctx.Value(keyScenarioState).(*ScenarioState)
-		devRepo := state.fixture.DevRepo.GetOrPanic()
-		fmt.Println("DEV")
-		output, err := devRepo.Query("git", "branch", "-vva")
-		fmt.Println(output)
-		return err
+		if devRepo, hasDevRepo := state.fixture.DevRepo.Get(); hasDevRepo {
+			fmt.Println("\nsha")
+			fmt.Println(asserts.NoError1(devRepo.Query("git", "branch", "-vva")))
+		}
+		if originRepo, hasOriginRepo := state.fixture.OriginRepo.Get(); hasOriginRepo {
+			fmt.Println("\nsha-in-origin")
+			fmt.Println(asserts.NoError1(originRepo.Query("git", "branch", "-vva")))
+		}
+		if initialSHAs, hasInitialSHAs := state.initialDevSHAs.Get(); hasInitialSHAs {
+			fmt.Println("\nsha-initial")
+			for _, commit := range initialSHAs {
+				fmt.Printf("- %s (%s)\n", commit.SHA.Truncate(7), commit.Message)
+			}
+		}
+		if initialOriginSHAs, hasInitialOriginSHAs := state.initialOriginSHAs.Get(); hasInitialOriginSHAs {
+			fmt.Println("\nsha-in-origin-initial")
+			for _, commit := range initialOriginSHAs {
+				fmt.Printf("- %s (%s)\n", commit.SHA.Truncate(7), commit.Message)
+			}
+		}
+		if worktreeRepo, hasWorktreeRepo := state.fixture.SecondWorktree.Get(); hasWorktreeRepo {
+			fmt.Println("\nsha-in-worktree")
+			fmt.Println(asserts.NoError1(worktreeRepo.Query("git", "branch", "-vva")))
+		}
+		if initialWorktreeSHAs, hasInitialWorktreeSHAs := state.initialWorktreeSHAs.Get(); hasInitialWorktreeSHAs {
+			fmt.Println("\nsha-in-worktree-initial")
+			for _, commit := range initialWorktreeSHAs {
+				fmt.Printf("- %s (%s)\n", commit.SHA.Truncate(7), commit.Message)
+			}
+		}
+		if devBeforeRunSHAs, hasDevBeforeRunSHAs := state.beforeRunDevSHAs.Get(); hasDevBeforeRunSHAs {
+			fmt.Println("\nsha-before-run")
+			for _, commit := range devBeforeRunSHAs {
+				fmt.Printf("- %s (%s)\n", commit.SHA.Truncate(7), commit.Message)
+			}
+		}
+		if originBeforeRunSHAs, hasOriginBeforeRunSHAs := state.beforeRunOriginSHAs.Get(); hasOriginBeforeRunSHAs {
+			fmt.Println("\nsha-in-origin-before-run")
+			for _, commit := range originBeforeRunSHAs {
+				fmt.Printf("- %s (%s)\n", commit.SHA.Truncate(7), commit.Message)
+			}
+		}
+		fmt.Println()
 	})
 
 	sc.Step(`^inspect the repo$`, func(ctx context.Context) {
diff --git a/internal/test/handlebars/expand.go b/internal/test/handlebars/expand.go
index 745f2e2e5..7fee4a8e5 100644
--- a/internal/test/handlebars/expand.go
+++ b/internal/test/handlebars/expand.go
@@ -57,6 +57,18 @@ func Expand(text string, args ExpandArgs) string {
 				panic("see error above")
 			}
 			text = strings.Replace(text, match, commit.SHA.String(), 1)
+		case strings.HasPrefix(match, "{{ sha-before-run "):
+			commitMessage := gitdomain.CommitMessage(match[19 : len(match)-4])
+			commit, found := args.BeforeRunDevSHAs.FindByCommitMessage(commitMessage).Get()
+			if !found {
+				fmt.Printf("I cannot find the before-run dev commit %q.\n", commitMessage)
+				fmt.Printf("I have records about %d commits:\n", len(args.BeforeRunDevSHAs))
+				for _, commit := range args.BeforeRunDevSHAs {
+					fmt.Printf("  - %q (%s)\n", commit.Message, commit.SHA)
+				}
+				panic("see error above")
+			}
+			text = strings.Replace(text, match, commit.SHA.String(), 1)
 		case strings.HasPrefix(match, "{{ sha-in-origin-initial "):
 			initialOriginCommits, has := args.InitialOriginCommits.Get()
 			if !has {
@@ -72,6 +84,21 @@ func Expand(text string, args ExpandArgs) string {
 				}
 			}
 			text = strings.Replace(text, match, commit.SHA.String(), 1)
+		case strings.HasPrefix(match, "{{ sha-in-origin-before-run "):
+			beforeRunOriginSHAs, has := args.BeforeRunOriginSHAsOpt.Get()
+			if !has {
+				panic("no origin SHAs recorded")
+			}
+			commitMessage := gitdomain.CommitMessage(match[29 : len(match)-4])
+			commit, hasCommit := beforeRunOriginSHAs.FindByCommitMessage(commitMessage).Get()
+			if !hasCommit {
+				fmt.Printf("I cannot find the initial origin commit %q.\n", commitMessage)
+				fmt.Printf("I have records about %d commits:\n", len(beforeRunOriginSHAs))
+				for _, commit := range beforeRunOriginSHAs {
+					fmt.Printf("  - %q (%s)\n", commit.Message, commit.SHA)
+				}
+			}
+			text = strings.Replace(text, match, commit.SHA.String(), 1)
 		case strings.HasPrefix(match, "{{ sha-in-worktree "):
 			commitMessage := gitdomain.CommitMessage(match[20 : len(match)-4])
 			shas := args.WorktreeRepo.SHAsForCommit(commitMessage)
@@ -100,6 +127,8 @@ func Expand(text string, args ExpandArgs) string {
 }
 
 type ExpandArgs struct {
+	BeforeRunDevSHAs       gitdomain.Commits
+	BeforeRunOriginSHAsOpt Option[gitdomain.Commits]
 	InitialDevCommits      gitdomain.Commits
 	InitialOriginCommits   Option[gitdomain.Commits]
 	InitialWorktreeCommits Option[gitdomain.Commits]
diff --git a/internal/vm/statefile/save_load_test.go b/internal/vm/statefile/save_load_test.go
index 42ad2bd87..97fc6cc13 100644
--- a/internal/vm/statefile/save_load_test.go
+++ b/internal/vm/statefile/save_load_test.go
@@ -74,6 +74,8 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.CheckoutHistoryPreserve{PreviousBranchCandidates: []Option[gitdomain.LocalBranchName]{gitdomain.NewLocalBranchNameOption("previous")}},
 				&opcodes.CheckoutIfNeeded{Branch: "branch"},
 				&opcodes.CheckoutUncached{Branch: "branch"},
+				&opcodes.CherryPick{SHA: "123456"},
+				&opcodes.CherryPickContinue{},
 				&opcodes.Commit{AuthorOverride: Some(gitdomain.Author("user@acme.com")), FallbackToDefaultCommitMessage: true, Message: Some(gitdomain.CommitMessage("my message"))},
 				&opcodes.CommitAutoUndo{AuthorOverride: Some(gitdomain.Author("user@acme.com")), FallbackToDefaultCommitMessage: true, Message: Some(gitdomain.CommitMessage("my message"))},
 				&opcodes.CommitMessageCommentOut{},
@@ -334,6 +336,16 @@ func TestLoadSave(t *testing.T) {
       },
       "type": "CheckoutUncached"
     },
+    {
+      "data": {
+        "SHA": "123456"
+      },
+      "type": "CherryPick"
+    },
+    {
+      "data": {},
+      "type": "CherryPickContinue"
+    },
     {
       "data": {
         "AuthorOverride": "user@acme.com",