Skip to content
Merged
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: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,9 +557,17 @@ gh stack view --short
gh stack view --json
```

`gh stack view --short` uses OSC 8 hyperlinks for PR numbers when the terminal
supports them. Otherwise, the full URL is shown for copy/paste. Set
`GH_STACK_HYPERLINKS=1` or `GH_STACK_HYPERLINKS=0` to override terminal detection.
Stack references follow the existing PR-link behavior in text output: OSC 8
hyperlinks when supported, or the full URL for copy/paste otherwise. Stack links open
the repository's `/stack/<number>` page; confirmed missing or removed stacks
are left unlinked. Set `GH_STACK_HYPERLINKS=1` or `GH_STACK_HYPERLINKS=0` to
override terminal detection.

TUIs keep labels compact: click `#N` in `view` or `submit` to
open its page, or use your terminal's hyperlink gesture in the `checkout`
picker and `merge` wizard. Those inline views keep plain numbers when terminal
hyperlinks are unavailable. The environment override controls OSC 8 links, not
the application-handled clicks in `view` and `submit`. JSON output is unchanged.

### `gh stack unstack`

Expand Down
49 changes: 35 additions & 14 deletions cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ func checkoutRemoteStackByBranch(cfg *config.Config, sf *stack.StackFile, gitDir
if len(matches) > 1 {
stackNumbers := make([]string, len(matches))
for i, match := range matches {
stackNumbers[i] = strconv.Itoa(match.Number)
stackNumbers[i] = cfg.Link(strconv.Itoa(match.Number), cfg.StackURL(match.Number))
}
cfg.Errorf("branch %q belongs to multiple stacks on GitHub (%s)",
branch, strings.Join(stackNumbers, ", "))
Expand Down Expand Up @@ -384,9 +384,10 @@ func checkoutStackByNumber(cfg *config.Config, sf *stack.StackFile, gitDir strin
}

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.
if err != nil {
return nil, "", errors.Join(errStackNumberNotFound, err)
}
if remoteStack == nil || len(remoteStack.PullRequests) == 0 {
return nil, "", errStackNumberNotFound
}

Expand Down Expand Up @@ -474,7 +475,7 @@ func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps,
if err := stack.Save(gitDir, sf); err != nil {
return nil, "", stackSaveError(cfg, err)
}
cfg.Successf("Local stack matches remote%s", stackLabel(remoteStack.Number))
cfg.Successf("Local stack matches remote%s", linkedStackLabel(cfg, remoteStack.Number))
return localStack, targetBranch, nil
}

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

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

Expand Down Expand Up @@ -748,7 +749,7 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string)
return nil, "", fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack")
}

rows, remoteStacks := gatherCheckoutRows(cfg, sf)
rows, remoteStacks, links := gatherCheckoutRows(cfg, sf)
if currentBranch, branchErr := git.CurrentBranch(); branchErr == nil {
stackNumber, confirmed, confirmErr := offerRemoteStackForBranch(cfg, sf, remoteStacks, currentBranch)
if confirmErr != nil {
Expand All @@ -772,7 +773,7 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string)
return nil, "", nil
}

