Skip to content

Commit 3058421

Browse files
committed
default to named branches, numbering is a flag set on init
1 parent 309e12a commit 3058421

8 files changed

Lines changed: 297 additions & 124 deletions

File tree

README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ Initialize a new stack in the current repository.
7171
gh stack init [branches...] [flags]
7272
```
7373

74-
Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. In interactive mode, you'll also be prompted to set an optional branch prefix for auto-naming (unless adopting existing branches). When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`.
74+
Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. In interactive mode, you'll also be prompted to set an optional branch prefix (unless adopting existing branches). When a prefix is set, branch names you enter are automatically prefixed. When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`.
75+
76+
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.
7577

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

@@ -80,6 +82,7 @@ Enables `git rerere` automatically so that conflict resolutions are remembered a
8082
| `-b, --base <branch>` | Trunk branch for the stack (defaults to the repository's default branch) |
8183
| `-a, --adopt` | Adopt existing branches into a stack instead of creating new ones |
8284
| `-p, --prefix <string>` | Set a branch name prefix for the stack |
85+
| `-n, --numbered` | Use auto-incrementing numbered branch names (requires `--prefix`) |
8386

8487
**Examples:**
8588

@@ -96,8 +99,14 @@ gh stack init --base develop feature-auth
9699
# Adopt existing branches into a stack
97100
gh stack init --adopt feature-auth feature-api
98101

99-
# Set a prefix for auto-naming branches
102+
# Set a prefix — you'll be prompted for a branch name
100103
gh stack init -p feat
104+
# → prompts "Enter a name for the first branch (will be prefixed with feat/)"
105+
# → type "auth" → creates feat/auth
106+
107+
# Use numbered auto-incrementing branch names
108+
gh stack init -p feat --numbered
109+
# → creates feat/01 automatically
101110
```
102111

103112
### `gh stack add`
@@ -110,7 +119,7 @@ gh stack add [branch] [flags]
110119

111120
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.
112121

113-
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. Auto-generated names use either numbered format (`prefix/01`, `prefix/02`) or date+slug format depending on prefix configuration and existing branch naming patterns.
122+
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`).
114123

115124
| Flag | Description |
116125
|------|-------------|
@@ -409,13 +418,13 @@ gh stack sync
409418

410419
## Abbreviated workflow
411420

412-
If you want to minimize keystrokes, use a branch prefix and the `-Am` flags to fold staging, committing, and branch creation into a single command. Branch names are auto-generated from your commit messages.
421+
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.
413422

414423
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.
415424

416425
```sh
417-
# 1. Start a stack with a prefix
418-
gh stack init -p feat
426+
# 1. Start a stack with a prefix and numbered branches
427+
gh stack init -p feat --numbered
419428
# → creates feat/01 and checks it out
420429

421430
# 2. Write code for the first layer

cmd/add.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
111111

112112
if opts.message != "" {
113113
// Auto-naming mode
114-
isFirstBranch := len(existingBranches) == 0
115-
name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, isFirstBranch)
114+
name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, s.Numbered)
116115
if name == "" {
117116
cfg.Errorf("could not generate branch name")
118117
return ErrSilent
@@ -124,10 +123,9 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
124123
} else if explicitName != "" {
125124
branchName = applyPrefix(cfg, s.Prefix, explicitName)
126125
} else {
127-
// No -m, no explicit name — auto-generate if following numbered
126+
// No -m, no explicit name — auto-generate if using numbered
128127
// convention, otherwise prompt for a name.
129-
if s.Prefix != "" && len(existingBranches) > 0 &&
130-
branch.FollowsNumbering(s.Prefix, existingBranches[len(existingBranches)-1]) {
128+
if s.Numbered && s.Prefix != "" {
131129
branchName = branch.NextNumberedName(s.Prefix, existingBranches)
132130
} else {
133131
p := prompter.New(cfg.In, cfg.Out, cfg.Err)

cmd/add_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ func TestAdd_NumberedNaming(t *testing.T) {
278278
gitDir := t.TempDir()
279279
saveStack(t, gitDir, stack.Stack{
280280
Prefix: "feat",
281+
Numbered: true,
281282
Trunk: stack.BranchRef{Branch: "main"},
282283
Branches: []stack.BranchRef{{Branch: "feat/01"}},
283284
})

cmd/init.go

Lines changed: 98 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type initOptions struct {
1818
base string
1919
adopt bool
2020
prefix string
21+
numbered bool
2122
}
2223

2324
func InitCmd(cfg *config.Config) *cobra.Command {
@@ -43,6 +44,7 @@ Trunk defaults to default branch, unless specified otherwise.`,
4344
cmd.Flags().StringVarP(&opts.base, "base", "b", "", "Trunk branch for stack (defaults to default branch)")
4445
cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Track existing branches as part of a stack")
4546
cmd.Flags().StringVarP(&opts.prefix, "prefix", "p", "", "Branch name prefix for the stack")
47+
cmd.Flags().BoolVarP(&opts.numbered, "numbered", "n", false, "Use auto-incrementing numbered branch names (requires --prefix)")
4648

