Skip to content

Commit f880f0d

Browse files
authored
Stack number as primary identifier (#178)
* Support addressing a stack by its stack number checkout now interprets a bare integer as a stack number first (the identifier shown in the github.com stack UI), falling back to a locally tracked PR number, then a PR number discovered from GitHub, then a branch name. A new checkoutStackByNumber resolves the stack via GetStack and checks out its top-most unmerged branch; the reconcile/import logic is shared with the PR-number path. unstack gains an optional <stack-number> positional argument to unstack a specific locally tracked stack instead of the current one. Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740 * Surface the stack number in output and TUIs Show the human-facing stack number wherever it is known: - Append a "(stack #N)" label to submit, link, checkout, and unstack success messages. - Add a "Stack #N" header line to the view command (short and static) and the stackview TUI header. - Add a "Stack #N" info line to the submit TUI header when submitting an already-created stack. Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740 * Update docs and agent instructions for the new API - cli.md: document checkout/unstack by stack number and drop the "PATs are not supported" note (any gh-authenticated user can now run stack operations). - quick-start.md: drop the PAT-not-supported note. - AGENTS.md / copilot-instructions.md: ClientOps is now 13 methods over the public Stacks REST API; remove the TokenForHostFn test hook; note the stack file's id/number identity. - SKILL.md: add checkout/unstack-by-stack-number quick references. Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740 * address review comments
1 parent a82dc3e commit f880f0d

20 files changed

Lines changed: 727 additions & 120 deletions

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai
1717

1818
- `cmd/`: One Cobra command per file. Each exports `<Name>Cmd(cfg *config.Config)` with logic in `run<Name>()`.
1919
- `internal/git/`: `Ops` interface (52 methods) wrapping git CLI. `MockOps` for tests. Package-level functions delegate to swappable `ops` variable.
20-
- `internal/github/`: `ClientOps` interface (11 methods) for GitHub API. `MockClient` for tests.
20+
- `internal/github/`: `ClientOps` interface (13 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`).
2121
- `internal/config/`: `Config` struct passed to all commands. Holds I/O, colors, and test hooks (`SelectFn`, `ConfirmFn`, `InputFn`, `GitHubClientOverride`).
2222
- `internal/stack/`: Stack file (`.git/gh-stack`, JSON) management with file locking.
2323
- `internal/tui/`: bubbletea views (`stackview`, `modifyview`).

AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ internal/
3535
gitops.go # Ops interface (52 methods)
3636
mock_ops.go # MockOps. Each method has a corresponding *Fn field.
3737
github/ # github.ClientOps interface + real Client
38-
client_interface.go # ClientOps interface (11 methods)
38+
client_interface.go # ClientOps interface (13 methods)
3939
mock_client.go # MockClient. Uses function-pointer fields for testing.
4040
stack/ # stack file (.git/gh-stack) management, JSON schema, locking
4141
schema.json # JSON Schema for the stack file format
@@ -109,13 +109,14 @@ if errors.As(err, &exitErr) { ... }
109109
### Key interfaces
110110

111111
- **`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.
112-
- **`github.ClientOps`** (`internal/github/client_interface.go`): 11 methods for GitHub API (PRs, stacks). Injected via `cfg.GitHubClientOverride` in tests.
113-
- **`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`).
112+
- **`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.
113+
- **`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`).
114114

115115
### Stack file
116116

117117
- **Location:** `.git/gh-stack` (JSON format, schema version 1).
118118
- **Schema:** `internal/stack/schema.json`.
119+
- **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.
119120
- **Locking:** Exclusive file lock at `.git/gh-stack.lock` with 5-second timeout. Errors surface as `LockError`.
120121
- **Staleness:** Concurrent modifications detected via `StaleError`.
121122

cmd/checkout.go

Lines changed: 106 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@ func CheckoutCmd(cfg *config.Config) *cobra.Command {
2323
opts := &checkoutOptions{}
2424

2525
cmd := &cobra.Command{
26-
Use: "checkout [<pr-number> | <pr-url> | <branch>]",
27-
Short: "Checkout a stack from a PR number, PR URL, or branch name",
28-
Long: `Check out a stack from a pull request number, PR URL, or branch name.
26+
Use: "checkout [<stack-number> | <pr-number> | <pr-url> | <branch>]",
27+
Short: "Checkout a stack by stack number, PR number, PR URL, or branch name",
28+
Long: `Check out a stack by stack number, pull request number, PR URL, or branch name.
29+
30+
A bare number is interpreted first as a stack number (the identifier shown in
31+
the GitHub stack UI). If no stack has that number, it is then tried as a
32+
locally tracked PR number, then a PR number whose stack is discovered from
33+
GitHub, and finally a branch name.
2934
3035
When a PR number or PR URL is provided (e.g. 123 or
3136
https://github.com/owner/repo/pull/123), the command first checks
@@ -39,7 +44,10 @@ locally tracked stacks only.
3944
4045
When run without arguments, shows a menu of all locally available
4146
stacks to choose from.`,
42-
Example: ` # Check out a stack by PR number
47+
Example: ` # Check out a stack by its stack number
48+
$ gh stack checkout 7
49+
50+
# Check out a stack by PR number
4351
$ gh stack checkout 42
4452
4553
# Check out a stack by PR URL
@@ -102,7 +110,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
102110
return err
103111
}
104112
} else if prNumber, parseErr := strconv.Atoi(opts.target); parseErr == nil && prNumber > 0 {
105-
// Target is a pure integer — try local PR, then remote API, then branch name
113+
// Target is a pure integer — try stack number, then PR, then branch name
106114
s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target)
107115
if err != nil {
108116
return err
@@ -137,29 +145,41 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
137145
return nil
138146
}
139147

