Skip to content

Commit e0b2104

Browse files
authored
Remote unstack by stack number (#180)
* unstack as a pure api wrapper if stack not checked out locally * update unstack docs * address review comments
1 parent ceffffc commit e0b2104

7 files changed

Lines changed: 377 additions & 72 deletions

File tree

README.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -477,26 +477,33 @@ gh stack view --json
477477

478478
### `gh stack unstack`
479479

480-
Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`.
480+
Remove a stack from local tracking and unstack it on GitHub. Also available as `gh stack delete`.
481481

482482
```
483-
gh stack unstack [flags]
483+
gh stack unstack [<stack-number>] [flags]
484484
```
485485

486-
You must have an active stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch.
486+
With no argument, the command targets the active stack — the one that contains the currently checked out branch — unstacking it on GitHub and removing local tracking.
487487

488-
Deletes the stack on GitHub first, if it exists, then removes local tracking. Use `--local` to only remove from local tracking.
488+
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 number is unstacked directly through the GitHub API. If the stack is also tracked locally, its local tracking is removed as well.
489+
490+
Use `--local` to only remove local tracking without contacting GitHub.
491+
492+
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).
489493

490494
| Flag | Description |
491495
|------|-------------|
492-
| `--local` | Only delete the stack locally (keep it on GitHub) |
496+
| `--local` | Only remove the stack locally (keep it on GitHub) |
493497

494498
**Examples:**
495499

496500
```sh
497-
# Remove the stack from local tracking and GitHub
501+
# Remove the current stack from local tracking and GitHub
498502
gh stack unstack
499503

504+
# Unstack a specific stack by its number
505+
gh stack unstack 7
506+
500507
# Only remove local tracking
501508
gh stack unstack --local
502509
```

cmd/unstack.go

Lines changed: 123 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/cli/go-gh/v2/pkg/api"
88
"github.com/github/gh-stack/internal/config"
9+
"github.com/github/gh-stack/internal/github"
910
"github.com/github/gh-stack/internal/modify"
1011
"github.com/github/gh-stack/internal/stack"
1112
"github.com/spf13/cobra"
@@ -25,17 +26,24 @@ func UnstackCmd(cfg *config.Config) *cobra.Command {
2526
Short: "Remove a stack locally and on GitHub",
2627
Long: `Remove a stack from local tracking and unstack it on GitHub.
2728
28-
With no argument, the current active stack is used. Provide a stack number (the
29-
identifier shown in the github.com stack UI) to unstack a specific locally
30-
tracked stack instead. Use --local to only remove local tracking.
29+
With no argument, the active stack (the one containing the currently checked out
30+
branch) is unstacked on GitHub and removed from local tracking.
31+
32+
Provide a stack number (the identifier shown in the GitHub stack UI) to
33+
unstack a specific stack on GitHub. This works from anywhere in the repository,
34+
whether or not the stack is checked out locally — the number is unstacked
35+
directly through the GitHub API. If the stack is also available locally, its
36+
local tracking is removed as well.
37+
38+
Use --local to only remove local tracking without touching remote (GitHub).
3139
3240
GitHub decides which pull requests can be unstacked: PRs that are queued for
3341
merge or have auto-merge enabled are left stacked. When some pull requests
34-
remain stacked, local tracking is kept.`,
42+
remain stacked, the stack is kept (and local tracking, if any, is unchanged).`,
3543
Example: ` # Unstack the current stack locally and on GitHub
3644
$ gh stack unstack
3745
38-
# Unstack a specific stack by its stack number
46+
# Unstack a specific stack by its number
3947
$ gh stack unstack 7
4048
4149
# Only remove local tracking (keep the stack on GitHub)
@@ -60,16 +68,40 @@ remain stacked, local tracking is kept.`,
6068
}
6169

