Repository: git-town/git-town

Issue:
remove the branch lists for contribution, observed, parked, and prototype branches
This is a possible simplification of the config settings, mentioned by @stephenwade in https://github.com/git-town/git-town/pull/4433#issuecomment-2571446788.

These config entries don't have a function anymore:

- `contribution-branches`: `contribution-regex` and overriding individual branches is enough
- `observed-branches`: `observed-regex` and overriding individual branches is enough
- `parked-branches`: `default-branch-type`, `new-branch-type`, and overriding individual branches is enough 
- `prototype-branches`: `default-branch-type`, `new-branch-type` and overriding individual branches is enough

This subset of facilities to define reusable branch types is sufficient:
- `default-branch-type`
- `new-branch-type`
- `feature-regex`
- `contribution-regex`
- `observed-regex`
- `perennial-regex`
- `perennial-branches`

For all other use cases, overriding the branch type locally using `git-town-branch.<name>.branch-type` is sufficient:

- mark local branches as parked or prototype 
- override the branch type of any other branch on a case by case basis


Discussion:
@kevgo: #4501 documents the new roles of the branch lists. They don't add much value, but there is cost to them in terms of making the product more complex, and increasing the learning curve for new users. People need to wrap their head around the various config settings and how they interact with each other. When wondering what type a branch has, people need to look at:

- branch type override for the branch
- list of hard-coded branch names
- regex
- default branch type

Removing these configuration settings would result in this hierarchy of config settings:

