Skip to content
Merged
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
7 changes: 6 additions & 1 deletion cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,12 @@ func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps,
if err := stack.Save(gitDir, sf); err != nil {
return nil, "", stackSaveError(cfg, err)
}
cfg.Successf("Local stack matches remote%s", linkedStackLabel(cfg, remoteStack.Number))
if base := remoteStack.Base.Ref; base != "" && localStack.Trunk.Branch != base {
cfg.Warningf("GitHub retargeted this stack%s from %s to %s", linkedStackLabel(cfg, remoteStack.Number), localStack.Trunk.Branch, base)
cfg.Printf(" Run `%s` to rebase the stack onto %s", cfg.ColorCyan("gh stack sync"), base)
} else {
cfg.Successf("Local stack matches remote%s", linkedStackLabel(cfg, remoteStack.Number))
}
return localStack, targetBranch, nil
}

Expand Down
44 changes: 44 additions & 0 deletions cmd/checkout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1667,3 +1667,47 @@ func TestCheckout_ByPRURL_Remote(t *testing.T) {
assert.Equal(t, "feat-2", checkedOut)
assert.Contains(t, output, "Imported stack with 2 branches")
}

func TestCheckout_BaseRetarget(t *testing.T) {
t.Run("reports a tracked stack", func(t *testing.T) {
repo := setupBaseRetargetRepo(t, "main", false)
withIssue250Repo(t, repo.dir)
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = repo.client

require.NoError(t, runCheckout(cfg, &checkoutOptions{target: "9"}))
_, diagnostics := commandOutput(t, cfg, outR, errR)
assert.Contains(t, diagnostics, "from parent to main")
assert.NotContains(t, diagnostics, "matches remote")
sf, err := stack.Load(repo.gitDir)
require.NoError(t, err)
assert.Equal(t, "parent", sf.Stacks[0].Trunk.Branch)
})

t.Run("keeps the old base until rebased", func(t *testing.T) {
// The child merged into the parent, which was deleted without merging.
repo := setupBaseRetargetRepo(t, "main", false)
withIssue250Repo(t, repo.dir)
sf, err := stack.Load(repo.gitDir)
require.NoError(t, err)
sf.Stacks = nil
require.NoError(t, stack.Save(repo.gitDir, sf))
child := issue250Git(t, repo.dir, "rev-parse", "child")
repo.client.FindPRByNumberFn = func(n int) (*github.PullRequest, error) {
if n == 11 {
return &github.PullRequest{Number: 11, HeadRefName: "child", BaseRefName: "parent", State: "MERGED", Merged: true, HeadRefOID: child}, nil
}
return &github.PullRequest{Number: 12, HeadRefName: "top", BaseRefName: "main", State: "OPEN"}, nil
}
cfg := issue250TestConfig(t)
cfg.GitHubClientOverride = repo.client

require.NoError(t, runCheckout(cfg, &checkoutOptions{target: "9"}))
sf, err = stack.Load(repo.gitDir)
require.NoError(t, err)
assert.Equal(t, "parent", sf.Stacks[0].Trunk.Branch)
top := issue250Git(t, repo.dir, "rev-parse", "top")
require.Error(t, runRebase(cfg, &rebaseOptions{remote: "origin"}), "dropping the unmerged parent needs consent")
assert.Equal(t, top, issue250Git(t, repo.dir, "rev-parse", "top"))
})
}
3 changes: 3 additions & 0 deletions cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
}
return ErrSilent
}
if err := requireCurrentBase(cfg, s, remote); err != nil {
return err
}
// Sync PR state to detect merged/queued PRs before pushing.
_ = syncStackPRs(cfg, s)

Expand Down
20 changes: 20 additions & 0 deletions cmd/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,23 @@ func TestPickRemote_OverrideTakesPrecedence(t *testing.T) {
assert.Equal(t, "custom", remote)
assert.False(t, resolveCalled, "ResolveRemote should not be called when override is provided")
}

func TestPush_BaseRetarget(t *testing.T) {
dir := t.TempDir()
writeStackFile(t, dir, stack.Stack{ID: "9", Number: 9, Trunk: stack.BranchRef{Branch: "parent"}, Branches: []stack.BranchRef{{Branch: "child"}}})
mock := newPushMock(dir, "child")
mock.PushFn = func(string, []string, bool, bool) error {
t.Fatal("must not push before rebasing onto the new base")
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
GetStackFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 9, Base: github.RemoteStackBase{Ref: "main"}}, nil
},
}

