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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ internal/
schema.json # JSON Schema for the stack file format
config/ # Config struct (I/O, colors, test overrides)
testing.go # NewTestConfig(). Returns *Config + stdout/stderr pipes.
branch/ # branch naming (Slugify, DateSlug, NextNumberedName)
branch/ # branch naming (Slugify, DateSlug)
modify/ # interactive stack modification state machine
pr/ # PR template discovery
tui/ # bubbletea/bubbles/lipgloss terminal UI
Expand Down
35 changes: 11 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,15 @@ Initialize a new stack in the current repository.
gh stack init [flags] [branches...]
```

Initializes a new stack locally. In interactive mode (no arguments), prompts for a branch name and offers to use the current branch as the first layer. If a branch name contains slashes (e.g., `feat/api`), prompts if you would like to use a prefix (e.g., `feat/`) for all branches in the stack.
Initializes a new stack locally. In interactive mode (no arguments), prompts for a branch name and offers to use the current branch as the first layer.

When explicit branch names are given, existing branches are adopted automatically and any missing branches are created. The trunk defaults to the repository's default branch unless overridden with `--base`.

Use `--numbered` with `--prefix` to enable auto-incrementing numbered branch names (`prefix/01`, `prefix/02`, …). Without `--numbered`, you'll always be prompted to provide a meaningful branch name.

Enables `git rerere` automatically so that conflict resolutions are remembered across rebases.

| Flag | Description |
|------|-------------|
| `-b, --base <branch>` | Trunk branch for the stack (defaults to the repository's default branch) |
| `-p, --prefix <string>` | Set a branch name prefix for the stack |
| `-n, --numbered` | Use auto-incrementing numbered branch names (requires `--prefix`) |

**Examples:**

Expand All @@ -104,15 +100,6 @@ gh stack init --base develop feature-auth

# Adopt existing branches into a stack
gh stack init feature-auth feature-api

# Set a prefix — you'll be prompted for a branch name
gh stack init -p feat
# → prompts "Enter a name for the first branch (will be prefixed with feat/)"
# → type "auth" → creates feat/auth

# Use numbered auto-incrementing branch names
gh stack init -p feat --numbered
# → creates feat/01 automatically
```

### `gh stack add`
Expand All @@ -125,7 +112,7 @@ gh stack add [flags] [branch]

Creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one.

You can optionally stage changes and create a commit as part of the `add` flow. When `-m` is provided without an explicit branch name, the branch name is auto-generated. If the stack was created with `--numbered`, auto-generated names use numbered format (`prefix/01`, `prefix/02`); otherwise, date+slug format is used (e.g., `prefix/2025-03-24-add-login`).
You can optionally stage changes and create a commit as part of the `add` flow. When `-m` is provided without an explicit branch name, the branch name is auto-generated in date+slug format (e.g., `03-24-add_login`).

| Flag | Description |
|------|-------------|
Expand Down Expand Up @@ -616,42 +603,42 @@ gh stack sync

## Abbreviated workflow

If you want to minimize keystrokes, use a branch prefix with `--numbered` and the `-Am` flags to fold staging, committing, and branch creation into a single command. Branch names are auto-generated as `prefix/01`, `prefix/02`, etc.
If you want to minimize keystrokes, use the `-Am` flags to fold staging, committing, and branch creation into a single command. When you don't pass a branch name, one is auto-generated from the commit message in date+slug format (e.g., `03-24-auth_middleware`).

When a branch has no commits yet (e.g., right after `init`), `add -Am` stages and commits directly on that branch instead of creating a new one. Once a branch has commits, `add -Am` creates a new branch, checks it out, and commits there.

```sh
# 1. Start a stack with a prefix and numbered branches
gh stack init -p feat --numbered
# → creates feat/01 and checks it out
# 1. Start a stack
gh stack init auth
# → creates auth and checks it out

# 2. Write code for the first layer
# ... write code ...

# 3. Stage and commit on the current branch
gh stack add -Am "Auth middleware"
# → feat/01 has no commits yet, so the commit lands here
# → auth has no commits yet, so the commit lands here
# (no new branch is created)

# 4. Write code for the next layer
# ... write code ...

# 5. Create the next branch and commit
gh stack add -Am "API routes"
# → feat/01 already has commits, so a new branch feat/02 is
# created, checked out, and the commit lands there
# → auth already has commits, so a new branch is created from the
# commit message, checked out, and the commit lands there

# 6. Keep going
# ... write code ...

gh stack add -Am "Frontend components"
# → feat/02 already has commits, creates feat/03 and commits there
# → creates another branch and commits there

# 7. Push everything and create PRs
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.
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. Pass an explicit branch name any time you want to control it: `gh stack add -Am "API routes" api-routes`.

## Terminal theme

Expand Down
72 changes: 24 additions & 48 deletions cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ func AddCmd(cfg *config.Config) *cobra.Command {

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.`,
the branch name is auto-generated from the commit message.`,
Example: ` # Add a new named branch to the stack
$ gh stack add my-feature