140-
// resolveNumericTarget handles the case where the user passes a pure integer.
141-
// It tries, in order:
142-
// 1. Local stack lookup by PR number
143-
// 2. Remote API discovery (ListStacks → find → import)
144-
// 3. Local stack lookup by branch name (for numeric branch names like "123")
145-
func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string, prNumber int, raw string) (*stack.Stack, string, error) {
146-
// 1. Try local PR number lookup
147-
if s, br := sf.FindStackByPRNumber(prNumber); s != nil && br != nil {
148+
// resolveNumericTarget handles the case where the user passes a pure integer or
149+
// a PR URL. The number is interpreted as, in order:
150+
// 1. A stack number (the primary identifier)
151+
// 2. A locally tracked PR number
152+
// 3. A PR number whose stack is discovered from GitHub
153+
// 4. A branch name (for numeric branch names like "123")
154+
//
155+
// Stack, PR, and issue numbers share a single repo-scoped numberspace,
156+
// so a given number is only ever one object type; a number that is not a stack
157+
// simply misses at step 1 and resolves at a later step.
158+
func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string, number int, raw string) (*stack.Stack, string, error) {
159+
// 1. Try as a stack number (the primary identifier).
160+
if s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, number); err == nil {
161+
return s, targetBranch, nil
162+
} else if !errors.Is(err, errStackNumberNotFound) {
163+
// A real error during import/reconcile (composition conflict, interrupted
164+
// import, etc.) — surface it rather than trying other interpretations.
165+
return nil, "", err
166+
}
167+
168+
// 2. Try a locally tracked PR number.
169+
if s, br := sf.FindStackByPRNumber(number); s != nil && br != nil {
148170
return s, br.Branch, nil
149171
}
150172

151-
// 2. Try remote API
152-
s, targetBranch, err := checkoutRemoteStack(cfg, sf, gitDir, prNumber)
173+
// 3. Try a PR number whose stack is on GitHub.
174+
s, targetBranch, err := checkoutRemoteStack(cfg, sf, gitDir, number)
153175
if err == nil {
154176
return s, targetBranch, nil
155177
}
156-
// If the API returned a definitive "not in a stack" or a real error,
157-
// fall through to the branch-name attempt only for "not in stack".
158-
// For API failures (404, network errors), still fall through —
159-
// the user might have a numeric branch name.
178+
// For API failures or "not in a stack", still fall through to the branch-name
179+
// attempt — the user might have a numeric branch name.
160180
remoteErr := err
161181

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

215-
// Determine trunk (base branch of the first PR) and the target branch
235+
// Determine trunk (base branch of the first PR) and the target branch (the
236+
// branch for the requested PR).
216237
trunk := prs[0].BaseRefName
217238
var targetBranch string
218-
allMerged := true
219239
for _, pr := range prs {
220240
if pr.Number == prNumber {
221241
targetBranch = pr.HeadRefName
222-
}
223-
if !pr.Merged {
224-
allMerged = false
242+
break
225243
}
226244
}
227245
if targetBranch == "" {
228246
cfg.Errorf("could not determine branch for PR #%d", prNumber)
229247
return nil, "", ErrAPIFailure
230248
}
231249

250+
return reconcileAndImportRemoteStack(cfg, client, sf, gitDir, remoteStack, prs, trunk, targetBranch)
251+
}
252+
253+
// errStackNumberNotFound is returned by checkoutStackByNumber when a numeric
254+
// argument does not resolve to a stack (no such stack, stacks unavailable, or
255+
// any lookup failure), signalling the caller to try interpreting the argument
256+
// as a PR number or branch name instead.
257+
var errStackNumberNotFound = errors.New("stack number not found")
258+
259+
// checkoutStackByNumber discovers a stack from GitHub by its stack number,
260+
// reconciles it with any local state, and checks out the top-most unmerged
261+
// branch. It returns errStackNumberNotFound when the number does not resolve to
262+
// a stack so the caller can fall back to other interpretations. Because stack,
263+
// PR, and issue numbers share one repo-scoped numberspace, a number that
264+
// belongs to a PR (or nothing) simply misses here and is resolved by the
265+
// caller's later steps.
266+
func checkoutStackByNumber(cfg *config.Config, sf *stack.StackFile, gitDir string, stackNumber int) (*stack.Stack, string, error) {
267+
client, err := cfg.GitHubClient()
268+
if err != nil {
269+
return nil, "", errStackNumberNotFound
270+
}
271+
272+
remoteStack, err := client.GetStack(stackNumber)
273+
if err != nil || remoteStack == nil || len(remoteStack.PullRequests) == 0 {
274+
// No such stack, stacks unavailable, or a transient failure — let the
275+
// caller try the number as a PR number or branch name.
276+
return nil, "", errStackNumberNotFound
277+
}
278+
279+
prs, err := fetchStackPRDetails(client, remoteStack.PRNumbers())
280+
if err != nil {
281+
cfg.Errorf("failed to fetch PR details: %v", err)
282+
return nil, "", ErrAPIFailure
283+
}
284+
285+
trunk := prs[0].BaseRefName
286+
// Target the top-most unmerged branch, falling back to the very top.
287+
targetBranch := prs[len(prs)-1].HeadRefName
288+
for i := len(prs) - 1; i >= 0; i-- {
289+
if !prs[i].Merged {
290+
targetBranch = prs[i].HeadRefName
291+
break
292+
}
293+
}
294+
295+
return reconcileAndImportRemoteStack(cfg, client, sf, gitDir, remoteStack, prs, trunk, targetBranch)
296+
}
297+
298+
// reconcileAndImportRemoteStack reconciles a resolved remote stack with local
299+
// state — adopting a matching local stack, resolving composition conflicts, or
300+
// importing the stack from the remote — and returns the resolved local stack
301+
// and the branch to check out.
302+
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) {
303+
allMerged := true
304+
for _, pr := range prs {
305+
if !pr.Merged {
306+
allMerged = false
307+
break
308+
}
309+
}
232310
if allMerged {
233311
cfg.Infof("All PRs in this stack have been merged")
234312
cfg.Printf("To start a new stack, use `%s`", cfg.ColorCyan("gh stack init"))
@@ -237,7 +315,7 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,
237315

238316
remoteStackID := strconv.Itoa(remoteStack.ID)
239317

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

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

@@ -522,7 +600,7 @@ func importRemoteStack(
522600
// Update base SHAs from actual local refs
523601
updateBaseSHAs(s)
524602

525-
cfg.Successf("Imported stack with %d branches from GitHub", len(prs))
603+
cfg.Successf("Imported stack with %d branches from GitHub%s", len(prs), stackLabel(remoteStackNumber))
526604
return s, nil
527605
}
528606

cmd/checkout_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,123 @@ func TestCheckout_NumericTarget_NewStack(t *testing.T) {
284284
assert.Equal(t, 12, sf.Stacks[0].Branches[2].PullRequest.Number)
285285
}
286286

287+
func TestCheckout_ByStackNumber(t *testing.T) {
288+
gitDir := t.TempDir()
289+
var checkedOut string
290+
var createdBranches []string
291+
292+
restore := git.SetOps(&git.MockOps{
293+
GitDirFn: func() (string, error) { return gitDir, nil },
294+
CurrentBranchFn: func() (string, error) { return "main", nil },
295+
BranchExistsFn: func(name string) bool { return name == "main" },
296+
FetchFn: func(remote string) error { return nil },
297+
CreateBranchFn: func(name, base string) error {
298+
createdBranches = append(createdBranches, name)
299+
return nil
300+
},
301+
SetUpstreamTrackingFn: func(branch, remote string) error { return nil },
302+
ResolveRemoteFn: func(branch string) (string, error) { return "origin", nil },
303+
CheckoutBranchFn: func(name string) error {
304+
checkedOut = name
305+
return nil
306+
},
307+
RevParseFn: func(ref string) (string, error) { return "abc123", nil },
308+
RevParseMultiFn: func(refs []string) ([]string, error) {
309+
shas := make([]string, len(refs))
310+
for i := range refs {
311+
shas[i] = "abc123"
312+
}
313+
return shas, nil
314+
},
315+
})
316+
defer restore()
317+
318+
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
319+
320+
var gotStackNumber int
321+
cfg, outR, errR := config.NewTestConfig()
322+
cfg.GitHubClientOverride = &github.MockClient{
323+
GetStackFn: func(n int) (*github.RemoteStack, error) {
324+
gotStackNumber = n
325+
if n == 7 {
326+
return &github.RemoteStack{ID: 42, Number: 7, PullRequests: []int{10, 11, 12}}, nil
327+
}
328+
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
329+
},
330+
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
331+
prs := map[int]*github.PullRequest{
332+
10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"},
333+
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"},
334+
12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2", URL: "https://github.com/o/r/pull/12"},
335+
}
336+
return prs[number], nil
337+
},
338+
}
339+
340+
err := runCheckout(cfg, &checkoutOptions{target: "7"})
341+
output := collectOutput(cfg, outR, errR)
342+
343+
require.NoError(t, err)
344+
assert.Equal(t, 7, gotStackNumber, "should look the stack up by its number")
345+
// The top-most (last) branch of the stack is checked out.
346+
assert.Equal(t, "feat-3", checkedOut)
347+
assert.Contains(t, output, "Imported stack with 3 branches")
348+
349+
// Verify the stack was imported with both its internal id and stack number.
350+
sf, loadErr := stack.Load(gitDir)
351+
require.NoError(t, loadErr)
352+
require.Len(t, sf.Stacks, 1)
353+
assert.Equal(t, "42", sf.Stacks[0].ID)
354+
assert.Equal(t, 7, sf.Stacks[0].Number)
355+
}
356+
357+
func TestCheckout_ByStackNumber_404FallsThroughToPR(t *testing.T) {
358+
// A 404 from GetStack means no such stack, so the number is tried as a PR.
359+
gitDir := t.TempDir()
360+
var checkedOut string
361+
restore := git.SetOps(&git.MockOps{
362+
GitDirFn: func() (string, error) { return gitDir, nil },
363+
CurrentBranchFn: func() (string, error) { return "main", nil },
364+
BranchExistsFn: func(name string) bool { return name == "main" },
365+
FetchFn: func(string) error { return nil },
366+
CreateBranchFn: func(string, string) error { return nil },
367+
SetUpstreamTrackingFn: func(string, string) error { return nil },
368+
RevParseFn: func(string) (string, error) { return "abc123", nil },
369+
ResolveRemoteFn: func(string) (string, error) { return "origin", nil },
370+
CheckoutBranchFn: func(name string) error {
371+
checkedOut = name
372+
return nil
373+
},
374+
})
375+
defer restore()
376+
377+
writeStackFile(t, gitDir, stack.Stack{})
378+
379+
cfg, outR, errR := config.NewTestConfig()
380+
cfg.GitHubClientOverride = &github.MockClient{
381+
GetStackFn: func(int) (*github.RemoteStack, error) {
382+
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
383+
},
384+
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
385+
return &github.RemoteStack{ID: 1, Number: 1, PullRequests: []int{11, 12}}, nil
386+
},
387+
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
388+
prs := map[int]*github.PullRequest{
389+
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "main", URL: "https://github.com/o/r/pull/11"},
390+
12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2", URL: "https://github.com/o/r/pull/12"},
391+
}
392+
return prs[n], nil
393+
},
394+
}
395+
396+
err := runCheckout(cfg, &checkoutOptions{target: "11"})
397+
output := collectOutput(cfg, outR, errR)
398+
399+
require.NoError(t, err)
400+
assert.Equal(t, "feat-2", checkedOut, "the number should resolve as PR #11 after a stack 404")
401+
assert.Contains(t, output, "Imported stack with 2 branches")
402+
}
403+
287404
func TestCheckout_NumericTarget_BranchExistsNoStack(t *testing.T) {
288405
gitDir := t.TempDir()
289406
var checkedOut string

0 commit comments

Comments
 (0)