Skip to content

Commit dbfe73f

Browse files
authored
feat: add ANSI color output following GitHub CLI conventions (#30)
* feat: add ANSI color output following GitHub CLI conventions Add colored terminal output to improve readability and user experience, following the GitHub CLI Primer color guidelines. - Add `internal/style` package using `mgutz/ansi` for ANSI colors - Respect `NO_COLOR`, `CLICOLOR`, and TTY detection - Apply semantic colors: green (success), red (errors), yellow (warnings), cyan (branches), magenta (merged), gray (muted), bold (headers) - Add icons: ✓ (success), ✗ (failure), ! (warning) - Update all cmd files and tree formatting with color support - Add `.claude/rules/colors.md` documenting the color scheme * fix: address PR review feedback - Pass style instance to saveUndoSnapshotByName instead of creating new one - Fix SuccessMessage newline placement in sync command - Remove undocumented ℹ icon from init command
1 parent f229f52 commit dbfe73f

File tree

16 files changed

+492
-164
lines changed

16 files changed

+492
-164
lines changed

.claude/rules/colors.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Terminal Output Colors
2+
3+
This project follows the [GitHub CLI Primer color conventions](https://github.com/cli/cli/tree/trunk/docs/primer/foundations) for consistent, accessible terminal output.
4+
5+
## Color Scheme
6+
7+
Use only the 8 basic ANSI colors for maximum terminal compatibility:
8+
9+
| Color | Usage | Style Method |
10+
| ------- | ---------------------------------- | -------------- |
11+
| Green | Success messages, open states | `Success()` |
12+
| Red | Errors, failures, conflicts | `Error()` |
13+
| Yellow | Warnings, pending states | `Warning()` |
14+
| Cyan | Branch names | `Branch()` |
15+
| Magenta | Merged PRs | `Merged()` |
16+
| Gray | Secondary text, hints, muted info | `Muted()` |
17+
| Bold | Phase headers, emphasis | `Bold()` |
18+
19+
## Icons
20+
21+
Use these Unicode symbols to enhance (not replace) meaning:
22+
23+
| Icon | Meaning | Style Method |
24+
| ---- | -------- | ----------------- |
25+
| `` | Success | `SuccessIcon()` |
26+
| `` | Failure | `FailureIcon()` |
27+
| `!` | Warning | `WarningIcon()` |
28+
29+
## Usage
30+
31+
Import the style package and create an instance:
32+
33+
```go
34+
import "github.com/boneskull/gh-stack/internal/style"
35+
36+
s := style.New()
37+
38+
// Success messages
39+
fmt.Printf("%s Sync complete!\n", s.SuccessIcon())
40+
fmt.Println(s.SuccessMessage("Operation complete"))
41+
42+
// Warnings
43+
fmt.Printf("%s could not fetch PR: %v\n", s.WarningIcon(), err)
44+
45+
// Branch names
46+
fmt.Printf("Rebasing %s onto %s\n", s.Branch(current), s.Branch(parent))
47+
48+
// Phase headers
49+
fmt.Println(s.Bold("=== Phase 1: Cascade ==="))
50+
51+
// Secondary/muted text
52+
fmt.Println(s.Muted("Run 'git config ...' to fix."))
53+
```
54+
55+
## Environment Variables
56+
57+
The style package respects standard terminal conventions:
58+
59+
- `NO_COLOR` - Disables all colors when set (any value)
60+
- `CLICOLOR=0` - Disables colors
61+
- `CLICOLOR_FORCE=1` - Forces colors even in non-TTY
62+
- `GH_FORCE_TTY` - Forces TTY behavior (from go-gh)
63+
64+
## Scriptability
65+
66+
When output is piped (non-TTY), colors are automatically disabled. This ensures:
67+
68+
- Machine-readable output when piped to other commands
69+
- State is communicated via text, not just color
70+
- Compatibility with tools like `grep`, `awk`, `cut`
71+
72+
## Guidelines
73+
74+
1. **Color enhances, never communicates alone** - Always include text that conveys the meaning
75+
2. **Be consistent** - Use the same color for the same semantic meaning
76+
3. **Test without colors** - Run with `NO_COLOR=1` to verify output is still clear
77+
4. **Prefer semantic methods** - Use `Success()` not raw green for success messages

cmd/abort.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/boneskull/gh-stack/internal/git"
99
"github.com/boneskull/gh-stack/internal/state"
10+
"github.com/boneskull/gh-stack/internal/style"
1011
"github.com/spf13/cobra"
1112
)
1213

@@ -22,6 +23,8 @@ func init() {
2223
}
2324

2425
func runAbort(cmd *cobra.Command, args []string) error {
26+
s := style.New()
27+
2528
cwd, err := os.Getwd()
2629
if err != nil {
2730
return err
@@ -50,10 +53,10 @@ func runAbort(cmd *cobra.Command, args []string) error {
5053
if st.StashRef != "" {
5154
fmt.Println("Restoring auto-stashed changes...")
5255
if popErr := g.StashPop(st.StashRef); popErr != nil {
53-
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr)
56+
fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(st.StashRef), popErr)
5457
}
5558
}
5659

57-
fmt.Printf("Cascade aborted. Original HEAD was %s\n", st.OriginalHead)
60+
fmt.Printf("%s Cascade aborted. Original HEAD was %s\n", s.WarningIcon(), st.OriginalHead)
5861
return nil
5962
}

cmd/adopt.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/boneskull/gh-stack/internal/config"
99
"github.com/boneskull/gh-stack/internal/git"
10+
"github.com/boneskull/gh-stack/internal/style"
1011
"github.com/boneskull/gh-stack/internal/tree"
1112
"github.com/spf13/cobra"
1213
)
@@ -103,6 +104,7 @@ func runAdopt(cmd *cobra.Command, args []string) error {
103104
_ = cfg.SetForkPoint(branchName, forkPoint) //nolint:errcheck // best effort
104105
}
105106

