From 5880261598523c982038811fc44a16f51960ac0c Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 28 Jul 2026 18:28:22 -0400 Subject: [PATCH 1/3] Adopt equivalent server-rebased branches Snapshot tracking refs before fetch so rebase and sync can distinguish remote rewrites from local work. Adopt only correctly stacked, same-order range-diff-equivalent commits, and stop safely when both sides changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 30bcbf2a-5ef1-4bdb-a0fc-618294ae8ded --- README.md | 4 +- cmd/rebase.go | 17 ++ cmd/rebase_test.go | 100 +++++++++ cmd/sync.go | 11 + cmd/utils.go | 197 ++++++++++++++++ cmd/utils_test.go | 297 +++++++++++++++++++++++++ docs/src/content/docs/guides/ui.md | 2 + docs/src/content/docs/reference/cli.md | 4 + internal/git/git.go | 6 + internal/git/gitops.go | 36 +++ internal/git/gitops_test.go | 55 +++++ internal/git/mock_ops.go | 8 + skills/gh-stack/SKILL.md | 4 + 13 files changed, 740 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b55df82e..bb634f79 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Pull from remote and do a cascading rebase across the stack. gh stack rebase [flags] [branch] ``` -Fetches the latest changes from `origin`, then ensures each branch in the stack has the tip of the previous layer in its commit history. Rebases branches in order from trunk upward. If a branch's PR has been merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. +Fetches the latest changes from `origin`, then ensures each branch in the stack has the tip of the previous layer in its commit history. Rebases branches in order from trunk upward. If GitHub already rebased the stack, matching rewritten remote tips are adopted locally when your branches have not changed since the previous fetch and contain the same ordered commits. If a branch's PR has been merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. If a rebase conflict occurs, the operation pauses and prints the conflicted files with line numbers. Resolve the conflicts, stage with `git add`, and continue with `--continue`. To undo the entire rebase, use `--abort` to restore all branches to their pre-rebase state. @@ -321,6 +321,8 @@ Performs a synchronization of the entire stack: A clean remote-ahead update (PRs added on top of your local stack) is pulled down automatically without prompting, so `sync` is safe to run in automation. Sync only prompts when the stacks have truly diverged. +If GitHub already rebased the same stack branches, `sync` adopts those rewritten remote tips when your local tips have not changed since the previous fetch and `git range-diff` confirms the same commits in the same order. If local and remote commits both changed, sync stops rather than overwriting either side. + #### Diverged stacks When neither stack is a clean prefix of the other — for example, you added a branch locally while separate PRs were added to the same stack on GitHub — sync cannot merge the two automatically. In an interactive terminal it offers three choices: diff --git a/cmd/rebase.go b/cmd/rebase.go index 3b4d4827..dac893fe 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -55,6 +55,10 @@ func RebaseCmd(cfg *config.Config) *cobra.Command { Ensures that each branch in the stack has the tip of the previous layer in its commit history, rebasing if necessary. +If the stack was already rebased on the remote, matching rewritten branch tips +are adopted locally when the local branches have not changed since the previous +fetch and the ordered commit ranges are equivalent. + Use --no-trunk to skip fetching and rebasing with the trunk branch. Only the inter-branch rebases are performed (branch 2 onto branch 1, branch 3 onto branch 2, etc.).`, @@ -146,11 +150,24 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } // Fast-forward stack branches that are behind their remote tracking branch. + branchSnapshots := snapshotBranchTips(s, remote) if err := git.FetchBranches(remote, activeBranchNames(s)); err != nil { cfg.Errorf("failed to fetch stack branches from %s: %v", remote, err) return ErrSilent } + adopted, err := adoptRemoteRebasedBranches(cfg, s, remote, currentBranch, branchSnapshots) + if err != nil { + cfg.Errorf("%v", err) + return ErrSilent + } fastForwardBranches(cfg, s, remote, currentBranch) + if len(adopted) > 0 && !stackNeedsRebase(s, trunk.Ref) { + updateBaseSHAs(s) + _ = syncStackPRs(cfg, s) + stack.SaveNonBlocking(gitDir, sf) + cfg.Printf("Local branches now match the rebased stack on %s", remote) + return nil + } } cfg.Printf("Stack detected: %s", s.DisplayChain()) diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 1be2f9f9..34fff0eb 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -2258,3 +2258,103 @@ func TestIntegration_AdoptedBranchRebasesFromCommonAncestor(t *testing.T) { assert.Equal(t, []string{"imported two", "imported one", "parent commit"}, subjects) require.NoError(t, issue250GitMayFail(t, cloneDir, "merge-base", "--is-ancestor", "parent", "imported")) } + +type serverRebasedRepo struct { + dir string + gitDir string + oldImported string + newImported string + parentSHA string +} + +func setupServerRebasedRepo(t *testing.T) serverRebasedRepo { + t.Helper() + remoteDir := filepath.Join(t.TempDir(), "remote.git") + localDir := filepath.Join(t.TempDir(), "local") + serverDir := filepath.Join(t.TempDir(), "server") + + issue250Git(t, ".", "-c", "safe.bareRepository=all", "init", "--bare", "-b", "main", remoteDir) + issue250Git(t, ".", "clone", remoteDir, localDir) + issue250Git(t, localDir, "config", "user.name", "Test") + issue250Git(t, localDir, "config", "user.email", "test@example.com") + + issue250WriteFile(t, localDir, "base.txt", "base\n") + issue250Git(t, localDir, "add", ".") + issue250Git(t, localDir, "commit", "-m", "base") + issue250Git(t, localDir, "push", "-u", "origin", "main") + mainSHA := issue250Git(t, localDir, "rev-parse", "main") + + issue250Git(t, localDir, "checkout", "-b", "parent") + issue250WriteFile(t, localDir, "parent.txt", "parent\n") + issue250Git(t, localDir, "add", ".") + issue250Git(t, localDir, "commit", "-m", "parent commit") + issue250Git(t, localDir, "push", "-u", "origin", "parent") + parentSHA := issue250Git(t, localDir, "rev-parse", "parent") + + issue250Git(t, localDir, "checkout", "-b", "imported", "main") + issue250WriteFile(t, localDir, "imported.txt", "imported\n") + issue250Git(t, localDir, "add", ".") + issue250Git(t, localDir, "commit", "-m", "imported commit") + issue250Git(t, localDir, "push", "-u", "origin", "imported") + oldImported := issue250Git(t, localDir, "rev-parse", "imported") + + gitDir := filepath.Join(localDir, ".git") + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main", Head: mainSHA}, + Branches: []stack.BranchRef{ + {Branch: "parent", Head: parentSHA, Base: mainSHA}, + {Branch: "imported", Head: oldImported, Base: mainSHA}, + }, + }) + + issue250Git(t, ".", "clone", remoteDir, serverDir) + issue250Git(t, serverDir, "config", "user.name", "Test") + issue250Git(t, serverDir, "config", "user.email", "test@example.com") + issue250Git(t, serverDir, "checkout", "parent") + issue250Git(t, serverDir, "checkout", "imported") + issue250Git(t, serverDir, "rebase", "--onto", "parent", "main", "imported") + issue250Git(t, serverDir, "push", "--force", "origin", "imported") + newImported := issue250Git(t, serverDir, "rev-parse", "imported") + + issue250Git(t, localDir, "checkout", "imported") + + return serverRebasedRepo{ + dir: localDir, + gitDir: gitDir, + oldImported: oldImported, + newImported: newImported, + parentSHA: parentSHA, + } +} + +func assertServerRebasedRepoAdopted(t *testing.T, repo serverRebasedRepo) { + t.Helper() + assert.Equal(t, repo.newImported, issue250Git(t, repo.dir, "rev-parse", "imported")) + assert.NotEqual(t, repo.oldImported, repo.newImported) + require.NoError(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", "parent", "imported")) + + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + require.Len(t, sf.Stacks[0].Branches, 2) + assert.Equal(t, repo.parentSHA, sf.Stacks[0].Branches[1].Base) + assert.Equal(t, repo.newImported, sf.Stacks[0].Branches[1].Head) +} + +func TestIntegration_RebaseAdoptsServerRebasedBranches(t *testing.T) { + repo := setupServerRebasedRepo(t) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + + require.NoError(t, runRebase(cfg, &rebaseOptions{remote: "origin"})) + assertServerRebasedRepoAdopted(t, repo) +} + +func TestIntegration_SyncAdoptsServerRebasedBranches(t *testing.T) { + repo := setupServerRebasedRepo(t) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + + require.NoError(t, runSync(cfg, &syncOptions{remote: "origin"})) + assertServerRebasedRepoAdopted(t, repo) +} diff --git a/cmd/sync.go b/cmd/sync.go index c9aeda7b..769146e6 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -48,6 +48,11 @@ GitHub and recreate it later with sync/submit, or cancel. Cancelling — or a divergence in a non-interactive terminal — aborts the sync without pushing branches or updating PRs. +When the same branches were rewritten by a server-side stack rebase, sync +adopts the remote tips only if the local tips still match their pre-fetch +tracking refs and the ordered commit ranges are equivalent. Otherwise it stops +instead of overwriting either side. + If a rebase conflict is detected, all branches are restored to their original state and you are advised to run "gh stack rebase" to resolve conflicts interactively. @@ -108,6 +113,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { // Fetch trunk + active branches so tracking refs are current for // fast-forward detection (Step 2) and --force-with-lease (Step 4). normalizeStackTrunk(cfg, s, remote) + branchSnapshots := snapshotBranchTips(s, remote) if err := git.FetchBranches(remote, activeBranchNames(s)); err != nil { cfg.Errorf("failed to fetch stack branches from %s: %v", remote, err) return ErrSilent @@ -147,6 +153,11 @@ func runSync(cfg *config.Config, opts *syncOptions) error { return err } + if _, err := adoptRemoteRebasedBranches(cfg, s, remote, currentBranch, branchSnapshots); err != nil { + cfg.Errorf("%v", err) + return ErrSilent + } + // --- Step 2b: Fast-forward stack branches behind their remote tracking branch --- updatedBranches := fastForwardBranches(cfg, s, remote, currentBranch) diff --git a/cmd/utils.go b/cmd/utils.go index 6da90fa7..4870ecae 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -822,6 +822,203 @@ func activeBranchNames(s *stack.Stack) []string { return names } +type branchTipSnapshot struct { + localSHA string + remoteSHA string + hasRemote bool +} + +// snapshotBranchTips captures local and remote-tracking tips before a fetch. +// The old remote tip lets us distinguish a server rewrite from an unpushed +// local rewrite after FetchBranches updates the tracking refs. +func snapshotBranchTips(s *stack.Stack, remote string) map[string]branchTipSnapshot { + snapshots := make(map[string]branchTipSnapshot) + for _, br := range s.Branches { + if br.IsMerged() { + continue + } + localSHA, err := git.RevParse(br.Branch) + if err != nil { + continue + } + snapshot := branchTipSnapshot{localSHA: localSHA} + if remoteSHA, err := git.RevParse(remote + "/" + br.Branch); err == nil { + snapshot.remoteSHA = remoteSHA + snapshot.hasRemote = true + } + snapshots[br.Branch] = snapshot + } + return snapshots +} + +type remoteRebaseUpdate struct { + branch string + localSHA string + remoteSHA string + remoteRef string +} + +// adoptRemoteRebasedBranches updates local branches after a remote stack +// rebase when the pre-fetch tracking refs prove the local tips were unchanged +// and range-diff proves each branch retained the same ordered commits. +func adoptRemoteRebasedBranches(cfg *config.Config, s *stack.Stack, remote, currentBranch string, snapshots map[string]branchTipSnapshot) ([]string, error) { + var updates []remoteRebaseUpdate + for _, br := range s.Branches { + if br.IsMerged() { + continue + } + snapshot, ok := snapshots[br.Branch] + if !ok { + continue + } + localSHA, err := git.RevParse(br.Branch) + if err != nil { + return nil, fmt.Errorf("could not resolve local branch %s: %w", br.Branch, err) + } + if localSHA != snapshot.localSHA { + return nil, fmt.Errorf("local branch %s changed while syncing", br.Branch) + } + + remoteRef := remote + "/" + br.Branch + remoteSHA, err := git.RevParse(remoteRef) + if err != nil || localSHA == remoteSHA { + continue + } + + localBehind, err := git.IsAncestor(localSHA, remoteSHA) + if err != nil { + return nil, fmt.Errorf("could not compare %s with %s: %w", br.Branch, remoteRef, err) + } + if localBehind { + // An ordinary fast-forward is handled by fastForwardBranches. + continue + } + + if !snapshot.hasRemote { + return nil, fmt.Errorf( + "%s and %s have diverged, but no pre-fetch tracking tip is available; reconcile them manually", + br.Branch, remoteRef, + ) + } + if snapshot.remoteSHA == remoteSHA { + // Only the local branch changed; preserve it for the normal local + // rebase and push flow. + continue + } + if snapshot.localSHA != snapshot.remoteSHA { + return nil, fmt.Errorf( + "%s and %s both changed since the last fetch; reconcile them manually", + br.Branch, remoteRef, + ) + } + + updates = append(updates, remoteRebaseUpdate{ + branch: br.Branch, + localSHA: localSHA, + remoteSHA: remoteSHA, + remoteRef: remoteRef, + }) + } + if len(updates) == 0 { + return nil, nil + } + + updateByBranch := make(map[string]remoteRebaseUpdate, len(updates)) + for _, update := range updates { + updateByBranch[update.branch] = update + } + lastUpdateIndex := -1 + for i, br := range s.Branches { + if _, ok := updateByBranch[br.Branch]; ok { + lastUpdateIndex = i + } + } + + localParent := s.Trunk.Branch + remoteParent := remote + "/" + s.Trunk.Branch + for i, br := range s.Branches { + if i > lastUpdateIndex { + break + } + if br.IsMerged() { + continue + } + remoteBranch := remote + "/" + br.Branch + stacked, err := git.IsAncestor(remoteParent, remoteBranch) + if err != nil || !stacked { + return nil, fmt.Errorf( + "remote branch %s is not stacked on %s; refusing to replace local branches", + remoteBranch, remoteParent, + ) + } + + if _, ok := updateByBranch[br.Branch]; ok { + localBase := br.Base + validBase := false + if localBase != "" { + validBase, _ = git.IsAncestor(localBase, br.Branch) + } + if !validBase { + localBase, err = git.MergeBase(localParent, br.Branch) + if err != nil { + return nil, fmt.Errorf("could not determine the local commit range for %s: %w", br.Branch, err) + } + } + equivalent, err := git.RangeDiffEquivalent(localBase, br.Branch, remoteParent, remoteBranch) + if err != nil { + return nil, fmt.Errorf("could not compare local and remote commits for %s: %w", br.Branch, err) + } + if !equivalent { + return nil, fmt.Errorf( + "%s was rewritten on the remote with different commits; reconcile it manually", + br.Branch, + ) + } + } + + localParent = br.Branch + remoteParent = remoteBranch + } + + dirty, err := git.HasUncommittedChanges() + if err != nil { + return nil, fmt.Errorf("could not determine whether the working tree is clean: %w", err) + } + if dirty { + return nil, errors.New("uncommitted changes prevent adopting the remote-rebased branches; commit or stash them first") + } + + var applied []remoteRebaseUpdate + for _, update := range updates { + var updateErr error + if update.branch == currentBranch { + updateErr = git.ResetHard(update.remoteRef) + } else { + updateErr = git.UpdateBranchRef(update.branch, update.remoteSHA) + } + if updateErr != nil { + for i := len(applied) - 1; i >= 0; i-- { + previous := applied[i] + if previous.branch == currentBranch { + _ = git.ResetHard(previous.localSHA) + } else { + _ = git.UpdateBranchRef(previous.branch, previous.localSHA) + } + } + return nil, fmt.Errorf("failed to update %s from %s: %w", update.branch, update.remoteRef, updateErr) + } + applied = append(applied, update) + } + + names := make([]string, len(updates)) + for i, update := range updates { + names[i] = update.branch + } + cfg.Successf("Adopted %d server-rebased %s from %s: %s", + len(names), plural(len(names), "branch", "branches"), remote, strings.Join(names, ", ")) + return names, nil +} + // fastForwardBranches fast-forwards each active stack branch to its remote // tracking branch when the local branch is strictly behind. Returns the names // of branches that were updated. Branches that are up-to-date, diverged, or diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 464e6196..fdabbeae 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -885,3 +885,300 @@ func TestUpdateBaseSHAsPreservesLastValidBase(t *testing.T) { assert.Equal(t, "old-parent", s.Branches[1].Base) assert.Equal(t, "child-tip", s.Branches[1].Head) } + +func TestAdoptRemoteRebasedBranches(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", Base: "main-old"}, + {Branch: "b2", Base: "b1-old"}, + }, + } + snapshots := map[string]branchTipSnapshot{ + "b1": {localSHA: "b1-old", remoteSHA: "b1-old", hasRemote: true}, + "b2": {localSHA: "b2-old", remoteSHA: "b2-old", hasRemote: true}, + } + + var resetTargets []string + var refUpdates [][2]string + restore := git.SetOps(&git.MockOps{ + RevParseFn: func(ref string) (string, error) { + switch ref { + case "b1": + return "b1-old", nil + case "b2": + return "b2-old", nil + case "origin/b1": + return "b1-new", nil + case "origin/b2": + return "b2-new", nil + default: + return "", fmt.Errorf("unexpected ref %s", ref) + } + }, + IsAncestorFn: func(ancestor, descendant string) (bool, error) { + switch { + case ancestor == "origin/main" && descendant == "origin/b1": + return true, nil + case ancestor == "origin/b1" && descendant == "origin/b2": + return true, nil + case ancestor == "main-old" && descendant == "b1": + return true, nil + case ancestor == "b1-old" && descendant == "b2": + return true, nil + default: + return false, nil + } + }, + RangeDiffEquivalentFn: func(oldBase, oldHead, newBase, newHead string) (bool, error) { + switch oldHead { + case "b1": + assert.Equal(t, "main-old", oldBase) + assert.Equal(t, "origin/main", newBase) + assert.Equal(t, "origin/b1", newHead) + case "b2": + assert.Equal(t, "b1-old", oldBase) + assert.Equal(t, "origin/b1", newBase) + assert.Equal(t, "origin/b2", newHead) + default: + t.Fatalf("unexpected old head %s", oldHead) + } + return true, nil + }, + HasUncommittedChangesFn: func() (bool, error) { return false, nil }, + ResetHardFn: func(ref string) error { + resetTargets = append(resetTargets, ref) + return nil + }, + UpdateBranchRefFn: func(branch, sha string) error { + refUpdates = append(refUpdates, [2]string{branch, sha}) + return nil + }, + }) + defer restore() + + cfg, _, _ := config.NewTestConfig() + adopted, err := adoptRemoteRebasedBranches(cfg, s, "origin", "b1", snapshots) + + require.NoError(t, err) + assert.Equal(t, []string{"b1", "b2"}, adopted) + assert.Equal(t, []string{"origin/b1"}, resetTargets) + assert.Equal(t, [][2]string{{"b2", "b2-new"}}, refUpdates) +} + +func TestAdoptRemoteRebasedBranchesPreservesLocalRewrite(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1", Base: "main-old"}}, + } + snapshots := map[string]branchTipSnapshot{ + "b1": {localSHA: "b1-local", remoteSHA: "b1-remote", hasRemote: true}, + } + + updated := false + restore := git.SetOps(&git.MockOps{ + RevParseFn: func(ref string) (string, error) { + if ref == "b1" { + return "b1-local", nil + } + return "b1-remote", nil + }, + IsAncestorFn: func(string, string) (bool, error) { return false, nil }, + UpdateBranchRefFn: func(string, string) error { + updated = true + return nil + }, + }) + defer restore() + + cfg, _, _ := config.NewTestConfig() + adopted, err := adoptRemoteRebasedBranches(cfg, s, "origin", "other", snapshots) + + require.NoError(t, err) + assert.Empty(t, adopted) + assert.False(t, updated) +} + +func TestAdoptRemoteRebasedBranchesAllowsLocalOnlyUpstackBranch(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", Base: "main-old"}, + {Branch: "local-only", Base: "b1-old"}, + }, + } + snapshots := map[string]branchTipSnapshot{ + "b1": {localSHA: "b1-old", remoteSHA: "b1-old", hasRemote: true}, + "local-only": {localSHA: "local-only-tip"}, + } + + var refUpdates [][2]string + restore := git.SetOps(&git.MockOps{ + RevParseFn: func(ref string) (string, error) { + switch ref { + case "b1": + return "b1-old", nil + case "origin/b1": + return "b1-new", nil + case "local-only": + return "local-only-tip", nil + default: + return "", fmt.Errorf("missing ref %s", ref) + } + }, + IsAncestorFn: func(ancestor, descendant string) (bool, error) { + if ancestor == "origin/main" && descendant == "origin/b1" { + return true, nil + } + return ancestor == "main-old" && descendant == "b1", nil + }, + RangeDiffEquivalentFn: func(string, string, string, string) (bool, error) { return true, nil }, + HasUncommittedChangesFn: func() (bool, error) { return false, nil }, + UpdateBranchRefFn: func(branch, sha string) error { + refUpdates = append(refUpdates, [2]string{branch, sha}) + return nil + }, + }) + defer restore() + + cfg, _, _ := config.NewTestConfig() + adopted, err := adoptRemoteRebasedBranches(cfg, s, "origin", "local-only", snapshots) + + require.NoError(t, err) + assert.Equal(t, []string{"b1"}, adopted) + assert.Equal(t, [][2]string{{"b1", "b1-new"}}, refUpdates) +} + +func TestAdoptRemoteRebasedBranchesRejectsConcurrentChanges(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1", Base: "main-old"}}, + } + snapshots := map[string]branchTipSnapshot{ + "b1": {localSHA: "b1-local", remoteSHA: "b1-old-remote", hasRemote: true}, + } + + restore := git.SetOps(&git.MockOps{ + RevParseFn: func(ref string) (string, error) { + if ref == "b1" { + return "b1-local", nil + } + return "b1-new-remote", nil + }, + IsAncestorFn: func(string, string) (bool, error) { return false, nil }, + }) + defer restore() + + cfg, _, _ := config.NewTestConfig() + _, err := adoptRemoteRebasedBranches(cfg, s, "origin", "b1", snapshots) + + require.Error(t, err) + assert.Contains(t, err.Error(), "both changed") +} + +func TestAdoptRemoteRebasedBranchesRejectsDifferentCommits(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1", Base: "main-old"}}, + } + snapshots := map[string]branchTipSnapshot{ + "b1": {localSHA: "b1-old", remoteSHA: "b1-old", hasRemote: true}, + } + + updated := false + restore := git.SetOps(&git.MockOps{ + RevParseFn: func(ref string) (string, error) { + if ref == "b1" { + return "b1-old", nil + } + return "b1-new", nil + }, + IsAncestorFn: func(ancestor, descendant string) (bool, error) { + if ancestor == "origin/main" && descendant == "origin/b1" { + return true, nil + } + return ancestor == "main-old" && descendant == "b1", nil + }, + RangeDiffEquivalentFn: func(string, string, string, string) (bool, error) { + return false, nil + }, + UpdateBranchRefFn: func(string, string) error { + updated = true + return nil + }, + }) + defer restore() + + cfg, _, _ := config.NewTestConfig() + _, err := adoptRemoteRebasedBranches(cfg, s, "origin", "other", snapshots) + + require.Error(t, err) + assert.Contains(t, err.Error(), "different commits") + assert.False(t, updated) +} + +func TestAdoptRemoteRebasedBranchesRollsBackPartialUpdate(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", Base: "main-old"}, + {Branch: "b2", Base: "b1-old"}, + }, + } + snapshots := map[string]branchTipSnapshot{ + "b1": {localSHA: "b1-old", remoteSHA: "b1-old", hasRemote: true}, + "b2": {localSHA: "b2-old", remoteSHA: "b2-old", hasRemote: true}, + } + + var resetTargets []string + restore := git.SetOps(&git.MockOps{ + RevParseFn: func(ref string) (string, error) { + switch ref { + case "b1": + return "b1-old", nil + case "b2": + return "b2-old", nil + case "origin/b1": + return "b1-new", nil + case "origin/b2": + return "b2-new", nil + default: + return "", fmt.Errorf("unexpected ref %s", ref) + } + }, + IsAncestorFn: func(ancestor, descendant string) (bool, error) { + switch { + case ancestor == "origin/main" && descendant == "origin/b1": + return true, nil + case ancestor == "origin/b1" && descendant == "origin/b2": + return true, nil + case ancestor == "main-old" && descendant == "b1": + return true, nil + case ancestor == "b1-old" && descendant == "b2": + return true, nil + default: + return false, nil + } + }, + RangeDiffEquivalentFn: func(string, string, string, string) (bool, error) { return true, nil }, + HasUncommittedChangesFn: func() (bool, error) { return false, nil }, + ResetHardFn: func(ref string) error { + resetTargets = append(resetTargets, ref) + return nil + }, + UpdateBranchRefFn: func(branch, sha string) error { + if branch == "b2" && sha == "b2-new" { + return assert.AnError + } + return nil + }, + }) + defer restore() + + cfg, _, _ := config.NewTestConfig() + _, err := adoptRemoteRebasedBranches(cfg, s, "origin", "b1", snapshots) + + require.Error(t, err) + assert.Equal(t, []string{"origin/b1", "b1-old"}, resetTargets, + "the current branch should be restored after a later ref update fails") +} diff --git a/docs/src/content/docs/guides/ui.md b/docs/src/content/docs/guides/ui.md index 53fd912c..93c877c3 100644 --- a/docs/src/content/docs/guides/ui.md +++ b/docs/src/content/docs/guides/ui.md @@ -113,6 +113,8 @@ When the stack is not linear (e.g., after changes were pushed to a lower branch, After the rebase completes, all PRs in the stack reflect the updated branches and CI checks are re-triggered. +The next `gh stack sync` or `gh stack rebase` fetches and adopts these rewritten branch tips when the local branches have not changed since their previous remote-tracking tips and the ordered commits are equivalent. If both local and remote commits changed, the CLI stops instead of choosing a side. + :::note[Commit signing] Commits created by a server-side rebase are **not signed**. If your repository requires signed commits, we recommend using the CLI. Running `gh stack rebase` uses local git operations, so the generated commits will follow your local git signing configuration. After rebasing locally, you can force push your updated branches with `gh stack push`. ::: diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 8143627a..6e0e2c04 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -320,6 +320,8 @@ Performs a synchronization of the entire stack: A clean remote-ahead update (PRs added on top of your local stack) is pulled down automatically without prompting, so `sync` is safe to run in automation. Sync only prompts when the stacks have truly diverged. +If GitHub already rebased the same stack branches, `sync` adopts those rewritten remote tips when your local tips have not changed since the previous fetch and `git range-diff` confirms the same commits in the same order. If local and remote commits both changed, sync stops rather than overwriting either side. + **Diverged stacks** When neither stack is a clean prefix of the other — for example, you added a branch locally while separate PRs were added to the same stack on GitHub — sync cannot merge the two automatically. In an interactive terminal it offers three choices: @@ -363,6 +365,8 @@ gh stack rebase [flags] [branch] Fetches the latest changes from `origin`, then ensures each branch in the stack has the tip of the previous layer in its commit history. Rebases branches in order from trunk upward. +If GitHub already rebased the stack, matching rewritten remote tips are adopted locally when your branches have not changed since the previous fetch and contain the same ordered commits. + If a branch's PR has been merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. If a rebase conflict occurs, the operation pauses and prints the conflicted files with line numbers. Resolve the conflicts, stage with `git add`, and continue with `--continue`. To undo the entire rebase, use `--abort` to restore all branches to their pre-rebase state. diff --git a/internal/git/git.go b/internal/git/git.go index 180083f6..a0c0b170 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -344,6 +344,12 @@ func MergeBaseForkPoint(ref, branch string) (string, error) { return ops.MergeBaseForkPoint(ref, branch) } +// RangeDiffEquivalent reports whether two commit ranges contain the same +// ordered commits after accounting for rewritten commit IDs. +func RangeDiffEquivalent(oldBase, oldHead, newBase, newHead string) (bool, error) { + return ops.RangeDiffEquivalent(oldBase, oldHead, newBase, newHead) +} + // Log returns recent commits for the given branch. func Log(ref string, maxCount int) ([]CommitInfo, error) { return ops.Log(ref, maxCount) diff --git a/internal/git/gitops.go b/internal/git/gitops.go index 2464f4ec..fa6c29f1 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -57,6 +57,7 @@ type Ops interface { RevParseMulti(refs []string) ([]string, error) MergeBase(a, b string) (string, error) MergeBaseForkPoint(ref, branch string) (string, error) + RangeDiffEquivalent(oldBase, oldHead, newBase, newHead string) (bool, error) Log(ref string, maxCount int) ([]CommitInfo, error) LogRange(base, head string) ([]CommitInfo, error) DiffStatRange(base, head string) (additions, deletions int, err error) @@ -443,6 +444,41 @@ func (d *defaultOps) MergeBaseForkPoint(ref, branch string) (string, error) { return run("merge-base", "--fork-point", ref, branch) } +func (d *defaultOps) RangeDiffEquivalent(oldBase, oldHead, newBase, newHead string) (bool, error) { + for _, rangeSpec := range []string{oldBase + ".." + oldHead, newBase + ".." + newHead} { + merges, err := run("log", "--merges", "--format=%H", rangeSpec) + if err != nil { + return false, err + } + if merges != "" { + // range-diff omits merge commits, so it cannot safely prove that + // ranges containing merges are equivalent. + return false, nil + } + } + + output, err := run( + "range-diff", + "--no-color", + "--no-patch", + oldBase+".."+oldHead, + newBase+".."+newHead, + ) + if err != nil { + return false, err + } + if output == "" { + return true, nil + } + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) < 5 || fields[2] != "=" || fields[0] != fields[3] { + return false, nil + } + } + return true, nil +} + func (d *defaultOps) Log(ref string, maxCount int) ([]CommitInfo, error) { format := "%H\t%s\t%at" output, err := run("log", ref, "--format="+format, "-n", strconv.Itoa(maxCount)) diff --git a/internal/git/gitops_test.go b/internal/git/gitops_test.go index 3d5a5f3d..8c0fffa7 100644 --- a/internal/git/gitops_test.go +++ b/internal/git/gitops_test.go @@ -455,6 +455,61 @@ func TestIntegration_Push_PlusPrefixedBranch_NonForce(t *testing.T) { "remote feature must not be force-updated") } +func TestIntegration_RangeDiffEquivalent(t *testing.T) { + _, cloneDir := setupBareAndClone(t) + restore := withGitDir(t, cloneDir) + defer restore() + + d := &defaultOps{} + + gitExec(t, cloneDir, "checkout", "-b", "feature") + writeFile(t, cloneDir, "feature-one.txt", "one") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "feature one") + writeFile(t, cloneDir, "feature-two.txt", "two") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "feature two") + + gitExec(t, cloneDir, "checkout", "-b", "new-base", "main") + writeFile(t, cloneDir, "base.txt", "new base") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "new base") + + gitExec(t, cloneDir, "branch", "rebased", "feature") + gitExec(t, cloneDir, "rebase", "--onto", "new-base", "main", "rebased") + + equivalent, err := d.RangeDiffEquivalent("main", "feature", "new-base", "rebased") + require.NoError(t, err) + assert.True(t, equivalent) + + commits := strings.Fields(gitExec(t, cloneDir, "rev-list", "--reverse", "main..feature")) + require.Len(t, commits, 2) + gitExec(t, cloneDir, "checkout", "-b", "reordered", "main") + gitExec(t, cloneDir, "cherry-pick", commits[1], commits[0]) + + equivalent, err = d.RangeDiffEquivalent("main", "feature", "main", "reordered") + require.NoError(t, err) + assert.False(t, equivalent, "the same commits in a different order must not be treated as equivalent") + + gitExec(t, cloneDir, "checkout", "rebased") + gitExec(t, cloneDir, "commit", "--amend", "-m", "changed message") + + equivalent, err = d.RangeDiffEquivalent("main", "feature", "new-base", "rebased") + require.NoError(t, err) + assert.False(t, equivalent, "a commit message change must not be treated as the same rewrite") + + gitExec(t, cloneDir, "checkout", "-b", "merge-side", "main") + writeFile(t, cloneDir, "merge-side.txt", "merge") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "merge side") + gitExec(t, cloneDir, "checkout", "-b", "merge-range", "main") + gitExec(t, cloneDir, "merge", "--no-ff", "merge-side", "-m", "merge commit") + + equivalent, err = d.RangeDiffEquivalent("main", "merge-range", "main", "merge-range") + require.NoError(t, err) + assert.False(t, equivalent, "ranges containing merge commits cannot be proven equivalent by range-diff") +} + func TestSplitCommitMessage(t *testing.T) { tests := []struct { name string diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index 25396f5d..1f239a2f 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -37,6 +37,7 @@ type MockOps struct { RevParseMultiFn func([]string) ([]string, error) MergeBaseFn func(string, string) (string, error) MergeBaseForkPointFn func(string, string) (string, error) + RangeDiffEquivalentFn func(string, string, string, string) (bool, error) LogFn func(string, int) ([]CommitInfo, error) LogRangeFn func(string, string) ([]CommitInfo, error) DiffStatRangeFn func(string, string) (int, int, error) @@ -293,6 +294,13 @@ func (m *MockOps) MergeBaseForkPoint(ref, branch string) (string, error) { return "", nil } +func (m *MockOps) RangeDiffEquivalent(oldBase, oldHead, newBase, newHead string) (bool, error) { + if m.RangeDiffEquivalentFn != nil { + return m.RangeDiffEquivalentFn(oldBase, oldHead, newBase, newHead) + } + return false, nil +} + func (m *MockOps) Log(ref string, maxCount int) ([]CommitInfo, error) { if m.LogFn != nil { return m.LogFn(ref, maxCount) diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index a09c25dd..93ec1692 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -639,6 +639,8 @@ gh stack sync [flags] 7. **Sync the stack object** — link the open PRs into a stack on GitHub. If the PRs are not yet in a stack, a new stack is created; if some PRs are already in a stack, it is updated (additive only). This only happens when two or more PRs exist. Sync **never opens PRs** — use `gh stack submit` for that 8. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to skip the prompt. In non-interactive environments, pruning only happens when `--prune` is passed explicitly +If the same branches were already rebased on GitHub, sync adopts the rewritten remote tips only when the local tips have not changed since their previous tracking refs and `git range-diff` confirms the same commits in the same order. If both sides changed, sync stops without overwriting either side. + **Output (stderr):** - `✓ Fetched latest changes from origin` @@ -660,6 +662,8 @@ gh stack sync [flags] Pull from remote and cascade-rebase stack branches. Use this when `sync` reports a conflict or when you need finer control (e.g., rebase only part of the stack). +When GitHub already rebased the stack, matching rewritten remote tips are adopted locally if the local branches are unchanged since the previous fetch and contain the same ordered commits. + ``` gh stack rebase [flags] [branch] ``` From f9c9bbbb854abc8a4fefadfe3b18738b39554f32 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 28 Jul 2026 18:46:11 -0400 Subject: [PATCH 2/3] Allow dirty unaffected branches during adoption Only require a clean worktree when adopting the checked-out branch with reset --hard. Non-current branch updates remain safe with unrelated working-tree changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 30bcbf2a-5ef1-4bdb-a0fc-618294ae8ded --- cmd/utils.go | 19 ++++++++++++++----- cmd/utils_test.go | 42 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/cmd/utils.go b/cmd/utils.go index 4870ecae..adacdcd5 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -980,12 +980,21 @@ func adoptRemoteRebasedBranches(cfg *config.Config, s *stack.Stack, remote, curr remoteParent = remoteBranch } - dirty, err := git.HasUncommittedChanges() - if err != nil { - return nil, fmt.Errorf("could not determine whether the working tree is clean: %w", err) + currentBranchWillMove := false + for _, update := range updates { + if update.branch == currentBranch { + currentBranchWillMove = true + break + } } - if dirty { - return nil, errors.New("uncommitted changes prevent adopting the remote-rebased branches; commit or stash them first") + if currentBranchWillMove { + dirty, err := git.HasUncommittedChanges() + if err != nil { + return nil, fmt.Errorf("could not determine whether the working tree is clean: %w", err) + } + if dirty { + return nil, errors.New("uncommitted changes prevent adopting the remote-rebased branches; commit or stash them first") + } } var applied []remoteRebaseUpdate diff --git a/cmd/utils_test.go b/cmd/utils_test.go index fdabbeae..b41012dd 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -1033,7 +1033,7 @@ func TestAdoptRemoteRebasedBranchesAllowsLocalOnlyUpstackBranch(t *testing.T) { return ancestor == "main-old" && descendant == "b1", nil }, RangeDiffEquivalentFn: func(string, string, string, string) (bool, error) { return true, nil }, - HasUncommittedChangesFn: func() (bool, error) { return false, nil }, + HasUncommittedChangesFn: func() (bool, error) { return true, nil }, UpdateBranchRefFn: func(branch, sha string) error { refUpdates = append(refUpdates, [2]string{branch, sha}) return nil @@ -1049,6 +1049,46 @@ func TestAdoptRemoteRebasedBranchesAllowsLocalOnlyUpstackBranch(t *testing.T) { assert.Equal(t, [][2]string{{"b1", "b1-new"}}, refUpdates) } +func TestAdoptRemoteRebasedBranchesRejectsDirtyCurrentBranch(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1", Base: "main-old"}}, + } + snapshots := map[string]branchTipSnapshot{ + "b1": {localSHA: "b1-old", remoteSHA: "b1-old", hasRemote: true}, + } + + resetCalled := false + restore := git.SetOps(&git.MockOps{ + RevParseFn: func(ref string) (string, error) { + if ref == "b1" { + return "b1-old", nil + } + return "b1-new", nil + }, + IsAncestorFn: func(ancestor, descendant string) (bool, error) { + if ancestor == "origin/main" && descendant == "origin/b1" { + return true, nil + } + return ancestor == "main-old" && descendant == "b1", nil + }, + RangeDiffEquivalentFn: func(string, string, string, string) (bool, error) { return true, nil }, + HasUncommittedChangesFn: func() (bool, error) { return true, nil }, + ResetHardFn: func(string) error { + resetCalled = true + return nil + }, + }) + defer restore() + + cfg, _, _ := config.NewTestConfig() + _, err := adoptRemoteRebasedBranches(cfg, s, "origin", "b1", snapshots) + + require.Error(t, err) + assert.Contains(t, err.Error(), "uncommitted changes") + assert.False(t, resetCalled) +} + func TestAdoptRemoteRebasedBranchesRejectsConcurrentChanges(t *testing.T) { s := &stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, From 131957ae963ab4704a8d928bdee3689cd34c726e Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 28 Jul 2026 22:16:32 -0400 Subject: [PATCH 3/3] Recover prefetched server rewrites Recover the previous remote tip from branch reflogs when an earlier fetch already advanced the tracking ref. Adopt equivalent server rebases while stopping sync before it overwrites non-equivalent remote commits, without blocking local-only rewrites. --- cmd/rebase_test.go | 24 +++++++++++++++++++++++- cmd/utils.go | 19 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 34fff0eb..1d8d79bf 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -1419,6 +1419,7 @@ func TestRebase_BranchDiverged_NoFF(t *testing.T) { } return true, nil } + mock.MergeBaseForkPointFn = func(string, string) (string, error) { return "b1-remote-sha", nil } mock.UpdateBranchRefFn = func(string, string) error { updateBranchRefCalls++ return nil @@ -2262,6 +2263,7 @@ func TestIntegration_AdoptedBranchRebasesFromCommonAncestor(t *testing.T) { type serverRebasedRepo struct { dir string gitDir string + serverDir string oldImported string newImported string parentSHA string @@ -2321,6 +2323,7 @@ func setupServerRebasedRepo(t *testing.T) serverRebasedRepo { return serverRebasedRepo{ dir: localDir, gitDir: gitDir, + serverDir: serverDir, oldImported: oldImported, newImported: newImported, parentSHA: parentSHA, @@ -2341,8 +2344,9 @@ func assertServerRebasedRepoAdopted(t *testing.T, repo serverRebasedRepo) { assert.Equal(t, repo.newImported, sf.Stacks[0].Branches[1].Head) } -func TestIntegration_RebaseAdoptsServerRebasedBranches(t *testing.T) { +func TestIntegration_RebaseAdoptsServerRebasedBranchesAfterPriorFetch(t *testing.T) { repo := setupServerRebasedRepo(t) + issue250Git(t, repo.dir, "fetch", "origin") withIssue250Repo(t, repo.dir) cfg := issue250TestConfig(t) @@ -2358,3 +2362,21 @@ func TestIntegration_SyncAdoptsServerRebasedBranches(t *testing.T) { require.NoError(t, runSync(cfg, &syncOptions{remote: "origin"})) assertServerRebasedRepoAdopted(t, repo) } + +func TestIntegration_SyncRejectsPrefetchedNonEquivalentRemoteRewrite(t *testing.T) { + repo := setupServerRebasedRepo(t) + issue250Git(t, repo.serverDir, "checkout", "imported") + issue250WriteFile(t, repo.serverDir, "server-only.txt", "server only\n") + issue250Git(t, repo.serverDir, "add", ".") + issue250Git(t, repo.serverDir, "commit", "-m", "server-only change") + issue250Git(t, repo.serverDir, "push", "origin", "imported") + issue250Git(t, repo.dir, "fetch", "origin") + + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + require.ErrorIs(t, runSync(cfg, &syncOptions{remote: "origin"}), ErrSilent) + + assert.Equal(t, repo.oldImported, issue250Git(t, repo.dir, "rev-parse", "imported")) + issue250Git(t, repo.serverDir, "fetch", "origin") + assert.Equal(t, "server only", issue250Git(t, repo.serverDir, "show", "origin/imported:server-only.txt")) +} diff --git a/cmd/utils.go b/cmd/utils.go index adacdcd5..97529011 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -842,9 +842,26 @@ func snapshotBranchTips(s *stack.Stack, remote string) map[string]branchTipSnaps continue } snapshot := branchTipSnapshot{localSHA: localSHA} - if remoteSHA, err := git.RevParse(remote + "/" + br.Branch); err == nil { + remoteRef := remote + "/" + br.Branch + if remoteSHA, err := git.RevParse(remoteRef); err == nil { snapshot.remoteSHA = remoteSHA snapshot.hasRemote = true + if remoteSHA != localSHA { + localForkPoint, _ := git.MergeBaseForkPoint(br.Branch, remoteRef) + if localForkPoint != remoteSHA { + snapshot.hasRemote = false + for _, candidate := range []string{localSHA, br.Head} { + if candidate == "" { + continue + } + if forkPoint, _ := git.MergeBaseForkPoint(remoteRef, candidate); forkPoint == candidate { + snapshot.remoteSHA = candidate + snapshot.hasRemote = true + break + } + } + } + } } snapshots[br.Branch] = snapshot }