Skip to content

Commit 38aba1b

Browse files
authored
Deprecate branch name prefixes (#182)
* deprecate prefix functionality * clean up branch auto-naming * update docs * preserve literal hyphens when slugifying branch names
1 parent 9dfef64 commit 38aba1b

19 files changed

Lines changed: 185 additions & 683 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ internal/
4141
schema.json # JSON Schema for the stack file format
4242
config/ # Config struct (I/O, colors, test overrides)
4343
testing.go # NewTestConfig(). Returns *Config + stdout/stderr pipes.
44-
branch/ # branch naming (Slugify, DateSlug, NextNumberedName)
44+
branch/ # branch naming (Slugify, DateSlug)
4545
modify/ # interactive stack modification state machine
4646
pr/ # PR template discovery
4747
tui/ # bubbletea/bubbles/lipgloss terminal UI

README.md

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,15 @@ Initialize a new stack in the current repository.
7676
gh stack init [flags] [branches...]
7777
```
7878

79-
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.
79+
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.
8080

8181
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`.
8282

83-
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.
84-
8583
Enables `git rerere` automatically so that conflict resolutions are remembered across rebases.
8684

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

9389
**Examples:**
9490

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

105101
# Adopt existing branches into a stack
106102
gh stack init feature-auth feature-api
107-
108-
# Set a prefix — you'll be prompted for a branch name
109-
gh stack init -p feat
110-
# → prompts "Enter a name for the first branch (will be prefixed with feat/)"
111-
# → type "auth" → creates feat/auth
112-
113-
# Use numbered auto-incrementing branch names
114-
gh stack init -p feat --numbered
115-
# → creates feat/01 automatically
116103
```
117104

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

126113
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.
127114

128-
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`).
115+
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`).
129116

130117
| Flag | Description |
131118
|------|-------------|
@@ -616,42 +603,42 @@ gh stack sync
616603

617604
## Abbreviated workflow
618605

619-
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.
606+
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`).
620607

621608
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.
622609

623610
```sh
624-
# 1. Start a stack with a prefix and numbered branches
625-
gh stack init -p feat --numbered
626-
# → creates feat/01 and checks it out
611+
# 1. Start a stack
612+
gh stack init auth
613+
# → creates auth and checks it out
627614

628615
# 2. Write code for the first layer
629616
# ... write code ...
630617

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

636623
# 4. Write code for the next layer
637624
# ... write code ...
638625

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

644631
# 6. Keep going
645632
# ... write code ...
646633

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

650637
# 7. Push everything and create PRs
651638
gh stack submit
652639
```
653640

654-
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.
641+
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`.
655642

656643
## Terminal theme
657644

cmd/add.go

Lines changed: 24 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ func AddCmd(cfg *config.Config) *cobra.Command {
2727
2828
When -m is omitted but -A or -u is used, your editor opens for the
2929
commit message. When -m is provided without an explicit branch name,
30-
the branch name is auto-generated based on the commit message and
31-
stack prefix.`,
30+
the branch name is auto-generated from the commit message.`,
3231
Example: ` # Add a new named branch to the stack
3332
$ gh stack add my-feature
3433
@@ -119,55 +118,41 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
119118
return nil
120119
}
121120

122-
// Resolve branch name
121+
// Resolve branch name:
122+
// explicit name -> used verbatim
123+
// -m without a name -> auto-generated from the commit message
124+
// neither -> prompt for a name
123125
var branchName string
124126
var explicitName string
125127
if len(args) > 0 {
126128
explicitName = args[0]
127129
}
128-
existingBranches := s.BranchNames()
129130

