Skip to content

Commit 0ba54a4

Browse files
authored
Merge pull request #12 from github/skarim/unstack
unstack command
2 parents f2d823c + 4b6f2bd commit 0ba54a4

16 files changed

Lines changed: 630 additions & 26 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. Also available as `gh stack delete`.
330+
331+
```
332+
gh stack unstack [branch] [flags]
333+
```
334+
335+
If no branch is specified, uses the current branch to find the stack. Deletes the stack on GitHub first, then removes local tracking. 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/init.go

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -174,25 +174,6 @@ func runInit(cfg *config.Config, opts *initOptions) error {
174174
}
175175
}
176176
branches = opts.branches
177-
178-
// Check if any adopted branches already have PRs on GitHub.
179-
// If offline or unable to create client, skip silently.
180-
if client, clientErr := cfg.GitHubClient(); clientErr == nil {
181-
for _, b := range branches {
182-
pr, err := client.FindAnyPRForBranch(b)
183-
if err != nil {
184-
continue
185-
}
186-
if pr != nil {
187-
state := "open"
188-
if pr.Merged {
189-
state = "merged"
190-
}
191-
cfg.Errorf("branch %q already has a %s PR (#%d: %s)", b, state, pr.Number, pr.URL)
192-
return ErrInvalidArgs
193-
}
194-
}
195-
}
196177
} else if len(opts.branches) > 0 {
197178
// Explicit branch names provided — apply prefix and create them
198179
prefixed := make([]string, 0, len(opts.branches))
@@ -323,8 +304,28 @@ func runInit(cfg *config.Config, opts *initOptions) error {
323304

324305
sf.AddStack(newStack)
325306

326-
// Sync PR state for adopted branches
327-
syncStackPRs(cfg, &sf.Stacks[len(sf.Stacks)-1])
307+
// Discover existing PRs for the new stack's branches.
308+
// For adopt, only record open/draft PRs (ignore closed/merged).
309+
// For non-adopt, use the standard sync which also detects merges.
310+
latestStack := &sf.Stacks[len(sf.Stacks)-1]
311+
if opts.adopt {
312+
if client, clientErr := cfg.GitHubClient(); clientErr == nil {
313+
for i := range latestStack.Branches {
314+
b := &latestStack.Branches[i]
315+
pr, err := client.FindPRForBranch(b.Branch)
316+
if err != nil || pr == nil {
317+
continue
318+
}
319+
b.PullRequest = &stack.PullRequestRef{
320+
Number: pr.Number,
321+
ID: pr.ID,
322+
URL: pr.URL,
323+
}
324+
}
325+
}
326+
} else {
327+
syncStackPRs(cfg, latestStack)
328+
}
328329

329330
if err := stack.Save(gitDir, sf); err != nil {
330331
return handleSaveError(cfg, err)

cmd/init_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/github/gh-stack/internal/config"
1010
"github.com/github/gh-stack/internal/git"
11+
"github.com/github/gh-stack/internal/github"
1112
"github.com/github/gh-stack/internal/stack"
1213
"github.com/stretchr/testify/assert"
1314
"github.com/stretchr/testify/require"
@@ -290,3 +291,91 @@ func TestInit_MultipleBranches_CreatesAll(t *testing.T) {
290291
names := sf.Stacks[0].BranchNames()
291292
assert.Equal(t, []string{"b1", "b2", "b3"}, names)
292293
}
294+
295+
func TestInit_AdoptWithExistingOpenPR(t *testing.T) {
296+
gitDir := t.TempDir()
297+
restore := git.SetOps(&git.MockOps{
298+
GitDirFn: func() (string, error) { return gitDir, nil },
299+
DefaultBranchFn: func() (string, error) { return "main", nil },
300+
CurrentBranchFn: func() (string, error) { return "main", nil },
301+
BranchExistsFn: func(string) bool { return true },
302+
})
303+
defer restore()
304+
305+
cfg, outR, errR := config.NewTestConfig()
306+
cfg.GitHubClientOverride = &github.MockClient{
307+
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
308+
if branch == "b1" {
309+
return &github.PullRequest{
310+
Number: 42,
311+
ID: "PR_42",
312+
URL: "https://github.com/owner/repo/pull/42",
313+
State: "OPEN",
314+
HeadRefName: "b1",
315+
}, nil
316+
}
317+
return nil, nil
318+
},
319+
}
320+
321+
err := runInit(cfg, &initOptions{
322+
branches: []string{"b1", "b2"},
323+
adopt: true,
324+
})
325+
output := collectOutput(cfg, outR, errR)
326+
327+
require.NoError(t, err, "adopt should succeed even when branch has an open PR")
328+
require.NotContains(t, output, "\u2717", "unexpected error in output")
329+
330+
sf, err := stack.Load(gitDir)
331+
require.NoError(t, err, "loading stack")
332+
require.Len(t, sf.Stacks, 1)
333+
334+
// b1 should have the open PR recorded
335+
b1 := sf.Stacks[0].Branches[0]
336+
require.NotNil(t, b1.PullRequest, "open PR should be recorded")
337+
assert.Equal(t, 42, b1.PullRequest.Number)
338+
assert.Equal(t, "https://github.com/owner/repo/pull/42", b1.PullRequest.URL)
339+
340+
// b2 should have no PR
341+
b2 := sf.Stacks[0].Branches[1]
342+
assert.Nil(t, b2.PullRequest, "branch without PR should have nil PullRequest")
343+
}
344+
345+
func TestInit_AdoptIgnoresClosedAndMergedPRs(t *testing.T) {
346+
gitDir := t.TempDir()
347+
restore := git.SetOps(&git.MockOps{
348+
GitDirFn: func() (string, error) { return gitDir, nil },
349+
DefaultBranchFn: func() (string, error) { return "main", nil },
350+
CurrentBranchFn: func() (string, error) { return "main", nil },
351+
BranchExistsFn: func(string) bool { return true },
352+
})
353+
defer restore()
354+
355+
cfg, outR, errR := config.NewTestConfig()
356+
// FindPRForBranch only returns OPEN PRs — closed/merged PRs won't be
357+
// returned by the API, so the mock returns nil for all branches.
358+
cfg.GitHubClientOverride = &github.MockClient{
359+
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
360+
return nil, nil
361+
},
362+
}
363+
364+
err := runInit(cfg, &initOptions{
365+
branches: []string{"b1", "b2"},
366+
adopt: true,
367+
})
368+
output := collectOutput(cfg, outR, errR)
369+
370+
require.NoError(t, err, "adopt should succeed when branches have closed/merged PRs")
371+
require.NotContains(t, output, "\u2717", "unexpected error in output")
372+
373+
sf, err := stack.Load(gitDir)
374+
require.NoError(t, err, "loading stack")
375+
require.Len(t, sf.Stacks, 1)
376+
377+
// Neither branch should have a PR recorded (closed/merged are filtered out)
378+
for _, b := range sf.Stacks[0].Branches {
379+
assert.Nil(t, b.PullRequest, "closed/merged PRs should not be recorded for branch %s", b.Branch)
380+
}
381+
}

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: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
Aliases: []string{"delete"},
23+
Short: "Delete a stack locally and on GitHub",
24+
Long: "Remove a stack from local tracking and delete it on GitHub. Use --local to only remove local tracking.",
25+
Args: cobra.MaximumNArgs(1),
26+
RunE: func(cmd *cobra.Command, args []string) error {
27+
if len(args) > 0 {
28+
opts.target = args[0]
29+
}
30+
return runUnstack(cfg, opts)
31+
},
32+
}
33+
34+
cmd.Flags().BoolVar(&opts.local, "local", false, "Only delete the stack locally")
35+
36+
return cmd
37+
}
38+
39+
func runUnstack(cfg *config.Config, opts *unstackOptions) error {
40+
result, err := loadStack(cfg, opts.target)
41+
if err != nil {
42+
return ErrNotInStack
43+
}
44+
gitDir := result.GitDir
45+
sf := result.StackFile
46+
s := result.Stack
47+
48+
// Delete the stack on GitHub first (unless --local).
49+
// Only proceed with local deletion after the remote operation succeeds.
50+
if !opts.local {
51+
if s.ID == "" {
52+
cfg.Warningf("Stack has no remote ID — skipping server-side deletion")
53+
} else {
54+
client, err := cfg.GitHubClient()
55+
if err != nil {
56+
cfg.Errorf("failed to create GitHub client: %s", err)
57+
return ErrAPIFailure
58+
}
59+
if err := client.DeleteStack(s.ID); err != nil {
60+
var httpErr *api.HTTPError
61+
if errors.As(err, &httpErr) {
62+
switch httpErr.StatusCode {
63+
case 404:
64+
// Stack already deleted on GitHub — treat as success.
65+
cfg.Warningf("Stack not found on GitHub — continuing with local unstack")
66+
default:
67+
cfg.Errorf("Failed to delete stack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message)
68+
return ErrAPIFailure
69+
}
70+
} else {
71+
cfg.Errorf("Failed to delete stack on GitHub: %v", err)
72+
return ErrAPIFailure
73+
}
74+
} else {
75+
cfg.Successf("Stack deleted on GitHub")
76+
}
77+
}
78+
}
79+
80+
// Remove the exact resolved stack from local tracking by pointer identity,
81+
// not by branch name — avoids removing the wrong stack when a trunk
82+
// branch is shared across multiple stacks.
83+
for i := range sf.Stacks {
84+
if &sf.Stacks[i] == s {
85+
sf.RemoveStack(i)
86+
break
87+
}
88+
}
89+
if err := stack.Save(gitDir, sf); err != nil {
90+
return handleSaveError(cfg, err)
91+
}
92+
cfg.Successf("Stack removed from local tracking")
93+
94+
return nil
95+
}

0 commit comments

Comments
 (0)