Skip to content

Commit 37505c7

Browse files
committed
address review comments
1 parent eb9ad81 commit 37505c7

3 files changed

Lines changed: 177 additions & 3 deletions

File tree

cmd/sync_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2060,6 +2060,133 @@ func TestSync_RemoteAhead_PullsNewBranches(t *testing.T) {
20602060
assert.Equal(t, []string{"b1", "b2", "b3", "b4", "b5"}, sf.Stacks[0].BranchNames())
20612061
}
20622062

2063+
// TestSync_RemoteAhead_QueuedBranchNotPushed verifies that a pulled branch whose
2064+
// PR is in the merge queue has its transient queued state copied from the fresh
2065+
// PR details during reconciliation, so it is not force-pushed by the later push
2066+
// step.
2067+
func TestSync_RemoteAhead_QueuedBranchNotPushed(t *testing.T) {
2068+
s := stack.Stack{
2069+
ID: "9",
2070+
Trunk: stack.BranchRef{Branch: "main"},
2071+
Branches: []stack.BranchRef{
2072+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
2073+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
2074+
},
2075+
}
2076+
tmpDir := t.TempDir()
2077+
writeStackFile(t, tmpDir, s)
2078+
2079+
var created []string
2080+
var pushes []pushCall
2081+
mock := newSyncMockNoRebase(tmpDir, "b1")
2082+
mock.BranchExistsFn = func(name string) bool { return name != "b3" }
2083+
mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil }
2084+
mock.SetUpstreamTrackingFn = func(string, string) error { return nil }
2085+
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
2086+
pushes = append(pushes, pushCall{remote, branches, force, atomic})
2087+
return nil
2088+
}
2089+
2090+
ghMock := &github.MockClient{
2091+
ListStacksFn: func() ([]github.RemoteStack, error) {
2092+
return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102, 103}}}, nil
2093+
},
2094+
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
2095+
branch := map[int]string{101: "b1", 102: "b2", 103: "b3"}[n]
2096+
if branch == "" {
2097+
return nil, nil
2098+
}
2099+
pr := &github.PullRequest{
2100+
Number: n, ID: fmt.Sprintf("PR_%d", n),
2101+
URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n),
2102+
HeadRefName: branch, State: "OPEN",
2103+
}
2104+
if n == 103 {
2105+
pr.MergeQueueEntry = &github.MergeQueueEntry{ID: "MQ1"}
2106+
}
2107+
return pr, nil
2108+
},
2109+
}
2110+
2111+
_, err := runSyncCfg(t, mock, func(cfg *config.Config) { cfg.GitHubClientOverride = ghMock })
2112+
require.NoError(t, err)
2113+
2114+
assert.Contains(t, created, "b3", "the queued branch is still pulled into the local stack")
2115+
for _, pc := range pushes {
2116+
assert.NotContains(t, pc.branches, "b3", "a merge-queued branch must not be pushed")
2117+
}
2118+
}
2119+
2120+
// TestSync_RemoteAhead_DuplicateBranchAborts verifies that pulling a remote
2121+
// addition whose branch is already owned by another local stack aborts rather
2122+
// than writing the branch into two stacks.
2123+
func TestSync_RemoteAhead_DuplicateBranchAborts(t *testing.T) {
2124+
tmpDir := t.TempDir()
2125+
writeStackFileMulti(t, tmpDir,
2126+
stack.Stack{
2127+
ID: "9",
2128+
Trunk: stack.BranchRef{Branch: "main"},
2129+
Branches: []stack.BranchRef{
2130+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
2131+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
2132+
},
2133+
},
2134+
stack.Stack{
2135+
Trunk: stack.BranchRef{Branch: "main"},
2136+
Branches: []stack.BranchRef{{Branch: "b3"}}, // another stack already owns b3
2137+
},
2138+
)
2139+
2140+
var created []string
2141+
mock := newSyncMockNoRebase(tmpDir, "b1")
2142+
mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil }
2143+
2144+
ghMock := &github.MockClient{
2145+
ListStacksFn: func() ([]github.RemoteStack, error) {
2146+
return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102, 103}}}, nil
2147+
},
2148+
FindPRByNumberFn: prByNumberFinder(map[int]string{101: "b1", 102: "b2", 103: "b3"}),
2149+
}
2150+
2151+
output, err := runSyncCfg(t, mock, func(cfg *config.Config) { cfg.GitHubClientOverride = ghMock })
2152+
2153+
assert.Error(t, err)
2154+
assert.Contains(t, output, "Cannot pull b3")
2155+
assert.NotContains(t, created, "b3", "must not pull a branch owned by another stack")
2156+
2157+
sf, loadErr := stack.Load(tmpDir)
2158+
require.NoError(t, loadErr)
2159+
assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames(), "tracked stack unchanged")
2160+
}
2161+
2162+
// TestSync_Divergent_UseRemote_DirtyCheckErrorAborts verifies that when the
2163+
// working-tree status cannot be determined, "use remote" aborts instead of
2164+
// treating the tree as clean and running the destructive replace.
2165+
func TestSync_Divergent_UseRemote_DirtyCheckErrorAborts(t *testing.T) {
2166+
tmpDir := t.TempDir()
2167+
divergentStack(t, tmpDir)
2168+
2169+
ghMock := divergentRemoteMock()
2170+
var created []string
2171+
mock := newSyncMockNoRebase(tmpDir, "b1")
2172+
mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil }
2173+
mock.HasUncommittedChangesFn = func() (bool, error) { return false, fmt.Errorf("git status failed") }
2174+
2175+
output, err := runSyncCfg(t, mock, func(cfg *config.Config) {
2176+
cfg.GitHubClientOverride = ghMock
2177+
cfg.ForceInteractive = true
2178+
cfg.SelectFn = func(_, _ string, _ []string) (int, error) { return 0, nil }
2179+
})
2180+
2181+
assert.Error(t, err)
2182+
assert.Contains(t, output, "Could not determine whether the working tree is clean")
2183+
assert.Empty(t, created, "must not replace the local stack when the working-tree check fails")
2184+
2185+
sf, loadErr := stack.Load(tmpDir)
2186+
require.NoError(t, loadErr)
2187+
assert.Equal(t, []string{"b1", "b2", "b3"}, sf.Stacks[0].BranchNames(), "local stack untouched")
2188+
}
2189+
20632190
// TestSync_RemoteInSync_NoPull verifies that when local and remote match, no
20642191
// branches are pulled and no divergence is reported.
20652192
func TestSync_RemoteInSync_NoPull(t *testing.T) {

cmd/utils.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,7 +1268,11 @@ func reconcileRemoteStack(cfg *config.Config, sf *stack.StackFile, s *stack.Stac
12681268

12691269
switch classifyRemoteStack(localActive, remoteActive) {
12701270
case remoteStackInSync, remoteStackLocalAhead:
1271-
// Nothing to pull; the existing flow pushes/updates the remote.
1271+
// Nothing to pull; the existing flow pushes/updates the remote. Copy the
1272+
// freshly fetched PR state (merged/queued) onto the local branches so the
1273+
// fast-forward/rebase/push steps skip merged and merge-queued branches
1274+
// rather than rewriting them before the later PR-sync step runs.
1275+
syncRemotePRState(s, prs)
12721276
return res, nil
12731277
case remoteStackCleanAhead:
12741278
return pullRemoteAdditions(cfg, sf, s, gitDir, remote, prs)
@@ -1365,6 +1369,21 @@ func pullRemoteAdditions(cfg *config.Config, sf *stack.StackFile, s *stack.Stack
13651369
return res, nil
13661370
}
13671371

1372+
// A remote-added branch must not collide with a branch already tracked by
1373+
// another local stack, or with an existing local branch we would otherwise
1374+
// adopt as "pulled" without actually fetching it. Abort rather than persist
1375+
// duplicate ownership or a stale branch.
1376+
for _, pr := range newPRs {
1377+
if err := sf.ValidateNoDuplicateBranch(pr.HeadRefName); err != nil {
1378+
cfg.Errorf("Cannot pull %s from the remote stack: %s", pr.HeadRefName, err)
1379+
return res, ErrSilent
1380+
}
1381+
if git.BranchExists(pr.HeadRefName) {
1382+
cfg.Errorf("Cannot pull %s from the remote stack: a local branch with that name already exists", pr.HeadRefName)
1383+
return res, ErrSilent
1384+
}
1385+
}
1386+
13681387
newBranchNames := make([]string, len(newPRs))
13691388
for i, pr := range newPRs {
13701389
newBranchNames[i] = pr.HeadRefName
@@ -1397,6 +1416,9 @@ func pullRemoteAdditions(cfg *config.Config, sf *stack.StackFile, s *stack.Stack
13971416
}
13981417

13991418
if added > 0 {
1419+
// Copy the freshly fetched PR state (including the transient queued flag)
1420+
// onto the pulled branches so the rebase/push steps skip merge-queued ones.
1421+
syncRemotePRState(s, prs)
14001422
updateBaseSHAs(s)
14011423
if err := stack.Save(gitDir, sf); err != nil {
14021424
return res, handleSaveError(cfg, err)
@@ -1469,7 +1491,15 @@ func resolveStackDivergence(cfg *config.Config, client github.ClientOps, sf *sta
14691491
func resolveDivergenceUseRemote(cfg *config.Config, sf *stack.StackFile, s *stack.Stack, currentBranch, gitDir, remote string, prs []*github.PullRequest) (remoteReconcileResult, error) {
14701492
var res remoteReconcileResult
14711493

1472-
if dirty, err := git.HasUncommittedChanges(); err == nil && dirty {
1494+
// Replacing the local stack is destructive, so require a known-clean working
1495+
// tree. Treat an inability to inspect the tree as a reason to abort (a failed
1496+
// status must not be read as "clean").
1497+
dirty, err := git.HasUncommittedChanges()
1498+
if err != nil {
1499+
cfg.Errorf("Could not determine whether the working tree is clean: %v", err)
1500+
return res, ErrSilent
1501+
}
1502+
if dirty {
14731503
cfg.Errorf("You have uncommitted changes — commit or stash them before replacing your local stack with the remote")
14741504
return res, ErrSilent
14751505
}
@@ -1479,11 +1509,26 @@ func resolveDivergenceUseRemote(cfg *config.Config, sf *stack.StackFile, s *stac
14791509
oldBranches := s.BranchNames()
14801510

14811511
removeLocalStack(sf, s)
1512+
1513+
// A remote PR branch must not already be owned by another local stack, or
1514+
// importing it would write the same branch into two stacks. Validate against
1515+
// the remaining stacks (the current one has been removed above).
1516+
for _, pr := range prs {
1517+
if err := sf.ValidateNoDuplicateBranch(pr.HeadRefName); err != nil {
1518+
cfg.Errorf("Cannot adopt the remote stack: %s", err)
1519+
return res, ErrSilent
1520+
}
1521+
}
1522+
14821523
newStack, err := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID)
14831524
if err != nil {
14841525
return res, err
14851526
}
14861527

1528+
// Populate the transient queued/merged state so the rebase/push steps skip
1529+
// merge-queued or merged branches in the adopted stack.
1530+
syncRemotePRState(newStack, prs)
1531+
14871532
// If the user was on a branch that the remote stack no longer contains,
14881533
// move them to the nearest surviving branch so they don't end up detached
14891534
// from the stack.

internal/tui/submitview/mouse.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,9 @@ func (m Model) leftStackButtonHit(x, y int) bool {
151151
if y-m.panelTopRow() != m.leftVisibleHeight()+1 {
152152
return false
153153
}
154-
return x < lipgloss.Width(m.renderStackButton(leftW-2))
154+
// The button is rendered inside the panel's one-cell left border, so the
155+
// hit target starts at screen column 1, not 0.
156+
return x >= 1 && x < 1+lipgloss.Width(m.renderStackButton(leftW-2))
155157
}
156158

157159
// handleClick routes a left click to a branch row (left map) or an editor

0 commit comments

Comments
 (0)