Repository: git-town/git-town

Issue:
limit pulling the main branch
First of all, this probably shouldn't be a github issue, but I'm not sure where else to ask this! I'm just getting started with git town so this might be a basic question --

I work in a big repo with lots of contributors. I created a new branch and `set-parent` to an existing branch. I'd have liked to just have rebased on top of that branch without pulling the latest master (when master gets pulled, i need to do a full `yarn` to download packages etc). Is this possible to do with `sync`?

Hints from issue comments:
@kevgo: Hey Amir, that's a great question and an interesting use case! Here are two ideas that might get you want you want in your situation. I'm using your terminology, i.e. "new branch" for the branch that you created, and "existing branch" for the branch that is the parent of "new branch".

You could make the "existing branch" a [perennial branch](https://www.git-town.com/preferences/perennial-branch-names). Perennial branches are long-lived standalone branches like `master`, `production`, `development`, `staging`, etc. What separates them from feature branches is that they don't receive updates from the main branch.

```
git town config perennial-branches update
```

In the dialog that shows up when you run this command, enable the checkbox next to the "existing branch" name. Now you can run `git sync` and it does what you want: it pulls updates from the remote of the "existing branch", but it doesn't update `master` and it doesn't pull `master` into "existing branch" and "new branch". You can create pull requests for your "new branch" and ship it into this "existing" (and now perennial) branch as usual. To create pull requests or ship the "existing branch", you have to remove it from the perennial branches.

Another idea is to put Git Town into offline mode. In offline mode, Git Town doesn't pull any updates from the network. It just syncs the branches in your local workspace with each other. In this scenario you would have to run `git pull` or `git pull --rebase` manually on the branches you want to fetch updates for. Then any `git sync` should be safe in the sense that it doesn't pull any additional updates from the network.

```bash
git town config offline on
```
@heyitsaamir: Thank you! I had actually tried the offline mode, but it doesn't seem to be working for me. It's actually doing the fetch and pull. I'm not sure if I'm doing something wrong 🤔 @heyitsaamir: For your reference -- notice how it rebases on top of origin/master.

![image](https://github.com/git-town/git-town/assets/48929123/cdf4ee05-b5f4-4732-8237-cebf0367a0c3)

Hmm, it's not doing a fetch, but I think my vscode periodically does a fetch, so maybe that combo is broken@heyitsaamir: FWIW, the perennial-branches is a great workaround. I'll probably write up a script so that this is easy to set up and sync when I need to@kevgo: Some background on how Git works might help understand what's going on here. Due to the nature of Git as a distributed version control system, your local clone contains the _entire_ repo content including all commits and all branches from the server. `origin/master` is the local copy of the `master` branch that exists at the `origin` server. 

Git operations like `merge` and `rebase` happen on local copies of branches. `git rebase origin/master` updates your local `master` branch with commits in the local copy of the `master` branch from `origin`. 

Then you periodically download updates from the server to your local client (`git fetch`) and upload updates made on your local client to the server (`git push`). `git pull --rebase` is a convenience command that runs `git fetch` and `git rebase origin/<branch>` under the hood.

The way offline mode works right now is that it simply doesn't fetch. It still syncs what your local repo is aware of, including local copies of remote branches. This does what it should do as long as you never fetch manually.

If you or VSCode runs `git fetch` periodically under the hood, then your local Git repo contains unsynced updates of the remote branches, and Git Town syncs them even if offline mode is enabled. Because offline mode is enabled, it doesn't push them up to the server.

You can verify whether you have successfully enabled offline mode by running `git town config offline`. @kevgo: FYI there have been a number of reports requesting this feature. I'm collecting them and ideas how to address your use case better in https://github.com/git-town/git-town/issues/2711.@kevgo: @heyitsaamir assuming Git Town has a feature that allows you to avoid pulling in updates from the master branch. Would you enable this behavior for all your feature branches, or just for a few that you manually select?@heyitsaamir: > @heyitsaamir assuming Git Town has a feature that allows you to avoid pulling in updates from the master branch. Would you enable this behavior for all your feature branches, or just for a few that you manually select?

I'd probably enable it by default. I'm used to graphite's model where they only pull things if you explicitly run a `sync` command. I generally prefer this because:
1. It works better for me for larger codebases (faster)
2. Less cognitive overhead that i'm in offline mode or not
3. I work while travelling (airplanes etc), so sometimes my access to wifi is slow. This is when i can get a lot of focused work done, so I'd rather not kick off a git pull if i can help it.@kevgo: Thanks this is helpful, and graphite looks cool! Follow-up question to help me fully understand what you have in mind here.

> I'm used to graphite's model where they only pull things if you explicitly run a sync command.

How is this different from Git Town? Git Town also only pulls things if you run a `sync` command.

> [when my internet is slow during traveling] I'd rather not kick off a git pull if i can help it

Simply don't run `git pull` or `git sync` then? Do you mean that you would want to sync only the local branches when traveling, and only pull updates over the network when running the explicit "sync" command?

I'm sure I'm missing something here. Can you please describe in more detail which exact Git operations you would like to be run (roughly) in each of the scenarios you describe (office with fast internet, traveling with slow internet)? How would an ideal Git Town tool behave in your use case? Thanks! 🙏 @kevgo: [New features](https://www.git-town.com/advanced-syncing) added in v12.1 improve the developer experience around this:

- make the existing branch a [contributing branch](https://www.git-town.com/commands/contribute)
- now when you run `git sync` it does what OP suggests: it updates `new branch` only with commits made to `existing branch` and not with commits added to `master`@kevgo: Possible implementation: a user-specific config setting `sync-root`.
- only in Git metadata since it's user-specific

If set to `true` (default): `git sync` works normally

If set to `false`:

- `git sync` syncs everything except pulling updates from the root branch origin.
  - It does not pull updates from `origin/main` into `main`.
- To pull updates from the root origin:
  - run `git sync` on the root branch
  - run `git sync --root`
@kevgo: Additional request for this in https://github.com/git-town/git-town/issues/2673#issuecomment-1829983692@kevgo: Possible idea to implement this: #2171 @kevgo: The simplest step towards providing this functionality is adding a flag to `git sync` that prevents pulling in the main branch. Something like `git sync --skip-main`. These manual overrides are needed anyways, even in the presence of a more systematic solution like #2171.@kevgo: To make this work for stacks based on perennial branches other than main, it's probably better to call this something like `--skip-root` or `--headless`.@kevgo: `--detached`@kevgo: It should be possible to combine the `--detached` flag with flags like `--all`, `--stack`, and `--no-push`.@kevgo: @heyitsaamir thanks for submitting this really useful feature request. Starting with [v16.1](https://github.com/git-town/git-town/releases/tag/v16.1.0) you can run `git sync --detached` to prevent pulling the main branch. Enjoy!@kevgo: Just an update on this. There is now a `--detached` flag for all Git Town commands, and since [v20.2](https://github.com/git-town/git-town/releases/tag/v20.2.0) it doesn't pull in any updates from the local main branch.

Solution patch:
diff --git a/features/sync/all_branches/merge_sync_strategy/detached.feature b/features/sync/all_branches/merge_sync_strategy/detached.feature
new file mode 100644
index 000000000..376d6c3d6
--- /dev/null
+++ b/features/sync/all_branches/merge_sync_strategy/detached.feature
@@ -0,0 +1,91 @@
+Feature: sync all feature branches
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME         | TYPE         | PARENT | LOCATIONS     |
+      | alpha        | feature      | main   | local, origin |
+      | beta         | feature      | main   | local, origin |
+      | production   | perennial    |        | local, origin |
+      | qa           | perennial    |        | local, origin |
+      | observed     | observed     |        | local, origin |
+      | contribution | contribution |        | local, origin |
+      | parked       | parked       | main   | local, origin |
+    And the commits
+      | BRANCH       | LOCATION      | MESSAGE                    |
+      | main         | origin        | main commit                |
+      | alpha        | local, origin | alpha commit               |
+      | beta         | local, origin | beta commit                |
+      | contribution | local         | local contribution commit  |
+      |              | origin        | origin contribution commit |
+      | observed     | local         | local observed commit      |
+      |              | origin        | origin observed commit     |
+      | parked       | local         | local parked commit        |
+      |              | origin        | origin parked commit       |
+      | production   | local         | local production commit    |
+      |              | origin        | origin production commit   |
+      | qa           | local         | qa local commit            |
+      |              | origin        | qa origin commit           |
+    And the current branch is "alpha"
+    When I run "git-town sync --all --detached"
+
+  Scenario: result
+    Then it runs the commands
+      | BRANCH       | COMMAND                               |
+      | alpha        | git fetch --prune --tags              |
+      |              | git merge --no-edit --ff origin/alpha |
+      |              | git merge --no-edit --ff main         |
+      |              | git checkout beta                     |
+      | beta         | git merge --no-edit --ff origin/beta  |
+      |              | git merge --no-edit --ff main         |
+      |              | git checkout contribution             |
+      | contribution | git rebase origin/contribution        |
+      |              | git push                              |
+      |              | git checkout observed                 |
+      | observed     | git rebase origin/observed            |
+      |              | git checkout alpha                    |
+      | alpha        | git push --tags                       |
+    And the current branch is still "alpha"
+    And these commits exist now
+      | BRANCH       | LOCATION      | MESSAGE                    |
+      | main         | origin        | main commit                |
+      | alpha        | local, origin | alpha commit               |
+      | beta         | local, origin | beta commit                |
+      | contribution | local, origin | origin contribution commit |
+      |              |               | local contribution commit  |
+      | observed     | local, origin | origin observed commit     |
+      |              | local         | local observed commit      |
+      | parked       | local         | local parked commit        |
+      |              | origin        | origin parked commit       |
+      | production   | local         | local production commit    |
+      |              | origin        | origin production commit   |
+      | qa           | local         | qa local commit            |
+      |              | origin        | qa origin commit           |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then it runs the commands
+      | BRANCH       | COMMAND                                                                                          |
+      | alpha        | git checkout contribution                                                                        |
+      | contribution | git reset --hard {{ sha 'local contribution commit' }}                                           |
+      |              | git push --force-with-lease origin {{ sha-in-origin 'origin contribution commit' }}:contribution |
+      |              | git checkout observed                                                                            |
+      | observed     | git reset --hard {{ sha 'local observed commit' }}                                               |
+      |              | git checkout alpha                                                                               |
+    And the current branch is still "alpha"
+    And these commits exist now
+      | BRANCH       | LOCATION      | MESSAGE                    |
+      | main         | origin        | main commit                |
+      | alpha        | local, origin | alpha commit               |
+      | beta         | local, origin | beta commit                |
+      | contribution | local         | local contribution commit  |
+      |              | origin        | origin contribution commit |
+      | observed     | local         | local observed commit      |
+      |              | origin        | origin observed commit     |
+      | parked       | local         | local parked commit        |
+      |              | origin        | origin parked commit       |
+      | production   | local         | local production commit    |
+      |              | origin        | origin production commit   |
+      | qa           | local         | qa local commit            |
+      |              | origin        | qa origin commit           |
+    And the initial branches and lineage exist
diff --git a/features/sync/current_branch/feature_branch/compress_sync_strategy/detached/enabled.feature b/features/sync/current_branch/feature_branch/compress_sync_strategy/detached/enabled.feature
new file mode 100644
index 000000000..a0fc64676
--- /dev/null
+++ b/features/sync/current_branch/feature_branch/compress_sync_strategy/detached/enabled.feature
@@ -0,0 +1,61 @@
+Feature: detached sync a grandchild feature branch using the "compress" strategy
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME  | TYPE    | PARENT | LOCATIONS     |
+      | alpha | feature | main   | local, origin |
+      | beta  | feature | alpha  | local, origin |
+    And the commits
+      | BRANCH | LOCATION | MESSAGE             |
+      | main   | local    | local main commit   |
+      |        | origin   | origin main commit  |
+      | alpha  | local    | local alpha commit  |
+      |        | origin   | origin alpha commit |
+      | beta   | local    | local beta commit   |
+      |        | origin   | origin beta commit  |
+    And wait 1 second to ensure new Git timestamps
+    And Git Town setting "sync-feature-strategy" is "compress"
+    And the current branch is "beta"
+    When I run "git-town sync --detached"
+
+  Scenario: result
+    Then it runs the commands
+      | BRANCH | COMMAND                               |
+      | beta   | git fetch --prune --tags              |
+      |        | git checkout alpha                    |
+      | alpha  | git merge --no-edit --ff origin/alpha |
+      |        | git merge --no-edit --ff main         |
+      |        | git reset --soft main                 |
+      |        | git commit -m "local alpha commit"    |
+      |        | git push --force-with-lease           |
+      |        | git checkout beta                     |
+      | beta   | git merge --no-edit --ff origin/beta  |
+      |        | git merge --no-edit --ff alpha        |
+      |        | git reset --soft alpha                |
+      |        | git commit -m "local beta commit"     |
+      |        | git push --force-with-lease           |
+    And the current branch is still "beta"
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE            |
+      | main   | local         | local main commit  |
+      |        | origin        | origin main commit |
+      | alpha  | local, origin | local main commit  |
+      |        |               | local alpha commit |
+      | beta   | local, origin | local main commit  |
+      |        |               | local alpha commit |
+      |        |               | local beta commit  |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then it runs the commands
+      | BRANCH | COMMAND                                                                                       |
+      | beta   | git checkout alpha                                                                            |
+      | alpha  | git reset --hard {{ sha-before-run 'local alpha commit' }}                                    |
+      |        | git push --force-with-lease origin {{ sha-in-origin-before-run 'origin alpha commit' }}:alpha |
+      |        | git checkout beta                                                                             |
+      | beta   | git reset --hard {{ sha-before-run 'local beta commit' }}                                     |
+      |        | git push --force-with-lease origin {{ sha-in-origin-before-run 'origin beta commit' }}:beta   |
+    And the current branch is still "beta"
+    And the initial commits exist
+    And the initial branches and lineage exist
diff --git a/features/sync/current_branch/feature_branch/merge_sync_strategy/detached/enabled.feature b/features/sync/current_branch/feature_branch/merge_sync_strategy/detached/enabled.feature
new file mode 100644
index 000000000..b1d0bdbef
--- /dev/null
+++ b/features/sync/current_branch/feature_branch/merge_sync_strategy/detached/enabled.feature
@@ -0,0 +1,65 @@
+@smoke
+Feature: sync the current feature branch with a tracking branch using the "merge" sync-feature strategy
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME  | TYPE    | PARENT | LOCATIONS     |
+      | alpha | feature | main   | local, origin |
+      | beta  | feature | alpha  | local, origin |
+    And the commits
+      | BRANCH | LOCATION | MESSAGE             |
+      | main   | local    | local main commit   |
+      |        | origin   | origin main commit  |
+      | alpha  | local    | local alpha commit  |
+      |        | origin   | origin alpha commit |
+      | beta   | local    | local beta commit   |
+      |        | origin   | origin beta commit  |
+    And the current branch is "beta"
+    When I run "git-town sync --detached"
+
+  Scenario: result
+    Then it runs the commands
+      | BRANCH | COMMAND                               |
+      | beta   | git fetch --prune --tags              |
+      |        | git checkout alpha                    |
+      | alpha  | git merge --no-edit --ff origin/alpha |
+      |        | git merge --no-edit --ff main         |
+      |        | git push                              |
+      |        | git checkout beta                     |
+      | beta   | git merge --no-edit --ff origin/beta  |
+      |        | git merge --no-edit --ff alpha        |
+      |        | git push                              |
+    And the current branch is still "beta"
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE                                                |
+      | main   | local         | local main commit                                      |
+      |        | origin        | origin main commit                                     |
+      | alpha  | local, origin | local alpha commit                                     |
+      |        |               | origin alpha commit                                    |
+      |        |               | Merge remote-tracking branch 'origin/alpha' into alpha |
+      |        |               | local main commit                                      |
+      |        |               | Merge branch 'main' into alpha                         |
+      | beta   | local, origin | local beta commit                                      |
+      |        |               | origin beta commit                                     |
+      |        |               | Merge remote-tracking branch 'origin/beta' into beta   |
+      |        |               | local alpha commit                                     |
+      |        |               | origin alpha commit                                    |
+      |        |               | Merge remote-tracking branch 'origin/alpha' into alpha |
+      |        |               | local main commit                                      |
+      |        |               | Merge branch 'main' into alpha                         |
+      |        |               | Merge branch 'alpha' into beta                         |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then it runs the commands
+      | BRANCH | COMMAND                                                                            |
+      | beta   | git checkout alpha                                                                 |
+      | alpha  | git reset --hard {{ sha 'local alpha commit' }}                                    |
+      |        | git push --force-with-lease origin {{ sha-in-origin 'origin alpha commit' }}:alpha |
+      |        | git checkout beta                                                                  |
+      | beta   | git reset --hard {{ sha 'local beta commit' }}                                     |
+      |        | git push --force-with-lease origin {{ sha-in-origin 'origin beta commit' }}:beta   |
+    And the current branch is still "beta"
+    And the initial commits exist
+    And the initial branches and lineage exist
diff --git a/features/sync/current_branch/feature_branch/merge_sync_strategy/no_push/detached.feature b/features/sync/current_branch/feature_branch/merge_sync_strategy/no_push/detached.feature
new file mode 100644
index 000000000..31220c612
--- /dev/null
+++ b/features/sync/current_branch/feature_branch/merge_sync_strategy/no_push/detached.feature
@@ -0,0 +1,62 @@
+Feature: detached syncing a stacked feature branch using --no-push
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME  | TYPE    | PARENT | LOCATIONS     |
+      | other | (none)  |        | local, origin |
+      | alpha | feature | main   | local, origin |
+      | beta  | feature | alpha  | local, origin |
+    And the current branch is "beta"
+    And the commits
+      | BRANCH | LOCATION | MESSAGE             |
+      | main   | local    | local main commit   |
+      |        | origin   | origin main commit  |
+      | alpha  | local    | local alpha commit  |
+      |        | origin   | origin alpha commit |
+      | beta   | local    | local beta commit   |
+      |        | origin   | origin beta commit  |
+    When I run "git-town sync --no-push --detached"
+
+  Scenario: result
+    Then it runs the commands
+      | BRANCH | COMMAND                               |
+      | beta   | git fetch --prune --tags              |
+      |        | git checkout alpha                    |
+      | alpha  | git merge --no-edit --ff origin/alpha |
+      |        | git merge --no-edit --ff main         |
+      |        | git checkout beta                     |
+      | beta   | git merge --no-edit --ff origin/beta  |
+      |        | git merge --no-edit --ff alpha        |
+    And the current branch is still "beta"
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE                                                |
+      | main   | local         | local main commit                                      |
+      |        | origin        | origin main commit                                     |
+      | alpha  | local         | local alpha commit                                     |
+      |        | local, origin | origin alpha commit                                    |
+      |        | local         | Merge remote-tracking branch 'origin/alpha' into alpha |
+      |        |               | local main commit                                      |
+      |        |               | Merge branch 'main' into alpha                         |
+      | beta   | local         | local beta commit                                      |
+      |        | local, origin | origin beta commit                                     |
+      |        | local         | Merge remote-tracking branch 'origin/beta' into beta   |
+      |        |               | local alpha commit                                     |
+      |        |               | origin alpha commit                                    |
+      |        |               | Merge remote-tracking branch 'origin/alpha' into alpha |
+      |        |               | local main commit                                      |
+      |        |               | Merge branch 'main' into alpha                         |
+      |        |               | Merge branch 'alpha' into beta                         |
+    And the initial branches and lineage exist
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then it runs the commands
+      | BRANCH | COMMAND                                         |
+      | beta   | git checkout alpha                              |
+      | alpha  | git reset --hard {{ sha 'local alpha commit' }} |
+      |        | git checkout beta                               |
+      | beta   | git reset --hard {{ sha 'local beta commit' }}  |
+    And the current branch is still "beta"
+    And the initial commits exist
+    And the initial branches and lineage exist
diff --git a/features/sync/current_branch/feature_branch/merge_sync_strategy/stacked_changes/detached.feature b/features/sync/current_branch/feature_branch/merge_sync_strategy/stacked_changes/detached.feature
new file mode 100644
index 000000000..a4668d1ae
--- /dev/null
+++ b/features/sync/current_branch/feature_branch/merge_sync_strategy/stacked_changes/detached.feature
@@ -0,0 +1,61 @@
+Feature: detached sync of the entire branch stack
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME  | TYPE    | PARENT | LOCATIONS     |
+      | alpha | feature | main   | local, origin |
+      | beta  | feature | alpha  | local, origin |
+    And the commits
+      | BRANCH | LOCATION | MESSAGE             |
+      | main   | local    | local main commit   |
+      |        | origin   | origin main commit  |
+      | alpha  | local    | local alpha commit  |
+      |        | origin   | origin alpha commit |
+      | beta   | local    | local beta commit   |
+      |        | origin   | origin beta commit  |
+    And the current branch is "alpha"
+    When I run "git-town sync --stack --detached"
+
+  Scenario:
+    Then it runs the commands
+      | BRANCH | COMMAND                               |
+      | alpha  | git fetch --prune --tags              |
+      |        | git merge --no-edit --ff origin/alpha |
+      |        | git merge --no-edit --ff main         |
+      |        | git push                              |
+      |        | git checkout beta                     |
+      | beta   | git merge --no-edit --ff origin/beta  |
+      |        | git merge --no-edit --ff alpha        |
+      |        | git push                              |
+      |        | git checkout alpha                    |
+    And the current branch is still "alpha"
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE                                                |
+      | main   | local         | local main commit                                      |
+      |        | origin        | origin main commit                                     |
+      | alpha  | local, origin | local alpha commit                                     |
+      |        |               | origin alpha commit                                    |
+      |        |               | Merge remote-tracking branch 'origin/alpha' into alpha |
+      |        |               | local main commit                                      |
+      |        |               | Merge branch 'main' into alpha                         |
+      | beta   | local, origin | local beta commit                                      |
+      |        |               | origin beta commit                                     |
+      |        |               | Merge remote-tracking branch 'origin/beta' into beta   |
+      |        |               | local alpha commit                                     |
+      |        |               | origin alpha commit                                    |
+      |        |               | Merge remote-tracking branch 'origin/alpha' into alpha |
+      |        |               | local main commit                                      |
+      |        |               | Merge branch 'main' into alpha                         |
+      |        |               | Merge branch 'alpha' into beta                         |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then it runs the commands
+      | BRANCH | COMMAND                                                                                       |
+      | alpha  | git reset --hard {{ sha-before-run 'local alpha commit' }}                                    |
+      |        | git push --force-with-lease origin {{ sha-in-origin-before-run 'origin alpha commit' }}:alpha |
+      |        | git checkout beta                                                                             |
+      | beta   | git reset --hard {{ sha-before-run 'local beta commit' }}                                     |
+      |        | git push --force-with-lease origin {{ sha-in-origin-before-run 'origin beta commit' }}:beta   |
+      |        | git checkout alpha                                                                            |
diff --git a/features/sync/current_branch/feature_branch/rebase_sync_strategy/detached/enabled.feature b/features/sync/current_branch/feature_branch/rebase_sync_strategy/detached/enabled.feature
new file mode 100644
index 000000000..a1640f363
--- /dev/null
+++ b/features/sync/current_branch/feature_branch/rebase_sync_strategy/detached/enabled.feature
@@ -0,0 +1,43 @@
+Feature: detached sync the current feature branch using the "rebase" sync-feature strategy
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME    | TYPE    | PARENT | LOCATIONS     |
+      | feature | feature | main   | local, origin |
+    And Git Town setting "sync-feature-strategy" is "rebase"
+    And the current branch is "feature"
+    And the commits
+      | BRANCH  | LOCATION | MESSAGE               |
+      | main    | local    | local main commit     |
+      |         | origin   | origin main commit    |
+      | feature | local    | local feature commit  |
+      |         | origin   | origin feature commit |
+    When I run "git-town sync --detached"
+
+  Scenario: result
+    Then it runs the commands
+      | BRANCH  | COMMAND                                         |
+      | feature | git fetch --prune --tags                        |
+      |         | git rebase main                                 |
+      |         | git push --force-with-lease --force-if-includes |
+      |         | git rebase origin/feature                       |
+      |         | git push --force-with-lease --force-if-includes |
+    And the current branch is still "feature"
+    And these commits exist now
+      | BRANCH  | LOCATION      | MESSAGE               |
+      | main    | local         | local main commit     |
+      |         | origin        | origin main commit    |
+      | feature | local, origin | origin feature commit |
+      |         |               | local main commit     |
+      |         |               | local feature commit  |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then it runs the commands
+      | BRANCH  | COMMAND                                                                                           |
+      | feature | git reset --hard {{ sha-before-run 'local feature commit' }}                                      |
+      |         | git push --force-with-lease origin {{ sha-in-origin-before-run 'origin feature commit' }}:feature |
+    And the current branch is still "feature"
+    And the initial commits exist
+    And the initial branches and lineage exist
diff --git a/features/sync/current_branch/feature_branch/rebase_sync_strategy/no_push/detached.feature b/features/sync/current_branch/feature_branch/rebase_sync_strategy/no_push/detached.feature
new file mode 100644
index 000000000..47f2bf79c
--- /dev/null
+++ b/features/sync/current_branch/feature_branch/rebase_sync_strategy/no_push/detached.feature
@@ -0,0 +1,57 @@
+Feature: detached syncing a stacked feature branch using --no-push
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME  | TYPE    | PARENT | LOCATIONS     |
+      | alpha | feature | main   | local, origin |
+      | beta  | feature | alpha  | local, origin |
+    And the current branch is "beta"
+    And the commits
+      | BRANCH | LOCATION | MESSAGE             |
+      | main   | local    | local main commit   |
+      |        | origin   | origin main commit  |
+      | alpha  | local    | local alpha commit  |
+      |        | origin   | origin alpha commit |
+      | beta   | local    | local beta commit   |
+      |        | origin   | origin beta commit  |
+    And Git Town setting "sync-feature-strategy" is "rebase"
+    When I run "git-town sync --no-push --detached"
+
+  Scenario: result
+    Then it runs the commands
+      | BRANCH | COMMAND                  |
+      | beta   | git fetch --prune --tags |
+      |        | git checkout alpha       |
+      | alpha  | git rebase main          |
+      |        | git rebase origin/alpha  |
+      |        | git checkout beta        |
+      | beta   | git rebase alpha         |
+      |        | git rebase origin/beta   |
+    And the current branch is still "beta"
+    And these commits exist now
+      | BRANCH | LOCATION      | MESSAGE             |
+      | main   | local         | local main commit   |
+      |        | origin        | origin main commit  |
+      | alpha  | local, origin | origin alpha commit |
+      |        | local         | local main commit   |
+      |        |               | local alpha commit  |
+      | beta   | local, origin | origin beta commit  |
+      |        | local         | origin alpha commit |
+      |        |               | local main commit   |
+      |        |               | local alpha commit  |
+      |        |               | local beta commit   |
+    And the initial branches and lineage exist
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then it runs the commands
+      | BRANCH | COMMAND                                         |
+      | beta   | git checkout alpha                              |
+      | alpha  | git reset --hard {{ sha 'local alpha commit' }} |
+      |        | git checkout beta                               |
+      | beta   | git reset --hard {{ sha 'local beta commit' }}  |
+
+    And the current branch is still "beta"
+    And the initial commits exist
+    And the initial branches and lineage exist
diff --git a/features/sync/stack/rebase_sync_strategy/detached.feature b/features/sync/stack/rebase_sync_strategy/detached.feature
new file mode 100644
index 000000000..d580a9cec
--- /dev/null
+++ b/features/sync/stack/rebase_sync_strategy/detached.feature
@@ -0,0 +1,83 @@
+Feature: sync the entire stack
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME       | TYPE      | PARENT | LOCATIONS     |
+      | alpha      | feature   | main   | local, origin |
+      | beta       | feature   | alpha  | local, origin |
+      | gamma      | feature   | beta   | local, origin |
+      | one        | feature   | main   | local, origin |
+      | two        | feature   | one    | local, origin |
+      | production | perennial |        | local, origin |
+      | qa         | perennial |        | local, origin |
+      | observed   | observed  |        | local, origin |
+      | parked     | parked    | main   | local, origin |
+    And the commits
+      | BRANCH     | LOCATION      | MESSAGE                  |
+      | main       | origin        | main commit              |
+      | alpha      | local, origin | alpha commit             |
+      | beta       | local, origin | beta commit              |
+      | gamma      | local, origin | gamma commit             |
+      | one        | local, origin | one commit               |
+      | two        | local, origin | two commit               |
+      | observed   | local         | local observed commit    |
+      |            | origin        | origin observed commit   |
+      | parked     | local         | local parked commit      |
+      |            | origin        | origin parked commit     |
+      | production | local         | local production commit  |
+      |            | origin        | origin production commit |
+      | qa         | local         | qa local commit          |
+      |            | origin        | qa origin commit         |
+    And the current branch is "alpha"
+    And Git Town setting "sync-feature-strategy" is "rebase"
+    When I run "git-town sync --stack --detached"
+
+  Scenario: result
+    Then it runs the commands
+      | BRANCH | COMMAND                                         |
+      | alpha  | git fetch --prune --tags                        |
+      |        | git rebase main                                 |
+      |        | git push --force-with-lease --force-if-includes |
+      |        | git checkout beta                               |
+      | beta   | git rebase alpha                                |
+      |        | git push --force-with-lease --force-if-includes |
+      |        | git checkout gamma                              |
+      | gamma  | git rebase beta                                 |
+      |        | git push --force-with-lease --force-if-includes |
+      |        | git checkout alpha                              |
+    And the current branch is still "alpha"
+    And these commits exist now
+      | BRANCH     | LOCATION      | MESSAGE                  |
+      | main       | origin        | main commit              |
+      | alpha      | local, origin | alpha commit             |
+      | beta       | local, origin | alpha commit             |
+      |            |               | beta commit              |
+      | gamma      | local, origin | alpha commit             |
+      |            |               | beta commit              |
+      |            |               | gamma commit             |
+      | observed   | local         | local observed commit    |
+      |            | origin        | origin observed commit   |
+      | one        | local, origin | one commit               |
+      | parked     | local         | local parked commit      |
+      |            | origin        | origin parked commit     |
+      | production | local         | local production commit  |
+      |            | origin        | origin production commit |
+      | qa         | local         | qa local commit          |
+      |            | origin        | qa origin commit         |
+      | two        | local, origin | two commit               |
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then it runs the commands
+      | BRANCH | COMMAND                                         |
+      | alpha  | git checkout beta                               |
+      | beta   | git reset --hard {{ sha 'beta commit' }}        |
+      |        | git push --force-with-lease --force-if-includes |
+      |        | git checkout gamma                              |
+      | gamma  | git reset --hard {{ sha 'gamma commit' }}       |
+      |        | git push --force-with-lease --force-if-includes |
+      |        | git checkout alpha                              |
+    And the current branch is still "alpha"
+    And the initial commits exist
+    And the initial branches and lineage exist
diff --git a/internal/cli/flags/detached.go b/internal/cli/flags/detached.go
new file mode 100644
index 000000000..b9aa59f60
--- /dev/null
+++ b/internal/cli/flags/detached.go
@@ -0,0 +1,26 @@
+package flags
+
+import (
+	"github.com/git-town/git-town/v16/internal/config/configdomain"
+	"github.com/spf13/cobra"
+)
+
+const detachedLong = "detached"
+
+// type-safe access to the CLI arguments of type configdomain.Detached
+func Detached() (AddFunc, ReadDetachedFlagFunc) {
+	addFlag := func(cmd *cobra.Command) {
+		cmd.PersistentFlags().BoolP(detachedLong, "d", false, "don't update the perennial root branch")
+	}
+	readFlag := func(cmd *cobra.Command) configdomain.Detached {
+		value, err := cmd.Flags().GetBool(detachedLong)
+		if err != nil {
+			panic(err)
+		}
+		return configdomain.Detached(value)
+	}
+	return addFlag, readFlag
+}
+
+// the type signature for the function that reads the detached flag from the args to the given Cobra command
+type ReadDetachedFlagFunc func(*cobra.Command) configdomain.Detached
diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go
index 9744e63ec..afa2d2da4 100644
--- a/internal/cmd/sync.go
+++ b/internal/cmd/sync.go
@@ -48,6 +48,7 @@ If the repository contains an "upstream" remote, syncs the main branch with its
 
 func syncCmd() *cobra.Command {
 	addVerboseFlag, readVerboseFlag := flags.Verbose()
+	addDetachedFlag, readDetachedFlag := flags.Detached()
 	addDryRunFlag, readDryRunFlag := flags.DryRun()
 	addAllFlag, readAllFlag := flags.All()
 	addNoPushFlag, readNoPushFlag := flags.NoPush()
@@ -59,10 +60,11 @@ func syncCmd() *cobra.Command {
 		Short:   syncDesc,
 		Long:    cmdhelpers.Long(syncDesc, fmt.Sprintf(syncHelp, configdomain.KeySyncUpstream)),
 		RunE: func(cmd *cobra.Command, _ []string) error {
-			return executeSync(readAllFlag(cmd), readStackFlag(cmd), readDryRunFlag(cmd), readVerboseFlag(cmd), readNoPushFlag(cmd))
+			return executeSync(readAllFlag(cmd), readStackFlag(cmd), readDetachedFlag(cmd), readDryRunFlag(cmd), readVerboseFlag(cmd), readNoPushFlag(cmd))
 		},
 	}
 	addAllFlag(&cmd)
+	addDetachedFlag(&cmd)
 	addVerboseFlag(&cmd)
 	addDryRunFlag(&cmd)
 	addNoPushFlag(&cmd)
@@ -70,7 +72,7 @@ func syncCmd() *cobra.Command {
 	return &cmd
 }
 
-func executeSync(syncAllBranches configdomain.SyncAllBranches, syncStack configdomain.FullStack, dryRun configdomain.DryRun, verbose configdomain.Verbose, pushBranches configdomain.PushBranches) error {
+func executeSync(syncAllBranches configdomain.SyncAllBranches, syncStack configdomain.FullStack, detached configdomain.Detached, dryRun configdomain.DryRun, verbose configdomain.Verbose, pushBranches configdomain.PushBranches) error {
 	repo, err := execute.OpenRepo(execute.OpenRepoArgs{
 		DryRun:           dryRun,
 		PrintBranchNames: true,
@@ -82,7 +84,7 @@ func executeSync(syncAllBranches configdomain.SyncAllBranches, syncStack configd
 	if err != nil {
 		return err
 	}
-	data, exit, err := determineSyncData(syncAllBranches, syncStack, repo, verbose)
+	data, exit, err := determineSyncData(syncAllBranches, syncStack, repo, verbose, detached)
 	if err != nil || exit {
 		return err
 	}
@@ -151,6 +153,7 @@ type syncData struct {
 	branchesSnapshot gitdomain.BranchesSnapshot
 	branchesToSync   []configdomain.BranchToSync
 	config           config.ValidatedConfig
+	detached         configdomain.Detached
 	dialogTestInputs components.TestInputs
 	hasOpenChanges   bool
 	initialBranch    gitdomain.LocalBranchName
@@ -160,7 +163,7 @@ type syncData struct {
 	stashSize        gitdomain.StashSize
 }
 
-func determineSyncData(syncAllBranches configdomain.SyncAllBranches, syncStack configdomain.FullStack, repo execute.OpenRepoResult, verbose configdomain.Verbose) (data syncData, exit bool, err error) {
+func determineSyncData(syncAllBranches configdomain.SyncAllBranches, syncStack configdomain.FullStack, repo execute.OpenRepoResult, verbose configdomain.Verbose, detached configdomain.Detached) (data syncData, exit bool, err error) {
 	dialogTestInputs := components.LoadTestInputs(os.Environ())
 	repoStatus, err := repo.Git.RepoStatus(repo.Backend)
 	if err != nil {
@@ -258,6 +261,9 @@ func determineSyncData(syncAllBranches configdomain.SyncAllBranches, syncStack c
 		shouldPushTags = validatedConfig.Config.IsMainOrPerennialBranch(initialBranch)
 	}
 	allBranchNamesToSync := validatedConfig.Config.Lineage.BranchesAndAncestors(branchNamesToSync)
+	if detached {
+		allBranchNamesToSync = validatedConfig.Config.RemovePerennials(allBranchNamesToSync)
+	}
 	branchesToSync, err := branchesToSync(allBranchNamesToSync, branchesSnapshot, repo, validatedConfig.Config.MainBranch)
 	if err != nil {
 		return data, false, err
@@ -267,6 +273,7 @@ func determineSyncData(syncAllBranches configdomain.SyncAllBranches, syncStack c
 		branchesSnapshot: branchesSnapshot,
 		branchesToSync:   branchesToSync,
 		config:           validatedConfig,
+		detached:         detached,
 		dialogTestInputs: dialogTestInputs,
 		hasOpenChanges:   repoStatus.OpenChanges,
 		initialBranch:    initialBranch,
diff --git a/internal/config/configdomain/detached.go b/internal/config/configdomain/detached.go
new file mode 100644
index 000000000..f19aa5f0e
--- /dev/null
+++ b/internal/config/configdomain/detached.go
@@ -0,0 +1,4 @@
+package configdomain
+
+// indicates whether a Git Town command should not update the root branch of the stack
+type Detached bool
diff --git a/internal/config/configdomain/validated_config.go b/internal/config/configdomain/validated_config.go
index 6c70617db..87a9f7514 100644
--- a/internal/config/configdomain/validated_config.go
+++ b/internal/config/configdomain/validated_config.go
@@ -58,3 +58,17 @@ func (self *ValidatedConfig) IsPerennialBranch(branch gitdomain.LocalBranchName)
 func (self *ValidatedConfig) MainAndPerennials() gitdomain.LocalBranchNames {
 	return append(gitdomain.LocalBranchNames{self.MainBranch}, self.PerennialBranches...)
 }
+
+// provides this collection without the perennial branch at the root
+func (self ValidatedConfig) RemovePerennials(stack gitdomain.LocalBranchNames) gitdomain.LocalBranchNames {
+	if len(stack) == 0 {
+		return stack
+	}
+	result := make(gitdomain.LocalBranchNames, 0, len(stack)-1)
+	for _, branch := range stack {
+		if !self.IsMainOrPerennialBranch(branch) {
+			result = append(result, branch)
+		}
+	}
+	return result
+}
diff --git a/website/src/commands/sync.md b/website/src/commands/sync.md
index 8a8926835..3ff393a25 100644
--- a/website/src/commands/sync.md
+++ b/website/src/commands/sync.md
@@ -24,6 +24,10 @@ By default this command syncs only the current branch. The `--all` parameter
 makes Git Town sync all local branches. The `--stack` parameter makes Git Town
 sync all branches in the stack that the current branch belongs to.
 
+The `--detached` flag stops pulling updates from the main or perennial branches.
+This allows you to keep your branches in sync with each other and decide when to
+pull in changes from other developers.
+
 The `--dry-run` parameter allows to test-drive this command. It prints the Git
 commands that would be run but doesn't execute them.
 



Test patch:
diff --git a/internal/config/validated_config_test.go b/internal/config/validated_config_test.go
index e0ca12b1f..4e50226a7 100644
--- a/internal/config/validated_config_test.go
+++ b/internal/config/validated_config_test.go
@@ -75,4 +75,40 @@ func TestValidatedConfig(t *testing.T) {
 			must.Eq(t, want, repo.Config.Config.Lineage)
 		})
 	})
+
+	t.Run("RemovePerennialRoot", func(t *testing.T) {
+		t.Parallel()
+		contribution := gitdomain.NewLocalBranchName("contribution")
+		feature1 := gitdomain.NewLocalBranchName("feature1")
+		feature2 := gitdomain.NewLocalBranchName("feature2")
+		main := gitdomain.NewLocalBranchName("main")
+		observed := gitdomain.NewLocalBranchName("observed")
+		parked := gitdomain.NewLocalBranchName("parked")
+		perennial1 := gitdomain.NewLocalBranchName("perennial-1")
+		perennial2 := gitdomain.NewLocalBranchName("perennial-2")
+		prototype := gitdomain.NewLocalBranchName("prototype")
+		config := configdomain.ValidatedConfig{
+			MainBranch: gitdomain.NewLocalBranchName("main"),
+			UnvalidatedConfig: &configdomain.UnvalidatedConfig{
+				ContributionBranches: gitdomain.LocalBranchNames{contribution},
+				ObservedBranches:     gitdomain.LocalBranchNames{observed},
+				ParkedBranches:       gitdomain.LocalBranchNames{parked},
+				PerennialBranches:    gitdomain.LocalBranchNames{perennial1},
+				PerennialRegex:       configdomain.ParsePerennialRegex("peren*"),
+				PrototypeBranches:    gitdomain.LocalBranchNames{prototype},
+			},
+		}
+		tests := map[*gitdomain.LocalBranchNames]gitdomain.LocalBranchNames{
+			{main}:                           {},
+			{perennial1, perennial2}:         {},
+			{main, feature1, feature2}:       {feature1, feature2},
+			{perennial1, feature1, feature2}: {feature1, feature2},
+			{main, feature1, observed, contribution, feature2}: {feature1, observed, contribution, feature2},
+			{main, perennial1, perennial2, feature1, feature2}: {feature1, feature2},
+		}
+		for give, want := range tests {
+			have := config.RemovePerennials(*give)
+			must.Eq(t, want, have)
+		}
+	})
 }