Skip to content

Commit 50fdc33

Browse files
committed
support commit msg editor, update stage all flag to match git commit, better error handling
1 parent 14364a2 commit 50fdc33

6 files changed

Lines changed: 178 additions & 99 deletions

File tree

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,11 @@ You can optionally stage changes and create a commit as part of the `add` flow.
102102

103103
| Flag | Description |
104104
|------|-------------|
105-
| `-a, --all` | Stage all changes (including untracked files); requires `-m` |
105+
| `-A, --all` | Stage all changes (including untracked files); requires `-m` |
106106
| `-u, --update` | Stage changes to tracked files only; requires `-m` |
107107
| `-m, --message <string>` | Create a commit with this message before creating the branch |
108108

109-
> **Note:** `-a` and `-u` are mutually exclusive.
109+
> **Note:** `-A` and `-u` are mutually exclusive.
110110
111111
**Examples:**
112112

@@ -118,7 +118,7 @@ gh stack add api-routes
118118
gh stack add
119119

120120
# Stage all changes, commit, and auto-generate the branch name
121-
gh stack add -am "Add login endpoint"
121+
gh stack add -Am "Add login endpoint"
122122

123123
# Stage only tracked files, commit, and auto-generate the branch name
124124
gh stack add -um "Fix auth bug"
@@ -127,7 +127,7 @@ gh stack add -um "Fix auth bug"
127127
gh stack add -m "Add user model"
128128

129129
# Stage all changes, commit, and use an explicit branch name
130-
gh stack add -am "Add tests" test-layer
130+
gh stack add -Am "Add tests" test-layer
131131

132132
# Stage only tracked files, commit, and use an explicit branch name
133133
gh stack add -um "Update docs" docs-layer
@@ -391,9 +391,9 @@ gh stack sync
391391

392392
## Abbreviated workflow
393393

394-
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.
394+
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.
395395

396-
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.
396+
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.
397397

398398
```sh
399399
# 1. Start a stack with a prefix
@@ -404,26 +404,26 @@ gh stack init -p feat
404404
# ... write code ...
405405

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

411411
# 4. Write code for the next layer
412412
# ... write code ...
413413

414414
# 5. Create the next branch and commit
415-
gh stack add -am "API routes"
415+
gh stack add -Am "API routes"
416416
# → feat/01 already has commits, so a new branch feat/02 is
417417
# created, checked out, and the commit lands there
418418

419419
# 6. Keep going
420420
# ... write code ...
421421

422-
gh stack add -am "Frontend components"
422+
gh stack add -Am "Frontend components"
423423
# → feat/02 already has commits, creates feat/03 and commits there
424424

425425
# 7. Push everything and create PRs
426426
gh stack push
427427
```
428428

429-
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.
429+
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.

cmd/add.go

Lines changed: 97 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,17 @@ func AddCmd(cfg *config.Config) *cobra.Command {
2525
Short: "Add a new branch on top of the current stack",
2626
Long: `Add a new branch on top of the current stack.
2727
28-
Optionally stage changes and create a commit before creating the branch:
29-
-a Stage all changes (including untracked) before committing
30-
-u Stage tracked file changes before committing
31-
-m Create a commit with the given message
32-
33-
When -m is provided without an explicit branch name, the branch name
34-
is auto-generated based on the commit message and stack prefix.`,
28+
When -m is omitted but -A or -u is used, your editor opens for the
29+
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.`,
3532
Args: cobra.MaximumNArgs(1),
3633
RunE: func(cmd *cobra.Command, args []string) error {
3734
return runAdd(cfg, opts, args)
3835
},
3936
}
4037

41-
cmd.Flags().BoolVarP(&opts.stageAll, "all", "a", false, "Stage all changes including untracked files")
38+
cmd.Flags().BoolVarP(&opts.stageAll, "all", "A", false, "Stage all changes including untracked files")
4239
cmd.Flags().BoolVarP(&opts.stageTracked, "update", "u", false, "Stage changes to tracked files only")
4340
cmd.Flags().StringVarP(&opts.message, "message", "m", "", "Create a commit with this message")
4441

@@ -48,11 +45,7 @@ is auto-generated based on the commit message and stack prefix.`,
4845
func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
4946
// Validate flag combinations
5047
if opts.stageAll && opts.stageTracked {
51-
cfg.Errorf("flags -a and -u are mutually exclusive")
52-
return nil
53-
}
54-
if (opts.stageAll || opts.stageTracked) && opts.message == "" {
55-
cfg.Errorf("staging flags (-a, -u) require -m to create a commit")
48+
cfg.Errorf("flags -A and -u are mutually exclusive")
5649
return nil
5750
}
5851

@@ -72,39 +65,32 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
7265
}
7366

