diff --git a/pkg/action/action.go b/pkg/action/action.go index 1e2953d..627ad0e 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -78,37 +78,63 @@ func (c *DiffCollector) Add(breadcrumb []string, target, diff string) { } // Print writes all collected diffs to w, colorized when w is a terminal. -// A breadcrumb/target header is written before each diff (deduplicated across a -// run of diffs from the same module and target). Nothing is written when the +// +// A fanned-out run (breadcrumb longer than one segment — a bulk item or a nested +// child) is grouped like the run log announces a dispatch: an inverted root chip +// banner naming the turn is written once per turn, a "▸ parent › child" hand-off +// once per submodule path beneath it, and the target line once per repo. So a +// batch reads as clearly-topped blocks instead of one flat wall, and the root is +// not repeated inside a turn. A single-module run keeps its plain chip + target +// header (deduplicated per target), unchanged. Nothing is written when the // collector is nil or empty. func (c *DiffCollector) Print(w io.Writer) { if c == nil || len(c.entries) == 0 { return } color := isTerminalWriter(w) - var lastKey string + var lastTurn, lastCrumb, lastKey string for i, e := range c.entries { - key := strings.Join(e.breadcrumb, "\x00") + "\x00" + e.target - if i == 0 || key != lastKey { - fmt.Fprint(w, DiffHeader(e.breadcrumb, e.target, color)) - lastKey = key + segs := nonEmptySegs(e.breadcrumb) + crumb := strings.Join(segs, "\x00") + key := crumb + "\x00" + e.target + + if len(segs) > 1 { + turn := segs[0] + "\x00" + segs[1] + if i == 0 || turn != lastTurn { + fmt.Fprint(w, diffTurnBanner(segs[0], segs[1], color)) + } + if i == 0 || crumb != lastCrumb { + fmt.Fprint(w, diffHandoffChain(segs, color)) + } + if i == 0 || key != lastKey { + fmt.Fprint(w, diffTargetLine(e.target, color)) + } + lastTurn = turn + } else { + if i == 0 || key != lastKey { + fmt.Fprint(w, DiffHeader(segs, e.target, color)) + } + lastTurn = "" } + + lastCrumb, lastKey = crumb, key fmt.Fprint(w, colorizeDiff(e.text, color)) } } -// DiffHeader renders the "which module / which repo" banner shown above a diff. -// The breadcrumb's root segment is an inverted chip; when the run fanned out to -// children it is wrapped in "≡ … ≡" to match the log's root marker, and the -// remaining instance names trail after it in muted " › " steps. The target (the -// repo URL and branch) sits on its own line beneath, so a long breadcrumb never -// crowds it. Returns just a leading blank line when there is no context to show. +// DiffHeader renders the whole "which module / which repo" banner above a diff. +// A single-module run (a one-segment breadcrumb) gets a plain inverted chip with +// the target on its own line beneath — unchanged. A fanned-out run gets the +// run-log dispatch header: a root-chip turn banner, a "▸ parent › child" hand-off +// for each submodule step, then the target. Returns just a leading blank line +// when there is no context to show. DiffCollector.Print composes the same pieces +// selectively so a shared banner is not repeated across a turn's diffs. func DiffHeader(breadcrumb []string, target string, color bool) string { - segs := make([]string, 0, len(breadcrumb)) - for _, s := range breadcrumb { - if s != "" { - segs = append(segs, s) - } + segs := nonEmptySegs(breadcrumb) + if len(segs) > 1 { + return diffTurnBanner(segs[0], segs[1], color) + + diffHandoffChain(segs, color) + + diffTargetLine(target, color) } if len(segs) == 0 && target == "" { return "\n" @@ -116,26 +142,15 @@ func DiffHeader(breadcrumb []string, target string, color bool) string { var b strings.Builder b.WriteByte('\n') - if len(segs) > 0 { - root, rest := segs[0], segs[1:] - rootLabel := root - if len(rest) > 0 { - rootLabel = "≡ " + root + " ≡" - } + if len(segs) == 1 { if color { - b.WriteString(diffColorInvert + " " + rootLabel + " " + diffColorReset) - for _, s := range rest { - b.WriteString(diffColorMuted + " › " + s + diffColorReset) - } + b.WriteString(diffColorInvert + " " + segs[0] + " " + diffColorReset) } else { - b.WriteString("[" + rootLabel + "]") - for _, s := range rest { - b.WriteString(" › " + s) - } + b.WriteString("[" + segs[0] + "]") } } if target != "" { - if len(segs) > 0 { + if len(segs) == 1 { b.WriteByte('\n') } if color { @@ -148,6 +163,63 @@ func DiffHeader(breadcrumb []string, target string, color bool) string { return b.String() } +// diffTurnBanner heads one bulk turn: a leading blank line, then the root module +// as an inverted "≡ … ≡" chip (mirroring the log's root section header) followed +// by the turn's instance name in bold. This is the anchor the eye lands on, so +// turn boundaries stand out in a long batch. +func diffTurnBanner(root, turn string, color bool) string { + if color { + return "\n" + + diffColorInvert + diffColorRoot + diffColorBold + " ≡ " + root + " ≡ " + diffColorReset + + " " + diffColorBold + turn + diffColorReset + "\n" + } + return "\n[≡ " + root + " ≡] " + turn + "\n" +} + +// diffHandoffChain renders the submodule hand-off lines beneath a turn banner: +// one "▸ parent › child" per descent step from the turn down to the leaf, the +// run log's nested-dispatch form — no root chip, so headers inside a turn read +// lighter. Empty when the breadcrumb stops at the turn (no submodule). +func diffHandoffChain(segs []string, color bool) string { + var b strings.Builder + for i := 1; i+1 < len(segs); i++ { + parent, child := segs[i], segs[i+1] + if color { + b.WriteString(diffColorWorker + "▸ " + diffColorReset + + diffColorMuted + parent + " › " + diffColorReset + + diffColorBold + child + diffColorReset + "\n") + } else { + b.WriteString("▸ " + parent + " › " + child + "\n") + } + } + return b.String() +} + +// diffTargetLine renders the target (repo URL and branch) on its own muted line +// beneath the header, so a long breadcrumb never crowds it. Empty when there is +// no target to name. +func diffTargetLine(target string, color bool) string { + if target == "" { + return "" + } + if color { + return diffColorMuted + target + diffColorReset + "\n" + } + return target + "\n" +} + +// nonEmptySegs drops empty breadcrumb segments so a defensive blank never +// produces a stray "› " step or an empty chip. +func nonEmptySegs(breadcrumb []string) []string { + segs := make([]string, 0, len(breadcrumb)) + for _, s := range breadcrumb { + if s != "" { + segs = append(segs, s) + } + } + return segs +} + // ExecutionContext holds runtime state shared across actions. type ExecutionContext struct { // ModuleName is the metadata.name of the executing module. diff --git a/pkg/action/diff.go b/pkg/action/diff.go index 02575dd..22426d5 100644 --- a/pkg/action/diff.go +++ b/pkg/action/diff.go @@ -11,11 +11,14 @@ import ( // ANSI color codes for diff output. const ( diffColorReset = "\033[0m" + diffColorBold = "\033[1m" diffColorRed = "\033[31m" diffColorGreen = "\033[32m" diffColorCyan = "\033[36m" diffColorInvert = "\033[7m" diffColorMuted = "\033[38;5;244m" // mid gray — matches the log handler's muted tone + diffColorRoot = "\033[38;5;61m" // muted indigo — the orchestrator chip (log's root color) + diffColorWorker = "\033[38;5;66m" // slate teal — the hand-off marker (log's worker color) ) // printDiff computes a unified diff between old and new content and hands it to diff --git a/pkg/action/diff_header_test.go b/pkg/action/diff_header_test.go index 2716b14..66f1ed1 100644 --- a/pkg/action/diff_header_test.go +++ b/pkg/action/diff_header_test.go @@ -16,23 +16,38 @@ func TestDiffHeader_SingleModuleIsPlainChip(t *testing.T) { } } -func TestDiffHeader_BulkItemShowsRootMarkerAndCrumb(t *testing.T) { - // Two segments = the orchestrator fanned out: the root is wrapped in "≡ … ≡" - // and the item's instance name trails after it, so two items with the same - // metadata name are told apart. +func TestDiffHeader_BulkTurnBannerNamesRootAndItem(t *testing.T) { + // Two segments = the orchestrator fanned out: the root becomes an inverted + // "≡ … ≡" turn banner and the item's instance name follows it, so two items + // with the same metadata name are told apart. No submodule, so no hand-off. got := DiffHeader([]string{"deps-bump", "patch-go-mod-service-a"}, "github.com/acme/service-a (main)", false) - want := "\n[≡ deps-bump ≡] › patch-go-mod-service-a\ngithub.com/acme/service-a (main)\n" + want := "\n[≡ deps-bump ≡] patch-go-mod-service-a\ngithub.com/acme/service-a (main)\n" if got != want { t.Errorf("got %q, want %q", got, want) } } -func TestDiffHeader_ColorRootChipThenMutedCrumb(t *testing.T) { - got := DiffHeader([]string{"deps-bump", "service-a"}, "acme/service-a", true) +func TestDiffHeader_SubmoduleGetsRootFreeHandoff(t *testing.T) { + // Three segments = a submodule under a bulk turn: the turn banner names the + // root and item, then a "▸ item › submodule" hand-off with no root chip. + got := DiffHeader([]string{"bulk-deploy-k8s", "deploy-k8s-2", "autocert"}, "acme/cluster-2 (main)", false) + want := "\n[≡ bulk-deploy-k8s ≡] deploy-k8s-2\n" + + "▸ deploy-k8s-2 › autocert\n" + + "acme/cluster-2 (main)\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestDiffHeader_ColorTurnBannerAndHandoff(t *testing.T) { + got := DiffHeader([]string{"deps-bump", "service-a", "autocert"}, "acme/service-a", true) want := "\n" + - diffColorInvert + " ≡ deps-bump ≡ " + diffColorReset + - diffColorMuted + " › service-a" + diffColorReset + - "\n" + diffColorMuted + "acme/service-a" + diffColorReset + "\n" + diffColorInvert + diffColorRoot + diffColorBold + " ≡ deps-bump ≡ " + diffColorReset + + " " + diffColorBold + "service-a" + diffColorReset + "\n" + + diffColorWorker + "▸ " + diffColorReset + + diffColorMuted + "service-a › " + diffColorReset + + diffColorBold + "autocert" + diffColorReset + "\n" + + diffColorMuted + "acme/service-a" + diffColorReset + "\n" if got != want { t.Errorf("got %q, want %q", got, want) } @@ -45,9 +60,10 @@ func TestDiffHeader_EmptyIsBlankLine(t *testing.T) { } func TestDiffHeader_SkipsEmptySegments(t *testing.T) { - // Defensive: empty breadcrumb segments must not produce empty "› " steps. + // Defensive: empty breadcrumb segments must not produce an empty chip or a + // stray "▸ › " step. ["root", "", "leaf"] collapses to a two-segment turn. got := DiffHeader([]string{"root", "", "leaf"}, "", false) - want := "\n[≡ root ≡] › leaf\n" + want := "\n[≡ root ≡] leaf\n" if got != want { t.Errorf("got %q, want %q", got, want) } @@ -70,3 +86,31 @@ func TestDiffCollector_HeaderDedupedPerBreadcrumbAndTarget(t *testing.T) { t.Errorf("expected item-b header, got:\n%s", out) } } + +func TestDiffCollector_BannerOncePerTurnHandoffPerSubmodule(t *testing.T) { + // One turn with two submodules, then a second turn: the root chip banner + // prints once per turn, the "▸" hand-off once per submodule, and the root is + // never repeated inside a turn. + c := &DiffCollector{} + c.Add([]string{"bulk-deploy", "deploy-1", "autocert"}, "cluster-1", "--- a\n+++ b\n") + c.Add([]string{"bulk-deploy", "deploy-1", "ingress"}, "cluster-1", "--- c\n+++ d\n") + c.Add([]string{"bulk-deploy", "deploy-2", "autocert"}, "cluster-2", "--- e\n+++ f\n") + + var b bytes.Buffer + c.Print(&b) + out := b.String() + + if n := strings.Count(out, "≡ bulk-deploy ≡"); n != 2 { + t.Errorf("expected root banner once per turn (2), got %d:\n%s", n, out) + } + if n := strings.Count(out, "deploy-1"); n != 3 { // banner + two hand-offs name the turn + t.Errorf("expected deploy-1 named 3 times, got %d:\n%s", n, out) + } + for _, want := range []string{ + "▸ deploy-1 › autocert", "▸ deploy-1 › ingress", "▸ deploy-2 › autocert", + } { + if !strings.Contains(out, want) { + t.Errorf("expected hand-off %q, got:\n%s", want, out) + } + } +}