Skip to content

Commit 83dbac2

Browse files
authored
Fix unstack to handle partially merged stacks (#574)
* fix unstack behavior to treat partial unstacks successfully * increase test timeout
1 parent 29a36d3 commit 83dbac2

8 files changed

Lines changed: 108 additions & 117 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@ jobs:
2727
run: go vet ./...
2828
- name: Test
2929
# The real-Git worktree cases can exceed Go's 10-minute package limit on Windows.
30-
run: go test -race -count=1 -timeout=20m ./...
30+
run: go test -race -count=1 -timeout=30m ./...

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ Provide a stack number (the identifier shown in the github.com stack UI) to unst
575575

576576
Use `--local` to only remove local tracking without contacting GitHub.
577577

578-
GitHub decides which pull requests can be unstacked: PRs that are queued for merge or have auto-merge enabled are left stacked. When some pull requests remain stacked, the stack is kept (and local tracking, if any, is unchanged).
578+
PRs that are merged, merging, or queued for merge cannot be removed from a stack on GitHub. After a successful unstack, local tracking is removed even when some PRs remain on GitHub.
579579

580580
| Flag | Description |
581581
|------|-------------|

‎cmd/unstack.go‎

Lines changed: 23 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ local tracking is removed as well.
3636
3737
Use --local to only remove local tracking without touching remote (GitHub).
3838
39-
GitHub decides which pull requests can be unstacked: PRs that are queued for
40-
merge or have auto-merge enabled are left stacked. When some pull requests
41-
remain stacked, the stack is kept (and local tracking, if any, is unchanged).`,
39+
GitHub decides which pull requests can be unstacked. Merged or queued PRs remain
40+
stacked on GitHub. After a successful unstack, local tracking is removed even
41+
when some PRs remain on GitHub.`,
4242
Example: ` # Unstack the current stack locally and on GitHub
4343
$ gh stack unstack
4444
@@ -110,18 +110,16 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error {
110110
return unstackTrackedStack(cfg, opts, result)
111111
}
112112

113-
// unstackTrackedStack unstacks a locally tracked stack: it removes the stack on
114-
// GitHub (unless --local) and then removes it from local tracking.
113+
// unstackTrackedStack unstacks eligible PRs on GitHub (unless --local) and then
114+
// removes the stack from local tracking.
115115
func unstackTrackedStack(cfg *config.Config, opts *unstackOptions, result *loadStackResult) error {
116116
gitDir := result.GitDir
117117

118118
sf := result.StackFile
119119
s := result.Stack
120120

121121
// Unstack on GitHub first (unless --local). The server decides which PRs
122-
// can be unstacked; PRs that are queued for merge or have auto-merge enabled
123-
// are left in place and the stack is kept. Local tracking is only removed
124-
// when the remote stack is fully dissolved.
122+
// can be removed; successful partial unstacking still removes local tracking.
125123
if !opts.local {
126124
if s.ID == "" && s.Number == 0 {
127125
cfg.Warningf("Stack has no remote ID — skipping server-side unstack")
@@ -140,16 +138,8 @@ func unstackTrackedStack(cfg *config.Config, opts *unstackOptions, result *loadS
140138

141139
if number == 0 {
142140
cfg.Warningf("Stack not found on GitHub — continuing with local unstack")
143-
} else {
144-
keepLocal, err := unstackNumberOnGitHub(cfg, client, number, true)
145-
if err != nil {
146-
return err
147-
}
148-
if keepLocal {
149-
// Some PRs remain stacked, so the stack still exists on
150-
// GitHub. Keep local tracking so it continues to reflect it.
151-
return nil
152-
}
141+
} else if err := unstackNumberOnGitHub(cfg, client, number, true); err != nil {
142+
return err
153143
}
154144
}
155145
}
@@ -181,24 +171,13 @@ func runRemoteUnstack(cfg *config.Config, number int) error {
181171
cfg.Errorf("failed to create GitHub client: %s", err)
182172
return ErrAPIFailure
183173
}
184-
if _, err := unstackNumberOnGitHub(cfg, client, number, false); err != nil {
185-
return err
186-
}
187-
return nil
174+
return unstackNumberOnGitHub(cfg, client, number, false)
188175
}
189176

190177
// unstackNumberOnGitHub calls the Unstack API for the given stack number and
191-
// reports the outcome. hasLocalTracking indicates whether the caller has a
192-
// locally tracked stack to reconcile, which changes how a 404 and a partial
193-
// unstack are handled: with local tracking a 404 is an idempotent success (the
194-
// caller finishes removing local state) and a partial unstack keeps local
195-
// tracking; without it a 404 is a hard error because the user targeted a stack
196-
// that does not exist on GitHub.
197-
//
198-
// It returns keepLocal=true when local tracking should be preserved because a
199-
// partial unstack left some PRs stacked. keepLocal is only meaningful when
200-
// hasLocalTracking is true.
201-
func unstackNumberOnGitHub(cfg *config.Config, client github.ClientOps, number int, hasLocalTracking bool) (keepLocal bool, err error) {
178+
// reports the outcome. With local tracking, a 404 is an idempotent success so
179+
// the caller can finish local cleanup; otherwise it is a not-found error.
180+
func unstackNumberOnGitHub(cfg *config.Config, client github.ClientOps, number int, hasLocalTracking bool) error {
202181
_, dissolved, err := client.Unstack(number)
203182
if err != nil {
204183
var httpErr *api.HTTPError
@@ -209,36 +188,28 @@ func unstackNumberOnGitHub(cfg *config.Config, client github.ClientOps, number i
209188
// Stack already gone on GitHub — treat as success and let
210189
// the caller finish removing local tracking.
211190
cfg.Warningf("Stack not found on GitHub — continuing with local unstack")
212-
return false, nil
191+
return nil
213192
}
214193
// Remote-first: the targeted stack does not exist on GitHub.
215194
cfg.Errorf("stack #%d not found on GitHub", number)
216-
return false, ErrNotInStack
195+
return ErrNotInStack
217196
case 422:
218-
// The server refused: every PR is queued for merge or has
219-
// auto-merge enabled, so nothing can be unstacked.
220197
cfg.Errorf("Unstacking not allowed: %s", httpErr.Message)
221-
return false, ErrInvalidArgs
198+
return ErrInvalidArgs
222199
default:
223200
cfg.Errorf("Failed to unstack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message)
224-
return false, ErrAPIFailure
201+
return ErrAPIFailure
225202
}
226203
}
227204
cfg.Errorf("Failed to unstack on GitHub: %v", err)
228-
return false, ErrAPIFailure
205+
return ErrAPIFailure
229206
}
230207

231-
if !dissolved {
232-
// Some PRs (queued for merge or with auto-merge enabled) remain stacked
233-
// on GitHub, so the stack still exists.
234-
cfg.Warningf("Some pull requests are queued for merge or have auto-merge enabled and remain stacked on GitHub")
235-
if hasLocalTracking {
236-
cfg.Printf("The stack was left in place — local tracking is unchanged")
237-
return true, nil
238-
}
239-
return false, nil
208+
if dissolved {
209+
cfg.Successf("Stack removed on GitHub%s", stackLabel(number))
210+
} else {
211+
cfg.Successf("Pull requests unstacked on GitHub%s", stackLabel(number))
212+
cfg.Infof("Some pull requests remain stacked on GitHub")
240213
}
241-
242-
cfg.Successf("Stack removed on GitHub%s", stackLabel(number))
243-
return false, nil
214+
return nil
244215
}

‎cmd/unstack_test.go‎

Lines changed: 72 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,7 @@ func TestUnstack_RemovesCorrectStackByPointer(t *testing.T) {
297297
}
298298

299299
func TestUnstack_AllLocked_ServerRejects(t *testing.T) {
300-
// Every PR is queued for merge or has auto-merge enabled. The server
301-
// (not the client) rejects the unstack with a 422; the command surfaces the
302-
// error and leaves local tracking in place.
300+
// The server rejects the unstack when no PRs can be removed.
303301
gitDir := t.TempDir()
304302
restore := git.SetOps(&git.MockOps{
305303
GitDirFn: func() (string, error) { return gitDir, nil },
@@ -322,7 +320,7 @@ func TestUnstack_AllLocked_ServerRejects(t *testing.T) {
322320
cfg.GitHubClientOverride = &github.MockClient{
323321
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
324322
unstackCalled = true
325-
return nil, false, &api.HTTPError{StatusCode: 422, Message: "all pull requests are queued for merge or have auto-merge enabled"}
323+
return nil, false, &api.HTTPError{StatusCode: 422, Message: "Pull requests #101, #102 cannot be removed from this stack"}
326324
},
327325
}
328326

@@ -339,45 +337,67 @@ func TestUnstack_AllLocked_ServerRejects(t *testing.T) {
339337
require.Len(t, sf.Stacks, 1)
340338
}
341339

342-
func TestUnstack_PartialUnstack_KeepsLocalTracking(t *testing.T) {
343-
// Some PRs (queued for merge / auto-merge) remain stacked, so the server
344-
// returns the surviving stack (dissolved=false). Local tracking is kept.
345-
gitDir := t.TempDir()
346-
restore := git.SetOps(&git.MockOps{
347-
GitDirFn: func() (string, error) { return gitDir, nil },
348-
CurrentBranchFn: func() (string, error) { return "b1", nil },
349-
})
350-
defer restore()
351-
352-
writeStackFile(t, gitDir, stack.Stack{
353-
ID: "99",
354-
Number: 99,
355-
Trunk: stack.BranchRef{Branch: "main"},
356-
Branches: []stack.BranchRef{
357-
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
358-
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
359-
},
360-
})
361-
362-
cfg, outR, errR := config.NewTestConfig()
363-
cfg.GitHubClientOverride = &github.MockClient{
364-
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
365-
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{102}}, false, nil
366-
},
340+
func TestUnstack_PartialUnstack_RemovesLocalTracking(t *testing.T) {
341+
tests := []struct {
342+
name string
343+
stackNumber int
344+
remainingPRs []int
345+
}{
346+
{name: "merged", remainingPRs: []int{101}},
347+
{name: "merged and queued", remainingPRs: []int{101, 102}},
348+
{name: "by stack number", stackNumber: 99, remainingPRs: []int{101}},
349+
}
350+
for _, tt := range tests {
351+
t.Run(tt.name, func(t *testing.T) {
352+
gitDir := t.TempDir()
353+
restore := git.SetOps(&git.MockOps{
354+
GitDirFn: func() (string, error) { return gitDir, nil },
355+
CurrentBranchFn: func() (string, error) { return "b2", nil },
356+
})
357+
defer restore()
358+
359+
writeTwoStacks(t, gitDir, stack.Stack{
360+
ID: "99",
361+
Number: 99,
362+
Trunk: stack.BranchRef{Branch: "main"},
363+
Branches: []stack.BranchRef{
364+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}},
365+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
366+
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 103}},
367+
},
368+
}, stack.Stack{
369+
ID: "42",
370+
Number: 42,
371+
Trunk: stack.BranchRef{Branch: "main"},
372+
Branches: []stack.BranchRef{{Branch: "other"}},
373+
})
374+
375+
cfg, outR, errR := config.NewTestConfig()
376+
cfg.GitHubClientOverride = &github.MockClient{
377+
UnstackFn: func(number int) (*github.RemoteStack, bool, error) {
378+
assert.Equal(t, 99, number)
379+
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: tt.remainingPRs}, false, nil
380+
},
381+
}
382+
383+
err := runUnstack(cfg, &unstackOptions{stackNumber: tt.stackNumber})
384+
output := collectOutput(cfg, outR, errR)
385+
386+
require.NoError(t, err)
387+
assert.Contains(t, output, "Pull requests unstacked on GitHub")
388+
assert.Contains(t, output, "Some pull requests remain stacked on GitHub")
389+
assert.Contains(t, output, "Stack removed from local tracking")
390+
assert.NotContains(t, output, "auto-merge")
391+
assert.NotContains(t, output, "local tracking is unchanged")
392+
assert.NotContains(t, output, "⚠")
393+
394+
sf, loadErr := stack.Load(gitDir)
395+
require.NoError(t, loadErr)
396+
require.Len(t, sf.Stacks, 1)
397+
assert.Equal(t, 42, sf.Stacks[0].Number)
398+
assert.Equal(t, []string{"other"}, sf.Stacks[0].BranchNames())
399+
})
367400
}
368-
369-
err := runUnstack(cfg, &unstackOptions{})
370-
output := collectOutput(cfg, outR, errR)
371-
372-
require.NoError(t, err)
373-
assert.Contains(t, output, "remain stacked on GitHub")
374-
assert.Contains(t, output, "local tracking is unchanged")
375-
assert.NotContains(t, output, "Stack removed from local tracking")
376-
377-
// The stack still exists remotely, so local tracking is preserved.
378-
sf, loadErr := stack.Load(gitDir)
379-
require.NoError(t, loadErr)
380-
require.Len(t, sf.Stacks, 1)
381401
}
382402

383403
func TestUnstack_NumberLookupFailure_StopsDeletion(t *testing.T) {
@@ -542,8 +562,7 @@ func TestUnstack_ByStackNumber_RemoteOnly_NotFound(t *testing.T) {
542562
}
543563

544564
func TestUnstack_ByStackNumber_RemoteOnly_Partial(t *testing.T) {
545-
// Some PRs (queued for merge / auto-merge) remain stacked. There is no local
546-
// tracking to keep, so the command reports the outcome and succeeds.
565+
// A partial remote-only unstack succeeds without changing unrelated local tracking.
547566
gitDir := t.TempDir()
548567
restore := git.SetOps(&git.MockOps{
549568
GitDirFn: func() (string, error) { return gitDir, nil },
@@ -568,7 +587,10 @@ func TestUnstack_ByStackNumber_RemoteOnly_Partial(t *testing.T) {
568587
output := collectOutput(cfg, outR, errR)
569588

570589
require.NoError(t, err)
571-
assert.Contains(t, output, "remain stacked on GitHub")
590+
assert.Contains(t, output, "Pull requests unstacked on GitHub")
591+
assert.Contains(t, output, "Some pull requests remain stacked on GitHub")
592+
assert.NotContains(t, output, "auto-merge")
593+
assert.NotContains(t, output, "⚠")
572594
// No local tracking is involved, so no local-tracking messaging is shown.
573595
assert.NotContains(t, output, "local tracking is unchanged")
574596
assert.NotContains(t, output, "Stack removed from local tracking")
@@ -579,8 +601,7 @@ func TestUnstack_ByStackNumber_RemoteOnly_Partial(t *testing.T) {
579601
}
580602

581603
func TestUnstack_ByStackNumber_RemoteOnly_AllLocked(t *testing.T) {
582-
// Every PR is queued for merge or has auto-merge enabled; the server rejects
583-
// the unstack with a 422 and the command surfaces the error.
604+
// The server rejects the unstack when no PRs can be removed.
584605
gitDir := t.TempDir()
585606
restore := git.SetOps(&git.MockOps{
586607
GitDirFn: func() (string, error) { return gitDir, nil },
@@ -598,7 +619,7 @@ func TestUnstack_ByStackNumber_RemoteOnly_AllLocked(t *testing.T) {
598619
cfg, outR, errR := config.NewTestConfig()
599620
cfg.GitHubClientOverride = &github.MockClient{
600621
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
601-
return nil, false, &api.HTTPError{StatusCode: 422, Message: "all pull requests are queued for merge or have auto-merge enabled"}
622+
return nil, false, &api.HTTPError{StatusCode: 422, Message: "Pull requests #101, #102 cannot be removed from this stack"}
602623
},
603624
}
604625
err := runUnstack(cfg, &unstackOptions{stackNumber: 999})
@@ -700,8 +721,7 @@ func TestUnstack_ByStackNumber_LocalFlag_LegacyStack_NoRemoteCall(t *testing.T)
700721

701722
func TestUnstack_ByStackNumber_LegacyStackResolvedByID(t *testing.T) {
702723
// A stack tracked before the number was recorded (Number == 0) is resolved
703-
// by mapping its internal ID to the remote stack number, and the backfilled
704-
// number is persisted.
724+
// by mapping its internal ID to the remote stack number.
705725
gitDir := t.TempDir()
706726
restore := git.SetOps(&git.MockOps{
707727
GitDirFn: func() (string, error) { return gitDir, nil },
@@ -726,7 +746,6 @@ func TestUnstack_ByStackNumber_LegacyStackResolvedByID(t *testing.T) {
726746
},
727747
UnstackFn: func(n int) (*github.RemoteStack, bool, error) {
728748
unstackedNumber = n
729-
// Some PRs remain stacked, so local tracking is kept.
730749
return &github.RemoteStack{ID: 99, Number: 7, PullRequests: []int{102}}, false, nil
731750
},
732751
}
@@ -737,10 +756,9 @@ func TestUnstack_ByStackNumber_LegacyStackResolvedByID(t *testing.T) {
737756
require.NoError(t, err)
738757
assert.Equal(t, 7, unstackedNumber, "should resolve the legacy stack and unstack by its remote number")
739758
assert.Contains(t, output, "remain stacked on GitHub")
759+
assert.Contains(t, output, "Stack removed from local tracking")
740760

741-
// The backfilled number is persisted to the stack file.
742761
sf, loadErr := stack.Load(gitDir)
743762
require.NoError(t, loadErr)
744-
require.Len(t, sf.Stacks, 1)
745-
assert.Equal(t, 7, sf.Stacks[0].Number, "the resolved stack number should be persisted")
763+
assert.Empty(t, sf.Stacks)
746764
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ With no argument, the command targets the active stack — the one that contains
306306

307307
Provide a stack number (the identifier shown in the github.com stack UI) to unstack a specific stack on GitHub. This works from anywhere in the repository, whether or not the stack is checked out locally — the stack is unstacked directly through the GitHub API. When the stack is also available locally, its local tracking is removed as well.
308308

309-
PRs that are merged, merging, or queued for merge cannot be removed from a stack on GitHub and are left part of the stack. When every pull request is removed, the stack is dissolved and any local tracking is removed; when some pull requests remain stacked, the stack is kept and local tracking, if any, is unchanged. Use `--local` to skip the remote operation and only remove local tracking.
309+
PRs that are merged, merging, or queued for merge cannot be removed from a stack on GitHub. After a successful unstack, local tracking is removed even when some PRs remain on GitHub. Use `--local` to skip the remote operation and only remove local tracking.
310310

311311
This is useful when you need to restructure a stack — remove a branch, insert a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init` to re-create the stack with the desired structure — existing branches are adopted automatically.
312312

‎docs/src/content/docs/reference/rest-api.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ echo '{"pull_requests": [104]}' | \
163163
POST /repos/{owner}/{repo}/stacks/{stack_number}/unstack
164164
```
165165

166-
Removes the unmerged pull requests from a stack. This endpoint takes no request body. Pull requests that cannot be unstacked (those merged, merging, or queued for merge) are left in place.
166+
Removes eligible pull requests from a stack. This endpoint takes no request body. Merged or queued pull requests are left in place.
167167

168168
- When pull requests remain in the stack, the updated stack is returned with `200 OK`.
169169
- When no pull requests remain, the stack is dissolved and `204 No Content` is returned.
@@ -174,7 +174,7 @@ gh api --method POST repos/OWNER/REPO/stacks/42/unstack
174174

175175
### The stack resource
176176

177-
Each stack is represented by the following resource. The get, create, and add endpoints return a single stack; the list endpoint returns an array of them; and unstack returns the remaining merged stack, or `204 No Content` when the stack is dissolved.
177+
Each stack is represented by the following resource. The get, create, and add endpoints return a single stack; the list endpoint returns an array of them; and unstack returns the remaining stack, or `204 No Content` when the stack is dissolved.
178178

179179
| Field | Type | Description |
180180
|-------|------|-------------|

‎internal/github/github.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -576,10 +576,10 @@ func (c *Client) AddToStack(stackNumber int, prNumbers []int) (*RemoteStack, err
576576
return &rs, nil
577577
}
578578

579-
// Unstack removes unlocked pull requests from a stack. The server leaves PRs
580-
// that cannot be unstacked (queued for merge or with auto-merge enabled) in
581-
// place. When PRs remain, the updated stack is returned with dissolved=false;
582-
// when none remain the stack is destroyed and dissolved=true (HTTP 204).
579+
// Unstack removes eligible pull requests from a stack. When PRs remain (e.g.
580+
// merged or queued PRs), the updated stack is returned with dissolved=false.
581+
// When none remain, the stack is destroyed and dissolved=true (HTTP 204).
582+
// Both outcomes indicate a successful unstack.
583583
func (c *Client) Unstack(stackNumber int) (rs *RemoteStack, dissolved bool, err error) {
584584
path := fmt.Sprintf("repos/%s/%s/stacks/%d/unstack", c.owner, c.repo, stackNumber)
585585
resp, err := c.rest.Request(http.MethodPost, path, nil)

0 commit comments

Comments
 (0)