Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
77 changes: 35 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ gh stack add auth-layer
gh stack add api-endpoints
# ... make commits ...

# Push all branches and create/update PRs
# Push all branches
gh stack push

# View the stack
gh stack view

# Open a stack of PRs
gh stack submit
```

## How it works
Expand All @@ -55,7 +58,7 @@ main (trunk)

The **bottom** of the stack is the branch closest to the trunk, and the **top** is the branch furthest from it. Each branch inherits from the one below it. Navigation commands (`up`, `down`, `top`, `bottom`) follow this model: `up` moves away from trunk, `down` moves toward it.

When you push, `gh stack` creates one PR per branch. Each PR's base is set to the branch below it in the stack, so reviewers see only the diff for that layer.
When you push, `gh stack` creates one PR per branch and links them together as a **Stack** on GitHub. Each PR's base is set to the branch below it in the stack, so reviewers see only the diff for that layer.
Comment thread
skarim marked this conversation as resolved.
Outdated

### Local tracking

Expand Down Expand Up @@ -253,84 +256,74 @@ gh stack sync

### `gh stack push`

Push all branches in the current stack and create or update pull requests.
Push all branches in the current stack to the remote.

```
gh stack push [flags]
```

Pushes every branch to the remote, then for each branch either creates a new PR (with the correct base branch) or updates the base of an existing PR if it has changed. Uses `--force-with-lease` by default to safely update rebased branches.

When creating new PRs, you will be prompted to enter a title for each one. Press Enter to accept the default (branch name), or use `--auto` to skip prompting entirely.
Pushes every branch to the remote using `--force-with-lease --atomic`. This is a lightweight wrapper around `git push` that knows about all branches in the stack. It does not create or update pull requests — use `gh stack submit` for that.

| Flag | Description |
|------|-------------|
| `--auto` | Use auto-generated PR titles without prompting |
| `--draft` | Create new PRs as drafts |
| `--skip-prs` | Push branches without creating or updating PRs |
| `--remote <name>` | Remote to push to (defaults to auto-detected remote) |

**Examples:**

```sh
gh stack push
gh stack push --auto
gh stack push --draft
gh stack push --skip-prs
gh stack push --remote upstream
```

### `gh stack view`
### `gh stack submit`

View the current stack.
Push all branches and create/update PRs and the stack on GitHub.

```
gh stack view [flags]
gh stack submit [flags]
```

Shows all branches in the stack, their ordering, PR links, and the most recent commit with a relative timestamp. Output is piped through a pager (respects `GIT_PAGER`, `PAGER`, or defaults to `less -R`).
Creates a Stacked PR for every branch in the stack, pushing branches to remote if needed.
Comment thread
skarim marked this conversation as resolved.
Outdated

After creating PRs, `submit` automatically creates a **Stack** on GitHub to link the PRs together. If the stack already exists on GitHub (e.g., from a previous submit), new PRs will be added to the top of the stack.

When creating new PRs, you will be prompted to enter a title for each one. Press Enter to accept the default (branch name), or use `--auto` to skip prompting entirely.

| Flag | Description |
|------|-------------|
| `-s, --short` | Compact output (branch names only) |
| `--json` | Output stack data as JSON |
| `--auto` | Use auto-generated PR titles without prompting |
| `--draft` | Create new PRs as drafts |
| `--remote <name>` | Remote to push to (defaults to auto-detected remote) |

**Examples:**

```sh
gh stack view
gh stack view --short
gh stack view --json
gh stack submit
gh stack submit --auto
gh stack submit --draft
```

### `gh stack unstack`
### `gh stack view`

Remove a stack from local tracking and optionally delete it on GitHub.
View the current stack.

```
gh stack unstack [branch] [flags]
gh stack view [flags]
```

If no branch is specified, uses the current branch to find the stack. By default, the stack is removed from both local tracking and GitHub. Use `--local` to only remove the local tracking entry.
Shows all branches in the stack, their ordering, PR links, and the most recent commit with a relative timestamp. Output is piped through a pager (respects `GIT_PAGER`, `PAGER`, or defaults to `less -R`).

| Flag | Description |
|------|-------------|
| `--local` | Only delete the stack locally (keep it on GitHub) |

| Argument | Description |
|----------|-------------|
| `[branch]` | A branch in the stack to delete (defaults to the current branch) |
| `-s, --short` | Compact output (branch names only) |
| `--json` | Output stack data as JSON |

**Examples:**

```sh
# Remove the stack from local tracking and GitHub
gh stack unstack

