Skip to content

Commit 8c54f38

Browse files
knight42claude
andauthored
feat: colorize field managers in blame output (#44)
Each unique field manager gets a distinct, deterministic ANSI color based on an FNV hash of its name, making it easier to visually distinguish managers when there are many. Adds a --color flag (auto|always|never, default auto) that auto-detects TTY. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dacd357 commit 8c54f38

File tree

9 files changed

+155
-12
lines changed

9 files changed

+155
-12
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,6 @@ cat deployment.yaml | kubectl blame
5858
| flag | default | description |
5959
|--------------------|------------|--------------------------------------------------------------------------|
6060
| `--time` | `relative` | Time format. One of: `full`, `relative`, `none`. |
61+
| `--color` | `auto` | Color output. One of: `auto`, `always`, `never`. |
6162
| `--filename`, `-f` | | Filename identifying the resource to get from a server. |
6263
| `--input`, `-i` | `auto` | Read object from the given file. When set to `auto`, automatically read from stdin if piped. Use `-` to force reading from stdin. |

cmd/blame.go

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

88
"github.com/spf13/cobra"
9+
"golang.org/x/term"
910
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1011
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1112
"k8s.io/apimachinery/pkg/util/yaml"
@@ -16,6 +17,7 @@ import (
1617

1718
type Options struct {
1819
timeFormat string
20+
colorMode string
1921
inputFile string
2022
fileNameOpts resource.FilenameOptions
2123

@@ -78,6 +80,7 @@ func NewCmdBlame() *cobra.Command {
7880
flags := cmd.Flags()
7981
f.AddFlags(flags)
8082
flags.StringVar(&o.timeFormat, "time", TimeFormatRelative, "Time format. One of: full|relative|none.")
83+
flags.StringVar(&o.colorMode, "color", "auto", "Color output. One of: auto|always|never.")
8184
flags.StringSliceVarP(&o.fileNameOpts.Filenames, "filename", "f", o.fileNameOpts.Filenames, "Filename identifying the resource to get from a server.")
8285
flags.StringVarP(&o.inputFile, "input", "i", "auto", "Read object from the given file. When set to 'auto', automatically read from stdin if piped. Use '-' to force reading from stdin.")
8386
return cmd
@@ -174,9 +177,24 @@ func (o *Options) visitClusterObjects(visit func(object metav1.Object) error) er
174177
})
175178
}
176179

180+
func (o *Options) resolveColorEnabled() bool {
181+
switch o.colorMode {
182+
case "always":
183+
return true
184+
case "never":
185+
return false
186+
default: // "auto"
187+
if f, ok := o.out.(*os.File); ok {
188+
return term.IsTerminal(int(f.Fd()))
189+
}
190+
return false
191+
}
192+
}
193+
177194
func (o *Options) Run() error {
195+
colorizer := NewColorizer(o.resolveColorEnabled())
178196
enc := newEncoder(o.out, func(obj metav1.Object) ([]byte, error) {
179-
return MarshalMetaObject(obj, o.timeFormat)
197+
return MarshalMetaObject(obj, o.timeFormat, colorizer)
180198
})
181199

182200
if o.inputFile == "auto" {

cmd/color.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package cmd
2+
3+
import (
4+
"hash/fnv"
5+
6+
"github.com/fatih/color"
7+
)
8+
9+
var colorPalette = []color.Attribute{
10+
color.FgRed,
11+
color.FgGreen,
12+
color.FgYellow,
13+
color.FgBlue,
14+
color.FgMagenta,
15+
color.FgCyan,
16+
color.FgHiRed,
17+
color.FgHiGreen,
18+
color.FgHiYellow,
19+
color.FgHiBlue,
20+
color.FgHiMagenta,
21+
color.FgHiCyan,
22+
}
23+
24+
type Colorizer struct {
25+
enabled bool
26+
cache map[string]*color.Color
27+
}
28+
29+
func NewColorizer(enabled bool) *Colorizer {
30+
return &Colorizer{
31+
enabled: enabled,
32+
cache: make(map[string]*color.Color),
33+
}
34+
}
35+
36+
func (c *Colorizer) Sprint(managerName, s string) string {
37+
if c == nil || !c.enabled {
38+
return s
39+
}
40+
clr, ok := c.cache[managerName]
41+
if !ok {
42+
h := fnv.New32a()
43+
h.Write([]byte(managerName))
44+
idx := int(h.Sum32()) % len(colorPalette)
45+
clr = color.New(colorPalette[idx])
46+
c.cache[managerName] = clr
47+
}
48+
return clr.Sprint(s)
49+
}

cmd/color_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestColorizer_Determinism(t *testing.T) {
9+
c := NewColorizer(true)
10+
first := c.Sprint("manager-a", "some text")
11+
second := c.Sprint("manager-a", "some text")
12+
if first != second {
13+
t.Errorf("expected deterministic output, got %q and %q", first, second)
14+
}
15+
}
16+
17+
func TestColorizer_DifferentManagers(t *testing.T) {
18+
c := NewColorizer(true)
19+
a := c.Sprint("manager-a", "text")
20+
b := c.Sprint("manager-b", "text")
21+
// Different managers should (with high probability) produce different colored output.
22+
// Both should contain the original text.
23+
if !strings.Contains(a, "text") {
24+
t.Errorf("expected output to contain original text, got %q", a)
25+
}
26+
if !strings.Contains(b, "text") {
27+
t.Errorf("expected output to contain original text, got %q", b)
28+
}
29+
}
30+
31+
func TestColorizer_Disabled(t *testing.T) {
32+
c := NewColorizer(false)
33+
got := c.Sprint("manager-a", "plain text")
34+
if got != "plain text" {
35+
t.Errorf("expected unmodified string when disabled, got %q", got)
36+
}
37+
}
38+
39+
func TestColorizer_Nil(t *testing.T) {
40+
var c *Colorizer
41+
got := c.Sprint("manager-a", "plain text")
42+
if got != "plain text" {
43+
t.Errorf("expected unmodified string for nil colorizer, got %q", got)
44+
}
45+
}
46+
47+
func TestGetInfoOr_NilColorizer(t *testing.T) {
48+
node := &Node{
49+
Managers: []ManagerInfo{
50+
{Manager: "mgr", Operation: "Apply"},
51+
},
52+
}
53+
got := getInfoOr(node, "default", nil)
54+
if got != "mgr Apply " {
55+
t.Errorf("unexpected result: %q", got)
56+
}
57+
}

cmd/marshal.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type Marshaller struct {
3535

3636
now time.Time
3737
timeFormat string
38+
colorizer *Colorizer
3839
}
3940

4041
type ManagerInfo struct {
@@ -176,10 +177,11 @@ func (m *Marshaller) buildTree(managedFields []metav1.ManagedFieldsEntry, mgrMax
176177
return root, nil
177178
}
178179

179-
func MarshalMetaObject(obj metav1.Object, timeFmt string) ([]byte, error) {
180+
func MarshalMetaObject(obj metav1.Object, timeFmt string, colorizer *Colorizer) ([]byte, error) {
180181
m := Marshaller{
181182
now: time.Now(),
182183
timeFormat: timeFmt,
184+
colorizer: colorizer,
183185
}
184186
return m.marshalMetaObject(obj)
185187
}
@@ -262,10 +264,10 @@ func (m *Marshaller) marshalMapWithCtx(ctx Context, o map[string]interface{}, w
262264
} else {
263265
ok := child != nil
264266
if ok {
265-
info := getInfoOr(child, m.emptyInfo)
267+
info := getInfoOr(child, m.emptyInfo, m.colorizer)
266268
writeString(w, info)
267269
} else {
268-
info := getInfoOr(root, m.emptyInfo)
270+
info := getInfoOr(root, m.emptyInfo, m.colorizer)
269271
writeString(w, info)
270272
}
271273
writeIndent(w, ctx.Level)
@@ -323,7 +325,7 @@ func (m *Marshaller) marshalListWithCtx(ctx Context, o []interface{}, w io.Write
323325
}
324326

325327
root := ctx.Node
326-
prefix := getInfoOr(root, m.emptyInfo)
328+
prefix := getInfoOr(root, m.emptyInfo, m.colorizer)
327329
for i, val := range o {
328330
switch actual := val.(type) {
329331
case map[string]interface{}:
@@ -334,11 +336,11 @@ func (m *Marshaller) marshalListWithCtx(ctx Context, o []interface{}, w io.Write
334336
break
335337
}
336338
}
337-
mapPrefix := getInfoOr(child, m.emptyInfo)
339+
mapPrefix := getInfoOr(child, m.emptyInfo, m.colorizer)
338340
if len(actual) > 0 {
339341
firstKey := firstSortedMapKey(actual)
340342
if fc := child.Fields[firstKey]; fc != nil {
341-
mapPrefix = getInfoOr(fc, m.emptyInfo)
343+
mapPrefix = getInfoOr(fc, m.emptyInfo, m.colorizer)
342344
}
343345
}
344346
writeString(w, mapPrefix)
@@ -364,7 +366,7 @@ func (m *Marshaller) marshalListWithCtx(ctx Context, o []interface{}, w io.Write
364366
if root.Values != nil {
365367
s := value.ToString(value.NewValueInterface(val))
366368
if v, ok := root.Values[s]; ok {
367-
valPrefix = getInfoOr(v.Node, m.emptyInfo)
369+
valPrefix = getInfoOr(v.Node, m.emptyInfo, m.colorizer)
368370
}
369371
}
370372
writeString(w, valPrefix)

cmd/marshal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ m1 (Update 2020-11-23 16:52:45 +0000) /
232232
m2 (Update 2020-11-23 16:52:45 +0000) serviceAccountName: foo
233233
status: {}
234234
`
235-
data, err := MarshalMetaObject(pod, TimeFormatFull)
235+
data, err := MarshalMetaObject(pod, TimeFormatFull, nil)
236236
r.NoError(err)
237237
if diff := cmp.Diff(expected, string(data)); len(diff) > 0 {
238238
t.Errorf("unexpected diff (-want +got): %s", diff)

cmd/util.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,19 @@ import (
1515
"sigs.k8s.io/yaml"
1616
)
1717

18-
func getInfoOr(n *Node, defVal string) string {
18+
func getInfoOr(n *Node, defVal string, colorizer *Colorizer) string {
1919
for n != nil {
2020
if len(n.Managers) > 0 {
2121
var buf strings.Builder
2222
for i, info := range n.Managers {
2323
if i > 0 {
2424
buf.WriteString("/\n")
2525
}
26-
buf.WriteString(info.String())
26+
s := info.String()
27+
if colorizer != nil {
28+
s = colorizer.Sprint(strings.TrimSpace(info.Manager), s)
29+
}
30+
buf.WriteString(s)
2731
}
2832
return buf.String()
2933
}

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ go 1.24.0
55
toolchain go1.24.1
66

77
require (
8+
github.com/fatih/color v1.18.0
89
github.com/google/go-cmp v0.7.0
910
github.com/spf13/cobra v1.9.1
1011
github.com/stretchr/testify v1.10.0
12+
golang.org/x/term v0.32.0
1113
k8s.io/api v0.33.2
1214
k8s.io/apimachinery v0.33.2
1315
k8s.io/cli-runtime v0.33.2
@@ -38,6 +40,8 @@ require (
3840
github.com/json-iterator/go v1.1.12 // indirect
3941
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
4042
github.com/mailru/easyjson v0.7.7 // indirect
43+
github.com/mattn/go-colorable v0.1.13 // indirect
44+
github.com/mattn/go-isatty v0.0.20 // indirect
4145
github.com/moby/term v0.5.0 // indirect
4246
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
4347
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -55,7 +59,6 @@ require (
5559
golang.org/x/oauth2 v0.27.0 // indirect
5660
golang.org/x/sync v0.14.0 // indirect
5761
golang.org/x/sys v0.33.0 // indirect
58-
golang.org/x/term v0.32.0 // indirect
5962
golang.org/x/text v0.25.0 // indirect
6063
golang.org/x/time v0.9.0 // indirect
6164
google.golang.org/protobuf v1.36.5 // indirect

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
1111
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1212
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
1313
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
14+
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
15+
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
1416
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
1517
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
1618
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@@ -64,6 +66,11 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn
6466
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
6567
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
6668
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
69+
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
70+
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
71+
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
72+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
73+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
6774
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
6875
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
6976
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -140,6 +147,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
140147
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
141148
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
142149
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
150+
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
151+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
143152
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
144153
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
145154
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=

0 commit comments

Comments
 (0)