assert.ErrorIs(t, runPush(cfg, &pushOptions{remote: "origin"}), ErrSilent)
}
29 changes: 27 additions & 2 deletions cmd/rebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type rebaseOptions struct {
noTrunk bool
remote string
committerDateIsAuthorDate bool
acceptBaseChange bool
}

type rebaseState struct {
Expand All @@ -34,6 +35,7 @@ type rebaseState struct {
Worktrees *worktree.Context `json:"worktrees,omitempty"`
StackID string `json:"stackId,omitempty"`
StackTrunk string `json:"stackTrunk,omitempty"`
TargetTrunk string `json:"targetTrunk,omitempty"`
StackBranches []string `json:"stackBranches,omitempty"`
OriginalStack *stack.Stack `json:"originalStack,omitempty"`
RebaseBase string `json:"rebaseBase,omitempty"`
Expand Down Expand Up @@ -72,7 +74,11 @@ layer in its commit history, rebasing if necessary.

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.).`,
branch 3 onto branch 2, etc.).

If GitHub retargeted the stack because its base branch was deleted, the
stack is rebased onto the new base. Removing commits from an old base that
did not merge requires confirmation, or --accept-base-change.`,
Example: ` # Rebase the entire stack
$ gh stack rebase

Expand Down Expand Up @@ -102,6 +108,7 @@ branch 3 onto branch 2, etc.).`,
cmd.Flags().BoolVar(&opts.downstack, "downstack", false, "Only rebase branches from trunk to current branch")
cmd.Flags().BoolVar(&opts.upstack, "upstack", false, "Only rebase branches from current branch to top")
cmd.Flags().BoolVar(&opts.noTrunk, "no-trunk", false, "Skip trunk — only rebase stack branches onto each other")
cmd.Flags().BoolVar(&opts.acceptBaseChange, "accept-base-change", false, "Rebase onto a retargeted base even if the old base did not merge")
cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue rebase after resolving conflicts")
cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort rebase and restore all branches")
cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from (defaults to auto-detected remote)")
Expand Down Expand Up @@ -192,6 +199,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
}

var trunk trunkTarget
var newBase, cutoff string
if !opts.noTrunk {
// Resolve remote for fetch and trunk comparison
remote, err := pickRemote(cfg, anchor, opts.remote)
Expand All @@ -201,6 +209,14 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
}
return ErrSilent
}
if newBase, err = retargetedBase(cfg, s, remote); err != nil {
cfg.Errorf("failed to read the stack's base on GitHub: %v", err)
return ErrAPIFailure
}
if i := s.FirstActiveBranchIndex(); newBase != "" && i >= 0 && (i < startIdx || i >= endIdx) {
cfg.Errorf("rebase the whole stack to move it onto %s", newBase)
return ErrInvalidArgs
}

// Fast-forward stack branches that are behind their remote tracking branch.
if err := git.FetchBranches(remote, activeBranchNames(s)); err != nil {
Expand All @@ -215,10 +231,15 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
cfg.Errorf("%s", err)
return ErrSilent
}
trunk, err = resolveTrunkTarget(cfg, s, remote, currentBranch, trunkResolveOptions{Worktrees: ctx})
trunk, err = resolveTrunkTarget(cfg, s, remote, currentBranch, trunkResolveOptions{Worktrees: ctx, Branch: newBase})
if err != nil {
return err
}
if newBase != "" {
if cutoff, err = retargetCutoff(cfg, s, trunk, planned, opts.acceptBaseChange); err != nil {
return err
}
}
if _, err := fastForwardBranches(cfg, planned, ctx); err != nil {
cfg.Errorf("%s", err)
return ErrSilent
Expand Down Expand Up @@ -254,11 +275,15 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
ontoOldBase = originalRefs[prev.Branch]
}
}
if cutoff != "" {
needsOnto, ontoOldBase = true, cutoff
}

state := newWorktreeRebaseState(s, ctx, currentBranch, originalRefs, trunk, startIdx, endIdx)
state.CommitterDateIsAuthorDate = opts.committerDateIsAuthorDate
state.NoTrunk = opts.noTrunk
state.UseOnto, state.OntoOldBase = needsOnto, ontoOldBase
state.TargetTrunk = newBase
if s.Trunk.Branch != originalTrunk {
if err := stack.Save(gitDir, sf); err != nil {
return handleSaveError(cfg, err)
Expand Down
Loading
Loading