4749
return cmd
4850
}
@@ -99,6 +101,13 @@ func runInit(cfg *config.Config, opts *initOptions) error {
99101

100102
var branches []string
101103

104+
// Validate --numbered requires a prefix (either from flag or interactive input,
105+
// but for non-interactive paths we can check early).
106+
if opts.numbered && opts.prefix == "" && !cfg.IsInteractive() {
107+
cfg.Errorf("--numbered requires --prefix")
108+
return ErrInvalidArgs
109+
}
110+
102111
if opts.adopt {
103112
// Adopt mode: validate all specified branches exist
104113
if len(opts.branches) == 0 {
@@ -160,69 +169,40 @@ func runInit(cfg *config.Config, opts *initOptions) error {
160169

161170
// Step 1: Ask for prefix
162171
if opts.prefix == "" {
163-
prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "")
164-
if err != nil {
165-
if isInterruptError(err) {
166-
printInterrupt(cfg)
167-
return ErrSilent
168-
}
169-
cfg.Errorf("failed to read prefix: %s", err)
170-
return ErrSilent
171-
}
172-
opts.prefix = strings.TrimSpace(prefixInput)
173-
}
174-
175-
// Step 2: Ask for branch name
176-
if currentBranch != "" && currentBranch != trunk {
177-
// Already on a non-trunk branch — offer to use it
178-
useCurrentBranch, err := p.Confirm(
179-
fmt.Sprintf("Would you like to use %s as the first layer of your stack?", currentBranch),
180-
true,
181-
)
182-
if err != nil {
183-
if isInterruptError(err) {
184-
printInterrupt(cfg)
172+
if opts.numbered {
173+
// --numbered requires a prefix; prompt specifically for one
174+
prefixInput, err := p.Input("Enter a branch prefix (required for --numbered)", "")
175+
if err != nil {
176+
if isInterruptError(err) {
177+
printInterrupt(cfg)
178+
return ErrSilent
179+
}
180+
cfg.Errorf("failed to read prefix: %s", err)
185181
return ErrSilent
186182
}
187-
cfg.Errorf("failed to confirm branch selection: %s", err)
188-
return ErrSilent
189-
}
190-
if useCurrentBranch {
191-
if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil {
192-
cfg.Errorf("branch %q already exists in the stack", currentBranch)
183+
opts.prefix = strings.TrimSpace(prefixInput)
184+
if opts.prefix == "" {
185+
cfg.Errorf("--numbered requires a prefix")
193186
return ErrInvalidArgs
194187
}
195-
branches = []string{currentBranch}
196-
}
197-
}
198-
199-
if len(branches) == 0 {
200-
prompt := "What branch would you like to use as the first layer of your stack?"
201-
if opts.prefix != "" {
202-
prompt = fmt.Sprintf("Name the first branch, or leave blank to use %s", branch.NextNumberedName(opts.prefix, nil))
203-
}
204-
branchName, err := p.Input(prompt, "")
205-
if err != nil {
206-
if isInterruptError(err) {
207-
printInterrupt(cfg)
188+
} else {
189+
prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "")
190+
if err != nil {
191+
if isInterruptError(err) {
192+
printInterrupt(cfg)
193+
return ErrSilent
194+
}
195+
cfg.Errorf("failed to read prefix: %s", err)
208196
return ErrSilent
209197
}
210-
cfg.Errorf("failed to read branch name: %s", err)
211-
return ErrSilent
212-
}
213-
branchName = strings.TrimSpace(branchName)
214-
215-
if branchName == "" && opts.prefix != "" {
216-
// Auto-generate numbered branch name
217-
branchName = branch.NextNumberedName(opts.prefix, nil)
218-
} else if branchName == "" {
219-
cfg.Errorf("branch name cannot be empty")
220-
return ErrInvalidArgs
221-
} else if opts.prefix != "" {
222-
// Prepend prefix to the user-provided name
223-
branchName = opts.prefix + "/" + branchName
198+
opts.prefix = strings.TrimSpace(prefixInput)
224199
}
200+
}
225201

202+
// Step 2: Ask for branch name (unless --numbered auto-generates it)
203+
if opts.numbered {
204+
// Auto-generate numbered branch name
205+
branchName := branch.NextNumberedName(opts.prefix, nil)
226206
if err := sf.ValidateNoDuplicateBranch(branchName); err != nil {
227207
cfg.Errorf("branch %q already exists in a stack", branchName)
228208
return ErrInvalidArgs
@@ -234,6 +214,67 @@ func runInit(cfg *config.Config, opts *initOptions) error {
234214
}
235215
}
236216
branches = []string{branchName}
217+
} else {
218+
if currentBranch != "" && currentBranch != trunk {
219+
// Already on a non-trunk branch — offer to use it
220+
useCurrentBranch, err := p.Confirm(
221+
fmt.Sprintf("Would you like to use %s as the first layer of your stack?", currentBranch),
222+
true,
223+
)
224+
if err != nil {
225+
if isInterruptError(err) {
226+
printInterrupt(cfg)
227+
return ErrSilent
228+
}
229+
cfg.Errorf("failed to confirm branch selection: %s", err)
230+
return ErrSilent
231+
}
232+
if useCurrentBranch {
233+
if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil {
234+
cfg.Errorf("branch %q already exists in the stack", currentBranch)
235+
return ErrInvalidArgs
236+
}
237+
branches = []string{currentBranch}
238+
}
239+
}
240+
241+
if len(branches) == 0 {
242+
prompt := "What branch would you like to use as the first layer of your stack?"
243+
if opts.prefix != "" {
244+
prompt = fmt.Sprintf("Enter a name for the first branch (will be prefixed with %s/)", opts.prefix)
245+
}
246+
branchName, err := p.Input(prompt, "")
247+
if err != nil {
248+
if isInterruptError(err) {
249+
printInterrupt(cfg)
250+
return ErrSilent
251+
}
252+
cfg.Errorf("failed to read branch name: %s", err)
253+
return ErrSilent
254+
}
255+
branchName = strings.TrimSpace(branchName)
256+
257+
if branchName == "" {
258+
cfg.Errorf("branch name cannot be empty")
259+
return ErrInvalidArgs
260+
}
261+
262+
if opts.prefix != "" {
263+
branchName = opts.prefix + "/" + branchName
264+
}
265+
266+
if err := sf.ValidateNoDuplicateBranch(branchName); err != nil {
267+
cfg.Errorf("branch %q already exists in a stack", branchName)
268+
return ErrInvalidArgs
269+
}
270+
if !git.BranchExists(branchName) {
271+
if err := git.CreateBranch(branchName, trunk); err != nil {
272+
cfg.Errorf("creating branch %s: %s", branchName, err)
273+
return ErrSilent
274+
}
275+
}
276+
branches = []string{branchName}
277+
}
237278
}
238279
}
239280

@@ -258,7 +299,8 @@ func runInit(cfg *config.Config, opts *initOptions) error {
258299
}
259300

260301
newStack := stack.Stack{
261-
Prefix: opts.prefix,
302+
Prefix: opts.prefix,
303+
Numbered: opts.numbered,
262304
Trunk: stack.BranchRef{
263305
Branch: trunk,
264306
Head: trunkSHA,

internal/branch/name.go

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,10 @@ func NextNumberedName(prefix string, existingBranches []string) string {
9797
// - message: commit message (from -m flag; may be empty if not using auto-naming)
9898
// - explicitName: branch name provided as argument (may be empty)
9999
// - existingBranches: current branch names in the stack
100-
// - isFirstBranch: true if this is the first branch being added to the stack
100+
// - numbered: true if the stack uses auto-incrementing numbered branches
101101
//
102102
// Returns the resolved branch name and an informational message (may be empty).
103-
func ResolveBranchName(prefix, message, explicitName string, existingBranches []string, isFirstBranch bool) (name string, info string) {
103+
func ResolveBranchName(prefix, message, explicitName string, existingBranches []string, numbered bool) (name string, info string) {
104104
if explicitName != "" {
105105
// Explicit name provided
106106
if prefix != "" {
@@ -118,18 +118,10 @@ func ResolveBranchName(prefix, message, explicitName string, existingBranches []
118118
}
119119

120120
if prefix != "" {
121-
// Check if we should use numbered format
122-
useNumbering := isFirstBranch
123-
if !useNumbering && len(existingBranches) > 0 {
124-
lastBranch := existingBranches[len(existingBranches)-1]
125-
useNumbering = FollowsNumbering(prefix, lastBranch)
126-
}
127-
128-
if useNumbering {
121+
if numbered {
129122
name = NextNumberedName(prefix, existingBranches)
130123
} else {
131124
name = prefix + "/" + DateSlug(message)
132-
info = "Branch name auto-generated using date+slug format because existing branches don't follow numbering convention"
133125
}
134126
} else {
135127
// No prefix — always use date+slug

internal/branch/name_test.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,24 +98,23 @@ func TestResolveBranchName(t *testing.T) {
9898
assert.Empty(t, info)
9999
})
100100

101-
t.Run("message with prefix first branch uses numbered format", func(t *testing.T) {
101+
t.Run("message with prefix and numbered uses numbered format", func(t *testing.T) {
102102
name, _ := ResolveBranchName("stack", "add login", "", nil, true)
103103
assert.Equal(t, "stack/01", name)
104104
})
105105

106-
t.Run("message with prefix last branch follows numbering uses next number", func(t *testing.T) {
106+
t.Run("message with prefix and numbered continues sequence", func(t *testing.T) {
107107
existing := []string{"stack/01", "stack/02"}
108-
name, _ := ResolveBranchName("stack", "add login", "", existing, false)
108+
name, _ := ResolveBranchName("stack", "add login", "", existing, true)
109109
assert.Equal(t, "stack/03", name)
110110
})
111111

112-
t.Run("message with prefix last branch not numbered uses date-slug", func(t *testing.T) {
112+
t.Run("message with prefix not numbered uses date-slug", func(t *testing.T) {
113113
existing := []string{"stack/some-feature"}
114-
name, info := ResolveBranchName("stack", "add login", "", existing, false)
114+
name, _ := ResolveBranchName("stack", "add login", "", existing, false)
115115
today := time.Now().Format("2006-01-02")
116116
assert.True(t, strings.HasPrefix(name, "stack/"+today), "expected date prefix, got: %s", name)
117117
assert.Contains(t, name, "add-login")
118-
assert.NotEmpty(t, info, "should explain why date+slug was used")
119118
})
120119

121120
t.Run("message without prefix uses date-slug", func(t *testing.T) {

internal/stack/stack.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type BranchRef struct {
3737
type Stack struct {
3838
ID string `json:"id,omitempty"`
3939
Prefix string `json:"prefix,omitempty"`
40+
Numbered bool `json:"numbered,omitempty"`
4041
Trunk BranchRef `json:"trunk"`
4142
Branches []BranchRef `json:"branches"`
4243
}

0 commit comments

Comments
 (0)