106-
fmt.Printf("Adopted branch %q with parent %q\n", branchName, parent)
107+
s := style.New()
108+
fmt.Printf("%s Adopted branch %s with parent %s\n", s.SuccessIcon(), s.Branch(branchName), s.Branch(parent))
107109
return nil
108110
}

cmd/cascade.go

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/boneskull/gh-stack/internal/config"
1010
"github.com/boneskull/gh-stack/internal/git"
1111
"github.com/boneskull/gh-stack/internal/state"
12+
"github.com/boneskull/gh-stack/internal/style"
1213
"github.com/boneskull/gh-stack/internal/tree"
1314
"github.com/boneskull/gh-stack/internal/undo"
1415
"github.com/spf13/cobra"
@@ -36,6 +37,8 @@ func init() {
3637
}
3738

3839
func runCascade(cmd *cobra.Command, args []string) error {
40+
s := style.New()
41+
3942
cwd, err := os.Getwd()
4043
if err != nil {
4144
return err
@@ -81,19 +84,19 @@ func runCascade(cmd *cobra.Command, args []string) error {
8184
var stashRef string
8285
if !cascadeDryRunFlag {
8386
var saveErr error
84-
stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "cascade", "gh stack cascade")
87+
stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "cascade", "gh stack cascade", s)
8588
if saveErr != nil {
86-
fmt.Printf("Warning: could not save undo state: %v\n", saveErr)
89+
fmt.Printf("%s could not save undo state: %v\n", s.WarningIcon(), saveErr)
8790
}
8891
}
8992

90-
err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef)
93+
err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, false, nil, stashRef, s)
9194

9295
// Restore auto-stashed changes after operation (unless conflict, which saves stash in state)
9396
if stashRef != "" && err != ErrConflict {
9497
fmt.Println("Restoring auto-stashed changes...")
9598
if popErr := g.StashPop(stashRef); popErr != nil {
96-
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(stashRef), popErr)
99+
fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(stashRef), popErr)
97100
}
98101
}
99102

@@ -103,7 +106,7 @@ func runCascade(cmd *cobra.Command, args []string) error {
103106
// doCascadeWithState performs cascade and saves state with the given operation type.
104107
// allBranches is the complete list of branches for submit operations (used for push/PR after continue).
105108
// stashRef is the commit hash of auto-stashed changes (if any), persisted to state on conflict.
106-
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly, web, pushOnly bool, allBranches []string, stashRef string) error {
109+
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly, web, pushOnly bool, allBranches []string, stashRef string, s *style.Style) error {
107110
originalBranch, err := g.CurrentBranch()
108111
if err != nil {
109112
return err
@@ -126,12 +129,12 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
126129
}
127130

128131
if !needsRebase {
129-
fmt.Printf("Cascading %s... already up to date\n", b.Name)
132+
fmt.Printf("Cascading %s... %s\n", s.Branch(b.Name), s.Muted("already up to date"))
130133
continue
131134
}
132135

133136
if dryRun {
134-
fmt.Printf("Would rebase %s onto %s\n", b.Name, parent)
137+
fmt.Printf("%s Would rebase %s onto %s\n", s.Muted("dry-run:"), s.Branch(b.Name), s.Branch(parent))
135138
continue
136139
}
137140

@@ -150,9 +153,9 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
150153
}
151154

152155
if useOnto {
153-
fmt.Printf("Cascading %s onto %s (using fork point)...\n", b.Name, parent)
156+
fmt.Printf("Cascading %s onto %s %s...\n", s.Branch(b.Name), s.Branch(parent), s.Muted("(using fork point)"))
154157
} else {
155-
fmt.Printf("Cascading %s onto %s...\n", b.Name, parent)
158+
fmt.Printf("Cascading %s onto %s...\n", s.Branch(b.Name), s.Branch(parent))
156159
}
157160