# Only remove local tracking
gh stack unstack --local

# Specify a branch to identify the stack
gh stack unstack feature-auth
gh stack view
gh stack view --short
gh stack view --json
```

### `gh stack merge`
Expand Down Expand Up @@ -399,8 +392,8 @@ gh stack add auth-middleware
gh stack add api-routes
# ... write code, make commits ...

# 4. Push everything and create PRs
gh stack push
# 4. Push everything and create Stacked PRs
gh stack submit

# 5. Reviewer requests changes on the first PR
gh stack bottom
Expand All @@ -409,7 +402,7 @@ gh stack bottom
# 6. Rebase the rest of the stack on top of your fix
gh stack rebase

# 7. Push the updated stack
# 7. Push the updated branches
gh stack push

# 8. When the first PR is merged, sync the stack
Expand Down Expand Up @@ -450,7 +443,7 @@ gh stack add -Am "Frontend components"
# → feat/02 already has commits, creates feat/03 and commits there

# 7. Push everything and create PRs
gh stack push
gh stack submit
```

Compared to the typical workflow, there's no need to name branches, run `git add`, or run `git commit` separately. Each `gh stack add -Am "..."` does it all.
Expand Down
2 changes: 1 addition & 1 deletion cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ func runInit(cfg *config.Config, opts *initOptions) error {
}

cfg.Printf("To add a new layer to your stack, run `%s`", cfg.ColorCyan("gh stack add"))
cfg.Printf("When you're ready to push to GitHub and open a stack of PRs, run `%s`", cfg.ColorCyan("gh stack push"))
cfg.Printf("When you're ready to push to GitHub and open a stack of PRs, run `%s`", cfg.ColorCyan("gh stack submit"))

