Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
100 changes: 100 additions & 0 deletions cmd/rebase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
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
197 changes: 197 additions & 0 deletions cmd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Comment thread
skarim marked this conversation as resolved.
Outdated

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
Expand Down
Loading