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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,13 @@ gh stack add -m "Refactor utils" cleanup-layer

### `gh stack checkout`

Check out a stack from a pull request number or branch name.
Check out a stack from a pull request number, URL, or branch name.

```
gh stack checkout [<pr-number> | <branch>]
gh stack checkout [<pr-number> | <pr-url> | <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.
When a PR number or URL is provided (e.g. `123` or `https://github.com/owner/repo/pull/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.

Expand All @@ -183,6 +183,9 @@ When run without arguments in an interactive terminal, shows a menu of all local
# Check out a stack by PR number
gh stack checkout 42

# Check out a stack by PR URL
gh stack checkout https://github.com/owner/repo/pull/42

# Check out a stack by branch name (local only)
gh stack checkout feature-auth

Expand Down Expand Up @@ -389,7 +392,7 @@ Link PRs into a stack on GitHub without local tracking.
gh stack link [flags] <branch-or-pr> <branch-or-pr> [...]
```

Creates or updates a stack on GitHub from branch names or PR numbers. This command does not store or modify any `gh stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs.
Creates or updates a stack on GitHub from branch names or PR numbers/URLs. This command does not store or modify any `gh stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs.

Arguments are provided in stack order (bottom to top). Branch arguments are automatically pushed to the remote before creating or looking up PRs. For branches that already have open PRs, those PRs are used. For branches without PRs, new PRs are created automatically with the correct base branch chaining. Existing PRs whose base branch doesn't match the expected chain are corrected automatically.

Expand All @@ -410,6 +413,9 @@ gh stack link feature-auth feature-api feature-ui
# Link existing PRs by number
gh stack link 10 20 30

# Link existing PRs by URL
gh stack link https://github.com/owner/repo/pull/10 https://github.com/owner/repo/pull/20

# Add branches to an existing stack of PRs
gh stack link 42 43 feature-auth feature-ui

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

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

When a PR number is provided (e.g. 123), the command first checks
When a PR number or PR URL is provided (e.g. 123 or
https://github.com/owner/repo/pull/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,
Expand All @@ -41,6 +42,9 @@ stacks to choose from.`,
Example: ` # Check out a stack by PR number
$ gh stack checkout 42

# Check out a stack by PR URL
$ gh stack checkout https://github.com/owner/repo/pull/42

# Check out a stack by branch name
$ gh stack checkout feat/api-routes

Expand Down Expand Up @@ -91,6 +95,12 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
return nil
}
targetBranch = s.Branches[len(s.Branches)-1].Branch
} else if prNumber, ok := parsePRURL(opts.target); ok {
// Target is a PR URL — extract number and resolve like a numeric target
s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target)
if err != nil {
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
s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target)
Expand Down
83 changes: 83 additions & 0 deletions cmd/checkout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -930,3 +930,86 @@ func TestFindRemoteStackForPR(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, rs)
}

func TestCheckout_ByPRURL_Local(t *testing.T) {
// When a PR URL resolves to a locally tracked stack, no API call needed
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: "b1", PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}},
},
})

cfg, outR, errR := config.NewTestConfig()
// No GitHubClientOverride — should resolve locally without API
err := runCheckout(cfg, &checkoutOptions{target: "https://github.com/o/r/pull/42"})
output := collectOutput(cfg, outR, errR)

require.NoError(t, err)
assert.Equal(t, "b1", checkedOut)
assert.Contains(t, output, "Switched to b1")
}

func TestCheckout_ByPRURL_Remote(t *testing.T) {
// When a PR URL is not tracked locally, fall back to remote API
gitDir := t.TempDir()
var checkedOut string

prDB := 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"},
}

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()

// Empty stack file — nothing local
writeStackFile(t, gitDir, 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
},
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
if pr, ok := prDB[n]; ok {
return pr, nil
}
return nil, nil
},
}

err := runCheckout(cfg, &checkoutOptions{target: "https://github.com/o/r/pull/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")
}
32 changes: 29 additions & 3 deletions cmd/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,19 @@ func LinkCmd(cfg *config.Config) *cobra.Command {
cmd := &cobra.Command{
Use: "link <branch-or-pr> <branch-or-pr> [<branch-or-pr>...]",
Short: "Link PRs into a stack on GitHub without local tracking",
Long: `Create or update a stack on GitHub from branch names or PR numbers.
Long: `Create or update a stack on GitHub from branch names, PR numbers, or PR URLs.

This command does not rely on gh-stack local tracking state. It is
designed for users who manage branches with external tools (e.g. jj,
Sapling, ghstack, git-town, etc...) and want to use GitHub stacked
PRs without adopting local stack tracking.

Arguments are provided in stack order (bottom to top). Each argument
can be a branch name or a PR number. For numeric arguments, the
can be a branch name, a PR number, or a PR URL (e.g.
https://github.com/owner/repo/pull/123). For numeric arguments, the
command first checks if a PR with that number exists; if not, it
treats the argument as a branch name.
treats the argument as a branch name. PR URLs are always resolved
as pull requests (never as branch names).

Branch arguments are automatically pushed to the remote before
creating or looking up PRs. For branches that already have open PRs,
Expand All @@ -51,6 +53,9 @@ the new PRs (existing PRs are never removed).`,
# Link existing PRs by number
$ gh stack link 41 42 43

