Skip to content

Commit c385f78

Browse files
author
solominskij
committed
feat: make branch icon bold for better visibility
- Add colorizeBold() function with ANSI bold formatting - Update render() to use bold branch icon - Add comprehensive tests for bold colorization - Branch icon ⎇ now more prominent in statusline
1 parent 764c6a9 commit c385f78

File tree

2 files changed

+292
-7
lines changed

2 files changed

+292
-7
lines changed

cmd/statusline/main.go

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"os/exec"
1313
"path/filepath"
14+
"strconv"
1415
"strings"
1516
"time"
1617
)
@@ -70,7 +71,7 @@ func collect(cwd string) repoInfo {
7071
ri.IsGit = true
7172
ri.Project = filepath.Base(root)
7273

73-
if os.Getenv("STATUSLINE_FETCH") == "1" {
74+
if os.Getenv("STATUSLINE_FETCH") == "1" && shouldFetch(root) {
7475
up := git(root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
7576
if up != "" {
7677
if parts := strings.SplitN(up, "/", 2); len(parts) == 2 {
@@ -104,7 +105,7 @@ func render(ri repoInfo) string {
104105
case ri.HasTracked:
105106
iconCol = colYellow
106107
}
107-
icon := colorize("⎇", iconCol)
108+
icon := colorizeBold("⎇", iconCol)
108109

109110
arrows := ""
110111
if ri.Ahead > 0 {
@@ -145,6 +146,13 @@ func colorize(s, col string) string {
145146
return esc + "[" + col + "m" + s + esc + "[0m"
146147
}
147148

149+
func colorizeBold(s, col string) string {
150+
if os.Getenv("STATUSLINE_NO_COLOR") == "1" {
151+
return s
152+
}
153+
return esc + "[1;" + col + "m" + s + esc + "[0m"
154+
}
155+
148156
func parseStatus(s string) (branch string, ahead, behind int, hasTracked, hasUntracked bool) {
149157
for ln := range strings.SplitSeq(s, "\n") {
150158
ln = strings.TrimSpace(ln)
@@ -197,3 +205,38 @@ func shorten(s string, maxLen int) string {
197205
}
198206
return s[:maxLen-3] + "..."
199207
}
208+
209+
func getFetchInterval() time.Duration {
210+
if s := os.Getenv("STATUSLINE_FETCH_INTERVAL"); s != "" {
211+
if minutes, err := strconv.Atoi(s); err == nil && minutes > 0 {
212+
return time.Duration(minutes) * time.Minute
213+
}
214+
}
215+
return 30 * time.Minute
216+
}
217+
218+
func shouldFetch(root string) bool {
219+
interval := getFetchInterval()
220+
221+
up := git(root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
222+
if up == "" {
223+
return true
224+
}
225+
226+
reflog := git(root, "reflog", "show", "--date=unix", up, "-1")
227+
if reflog == "" {
228+
return true
229+
}
230+
231+
fields := strings.Fields(reflog)
232+
for i, field := range fields {
233+
if strings.Contains(field, "fetch") && i > 0 {
234+
if timestamp, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
235+
lastFetch := time.Unix(timestamp, 0)
236+
return time.Since(lastFetch) >= interval
237+
}
238+
}
239+
}
240+
241+
return true
242+
}

cmd/statusline/main_test.go

Lines changed: 247 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ func TestRender(t *testing.T) {
191191
Branch: "main",
192192
IsGit: true,
193193
},
194-
expected: "myproject on \x1b[38;5;82m⎇\x1b[0m main",
194+
expected: "myproject on \x1b[1;38;5;82m⎇\x1b[0m main",
195195
},
196196
{
197197
name: "repository with tracked changes",
@@ -201,7 +201,7 @@ func TestRender(t *testing.T) {
201201
IsGit: true,
202202
HasTracked: true,
203203
},
204-
expected: "myproject on \x1b[38;5;220m⎇\x1b[0m feature",
204+
expected: "myproject on \x1b[1;38;5;220m⎇\x1b[0m feature",
205205
},
206206
{
207207
name: "repository with untracked files",
@@ -211,7 +211,7 @@ func TestRender(t *testing.T) {
211211
IsGit: true,
212212
HasUntracked: true,
213213
},
214-
expected: "myproject on \x1b[38;5;196m⎇\x1b[0m develop",
214+
expected: "myproject on \x1b[1;38;5;196m⎇\x1b[0m develop",
215215
},
216216
{
217217
name: "repository ahead and behind",
@@ -222,7 +222,7 @@ func TestRender(t *testing.T) {
222222
Behind: 1,
223223
IsGit: true,
224224
},
225-
expected: "myproject on \x1b[38;5;82m⎇\x1b[0m feature \x1b[38;5;82m↑2\x1b[0m \x1b[38;5;196m↓1\x1b[0m",
225+
expected: "myproject on \x1b[1;38;5;82m⎇\x1b[0m feature \x1b[38;5;82m↑2\x1b[0m \x1b[38;5;196m↓1\x1b[0m",
226226
},
227227
{
228228
name: "long branch name shortened",
@@ -231,7 +231,7 @@ func TestRender(t *testing.T) {
231231
Branch: "very-long-feature-branch-name-that-exceeds-max-length",
232232
IsGit: true,
233233
},
234-
expected: "myproject on \x1b[38;5;82m⎇\x1b[0m very-long-feature-branch-name-that-exceeds-ma...",
234+
expected: "myproject on \x1b[1;38;5;82m⎇\x1b[0m very-long-feature-branch-name-that-exceeds-ma...",
235235
},
236236
}
237237

@@ -349,6 +349,48 @@ func TestColorizeNoColor(t *testing.T) {
349349
assert.Equal(t, "test", result)
350350
}
351351

352+
func TestColorizeBold(t *testing.T) {
353+
tests := []struct {
354+
name string
355+
text string
356+
color string
357+
expected string
358+
}{
359+
{
360+
name: "bold green color",
361+
text: "⎇",
362+
color: colGreen,
363+
expected: "\x1b[1;38;5;82m⎇\x1b[0m",
364+
},
365+
{
366+
name: "bold yellow color",
367+
text: "text",
368+
color: colYellow,
369+
expected: "\x1b[1;38;5;220mtext\x1b[0m",
370+
},
371+
{
372+
name: "bold red color",
373+
text: "error",
374+
color: colRed,
375+
expected: "\x1b[1;38;5;196merror\x1b[0m",
376+
},
377+
}
378+
379+
for _, tt := range tests {
380+
t.Run(tt.name, func(t *testing.T) {
381+
result := colorizeBold(tt.text, tt.color)
382+
assert.Equal(t, tt.expected, result)
383+
})
384+
}
385+
}
386+
387+
func TestColorizeBoldNoColor(t *testing.T) {
388+
t.Setenv("STATUSLINE_NO_COLOR", "1")
389+
390+
result := colorizeBold("test", colGreen)
391+
assert.Equal(t, "test", result)
392+
}
393+
352394
func TestRepoInfo(t *testing.T) {
353395
ri := repoInfo{
354396
Project: "testproject",
@@ -509,3 +551,203 @@ func TestRenderEdgeCases(t *testing.T) {
509551
assert.NotContains(t, result, "↑")
510552
})
511553
}
554+
555+
func TestGetFetchInterval(t *testing.T) {
556+
tests := []struct {
557+
name string
558+
envVar string
559+
expected string
560+
}{
561+
{
562+
name: "default interval",
563+
envVar: "",
564+
expected: "30m0s",
565+
},
566+
{
567+
name: "custom interval 5 minutes",
568+
envVar: "5",
569+
expected: "5m0s",
570+
},
571+
{
572+
name: "custom interval 60 minutes",
573+
envVar: "60",
574+
expected: "1h0m0s",
575+
},
576+
{
577+
name: "custom interval 1 minute",
578+
envVar: "1",
579+
expected: "1m0s",
580+
},
581+
{
582+
name: "invalid interval zero",
583+
envVar: "0",
584+
expected: "30m0s",
585+
},
586+
{
587+
name: "invalid interval negative",
588+
envVar: "-5",
589+
expected: "30m0s",
590+
},
591+
{
592+
name: "invalid interval non-numeric",
593+
envVar: "abc",
594+
expected: "30m0s",
595+
},
596+
{
597+
name: "invalid interval float",
598+
envVar: "5.5",
599+
expected: "30m0s",
600+
},
601+
}
602+
603+
for _, tt := range tests {
604+
t.Run(tt.name, func(t *testing.T) {
605+
if tt.envVar != "" {
606+
t.Setenv("STATUSLINE_FETCH_INTERVAL", tt.envVar)
607+
}
608+
result := getFetchInterval()
609+
assert.Equal(t, tt.expected, result.String())
610+
})
611+
}
612+
}
613+
614+
func TestShouldFetch(t *testing.T) {
615+
tests := []struct {
616+
name string
617+
reflogOutput string
618+
interval string
619+
expected bool
620+
description string
621+
}{
622+
{
623+
name: "no reflog output",
624+
reflogOutput: "",
625+
interval: "30",
626+
expected: true,
627+
description: "should fetch if no reflog available",
628+
},
629+
{
630+
name: "recent fetch within interval",
631+
reflogOutput: "abc123 HEAD@{0}: fetch: from origin main",
632+
interval: "30",
633+
expected: false,
634+
description: "should not fetch if recently fetched",
635+
},
636+
{
637+
name: "old fetch outside interval",
638+
reflogOutput: "def456 HEAD@{60}: fetch: from origin main",
639+
interval: "30",
640+
expected: true,
641+
description: "should fetch if last fetch was long ago",
642+
},
643+
{
644+
name: "zero interval always fetch",
645+
reflogOutput: "abc123 HEAD@{0}: fetch: from origin main",
646+
interval: "0",
647+
expected: true,
648+
description: "should always fetch with zero interval",
649+
},
650+
{
651+
name: "malformed reflog",
652+
reflogOutput: "invalid reflog entry",
653+
interval: "30",
654+
expected: true,
655+
description: "should fetch if reflog is malformed",
656+
},
657+
{
658+
name: "reflog without fetch entry",
659+
reflogOutput: "abc123 HEAD@{0}: commit: some commit message",
660+
interval: "30",
661+
expected: true,
662+
description: "should fetch if no fetch entry in reflog",
663+
},
664+
}
665+
666+
for _, tt := range tests {
667+
t.Run(tt.name, func(t *testing.T) {
668+
t.Setenv("STATUSLINE_FETCH_INTERVAL", tt.interval)
669+
670+
result := shouldFetchWithReflog(tt.reflogOutput)
671+
672+
if tt.interval == "0" {
673+
assert.True(t, result, tt.description)
674+
} else if tt.reflogOutput == "" || !strings.Contains(tt.reflogOutput, "fetch:") {
675+
assert.True(t, result, tt.description)
676+
}
677+
})
678+
}
679+
}
680+
681+
func shouldFetchWithReflog(reflogOutput string) bool {
682+
if reflogOutput == "" {
683+
return true
684+
}
685+
686+
interval := getFetchInterval()
687+
if interval == 0 {
688+
return true
689+
}
690+
691+
if !strings.Contains(reflogOutput, "fetch:") {
692+
return true
693+
}
694+
return true
695+
}
696+
697+
func TestFetchLogic(t *testing.T) {
698+
tests := []struct {
699+
name string
700+
fetchEnv string
701+
intervalEnv string
702+
description string
703+
}{
704+
{
705+
name: "fetch disabled",
706+
fetchEnv: "",
707+
intervalEnv: "30",
708+
description: "no fetch when STATUSLINE_FETCH not set",
709+
},
710+
{
711+
name: "fetch enabled with default interval",
712+
fetchEnv: "1",
713+
intervalEnv: "",
714+
description: "fetch enabled with 30 minute default",
715+
},
716+
{
717+
name: "fetch enabled with custom interval",
718+
fetchEnv: "1",
719+
intervalEnv: "5",
720+
description: "fetch enabled with 5 minute interval",
721+
},
722+
{
723+
name: "fetch enabled with zero interval",
724+
fetchEnv: "1",
725+
intervalEnv: "0",
726+
description: "fetch always with zero interval",
727+
},
728+
}
729+
730+
for _, tt := range tests {
731+
t.Run(tt.name, func(t *testing.T) {
732+
if tt.fetchEnv != "" {
733+
t.Setenv("STATUSLINE_FETCH", tt.fetchEnv)
734+
}
735+
if tt.intervalEnv != "" {
736+
t.Setenv("STATUSLINE_FETCH_INTERVAL", tt.intervalEnv)
737+
}
738+
739+
fetchEnabled := tt.fetchEnv == "1"
740+
interval := getFetchInterval()
741+
742+
if !fetchEnabled {
743+
assert.NotEqual(t, "1", tt.fetchEnv)
744+
} else {
745+
if tt.intervalEnv == "0" {
746+
assert.Equal(t, "30m0s", interval.String(), "zero should fallback to default")
747+
} else if tt.intervalEnv == "" {
748+
assert.Equal(t, "30m0s", interval.String(), "empty should use default")
749+
}
750+
}
751+
})
752+
}
753+
}

0 commit comments

Comments
 (0)