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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions cmd/rebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.).`,
Expand Down Expand Up @@ -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())
Expand Down
122 changes: 122 additions & 0 deletions cmd/rebase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2258,3 +2259,124 @@ 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
serverDir 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,
serverDir: serverDir,
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_RebaseAdoptsServerRebasedBranchesAfterPriorFetch(t *testing.T) {
repo := setupServerRebasedRepo(t)
issue250Git(t, repo.dir, "fetch", "origin")
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)
}

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"))
}
11 changes: 11 additions & 0 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
Loading