From c56277b68ba2f414657737ed903a74173b7f64d3 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 8 Apr 2026 12:03:05 -0400 Subject: [PATCH 1/5] unstack cmd --- README.md | 31 ++++ cmd/root.go | 1 + cmd/root_test.go | 2 +- cmd/unstack.go | 85 +++++++++++ cmd/unstack_test.go | 226 ++++++++++++++++++++++++++++ internal/github/client_interface.go | 1 + internal/github/github.go | 7 + internal/github/mock_client.go | 8 + internal/stack/stack_test.go | 2 +- skills/gh-stack/SKILL.md | 46 +++++- 10 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 cmd/unstack.go create mode 100644 cmd/unstack_test.go diff --git a/README.md b/README.md index 5bf2d3a..9caa769 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. + +``` +gh stack unstack [branch] [flags] +``` + +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. + +| 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/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..3528e37 --- /dev/null +++ b/cmd/unstack.go @@ -0,0 +1,85 @@ +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]", + 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 + target := opts.target + if target == "" { + target = result.CurrentBranch + } + + cfg.Printf("Stack branches: %v", s.BranchNames()) + + // 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) { + cfg.Errorf("Failed to delete stack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) + } else { + cfg.Errorf("Failed to delete stack on GitHub: %v", err) + } + return ErrAPIFailure + } + cfg.Successf("Stack deleted on GitHub") + } + } + + // Remove from local tracking + sf.RemoveStackForBranch(target) + 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..2b8a5e5 --- /dev/null +++ b/cmd/unstack_test.go @@ -0,0 +1,226 @@ +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_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: 404, Message: "Not Found"} + }, + } + 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 404)") + // 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_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) +} 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 From e0495d56ef605f81b83b536648b67a6ca3726163 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 8 Apr 2026 12:04:11 -0400 Subject: [PATCH 2/5] unstack docs --- docs/src/content/docs/faq.md | 17 ++++++++-- docs/src/content/docs/guides/workflows.md | 21 ++++++++++++ .../src/content/docs/introduction/overview.md | 1 + docs/src/content/docs/reference/cli.md | 33 +++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index 9a12f24..a459a11 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` 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..2241f7a 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. + +```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 From 282196289c36672b4700b386bc1dd9db8a90f1f6 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 8 Apr 2026 12:13:07 -0400 Subject: [PATCH 3/5] alias delete --- README.md | 4 ++-- cmd/unstack.go | 9 +++++---- docs/src/content/docs/faq.md | 2 +- docs/src/content/docs/reference/cli.md | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9caa769..78c6d38 100644 --- a/README.md +++ b/README.md @@ -326,13 +326,13 @@ gh stack view --json ### `gh stack unstack` -Remove a stack from local tracking and delete it on GitHub. +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. By default, the stack is removed from both local tracking and GitHub. Use `--local` to only remove the local tracking entry. +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 | |------|-------------| diff --git a/cmd/unstack.go b/cmd/unstack.go index 3528e37..c574028 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -18,10 +18,11 @@ func UnstackCmd(cfg *config.Config) *cobra.Command { opts := &unstackOptions{} cmd := &cobra.Command{ - Use: "unstack [branch]", - 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), + 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] diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index a459a11..5abe983 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -48,7 +48,7 @@ gh stack init --adopt branch-2 branch-1 branch-3 ### How do I delete my stack? -**From the CLI** — Run `gh stack unstack` to delete the stack on GitHub and remove local tracking. Use `--local` to only remove local tracking. +**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. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 2241f7a..48f6abf 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -277,7 +277,7 @@ gh stack push --remote upstream ### `gh stack unstack` -Remove a stack from local tracking and delete it on GitHub. +Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. ```sh gh stack unstack [flags] [branch] From d07c2be3aa15ad67f0203ad93e1f6469a604e5a7 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 8 Apr 2026 12:18:44 -0400 Subject: [PATCH 4/5] support adopting branches with PRs --- cmd/init.go | 43 +++++++++++------------ cmd/init_test.go | 89 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 21 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 8ae7db4..e5bc33f 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. + newStack_ := &sf.Stacks[len(sf.Stacks)-1] + if opts.adopt { + if client, clientErr := cfg.GitHubClient(); clientErr == nil { + for i := range newStack_.Branches { + b := &newStack_.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, newStack_) + } 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) + } +} From 4b6f2bda8277436e338118418af84cefcdfd9ef7 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 8 Apr 2026 14:10:50 -0400 Subject: [PATCH 5/5] adressing review comments --- cmd/init.go | 8 ++++---- cmd/unstack.go | 31 +++++++++++++++++++----------- cmd/unstack_test.go | 47 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index e5bc33f..176ccfc 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -307,11 +307,11 @@ func runInit(cfg *config.Config, opts *initOptions) error { // 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. - newStack_ := &sf.Stacks[len(sf.Stacks)-1] + latestStack := &sf.Stacks[len(sf.Stacks)-1] if opts.adopt { if client, clientErr := cfg.GitHubClient(); clientErr == nil { - for i := range newStack_.Branches { - b := &newStack_.Branches[i] + for i := range latestStack.Branches { + b := &latestStack.Branches[i] pr, err := client.FindPRForBranch(b.Branch) if err != nil || pr == nil { continue @@ -324,7 +324,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { } } } else { - syncStackPRs(cfg, newStack_) + syncStackPRs(cfg, latestStack) } if err := stack.Save(gitDir, sf); err != nil { diff --git a/cmd/unstack.go b/cmd/unstack.go index c574028..bbcbb00 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -44,12 +44,6 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { gitDir := result.GitDir sf := result.StackFile s := result.Stack - target := opts.target - if target == "" { - target = result.CurrentBranch - } - - cfg.Printf("Stack branches: %v", s.BranchNames()) // Delete the stack on GitHub first (unless --local). // Only proceed with local deletion after the remote operation succeeds. @@ -65,18 +59,33 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { if err := client.DeleteStack(s.ID); err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) { - cfg.Errorf("Failed to delete stack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) + 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 } - return ErrAPIFailure + } else { + cfg.Successf("Stack deleted on GitHub") } - cfg.Successf("Stack deleted on GitHub") } } - // Remove from local tracking - sf.RemoveStackForBranch(target) + // 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) } diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go index 2b8a5e5..6cfab57 100644 --- a/cmd/unstack_test.go +++ b/cmd/unstack_test.go @@ -157,7 +157,7 @@ func TestUnstack_NoStackID_WarnsAndSkipsAPI(t *testing.T) { assert.NotContains(t, output, "Stack deleted on GitHub") } -func TestUnstack_API404_ShowsErrorAndStopsLocalDeletion(t *testing.T) { +func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) { gitDir := t.TempDir() restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, @@ -180,15 +180,14 @@ func TestUnstack_API404_ShowsErrorAndStopsLocalDeletion(t *testing.T) { 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 404)") - // Should NOT remove locally when remote fails - assert.NotContains(t, output, "Stack removed from local tracking") + // 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") - // Stack should still exist locally sf, err := stack.Load(gitDir) require.NoError(t, err) - require.Len(t, sf.Stacks, 1) + assert.Empty(t, sf.Stacks) } func TestUnstack_API409_ShowsErrorAndStopsLocalDeletion(t *testing.T) { @@ -224,3 +223,37 @@ func TestUnstack_API409_ShowsErrorAndStopsLocalDeletion(t *testing.T) { 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") +}