158161
// Checkout and rebase
@@ -187,15 +190,15 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
187190
}
188191
_ = state.Save(g.GetGitDir(), st) //nolint:errcheck // best effort - user can recover manually
189192

190-
fmt.Printf("\nCONFLICT: Resolve conflicts and run 'gh stack continue', or 'gh stack abort' to cancel.\n")
193+
fmt.Printf("\n%s %s\n", s.FailureIcon(), s.Error("CONFLICT: Resolve conflicts and run 'gh stack continue', or 'gh stack abort' to cancel."))
191194
fmt.Printf("Remaining branches: %v\n", remaining)
192195
if stashRef != "" {
193-
fmt.Printf("Note: Your uncommitted changes are stashed and will be restored when you continue or abort.\n")
196+
fmt.Println(s.Muted("Note: Your uncommitted changes are stashed and will be restored when you continue or abort."))
194197
}
195198
return ErrConflict
196199
}
197200

198-
fmt.Printf("Cascading %s... ok\n", b.Name)
201+
fmt.Printf("Cascading %s... %s\n", s.Branch(b.Name), s.Success("ok"))
199202

200203
// Update fork point to current parent tip
201204
parentTip, tipErr := g.GetTip(parent)
@@ -217,7 +220,7 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
217220
// branches: branches that will be modified (rebased)
218221
// deletedBranches: branches that will be deleted (for sync)
219222
// Returns the stash ref (commit hash) if changes were stashed, empty string otherwise.
220-
func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, deletedBranches []*tree.Node, operation, command string) (string, error) {
223+
func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, deletedBranches []*tree.Node, operation, command string, s *style.Style) (string, error) {
221224
gitDir := g.GetGitDir()
222225

223226
// Get current branch for original head
@@ -243,7 +246,7 @@ func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, del
243246
}
244247
if stashRef != "" {
245248
snapshot.StashRef = stashRef
246-
fmt.Println("Auto-stashed uncommitted changes")
249+
fmt.Println(s.Muted("Auto-stashed uncommitted changes"))
247250
}
248251
}
249252

@@ -252,7 +255,7 @@ func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, del
252255
bs, captureErr := captureBranchState(g, cfg, node.Name)
253256
if captureErr != nil {
254257
// Non-fatal: log warning and continue
255-
fmt.Printf("Warning: could not capture state for %s: %v\n", node.Name, captureErr)
258+
fmt.Printf("%s could not capture state for %s: %v\n", s.WarningIcon(), s.Branch(node.Name), captureErr)
256259
continue
257260
}
258261
snapshot.Branches[node.Name] = bs
@@ -262,7 +265,7 @@ func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, del
262265
for _, node := range deletedBranches {
263266
bs, captureErr := captureBranchState(g, cfg, node.Name)
264267
if captureErr != nil {
265-
fmt.Printf("Warning: could not capture state for deleted branch %s: %v\n", node.Name, captureErr)
268+
fmt.Printf("%s could not capture state for deleted branch %s: %v\n", s.WarningIcon(), s.Branch(node.Name), captureErr)
266269
continue
267270
}
268271
snapshot.DeletedBranches[node.Name] = bs
@@ -278,7 +281,7 @@ func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, del
278281
// saveUndoSnapshotByName is like saveUndoSnapshot but takes branch names instead of tree nodes.
279282
// Useful for sync where we don't always have tree nodes.
280283
// Returns the stash ref (commit hash) if changes were stashed, empty string otherwise.
281-
func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string, deletedBranchNames []string, operation, command string) (string, error) {
284+
func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string, deletedBranchNames []string, operation, command string, s *style.Style) (string, error) {
282285
gitDir := g.GetGitDir()
283286

284287
// Get current branch for original head
@@ -304,15 +307,15 @@ func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string
304307
}
305308
if stashRef != "" {
306309
snapshot.StashRef = stashRef
307-
fmt.Println("Auto-stashed uncommitted changes")
310+
fmt.Println(s.Muted("Auto-stashed uncommitted changes"))
308311
}
309312
}
310313

