Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 105 additions & 33 deletions pkg/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,64 +78,79 @@ 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"
}

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 {
Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions pkg/action/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 56 additions & 12 deletions pkg/action/diff_header_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
}
}
Loading