From 8b0af8a3654e1856c3f24890798bc8679cd285a1 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Wed, 10 Jun 2026 09:58:21 -0400 Subject: [PATCH 01/12] sync: Add failing test scripts for branch sync feature Drafts the end-to-end test surface for the upcoming `gs branch sync` and its multi-branch / repo-sync variants. Five script tests covering: - fast-forward when local matches LastPushed - rebase when both sides have new commits - conflict surfacing + gs rebase continue resumption - .gitattributes regenerate merge driver auto-resolution - repo sync orchestrating multiple tracked branches bottom-up All five fail today because the sync commands and per-branch LastPushedHash state do not yet exist. Subsequent commits add the implementation needed to turn them green. --- testdata/script/branch_sync_conflict.txt | 74 ++++++++++++++++ testdata/script/branch_sync_fast_forward.txt | 75 ++++++++++++++++ testdata/script/branch_sync_rebase.txt | 72 ++++++++++++++++ .../script/branch_sync_regenerate_driver.txt | 85 +++++++++++++++++++ testdata/script/repo_sync_pull_branches.txt | 81 ++++++++++++++++++ 5 files changed, 387 insertions(+) create mode 100644 testdata/script/branch_sync_conflict.txt create mode 100644 testdata/script/branch_sync_fast_forward.txt create mode 100644 testdata/script/branch_sync_rebase.txt create mode 100644 testdata/script/branch_sync_regenerate_driver.txt create mode 100644 testdata/script/repo_sync_pull_branches.txt diff --git a/testdata/script/branch_sync_conflict.txt b/testdata/script/branch_sync_conflict.txt new file mode 100644 index 000000000..acd166041 --- /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 'There was a conflict while rebasing' + +# 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_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_regenerate_driver.txt b/testdata/script/branch_sync_regenerate_driver.txt new file mode 100644 index 000000000..c4f7d4e84 --- /dev/null +++ b/testdata/script/branch_sync_regenerate_driver.txt @@ -0,0 +1,85 @@ +# '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 +git config merge.regenerate.name 'Regenerate derived files' +git config merge.regenerate.driver "$WORK/extra/regen-driver.sh %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 From 5acf8f07d6f3ba621813398bf1882c537b0f0d99 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Wed, 10 Jun 2026 10:04:10 -0400 Subject: [PATCH 02/12] state: Track LastPushedHash per upstream Add LastPushedHash to branchUpstreamState so each tracked branch remembers the commit hash that was last successfully pushed to its upstream. This is the missing piece needed by the forthcoming 'gs branch sync' command to distinguish: - "remote moved, local stayed" (fast-forward safe) - "local moved past last push, remote stayed" (will push next) - "both moved past last push" (diverged; needs rebase) Plumbing: - branchUpstreamState gains LastPushedHash with json:"omitempty" so existing state files don't grow until a hash is recorded. - LookupResponse exposes it as UpstreamLastPushedHash (git.Hash). - UpsertRequest gains *git.Hash UpstreamLastPushedHash, preserving the existing-value semantics from other Upsert fields. - Setting UpstreamBranch to the same name preserves the prior LastPushedHash; clearing or renaming the upstream resets it. Tests cover JSON round-trip (with and without the hash), the omit-when- empty marshaling contract, and the Upsert preserve / advance / clear flows. --- internal/spice/state/branch.go | 28 +++++++++++ internal/spice/state/branch_int_test.go | 47 +++++++++++++++++ internal/spice/state/branch_test.go | 67 +++++++++++++++++++++++++ 3 files changed, 142 insertions(+) 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) { From 54651e2404505807a6456027d9d962f4917d26ff Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Wed, 10 Jun 2026 10:07:19 -0400 Subject: [PATCH 03/12] submit: Record LastPushedHash on every push Capture the pushed commit hash at both push sites in the submit handler (initial publish and subsequent updates) and write it into per-branch state alongside the upstream name. This populates the marker the upcoming 'gs branch sync' command uses to distinguish a fast-forwardable remote update from a true divergence. If hash recording fails (peel or store error), the push has already succeeded -- log a warning and continue rather than failing the submit. The branch will pick up a fresh hash on the next push. --- internal/handler/submit/handler.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) 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 { From 4daf97c947d7484e85615f094e0113607e4c2791 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Wed, 10 Jun 2026 10:16:34 -0400 Subject: [PATCH 04/12] sync: Add 'gs branch sync' fast-forward primitive Add a branchsync handler and the 'gs branch sync' CLI command that pulls remote-side commits added to a tracked branch since the last push. Fast-forward only in this commit: - if the remote is strictly ahead of LastPushedHash and local hasn't moved past LastPushedHash, advance the branch to the remote and record the new LastPushedHash; - if local is ahead of remote, report "ahead of remote" and exit; - if both diverge, report and exit; rebase support lands in a follow-up. For the currently checked-out branch the handler runs 'git pull --rebase --autostash' so HEAD and the working tree advance together; a no-commits-to-replay rebase degenerates into a clean fast-forward. For other branches it does a plain SetRef update. Wired into main.go so the command resolves through the existing Kong provider tree. testdata/script/branch_sync_fast_forward.txt now passes. --- branch.go | 1 + branch_sync.go | 68 ++++++++ internal/handler/branchsync/handler.go | 207 +++++++++++++++++++++++++ main.go | 16 ++ 4 files changed, 292 insertions(+) create mode 100644 branch_sync.go create mode 100644 internal/handler/branchsync/handler.go 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..ac5cbe31d --- /dev/null +++ b/branch_sync.go @@ -0,0 +1,68 @@ +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"` +} + +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 { + res, err := handler.Sync(ctx, cmd.Branch) + 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.ActionBehind: + log.Infof("%v: ahead of remote; nothing to pull", res.Branch) + case branchsync.ActionDiverged: + log.Warnf("%v: diverged from remote; both sides have new commits since last push -- skipping (rebase mode not yet supported)", res.Branch) + case branchsync.ActionSkipped: + log.Warnf("%v: skipped (%v)", res.Branch, res.SkipReason) + } + + return nil +} diff --git a/internal/handler/branchsync/handler.go b/internal/handler/branchsync/handler.go new file mode 100644 index 000000000..c04512588 --- /dev/null +++ b/internal/handler/branchsync/handler.go @@ -0,0 +1,207 @@ +// 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) + Pull(ctx context.Context, opts git.PullOptions) 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 + + // ActionBehind means the remote is behind the local branch; the + // branch will need 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") + +// Sync syncs a single tracked branch. 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, branch string) (*SyncResult, error) { + 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 + } + + // Truly diverged. v1 doesn't pull in this case. + res.Action = ActionDiverged + return res, 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/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, From 64a4f8b0debdfb154cb257898b9d0d2443c87159 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Wed, 10 Jun 2026 10:20:07 -0400 Subject: [PATCH 05/12] repo sync: Pull remote-side commits into tracked branches After the trunk pull and merged-branch handling, iterate every tracked branch and run the branchsync handler against it. Fast-forwarded branches trigger an upstack restack so children that were based on the old hash get rebased onto the new tip; otherwise the next 'gs branch restack' would still see the stale base. Add --no-pull-branches to opt out (matches the safe default mental model: someone with no autofix bots, no force-pushes from collaborators, and no co-developers on a branch can skip the extra fetches). testdata/script/repo_sync_pull_branches.txt now passes. --- repo_sync.go | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/repo_sync.go b/repo_sync.go index 3cac2100e..4d690ea65 100644 --- a/repo_sync.go +++ b/repo_sync.go @@ -2,13 +2,21 @@ 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 repoSyncCmd struct { sync.TrunkOptions + + NoPullBranches bool `name:"no-pull-branches" help:"Skip pulling remote-side commits into tracked stack branches; only sync trunk and prune merged branches."` } func (*repoSyncCmd) Help() string { @@ -25,6 +33,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 +48,54 @@ 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.NoPullBranches { + return nil + } + + 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) + + for _, branch := range branches { + res, err := branchSyncHandler.Sync(ctx, branch) + 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 } From 60382522a22ff1fb5b810a078b7eac96d1b92402 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Wed, 10 Jun 2026 21:10:07 -0400 Subject: [PATCH 06/12] sync: Add --rebase mode for diverged branches Extend 'gs branch sync' with --rebase. When local has commits past LastPushedHash AND the remote has new commits (the typical case after a local restack races a CI bot push), replay the remote-only commits on top of local instead of skipping the branch. Algorithm: 1. Confirm LastPushedHash is in both L and R's history. If either side rewrote history below it, skip with a clear reason. 2. Check out the branch, reset --hard to R so HEAD = R. 3. 'git rebase --onto L LastPushedHash' replays p..R onto L. Conflicts surface as a normal interrupted rebase; 'gs rebase --continue' resumes the operation because git-spice's continuation already inspects .git/rebase-merge/head-name and our rebase sets it to the branch name. .gitattributes merge drivers DO apply during step 3 because git uses three-way merges for every commit replay. A file mapped to merge=regenerate has its conflict auto-resolved by re-running the configured generator. git rerere is NOT force-enabled by this command; honor user-set rerere.enabled if present. Surfaced via SyncRequest{Branch, Mode} so multi-branch callers can opt in per-branch; the Sync entry point now takes the request struct. All five script tests pass: fast_forward, rebase, conflict (with gs rebase --continue resumption), regenerate_driver (with merge driver auto-resolution), and the repo_sync_pull_branches orchestrator. --- branch_sync.go | 11 +- internal/handler/branchsync/handler.go | 112 +++++++++++++++++- repo_sync.go | 2 +- testdata/script/branch_sync_conflict.txt | 2 +- .../script/branch_sync_regenerate_driver.txt | 4 +- 5 files changed, 120 insertions(+), 11 deletions(-) diff --git a/branch_sync.go b/branch_sync.go index ac5cbe31d..c8f20169e 100644 --- a/branch_sync.go +++ b/branch_sync.go @@ -12,6 +12,7 @@ import ( 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 { @@ -46,7 +47,11 @@ func (cmd *branchSyncCmd) Run( log *silog.Logger, handler *branchsync.Handler, ) error { - res, err := handler.Sync(ctx, cmd.Branch) + req := branchsync.SyncRequest{Branch: cmd.Branch} + if cmd.Rebase { + req.Mode = branchsync.ModeRebase + } + res, err := handler.Sync(ctx, req) if err != nil { return err } @@ -56,10 +61,12 @@ func (cmd *branchSyncCmd) Run( 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; both sides have new commits since last push -- skipping (rebase mode not yet supported)", res.Branch) + 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) } diff --git a/internal/handler/branchsync/handler.go b/internal/handler/branchsync/handler.go index c04512588..c95413ba8 100644 --- a/internal/handler/branchsync/handler.go +++ b/internal/handler/branchsync/handler.go @@ -32,7 +32,10 @@ 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) @@ -65,6 +68,10 @@ const ( // 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 is behind the local branch; the // branch will need to be pushed. ActionBehind @@ -98,10 +105,32 @@ type SyncResult struct { // and therefore cannot be synced. var ErrNoUpstream = errors.New("branch has no upstream configured") -// Sync syncs a single tracked branch. 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, branch string) (*SyncResult, error) { +// 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() { @@ -163,11 +192,82 @@ func (h *Handler) Sync(ctx context.Context, branch string) (*SyncResult, error) return res, nil } - // Truly diverged. v1 doesn't pull in this case. - res.Action = ActionDiverged + // Truly diverged. + if req.Mode != ModeRebase { + res.Action = ActionDiverged + return res, nil + } + + // Rebase path. Validate that LastPushedHash is in both L and R's + // history; otherwise we can't safely identify which commits to pull. + if lookup.UpstreamLastPushedHash == "" { + res.Action = ActionSkipped + res.SkipReason = "no LastPushedHash recorded; push the branch once to establish a baseline" + return res, nil + } + pHash := lookup.UpstreamLastPushedHash + if !h.Repository.IsAncestor(ctx, pHash, remoteHash) { + res.Action = ActionSkipped + res.SkipReason = "remote history has been rewritten since last push; manual recovery required" + return res, nil + } + if !h.Repository.IsAncestor(ctx, pHash, localHash) { + res.Action = ActionSkipped + res.SkipReason = "local history has been rewritten below the last-pushed hash; manual recovery required" + return res, nil + } + + 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 diff --git a/repo_sync.go b/repo_sync.go index 4d690ea65..b9340ca1a 100644 --- a/repo_sync.go +++ b/repo_sync.go @@ -74,7 +74,7 @@ func (cmd *repoSyncCmd) Run( slices.Sort(branches) for _, branch := range branches { - res, err := branchSyncHandler.Sync(ctx, branch) + res, err := branchSyncHandler.Sync(ctx, branchsync.SyncRequest{Branch: branch}) if err != nil { if errors.Is(err, branchsync.ErrNoUpstream) { continue diff --git a/testdata/script/branch_sync_conflict.txt b/testdata/script/branch_sync_conflict.txt index acd166041..f3f19c0e7 100644 --- a/testdata/script/branch_sync_conflict.txt +++ b/testdata/script/branch_sync_conflict.txt @@ -41,7 +41,7 @@ git commit -m 'local: tweak feat1' # 'gs branch sync --rebase' begins a rebase that conflicts. ! gs branch sync --rebase -stderr 'There was a conflict while rebasing' +stderr 'interrupted by a conflict' # resolve the conflict and continue. cp $WORK/extra/feat1-resolved.txt feat1.txt diff --git a/testdata/script/branch_sync_regenerate_driver.txt b/testdata/script/branch_sync_regenerate_driver.txt index c4f7d4e84..b22750a78 100644 --- a/testdata/script/branch_sync_regenerate_driver.txt +++ b/testdata/script/branch_sync_regenerate_driver.txt @@ -22,8 +22,10 @@ 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' -git config merge.regenerate.driver "$WORK/extra/regen-driver.sh %A %O %B" +# 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) From c3267543eb9d55d1235675ce54f4603c877017df Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Wed, 10 Jun 2026 21:13:00 -0400 Subject: [PATCH 07/12] sync: Add stack / upstack / downstack sync variants Add three new CLI commands mirroring the existing stack / upstack / downstack restack shape: - gs stack sync -- sync every branch in the current stack - gs upstack sync -- sync the current branch and those above it - gs downstack sync -- sync the current branch and those below it Each accepts --branch to retarget and --rebase to integrate diverged branches via the rebase path. All three share a syncBranches() helper that iterates in dependency order and triggers an upstack restack for any branch whose tip moved -- the same orchestration 'gs repo sync' performs across all tracked branches. --- downstack.go | 1 + downstack_sync.go | 60 +++++++++++++++++++++++++ stack.go | 1 + stack_sync.go | 65 +++++++++++++++++++++++++++ upstack.go | 1 + upstack_sync.go | 109 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+) create mode 100644 downstack_sync.go create mode 100644 stack_sync.go create mode 100644 upstack_sync.go 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/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..1174dd5b8 --- /dev/null +++ b/stack_sync.go @@ -0,0 +1,65 @@ +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 := append(downs, ups...) + branches = slices.DeleteFunc(branches, func(b string) bool { return b == trunk }) + + return syncBranches(ctx, log, branchSyncHandler, restackHandler, branches, cmd.Rebase) +} 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 +} From 294d9821e356497e5fb8b642411fc3b14dd56b01 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Wed, 10 Jun 2026 21:14:22 -0400 Subject: [PATCH 08/12] repo sync: Make pull-branches policy configurable Replace the binary --no-pull-branches flag with an enum-backed spice.repoSync.pullBranches config key. Values: off -- skip the per-branch pull entirely fastForward -- (default) advance safe branches, skip diverged ones rebase -- replay diverged branches' local commits onto the remote (matches 'gs branch sync --rebase' semantics) Default is fastForward, which solves the autofix-ci stale-info problem without ever surfacing a conflict mid repo sync. Users with stacks that have mature merge drivers can opt up to rebase via either the flag or `gs config set spice.repoSync.pullBranches rebase`. --- repo_sync.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/repo_sync.go b/repo_sync.go index b9340ca1a..1fbe81b75 100644 --- a/repo_sync.go +++ b/repo_sync.go @@ -13,10 +13,18 @@ import ( "go.abhg.dev/gs/internal/text" ) +type pullBranchesMode string + +const ( + pullBranchesOff pullBranchesMode = "off" + pullBranchesFastForward pullBranchesMode = "fastForward" + pullBranchesRebase pullBranchesMode = "rebase" +) + type repoSyncCmd struct { sync.TrunkOptions - NoPullBranches bool `name:"no-pull-branches" help:"Skip pulling remote-side commits into tracked stack branches; only sync trunk and prune merged branches."` + 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 { @@ -60,9 +68,10 @@ func (cmd *repoSyncCmd) Run( return err } - if cmd.NoPullBranches { + if cmd.PullBranches == pullBranchesOff { return nil } + rebase := cmd.PullBranches == pullBranchesRebase var branches []string for branch, err := range store.ListBranches(ctx) { @@ -73,8 +82,13 @@ func (cmd *repoSyncCmd) Run( } slices.Sort(branches) + mode := branchsync.ModeFastForward + if rebase { + mode = branchsync.ModeRebase + } + for _, branch := range branches { - res, err := branchSyncHandler.Sync(ctx, branchsync.SyncRequest{Branch: branch}) + res, err := branchSyncHandler.Sync(ctx, branchsync.SyncRequest{Branch: branch, Mode: mode}) if err != nil { if errors.Is(err, branchsync.ErrNoUpstream) { continue From 122b0e1cff2eb676355733fd19819bb8ea605064 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Wed, 10 Jun 2026 21:16:49 -0400 Subject: [PATCH 09/12] sync: Add docs, changelog entries, and regenerated CLI reference - Document the new pull-branches behavior in the syncing-with-upstream guide, including the per-scope (branch/upstack/downstack/stack) variants and how to opt up to rebase mode. - Four changelog entries: Added: submit records LastPushedHash on push. Added: gs branch sync + scope variants. Changed: gs repo sync now pulls every tracked branch by default. Fixed: shamhub bare-repo --git-dir on git 2.50+. - Regenerate doc/includes/cli-reference.md, cli-shorthands.md, the per-command testdata/help/*.txt fixtures, and gs.txt. --- .../unreleased/Added-20260610-211549.yaml | 3 + .../unreleased/Added-20260610-211604.yaml | 3 + .../unreleased/Changed-20260610-211549.yaml | 3 + .../unreleased/Fixed-20260610-211549.yaml | 3 + doc/includes/cli-reference.md | 167 ++++++++++-------- doc/includes/cli-shorthands.md | 6 +- doc/src/guide/cr.md | 35 ++++ stack_sync.go | 4 +- testdata/help/branch_sync.txt | 28 +++ testdata/help/downstack_sync.txt | 17 ++ testdata/help/gs.txt | 9 +- testdata/help/repo_sync.txt | 18 +- testdata/help/stack_sync.txt | 18 ++ testdata/help/upstack_sync.txt | 19 ++ 14 files changed, 252 insertions(+), 81 deletions(-) create mode 100644 .changes/unreleased/Added-20260610-211549.yaml create mode 100644 .changes/unreleased/Added-20260610-211604.yaml create mode 100644 .changes/unreleased/Changed-20260610-211549.yaml create mode 100644 .changes/unreleased/Fixed-20260610-211549.yaml create mode 100644 testdata/help/branch_sync.txt create mode 100644 testdata/help/downstack_sync.txt create mode 100644 testdata/help/stack_sync.txt create mode 100644 testdata/help/upstack_sync.txt diff --git a/.changes/unreleased/Added-20260610-211549.yaml b/.changes/unreleased/Added-20260610-211549.yaml new file mode 100644 index 000000000..d55ef6b64 --- /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 diverged branches.' +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/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 507c9d1c6..c28c1eb70 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} @@ -286,49 +294,24 @@ 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 stack merge {#gs-stack-merge} +### git-spice stack sync {#gs-stack-sync} ``` -gs stack (s) merge (m) [flags] +gs stack (s) sync (sy) [flags] ``` -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a stack - -Merges the CRs for the current branch's stack into trunk. -Use --branch to merge a different branch's stack. - -The stack includes the selected branch, -its downstack branches down to trunk, -and every upstack branch. - -Already-merged branches are skipped automatically. -Branches must have an open Change Request to be merged. - -Before merging, the stack is checked for branches -whose base PR was already merged on the forge. -Use --no-branch-check to skip this validation. +:material-tag-hidden:{ title="Released in version" }Unreleased -Before each merge, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait -before failing if merge readiness is not reached. +Pull remote-side commits for every branch in the stack -By default, a branch failure skips that branch's upstack descendants, -but independent sibling branches continue. -Use --fail-fast to stop the queue after the first branch failure. +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** -* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. -* `--no-branch-check`: Skip stale base validation before merging. -* `--fail-fast`: Stop the merge queue after the first branch failure. -* `--branch=NAME`: Branch whose stack to merge - -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +* `--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 +436,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 +612,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} ``` @@ -626,7 +649,7 @@ This command acts as a local merge queue: it merges one Change Request, waits for that merge to finish, restacks and updates the next Change Request, -waits for merge readiness on the updated Change Request, +waits for its CI checks to pass, and then repeats the process. For a stack like this: @@ -644,15 +667,13 @@ Before merging, the downstack is checked for branches whose base PR was already merged on the forge. Use --no-branch-check to skip this validation. -Before each merge, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait +Before each merge, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait (default: 30m, 0 means fail immediately if not ready). Between merges, the command waits for each merge to complete, restacks and updates the next PR, -waits for merge readiness on the updated PR, +waits for CI checks on the updated PR, and syncs merged branch cleanup. Use --no-wait for single branch merging @@ -661,13 +682,13 @@ when you don't want to wait for the merge to propagate. **Flags** -* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. +* `--branch=NAME`: Branch to start merging from * `--no-wait`: Skip polling for a single branch merge to propagate. * `--no-branch-check`: Skip stale base validation before merging. -* `--branch=NAME`: Branch to start merging from +* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) ### git-spice downstack edit {#gs-downstack-edit} @@ -1121,35 +1142,6 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff -### git-spice branch merge {#gs-branch-merge} - -``` -gs branch (b) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a branch into trunk - -Merges the CR for the current branch into trunk. -Use --branch to merge a different branch. - -The branch must be based directly on trunk. -To merge a stacked branch, use 'gs downstack merge'. - -Before merging, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait. - -**Flags** - -* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. -* `--branch=NAME`: Branch to merge - -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) - ### git-spice branch submit {#gs-branch-submit} ``` @@ -1205,6 +1197,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..d8b2b72df 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -6,13 +6,13 @@ | gs bdi | [gs branch diff](/cli/reference.md#gs-branch-diff) | | gs be | [gs branch edit](/cli/reference.md#gs-branch-edit) | | gs bfo | [gs branch fold](/cli/reference.md#gs-branch-fold) | -| gs bm | [gs branch merge](/cli/reference.md#gs-branch-merge) | | gs bon | [gs branch onto](/cli/reference.md#gs-branch-onto) | | gs br | [gs branch restack](/cli/reference.md#gs-branch-restack) | | gs brn | [gs branch rename](/cli/reference.md#gs-branch-rename) | | 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 +24,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) | @@ -34,10 +35,11 @@ | gs rs | [gs repo sync](/cli/reference.md#gs-repo-sync) | | gs sd | [gs stack delete](/cli/reference.md#gs-stack-delete) | | gs se | [gs stack edit](/cli/reference.md#gs-stack-edit) | -| 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..a271e0c63 100644 --- a/doc/src/guide/cr.md +++ b/doc/src/guide/cr.md @@ -269,6 +269,41 @@ 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; +diverged branches (both local and remote have new commits since the +last push) are reported and skipped by default. +To replay diverged branches' local commits on top of the remote, 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/stack_sync.go b/stack_sync.go index 1174dd5b8..7f27373a4 100644 --- a/stack_sync.go +++ b/stack_sync.go @@ -58,7 +58,9 @@ func (cmd *stackSyncCmd) Run( } trunk := store.Trunk() - branches := append(downs, ups...) + 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..b6bf81cf6 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -31,16 +31,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 @@ -59,8 +64,8 @@ Branch branch (b) restack (r) Restack a branch branch (b) onto (on) Move a branch onto another 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 From 04e01961e153fd8be263a24ef5e9f69e4f3b67cc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:01:32 +0000 Subject: [PATCH 10/12] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 75 ++++++++++++++++++++++++++++++++-- doc/includes/cli-shorthands.md | 2 + testdata/help/gs.txt | 2 + 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index c28c1eb70..825e7970b 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -294,6 +294,48 @@ 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 stack merge {#gs-stack-merge} + +``` +gs stack (s) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a stack + +Merges the CRs for the current branch's stack into trunk. +Use --branch to merge a different branch's stack. + +The stack includes the selected branch, +its downstack branches down to trunk, +and every upstack branch. + +Already-merged branches are skipped automatically. +Branches must have an open Change Request to be merged. + +Before merging, the stack is checked for branches +whose base PR was already merged on the forge. +Use --no-branch-check to skip this validation. + +Before each merge, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait +before failing if checks are not ready. + +By default, a branch failure skips that branch's upstack descendants, +but independent sibling branches continue. +Use --fail-fast to stop the queue after the first branch failure. + +**Flags** + +* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--no-branch-check`: Skip stale base validation before merging. +* `--fail-fast`: Stop the merge queue after the first branch failure. +* `--branch=NAME`: Branch whose stack to merge + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) + ### git-spice stack sync {#gs-stack-sync} ``` @@ -682,11 +724,11 @@ when you don't want to wait for the merge to propagate. **Flags** -* `--branch=NAME`: Branch to start merging from -* `--no-wait`: Skip polling for a single branch merge to propagate. -* `--no-branch-check`: Skip stale base validation before merging. * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. * `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--no-wait`: Skip polling for a single branch merge to propagate. +* `--no-branch-check`: Skip stale base validation before merging. +* `--branch=NAME`: Branch to start merging from **Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) @@ -1142,6 +1184,33 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff +### git-spice branch merge {#gs-branch-merge} + +``` +gs branch (b) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a branch into trunk + +Merges the CR for the current branch into trunk. +Use --branch to merge a different branch. + +The branch must be based directly on trunk. +To merge a stacked branch, use 'gs downstack merge'. + +Before merging, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait. + +**Flags** + +* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--branch=NAME`: Branch to merge + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) + ### git-spice branch submit {#gs-branch-submit} ``` diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index d8b2b72df..c51065d1b 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -6,6 +6,7 @@ | gs bdi | [gs branch diff](/cli/reference.md#gs-branch-diff) | | gs be | [gs branch edit](/cli/reference.md#gs-branch-edit) | | gs bfo | [gs branch fold](/cli/reference.md#gs-branch-fold) | +| gs bm | [gs branch merge](/cli/reference.md#gs-branch-merge) | | gs bon | [gs branch onto](/cli/reference.md#gs-branch-onto) | | gs br | [gs branch restack](/cli/reference.md#gs-branch-restack) | | gs brn | [gs branch rename](/cli/reference.md#gs-branch-rename) | @@ -35,6 +36,7 @@ | gs rs | [gs repo sync](/cli/reference.md#gs-repo-sync) | | gs sd | [gs stack delete](/cli/reference.md#gs-stack-delete) | | gs se | [gs stack edit](/cli/reference.md#gs-stack-edit) | +| 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) | diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index b6bf81cf6..38d6e7504 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -31,6 +31,7 @@ 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 @@ -64,6 +65,7 @@ Branch branch (b) restack (r) Restack a branch branch (b) onto (on) Move a branch onto another 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 From 22d2c237d467dab5ace0b665cd4e7582ce8c8a19 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Fri, 19 Jun 2026 06:30:43 -0400 Subject: [PATCH 11/12] sync: Fix divergence detection for locally-restacked branches --- .../unreleased/Added-20260610-211549.yaml | 2 +- doc/src/guide/cr.md | 13 ++-- internal/handler/branchsync/handler.go | 67 +++++++++++++------ testdata/script/branch_sync_local_rewrite.txt | 46 +++++++++++++ .../script/branch_sync_rebase_restacked.txt | 67 +++++++++++++++++++ 5 files changed, 169 insertions(+), 26 deletions(-) create mode 100644 testdata/script/branch_sync_local_rewrite.txt create mode 100644 testdata/script/branch_sync_rebase_restacked.txt diff --git a/.changes/unreleased/Added-20260610-211549.yaml b/.changes/unreleased/Added-20260610-211549.yaml index d55ef6b64..c92604e81 100644 --- a/.changes/unreleased/Added-20260610-211549.yaml +++ b/.changes/unreleased/Added-20260610-211549.yaml @@ -1,3 +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 diverged branches.' +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/doc/src/guide/cr.md b/doc/src/guide/cr.md index a271e0c63..500947076 100644 --- a/doc/src/guide/cr.md +++ b/doc/src/guide/cr.md @@ -280,10 +280,15 @@ 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; -diverged branches (both local and remote have new commits since the -last push) are reported and skipped by default. -To replay diverged branches' local commits on top of the remote, set: +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} diff --git a/internal/handler/branchsync/handler.go b/internal/handler/branchsync/handler.go index c95413ba8..bf60ffed9 100644 --- a/internal/handler/branchsync/handler.go +++ b/internal/handler/branchsync/handler.go @@ -72,8 +72,9 @@ const ( // remote-side commits brought in from the upstream. ActionRebased - // ActionBehind means the remote is behind the local branch; the - // branch will need to be pushed. + // 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 @@ -192,31 +193,55 @@ func (h *Handler) Sync(ctx context.Context, req SyncRequest) (*SyncResult, error return res, nil } - // Truly diverged. - if req.Mode != ModeRebase { - res.Action = ActionDiverged - return res, nil - } - - // Rebase path. Validate that LastPushedHash is in both L and R's - // history; otherwise we can't safely identify which commits to pull. - if lookup.UpstreamLastPushedHash == "" { - res.Action = ActionSkipped - res.SkipReason = "no LastPushedHash recorded; push the branch once to establish a baseline" - 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 - if !h.Repository.IsAncestor(ctx, pHash, remoteHash) { - res.Action = ActionSkipped - res.SkipReason = "remote history has been rewritten since last push; manual recovery required" + 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 } - if !h.Repository.IsAncestor(ctx, pHash, localHash) { - res.Action = ActionSkipped - res.SkipReason = "local history has been rewritten below the last-pushed hash; manual recovery required" + + // 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 } 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_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 From 7363b3aec0d61e3e7699c4d0c32925870ba51c71 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:44:50 +0000 Subject: [PATCH 12/12] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 36 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 825e7970b..d8a733758 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -318,9 +318,11 @@ Before merging, the stack is checked for branches whose base PR was already merged on the forge. Use --no-branch-check to skip this validation. -Before each merge, waits for CI checks to pass. -Use --build-timeout to configure the maximum wait -before failing if checks are not ready. +Before each merge, waits for merge readiness: +the forge must observe the pushed head +and report that the CR is ready to merge. +Use --ready-timeout to configure the maximum wait +before failing if merge readiness is not reached. By default, a branch failure skips that branch's upstack descendants, but independent sibling branches continue. @@ -329,12 +331,12 @@ Use --fail-fast to stop the queue after the first branch failure. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. * `--no-branch-check`: Skip stale base validation before merging. * `--fail-fast`: Stop the merge queue after the first branch failure. * `--branch=NAME`: Branch whose stack to merge -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) +**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) ### git-spice stack sync {#gs-stack-sync} @@ -691,7 +693,7 @@ This command acts as a local merge queue: it merges one Change Request, waits for that merge to finish, restacks and updates the next Change Request, -waits for its CI checks to pass, +waits for merge readiness on the updated Change Request, and then repeats the process. For a stack like this: @@ -709,13 +711,15 @@ Before merging, the downstack is checked for branches whose base PR was already merged on the forge. Use --no-branch-check to skip this validation. -Before each merge, waits for CI checks to pass. -Use --build-timeout to configure the maximum wait +Before each merge, waits for merge readiness: +the forge must observe the pushed head +and report that the CR is ready to merge. +Use --ready-timeout to configure the maximum wait (default: 30m, 0 means fail immediately if not ready). Between merges, the command waits for each merge to complete, restacks and updates the next PR, -waits for CI checks on the updated PR, +waits for merge readiness on the updated PR, and syncs merged branch cleanup. Use --no-wait for single branch merging @@ -725,12 +729,12 @@ when you don't want to wait for the merge to propagate. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. * `--no-wait`: Skip polling for a single branch merge to propagate. * `--no-branch-check`: Skip stale base validation before merging. * `--branch=NAME`: Branch to start merging from -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) +**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) ### git-spice downstack edit {#gs-downstack-edit} @@ -1200,16 +1204,18 @@ Use --branch to merge a different branch. The branch must be based directly on trunk. To merge a stacked branch, use 'gs downstack merge'. -Before merging, waits for CI checks to pass. -Use --build-timeout to configure the maximum wait. +Before merging, waits for merge readiness: +the forge must observe the pushed head +and report that the CR is ready to merge. +Use --ready-timeout to configure the maximum wait. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. * `--branch=NAME`: Branch to merge -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) +**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) ### git-spice branch submit {#gs-branch-submit}