Skip to content
Closed
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,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 squash-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 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
60 changes: 52 additions & 8 deletions cmd/rebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
}
}

// Fast-forward stack branches that are behind their remote tracking branch.
fastForwardBranches(cfg, s, remote, currentBranch)

cfg.Printf("Stack detected: %s", s.DisplayChain())

currentIdx := s.IndexOf(currentBranch)
Expand Down Expand Up @@ -169,20 +172,50 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
// Sync PR state before rebase so we can detect merged PRs.
syncStackPRs(cfg, s)

branchNames := make([]string, len(s.Branches))
for i, b := range s.Branches {
branchNames[i] = b.Branch
branchNames := make([]string, 0, len(s.Branches))
for _, b := range s.Branches {
// Merged branches that no longer exist locally have no ref to
// resolve. They are always skipped during rebase, but we must
// also exclude them here to avoid a rev-parse error.
if b.IsMerged() && !git.BranchExists(b.Branch) {
continue
}
branchNames = append(branchNames, b.Branch)
}
Comment thread
skarim marked this conversation as resolved.
originalRefs, err := git.RevParseMap(branchNames)
if err != nil {
cfg.Errorf("failed to resolve branch SHAs: %s", err)
return ErrSilent
}

// Track --onto rebase state for squash-merged branches.
// Backfill originalRefs for merged branches that were deleted locally.
// The rebase loop uses originalRefs[br.Branch] as ontoOldBase; without
// a valid entry the subsequent --onto rebase would receive an empty ref.
for _, b := range s.Branches {
if b.IsMerged() && !git.BranchExists(b.Branch) {
if b.Head != "" {
originalRefs[b.Branch] = b.Head
}
}
}

// Track --onto rebase state for merged branches.
needsOnto := false
var ontoOldBase string

// Get --onto state from merged branches below the rebase range.
// Ensures that when --upstack excludes merged branches, we still check
// the immediate predecessor for a merged PR and use --onto if needed.
if startIdx > 0 {
prev := s.Branches[startIdx-1]
if prev.IsMerged() {
if sha, ok := originalRefs[prev.Branch]; ok {
needsOnto = true
ontoOldBase = sha
}
}
}

for i, br := range branchesToRebase {
var base string
absIdx := startIdx + i
Expand All @@ -192,7 +225,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
base = s.Branches[absIdx-1].Branch
}

// Skip branches whose PRs have already been merged (e.g. via squash).
// Skip branches whose PRs have already been merged.
// Record state so subsequent branches can use --onto rebase.
if br.IsMerged() {
ontoOldBase = originalRefs[br.Branch]
Expand All @@ -212,7 +245,18 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
}
}

if err := git.RebaseOnto(newBase, ontoOldBase, br.Branch); err != nil {
// If ontoOldBase is stale (not an ancestor of the branch), the
// branch was already rebased past it (e.g. by a previous run).
// Fall back to merge-base(newBase, branch) which gives the correct
// divergence point and avoids replaying already-applied commits.
actualOldBase := ontoOldBase
if isAnc, err := git.IsAncestor(ontoOldBase, br.Branch); err == nil && !isAnc {
if mb, err := git.MergeBase(newBase, br.Branch); err == nil {
actualOldBase = mb
}
}

if err := git.RebaseOnto(newBase, actualOldBase, br.Branch); err != nil {
cfg.Warningf("Rebasing %s onto %s — conflict", br.Branch, newBase)

remaining := make([]string, 0)
Expand Down Expand Up @@ -243,7 +287,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
return ErrConflict
}

cfg.Successf("Rebased %s onto %s (squash-merge detected)", br.Branch, newBase)
cfg.Successf("Rebased %s onto %s (adjusted for merged PR)", br.Branch, newBase)
// Keep --onto mode; update old base for the next branch.
ontoOldBase = originalRefs[br.Branch]
} else {
Expand Down Expand Up @@ -441,7 +485,7 @@ func continueRebase(cfg *config.Config, gitDir string) error {
return ErrConflict
}

cfg.Successf("Rebased %s onto %s (squash-merge detected)", branchName, newBase)
cfg.Successf("Rebased %s onto %s (adjusted for merged PR)", branchName, newBase)
state.OntoOldBase = state.OriginalRefs[branchName]
} else {
var rebaseErr error
Expand Down
Loading
Loading