6270
func runUnstack(cfg *config.Config, opts *unstackOptions) error {
63-
var result *loadStackResult
64-
var err error
71+
// A stack number targets a specific stack. It is unstacked directly on
72+
// GitHub by number (remote-first), so this works from anywhere in the
73+
// repository whether or not the stack is tracked locally.
6574
if opts.stackNumber > 0 {
66-
result, err = loadStackByNumber(cfg, opts.stackNumber)
67-
} else {
68-
result, err = loadStack(cfg, "")
75+
// --local must never contact GitHub, so it uses a strictly local lookup
76+
result, ok, err := lookupStackByNumber(cfg, opts.stackNumber, !opts.local)
77+
if err != nil {
78+
return ErrNotInStack
79+
}
80+
if !ok {
81+
// The stack number isn't tracked locally.
82+
if opts.local {
83+
// --local never contacts GitHub, and there is nothing to remove
84+
// locally, so there is nothing to do.
85+
cfg.Errorf("stack #%d is not tracked locally", opts.stackNumber)
86+
cfg.Printf("Omit %s to unstack it on GitHub", cfg.ColorCyan("--local"))
87+
return ErrNotInStack
88+
}
89+
return runRemoteUnstack(cfg, opts.stackNumber)
90+
}
91+
return unstackTrackedStack(cfg, opts, result)
6992
}
93+
94+
// No argument: operate on the active stack for the current branch.
95+
result, err := loadStack(cfg, "")
7096
if err != nil {
7197
return ErrNotInStack
7298
}
99+
return unstackTrackedStack(cfg, opts, result)
100+
}
101+
102+
// unstackTrackedStack unstacks a locally tracked stack: it removes the stack on
103+
// GitHub (unless --local) and then removes it from local tracking.
104+
func unstackTrackedStack(cfg *config.Config, opts *unstackOptions, result *loadStackResult) error {
73105
gitDir := result.GitDir
74106

75107
if err := modify.CheckStateGuard(gitDir); err != nil {
@@ -102,35 +134,16 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error {
102134

103135
if number == 0 {
104136
cfg.Warningf("Stack not found on GitHub — continuing with local unstack")
105-
} else if _, dissolved, err := client.Unstack(number); err != nil {
106-
var httpErr *api.HTTPError
107-
if errors.As(err, &httpErr) {
108-
switch httpErr.StatusCode {
109-
case 404:
110-
// Stack already gone on GitHub — treat as success.
111-
cfg.Warningf("Stack not found on GitHub — continuing with local unstack")
112-
case 422:
113-
// The server refused: every PR is queued for merge or has
114-
// auto-merge enabled, so nothing can be unstacked.
115-
cfg.Errorf("Unstacking not allowed: %s", httpErr.Message)
116-
return ErrInvalidArgs
117-
default:
118-
cfg.Errorf("Failed to unstack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message)
119-
return ErrAPIFailure
120-
}
121-
} else {
122-
cfg.Errorf("Failed to unstack on GitHub: %v", err)
123-
return ErrAPIFailure
124-
}
125-
} else if !dissolved {
126-
// Some PRs (queued for merge or with auto-merge enabled) remain
127-
// stacked on GitHub, so the stack still exists. Keep local
128-
// tracking so it continues to reflect the remote stack.
129-
cfg.Warningf("Some pull requests are queued for merge or have auto-merge enabled and remain stacked on GitHub")
130-
cfg.Printf("The stack was left in place — local tracking is unchanged")
131-
return nil
132137
} else {
133-
cfg.Successf("Stack removed on GitHub%s", stackLabel(number))
138+
keepLocal, err := unstackNumberOnGitHub(cfg, client, number, true)
139+
if err != nil {
140+
return err
141+
}
142+
if keepLocal {
143+
// Some PRs remain stacked, so the stack still exists on
144+
// GitHub. Keep local tracking so it continues to reflect it.
145+
return nil
146+
}
134147
}
135148
}
136149
}
@@ -151,3 +164,75 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error {
151164

152165
return nil
153166
}
167+
168+
// runRemoteUnstack unstacks a stack on GitHub purely by its number, without any
169+
// local tracking. This is the remote-first path that lets `gh stack unstack
170+
// <number>` run from anywhere in the repository (like `gh stack link`), whether
171+
// or not the stack is checked out locally.
172+
func runRemoteUnstack(cfg *config.Config, number int) error {
173+
client, err := cfg.GitHubClient()
174+
if err != nil {
175+
cfg.Errorf("failed to create GitHub client: %s", err)
176+
return ErrAPIFailure
177+
}
178+
if _, err := unstackNumberOnGitHub(cfg, client, number, false); err != nil {
179+
return err
180+
}
181+
return nil
182+
}
183+
184+
// unstackNumberOnGitHub calls the Unstack API for the given stack number and
185+
// reports the outcome. hasLocalTracking indicates whether the caller has a
186+
// locally tracked stack to reconcile, which changes how a 404 and a partial
187+
// unstack are handled: with local tracking a 404 is an idempotent success (the
188+
// caller finishes removing local state) and a partial unstack keeps local
189+
// tracking; without it a 404 is a hard error because the user targeted a stack
190+
// that does not exist on GitHub.
191+
//
192+
// It returns keepLocal=true when local tracking should be preserved because a
193+
// partial unstack left some PRs stacked. keepLocal is only meaningful when
194+
// hasLocalTracking is true.
195+
func unstackNumberOnGitHub(cfg *config.Config, client github.ClientOps, number int, hasLocalTracking bool) (keepLocal bool, err error) {
196+
_, dissolved, err := client.Unstack(number)
197+
if err != nil {
198+
var httpErr *api.HTTPError
199+
if errors.As(err, &httpErr) {
200+
switch httpErr.StatusCode {
201+
case 404:
202+
if hasLocalTracking {
203+
// Stack already gone on GitHub — treat as success and let
204+
// the caller finish removing local tracking.
205+
cfg.Warningf("Stack not found on GitHub — continuing with local unstack")
206+
return false, nil
207+
}
208+
// Remote-first: the targeted stack does not exist on GitHub.
209+
cfg.Errorf("stack #%d not found on GitHub", number)
210+
return false, ErrNotInStack
211+
case 422:
212+
// The server refused: every PR is queued for merge or has
213+
// auto-merge enabled, so nothing can be unstacked.
214+
cfg.Errorf("Unstacking not allowed: %s", httpErr.Message)
215+
return false, ErrInvalidArgs
216+
default:
217+
cfg.Errorf("Failed to unstack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message)
218+
return false, ErrAPIFailure
219+
}
220+
}
221+
cfg.Errorf("Failed to unstack on GitHub: %v", err)
222+
return false, ErrAPIFailure
223+
}
224+
225+
if !dissolved {
226+
// Some PRs (queued for merge or with auto-merge enabled) remain stacked
227+
// on GitHub, so the stack still exists.
228+
cfg.Warningf("Some pull requests are queued for merge or have auto-merge enabled and remain stacked on GitHub")
229+
if hasLocalTracking {
230+
cfg.Printf("The stack was left in place — local tracking is unchanged")
231+
return true, nil
232+
}
233+
return false, nil
234+
}
235+
236+
cfg.Successf("Stack removed on GitHub%s", stackLabel(number))
237+
return false, nil
238+
}

0 commit comments

Comments
 (0)