Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,37 @@ gh stack view --short
gh stack view --json
```

### `gh stack unstack`

Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`.

```
gh stack unstack [branch] [flags]
```

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.

| Flag | Description |
|------|-------------|
| `--local` | Only delete the stack locally (keep it on GitHub) |

| Argument | Description |
|----------|-------------|
| `[branch]` | A branch in the stack to delete (defaults to the current branch) |

**Examples:**

```sh
# Remove the stack from local tracking and GitHub
gh stack unstack

# Only remove local tracking
gh stack unstack --local

# Specify a branch to identify the stack
gh stack unstack feature-auth
```

### `gh stack merge`

Merge a stack of PRs.
Expand Down
43 changes: 22 additions & 21 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,25 +174,6 @@ func runInit(cfg *config.Config, opts *initOptions) error {
}
}
branches = opts.branches

// Check if any adopted branches already have PRs on GitHub.
// If offline or unable to create client, skip silently.
if client, clientErr := cfg.GitHubClient(); clientErr == nil {
for _, b := range branches {
pr, err := client.FindAnyPRForBranch(b)
if err != nil {
continue
}
if pr != nil {
state := "open"
if pr.Merged {
state = "merged"
}
cfg.Errorf("branch %q already has a %s PR (#%d: %s)", b, state, pr.Number, pr.URL)
return ErrInvalidArgs
}
}
}
} else if len(opts.branches) > 0 {
// Explicit branch names provided — apply prefix and create them
prefixed := make([]string, 0, len(opts.branches))
Expand Down Expand Up @@ -323,8 +304,28 @@ func runInit(cfg *config.Config, opts *initOptions) error {

sf.AddStack(newStack)

// Sync PR state for adopted branches
syncStackPRs(cfg, &sf.Stacks[len(sf.Stacks)-1])
// Discover existing PRs for the new stack's branches.
// For adopt, only record open/draft PRs (ignore closed/merged).
// For non-adopt, use the standard sync which also detects merges.
latestStack := &sf.Stacks[len(sf.Stacks)-1]
if opts.adopt {
if client, clientErr := cfg.GitHubClient(); clientErr == nil {
Comment thread
skarim marked this conversation as resolved.
for i := range latestStack.Branches {
b := &latestStack.Branches[i]
pr, err := client.FindPRForBranch(b.Branch)
if err != nil || pr == nil {
continue
}
b.PullRequest = &stack.PullRequestRef{
Number: pr.Number,
ID: pr.ID,
URL: pr.URL,
}
}
}
} else {
syncStackPRs(cfg, latestStack)
}

if err := stack.Save(gitDir, sf); err != nil {
return handleSaveError(cfg, err)
Expand Down
89 changes: 89 additions & 0 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/stack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -290,3 +291,91 @@ func TestInit_MultipleBranches_CreatesAll(t *testing.T) {
names := sf.Stacks[0].BranchNames()
assert.Equal(t, []string{"b1", "b2", "b3"}, names)
}

func TestInit_AdoptWithExistingOpenPR(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) bool { return true },
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
if branch == "b1" {
return &github.PullRequest{
Number: 42,
ID: "PR_42",
URL: "https://github.com/owner/repo/pull/42",
State: "OPEN",
HeadRefName: "b1",
}, nil
}
return nil, nil
},
}

err := runInit(cfg, &initOptions{
branches: []string{"b1", "b2"},
adopt: true,
})
output := collectOutput(cfg, outR, errR)

require.NoError(t, err, "adopt should succeed even when branch has an open PR")
require.NotContains(t, output, "\u2717", "unexpected error in output")

sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
require.Len(t, sf.Stacks, 1)

// b1 should have the open PR recorded
b1 := sf.Stacks[0].Branches[0]
require.NotNil(t, b1.PullRequest, "open PR should be recorded")
assert.Equal(t, 42, b1.PullRequest.Number)
assert.Equal(t, "https://github.com/owner/repo/pull/42", b1.PullRequest.URL)

// b2 should have no PR
b2 := sf.Stacks[0].Branches[1]
assert.Nil(t, b2.PullRequest, "branch without PR should have nil PullRequest")
}