311314
// Capture state of branches that will be modified
312315
for _, name := range branchNames {
313316
bs, captureErr := captureBranchState(g, cfg, name)
314317
if captureErr != nil {
315-
fmt.Printf("Warning: could not capture state for %s: %v\n", name, captureErr)
318+
fmt.Printf("%s could not capture state for %s: %v\n", s.WarningIcon(), s.Branch(name), captureErr)
316319
continue
317320
}
318321
snapshot.Branches[name] = bs
@@ -322,7 +325,7 @@ func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string
322325
for _, name := range deletedBranchNames {
323326
bs, captureErr := captureBranchState(g, cfg, name)
324327
if captureErr != nil {
325-
fmt.Printf("Warning: could not capture state for deleted branch %s: %v\n", name, captureErr)
328+
fmt.Printf("%s could not capture state for deleted branch %s: %v\n", s.WarningIcon(), s.Branch(name), captureErr)
326329
continue
327330
}
328331
snapshot.DeletedBranches[name] = bs

cmd/continue.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/boneskull/gh-stack/internal/config"
99
"github.com/boneskull/gh-stack/internal/git"
1010
"github.com/boneskull/gh-stack/internal/state"
11+
"github.com/boneskull/gh-stack/internal/style"
1112
"github.com/boneskull/gh-stack/internal/tree"
1213
"github.com/spf13/cobra"
1314
)
@@ -24,6 +25,8 @@ func init() {
2425
}
2526

2627
func runContinue(cmd *cobra.Command, args []string) error {
28+
s := style.New()
29+
2730
cwd, err := os.Getwd()
2831
if err != nil {
2932
return err
@@ -45,7 +48,7 @@ func runContinue(cmd *cobra.Command, args []string) error {
4548
}
4649
}
4750

48-
fmt.Printf("Completed %s\n", st.Current)
51+
fmt.Printf("%s Completed %s\n", s.SuccessIcon(), s.Branch(st.Current))
4952

5053
cfg, err := config.Load(cwd)
5154
if err != nil {
@@ -70,12 +73,12 @@ func runContinue(cmd *cobra.Command, args []string) error {
7073
// Remove state file before continuing (will be recreated if conflict)
7174
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup
7275

73-
if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.PushOnly, st.Branches, st.StashRef); cascadeErr != nil {
76+
if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.PushOnly, st.Branches, st.StashRef, s); cascadeErr != nil {
7477
// Stash handling is done by doCascadeWithState (conflict saves in state, errors restore)
7578
if cascadeErr != ErrConflict && st.StashRef != "" {
7679
fmt.Println("Restoring auto-stashed changes...")
7780
if popErr := g.StashPop(st.StashRef); popErr != nil {
78-
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr)
81+
fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(st.StashRef), popErr)
7982
}
8083
}
8184
return cascadeErr // Another conflict - state saved
@@ -111,12 +114,12 @@ func runContinue(cmd *cobra.Command, args []string) error {
111114
allBranches = append(allBranches, node)
112115
}
113116

114-
err = doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly, st.Web, st.PushOnly)
117+
err = doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly, st.Web, st.PushOnly, s)
115118
// Restore stash after submit completes
116119
if st.StashRef != "" {
117120
fmt.Println("Restoring auto-stashed changes...")
118121
if popErr := g.StashPop(st.StashRef); popErr != nil {
119-
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr)
122+
fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(st.StashRef), popErr)
120123
}
121124
}
122125
return err
@@ -126,10 +129,10 @@ func runContinue(cmd *cobra.Command, args []string) error {
126129
if st.StashRef != "" {
127130
fmt.Println("Restoring auto-stashed changes...")
128131
if popErr := g.StashPop(st.StashRef); popErr != nil {
129-
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr)
132+
fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(st.StashRef), popErr)
130133
}
131134
}
132135

133-
fmt.Println("Cascade complete!")
136+
fmt.Println(s.SuccessMessage("Cascade complete!"))
134137
return nil
135138
}

cmd/create.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/boneskull/gh-stack/internal/config"
99
"github.com/boneskull/gh-stack/internal/git"
10+
"github.com/boneskull/gh-stack/internal/style"
1011
"github.com/spf13/cobra"
1112
)
1213

@@ -84,12 +85,14 @@ func runCreate(cmd *cobra.Command, args []string) error {
8485
return err
8586
}
8687

88+
s := style.New()
89+
8790
// Commit staged changes if any
8891
if hasStaged && !createEmptyFlag && createMessageFlag != "" {
8992
if err := g.Commit(createMessageFlag); err != nil {
9093
return err
9194
}
92-
fmt.Printf("Committed staged changes: %s\n", createMessageFlag)
95+
fmt.Printf("%s Committed staged changes: %s\n", s.SuccessIcon(), createMessageFlag)
9396
}
9497

9598
// Set parent
@@ -103,6 +106,6 @@ func runCreate(cmd *cobra.Command, args []string) error {
103106
_ = cfg.SetForkPoint(branchName, forkPoint) //nolint:errcheck // best effort
104107
}
105108

106-
fmt.Printf("Created branch %q stacked on %q\n", branchName, currentBranch)
109+
fmt.Printf("%s Created branch %s stacked on %s\n", s.SuccessIcon(), s.Branch(branchName), s.Branch(currentBranch))
107110
return nil
108111
}

0 commit comments

Comments
 (0)