From d1acbf00923facbdaffdf69437089b4705aa0880 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 9 Apr 2026 21:47:55 -0400 Subject: [PATCH 1/3] remote stack checkout --- README.md | 14 +- cmd/checkout.go | 452 +++++++++++++++- cmd/checkout_test.go | 691 ++++++++++++++++++++++++- cmd/init.go | 4 - cmd/rebase.go | 4 - cmd/submit.go | 2 +- cmd/submit_test.go | 2 +- docs/src/content/docs/reference/cli.md | 12 +- internal/github/client_interface.go | 2 + internal/github/github.go | 108 +++- internal/github/mock_client.go | 16 + skills/gh-stack/SKILL.md | 16 +- 12 files changed, 1255 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 78c6d38..8855c03 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# gh-stack +# GitHub Stacked PRs A GitHub CLI extension for managing stacked branches and pull requests. @@ -160,15 +160,17 @@ gh stack add -m "Refactor utils" cleanup-layer ### `gh stack checkout` -Check out a locally tracked stack from a pull request number or branch name. +Check out a stack from a pull request number or branch name. ``` -gh stack checkout [] +gh stack checkout [ | ] ``` -Resolves the target against stacks stored in local tracking (`.git/gh-stack`). Accepts a PR number (e.g. `42`) or a branch name that belongs to a locally tracked stack. When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from. +When a PR number is provided (e.g. `123`), the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict. -> **Note:** Server-side stack discovery is not yet implemented. This command currently only works with stacks that have been created locally (via `gh stack init`). Checking out a stack that is not tracked locally will require passing in an explicit branch name or PR number once the server API is available. +When a branch name is provided, the command resolves it against locally tracked stacks only. + +When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from. **Examples:** @@ -176,7 +178,7 @@ Resolves the target against stacks stored in local tracking (`.git/gh-stack`). A # Check out a stack by PR number gh stack checkout 42 -# Check out a stack by branch name +# Check out a stack by branch name (local only) gh stack checkout feature-auth # Interactive — select from locally tracked stacks diff --git a/cmd/checkout.go b/cmd/checkout.go index a32e1d8..3922707 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -3,10 +3,14 @@ package cmd import ( "errors" "fmt" + "strconv" + "strings" + "github.com/cli/go-gh/v2/pkg/api" "github.com/cli/go-gh/v2/pkg/prompter" "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/spf13/cobra" ) @@ -19,16 +23,21 @@ func CheckoutCmd(cfg *config.Config) *cobra.Command { opts := &checkoutOptions{} cmd := &cobra.Command{ - Use: "checkout []", + Use: "checkout [ | ]", Short: "Checkout a stack from a PR number or branch name", Long: `Check out a stack from a pull request number or branch name. -Currently resolves stacks from local tracking only (.git/gh-stack). -Accepts a PR number (e.g. 42) or a branch name that belongs to -a locally tracked stack. When run without arguments, shows a menu of -all locally available stacks to choose from. +When a PR number is provided (e.g. 123), the command first checks +local tracking. If the PR is not tracked locally, it queries the +GitHub API to discover the stack, fetches the branches, and sets up +the stack locally. If the stack already exists locally and matches, +it simply switches to the branch. -Server-side stack discovery will be added in a future release.`, +When a branch name is provided, the command resolves it against +locally tracked stacks only. + +When run without arguments, shows a menu of all locally available +stacks to choose from.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -41,14 +50,10 @@ Server-side stack discovery will be added in a future release.`, return cmd } -// runCheckout resolves a stack from local tracking and checks out the target branch. -// -// Future behavior (once the server API is available): -// 1. Resolve the target (PR number, URL, or branch name) to a PR via the API -// 2. If the PR is part of a stack, discover the full set of PRs in the stack -// 3. Fetch and create local tracking branches for every branch in the stack -// 4. Save the stack to local tracking (.git/gh-stack, similar to gh stack init --adopt) -// 5. Switch to the target branch +// runCheckout resolves a stack and checks out the target branch. +// For numeric targets, it tries local lookup first, then falls back to +// the GitHub API to discover remote stacks, then tries as a branch name. +// Non-numeric targets use local resolution only. func runCheckout(cfg *config.Config, opts *checkoutOptions) error { gitDir, err := git.GitDir() if err != nil { @@ -77,10 +82,15 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { if s == nil { return nil } - // Check out the top active branch of the selected stack targetBranch = s.Branches[len(s.Branches)-1].Branch + } else if prNumber, parseErr := strconv.Atoi(opts.target); parseErr == nil && prNumber > 0 { + // Target is a pure integer — try local PR, then remote API, then branch name + s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target) + if err != nil { + return err + } } else { - // Resolve target against local stacks + // Non-numeric target — resolve against local stacks only var br *stack.BranchRef s, br, err = resolvePR(sf, opts.target) if err != nil { @@ -107,6 +117,412 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { return nil } +// resolveNumericTarget handles the case where the user passes a pure integer. +// It tries, in order: +// 1. Local stack lookup by PR number +// 2. Remote API discovery (ListStacks → find → import) +// 3. Local stack lookup by branch name (for numeric branch names like "123") +func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string, prNumber int, raw string) (*stack.Stack, string, error) { + // 1. Try local PR number lookup + if s, br := sf.FindStackByPRNumber(prNumber); s != nil && br != nil { + return s, br.Branch, nil + } + + // 2. Try remote API + s, targetBranch, err := checkoutRemoteStack(cfg, sf, gitDir, prNumber) + if err == nil { + return s, targetBranch, nil + } + // If the API returned a definitive "not in a stack" or a real error, + // fall through to the branch-name attempt only for "not in stack". + // For API failures (404, network errors), still fall through — + // the user might have a numeric branch name. + remoteErr := err + + // 3. Fall back to branch name lookup (handles numeric branch names) + stacks := sf.FindAllStacksForBranch(raw) + if len(stacks) > 0 { + s := stacks[0] + idx := s.IndexOf(raw) + if idx >= 0 { + return s, s.Branches[idx].Branch, nil + } + // Matched as trunk + if len(s.Branches) > 0 { + return s, s.Branches[0].Branch, nil + } + } + + // Nothing worked — return the remote error which has the most + // informative message for a numeric input + return nil, "", remoteErr +} + +// checkoutRemoteStack discovers a stack from GitHub for the given PR number, +// reconciles it with any local state, and returns the resolved stack and +// target branch name. The stack file is saved before returning. +func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, prNumber int) (*stack.Stack, string, error) { + client, err := cfg.GitHubClient() + if err != nil { + cfg.Errorf("failed to create GitHub client: %s", err) + return nil, "", ErrAPIFailure + } + + // Step 1: List stacks and find one containing the target PR + remoteStack, err := findRemoteStackForPR(client, prNumber) + if err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { + cfg.Errorf("Stacked PRs are not enabled for this repository") + return nil, "", ErrAPIFailure + } + cfg.Errorf("failed to list stacks: %v", err) + return nil, "", ErrAPIFailure + } + if remoteStack == nil { + cfg.Errorf("PR #%d is not part of a stack on GitHub", prNumber) + return nil, "", ErrNotInStack + } + + // Step 2: Fetch PR details for every PR in the remote stack + prs, err := fetchStackPRDetails(client, remoteStack.PullRequests) + if err != nil { + cfg.Errorf("failed to fetch PR details: %v", err) + return nil, "", ErrAPIFailure + } + + // Determine trunk (base branch of the first PR) and the target branch + trunk := prs[0].BaseRefName + var targetBranch string + for _, pr := range prs { + if pr.Number == prNumber { + targetBranch = pr.HeadRefName + break + } + } + if targetBranch == "" { + cfg.Errorf("could not determine branch for PR #%d", prNumber) + return nil, "", ErrAPIFailure + } + + remoteStackID := strconv.Itoa(remoteStack.ID) + + // Step 3: Check if the target branch is already in a local stack + localStack := findLocalStackForRemotePRs(sf, prs) + + if localStack != nil { + // Case A: branch is in a local stack — check composition + if stackCompositionMatches(localStack, remoteStack.PullRequests) { + // Composition matches — sync PR state and checkout + syncRemotePRState(localStack, prs) + if localStack.ID == "" { + localStack.ID = remoteStackID + } + if err := stack.Save(gitDir, sf); err != nil { + return nil, "", handleSaveError(cfg, err) + } + cfg.Successf("Local stack matches remote — switching to branch") + return localStack, targetBranch, nil + } + + // Composition mismatch — prompt for resolution + resolved, resolveErr := handleCompositionConflict(cfg, client, sf, localStack, remoteStack, prs, gitDir, trunk) + if resolveErr != nil { + return nil, "", resolveErr + } + return resolved, targetBranch, nil + } + + // Case B/C: no matching local stack — import from remote + remote, err := pickRemote(cfg, trunk, "") + if err != nil { + if !errors.Is(err, errInterrupt) { + cfg.Errorf("%s", err) + } + return nil, "", ErrSilent + } + + s, err := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID) + if err != nil { + return nil, "", err + } + + if err := stack.Save(gitDir, sf); err != nil { + return nil, "", handleSaveError(cfg, err) + } + + return s, targetBranch, nil +} + +// findRemoteStackForPR queries the list stacks API and returns the stack +// containing the given PR number, or nil if no stack contains it. +func findRemoteStackForPR(client github.ClientOps, prNumber int) (*github.RemoteStack, error) { + stacks, err := client.ListStacks() + if err != nil { + return nil, err + } + for i := range stacks { + for _, n := range stacks[i].PullRequests { + if n == prNumber { + return &stacks[i], nil + } + } + } + return nil, nil +} + +// fetchStackPRDetails fetches PR details for each number in the stack. +// Returns PRs in the same order as the input numbers. +func fetchStackPRDetails(client github.ClientOps, prNumbers []int) ([]*github.PullRequest, error) { + prs := make([]*github.PullRequest, 0, len(prNumbers)) + for _, n := range prNumbers { + pr, err := client.FindPRByNumber(n) + if err != nil { + return nil, fmt.Errorf("fetching PR #%d: %w", n, err) + } + prs = append(prs, pr) + } + return prs, nil +} + +// findLocalStackForRemotePRs checks if any PR's branch is already tracked +// in a local stack and returns that stack (first match). +func findLocalStackForRemotePRs(sf *stack.StackFile, prs []*github.PullRequest) *stack.Stack { + for _, pr := range prs { + stacks := sf.FindAllStacksForBranch(pr.HeadRefName) + for _, s := range stacks { + if s.IndexOf(pr.HeadRefName) >= 0 { + return s + } + } + } + return nil +} + +// stackCompositionMatches checks if a local stack's PR numbers match +// the remote stack's PR numbers in the same order. +func stackCompositionMatches(localStack *stack.Stack, remotePRNumbers []int) bool { + var localPRNumbers []int + for _, b := range localStack.Branches { + if b.PullRequest != nil { + localPRNumbers = append(localPRNumbers, b.PullRequest.Number) + } + } + if len(localPRNumbers) != len(remotePRNumbers) { + return false + } + for i := range localPRNumbers { + if localPRNumbers[i] != remotePRNumbers[i] { + return false + } + } + return true +} + +// handleCompositionConflict prompts the user to resolve a mismatch between +// local and remote stack composition. Returns the resolved stack. +func handleCompositionConflict( + cfg *config.Config, + client github.ClientOps, + sf *stack.StackFile, + localStack *stack.Stack, + remoteStack *github.RemoteStack, + prs []*github.PullRequest, + gitDir string, + trunk string, +) (*stack.Stack, error) { + if !cfg.IsInteractive() { + cfg.Errorf("local stack composition differs from remote") + cfg.Printf(" Local: %s", localStack.DisplayChain()) + remoteBranches := make([]string, len(prs)) + for i, pr := range prs { + remoteBranches[i] = pr.HeadRefName + } + cfg.Printf(" Remote: (%s) <- %s", trunk, strings.Join(remoteBranches, " <- ")) + cfg.Printf(" Unstack on remote or use `%s` to unstack locally", + cfg.ColorCyan("gh stack unstack --local")) + return nil, ErrConflict + } + + cfg.Warningf("Local stack differs from remote stack") + cfg.Printf(" Local: %s", localStack.DisplayChain()) + remoteBranches := make([]string, len(prs)) + for i, pr := range prs { + remoteBranches[i] = pr.HeadRefName + } + cfg.Printf(" Remote: (%s) <- %s", trunk, strings.Join(remoteBranches, " <- ")) + + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + options := []string{ + "Replace local stack with remote version", + "Delete remote stack and keep local version", + "Cancel", + } + selected, err := p.Select("How would you like to resolve this?", "", options) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil, errInterrupt + } + return nil, ErrSilent + } + + remoteStackID := strconv.Itoa(remoteStack.ID) + + switch selected { + case 0: + // Replace local with remote + removeLocalStack(sf, localStack) + + remote, remoteErr := pickRemote(cfg, trunk, "") + if remoteErr != nil { + if !errors.Is(remoteErr, errInterrupt) { + cfg.Errorf("%s", remoteErr) + } + return nil, ErrSilent + } + + s, importErr := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID) + if importErr != nil { + return nil, importErr + } + if err := stack.Save(gitDir, sf); err != nil { + return nil, handleSaveError(cfg, err) + } + cfg.Successf("Local stack replaced with remote version") + return s, nil + + case 1: + // Delete remote stack, keep local + if err := client.DeleteStack(remoteStackID); err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { + cfg.Warningf("Remote stack already deleted") + } else { + cfg.Errorf("failed to delete remote stack: %v", err) + return nil, ErrAPIFailure + } + } else { + cfg.Successf("Remote stack deleted") + } + localStack.ID = "" + if err := stack.Save(gitDir, sf); err != nil { + return nil, handleSaveError(cfg, err) + } + return localStack, nil + + default: + // Cancel + cfg.Infof("Checkout cancelled") + return nil, ErrSilent + } +} + +// removeLocalStack removes a stack from the stack file by pointer identity. +func removeLocalStack(sf *stack.StackFile, target *stack.Stack) { + for i := range sf.Stacks { + if &sf.Stacks[i] == target { + sf.RemoveStack(i) + return + } + } +} + +// importRemoteStack fetches branches from the remote, creates any that are +// missing locally, builds a Stack from the PR data, and adds it to the +// StackFile. Returns the newly created stack. +func importRemoteStack( + cfg *config.Config, + sf *stack.StackFile, + gitDir string, + remote string, + trunk string, + prs []*github.PullRequest, + remoteStackID string, +) (*stack.Stack, error) { + // Fetch latest refs from remote + if err := git.Fetch(remote); err != nil { + cfg.Warningf("failed to fetch from %s: %v", remote, err) + } + + // Ensure trunk exists locally + if !git.BranchExists(trunk) { + remoteTrunk := remote + "/" + trunk + if err := git.CreateBranch(trunk, remoteTrunk); err != nil { + cfg.Warningf("could not pull trunk branch %s: %v", trunk, err) + } + } + + // Create local branches for each PR's head branch + for _, pr := range prs { + branch := pr.HeadRefName + if git.BranchExists(branch) { + continue + } + remoteRef := remote + "/" + branch + if err := git.CreateBranch(branch, remoteRef); err != nil { + cfg.Errorf("failed to pull branch %s from %s: %v", branch, remoteRef, err) + return nil, ErrSilent + } + _ = git.SetUpstreamTracking(branch, remote) + cfg.Successf("Pulled branch %s", branch) + } + + // Build the stack + branchRefs := make([]stack.BranchRef, len(prs)) + for i, pr := range prs { + branchRefs[i] = stack.BranchRef{ + Branch: pr.HeadRefName, + PullRequest: &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + Merged: pr.Merged, + }, + } + } + + trunkSHA, _ := git.RevParse(trunk) + newStack := stack.Stack{ + ID: remoteStackID, + Trunk: stack.BranchRef{ + Branch: trunk, + Head: trunkSHA, + }, + Branches: branchRefs, + } + + sf.AddStack(newStack) + s := &sf.Stacks[len(sf.Stacks)-1] + + // Update base SHAs from actual local refs + updateBaseSHAs(s) + + cfg.Successf("Imported stack with %d branches from GitHub", len(prs)) + return s, nil +} + +// syncRemotePRState updates a local stack's PR metadata from fetched PR data. +func syncRemotePRState(s *stack.Stack, prs []*github.PullRequest) { + prMap := make(map[string]*github.PullRequest, len(prs)) + for _, pr := range prs { + prMap[pr.HeadRefName] = pr + } + for i := range s.Branches { + pr, ok := prMap[s.Branches[i].Branch] + if !ok { + continue + } + s.Branches[i].PullRequest = &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + Merged: pr.Merged, + } + s.Branches[i].Queued = pr.IsQueued() + } +} + // interactiveStackPicker shows a menu of all locally tracked stacks and returns // the one the user selects. Returns nil, nil if the user has no stacks. func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Stack, error) { @@ -116,7 +532,9 @@ func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Sta if len(sf.Stacks) == 0 { cfg.Infof("No locally tracked stacks found") - cfg.Printf("Create a stack with `%s` or check out a specific branch/PR once server-side discovery is available.", cfg.ColorCyan("gh stack init")) + cfg.Printf("Create a stack with `%s` or check out a remote stack with `%s`", + cfg.ColorCyan("gh stack init"), + cfg.ColorCyan("gh stack checkout 123")) return nil, nil } diff --git a/cmd/checkout_test.go b/cmd/checkout_test.go index 1862699..7ce18fb 100644 --- a/cmd/checkout_test.go +++ b/cmd/checkout_test.go @@ -1,10 +1,13 @@ package cmd import ( + "fmt" "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" @@ -40,7 +43,8 @@ func TestCheckout_ByBranchName(t *testing.T) { assert.Contains(t, output, "Switched to b2") } -func TestCheckout_ByPRNumber(t *testing.T) { +func TestCheckout_ByPRNumber_Local(t *testing.T) { + // When a PR number exists locally, no API call should be made gitDir := t.TempDir() var checkedOut string restore := git.SetOps(&git.MockOps{ @@ -61,6 +65,7 @@ func TestCheckout_ByPRNumber(t *testing.T) { }) cfg, outR, errR := config.NewTestConfig() + // No GitHubClientOverride — should resolve locally without API err := runCheckout(cfg, &checkoutOptions{target: "42"}) output := collectOutput(cfg, outR, errR) @@ -139,3 +144,687 @@ func TestCheckout_BranchNotFound(t *testing.T) { assert.ErrorIs(t, err, ErrNotInStack) assert.Contains(t, output, "no locally tracked stack found") } + +// --- Remote checkout tests (numeric target, local miss → API fallback) --- + +func TestCheckout_NumericTarget_StacksNotAvailable(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "123"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrAPIFailure) + assert.Contains(t, output, "not enabled") +} + +func TestCheckout_NumericTarget_PRNotInStack(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 1, PullRequests: []int{10, 11}}, + }, nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "99"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.Contains(t, output, "PR #99 is not part of a stack") +} + +func TestCheckout_NumericTarget_NewStack(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + var createdBranches []string + var trackingSet []string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { + return name == "main" // only trunk exists + }, + FetchFn: func(remote string) error { return nil }, + CreateBranchFn: func(name, base string) error { + createdBranches = append(createdBranches, name) + return nil + }, + SetUpstreamTrackingFn: func(branch, remote string) error { + trackingSet = append(trackingSet, branch) + return nil + }, + ResolveRemoteFn: func(branch string) (string, error) { + return "origin", nil + }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { + return "abc123", nil + }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 42, PullRequests: []int{10, 11, 12}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"}, + 12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2", URL: "https://github.com/o/r/pull/12"}, + } + pr, ok := prs[number] + if !ok { + return nil, fmt.Errorf("PR #%d not found", number) + } + return pr, nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "11"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + + // Should create the 3 branches (trunk "main" already exists) + assert.Equal(t, []string{"feat-1", "feat-2", "feat-3"}, createdBranches) + assert.Equal(t, []string{"feat-1", "feat-2", "feat-3"}, trackingSet) + + // Should checkout the target PR's branch + assert.Equal(t, "feat-2", checkedOut) + assert.Contains(t, output, "Imported stack with 3 branches") + assert.Contains(t, output, "Switched to feat-2") + + // Verify stack was saved + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, "42", sf.Stacks[0].ID) + assert.Equal(t, "main", sf.Stacks[0].Trunk.Branch) + assert.Len(t, sf.Stacks[0].Branches, 3) + assert.Equal(t, 10, sf.Stacks[0].Branches[0].PullRequest.Number) + assert.Equal(t, 11, sf.Stacks[0].Branches[1].PullRequest.Number) + assert.Equal(t, 12, sf.Stacks[0].Branches[2].PullRequest.Number) +} + +func TestCheckout_NumericTarget_BranchExistsNoStack(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + var createdBranches []string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { + // feat-1 exists locally but feat-2 does not + return name == "main" || name == "feat-1" + }, + FetchFn: func(remote string) error { return nil }, + CreateBranchFn: func(name, base string) error { + createdBranches = append(createdBranches, name) + return nil + }, + SetUpstreamTrackingFn: func(branch, remote string) error { return nil }, + ResolveRemoteFn: func(branch string) (string, error) { + return "origin", nil + }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { + return "abc123", nil + }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + // No stacks exist locally + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 99, PullRequests: []int{10, 11}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"}, + } + return prs[number], nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "11"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + + // Only feat-2 should be created (feat-1 and main already exist) + assert.Equal(t, []string{"feat-2"}, createdBranches) + assert.Equal(t, "feat-2", checkedOut) + assert.Contains(t, output, "Imported stack with 2 branches") +} + +func TestCheckout_NumericTarget_AlreadyInMatchingStack(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { + return "abc123", nil + }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + // Stack already exists locally with matching PRs + writeStackFile(t, gitDir, stack.Stack{ + ID: "42", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 10, URL: "https://github.com/o/r/pull/10"}}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 11, URL: "https://github.com/o/r/pull/11"}}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + // PR 10 is found locally → no API call needed + // No GitHubClientOverride means API calls would panic + err := runCheckout(cfg, &checkoutOptions{target: "10"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "feat-1", checkedOut) + assert.Contains(t, output, "Switched to feat-1") +} + +func TestCheckout_NumericTarget_LocalMiss_RemoteMatch(t *testing.T) { + // PR 11 is NOT in any local stack, but IS in a remote stack. + // The API should be called as a fallback. + gitDir := t.TempDir() + var checkedOut string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { + return name == "main" + }, + FetchFn: func(remote string) error { return nil }, + CreateBranchFn: func(name, base string) error { return nil }, + SetUpstreamTrackingFn: func(branch, remote string) error { return nil }, + ResolveRemoteFn: func(branch string) (string, error) { return "origin", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { + return "abc123", nil + }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + // Local stack has PR 42 only — PR 11 is not tracked + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "other-branch", PullRequest: &stack.PullRequestRef{Number: 42}}, + }, + }) + + apiCalled := false + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + apiCalled = true + return []github.RemoteStack{ + {ID: 99, PullRequests: []int{10, 11}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"}, + } + return prs[number], nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "11"}) + _ = collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.True(t, apiCalled, "should have called ListStacks API when local miss") + assert.Equal(t, "feat-2", checkedOut) +} + +func TestCheckout_NumericTarget_FallbackToBranchName(t *testing.T) { + // PR 999 is not in any local stack and not in any remote stack, + // but "999" happens to be a branch name in a local stack + gitDir := t.TempDir() + var checkedOut string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "999", PullRequest: &stack.PullRequestRef{Number: 50}}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil // no remote stacks + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "999"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "999", checkedOut) + assert.Contains(t, output, "Switched to 999") +} + +func TestCheckout_NumericTarget_CompositionMismatch_NonInteractive(t *testing.T) { + gitDir := t.TempDir() + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + // Local stack has PRs 10, 11 + writeStackFile(t, gitDir, stack.Stack{ + ID: "42", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 11}}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + // Remote stack has PRs 10, 11, 12 (extra PR added) + return []github.RemoteStack{ + {ID: 42, PullRequests: []int{10, 11, 12}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1"}, + 12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2"}, + } + return prs[number], nil + }, + } + + // PR 12 not found locally → remote lookup → finds stack → mismatch with local + err := runCheckout(cfg, &checkoutOptions{target: "12"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrConflict) + assert.Contains(t, output, "local stack composition differs from remote") + assert.Contains(t, output, "Local:") + assert.Contains(t, output, "Remote:") +} + +func TestCheckout_NumericTarget_ClosedMergedPR(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { + return name == "main" + }, + FetchFn: func(remote string) error { return nil }, + CreateBranchFn: func(name, base string) error { return nil }, + SetUpstreamTrackingFn: func(branch, remote string) error { return nil }, + ResolveRemoteFn: func(branch string) (string, error) { + return "origin", nil + }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { + return "abc123", nil + }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 50, PullRequests: []int{10, 11}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", Merged: true, State: "MERGED", URL: "https://github.com/o/r/pull/10"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", State: "OPEN", URL: "https://github.com/o/r/pull/11"}, + } + return prs[number], nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "11"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "feat-2", checkedOut) + assert.Contains(t, output, "Imported stack with 2 branches") + + // Verify merged state is saved + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) + assert.True(t, sf.Stacks[0].Branches[0].PullRequest.Merged) + assert.False(t, sf.Stacks[0].Branches[1].PullRequest.Merged) +} + +func TestCheckout_NumericTarget_APIError(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return nil, fmt.Errorf("network error") + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "123"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrAPIFailure) + assert.Contains(t, output, "failed to list stacks") +} + +func TestCheckout_NumericTarget_SyncsState(t *testing.T) { + gitDir := t.TempDir() + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CheckoutBranchFn: func(name string) error { + return nil + }, + RevParseFn: func(ref string) (string, error) { + return "abc123", nil + }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + // Existing stack with stale PR data — PR 10 found locally + writeStackFile(t, gitDir, stack.Stack{ + ID: "42", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 10, URL: "old-url"}}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 11, URL: "old-url"}}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + // PR 10 is found locally → no API call needed, resolved directly + err := runCheckout(cfg, &checkoutOptions{target: "10"}) + _ = collectOutput(cfg, outR, errR) + + require.NoError(t, err) +} + +func TestCheckout_NumericTarget_EmptyStacks(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil // no stacks at all + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "123"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.Contains(t, output, "PR #123 is not part of a stack") +} + +func TestCheckout_NumericTarget_AlreadyOnTarget(t *testing.T) { + gitDir := t.TempDir() + checkoutCalled := false + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "feat-1", nil }, + CheckoutBranchFn: func(name string) error { + checkoutCalled = true + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "42", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 10, URL: "https://github.com/o/r/pull/10"}}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 11, URL: "https://github.com/o/r/pull/11"}}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + // PR 10 found locally → resolved without API + err := runCheckout(cfg, &checkoutOptions{target: "10"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.False(t, checkoutCalled, "should not call CheckoutBranch when already on target") + assert.Contains(t, output, "Already on feat-1") +} + +// --- Helper tests --- + +func TestStackCompositionMatches(t *testing.T) { + tests := []struct { + name string + local *stack.Stack + remote []int + matches bool + }{ + { + name: "exact match", + local: &stack.Stack{ + Branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 11}}, + }, + }, + remote: []int{10, 11}, + matches: true, + }, + { + name: "different order", + local: &stack.Stack{ + Branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 11}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 10}}, + }, + }, + remote: []int{10, 11}, + matches: false, + }, + { + name: "remote has more", + local: &stack.Stack{ + Branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 10}}, + }, + }, + remote: []int{10, 11}, + matches: false, + }, + { + name: "local has branch without PR", + local: &stack.Stack{ + Branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "b"}, // no PR + }, + }, + remote: []int{10, 11}, + matches: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stackCompositionMatches(tt.local, tt.remote) + assert.Equal(t, tt.matches, result) + }) + } +} + +func TestFindRemoteStackForPR(t *testing.T) { + mock := &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 1, PullRequests: []int{10, 11}}, + {ID: 2, PullRequests: []int{20, 21, 22}}, + }, nil + }, + } + + // Found in first stack + rs, err := findRemoteStackForPR(mock, 11) + require.NoError(t, err) + require.NotNil(t, rs) + assert.Equal(t, 1, rs.ID) + + // Found in second stack + rs, err = findRemoteStackForPR(mock, 21) + require.NoError(t, err) + require.NotNil(t, rs) + assert.Equal(t, 2, rs.ID) + + // Not found + rs, err = findRemoteStackForPR(mock, 99) + require.NoError(t, err) + assert.Nil(t, rs) +} diff --git a/cmd/init.go b/cmd/init.go index 176ccfc..f625814 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -31,10 +31,6 @@ func InitCmd(cfg *config.Config) *cobra.Command { Unless specified, prompts user to create/select branch for first layer of the stack. Trunk defaults to default branch, unless specified otherwise.`, - Example: ` $ gh stack init - $ gh stack init myBranch - $ gh stack init --adopt branch1 branch2 branch3 - $ gh stack init --base integrationBranch firstBranch`, RunE: func(cmd *cobra.Command, args []string) error { opts.branches = args return runInit(cfg, opts) diff --git a/cmd/rebase.go b/cmd/rebase.go index 7805d47..26059cc 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -45,10 +45,6 @@ func RebaseCmd(cfg *config.Config) *cobra.Command { Ensures that each branch in the stack has the tip of the previous layer in its commit history, rebasing if necessary.`, - Example: ` $ gh stack rebase - $ gh stack rebase --downstack - $ gh stack rebase --continue - $ gh stack rebase --abort`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { diff --git a/cmd/submit.go b/cmd/submit.go index 84158a8..97f112d 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -321,7 +321,7 @@ func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, case 422: handleCreate422(cfg, httpErr, prNumbers) case 404: - cfg.Warningf("Stacked PRs are not yet available for this repository") + cfg.Warningf("Stacked PRs are not enabled for this repository") default: cfg.Warningf("Failed to create stack on GitHub: %s", httpErr.Message) } diff --git a/cmd/submit_test.go b/cmd/submit_test.go index 7b0179b..1b130a3 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -516,7 +516,7 @@ func TestSyncStack_NotAvailable(t *testing.T) { errOut, _ := io.ReadAll(errR) output := string(errOut) - assert.Contains(t, output, "not yet available") + assert.Contains(t, output, "not enabled") } func TestSyncStack_SkippedForSinglePR(t *testing.T) { diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 48f6abf..3146773 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -132,13 +132,17 @@ gh stack view --json ### `gh stack checkout` -Check out a locally tracked stack from a pull request number or branch name. +Check out a stack from a pull request number or branch name. ```sh -gh stack checkout [] +gh stack checkout [ | ] ``` -Resolves the target against stacks stored in local tracking (`.git/gh-stack`). Accepts a PR number (e.g. `42`) or a branch name that belongs to a locally tracked stack. When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from. +When a PR number is provided (e.g. `123`), the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict. + +When a branch name is provided, the command resolves it against locally tracked stacks only. + +When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from. **Examples:** @@ -146,7 +150,7 @@ Resolves the target against stacks stored in local tracking (`.git/gh-stack`). A # Check out a stack by PR number gh stack checkout 42 -# Check out a stack by branch name +# Check out a stack by branch name (local only) gh stack checkout feature-auth # Interactive — select from locally tracked stacks diff --git a/internal/github/client_interface.go b/internal/github/client_interface.go index 02f476b..8b8ce37 100644 --- a/internal/github/client_interface.go +++ b/internal/github/client_interface.go @@ -6,9 +6,11 @@ package github type ClientOps interface { FindPRForBranch(branch string) (*PullRequest, error) FindAnyPRForBranch(branch string) (*PullRequest, error) + FindPRByNumber(number int) (*PullRequest, error) FindPRDetailsForBranch(branch string) (*PRDetails, error) CreatePR(base, head, title, body string, draft bool) (*PullRequest, error) UpdatePRBase(number int, base string) error + ListStacks() ([]RemoteStack, error) CreateStack(prNumbers []int) (int, error) UpdateStack(stackID string, prNumbers []int) error DeleteStack(stackID string) error diff --git a/internal/github/github.go b/internal/github/github.go index 9237b12..4af97d1 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -233,6 +233,25 @@ func (c *Client) UpdatePRBase(number int, base string) error { return c.rest.Patch(path, bytes.NewReader(body), nil) } +func (c *Client) repositoryID() (string, error) { + var query struct { + Repository struct { + ID string + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + } + + if err := c.gql.Query("RepositoryID", &query, variables); err != nil { + return "", fmt.Errorf("fetching repository ID: %w", err) + } + + return query.Repository.ID, nil +} + // PRDetails holds enriched pull request data for display in the TUI. type PRDetails struct { Number int @@ -298,11 +317,68 @@ 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) +// FindPRByNumber fetches a pull request by its number. +func (c *Client) FindPRByNumber(number int) (*PullRequest, error) { + var query struct { + Repository struct { + PullRequest struct { + ID string `graphql:"id"` + Number int `graphql:"number"` + Title string `graphql:"title"` + State string `graphql:"state"` + URL string `graphql:"url"` + HeadRefName string `graphql:"headRefName"` + BaseRefName string `graphql:"baseRefName"` + IsDraft bool `graphql:"isDraft"` + Merged bool `graphql:"merged"` + MergeQueueEntry *MergeQueueEntry `graphql:"mergeQueueEntry"` + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + "number": graphql.Int(number), + } + + if err := c.gql.Query("FindPRByNumber", &query, variables); err != nil { + return nil, fmt.Errorf("querying PR #%d: %w", number, err) + } + + n := query.Repository.PullRequest + return &PullRequest{ + ID: n.ID, + Number: n.Number, + Title: n.Title, + State: n.State, + URL: n.URL, + HeadRefName: n.HeadRefName, + BaseRefName: n.BaseRefName, + IsDraft: n.IsDraft, + Merged: n.Merged, + MergeQueueEntry: n.MergeQueueEntry, + }, nil +} + +type RemoteStack struct { + ID int `json:"id"` + PullRequests []int `json:"pull_requests"` +} + +// ListStacks returns all stacks in the repository. +// Returns an empty slice if no stacks exist. +// A 404 response indicates stacked PRs are not enabled for this repository. +func (c *Client) ListStacks() ([]RemoteStack, error) { + path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks", c.owner, c.repo) + var stacks []RemoteStack + if err := c.rest.Get(path, &stacks); err != nil { + return nil, err + } + if stacks == nil { + stacks = []RemoteStack{} + } + return stacks, nil } // CreateStack creates a stack on GitHub from an ordered list of PR numbers. @@ -354,21 +430,9 @@ func (c *Client) UpdateStack(stackID string, prNumbers []int) error { return c.rest.Put(path, bytes.NewReader(body), &response) } -func (c *Client) repositoryID() (string, error) { - var query struct { - Repository struct { - ID string - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables := map[string]interface{}{ - "owner": graphql.String(c.owner), - "name": graphql.String(c.repo), - } - - if err := c.gql.Query("RepositoryID", &query, variables); err != nil { - return "", fmt.Errorf("fetching repository ID: %w", err) - } - - return query.Repository.ID, 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) } diff --git a/internal/github/mock_client.go b/internal/github/mock_client.go index 1f839fa..eadab01 100644 --- a/internal/github/mock_client.go +++ b/internal/github/mock_client.go @@ -6,9 +6,11 @@ package github type MockClient struct { FindPRForBranchFn func(string) (*PullRequest, error) FindAnyPRForBranchFn func(string) (*PullRequest, error) + FindPRByNumberFn func(int) (*PullRequest, error) FindPRDetailsForBranchFn func(string) (*PRDetails, error) CreatePRFn func(string, string, string, string, bool) (*PullRequest, error) UpdatePRBaseFn func(int, string) error + ListStacksFn func() ([]RemoteStack, error) CreateStackFn func([]int) (int, error) UpdateStackFn func(string, []int) error DeleteStackFn func(string) error @@ -31,6 +33,13 @@ func (m *MockClient) FindAnyPRForBranch(branch string) (*PullRequest, error) { return nil, nil } +func (m *MockClient) FindPRByNumber(number int) (*PullRequest, error) { + if m.FindPRByNumberFn != nil { + return m.FindPRByNumberFn(number) + } + return nil, nil +} + func (m *MockClient) FindPRDetailsForBranch(branch string) (*PRDetails, error) { if m.FindPRDetailsForBranchFn != nil { return m.FindPRDetailsForBranchFn(branch) @@ -52,6 +61,13 @@ func (m *MockClient) UpdatePRBase(number int, base string) error { return nil } +func (m *MockClient) ListStacks() ([]RemoteStack, error) { + if m.ListStacksFn != nil { + return m.ListStacksFn() + } + return nil, nil +} + func (m *MockClient) CreateStack(prNumbers []int) (int, error) { if m.CreateStackFn != nil { return m.CreateStackFn(prNumbers) diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 4d217f8..d5e2a8f 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -141,7 +141,7 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current | Switch branches up/down in stack | `gh stack up [n]` / `gh stack down [n]` | | 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` | +| Check out by branch (local only) | `gh stack checkout feature-auth` | | Tear down a stack to restructure it | `gh stack unstack` | --- @@ -705,23 +705,23 @@ Navigation clamps to stack bounds. Merged branches are skipped when navigating f ### Check out a stack — `gh stack checkout` -Check out a locally tracked stack by PR number or branch name. Always provide the target as an argument. +Check out a stack from a pull request number or branch name. ``` -gh stack checkout +gh stack checkout ``` ```bash -# By PR number +# By PR number (pulls from GitHub) gh stack checkout 42 -# By branch name +# By branch name (local only) gh stack checkout feature-auth ``` -Resolves the target against locally tracked stacks. Accepts a PR number, PR URL, or branch name. Checks out the matching branch. +When a PR number is provided (e.g. `123`), the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict by deciding whether to replace the local stack with the remote version or delete the remote stack and keep the local version. -> **Note:** This command only works with stacks that have been created locally (via `gh stack init`). Server-side stack discovery is not yet implemented. +When a branch name is provided, the command resolves it against locally tracked stacks only. --- @@ -781,5 +781,5 @@ gh stack unstack feature-auth 2. **Stack disambiguation cannot be bypassed.** If the current branch is the trunk of multiple stacks, commands error with code 6. Check out a non-shared branch first. 3. **Multiple remotes require `--remote` or config.** If more than one remote is configured, pass `--remote ` or set `remote.pushDefault` in git config before running `push`, `sync`, or `rebase`. 4. **Merging PRs:** Merging Stacked PRs from the CLI is not supported yet. Direct users to open the PR URL in a browser to merge PRs. -5. **Server-side stack discovery is not supported.** `checkout` only works with locally tracked stacks. +5. **Remote stack checkout requires a PR number.** `checkout` with a branch name only works with locally tracked stacks. Use a PR number (e.g. `gh stack checkout 123`) to pull stacks from GitHub. 6. **PR title and body are auto-generated.** There is no flag to set a custom PR title or body during `submit`. The title and body are generated from commit messages plus a footer. Use `gh pr edit` to modify PR title and body after creation. From e8344e10acb347b93cc92f37023491445ab5a144 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 9 Apr 2026 22:51:00 -0400 Subject: [PATCH 2/3] clear interactive selector on interrupt --- cmd/checkout.go | 2 ++ cmd/push.go | 1 + cmd/utils.go | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/cmd/checkout.go b/cmd/checkout.go index 3922707..9f17587 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -361,6 +361,7 @@ func handleCompositionConflict( selected, err := p.Select("How would you like to resolve this?", "", options) if err != nil { if isInterruptError(err) { + clearSelectPrompt(cfg, len(options)) printInterrupt(cfg) return nil, errInterrupt } @@ -551,6 +552,7 @@ func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Sta ) if err != nil { if isInterruptError(err) { + clearSelectPrompt(cfg, len(options)) printInterrupt(cfg) return nil, errInterrupt } diff --git a/cmd/push.go b/cmd/push.go index f3d4ff4..dbf07e3 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -145,6 +145,7 @@ func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, erro selected, promptErr := p.Select("Multiple remotes found. Which remote should be used?", "", multi.Remotes) if promptErr != nil { if isInterruptError(promptErr) { + clearSelectPrompt(cfg, len(multi.Remotes)) printInterrupt(cfg) return "", errInterrupt } diff --git a/cmd/utils.go b/cmd/utils.go index b735ce2..9374588 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -65,6 +65,23 @@ func printInterrupt(cfg *config.Config) { cfg.Infof("Received interrupt, aborting operation") } +// selectPromptPageSize matches the PageSize used by the go-gh prompter. +const selectPromptPageSize = 20 + +// clearSelectPrompt erases the rendered Select prompt from the terminal. +// survey/v2 does not call Cleanup on interrupt, leaving the question and +// option lines visible. This function moves the cursor up past those lines +// and clears to the end of the screen. +func clearSelectPrompt(cfg *config.Config, numOptions int) { + visible := numOptions + if visible > selectPromptPageSize { + visible = selectPromptPageSize + } + // 1 line for the question/filter + visible option lines + lines := 1 + visible + fmt.Fprintf(cfg.Err, "\033[%dA\033[J", lines) +} + // loadStackResult holds everything returned by loadStack. type loadStackResult struct { GitDir string @@ -186,6 +203,7 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac selected, err := p.Select("Which stack would you like to use?", "", options) if err != nil { if isInterruptError(err) { + clearSelectPrompt(cfg, len(options)) printInterrupt(cfg) return nil, errInterrupt } From e3db9524ebffcca25ad44c2668cff8ef1586aceb Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 10 Apr 2026 01:11:26 -0400 Subject: [PATCH 3/3] adressing review comments --- cmd/checkout.go | 13 ++++++++++--- cmd/utils.go | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/cmd/checkout.go b/cmd/checkout.go index 9f17587..e0d2162 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -211,10 +211,13 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, localStack := findLocalStackForRemotePRs(sf, prs) if localStack != nil { + // Sync remote PR metadata before comparing composition so locally + // tracked stacks with incomplete PR refs don't appear to conflict. + syncRemotePRState(localStack, prs) + // Case A: branch is in a local stack — check composition if stackCompositionMatches(localStack, remoteStack.PullRequests) { - // Composition matches — sync PR state and checkout - syncRemotePRState(localStack, prs) + // Composition matches — checkout if localStack.ID == "" { localStack.ID = remoteStackID } @@ -280,6 +283,9 @@ func fetchStackPRDetails(client github.ClientOps, prNumbers []int) ([]*github.Pu if err != nil { return nil, fmt.Errorf("fetching PR #%d: %w", n, err) } + if pr == nil { + return nil, fmt.Errorf("PR #%d not found", n) + } prs = append(prs, pr) } return prs, nil @@ -450,7 +456,8 @@ func importRemoteStack( if !git.BranchExists(trunk) { remoteTrunk := remote + "/" + trunk if err := git.CreateBranch(trunk, remoteTrunk); err != nil { - cfg.Warningf("could not pull trunk branch %s: %v", trunk, err) + cfg.Errorf("could not create trunk branch %s from %s: %v", trunk, remoteTrunk, err) + return nil, ErrSilent } } diff --git a/cmd/utils.go b/cmd/utils.go index 9374588..60cb0da 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -79,7 +79,7 @@ func clearSelectPrompt(cfg *config.Config, numOptions int) { } // 1 line for the question/filter + visible option lines lines := 1 + visible - fmt.Fprintf(cfg.Err, "\033[%dA\033[J", lines) + fmt.Fprintf(cfg.Out, "\033[%dA\033[J", lines) } // loadStackResult holds everything returned by loadStack.