# Link existing PRs by URL
$ gh stack link https://github.com/owner/repo/pull/41 https://github.com/owner/repo/pull/42

# Specify a custom base branch for stack
$ gh stack link --base develop auth-layer api-routes`,
Args: cobra.MinimumNArgs(2),
Expand Down Expand Up @@ -233,6 +238,27 @@ func findExistingPRs(cfg *config.Config, client github.ClientOps, args []string)
// findExistingPR looks up an existing PR for a single arg.
// Returns nil if the arg is a branch with no open PR.
func findExistingPR(cfg *config.Config, client github.ClientOps, arg string) (*resolvedArg, error) {
// If the arg is a PR URL, extract the number and look it up.
// Unlike numeric args, a URL can never be a valid branch name,
// so we error instead of falling through to branch lookup.
if n, ok := parsePRURL(arg); ok {
pr, err := client.FindPRByNumber(n)
if err != nil {
cfg.Errorf("failed to look up PR #%d: %v", n, err)
return nil, ErrAPIFailure
}
if pr == nil {
cfg.Errorf("PR #%d not found", n)
return nil, ErrInvalidArgs
}
return &resolvedArg{
branch: pr.HeadRefName,
prNumber: pr.Number,
prURL: pr.URL,
pr: pr,
}, nil
}

// If numeric, try as PR number first
if n, err := strconv.Atoi(arg); err == nil && n > 0 {
pr, err := client.FindPRByNumber(n)
Expand Down
103 changes: 103 additions & 0 deletions cmd/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1566,3 +1566,106 @@ func TestLink_PRNumbers_NoTemplateUsesFooter(t *testing.T) {
assert.NoError(t, err)
assert.Contains(t, capturedBody, "GitHub Stacks CLI", "footer should be present when no template")
}

// --- PR URL tests ---

func TestLink_PRURLs_CreateNewStack(t *testing.T) {
var createdPRs []int
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
return &github.PullRequest{
Number: n,
HeadRefName: fmt.Sprintf("branch-%d", n),
BaseRefName: "main",
URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n),
}, nil
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
createdPRs = prNumbers
return 42, nil
},
}

cmd := LinkCmd(cfg)
cmd.SetArgs([]string{
"https://github.com/o/r/pull/10",
"https://github.com/o/r/pull/20",
"https://github.com/o/r/pull/30",
})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)

assert.NoError(t, err)
assert.Equal(t, []int{10, 20, 30}, createdPRs)
assert.Contains(t, output, "Created stack with 3 PRs")
}

func TestLink_PRURLs_NotFound(t *testing.T) {
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
return nil, nil // PR not found
},
}

cmd := LinkCmd(cfg)
cmd.SetArgs([]string{
"https://github.com/o/r/pull/999",
"https://github.com/o/r/pull/1000",
})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)

assert.ErrorIs(t, err, ErrInvalidArgs)
assert.Contains(t, output, "PR #999 not found")
}

func TestLink_MixedURLsAndNumbers(t *testing.T) {
var createdPRs []int
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
return &github.PullRequest{
Number: n,
HeadRefName: fmt.Sprintf("branch-%d", n),
BaseRefName: "main",
URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n),
}, nil
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
createdPRs = prNumbers
return 42, nil
},
}

cmd := LinkCmd(cfg)
cmd.SetArgs([]string{"10", "https://github.com/o/r/pull/20", "30"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)

assert.NoError(t, err)
assert.Equal(t, []int{10, 20, 30}, createdPRs)
assert.Contains(t, output, "Created stack with 3 PRs")
}
2 changes: 1 addition & 1 deletion docs/src/content/docs/introduction/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ While the PR UI provides the review and merge experience, the `gh stack` CLI han
- **Syncing everything** — `gh stack sync` fetches, rebases, pushes, and updates PR state in one command.
- **Restructuring stacks** — `gh stack modify` opens an interactive terminal UI to drop, fold, insert, rename, and reorder branches in a stack.
- **Tearing down stacks** — `gh stack unstack` removes a stack from GitHub and local tracking.
- **Checking out a stack** — `gh stack checkout <pr-number>` pulls down a stack, with all its branches, from GitHub to your local machine.
- **Checking out a stack** — `gh stack checkout <pr-number|url>` pulls down a stack, with all its branches, from GitHub to your local machine.

The CLI is not required to use Stacked PRs — the underlying git operations are standard. But it makes the workflow simpler, and you can create Stacked PRs from the CLI instead of the UI.

Expand Down
14 changes: 10 additions & 4 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,13 @@ gh stack view --json

### `gh stack checkout`

Check out a stack from a pull request number or branch name.
Check out a stack from a pull request number, URL, or branch name.

```sh
gh stack checkout [<pr-number> | <branch>]
gh stack checkout [<pr-number> | <pr-url> | <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.
When a PR number or URL is provided (e.g., `123` or `https://github.com/owner/repo/pull/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.

Expand All @@ -153,6 +153,9 @@ When run without arguments in an interactive terminal, shows a menu of all local
# Check out a stack by PR number
gh stack checkout 42

# Check out a stack by PR URL
gh stack checkout https://github.com/owner/repo/pull/42

# Check out a stack by branch name (local only)
gh stack checkout feature-auth

Expand Down Expand Up @@ -391,7 +394,7 @@ Link PRs into a stack on GitHub without local tracking.
gh stack link [flags] <branch-or-pr> <branch-or-pr> [...]
```

Creates or updates a stack on GitHub from branch names or PR numbers. This command does not create or modify any `gh-stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs.
Creates or updates a stack on GitHub from branch names or PR numbers/URLs. This command does not create or modify any `gh-stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs.

Arguments are provided in stack order (bottom to top). Branch arguments are automatically pushed to the remote before creating or looking up PRs. For branches that already have open PRs, those PRs are used. For branches without PRs, new PRs are created automatically with the correct base branch chaining. Existing PRs whose base branch doesn't match the expected chain are corrected automatically.

Expand All @@ -412,6 +415,9 @@ gh stack link feature-auth feature-api feature-ui
# Link existing PRs by number
gh stack link 10 20 30

# Link existing PRs by URL
gh stack link https://github.com/owner/repo/pull/10 https://github.com/owner/repo/pull/20

# Add branches to an existing stack of PRs
gh stack link 42 43 feature-auth feature-ui

Expand Down
Loading