Expand Down Expand Up @@ -119,55 +118,41 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
return nil
}

// Resolve branch name
// Resolve branch name:
// explicit name -> used verbatim
// -m without a name -> auto-generated from the commit message
// neither -> prompt for a name
var branchName string
var explicitName string
if len(args) > 0 {
explicitName = args[0]
}
existingBranches := s.BranchNames()

if opts.message != "" {
// Auto-naming mode
name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, s.Numbered)
if name == "" {
if explicitName != "" {
branchName = explicitName
} else if opts.message != "" {
branchName = branch.DateSlug(opts.message)
if branchName == "" {
cfg.Errorf("could not generate branch name")
return ErrSilent
}
branchName = name
if info != "" {
cfg.Infof("%s", info)
}
} else if explicitName != "" {
branchName = applyPrefix(cfg, s.Prefix, explicitName)
} else {
// No -m, no explicit name — auto-generate if using numbered
// convention, otherwise prompt for a name.
if s.Numbered && s.Prefix != "" {
branchName = branch.NextNumberedName(s.Prefix, existingBranches)
} else {
// Pre-fill the prompt with the prefix so the user can see
// (and optionally edit) the full branch name.
prefill := ""
if s.Prefix != "" {
prefill = s.Prefix + "/"
}
for {
input, err := inputWithPrefill(cfg, "Enter a name for the new branch:", prefill)
if err != nil {
if isInterruptError(err) {
printInterrupt(cfg)
return ErrSilent
}
return fmt.Errorf("could not read branch name: %w", err)
}
if input == "" {
cfg.Warningf("branch name cannot be empty, please try again")
continue
// No -m and no explicit name — prompt for one.
for {
input, err := promptInput(cfg, "Enter a name for the new branch:")
if err != nil {
if isInterruptError(err) {
printInterrupt(cfg)
return ErrSilent
}
branchName = input
break
return fmt.Errorf("could not read branch name: %w", err)
}
if input == "" {
cfg.Warningf("branch name cannot be empty, please try again")
continue
}
branchName = input
break
}
}

Expand Down Expand Up @@ -281,12 +266,3 @@ func doCommit(message string) (string, error) {
}
return git.CommitInteractive()
}

// applyPrefix prepends the stack prefix to a branch name if set.
func applyPrefix(cfg *config.Config, prefix, name string) string {
if prefix != "" {
name = prefix + "/" + name
cfg.Infof("Branch name prefixed: %s", name)
}
return name
}
83 changes: 20 additions & 63 deletions cmd/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"testing"
"time"

"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
Expand Down Expand Up @@ -212,18 +213,17 @@ func TestAdd_BranchWithCommitsCreatesNew(t *testing.T) {
assert.True(t, commitCalled, "expected Commit to be called on the new branch")
}

func TestAdd_PrefixAppliedWithSlash(t *testing.T) {
func TestAdd_ExplicitNameUsedVerbatim(t *testing.T) {
gitDir := t.TempDir()
saveStack(t, gitDir, stack.Stack{
Prefix: "feat",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "feat/01"}},
Branches: []stack.BranchRef{{Branch: "b1"}},
})

var createdBranch string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
CreateBranchFn: func(name, base string) error {
createdBranch = name
return nil
Expand All @@ -236,22 +236,20 @@ func TestAdd_PrefixAppliedWithSlash(t *testing.T) {
output := collectOutput(cfg, outR, errR)

require.NotContains(t, output, "\u2717", "unexpected error")
assert.Equal(t, "feat/mybranch", createdBranch)
assert.Equal(t, "mybranch", createdBranch)
}

func TestAdd_NumberedNaming(t *testing.T) {
func TestAdd_MessageAutoGeneratesDateSlug(t *testing.T) {
gitDir := t.TempDir()
saveStack(t, gitDir, stack.Stack{
Prefix: "feat",
Numbered: true,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "feat/01"}},
Branches: []stack.BranchRef{{Branch: "b1"}},
})

var createdBranch string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
RevParseMultiFn: func(refs []string) ([]string, error) {
return []string{"aaa", "bbb"}, nil
},
Expand All @@ -271,7 +269,8 @@ func TestAdd_NumberedNaming(t *testing.T) {
output := collectOutput(cfg, outR, errR)

require.NotContains(t, output, "\u2717", "unexpected error")
assert.Equal(t, "feat/02", createdBranch)
today := time.Now().Format("01-02")
assert.Equal(t, today+"-next_feature", createdBranch)
}

func TestAdd_FullyMergedStackBlocked(t *testing.T) {
Expand Down Expand Up @@ -322,47 +321,7 @@ func TestAdd_NothingToCommit(t *testing.T) {
assert.Contains(t, output, "no changes to commit")
}

func TestAdd_PromptPrefillsPrefix(t *testing.T) {
gitDir := t.TempDir()
saveStack(t, gitDir, stack.Stack{
Prefix: "feat",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "feat/01"}},
})

var createdBranch string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
CreateBranchFn: func(name, base string) error {
createdBranch = name
return nil
},
CheckoutBranchFn: func(name string) error { return nil },
RevParseFn: func(ref string) (string, error) { return "abc", nil },
})
defer restore()

cfg, outR, errR := config.NewTestConfig()

var gotPrompt, gotDefault string
cfg.InputFn = func(prompt, defaultValue string) (string, error) {
gotPrompt = prompt
gotDefault = defaultValue
return "feat/my-branch", nil
}

err := runAdd(cfg, &addOptions{}, nil)
output := collectOutput(cfg, outR, errR)

require.NoError(t, err)
require.NotContains(t, output, "\u2717", "unexpected error")
assert.Contains(t, gotPrompt, ":", "prompt should end with a colon")
assert.Equal(t, "feat/", gotDefault, "prompt should pre-fill prefix/")
assert.Equal(t, "feat/my-branch", createdBranch, "full input should be used as branch name")
}

func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) {
func TestAdd_PromptForBranchName(t *testing.T) {
gitDir := t.TempDir()
saveStack(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Expand All @@ -384,9 +343,9 @@ func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) {

cfg, outR, errR := config.NewTestConfig()

var gotDefault string
cfg.InputFn = func(prompt, defaultValue string) (string, error) {
gotDefault = defaultValue
var gotPrompt string
cfg.InputFn = func(prompt string) (string, error) {
gotPrompt = prompt
return "my-branch", nil
}

Expand All @@ -395,22 +354,21 @@ func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) {

require.NoError(t, err)
require.NotContains(t, output, "\u2717", "unexpected error")
assert.Equal(t, "", gotDefault, "prompt should have empty default when no prefix")
assert.Contains(t, gotPrompt, ":", "prompt should end with a colon")
assert.Equal(t, "my-branch", createdBranch, "input should be used as-is")
}

func TestAdd_PromptUserModifiesPrefix(t *testing.T) {
func TestAdd_PromptInputUsedVerbatim(t *testing.T) {
gitDir := t.TempDir()
saveStack(t, gitDir, stack.Stack{
Prefix: "feat",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "feat/01"}},
Branches: []stack.BranchRef{{Branch: "b1"}},
})

var createdBranch string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
CreateBranchFn: func(name, base string) error {
createdBranch = name
return nil
Expand All @@ -422,8 +380,7 @@ func TestAdd_PromptUserModifiesPrefix(t *testing.T) {

cfg, outR, errR := config.NewTestConfig()

cfg.InputFn = func(prompt, defaultValue string) (string, error) {
// Simulate user changing the prefix entirely
cfg.InputFn = func(prompt string) (string, error) {
return "custom/other-name", nil
}

Expand All @@ -432,7 +389,7 @@ func TestAdd_PromptUserModifiesPrefix(t *testing.T) {

require.NoError(t, err)
require.NotContains(t, output, "\u2717", "unexpected error")
assert.Equal(t, "custom/other-name", createdBranch, "user-modified input should be used verbatim")
assert.Equal(t, "custom/other-name", createdBranch, "typed input should be used verbatim")
}

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