130-
if opts.message != "" {
131-
// Auto-naming mode
132-
name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, s.Numbered)
133-
if name == "" {
131+
if explicitName != "" {
132+
branchName = explicitName
133+
} else if opts.message != "" {
134+
branchName = branch.DateSlug(opts.message)
135+
if branchName == "" {
134136
cfg.Errorf("could not generate branch name")
135137
return ErrSilent
136138
}
137-
branchName = name
138-
if info != "" {
139-
cfg.Infof("%s", info)
140-
}
141-
} else if explicitName != "" {
142-
branchName = applyPrefix(cfg, s.Prefix, explicitName)
143139
} else {
144-
// No -m, no explicit name — auto-generate if using numbered
145-
// convention, otherwise prompt for a name.
146-
if s.Numbered && s.Prefix != "" {
147-
branchName = branch.NextNumberedName(s.Prefix, existingBranches)
148-
} else {
149-
// Pre-fill the prompt with the prefix so the user can see
150-
// (and optionally edit) the full branch name.
151-
prefill := ""
152-
if s.Prefix != "" {
153-
prefill = s.Prefix + "/"
154-
}
155-
for {
156-
input, err := inputWithPrefill(cfg, "Enter a name for the new branch:", prefill)
157-
if err != nil {
158-
if isInterruptError(err) {
159-
printInterrupt(cfg)
160-
return ErrSilent
161-
}
162-
return fmt.Errorf("could not read branch name: %w", err)
163-
}
164-
if input == "" {
165-
cfg.Warningf("branch name cannot be empty, please try again")
166-
continue
140+
// No -m and no explicit name — prompt for one.
141+
for {
142+
input, err := promptInput(cfg, "Enter a name for the new branch:")
143+
if err != nil {
144+
if isInterruptError(err) {
145+
printInterrupt(cfg)
146+
return ErrSilent
167147
}
168-
branchName = input
169-
break
148+
return fmt.Errorf("could not read branch name: %w", err)
170149
}
150+
if input == "" {
151+
cfg.Warningf("branch name cannot be empty, please try again")
152+
continue
153+
}
154+
branchName = input
155+
break
171156
}
172157
}
173158

@@ -281,12 +266,3 @@ func doCommit(message string) (string, error) {
281266
}
282267
return git.CommitInteractive()
283268
}
284-
285-
// applyPrefix prepends the stack prefix to a branch name if set.
286-
func applyPrefix(cfg *config.Config, prefix, name string) string {
287-
if prefix != "" {
288-
name = prefix + "/" + name
289-
cfg.Infof("Branch name prefixed: %s", name)
290-
}
291-
return name
292-
}

cmd/add_test.go

Lines changed: 20 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"testing"
5+
"time"
56

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

215-
func TestAdd_PrefixAppliedWithSlash(t *testing.T) {
216+
func TestAdd_ExplicitNameUsedVerbatim(t *testing.T) {
216217
gitDir := t.TempDir()
217218
saveStack(t, gitDir, stack.Stack{
218-
Prefix: "feat",
219219
Trunk: stack.BranchRef{Branch: "main"},
220-
Branches: []stack.BranchRef{{Branch: "feat/01"}},
220+
Branches: []stack.BranchRef{{Branch: "b1"}},
221221
})
222222

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

238238
require.NotContains(t, output, "\u2717", "unexpected error")
239-
assert.Equal(t, "feat/mybranch", createdBranch)
239+
assert.Equal(t, "mybranch", createdBranch)
240240
}
241241

242-
func TestAdd_NumberedNaming(t *testing.T) {
242+
func TestAdd_MessageAutoGeneratesDateSlug(t *testing.T) {
243243
gitDir := t.TempDir()
244244
saveStack(t, gitDir, stack.Stack{
245-
Prefix: "feat",
246-
Numbered: true,
247245
Trunk: stack.BranchRef{Branch: "main"},
248-
Branches: []stack.BranchRef{{Branch: "feat/01"}},
246+
Branches: []stack.BranchRef{{Branch: "b1"}},
249247
})
250248

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

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

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

325-
func TestAdd_PromptPrefillsPrefix(t *testing.T) {
326-
gitDir := t.TempDir()
327-
saveStack(t, gitDir, stack.Stack{
328-
Prefix: "feat",
329-
Trunk: stack.BranchRef{Branch: "main"},
330-
Branches: []stack.BranchRef{{Branch: "feat/01"}},
331-
})
332-
333-
var createdBranch string
334-
restore := git.SetOps(&git.MockOps{
335-
GitDirFn: func() (string, error) { return gitDir, nil },
336-
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
337-
CreateBranchFn: func(name, base string) error {
338-
createdBranch = name
339-
return nil
340-
},
341-
CheckoutBranchFn: func(name string) error { return nil },
342-
RevParseFn: func(ref string) (string, error) { return "abc", nil },
343-
})
344-
defer restore()
345-
346-
cfg, outR, errR := config.NewTestConfig()
347-
348-
var gotPrompt, gotDefault string
349-
cfg.InputFn = func(prompt, defaultValue string) (string, error) {
350-
gotPrompt = prompt
351-
gotDefault = defaultValue
352-
return "feat/my-branch", nil
353-
}
354-
355-
err := runAdd(cfg, &addOptions{}, nil)
356-
output := collectOutput(cfg, outR, errR)
357-
358-
require.NoError(t, err)
359-
require.NotContains(t, output, "\u2717", "unexpected error")
360-
assert.Contains(t, gotPrompt, ":", "prompt should end with a colon")
361-
assert.Equal(t, "feat/", gotDefault, "prompt should pre-fill prefix/")
362-
assert.Equal(t, "feat/my-branch", createdBranch, "full input should be used as branch name")
363-
}
364-
365-
func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) {
324+
func TestAdd_PromptForBranchName(t *testing.T) {
366325
gitDir := t.TempDir()
367326
saveStack(t, gitDir, stack.Stack{
368327
Trunk: stack.BranchRef{Branch: "main"},
@@ -384,9 +343,9 @@ func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) {
384343

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

387-
var gotDefault string
388-
cfg.InputFn = func(prompt, defaultValue string) (string, error) {
389-
gotDefault = defaultValue
346+
var gotPrompt string
347+
cfg.InputFn = func(prompt string) (string, error) {
348+
gotPrompt = prompt
390349
return "my-branch", nil
391350
}
392351

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

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

402-
func TestAdd_PromptUserModifiesPrefix(t *testing.T) {
361+
func TestAdd_PromptInputUsedVerbatim(t *testing.T) {
403362
gitDir := t.TempDir()
404363
saveStack(t, gitDir, stack.Stack{
405-
Prefix: "feat",
406364
Trunk: stack.BranchRef{Branch: "main"},
407-
Branches: []stack.BranchRef{{Branch: "feat/01"}},
365+
Branches: []stack.BranchRef{{Branch: "b1"}},
408366
})
409367

410368
var createdBranch string
411369
restore := git.SetOps(&git.MockOps{
412370
GitDirFn: func() (string, error) { return gitDir, nil },
413-
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
371+
CurrentBranchFn: func() (string, error) { return "b1", nil },
414372
CreateBranchFn: func(name, base string) error {
415373
createdBranch = name
416374
return nil
@@ -422,8 +380,7 @@ func TestAdd_PromptUserModifiesPrefix(t *testing.T) {
422380

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

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

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

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

438395
func TestAdd_FromTrunk(t *testing.T) {

0 commit comments

Comments
 (0)