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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai

- `cmd/`: One Cobra command per file. Each exports `<Name>Cmd(cfg *config.Config)` with logic in `run<Name>()`.
- `internal/git/`: `Ops` interface (52 methods) wrapping git CLI. `MockOps` for tests. Package-level functions delegate to swappable `ops` variable.
- `internal/github/`: `ClientOps` interface (11 methods) for GitHub API. `MockClient` for tests.
- `internal/github/`: `ClientOps` interface (13 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`).
- `internal/config/`: `Config` struct passed to all commands. Holds I/O, colors, and test hooks (`SelectFn`, `ConfirmFn`, `InputFn`, `GitHubClientOverride`).
- `internal/stack/`: Stack file (`.git/gh-stack`, JSON) management with file locking.
- `internal/tui/`: bubbletea views (`stackview`, `modifyview`).
Expand Down
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ internal/
gitops.go # Ops interface (52 methods)
mock_ops.go # MockOps. Each method has a corresponding *Fn field.
github/ # github.ClientOps interface + real Client
client_interface.go # ClientOps interface (11 methods)
client_interface.go # ClientOps interface (13 methods)
mock_client.go # MockClient. Uses function-pointer fields for testing.
stack/ # stack file (.git/gh-stack) management, JSON schema, locking
schema.json # JSON Schema for the stack file format
Expand Down Expand Up @@ -109,13 +109,14 @@ if errors.As(err, &exitErr) { ... }
### Key interfaces

- **`git.Ops`** (`internal/git/gitops.go`): 52 methods wrapping git CLI calls. The production implementation uses `cli/go-gh`'s `client.Command()` via `run()` and `runSilent()` helpers. Package-level functions (e.g., `git.CurrentBranch()`) delegate to a swappable package-level `ops` variable.
- **`github.ClientOps`** (`internal/github/client_interface.go`): 11 methods for GitHub API (PRs, stacks). Injected via `cfg.GitHubClientOverride` in tests.
- **`config.Config`** (`internal/config/config.go`): Central configuration passed to all commands. Holds I/O streams, color functions, and test hook fields (`SelectFn`, `ConfirmFn`, `InputFn`, `TokenForHostFn`, `RepoOverride`).
- **`github.ClientOps`** (`internal/github/client_interface.go`): 13 methods for GitHub API (PRs, stacks). Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`): `ListStacks`, `FindStackForPR`, `GetStack`, `CreateStack`, `AddToStack` (delta append), `Unstack`. Injected via `cfg.GitHubClientOverride` in tests.
- **`config.Config`** (`internal/config/config.go`): Central configuration passed to all commands. Holds I/O streams, color functions, and test hook fields (`SelectFn`, `ConfirmFn`, `InputFn`, `RepoOverride`).

### Stack file

- **Location:** `.git/gh-stack` (JSON format, schema version 1).
- **Schema:** `internal/stack/schema.json`.
- **Identity:** each stack stores GitHub's global `id` (string) and repo-scoped `number` (int, shown in the GitHub UI and used as the primary way to reference a stack, e.g. `gh stack checkout <number>`). `number` may be `0` for stack files created before it was tracked; it is backfilled from the API on the next stack operation.
- **Locking:** Exclusive file lock at `.git/gh-stack.lock` with 5-second timeout. Errors surface as `LockError`.
- **Staleness:** Concurrent modifications detected via `StaleError`.

