Skip to content

Commit c56277b

Browse files
committed
unstack cmd
1 parent f2d823c commit c56277b

10 files changed

Lines changed: 406 additions & 3 deletions

File tree

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,37 @@ gh stack view --short
324324
gh stack view --json
325325
```
326326

327+
### `gh stack unstack`
328+
329+
Remove a stack from local tracking and delete it on GitHub.
330+
331+
```
332+
gh stack unstack [branch] [flags]
333+
```
334+
335+
If no branch is specified, uses the current branch to find the stack. By default, the stack is removed from both local tracking and GitHub. Use `--local` to only remove the local tracking entry.
336+
337+
| Flag | Description |
338+
|------|-------------|
339+
| `--local` | Only delete the stack locally (keep it on GitHub) |
340+
341+
| Argument | Description |
342+
|----------|-------------|
343+
| `[branch]` | A branch in the stack to delete (defaults to the current branch) |
344+
345+
**Examples:**
346+
347+
```sh
348+
# Remove the stack from local tracking and GitHub
349+
gh stack unstack
350+
351+
# Only remove local tracking
352+
gh stack unstack --local
353+
354+
# Specify a branch to identify the stack
355+
gh stack unstack feature-auth
356+
```
357+
327358
### `gh stack merge`
328359

329360
Merge a stack of PRs.

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func RootCmd() *cobra.Command {
3535
root.AddCommand(PushCmd(cfg))
3636
root.AddCommand(SubmitCmd(cfg))
3737
root.AddCommand(SyncCmd(cfg))
38+
root.AddCommand(UnstackCmd(cfg))
3839
root.AddCommand(MergeCmd(cfg))
3940

4041
// Helper commands

cmd/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88

99
func TestRootCmd_SubcommandRegistration(t *testing.T) {
1010
root := RootCmd()
11-
expected := []string{"init", "add", "checkout", "push", "sync", "merge", "view", "rebase", "up", "down", "top", "bottom", "alias", "feedback", "submit"}
11+
expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "merge", "view", "rebase", "up", "down", "top", "bottom", "alias", "feedback", "submit"}
1212

1313
registered := make(map[string]bool)
1414
for _, cmd := range root.Commands() {

cmd/unstack.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
6+
"github.com/cli/go-gh/v2/pkg/api"
7+
"github.com/github/gh-stack/internal/config"
8+
"github.com/github/gh-stack/internal/stack"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
type unstackOptions struct {
13+
target string
14+
local bool
15+
}
16+
17+
func UnstackCmd(cfg *config.Config) *cobra.Command {
18+
opts := &unstackOptions{}
19+
20+
cmd := &cobra.Command{
21+
Use: "unstack [branch]",
22+
Short: "Delete a stack locally and on GitHub",
23+
Long: "Remove a stack from local tracking and delete it on GitHub. Use --local to only remove local tracking.",
24+
Args: cobra.MaximumNArgs(1),
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
if len(args) > 0 {
27+
opts.target = args[0]
28+
}
29+
return runUnstack(cfg, opts)
30+
},
31+
}
32+
33+
cmd.Flags().BoolVar(&opts.local, "local", false, "Only delete the stack locally")
34+
35+
return cmd
36+
}
37+
38+
func runUnstack(cfg *config.Config, opts *unstackOptions) error {
39+
result, err := loadStack(cfg, opts.target)
40+
if err != nil {
41+
return ErrNotInStack
42+
}
43+
gitDir := result.GitDir
44+
sf := result.StackFile
45+
s := result.Stack
46+
target := opts.target
47+
if target == "" {
48+
target = result.CurrentBranch
49+
}
50+
51+
cfg.Printf("Stack branches: %v", s.BranchNames())
52+
53+
// Delete the stack on GitHub first (unless --local).
54+
// Only proceed with local deletion after the remote operation succeeds.
55+
if !opts.local {
56+
if s.ID == "" {
57+
cfg.Warningf("Stack has no remote ID — skipping server-side deletion")
58+
} else {
59+
client, err := cfg.GitHubClient()
60+
if err != nil {
61+
cfg.Errorf("failed to create GitHub client: %s", err)
62+
return ErrAPIFailure
63+
}
64+
if err := client.DeleteStack(s.ID); err != nil {
65+
var httpErr *api.HTTPError
66+
if errors.As(err, &httpErr) {
67+
cfg.Errorf("Failed to delete stack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message)
68+
} else {
69+
cfg.Errorf("Failed to delete stack on GitHub: %v", err)
70+
}
71+
return ErrAPIFailure
72+
}
73+
cfg.Successf("Stack deleted on GitHub")
74+
}
75+
}
76+
77+
// Remove from local tracking
78+
sf.RemoveStackForBranch(target)
79+
if err := stack.Save(gitDir, sf); err != nil {
80+
return handleSaveError(cfg, err)
81+
}
82+
cfg.Successf("Stack removed from local tracking")
83+
84+
return nil
85+
}

cmd/unstack_test.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/cli/go-gh/v2/pkg/api"
10+
"github.com/github/gh-stack/internal/config"
11+
"github.com/github/gh-stack/internal/git"
12+
"github.com/github/gh-stack/internal/github"
13+
"github.com/github/gh-stack/internal/stack"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func writeTwoStacks(t *testing.T, dir string, s1, s2 stack.Stack) {
19+
t.Helper()
20+
sf := &stack.StackFile{
21+
SchemaVersion: 1,
22+
Stacks: []stack.Stack{s1, s2},
23+
}
24+
data, err := json.MarshalIndent(sf, "", " ")
25+
require.NoError(t, err)
26+
require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack"), data, 0644))
27+
}
28+
29+
func TestUnstack_RemovesStack(t *testing.T) {
30+
gitDir := t.TempDir()
31+
restore := git.SetOps(&git.MockOps{
32+
GitDirFn: func() (string, error) { return gitDir, nil },
33+
CurrentBranchFn: func() (string, error) { return "b1", nil },
34+
})
35+
defer restore()
36+
37+
s1 := stack.Stack{
38+
ID: "42",
39+
Trunk: stack.BranchRef{Branch: "main"},
40+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
41+
}
42+
s2 := stack.Stack{
43+
Trunk: stack.BranchRef{Branch: "main"},
44+
Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}},
45+
}
46+
writeTwoStacks(t, gitDir, s1, s2)
47+
48+
var deletedStackID string
49+
cfg, outR, errR := config.NewTestConfig()
50+
cfg.GitHubClientOverride = &github.MockClient{
51+
DeleteStackFn: func(stackID string) error {
52+
deletedStackID = stackID
53+
return nil
54+
},
55+
}
56+
err := runUnstack(cfg, &unstackOptions{})
57+
output := collectOutput(cfg, outR, errR)
58+
59+
require.NoError(t, err)
60+
assert.Contains(t, output, "Stack removed from local tracking")
61+
assert.Contains(t, output, "Stack deleted on GitHub")
62+
assert.Equal(t, "42", deletedStackID)
63+
64+
sf, err := stack.Load(gitDir)
65+
require.NoError(t, err)
66+
require.Len(t, sf.Stacks, 1)
67+
assert.Equal(t, []string{"b3", "b4"}, sf.Stacks[0].BranchNames())
68+
}
69+
70+
func TestUnstack_Local(t *testing.T) {
71+
gitDir := t.TempDir()
72+
restore := git.SetOps(&git.MockOps{
73+
GitDirFn: func() (string, error) { return gitDir, nil },
74+
CurrentBranchFn: func() (string, error) { return "b1", nil },
75+
})
76+
defer restore()
77+
78+
writeStackFile(t, gitDir, stack.Stack{
79+
Trunk: stack.BranchRef{Branch: "main"},
80+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
81+
})
82+
83+
cfg, outR, errR := config.NewTestConfig()
84+
err := runUnstack(cfg, &unstackOptions{local: true})
85+
output := collectOutput(cfg, outR, errR)
86+
87+
require.NoError(t, err)
88+
assert.Contains(t, output, "Stack removed")
89+
// With --local, the GitHub API should NOT be called.
90+
assert.NotContains(t, output, "Stack deleted on GitHub")
91+
92+
sf, err := stack.Load(gitDir)
93+
require.NoError(t, err)
94+
assert.Empty(t, sf.Stacks)
95+
}
96+
97+
func TestUnstack_WithTarget(t *testing.T) {
98+
gitDir := t.TempDir()
99+
restore := git.SetOps(&git.MockOps{
100+
GitDirFn: func() (string, error) { return gitDir, nil },
101+
CurrentBranchFn: func() (string, error) { return "unrelated", nil },
102+
})
103+
defer restore()
104+
105+
s1 := stack.Stack{
106+
Trunk: stack.BranchRef{Branch: "main"},
107+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
108+
}
109+
s2 := stack.Stack{
110+
Trunk: stack.BranchRef{Branch: "main"},
111+
Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}},
112+
}
113+
writeTwoStacks(t, gitDir, s1, s2)
114+
115+
cfg, outR, errR := config.NewTestConfig()
116+
err := runUnstack(cfg, &unstackOptions{target: "b3", local: true})
117+
output := collectOutput(cfg, outR, errR)
118+
119+
require.NoError(t, err)
120+
assert.Contains(t, output, "Stack removed")
121+
122+
sf, err := stack.Load(gitDir)
123+
require.NoError(t, err)
124+
require.Len(t, sf.Stacks, 1)
125+
assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames())
126+
}
127+
128+
func TestUnstack_NoStackID_WarnsAndSkipsAPI(t *testing.T) {
129+
gitDir := t.TempDir()
130+
restore := git.SetOps(&git.MockOps{
131+
GitDirFn: func() (string, error) { return gitDir, nil },
132+
CurrentBranchFn: func() (string, error) { return "b1", nil },
133+
})
134+
defer restore()
135+
136+
// Stack with no ID (never synced to GitHub)
137+
writeStackFile(t, gitDir, stack.Stack{
138+
Trunk: stack.BranchRef{Branch: "main"},
139+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
140+
})
141+
142+
apiCalled := false
143+
cfg, outR, errR := config.NewTestConfig()
144+
cfg.GitHubClientOverride = &github.MockClient{
145+
DeleteStackFn: func(stackID string) error {
146+
apiCalled = true
147+
return nil
148+
},
149+
}
150+
err := runUnstack(cfg, &unstackOptions{})
151+
output := collectOutput(cfg, outR, errR)
152+
153+
require.NoError(t, err)
154+
assert.False(t, apiCalled, "API should not be called when stack has no ID")
155+
assert.Contains(t, output, "no remote ID")
156+
assert.Contains(t, output, "Stack removed from local tracking")
157+
assert.NotContains(t, output, "Stack deleted on GitHub")
158+
}
159+
160+
func TestUnstack_API404_ShowsErrorAndStopsLocalDeletion(t *testing.T) {
161+
gitDir := t.TempDir()
162+
restore := git.SetOps(&git.MockOps{
163+
GitDirFn: func() (string, error) { return gitDir, nil },
164+
CurrentBranchFn: func() (string, error) { return "b1", nil },
165+
})
166+
defer restore()
167+
168+
writeStackFile(t, gitDir, stack.Stack{
169+
ID: "99",
170+
Trunk: stack.BranchRef{Branch: "main"},
171+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
172+
})
173+
174+
cfg, outR, errR := config.NewTestConfig()
175+
cfg.GitHubClientOverride = &github.MockClient{
176+
DeleteStackFn: func(stackID string) error {
177+
return &api.HTTPError{StatusCode: 404, Message: "Not Found"}
178+
},
179+
}
180+
err := runUnstack(cfg, &unstackOptions{})
181+
output := collectOutput(cfg, outR, errR)
182+
183+
assert.ErrorIs(t, err, ErrAPIFailure)
184+
assert.Contains(t, output, "Failed to delete stack on GitHub (HTTP 404)")
185+
// Should NOT remove locally when remote fails
186+
assert.NotContains(t, output, "Stack removed from local tracking")
187+
188+
// Stack should still exist locally
189+
sf, err := stack.Load(gitDir)
190+
require.NoError(t, err)
191+
require.Len(t, sf.Stacks, 1)
192+
}
193+
194+
func TestUnstack_API409_ShowsErrorAndStopsLocalDeletion(t *testing.T) {
195+
gitDir := t.TempDir()
196+
restore := git.SetOps(&git.MockOps{
197+
GitDirFn: func() (string, error) { return gitDir, nil },
198+
CurrentBranchFn: func() (string, error) { return "b1", nil },
199+
})
200+
defer restore()
201+
202+
writeStackFile(t, gitDir, stack.Stack{
203+
ID: "99",
204+
Trunk: stack.BranchRef{Branch: "main"},
205+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
206+
})
207+
208+
cfg, outR, errR := config.NewTestConfig()
209+
cfg.GitHubClientOverride = &github.MockClient{
210+
DeleteStackFn: func(stackID string) error {
211+
return &api.HTTPError{StatusCode: 409, Message: "Stack is currently being modified"}
212+
},
213+
}
214+
err := runUnstack(cfg, &unstackOptions{})
215+
output := collectOutput(cfg, outR, errR)
216+
217+
assert.ErrorIs(t, err, ErrAPIFailure)
218+
assert.Contains(t, output, "Failed to delete stack on GitHub (HTTP 409)")
219+
// Should NOT remove locally when remote fails
220+
assert.NotContains(t, output, "Stack removed from local tracking")
221+
222+
// Stack should still exist locally
223+
sf, err := stack.Load(gitDir)
224+
require.NoError(t, err)
225+
require.Len(t, sf.Stacks, 1)
226+
}

internal/github/client_interface.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type ClientOps interface {
1111
UpdatePRBase(number int, base string) error
1212
CreateStack(prNumbers []int) (int, error)
1313
UpdateStack(stackID string, prNumbers []int) error
14+
DeleteStack(stackID string) error
1415
}
1516

1617
// Compile-time check that Client satisfies ClientOps.

internal/github/github.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,13 @@ func (c *Client) FindPRDetailsForBranch(branch string) (*PRDetails, error) {
281281
}, nil
282282
}
283283

284+
// DeleteStack deletes a stack on GitHub.
285+
// The stack is identified by stackID. Returns nil on success (204).
286+
func (c *Client) DeleteStack(stackID string) error {
287+
path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks/%s", c.owner, c.repo, stackID)
288+
return c.rest.Delete(path, nil)
289+
}
290+
284291
// CreateStack creates a stack on GitHub from an ordered list of PR numbers.
285292
// The PR numbers must be ordered from bottom to top of the stack and must
286293
// form a valid base-to-head chain. Returns the server-assigned stack ID.

0 commit comments

Comments
 (0)