From f308f8a5a3b31001ea725921aa027d41fe4ecfca Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Jul 2026 18:57:53 -0400 Subject: [PATCH 1/3] unstack as a pure api wrapper if stack not checked out locally --- cmd/unstack.go | 160 +++++++++++++++++++++++++++++++++----------- cmd/unstack_test.go | 154 +++++++++++++++++++++++++++++++++++++++++- cmd/utils.go | 33 ++++----- 3 files changed, 291 insertions(+), 56 deletions(-) diff --git a/cmd/unstack.go b/cmd/unstack.go index 2100661..c779265 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -6,6 +6,7 @@ import ( "github.com/cli/go-gh/v2/pkg/api" "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" @@ -25,17 +26,24 @@ func UnstackCmd(cfg *config.Config) *cobra.Command { Short: "Remove a stack locally and on GitHub", Long: `Remove a stack from local tracking and unstack it on GitHub. -With no argument, the current active stack is used. Provide a stack number (the -identifier shown in the github.com stack UI) to unstack a specific locally -tracked stack instead. Use --local to only remove local tracking. +With no argument, the active stack (the one containing the currently checked out +branch) is unstacked on GitHub and removed from local tracking. + +Provide a stack number (the identifier shown in the GitHub stack UI) to +unstack a specific stack on GitHub. This works from anywhere in the repository, +whether or not the stack is checked out locally — the number is unstacked +directly through the GitHub API. If the stack is also available locally, its +local tracking is removed as well. + +Use --local to only remove local tracking without touching remote (GitHub). GitHub decides which pull requests can be unstacked: PRs that are queued for merge or have auto-merge enabled are left stacked. When some pull requests -remain stacked, local tracking is kept.`, +remain stacked, the stack is kept (and local tracking, if any, is unchanged).`, Example: ` # Unstack the current stack locally and on GitHub $ gh stack unstack - # Unstack a specific stack by its stack number + # Unstack a specific stack by its number $ gh stack unstack 7 # Only remove local tracking (keep the stack on GitHub) @@ -60,16 +68,39 @@ remain stacked, local tracking is kept.`, } func runUnstack(cfg *config.Config, opts *unstackOptions) error { - var result *loadStackResult - var err error + // A stack number targets a specific stack. It is unstacked directly on + // GitHub by number (remote-first), so this works from anywhere in the + // repository whether or not the stack is tracked locally. if opts.stackNumber > 0 { - result, err = loadStackByNumber(cfg, opts.stackNumber) - } else { - result, err = loadStack(cfg, "") + result, ok, err := lookupStackByNumber(cfg, opts.stackNumber) + if err != nil { + return ErrNotInStack + } + if !ok { + // The stack number isn't tracked locally. + if opts.local { + // --local never contacts GitHub, and there is nothing to remove + // locally, so there is nothing to do. + cfg.Errorf("stack #%d is not tracked locally", opts.stackNumber) + cfg.Printf("Omit %s to unstack it on GitHub", cfg.ColorCyan("--local")) + return ErrNotInStack + } + return runRemoteUnstack(cfg, opts.stackNumber) + } + return unstackTrackedStack(cfg, opts, result) } + + // No argument: operate on the active stack for the current branch. + result, err := loadStack(cfg, "") if err != nil { return ErrNotInStack } + return unstackTrackedStack(cfg, opts, result) +} + +// unstackTrackedStack unstacks a locally tracked stack: it removes the stack on +// GitHub (unless --local) and then removes it from local tracking. +func unstackTrackedStack(cfg *config.Config, opts *unstackOptions, result *loadStackResult) error { gitDir := result.GitDir if err := modify.CheckStateGuard(gitDir); err != nil { @@ -102,35 +133,16 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { if number == 0 { cfg.Warningf("Stack not found on GitHub — continuing with local unstack") - } else if _, dissolved, err := client.Unstack(number); err != nil { - var httpErr *api.HTTPError - if errors.As(err, &httpErr) { - switch httpErr.StatusCode { - case 404: - // Stack already gone on GitHub — treat as success. - cfg.Warningf("Stack not found on GitHub — continuing with local unstack") - case 422: - // The server refused: every PR is queued for merge or has - // auto-merge enabled, so nothing can be unstacked. - cfg.Errorf("Unstacking not allowed: %s", httpErr.Message) - return ErrInvalidArgs - default: - cfg.Errorf("Failed to unstack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) - return ErrAPIFailure - } - } else { - cfg.Errorf("Failed to unstack on GitHub: %v", err) - return ErrAPIFailure - } - } else if !dissolved { - // Some PRs (queued for merge or with auto-merge enabled) remain - // stacked on GitHub, so the stack still exists. Keep local - // tracking so it continues to reflect the remote stack. - cfg.Warningf("Some pull requests are queued for merge or have auto-merge enabled and remain stacked on GitHub") - cfg.Printf("The stack was left in place — local tracking is unchanged") - return nil } else { - cfg.Successf("Stack removed on GitHub%s", stackLabel(number)) + keepLocal, err := unstackNumberOnGitHub(cfg, client, number, true) + if err != nil { + return err + } + if keepLocal { + // Some PRs remain stacked, so the stack still exists on + // GitHub. Keep local tracking so it continues to reflect it. + return nil + } } } } @@ -151,3 +163,75 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { return nil } + +// runRemoteUnstack unstacks a stack on GitHub purely by its number, without any +// local tracking. This is the remote-first path that lets `gh stack unstack +// ` run from anywhere in the repository (like `gh stack link`), whether +// or not the stack is checked out locally. +func runRemoteUnstack(cfg *config.Config, number int) error { + client, err := cfg.GitHubClient() + if err != nil { + cfg.Errorf("failed to create GitHub client: %s", err) + return ErrAPIFailure + } + if _, err := unstackNumberOnGitHub(cfg, client, number, false); err != nil { + return err + } + return nil +} + +// unstackNumberOnGitHub calls the Unstack API for the given stack number and +// reports the outcome. hasLocalTracking indicates whether the caller has a +// locally tracked stack to reconcile, which changes how a 404 and a partial +// unstack are handled: with local tracking a 404 is an idempotent success (the +// caller finishes removing local state) and a partial unstack keeps local +// tracking; without it a 404 is a hard error because the user targeted a stack +// that does not exist on GitHub. +// +// It returns keepLocal=true when local tracking should be preserved because a +// partial unstack left some PRs stacked. keepLocal is only meaningful when +// hasLocalTracking is true. +func unstackNumberOnGitHub(cfg *config.Config, client github.ClientOps, number int, hasLocalTracking bool) (keepLocal bool, err error) { + _, dissolved, err := client.Unstack(number) + if err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case 404: + if hasLocalTracking { + // Stack already gone on GitHub — treat as success and let + // the caller finish removing local tracking. + cfg.Warningf("Stack not found on GitHub — continuing with local unstack") + return false, nil + } + // Remote-first: the targeted stack does not exist on GitHub. + cfg.Errorf("stack #%d not found on GitHub", number) + return false, ErrNotInStack + case 422: + // The server refused: every PR is queued for merge or has + // auto-merge enabled, so nothing can be unstacked. + cfg.Errorf("Unstacking not allowed: %s", httpErr.Message) + return false, ErrInvalidArgs + default: + cfg.Errorf("Failed to unstack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) + return false, ErrAPIFailure + } + } + cfg.Errorf("Failed to unstack on GitHub: %v", err) + return false, ErrAPIFailure + } + + if !dissolved { + // Some PRs (queued for merge or with auto-merge enabled) remain stacked + // on GitHub, so the stack still exists. + cfg.Warningf("Some pull requests are queued for merge or have auto-merge enabled and remain stacked on GitHub") + if hasLocalTracking { + cfg.Printf("The stack was left in place — local tracking is unchanged") + return true, nil + } + return false, nil + } + + cfg.Successf("Stack removed on GitHub%s", stackLabel(number)) + return false, nil +} diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go index d2ac26d..4b5773e 100644 --- a/cmd/unstack_test.go +++ b/cmd/unstack_test.go @@ -449,7 +449,155 @@ func TestUnstack_ByStackNumber(t *testing.T) { assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames()) } -func TestUnstack_ByStackNumber_NotTrackedLocally(t *testing.T) { +func TestUnstack_ByStackNumber_RemoteOnly_Dissolved(t *testing.T) { + // A stack number that isn't tracked locally is unstacked directly on GitHub + // (remote-first), leaving unrelated local tracking untouched. + 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: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + var unstackedNumber int + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(n int) (*github.RemoteStack, bool, error) { + unstackedNumber = n + return nil, true, nil // dissolved + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 999}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 999, unstackedNumber, "should unstack the requested number on GitHub") + assert.Contains(t, output, "Stack removed on GitHub") + // No local tracking was touched. + assert.NotContains(t, output, "Stack removed from local tracking") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, 42, sf.Stacks[0].Number, "the unrelated local stack is left intact") +} + +func TestUnstack_ByStackNumber_RemoteOnly_NotFound(t *testing.T) { + // With no local tracking to reconcile, a 404 means the targeted stack does + // not exist on GitHub — a hard error, not an idempotent success. + 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: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return nil, false, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 999}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.Contains(t, output, "stack #999 not found on GitHub") + assert.NotContains(t, output, "Stack removed") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) +} + +func TestUnstack_ByStackNumber_RemoteOnly_Partial(t *testing.T) { + // Some PRs (queued for merge / auto-merge) remain stacked. There is no local + // tracking to keep, so the command reports the outcome and succeeds. + 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: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return &github.RemoteStack{ID: 555, Number: 999, PullRequests: []int{102}}, false, nil + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 999}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "remain stacked on GitHub") + // No local tracking is involved, so no local-tracking messaging is shown. + assert.NotContains(t, output, "local tracking is unchanged") + assert.NotContains(t, output, "Stack removed from local tracking") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) +} + +func TestUnstack_ByStackNumber_RemoteOnly_AllLocked(t *testing.T) { + // Every PR is queued for merge or has auto-merge enabled; the server rejects + // the unstack with a 422 and the command surfaces the error. + 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: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return nil, false, &api.HTTPError{StatusCode: 422, Message: "all pull requests are queued for merge or have auto-merge enabled"} + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 999}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "Unstacking not allowed") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) +} + +func TestUnstack_ByStackNumber_NotTracked_LocalFlag(t *testing.T) { + // --local never contacts GitHub. Targeting a number that isn't tracked + // locally with --local is an error: there is nothing to remove locally. gitDir := t.TempDir() restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, @@ -472,11 +620,11 @@ func TestUnstack_ByStackNumber_NotTrackedLocally(t *testing.T) { return nil, true, nil }, } - err := runUnstack(cfg, &unstackOptions{stackNumber: 999}) + err := runUnstack(cfg, &unstackOptions{stackNumber: 999, local: true}) output := collectOutput(cfg, outR, errR) assert.ErrorIs(t, err, ErrNotInStack) - assert.False(t, unstackCalled, "should not unstack when the number isn't tracked locally") + assert.False(t, unstackCalled, "--local must never contact GitHub") assert.Contains(t, output, "stack #999 is not tracked locally") sf, err := stack.Load(gitDir) diff --git a/cmd/utils.go b/cmd/utils.go index c57567d..8affb93 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -259,28 +259,33 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { }, nil } -// loadStackByNumber loads the locally tracked stack whose stack number matches -// the given value. Stack files created before the number was tracked store only -// the internal ID (Number == 0); such legacy stacks are resolved by mapping -// their ID to a remote stack number so they can still be targeted by number. It -// prints a helpful error and returns a non-nil error when no local stack -// resolves to that number. -func loadStackByNumber(cfg *config.Config, number int) (*loadStackResult, error) { +// lookupStackByNumber looks up the locally tracked stack whose stack number +// matches the given value, without printing a "not tracked" error. It returns +// ok=false (with a nil error) when no local stack resolves to that number — +// including when there is no git repository, since a stack cannot be tracked +// locally without one — so callers can fall back to a remote-only operation. A +// non-nil error signals a real failure (the stack file could not be loaded) that +// has already been reported via cfg. +// +// Stack files created before the number was tracked store only the internal ID +// (Number == 0); such legacy stacks are resolved by mapping their ID to a remote +// stack number so they can still be targeted by number. +func lookupStackByNumber(cfg *config.Config, number int) (result *loadStackResult, ok bool, err error) { gitDir, err := git.GitDir() if err != nil { - cfg.Errorf("not a git repository") - return nil, fmt.Errorf("not a git repository") + // Not a git repository — nothing can be tracked locally. + return nil, false, nil } sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) - return nil, fmt.Errorf("failed to load stack state: %w", err) + return nil, false, fmt.Errorf("failed to load stack state: %w", err) } // Direct match on the tracked stack number. if result := stackResultByNumber(sf, gitDir, number); result != nil { - return result, nil + return result, true, nil } // No direct match — backfill legacy stacks' numbers from the remote and @@ -288,13 +293,11 @@ func loadStackByNumber(cfg *config.Config, number int) (*loadStackResult, error) // before the number was recorded locally. if backfillLegacyStackNumbers(cfg, sf, gitDir) { if result := stackResultByNumber(sf, gitDir, number); result != nil { - return result, nil + return result, true, nil } } - cfg.Errorf("stack #%d is not tracked locally", number) - cfg.Printf("Run `%s` to check it out first", cfg.ColorCyan(fmt.Sprintf("gh stack checkout %d", number))) - return nil, fmt.Errorf("stack #%d is not tracked locally", number) + return nil, false, nil } // stackResultByNumber returns a loadStackResult for the locally tracked stack From ea4d7163599e7631fa0ef13fa0ff069d4cb52c53 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Jul 2026 18:59:26 -0400 Subject: [PATCH 2/3] update unstack docs --- README.md | 19 +++++++++++++------ docs/src/content/docs/faq.md | 2 +- docs/src/content/docs/reference/cli.md | 8 +++++--- skills/gh-stack/SKILL.md | 15 +++++++++++---- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c280525..3f205bf 100644 --- a/README.md +++ b/README.md @@ -477,26 +477,33 @@ gh stack view --json ### `gh stack unstack` -Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. +Remove a stack from local tracking and unstack it on GitHub. Also available as `gh stack delete`. ``` -gh stack unstack [flags] +gh stack unstack [] [flags] ``` -You must have an active stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch. +With no argument, the command targets the active stack — the one that contains the currently checked out branch — unstacking it on GitHub and removing local tracking. -Deletes the stack on GitHub first, if it exists, then removes local tracking. Use `--local` to only remove from local tracking. +Provide a stack number (the identifier shown in the github.com stack UI) to unstack a specific stack on GitHub. This works from anywhere in the repository, whether or not the stack is checked out locally — the number is unstacked directly through the GitHub API. If the stack is also tracked locally, its local tracking is removed as well. + +Use `--local` to only remove local tracking without contacting GitHub. + +GitHub decides which pull requests can be unstacked: PRs that are queued for merge or have auto-merge enabled are left stacked. When some pull requests remain stacked, the stack is kept (and local tracking, if any, is unchanged). | Flag | Description | |------|-------------| -| `--local` | Only delete the stack locally (keep it on GitHub) | +| `--local` | Only remove the stack locally (keep it on GitHub) | **Examples:** ```sh -# Remove the stack from local tracking and GitHub +# Remove the current stack from local tracking and GitHub gh stack unstack +# Unstack a specific stack by its number +gh stack unstack 7 + # Only remove local tracking gh stack unstack --local ``` diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index fc18c1e..744592b 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -50,7 +50,7 @@ gh stack init db-migrations api-routes frontend ### How do I delete my stack? -**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 CLI** — Run `gh stack unstack` (or `gh stack delete`) to delete the stack on GitHub and remove local tracking. You can also unstack any stack by its number from anywhere in the repository — `gh stack unstack 7` — whether or not it's checked out locally. 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 2e356ac..4025128 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -240,9 +240,11 @@ Remove a stack from local tracking and unstack it on GitHub. Also available as ` gh stack unstack [] [flags] ``` -With no argument, the command targets the active stack — the one that contains the currently checked out branch. Provide a stack number (the identifier shown in the github.com stack UI) to target a specific locally tracked stack instead. +With no argument, the command targets the active stack — the one that contains the currently checked out branch — unstacking it on GitHub and removing local tracking. -PRs that are merged, merging, or queued for merge cannot be removed from a stack on GitHub and are left part of the stack. When every pull request is removed, the stack is dissolved and local tracking is removed; when some pull requests remain stacked, local tracking is kept. Use `--local` to skip the remote operation and only remove local tracking. +Provide a stack number (the identifier shown in the github.com stack UI) to unstack a specific stack on GitHub. This works from anywhere in the repository, whether or not the stack is checked out locally — the stack is unstacked directly through the GitHub API. When the stack is also available locally, its local tracking is removed as well. + +PRs that are merged, merging, or queued for merge cannot be removed from a stack on GitHub and are left part of the stack. When every pull request is removed, the stack is dissolved and any local tracking is removed; when some pull requests remain stacked, the stack is kept and local tracking, if any, is unchanged. Use `--local` to skip the remote operation and only remove local tracking. This is useful when you need to restructure a stack — remove a branch, insert 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 — existing branches are adopted automatically. @@ -256,7 +258,7 @@ This is useful when you need to restructure a stack — remove a branch, insert # Unstack the current stack on GitHub and remove local tracking gh stack unstack -# Unstack a specific stack by its stack number +# Unstack a specific stack by its numbe gh stack unstack 7 # Only remove local tracking diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 2fdc274..24ce604 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -815,24 +815,31 @@ When a branch name is provided, the command resolves it against locally tracked 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. -You must have a branch from the stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch. +With no argument, the command targets the active stack — the one containing the currently checked out branch — unstacking it on GitHub and removing local tracking. + +Provide a stack number to unstack a specific stack on GitHub. This works from anywhere in the repository, whether or not the stack is checked out locally — the number is unstacked directly through the GitHub API (like `gh stack link`, no local tracking required). If the stack is also tracked locally, its local tracking is removed as well. ``` -gh stack unstack [flags] +gh stack unstack [] [flags] ``` ```bash -# Tear down the stack (locally and on GitHub), then rebuild +# Tear down the current stack (locally and on GitHub), then rebuild gh stack unstack gh stack init --base main branch-2 branch-1 branch-3 # reordered +# Unstack a specific stack by its number, from anywhere in the repo +gh stack unstack 7 + # Only remove local tracking (keep the stack on GitHub) gh stack unstack --local ``` | Flag | Description | |------|-------------| -| `--local` | Only delete the stack locally (keep it on GitHub) | +| `--local` | Only remove the stack locally (keep it on GitHub); never contacts GitHub | + +> **Note for agents:** `gh stack unstack ` is a remote-first API wrapper — it unstacks on GitHub by number from anywhere in the repo, tracked locally or not, and is safe for non-interactive use. `--local` never contacts GitHub; combining `--local` with a number that isn't tracked locally is an error. An unknown stack number returns a "not found on GitHub" error (exit code 2). --- From 7888026be46420d406e0b7ee4d724e78951d31be Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Jul 2026 19:49:26 -0400 Subject: [PATCH 3/3] address review comments --- cmd/unstack.go | 3 +- cmd/unstack_test.go | 49 ++++++++++++++++++++++++++ cmd/utils.go | 12 ++++--- docs/src/content/docs/reference/cli.md | 2 +- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/cmd/unstack.go b/cmd/unstack.go index c779265..e0251a0 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -72,7 +72,8 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { // GitHub by number (remote-first), so this works from anywhere in the // repository whether or not the stack is tracked locally. if opts.stackNumber > 0 { - result, ok, err := lookupStackByNumber(cfg, opts.stackNumber) + // --local must never contact GitHub, so it uses a strictly local lookup + result, ok, err := lookupStackByNumber(cfg, opts.stackNumber, !opts.local) if err != nil { return ErrNotInStack } diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go index 4b5773e..04817c6 100644 --- a/cmd/unstack_test.go +++ b/cmd/unstack_test.go @@ -632,6 +632,55 @@ func TestUnstack_ByStackNumber_NotTracked_LocalFlag(t *testing.T) { require.Len(t, sf.Stacks, 1) } +func TestUnstack_ByStackNumber_LocalFlag_LegacyStack_NoRemoteCall(t *testing.T) { + // --local must never contact GitHub. A legacy stack (Number == 0) can only + // be matched by number via a remote backfill (ListStacks); under --local + // that lookup must be skipped and the number reported as not tracked. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + // Legacy: internal ID present, Number unset (0). + writeStackFile(t, gitDir, stack.Stack{ + ID: "99", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, + }, + }) + + listCalled := false + unstackCalled := false + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + listCalled = true + return []github.RemoteStack{{ID: 99, Number: 7, PullRequests: []int{101, 102}}}, nil + }, + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + unstackCalled = true + return nil, true, nil + }, + } + + err := runUnstack(cfg, &unstackOptions{stackNumber: 7, local: true}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.False(t, listCalled, "--local must not contact GitHub (no ListStacks backfill)") + assert.False(t, unstackCalled, "--local must not contact GitHub") + assert.Contains(t, output, "stack #7 is not tracked locally") + + // Local tracking is untouched. + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) +} + func TestUnstack_ByStackNumber_LegacyStackResolvedByID(t *testing.T) { // A stack tracked before the number was recorded (Number == 0) is resolved // by mapping its internal ID to the remote stack number, and the backfilled diff --git a/cmd/utils.go b/cmd/utils.go index 8affb93..942aef5 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -269,8 +269,11 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { // // Stack files created before the number was tracked store only the internal ID // (Number == 0); such legacy stacks are resolved by mapping their ID to a remote -// stack number so they can still be targeted by number. -func lookupStackByNumber(cfg *config.Config, number int) (result *loadStackResult, ok bool, err error) { +// stack number so they can still be targeted by number. That mapping contacts +// GitHub (ListStacks), so it is only attempted when allowRemote is true — callers +// that must stay purely local (e.g. `--local`) pass false, and legacy stacks +// whose number isn't recorded locally are reported as not tracked. +func lookupStackByNumber(cfg *config.Config, number int, allowRemote bool) (result *loadStackResult, ok bool, err error) { gitDir, err := git.GitDir() if err != nil { // Not a git repository — nothing can be tracked locally. @@ -290,8 +293,9 @@ func lookupStackByNumber(cfg *config.Config, number int) (result *loadStackResul // No direct match — backfill legacy stacks' numbers from the remote and // retry, so `gh stack unstack ` also works for stacks tracked - // before the number was recorded locally. - if backfillLegacyStackNumbers(cfg, sf, gitDir) { + // before the number was recorded locally. This reaches GitHub, so it is + // skipped when the caller requires a purely local lookup. + if allowRemote && backfillLegacyStackNumbers(cfg, sf, gitDir) { if result := stackResultByNumber(sf, gitDir, number); result != nil { return result, true, nil } diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 4025128..f07f380 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -258,7 +258,7 @@ This is useful when you need to restructure a stack — remove a branch, insert # Unstack the current stack on GitHub and remove local tracking gh stack unstack -# Unstack a specific stack by its numbe +# Unstack a specific stack by its number gh stack unstack 7 # Only remove local tracking