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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,37 @@ gh extension install github/gh-stack

Requires the [GitHub CLI](https://cli.github.com/) (`gh`) v2.0+ and Git 2.36+.

### Updating

```sh
gh extension upgrade stack
```

Official, unpinned stable release installations check for a newer
[latest release](https://github.com/github/gh-stack/releases/latest) in the
background, caching successful checks for 24 hours. When an update is available,
commands can append an upgrade notice to stderr after success or an operational failure,
also at most once every 24 hours. On failure, the original error appears first
and the exit code is unchanged. This includes non-interactive use; stdout and
JSON output are unchanged.
The check and reminder timestamps are shared across repositories for your user.
They are stored as YAML in `gh-stack/state.yml` beneath GitHub CLI's state
directory (`~/.local/state/gh` by default on macOS/Linux).

Commands never wait for a release check. Short commands may finish before a
notice is ready. Failed or interrupted attempts can retry after a 15-minute
cooldown instead of waiting a full day. Completed checks are cached so a later
command can show the notice.
No upgrades happen automatically, and development, locally linked, prerelease,
and pinned installations are excluded. Help, version, and completion commands
do not run the notifier.
Usage errors and explicit user cancellations do not show upgrade notices.

Set `GH_STACK_NO_UPDATE_NOTIFIER=1` to disable these checks and notices, including
in CI or scripts. Any non-empty value disables the notifier. Optional check
failures do not affect command exit codes; set `GH_DEBUG=1` for diagnostics.
Install a release containing this feature to receive notices for future releases.

## AI agent integration

Install the gh-stack skill so your AI coding agent knows how to work with stacked PRs and the `gh stack` CLI:
Expand Down
2 changes: 2 additions & 0 deletions cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ func handleCompositionConflict(

default:
// Cancel
cfg.Canceled = true
cfg.Infof("Checkout cancelled")
return nil, ErrSilent
}
Expand Down Expand Up @@ -805,6 +806,7 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string)
}
if !ok {
// The user dismissed the picker without selecting.
cfg.Canceled = true
return nil, "", nil
}

Expand Down
2 changes: 2 additions & 0 deletions cmd/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,10 +364,12 @@ func runMergeInteractive(cfg *config.Config, client github.ClientOps, stackNumbe
cfg.Printf("Stack merges are atomic, so nothing was merged.")
return mergeFailureExit(out.Message)
case out.WatchStopped:
cfg.Canceled = true
cfg.Infof("Stopped watching. Merge is still in progress. Check the pull requests on GitHub.")
return ErrSilent
default:
// Cancelled via esc/ctrl+c before submitting.
cfg.Canceled = true
cfg.Infof("Cancelled operation, nothing merged")
return ErrSilent
}
Expand Down
7 changes: 2 additions & 5 deletions cmd/modify.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,8 @@ func runModify(cfg *config.Config) error {
}

// Handle TUI result
if m.Cancelled() {
return nil
}

if !m.ApplyRequested() {
if m.Cancelled() || !m.ApplyRequested() {
cfg.Canceled = true
return nil
}

Expand Down
92 changes: 80 additions & 12 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
package cmd

import (
"context"
"errors"
"fmt"
"os"

"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/theme"
"github.com/github/gh-stack/internal/update"
"github.com/spf13/cobra"
)

type updateResult struct {
notify update.Notification
err error
}

type rootCommand struct {
*cobra.Command
finish func(error)
}

func RootCmd() *cobra.Command {
cfg := config.New()
return newRootCmd(config.New(), startUpdateCheck).Command
}

func newRootCmd(cfg *config.Config, startCheck func(context.Context, string) <-chan updateResult) *rootCommand {
root := &cobra.Command{
Use: "stack <command>",
Short: "Manage stacked branches and pull requests",
Expand All @@ -36,10 +50,35 @@ locally, then push to GitHub to create your stack of PRs.`,
Version: Version,
SilenceUsage: true,
SilenceErrors: true,
// Honor GH_STACK_THEME (auto|light|dark) before any command renders
PersistentPreRun: func(_ *cobra.Command, _ []string) {
theme.ApplyOverride()
},
}

var updates <-chan updateResult
root.PersistentPreRun = func(cmd *cobra.Command, _ []string) {
theme.ApplyOverride()
cfg.Canceled = false
updates = nil
if update.Enabled(root.Version) && !isHelpOrCompletionCommand(cmd) {
updates = startCheck(cmd.Context(), root.Version)
}
}
finish := func(err error) {
results := updates
updates = nil
if cfg.Canceled || errors.Is(err, ErrInvalidArgs) ||
errors.Is(err, context.Canceled) || errors.Is(err, errInterrupt) || isInterruptError(err) {
return
}
select {
case result := <-results:
if result.notify != nil {
result.err = errors.Join(result.err, result.notify(root.ErrOrStderr()))
}
if result.err != nil && os.Getenv("GH_DEBUG") != "" {
fmt.Fprintf(root.ErrOrStderr(), "debug: gh-stack update notification: %v\n", result.err)
}
default:
// Never wait for a release check if the command finishes quickly
}
}

root.SetVersionTemplate("gh stack version {{.Version}}\n")
Expand Down Expand Up @@ -150,23 +189,52 @@ locally, then push to GitHub to create your stack of PRs.`,
feedbackCmd.GroupID = "utils"
root.AddCommand(feedbackCmd)

return root
return &rootCommand{Command: root, finish: finish}
}

func Execute() {
cmd := RootCmd()
func startUpdateCheck(ctx context.Context, version string) <-chan updateResult {
results := make(chan updateResult, 1)
go func() {
notify, err := update.Check(ctx, version)
results <- updateResult{notify: notify, err: err}
}()
return results
}

func isHelpOrCompletionCommand(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
switch c.Name() {
case "help", "completion", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd:
return true
}
}
return false
}

func execute(cmd *rootCommand, args []string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Wrap in a "gh" parent so help output shows "gh stack" instead of just "stack".
wrapCmd := &cobra.Command{Use: "gh", SilenceUsage: true, SilenceErrors: true}
wrapCmd.AddCommand(cmd)
wrapCmd.SetArgs(append([]string{"stack"}, os.Args[1:]...))
wrapCmd.AddCommand(cmd.Command)
wrapCmd.SetArgs(append([]string{"stack"}, args...))
err := wrapCmd.ExecuteContext(ctx)
var exitErr *ExitError
if err != nil && !errors.As(err, &exitErr) {
fmt.Fprintln(cmd.ErrOrStderr(), err)
}
cmd.finish(err)
return err
}

if err := wrapCmd.Execute(); err != nil {
func Execute() {
cmd := newRootCmd(config.New(), startUpdateCheck)
if err := execute(cmd, os.Args[1:]); err != nil {
var exitErr *ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.Code)
}
fmt.Fprintln(cmd.ErrOrStderr(), err)
os.Exit(1)
}
}
Loading
Loading