diff --git a/.changes/unreleased/Added-20260610-211549.yaml b/.changes/unreleased/Added-20260610-211549.yaml
new file mode 100644
index 000000000..c92604e81
--- /dev/null
+++ b/.changes/unreleased/Added-20260610-211549.yaml
@@ -0,0 +1,3 @@
+kind: Added
+body: 'sync: Add `gs branch sync` (and `gs stack sync` / `gs upstack sync` / `gs downstack sync`) to pull remote-side commits added to a tracked branch since the last push. Default behavior is fast-forward only; use `--rebase` (or `spice.repoSync.pullBranches=rebase`) to integrate remote-side commits even when the branch has been restacked since its last push. A branch that has only moved locally, with no new remote commits, is reported as owing a push rather than as diverged.'
+time: 2026-06-10T21:15:49.107419-04:00
diff --git a/.changes/unreleased/Added-20260610-211604.yaml b/.changes/unreleased/Added-20260610-211604.yaml
new file mode 100644
index 000000000..78630afb5
--- /dev/null
+++ b/.changes/unreleased/Added-20260610-211604.yaml
@@ -0,0 +1,3 @@
+kind: Added
+body: 'submit: Record the pushed commit hash for each tracked branch as `LastPushedHash` so subsequent syncs can distinguish bot-added commits from divergence.'
+time: 2026-06-10T21:16:04.581853-04:00
diff --git a/.changes/unreleased/Changed-20260610-211549.yaml b/.changes/unreleased/Changed-20260610-211549.yaml
new file mode 100644
index 000000000..7e628bdb3
--- /dev/null
+++ b/.changes/unreleased/Changed-20260610-211549.yaml
@@ -0,0 +1,3 @@
+kind: Changed
+body: 'repo sync: After the trunk pull, also pull remote-side commits on tracked stack branches via the new `spice.repoSync.pullBranches` config (default `fastForward`). Closes the common loop where CI bots add a commit and the next `gs submit` is rejected with "stale info".'
+time: 2026-06-10T21:15:49.138568-04:00
diff --git a/.changes/unreleased/Fixed-20260610-211549.yaml b/.changes/unreleased/Fixed-20260610-211549.yaml
new file mode 100644
index 000000000..00a28275d
--- /dev/null
+++ b/.changes/unreleased/Fixed-20260610-211549.yaml
@@ -0,0 +1,3 @@
+kind: Fixed
+body: 'test/shamhub: Pass `--git-dir` to git invocations against bare repos so shamhub-backed tests work on git 2.50+ (which no longer auto-discovers a bare repo from cwd alone).'
+time: 2026-06-10T21:15:49.166884-04:00
diff --git a/branch.go b/branch.go
index 160587832..cf69de503 100644
--- a/branch.go
+++ b/branch.go
@@ -38,6 +38,7 @@ type branchCmd struct {
// Pull request management
Merge branchMergeCmd `cmd:"" aliases:"m" experiment:"merge" help:"Merge a branch into trunk"`
Submit branchSubmitCmd `cmd:"" aliases:"s" help:"Submit a branch"`
+ Sync branchSyncCmd `cmd:"" aliases:"sy" help:"Pull remote-side commits into a tracked branch" released:"unreleased"`
}
// BranchPromptConfig defines configuration for the branch tree prompt
diff --git a/branch_sync.go b/branch_sync.go
new file mode 100644
index 000000000..c8f20169e
--- /dev/null
+++ b/branch_sync.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+ "context"
+ "fmt"
+
+ "go.abhg.dev/gs/internal/git"
+ "go.abhg.dev/gs/internal/handler/branchsync"
+ "go.abhg.dev/gs/internal/silog"
+ "go.abhg.dev/gs/internal/text"
+)
+
+type branchSyncCmd struct {
+ Branch string `placeholder:"NAME" help:"Branch to sync" predictor:"trackedBranches"`
+ Rebase bool `help:"On divergence (both local and remote have new commits since the last push), replay remote-side commits onto local. Conflicts surface as a normal interrupted rebase; resume with 'gs rebase --continue'."`
+}
+
+func (*branchSyncCmd) Help() string {
+ return text.Dedent(`
+ Pull remote-side commits added to a tracked branch since the
+ last push (typically by a CI bot like autofix-ci).
+
+ The branch is fast-forwarded only if local has no commits past
+ the last push and the remote has moved forward. Diverged
+ branches (both sides have new commits) are reported and left
+ unchanged in this release; use 'gs upstack restack' after
+ moving the parent if you need to integrate them manually.
+
+ Children of a fast-forwarded branch will need a restack;
+ 'gs upstack restack' from this branch will pick that up.
+ `)
+}
+
+func (cmd *branchSyncCmd) AfterApply(ctx context.Context, wt *git.Worktree) error {
+ if cmd.Branch == "" {
+ currentBranch, err := wt.CurrentBranch(ctx)
+ if err != nil {
+ return fmt.Errorf("get current branch: %w", err)
+ }
+ cmd.Branch = currentBranch
+ }
+ return nil
+}
+
+func (cmd *branchSyncCmd) Run(
+ ctx context.Context,
+ log *silog.Logger,
+ handler *branchsync.Handler,
+) error {
+ req := branchsync.SyncRequest{Branch: cmd.Branch}
+ if cmd.Rebase {
+ req.Mode = branchsync.ModeRebase
+ }
+ res, err := handler.Sync(ctx, req)
+ if err != nil {
+ return err
+ }
+
+ switch res.Action {
+ case branchsync.ActionClean:
+ log.Infof("%v: already in sync", res.Branch)
+ case branchsync.ActionFastForward:
+ log.Infof("%v: fast-forwarded %s..%s", res.Branch, res.FromHash.Short(), res.ToHash.Short())
+ case branchsync.ActionRebased:
+ log.Infof("%v: rebased onto remote %s", res.Branch, res.ToHash.Short())
+ case branchsync.ActionBehind:
+ log.Infof("%v: ahead of remote; nothing to pull", res.Branch)
+ case branchsync.ActionDiverged:
+ log.Warnf("%v: diverged from remote; pass --rebase to integrate remote-side commits", res.Branch)
+ case branchsync.ActionSkipped:
+ log.Warnf("%v: skipped (%v)", res.Branch, res.SkipReason)
+ }
+
+ return nil
+}
diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md
index 507c9d1c6..d8a733758 100644
--- a/doc/includes/cli-reference.md
+++ b/doc/includes/cli-reference.md
@@ -162,11 +162,19 @@ Run with --restack to also restack them and their upstacks.
Run with --restack=aboves to only restack direct upstacks
of deleted branches, leaving higher branches in place.
+After the trunk sync, every other tracked branch is also
+checked against its remote: if the remote has new commits
+(typically from a CI bot) and local has not moved past the
+last-pushed hash, the local branch is fast-forwarded so the
+next 'gs submit' will not be rejected with "stale info".
+Pass --no-pull-branches to skip this step.
+
**Flags**
* `--restack` ([:material-wrench:{ .middle title="spice.repoSync.restack" }](/cli/config.md#spicereposyncrestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'.
+* `--pull-branches="fastForward"` ([:material-wrench:{ .middle title="spice.repoSync.pullBranches" }](/cli/config.md#spicereposyncpullbranches)): How to integrate remote-side commits on tracked stack branches. 'off' skips the per-branch pull; 'fastForward' (default) advances safe branches and skips diverged ones; 'rebase' replays diverged branches' local commits on top of the remote.
-**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges), [spice.repoSync.restack](/cli/config.md#spicereposyncrestack)
+**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges), [spice.repoSync.pullBranches](/cli/config.md#spicereposyncpullbranches), [spice.repoSync.restack](/cli/config.md#spicereposyncrestack)
### git-spice repo restack {#gs-repo-restack}
@@ -330,6 +338,25 @@ Use --fail-fast to stop the queue after the first branch failure.
**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)
+### git-spice stack sync {#gs-stack-sync}
+
+```
+gs stack (s) sync (sy) [flags]
+```
+
+:material-tag-hidden:{ title="Released in version" }Unreleased
+
+Pull remote-side commits for every branch in the stack
+
+Pull remote-side commits for every branch in the current
+stack (downstack and upstack of the named branch), bottom-up.
+Use --branch to identify a different stack.
+
+**Flags**
+
+* `--branch=NAME`: Branch to identify the stack
+* `--rebase`: On divergence, replay remote-side commits onto local.
+
### git-spice stack restack {#gs-stack-restack}
```
@@ -453,6 +480,27 @@ only if there are multiple CRs in the stack.
**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb)
+### git-spice upstack sync {#gs-upstack-sync}
+
+```
+gs upstack (us) sync (sy) [flags]
+```
+
+:material-tag-hidden:{ title="Released in version" }Unreleased
+
+Pull remote-side commits for a branch and those above it
+
+Pull remote-side commits for the current branch and all
+branches upstack from it. Branches that fast-forward
+(or rebase, with --rebase) trigger an upstack restack of
+their children so the stack stays coherent.
+Use --branch to start at a different branch.
+
+**Flags**
+
+* `--branch=NAME`: Branch to start at
+* `--rebase`: On divergence, replay remote-side commits onto local.
+
### git-spice upstack restack {#gs-upstack-restack}
```
@@ -608,6 +656,25 @@ only if there are multiple CRs in the stack.
**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb)
+### git-spice downstack sync {#gs-downstack-sync}
+
+```
+gs downstack (ds) sync (sy) [flags]
+```
+
+:material-tag-hidden:{ title="Released in version" }Unreleased
+
+Pull remote-side commits for a branch and those below it
+
+Pull remote-side commits for the current branch and all
+branches downstack from it (excluding trunk).
+Use --branch to start at a different branch.
+
+**Flags**
+
+* `--branch=NAME`: Branch to start at
+* `--rebase`: On divergence, replay remote-side commits onto local.
+
### git-spice downstack merge {#gs-downstack-merge}
```
@@ -1205,6 +1272,33 @@ only if there are multiple CRs in the stack.
**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb)
+### git-spice branch sync {#gs-branch-sync}
+
+```
+gs branch (b) sync (sy) [flags]
+```
+
+:material-tag-hidden:{ title="Released in version" }Unreleased
+
+Pull remote-side commits into a tracked branch
+
+Pull remote-side commits added to a tracked branch since the
+last push (typically by a CI bot like autofix-ci).
+
+The branch is fast-forwarded only if local has no commits past
+the last push and the remote has moved forward. Diverged
+branches (both sides have new commits) are reported and left
+unchanged in this release; use 'gs upstack restack' after
+moving the parent if you need to integrate them manually.
+
+Children of a fast-forwarded branch will need a restack;
+'gs upstack restack' from this branch will pick that up.
+
+**Flags**
+
+* `--branch=NAME`: Branch to sync
+* `--rebase`: On divergence (both local and remote have new commits since the last push), replay remote-side commits onto local. Conflicts surface as a normal interrupted rebase; resume with 'gs rebase --continue'.
+
## Commit
### git-spice commit create {#gs-commit-create}
diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md
index 064cad722..c51065d1b 100644
--- a/doc/includes/cli-shorthands.md
+++ b/doc/includes/cli-shorthands.md
@@ -13,6 +13,7 @@
| gs bs | [gs branch submit](/cli/reference.md#gs-branch-submit) |
| gs bsp | [gs branch split](/cli/reference.md#gs-branch-split) |
| gs bsq | [gs branch squash](/cli/reference.md#gs-branch-squash) |
+| gs bsy | [gs branch sync](/cli/reference.md#gs-branch-sync) |
| gs btr | [gs branch track](/cli/reference.md#gs-branch-track) |
| gs buntr | [gs branch untrack](/cli/reference.md#gs-branch-untrack) |
| gs ca | [gs commit amend](/cli/reference.md#gs-commit-amend) |
@@ -24,6 +25,7 @@
| gs dsm | [gs downstack merge](/cli/reference.md#gs-downstack-merge) |
| gs dsr | [gs downstack restack](/cli/reference.md#gs-downstack-restack) |
| gs dss | [gs downstack submit](/cli/reference.md#gs-downstack-submit) |
+| gs dssy | [gs downstack sync](/cli/reference.md#gs-downstack-sync) |
| gs dstr | [gs downstack track](/cli/reference.md#gs-downstack-track) |
| gs ll | [gs log long](/cli/reference.md#gs-log-long) |
| gs ls | [gs log short](/cli/reference.md#gs-log-short) |
@@ -37,7 +39,9 @@
| gs sm | [gs stack merge](/cli/reference.md#gs-stack-merge) |
| gs sr | [gs stack restack](/cli/reference.md#gs-stack-restack) |
| gs ss | [gs stack submit](/cli/reference.md#gs-stack-submit) |
+| gs ssy | [gs stack sync](/cli/reference.md#gs-stack-sync) |
| gs usd | [gs upstack delete](/cli/reference.md#gs-upstack-delete) |
| gs uso | [gs upstack onto](/cli/reference.md#gs-upstack-onto) |
| gs usr | [gs upstack restack](/cli/reference.md#gs-upstack-restack) |
| gs uss | [gs upstack submit](/cli/reference.md#gs-upstack-submit) |
+| gs ussy | [gs upstack sync](/cli/reference.md#gs-upstack-sync) |
diff --git a/doc/src/guide/cr.md b/doc/src/guide/cr.md
index 2e0932fe0..500947076 100644
--- a/doc/src/guide/cr.md
+++ b/doc/src/guide/cr.md
@@ -269,6 +269,46 @@ in one step, use the `--restack` flag:
{green}${reset} gs repo sync --restack
```
+### Pulling remote-side commits on stack branches
+
+
+
+After the trunk pull and merged-branch handling,
+$$gs repo sync$$ also pulls remote-side commits added to each tracked
+stack branch since its last push --
+typically commits added by CI bots such as autofix-ci, license-headers,
+or codeowners.
+
+Branches that fast-forward bring their children along by triggering an
+upstack restack.
+When the remote has genuinely gained commits but the local branch has
+also moved -- including branches restacked onto a newer trunk since
+their last push -- the branch is reported as diverged and skipped by
+default.
+A branch that has only moved locally, with no new remote commits,
+is recognized as simply owing a push and is not reported as diverged.
+To replay the remote-side commits on top of a diverged branch
+(even one that has been restacked), set:
+
+```freeze language="terminal"
+{green}${reset} git config {red}spice.repoSync.pullBranches{reset} {mag}rebase{reset}
+```
+
+To skip the per-branch pull entirely,
+set the value to `off`.
+See the [configuration reference](../cli/config.md#spicereposyncpullbranches).
+
+For finer-grained control,
+the same machinery is also available per branch and per scope:
+
+```freeze language="terminal"
+{green}${reset} gs branch sync {gray}# current branch only{reset}
+{green}${reset} gs upstack sync {gray}# current branch and above{reset}
+{green}${reset} gs downstack sync {gray}# current branch and below{reset}
+{green}${reset} gs stack sync {gray}# the whole stack{reset}
+{green}${reset} gs branch sync --rebase {gray}# integrate a diverged branch{reset}
+```
+
### Handling closed Change Requests
When running $$gs repo sync$$, if a Change Request was closed
diff --git a/downstack.go b/downstack.go
index ed135fcbf..ad6c621ed 100644
--- a/downstack.go
+++ b/downstack.go
@@ -3,6 +3,7 @@ package main
type downstackCmd struct {
Track downstackTrackCmd `cmd:"" aliases:"tr" help:"Track all untracked branches below a branch"`
Submit downstackSubmitCmd `cmd:"" aliases:"s" help:"Submit a branch and those below it"`
+ Sync downstackSyncCmd `cmd:"" aliases:"sy" help:"Pull remote-side commits for a branch and those below it" released:"unreleased"`
Merge downstackMergeCmd `cmd:"" aliases:"m" experiment:"merge" help:"Merge a branch and those below it"`
Edit downstackEditCmd `cmd:"" aliases:"e" help:"Edit the order of branches below a branch"`
Restack downstackRestackCmd `cmd:"" aliases:"r" help:"Restack a branch and its downstack"`
diff --git a/downstack_sync.go b/downstack_sync.go
new file mode 100644
index 000000000..ce6cba386
--- /dev/null
+++ b/downstack_sync.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "slices"
+
+ "go.abhg.dev/gs/internal/git"
+ "go.abhg.dev/gs/internal/handler/branchsync"
+ "go.abhg.dev/gs/internal/silog"
+ "go.abhg.dev/gs/internal/spice"
+ "go.abhg.dev/gs/internal/spice/state"
+ "go.abhg.dev/gs/internal/text"
+)
+
+type downstackSyncCmd struct {
+ Branch string `placeholder:"NAME" help:"Branch to start at" predictor:"trackedBranches"`
+ Rebase bool `help:"On divergence, replay remote-side commits onto local."`
+}
+
+func (*downstackSyncCmd) Help() string {
+ return text.Dedent(`
+ Pull remote-side commits for the current branch and all
+ branches downstack from it (excluding trunk).
+ Use --branch to start at a different branch.
+ `)
+}
+
+func (cmd *downstackSyncCmd) Run(
+ ctx context.Context,
+ log *silog.Logger,
+ wt *git.Worktree,
+ store *state.Store,
+ svc *spice.Service,
+ branchSyncHandler *branchsync.Handler,
+ restackHandler RestackHandler,
+) error {
+ if cmd.Branch == "" {
+ currentBranch, err := wt.CurrentBranch(ctx)
+ if err != nil {
+ return fmt.Errorf("get current branch: %w", err)
+ }
+ cmd.Branch = currentBranch
+ }
+
+ graph, err := svc.BranchGraph(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("build branch graph: %w", err)
+ }
+
+ branches := slices.Collect(graph.Downstack(cmd.Branch))
+ // Downstack is [branch, child1, child2, ..., trunk-adjacent]; we want
+ // to sync bottom-up so children pick up updated bases.
+ slices.Reverse(branches)
+ // Drop trunk if present.
+ trunk := store.Trunk()
+ branches = slices.DeleteFunc(branches, func(b string) bool { return b == trunk })
+
+ return syncBranches(ctx, log, branchSyncHandler, restackHandler, branches, cmd.Rebase)
+}
diff --git a/internal/handler/branchsync/handler.go b/internal/handler/branchsync/handler.go
new file mode 100644
index 000000000..bf60ffed9
--- /dev/null
+++ b/internal/handler/branchsync/handler.go
@@ -0,0 +1,332 @@
+// Package branchsync implements the per-branch sync operation used by
+// 'gs branch sync' and friends. It pulls remote-side commits added to a
+// tracked branch since the last push -- typically by a CI bot like
+// autofix-ci, license-headers, or codeowners.
+//
+// The v1 surface is fast-forward only: a branch is updated if its remote
+// is strictly ahead of the last-pushed hash AND the local branch hasn't
+// moved past that hash. Diverged branches are reported and skipped; the
+// rebase strategy is reserved for a follow-up commit.
+package branchsync
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "go.abhg.dev/gs/internal/git"
+ "go.abhg.dev/gs/internal/silog"
+ "go.abhg.dev/gs/internal/spice/state"
+)
+
+// GitRepository is the subset of git.Repository this handler uses.
+type GitRepository interface {
+ PeelToCommit(ctx context.Context, ref string) (git.Hash, error)
+ IsAncestor(ctx context.Context, a, b git.Hash) bool
+ Fetch(ctx context.Context, opts git.FetchOptions) error
+ SetRef(ctx context.Context, req git.SetRefRequest) error
+}
+
+var _ GitRepository = (*git.Repository)(nil)
+
+// GitWorktree is the subset of git.Worktree this handler uses.
+type GitWorktree interface {
+ CurrentBranch(ctx context.Context) (string, error)
+ CheckoutBranch(ctx context.Context, branch string) error
+ Pull(ctx context.Context, opts git.PullOptions) error
+ Reset(ctx context.Context, commit string, opts git.ResetOptions) error
+ Rebase(ctx context.Context, req git.RebaseRequest) error
+}
+
+var _ GitWorktree = (*git.Worktree)(nil)
+
+// Store is the subset of state.Store this handler uses.
+type Store interface {
+ LookupBranch(ctx context.Context, name string) (*state.LookupResponse, error)
+ BeginBranchTx() *state.BranchTx
+ Trunk() string
+}
+
+// Handler implements per-branch sync.
+type Handler struct {
+ Log *silog.Logger // required
+ Repository GitRepository // required
+ Worktree GitWorktree // required
+ Store Store // required
+
+ // Remote is the name of the remote to fetch from (e.g., "origin").
+ Remote string // required
+}
+
+// Action reports what 'gs branch sync' did to a branch.
+type Action int
+
+const (
+ // ActionClean means the branch was already in sync.
+ ActionClean Action = iota
+
+ // ActionFastForward means the branch was fast-forwarded to its remote.
+ ActionFastForward
+
+ // ActionRebased means local commits were replayed on top of the
+ // remote-side commits brought in from the upstream.
+ ActionRebased
+
+ // ActionBehind means the remote has no commits to integrate: it sits
+ // at (or behind) our last push. The local branch is ahead of, or has
+ // only moved away from, a stale remote and simply needs to be pushed.
+ ActionBehind
+
+ // ActionDiverged means both local and remote have new commits since
+ // the last push. Skipped under fast-forward policy.
+ ActionDiverged
+
+ // ActionSkipped means the branch was skipped for a reason that
+ // merits a warning (no upstream, no LastPushedHash recorded, etc.).
+ ActionSkipped
+)
+
+// SyncResult reports the outcome of syncing a single branch.
+type SyncResult struct {
+ Branch string
+ Action Action
+
+ // FromHash is the local tip before the sync.
+ FromHash git.Hash
+
+ // ToHash is the local tip after the sync. Equal to FromHash when no
+ // update was applied.
+ ToHash git.Hash
+
+ // SkipReason is set when Action is ActionSkipped.
+ SkipReason string
+}
+
+// ErrNoUpstream is returned when the branch has no upstream configured
+// and therefore cannot be synced.
+var ErrNoUpstream = errors.New("branch has no upstream configured")
+
+// Mode controls how diverged branches are handled.
+type Mode int
+
+const (
+ // ModeFastForward is the default: fast-forward when safe; skip when
+ // the branch has diverged from its remote.
+ ModeFastForward Mode = iota
+
+ // ModeRebase replays remote-side commits on top of the local branch
+ // when both sides have new commits since the last push. Conflicts
+ // surface as an interrupted rebase, recoverable with
+ // 'gs rebase --continue'.
+ ModeRebase
+)
+
+// SyncRequest configures a single-branch sync.
+type SyncRequest struct {
+ Branch string
+ Mode Mode
+}
+
+// Sync syncs a single tracked branch according to the request. Returns
+// the result of the sync. The caller is responsible for restacking any
+// children of the branch when ToHash != FromHash.
+func (h *Handler) Sync(ctx context.Context, req SyncRequest) (*SyncResult, error) {
+ branch := req.Branch
+ log := h.Log.With("branch", branch)
+
+ if branch == h.Store.Trunk() {
+ return &SyncResult{Branch: branch, Action: ActionSkipped, SkipReason: "trunk is synced by 'gs repo sync'"}, nil
+ }
+
+ lookup, err := h.Store.LookupBranch(ctx, branch)
+ if err != nil {
+ return nil, fmt.Errorf("lookup branch: %w", err)
+ }
+
+ if lookup.UpstreamBranch == "" {
+ return nil, ErrNoUpstream
+ }
+
+ // Fetch the remote ref so origin/ is up to date.
+ refspec := git.Refspec(h.Remote + "/" + lookup.UpstreamBranch)
+ if err := h.Repository.Fetch(ctx, git.FetchOptions{
+ Remote: h.Remote,
+ Refspecs: []git.Refspec{git.Refspec(lookup.UpstreamBranch)},
+ }); err != nil {
+ return nil, fmt.Errorf("fetch %v: %w", refspec, err)
+ }
+
+ localHash, err := h.Repository.PeelToCommit(ctx, branch)
+ if err != nil {
+ return nil, fmt.Errorf("resolve local %v: %w", branch, err)
+ }
+
+ remoteRef := h.Remote + "/" + lookup.UpstreamBranch
+ remoteHash, err := h.Repository.PeelToCommit(ctx, remoteRef)
+ if err != nil {
+ return nil, fmt.Errorf("resolve %v: %w", remoteRef, err)
+ }
+
+ res := &SyncResult{Branch: branch, FromHash: localHash, ToHash: localHash}
+
+ if localHash == remoteHash {
+ res.Action = ActionClean
+ return res, nil
+ }
+
+ // Local is an ancestor of remote -> can fast-forward.
+ if h.Repository.IsAncestor(ctx, localHash, remoteHash) {
+ if err := h.fastForward(ctx, branch, lookup.UpstreamBranch, localHash, remoteHash); err != nil {
+ return nil, err
+ }
+ if err := h.recordPushedHash(ctx, branch, lookup.UpstreamBranch, remoteHash); err != nil {
+ log.Warn("Could not record pushed hash", "error", err)
+ }
+ res.Action = ActionFastForward
+ res.ToHash = remoteHash
+ return res, nil
+ }
+
+ // Remote is an ancestor of local -> nothing to pull; we owe a push.
+ if h.Repository.IsAncestor(ctx, remoteHash, localHash) {
+ res.Action = ActionBehind
+ return res, nil
+ }
+
+ // Local and remote have diverged in raw Git terms: neither tip is an
+ // ancestor of the other. Use the last-pushed hash as a baseline to
+ // tell apart two very different situations that look identical here:
+ //
+ // - The remote is stale and only our local branch moved (e.g. it
+ // was restacked onto a newer trunk). There is nothing to pull;
+ // the branch owes a push.
+ // - The remote genuinely gained commits since our last push (e.g. a
+ // CI bot). Those are the commits we want to integrate.
+ //
+ // The remote-side commits are the range pHash..remote. If that range
+ // is empty, the remote has gained nothing and the divergence is purely
+ // local.
+ pHash := lookup.UpstreamLastPushedHash
+ remoteAhead := pHash != "" && pHash != remoteHash &&
+ h.Repository.IsAncestor(ctx, pHash, remoteHash)
+
+ if !remoteAhead {
+ switch {
+ case pHash != "" && pHash == remoteHash:
+ // Remote sits exactly at our last push: it gained nothing.
+ // The local branch moved on its own and just owes a push.
+ res.Action = ActionBehind
+ case pHash == "":
+ // No baseline recorded, so we can't identify what to pull.
+ res.Action = ActionSkipped
+ res.SkipReason = "no last-pushed hash recorded; push the branch once to establish a baseline"
+ default:
+ // Remote was rewritten away from our baseline.
+ res.Action = ActionDiverged
+ }
+ return res, nil
+ }
+
+ // pHash..remote is a non-empty, well-formed range: the remote has real
+ // commits to integrate. Bringing them in means replaying them onto the
+ // local tip, which is a rebase, not a fast-forward.
+ if req.Mode != ModeRebase {
+ res.Action = ActionDiverged
+ return res, nil
+ }
+
+ // Replay pHash..remote onto the local tip via 'git rebase --onto'.
+ //
+ // We deliberately do NOT require pHash to be an ancestor of local: a
+ // restacked branch drops pHash from its history, yet rebase --onto
+ // only needs pHash..remote to be a valid range, which remoteAhead
+ // already guarantees. This is what lets remote-side commits survive
+ // even after the branch has been restacked locally.
+ if err := h.rebase(ctx, branch, lookup.UpstreamBranch, localHash, remoteHash, pHash); err != nil {
+ return nil, err
+ }
+ if err := h.recordPushedHash(ctx, branch, lookup.UpstreamBranch, remoteHash); err != nil {
+ log.Warn("Could not record pushed hash", "error", err)
+ }
+ // Recompute the post-rebase tip.
+ newHash, err := h.Repository.PeelToCommit(ctx, branch)
+ if err != nil {
+ return nil, fmt.Errorf("resolve post-rebase %v: %w", branch, err)
+ }
+ res.Action = ActionRebased
+ res.ToHash = newHash
+ return res, nil
+}
+
+// rebase replays the remote-only commits (p..R) onto the local tip (L)
+// by checking out the branch, resetting it to R, then running
+// git rebase --onto L p. Conflicts surface as a normal interrupted
+// rebase; the caller can resume with 'gs rebase --continue'.
+func (h *Handler) rebase(ctx context.Context, branch, upstream string, localHash, remoteHash, lastPushed git.Hash) error {
+ _ = upstream // currently unused; reserved for future logging
+
+ currentBranch, err := h.Worktree.CurrentBranch(ctx)
+ if err != nil {
+ return fmt.Errorf("current branch: %w", err)
+ }
+ if currentBranch != branch {
+ if err := h.Worktree.CheckoutBranch(ctx, branch); err != nil {
+ return fmt.Errorf("checkout %v: %w", branch, err)
+ }
+ }
+
+ // Move HEAD to R so the rebase sees p..R as the commit range.
+ if err := h.Worktree.Reset(ctx, remoteHash.String(), git.ResetOptions{Mode: git.ResetHard}); err != nil {
+ return fmt.Errorf("reset %v to remote: %w", branch, err)
+ }
+
+ // Replay p..R (which is now HEAD since HEAD = R) onto L.
+ if err := h.Worktree.Rebase(ctx, git.RebaseRequest{
+ Branch: branch,
+ Upstream: lastPushed.String(),
+ Onto: localHash.String(),
+ Autostash: true,
+ }); err != nil {
+ return fmt.Errorf("rebase remote commits onto local: %w", err)
+ }
+
+ return nil
+}
+
+// fastForward updates a branch ref to a new (descendant) hash. For a
+// non-current branch this is a plain ref update; for the currently
+// checked-out branch we use 'git pull --rebase --autostash' to bring
+// HEAD and the working tree along (a no-commits-to-replay rebase
+// degenerates into a fast-forward).
+func (h *Handler) fastForward(ctx context.Context, branch, upstream string, oldHash, newHash git.Hash) error {
+ currentBranch, err := h.Worktree.CurrentBranch(ctx)
+ if err != nil {
+ return fmt.Errorf("current branch: %w", err)
+ }
+ if currentBranch == branch {
+ return h.Worktree.Pull(ctx, git.PullOptions{
+ Remote: h.Remote,
+ Rebase: true,
+ Autostash: true,
+ Refspec: git.Refspec(upstream),
+ })
+ }
+
+ return h.Repository.SetRef(ctx, git.SetRefRequest{
+ Ref: "refs/heads/" + branch,
+ Hash: newHash,
+ OldHash: oldHash,
+ })
+}
+
+func (h *Handler) recordPushedHash(ctx context.Context, branch, upstream string, hash git.Hash) error {
+ tx := h.Store.BeginBranchTx()
+ if err := tx.Upsert(ctx, state.UpsertRequest{
+ Name: branch,
+ UpstreamBranch: &upstream,
+ UpstreamLastPushedHash: &hash,
+ }); err != nil {
+ return fmt.Errorf("upsert: %w", err)
+ }
+ return tx.Commit(ctx, "branch sync "+branch+": record fast-forward")
+}
diff --git a/internal/handler/submit/handler.go b/internal/handler/submit/handler.go
index f1587929e..8b4568dcb 100644
--- a/internal/handler/submit/handler.go
+++ b/internal/handler/submit/handler.go
@@ -1005,6 +1005,15 @@ func (h *Handler) submitBranch(
Name: branchToSubmit,
UpstreamBranch: &upstreamBranch,
}
+ // Capture the hash we just pushed so 'gs branch sync' can
+ // detect remote-side commits added later (e.g. by CI bots).
+ if pushedHash, err := h.Repository.PeelToCommit(ctx, branchToSubmit); err == nil {
+ upsert.UpstreamLastPushedHash = &pushedHash
+ } else {
+ log.Warn("Could not record pushed hash; sync will not detect remote-side commits until next push",
+ "branch", branchToSubmit,
+ "error", err)
+ }
defer func() {
msg := "branch submit " + branchToSubmit
tx := h.Store.BeginBranchTx()
@@ -1182,6 +1191,21 @@ func (h *Handler) submitBranch(
log.Error("Push failed. Branch may have been updated by someone else. Try with --force.")
return status, fmt.Errorf("push branch: %w", err)
}
+
+ // Record the pushed hash so 'gs branch sync' can detect
+ // remote-side commits added later.
+ tx := h.Store.BeginBranchTx()
+ if err := errors.Join(
+ tx.Upsert(ctx, state.UpsertRequest{
+ Name: branchToSubmit,
+ UpstreamLastPushedHash: &commitHash,
+ }),
+ tx.Commit(ctx, "branch submit "+branchToSubmit+": record pushed hash"),
+ ); err != nil {
+ log.Warn("Could not record pushed hash; sync will not detect remote-side commits until next push",
+ "branch", branchToSubmit,
+ "error", err)
+ }
}
if len(updates) > 0 {
diff --git a/internal/spice/state/branch.go b/internal/spice/state/branch.go
index 44e02e1f2..0c2b7ab3f 100644
--- a/internal/spice/state/branch.go
+++ b/internal/spice/state/branch.go
@@ -32,6 +32,12 @@ type branchStateBase struct {
type branchUpstreamState struct {
Branch string `json:"branch,omitempty"`
+
+ // LastPushedHash is the commit hash that was last successfully pushed
+ // to the upstream branch. Used by `gs branch sync` to determine
+ // whether the remote has new commits since the last push.
+ // Empty if the branch has never been pushed.
+ LastPushedHash string `json:"lastPushedHash,omitempty"`
}
type branchChangeState struct {
@@ -99,6 +105,11 @@ type LookupResponse struct {
// or an empty string if the branch is not tracking an upstream branch.
UpstreamBranch string
+ // UpstreamLastPushedHash is the commit hash that was last successfully
+ // pushed to the upstream branch. Empty if the branch has never been
+ // pushed or has no upstream configured.
+ UpstreamLastPushedHash git.Hash
+
// MergedDownstack holds information about branches
// that were previously downstack from this branch
// that have since been merged into trunk.
@@ -130,6 +141,7 @@ func (s *Store) LookupBranch(ctx context.Context, name string) (*LookupResponse,
if upstream := state.Upstream; upstream != nil {
res.UpstreamBranch = upstream.Branch
+ res.UpstreamLastPushedHash = git.Hash(upstream.LastPushedHash)
}
return res, nil
@@ -229,6 +241,12 @@ type UpsertRequest struct {
// Leave nil to leave it unchanged, or set to an empty string to clear it.
UpstreamBranch *string
+ // UpstreamLastPushedHash is the commit hash to record as the last
+ // successful push to the upstream branch. Leave nil to leave it
+ // unchanged, or set to the empty hash to clear it. Ignored if the
+ // branch has no upstream after this update.
+ UpstreamLastPushedHash *git.Hash
+
// MergedDownstack is a list of branches that were previously
// downstack from this branch that have since been merged into trunk.
MergedDownstack *[]json.RawMessage
@@ -304,12 +322,22 @@ func (tx *BranchTx) Upsert(ctx context.Context, req UpsertRequest) error {
if *req.UpstreamBranch == "" {
state.Upstream = nil
} else {
+ // Preserve the existing LastPushedHash if the upstream name
+ // is unchanged; reset to empty if the upstream is new.
+ prev := state.Upstream
state.Upstream = &branchUpstreamState{
Branch: *req.UpstreamBranch,
}
+ if prev != nil && prev.Branch == *req.UpstreamBranch {
+ state.Upstream.LastPushedHash = prev.LastPushedHash
+ }
}
}
+ if req.UpstreamLastPushedHash != nil && state.Upstream != nil {
+ state.Upstream.LastPushedHash = req.UpstreamLastPushedHash.String()
+ }
+
if req.MergedDownstack != nil {
state.MergedDownstack = *req.MergedDownstack
}
diff --git a/internal/spice/state/branch_int_test.go b/internal/spice/state/branch_int_test.go
index a821d2406..9834cf6e5 100644
--- a/internal/spice/state/branch_int_test.go
+++ b/internal/spice/state/branch_int_test.go
@@ -97,6 +97,41 @@ func TestBranchStateUnmarshal(t *testing.T) {
},
},
},
+
+ {
+ name: "UpstreamWithLastPushedHash",
+ give: `{
+ "base": {"name": "main", "hash": "abc123"},
+ "upstream": {"branch": "feat1", "lastPushedHash": "deadbeefcafe"}
+ }`,
+ want: &branchState{
+ Base: branchStateBase{
+ Name: "main",
+ Hash: "abc123",
+ },
+ Upstream: &branchUpstreamState{
+ Branch: "feat1",
+ LastPushedHash: "deadbeefcafe",
+ },
+ },
+ },
+
+ {
+ name: "UpstreamWithoutLastPushedHash_RoundTrips",
+ give: `{
+ "base": {"name": "main", "hash": "abc123"},
+ "upstream": {"branch": "feat1"}
+ }`,
+ want: &branchState{
+ Base: branchStateBase{
+ Name: "main",
+ Hash: "abc123",
+ },
+ Upstream: &branchUpstreamState{
+ Branch: "feat1",
+ },
+ },
+ },
}
for _, tt := range tests {
@@ -115,3 +150,15 @@ func TestBranchStateUnmarshal(t *testing.T) {
})
}
}
+
+func TestBranchUpstreamState_LastPushedHashOmittedWhenEmpty(t *testing.T) {
+ state := &branchState{
+ Base: branchStateBase{Name: "main", Hash: "abc123"},
+ Upstream: &branchUpstreamState{Branch: "feat1"},
+ }
+
+ out, err := json.Marshal(state)
+ require.NoError(t, err)
+ assert.NotContains(t, string(out), "lastPushedHash",
+ "empty LastPushedHash must be omitted to keep existing state files compact")
+}
diff --git a/internal/spice/state/branch_test.go b/internal/spice/state/branch_test.go
index a279bc5a0..e037ca6c2 100644
--- a/internal/spice/state/branch_test.go
+++ b/internal/spice/state/branch_test.go
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/sliceutil"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/spice/state/statetest"
@@ -287,6 +288,72 @@ func TestBranchTxUpsert_canClearUpstream(t *testing.T) {
assert.Equal(t, "", foo.UpstreamBranch)
}
+func TestBranchTxUpsert_lastPushedHash(t *testing.T) {
+ ctx := t.Context()
+ db := storage.NewDB(make(storage.MapBackend))
+ store, err := state.InitStore(ctx, state.InitStoreRequest{
+ DB: db,
+ Trunk: "main",
+ })
+ require.NoError(t, err)
+
+ upstream := "remote-foo"
+ hashA := git.Hash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+ hashB := git.Hash("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
+
+ // Initial upsert with upstream and pushed hash.
+ require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{
+ Upserts: []state.UpsertRequest{
+ {
+ Name: "foo",
+ Base: "main",
+ UpstreamBranch: &upstream,
+ UpstreamLastPushedHash: &hashA,
+ },
+ },
+ Message: "add foo",
+ }))
+
+ foo, err := store.LookupBranch(ctx, "foo")
+ require.NoError(t, err)
+ assert.Equal(t, upstream, foo.UpstreamBranch)
+ assert.Equal(t, hashA, foo.UpstreamLastPushedHash)
+
+ // Update only the pushed hash; upstream unchanged.
+ require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{
+ Upserts: []state.UpsertRequest{
+ {
+ Name: "foo",
+ Base: "main",
+ UpstreamLastPushedHash: &hashB,
+ },
+ },
+ Message: "advance pushed hash",
+ }))
+
+ foo, err = store.LookupBranch(ctx, "foo")
+ require.NoError(t, err)
+ assert.Equal(t, upstream, foo.UpstreamBranch)
+ assert.Equal(t, hashB, foo.UpstreamLastPushedHash)
+
+ // Clearing the upstream also clears the pushed hash.
+ var empty string
+ require.NoError(t, statetest.UpdateBranch(ctx, store, &statetest.UpdateRequest{
+ Upserts: []state.UpsertRequest{
+ {
+ Name: "foo",
+ UpstreamBranch: &empty,
+ },
+ },
+ Message: "drop upstream",
+ }))
+
+ foo, err = store.LookupBranch(ctx, "foo")
+ require.NoError(t, err)
+ assert.Empty(t, foo.UpstreamBranch)
+ assert.Empty(t, foo.UpstreamLastPushedHash)
+}
+
// Uses rapid to run randomized scenarios on the branch state
// to ensure we never leave it in a corrupted state.
func TestBranchStateUncorruptible(t *testing.T) {
diff --git a/main.go b/main.go
index d020ea4cd..2d9860abe 100644
--- a/main.go
+++ b/main.go
@@ -25,6 +25,7 @@ import (
"go.abhg.dev/gs/internal/forge/gitlab"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/autostash"
+ "go.abhg.dev/gs/internal/handler/branchsync"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/handler/cherrypick"
"go.abhg.dev/gs/internal/handler/delete"
@@ -505,6 +506,21 @@ func (cmd *mainCmd) AfterApply(
Service: svc,
}, nil
}),
+ kctx.BindSingletonProvider(func(
+ log *silog.Logger,
+ repo *git.Repository,
+ worktree *git.Worktree,
+ store *state.Store,
+ remote state.Remote,
+ ) (*branchsync.Handler, error) {
+ return &branchsync.Handler{
+ Log: log,
+ Repository: repo,
+ Worktree: worktree,
+ Store: store,
+ Remote: remote.Upstream,
+ }, nil
+ }),
kctx.BindSingletonProvider(func(
log *silog.Logger,
wt *git.Worktree,
diff --git a/repo_sync.go b/repo_sync.go
index 3cac2100e..1fbe81b75 100644
--- a/repo_sync.go
+++ b/repo_sync.go
@@ -2,13 +2,29 @@ package main
import (
"context"
+ "errors"
+ "fmt"
+ "slices"
+ "go.abhg.dev/gs/internal/handler/branchsync"
"go.abhg.dev/gs/internal/handler/sync"
+ "go.abhg.dev/gs/internal/silog"
+ "go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
)
+type pullBranchesMode string
+
+const (
+ pullBranchesOff pullBranchesMode = "off"
+ pullBranchesFastForward pullBranchesMode = "fastForward"
+ pullBranchesRebase pullBranchesMode = "rebase"
+)
+
type repoSyncCmd struct {
sync.TrunkOptions
+
+ PullBranches pullBranchesMode `default:"fastForward" config:"repoSync.pullBranches" enum:"off,fastForward,rebase" help:"How to integrate remote-side commits on tracked stack branches. 'off' skips the per-branch pull; 'fastForward' (default) advances safe branches and skips diverged ones; 'rebase' replays diverged branches' local commits on top of the remote."`
}
func (*repoSyncCmd) Help() string {
@@ -25,6 +41,13 @@ func (*repoSyncCmd) Help() string {
Run with --restack to also restack them and their upstacks.
Run with --restack=aboves to only restack direct upstacks
of deleted branches, leaving higher branches in place.
+
+ After the trunk sync, every other tracked branch is also
+ checked against its remote: if the remote has new commits
+ (typically from a CI bot) and local has not moved past the
+ last-pushed hash, the local branch is fast-forwarded so the
+ next 'gs submit' will not be rejected with "stale info".
+ Pass --no-pull-branches to skip this step.
`)
}
@@ -33,6 +56,60 @@ type SyncHandler interface {
SyncTrunk(ctx context.Context, opts *sync.TrunkOptions) error
}
-func (cmd *repoSyncCmd) Run(ctx context.Context, syncHandler SyncHandler) error {
- return syncHandler.SyncTrunk(ctx, &cmd.TrunkOptions)
+func (cmd *repoSyncCmd) Run(
+ ctx context.Context,
+ log *silog.Logger,
+ store *state.Store,
+ syncHandler SyncHandler,
+ branchSyncHandler *branchsync.Handler,
+ restackHandler RestackHandler,
+) error {
+ if err := syncHandler.SyncTrunk(ctx, &cmd.TrunkOptions); err != nil {
+ return err
+ }
+
+ if cmd.PullBranches == pullBranchesOff {
+ return nil
+ }
+ rebase := cmd.PullBranches == pullBranchesRebase
+
+ var branches []string
+ for branch, err := range store.ListBranches(ctx) {
+ if err != nil {
+ return fmt.Errorf("list tracked branches: %w", err)
+ }
+ branches = append(branches, branch)
+ }
+ slices.Sort(branches)
+
+ mode := branchsync.ModeFastForward
+ if rebase {
+ mode = branchsync.ModeRebase
+ }
+
+ for _, branch := range branches {
+ res, err := branchSyncHandler.Sync(ctx, branchsync.SyncRequest{Branch: branch, Mode: mode})
+ if err != nil {
+ if errors.Is(err, branchsync.ErrNoUpstream) {
+ continue
+ }
+ log.Warnf("%v: sync failed: %v", branch, err)
+ continue
+ }
+ switch res.Action {
+ case branchsync.ActionFastForward:
+ log.Infof("%v: fast-forwarded %s..%s", res.Branch, res.FromHash.Short(), res.ToHash.Short())
+ // The branch moved; children built on the old hash need
+ // to be rebased onto the new tip.
+ if err := restackHandler.RestackUpstack(ctx, res.Branch, nil); err != nil {
+ log.Warnf("%v: upstack restack after sync failed: %v", res.Branch, err)
+ }
+ case branchsync.ActionDiverged:
+ log.Warnf("%v: diverged from remote; run 'gs branch sync --rebase' to integrate", res.Branch)
+ case branchsync.ActionSkipped:
+ log.Warnf("%v: skipped (%v)", res.Branch, res.SkipReason)
+ }
+ }
+
+ return nil
}
diff --git a/stack.go b/stack.go
index 822c5f603..27b2f696a 100644
--- a/stack.go
+++ b/stack.go
@@ -3,6 +3,7 @@ package main
type stackCmd struct {
Submit stackSubmitCmd `cmd:"" aliases:"s" help:"Submit a stack"`
Merge stackMergeCmd `cmd:"" aliases:"m" experiment:"merge" help:"Merge a stack"`
+ Sync stackSyncCmd `cmd:"" aliases:"sy" help:"Pull remote-side commits for every branch in the stack" released:"unreleased"`
Restack stackRestackCmd `cmd:"" aliases:"r" help:"Restack a stack"`
Edit stackEditCmd `cmd:"" aliases:"e" help:"Edit the order of branches in a stack"`
Delete stackDeleteCmd `cmd:"" aliases:"d" released:"v0.16.0" help:"Delete all branches in a stack"`
diff --git a/stack_sync.go b/stack_sync.go
new file mode 100644
index 000000000..7f27373a4
--- /dev/null
+++ b/stack_sync.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "slices"
+
+ "go.abhg.dev/gs/internal/git"
+ "go.abhg.dev/gs/internal/handler/branchsync"
+ "go.abhg.dev/gs/internal/silog"
+ "go.abhg.dev/gs/internal/spice"
+ "go.abhg.dev/gs/internal/spice/state"
+ "go.abhg.dev/gs/internal/text"
+)
+
+type stackSyncCmd struct {
+ Branch string `placeholder:"NAME" help:"Branch to identify the stack" predictor:"trackedBranches"`
+ Rebase bool `help:"On divergence, replay remote-side commits onto local."`
+}
+
+func (*stackSyncCmd) Help() string {
+ return text.Dedent(`
+ Pull remote-side commits for every branch in the current
+ stack (downstack and upstack of the named branch), bottom-up.
+ Use --branch to identify a different stack.
+ `)
+}
+
+func (cmd *stackSyncCmd) Run(
+ ctx context.Context,
+ log *silog.Logger,
+ wt *git.Worktree,
+ store *state.Store,
+ svc *spice.Service,
+ branchSyncHandler *branchsync.Handler,
+ restackHandler RestackHandler,
+) error {
+ if cmd.Branch == "" {
+ currentBranch, err := wt.CurrentBranch(ctx)
+ if err != nil {
+ return fmt.Errorf("get current branch: %w", err)
+ }
+ cmd.Branch = currentBranch
+ }
+
+ graph, err := svc.BranchGraph(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("build branch graph: %w", err)
+ }
+
+ // Downstack bottom-up, then upstack top-down (excluding the
+ // duplicated start branch).
+ downs := slices.Collect(graph.Downstack(cmd.Branch))
+ slices.Reverse(downs)
+ ups := slices.Collect(graph.Upstack(cmd.Branch))
+ if len(ups) > 0 && ups[0] == cmd.Branch {
+ ups = ups[1:]
+ }
+
+ trunk := store.Trunk()
+ branches := make([]string, 0, len(downs)+len(ups))
+ branches = append(branches, downs...)
+ branches = append(branches, ups...)
+ branches = slices.DeleteFunc(branches, func(b string) bool { return b == trunk })
+
+ return syncBranches(ctx, log, branchSyncHandler, restackHandler, branches, cmd.Rebase)
+}
diff --git a/testdata/help/branch_sync.txt b/testdata/help/branch_sync.txt
new file mode 100644
index 000000000..1928a38ab
--- /dev/null
+++ b/testdata/help/branch_sync.txt
@@ -0,0 +1,28 @@
+Usage: gs branch (b) sync (sy) [flags]
+
+Pull remote-side commits into a tracked branch
+
+Pull remote-side commits added to a tracked branch since the last push
+(typically by a CI bot like autofix-ci).
+
+The branch is fast-forwarded only if local has no commits past the last push and
+the remote has moved forward. Diverged branches (both sides have new commits)
+are reported and left unchanged in this release; use 'gs upstack restack' after
+moving the parent if you need to integrate them manually.
+
+Children of a fast-forwarded branch will need a restack; 'gs upstack restack'
+from this branch will pick that up.
+
+Flags:
+ --branch=NAME Branch to sync
+ --rebase On divergence (both local and remote have new commits since
+ the last push), replay remote-side commits onto local.
+ Conflicts surface as a normal interrupted rebase; resume with
+ 'gs rebase --continue'.
+
+Global Flags:
+ -h, --help Show help for the command
+ --version Print version information and quit
+ -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE)
+ -C, --dir=DIR Change to DIR before doing anything
+ --[no-]prompt Whether to prompt for missing information
diff --git a/testdata/help/downstack_sync.txt b/testdata/help/downstack_sync.txt
new file mode 100644
index 000000000..4dc365c36
--- /dev/null
+++ b/testdata/help/downstack_sync.txt
@@ -0,0 +1,17 @@
+Usage: gs downstack (ds) sync (sy) [flags]
+
+Pull remote-side commits for a branch and those below it
+
+Pull remote-side commits for the current branch and all branches downstack from
+it (excluding trunk). Use --branch to start at a different branch.
+
+Flags:
+ --branch=NAME Branch to start at
+ --rebase On divergence, replay remote-side commits onto local.
+
+Global Flags:
+ -h, --help Show help for the command
+ --version Print version information and quit
+ -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE)
+ -C, --dir=DIR Change to DIR before doing anything
+ --[no-]prompt Whether to prompt for missing information
diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt
index 43406fde2..38d6e7504 100644
--- a/testdata/help/gs.txt
+++ b/testdata/help/gs.txt
@@ -32,15 +32,21 @@ Log
Stack
stack (s) submit (s) Submit a stack
stack (s) merge (m) Merge a stack
+ stack (s) sync (sy) Pull remote-side commits for every branch in the
+ stack
stack (s) restack (r) Restack a stack
stack (s) edit (e) Edit the order of branches in a stack
stack (s) delete (d) Delete all branches in a stack
upstack (us) submit (s) Submit a branch and those above it
+ upstack (us) sync (sy) Pull remote-side commits for a branch and those
+ above it
upstack (us) restack (r) Restack a branch and its upstack
upstack (us) onto (o) Move a branch onto another branch
upstack (us) delete (d) Delete all branches above the current branch
downstack (ds) track (tr) Track all untracked branches below a branch
downstack (ds) submit (s) Submit a branch and those below it
+ downstack (ds) sync (sy) Pull remote-side commits for a branch and those
+ below it
downstack (ds) merge (m) Merge a branch and those below it
downstack (ds) edit (e) Edit the order of branches below a branch
downstack (ds) restack (r) Restack a branch and its downstack
@@ -61,6 +67,7 @@ Branch
branch (b) diff (di) Show diff between a branch and its base
branch (b) merge (m) Merge a branch into trunk
branch (b) submit (s) Submit a branch
+ branch (b) sync (sy) Pull remote-side commits into a tracked branch
Commit
commit (c) create (c) Create a new commit
diff --git a/testdata/help/repo_sync.txt b/testdata/help/repo_sync.txt
index 4bc5a556b..a0a6afbb8 100644
--- a/testdata/help/repo_sync.txt
+++ b/testdata/help/repo_sync.txt
@@ -12,9 +12,23 @@ Run with --restack to also restack them and their upstacks. Run with
--restack=aboves to only restack direct upstacks of deleted branches, leaving
higher branches in place.
+After the trunk sync, every other tracked branch is also checked against its
+remote: if the remote has new commits (typically from a CI bot) and local has
+not moved past the last-pushed hash, the local branch is fast-forwarded so the
+next 'gs submit' will not be rejected with "stale info". Pass --no-pull-branches
+to skip this step.
+
Flags:
- --restack How to restack branches above deleted branches. One of 'none',
- 'aboves', and 'upstack'. (🔧 spice.repoSync.restack)
+ --restack How to restack branches above deleted
+ branches. One of 'none', 'aboves', and
+ 'upstack'. (🔧 spice.repoSync.restack)
+ --pull-branches="fastForward" How to integrate remote-side commits on
+ tracked stack branches. 'off' skips the
+ per-branch pull; 'fastForward' (default)
+ advances safe branches and skips diverged
+ ones; 'rebase' replays diverged branches'
+ local commits on top of the remote.
+ (🔧 spice.repoSync.pullBranches)
Global Flags:
-h, --help Show help for the command
diff --git a/testdata/help/stack_sync.txt b/testdata/help/stack_sync.txt
new file mode 100644
index 000000000..2deb24797
--- /dev/null
+++ b/testdata/help/stack_sync.txt
@@ -0,0 +1,18 @@
+Usage: gs stack (s) sync (sy) [flags]
+
+Pull remote-side commits for every branch in the stack
+
+Pull remote-side commits for every branch in the current stack (downstack and
+upstack of the named branch), bottom-up. Use --branch to identify a different
+stack.
+
+Flags:
+ --branch=NAME Branch to identify the stack
+ --rebase On divergence, replay remote-side commits onto local.
+
+Global Flags:
+ -h, --help Show help for the command
+ --version Print version information and quit
+ -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE)
+ -C, --dir=DIR Change to DIR before doing anything
+ --[no-]prompt Whether to prompt for missing information
diff --git a/testdata/help/upstack_sync.txt b/testdata/help/upstack_sync.txt
new file mode 100644
index 000000000..c74ec9734
--- /dev/null
+++ b/testdata/help/upstack_sync.txt
@@ -0,0 +1,19 @@
+Usage: gs upstack (us) sync (sy) [flags]
+
+Pull remote-side commits for a branch and those above it
+
+Pull remote-side commits for the current branch and all branches upstack from
+it. Branches that fast-forward (or rebase, with --rebase) trigger an upstack
+restack of their children so the stack stays coherent. Use --branch to start at
+a different branch.
+
+Flags:
+ --branch=NAME Branch to start at
+ --rebase On divergence, replay remote-side commits onto local.
+
+Global Flags:
+ -h, --help Show help for the command
+ --version Print version information and quit
+ -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE)
+ -C, --dir=DIR Change to DIR before doing anything
+ --[no-]prompt Whether to prompt for missing information
diff --git a/testdata/script/branch_sync_conflict.txt b/testdata/script/branch_sync_conflict.txt
new file mode 100644
index 000000000..f3f19c0e7
--- /dev/null
+++ b/testdata/script/branch_sync_conflict.txt
@@ -0,0 +1,74 @@
+# 'gs branch sync --rebase' surfaces conflicts as a normal interrupted rebase.
+# 'gs rebase continue' after manual resolution finishes the sync.
+
+as 'Test '
+at '2026-06-10T10:00:00Z'
+
+# setup
+cd repo
+git init
+git commit --allow-empty -m 'Initial commit'
+
+shamhub init
+shamhub new origin alice/example.git
+shamhub register alice
+git push origin main
+
+env SHAMHUB_USERNAME=alice
+gs auth login
+
+# create feat1 and submit
+git add feat1.txt
+gs bc -m 'Add feat1' feat1
+gs branch submit --fill
+stderr 'Created #'
+
+# simulate a contributor pushing a conflicting change to feat1.
+cd ..
+git clone $SHAMHUB_URL/alice/example.git fork
+cd fork
+git checkout feat1
+cp $WORK/extra/feat1-remote.txt feat1.txt
+git add feat1.txt
+git commit -m 'remote: tweak feat1'
+git push origin feat1
+
+# locally, make a different change to the same file.
+cd ../repo
+cp $WORK/extra/feat1-local.txt feat1.txt
+git add feat1.txt
+git commit -m 'local: tweak feat1'
+
+# 'gs branch sync --rebase' begins a rebase that conflicts.
+! gs branch sync --rebase
+stderr 'interrupted by a conflict'
+
+# resolve the conflict and continue.
+cp $WORK/extra/feat1-resolved.txt feat1.txt
+git add feat1.txt
+env EDITOR=true
+gs rebase continue
+
+# verify final state: the resolution is the committed content.
+cmp feat1.txt $WORK/extra/feat1-resolved.txt
+
+# follow-up submit succeeds.
+gs branch submit
+stderr 'Updated #'
+
+-- repo/feat1.txt --
+line one
+line two
+line three
+-- extra/feat1-remote.txt --
+line one
+line two REMOTE
+line three
+-- extra/feat1-local.txt --
+line one
+line two LOCAL
+line three
+-- extra/feat1-resolved.txt --
+line one
+line two RESOLVED
+line three
diff --git a/testdata/script/branch_sync_fast_forward.txt b/testdata/script/branch_sync_fast_forward.txt
new file mode 100644
index 000000000..504da0f0d
--- /dev/null
+++ b/testdata/script/branch_sync_fast_forward.txt
@@ -0,0 +1,75 @@
+# 'gs branch sync' fast-forwards a tracked branch
+# when the remote has new commits and local has none past the last push.
+# After sync, 'gs branch submit' succeeds without --force.
+
+as 'Test '
+at '2026-06-10T10:00:00Z'
+
+# setup
+cd repo
+git init
+git commit --allow-empty -m 'Initial commit'
+
+# set up a fake GitHub remote
+shamhub init
+shamhub new origin alice/example.git
+shamhub register alice
+git push origin main
+
+env SHAMHUB_USERNAME=alice
+gs auth login
+
+# create a branch and submit it
+git add feat1.txt
+gs bc -m 'Add feat1' feat1
+gs branch submit --fill
+stderr 'Created #'
+
+# verify branch is in sync
+gs ls
+cmp stderr $WORK/golden/ls-before.txt
+
+# simulate a bot pushing a commit to feat1's remote.
+# clone the repo separately and push a regen-style commit.
+cd ..
+git clone $SHAMHUB_URL/alice/example.git fork
+cd fork
+git checkout feat1
+cp $WORK/extra/feat1-regen.txt feat1.txt
+git add feat1.txt
+git commit -m 'bot: regenerate derived files'
+git push origin feat1
+
+# back in the main repo
+cd ../repo
+
+# 'gs branch sync' fast-forwards local feat1 to match the remote.
+gs branch sync
+stderr 'feat1: fast-forwarded'
+
+# local feat1 now has the bot commit
+cmp feat1.txt $WORK/extra/feat1-regen.txt
+git log --oneline feat1
+stdout 'bot: regenerate derived files'
+stdout 'Add feat1'
+
+# a follow-up submit succeeds without --force
+# because the local LastPushedHash is updated.
+cp $WORK/extra/feat1-followup.txt feat1.txt
+git add feat1.txt
+git commit -m 'Continue feat1'
+gs branch submit
+stderr 'Updated #'
+
+-- repo/feat1.txt --
+feat 1
+-- extra/feat1-regen.txt --
+feat 1
+regenerated by bot
+-- extra/feat1-followup.txt --
+feat 1
+regenerated by bot
+follow-up
+-- golden/ls-before.txt --
+┏━■ feat1 (#1) ◀
+main
diff --git a/testdata/script/branch_sync_local_rewrite.txt b/testdata/script/branch_sync_local_rewrite.txt
new file mode 100644
index 000000000..c7da07822
--- /dev/null
+++ b/testdata/script/branch_sync_local_rewrite.txt
@@ -0,0 +1,46 @@
+# When a branch has been rewritten locally (e.g. restacked onto a newer
+# trunk) but the remote has gained nothing since the last push, the
+# branch has not really "diverged": there is nothing to pull and it
+# simply owes a push. 'gs branch sync' reports this benignly and never
+# warns about divergence.
+
+as 'Test '
+at '2026-06-18T10:00:00Z'
+
+# setup
+cd repo
+git init
+git commit --allow-empty -m 'Initial commit'
+
+shamhub init
+shamhub new origin alice/example.git
+shamhub register alice
+git push origin main
+
+env SHAMHUB_USERNAME=alice
+gs auth login
+
+# create feat1 and submit; this records the last-pushed hash.
+git add feat1.txt
+gs bc -m 'Add feat1' feat1
+gs branch submit --fill
+stderr 'Created #'
+
+# rewrite the branch locally below the last-pushed hash (an amend stands
+# in for a restack onto a moved trunk: the pushed commit leaves local
+# history). The remote still sits at the last-pushed hash.
+cp $WORK/extra/feat1-v2.txt feat1.txt
+git add feat1.txt
+gs commit amend --no-edit
+
+# 'gs branch sync' sees raw divergence but recognizes the remote gained
+# nothing: it reports the branch as ahead and does NOT warn about
+# divergence.
+gs branch sync
+stderr 'feat1: ahead of remote; nothing to pull'
+! stderr 'diverged'
+
+-- repo/feat1.txt --
+feat 1
+-- extra/feat1-v2.txt --
+feat 1 rewritten
diff --git a/testdata/script/branch_sync_rebase.txt b/testdata/script/branch_sync_rebase.txt
new file mode 100644
index 000000000..98c23f85d
--- /dev/null
+++ b/testdata/script/branch_sync_rebase.txt
@@ -0,0 +1,72 @@
+# 'gs branch sync --rebase' replays remote-side commits onto a local branch
+# that has new local commits since the last push.
+# This is the diverged case: local has work, remote has bot commits.
+
+as 'Test '
+at '2026-06-10T10:00:00Z'
+
+# setup
+cd repo
+git init
+git commit --allow-empty -m 'Initial commit'
+
+shamhub init
+shamhub new origin alice/example.git
+shamhub register alice
+git push origin main
+
+env SHAMHUB_USERNAME=alice
+gs auth login
+
+# create feat1 and submit
+git add feat1.txt
+gs bc -m 'Add feat1' feat1
+gs branch submit --fill
+stderr 'Created #'
+
+# simulate a bot pushing a regen commit to feat1 on the remote.
+cd ..
+git clone $SHAMHUB_URL/alice/example.git fork
+cd fork
+git checkout feat1
+cp $WORK/extra/feat1-regen.txt feat1.txt
+git add feat1.txt
+git commit -m 'bot: regenerate derived files'
+git push origin feat1
+
+# meanwhile, locally, the user adds a commit to a different file.
+cd ../repo
+git add other.txt
+git commit -m 'Add other'
+
+# 'gs branch sync --rebase' takes the bot commit from origin/feat1
+# and replays it onto the local feat1 (which has 'Add other' on top).
+gs branch sync --rebase
+stderr 'feat1: rebased'
+
+# verify the final history: bot commit replayed on top of 'Add other'
+git log --pretty=%s feat1
+cmp stdout $WORK/golden/log-after.txt
+
+# the local working tree has both:
+# - other.txt (local commit)
+# - feat1.txt with regen content (bot commit)
+cmp feat1.txt $WORK/extra/feat1-regen.txt
+exists other.txt
+
+# follow-up submit succeeds; bot commit is part of the PR now.
+gs branch submit
+stderr 'Updated #'
+
+-- repo/feat1.txt --
+feat 1
+-- repo/other.txt --
+other content
+-- extra/feat1-regen.txt --
+feat 1
+regenerated by bot
+-- golden/log-after.txt --
+bot: regenerate derived files
+Add other
+Add feat1
+Initial commit
diff --git a/testdata/script/branch_sync_rebase_restacked.txt b/testdata/script/branch_sync_rebase_restacked.txt
new file mode 100644
index 000000000..f21b7815c
--- /dev/null
+++ b/testdata/script/branch_sync_rebase_restacked.txt
@@ -0,0 +1,67 @@
+# 'gs branch sync --rebase' integrates remote-side commits even when the
+# local branch was rewritten below the last-pushed hash (e.g. restacked
+# onto a newer trunk). The remote-side commits (pHash..remote) are
+# replayed onto the current local tip; they are not stranded just
+# because the pushed commit is no longer in local history.
+
+as 'Test '
+at '2026-06-18T10:00:00Z'
+
+# setup
+cd repo
+git init
+git commit --allow-empty -m 'Initial commit'
+
+shamhub init
+shamhub new origin alice/example.git
+shamhub register alice
+git push origin main
+
+env SHAMHUB_USERNAME=alice
+gs auth login
+
+# create feat1 and submit; records the last-pushed hash.
+git add feat1.txt
+gs bc -m 'Add feat1' feat1
+gs branch submit --fill
+stderr 'Created #'
+
+# a bot pushes a derived-file commit to feat1 on the remote.
+cd ..
+git clone $SHAMHUB_URL/alice/example.git fork
+cd fork
+git checkout feat1
+cp $WORK/extra/derived.txt derived.txt
+git add derived.txt
+git commit -m 'bot: regenerate derived files'
+git push origin feat1
+
+# meanwhile, the user rewrites feat1 locally below the last-pushed hash
+# (an amend stands in for a restack: the pushed commit leaves local
+# history). The bot commit is NOT in local history.
+cd ../repo
+cp $WORK/extra/feat1-v2.txt feat1.txt
+git add feat1.txt
+gs commit amend --no-edit
+
+# 'gs branch sync --rebase' replays the bot commit onto the rewritten
+# local tip instead of skipping it as unrecoverable.
+gs branch sync --rebase
+stderr 'feat1: rebased'
+
+# the bot's derived file is now present locally on top of the rewrite.
+git log --pretty=%s feat1
+cmp stdout $WORK/golden/log-after.txt
+exists derived.txt
+cmp feat1.txt $WORK/extra/feat1-v2.txt
+
+-- repo/feat1.txt --
+feat 1
+-- extra/feat1-v2.txt --
+feat 1 rewritten
+-- extra/derived.txt --
+generated by bot
+-- golden/log-after.txt --
+bot: regenerate derived files
+Add feat1
+Initial commit
diff --git a/testdata/script/branch_sync_regenerate_driver.txt b/testdata/script/branch_sync_regenerate_driver.txt
new file mode 100644
index 000000000..b22750a78
--- /dev/null
+++ b/testdata/script/branch_sync_regenerate_driver.txt
@@ -0,0 +1,87 @@
+# 'gs branch sync --rebase' respects .gitattributes merge drivers.
+# When a file matches a custom 'regenerate' driver, conflicts in that file
+# are auto-resolved by re-running the configured generator, not by leaving
+# conflict markers for the user.
+
+as 'Test '
+at '2026-06-10T10:00:00Z'
+
+# setup
+cd repo
+git init
+git commit --allow-empty -m 'Initial commit'
+
+shamhub init
+shamhub new origin alice/example.git
+shamhub register alice
+git push origin main
+
+env SHAMHUB_USERNAME=alice
+gs auth login
+
+# install a custom merge driver named 'regenerate'.
+# the driver script writes a sentinel value to the merged path.
+chmod 755 $WORK/extra/regen-driver.sh
+env REGEN_DRIVER=$WORK/extra/regen-driver.sh
+git config merge.regenerate.name 'Regenerate derived files'
+# Driver string includes %A %O %B placeholders for the file paths.
+git config merge.regenerate.driver $REGEN_DRIVER' %A %O %B'
+
+# create a branch with two files:
+# - input.txt: the source file (no merge driver)
+# - doc.txt: a derived file (uses the regenerate driver)
+git add .gitattributes input.txt doc.txt
+gs bc -m 'Add feature' feat1
+gs branch submit --fill
+stderr 'Created #'
+
+# simulate the bot regenerating doc.txt on the remote.
+cd ..
+git clone $SHAMHUB_URL/alice/example.git fork
+cd fork
+git checkout feat1
+cp $WORK/extra/doc-bot.txt doc.txt
+git add doc.txt
+git commit -m 'bot: regenerate doc.txt'
+git push origin feat1
+
+# locally, change both input.txt and doc.txt.
+cd ../repo
+cp $WORK/extra/input-local.txt input.txt
+cp $WORK/extra/doc-local.txt doc.txt
+git add input.txt doc.txt
+git commit -m 'local: change input and doc'
+
+# 'gs branch sync --rebase' should succeed because doc.txt is
+# auto-resolved by the regenerate driver.
+gs branch sync --rebase
+stderr 'feat1: rebased'
+
+# verify the resolved doc.txt has the sentinel from the driver,
+# and input.txt has the local change.
+cmp doc.txt $WORK/extra/doc-sentinel.txt
+cmp input.txt $WORK/extra/input-local.txt
+
+# follow-up submit succeeds.
+gs branch submit
+stderr 'Updated #'
+
+-- repo/.gitattributes --
+doc.txt merge=regenerate
+-- repo/input.txt --
+input v1
+-- repo/doc.txt --
+DOC: input v1
+-- extra/input-local.txt --
+input local
+-- extra/doc-local.txt --
+DOC: input local
+-- extra/doc-bot.txt --
+DOC: input v1 (generated by bot)
+-- extra/doc-sentinel.txt --
+REGENERATED-BY-DRIVER
+-- extra/regen-driver.sh --
+#!/bin/sh
+# Custom merge driver: writes a sentinel to %A (the merged path).
+echo "REGENERATED-BY-DRIVER" > "$1"
+exit 0
diff --git a/testdata/script/repo_sync_pull_branches.txt b/testdata/script/repo_sync_pull_branches.txt
new file mode 100644
index 000000000..50d88f9a9
--- /dev/null
+++ b/testdata/script/repo_sync_pull_branches.txt
@@ -0,0 +1,81 @@
+# 'gs repo sync' pulls remote-side commits on tracked stack branches
+# in addition to trunk. Branches that fast-forward bring their children
+# along by triggering a restack; clean branches with moved parents
+# also restack so the stack stays coherent.
+
+as 'Test '
+at '2026-06-10T10:00:00Z'
+
+# setup
+cd repo
+git init
+git commit --allow-empty -m 'Initial commit'
+
+shamhub init
+shamhub new origin alice/example.git
+shamhub register alice
+git push origin main
+
+env SHAMHUB_USERNAME=alice
+gs auth login
+
+# build a stack: main -> feat1 -> feat2 -> feat3
+git add feat1.txt
+gs bc -m 'Add feat1' feat1
+git add feat2.txt
+gs bc -m 'Add feat2' feat2
+git add feat3.txt
+gs bc -m 'Add feat3' feat3
+gs stack submit --fill
+stderr 'Created #1'
+stderr 'Created #2'
+stderr 'Created #3'
+
+# simulate a bot pushing a regen commit to feat1 on the remote.
+# feat2 and feat3 are untouched on the remote.
+cd ..
+git clone $SHAMHUB_URL/alice/example.git fork
+cd fork
+
+git checkout feat1
+cp $WORK/extra/feat1-regen.txt feat1-regen.txt
+git add feat1-regen.txt
+git commit -m 'bot: regenerate feat1'
+git push origin feat1
+
+# back in the main repo
+cd ../repo
+gs bco feat3
+
+# 'gs repo sync' pulls trunk, then each tracked branch in bottom-up order.
+# feat1 fast-forwards, then feat2 and feat3 restack onto the new bases.
+gs repo sync
+stderr 'feat1: fast-forwarded'
+
+# verify all branches now have the bot commit in their history.
+gs bco feat1
+exists feat1-regen.txt
+
+gs bco feat2
+exists feat1-regen.txt
+exists feat2.txt
+
+gs bco feat3
+exists feat1-regen.txt
+exists feat2.txt
+exists feat3.txt
+
+# stack submit succeeds. feat2 and feat3 will show 'Updated #' because
+# their SHAs changed during the restack.
+gs stack submit
+! stderr '\(stale info\)'
+stderr 'Updated #'
+
+-- repo/feat1.txt --
+feat 1
+-- repo/feat2.txt --
+feat 2
+-- repo/feat3.txt --
+feat 3
+-- extra/feat1-regen.txt --
+generated content for feat1
diff --git a/upstack.go b/upstack.go
index c569105fe..78a7b542d 100644
--- a/upstack.go
+++ b/upstack.go
@@ -2,6 +2,7 @@ package main
type upstackCmd struct {
Submit upstackSubmitCmd `cmd:"" aliases:"s" help:"Submit a branch and those above it"`
+ Sync upstackSyncCmd `cmd:"" aliases:"sy" help:"Pull remote-side commits for a branch and those above it" released:"unreleased"`
Restack upstackRestackCmd `cmd:"" aliases:"r" help:"Restack a branch and its upstack"`
Onto upstackOntoCmd `cmd:"" aliases:"o" help:"Move a branch onto another branch"`
Delete upstackDeleteCmd `cmd:"" aliases:"d" released:"v0.16.0" help:"Delete all branches above the current branch"`
diff --git a/upstack_sync.go b/upstack_sync.go
new file mode 100644
index 000000000..445c1463b
--- /dev/null
+++ b/upstack_sync.go
@@ -0,0 +1,109 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+
+ "go.abhg.dev/gs/internal/git"
+ "go.abhg.dev/gs/internal/handler/branchsync"
+ "go.abhg.dev/gs/internal/silog"
+ "go.abhg.dev/gs/internal/spice"
+ "go.abhg.dev/gs/internal/spice/state"
+ "go.abhg.dev/gs/internal/text"
+)
+
+type upstackSyncCmd struct {
+ Branch string `placeholder:"NAME" help:"Branch to start at" predictor:"trackedBranches"`
+ Rebase bool `help:"On divergence, replay remote-side commits onto local."`
+}
+
+func (*upstackSyncCmd) Help() string {
+ return text.Dedent(`
+ Pull remote-side commits for the current branch and all
+ branches upstack from it. Branches that fast-forward
+ (or rebase, with --rebase) trigger an upstack restack of
+ their children so the stack stays coherent.
+ Use --branch to start at a different branch.
+ `)
+}
+
+func (cmd *upstackSyncCmd) Run(
+ ctx context.Context,
+ log *silog.Logger,
+ wt *git.Worktree,
+ store *state.Store,
+ svc *spice.Service,
+ branchSyncHandler *branchsync.Handler,
+ restackHandler RestackHandler,
+) error {
+ if cmd.Branch == "" {
+ currentBranch, err := wt.CurrentBranch(ctx)
+ if err != nil {
+ return fmt.Errorf("get current branch: %w", err)
+ }
+ cmd.Branch = currentBranch
+ }
+
+ graph, err := svc.BranchGraph(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("build branch graph: %w", err)
+ }
+
+ branches := slices.Collect(graph.Upstack(cmd.Branch))
+ if cmd.Branch == store.Trunk() && len(branches) > 0 && branches[0] == cmd.Branch {
+ branches = branches[1:]
+ }
+
+ return syncBranches(ctx, log, branchSyncHandler, restackHandler, branches, cmd.Rebase)
+}
+
+// syncBranches runs Sync on each branch in order and restacks the
+// upstack of any branch whose tip moved.
+func syncBranches(
+ ctx context.Context,
+ log *silog.Logger,
+ branchSyncHandler *branchsync.Handler,
+ restackHandler RestackHandler,
+ branches []string,
+ rebase bool,
+) error {
+ mode := branchsync.ModeFastForward
+ if rebase {
+ mode = branchsync.ModeRebase
+ }
+
+ for _, branch := range branches {
+ res, err := branchSyncHandler.Sync(ctx, branchsync.SyncRequest{Branch: branch, Mode: mode})
+ if err != nil {
+ if errors.Is(err, branchsync.ErrNoUpstream) {
+ continue
+ }
+ log.Warnf("%v: sync failed: %v", branch, err)
+ continue
+ }
+ switch res.Action {
+ case branchsync.ActionClean:
+ // quiet
+ case branchsync.ActionFastForward:
+ log.Infof("%v: fast-forwarded %s..%s", res.Branch, res.FromHash.Short(), res.ToHash.Short())
+ case branchsync.ActionRebased:
+ log.Infof("%v: rebased onto remote %s", res.Branch, res.ToHash.Short())
+ case branchsync.ActionBehind:
+ // quiet
+ case branchsync.ActionDiverged:
+ log.Warnf("%v: diverged from remote; pass --rebase to integrate", res.Branch)
+ case branchsync.ActionSkipped:
+ log.Warnf("%v: skipped (%v)", res.Branch, res.SkipReason)
+ }
+
+ if res.FromHash != res.ToHash {
+ if err := restackHandler.RestackUpstack(ctx, res.Branch, nil); err != nil {
+ log.Warnf("%v: upstack restack after sync failed: %v", res.Branch, err)
+ }
+ }
+ }
+
+ return nil
+}