diff --git a/README.md b/README.md index 5bf2d3a..78c6d38 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/init.go b/cmd/init.go index 8ae7db4..176ccfc 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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)) @@ -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 { + 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) diff --git a/cmd/init_test.go b/cmd/init_test.go index e7df3fc..3ed70bc 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -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" @@ -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) + } +} diff --git a/cmd/root.go b/cmd/root.go index a55f9dd..704fe1e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 diff --git a/cmd/root_test.go b/cmd/root_test.go index d902390..b90409f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -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() { diff --git a/cmd/unstack.go b/cmd/unstack.go new file mode 100644 index 0000000..bbcbb00 --- /dev/null +++ b/cmd/unstack.go @@ -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 +} diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go new file mode 100644 index 0000000..6cfab57 --- /dev/null +++ b/cmd/unstack_test.go @@ -0,0 +1,259 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/cli/go-gh/v2/pkg/api" + "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" +) + +func writeTwoStacks(t *testing.T, dir string, s1, s2 stack.Stack) { + t.Helper() + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{s1, s2}, + } + data, err := json.MarshalIndent(sf, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack"), data, 0644)) +} + +func TestUnstack_RemovesStack(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + s1 := stack.Stack{ + ID: "42", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + s2 := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}}, + } + writeTwoStacks(t, gitDir, s1, s2) + + var deletedStackID string + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + DeleteStackFn: func(stackID string) error { + deletedStackID = stackID + return nil + }, + } + err := runUnstack(cfg, &unstackOptions{}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Stack removed from local tracking") + assert.Contains(t, output, "Stack deleted on GitHub") + assert.Equal(t, "42", deletedStackID) + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"b3", "b4"}, sf.Stacks[0].BranchNames()) +} + +func TestUnstack_Local(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + err := runUnstack(cfg, &unstackOptions{local: true}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Stack removed") + // With --local, the GitHub API should NOT be called. + assert.NotContains(t, output, "Stack deleted on GitHub") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + assert.Empty(t, sf.Stacks) +} + +func TestUnstack_WithTarget(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "unrelated", nil }, + }) + defer restore() + + s1 := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + s2 := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}}, + } + writeTwoStacks(t, gitDir, s1, s2) + + cfg, outR, errR := config.NewTestConfig() + err := runUnstack(cfg, &unstackOptions{target: "b3", local: true}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Stack removed") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames()) +} + +func TestUnstack_NoStackID_WarnsAndSkipsAPI(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + // Stack with no ID (never synced to GitHub) + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + apiCalled := false + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + DeleteStackFn: func(stackID string) error { + apiCalled = true + return nil + }, + } + err := runUnstack(cfg, &unstackOptions{}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.False(t, apiCalled, "API should not be called when stack has no ID") + assert.Contains(t, output, "no remote ID") + assert.Contains(t, output, "Stack removed from local tracking") + assert.NotContains(t, output, "Stack deleted on GitHub") +} + +func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "99", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + DeleteStackFn: func(stackID string) error { + return &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + } + err := runUnstack(cfg, &unstackOptions{}) + output := collectOutput(cfg, outR, errR) + + // 404 means already deleted — should succeed and remove locally + require.NoError(t, err) + assert.Contains(t, output, "continuing with local unstack") + assert.Contains(t, output, "Stack removed from local tracking") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + assert.Empty(t, sf.Stacks) +} + +func TestUnstack_API409_ShowsErrorAndStopsLocalDeletion(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "99", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + DeleteStackFn: func(stackID string) error { + return &api.HTTPError{StatusCode: 409, Message: "Stack is currently being modified"} + }, + } + err := runUnstack(cfg, &unstackOptions{}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrAPIFailure) + assert.Contains(t, output, "Failed to delete stack on GitHub (HTTP 409)") + // Should NOT remove locally when remote fails + assert.NotContains(t, output, "Stack removed from local tracking") + + // Stack should still exist locally + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) +} + +func TestUnstack_RemovesCorrectStackByPointer(t *testing.T) { + // Two stacks share the same trunk "main". Targeting "b3" should remove + // only the second stack (b3,b4), leaving the first (b1,b2) intact. + // This verifies pointer-based removal instead of branch-name-based. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b3", nil }, + }) + defer restore() + + s1 := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + s2 := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}}, + } + writeTwoStacks(t, gitDir, s1, s2) + + cfg, outR, errR := config.NewTestConfig() + err := runUnstack(cfg, &unstackOptions{target: "b3", local: true}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Stack removed from local tracking") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1, "should remove exactly one stack") + assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames(), "should keep the OTHER stack intact") +} diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index 9a12f24..5abe983 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -33,11 +33,24 @@ You can also add PRs to an existing stack from the GitHub UI. See [Adding to an ### How can I modify my stack? -Reordering or inserting branches into the middle of a stack is not currently supported. To restructure a stack, you need to delete it and recreate it with the desired order. +Reordering or inserting branches into the middle of a stack is not currently supported. To restructure a stack, use `gh stack unstack` to tear it down and then recreate it with `gh stack init --adopt`: + +```sh +# 1. Remove the stack +gh stack unstack + +# 2. Make structural changes (reorder, rename, delete branches) +git branch -m old-name new-name + +# 3. Re-create the stack with the new structure +gh stack init --adopt branch-2 branch-1 branch-3 +``` ### How do I delete my stack? -You can unstack PRs from the GitHub UI — see [Unstacking](/gh-stack/guides/ui/#unstacking) for a walkthrough. This dissolves the association between PRs, turning them back into standard independent PRs. CLI support for unstacking is coming soon. +**From the CLI** — Run `gh stack unstack` (or `gh stack delete`) to delete the stack on GitHub and remove local tracking. Use `--local` to only remove local tracking. + +**From the UI** — You can unstack PRs from the GitHub UI — see [Unstacking](/gh-stack/guides/ui/#unstacking) for a walkthrough. This dissolves the association between PRs, turning them back into standard independent PRs. ### Can stacks be created across forks? diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index 144cab9..4f935ff 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -163,6 +163,27 @@ Create a new branch (`gh stack add`) when you're starting a **different concern* All branches in a stack should be part of the same feature or project. If you need to work on something unrelated, start a separate stack with `gh stack init` or switch to an existing one with `gh stack checkout`. +## Restructuring a Stack + +When you need to remove a branch, reorder branches, or rename branches, tear down the stack and rebuild it: + +```sh +# 1. Remove the stack on GitHub and locally +gh stack unstack + +# 2. Make structural changes +git branch -m old-branch-1 new-branch-1 # rename a branch +git branch -D branch-3 # delete a branch + +# 3. Re-create the stack with the new order +gh stack init --adopt new-branch-1 branch-2 branch-4 + +# 4. Push and sync the new stack +gh stack submit +``` + +The `unstack` command deletes the stack on GitHub first, then removes local tracking. Your branches and PRs are not affected — only the stack relationship is removed. After `init --adopt`, any existing open PRs are automatically re-associated with the new stack. + ## Using AI Agents with Stacks AI coding agents (like GitHub Copilot) can create and manage Stacked PRs on your behalf. Install the gh-stack skill to give them the context they need: diff --git a/docs/src/content/docs/introduction/overview.md b/docs/src/content/docs/introduction/overview.md index a191823..20a3024 100644 --- a/docs/src/content/docs/introduction/overview.md +++ b/docs/src/content/docs/introduction/overview.md @@ -88,6 +88,7 @@ While the PR UI provides the review and merge experience, the `gh stack` CLI han - **Creating PRs** — `gh stack submit` pushes branches and creates or updates PRs, linking them as a Stack on GitHub. - **Navigating the stack** — `gh stack up`, `down`, `top`, and `bottom` let you move between layers without remembering branch names. - **Syncing everything** — `gh stack sync` fetches, rebases, pushes, and updates PR state in one command. +- **Tearing down stacks** — `gh stack unstack` removes a stack from GitHub and local tracking if you need to restructure it. The CLI is not required to use Stacked PRs — the underlying git operations are standard. But it makes the workflow dramatically simpler. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 8f9abbf..48f6abf 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -275,6 +275,39 @@ gh stack push gh stack push --remote upstream ``` +### `gh stack unstack` + +Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. + +```sh +gh stack unstack [flags] [branch] +``` + +Deletes the stack on GitHub first, then removes it from local tracking. If the remote deletion fails, the local state is left untouched so you can retry. Use `--local` to skip the remote deletion and only remove local tracking. + +This is useful when you need to restructure a stack — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init --adopt` to re-create the stack with the desired structure. + +| Flag | Description | +|------|-------------| +| `--local` | Only delete the stack locally (keep it on GitHub) | + +| Argument | Description | +|----------|-------------| +| `[branch]` | A branch in the stack to identify which stack to delete (defaults to the current branch) | + +**Examples:** + +```sh +# Delete the stack on GitHub and remove local tracking +gh stack unstack + +# Only remove local tracking +gh stack unstack --local + +# Specify a branch to identify which stack +gh stack unstack feature-auth +``` + --- ## Navigation diff --git a/internal/github/client_interface.go b/internal/github/client_interface.go index 9faff2f..02f476b 100644 --- a/internal/github/client_interface.go +++ b/internal/github/client_interface.go @@ -11,6 +11,7 @@ type ClientOps interface { UpdatePRBase(number int, base string) error CreateStack(prNumbers []int) (int, error) UpdateStack(stackID string, prNumbers []int) error + DeleteStack(stackID string) error } // Compile-time check that Client satisfies ClientOps. diff --git a/internal/github/github.go b/internal/github/github.go index a045136..54f8bdb 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -281,6 +281,13 @@ func (c *Client) FindPRDetailsForBranch(branch string) (*PRDetails, error) { }, nil } +// DeleteStack deletes a stack on GitHub. +// The stack is identified by stackID. Returns nil on success (204). +func (c *Client) DeleteStack(stackID string) error { + path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks/%s", c.owner, c.repo, stackID) + return c.rest.Delete(path, nil) +} + // CreateStack creates a stack on GitHub from an ordered list of PR numbers. // The PR numbers must be ordered from bottom to top of the stack and must // form a valid base-to-head chain. Returns the server-assigned stack ID. diff --git a/internal/github/mock_client.go b/internal/github/mock_client.go index 74fc6cb..1f839fa 100644 --- a/internal/github/mock_client.go +++ b/internal/github/mock_client.go @@ -11,6 +11,7 @@ type MockClient struct { UpdatePRBaseFn func(int, string) error CreateStackFn func([]int) (int, error) UpdateStackFn func(string, []int) error + DeleteStackFn func(string) error } // Compile-time check that MockClient satisfies ClientOps. @@ -64,3 +65,10 @@ func (m *MockClient) UpdateStack(stackID string, prNumbers []int) error { } return nil } + +func (m *MockClient) DeleteStack(stackID string) error { + if m.DeleteStackFn != nil { + return m.DeleteStackFn(stackID) + } + return nil +} diff --git a/internal/stack/stack_test.go b/internal/stack/stack_test.go index 7735993..3fa65de 100644 --- a/internal/stack/stack_test.go +++ b/internal/stack/stack_test.go @@ -359,7 +359,7 @@ func TestValidateNoDuplicateBranch(t *testing.T) { }) } -// --- RemoveStackForBranch --- +// --- RemoveStackForBranch: used by unstack --- func TestRemoveStackForBranch(t *testing.T) { t.Run("found and removed", func(t *testing.T) { diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 63ca208..4d217f8 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -31,7 +31,7 @@ Use this skill when the user wants to: - Create, rebase, push, or sync a stack of dependent branches - Navigate between layers of a branch stack - View the status of stacked PRs -- Clean up a stack after PRs are merged +- Tear down and rebuild a stack to remove, reorder, or rename branches ## Prerequisites @@ -142,6 +142,7 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current | Switch to top/bottom branch | `gh stack top` / `gh stack bottom` | | Check out by PR | `gh stack checkout 42` | | Check out by branch | `gh stack checkout feature-auth` | +| Tear down a stack to restructure it | `gh stack unstack` | --- @@ -356,6 +357,21 @@ echo "$output" | jq -r '.currentBranch' echo "$output" | jq '[.branches[] | .isMerged] | all' ``` +### Restructure a stack (remove a branch, reorder, or rename) + +Use `unstack` to tear down the stack, make structural changes, then re-init: + +```bash +# 1. Remove the stack (locally and on GitHub) +gh stack unstack + +# 2. Make structural changes — e.g. delete a branch, reorder, rename +git branch -m old-branch-1 new-branch-1 + +# 3. Re-create the stack with the new structure +gh stack init --base main --adopt new-branch-1 new-branch-2 new-branch-3 +``` + --- ## Commands @@ -709,6 +725,34 @@ Resolves the target against locally tracked stacks. Accepts a PR number, PR URL, --- +### Remove a stack — `gh stack unstack` + +Tear down a stack so you can restructure it — remove 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. + +``` +gh stack unstack [branch] [flags] +``` + +```bash +# Tear down the stack (locally and on GitHub), then rebuild +gh stack unstack +gh stack init --base main --adopt branch-2 branch-1 branch-3 # reordered + +# Only remove local tracking (keep the stack on GitHub) +gh stack unstack --local + +# Specify a branch to identify which stack to tear down +gh stack unstack feature-auth +``` + +| Flag | Description | +|------|-------------| +| `--local` | Only delete the stack locally (keep it on GitHub) | + +| Argument | Description | +|----------|-------------| +| `[branch]` | A branch in the stack (defaults to the current branch) | + --- ## Output conventions