diff --git a/cmd/add.go b/cmd/add.go index 0fdd1ab..2ced489 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -30,6 +30,17 @@ When -m is omitted but -A or -u is used, your editor opens for the commit message. When -m is provided without an explicit branch name, the branch name is auto-generated based on the commit message and stack prefix.`, + Example: ` # Add a new named branch to the stack + $ gh stack add my-feature + + # Add a branch and commit staged changes + $ gh stack add -Am "Add user authentication" my-feature + + # Auto-generate branch name from the commit message + $ gh stack add -m "Fix login bug" + + # Add a branch and open editor to write commit message + $ gh stack add -A my-feature`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runAdd(cfg, opts, args) diff --git a/cmd/alias.go b/cmd/alias.go index a3ecf8f..cb82765 100644 --- a/cmd/alias.go +++ b/cmd/alias.go @@ -32,6 +32,14 @@ func AliasCmd(cfg *config.Config) *cobra.Command { This installs a small wrapper script into ~/.local/bin/ that forwards all arguments to "gh stack". The default alias name is "gs", but you can choose any name by passing it as an argument.`, + Example: ` # Create the default 'gs' alias + $ gh stack alias + + # Create a custom alias + $ gh stack alias gst + + # Remove alias + $ gh stack alias --remove`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := defaultAliasName diff --git a/cmd/checkout.go b/cmd/checkout.go index d66087c..31537ac 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -38,6 +38,14 @@ 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 + $ gh stack checkout 42 + + # Check out a stack by branch name + $ gh stack checkout feat/api-routes + + # Show a menu of all locally tracked stacks + $ gh stack checkout`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -114,6 +122,8 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { cfg.Successf("Switched to %s", targetBranch) cfg.Printf("Stack: %s", s.DisplayChain()) + cfg.Printf("Run `%s` to see the full stack", + cfg.ColorCyan("gh stack view")) return nil } diff --git a/cmd/feedback.go b/cmd/feedback.go index f1e88b3..b55c056 100644 --- a/cmd/feedback.go +++ b/cmd/feedback.go @@ -19,6 +19,11 @@ func FeedbackCmd(cfg *config.Config) *cobra.Command { Use: "feedback [title]", Short: "Submit feedback for gh-stack", Long: "Opens a GitHub Discussion in the gh-stack repository to submit feedback. Optionally provide a title for the discussion post.", + Example: ` # Open the feedback form in your browser + $ gh stack feedback + + # Open with a pre-filled title + $ gh stack feedback "My feature request"`, RunE: func(cmd *cobra.Command, args []string) error { return runFeedback(cfg, args) }, diff --git a/cmd/link.go b/cmd/link.go index 93bb749..1367e51 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -45,6 +45,14 @@ automatically with the correct base branch chaining. If the PRs are not yet in a stack, a new stack is created. If some of the PRs are already in a stack, the existing stack is updated to include the new PRs (existing PRs are never removed).`, + Example: ` # Link branches into a stack (bottom to top) + $ gh stack link auth-layer api-routes ui-components + + # Link existing PRs by number + $ gh stack link 41 42 43 + + # Specify a custom base branch for stack + $ gh stack link --base develop auth-layer api-routes`, Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { return runLink(cfg, opts, args) diff --git a/cmd/modify.go b/cmd/modify.go index 6c25c96..4e1456c 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -34,6 +34,14 @@ Operations available: All changes are staged in the TUI and applied together when you press Ctrl+S. After applying, run 'gh stack submit' to push changes and recreate the stack on GitHub.`, + Example: ` # Open the interactive TUI to restructure the stack + $ gh stack modify + + # Abort a modify session and restore the stack + $ gh stack modify --abort + + # Continue after resolving conflicts from a modify + $ gh stack modify --continue`, RunE: func(cmd *cobra.Command, args []string) error { if opts.abort { return runModifyAbort(cfg) @@ -202,9 +210,9 @@ func runModifyAbort(cfg *config.Config) error { if err := modify.UnwindFromStateFile(cfg, gitDir); err != nil { cfg.Errorf("recovery failed: %s", err) cfg.Printf("The stack may be in an inconsistent state.") - cfg.Printf("Try `%s` to fix, or `%s` + `%s` to recreate.", - cfg.ColorCyan("gh stack rebase"), cfg.ColorCyan("gh stack unstack --local"), - cfg.ColorCyan("gh stack init --adopt")) + cfg.Printf("Try `%s` to fix, or `%s` + `%s` to recreate.", + cfg.ColorCyan("gh stack rebase"), cfg.ColorCyan("gh stack unstack --local"), + cfg.ColorCyan("gh stack init --adopt")) return ErrSilent } cfg.Successf("Stack restored successfully") diff --git a/cmd/navigate.go b/cmd/navigate.go index aad9c7b..fe0fe53 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -12,7 +12,14 @@ func UpCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "up [n]", Short: "Check out a branch further up in the stack (further from the trunk)", - Args: cobra.MaximumNArgs(1), + Long: `Check out a branch further up in the stack (further from the trunk). +Merged branches are automatically skipped.`, + Example: ` # Move one branch up + $ gh stack up + + # Move three branches up + $ gh stack up 3`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { n := 1 if len(args) > 0 { @@ -32,7 +39,14 @@ func DownCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "down [n]", Short: "Check out a branch further down in the stack (closer to the trunk)", - Args: cobra.MaximumNArgs(1), + Long: `Check out a branch further down in the stack (closer to the trunk). +Merged branches are automatically skipped.`, + Example: ` # Move one branch down + $ gh stack down + + # Move two branches down + $ gh stack down 2`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { n := 1 if len(args) > 0 { @@ -52,6 +66,10 @@ func TopCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "top", Short: "Check out the top branch of the stack (furthest from the trunk)", + Long: `Check out the top branch of the stack (furthest from the trunk). +Merged branches are automatically skipped.`, + Example: ` # Jump to the top of the stack + $ gh stack top`, RunE: func(cmd *cobra.Command, args []string) error { return runNavigateToEnd(cfg, true) }, @@ -62,6 +80,10 @@ func BottomCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "bottom", Short: "Check out the bottom branch of the stack (closest to the trunk)", + Long: `Check out the bottom branch of the stack (closest to the trunk). +Merged branches are automatically skipped.`, + Example: ` # Jump to the bottom of the stack + $ gh stack bottom`, RunE: func(cmd *cobra.Command, args []string) error { return runNavigateToEnd(cfg, false) }, diff --git a/cmd/push.go b/cmd/push.go index 40c63e7..2d6d088 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -22,6 +22,16 @@ func PushCmd(cfg *config.Config) *cobra.Command { cmd := &cobra.Command{ Use: "push", Short: "Push all branches in the current stack to the remote", + Long: `Push all branches in the current stack to the remote. + +Uses --force-with-lease and --atomic to ensure safe, all-or-nothing pushes. +Merged and queued branches are automatically skipped. This command is safe to +run repeatedly — it will only update branches that have changed.`, + Example: ` # Push all stack branches to the default remote + $ gh stack push + + # Push to a specific remote + $ gh stack push --remote upstream`, RunE: func(cmd *cobra.Command, args []string) error { return runPush(cfg, opts) }, @@ -123,6 +133,8 @@ func runPush(cfg *config.Config, opts *pushOptions) error { if hasBranchWithoutPR { cfg.Printf("To create PRs for this stack, run `%s`", cfg.ColorCyan("gh stack submit")) + } else { + cfg.Printf("Run `%s` to see your stack of PRs", cfg.ColorCyan("gh stack view")) } return nil } diff --git a/cmd/rebase.go b/cmd/rebase.go index 4e16158..b6bcb9e 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -46,6 +46,20 @@ func RebaseCmd(cfg *config.Config) *cobra.Command { Ensures that each branch in the stack has the tip of the previous layer in its commit history, rebasing if necessary.`, + Example: ` # Rebase the entire stack + $ gh stack rebase + + # Only rebase from trunk to the current branch + $ gh stack rebase --downstack + + # Only rebase from current branch to the top + $ gh stack rebase --upstack + + # Continue after resolving conflicts + $ gh stack rebase --continue + + # Abort and restore all branches + $ gh stack rebase --abort`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { diff --git a/cmd/root.go b/cmd/root.go index c566aba..217016d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,9 +13,25 @@ func RootCmd() *cobra.Command { cfg := config.New() root := &cobra.Command{ - Use: "stack ", - Short: "Manage stacked branches and pull requests", - Long: "Create, navigate, and manage stacks of branches and pull requests.", + Use: "stack ", + Short: "Manage stacked branches and pull requests", + Long: `Stacked PRs let you break a large change into a chain of pull requests +that build on each other. Use ` + "`gh stack`" + ` to create and manage your stack +locally, then push to GitHub to create your stack of PRs.`, + Example: ` # Start a new stack targeting your default branch + $ gh stack init + + # Or turn an existing set of branches into a stack + $ gh stack init --adopt branch1 branch2 branch3 + + # Make changes and commit, then add a branch to the stack + $ gh stack add branch4 + + # Push all branches and create/update PRs on GitHub + $ gh stack submit + + # Keep your local in sync with remote + $ gh stack sync`, Version: Version, SilenceUsage: true, SilenceErrors: true, @@ -26,36 +42,104 @@ func RootCmd() *cobra.Command { root.SetOut(cfg.Out) root.SetErr(cfg.Err) - // Local operations - root.AddCommand(InitCmd(cfg)) - root.AddCommand(AddCmd(cfg)) + root.AddGroup( + &cobra.Group{ID: "stack", Title: "Stack management:"}, + &cobra.Group{ID: "remote", Title: "Remote operations:"}, + &cobra.Group{ID: "nav", Title: "Navigation:"}, + &cobra.Group{ID: "utils", Title: "Utilities:"}, + ) + + defaultHelp := root.HelpFunc() + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + defaultHelp(cmd, args) + if cmd.Name() == "stack" { + out := cmd.OutOrStderr() + fmt.Fprintln(out) + fmt.Fprintln(out, "Learn more:") + fmt.Fprintln(out, " Documentation: https://gh.io/stacks") + fmt.Fprintln(out, " Feedback: https://gh.io/stacks-feedback") + } + }) - // Remote operations - root.AddCommand(CheckoutCmd(cfg)) - root.AddCommand(PushCmd(cfg)) - root.AddCommand(SubmitCmd(cfg)) - root.AddCommand(SyncCmd(cfg)) - root.AddCommand(UnstackCmd(cfg)) - root.AddCommand(MergeCmd(cfg)) - root.AddCommand(LinkCmd(cfg)) + // Stack management commands + initCmd := InitCmd(cfg) + initCmd.GroupID = "stack" + root.AddCommand(initCmd) - // Helper commands - root.AddCommand(ViewCmd(cfg)) - root.AddCommand(RebaseCmd(cfg)) - root.AddCommand(ModifyCmd(cfg)) + addCmd := AddCmd(cfg) + addCmd.GroupID = "stack" + root.AddCommand(addCmd) - // Navigation commands - root.AddCommand(UpCmd(cfg)) - root.AddCommand(DownCmd(cfg)) - root.AddCommand(TopCmd(cfg)) - root.AddCommand(BottomCmd(cfg)) - root.AddCommand(SwitchCmd(cfg)) + viewCmd := ViewCmd(cfg) + viewCmd.GroupID = "stack" + root.AddCommand(viewCmd) + + checkoutCmd := CheckoutCmd(cfg) + checkoutCmd.GroupID = "stack" + root.AddCommand(checkoutCmd) + + modifyCmd := ModifyCmd(cfg) + modifyCmd.GroupID = "stack" + root.AddCommand(modifyCmd) - // Alias - root.AddCommand(AliasCmd(cfg)) + unstackCmd := UnstackCmd(cfg) + unstackCmd.GroupID = "stack" + root.AddCommand(unstackCmd) - // Feedback - root.AddCommand(FeedbackCmd(cfg)) + // Remote operations commands + submitCmd := SubmitCmd(cfg) + submitCmd.GroupID = "remote" + root.AddCommand(submitCmd) + + syncCmd := SyncCmd(cfg) + syncCmd.GroupID = "remote" + root.AddCommand(syncCmd) + + rebaseCmd := RebaseCmd(cfg) + rebaseCmd.GroupID = "remote" + root.AddCommand(rebaseCmd) + + pushCmd := PushCmd(cfg) + pushCmd.GroupID = "remote" + root.AddCommand(pushCmd) + + linkCmd := LinkCmd(cfg) + linkCmd.GroupID = "remote" + root.AddCommand(linkCmd) + + mergeCmd := MergeCmd(cfg) + mergeCmd.GroupID = "remote" + root.AddCommand(mergeCmd) + + // Navigation commands + switchCmd := SwitchCmd(cfg) + switchCmd.GroupID = "nav" + root.AddCommand(switchCmd) + + upCmd := UpCmd(cfg) + upCmd.GroupID = "nav" + root.AddCommand(upCmd) + + downCmd := DownCmd(cfg) + downCmd.GroupID = "nav" + root.AddCommand(downCmd) + + topCmd := TopCmd(cfg) + topCmd.GroupID = "nav" + root.AddCommand(topCmd) + + bottomCmd := BottomCmd(cfg) + bottomCmd.GroupID = "nav" + root.AddCommand(bottomCmd) + + // Utility commands + aliasCmd := AliasCmd(cfg) + aliasCmd.GroupID = "utils" + root.AddCommand(aliasCmd) + + feedbackCmd := FeedbackCmd(cfg) + feedbackCmd.GroupID = "utils" + root.AddCommand(feedbackCmd) return root } diff --git a/cmd/root_test.go b/cmd/root_test.go index b90409f..25a68a7 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,9 +1,11 @@ package cmd import ( + "bytes" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRootCmd_SubcommandRegistration(t *testing.T) { @@ -19,3 +21,21 @@ func TestRootCmd_SubcommandRegistration(t *testing.T) { assert.True(t, registered[name], "expected subcommand %q to be registered", name) } } + +func TestRootCmd_HelpOutput(t *testing.T) { + root := RootCmd() + var stdout bytes.Buffer + var stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"--help"}) + + err := root.Execute() + require.NoError(t, err) + + output := stdout.String() + stderr.String() + assert.Contains(t, output, "Stacked PRs") + assert.Contains(t, output, "Stack management:") + assert.Contains(t, output, "Learn more:") + assert.Contains(t, output, "https://gh.io/stacks") +} diff --git a/cmd/submit.go b/cmd/submit.go index 3c8397e..86e02ed 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -29,6 +29,24 @@ func SubmitCmd(cfg *config.Config) *cobra.Command { cmd := &cobra.Command{ Use: "submit", Short: "Create a stack of PRs on GitHub", + Long: `Push all branches and create or update a stack of PRs on GitHub. + +This command performs several steps: + 1. Pushes all branches to the remote + 2. Creates new PRs for branches that don't have one + 3. Updates base branches for existing PRs + 4. Creates or updates the stack on GitHub + +New PRs are created as drafts by default. Use --open to mark them as ready +for review.`, + Example: ` # Push and create/update PRs (prompts for PR titles) + $ gh stack submit + + # Use auto-generated PR titles without prompting + $ gh stack submit --auto + + # Mark all PRs as ready for review + $ gh stack submit --open`, RunE: func(cmd *cobra.Command, args []string) error { return runSubmit(cfg, opts) }, diff --git a/cmd/switch.go b/cmd/switch.go index 3e7cbcd..4e3c9c1 100644 --- a/cmd/switch.go +++ b/cmd/switch.go @@ -13,11 +13,17 @@ func SwitchCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "switch", Short: "Interactively switch to another branch in the stack", - Long: `Show an interactive picker listing all branches in the current -stack and switch to the selected one. + Long: `Show an interactive picker listing all branches in the current stack +and switch to the selected one. -Branches are displayed from top (furthest from trunk) to bottom -(closest to trunk) with their position number.`, +Branches are displayed from top (furthest from trunk) to bottom (closest to +trunk) with their position number. Use the arrow keys to navigate and Enter +to select. + +To move one branch up or down without an interactive picker, use +'gh stack up' or 'gh stack down' instead.`, + Example: ` # Open the branch picker for the current stack + $ gh stack switch`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runSwitch(cfg) diff --git a/cmd/unstack.go b/cmd/unstack.go index 67762fc..3bdf40e 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -22,7 +22,12 @@ func UnstackCmd(cfg *config.Config) *cobra.Command { Aliases: []string{"delete"}, Short: "Delete a stack locally and on GitHub", Long: "Remove the current active stack from local tracking and delete it on GitHub. Use --local to only remove local tracking.", - Args: cobra.NoArgs, + Example: ` # Delete the stack locally and on GitHub + $ gh stack unstack + + # Only remove local tracking (keep the stack on GitHub) + $ gh stack unstack --local`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runUnstack(cfg, opts) }, diff --git a/cmd/view.go b/cmd/view.go index 5cfe8f8..2613f05 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -29,6 +29,24 @@ func ViewCmd(cfg *config.Config) *cobra.Command { cmd := &cobra.Command{ Use: "view", Short: "View the current stack", + Long: `View the current stack as a list showing branches and PR status. + +Status icons: + ✓ PR merged + ◎ PR queued + ○ PR open + ⚠ Needs rebase + +The current branch is highlighted. Use --short for a compact one-line-per-branch +view, or --json for machine-readable output.`, + Example: ` # Show the stack (default interactive view) + $ gh stack view + + # Show compact output + $ gh stack view --short + + # Output as JSON + $ gh stack view --json`, RunE: func(cmd *cobra.Command, args []string) error { return runView(cfg, opts) }, diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index d3d40b7..f2bac85 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -223,6 +223,34 @@ gh stack modify --continue gh stack modify --abort ``` +### `gh stack unstack` + +Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. + +```sh +gh stack unstack [flags] +``` + +You must have a branch from the stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch. + +Deletes the stack on GitHub first, if it exists, then removes it from local tracking. If the remote deletion fails, the local state is left untouched so you can retry. Use `--local` to skip the remote deletion and only remove local tracking. + +This is useful when you need to restructure a stack — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init --adopt` to re-create the stack with the desired structure. + +| Flag | Description | +|------|-------------| +| `--local` | Only delete the stack locally (keep it on GitHub) | + +**Examples:** + +```sh +# Delete the stack on GitHub and remove local tracking +gh stack unstack + +# Only remove local tracking +gh stack unstack --local +``` + --- ## Remote Operations @@ -345,34 +373,6 @@ gh stack push gh stack push --remote upstream ``` -### `gh stack unstack` - -Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. - -```sh -gh stack unstack [flags] -``` - -You must have a branch from the stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch. - -Deletes the stack on GitHub first, if it exists, then removes it from local tracking. If the remote deletion fails, the local state is left untouched so you can retry. Use `--local` to skip the remote deletion and only remove local tracking. - -This is useful when you need to restructure a stack — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init --adopt` to re-create the stack with the desired structure. - -| Flag | Description | -|------|-------------| -| `--local` | Only delete the stack locally (keep it on GitHub) | - -**Examples:** - -```sh -# Delete the stack on GitHub and remove local tracking -gh stack unstack - -# Only remove local tracking -gh stack unstack --local -``` - ### `gh stack link` Link PRs into a stack on GitHub without local tracking. @@ -417,6 +417,30 @@ Move between branches in the current stack without having to remember branch nam All navigation commands clamp to the bounds of the stack — moving up from the top or down from the bottom is a no-op with a message. +### `gh stack switch` + +Interactively switch to another branch in the stack. + +```sh +gh stack switch +``` + +Shows an interactive picker listing all branches in the current stack, ordered from top (furthest from trunk) to bottom (closest to trunk) with their position number. Select a branch to check it out. + +Requires an interactive terminal. + +**Examples:** + +```sh +gh stack switch +# → Select a branch in the stack to switch to +# 5. frontend +# 4. api-endpoints +# 3. auth-layer +# 2. db-schema +# 1. config-setup +``` + ### `gh stack up` Move up toward the top of the stack (away from trunk). @@ -471,30 +495,6 @@ gh stack bottom Checks out the branch closest to the trunk. -### `gh stack switch` - -Interactively switch to another branch in the stack. - -```sh -gh stack switch -``` - -Shows an interactive picker listing all branches in the current stack, ordered from top (furthest from trunk) to bottom (closest to trunk) with their position number. Select a branch to check it out. - -Requires an interactive terminal. - -**Examples:** - -```sh -gh stack switch -# → Select a branch in the stack to switch to -# 5. frontend -# 4. api-endpoints -# 3. auth-layer -# 2. db-schema -# 1. config-setup -``` - --- ## Utilities