Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260610-211549.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260610-211604.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .changes/unreleased/Changed-20260610-211549.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .changes/unreleased/Fixed-20260610-211549.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions branch_sync.go
Original file line number Diff line number Diff line change
@@ -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
}
96 changes: 95 additions & 1 deletion doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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]
```

<span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span></span>

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}

```
Expand Down Expand Up @@ -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]
```

<span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span></span>

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}

```
Expand Down Expand Up @@ -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]
```

<span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span></span>

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}

```
Expand Down Expand Up @@ -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]
```

<span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span></span>

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}
Expand Down
4 changes: 4 additions & 0 deletions doc/includes/cli-shorthands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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) |
Expand All @@ -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) |
40 changes: 40 additions & 0 deletions doc/src/guide/cr.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,46 @@ in one step, use the `--restack` flag:
{green}${reset} gs repo sync --restack
```

### Pulling remote-side commits on stack branches

<!-- gs:version unreleased -->

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
Expand Down
1 change: 1 addition & 0 deletions downstack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
60 changes: 60 additions & 0 deletions downstack_sync.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading