Skip to content

Commit 986265a

Browse files
authored
feat: add color-coded output to wt status (#79)
* feat: add color-coded output to wt status Adds ANSI colors to status output: green for clean, red for dirty, bold for current worktree, yellow for ahead/behind. Respects NO_COLOR env var and disables colors when piped or in JSON mode. * docs: update examples and README for color output
1 parent 818d986 commit 986265a

7 files changed

Lines changed: 357 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Inspired by [haacked/dotfiles/tree-me](https://github.com/haacked/dotfiles/blob/
2121
- GitLab MR support via `wt mr` command (uses `glab` CLI) — checks out the MR's actual branch name
2222
- **Pre/post command hooks** — run custom scripts (e.g. copy `.env`, install deps) on create/checkout/remove
2323
- **Stale worktree detection** — find worktrees with deleted remote branches or inactive commits (`wt cleanup --stale`)
24+
- **Color-coded status output** — green (clean), red (dirty), yellow (ahead/behind), bold cyan (current); respects `NO_COLOR=1` and auto-strips colors when piped
2425
- Shell integration with auto-cd functionality
2526
- Tab completion for Bash and Zsh
2627

color.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"golang.org/x/term"
8+
)
9+
10+
// ANSI escape code constants.
11+
const (
12+
ansiReset = "\033[0m"
13+
ansiBold = "1"
14+
ansiDim = "2"
15+
ansiRed = "31"
16+
ansiGreen = "32"
17+
ansiYellow = "33"
18+
ansiCyan = "36"
19+
ansiCyanRaw = "36" // for combining with other codes
20+
)
21+
22+
// colorize wraps s in ANSI escape codes. The code parameter can be a single
23+
// code (e.g. "31" for red) or combined codes separated by semicolons
24+
// (e.g. "1;36" for bold cyan).
25+
func colorize(s string, code string) string {
26+
return fmt.Sprintf("\033[%sm%s%s", code, s, ansiReset)
27+
}
28+
29+
// isColorEnabled returns true if color output should be used.
30+
// Color is disabled when:
31+
// - the NO_COLOR environment variable is set (any value, per https://no-color.org/)
32+
// - stdout is not a terminal (i.e., output is piped)
33+
func isColorEnabled() bool {
34+
if _, ok := os.LookupEnv("NO_COLOR"); ok {
35+
return false
36+
}
37+
return term.IsTerminal(int(os.Stdout.Fd()))
38+
}

color_test.go

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestColorize(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
text string
12+
code string
13+
want string
14+
}{
15+
{
16+
name: "Bold text",
17+
text: "hello",
18+
code: ansiBold,
19+
want: "\033[1mhello\033[0m",
20+
},
21+
{
22+
name: "Red text",
23+
text: "dirty",
24+
code: ansiRed,
25+
want: "\033[31mdirty\033[0m",
26+
},
27+
{
28+
name: "Green text",
29+
text: "clean",
30+
code: ansiGreen,
31+
want: "\033[32mclean\033[0m",
32+
},
33+
{
34+
name: "Yellow text",
35+
text: "3",
36+
code: ansiYellow,
37+
want: "\033[33m3\033[0m",
38+
},
39+
{
40+
name: "Cyan text",
41+
text: "*",
42+
code: ansiCyan,
43+
want: "\033[36m*\033[0m",
44+
},
45+
{
46+
name: "Dim text",
47+
text: "no upstream",
48+
code: ansiDim,
49+
want: "\033[2mno upstream\033[0m",
50+
},
51+
{
52+
name: "Empty string",
53+
text: "",
54+
code: ansiBold,
55+
want: "\033[1m\033[0m",
56+
},
57+
{
58+
name: "Combined bold+cyan",
59+
text: "*",
60+
code: ansiBold + ";" + ansiCyanRaw,
61+
want: "\033[1;36m*\033[0m",
62+
},
63+
}
64+
65+
for _, tt := range tests {
66+
t.Run(tt.name, func(t *testing.T) {
67+
got := colorize(tt.text, tt.code)
68+
if got != tt.want {
69+
t.Errorf("colorize(%q, %q) = %q, want %q", tt.text, tt.code, got, tt.want)
70+
}
71+
})
72+
}
73+
}
74+
75+
func TestIsColorEnabledRespectsNO_COLOR(t *testing.T) {
76+
// When NO_COLOR is set, color should be disabled regardless
77+
t.Setenv("NO_COLOR", "1")
78+
if isColorEnabled() {
79+
t.Error("isColorEnabled() = true, want false when NO_COLOR is set")
80+
}
81+
}
82+
83+
func TestIsColorEnabledRespectsEmptyNO_COLOR(t *testing.T) {
84+
// When NO_COLOR is set to empty string, it still counts per no-color.org spec
85+
t.Setenv("NO_COLOR", "")
86+
if isColorEnabled() {
87+
t.Error("isColorEnabled() = true, want false when NO_COLOR is set (even empty)")
88+
}
89+
}
90+
91+
func TestFormatStatusLineNoColor(t *testing.T) {
92+
// When color is disabled, formatStatusLine should produce the same output as before
93+
tests := []struct {
94+
name string
95+
entry worktreeStatus
96+
}{
97+
{
98+
name: "Current worktree dirty with ahead/behind",
99+
entry: worktreeStatus{
100+
Path: "/path/to/worktree",
101+
Branch: "feat/foo",
102+
HEAD: "abc1234",
103+
Dirty: true,
104+
Ahead: 2,
105+
Behind: 1,
106+
Current: true,
107+
HasUpstream: true,
108+
},
109+
},
110+
{
111+
name: "Non-current clean worktree",
112+
entry: worktreeStatus{
113+
Path: "/path/to/main",
114+
Branch: "main",
115+
HEAD: "def5678",
116+
Dirty: false,
117+
Ahead: 0,
118+
Behind: 0,
119+
Current: false,
120+
HasUpstream: true,
121+
},
122+
},
123+
{
124+
name: "No upstream",
125+
entry: worktreeStatus{
126+
Path: "/path/to/wt",
127+
Branch: "fix/bar",
128+
HEAD: "ghi9012",
129+
Dirty: false,
130+
Ahead: 0,
131+
Behind: 0,
132+
Current: false,
133+
HasUpstream: false,
134+
},
135+
},
136+
}
137+
138+
for _, tt := range tests {
139+
t.Run(tt.name, func(t *testing.T) {
140+
noColor := formatStatusLineColor(tt.entry, false)
141+
// Should not contain any ANSI escape sequences
142+
if strings.Contains(noColor, "\033[") {
143+
t.Errorf("formatStatusLineColor(color=false) contains ANSI codes: %q", noColor)
144+
}
145+
// Verify same content as original formatStatusLine
146+
original := formatStatusLine(tt.entry)
147+
if noColor != original {
148+
t.Errorf("formatStatusLineColor(color=false) = %q, want %q (same as formatStatusLine)", noColor, original)
149+
}
150+
})
151+
}
152+
}
153+
154+
func TestFormatStatusLineWithColor(t *testing.T) {
155+
tests := []struct {
156+
name string
157+
entry worktreeStatus
158+
wantContains []string
159+
wantNotContain []string
160+
}{
161+
{
162+
name: "Current dirty worktree with ahead/behind",
163+
entry: worktreeStatus{
164+
Path: "/path/to/worktree",
165+
Branch: "feat/foo",
166+
HEAD: "abc1234",
167+
Dirty: true,
168+
Ahead: 2,
169+
Behind: 1,
170+
Current: true,
171+
HasUpstream: true,
172+
},
173+
wantContains: []string{
174+
"\033[1;36m*\033[0m", // bold+cyan marker
175+
"\033[1mfeat/foo\033[0m", // bold branch
176+
"\033[31mdirty\033[0m", // red dirty
177+
"\033[33m", // yellow for ahead/behind
178+
},
179+
},
180+
{
181+
name: "Non-current clean worktree",
182+
entry: worktreeStatus{
183+
Path: "/path/to/main",
184+
Branch: "main",
185+
HEAD: "def5678",
186+
Dirty: false,
187+
Ahead: 0,
188+
Behind: 0,
189+
Current: false,
190+
HasUpstream: true,
191+
},
192+
wantContains: []string{
193+
"\033[32mclean\033[0m", // green clean
194+
},
195+
wantNotContain: []string{
196+
"\033[1;36m*\033[0m", // should NOT have colored marker
197+
},
198+
},
199+
{
200+
name: "No upstream",
201+
entry: worktreeStatus{
202+
Path: "/path/to/wt",
203+
Branch: "fix/bar",
204+
HEAD: "ghi9012",
205+
Dirty: false,
206+
Ahead: 0,
207+
Behind: 0,
208+
Current: false,
209+
HasUpstream: false,
210+
},
211+
wantContains: []string{
212+
"\033[2mno upstream\033[0m", // dim no upstream
213+
},
214+
},
215+
{
216+
name: "Ahead only colored yellow",
217+
entry: worktreeStatus{
218+
Path: "/path/to/wt",
219+
Branch: "feat/x",
220+
HEAD: "abc123",
221+
Dirty: false,
222+
Ahead: 5,
223+
Behind: 0,
224+
Current: false,
225+
HasUpstream: true,
226+
},
227+
wantContains: []string{
228+
"\033[33m↑5\033[0m", // yellow ahead
229+
},
230+
},
231+
{
232+
name: "Behind only colored yellow",
233+
entry: worktreeStatus{
234+
Path: "/path/to/wt",
235+
Branch: "feat/y",
236+
HEAD: "abc123",
237+
Dirty: false,
238+
Ahead: 0,
239+
Behind: 3,
240+
Current: false,
241+
HasUpstream: true,
242+
},
243+
wantContains: []string{
244+
"\033[33m↓3\033[0m", // yellow behind
245+
},
246+
},
247+
}
248+
249+
for _, tt := range tests {
250+
t.Run(tt.name, func(t *testing.T) {
251+
got := formatStatusLineColor(tt.entry, true)
252+
for _, want := range tt.wantContains {
253+
if !strings.Contains(got, want) {
254+
t.Errorf("formatStatusLineColor(color=true) = %q, want it to contain %q", got, want)
255+
}
256+
}
257+
for _, notWant := range tt.wantNotContain {
258+
if strings.Contains(got, notWant) {
259+
t.Errorf("formatStatusLineColor(color=true) = %q, should NOT contain %q", got, notWant)
260+
}
261+
}
262+
})
263+
}
264+
}