selected, ok, err := launchCheckoutPicker(rows)
selected, ok, err := launchCheckoutPicker(rows, links)
if err != nil {
return nil, "", err
}
Expand All @@ -789,14 +790,29 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string)
// with the local stacks into the picker's rows. Any GitHub failure (stacks not
// enabled for the repo, no auth, network error) gracefully degrades to a
// local-only list.
func gatherCheckoutRows(cfg *config.Config, sf *stack.StackFile) ([]checkoutview.StackRow, []github.RemoteStack) {
func gatherCheckoutRows(cfg *config.Config, sf *stack.StackFile) ([]checkoutview.StackRow, []github.RemoteStack, map[int]string) {
var remote []github.RemoteStack
var remoteNumbers map[int]bool // nil means the remote listing was unavailable
if client, err := cfg.GitHubClient(); err == nil {
if stacks, err := client.ListStacks(); err == nil {
remote = stacks
remoteNumbers = make(map[int]bool, len(stacks))
for _, s := range stacks {
remoteNumbers[s.Number] = true
}
}
}
return checkoutview.BuildRows(sf.Stacks, remote), remote
rows := checkoutview.BuildRows(sf.Stacks, remote)
links := make(map[int]string)
if repo, err := cfg.Repo(); err == nil && repo.Owner != "" && repo.Name != "" {
for _, row := range rows {
if row.Number > 0 && (remoteNumbers == nil || remoteNumbers[row.Number]) {
url := github.StackURL(repo.Host, repo.Owner, repo.Name, row.Number)
links[row.Number] = cfg.TerminalLink(row.NumberDisplay(), url)
}
}
}
return rows, remote, links
}

// offerRemoteStackForBranch asks to check out the unique active remote stack
Expand All @@ -814,7 +830,8 @@ func offerRemoteStackForBranch(cfg *config.Config, sf *stack.StackFile, remote [
}

stackNumber := matches[0].Number
prompt := fmt.Sprintf("Found stack #%d that includes branch %q. Check out stack #%d?", stackNumber, branch, stackNumber)
cfg.Infof("Found stack %s that includes branch %q.", cfg.StackLink(stackNumber), branch)
prompt := fmt.Sprintf("Check out stack #%d?", stackNumber)
confirmed, err := confirmRemoteStackCheckout(cfg, prompt)
if err != nil {
if errors.Is(err, errInterrupt) {
Expand Down Expand Up @@ -886,7 +903,11 @@ func resolveCheckoutSelection(cfg *config.Config, sf *stack.StackFile, gitDir st
s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, selected.Number)
if err != nil {
if errors.Is(err, errStackNumberNotFound) {
cfg.Errorf("stack #%d could not be loaded from GitHub", selected.Number)
label := fmt.Sprintf("#%d", selected.Number)
if !isNotFound(err) {
label = cfg.StackLink(selected.Number)
}
cfg.Errorf("stack %s could not be loaded from GitHub", label)
return nil, "", ErrAPIFailure
}
return nil, "", err
Expand All @@ -899,8 +920,8 @@ func resolveCheckoutSelection(cfg *config.Config, sf *stack.StackFile, gitDir st
// picker occupies only a few lines and leaves the surrounding terminal output
// intact. Mouse motion is intentionally not enabled so the search field never
// receives stray mouse bytes.
func launchCheckoutPicker(rows []checkoutview.StackRow) (checkoutview.StackRow, bool, error) {
p := tea.NewProgram(checkoutview.New(rows))
func launchCheckoutPicker(rows []checkoutview.StackRow, links map[int]string) (checkoutview.StackRow, bool, error) {
p := tea.NewProgram(checkoutview.New(rows).WithNumberLinks(links))
finalModel, err := p.Run()
if err != nil {
return checkoutview.StackRow{}, false, fmt.Errorf("running stack picker: %w", err)
Expand Down
80 changes: 50 additions & 30 deletions cmd/checkout_picker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func TestGatherCheckoutRows_FallbackToLocalOnListError(t *testing.T) {
},
}}}

rows, remote := gatherCheckoutRows(cfg, sf)
rows, remote, _ := gatherCheckoutRows(cfg, sf)
require.Len(t, rows, 1, "falls back to a local-only list when ListStacks fails")
assert.Empty(t, remote)
assert.Equal(t, checkoutview.TypeLocal, rows[0].Type)
Expand Down Expand Up @@ -70,7 +70,7 @@ func TestGatherCheckoutRows_IncludesRemoteOnlyStacks(t *testing.T) {
Branches: []stack.BranchRef{{Branch: "local-a"}},
}}}

rows, remote := gatherCheckoutRows(cfg, sf)
rows, remote, _ := gatherCheckoutRows(cfg, sf)
require.Len(t, rows, 2, "local and remote-only stacks are both listed")
require.Len(t, remote, 1)

Expand Down Expand Up @@ -173,23 +173,29 @@ func TestMatchingRemoteStacksForBranch(t *testing.T) {
}

func TestOfferRemoteStackForBranch_Confirmed(t *testing.T) {
cfg, _, _ := config.NewTestConfig()
cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) {
assert.Equal(t, `Found stack #7 that includes branch "feature". Check out stack #7?`, prompt)
assert.True(t, defaultValue)
return true, nil
for _, force := range []string{"0", "1"} {
t.Run(force, func(t *testing.T) {
t.Setenv("GH_STACK_HYPERLINKS", force)
cfg, outR, errR := config.NewTestConfig()
setTestRepo(cfg)
cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) {
assert.Equal(t, "Check out stack #7?", prompt)
assert.True(t, defaultValue)
return true, nil
}
number, confirmed, err := offerRemoteStackForBranch(cfg, &stack.StackFile{}, []github.RemoteStack{{
Number: 7,
PRDetails: []github.RemoteStackPR{
{Number: 10, State: "open", Head: github.RemoteStackPRHead{Ref: "feature"}},
},
}}, "feature")
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.True(t, confirmed)
assert.Equal(t, 7, number)
assert.Contains(t, output, "Found stack "+cfg.StackLink(7)+` that includes branch "feature".`)
})
}

number, confirmed, err := offerRemoteStackForBranch(cfg, &stack.StackFile{}, []github.RemoteStack{{
Number: 7,
PRDetails: []github.RemoteStackPR{
{Number: 10, State: "open", Head: github.RemoteStackPRHead{Ref: "feature"}},
},
}}, "feature")

require.NoError(t, err)
assert.True(t, confirmed)
assert.Equal(t, 7, number)
}

func TestOfferRemoteStackForBranch_FallsBack(t *testing.T) {
Expand Down Expand Up @@ -445,19 +451,33 @@ func TestResolveCheckoutSelection_RemoteRoutesToClone(t *testing.T) {
}

func TestResolveCheckoutSelection_RemoteLoadFailure(t *testing.T) {
cfg, _, _ := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
GetStackFn: func(n int) (*github.RemoteStack, error) {
return nil, errors.New("boom")
},
t.Setenv("GH_STACK_HYPERLINKS", "1")
for _, tt := range []struct {
name string
err error
link bool
}{
{"transient failure", errors.New("boom"), true},
{"deleted stack", &api.HTTPError{StatusCode: 404}, false},
} {
t.Run(tt.name, func(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
setTestRepo(cfg)
cfg.GitHubClientOverride = &github.MockClient{
GetStackFn: func(int) (*github.RemoteStack, error) { return nil, tt.err },
}
sel := checkoutview.StackRow{Type: checkoutview.TypeRemote, Number: 7}
_, _, err := resolveCheckoutSelection(cfg, &stack.StackFile{}, t.TempDir(), sel)
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrAPIFailure)
if tt.link {
assert.Contains(t, output, "\x1b]8;;https://github.com/o/r/stack/7\x1b\\#7\x1b]8;;\x1b\\")
} else {
assert.Contains(t, output, "stack #7 could not be loaded")
assert.NotContains(t, output, "/stack/")
}
})
}

sel := checkoutview.StackRow{Type: checkoutview.TypeRemote, Number: 7}
_, _, err := resolveCheckoutSelection(cfg, &stack.StackFile{}, t.TempDir(), sel)

var exitErr *ExitError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, ErrAPIFailure, err)
}

func TestTopUnmergedBranch(t *testing.T) {
Expand Down
Loading
Loading