7467
idx := s.IndexOf(currentBranch)
68+
// idx < 0 means we're on the trunk — that's allowed (we'll create
69+
// a new branch from it). Only block if we're in the middle of the stack.
7570
if idx >= 0 && idx < len(s.Branches)-1 {
7671
cfg.Errorf("can only add branches on top of the stack; run `%s` to switch to %q", cfg.ColorCyan("gh stack top"), s.Branches[len(s.Branches)-1].Branch)
7772
return nil
7873
}
7974

80-
// When -m is provided, check if the current branch is a stack branch with
81-
// no unique commits relative to its parent. If so, the commit should land
82-
// on this branch without creating a new one (e.g., right after init).
75+
// Check if the current branch is a stack branch with no unique commits
76+
// relative to its parent. If so, the commit should land on this branch
77+
// without creating a new one (e.g., right after init).
78+
wantsCommit := opts.message != "" || opts.stageAll || opts.stageTracked
8379
var branchIsEmpty bool
84-
if opts.message != "" && idx >= 0 {
80+
if wantsCommit && idx >= 0 {
8581
parentBranch := s.ActiveBaseBranch(currentBranch)
86-
commits, _ := git.LogRange(parentBranch, currentBranch)
87-
branchIsEmpty = len(commits) == 0
82+
shas, err := git.RevParseMulti([]string{parentBranch, currentBranch})
83+
if err == nil {
84+
branchIsEmpty = shas[0] == shas[1]
85+
}
8886
}
8987

9088
// Empty branch path: stage and commit here, don't create a new branch.
91-
if branchIsEmpty && opts.message != "" {
92-
if opts.stageAll {
93-
if err := git.StageAll(); err != nil {
94-
cfg.Errorf("failed to stage changes: %s", err)
95-
return nil
96-
}
97-
} else if opts.stageTracked {
98-
if err := git.StageTracked(); err != nil {
99-
cfg.Errorf("failed to stage changes: %s", err)
100-
return nil
101-
}
102-
}
103-
if !git.HasStagedChanges() {
104-
cfg.Errorf("nothing to commit; stage changes first or use -a/-u")
89+
if branchIsEmpty {
90+
if err := stageAndValidate(cfg, opts); err != nil {
10591
return nil
10692
}
107-
sha, err := git.Commit(opts.message)
93+
sha, err := doCommit(opts.message)
10894
if err != nil {
10995
cfg.Errorf("failed to commit: %s", err)
11096
return nil
@@ -121,10 +107,10 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
121107
if len(args) > 0 {
122108
explicitName = args[0]
123109
}
110+
existingBranches := s.BranchNames()
124111

125112
if opts.message != "" {
126113
// Auto-naming mode
127-
existingBranches := s.BranchNames()
128114
isFirstBranch := len(existingBranches) == 0
129115
name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, isFirstBranch)
130116
if name == "" {
@@ -136,34 +122,30 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
136122
cfg.Infof("%s", info)
137123
}
138124
} else if explicitName != "" {
139-
// No -m, but explicit name given
140-
if s.Prefix != "" {
141-
branchName = s.Prefix + "/" + explicitName
142-
cfg.Infof("Branch name prefixed: %s", branchName)
143-
} else {
144-
branchName = explicitName
145-
}
125+
branchName = applyPrefix(cfg, s.Prefix, explicitName)
146126
} else {
147127
// No -m, no explicit name — auto-generate if following numbered
148128
// convention, otherwise prompt for a name.
149-
existingBranches := s.BranchNames()
150129
if s.Prefix != "" && len(existingBranches) > 0 &&
151130
branch.FollowsNumbering(s.Prefix, existingBranches[len(existingBranches)-1]) {
152131
branchName = branch.NextNumberedName(s.Prefix, existingBranches)
153132
} else {
154133
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
155-
input, err := p.Input("Enter a name for the new branch", "")
156-
if err != nil {
157-
if isInterruptError(err) {
158-
printInterrupt(cfg)
159-
return nil
134+
for {
135+
input, err := p.Input("Enter a name for the new branch", "")
136+
if err != nil {
137+
if isInterruptError(err) {
138+
printInterrupt(cfg)
139+
return nil
140+
}
141+
return fmt.Errorf("could not read branch name: %w", err)
160142
}
161-
return fmt.Errorf("could not read branch name: %w", err)
162-
}
163-
branchName = input
164-
if s.Prefix != "" && branchName != "" {
165-
branchName = s.Prefix + "/" + branchName
166-
cfg.Infof("Branch name prefixed: %s", branchName)
143+
if input == "" {
144+
cfg.Warningf("branch name cannot be empty, please try again")
145+
continue
146+
}
147+
branchName = applyPrefix(cfg, s.Prefix, input)
148+
break
167149
}
168150
}
169151
}
@@ -179,10 +161,18 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
179161
}
180162

181163
if git.BranchExists(branchName) {
182-
cfg.Errorf("branch %q already exists; provide an explicit name", branchName)
164+
cfg.Errorf("branch %q already exists", branchName)
183165
return nil
184166
}
185167

168+
// Stage changes before creating the branch so we can fail early if
169+
// there's nothing to commit (avoids leaving an empty orphan branch).
170+
if wantsCommit {
171+
if err := stageAndValidate(cfg, opts); err != nil {
172+
return nil
173+
}
174+
}
175+
186176
// Create the new branch from the current HEAD and check it out
187177
if err := git.CreateBranch(branchName, currentBranch); err != nil {
188178
cfg.Errorf("failed to create branch: %s", err)
@@ -194,28 +184,16 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
194184
return nil
195185
}
196186

197-
base, _ := git.RevParse(currentBranch)
187+
base, err := git.RevParse(currentBranch)
188+
if err != nil {
189+
cfg.Warningf("could not resolve base SHA for %s: %s", currentBranch, err)
190+
}
198191
s.Branches = append(s.Branches, stack.BranchRef{Branch: branchName, Base: base})
199192

200-
// Stage and commit on the NEW branch if -m is provided
193+
// Commit on the NEW branch (staging already done above)
201194
var commitSHA string
202-
if opts.message != "" {
203-
if opts.stageAll {
204-
if err := git.StageAll(); err != nil {
205-
cfg.Errorf("failed to stage changes: %s", err)
206-
return nil
207-
}
208-
} else if opts.stageTracked {
209-
if err := git.StageTracked(); err != nil {
210-
cfg.Errorf("failed to stage changes: %s", err)
211-
return nil
212-
}
213-
}
214-
if !git.HasStagedChanges() {
215-
cfg.Errorf("nothing to commit; stage changes first or use -a/-u")
216-
return nil
217-
}
218-
sha, err := git.Commit(opts.message)
195+
if wantsCommit {
196+
sha, err := doCommit(opts.message)
219197
if err != nil {
220198
cfg.Errorf("failed to commit: %s", err)
221199
return nil
@@ -238,3 +216,48 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
238216

239217
return nil
240218
}
219+
220+
// stageAndValidate stages files (if -A or -u is set) and verifies there are
221+
// staged changes to commit. Prints a user-facing error and returns non-nil
222+
// if staging fails or there is nothing to commit.
223+
func stageAndValidate(cfg *config.Config, opts *addOptions) error {
224+
if opts.stageAll {
225+
if err := git.StageAll(); err != nil {
226+
cfg.Errorf("failed to stage changes: %s", err)
227+
return err
228+
}
229+
} else if opts.stageTracked {
230+
if err := git.StageTracked(); err != nil {
231+
cfg.Errorf("failed to stage changes: %s", err)
232+
return err
233+
}
234+
}
235+
236+
if !git.HasStagedChanges() {
237+
if opts.stageAll || opts.stageTracked {
238+
cfg.Errorf("no changes to commit after staging")
239+
} else {
240+
cfg.Errorf("nothing to commit; stage changes first or use -A/-u")
241+
}
242+
return fmt.Errorf("nothing to commit")
243+
}
244+
return nil
245+
}
246+
247+
// doCommit commits staged changes. If message is provided, uses it directly.
248+
// If message is empty, launches the user's editor via git commit.
249+
func doCommit(message string) (string, error) {
250+
if message != "" {
251+
return git.Commit(message)
252+
}
253+
return git.CommitInteractive()
254+
}
255+
256+
// applyPrefix prepends the stack prefix to a branch name if set.
257+
func applyPrefix(cfg *config.Config, prefix, name string) string {
258+
if prefix != "" {
259+
name = prefix + "/" + name
260+
cfg.Infof("Branch name prefixed: %s", name)
261+
}
262+
return name
263+
}

0 commit comments

Comments
 (0)