examples.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -448,16 +448,17 @@ var exampleCatalog = map[string]exampleTopic{
448448
},
449449
"status": {
450450
Name: "status",
451-
Description: "Show status dashboard of all worktrees",
451+
Description: "Show color-coded status dashboard of all worktrees",
452452
Examples: []usageExample{
453453
{
454454
Command: "wt status",
455-
Purpose: "Show a dashboard of all worktrees with dirty/clean state and ahead/behind counts.",
456-
Outcome: "Human-readable table with branch, path, state, and tracking info for each worktree.",
455+
Purpose: "Show a color-coded dashboard of all worktrees with dirty/clean state and ahead/behind counts.",
456+
Outcome: "Human-readable table with branch, path, state, and tracking info for each worktree. Output is color-coded: green for clean worktrees, red for dirty worktrees, yellow for ahead/behind upstream, and bold cyan for the current worktree marker (*).",
457457
ExitCode: "0 on success; non-zero if not in a git repository.",
458458
TextExample: " main /path/to/main clean ↑0 ↓0\n* feat/foo /path/to/worktree dirty ↑2 ↓1",
459459
SideEffects: []string{"Read-only command."},
460460
FollowUp: []string{"wt list", "wt cleanup"},
461+
Notes: []string{"Colors are automatically stripped when output is piped to another command.", "Set NO_COLOR=1 to disable colors in interactive terminals."},
461462
},
462463
{
463464
Command: "wt --format json status",

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/timvw/wt
22

3-
go 1.24.0
3+
go 1.25.0
44

55
require (
66
github.com/BurntSushi/toml v1.6.0
@@ -17,5 +17,6 @@ require (
1717
github.com/spf13/pflag v1.0.9 // indirect
1818
github.com/u-root/u-root v0.11.0 // indirect
1919
golang.org/x/crypto v0.45.0 // indirect
20-
golang.org/x/sys v0.38.0 // indirect
20+
golang.org/x/sys v0.42.0 // indirect
21+
golang.org/x/term v0.41.0 // indirect
2122
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
99
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
1010
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
1111
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
12+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
1213
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
1314
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
1415
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -28,13 +29,18 @@ github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90 h1:zTk5683I9K
2829
github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90/go.mod h1:lYt+LVfZBBwDZ3+PHk4k/c/TnKOkjJXiJO73E32Mmpc=
2930
github.com/u-root/u-root v0.11.0 h1:6gCZLOeRyevw7gbTwMj3fKxnr9+yHFlgF3N7udUVNO8=
3031
github.com/u-root/u-root v0.11.0/go.mod h1:DBkDtiZyONk9hzVEdB/PWI9B4TxDkElWlVTHseglrZY=
32+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
3133
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
3234
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
3335
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
3436
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
3537
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
38+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
39+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
3640
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
3741
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
42+
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
43+
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
3844
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
3945
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
4046
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)