diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 3e2d4e7..e256a59 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -2843,6 +2843,57 @@ func TestIntegration_AmendedParentWithoutForkPointFailsSafely(t *testing.T) { assert.Equal(t, childBefore, issue250Git(t, repo.dir, "rev-parse", "child")) } +func TestRebase_ImportedStackAfterParentAdvance(t *testing.T) { + repo := setupIssue512Repo(t) + issue250Git(t, repo.dir, "checkout", "parent") + issue250WriteFile(t, repo.dir, "parent-extra.txt", "new parent content\n") + issue250Git(t, repo.dir, "add", "parent-extra.txt") + issue250Git(t, repo.dir, "commit", "-m", "parent extra") + issue250Git(t, repo.dir, "push", "origin", "parent") + newParent := issue250Git(t, repo.dir, "rev-parse", "parent") + + prs := []*github.PullRequest{ + {Number: 10, HeadRefName: "parent", BaseRefName: "main", BaseRefOID: repo.main, State: "OPEN"}, + {Number: 11, HeadRefName: "child", BaseRefName: "parent", BaseRefOID: repo.parent, State: "OPEN"}, + } + remote := github.RemoteStack{ + ID: 1, Number: 1, Base: github.RemoteStackBase{Ref: "main"}, PullRequests: []int{10, 11}, + } + cfg := issue250TestConfig(t) + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{remote}, nil + }, + GetStackFn: func(int) (*github.RemoteStack, error) { return &remote, nil }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + return prs[number-10], nil + }, + } + withIssue250Repo(t, repo.writer) + gitDir := filepath.Join(repo.writer, ".git") + sf, err := stack.Load(gitDir) + require.NoError(t, err) + s, err := importRemoteStack(cfg, sf, gitDir, "origin", "main", prs, "1", 1) + require.NoError(t, err) + require.Equal(t, newParent, s.Branches[1].Base) + require.NoError(t, stack.Save(gitDir, sf)) + issue250Git(t, repo.writer, "checkout", "child") + forkPoint, err := git.MergeBaseForkPoint("parent", "child") + require.NoError(t, err) + require.Empty(t, forkPoint) + + require.NoError(t, runRebase(cfg, &rebaseOptions{remote: "origin"})) + + assert.Equal(t, newParent, issue250Git(t, repo.writer, "merge-base", "parent", "child")) + assert.Equal(t, "child only", issue250Git(t, repo.writer, "log", "--format=%s", "parent..child")) + assert.Equal(t, "new parent content", issue250Git(t, repo.writer, "show", "child:parent-extra.txt")) + assert.Equal(t, "child", issue250Git(t, repo.writer, "show", "child:child.txt")) + sf, err = stack.Load(gitDir) + require.NoError(t, err) + assert.Equal(t, newParent, sf.Stacks[0].Branches[1].Base) + assert.NoFileExists(t, filepath.Join(gitDir, rebaseStateFile)) +} + func TestIntegration_AdoptedBranchRebasesFromCommonAncestor(t *testing.T) { remoteDir := filepath.Join(t.TempDir(), "remote.git") cloneDir := filepath.Join(t.TempDir(), "clone") diff --git a/cmd/utils.go b/cmd/utils.go index db43aa9..de751aa 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -549,7 +549,7 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) map[string]*github.PRDetai // Each branch is processed concurrently; results are collected and applied sequentially. type branchResult struct { index int - pullRequest *stack.PullRequestRef + pullRequest *github.PullRequest queued bool details *github.PRDetails skip bool // true means keep existing data, don't update @@ -603,7 +603,7 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) map[string]*github.PRDetai } if pr != nil && pr.State != "CLOSED" { // PR is open or merged — keep it - res.pullRequest = pullRequestRef(pr, branch.PullRequest) + res.pullRequest = pr res.queued = pr.IsQueued() res.details = prDetailsFromPR(pr) results[idx] = res @@ -622,7 +622,7 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) map[string]*github.PRDetai results[idx] = res return } - res.pullRequest = pullRequestRef(pr, nil) + res.pullRequest = pr res.queued = pr.IsQueued() // FindPRForBranch only returns OPEN PRs res.details = &github.PRDetails{ @@ -651,8 +651,9 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) map[string]*github.PRDetai } b := &s.Branches[res.index] if res.pullRequest != nil { - b.PullRequest = res.pullRequest + b.PullRequest = pullRequestRef(res.pullRequest, b.PullRequest) b.Queued = res.queued + updateBaseFromPR(s, b, res.pullRequest) } else if !b.IsMerged() { // Clear if we didn't find anything (and original was cleared during discovery) if b.PullRequest != nil && res.pullRequest == nil { @@ -694,6 +695,16 @@ func pullRequestRef(pr *github.PullRequest, previous *stack.PullRequestRef) *sta return ref } +func updateBaseFromPR(s *stack.Stack, b *stack.BranchRef, pr *github.PullRequest) { + if b.IsMerged() || pr.BaseRefOID == "" || pr.BaseRefName != s.ActiveBaseBranch(b.Branch) { + return + } + // A PR retains its old parent tip even when a fresh clone has no reflog for it. + if canUpdateBase(pr.BaseRefOID, b.Branch, b.Base) { + b.Base = pr.BaseRefOID + } +} + // prDetailsFromPR builds PRDetails from a PullRequest returned by FindPRByNumber. func prDetailsFromPR(pr *github.PullRequest) *github.PRDetails { if pr == nil { @@ -854,6 +865,7 @@ func syncStackPRsFromRemote(client github.ClientOps, s *stack.Stack) (map[string } b.PullRequest = pullRequestRef(pr, b.PullRequest) b.Queued = pr.IsQueued() + updateBaseFromPR(s, b, pr) details[b.Branch] = prDetailsFromPR(pr) } diff --git a/cmd/utils_test.go b/cmd/utils_test.go index f421188..3dfd443 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -1261,10 +1261,12 @@ func TestSyncStackPRs_NoTrackedPR_AdoptsOpenPR(t *testing.T) { cfg.GitHubClientOverride = &github.MockClient{ FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { return &github.PullRequest{ - Number: 99, - ID: "PR_99", - URL: "https://github.com/o/r/pull/99", - State: "OPEN", + Number: 99, + ID: "PR_99", + URL: "https://github.com/o/r/pull/99", + State: "OPEN", + BaseRefName: "main", + BaseRefOID: "base-sha", }, nil }, } @@ -1275,6 +1277,34 @@ func TestSyncStackPRs_NoTrackedPR_AdoptsOpenPR(t *testing.T) { require.NotNil(t, s.Branches[0].PullRequest) assert.Equal(t, 99, s.Branches[0].PullRequest.Number) assert.False(t, s.Branches[0].PullRequest.Merged) + assert.Equal(t, "base-sha", s.Branches[0].Base) +} + +func TestSyncStackPRs_PreservesDifferentLocalBaseBranch(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{ + Branch: "feature", Base: "local-base", PullRequest: &stack.PullRequestRef{Number: 99}, + }}, + } + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(a, b string) (bool, error) { + return a == "remote-base" && b == "feature", nil + }, + }) + defer restore() + cfg := issue250TestConfig(t) + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: 99, State: "OPEN", BaseRefName: "previous-parent", BaseRefOID: "remote-base", + }, nil + }, + } + + syncStackPRs(cfg, s) + + assert.Equal(t, "local-base", s.Branches[0].Base) } func TestSyncStackPRs_TrackedPR_DetectsMerge(t *testing.T) { diff --git a/internal/github/github.go b/internal/github/github.go index 88d3f6f..b2bccb0 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -35,6 +35,7 @@ type PullRequest struct { HeadRefName string `graphql:"headRefName"` HeadRefOID string `graphql:"headRefOid"` BaseRefName string `graphql:"baseRefName"` + BaseRefOID string `graphql:"baseRefOid"` IsDraft bool `graphql:"isDraft"` Merged bool `graphql:"merged"` MergeQueueEntry *MergeQueueEntry `graphql:"mergeQueueEntry"` @@ -116,6 +117,7 @@ func (c *Client) FindPRForBranch(branch string) (*PullRequest, error) { Title string `graphql:"title"` Body string `graphql:"body"` BaseRefName string `graphql:"baseRefName"` + BaseRefOID string `graphql:"baseRefOid"` IsDraft bool `graphql:"isDraft"` MergeQueueEntry *MergeQueueEntry `graphql:"mergeQueueEntry"` AutoMergeRequest *AutoMergeRequest `graphql:"autoMergeRequest"` @@ -147,6 +149,7 @@ func (c *Client) FindPRForBranch(branch string) (*PullRequest, error) { Title: n.Title, Body: n.Body, BaseRefName: n.BaseRefName, + BaseRefOID: n.BaseRefOID, IsDraft: n.IsDraft, MergeQueueEntry: n.MergeQueueEntry, AutoMergeRequest: n.AutoMergeRequest, @@ -376,6 +379,7 @@ func (c *Client) FindPRByNumber(number int) (*PullRequest, error) { HeadRefName string `graphql:"headRefName"` HeadRefOID string `graphql:"headRefOid"` BaseRefName string `graphql:"baseRefName"` + BaseRefOID string `graphql:"baseRefOid"` IsDraft bool `graphql:"isDraft"` Merged bool `graphql:"merged"` MergeQueueEntry *MergeQueueEntry `graphql:"mergeQueueEntry"` @@ -408,6 +412,7 @@ func (c *Client) FindPRByNumber(number int) (*PullRequest, error) { HeadRefName: n.HeadRefName, HeadRefOID: n.HeadRefOID, BaseRefName: n.BaseRefName, + BaseRefOID: n.BaseRefOID, IsDraft: n.IsDraft, Merged: n.Merged, MergeQueueEntry: n.MergeQueueEntry, diff --git a/internal/github/github_test.go b/internal/github/github_test.go index 0df9261..2d95a79 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -118,6 +118,7 @@ func TestFindPRByNumber_MergedHead(t *testing.T) { t.Run(tt.name, func(t *testing.T) { pr := map[string]any{ "number": 42, "state": "MERGED", "merged": true, "headRefName": "feature", + "baseRefOid": "previous-base", } if !tt.omit { pr["headRefOid"] = tt.head @@ -132,6 +133,7 @@ func TestFindPRByNumber_MergedHead(t *testing.T) { } require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) assert.Contains(t, request.Query, "headRefOid") + assert.Contains(t, request.Query, "baseRefOid") return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body)), @@ -150,6 +152,7 @@ func TestFindPRByNumber_MergedHead(t *testing.T) { require.NotNil(t, got) assert.True(t, got.Merged) assert.Equal(t, tt.head, got.HeadRefOID) + assert.Equal(t, "previous-base", got.BaseRefOID) }) } }