Skip to content

Commit 282dffa

Browse files
authored
Handle partial unstacking when keeping local stacks (#576)
1 parent 2d07a1c commit 282dffa

7 files changed

Lines changed: 205 additions & 94 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ A clean remote-ahead update (PRs added on top of your local stack) is pulled dow
381381
When neither stack is a clean prefix of the other — for example, you added a branch locally while separate PRs were added to the same stack on GitHub — sync cannot merge the two automatically. In an interactive terminal it offers three choices:
382382

383383
- **Use the remote stack as the source of truth** — replaces your local stack composition with the remote's, pulling any missing branches. If you were on a branch that the remote stack no longer contains, you're moved to the nearest surviving branch. Requires a clean working state with no uncommitted changes.
384-
- **Delete the stack on GitHub** — deletes the stack object on GitHub and stops the sync. Your PRs and local branches are untouched (only the stack on GitHub is removed); recreate the stack with `gh stack submit` (run `gh stack modify` first if you want to change its structure). This is the way to make GitHub match your local stack, because `submit` — unlike `sync` — also creates PRs for any branches you haven't submitted yet.
384+
- **Unstack on GitHub and keep local** — unstacks eligible PRs and stops sync without changing local branches or their order. Merged PRs may remain in the old stack; recreate the active stack with `gh stack submit` (or run `gh stack modify` first to change its structure). If unmerged PRs remain stacked, the local association is kept and the command reports a conflict. Unlike `sync`, `submit` also creates PRs for branches you haven't submitted yet.
385385
- **Cancel** — aborts the sync without pushing branches or updating any PRs.
386386

387387
In a non-interactive terminal, a divergence aborts the sync (exit success) without pushing branches or updating PRs; resolve it by unstacking and recreating the stack.

‎cmd/checkout.go‎

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -580,13 +580,19 @@ func handleCompositionConflict(
580580
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
581581
options := []string{
582582
"Replace local stack with remote version",
583-
"Delete remote stack and keep local version",
583+
"Unstack on GitHub and keep local version",
584584
"Cancel",
585585
}
586-
selected, err := p.Select("How would you like to resolve this?", "", options)
586+
selectFn := p.Select
587+
if cfg.SelectFn != nil {
588+
selectFn = cfg.SelectFn
589+
}
590+
selected, err := selectFn("How would you like to resolve this?", "", options)
587591
if err != nil {
588592
if isInterruptError(err) {
589-
clearSelectPrompt(cfg, len(options))
593+
if cfg.SelectFn == nil {
594+
clearSelectPrompt(cfg, len(options))
595+
}
590596
printInterrupt(cfg)
591597
return nil, errInterrupt
592598
}
@@ -619,28 +625,8 @@ func handleCompositionConflict(
619625
return s, nil
620626

621627
case 1:
622-
// Unstack the remote stack, keep local
623-
_, dissolved, err := client.Unstack(remoteStack.Number)
624-
if err != nil {
625-
var httpErr *api.HTTPError
626-
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
627-
cfg.Warningf("Remote stack already removed")
628-
} else if errors.As(err, &httpErr) && httpErr.StatusCode == 422 {
629-
cfg.Errorf("Cannot unstack remote stack: %s", httpErr.Message)
630-
return nil, ErrAPIFailure
631-
} else {
632-
cfg.Errorf("failed to unstack remote stack: %v", err)
633-
return nil, ErrAPIFailure
634-
}
635-
} else if dissolved {
636-
cfg.Successf("Remote stack removed")
637-
} else {
638-
cfg.Warningf("Some pull requests could not be unstacked and remain on GitHub")
639-
}
640-
localStack.ID = ""
641-
localStack.Number = 0
642-
if err := stack.Save(gitDir, sf); err != nil {
643-
return nil, stackSaveError(cfg, err)
628+
if err := unstackAndKeepLocal(cfg, client, sf, localStack, remoteStack, gitDir); err != nil {
629+
return nil, err
644630
}
645631
return localStack, nil
646632

‎cmd/checkout_test.go‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,6 +1323,82 @@ func TestCheckout_NumericTarget_AlreadyOnTarget(t *testing.T) {
13231323

13241324
// --- Helper tests ---
13251325

1326+
func TestCheckout_CompositionConflict_KeepLocal(t *testing.T) {
1327+
for _, tc := range []struct {
1328+
name string
1329+
remaining *github.RemoteStack
1330+
apiErr error
1331+
alreadyUnstacked bool
1332+
wantErr error
1333+
}{
1334+
{name: "dissolved"},
1335+
{name: "merged", remaining: &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101}}},
1336+
{name: "already unstacked", remaining: &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101}}, alreadyUnstacked: true},
1337+
{name: "queued", remaining: &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{102}}, wantErr: ErrConflict},
1338+
{name: "server rejection", apiErr: &api.HTTPError{StatusCode: 422, Message: "Pull requests cannot be removed"}, wantErr: ErrAPIFailure},
1339+
{name: "already gone", apiErr: &api.HTTPError{StatusCode: 404}},
1340+
} {
1341+
t.Run(tc.name, func(t *testing.T) {
1342+
dir := t.TempDir()
1343+
writeStackFileMulti(t, dir, stack.Stack{
1344+
ID: "9", Number: 9, Trunk: stack.BranchRef{Branch: "main"},
1345+
Branches: []stack.BranchRef{
1346+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}},
1347+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
1348+
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 103}},
1349+
},
1350+
}, stack.Stack{
1351+
ID: "20", Number: 20, Trunk: stack.BranchRef{Branch: "main"},
1352+
Branches: []stack.BranchRef{{Branch: "other"}},
1353+
})
1354+
sf, err := stack.Load(dir)
1355+
require.NoError(t, err)
1356+
local := &sf.Stacks[0]
1357+
remote := &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 104}}
1358+
if tc.alreadyUnstacked {
1359+
remote = tc.remaining
1360+
}
1361+
unstacked := false
1362+
client := &github.MockClient{
1363+
UnstackFn: func(number int) (*github.RemoteStack, bool, error) {
1364+
assert.Equal(t, 9, number)
1365+
unstacked = true
1366+
return tc.remaining, tc.remaining == nil, tc.apiErr
1367+
},
1368+
}
1369+
cfg, outR, errR := config.NewTestConfig()
1370+
cfg.ForceInteractive = true
1371+
cfg.SelectFn = func(_, _ string, _ []string) (int, error) { return 1, nil }
1372+
prs := []*github.PullRequest{{HeadRefName: "b1"}, {HeadRefName: "b2"}, {HeadRefName: "b4"}}
1373+
result, err := handleCompositionConflict(cfg, client, sf, local, remote, prs, dir, "main")
1374+
_, output := commandOutput(t, cfg, outR, errR)
1375+
assert.Equal(t, !tc.alreadyUnstacked, unstacked)
1376+
saved, loadErr := stack.Load(dir)
1377+
require.NoError(t, loadErr)
1378+
require.Len(t, saved.Stacks, 2)
1379+
assert.Equal(t, []string{"b1", "b2", "b3"}, saved.Stacks[0].BranchNames())
1380+
assert.Equal(t, "20", saved.Stacks[1].ID)
1381+
assert.Equal(t, []string{"other"}, saved.Stacks[1].BranchNames())
1382+
if tc.wantErr != nil {
1383+
assert.ErrorIs(t, err, tc.wantErr)
1384+
assert.Nil(t, result)
1385+
assert.Equal(t, "9", saved.Stacks[0].ID)
1386+
assert.Equal(t, 9, saved.Stacks[0].Number)
1387+
} else {
1388+
require.NoError(t, err)
1389+
assert.Same(t, local, result)
1390+
assert.Empty(t, saved.Stacks[0].ID)
1391+
assert.Zero(t, saved.Stacks[0].Number)
1392+
if tc.remaining != nil {
1393+
assert.Contains(t, output, "remain stacked on GitHub")
1394+
assert.NotContains(t, output, "Remote stack removed")
1395+
assert.NotContains(t, output, "could not be unstacked")
1396+
}
1397+
}
1398+
})
1399+
}
1400+
}
1401+
13261402
func TestStackCompositionMatches(t *testing.T) {
13271403
tests := []struct {
13281404
name string

‎cmd/sync_test.go‎

Lines changed: 79 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2473,52 +2473,87 @@ func TestSync_Divergent_UseRemote_DirtyBlocked(t *testing.T) {
24732473
assert.Equal(t, "9", sf.Stacks[0].ID)
24742474
}
24752475

2476-
// TestSync_Divergent_DeleteRemote deletes the diverged remote stack, clears the
2477-
// local association, and stops the sync (pointing the user at submit) without
2478-
// recreating the stack or pushing.
2476+
// The keep-local resolution stops before pushing or recreating the stack.
24792477
func TestSync_Divergent_DeleteRemote(t *testing.T) {
2480-
tmpDir := t.TempDir()
2481-
divergentStack(t, tmpDir)
2482-
2483-
deleted := false
2484-
var deletedNumber int
2485-
var pushed bool
2486-
ghMock := divergentRemoteMock()
2487-
ghMock.UnstackFn = func(number int) (*github.RemoteStack, bool, error) {
2488-
deleted = true
2489-
deletedNumber = number
2490-
return nil, true, nil
2491-
}
2492-
ghMock.CreateStackFn = func([]int) (*github.RemoteStack, error) {
2493-
t.Fatal("CreateStack must not be called")
2494-
return nil, nil
2495-
}
2496-
ghMock.AddToStackFn = func(int, []int) (*github.RemoteStack, error) {
2497-
t.Fatal("AddToStack must not be called")
2498-
return nil, nil
2478+
mergedAt := "2026-01-01T00:00:00Z"
2479+
for _, tc := range []struct {
2480+
name string
2481+
remaining *github.RemoteStack
2482+
apiErr error
2483+
lookupFails bool
2484+
wantErr error
2485+
}{
2486+
{name: "dissolved"},
2487+
{name: "merged", remaining: &github.RemoteStack{
2488+
ID: 9, Number: 9, PullRequests: []int{101},
2489+
PRDetails: []github.RemoteStackPR{{Number: 101, MergedAt: &mergedAt}},
2490+
}},
2491+
{name: "queued", remaining: &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{102}}, wantErr: ErrConflict},
2492+
{name: "server rejection", apiErr: &api.HTTPError{StatusCode: 422, Message: "Pull requests cannot be removed"}, wantErr: ErrAPIFailure},
2493+
{name: "lookup failure", lookupFails: true, wantErr: ErrAPIFailure},
2494+
} {
2495+
t.Run(tc.name, func(t *testing.T) {
2496+
tmpDir := t.TempDir()
2497+
divergentStack(t, tmpDir)
2498+
unstacked, pushed := false, false
2499+
ghMock := divergentRemoteMock()
2500+
listStacks := ghMock.ListStacksFn
2501+
lookups := 0
2502+
ghMock.ListStacksFn = func() ([]github.RemoteStack, error) {
2503+
lookups++
2504+
if tc.lookupFails && lookups > 1 {
2505+
return nil, assert.AnError
2506+
}
2507+
return listStacks()
2508+
}
2509+
ghMock.UnstackFn = func(number int) (*github.RemoteStack, bool, error) {
2510+
assert.Equal(t, 9, number)
2511+
unstacked = true
2512+
return tc.remaining, tc.remaining == nil, tc.apiErr
2513+
}
2514+
ghMock.CreateStackFn = func([]int) (*github.RemoteStack, error) {
2515+
t.Fatal("CreateStack must not be called")
2516+
return nil, nil
2517+
}
2518+
ghMock.AddToStackFn = func(int, []int) (*github.RemoteStack, error) {
2519+
t.Fatal("AddToStack must not be called")
2520+
return nil, nil
2521+
}
2522+
mock := newSyncMockNoRebase(tmpDir, "b1")
2523+
mock.PushFn = func(string, []string, bool, bool) error { pushed = true; return nil }
2524+
output, err := runSyncCfg(t, mock, func(cfg *config.Config) {
2525+
cfg.GitHubClientOverride = ghMock
2526+
cfg.ForceInteractive = true
2527+
cfg.SelectFn = func(_, _ string, _ []string) (int, error) { return 1, nil }
2528+
})
2529+
2530+
assert.Equal(t, !tc.lookupFails, unstacked)
2531+
assert.False(t, pushed)
2532+
assert.NotContains(t, output, "Stack synced")
2533+
assert.NotContains(t, output, "Branches synced")
2534+
sf, loadErr := stack.Load(tmpDir)
2535+
require.NoError(t, loadErr)
2536+
assert.Equal(t, []string{"b1", "b2", "b3"}, sf.Stacks[0].BranchNames())
2537+
if tc.wantErr != nil {
2538+
assert.ErrorIs(t, err, tc.wantErr)
2539+
assert.Equal(t, "9", sf.Stacks[0].ID)
2540+
assert.NotContains(t, output, "gh stack submit")
2541+
} else {
2542+
require.NoError(t, err)
2543+
assert.Empty(t, sf.Stacks[0].ID)
2544+
assert.Zero(t, sf.Stacks[0].Number)
2545+
assert.Contains(t, output, "gh stack submit")
2546+
if tc.remaining != nil {
2547+
assert.Contains(t, output, "remain stacked on GitHub")
2548+
assert.NotContains(t, output, "Remote stack removed")
2549+
assert.NotContains(t, output, "only the stack on GitHub was removed")
2550+
assert.True(t, sf.Stacks[0].Branches[0].IsMerged())
2551+
} else {
2552+
assert.Contains(t, output, "Remote stack removed")
2553+
}
2554+
}
2555+
})
24992556
}
2500-
mock := newSyncMockNoRebase(tmpDir, "b1")
2501-
mock.PushFn = func(string, []string, bool, bool) error { pushed = true; return nil }
2502-
2503-
output, err := runSyncCfg(t, mock, func(cfg *config.Config) {
2504-
cfg.GitHubClientOverride = ghMock
2505-
cfg.ForceInteractive = true
2506-
cfg.SelectFn = func(_, _ string, _ []string) (int, error) { return 1, nil }
2507-
})
2508-
require.NoError(t, err)
2509-
2510-
assert.True(t, deleted, "remote stack should be deleted")
2511-
assert.Equal(t, 9, deletedNumber)
2512-
assert.False(t, pushed, "sync should stop after deleting the remote stack")
2513-
assert.Contains(t, output, "Deleted the stack on GitHub")
2514-
assert.Contains(t, output, "gh stack submit")
2515-
assert.NotContains(t, output, "Stack synced")
2516-
assert.NotContains(t, output, "Branches synced")
2517-
2518-
sf, err := stack.Load(tmpDir)
2519-
require.NoError(t, err)
2520-
assert.Equal(t, "", sf.Stacks[0].ID, "local association is cleared")
2521-
assert.Equal(t, []string{"b1", "b2", "b3"}, sf.Stacks[0].BranchNames(), "local branches untouched")
25222557
}
25232558

25242559
// TestSync_Divergent_Cancel makes no changes and preserves the association.

‎cmd/utils.go‎

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2000,7 +2000,7 @@ func resolveStackDivergence(cfg *config.Config, client github.ClientOps, sf *sta
20002000

20012001
options := []string{
20022002
"Update local to match remote — replace your local stack with the remote version",
2003-
"Delete the remote stack on GitHub — keep your local stack and recreate on remote later",
2003+
"Unstack pull requests on GitHub — keep your local stack and recreate on remote later",
20042004
"Cancel — make no changes",
20052005
}
20062006
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
@@ -2136,40 +2136,54 @@ func nearestBranchAfterReplace(oldBranches []string, currentBranch string, newSt
21362136
return newStack.Trunk.Branch
21372137
}
21382138

2139-
// resolveDivergenceDeleteRemote deletes the diverged stack object on GitHub and
2140-
// removes the local association (clearing the stack ID). The PRs and local
2141-
// branches are left untouched — only the stack grouping on GitHub is removed. It
2142-
// stops the sync and points the user at `gh stack submit` to recreate the stack
2143-
// (which, unlike sync, also creates PRs for any un-submitted branches).
2139+
// resolveDivergenceDeleteRemote unstacks the remote PRs while keeping the local
2140+
// branch layout. It stops sync so submit can recreate the active stack.
21442141
func resolveDivergenceDeleteRemote(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, s *stack.Stack, gitDir string) (remoteReconcileResult, error) {
21452142
res := remoteReconcileResult{stop: true}
21462143

2147-
number, err := ensureStackNumber(client, s)
2148-
if err != nil || number == 0 {
2149-
cfg.Warningf("Remote stack already deleted")
2150-
} else if _, dissolved, unstackErr := client.Unstack(number); unstackErr != nil {
2144+
remote, err := remoteStackByID(client, s.ID)
2145+
if err != nil {
2146+
cfg.Errorf("failed to look up remote stack: %v", err)
2147+
return res, ErrAPIFailure
2148+
}
2149+
if err := unstackAndKeepLocal(cfg, client, sf, s, remote, gitDir); err != nil {
2150+
return res, err
2151+
}
2152+
2153+
cfg.Printf("")
2154+
cfg.Printf("Your local branches and stack order are unchanged.")
2155+
cfg.Printf(" Run `%s` to recreate the stack on GitHub.", cfg.ColorCyan("gh stack submit"))
2156+
cfg.Printf(" Run `%s` first if you want to change the stack's structure.", cfg.ColorCyan("gh stack modify"))
2157+
return res, nil
2158+
}
2159+
2160+
func unstackAndKeepLocal(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, s *stack.Stack, remote *github.RemoteStack, gitDir string) error {
2161+
if remote == nil {
2162+
cfg.Warningf("Remote stack already removed")
2163+
} else if prepareStackRecreation(s, remote) {
2164+
cfg.Infof("Merged pull requests remain stacked on GitHub%s", stackLabel(remote.Number))
2165+
} else if remaining, dissolved, unstackErr := client.Unstack(remote.Number); unstackErr != nil {
21512166
var httpErr *api.HTTPError
21522167
if errors.As(unstackErr, &httpErr) && httpErr.StatusCode == 404 {
2153-
cfg.Warningf("Remote stack already deleted")
2168+
cfg.Warningf("Remote stack already removed")
21542169
} else {
2155-
cfg.Errorf("failed to delete remote stack: %v", unstackErr)
2156-
return res, ErrAPIFailure
2170+
cfg.Errorf("failed to unstack remote stack: %v", unstackErr)
2171+
return ErrAPIFailure
21572172
}
2173+
} else if !dissolved && !prepareStackRecreation(s, remaining) {
2174+
cfg.Errorf("Some pull requests still belong to the remote stack; it cannot be recreated yet")
2175+
return ErrConflict
21582176
} else if dissolved {
2159-
cfg.Successf("Deleted the stack on GitHub")
2177+
cfg.Successf("Remote stack removed%s", stackLabel(remote.Number))
21602178
} else {
2161-
cfg.Warningf("Some pull requests could not be unstacked and remain on GitHub")
2179+
cfg.Successf("Pull requests unstacked on GitHub%s", stackLabel(remote.Number))
2180+
cfg.Infof("Merged pull requests remain stacked on GitHub%s", stackLabel(remote.Number))
21622181
}
21632182

21642183
s.ID = ""
21652184
s.Number = 0
21662185
if err := stack.Save(gitDir, sf); err != nil {
2167-
return res, handleSaveError(cfg, err)
2186+
return stackSaveError(cfg, err)
21682187
}
2169-
2170-
cfg.Printf("")
2171-
cfg.Printf("Your PRs and local branches are unchanged — only the stack on GitHub was removed.")
2172-
cfg.Printf(" Run `%s` to recreate the stack on GitHub.", cfg.ColorCyan("gh stack submit"))
2173-
cfg.Printf(" Run `%s` first if you want to change the stack's structure.", cfg.ColorCyan("gh stack modify"))
2174-
return res, nil
2188+
return nil
21752189
}

‎docs/src/content/docs/guides/workflows.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ If PRs are added to the stack on GitHub by someone else, `gh stack sync` fetches
250250
If your local and remote stacks have diverged — for example, you added a branch locally while different PRs/branches were added to the same stack on GitHub — sync can't merge them automatically. In an interactive terminal it offers three choices:
251251

252252
- **Use the remote stack as the source of truth** — replaces your local stack composition with the remote's, pulling any missing branches. If you were on a branch that the remote stack no longer contains, you're moved to the nearest surviving branch. Requires a clean working state with no uncommitted changes.
253-
- **Delete the stack on GitHub** — deletes the stack object on GitHub and stops the sync. Your PRs and local branches are untouched (only the stack on GitHub is removed); recreate the stack with `gh stack submit` (run `gh stack modify` first if you want to change its structure). This is the way to make GitHub match your local stack, because `submit` — unlike `sync` — also creates PRs for any branches you haven't submitted yet.
253+
- **Unstack on GitHub and keep local** — unstacks eligible PRs and stops sync without changing local branches or their order. Merged PRs may remain in the old stack; recreate the active stack with `gh stack submit` (or run `gh stack modify` first to change its structure). If unmerged PRs remain stacked, the local association is kept and the command reports a conflict. Unlike `sync`, `submit` also creates PRs for branches you haven't submitted yet.
254254
- **Cancel** — aborts the sync without pushing branches or updating any PRs.
255255

256256
In a non-interactive terminal, a divergence aborts the sync (exit success) without pushing branches or updating PRs; resolve it by unstacking and recreating the stack.

‎docs/src/content/docs/reference/cli.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ A clean remote-ahead update (PRs added on top of your local stack) is pulled dow
395395
When neither stack is a clean prefix of the other — for example, you added a branch locally while separate PRs were added to the same stack on GitHub — sync cannot merge the two automatically. In an interactive terminal it offers three choices:
396396

397397
- **Use the remote stack as the source of truth** — replaces your local stack composition with the remote's, pulling any missing branches. If you were on a branch that the remote stack no longer contains, you're moved to the nearest surviving branch. Requires a clean working state with no uncommitted changes.
398-
- **Delete the stack on GitHub** — deletes the stack object on GitHub and stops the sync. Your PRs and local branches are untouched (only the stack on GitHub is removed); recreate the stack with `gh stack submit` (run `gh stack modify` first if you want to change its structure). This is the way to make GitHub match your local stack, because `submit` — unlike `sync` — also creates PRs for any branches you haven't submitted yet.
398+
- **Unstack on GitHub and keep local** — unstacks eligible PRs and stops sync without changing local branches or their order. Merged PRs may remain in the old stack; recreate the active stack with `gh stack submit` (or run `gh stack modify` first to change its structure). If unmerged PRs remain stacked, the local association is kept and the command reports a conflict. Unlike `sync`, `submit` also creates PRs for branches you haven't submitted yet.
399399
- **Cancel** — aborts the sync without pushing branches or updating any PRs.
400400

401401
In a non-interactive terminal, a divergence aborts the sync (exit success) without pushing branches or updating PRs; resolve it by unstacking and recreating the stack.

0 commit comments

Comments
 (0)