func TestInit_AdoptIgnoresClosedAndMergedPRs(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) bool { return true },
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
// FindPRForBranch only returns OPEN PRs — closed/merged PRs won't be
// returned by the API, so the mock returns nil for all branches.
cfg.GitHubClientOverride = &github.MockClient{
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
return nil, nil
},
}

err := runInit(cfg, &initOptions{
branches: []string{"b1", "b2"},
adopt: true,
})
output := collectOutput(cfg, outR, errR)

require.NoError(t, err, "adopt should succeed when branches have closed/merged PRs")
require.NotContains(t, output, "\u2717", "unexpected error in output")

sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
require.Len(t, sf.Stacks, 1)

// Neither branch should have a PR recorded (closed/merged are filtered out)
for _, b := range sf.Stacks[0].Branches {
assert.Nil(t, b.PullRequest, "closed/merged PRs should not be recorded for branch %s", b.Branch)
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func RootCmd() *cobra.Command {
root.AddCommand(PushCmd(cfg))
root.AddCommand(SubmitCmd(cfg))
root.AddCommand(SyncCmd(cfg))
root.AddCommand(UnstackCmd(cfg))
root.AddCommand(MergeCmd(cfg))

// Helper commands
Expand Down
2 changes: 1 addition & 1 deletion cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (

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

registered := make(map[string]bool)
for _, cmd := range root.Commands() {
Expand Down
95 changes: 95 additions & 0 deletions cmd/unstack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package cmd

import (
"errors"

"github.com/cli/go-gh/v2/pkg/api"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/stack"
"github.com/spf13/cobra"
)

type unstackOptions struct {
target string
local bool
}

func UnstackCmd(cfg *config.Config) *cobra.Command {
opts := &unstackOptions{}

cmd := &cobra.Command{
Use: "unstack [branch]",
Aliases: []string{"delete"},
Short: "Delete a stack locally and on GitHub",
Long: "Remove a stack from local tracking and delete it on GitHub. Use --local to only remove local tracking.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.target = args[0]
}
return runUnstack(cfg, opts)
},
}

cmd.Flags().BoolVar(&opts.local, "local", false, "Only delete the stack locally")

return cmd
}

func runUnstack(cfg *config.Config, opts *unstackOptions) error {
result, err := loadStack(cfg, opts.target)
if err != nil {
return ErrNotInStack
}
gitDir := result.GitDir
sf := result.StackFile
s := result.Stack

// Delete the stack on GitHub first (unless --local).
// Only proceed with local deletion after the remote operation succeeds.
if !opts.local {
if s.ID == "" {
cfg.Warningf("Stack has no remote ID — skipping server-side deletion")
} else {
client, err := cfg.GitHubClient()
if err != nil {
cfg.Errorf("failed to create GitHub client: %s", err)
return ErrAPIFailure
}
if err := client.DeleteStack(s.ID); err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
case 404:
// Stack already deleted on GitHub — treat as success.
cfg.Warningf("Stack not found on GitHub — continuing with local unstack")
default:
cfg.Errorf("Failed to delete stack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message)
return ErrAPIFailure
}
} else {
cfg.Errorf("Failed to delete stack on GitHub: %v", err)
return ErrAPIFailure
}
} else {
cfg.Successf("Stack deleted on GitHub")
}
}
}

// Remove the exact resolved stack from local tracking by pointer identity,
// not by branch name — avoids removing the wrong stack when a trunk
// branch is shared across multiple stacks.
for i := range sf.Stacks {
if &sf.Stacks[i] == s {
sf.RemoveStack(i)
break
}
}
if err := stack.Save(gitDir, sf); err != nil {
return handleSaveError(cfg, err)
}
cfg.Successf("Stack removed from local tracking")

return nil
}
Loading
Loading