diff --git a/cmd/checkout.go b/cmd/checkout.go index 61c28b5..64675b9 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -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 } diff --git a/cmd/checkout_test.go b/cmd/checkout_test.go index 4fb0870..7c3fcd1 100644 --- a/cmd/checkout_test.go +++ b/cmd/checkout_test.go @@ -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")) + }) +} diff --git a/cmd/push.go b/cmd/push.go index 9455a30..38529f4 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -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) diff --git a/cmd/push_test.go b/cmd/push_test.go index 059b003..15fa187 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -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) +} diff --git a/cmd/rebase.go b/cmd/rebase.go index a567c11..6e29d65 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -26,6 +26,7 @@ type rebaseOptions struct { noTrunk bool remote string committerDateIsAuthorDate bool + acceptBaseChange bool } type rebaseState struct { @@ -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"` @@ -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 @@ -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)") @@ -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) @@ -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 { @@ -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 @@ -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) diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 308aa34..3e2d4e7 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" @@ -3822,3 +3823,349 @@ func TestRebase_LegacyCompletionRetainsRetryState(t *testing.T) { }) } } + +type baseRetargetRepo struct { + amendedParentRepo + target string + remote *github.RemoteStack + client *github.MockClient +} + +// setupBaseRetargetRepo builds child <- top on a deleted parent branch whose +// stack GitHub retargeted to target. When merged, target has the squashed parent. +func setupBaseRetargetRepo(t *testing.T, target string, merged bool) baseRetargetRepo { + t.Helper() + repo := setupAmendedParentRepo(t, false) + issue250Git(t, repo.dir, "checkout", "-b", "top") + issue250WriteFile(t, repo.dir, "top.txt", "top\n") + issue250Git(t, repo.dir, "add", ".") + issue250Git(t, repo.dir, "commit", "-m", "top commit") + issue250Git(t, repo.dir, "push", "-u", "origin", "top") + issue250Git(t, repo.dir, "checkout", "-B", target, "main") + if merged { + issue250Git(t, repo.dir, "merge", "--squash", "origin/parent") + issue250Git(t, repo.dir, "commit", "-m", "squashed parent") + } + mergeCommit := issue250Git(t, repo.dir, "rev-parse", target) + issue250Git(t, repo.dir, "push", "origin", target) + issue250Git(t, repo.dir, "push", "origin", "--delete", "parent") + issue250Git(t, repo.dir, "checkout", "top") + child := issue250Git(t, repo.dir, "rev-parse", "child") + writeStackFile(t, repo.gitDir, stack.Stack{ + ID: "9", Number: 9, + Trunk: stack.BranchRef{Branch: "parent", Head: repo.oldParent}, + Branches: []stack.BranchRef{ + {Branch: "child", Base: repo.oldParent, Head: child, PullRequest: &stack.PullRequestRef{Number: 11}}, + {Branch: "top", Base: child, Head: issue250Git(t, repo.dir, "rev-parse", "top"), PullRequest: &stack.PullRequestRef{Number: 12}}, + }, + }) + remote := &github.RemoteStack{ID: 9, Number: 9, Base: github.RemoteStackBase{Ref: target}, PullRequests: []int{11, 12}} + forbid := func(string) { t.Fatal("following a retarget must not restructure the stack on GitHub") } + client := &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{*remote}, nil }, + GetStackFn: func(int) (*github.RemoteStack, error) { return remote, nil }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + heads := map[int]string{11: "child", 12: "top"} + return &github.PullRequest{Number: n, HeadRefName: heads[n], State: "OPEN"}, nil + }, + FindPRDetailsForBranchFn: func(branch string) (*github.PRDetails, error) { + return &github.PRDetails{ + Number: 10, Merged: merged && branch == "parent", + HeadRefOID: repo.oldParent, MergeCommitOID: mergeCommit, + }, nil + }, + UnstackFn: func(int) (*github.RemoteStack, bool, error) { forbid("unstack"); return nil, false, nil }, + UpdatePRBaseFn: func(int, string) error { forbid("base"); return nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { forbid("create"); return nil, nil }, + } + return baseRetargetRepo{repo, target, remote, client} +} + +func assertBaseRetargetHistory(t *testing.T, repo baseRetargetRepo) { + t.Helper() + assert.Equal(t, "top commit\nchild commit", issue250Git(t, repo.dir, "log", "--format=%s", repo.target+"..top")) + assert.Error(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", repo.oldParent, "child")) + assert.Equal(t, repo.newParent, issue250Git(t, repo.dir, "rev-parse", "parent"), "the old local base is kept") + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + s := sf.Stacks[0] + assert.Equal(t, "9", s.ID) + assert.Equal(t, repo.target, s.Trunk.Branch) + target := issue250Git(t, repo.dir, "rev-parse", repo.target) + assert.Equal(t, target, s.Trunk.Head) + assert.Equal(t, target, s.Branches[0].Base) +} + +func TestRebase_BaseRetarget(t *testing.T) { + for _, target := range []string{"main", "release"} { + t.Run(target, func(t *testing.T) { + repo := setupBaseRetargetRepo(t, target, true) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + cfg.GitHubClientOverride = repo.client + + require.NoError(t, runRebase(cfg, &rebaseOptions{remote: "origin"})) + assertBaseRetargetHistory(t, repo) + top := issue250Git(t, repo.dir, "rev-parse", "top") + require.NoError(t, runRebase(cfg, &rebaseOptions{remote: "origin"})) + assert.Equal(t, top, issue250Git(t, repo.dir, "rev-parse", "top")) + }) + } +} + +func TestRebase_BaseRetargetConsent(t *testing.T) { + for _, mode := range []string{"non-interactive", "decline", "interrupt", "upstack", "accept", "flag"} { + t.Run(mode, func(t *testing.T) { + repo := setupBaseRetargetRepo(t, "main", false) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + cfg.GitHubClientOverride = repo.client + cfg.ForceInteractive = mode == "decline" || mode == "interrupt" || mode == "accept" + cfg.ConfirmFn = func(_ string, defaultValue bool) (bool, error) { + assert.False(t, defaultValue) + if mode == "interrupt" { + return false, terminal.InterruptErr + } + return mode == "accept", nil + } + catalog, err := os.ReadFile(filepath.Join(repo.gitDir, "gh-stack")) + require.NoError(t, err) + top := issue250Git(t, repo.dir, "rev-parse", "top") + + err = runRebase(cfg, &rebaseOptions{remote: "origin", upstack: mode == "upstack", acceptBaseChange: mode == "flag"}) + if mode == "accept" || mode == "flag" { + require.NoError(t, err) + assertBaseRetargetHistory(t, repo) + assert.NoFileExists(t, filepath.Join(repo.dir, "old-parent.txt")) + return + } + require.Error(t, err) + after, err := os.ReadFile(filepath.Join(repo.gitDir, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, string(catalog), string(after)) + assert.Equal(t, top, issue250Git(t, repo.dir, "rev-parse", "top")) + assert.NoFileExists(t, filepath.Join(repo.gitDir, rebaseStateFile)) + }) + } +} + +func TestRebase_BaseRetargetRecovery(t *testing.T) { + for _, action := range []string{"continue", "abort"} { + t.Run(action, func(t *testing.T) { + repo := setupBaseRetargetRepo(t, "main", true) + issue250Git(t, repo.dir, "checkout", "main") + issue250WriteFile(t, repo.dir, "child.txt", "conflict\n") + issue250Git(t, repo.dir, "add", ".") + issue250Git(t, repo.dir, "commit", "-m", "conflict") + issue250Git(t, repo.dir, "push", "origin", "main") + issue250Git(t, repo.dir, "checkout", "top") + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + cfg.GitHubClientOverride = repo.client + top := issue250Git(t, repo.dir, "rev-parse", "top") + + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{remote: "origin"}), ErrConflict) + state, err := loadRebaseState(repo.gitDir) + require.NoError(t, err) + assert.Equal(t, "main", state.TargetTrunk) + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + assert.Equal(t, "parent", sf.Stacks[0].Trunk.Branch, "the trunk changes only after the rebase completes") + + // Recovery keeps the target accepted when the rebase started. + repo.remote.Base.Ref = "later-base" + if action == "continue" { + issue250WriteFile(t, repo.dir, "child.txt", "resolved\n") + issue250Git(t, repo.dir, "add", "child.txt") + require.NoError(t, runRebase(cfg, &rebaseOptions{cont: true})) + assertBaseRetargetHistory(t, repo) + } else { + require.NoError(t, runRebase(cfg, &rebaseOptions{abort: true})) + sf, err = stack.Load(repo.gitDir) + require.NoError(t, err) + assert.Equal(t, "parent", sf.Stacks[0].Trunk.Branch) + assert.Equal(t, repo.oldParent, sf.Stacks[0].Branches[0].Base) + assert.Equal(t, top, issue250Git(t, repo.dir, "rev-parse", "top")) + } + assert.NoFileExists(t, filepath.Join(repo.gitDir, rebaseStateFile)) + }) + } +} + +// A crash after publishing the new trunk leaves a completed journal behind. +func TestRebase_BaseRetargetCompletedJournal(t *testing.T) { + for _, action := range []string{"continue", "abort"} { + t.Run(action, func(t *testing.T) { + dir := t.TempDir() + original := stack.Stack{Trunk: stack.BranchRef{Branch: "parent", Head: "old-parent"}, Branches: []stack.BranchRef{{Branch: "b1", Base: "old-parent"}}} + published := original + published.Trunk.Branch = "main" + writeStackFile(t, dir, published) + restore := git.SetOps(newRebaseMock(dir, "b1")) + defer restore() + ctx, err := worktree.New() + require.NoError(t, err) + state := newWorktreeRebaseState(&original, ctx, "b1", map[string]string{"b1": "sha-b1"}, trunkTarget{Branch: "main", Ref: "main", SHA: "sha-main"}, 0, 1) + state.TargetTrunk = "main" + state.Phase, state.CurrentBranchIndex = "complete", state.EndIndex + require.NoError(t, saveRebaseState(dir, state)) + + require.NoError(t, runRebase(issue250TestConfig(t), &rebaseOptions{cont: action == "continue", abort: action == "abort"})) + sf, err := stack.Load(dir) + require.NoError(t, err) + want := map[string]string{"continue": "main", "abort": "parent"}[action] + assert.Equal(t, want, sf.Stacks[0].Trunk.Branch) + assert.NoFileExists(t, filepath.Join(dir, rebaseStateFile)) + }) + } +} + +func TestRebase_BaseRetargetReviewCases(t *testing.T) { + testBaseRetargetReviewCases(t, func(cfg *config.Config, accept bool) error { + return runRebase(cfg, &rebaseOptions{remote: "origin", acceptBaseChange: accept}) + }) +} + +func testBaseRetargetReviewCases(t *testing.T, run func(*config.Config, bool) error) { + t.Helper() + for _, scenario := range []string{ + "stale local trunk", "old trunk reflog", "missing old trunk", + "missing cutoff", "planned fast-forward", "prefix merged before base", + "prefix merged after base", "prefix merged into target", + "merged elsewhere", "commits after merge", "amended before merge", + } { + t.Run(scenario, func(t *testing.T) { + target := "main" + if scenario == "merged elsewhere" { + target = "release" + } + prefix := strings.HasPrefix(scenario, "prefix") + merged := prefix || scenario == "merged elsewhere" || + scenario == "commits after merge" || scenario == "amended before merge" + repo := setupBaseRetargetRepo(t, target, merged) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + cfg.GitHubClientOverride = repo.client + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + s := &sf.Stacks[0] + child := issue250Git(t, repo.dir, "rev-parse", "child") + wantConsent := false + accept := false + switch scenario { + case "stale local trunk": + issue250Git(t, repo.dir, "branch", "-f", "parent", "main") + wantConsent = true + case "old trunk reflog", "missing cutoff": + s.Trunk.Head = repo.newParent + s.Branches[0].Base = repo.newParent + accept = true + if scenario == "missing cutoff" { + issue250Git(t, repo.dir, "reflog", "expire", "--expire=now", "--all") + } + case "missing old trunk": + issue250Git(t, repo.dir, "branch", "-D", "parent") + accept = true + case "planned fast-forward": + // The fetched child contains a parent update that the local child + // has not seen yet; consent must cover the post-fast-forward history. + issue250Git(t, repo.dir, "branch", "-f", "main", repo.oldParent) + issue250Git(t, repo.dir, "push", "origin", "main") + issue250Git(t, repo.dir, "checkout", "-B", "parent", repo.oldParent) + issue250WriteFile(t, repo.dir, "parent-extra.txt", "unmerged parent update\n") + issue250Git(t, repo.dir, "add", ".") + issue250Git(t, repo.dir, "commit", "-m", "parent extra") + issue250Git(t, repo.dir, "checkout", "child") + issue250Git(t, repo.dir, "merge", "--no-edit", "parent") + issue250Git(t, repo.dir, "push", "origin", "child") + issue250Git(t, repo.dir, "checkout", "top") + issue250Git(t, repo.dir, "branch", "-f", "child", child) + wantConsent = true + case "prefix merged before base", "prefix merged after base", "prefix merged into target": + s.Branches[0].PullRequest.Merged = true + s.Branches[0].PullRequest.MergedHead = child + lookup := repo.client.FindPRByNumberFn + repo.client.FindPRByNumberFn = func(n int) (*github.PullRequest, error) { + pr, err := lookup(n) + if n == 11 { + pr.Merged, pr.State, pr.HeadRefOID = true, "MERGED", child + } + return pr, err + } + wantConsent = scenario == "prefix merged after base" + if wantConsent { + break + } + // The child's changes reached main through the parent or directly. + issue250Git(t, repo.dir, "checkout", "main") + issue250Git(t, repo.dir, "merge", "--squash", "origin/child") + issue250Git(t, repo.dir, "commit", "-m", "squashed child") + issue250Git(t, repo.dir, "push", "origin", "main") + issue250Git(t, repo.dir, "checkout", "top") + squash := issue250Git(t, repo.dir, "rev-parse", "main") + details := repo.client.FindPRDetailsForBranchFn + repo.client.FindPRDetailsForBranchFn = func(branch string) (*github.PRDetails, error) { + pr, err := details(branch) + if scenario == "prefix merged before base" && branch == "parent" { + pr.HeadRefOID = repo.newParent // the parent took the child before merging + } else if scenario == "prefix merged into target" && branch == "child" { + pr.Merged, pr.HeadRefOID, pr.MergeCommitOID = true, child, squash + } + return pr, err + } + case "merged elsewhere": + repo.remote.Base.Ref = "main" + target = "main" + wantConsent = true + case "commits after merge": + issue250Git(t, repo.dir, "checkout", "-B", "parent", repo.oldParent) + issue250WriteFile(t, repo.dir, "parent-extra.txt", "not in the merged PR\n") + issue250Git(t, repo.dir, "add", ".") + issue250Git(t, repo.dir, "commit", "-m", "parent extra") + issue250Git(t, repo.dir, "rebase", "--onto", "parent", repo.oldParent, "child") + issue250Git(t, repo.dir, "rebase", "--onto", "child", child, "top") + s.Trunk.Head = issue250Git(t, repo.dir, "rev-parse", "parent") + s.Branches[0].Base = s.Trunk.Head + s.Branches[1].Base = issue250Git(t, repo.dir, "rev-parse", "child") + wantConsent = true + case "amended before merge": + // The parent was amended after the child last synced, then merged. + details := repo.client.FindPRDetailsForBranchFn + repo.client.FindPRDetailsForBranchFn = func(branch string) (*github.PRDetails, error) { + pr, err := details(branch) + pr.HeadRefOID = repo.newParent + return pr, err + } + } + require.NoError(t, stack.Save(repo.gitDir, sf)) + before, err := os.ReadFile(filepath.Join(repo.gitDir, "gh-stack")) + require.NoError(t, err) + refs := issue250Git(t, repo.dir, "rev-parse", "child", "top", "origin/child", "origin/top") + + err = run(cfg, accept) + if wantConsent || scenario == "missing cutoff" { + require.Error(t, err) + after, readErr := os.ReadFile(filepath.Join(repo.gitDir, "gh-stack")) + require.NoError(t, readErr) + assert.Equal(t, before, after) + assert.Equal(t, refs, issue250Git(t, repo.dir, "rev-parse", "child", "top", "origin/child", "origin/top")) + assert.NoFileExists(t, filepath.Join(repo.gitDir, rebaseStateFile)) + if scenario == "missing cutoff" { + return + } + err = run(cfg, true) + } + require.NoError(t, err) + wantCommits := "top commit\nchild commit" + if prefix { + wantCommits = "top commit" + } + assert.Equal(t, wantCommits, issue250Git(t, repo.dir, "log", "--format=%s", target+"..top")) + assert.NoFileExists(t, filepath.Join(repo.dir, "parent-extra.txt")) + after, err := stack.Load(repo.gitDir) + require.NoError(t, err) + assert.Equal(t, target, after.Stacks[0].Trunk.Branch) + assert.Equal(t, "9", after.Stacks[0].ID) + }) + } +} diff --git a/cmd/rebase_worktree.go b/cmd/rebase_worktree.go index cbb36c2..f078f10 100644 --- a/cmd/rebase_worktree.go +++ b/cmd/rebase_worktree.go @@ -125,7 +125,7 @@ func restoreWorktreeRebaseMetadata(dir string, state *rebaseState) error { if !slices.Equal(state.OriginalStack.BranchNames(), target.BranchNames()) { return fmt.Errorf("original stack snapshot does not match the recovery target") } - target.Trunk.Head = state.OriginalStack.Trunk.Head + target.Trunk = state.OriginalStack.Trunk for i, before := range state.OriginalStack.Branches { target.Branches[i].Base = before.Base target.Branches[i].Head = before.Head @@ -147,7 +147,8 @@ func rebaseStackFromState(sf *stack.StackFile, state *rebaseState) (*stack.Stack var target *stack.Stack for i := range sf.Stacks { s := &sf.Stacks[i] - if s.Trunk.Branch != state.StackTrunk || !slices.Equal(s.BranchNames(), state.StackBranches) { + trunk := s.Trunk.Branch + if (trunk != state.StackTrunk && trunk != state.TargetTrunk) || !slices.Equal(s.BranchNames(), state.StackBranches) { continue } if (state.Phase == "applying" || state.Phase == "conflict") && state.StackID != "" && s.ID != "" && state.StackID != s.ID { @@ -328,6 +329,9 @@ func finishWorktreeRebase(cfg *config.Config, dir string, state *rebaseState, sf } func publishCompletedRebase(cfg *config.Config, dir string, state *rebaseState, sf *stack.StackFile, s *stack.Stack) error { + if state.TargetTrunk != "" { + s.Trunk.Branch = state.TargetTrunk + } updateBaseSHAsWithTrunk(s, state.TrunkSHA) state.Phase = "complete" state.CurrentBranchIndex = state.EndIndex diff --git a/cmd/submit.go b/cmd/submit.go index 36fe8c8..4151efd 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -156,6 +156,9 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { } return ErrSilent } + if err := requireCurrentBase(cfg, s, remote); err != nil { + return err + } merged := s.MergedBranches() if len(merged) > 0 { cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches")) diff --git a/cmd/submit_test.go b/cmd/submit_test.go index 2f4dfa9..6286839 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -3145,3 +3145,33 @@ func TestEnsurePR_DeselectedMissingPRIsIncompleteWithCachedAssociation(t *testin assert.False(t, submitted, "a cached PR count must not turn a deselected missing PR into success") commandOutput(t, cfg, outR, errR) } + +func TestSubmit_BaseRetarget(t *testing.T) { + for name, tt := range map[string]struct { + err error + want error + }{ + "retargeted": {want: ErrSilent}, + "lookup failure": {err: fmt.Errorf("unavailable"), want: ErrAPIFailure}, + } { + t.Run(name, func(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 := newSubmitMock(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"}}, tt.err + }, + } + + assert.ErrorIs(t, runSubmit(cfg, &submitOptions{auto: true, remote: "origin"}), tt.want) + }) + } +} diff --git a/cmd/sync.go b/cmd/sync.go index d3800a1..c2c058a 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -15,8 +15,9 @@ import ( ) type syncOptions struct { - remote string - prune bool + remote string + prune bool + acceptBaseChange bool } func SyncCmd(cfg *config.Config) *cobra.Command { @@ -60,6 +61,10 @@ stack, while "Branches synced" means the branches were rebased and pushed but no remote stack object was created or updated (for example, when fewer than two PRs exist yet). +If GitHub retargeted the stack because its base branch was deleted, sync +rebases the stack onto the new base. Removing commits from an old base that +did not merge requires confirmation, or --accept-base-change. + Use --prune to delete local branches for merged PRs. Stack metadata is preserved so that rebase and display logic continue to work correctly. If you are on a branch that would be pruned, your checkout is moved to @@ -71,6 +76,7 @@ the first active branch in the stack, or the trunk if all are merged.`, cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from and push to (defaults to auto-detected remote)") cmd.Flags().BoolVar(&opts.prune, "prune", false, "Delete local branches for merged PRs") + cmd.Flags().BoolVar(&opts.acceptBaseChange, "accept-base-change", false, "Rebase onto a retargeted base even if the old base did not merge") return cmd } @@ -151,6 +157,11 @@ func runSync(cfg *config.Config, opts *syncOptions) error { currentBranch = cb } _ = syncStackPRs(cfg, s) + newBase, err := retargetedBase(cfg, s, remote) + if err != nil { + cfg.Errorf("failed to read the stack's base on GitHub: %v", err) + return ErrAPIFailure + } ctx, err := worktree.New() if err != nil { cfg.Errorf("%s", err) @@ -161,12 +172,13 @@ func runSync(cfg *config.Config, opts *syncOptions) error { // --- Step 2: Resolve trunk --- trunk, err := resolveTrunkTarget(cfg, s, remote, currentBranch, trunkResolveOptions{ Worktrees: ctx, + Branch: newBase, Preflight: func(sha string, moved bool) error { var required []string for _, forward := range planned { required = append(required, forward.Branch) } - if moved || len(planned) > 0 || stackNeedsRebase(s, sha) { + if newBase != "" || moved || len(planned) > 0 || stackNeedsRebase(s, sha) { required = append(required, activeBranchNames(s)...) } if err := ctx.Preflight(required); err != nil { @@ -179,6 +191,12 @@ func runSync(cfg *config.Config, opts *syncOptions) error { if err != nil { return err } + var cutoff string + if newBase != "" { + if cutoff, err = retargetCutoff(cfg, s, trunk, planned, opts.acceptBaseChange); err != nil { + return err + } + } // --- Step 2b: Fast-forward stack branches behind their remote tracking branch --- updatedBranches, err := fastForwardBranches(cfg, planned, ctx) @@ -188,7 +206,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } // --- Step 3: Cascade rebase --- - needsRebase := trunk.Moved || len(updatedBranches) > 0 || stackNeedsRebase(s, trunk.Ref) + needsRebase := newBase != "" || trunk.Moved || len(updatedBranches) > 0 || stackNeedsRebase(s, trunk.Ref) rebased := false var originalRefs map[string]string var state *rebaseState @@ -202,6 +220,8 @@ func runSync(cfg *config.Config, opts *syncOptions) error { return ErrSilent } else { state = newWorktreeRebaseState(s, ctx, currentBranch, originalRefs, trunk, 0, len(s.Branches)) + state.TargetTrunk = newBase + state.UseOnto, state.OntoOldBase = cutoff != "", cutoff if s.Trunk.Branch != originalTrunk { if err := stack.Save(gitDir, sf); err != nil { return handleSaveError(cfg, err) @@ -217,6 +237,8 @@ func runSync(cfg *config.Config, opts *syncOptions) error { Branches: s.Branches, StartAbsIdx: 0, OriginalRefs: originalRefs, + NeedsOnto: cutoff != "", + OntoOldBase: cutoff, TrunkRef: trunk.Ref, TrunkSHA: trunk.SHA, Worktrees: ctx, diff --git a/cmd/sync_test.go b/cmd/sync_test.go index b8e81a7..2682c76 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -2857,3 +2857,50 @@ func TestSync_RollbackFailureRetainsRecovery(t *testing.T) { assert.Equal(t, "b2", current) assert.NoFileExists(t, filepath.Join(dir, rebaseStateFile)) } + +func TestSync_BaseRetarget(t *testing.T) { + for _, merged := range []bool{true, false} { + t.Run(fmt.Sprintf("merged=%t", merged), func(t *testing.T) { + repo := setupBaseRetargetRepo(t, "main", merged) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + cfg.GitHubClientOverride = repo.client + + require.NoError(t, runSync(cfg, &syncOptions{remote: "origin", acceptBaseChange: !merged})) + assertBaseRetargetHistory(t, repo) + assert.Equal(t, issue250Git(t, repo.dir, "rev-parse", "top"), issue250Git(t, repo.dir, "rev-parse", "origin/top")) + top := issue250Git(t, repo.dir, "rev-parse", "top") + require.NoError(t, runSync(cfg, &syncOptions{remote: "origin"})) + assert.Equal(t, top, issue250Git(t, repo.dir, "rev-parse", "top")) + }) + } +} + +func TestSync_BaseRetargetConflictKeepsOldBase(t *testing.T) { + repo := setupBaseRetargetRepo(t, "main", true) + issue250Git(t, repo.dir, "checkout", "main") + issue250WriteFile(t, repo.dir, "child.txt", "conflict\n") + issue250Git(t, repo.dir, "add", ".") + issue250Git(t, repo.dir, "commit", "-m", "conflict") + issue250Git(t, repo.dir, "push", "origin", "main") + issue250Git(t, repo.dir, "checkout", "top") + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + cfg.GitHubClientOverride = repo.client + top := issue250Git(t, repo.dir, "rev-parse", "top") + + require.ErrorIs(t, runSync(cfg, &syncOptions{remote: "origin"}), ErrConflict) + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + assert.Equal(t, "parent", sf.Stacks[0].Trunk.Branch) + assert.Equal(t, repo.oldParent, sf.Stacks[0].Branches[0].Base) + assert.Equal(t, top, issue250Git(t, repo.dir, "rev-parse", "top")) + assert.Equal(t, top, issue250Git(t, repo.dir, "rev-parse", "origin/top")) + assert.NoFileExists(t, filepath.Join(repo.gitDir, rebaseStateFile)) +} + +func TestSync_BaseRetargetReviewCases(t *testing.T) { + testBaseRetargetReviewCases(t, func(cfg *config.Config, accept bool) error { + return runSync(cfg, &syncOptions{remote: "origin", acceptBaseChange: accept}) + }) +} diff --git a/cmd/utils.go b/cmd/utils.go index 70cf45d..d8aa476 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -1087,6 +1087,155 @@ func normalizeStackTrunk(cfg *config.Config, s *stack.Stack, remote string) erro return nil } +// retargetedBase returns the branch GitHub moved a tracked stack onto, or "" +// if the base is unchanged. GitHub retargets a stack when its base branch is +// deleted but does not rebase the stack's branches. +func retargetedBase(cfg *config.Config, s *stack.Stack, remote string) (string, error) { + if s.ID == "" { + return "", nil + } + client, err := cfg.GitHubClient() + if err != nil { + return "", err + } + number, err := ensureStackNumber(client, s) + if err != nil || number == 0 { + return "", err + } + rs, err := client.GetStack(number) + if isNotFound(err) { + return "", nil + } + if err != nil || rs == nil || rs.Base.Ref == "" { + return "", err + } + trunk, err := normalizeTrunkBranch(s.Trunk.Branch, remote) + if err != nil || rs.Base.Ref == trunk { + return "", err + } + cfg.Warningf("GitHub retargeted this stack%s from %s to %s", linkedStackLabel(cfg, s.Number), s.Trunk.Branch, rs.Base.Ref) + return rs.Base.Ref, nil +} + +// requireCurrentBase stops publishing a stack that GitHub retargeted until it +// has been rebased onto its new base. +func requireCurrentBase(cfg *config.Config, s *stack.Stack, remote string) error { + newBase, err := retargetedBase(cfg, s, remote) + if err != nil { + cfg.Errorf("failed to read the stack's base on GitHub: %v", err) + return ErrAPIFailure + } + if newBase != "" { + cfg.Errorf("run `%s` to rebase the stack onto %s before publishing", cfg.ColorCyan("gh stack sync"), newBase) + return ErrSilent + } + return nil +} + +// retargetCutoff returns the old base commit for rebasing the bottom branch +// onto a retargeted base, or "" when merged branches below it already set the +// cutoff. Dropping commits that are not known to have landed in the new base +// requires consent. +func retargetCutoff(cfg *config.Config, s *stack.Stack, target trunkTarget, planned []branchFastForward, accept bool) (string, error) { + i := s.FirstActiveBranchIndex() + if i < 0 { + return "", nil + } + b := s.Branches[i] + head := b.Branch + for _, forward := range planned { + if forward.Branch == b.Branch { + head = forward.NewSHA + } + } + var cutoff string + var err error + if i == 0 { + cutoff, err = resolveRetargetOldBase(s.Trunk.Branch, b.Base, head) + } else if prev := s.Branches[i-1]; prev.IsMerged() { + // The cascade also drops the merged branches below; check that boundary. + var refs map[string]string + if refs, err = resolveOriginalRefs(s); err == nil { + cutoff, err = resolveMergedRebaseOldBase(prev.PullRequest.MergedHead, refs[prev.Branch], b.Base, target.Ref, head) + } + } + landed := false + if err == nil && cutoff != "" { + landed, err = git.IsAncestor(cutoff, target.SHA) + } + if err != nil { + cfg.Errorf("%s", err) + return "", ErrSilent + } + if !landed && !accept { + // Merged branches below may have landed directly in the new base. + landed = retargetBaseMerged(cfg, s.Trunk.Branch, cutoff, target.SHA) || + i > 0 && retargetBaseMerged(cfg, s.Branches[i-1].Branch, cutoff, target.SHA) + } + if i > 0 { + cutoff = "" // the merged-branch cascade selects this boundary itself + } + if landed || accept { + return cutoff, nil + } + cfg.Warningf("%s is not known to have merged into %s; rebasing onto %s removes its commits from this stack", + s.Trunk.Branch, target.Branch, target.Branch) + if cfg.IsInteractive() { + ok, err := confirmPrune(cfg, "Rebase onto "+target.Branch+" without them?", false) + if isInterruptError(err) { + printInterrupt(cfg) + return "", ErrSilent + } + if err == nil && ok { + return cutoff, nil + } + cfg.Canceled = true + } + cfg.Errorf("stack not rebased; rerun with --accept-base-change to rebase onto %s", target.Branch) + return "", ErrSilent +} + +// resolveRetargetOldBase finds the old base from the recorded base and the old +// trunk's reflog. The new base's fork point may sit below the old base's +// commits, so it is never used. +func resolveRetargetOldBase(oldTrunk, recordedBase, head string) (string, error) { + exists, err := git.BranchExists(oldTrunk) + if err != nil { + return "", err + } + if exists { + return resolveRebaseOldBase("", recordedBase, oldTrunk, head) + } + valid, err := rebaseBoundaryContains(recordedBase, head) + if err != nil { + return "", err + } + if !valid { + return "", fmt.Errorf("could not determine the previous base from %s; rebase this branch manually", oldTrunk) + } + return recordedBase, nil +} + +// retargetBaseMerged reports whether the old base's PR merged into the new base +// before the stack built on any later commits to the old base. +func retargetBaseMerged(cfg *config.Config, oldBase, cutoff, target string) bool { + client, err := cfg.GitHubClient() + if err != nil { + return false + } + pr, err := client.FindPRDetailsForBranch(oldBase) + if err != nil || pr == nil || !pr.Merged { + return false + } + if cutoff != "" && cutoff != pr.HeadRefOID { + if after, err := rebaseBoundaryContains(pr.HeadRefOID, cutoff); err != nil || after { + return false + } + } + landed, err := rebaseBoundaryContains(pr.MergeCommitOID, target) + return err == nil && landed +} + type trunkTarget struct { Branch string Ref string @@ -1097,6 +1246,7 @@ type trunkTarget struct { type trunkResolveOptions struct { Worktrees *worktree.Context Preflight func(string, bool) error + Branch string // overrides the stack's trunk } func (t trunkTarget) Describe() string { @@ -1123,6 +1273,9 @@ func resolveTrunkTarget(cfg *config.Config, s *stack.Stack, remote, currentBranc return trunkTarget{}, err } trunk := s.Trunk.Branch + if opts.Branch != "" { + trunk = opts.Branch + } remoteRef := remote + "/" + trunk if err := git.FetchBranch(remote, trunk); err != nil { diff --git a/cmd/view.go b/cmd/view.go index 548f048..eca65e8 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -75,6 +75,7 @@ func runView(cfg *config.Config, opts *viewOptions) error { sf := result.StackFile s := result.Stack currentBranch := result.CurrentBranch + warnRetargeted(cfg, s) // Show loading indicator for interactive TUI mode. showingLoader := false @@ -130,10 +131,21 @@ func runViewJSON(cfg *config.Config) error { } s := stacks[0] + retargeted := warnRetargeted(cfg, s) syncStackPRs(cfg, s) stack.SaveNonBlocking(gitDir, sf) - return viewJSON(cfg, s, currentBranch) + return viewJSON(cfg, s, currentBranch, retargeted) +} + +// warnRetargeted reports a GitHub retarget that has not been rebased onto yet. +func warnRetargeted(cfg *config.Config, s *stack.Stack) bool { + remote, _ := git.ResolveRemote(s.Trunk.Branch) + newBase, _ := retargetedBase(cfg, s, remote) + if newBase != "" { + cfg.Printf(" Run `%s` to rebase the stack onto %s", cfg.ColorCyan("gh stack sync"), newBase) + } + return newBase != "" } func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error { @@ -229,14 +241,18 @@ type viewJSONPR struct { State string `json:"state"` } -func viewJSON(cfg *config.Config, s *stack.Stack, currentBranch string) error { +func viewJSON(cfg *config.Config, s *stack.Stack, currentBranch string, retargeted bool) error { out := viewJSONOutput{ Trunk: s.Trunk.Branch, CurrentBranch: currentBranch, Branches: make([]viewJSONBranch, 0, len(s.Branches)), } + bottom := -1 + if retargeted { + bottom = s.FirstActiveBranchIndex() + } - for _, b := range s.Branches { + for i, b := range s.Branches { jb := viewJSONBranch{ Name: b.Branch, Head: b.Head, @@ -249,7 +265,7 @@ func viewJSON(cfg *config.Config, s *stack.Stack, currentBranch string) error { // Check if the branch needs rebasing (base not ancestor of branch). if !jb.IsMerged { baseBranch := s.ActiveBaseBranch(b.Branch) - if isAnc, err := git.IsAncestor(baseBranch, b.Branch); err == nil && !isAnc { + if isAnc, err := git.IsAncestor(baseBranch, b.Branch); i == bottom || (err == nil && !isAnc) { jb.NeedsRebase = true } } diff --git a/cmd/view_test.go b/cmd/view_test.go index d8410f8..086952e 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -148,7 +148,7 @@ func TestViewJSON(t *testing.T) { defer outR.Close() tt.stack.Number = 7 - err := viewJSON(cfg, tt.stack, tt.currentBranch) + err := viewJSON(cfg, tt.stack, tt.currentBranch, false) require.NoError(t, err) cfg.Out.Close() @@ -206,7 +206,7 @@ func TestViewJSON_BranchFields(t *testing.T) { cfg, outR, _ := config.NewTestConfig() defer outR.Close() - err := viewJSON(cfg, s, "feat/02") + err := viewJSON(cfg, s, "feat/02", false) require.NoError(t, err) cfg.Out.Close() @@ -622,3 +622,39 @@ func TestRunViewJSON_SharedCatalogDuringMutation(t *testing.T) { assert.Equal(t, before, after, "read-only metadata refresh cannot overwrite a mutation's catalog") assert.NoFileExists(t, filepath.Join(local, "gh-stack")) } + +func TestView_BaseRetarget(t *testing.T) { + for _, trunk := range []string{"parent", "origin/main"} { + t.Run(trunk, func(t *testing.T) { + repo := setupBaseRetargetRepo(t, "main", false) + withIssue250Repo(t, repo.dir) + retargeted := trunk == "parent" + if !retargeted { + // A remote-qualified trunk matching GitHub's base is not a retarget. + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + sf.Stacks[0].Trunk.Branch = trunk + require.NoError(t, stack.Save(repo.gitDir, sf)) + } + catalog, err := os.ReadFile(filepath.Join(repo.gitDir, "gh-stack")) + require.NoError(t, err) + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = repo.client + + require.NoError(t, runView(cfg, &viewOptions{asJSON: true})) + out, diagnostics := commandOutput(t, cfg, outR, errR) + var data viewJSONOutput + require.NoError(t, json.Unmarshal([]byte(out), &data)) + assert.Equal(t, trunk, data.Trunk) + assert.Equal(t, retargeted, data.Branches[0].NeedsRebase, "only a retargeted bottom branch needs a rebase") + if retargeted { + assert.Contains(t, diagnostics, "from parent to main") + } else { + assert.NotContains(t, diagnostics, "retargeted") + } + after, err := os.ReadFile(filepath.Join(repo.gitDir, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, string(catalog), string(after)) + }) + } +} diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 97047dd..6368d0c 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -384,6 +384,7 @@ gh stack sync [flags] |------|-------------| | `--remote ` | Remote to fetch from and push to (defaults to auto-detected remote) | | `--prune` | Delete local branches for merged PRs | +| `--accept-base-change` | Rebase onto a base GitHub retargeted the stack to, even if the old base did not merge | Performs a synchronization of the entire stack: @@ -396,7 +397,11 @@ Performs a synchronization of the entire stack: 7. **Sync the stack** — links the stack's open PRs into a stack on GitHub, creating the remote stack object if it doesn't exist yet or updating it if it's partially formed. 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 prune automatically. -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. +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, or before removing an unmerged base's commits (see **Retargeted stacks** below). + +**Retargeted stacks** + +When a stack's base branch is deleted, GitHub moves the stack onto a new base but does not rebase its branches. `sync` and `rebase` rebase the stack onto that base, keep its stack and PR numbers, and drop the old base's commits. If the old base did not merge into the new base, they ask before dropping its commits; in a non-interactive terminal, pass `--accept-base-change`. Until the stack is rebased, `push` and `submit` stop, and `view` reports the new base. **Diverged stacks** @@ -430,6 +435,7 @@ gh stack rebase [flags] [branch] | `--downstack` | Only rebase branches from trunk to the current branch | | `--upstack` | Only rebase branches from the current branch to the top | | `--no-trunk` | Skip trunk — only rebase stack branches onto each other (no fetch, no trunk rebase) | +| `--accept-base-change` | Rebase onto a base GitHub retargeted the stack to, even if the old base did not merge (see [`gh stack sync`](#gh-stack-sync)) | | `--continue` | Continue the rebase after resolving conflicts | | `--abort` | Abort the rebase and restore all branches to their pre-rebase state | | `--remote ` | Remote to fetch from (defaults to auto-detected remote) | diff --git a/internal/github/github.go b/internal/github/github.go index b6a96e4..88d3f6f 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -300,6 +300,10 @@ type PRDetails struct { IsDraft bool Merged bool IsQueued bool + + // Merge evidence for a stack retargeted off this PR's branch. + HeadRefOID string + MergeCommitOID string } // FindPRDetailsForBranch fetches enriched PR data for display purposes. @@ -315,6 +319,10 @@ func (c *Client) FindPRDetailsForBranch(branch string) (*PRDetails, error) { IsDraft bool `graphql:"isDraft"` Merged bool `graphql:"merged"` MergeQueueEntry *MergeQueueEntry `graphql:"mergeQueueEntry"` + HeadRefOID string `graphql:"headRefOid"` + MergeCommit struct { + OID string `graphql:"oid"` + } `graphql:"mergeCommit"` } } `graphql:"pullRequests(headRefName: $head, last: 1)"` } `graphql:"repository(owner: $owner, name: $name)"` @@ -343,6 +351,9 @@ func (c *Client) FindPRDetailsForBranch(branch string) (*PRDetails, error) { IsDraft: n.IsDraft, Merged: n.Merged, IsQueued: n.MergeQueueEntry != nil && n.MergeQueueEntry.ID != "", + + HeadRefOID: n.HeadRefOID, + MergeCommitOID: n.MergeCommit.OID, }, nil }