- branch type override
- regex
- default branch type@kevgo: This is now available as part of Git Town [v19.0](https://github.com/git-town/git-town/releases/tag/v19.0.0).

Solution patch:
diff --git a/features/config/setup/change_git_metadata.feature b/features/config/setup/change_git_metadata.feature
index 24198d1d0..8abc4b81e 100644
--- a/features/config/setup/change_git_metadata.feature
+++ b/features/config/setup/change_git_metadata.feature
@@ -69,7 +69,7 @@ Feature: change existing information in Git metadata
     And global Git setting "alias.ship" is now "town ship"
     And global Git setting "alias.sync" is now "town sync"
     And the main branch is now "main"
-    And the perennial branches are now "production"
+    And local Git setting "git-town.perennial-branches" is now "production qa"
     And local Git setting "git-town.dev-remote" now doesn't exist
     And local Git setting "git-town.new-branch-type" is now "parked"
     And local Git setting "git-town.hosting-platform" is now "github"
diff --git a/features/config/setup/migrate_git_to_file.feature b/features/config/setup/migrate_git_to_file.feature
index b71dd493d..b05100138 100644
--- a/features/config/setup/migrate_git_to_file.feature
+++ b/features/config/setup/migrate_git_to_file.feature
@@ -3,11 +3,9 @@ Feature: migrate existing configuration in Git metadata to a config file
 
   Background:
     Given a Git repo with origin
-    And the branches
-      | NAME | TYPE      | LOCATIONS     |
-      | qa   | perennial | local, origin |
     And the main branch is "main"
     And local Git setting "git-town.perennial-regex" is "release-.*"
+    And local Git setting "git-town.perennial-branches" is "qa"
     And local Git setting "git-town.feature-regex" is "user-.*"
     And local Git setting "git-town.default-branch-type" is "observed"
     And local Git setting "git-town.dev-remote" is "fork"
diff --git a/features/config/setup/remove_git_metadata.feature b/features/config/setup/remove_git_metadata.feature
index 2a71b8f53..5c0886c7f 100644
--- a/features/config/setup/remove_git_metadata.feature
+++ b/features/config/setup/remove_git_metadata.feature
@@ -5,9 +5,9 @@ Feature: remove existing configuration in Git metadata
     Given a Git repo with origin
     And I rename the "origin" remote to "fork"
     And the branches
-      | NAME       | TYPE      | LOCATIONS |
-      | qa         | perennial | local     |
-      | production | (none)    | local     |
+      | NAME       | TYPE   | LOCATIONS |
+      | qa         | (none) | local     |
+      | production | (none) | local     |
     And the main branch is "main"
     And global Git setting "alias.append" is "town append"
     And global Git setting "alias.diff-parent" is "town diff-parent"
@@ -21,6 +21,7 @@ Feature: remove existing configuration in Git metadata
     And global Git setting "alias.ship" is "town ship"
     And global Git setting "alias.sync" is "town sync"
     And local Git setting "git-town.hosting-platform" is "github"
+    And local Git setting "git-town.perennial-branches" is "qa"
     And local Git setting "git-town.perennial-regex" is "qa.*"
     And local Git setting "git-town.feature-regex" is "user.*"
     And local Git setting "git-town.default-branch-type" is "observed"
diff --git a/features/config/view/happy_path.feature b/features/config/view/happy_path.feature
index ed0f57c06..66cfb45e5 100644
--- a/features/config/view/happy_path.feature
+++ b/features/config/view/happy_path.feature
@@ -221,12 +221,13 @@ Feature: show the configuration
 
   Scenario: all configured, with stacked changes
     Given the branches
-      | NAME   | TYPE      | PARENT | LOCATIONS |
-      | alpha  | feature   | main   | local     |
-      | qa     | perennial |        | local     |
-      | beta   | feature   | main   | local     |
-      | child  | feature   | alpha  | local     |
-      | hotfix | feature   | qa     | local     |
+      | NAME   | TYPE    | PARENT | LOCATIONS |
+      | alpha  | feature | main   | local     |
+      | qa     | (none)  |        | local     |
+      | beta   | feature | main   | local     |
+      | child  | feature | alpha  | local     |
+      | hotfix | feature | qa     | local     |
+    And Git setting "git-town.perennial-branches" is "qa"
     When I run "git-town config"
     Then Git Town prints:
       """
diff --git a/features/contribute/give_existing_branch/observed_branch.feature b/features/contribute/give_existing_branch/observed_branch.feature
index ea899627b..b15a5ae09 100644
--- a/features/contribute/give_existing_branch/observed_branch.feature
+++ b/features/contribute/give_existing_branch/observed_branch.feature
@@ -3,8 +3,8 @@ Feature: make another observed branch a contribution branch
   Background:
     Given a Git repo with origin
     And the branches
-      | NAME     | TYPE     | PARENT | LOCATIONS     |
-      | observed | observed |        | local, origin |
+      | NAME     | TYPE     | LOCATIONS     |
+      | observed | observed | local, origin |
     When I run "git-town contribute observed"
 
   Scenario: result
diff --git a/features/contribute/give_no_branch/verbose.feature b/features/contribute/give_no_branch/verbose.feature
index 2f50a1fdf..904c9ec8a 100644
--- a/features/contribute/give_no_branch/verbose.feature
+++ b/features/contribute/give_no_branch/verbose.feature
@@ -11,16 +11,16 @@ Feature: make the current branch a contribution branch verbosely
 
   Scenario: result
     Then Git Town runs the commands
-      | BRANCH | COMMAND                                           |
-      |        | git version                                       |
-      |        | git rev-parse --show-toplevel                     |
-      |        | git config -lz --includes --global                |
-      |        | git config -lz --includes --local                 |
-      |        | git branch -vva --sort=refname                    |
-      |        | git config git-town.contribution-branches feature |
-      |        | git branch -vva --sort=refname                    |
-      |        | git config -lz --includes --global                |
-      |        | git config -lz --includes --local                 |
+      | BRANCH | COMMAND                                                    |
+      |        | git version                                                |
+      |        | git rev-parse --show-toplevel                              |
+      |        | git config -lz --includes --global                         |
+      |        | git config -lz --includes --local                          |
+      |        | git branch -vva --sort=refname                             |
+      |        | git config git-town-branch.feature.branchtype contribution |
+      |        | git branch -vva --sort=refname                             |
+      |        | git config -lz --includes --global                         |
+      |        | git config -lz --includes --local                          |
     And Git Town prints:
       """
       Ran 9 shell commands
@@ -36,22 +36,22 @@ Feature: make the current branch a contribution branch verbosely
   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 --includes --global                |
-      |         | git config -lz --includes --local                 |
-      |         | git status --long --ignore-submodules             |
-      |         | git stash list                                    |
-      |         | git branch -vva --sort=refname                    |
-      |         | git remote get-url origin                         |
-      |         | git rev-parse --verify --abbrev-ref @{-1}         |
-      |         | git remote get-url origin                         |
-      | feature | git add -A                                        |
-      |         | git stash                                         |
-      | <none>  | git config --unset git-town.contribution-branches |
-      |         | git stash list                                    |
-      | feature | git stash pop                                     |
+      | BRANCH  | COMMAND                                               |
+      |         | git version                                           |
+      |         | git rev-parse --show-toplevel                         |
+      |         | git config -lz --includes --global                    |
+      |         | git config -lz --includes --local                     |
+      |         | git status --long --ignore-submodules                 |
+      |         | git stash list                                        |
+      |         | git branch -vva --sort=refname                        |
+      |         | git remote get-url origin                             |
+      |         | git rev-parse --verify --abbrev-ref @{-1}             |
+      |         | git remote get-url origin                             |
+      | feature | git add -A                                            |
+      |         | git stash                                             |
+      | <none>  | git config --unset git-town-branch.feature.branchtype |
+      |         | git stash list                                        |
+      | feature | git stash pop                                         |
     And Git Town prints:
       """
       Ran 15 shell commands
diff --git a/features/delete/current_branch/features/overridden_branch_type.feature b/features/delete/current_branch/features/overridden_branch_type.feature
new file mode 100644
index 000000000..d5affcf10
--- /dev/null
+++ b/features/delete/current_branch/features/overridden_branch_type.feature
@@ -0,0 +1,46 @@
+Feature: delete a branch that has an overridden branch type
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME         | TYPE         | PARENT | LOCATIONS     |
+      | contribution | contribution | main   | local, origin |
+      | other        | feature      | main   | local, origin |
+    And the commits
+      | BRANCH       | LOCATION      | MESSAGE             |
+      | contribution | local, origin | contribution commit |
+      | other        | local, origin | other commit        |
+    And the current branch is "contribution"
+    And Git setting "git-town-branch.contribution.branchtype" is "feature"
+    When I run "git-town delete"
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH       | COMMAND                       |
+      | contribution | git fetch --prune --tags      |
+      |              | git push origin :contribution |
+      |              | git checkout main             |
+      | main         | git branch -D contribution    |
+    And the current branch is now "main"
+    And Git setting "git-town-branch.contribution.branchtype" now doesn't exist
+    And the branches are now
+      | REPOSITORY    | BRANCHES    |
+      | local, origin | main, other |
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE      |
+      | other  | local, origin | other commit |
+    And this lineage exists now
+      | BRANCH | PARENT |
+      | other  | main   |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                                                 |
+      | main   | git branch contribution {{ sha 'contribution commit' }} |
+      |        | git push -u origin contribution                         |
+      |        | git checkout contribution                               |
+    And the current branch is now "contribution"
+    And Git setting "git-town-branch.contribution.branchtype" is now "feature"
+    And the initial commits exist now
+    And the initial branches and lineage exist now
diff --git a/features/hack/give_no_branch/on_contribution_branch.feature b/features/hack/give_no_branch/on_contribution_branch.feature
index 3f06902b9..94cdd3644 100644
--- a/features/hack/give_no_branch/on_contribution_branch.feature
+++ b/features/hack/give_no_branch/on_contribution_branch.feature
@@ -3,8 +3,9 @@ Feature: making the current contribution branch a feature branch
   Background:
     Given a Git repo with origin
     And the branches
-      | NAME         | TYPE         | LOCATIONS |
-      | contribution | contribution | local     |
+      | NAME         | TYPE   | LOCATIONS |
+      | contribution | (none) | local     |
+    And local Git setting "git-town.contribution-branches" is "contribution"
     And the current branch is "contribution"
     When I run "git-town hack"
 
@@ -15,8 +16,11 @@ Feature: making the current contribution branch a feature branch
       branch "contribution" is now a feature branch
       """
     And branch "contribution" is now a feature branch
+    And local Git setting "git-town-branch.contribution.branchtype" is now "feature"
+    And local Git setting "git-town.contribution-branches" is still "contribution"
 
   Scenario: undo
     When I run "git-town undo"
     Then Git Town runs no commands
     And branch "contribution" is now a contribution branch
+    And local Git setting "git-town-branch.contribution.branchtype" now doesn't exist
diff --git a/features/hack/give_no_branch/on_observed_branch.feature b/features/hack/give_no_branch/on_observed_branch.feature
index 0976d8982..55200821f 100644
--- a/features/hack/give_no_branch/on_observed_branch.feature
+++ b/features/hack/give_no_branch/on_observed_branch.feature
@@ -3,9 +3,10 @@ Feature: making the current observed branch a feature branch
   Background:
     Given a Git repo with origin
     And the branches
-      | NAME     | TYPE     | PARENT | LOCATIONS |
-      | observed | observed |        | local     |
+      | NAME     | TYPE   | LOCATIONS |
+      | observed | (none) | local     |
     And the current branch is "observed"
+    And local Git setting "git-town.observed-branches" is "observed"
     When I run "git-town hack"
 
   Scenario: result
@@ -15,8 +16,11 @@ Feature: making the current observed branch a feature branch
       branch "observed" is now a feature branch
       """
     And branch "observed" is now a feature branch
+    And local Git setting "git-town-branch.observed.branchtype" is now "feature"
+    And local Git setting "git-town.observed-branches" is still "observed"
 
   Scenario: undo
     When I run "git-town undo"
     Then Git Town runs no commands
     And branch "observed" is now observed
+    And local Git setting "git-town-branch.observed.branchtype" now doesn't exist
diff --git a/features/hack/give_no_branch/on_parked_branch.feature b/features/hack/give_no_branch/on_parked_branch.feature
index 07b21e62d..b7c54bec4 100644
--- a/features/hack/give_no_branch/on_parked_branch.feature
+++ b/features/hack/give_no_branch/on_parked_branch.feature
@@ -4,8 +4,9 @@ Feature: making the current parked branch a feature branch
     Given a Git repo with origin
     And the branches
       | NAME   | TYPE   | PARENT | LOCATIONS |
-      | parked | parked | main   | local     |
+      | parked | (none) | main   | local     |
     And the current branch is "parked"
+    And local Git setting "git-town.parked-branches" is "parked"
     When I run "git-town hack"
 
   Scenario: result
@@ -15,8 +16,11 @@ Feature: making the current parked branch a feature branch
       branch "parked" is now a feature branch
       """
     And branch "parked" is now a feature branch
+    And local Git setting "git-town-branch.parked.branchtype" is now "feature"
+    And local Git setting "git-town.parked-branches" is still "parked"
 
   Scenario: undo
     When I run "git-town undo"
     Then Git Town runs no commands
     And branch "parked" is now parked
+    And local Git setting "git-town-branch.parked.branchtype" now doesn't exist
diff --git a/features/hack/give_no_branch/on_perennial_branch.feature b/features/hack/give_no_branch/on_perennial_branch.feature
index 3ea5768b2..0e14fc60b 100644
--- a/features/hack/give_no_branch/on_perennial_branch.feature
+++ b/features/hack/give_no_branch/on_perennial_branch.feature
@@ -3,9 +3,10 @@ Feature: cannot make the current perennial branch a feature branch
   Background:
     Given a Git repo with origin
     And the branches
-      | NAME      | TYPE      | LOCATIONS |
-      | perennial | perennial | local     |
+      | NAME      | TYPE   | LOCATIONS |
+      | perennial | (none) | local     |
     And the current branch is "perennial"
+    And local Git setting "git-town.perennial-branches" is "perennial"
     When I run "git-town hack"
 
   Scenario: result
@@ -15,8 +16,11 @@ Feature: cannot make the current perennial branch a feature branch
       branch "perennial" is a perennial branch
       """
     And branch "perennial" is still perennial
+    And local Git setting "git-town-branch.perennial.branchtype" still doesn't exist
+    And local Git setting "git-town.perennial-branches" is still "perennial"
 
   Scenario: undo
     When I run "git-town undo"
     Then Git Town runs no commands
     And branch "perennial" is still perennial
+    And local Git setting "git-town-branch.perennial.branchtype" still doesn't exist
diff --git a/features/observe/give_no_branch/verbose.feature b/features/observe/give_no_branch/verbose.feature
index 15862d237..12affc564 100644
--- a/features/observe/give_no_branch/verbose.feature
+++ b/features/observe/give_no_branch/verbose.feature
@@ -11,16 +11,16 @@ Feature: observe the current branch verbosely
 
   Scenario: result
     Then Git Town runs the commands
-      | BRANCH | COMMAND                                       |
-      |        | git version                                   |
-      |        | git rev-parse --show-toplevel                 |
-      |        | git config -lz --includes --global            |
-      |        | git config -lz --includes --local             |
-      |        | git branch -vva --sort=refname                |
-      |        | git config git-town.observed-branches feature |
-      |        | git branch -vva --sort=refname                |
-      |        | git config -lz --includes --global            |
-      |        | git config -lz --includes --local             |
+      | BRANCH | COMMAND                                                |
+      |        | git version                                            |
+      |        | git rev-parse --show-toplevel                          |
+      |        | git config -lz --includes --global                     |
+      |        | git config -lz --includes --local                      |
+      |        | git branch -vva --sort=refname                         |
+      |        | git config git-town-branch.feature.branchtype observed |
+      |        | git branch -vva --sort=refname                         |
+      |        | git config -lz --includes --global                     |
+      |        | git config -lz --includes --local                      |
     And Git Town prints:
       """
       Ran 9 shell commands
@@ -36,22 +36,22 @@ Feature: observe the current branch verbosely
   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 --includes --global            |
-      |         | git config -lz --includes --local             |
-      |         | git status --long --ignore-submodules         |
-      |         | git stash list                                |
-      |         | git branch -vva --sort=refname                |
-      |         | git remote get-url origin                     |
-      |         | git rev-parse --verify --abbrev-ref @{-1}     |
-      |         | git remote get-url origin                     |
-      | feature | git add -A                                    |
-      |         | git stash                                     |
-      | <none>  | git config --unset git-town.observed-branches |
-      |         | git stash list                                |
-      | feature | git stash pop                                 |
+      | BRANCH  | COMMAND                                               |
+      |         | git version                                           |
+      |         | git rev-parse --show-toplevel                         |
+      |         | git config -lz --includes --global                    |
+      |         | git config -lz --includes --local                     |
+      |         | git status --long --ignore-submodules                 |
+      |         | git stash list                                        |
+      |         | git branch -vva --sort=refname                        |
+      |         | git remote get-url origin                             |
+      |         | git rev-parse --verify --abbrev-ref @{-1}             |
+      |         | git remote get-url origin                             |
+      | feature | git add -A                                            |
+      |         | git stash                                             |
+      | <none>  | git config --unset git-town-branch.feature.branchtype |
+      |         | git stash list                                        |
+      | feature | git stash pop                                         |
     And Git Town prints:
       """
       Ran 15 shell commands
diff --git a/features/park/give_existing_branch/prototype_branch.feature b/features/park/give_existing_branch/prototype_branch.feature
index aa34294b0..c55236397 100644
--- a/features/park/give_existing_branch/prototype_branch.feature
+++ b/features/park/give_existing_branch/prototype_branch.feature
@@ -14,8 +14,7 @@ Feature: park another prototype branch
       """
       branch "prototype" is now parked
       """
-    And the prototype branches are now "prototype"
-    And the parked branches are now "prototype"
+    And branch "prototype" is now parked
 
   Scenario: undo
     When I run "git-town undo"
diff --git a/features/park/give_no_branch/on_prototype_branch.feature b/features/park/give_no_branch/on_prototype_branch.feature
index df8078754..448a4b2b6 100644
--- a/features/park/give_no_branch/on_prototype_branch.feature
+++ b/features/park/give_no_branch/on_prototype_branch.feature
@@ -14,8 +14,7 @@ Feature: park the current prototype branch
       """
       branch "prototype" is now parked
       """
-    And the parked branches are now "prototype"
-    And the prototype branches are still "prototype"
+    And branch "prototype" is now parked
 
   Scenario: undo
     When I run "git-town undo"
diff --git a/features/park/give_no_branch/verbose.feature b/features/park/give_no_branch/verbose.feature
index 7c380cf9a..d92d1a44c 100644
--- a/features/park/give_no_branch/verbose.feature
+++ b/features/park/give_no_branch/verbose.feature
@@ -11,16 +11,16 @@ Feature: park the current branch verbosely
 
   Scenario: result
     Then Git Town runs the commands
-      | BRANCH | COMMAND                                     |
-      |        | git version                                 |
-      |        | git rev-parse --show-toplevel               |
-      |        | git config -lz --includes --global          |
-      |        | git config -lz --includes --local           |
-      |        | git branch -vva --sort=refname              |
-      |        | git config git-town.parked-branches feature |
-      |        | git branch -vva --sort=refname              |
-      |        | git config -lz --includes --global          |
-      |        | git config -lz --includes --local           |
+      | BRANCH | COMMAND                                              |
+      |        | git version                                          |
+      |        | git rev-parse --show-toplevel                        |
+      |        | git config -lz --includes --global                   |
+      |        | git config -lz --includes --local                    |
+      |        | git branch -vva --sort=refname                       |
+      |        | git config git-town-branch.feature.branchtype parked |
+      |        | git branch -vva --sort=refname                       |
+      |        | git config -lz --includes --global                   |
+      |        | git config -lz --includes --local                    |
     And Git Town prints:
       """
       Ran 9 shell commands
@@ -36,22 +36,22 @@ Feature: park the current branch verbosely
   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 --includes --global          |
-      |         | git config -lz --includes --local           |
-      |         | git status --long --ignore-submodules       |
-      |         | git stash list                              |
-      |         | git branch -vva --sort=refname              |
-      |         | git remote get-url origin                   |
-      |         | git rev-parse --verify --abbrev-ref @{-1}   |
-      |         | git remote get-url origin                   |
-      | feature | git add -A                                  |
-      |         | git stash                                   |
-      | <none>  | git config --unset git-town.parked-branches |
-      |         | git stash list                              |
-      | feature | git stash pop                               |
+      | BRANCH  | COMMAND                                               |
+      |         | git version                                           |
+      |         | git rev-parse --show-toplevel                         |
+      |         | git config -lz --includes --global                    |
+      |         | git config -lz --includes --local                     |
+      |         | git status --long --ignore-submodules                 |
+      |         | git stash list                                        |
+      |         | git branch -vva --sort=refname                        |
+      |         | git remote get-url origin                             |
+      |         | git rev-parse --verify --abbrev-ref @{-1}             |
+      |         | git remote get-url origin                             |
+      | feature | git add -A                                            |
+      |         | git stash                                             |
+      | <none>  | git config --unset git-town-branch.feature.branchtype |
+      |         | git stash list                                        |
+      | feature | git stash pop                                         |
     And Git Town prints:
       """
       Ran 15 shell commands
diff --git a/features/prototype/give_no_branch/verbose.feature b/features/prototype/give_no_branch/verbose.feature
index d984cfd84..1f1f3d52e 100644
--- a/features/prototype/give_no_branch/verbose.feature
+++ b/features/prototype/give_no_branch/verbose.feature
@@ -11,16 +11,16 @@ Feature: prototype the current branch verbosely
 
   Scenario: result
     Then Git Town runs the commands
-      | BRANCH | COMMAND                                        |
-      |        | git version                                    |
-      |        | git rev-parse --show-toplevel                  |
-      |        | git config -lz --includes --global             |
-      |        | git config -lz --includes --local              |
-      |        | git branch -vva --sort=refname                 |
-      |        | git config git-town.prototype-branches feature |
-      |        | git branch -vva --sort=refname                 |
-      |        | git config -lz --includes --global             |
-      |        | git config -lz --includes --local              |
+      | BRANCH | COMMAND                                                 |
+      |        | git version                                             |
+      |        | git rev-parse --show-toplevel                           |
+      |        | git config -lz --includes --global                      |
+      |        | git config -lz --includes --local                       |
+      |        | git branch -vva --sort=refname                          |
+      |        | git config git-town-branch.feature.branchtype prototype |
+      |        | git branch -vva --sort=refname                          |
+      |        | git config -lz --includes --global                      |
+      |        | git config -lz --includes --local                       |
     And Git Town prints:
       """
       Ran 9 shell commands
@@ -36,22 +36,22 @@ Feature: prototype the current branch verbosely
   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 --includes --global             |
-      |         | git config -lz --includes --local              |
-      |         | git status --long --ignore-submodules          |
-      |         | git stash list                                 |
-      |         | git branch -vva --sort=refname                 |
-      |         | git remote get-url origin                      |
-      |         | git rev-parse --verify --abbrev-ref @{-1}      |
-      |         | git remote get-url origin                      |
-      | feature | git add -A                                     |
-      |         | git stash                                      |
-      | <none>  | git config --unset git-town.prototype-branches |
-      |         | git stash list                                 |
-      | feature | git stash pop                                  |
+      | BRANCH  | COMMAND                                               |
+      |         | git version                                           |
+      |         | git rev-parse --show-toplevel                         |
+      |         | git config -lz --includes --global                    |
+      |         | git config -lz --includes --local                     |
+      |         | git status --long --ignore-submodules                 |
+      |         | git stash list                                        |
+      |         | git branch -vva --sort=refname                        |
+      |         | git remote get-url origin                             |
+      |         | git rev-parse --verify --abbrev-ref @{-1}             |
+      |         | git remote get-url origin                             |
+      | feature | git add -A                                            |
+      |         | git stash                                             |
+      | <none>  | git config --unset git-town-branch.feature.branchtype |
+      |         | git stash list                                        |
+      | feature | git stash pop                                         |
     And Git Town prints:
       """
       Ran 15 shell commands
diff --git a/features/rename/features/overridden_branch_type.feature b/features/rename/features/overridden_branch_type.feature
new file mode 100644
index 000000000..26395cc18
--- /dev/null
+++ b/features/rename/features/overridden_branch_type.feature
@@ -0,0 +1,46 @@
+Feature: rename a branch that has an overridden branch type
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME | TYPE         | PARENT | LOCATIONS     |
+      | old  | contribution | main   | local, origin |
+    And the commits
+      | BRANCH | LOCATION      | MESSAGE     |
+      | main   | local, origin | main commit |
+      | old    | local, origin | old commit  |
+    And the current branch is "old"
+    And Git setting "git-town-branch.old.branchtype" is "feature"
+    When I run "git-town rename new" and enter into the dialog:
+      | DIALOG                 | KEYS  |
+      | parent branch of "old" | enter |
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                   |
+      | old    | git fetch --prune --tags  |
+      |        | git branch --move old new |
+      |        | git checkout new          |
+      | new    | git push -u origin new    |
+      |        | git push origin :old      |
+    And the current branch is now "new"
+    And Git setting "git-town-branch.new.branchtype" is now "feature"
+    And Git setting "git-town-branch.old.branchtype" now doesn't exist
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE     |
+      | main   | local, origin | main commit |
+      | new    | local, origin | old commit  |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH | COMMAND                               |
+      | new    | git branch old {{ sha 'old commit' }} |
+      |        | git push -u origin old                |
+      |        | git checkout old                      |
+      | old    | git branch -D new                     |
+      |        | git push origin :new                  |
+    And the current branch is now "old"
+    And Git setting "git-town-branch.new.branchtype" now doesn't exist
+    And Git setting "git-town-branch.old.branchtype" is now "feature"
+    And the initial branches and lineage exist now
diff --git a/features/sync/current_branch/feature_branch/merge_sync_strategy/overridden_branch_type/overridden_branch_type.feature b/features/sync/current_branch/feature_branch/merge_sync_strategy/overridden_branch_type/overridden_branch_type.feature
new file mode 100644
index 000000000..6f755e11e
--- /dev/null
+++ b/features/sync/current_branch/feature_branch/merge_sync_strategy/overridden_branch_type/overridden_branch_type.feature
@@ -0,0 +1,43 @@
+Feature: sync the current branch which has a branch-type override
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME         | TYPE         | LOCATIONS |
+      | contribution | contribution | local     |
+    And the commits
+      | BRANCH       | LOCATION      | MESSAGE             |
+      | main         | local, origin | main commit         |
+      | contribution | local         | contribution commit |
+    And the current branch is "contribution"
+    And I ran "git-town hack"
+    When I run "git-town sync" and enter into the dialog:
+      | DIALOG                          | KEYS  |
+      | parent branch of "contribution" | enter |
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH       | COMMAND                                 |
+      | contribution | git fetch --prune --tags                |
+      |              | git checkout main                       |
+      | main         | git rebase origin/main --no-update-refs |
+      |              | git checkout contribution               |
+      | contribution | git merge --no-edit --ff main           |
+      |              | git push -u origin contribution         |
+    And all branches are now synchronized
+    And the current branch is still "contribution"
+    And these commits exist now
+      | BRANCH       | LOCATION      | MESSAGE                               |
+      | main         | local, origin | main commit                           |
+      | contribution | local, origin | contribution commit                   |
+      |              |               | Merge branch 'main' into contribution |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH       | COMMAND                                          |
+      | contribution | git reset --hard {{ sha 'contribution commit' }} |
+      |              | git push origin :contribution                    |
+    And the current branch is still "contribution"
+    And the initial commits exist now
+    And the initial branches and lineage exist now
diff --git a/features/sync/current_branch/perennial_branch/tracking/deleted/happy_path.feature b/features/sync/current_branch/perennial_branch/tracking/deleted/happy_path.feature
index e5da1fe08..d4ed71092 100644
--- a/features/sync/current_branch/perennial_branch/tracking/deleted/happy_path.feature
+++ b/features/sync/current_branch/perennial_branch/tracking/deleted/happy_path.feature
@@ -3,42 +3,37 @@ Feature: sync perennial branch that was deleted at the remote
   Background:
     Given a Git repo with origin
     And the branches
-      | NAME        | TYPE      | PARENT      | LOCATIONS     |
-      | perennial-1 | perennial |             | local, origin |
-      | perennial-2 | perennial |             | local, origin |
-      | feature-1a  | feature   | perennial-1 | local, origin |
-      | feature-1b  | feature   | perennial-1 | local, origin |
-      | feature-2a  | feature   | perennial-2 | local, origin |
-    And origin deletes the "perennial-1" branch
-    And the current branch is "perennial-1"
+      | NAME      | TYPE      | PARENT    | LOCATIONS     |
+      | perennial | perennial |           | local, origin |
+      | feature-1 | feature   | perennial | local, origin |
+      | feature-2 | feature   | perennial | local, origin |
+    And origin deletes the "perennial" branch
+    And the current branch is "perennial"
     When I run "git-town sync"
 
   Scenario: result
     Then Git Town runs the commands
-      | BRANCH      | COMMAND                   |
-      | perennial-1 | git fetch --prune --tags  |
-      |             | git checkout main         |
-      | main        | git branch -D perennial-1 |
-      |             | git push --tags           |
+      | BRANCH    | COMMAND                  |
+      | perennial | git fetch --prune --tags |
+      |           | git checkout main        |
+      | main      | git branch -D perennial  |
+      |           | git push --tags          |
     And Git Town prints:
       """
-      deleted branch "perennial-1"
+      deleted branch "perennial"
       """
     And the current branch is now "main"
     And the branches are now
-      | REPOSITORY    | BRANCHES                                              |
-      | local, origin | main, feature-1a, feature-1b, feature-2a, perennial-2 |
-    And the perennial branches are now "perennial-2"
-    And this lineage exists now
-      | BRANCH     | PARENT      |
-      | feature-2a | perennial-2 |
+      | REPOSITORY    | BRANCHES                   |
+      | local, origin | main, feature-1, feature-2 |
+    And no lineage exists now
 
   Scenario: undo
     When I run "git-town undo"
     Then Git Town runs the commands
-      | BRANCH | COMMAND                                           |
-      | main   | git branch perennial-1 {{ sha 'initial commit' }} |
-      |        | git checkout perennial-1                          |
-    And the current branch is now "perennial-1"
+      | BRANCH | COMMAND                                         |
+      | main   | git branch perennial {{ sha 'initial commit' }} |
+      |        | git checkout perennial                          |
+    And the current branch is now "perennial"
     And the initial branches and lineage exist now
-    And the perennial branches are now "perennial-1" and "perennial-2"
+    And branch "perennial" is now perennial
diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go
index d59a02e32..8335cf24c 100644
--- a/internal/cmd/config/remove.go
+++ b/internal/cmd/config/remove.go
@@ -45,7 +45,7 @@ func executeRemoveConfig(verbose configdomain.Verbose) error {
 	if err != nil {
 		return err
 	}
-	err = repo.UnvalidatedConfig.NormalConfig.GitConfigAccess.RemoveLocalGitConfiguration(repo.UnvalidatedConfig.NormalConfig.Lineage)
+	err = repo.UnvalidatedConfig.NormalConfig.GitConfigAccess.RemoveLocalGitConfiguration(repo.UnvalidatedConfig.NormalConfig.Lineage, repo.UnvalidatedConfig.NormalConfig.BranchTypeOverrides)
 	if err != nil {
 		return err
 	}
diff --git a/internal/cmd/contribute.go b/internal/cmd/contribute.go
index 5bab5d327..0b3ecac7d 100644
--- a/internal/cmd/contribute.go
+++ b/internal/cmd/contribute.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/git-town/git-town/v17/internal/cli/flags"
 	"github.com/git-town/git-town/v17/internal/cmd/cmdhelpers"
-	"github.com/git-town/git-town/v17/internal/config"
 	"github.com/git-town/git-town/v17/internal/config/configdomain"
 	"github.com/git-town/git-town/v17/internal/execute"
 	"github.com/git-town/git-town/v17/internal/git/gitdomain"
@@ -73,10 +72,7 @@ func executeContribute(args []string, verbose configdomain.Verbose) error {
 		return err
 	}
 	branchNames := data.branchesToMark.Keys()
-	if err = repo.UnvalidatedConfig.NormalConfig.AddToContributionBranches(branchNames...); err != nil {
-		return err
-	}
-	if err = removeNonContributionBranchTypes(data.branchesToMark, repo.UnvalidatedConfig); err != nil {
+	if err = repo.UnvalidatedConfig.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeContributionBranch, branchNames...); err != nil {
 		return err
 	}
 	printContributeBranches(branchNames)
@@ -112,31 +108,6 @@ func printContributeBranches(branches gitdomain.LocalBranchNames) {
 	}
 }
 
-func removeNonContributionBranchTypes(branches configdomain.BranchesAndTypes, config config.UnvalidatedConfig) error {
-	for branchName, branchType := range branches {
-		switch branchType {
-		case configdomain.BranchTypeObservedBranch:
-			if err := config.NormalConfig.RemoveFromObservedBranches(branchName); err != nil {
-				return err
-			}
-		case configdomain.BranchTypeParkedBranch:
-			if err := config.NormalConfig.RemoveFromParkedBranches(branchName); err != nil {
-				return err
-			}
-		case configdomain.BranchTypePrototypeBranch:
-			if err := config.NormalConfig.RemoveFromPrototypeBranches(branchName); err != nil {
-				return err
-			}
-		case
-			configdomain.BranchTypeFeatureBranch,
-			configdomain.BranchTypeContributionBranch,
-			configdomain.BranchTypeMainBranch,
-			configdomain.BranchTypePerennialBranch:
-		}
-	}
-	return nil
-}
-
 func determineContributeData(args []string, repo execute.OpenRepoResult) (contributeData, error) {
 	branchesSnapshot, err := repo.Git.BranchesSnapshot(repo.Backend)
 	if err != nil {
diff --git a/internal/cmd/delete.go b/internal/cmd/delete.go
index 4a1d695d6..9b50efaa7 100644
--- a/internal/cmd/delete.go
+++ b/internal/cmd/delete.go
@@ -248,6 +248,13 @@ func deleteProgram(data deleteData, finalMessages stringslice.Collector) (runPro
 		configdomain.BranchTypePerennialBranch:
 		panic(fmt.Sprintf("this branch type should have been filtered in validation: %s", data.branchToDeleteType))
 	}
+	localBranchNameToDelete := data.branchToDeleteInfo.LocalBranchName()
+	if _, hasOverride := data.config.NormalConfig.BranchTypeOverrides[localBranchNameToDelete]; hasOverride {
+		prog.Value.Add(&opcodes.ConfigRemove{
+			Key:   configdomain.NewBranchTypeOverrideKeyForBranch(localBranchNameToDelete).Key,
+			Scope: configdomain.ConfigScopeLocal,
+		})
+	}
 	localBranchNameToDelete, hasLocalBranchToDelete := data.branchToDeleteInfo.LocalName.Get()
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   data.dryRun,
diff --git a/internal/cmd/hack.go b/internal/cmd/hack.go
index 11f96933a..9c03fe4a2 100644
--- a/internal/cmd/hack.go
+++ b/internal/cmd/hack.go
@@ -306,28 +306,22 @@ func determineHackData(args []string, repo execute.OpenRepoResult, detached conf
 }
 
 func convertToFeatureBranch(args convertToFeatureBranchArgs) error {
-	err := validateConvertToFeatureData(args.makeFeatureData)
-	if err != nil {
-		return err
-	}
 	for branchName, branchType := range args.makeFeatureData.targetBranches {
 		switch branchType {
-		case configdomain.BranchTypeContributionBranch:
-			err = args.config.NormalConfig.RemoveFromContributionBranches(branchName)
-		case configdomain.BranchTypeObservedBranch:
-			err = args.config.NormalConfig.RemoveFromObservedBranches(branchName)
-		case configdomain.BranchTypeParkedBranch:
-			err = args.config.NormalConfig.RemoveFromParkedBranches(branchName)
-		case configdomain.BranchTypePrototypeBranch:
-			err = args.config.NormalConfig.RemoveFromPrototypeBranches(branchName)
 		case
-			configdomain.BranchTypeFeatureBranch,
-			configdomain.BranchTypeMainBranch,
-			configdomain.BranchTypePerennialBranch:
-			panic(fmt.Sprintf("unchecked branch type: %s", branchType))
-		}
-		if err != nil {
-			return err
+			configdomain.BranchTypeContributionBranch,
+			configdomain.BranchTypeObservedBranch,
+			configdomain.BranchTypeParkedBranch,
+			configdomain.BranchTypePrototypeBranch:
+			if err := args.config.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeFeatureBranch, branchName); err != nil {
+				return err
+			}
+		case configdomain.BranchTypeFeatureBranch:
+			return fmt.Errorf(messages.HackBranchIsAlreadyFeature, branchName)
+		case configdomain.BranchTypeMainBranch:
+			return errors.New(messages.HackCannotFeatureMainBranch)
+		case configdomain.BranchTypePerennialBranch:
+			return fmt.Errorf(messages.HackCannotFeaturePerennialBranch, branchName)
 		}
 		fmt.Printf(messages.HackBranchIsNowFeature, branchName)
 	}
@@ -353,24 +347,3 @@ type convertToFeatureBranchArgs struct {
 	rootDir             gitdomain.RepoRootDir
 	verbose             configdomain.Verbose
 }
-
-func validateConvertToFeatureData(data convertToFeatureData) error {
-	for branchName, branchType := range data.targetBranches {
-		switch branchType {
-		case
-			configdomain.BranchTypeContributionBranch,
-			configdomain.BranchTypeObservedBranch,
-			configdomain.BranchTypeParkedBranch,
-			configdomain.BranchTypePrototypeBranch:
-			return nil
-		case configdomain.BranchTypeFeatureBranch:
-			return fmt.Errorf(messages.HackBranchIsAlreadyFeature, branchName)
-		case configdomain.BranchTypeMainBranch:
-			return errors.New(messages.HackCannotFeatureMainBranch)
-		case configdomain.BranchTypePerennialBranch:
-			return fmt.Errorf(messages.HackCannotFeaturePerennialBranch, branchName)
-		}
-		panic(fmt.Sprintf("unhandled branch type: %s", branchType))
-	}
-	return nil
-}
diff --git a/internal/cmd/observe.go b/internal/cmd/observe.go
index b4bd9cd6f..c0e87045a 100644
--- a/internal/cmd/observe.go
+++ b/internal/cmd/observe.go
@@ -73,7 +73,7 @@ func executeObserve(args []string, verbose configdomain.Verbose) error {
 		return err
 	}
 	branchNames := data.branchesToObserve.Keys()
-	if err = repo.UnvalidatedConfig.NormalConfig.AddToObservedBranches(branchNames...); err != nil {
+	if err = repo.UnvalidatedConfig.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeObservedBranch, branchNames...); err != nil {
 		return err
 	}
 	if err = removeNonObserveBranchTypes(data.branchesToObserve, repo.UnvalidatedConfig); err != nil {
diff --git a/internal/cmd/park.go b/internal/cmd/park.go
index 04638d7d3..cd61b9cb1 100644
--- a/internal/cmd/park.go
+++ b/internal/cmd/park.go
@@ -66,7 +66,7 @@ func executePark(args []string, verbose configdomain.Verbose) error {
 		return err
 	}
 	branchNames := data.branchesToPark.Keys()
-	if err = repo.UnvalidatedConfig.NormalConfig.AddToParkedBranches(branchNames...); err != nil {
+	if err = repo.UnvalidatedConfig.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeParkedBranch, branchNames...); err != nil {
 		return err
 	}
 	if err = removeNonParkBranchTypes(data.branchesToPark, repo.UnvalidatedConfig); err != nil {
diff --git a/internal/cmd/prototype.go b/internal/cmd/prototype.go
index c47bf694a..d01f57042 100644
--- a/internal/cmd/prototype.go
+++ b/internal/cmd/prototype.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/git-town/git-town/v17/internal/cli/flags"
 	"github.com/git-town/git-town/v17/internal/cmd/cmdhelpers"
-	"github.com/git-town/git-town/v17/internal/config"
 	"github.com/git-town/git-town/v17/internal/config/configdomain"
 	"github.com/git-town/git-town/v17/internal/execute"
 	"github.com/git-town/git-town/v17/internal/git/gitdomain"
@@ -64,10 +63,7 @@ func executePrototype(args []string, verbose configdomain.Verbose) error {
 		return err
 	}
 	branchNames := data.branchesToPrototype.Keys()
-	if err = repo.UnvalidatedConfig.NormalConfig.AddToPrototypeBranches(branchNames...); err != nil {
-		return err
-	}
-	if err = removeNonPrototypeBranchTypes(data.branchesToPrototype, repo.UnvalidatedConfig); err != nil {
+	if err = repo.UnvalidatedConfig.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypePrototypeBranch, branchNames...); err != nil {
 		return err
 	}
 	if checkout, hasCheckout := data.checkout.Get(); hasCheckout {
@@ -103,31 +99,6 @@ func printPrototypeBranches(branches gitdomain.LocalBranchNames) {
 	}
 }
 
-func removeNonPrototypeBranchTypes(branches configdomain.BranchesAndTypes, config config.UnvalidatedConfig) error {
-	for branchName, branchType := range branches {
-		switch branchType {
-		case configdomain.BranchTypeContributionBranch:
-			if err := config.NormalConfig.RemoveFromContributionBranches(branchName); err != nil {
-				return err
-			}
-		case configdomain.BranchTypeObservedBranch:
-			if err := config.NormalConfig.RemoveFromObservedBranches(branchName); err != nil {
-				return err
-			}
-		case configdomain.BranchTypeParkedBranch:
-			if err := config.NormalConfig.RemoveFromParkedBranches(branchName); err != nil {
-				return err
-			}
-		case
-			configdomain.BranchTypeFeatureBranch,
-			configdomain.BranchTypePrototypeBranch,
-			configdomain.BranchTypeMainBranch,
-			configdomain.BranchTypePerennialBranch:
-		}
-	}
-	return nil
-}
-
 func determinePrototypeData(args []string, repo execute.OpenRepoResult) (prototypeData, error) {
 	branchesSnapshot, err := repo.Git.BranchesSnapshot(repo.Backend)
 	if err != nil {
diff --git a/internal/cmd/rename.go b/internal/cmd/rename.go
index 380cc8a4f..59f2bf0f3 100644
--- a/internal/cmd/rename.go
+++ b/internal/cmd/rename.go
@@ -4,7 +4,6 @@ import (
 	"errors"
 	"fmt"
 	"os"
-	"slices"
 
 	"github.com/git-town/git-town/v17/internal/cli/dialog/components"
 	"github.com/git-town/git-town/v17/internal/cli/flags"
@@ -274,27 +273,18 @@ func renameProgram(data renameData, finalMessages stringslice.Collector) program
 		result.Value.Add(&opcodes.CheckoutIfNeeded{Branch: data.newBranch})
 	}
 	if !data.dryRun {
-		// TODO: renaming a branch should not update the these configuration settings.
-		// Rather, update the upcoming branch type override.
-		if slices.Contains(data.config.NormalConfig.PerennialBranches, data.initialBranch) {
-			result.Value.Add(&opcodes.BranchesPerennialRemove{Branch: oldLocalBranch})
-			result.Value.Add(&opcodes.BranchesPerennialAdd{Branch: data.newBranch})
-		}
-		if slices.Contains(data.config.NormalConfig.PrototypeBranches, data.initialBranch) {
-			result.Value.Add(&opcodes.BranchesPrototypeRemove{Branch: oldLocalBranch})
-			result.Value.Add(&opcodes.BranchesPrototypeAdd{Branch: data.newBranch})
-		}
-		if slices.Contains(data.config.NormalConfig.ObservedBranches, data.initialBranch) {
-			result.Value.Add(&opcodes.BranchesObservedRemove{Branch: oldLocalBranch})
-			result.Value.Add(&opcodes.BranchesObservedAdd{Branch: data.newBranch})
-		}
-		if slices.Contains(data.config.NormalConfig.ContributionBranches, data.initialBranch) {
-			result.Value.Add(&opcodes.BranchesContributionRemove{Branch: oldLocalBranch})
-			result.Value.Add(&opcodes.BranchesContributionAdd{Branch: data.newBranch})
-		}
-		if slices.Contains(data.config.NormalConfig.ParkedBranches, data.initialBranch) {
-			result.Value.Add(&opcodes.BranchesParkedRemove{Branch: oldLocalBranch})
-			result.Value.Add(&opcodes.BranchesParkedAdd{Branch: data.newBranch})
+		if override, hasBranchTypeOverride := data.config.NormalConfig.BranchTypeOverrides[oldLocalBranch]; hasBranchTypeOverride {
+			result.Value.Add(
+				&opcodes.ConfigSet{
+					Key:   configdomain.NewBranchTypeOverrideKeyForBranch(data.newBranch).Key,
+					Scope: configdomain.ConfigScopeLocal,
+					Value: override.String(),
+				},
+				&opcodes.ConfigRemove{
+					Key:   configdomain.NewBranchTypeOverrideKeyForBranch(oldLocalBranch).Key,
+					Scope: configdomain.ConfigScopeLocal,
+				},
+			)
 		}
 		if parentBranch, hasParent := data.config.NormalConfig.Lineage.Parent(oldLocalBranch).Get(); hasParent {
 			result.Value.Add(&opcodes.LineageParentSet{Branch: data.newBranch, Parent: parentBranch})
diff --git a/internal/config/configdomain/branch_type_override_key.go b/internal/config/configdomain/branch_type_override_key.go
new file mode 100644
index 000000000..bcb6bbf41
--- /dev/null
+++ b/internal/config/configdomain/branch_type_override_key.go
@@ -0,0 +1,45 @@
+package configdomain
+
+import (
+	"strings"
+
+	"github.com/git-town/git-town/v17/internal/git/gitdomain"
+	. "github.com/git-town/git-town/v17/pkg/prelude"
+)
+
+const BranchTypeSuffix = ".branchtype"
+
+// a Key that contains a BranchTypeOverrides entry,
+// for example "git-town-branch.foo.branchtype"
+type BranchTypeOverrideKey struct {
+	BranchSpecificKey
+}
+
+// provides the name of the child branch encoded in this LineageKey
+func (self BranchTypeOverrideKey) Branch() gitdomain.LocalBranchName {
+	text := strings.TrimSuffix(strings.TrimPrefix(self.String(), BranchSpecificKeyPrefix), BranchTypeSuffix)
+	return gitdomain.NewLocalBranchName(text)
+}
+
+func IsBranchTypeOverrideKey(key string) bool {
+	return isBranchSpecificKey(key) && strings.HasSuffix(key, BranchTypeSuffix)
+}
+
+func NewBranchTypeOverrideKeyForBranch(branch gitdomain.LocalBranchName) BranchTypeOverrideKey {
+	return BranchTypeOverrideKey{
+		BranchSpecificKey: BranchSpecificKey{
+			Key: Key(BranchSpecificKeyPrefix + branch.String() + BranchTypeSuffix),
+		},
+	}
+}
+
+func ParseBranchTypeOverrideKey(key Key) Option[BranchTypeOverrideKey] {
+	if IsBranchTypeOverrideKey(key.String()) {
+		return Some(BranchTypeOverrideKey{
+			BranchSpecificKey: BranchSpecificKey{
+				Key: key,
+			},
+		})
+	}
+	return None[BranchTypeOverrideKey]()
+}
diff --git a/internal/config/configdomain/branch_type_overrides.go b/internal/config/configdomain/branch_type_overrides.go
new file mode 100644
index 000000000..399271049
--- /dev/null
+++ b/internal/config/configdomain/branch_type_overrides.go
@@ -0,0 +1,55 @@
+package configdomain
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/git-town/git-town/v17/internal/cli/colors"
+	"github.com/git-town/git-town/v17/internal/git/gitdomain"
+	"github.com/git-town/git-town/v17/internal/messages"
+)
+
+// BranchTypeOverrides contains all configured branch type overrides.
+// These are stored in Git metadata like this: "git-town-branch.<name>.branchtype".
+type BranchTypeOverrides map[gitdomain.LocalBranchName]BranchType
+
+// adds the given BranchTypeOverrides to this BranchTypeOverrides
+func (self BranchTypeOverrides) Concat(other BranchTypeOverrides) BranchTypeOverrides {
+	result := make(BranchTypeOverrides, len(self)+len(other))
+	for key, value := range self {
+		result[key] = value
+	}
+	for key, value := range other {
+		result[key] = value
+	}
+	return result
+}
+
+// provides the branch type overrides stored in the given Git metadata snapshot
+func NewBranchTypeOverridesInSnapshot(snapshot SingleSnapshot, removeLocalConfigValue removeLocalConfigValueFunc) (BranchTypeOverrides, error) {
+	result := BranchTypeOverrides{}
+	for key, value := range snapshot.BranchTypeOverrideEntries() {
+		branch := key.Branch()
+		if branch == "" {
+			// empty branch name --> delete it
+			fmt.Println(colors.Cyan().Styled(messages.ConfigBranchTypeOverrideEmpty))
+			_ = removeLocalConfigValue(key.Key)
+			continue
+		}
+		value = strings.TrimSpace(value)
+		if value == "" {
+			// empty branch type values are invalid --> delete it
+			fmt.Println(colors.Cyan().Styled(messages.ConfigBranchTypeOverrideEmpty))
+			_ = removeLocalConfigValue(key.Key)
+			continue
+		}
+		branchTypeOpt, err := ParseBranchType(value)
+		if err != nil {
+			return result, err
+		}
+		if branchType, hasBranchType := branchTypeOpt.Get(); hasBranchType {
+			result[branch] = branchType
+		}
+	}
+	return result, nil
+}
diff --git a/internal/config/configdomain/key.go b/internal/config/configdomain/key.go
index 94ec21c02..981edda32 100644
--- a/internal/config/configdomain/key.go
+++ b/internal/config/configdomain/key.go
@@ -161,7 +161,7 @@ func ParseKey(name string) Option[Key] {
 			return Some(configKey)
 		}
 	}
-	if isLineageKey(name) {
+	if isLineageKey(name) || IsBranchTypeOverrideKey(name) {
 		return Some(Key(name))
 	}
 	if aliasKey, isAliasKey := AllAliasableCommands().LookupKey(name).Get(); isAliasKey {
diff --git a/internal/config/configdomain/normal_config_data.go b/internal/config/configdomain/normal_config_data.go
index 95d342537..1d5f166a7 100644
--- a/internal/config/configdomain/normal_config_data.go
+++ b/internal/config/configdomain/normal_config_data.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/git-town/git-town/v17/internal/git/gitdomain"
 	. "github.com/git-town/git-town/v17/pkg/prelude"
+	"github.com/git-town/git-town/v17/pkg/set"
 )
 
 // configuration settings that exist in both UnvalidatedConfig and ValidatedConfig
@@ -12,6 +13,7 @@ type NormalConfigData struct {
 	Aliases                  Aliases
 	BitbucketAppPassword     Option[BitbucketAppPassword]
 	BitbucketUsername        Option[BitbucketUsername]
+	BranchTypeOverrides      BranchTypeOverrides
 	ContributionBranches     gitdomain.LocalBranchNames
 	ContributionRegex        Option[ContributionRegex]
 	DefaultBranchType        BranchType
@@ -55,6 +57,10 @@ func (self *NormalConfigData) Online() Online {
 }
 
 func (self *NormalConfigData) PartialBranchType(branch gitdomain.LocalBranchName) BranchType {
+	// check the branch type overrides
+	if branchTypeOverride, hasBranchTypeOverride := self.BranchTypeOverrides[branch]; hasBranchTypeOverride {
+		return branchTypeOverride
+	}
 	// check the configured branch lists
 	if slices.Contains(self.ContributionBranches, branch) {
 		return BranchTypeContributionBranch
@@ -88,6 +94,31 @@ func (self *NormalConfigData) PartialBranchType(branch gitdomain.LocalBranchName
 	return self.DefaultBranchType
 }
 
+func (self *NormalConfigData) PartialBranchesOfType(branchType BranchType) gitdomain.LocalBranchNames {
+	matching := set.New[gitdomain.LocalBranchName]()
+	switch branchType {
+	case BranchTypeContributionBranch:
+		matching.Add(self.ContributionBranches...)
+	case BranchTypeFeatureBranch:
+	case BranchTypeMainBranch:
+		// main branch is stored in ValidatedConfig
+	case BranchTypeObservedBranch:
+		matching.Add(self.ObservedBranches...)
+	case BranchTypeParkedBranch:
+		matching.Add(self.ParkedBranches...)
+	case BranchTypePerennialBranch:
+		matching.Add(self.PerennialBranches...)
+	case BranchTypePrototypeBranch:
+		matching.Add(self.PrototypeBranches...)
+	}
+	for key, value := range self.BranchTypeOverrides {
+		if value == branchType {
+			matching.Add(key)
+		}
+	}
+	return matching.Values()
+}
+
 func (self *NormalConfigData) ShouldPushNewBranches() bool {
 	return self.PushNewBranches.IsTrue()
 }
@@ -97,6 +128,7 @@ func DefaultNormalConfig() NormalConfigData {
 		Aliases:                  Aliases{},
 		BitbucketAppPassword:     None[BitbucketAppPassword](),
 		BitbucketUsername:        None[BitbucketUsername](),
+		BranchTypeOverrides:      BranchTypeOverrides{},
 		ContributionBranches:     gitdomain.LocalBranchNames{},
 		ContributionRegex:        None[ContributionRegex](),
 		DefaultBranchType:        BranchTypeFeatureBranch,
diff --git a/internal/config/configdomain/partial_config.go b/internal/config/configdomain/partial_config.go
index b59c6ddf4..20188d616 100644
--- a/internal/config/configdomain/partial_config.go
+++ b/internal/config/configdomain/partial_config.go
@@ -12,6 +12,7 @@ type PartialConfig struct {
 	Aliases                  Aliases
 	BitbucketAppPassword     Option[BitbucketAppPassword]
 	BitbucketUsername        Option[BitbucketUsername]
+	BranchTypeOverrides      BranchTypeOverrides
 	ContributionBranches     gitdomain.LocalBranchNames
 	ContributionRegex        Option[ContributionRegex]
 	DefaultBranchType        Option[BranchType]
@@ -54,6 +55,8 @@ func EmptyPartialConfig() PartialConfig {
 func NewPartialConfigFromSnapshot(snapshot SingleSnapshot, updateOutdated bool, removeLocalConfigValue removeLocalConfigValueFunc) (PartialConfig, error) {
 	ec := gohacks.ErrorCollector{}
 	aliases := snapshot.Aliases()
+	branchTypeOverrides, err := NewBranchTypeOverridesInSnapshot(snapshot, removeLocalConfigValue)
+	ec.Check(err)
 	contributionRegex, err := ParseContributionRegex(snapshot[KeyContributionRegex])
 	ec.Check(err)
 	defaultBranchType, err := ParseBranchType(snapshot[KeyDefaultBranchType])
@@ -94,6 +97,7 @@ func NewPartialConfigFromSnapshot(snapshot SingleSnapshot, updateOutdated bool,
 		Aliases:                  aliases,
 		BitbucketAppPassword:     ParseBitbucketAppPassword(snapshot[KeyBitbucketAppPassword]),
 		BitbucketUsername:        ParseBitbucketUsername(snapshot[KeyBitbucketUsername]),
+		BranchTypeOverrides:      branchTypeOverrides,
 		ContributionBranches:     gitdomain.ParseLocalBranchNames(snapshot[KeyContributionBranches]),
 		ContributionRegex:        contributionRegex,
 		DefaultBranchType:        defaultBranchType,
@@ -137,6 +141,7 @@ func (self PartialConfig) Merge(other PartialConfig) PartialConfig {
 		Aliases:                  mapstools.Merge(other.Aliases, self.Aliases),
 		BitbucketAppPassword:     other.BitbucketAppPassword.Or(self.BitbucketAppPassword),
 		BitbucketUsername:        other.BitbucketUsername.Or(self.BitbucketUsername),
+		BranchTypeOverrides:      other.BranchTypeOverrides.Concat(self.BranchTypeOverrides),
 		ContributionBranches:     append(other.ContributionBranches, self.ContributionBranches...),
 		ContributionRegex:        other.ContributionRegex.Or(self.ContributionRegex),
 		DefaultBranchType:        other.DefaultBranchType.Or(self.DefaultBranchType),
@@ -177,6 +182,7 @@ func (self PartialConfig) ToNormalConfig(defaults NormalConfigData) NormalConfig
 		Aliases:                  self.Aliases,
 		BitbucketAppPassword:     self.BitbucketAppPassword,
 		BitbucketUsername:        self.BitbucketUsername,
+		BranchTypeOverrides:      self.BranchTypeOverrides,
 		ContributionBranches:     self.ContributionBranches,
 		ContributionRegex:        self.ContributionRegex,
 		DefaultBranchType:        self.DefaultBranchType.GetOrElse(BranchTypeFeatureBranch),
diff --git a/internal/config/configdomain/single_snapshot.go b/internal/config/configdomain/single_snapshot.go
index afa5c75cb..3e32aed18 100644
--- a/internal/config/configdomain/single_snapshot.go
+++ b/internal/config/configdomain/single_snapshot.go
@@ -23,6 +23,17 @@ func (self SingleSnapshot) Aliases() Aliases {
 	return result
 }
 
+// provides all the configuration entries that describe branch type overrides
+func (self SingleSnapshot) BranchTypeOverrideEntries() map[BranchTypeOverrideKey]string {
+	result := map[BranchTypeOverrideKey]string{}
+	for key, value := range self {
+		if branchTypeKey, isBranchTypeKey := ParseBranchTypeOverrideKey(key).Get(); isBranchTypeKey {
+			result[branchTypeKey] = value
+		}
+	}
+	return result
+}
+
 // provides all the keys that describe lineage entries
 func (self SingleSnapshot) LineageEntries() map[LineageKey]string {
 	result := map[LineageKey]string{}
diff --git a/internal/config/configfile/load.go b/internal/config/configfile/load.go
index 78589920e..1e3ec38e1 100644
--- a/internal/config/configfile/load.go
+++ b/internal/config/configfile/load.go
@@ -219,6 +219,7 @@ func Validate(data Data, finalMessages stringslice.Collector) (configdomain.Part
 		Aliases:                  map[configdomain.AliasableCommand]string{},
 		BitbucketAppPassword:     None[configdomain.BitbucketAppPassword](),
 		BitbucketUsername:        None[configdomain.BitbucketUsername](),
+		BranchTypeOverrides:      configdomain.BranchTypeOverrides{},
 		ContributionBranches:     gitdomain.LocalBranchNames{},
 		ContributionRegex:        contributionRegex,
 		DefaultBranchType:        defaultBranchType,
diff --git a/internal/config/gitconfig/access.go b/internal/config/gitconfig/access.go
index 05bd22a2c..9a774cddc 100644
--- a/internal/config/gitconfig/access.go
+++ b/internal/config/gitconfig/access.go
@@ -59,7 +59,7 @@ func (self *Access) RemoveLocalConfigValue(key configdomain.Key) error {
 }
 
 // RemoveLocalGitConfiguration removes all Git Town configuration.
-func (self *Access) RemoveLocalGitConfiguration(lineage configdomain.Lineage) error {
+func (self *Access) RemoveLocalGitConfiguration(lineage configdomain.Lineage, branchTypeOverrides configdomain.BranchTypeOverrides) error {
 	err := self.Run("git", "config", "--remove-section", "git-town")
 	if err != nil {
 		var exitErr *exec.ExitError
@@ -79,6 +79,13 @@ func (self *Access) RemoveLocalGitConfiguration(lineage configdomain.Lineage) er
 			return fmt.Errorf(messages.ConfigRemoveError, err)
 		}
 	}
+	for branch := range branchTypeOverrides {
+		key := configdomain.NewBranchTypeOverrideKeyForBranch(branch)
+		err = self.Run("git", "config", "--unset", key.String())
+		if err != nil {
+			return fmt.Errorf(messages.ConfigRemoveError, err)
+		}
+	}
 	return nil
 }
 
diff --git a/internal/config/normal_config.go b/internal/config/normal_config.go
index 590fa3642..e7f478762 100644
--- a/internal/config/normal_config.go
+++ b/internal/config/normal_config.go
@@ -10,6 +10,7 @@ import (
 	"github.com/git-town/git-town/v17/internal/git"
 	"github.com/git-town/git-town/v17/internal/git/gitdomain"
 	"github.com/git-town/git-town/v17/internal/git/giturl"
+	"github.com/git-town/git-town/v17/internal/gohacks"
 	"github.com/git-town/git-town/v17/internal/gohacks/slice"
 	"github.com/git-town/git-town/v17/internal/gohacks/stringslice"
 	"github.com/git-town/git-town/v17/internal/messages"
@@ -26,36 +27,6 @@ type NormalConfig struct {
 	LocalGitConfig  configdomain.PartialConfig         // content of the local Git configuration
 }
 
-// AddToContributionBranches registers the given branch names as contribution branches.
-// The branches must exist.
-func (self *NormalConfig) AddToContributionBranches(branches ...gitdomain.LocalBranchName) error {
-	return self.SetContributionBranches(append(self.ContributionBranches, branches...))
-}
-
-// AddToObservedBranches registers the given branch names as observed branches.
-// The branches must exist.
-func (self *NormalConfig) AddToObservedBranches(branches ...gitdomain.LocalBranchName) error {
-	return self.SetObservedBranches(append(self.ObservedBranches, branches...))
-}
-
-// AddToParkedBranches registers the given branch names as parked branches.
-// The branches must exist.
-func (self *NormalConfig) AddToParkedBranches(branches ...gitdomain.LocalBranchName) error {
-	return self.SetParkedBranches(append(self.ParkedBranches, branches...))
-}
-
-// AddToPerennialBranches registers the given branch names as perennial branches.
-// The branches must exist.
-func (self *NormalConfig) AddToPerennialBranches(branches ...gitdomain.LocalBranchName) error {
-	return self.SetPerennialBranches(append(self.PerennialBranches, branches...))
-}
-
-// AddToPrototypeBranches registers the given branch names as prototype branches.
-// The branches must exist.
-func (self *NormalConfig) AddToPrototypeBranches(branches ...gitdomain.LocalBranchName) error {
-	return self.SetPrototypeBranches(append(self.PrototypeBranches, branches...))
-}
-
 // removes the given branch from the lineage, and updates its children
 func (self *NormalConfig) CleanupBranchFromLineage(branch gitdomain.LocalBranchName) {
 	parent, hasParent := self.LocalGitConfig.Lineage.Parent(branch).Get()
@@ -121,30 +92,35 @@ func (self *NormalConfig) RemoveFeatureRegex() {
 }
 
 // RemoveFromContributionBranches removes the given branch as a perennial branch.
+// TODO: delete this
 func (self *NormalConfig) RemoveFromContributionBranches(branch gitdomain.LocalBranchName) error {
 	self.ContributionBranches = slice.Remove(self.ContributionBranches, branch)
 	return self.SetContributionBranches(self.ContributionBranches)
 }
 
 // RemoveFromObservedBranches removes the given branch as a perennial branch.
+// TODO: delete this
 func (self *NormalConfig) RemoveFromObservedBranches(branch gitdomain.LocalBranchName) error {
 	self.ObservedBranches = slice.Remove(self.ObservedBranches, branch)
 	return self.SetObservedBranches(self.ObservedBranches)
 }
 
 // RemoveFromParkedBranches removes the given branch as a perennial branch.
+// TODO: delete this
 func (self *NormalConfig) RemoveFromParkedBranches(branch gitdomain.LocalBranchName) error {
 	self.ParkedBranches = slice.Remove(self.ParkedBranches, branch)
 	return self.SetParkedBranches(self.ParkedBranches)
 }
 
 // RemoveFromPerennialBranches removes the given branch as a perennial branch.
+// TODO: delete this
 func (self *NormalConfig) RemoveFromPerennialBranches(branch gitdomain.LocalBranchName) error {
 	self.PerennialBranches = slice.Remove(self.PerennialBranches, branch)
 	return self.SetPerennialBranches(self.PerennialBranches)
 }
 
 // RemoveFromPerennialBranches removes the given branch as a perennial branch.
+// TODO: delete this
 func (self *NormalConfig) RemoveFromPrototypeBranches(branch gitdomain.LocalBranchName) error {
 	self.PrototypeBranches = slice.Remove(self.PrototypeBranches, branch)
 	return self.SetPrototypeBranches(self.PrototypeBranches)
@@ -214,6 +190,17 @@ func (self *NormalConfig) RemoveSyncUpstream() {
 	_ = self.GitConfigAccess.RemoveLocalConfigValue(configdomain.KeySyncUpstream)
 }
 
+// SetBranchTypeOverride registers the given branch names as contribution branches.
+// The branches must exist.
+func (self *NormalConfig) SetBranchTypeOverride(branchType configdomain.BranchType, branches ...gitdomain.LocalBranchName) error {
+	result := gohacks.ErrorCollector{}
+	for _, branch := range branches {
+		self.BranchTypeOverrides[branch] = branchType
+		result.Check(self.GitConfigAccess.SetConfigValue(configdomain.ConfigScopeLocal, configdomain.NewBranchTypeOverrideKeyForBranch(branch).Key, branchType.String()))
+	}
+	return result.Err
+}
+
 // SetObservedBranches marks the given branches as observed branches.
 func (self *NormalConfig) SetContributionBranches(branches gitdomain.LocalBranchNames) error {
 	self.ContributionBranches = branches
diff --git a/internal/messages/en.go b/internal/messages/en.go
index 7b3bcc41f..25c8cce10 100644
--- a/internal/messages/en.go
+++ b/internal/messages/en.go
@@ -48,6 +48,7 @@ const (
 	CompressObservedBranch             = "you are merely observing branch %q and should leave compressing it to the branch owner"
 	CompressParkedBranch               = "branch %q and should not compress it"
 	CompletionTypeUnknown              = "unknown completion type: %q"
+	ConfigBranchTypeOverrideEmpty      = "removing empty branch type entry"
 	ConfigFileCannotRead               = "cannot read the configuration file %q: %w"
 	ConfigFileInvalidContent           = "the configuration file %q does not contain TOML-formatted content: %w"
 	ConfigLineageParentIsChild         = "removing lineage entry for %q because the parent is the child"
diff --git a/internal/vm/opcodes/branches_contribution_add.go b/internal/vm/opcodes/branches_contribution_add.go
index 88ec55251..63d2d0e7d 100644
--- a/internal/vm/opcodes/branches_contribution_add.go
+++ b/internal/vm/opcodes/branches_contribution_add.go
@@ -1,16 +1,18 @@
 package opcodes
 
 import (
+	"github.com/git-town/git-town/v17/internal/config/configdomain"
 	"github.com/git-town/git-town/v17/internal/git/gitdomain"
 	"github.com/git-town/git-town/v17/internal/vm/shared"
 )
 
 // registers the branch with the given name as a contribution branch in the Git config
+// TODO: convert to more generic SetBranchTypeOverride opcode
 type BranchesContributionAdd struct {
 	Branch                  gitdomain.LocalBranchName
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
 func (self *BranchesContributionAdd) Run(args shared.RunArgs) error {
-	return args.Config.Value.NormalConfig.AddToContributionBranches(self.Branch)
+	return args.Config.Value.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeContributionBranch, self.Branch)
 }
diff --git a/internal/vm/opcodes/branches_observed_add.go b/internal/vm/opcodes/branches_observed_add.go
index 175f01ec9..62e5dfc80 100644
--- a/internal/vm/opcodes/branches_observed_add.go
+++ b/internal/vm/opcodes/branches_observed_add.go
@@ -1,16 +1,18 @@
 package opcodes
 
 import (
+	"github.com/git-town/git-town/v17/internal/config/configdomain"
 	"github.com/git-town/git-town/v17/internal/git/gitdomain"
 	"github.com/git-town/git-town/v17/internal/vm/shared"
 )
 
 // registers the branch with the given name as an observed branch in the Git config
+// TODO: convert to more generic SetBranchTypeOverride opcode
 type BranchesObservedAdd struct {
 	Branch                  gitdomain.LocalBranchName
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
 func (self *BranchesObservedAdd) Run(args shared.RunArgs) error {
-	return args.Config.Value.NormalConfig.AddToObservedBranches(self.Branch)
+	return args.Config.Value.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeObservedBranch, self.Branch)
 }
diff --git a/internal/vm/opcodes/branches_parked_add.go b/internal/vm/opcodes/branches_parked_add.go
index c47211267..ae2c9b582 100644
--- a/internal/vm/opcodes/branches_parked_add.go
+++ b/internal/vm/opcodes/branches_parked_add.go
@@ -1,16 +1,18 @@
 package opcodes
 
 import (
+	"github.com/git-town/git-town/v17/internal/config/configdomain"
 	"github.com/git-town/git-town/v17/internal/git/gitdomain"
 	"github.com/git-town/git-town/v17/internal/vm/shared"
 )
 
 // registers the branch with the given name as a parked branch in the Git config
+// TODO: make generic
 type BranchesParkedAdd struct {
 	Branch                  gitdomain.LocalBranchName
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
 func (self *BranchesParkedAdd) Run(args shared.RunArgs) error {
-	return args.Config.Value.NormalConfig.AddToParkedBranches(self.Branch)
+	return args.Config.Value.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeParkedBranch, self.Branch)
 }
diff --git a/internal/vm/opcodes/branches_perennial_add.go b/internal/vm/opcodes/branches_perennial_add.go
index ae3fbbdd8..7065256a9 100644
--- a/internal/vm/opcodes/branches_perennial_add.go
+++ b/internal/vm/opcodes/branches_perennial_add.go
@@ -1,16 +1,18 @@
 package opcodes
 
 import (
+	"github.com/git-town/git-town/v17/internal/config/configdomain"
 	"github.com/git-town/git-town/v17/internal/git/gitdomain"
 	"github.com/git-town/git-town/v17/internal/vm/shared"
 )
 
 // registers the branch with the given name as a perennial branch in the Git config
+// TODO: generalize
 type BranchesPerennialAdd struct {
 	Branch                  gitdomain.LocalBranchName
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
 func (self *BranchesPerennialAdd) Run(args shared.RunArgs) error {
-	return args.Config.Value.NormalConfig.AddToPerennialBranches(self.Branch)
+	return args.Config.Value.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypePerennialBranch, self.Branch)
 }
diff --git a/internal/vm/opcodes/branches_prototype_add.go b/internal/vm/opcodes/branches_prototype_add.go
index 527aae1e5..53838ca80 100644
--- a/internal/vm/opcodes/branches_prototype_add.go
+++ b/internal/vm/opcodes/branches_prototype_add.go
@@ -1,6 +1,7 @@
 package opcodes
 
 import (
+	"github.com/git-town/git-town/v17/internal/config/configdomain"
 	"github.com/git-town/git-town/v17/internal/git/gitdomain"
 	"github.com/git-town/git-town/v17/internal/vm/shared"
 )
@@ -12,5 +13,5 @@ type BranchesPrototypeAdd struct {
 }
 
 func (self *BranchesPrototypeAdd) Run(args shared.RunArgs) error {
-	return args.Config.Value.NormalConfig.AddToPrototypeBranches(self.Branch)
+	return args.Config.Value.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypePrototypeBranch, self.Branch)
 }



Test patch:
diff --git a/internal/config/configdomain/branch_type_override_key_test.go b/internal/config/configdomain/branch_type_override_key_test.go
new file mode 100644
index 000000000..34c20d9ea
--- /dev/null
+++ b/internal/config/configdomain/branch_type_override_key_test.go
@@ -0,0 +1,72 @@
+package configdomain_test
+
+import (
+	"testing"
+
+	"github.com/git-town/git-town/v17/internal/config/configdomain"
+	"github.com/git-town/git-town/v17/internal/git/gitdomain"
+	. "github.com/git-town/git-town/v17/pkg/prelude"
+	"github.com/shoenig/test/must"
+)
+
+func TestBranchTypeOverrideKey(t *testing.T) {
+	t.Parallel()
+
+	t.Run("Branch", func(t *testing.T) {
+		t.Parallel()
+		branch := gitdomain.LocalBranchName("my-branch")
+		key := configdomain.NewBranchTypeOverrideKeyForBranch(branch)
+		have := key.Branch()
+		must.EqOp(t, branch, have)
+	})
+
+	t.Run("IsBranchTypeOverrideKey", func(t *testing.T) {
+		t.Parallel()
+		tests := map[string]bool{
+			"git-town-branch.foo.branchtype": true,
+			"git-town-branch.foo.parent":     false,
+			"git-town.prototype-branches":    false,
+		}
+		for give, want := range tests {
+			have := configdomain.IsBranchTypeOverrideKey(give)
+			must.EqOp(t, want, have)
+		}
+	})
+
+	t.Run("NewBranchTypeOverrideKeyForBranch", func(t *testing.T) {
+		t.Parallel()
+		have := configdomain.NewBranchTypeOverrideKeyForBranch("my-branch")
+		want := configdomain.BranchTypeOverrideKey{
+			BranchSpecificKey: configdomain.BranchSpecificKey{
+				Key: "git-town-branch.my-branch.branchtype",
+			},
+		}
+		must.EqOp(t, want, have)
+	})
+
+	t.Run("ParseBranchTypeOverrideKey", func(t *testing.T) {
+		t.Parallel()
+		t.Run("is branch type override", func(t *testing.T) {
+			t.Parallel()
+			have := configdomain.ParseBranchTypeOverrideKey("git-town-branch.my-branch.branchtype")
+			want := Some(configdomain.BranchTypeOverrideKey{
+				BranchSpecificKey: configdomain.BranchSpecificKey{
+					Key: "git-town-branch.my-branch.branchtype",
+				},
+			})
+			must.Eq(t, want, have)
+		})
+		t.Run("is parent entry", func(t *testing.T) {
+			t.Parallel()
+			have := configdomain.ParseBranchTypeOverrideKey("git-town-branch.my-branch.parent")
+			want := None[configdomain.BranchTypeOverrideKey]()
+			must.Eq(t, want, have)
+		})
+		t.Run("is something else", func(t *testing.T) {
+			t.Parallel()
+			have := configdomain.ParseBranchTypeOverrideKey("git-town.feature-regex")
+			want := None[configdomain.BranchTypeOverrideKey]()
+			must.Eq(t, want, have)
+		})
+	})
+}
diff --git a/internal/config/configdomain/branch_type_overrides_test.go b/internal/config/configdomain/branch_type_overrides_test.go
new file mode 100644
index 000000000..f8a627bff
--- /dev/null
+++ b/internal/config/configdomain/branch_type_overrides_test.go
@@ -0,0 +1,49 @@
+package configdomain_test
+
+import (
+	"testing"
+
+	"github.com/git-town/git-town/v17/internal/config/configdomain"
+	"github.com/shoenig/test/must"
+)
+
+func TestBranchTypeOverrides(t *testing.T) {
+	t.Parallel()
+
+	t.Run("Concat", func(t *testing.T) {
+		t.Parallel()
+		data1 := configdomain.BranchTypeOverrides{
+			"branch-1": configdomain.BranchTypeFeatureBranch,
+			"branch-2": configdomain.BranchTypeParkedBranch,
+		}
+		data2 := configdomain.BranchTypeOverrides{
+			"branch-1": configdomain.BranchTypeContributionBranch,
+			"branch-3": configdomain.BranchTypeObservedBranch,
+		}
+		have := data1.Concat(data2)
+		want := configdomain.BranchTypeOverrides{
+			"branch-1": configdomain.BranchTypeContributionBranch,
+			"branch-2": configdomain.BranchTypeParkedBranch,
+			"branch-3": configdomain.BranchTypeObservedBranch,
+		}
+		must.Eq(t, want, have)
+	})
+
+	t.Run("NewBranchTypeOverridesFromSnapshot", func(t *testing.T) {
+		t.Parallel()
+		snapshot := configdomain.SingleSnapshot{
+			"git-town-branch.branch-1.branchtype": "feature",
+			"git-town-branch.branch-2.branchtype": "observed",
+			"git-town-branch.branch-3.parent":     "main",
+			"git-town.prototype-branches":         "foo",
+		}
+		removeFunc := func(configdomain.Key) error { return nil }
+		have, err := configdomain.NewBranchTypeOverridesInSnapshot(snapshot, removeFunc)
+		must.NoError(t, err)
+		want := configdomain.BranchTypeOverrides{
+			"branch-1": configdomain.BranchTypeFeatureBranch,
+			"branch-2": configdomain.BranchTypeObservedBranch,
+		}
+		must.Eq(t, want, have)
+	})
+}
diff --git a/internal/config/unvalidated_config_test.go b/internal/config/unvalidated_config_test.go
index 5cbefefdd..2ceea6349 100644
--- a/internal/config/unvalidated_config_test.go
+++ b/internal/config/unvalidated_config_test.go
@@ -4,7 +4,6 @@ import (
 	"testing"
 
 	"github.com/git-town/git-town/v17/internal/config/configdomain"
-	"github.com/git-town/git-town/v17/internal/git/gitdomain"
 	"github.com/git-town/git-town/v17/test/testruntime"
 	"github.com/shoenig/test/must"
 )
@@ -28,11 +27,10 @@ func TestUnvalidatedConfig(t *testing.T) {
 			t.Parallel()
 			repo := testruntime.CreateGitTown(t)
 			repo.CreateBranch("branch", "main")
-			err := repo.Config.NormalConfig.AddToContributionBranches("branch")
+			err := repo.Config.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeContributionBranch, "branch")
 			must.NoError(t, err)
 			repo.Config.Reload()
-			want := gitdomain.NewLocalBranchNames("branch")
-			must.Eq(t, want, repo.Config.NormalConfig.ContributionBranches)
+			must.Eq(t, configdomain.BranchTypeContributionBranch, repo.Config.BranchType("branch"))
 		})
 	})
 }
diff --git a/test/commands/test_commands.go b/test/commands/test_commands.go
index 66d16e3b3..57f17cd4e 100644
--- a/test/commands/test_commands.go
+++ b/test/commands/test_commands.go
@@ -164,7 +164,7 @@ func (self *TestCommands) CreateCommit(commit testgit.Commit) {
 // creates a contribution branches with the given name in this repository
 func (self *TestCommands) CreateContributionBranch(name gitdomain.LocalBranchName) {
 	self.CreateBranch(name, "main")
-	asserts.NoError(self.Config.NormalConfig.AddToContributionBranches(name))
+	asserts.NoError(self.Config.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeContributionBranch, name))
 }
 
 // creates a feature branch with the given name in this repository
@@ -189,27 +189,31 @@ func (self *TestCommands) CreateFolder(name string) {
 }
 
 // creates an observed branch with the given name in this repository
+// TODO: convert to more generic CreateBranchWithType function
 func (self *TestCommands) CreateObservedBranch(name gitdomain.LocalBranchName) {
 	self.CreateBranch(name, "main")
-	asserts.NoError(self.Config.NormalConfig.AddToObservedBranches(name))
+	asserts.NoError(self.Config.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeObservedBranch, name))
 }
 
 // creates a parked branch with the given name and parent in this repository
+// TODO: make generic
 func (self *TestCommands) CreateParkedBranch(name, parent gitdomain.LocalBranchName) {
 	self.CreateFeatureBranch(name, parent.BranchName())
-	asserts.NoError(self.Config.NormalConfig.AddToParkedBranches(name))
+	asserts.NoError(self.Config.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypeParkedBranch, name))
 }
 
 // creates a perennial branch with the given name in this repository
+// TODO: make generic
 func (self *TestCommands) CreatePerennialBranch(name gitdomain.LocalBranchName) {
 	self.CreateBranch(name, "main")
-	asserts.NoError(self.Config.NormalConfig.AddToPerennialBranches(name))
+	asserts.NoError(self.Config.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypePerennialBranch, name))
 }
 
 // creates a prototype branch with the given name and parent in this repository
+// TODO: make generic
 func (self *TestCommands) CreatePrototypeBranch(name, parent gitdomain.LocalBranchName) {
 	self.CreateFeatureBranch(name, parent.BranchName())
-	asserts.NoError(self.Config.NormalConfig.AddToPrototypeBranches(name))
+	asserts.NoError(self.Config.NormalConfig.SetBranchTypeOverride(configdomain.BranchTypePrototypeBranch, name))
 }
 
 // CreateStandaloneTag creates a tag not on a branch.
diff --git a/test/cucumber/steps.go b/test/cucumber/steps.go
index c521e4923..aa95cdd1e 100644
--- a/test/cucumber/steps.go
+++ b/test/cucumber/steps.go
@@ -8,7 +8,6 @@ import (
 	"os/exec"
 	"path/filepath"
 	"regexp"
-	"slices"
 	"strings"
 	"time"
 
@@ -280,11 +279,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 		branch := gitdomain.NewLocalBranchName(name)
 		branchType := devRepo.Config.BranchType(branch)
 		if branchType != configdomain.BranchTypeContributionBranch {
-			return fmt.Errorf(
-				"branch %q isn't contribution as expected.\nContribution branches: %s",
-				branch,
-				strings.Join(devRepo.Config.NormalConfig.ContributionBranches.Strings(), ", "),
-			)
+			return fmt.Errorf("branch %q is %q", branch, branchType)
 		}
 		return nil
 	})
@@ -295,7 +290,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 		branch := gitdomain.NewLocalBranchName(name)
 		branchType := devRepo.Config.BranchType(branch)
 		if branchType != configdomain.BranchTypeFeatureBranch {
-			return fmt.Errorf("branch %q is a %s", branch, branchType)
+			return fmt.Errorf("branch %q is %s", branch, branchType)
 		}
 		return nil
 	})
@@ -304,12 +299,9 @@ func defineSteps(sc *godog.ScenarioContext) {
 		state := ctx.Value(keyScenarioState).(*ScenarioState)
 		devRepo := state.fixture.DevRepo.GetOrPanic()
 		branch := gitdomain.NewLocalBranchName(name)
-		if !slices.Contains(devRepo.Config.NormalConfig.ObservedBranches, branch) {
-			return fmt.Errorf(
-				"branch %q isn't observed as expected.\nObserved branches: %s",
-				branch,
-				strings.Join(devRepo.Config.NormalConfig.ObservedBranches.Strings(), ", "),
-			)
+		branchType := devRepo.Config.BranchType(branch)
+		if branchType != configdomain.BranchTypeObservedBranch {
+			return fmt.Errorf("branch %q is %s", branch, branchType)
 		}
 		return nil
 	})
@@ -318,12 +310,9 @@ func defineSteps(sc *godog.ScenarioContext) {
 		state := ctx.Value(keyScenarioState).(*ScenarioState)
 		devRepo := state.fixture.DevRepo.GetOrPanic()
 		branch := gitdomain.NewLocalBranchName(name)
-		if !slices.Contains(devRepo.Config.NormalConfig.ParkedBranches, branch) {
-			return fmt.Errorf(
-				"branch %q isn't parked as expected.\nParked branches: %s",
-				branch,
-				strings.Join(devRepo.Config.NormalConfig.ParkedBranches.Strings(), ", "),
-			)
+		branchType := devRepo.Config.NormalConfig.PartialBranchType(branch)
+		if branchType != configdomain.BranchTypeParkedBranch {
+			return fmt.Errorf("branch %q is %q", branch, branchType)
 		}
 		return nil
 	})
@@ -335,11 +324,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 		branch := gitdomain.NewLocalBranchName(name)
 		branchType := devRepo.Config.BranchType(branch)
 		if branchType != configdomain.BranchTypePerennialBranch {
-			return fmt.Errorf(
-				"branch %q isn't perennial as expected.\nPerennial branches: %s",
-				branch,
-				strings.Join(devRepo.Config.NormalConfig.PerennialBranches.Strings(), ", "),
-			)
+			return fmt.Errorf("branch %q is %s", branch, branchType)
 		}
 		return nil
 	})
@@ -1124,7 +1109,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 	sc.Step(`^the contribution branches are (?:now|still) "([^"]+)"$`, func(ctx context.Context, name string) error {
 		state := ctx.Value(keyScenarioState).(*ScenarioState)
 		devRepo := state.fixture.DevRepo.GetOrPanic()
-		actual := devRepo.Config.NormalConfig.LocalGitConfig.ContributionBranches
+		actual := devRepo.Config.NormalConfig.PartialBranchesOfType(configdomain.BranchTypeContributionBranch)
 		if len(actual) != 1 {
 			return fmt.Errorf("expected 1 contribution branch, got %q", actual)
 		}
@@ -1403,7 +1388,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 	sc.Step(`^the observed branches are (?:now|still) "([^"]+)"$`, func(ctx context.Context, name string) error {
 		state := ctx.Value(keyScenarioState).(*ScenarioState)
 		devRepo := state.fixture.DevRepo.GetOrPanic()
-		actual := devRepo.Config.NormalConfig.LocalGitConfig.ObservedBranches
+		actual := devRepo.Config.NormalConfig.PartialBranchesOfType(configdomain.BranchTypeObservedBranch)
 		if len(actual) != 1 {
 			return fmt.Errorf("expected 1 observed branch, got %q", actual)
 		}
@@ -1422,7 +1407,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 	sc.Step(`^the parked branches are (?:now|still) "([^"]+)"$`, func(ctx context.Context, name string) error {
 		state := ctx.Value(keyScenarioState).(*ScenarioState)
 		devRepo := state.fixture.DevRepo.GetOrPanic()
-		actual := devRepo.Config.NormalConfig.LocalGitConfig.ParkedBranches
+		actual := devRepo.Config.NormalConfig.PartialBranchesOfType(configdomain.BranchTypeParkedBranch)
 		if len(actual) != 1 {
 			return fmt.Errorf("expected 1 parked branch, got %q", actual)
 		}
@@ -1441,7 +1426,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 	sc.Step(`^the perennial branches are (?:now|still) "([^"]+)"$`, func(ctx context.Context, name string) error {
 		state := ctx.Value(keyScenarioState).(*ScenarioState)
 		devRepo := state.fixture.DevRepo.GetOrPanic()
-		actual := devRepo.Config.NormalConfig.LocalGitConfig.PerennialBranches
+		actual := devRepo.Config.NormalConfig.PartialBranchesOfType(configdomain.BranchTypePerennialBranch)
 		if len(actual) != 1 {
 			return fmt.Errorf("expected 1 perennial branch, got %q", actual)
 		}
@@ -1451,19 +1436,6 @@ func defineSteps(sc *godog.ScenarioContext) {
 		return nil
 	})
 
-	sc.Step(`^the perennial branches are (?:now|still) "([^"]+)" and "([^"]+)"$`, func(ctx context.Context, branch1, branch2 string) error {
-		state := ctx.Value(keyScenarioState).(*ScenarioState)
-		devRepo := state.fixture.DevRepo.GetOrPanic()
-		actual := devRepo.Config.NormalConfig.LocalGitConfig.PerennialBranches
-		if len(actual) != 2 {
-			return fmt.Errorf("expected 2 perennial branches, got %q", actual)
-		}
-		if (actual)[0].String() != branch1 || (actual)[1].String() != branch2 {
-			return fmt.Errorf("expected %q, got %q", []string{branch1, branch2}, actual)
-		}
-		return nil
-	})
-
 	sc.Step(`^the previous Git branch is (?:now|still) "([^"]*)"$`, func(ctx context.Context, want string) error {
 		state := ctx.Value(keyScenarioState).(*ScenarioState)
 		devRepo := state.fixture.DevRepo.GetOrPanic()
@@ -1477,7 +1449,7 @@ func defineSteps(sc *godog.ScenarioContext) {
 	sc.Step(`^the prototype branches are (?:now|still) "([^"]+)"$`, func(ctx context.Context, name string) error {
 		state := ctx.Value(keyScenarioState).(*ScenarioState)
 		devRepo := state.fixture.DevRepo.GetOrPanic()
-		actual := devRepo.Config.NormalConfig.LocalGitConfig.PrototypeBranches
+		actual := devRepo.Config.NormalConfig.PartialBranchesOfType(configdomain.BranchTypePrototypeBranch)
 		if len(actual) != 1 {
 			return fmt.Errorf("expected 1 prototype branch, got %q", actual)
 		}