Repository: git-town/git-town

Issue:
"git town walk" command
```
git town walk <--all|--stack> [command...]
```

Sometimes I want to execute a shell command for each branch in a stack. Examples: 

- run the linter in all branches of a stack to see which branch introduces a change that breaks the linter
- run the formatter in all branches of a stack
- run tests all stacks locally so that I don't have to wait for CI
- find and/or delete local branches that haven't been updated in a while
- go through all your local branches to investigate them, see if you want to make any of them parked or observed

Right now I could use `git town switch` to manually go through each branch. It would be nice to be able to run a single command, get some water, and when I return, Git Town has done all the work.

### Examples

```
git town walk --stack -- make lint

git town walk --all -- ./print-last-commit-date.sh
```

### Options
 
`--stack` runs the command on all branches of the stack (oldest first, excluding the perennial root branch). `--all` runs the command on all local branches (not the perennial branches)

If no command is given, Git Town exits on each branch, letting you run whatever shell commands you want. Run `git town continue` goes to the next branch. The benefit is that it remembers what the next branch is and automatically goes to it.

If a command is given, Git Town executes the given command for each branch, and exits if the command returns a non-zero exit code.

Git Town does not `cd` into the root repo of the Git directory, but stay in the current directory

Hints from issue comments:
@kevgo: This is now available as part of [Git Town 21](https://github.com/git-town/git-town/releases/tag/v21.0.0).

Solution patch:
diff --git a/features/walk/all/no_args.feature b/features/walk/all/no_args.feature
new file mode 100644
index 000000000..ca80940c6
--- /dev/null
+++ b/features/walk/all/no_args.feature
@@ -0,0 +1,96 @@
+Feature: walk all local branches
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT   | LOCATIONS |
+      | branch-1 | feature | main     | local     |
+      | branch-2 | feature | branch-1 | local     |
+      | branch-3 | feature | branch-2 | local     |
+      | branch-A | feature | main     | local     |
+    And the current branch is "branch-2"
+    When I run "git-town walk --all"
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-1 |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-1"
+
+  Scenario: continue through all remaining branches
+    When I run "git-town continue"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-1 | git checkout branch-2 |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-2"
+    When I run "git-town continue"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-3 |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-3"
+    When I run "git-town continue"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-3 | git checkout branch-A |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-A"
+    When I run "git-town continue"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-A | git checkout branch-2 |
+    And Git Town prints:
+      """
+      Branch walk done.
+      """
+
+  Scenario: skip all remaining branches
+    When I run "git-town skip"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-1 | git checkout branch-2 |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-2"
+    When I run "git-town skip"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-3 |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-3"
+    When I run "git-town skip"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-3 | git checkout branch-A |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-A"
+    When I run "git-town skip"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-A | git checkout branch-2 |
+    And Git Town prints:
+      """
+      Branch walk done.
+      """
diff --git a/features/walk/all/with_args.feature b/features/walk/all/with_args.feature
new file mode 100644
index 000000000..3a9bf5ae4
--- /dev/null
+++ b/features/walk/all/with_args.feature
@@ -0,0 +1,27 @@
+Feature: execute a script in all local branches
+
+  Scenario: iterate the full stack
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT   | LOCATIONS |
+      | branch-1 | feature | main     | local     |
+      | branch-2 | feature | branch-1 | local     |
+      | branch-3 | feature | branch-2 | local     |
+      | branch-A | feature | main     | local     |
+    And the current branch is "branch-2"
+    When I run "git-town walk --all echo hello"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-1 |
+      | branch-1 | echo hello            |
+      |          | git checkout branch-2 |
+      | branch-2 | echo hello            |
+      |          | git checkout branch-3 |
+      | branch-3 | echo hello            |
+      |          | git checkout branch-A |
+      | branch-A | echo hello            |
+      |          | git checkout branch-2 |
+    And Git Town prints:
+      """
+      Branch walk done.
+      """
diff --git a/features/walk/dry_run/with_command.feature b/features/walk/dry_run/with_command.feature
new file mode 100644
index 000000000..ea85944ff
--- /dev/null
+++ b/features/walk/dry_run/with_command.feature
@@ -0,0 +1,54 @@
+@skipWindows
+Feature: walk each branch of a stack in dry-run mode
+
+  Scenario: iterate the full stack
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT   | LOCATIONS |
+      | branch-1 | feature | main     | local     |
+      | branch-2 | feature | branch-1 | local     |
+      | branch-3 | feature | branch-2 | local     |
+      | branch-A | feature | main     | local     |
+    And the current branch is "branch-2"
+    When I run "git-town walk --all --dry-run touch file"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-1 |
+      | branch-1 | touch file            |
+      |          | git checkout branch-2 |
+      | branch-2 | touch file            |
+      |          | git checkout branch-3 |
+      | branch-3 | touch file            |
+      |          | git checkout branch-A |
+      | branch-A | touch file            |
+      |          | git checkout branch-2 |
+    And Git Town prints:
+      """
+      [branch-2] git checkout branch-1
+      (dry run)
+
+      [branch-1] touch file
+      (dry run)
+
+      [branch-1] git checkout branch-2
+      (dry run)
+
+      [branch-2] touch file
+      (dry run)
+
+      [branch-2] git checkout branch-3
+      (dry run)
+
+      [branch-3] touch file
+      (dry run)
+
+      [branch-3] git checkout branch-A
+      (dry run)
+
+      [branch-A] touch file
+      (dry run)
+
+      [branch-A] git checkout branch-2
+      (dry run)
+      """
+    And no uncommitted files exist now
diff --git a/features/walk/dry_run/without_command.feature b/features/walk/dry_run/without_command.feature
new file mode 100644
index 000000000..39b3efbf1
--- /dev/null
+++ b/features/walk/dry_run/without_command.feature
@@ -0,0 +1,16 @@
+Feature: walk each branch of a stack in dry-run mode
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT   | LOCATIONS |
+      | branch-1 | feature | main     | local     |
+      | branch-2 | feature | branch-1 | local     |
+    And the current branch is "branch-2"
+    When I run "git-town walk --all --dry-run"
+
+  Scenario: result
+    Then Git Town prints the error:
+      """
+      Error: there is no dry-run mode for walking through branches on your shell, please call with a command to run on each branch
+      """
diff --git a/features/walk/failing_script/failing_script.feature b/features/walk/failing_script/failing_script.feature
new file mode 100644
index 000000000..b9175fe61
--- /dev/null
+++ b/features/walk/failing_script/failing_script.feature
@@ -0,0 +1,64 @@
+@skipWindows
+Feature: handle errors in the given script
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT | LOCATIONS |
+      | branch-1 | feature | main   | local     |
+      | branch-2 | feature | main   | local     |
+      | branch-3 | feature | main   | local     |
+    And the current branch is "branch-2"
+    When I run "git-town walk --all test"
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-1 |
+      | branch-1 | test                  |
+    And Git Town prints the error:
+      """
+      To continue after having resolved conflicts, run "git town continue".
+      To go back to where you started, run "git town undo".
+      """
+
+  Scenario: continue re-runs the failed script
+    When I run "git-town continue"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND |
+      | branch-1 | test    |
+    And Git Town prints the error:
+      """
+      To continue after having resolved conflicts, run "git town continue".
+      To go back to where you started, run "git town undo".
+      """
+
+  Scenario: skip runs the given script on the next branch
+    When I run "git-town skip"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-1 | git checkout branch-2 |
+      | branch-2 | test                  |
+    And Git Town prints the error:
+      """
+      To continue after having resolved conflicts, run "git town continue".
+      To go back to where you started, run "git town undo".
+      """
+    When I run "git-town skip"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-3 |
+      | branch-3 | test                  |
+    And Git Town prints the error:
+      """
+      To continue after having resolved conflicts, run "git town continue".
+      To go back to where you started, run "git town undo".
+      """
+    When I run "git-town skip"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-3 | git checkout branch-2 |
+    And Git Town prints:
+      """
+      Branch walk done.
+      """
diff --git a/features/walk/stack/no_args.feature b/features/walk/stack/no_args.feature
new file mode 100644
index 000000000..38d75f3a3
--- /dev/null
+++ b/features/walk/stack/no_args.feature
@@ -0,0 +1,46 @@
+Feature: walk each branch of a stack without arguments
+
+  Scenario: action
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT   | LOCATIONS |
+      | branch-1 | feature | main     | local     |
+      | branch-2 | feature | branch-1 | local     |
+      | branch-3 | feature | branch-2 | local     |
+      | branch-A | feature | main     | local     |
+    And the current branch is "branch-2"
+    When I run "git-town walk --stack"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-1 |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-1"
+    When I run "git-town continue"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-1 | git checkout branch-2 |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-2"
+    When I run "git-town continue"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-3 |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-3"
+    When I run "git-town continue"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-3 | git checkout branch-2 |
+    And Git Town prints:
+      """
+      Branch walk done.
+      """
diff --git a/features/walk/stack/with_args.feature b/features/walk/stack/with_args.feature
new file mode 100644
index 000000000..a1ce03449
--- /dev/null
+++ b/features/walk/stack/with_args.feature
@@ -0,0 +1,25 @@
+Feature: walk each branch of a stack with arguments
+
+  Scenario: iterate the full stack
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT   | LOCATIONS |
+      | branch-1 | feature | main     | local     |
+      | branch-2 | feature | branch-1 | local     |
+      | branch-3 | feature | branch-2 | local     |
+      | branch-A | feature | main     | local     |
+    And the current branch is "branch-2"
+    When I run "git-town walk --stack echo hello"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-1 |
+      | branch-1 | echo hello            |
+      |          | git checkout branch-2 |
+      | branch-2 | echo hello            |
+      |          | git checkout branch-3 |
+      | branch-3 | echo hello            |
+      |          | git checkout branch-2 |
+    And Git Town prints:
+      """
+      Branch walk done.
+      """
diff --git a/features/walk/uncommitted/file.feature b/features/walk/uncommitted/file.feature
new file mode 100644
index 000000000..3d2b5018e
--- /dev/null
+++ b/features/walk/uncommitted/file.feature
@@ -0,0 +1,37 @@
+Feature: handle uncommitted files
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT   | LOCATIONS |
+      | branch-1 | feature | main     | local     |
+      | branch-2 | feature | branch-1 | local     |
+    And the current branch is "branch-2"
+    And an uncommitted file
+    When I run "git-town walk --stack echo hello"
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND                     |
+      | branch-2 | git add -A                  |
+      |          | git stash -m "Git Town WIP" |
+      |          | git checkout branch-1       |
+      | branch-1 | echo hello                  |
+      |          | git checkout branch-2       |
+      | branch-2 | echo hello                  |
+      |          | git stash pop               |
+      |          | git restore --staged .      |
+    And Git Town prints:
+      """
+      Branch walk done.
+      """
+    And the uncommitted file still exists
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND                     |
+      | branch-2 | git add -A                  |
+      |          | git stash -m "Git Town WIP" |
+      |          | git stash pop               |
+      |          | git restore --staged .      |
diff --git a/features/walk/undo/command.feature b/features/walk/undo/command.feature
new file mode 100644
index 000000000..17d8b5700
--- /dev/null
+++ b/features/walk/undo/command.feature
@@ -0,0 +1,37 @@
+Feature: undo changes made manually
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT | LOCATIONS |
+      | branch-1 | feature | main   | local     |
+      | branch-2 | feature | main   | local     |
+    And the current branch is "branch-2"
+    When I run "git-town walk --all -- git commit --allow-empty -m commit"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND                            |
+      | branch-2 | git checkout branch-1              |
+      | branch-1 | git commit --allow-empty -m commit |
+      |          | git checkout branch-2              |
+      | branch-2 | git commit --allow-empty -m commit |
+    And Git Town prints:
+      """
+      Branch walk done.
+      """
+
+  Scenario: result
+    Then these commits exist now
+      | BRANCH   | LOCATION | MESSAGE |
+      | branch-1 | local    | commit  |
+      | branch-2 | local    | commit  |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND                                     |
+      | branch-2 | git checkout branch-1                       |
+      | branch-1 | git reset --hard {{ sha 'initial commit' }} |
+      |          | git checkout branch-2                       |
+      | branch-2 | git reset --hard {{ sha 'initial commit' }} |
+    And the current branch is now "branch-2"
+    And no commits exist now
diff --git a/features/walk/undo/manual.feature b/features/walk/undo/manual.feature
new file mode 100644
index 000000000..f22196a03
--- /dev/null
+++ b/features/walk/undo/manual.feature
@@ -0,0 +1,54 @@
+Feature: undo changes made manually
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT   | LOCATIONS |
+      | branch-1 | feature | main     | local     |
+      | branch-2 | feature | branch-1 | local     |
+    And the current branch is "branch-2"
+    When I run "git-town walk --all"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-2 | git checkout branch-1 |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And I add this commit to the current branch:
+      | MESSAGE  | FILE NAME | FILE CONTENT |
+      | commit 1 | file_1    | content 1    |
+    When I run "git-town continue"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND               |
+      | branch-1 | git checkout branch-2 |
+    And Git Town prints:
+      """
+      Run "git town continue" to go to the next branch.
+      """
+    And the current branch is now "branch-2"
+    And I add this commit to the current branch:
+      | MESSAGE  | FILE NAME | FILE CONTENT |
+      | commit 2 | file_2    | content 2    |
+    When I run "git-town continue"
+    And Git Town prints:
+      """
+      Branch walk done.
+      """
+
+  Scenario: result
+    Then these commits exist now
+      | BRANCH   | LOCATION | MESSAGE  |
+      | branch-1 | local    | commit 1 |
+      | branch-2 | local    | commit 2 |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND                                     |
+      | branch-2 | git checkout branch-1                       |
+      | branch-1 | git reset --hard {{ sha 'initial commit' }} |
+      |          | git checkout branch-2                       |
+      | branch-2 | git reset --hard {{ sha 'initial commit' }} |
+    And the current branch is now "branch-2"
+    And no commits exist now
diff --git a/features/walk/verbose/enabled.feature b/features/walk/verbose/enabled.feature
new file mode 100644
index 000000000..25c5ba6e9
--- /dev/null
+++ b/features/walk/verbose/enabled.feature
@@ -0,0 +1,59 @@
+Feature: walk each branch verbosely
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME     | TYPE    | PARENT   | LOCATIONS |
+      | branch-1 | feature | main     | local     |
+      | branch-2 | feature | branch-1 | local     |
+    And the current branch is "branch-2"
+    When I run "git-town walk --stack --verbose echo hello"
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH   | COMMAND                                                                                                                                                                                                                                                                                                                                          |
+      |          | git version                                                                                                                                                                                                                                                                                                                                      |
+      |          | git rev-parse --show-toplevel                                                                                                                                                                                                                                                                                                                    |
+      |          | git config -lz --global                                                                                                                                                                                                                                                                                                                          |
+      |          | git config -lz --local                                                                                                                                                                                                                                                                                                                           |
+      |          | git config -lz                                                                                                                                                                                                                                                                                                                                   |
+      |          | git status -z --ignore-submodules                                                                                                                                                                                                                                                                                                                |
+      |          | git rev-parse --verify -q MERGE_HEAD                                                                                                                                                                                                                                                                                                             |
+      |          | git rev-parse --absolute-git-dir                                                                                                                                                                                                                                                                                                                 |
+      |          | git stash list                                                                                                                                                                                                                                                                                                                                   |
+      |          | git for-each-ref "--format=refname:%(refname) branchname:%(refname:lstrip=2) sha:%(objectname) head:%(if)%(HEAD)%(then)Y%(else)N%(end) worktree:%(if)%(worktreepath)%(then)Y%(else)N%(end) symref:%(if)%(symref)%(then)Y%(else)N%(end) upstream:%(upstream:lstrip=2) track:%(upstream:track,nobracket)" --sort=refname refs/heads/ refs/remotes/ |
+      |          | git rev-parse --verify --abbrev-ref @{-1}                                                                                                                                                                                                                                                                                                        |
+      |          | git remote get-url origin                                                                                                                                                                                                                                                                                                                        |
+      | branch-2 | git checkout branch-1                                                                                                                                                                                                                                                                                                                            |
+      | branch-1 | echo hello                                                                                                                                                                                                                                                                                                                                       |
+      |          | git checkout branch-2                                                                                                                                                                                                                                                                                                                            |
+      | branch-2 | echo hello                                                                                                                                                                                                                                                                                                                                       |
+      |          | git rev-parse --verify -q refs/heads/main                                                                                                                                                                                                                                                                                                        |
+      |          | git checkout main                                                                                                                                                                                                                                                                                                                                |
+      |          | git checkout branch-2                                                                                                                                                                                                                                                                                                                            |
+      |          | git for-each-ref "--format=refname:%(refname) branchname:%(refname:lstrip=2) sha:%(objectname) head:%(if)%(HEAD)%(then)Y%(else)N%(end) worktree:%(if)%(worktreepath)%(then)Y%(else)N%(end) symref:%(if)%(symref)%(then)Y%(else)N%(end) upstream:%(upstream:lstrip=2) track:%(upstream:track,nobracket)" --sort=refname refs/heads/ refs/remotes/ |
+      |          | git config -lz --global                                                                                                                                                                                                                                                                                                                          |
+      |          | git config -lz --local                                                                                                                                                                                                                                                                                                                           |
+      |          | git stash list                                                                                                                                                                                                                                                                                                                                   |
+    And Git Town prints:
+      """
+      Branch walk done.
+      """
+
+  Scenario: undo
+    When I run "git-town undo --verbose"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                                                                                                                                                                                                                                                                                                                          |
+      |        | git version                                                                                                                                                                                                                                                                                                                                      |
+      |        | git rev-parse --show-toplevel                                                                                                                                                                                                                                                                                                                    |
+      |        | git config -lz --global                                                                                                                                                                                                                                                                                                                          |
+      |        | git config -lz --local                                                                                                                                                                                                                                                                                                                           |
+      |        | git config -lz                                                                                                                                                                                                                                                                                                                                   |
+      |        | git status -z --ignore-submodules                                                                                                                                                                                                                                                                                                                |
+      |        | git rev-parse --verify -q MERGE_HEAD                                                                                                                                                                                                                                                                                                             |
+      |        | git rev-parse --absolute-git-dir                                                                                                                                                                                                                                                                                                                 |
+      |        | git stash list                                                                                                                                                                                                                                                                                                                                   |
+      |        | git for-each-ref "--format=refname:%(refname) branchname:%(refname:lstrip=2) sha:%(objectname) head:%(if)%(HEAD)%(then)Y%(else)N%(end) worktree:%(if)%(worktreepath)%(then)Y%(else)N%(end) symref:%(if)%(symref)%(then)Y%(else)N%(end) upstream:%(upstream:lstrip=2) track:%(upstream:track,nobracket)" --sort=refname refs/heads/ refs/remotes/ |
+      |        | git remote get-url origin                                                                                                                                                                                                                                                                                                                        |
+      |        | git rev-parse --verify --abbrev-ref @{-1}                                                                                                                                                                                                                                                                                                        |
+      |        | git remote get-url origin                                                                                                                                                                                                                                                                                                                        |
diff --git a/internal/cmd/doc.go b/internal/cmd/doc.go
index 32384150e..dfb07191a 100644
--- a/internal/cmd/doc.go
+++ b/internal/cmd/doc.go
@@ -42,5 +42,6 @@ func Execute() error {
 	rootCmd.AddCommand(switchCmd())
 	rootCmd.AddCommand(sync.Cmd())
 	rootCmd.AddCommand(undoCmd())
+	rootCmd.AddCommand(walkCommand())
 	return rootCmd.Execute()
 }
diff --git a/internal/cmd/walk.go b/internal/cmd/walk.go
new file mode 100644
index 000000000..5f5aa8ad3
--- /dev/null
+++ b/internal/cmd/walk.go
@@ -0,0 +1,309 @@
+package cmd
+
+import (
+	"errors"
+	"os"
+
+	"github.com/git-town/git-town/v20/internal/cli/dialog/components"
+	"github.com/git-town/git-town/v20/internal/cli/flags"
+	"github.com/git-town/git-town/v20/internal/cli/print"
+	"github.com/git-town/git-town/v20/internal/cmd/cmdhelpers"
+	"github.com/git-town/git-town/v20/internal/config"
+	"github.com/git-town/git-town/v20/internal/config/configdomain"
+	"github.com/git-town/git-town/v20/internal/execute"
+	"github.com/git-town/git-town/v20/internal/forge"
+	"github.com/git-town/git-town/v20/internal/forge/forgedomain"
+	"github.com/git-town/git-town/v20/internal/git/gitdomain"
+	"github.com/git-town/git-town/v20/internal/messages"
+	"github.com/git-town/git-town/v20/internal/undo/undoconfig"
+	"github.com/git-town/git-town/v20/internal/validate"
+	"github.com/git-town/git-town/v20/internal/vm/interpreter/fullinterpreter"
+	"github.com/git-town/git-town/v20/internal/vm/opcodes"
+	"github.com/git-town/git-town/v20/internal/vm/optimizer"
+	"github.com/git-town/git-town/v20/internal/vm/program"
+	"github.com/git-town/git-town/v20/internal/vm/runstate"
+	. "github.com/git-town/git-town/v20/pkg/prelude"
+	"github.com/spf13/cobra"
+)
+
+const (
+	walkCmd  = "walk"
+	walkDesc = "Run a command on each local feature branch"
+	walkHelp = `
+Executes the given command on each local feature branch in stack order.
+Stops if the command exits with an error,
+giving you a chance to investigate and fix the issue.
+
+* use "git town continue" to retry the command on the current branch
+* use "git town skip" to move on to the next branch
+* use "git town undo" to abort the iteration and undo all changes made
+* use "git town status reset" to abort the iteration and keep all changes made
+
+If no shell command is provided, drops you into an interactive shell for each branch.
+You can manually run any shell commands,
+then proceed to the next branch with "git town continue".
+
+Consider this stack:
+
+main
+	\
+   branch-1
+	 	\
+     branch-2
+		 	\
+       branch-3
+
+Running "git town walk --stack make lint" produces this output:
+
+[branch-1] make lint
+... output of make lint
+
+[branch-2] make lint
+... output of make lint
+
+[branch-3] make lint
+... output of make lint
+`
+)
+
+func walkCommand() *cobra.Command {
+	addAllFlag, readAllFlag := flags.All("iterate all local branches")
+	addDryRunFlag, readDryRunFlag := flags.DryRun()
+	addStackFlag, readStackFlag := flags.Stack("iterate all branches in the current stack")
+	addVerboseFlag, readVerboseFlag := flags.Verbose()
+	cmd := cobra.Command{
+		Use:     walkCmd,
+		Args:    cobra.ArbitraryArgs,
+		GroupID: cmdhelpers.GroupIDStack,
+		Short:   walkDesc,
+		Long:    cmdhelpers.Long(walkDesc, walkHelp),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			allBranches, err := readAllFlag(cmd)
+			if err != nil {
+				return err
+			}
+			dryRun, err := readDryRunFlag(cmd)
+			if err != nil {
+				return err
+			}
+			stack, err := readStackFlag(cmd)
+			if err != nil {
+				return err
+			}
+			verbose, err := readVerboseFlag(cmd)
+			if err != nil {
+				return err
+			}
+			return executeWalk(args, dryRun, allBranches, stack, verbose)
+		},
+	}
+	addAllFlag(&cmd)
+	addDryRunFlag(&cmd)
+	addStackFlag(&cmd)
+	addVerboseFlag(&cmd)
+	return &cmd
+}
+
+func executeWalk(args []string, dryRun configdomain.DryRun, allBranches configdomain.AllBranches, fullStack configdomain.FullStack, verbose configdomain.Verbose) error {
+	if len(args) == 0 && dryRun {
+		return errors.New(messages.WalkNoDryRun)
+	}
+	err := validateArgs(allBranches, fullStack)
+	if err != nil {
+		return err
+	}
+	data, exit, err := determineWalkData(allBranches, dryRun, fullStack, verbose)
+	if err != nil || exit {
+		return err
+	}
+	runProgram := walkProgram(args, data, dryRun)
+	runState := runstate.RunState{
+		BeginBranchesSnapshot: data.branchesSnapshot,
+		BeginConfigSnapshot:   data.repo.ConfigSnapshot,
+		BeginStashSize:        data.stashSize,
+		BranchInfosLastRun:    data.branchInfosLastRun,
+		Command:               walkCmd,
+		DryRun:                dryRun,
+		EndBranchesSnapshot:   None[gitdomain.BranchesSnapshot](),
+		EndConfigSnapshot:     None[undoconfig.ConfigSnapshot](),
+		EndStashSize:          None[gitdomain.StashSize](),
+		RunProgram:            runProgram,
+		TouchedBranches:       runProgram.TouchedBranches(),
+		UndoAPIProgram:        program.Program{},
+	}
+	return fullinterpreter.Execute(fullinterpreter.ExecuteArgs{
+		Backend:                 data.repo.Backend,
+		CommandsCounter:         data.repo.CommandsCounter,
+		Config:                  data.config,
+		Connector:               data.connector,
+		Detached:                true,
+		DialogTestInputs:        data.dialogTestInputs,
+		FinalMessages:           data.repo.FinalMessages,
+		Frontend:                data.repo.Frontend,
+		Git:                     data.repo.Git,
+		HasOpenChanges:          data.hasOpenChanges,
+		InitialBranch:           data.initialBranch,
+		InitialBranchesSnapshot: data.branchesSnapshot,
+		InitialConfigSnapshot:   data.repo.ConfigSnapshot,
+		InitialStashSize:        data.stashSize,
+		RootDir:                 data.repo.RootDir,
+		RunState:                runState,
+		Verbose:                 verbose,
+	})
+}
+
+type walkData struct {
+	branchInfosLastRun Option[gitdomain.BranchInfos]
+	branchesSnapshot   gitdomain.BranchesSnapshot
+	branchesToWalk     gitdomain.LocalBranchNames
+	config             config.ValidatedConfig
+	connector          Option[forgedomain.Connector]
+	dialogTestInputs   components.TestInputs
+	hasOpenChanges     bool
+	initialBranch      gitdomain.LocalBranchName
+	previousBranch     Option[gitdomain.LocalBranchName]
+	repo               execute.OpenRepoResult
+	stashSize          gitdomain.StashSize
+}
+
+func determineWalkData(all configdomain.AllBranches, dryRun configdomain.DryRun, stack configdomain.FullStack, verbose configdomain.Verbose) (walkData, bool, error) {
+	repo, err := execute.OpenRepo(execute.OpenRepoArgs{
+		DryRun:           dryRun,
+		PrintBranchNames: true,
+		PrintCommands:    true,
+		ValidateGitRepo:  true,
+		ValidateIsOnline: false,
+		Verbose:          verbose,
+	})
+	if err != nil {
+		return walkData{}, false, err
+	}
+	dialogTestInputs := components.LoadTestInputs(os.Environ())
+	repoStatus, err := repo.Git.RepoStatus(repo.Backend)
+	if err != nil {
+		return walkData{}, false, err
+	}
+	branchesSnapshot, stashSize, branchInfosLastRun, exit, err := execute.LoadRepoSnapshot(execute.LoadRepoSnapshotArgs{
+		Backend:               repo.Backend,
+		CommandsCounter:       repo.CommandsCounter,
+		ConfigSnapshot:        repo.ConfigSnapshot,
+		Detached:              true,
+		DialogTestInputs:      dialogTestInputs,
+		Fetch:                 false,
+		FinalMessages:         repo.FinalMessages,
+		Frontend:              repo.Frontend,
+		Git:                   repo.Git,
+		HandleUnfinishedState: true,
+		Repo:                  repo,
+		RepoStatus:            repoStatus,
+		RootDir:               repo.RootDir,
+		UnvalidatedConfig:     repo.UnvalidatedConfig,
+		ValidateNoOpenChanges: false,
+		Verbose:               verbose,
+	})
+	if err != nil || exit {
+		return walkData{}, exit, err
+	}
+	previousBranch := repo.Git.PreviouslyCheckedOutBranch(repo.Backend)
+	initialBranch, hasInitialBranch := branchesSnapshot.Active.Get()
+	if !hasInitialBranch {
+		return walkData{}, false, errors.New(messages.CurrentBranchCannotDetermine)
+	}
+	connector, err := forge.NewConnector(repo.UnvalidatedConfig, repo.UnvalidatedConfig.NormalConfig.DevRemote, print.Logger{})
+	if err != nil {
+		return walkData{}, false, err
+	}
+	branchesAndTypes := repo.UnvalidatedConfig.UnvalidatedBranchesAndTypes(branchesSnapshot.Branches.LocalBranches().Names())
+	localBranches := branchesSnapshot.Branches.LocalBranches().Names()
+	validatedConfig, exit, err := validate.Config(validate.ConfigArgs{
+		Backend:            repo.Backend,
+		BranchesAndTypes:   branchesAndTypes,
+		BranchesSnapshot:   branchesSnapshot,
+		BranchesToValidate: gitdomain.LocalBranchNames{initialBranch},
+		Connector:          connector,
+		DialogTestInputs:   dialogTestInputs,
+		Frontend:           repo.Frontend,
+		Git:                repo.Git,
+		LocalBranches:      localBranches,
+		RepoStatus:         repoStatus,
+		TestInputs:         dialogTestInputs,
+		Unvalidated:        NewMutable(&repo.UnvalidatedConfig),
+	})
+	if err != nil || exit {
+		return walkData{}, exit, err
+	}
+	perennialBranchNames := branchesAndTypes.BranchesOfTypes(configdomain.BranchTypePerennialBranch, configdomain.BranchTypeMainBranch)
+	branchesToWalk := gitdomain.LocalBranchNames{}
+	switch {
+	case all.Enabled():
+		branchesToWalk = localBranches.Remove(perennialBranchNames...)
+	case stack.Enabled():
+		branchesToWalk = validatedConfig.NormalConfig.Lineage.BranchLineageWithoutRoot(initialBranch, perennialBranchNames)
+	}
+	return walkData{
+		branchInfosLastRun: branchInfosLastRun,
+		branchesSnapshot:   branchesSnapshot,
+		branchesToWalk:     branchesToWalk,
+		config:             validatedConfig,
+		connector:          connector,
+		dialogTestInputs:   dialogTestInputs,
+		hasOpenChanges:     repoStatus.OpenChanges,
+		initialBranch:      initialBranch,
+		previousBranch:     previousBranch,
+		repo:               repo,
+		stashSize:          stashSize,
+	}, false, err
+}
+
+func walkProgram(args []string, data walkData, dryRun configdomain.DryRun) program.Program {
+	prog := NewMutable(&program.Program{})
+	hasCall, executable, callArgs := parseArgs(args)
+	for _, branchToWalk := range data.branchesToWalk {
+		prog.Value.Add(&opcodes.CheckoutIfNeeded{Branch: branchToWalk})
+		if hasCall {
+			prog.Value.Add(
+				&opcodes.ExecuteShellCommand{
+					Executable: executable,
+					Args:       callArgs,
+				},
+			)
+		} else {
+			prog.Value.Add(
+				&opcodes.ExitToShell{},
+			)
+		}
+		prog.Value.Add(
+			&opcodes.ProgramEndOfBranch{},
+		)
+	}
+	prog.Value.Add(
+		&opcodes.CheckoutIfNeeded{
+			Branch: data.initialBranch,
+		},
+		&opcodes.MessageQueue{
+			Message: messages.WalkDone,
+		},
+	)
+	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
+		DryRun:                   dryRun,
+		InitialStashSize:         data.stashSize,
+		RunInGitRoot:             true,
+		StashOpenChanges:         data.hasOpenChanges,
+		PreviousBranchCandidates: []Option[gitdomain.LocalBranchName]{data.previousBranch},
+	})
+	return optimizer.Optimize(prog.Immutable())
+}
+
+func validateArgs(all configdomain.AllBranches, stack configdomain.FullStack) error {
+	if all.Enabled() == stack.Enabled() {
+		return errors.New(messages.WalkAllOrStack)
+	}
+	return nil
+}
+
+func parseArgs(args []string) (bool, string, []string) {
+	if len(args) == 0 {
+		return false, "", []string{}
+	}
+	return true, args[0], args[1:]
+}
diff --git a/internal/messages/en.go b/internal/messages/en.go
index 7c8c64773..e1dc79427 100644
--- a/internal/messages/en.go
+++ b/internal/messages/en.go
@@ -277,4 +277,7 @@ and will be removed in future versions of Git Town.`
 	UnfinishedRunStateQuit                  = "Quit without running anything"
 	UnfinishedRunStateSkip                  = "Skip the current branch and continue the \"%s\" command on the next branch"
 	UnfinishedRunStateUndo                  = "Undo the previous \"%s\" command"
+	WalkAllOrStack                          = "please provide either --all or --stack"
+	WalkDone                                = "Branch walk done."
+	WalkNoDryRun                            = "there is no dry-run mode for walking through branches on your shell, please call with a command to run on each branch"
 )
diff --git a/internal/vm/interpreter/fullinterpreter/errored.go b/internal/vm/interpreter/fullinterpreter/errored.go
index 9f2128daf..d5ca137e7 100644
--- a/internal/vm/interpreter/fullinterpreter/errored.go
+++ b/internal/vm/interpreter/fullinterpreter/errored.go
@@ -48,10 +48,6 @@ func errored(failedOpcode shared.Opcode, runErr error, args ExecuteArgs) error {
 		continueProgram = []shared.Opcode{failedOpcode}
 	}
 	args.RunState.RunProgram.Prepend(continueProgram...)
-	err = args.RunState.MarkAsUnfinished(args.Git, args.Backend)
-	if err != nil {
-		return err
-	}
 	currentBranch, err := args.Git.CurrentBranch(args.Backend)
 	if err != nil {
 		return err
@@ -60,10 +56,16 @@ func errored(failedOpcode shared.Opcode, runErr error, args ExecuteArgs) error {
 	if err != nil {
 		return err
 	}
+	canSkip := false
 	if args.RunState.Command == "sync" && !(repoStatus.RebaseInProgress && args.Config.ValidatedConfigData.IsMainBranch(currentBranch)) {
-		if unfinishedDetails, hasUnfinishedDetails := args.RunState.UnfinishedDetails.Get(); hasUnfinishedDetails {
-			unfinishedDetails.CanSkip = true
-		}
+		canSkip = true
+	}
+	if args.RunState.Command == "walk" {
+		canSkip = true
+	}
+	err = args.RunState.MarkAsUnfinished(args.Git, args.Backend, canSkip)
+	if err != nil {
+		return err
 	}
 	err = statefile.Save(args.RunState, args.RootDir)
 	if err != nil {
diff --git a/internal/vm/interpreter/fullinterpreter/execute.go b/internal/vm/interpreter/fullinterpreter/execute.go
index 58cc1a653..32263c1fd 100644
--- a/internal/vm/interpreter/fullinterpreter/execute.go
+++ b/internal/vm/interpreter/fullinterpreter/execute.go
@@ -10,6 +10,7 @@ import (
 	"github.com/git-town/git-town/v20/internal/gohacks"
 	"github.com/git-town/git-town/v20/internal/gohacks/stringslice"
 	"github.com/git-town/git-town/v20/internal/undo/undoconfig"
+	"github.com/git-town/git-town/v20/internal/vm/opcodes"
 	"github.com/git-town/git-town/v20/internal/vm/runstate"
 	"github.com/git-town/git-town/v20/internal/vm/shared"
 	. "github.com/git-town/git-town/v20/pkg/prelude"
@@ -30,6 +31,9 @@ func Execute(args ExecuteArgs) error {
 				Verbose:         args.Verbose,
 			})
 		}
+		if _, ok := nextStep.(*opcodes.ExitToShell); ok {
+			return exitToShell(args)
+		}
 		err := nextStep.Run(shared.RunArgs{
 			Backend:                         args.Backend,
 			BranchInfos:                     Some(args.InitialBranchesSnapshot.Branches),
diff --git a/internal/vm/interpreter/fullinterpreter/exit_to_shell.go b/internal/vm/interpreter/fullinterpreter/exit_to_shell.go
new file mode 100644
index 000000000..e5742ea48
--- /dev/null
+++ b/internal/vm/interpreter/fullinterpreter/exit_to_shell.go
@@ -0,0 +1,51 @@
+package fullinterpreter
+
+import (
+	"fmt"
+
+	"github.com/git-town/git-town/v20/internal/cli/print"
+	"github.com/git-town/git-town/v20/internal/config/configdomain"
+	"github.com/git-town/git-town/v20/internal/config/gitconfig"
+	"github.com/git-town/git-town/v20/internal/messages"
+	"github.com/git-town/git-town/v20/internal/undo/undoconfig"
+	"github.com/git-town/git-town/v20/internal/vm/statefile"
+	. "github.com/git-town/git-town/v20/pkg/prelude"
+)
+
+// exitToShell is called when Git Town should exit to the shell without an error
+func exitToShell(args ExecuteArgs) error {
+	endBranchesSnapshot, err := args.Git.BranchesSnapshot(args.Backend)
+	if err != nil {
+		return err
+	}
+	args.RunState.EndBranchesSnapshot = Some(endBranchesSnapshot)
+	configGitAccess := gitconfig.Access{Runner: args.Backend}
+	globalSnapshot, err := configGitAccess.Load(Some(configdomain.ConfigScopeGlobal), configdomain.UpdateOutdatedNo)
+	if err != nil {
+		return err
+	}
+	localSnapshot, err := configGitAccess.Load(Some(configdomain.ConfigScopeLocal), configdomain.UpdateOutdatedNo)
+	if err != nil {
+		return err
+	}
+	args.RunState.EndConfigSnapshot = Some(undoconfig.ConfigSnapshot{
+		Global: globalSnapshot,
+		Local:  localSnapshot,
+	})
+	endStashSize, err := args.Git.StashSize(args.Backend)
+	if err != nil {
+		return err
+	}
+	args.RunState.EndStashSize = Some(endStashSize)
+	err = args.RunState.MarkAsUnfinished(args.Git, args.Backend, true)
+	if err != nil {
+		return err
+	}
+	err = statefile.Save(args.RunState, args.RootDir)
+	if err != nil {
+		return fmt.Errorf(messages.RunstateSaveProblem, err)
+	}
+	args.FinalMessages.Add(`Run "git town continue" to go to the next branch.`)
+	print.Footer(args.Verbose, args.CommandsCounter.Immutable(), args.FinalMessages.Result())
+	return nil
+}
diff --git a/internal/vm/opcodes/all.go b/internal/vm/opcodes/all.go
index af2a79ab7..b9c518e0e 100644
--- a/internal/vm/opcodes/all.go
+++ b/internal/vm/opcodes/all.go
@@ -54,6 +54,8 @@ func All() []shared.Opcode {
 		&ConflictPhantomResolveAll{},
 		&ConflictPhantomResolve{},
 		&ConnectorProposalMerge{},
+		&ExecuteShellCommand{},
+		&ExitToShell{},
 		&FetchUpstream{},
 		&LineageBranchRemove{},
 		&LineageParentRemove{},
diff --git a/internal/vm/opcodes/execute_shell_command.go b/internal/vm/opcodes/execute_shell_command.go
new file mode 100644
index 000000000..2f4828aeb
--- /dev/null
+++ b/internal/vm/opcodes/execute_shell_command.go
@@ -0,0 +1,15 @@
+package opcodes
+
+import "github.com/git-town/git-town/v20/internal/vm/shared"
+
+// FetchUpstream brings the Git history of the local repository
+// up to speed with activities that happened in the upstream remote.
+type ExecuteShellCommand struct {
+	Args                    []string
+	Executable              string
+	undeclaredOpcodeMethods `exhaustruct:"optional"`
+}
+
+func (self *ExecuteShellCommand) Run(args shared.RunArgs) error {
+	return args.Frontend.Run(self.Executable, self.Args...)
+}
diff --git a/internal/vm/opcodes/exit_to_shell.go b/internal/vm/opcodes/exit_to_shell.go
new file mode 100644
index 000000000..7d559952d
--- /dev/null
+++ b/internal/vm/opcodes/exit_to_shell.go
@@ -0,0 +1,5 @@
+package opcodes
+
+type ExitToShell struct {
+	undeclaredOpcodeMethods `exhaustruct:"optional"`
+}
diff --git a/internal/vm/runstate/runstate.go b/internal/vm/runstate/runstate.go
index 89bcf9bfd..cc8a6d2a2 100644
--- a/internal/vm/runstate/runstate.go
+++ b/internal/vm/runstate/runstate.go
@@ -79,13 +79,13 @@ func (self *RunState) MarkAsFinished(endBranchesSnapshot gitdomain.BranchesSnaps
 }
 
 // MarkAsUnfinished updates the run state to be marked as unfinished and populates informational fields.
-func (self *RunState) MarkAsUnfinished(gitCommands git.Commands, backend gitdomain.Querier) error {
+func (self *RunState) MarkAsUnfinished(gitCommands git.Commands, backend gitdomain.Querier, canSkip bool) error {
 	currentBranch, err := gitCommands.CurrentBranch(backend)
 	if err != nil {
 		return err
 	}
 	self.UnfinishedDetails = MutableSome(&UnfinishedRunStateDetails{
-		CanSkip:   false,
+		CanSkip:   canSkip,
 		EndBranch: currentBranch,
 		EndTime:   time.Now(),
 	})
diff --git a/website/src/commands/walk.md b/website/src/commands/walk.md
new file mode 100644
index 000000000..50d811347
--- /dev/null
+++ b/website/src/commands/walk.md
@@ -0,0 +1,67 @@
+# git town walk
+
+```command-summary
+git town walk [-a | --all] [-s | --stack] [--dry-run] [-v | --verbose] [<command and arguments>]
+```
+
+The _walk_ command ("walking the branch hierarchy") executes a given command for
+each feature branch. It stops if the command exits with an error, giving you a
+chance to investigate and fix the issue.
+
+- use [git town continue](continue.md) to retry the command on the current
+  branch
+- use [git town skip](skip.md) to move on to the next branch
+- use [git town undo](undo.md) to abort the iteration and undo all changes made
+- use [git town status reset](status-reset.md) to abort the iteration and keep
+  all changes made
+
+If no shell command is provided, drops you into an interactive shell for each
+branch. You can manually run any shell commands, then proceed to the next branch
+with [git town continue](continue.md)
+
+## Examples
+
+Consider this stack:
+
+```
+main
+ \
+  branch-1
+   \
+*   branch-2
+     \
+      branch-3
+```
+
+Running `git town walk --stack make lint` produces this output:
+
+```bash
+[branch-1] make lint
+# ... output of "make lint" for branch-1
+
+[branch-2] make lint
+# ... output of "make lint" for branch-2
+
+[branch-3] make lint
+# ... output of "make lint" for branch-3
+```
+
+## Options
+
+#### `-a`<br>`--all`
+
+Iterate through all local branches.
+
+#### `-s`<br>`--stack`
+
+Iterate through all branches of the stack that the current branch belongs to.
+
+#### `--dry-run`
+
+Test-drive this command: It prints the commands that would be run but doesn't
+execute them.
+
+#### `-v`<br>`--verbose`
+
+Print all Git commands executed under the hood to determine the repository
+state.



Test patch:
diff --git a/internal/vm/statefile/save_load_test.go b/internal/vm/statefile/save_load_test.go
index 9f2f3a5d4..42ad2bd87 100644
--- a/internal/vm/statefile/save_load_test.go
+++ b/internal/vm/statefile/save_load_test.go
@@ -87,6 +87,8 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.ConflictPhantomFinalize{},
 				&opcodes.ConflictPhantomResolve{FilePath: "file", Resolution: gitdomain.ConflictResolutionOurs},
 				&opcodes.ConnectorProposalMerge{Branch: "branch", CommitMessage: Some(gitdomain.CommitMessage("commit message")), Proposal: forgedomain.Proposal{Data: forgedomain.BitbucketCloudProposalData{ProposalData: forgedomain.ProposalData{Body: Some("body"), MergeWithAPI: true, Number: 123, Source: "source", Target: "target", Title: "title", URL: "url"}}, ForgeType: forgedomain.ForgeTypeBitbucket}},
+				&opcodes.ExecuteShellCommand{Args: []string{"arg1", "arg2"}, Executable: "executable"},
+				&opcodes.ExitToShell{},
 				&opcodes.FetchUpstream{Branch: "branch"},
 				&opcodes.LineageBranchRemove{Branch: "branch"},
 				&opcodes.LineageParentRemove{Branch: "branch"},
@@ -433,6 +435,20 @@ func TestLoadSave(t *testing.T) {
       },
       "type": "ConnectorProposalMerge"
     },
+    {
+      "data": {
+        "Args": [
+          "arg1",
+          "arg2"
+        ],
+        "Executable": "executable"
+      },
+      "type": "ExecuteShellCommand"
+    },
+    {
+      "data": {},
+      "type": "ExitToShell"
+    },
     {
       "data": {
         "Branch": "branch"