From f248ed9d0407e4a48ae857073a2c79523c14f3e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=84=B1?= <1877685089@qq.com> Date: Wed, 10 Jun 2026 11:27:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(output):=20=E8=A1=A8=E5=A4=B4=E5=8F=96?= =?UTF-8?q?=E6=89=80=E6=9C=89=E8=A1=8C=E5=AD=97=E6=AE=B5=E5=B9=B6=E9=9B=86?= =?UTF-8?q?=E5=B9=B6=E5=9B=BA=E5=AE=9A=E6=A0=B8=E5=BF=83=E5=88=97=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit printSliceTable 原先只从第一行取表头,导致列时有时无;改为取所有行并集。新增 protected/last_commit/commit_time 到优先表头,固定分支核心列顺序。 --- internal/output/formatter.go | 60 +++++++++++++++++++++++------- internal/output/formatter_test.go | 61 +++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 13 deletions(-) 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) + } + } + } +}