diff --git a/internal/output/formatter.go b/internal/output/formatter.go index c90192c..0e60cbf 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -150,15 +150,19 @@ func printSliceTable(w io.Writer, items []interface{}) error { return nil } - // Collect headers from first item - first, ok := items[0].(map[string]interface{}) - if !ok { + // If the first item isn't a map, fall back to JSON (unchanged behavior). + if _, ok := items[0].(map[string]interface{}); !ok { data, _ := json.MarshalIndent(items, "", " ") fmt.Fprintln(w, string(data)) return nil } - headers := collectKeys(first) + // Headers are the UNION of every row's keys, so a column appears whenever + // any row carries the field. GitLink payloads (e.g. branch lists) do not + // always include every key on every item; taking headers from items[0] + // alone made columns like `protected` appear or vanish depending on which + // item happened to rank first. + headers := collectKeysUnion(items) omitted := 0 if len(headers) > maxTableColumns { omitted = len(headers) - maxTableColumns @@ -240,20 +244,24 @@ func writeMapRow(tw *tabwriter.Writer, key string, v interface{}, depth int) { // columns a user actually scans. var preferredHeaders = []string{ "id", "name", "login", "title", "subject", "number", - "status", "state", "type", "time_ago", "created_time", - "created_at", "updated_at", + "status", "state", "protected", "last_commit", "commit_time", + "type", "time_ago", "created_time", "created_at", "updated_at", } -func collectKeys(m map[string]interface{}) []string { - keys := make([]string, 0, len(m)) +// orderKeySet returns the keys of keySet ordered by preferredHeaders: priority +// fields come first in their defined order, the rest follow in set-iteration +// order. Keys absent from preferredHeaders still appear, just after the +// priority fields. +func orderKeySet(keySet map[string]bool) []string { + keys := make([]string, 0, len(keySet)) seen := map[string]bool{} - for _, k := range preferredHeaders { - if _, ok := m[k]; ok { - keys = append(keys, k) - seen[k] = true + for _, p := range preferredHeaders { + if keySet[p] { + keys = append(keys, p) + seen[p] = true } } - for k := range m { + for k := range keySet { if !seen[k] { keys = append(keys, k) } @@ -261,6 +269,32 @@ func collectKeys(m map[string]interface{}) []string { return keys } +// collectKeysUnion merges the keys of every map in items into a single +// priority-ordered header list. Used by printSliceTable so a column shows +// whenever any row has the field — preventing columns from disappearing when +// the first row happens to omit a key (e.g. `protected` on GitLink branches). +func collectKeysUnion(items []interface{}) []string { + keySet := map[string]bool{} + for _, it := range items { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + for k := range m { + keySet[k] = true + } + } + return orderKeySet(keySet) +} + +func collectKeys(m map[string]interface{}) []string { + keySet := make(map[string]bool, len(m)) + for k := range m { + keySet[k] = true + } + return orderKeySet(keySet) +} + func formatValue(v interface{}) string { if v == nil { return "" diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go index 23f1388..0ed25e5 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -305,3 +305,64 @@ func TestFormatNestedMap_LoginAndSha(t *testing.T) { t.Errorf("expected sha to be truncated, got %q", got) } } + +// TestPrintSliceTable_HeaderUnionAcrossRows 验证当各行字段集合不一致时 +// (GitLink 分支列表的典型情况:并非每个分支都返回 protected 字段), +// 表头应取所有行的字段并集,而不是仅取第一行。否则 protected 列会因落在 +// 第一位的样本恰好缺失而「时有时无」。 +func TestPrintSliceTable_HeaderUnionAcrossRows(t *testing.T) { + items := []interface{}{ + map[string]interface{}{"name": "develop"}, // 第一行缺 protected + map[string]interface{}{"name": "main", "protected": false}, // 第二行带 protected + } + var buf bytes.Buffer + if err := printSliceTable(&buf, items); err != nil { + t.Fatalf("printSliceTable returned error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "protected") { + t.Errorf("expected header to include \"protected\" from row union (not just first row); got:\n%s", out) + } + if !strings.Contains(out, "develop") || !strings.Contains(out, "main") { + t.Errorf("expected both rows present in output; got:\n%s", out) + } +} + +// TestPrintSliceTable_ProtectedSurvivesColumnCap 验证当列数超过上限时, +// protected(是否保护分支)作为优先字段会被保留、不被截断丢弃——否则在 +// 字段较多的分支列表里这一列仍可能消失。 +func TestPrintSliceTable_ProtectedSurvivesColumnCap(t *testing.T) { + row := map[string]interface{}{} + cols := []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "id", "name", "protected"} + for _, c := range cols { + row[c] = "v" + } + var buf bytes.Buffer + if err := printSliceTable(&buf, []interface{}{row}); err != nil { + t.Fatalf("printSliceTable returned error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "protected") { + t.Errorf("expected priority field \"protected\" to survive the column cap; got:\n%s", out) + } +} + +// TestOrderKeySet_BranchColumnOrderIsStable 守护分支核心列的固定顺序 +// name / protected / last_commit / commit_time。当前若 last_commit / +// commit_time 不在 preferredHeaders,orderKeySet 会把这两个非优先键按 map +// 遍历序追加,导致列顺序随机——重复调用必暴露不一致。 +func TestOrderKeySet_BranchColumnOrderIsStable(t *testing.T) { + want := []string{"name", "protected", "last_commit", "commit_time"} + keys := map[string]bool{"name": true, "protected": true, "last_commit": true, "commit_time": true} + for i := 0; i < 100; i++ { + got := orderKeySet(keys) + if len(got) != len(want) { + t.Fatalf("iteration %d: got %d keys %v, want %v", i, len(got), got, want) + } + for j, w := range want { + if got[j] != w { + t.Fatalf("iteration %d: got[%d]=%q, want %q (full %v)", i, j, got[j], w, got) + } + } + } +}