Expand Down
134 changes: 106 additions & 28 deletions cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ func CheckoutCmd(cfg *config.Config) *cobra.Command {
opts := &checkoutOptions{}

cmd := &cobra.Command{
Use: "checkout [<pr-number> | <pr-url> | <branch>]",
Short: "Checkout a stack from a PR number, PR URL, or branch name",
Long: `Check out a stack from a pull request number, PR URL, or branch name.
Use: "checkout [<stack-number> | <pr-number> | <pr-url> | <branch>]",
Short: "Checkout a stack by stack number, PR number, PR URL, or branch name",
Long: `Check out a stack by stack number, pull request number, PR URL, or branch name.

A bare number is interpreted first as a stack number (the identifier shown in
the GitHub stack UI). If no stack has that number, it is then tried as a
locally tracked PR number, then a PR number whose stack is discovered from
GitHub, and finally a branch name.

When a PR number or PR URL is provided (e.g. 123 or
https://github.com/owner/repo/pull/123), the command first checks
Expand All @@ -39,7 +44,10 @@ locally tracked stacks only.

When run without arguments, shows a menu of all locally available
stacks to choose from.`,
Example: ` # Check out a stack by PR number
Example: ` # Check out a stack by its stack number
$ gh stack checkout 7

# Check out a stack by PR number
$ gh stack checkout 42

# Check out a stack by PR URL
Expand Down Expand Up @@ -102,7 +110,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
return err
}
} 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
// Target is a pure integer — try stack number, then PR, then branch name
s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target)
if err != nil {
return err
Expand Down Expand Up @@ -137,29 +145,41 @@ 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 {
// resolveNumericTarget handles the case where the user passes a pure integer or
// a PR URL. The number is interpreted as, in order:
// 1. A stack number (the primary identifier)
// 2. A locally tracked PR number
// 3. A PR number whose stack is discovered from GitHub
// 4. A branch name (for numeric branch names like "123")
//
// Stack, PR, and issue numbers share a single repo-scoped numberspace,
// so a given number is only ever one object type; a number that is not a stack
// simply misses at step 1 and resolves at a later step.
func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string, number int, raw string) (*stack.Stack, string, error) {
// 1. Try as a stack number (the primary identifier).
if s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, number); err == nil {
return s, targetBranch, nil
} else if !errors.Is(err, errStackNumberNotFound) {
// A real error during import/reconcile (composition conflict, interrupted
// import, etc.) — surface it rather than trying other interpretations.
return nil, "", err
}

// 2. Try a locally tracked PR number.
if s, br := sf.FindStackByPRNumber(number); s != nil && br != nil {
return s, br.Branch, nil
}

// 2. Try remote API
s, targetBranch, err := checkoutRemoteStack(cfg, sf, gitDir, prNumber)
// 3. Try a PR number whose stack is on GitHub.
s, targetBranch, err := checkoutRemoteStack(cfg, sf, gitDir, number)
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.
// For API failures or "not in a stack", still fall through to the branch-name
// attempt — the user might have a numeric branch name.
remoteErr := err

// 3. Fall back to branch name lookup (handles numeric branch names)
// 4. Fall back to branch name lookup (handles numeric branch names).
stacks := sf.FindAllStacksForBranch(raw)
if len(stacks) > 0 {
s := stacks[0]
Expand Down Expand Up @@ -212,23 +232,81 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,
return nil, "", ErrAPIFailure
}

// Determine trunk (base branch of the first PR) and the target branch
// Determine trunk (base branch of the first PR) and the target branch (the
// branch for the requested PR).
trunk := prs[0].BaseRefName
var targetBranch string
allMerged := true
for _, pr := range prs {
if pr.Number == prNumber {
targetBranch = pr.HeadRefName
}
if !pr.Merged {
allMerged = false
break
}
}
if targetBranch == "" {
cfg.Errorf("could not determine branch for PR #%d", prNumber)
return nil, "", ErrAPIFailure
}

return reconcileAndImportRemoteStack(cfg, client, sf, gitDir, remoteStack, prs, trunk, targetBranch)
}

// errStackNumberNotFound is returned by checkoutStackByNumber when a numeric
// argument does not resolve to a stack (no such stack, stacks unavailable, or
// any lookup failure), signalling the caller to try interpreting the argument
// as a PR number or branch name instead.
var errStackNumberNotFound = errors.New("stack number not found")

// checkoutStackByNumber discovers a stack from GitHub by its stack number,
// reconciles it with any local state, and checks out the top-most unmerged
// branch. It returns errStackNumberNotFound when the number does not resolve to
// a stack so the caller can fall back to other interpretations. Because stack,
// PR, and issue numbers share one repo-scoped numberspace, a number that
// belongs to a PR (or nothing) simply misses here and is resolved by the
// caller's later steps.
func checkoutStackByNumber(cfg *config.Config, sf *stack.StackFile, gitDir string, stackNumber int) (*stack.Stack, string, error) {
client, err := cfg.GitHubClient()
if err != nil {
return nil, "", errStackNumberNotFound
}

remoteStack, err := client.GetStack(stackNumber)
if err != nil || remoteStack == nil || len(remoteStack.PullRequests) == 0 {
// No such stack, stacks unavailable, or a transient failure — let the
// caller try the number as a PR number or branch name.
return nil, "", errStackNumberNotFound
}
Comment thread
skarim marked this conversation as resolved.

prs, err := fetchStackPRDetails(client, remoteStack.PRNumbers())
if err != nil {
cfg.Errorf("failed to fetch PR details: %v", err)
return nil, "", ErrAPIFailure
}

trunk := prs[0].BaseRefName
// Target the top-most unmerged branch, falling back to the very top.
targetBranch := prs[len(prs)-1].HeadRefName
for i := len(prs) - 1; i >= 0; i-- {
if !prs[i].Merged {
targetBranch = prs[i].HeadRefName
break
}
}

return reconcileAndImportRemoteStack(cfg, client, sf, gitDir, remoteStack, prs, trunk, targetBranch)
}

// reconcileAndImportRemoteStack reconciles a resolved remote stack with local
// state — adopting a matching local stack, resolving composition conflicts, or
// importing the stack from the remote — and returns the resolved local stack
// and the branch to check out.
func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, gitDir string, remoteStack *github.RemoteStack, prs []*github.PullRequest, trunk, targetBranch string) (*stack.Stack, string, error) {
allMerged := true
for _, pr := range prs {
if !pr.Merged {
allMerged = false
break
}
}
if allMerged {
cfg.Infof("All PRs in this stack have been merged")
cfg.Printf("To start a new stack, use `%s`", cfg.ColorCyan("gh stack init"))
Expand All @@ -237,7 +315,7 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,

remoteStackID := strconv.Itoa(remoteStack.ID)

// Step 3: Check if the target branch is already in a local stack
// Check if the target branch is already in a local stack.
localStack := findLocalStackForRemotePRs(sf, prs)

if localStack != nil {
Expand All @@ -257,7 +335,7 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,
if err := stack.Save(gitDir, sf); err != nil {
return nil, "", handleSaveError(cfg, err)
}
cfg.Successf("Local stack matches remote — switching to branch")
cfg.Successf("Local stack matches remote — switching to branch%s", stackLabel(remoteStack.Number))
return localStack, targetBranch, nil
}

Expand Down Expand Up @@ -522,7 +600,7 @@ func importRemoteStack(
// Update base SHAs from actual local refs
updateBaseSHAs(s)

cfg.Successf("Imported stack with %d branches from GitHub", len(prs))
cfg.Successf("Imported stack with %d branches from GitHub%s", len(prs), stackLabel(remoteStackNumber))
return s, nil
}

Expand Down
117 changes: 117 additions & 0 deletions cmd/checkout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,123 @@ func TestCheckout_NumericTarget_NewStack(t *testing.T) {
assert.Equal(t, 12, sf.Stacks[0].Branches[2].PullRequest.Number)
}

func TestCheckout_ByStackNumber(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 { return name == "main" },
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()

require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))

var gotStackNumber int
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
GetStackFn: func(n int) (*github.RemoteStack, error) {
gotStackNumber = n
if n == 7 {
return &github.RemoteStack{ID: 42, Number: 7, PullRequests: []int{10, 11, 12}}, nil
}
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
},
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"},
}
return prs[number], nil
},
}

err := runCheckout(cfg, &checkoutOptions{target: "7"})
output := collectOutput(cfg, outR, errR)

require.NoError(t, err)
assert.Equal(t, 7, gotStackNumber, "should look the stack up by its number")
// The top-most (last) branch of the stack is checked out.
assert.Equal(t, "feat-3", checkedOut)
assert.Contains(t, output, "Imported stack with 3 branches")

// Verify the stack was imported with both its internal id and stack number.
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, 7, sf.Stacks[0].Number)
}

func TestCheckout_ByStackNumber_404FallsThroughToPR(t *testing.T) {
// A 404 from GetStack means no such stack, so the number is tried as a PR.
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(string) error { return nil },
CreateBranchFn: func(string, string) error { return nil },
SetUpstreamTrackingFn: func(string, string) error { return nil },
RevParseFn: func(string) (string, error) { return "abc123", nil },
ResolveRemoteFn: func(string) (string, error) { return "origin", nil },
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
})
defer restore()

writeStackFile(t, gitDir, stack.Stack{})

cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
GetStackFn: func(int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
},
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 1, Number: 1, PullRequests: []int{11, 12}}, nil
},
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "main", 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"},
}
return prs[n], nil
},
}

err := runCheckout(cfg, &checkoutOptions{target: "11"})
output := collectOutput(cfg, outR, errR)

require.NoError(t, err)
assert.Equal(t, "feat-2", checkedOut, "the number should resolve as PR #11 after a stack 404")
assert.Contains(t, output, "Imported stack with 2 branches")
}

func TestCheckout_NumericTarget_BranchExistsNoStack(t *testing.T) {
gitDir := t.TempDir()
var checkedOut string
Expand Down
Loading
Loading