return nil
}
2 changes: 1 addition & 1 deletion cmd/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func runMerge(cfg *config.Config, target string) error {

if br.PullRequest == nil {
cfg.Errorf("no pull request found for branch %q", br.Branch)
cfg.Printf(" Run %s to create PRs for this stack.", cfg.ColorCyan("gh stack push"))
cfg.Printf(" Run %s to create PRs for this stack.", cfg.ColorCyan("gh stack submit"))
return ErrSilent
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestMerge_NoPullRequest(t *testing.T) {

assert.ErrorIs(t, err, ErrSilent)
assert.Contains(t, output, "no pull request found")
assert.Contains(t, output, "gh stack push")
assert.Contains(t, output, "gh stack submit")
}

func TestMerge_AlreadyMerged(t *testing.T) {
Expand Down
150 changes: 15 additions & 135 deletions cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cmd
import (
"errors"
"fmt"
"strings"

"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/config"
Expand All @@ -13,26 +12,20 @@ import (
)

type pushOptions struct {
auto bool
draft bool
skipPRs bool
remote string
remote string
}

func PushCmd(cfg *config.Config) *cobra.Command {
opts := &pushOptions{}

cmd := &cobra.Command{
Use: "push",
Short: "Push all branches in the current stack and create/update PRs",
Short: "Push all branches in the current stack to the remote",
RunE: func(cmd *cobra.Command, args []string) error {
return runPush(cfg, opts)
},
}

cmd.Flags().BoolVar(&opts.auto, "auto", false, "Use auto-generated PR titles without prompting")
cmd.Flags().BoolVar(&opts.draft, "draft", false, "Create PRs as drafts")
cmd.Flags().BoolVar(&opts.skipPRs, "skip-prs", false, "Push branches without creating or updating PRs")
cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to push to (defaults to auto-detected remote)")

return cmd
Expand Down Expand Up @@ -70,12 +63,6 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
}
s := stacks[0]

client, err := cfg.GitHubClient()
if err != nil {
cfg.Errorf("failed to create GitHub client: %s", err)
return ErrAPIFailure
}

// Push all active branches atomically
remote, err := pickRemote(cfg, currentBranch, opts.remote)
if err != nil {
Expand All @@ -95,135 +82,28 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
return ErrSilent
}
Comment thread
skarim marked this conversation as resolved.

if opts.skipPRs {
cfg.Successf("Pushed %d branches (PR creation skipped)", len(s.ActiveBranches()))
return nil
}

// Create or update PRs
for i, b := range s.Branches {
if s.Branches[i].IsMerged() {
continue
}
baseBranch := s.ActiveBaseBranch(b.Branch)

pr, err := client.FindPRForBranch(b.Branch)
if err != nil {
cfg.Warningf("failed to check PR for %s: %v", b.Branch, err)
continue
}

if pr == nil {
// Create new PR — auto-generate title from commits/branch name,
// then prompt interactively unless --auto or non-interactive.
baseBranchForDiff := s.ActiveBaseBranch(b.Branch)
title, commitBody := defaultPRTitleBody(baseBranchForDiff, b.Branch)
originalTitle := title
if !opts.auto && cfg.IsInteractive() {
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title)
if err != nil {
if isInterruptError(err) {
printInterrupt(cfg)
return ErrSilent
}
// Non-interrupt error: keep the auto-generated title.
} else if input != "" {
title = input
}
}

// If the user changed the title and the commit had a multi-line
// message, put the full commit message in the PR body so no
// content is lost.
prBody := commitBody
if title != originalTitle && commitBody != "" {
prBody = originalTitle + "\n\n" + commitBody
}
body := generatePRBody(prBody)

newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, opts.draft)
if createErr != nil {
cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr)
continue
}
cfg.Successf("Created PR %s for %s", cfg.PRLink(newPR.Number, newPR.URL), b.Branch)
s.Branches[i].PullRequest = &stack.PullRequestRef{
Number: newPR.Number,
ID: newPR.ID,
URL: newPR.URL,
}
} else {
cfg.Printf("PR %s for %s is up to date", cfg.PRLink(pr.Number, pr.URL), b.Branch)
if s.Branches[i].PullRequest == nil {
s.Branches[i].PullRequest = &stack.PullRequestRef{
Number: pr.Number,
ID: pr.ID,
URL: pr.URL,
}
}
}
}

// TODO: Add PRs to a stack
//
// We can call an API after all the individual PRs are created/updated to create the stack at once,
// or we can add a flag to the existing PR API to incrementally build the stack.
//
// For now, the PRs are pushed and created individually but are NOT linked as a formal stack on GitHub.
cfg.Warningf("Stacked PRs is not yet implemented — PRs were created individually.")
fmt.Fprintf(cfg.Err, " Once the GitHub Stacks API is available, PRs will be automatically\n")
fmt.Fprintf(cfg.Err, " grouped into a Stack.\n")

// Update base commit hashes and sync PR state
// Update base commit hashes after push
updateBaseSHAs(s)
syncStackPRs(cfg, s)

if err := stack.Save(gitDir, sf); err != nil {
return handleSaveError(cfg, err)
}

cfg.Successf("Pushed and synced %d branches", len(s.ActiveBranches()))
return nil
}
cfg.Successf("Pushed %d branches", len(activeBranches))

// defaultPRTitleBody generates a PR title and body from the branch's commits.
// If there is exactly one commit, use its subject as the title and its body
// (if any) as the PR body. Otherwise, humanize the branch name for the title.
func defaultPRTitleBody(base, head string) (string, string) {
commits, err := git.LogRange(base, head)
if err == nil && len(commits) == 1 {
return commits[0].Subject, strings.TrimSpace(commits[0].Body)
// Hint about submit only if there are branches without PRs
hasBranchWithoutPR := false
for _, b := range s.ActiveBranches() {
if b.PullRequest == nil {
hasBranchWithoutPR = true
break
}
}
return humanize(head), ""
}

// generatePRBody builds a PR description from the commit body (if any)
// and a footer linking to the CLI and feedback form.
func generatePRBody(commitBody string) string {
var parts []string

if commitBody != "" {
parts = append(parts, commitBody)
if hasBranchWithoutPR {
cfg.Printf("To create PRs for this stack, run `%s`",
cfg.ColorCyan("gh stack submit"))
}

footer := fmt.Sprintf(
"<sub>Stack created with <a href=\"https://github.com/github/gh-stack\">GitHub Stacks CLI</a> • <a href=\"%s\">Give Feedback 💬</a></sub>",
feedbackBaseURL,
)
parts = append(parts, footer)

return strings.Join(parts, "\n\n---\n\n")
}

// humanize replaces hyphens and underscores with spaces.
func humanize(s string) string {
return strings.Map(func(r rune) rune {
if r == '-' || r == '_' {
return ' '
}
return r
}, s)
return nil
}

// pickRemote determines which remote to push to. If remoteOverride is
Expand Down
Loading
Loading