From 31a70ec2271ef8a5d837ee8a6f8b9690a9919ba3 Mon Sep 17 00:00:00 2001 From: timsolovev <7433083+timsolovev@users.noreply.github.com> Date: Fri, 3 Oct 2025 21:47:40 +0200 Subject: [PATCH 1/7] feat: initial list --format json implementation --- commands/stack/list/list.go | 93 ++++++++++++++ e2etests/core/list_json_test.go | 210 ++++++++++++++++++++++++++++++++ run/dag/dag.go | 58 +++++++++ ui/tui/cli_handler.go | 3 + ui/tui/cli_spec.go | 3 +- 5 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 e2etests/core/list_json_test.go diff --git a/commands/stack/list/list.go b/commands/stack/list/list.go index fe44c4b69..be64e680d 100644 --- a/commands/stack/list/list.go +++ b/commands/stack/list/list.go @@ -6,6 +6,7 @@ package list import ( "context" + "encoding/json" "fmt" "github.com/terramate-io/terramate/cloud/api/status" @@ -25,6 +26,7 @@ type Spec struct { Target string StatusFilters StatusFilters RunOrder bool + Format string Tags []string NoTags []string Printers printer.Printers @@ -37,6 +39,18 @@ type StatusFilters struct { DriftStatus string } +// StackInfo represents stack information for JSON output. +type StackInfo struct { + Path string `json:"path"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Tags []string `json:"tags"` + Dependencies []string `json:"dependencies"` + Reason string `json:"reason"` + IsChanged bool `json:"is_changed"` +} + // Name returns the name of the command. func (s *Spec) Name() string { return "list" } @@ -90,6 +104,10 @@ func (s *Spec) printStacksList(allStacks []stack.Entry) error { reasons[entry.Stack.ID] = entry.Reason } + if s.Format == "json" { + return s.printStacksListJSON(stacks, filteredStacks) + } + if s.RunOrder { var failReason string var err error @@ -116,3 +134,78 @@ func (s *Spec) printStacksList(allStacks []stack.Entry) error { } return nil } + +func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) error { + // Create a map from stack ID to Entry for quick lookup + entryMap := make(map[string]stack.Entry) + for _, entry := range filteredStacks { + entryMap[entry.Stack.ID] = entry + } + + d, reason, err := run.BuildDAGFromStacks( + s.Engine.Config(), + stacks, + func(s *config.SortableStack) *config.Stack { return s.Stack }, + ) + if err != nil { + return errors.E(err, "Invalid stack configuration: "+reason) + } + + stackInfos := make(map[string]StackInfo) + + for _, id := range d.IDs() { + st, err := d.Node(id) + if err != nil { + return errors.E(err, "getting node from DAG") + } + + dir := st.Dir().String() + friendlyDir, ok := s.Engine.FriendlyFmtDir(dir) + if !ok { + return errors.E("unable to format stack dir %s", dir) + } + + ancestors := d.DirectAncestorsOf(id) + deps := make([]string, 0, len(ancestors)) + for _, ancestorID := range ancestors { + ancestorStack, err := d.Node(ancestorID) + if err != nil { + return errors.E(err, "getting ancestor node from DAG") + } + + ancestorDir := ancestorStack.Dir().String() + friendlyAncestorDir, ok := s.Engine.FriendlyFmtDir(ancestorDir) + if !ok { + return errors.E("unable to format stack dir %s", ancestorDir) + } + deps = append(deps, friendlyAncestorDir) + } + + entry, hasEntry := entryMap[st.ID] + reasonStr := "" + if hasEntry { + reasonStr = entry.Reason + } + + info := StackInfo{ + Path: friendlyDir, + ID: st.ID, + Name: st.Name, + Description: st.Description, + Tags: st.Tags, + Dependencies: deps, + Reason: reasonStr, + IsChanged: st.IsChanged, + } + + stackInfos[friendlyDir] = info + } + + jsonData, err := json.MarshalIndent(stackInfos, "", " ") + if err != nil { + return errors.E(err, "marshaling JSON") + } + + s.Printers.Stdout.Println(string(jsonData)) + return nil +} diff --git a/e2etests/core/list_json_test.go b/e2etests/core/list_json_test.go new file mode 100644 index 000000000..8d4fbd316 --- /dev/null +++ b/e2etests/core/list_json_test.go @@ -0,0 +1,210 @@ +// Copyright 2024 Terramate GmbH +// SPDX-License-Identifier: MPL-2.0 + +package core_test + +import ( + "encoding/json" + "testing" + + . "github.com/terramate-io/terramate/e2etests/internal/runner" + "github.com/terramate-io/terramate/test/sandbox" +) + +func TestListJSON(t *testing.T) { + t.Parallel() + + type stackInfo struct { + Path string `json:"path"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Tags []string `json:"tags"` + Dependencies []string `json:"dependencies"` + Reason string `json:"reason"` + IsChanged bool `json:"is_changed"` + } + + type testcase struct { + name string + layout []string + want map[string][]string // map of stack path -> dependencies + } + + for _, tcase := range []testcase{ + { + name: "simple dependency: stack1 after stack2", + layout: []string{ + `s:stack1:after=["/stack2"]`, + "s:stack2", + }, + want: map[string][]string{ + "stack1": {"stack2"}, + "stack2": {}, + }, + }, + { + name: "linear chain: A -> B -> C", + layout: []string{ + "s:A", + `s:B:after=["/A"]`, + `s:C:after=["/B"]`, + }, + want: map[string][]string{ + "A": {}, + "B": {"A"}, + "C": {"B"}, + }, + }, + { + name: "diamond dependency", + layout: []string{ + "s:A", + `s:B:after=["/A"]`, + `s:C:after=["/A"]`, + `s:D:after=["/B","/C"]`, + }, + want: map[string][]string{ + "A": {}, + "B": {"A"}, + "C": {"A"}, + "D": {"B", "C"}, + }, + }, + { + name: "fork-fork-join-join", + layout: []string{ + "s:A", + `s:B:after=["/A"]`, + `s:C:after=["/B"]`, + `s:D:after=["/C","/X"]`, + `s:E:after=["/A"]`, + `s:F:after=["/E"]`, + `s:G:after=["/F","/Y"]`, + `s:X:after=["/B"]`, + `s:Y:after=["/E"]`, + `s:Z:after=["/D","/G"]`, + }, + want: map[string][]string{ + "A": {}, + "B": {"A"}, + "C": {"B"}, + "D": {"C", "X"}, + "E": {"A"}, + "F": {"E"}, + "G": {"F", "Y"}, + "X": {"B"}, + "Y": {"E"}, + "Z": {"D", "G"}, + }, + }, + { + name: "no dependencies", + layout: []string{ + "s:stack1", + "s:stack2", + "s:stack3", + }, + want: map[string][]string{ + "stack1": {}, + "stack2": {}, + "stack3": {}, + }, + }, + { + name: "before statement: stack1 before stack2", + layout: []string{ + `s:stack1:before=["/stack2"]`, + "s:stack2", + }, + want: map[string][]string{ + "stack1": {}, + "stack2": {"stack1"}, + }, + }, + { + name: "mixed before and after statements", + layout: []string{ + `s:A:before=["/B"]`, + `s:B:after=["/C"]`, + "s:C", + }, + want: map[string][]string{ + "A": {}, + "B": {"A", "C"}, + "C": {}, + }, + }, + { + name: "complex before statements", + layout: []string{ + `s:A:before=["/B", "/C"]`, + "s:B", + "s:C", + `s:D:after=["/B", "/C"]`, + }, + want: map[string][]string{ + "A": {}, + "B": {"A"}, + "C": {"A"}, + "D": {"B", "C"}, + }, + }, + } { + tc := tcase + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + s := sandbox.New(t) + s.BuildTree(tc.layout) + + cli := NewCLI(t, s.RootDir()) + result := cli.Run("list", "--run-order", "--format=json") + + if result.Status != 0 { + t.Fatalf("command failed with status %d: %s", result.Status, result.Stderr) + } + + var got map[string]stackInfo + if err := json.Unmarshal([]byte(result.Stdout), &got); err != nil { + t.Fatalf("failed to unmarshal JSON output: %v\nOutput: %s", err, result.Stdout) + } + + if len(got) != len(tc.want) { + t.Fatalf("expected %d stacks, got %d", len(tc.want), len(got)) + } + + for stackPath, wantDeps := range tc.want { + info, ok := got[stackPath] + if !ok { + t.Fatalf("stack %q not found in output", stackPath) + } + + if info.Path != stackPath { + t.Fatalf("stack path mismatch: expected %q, got %q", stackPath, info.Path) + } + + // convert to maps, order doesn't matter for dependencies + wantDepsMap := make(map[string]bool) + for _, dep := range wantDeps { + wantDepsMap[dep] = true + } + + gotDepsMap := make(map[string]bool) + for _, dep := range info.Dependencies { + gotDepsMap[dep] = true + } + + if len(wantDepsMap) != len(gotDepsMap) { + t.Fatalf("stack %q: expected %d dependencies, got %d", stackPath, len(wantDeps), len(info.Dependencies)) + } + + for dep := range wantDepsMap { + if !gotDepsMap[dep] { + t.Fatalf("stack %q: missing dependency %q", stackPath, dep) + } + } + } + }) + } +} diff --git a/run/dag/dag.go b/run/dag/dag.go index 7e9d26db5..2618b19eb 100644 --- a/run/dag/dag.go +++ b/run/dag/dag.go @@ -219,6 +219,64 @@ func (d *DAG[V]) AncestorsOf(id ID) []ID { return d.dag[id] } +// DirectAncestorsOf returns only the immediate ancestors of the given node +// by performing transitive reduction - removing ancestors reachable through other ancestors. +func (d *DAG[V]) DirectAncestorsOf(id ID) []ID { + ancestors := d.AncestorsOf(id) + if len(ancestors) <= 1 { + return ancestors // Already minimal + } + + var directAncestors []ID + + for _, ancestorID := range ancestors { + isTransitive := false + + for _, otherID := range ancestors { + if ancestorID == otherID { + continue + } + if d.hasPath(otherID, ancestorID) { + isTransitive = true + break + } + } + + if !isTransitive { + directAncestors = append(directAncestors, ancestorID) + } + } + + return directAncestors +} + +// hasPath checks if 'to' is reachable from 'from' via ancestors (BFS traversal). +func (d *DAG[V]) hasPath(from, to ID) bool { + visited := make(map[ID]bool) + queue := []ID{from} + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + if visited[current] { + continue + } + visited[current] = true + + for _, ancestor := range d.AncestorsOf(current) { + if ancestor == to { + return true + } + if !visited[ancestor] { + queue = append(queue, ancestor) + } + } + } + + return false +} + // HasCycle returns true if the DAG has a cycle. func (d *DAG[V]) HasCycle(id ID) bool { if !d.validated { diff --git a/ui/tui/cli_handler.go b/ui/tui/cli_handler.go index f28c99df4..99a2dfd18 100644 --- a/ui/tui/cli_handler.go +++ b/ui/tui/cli_handler.go @@ -322,6 +322,7 @@ func DefaultAfterConfigHandler(ctx context.Context, c *CLI) (commands.Executor, tel.StringFlag("filter-deployment-status", parsedArgs.List.DeploymentStatus), tel.StringFlag("filter-target", parsedArgs.List.Target), tel.BoolFlag("run-order", parsedArgs.List.RunOrder), + tel.StringFlag("format", parsedArgs.List.Format), ) expStatus := parsedArgs.List.ExperimentalStatus cloudStatus := parsedArgs.List.Status @@ -355,8 +356,10 @@ func DefaultAfterConfigHandler(ctx context.Context, c *CLI) (commands.Executor, DriftStatus: parsedArgs.List.DriftStatus, }, RunOrder: parsedArgs.List.RunOrder, + Format: parsedArgs.List.Format, Tags: parsedArgs.Tags, NoTags: parsedArgs.NoTags, + Printers: c.printers, }, true, false, nil case "generate": diff --git a/ui/tui/cli_spec.go b/ui/tui/cli_spec.go index 8021c8dfc..cdf2f4fb6 100644 --- a/ui/tui/cli_spec.go +++ b/ui/tui/cli_spec.go @@ -45,7 +45,8 @@ type FlagSpec struct { } `cmd:"" help:"Format configuration files."` List struct { - Why bool `help:"Shows the reason why the stack has changed."` + Why bool `help:"Shows the reason why the stack has changed."` + Format string `default:"text" enum:"text,json" help:"Output format (text or json)"` cloudFilterFlags Target string `help:"Select the deployment target of the filtered stacks."` From 3bff5aceb5400fb19b35c35af3c69bbf8cd0e396 Mon Sep 17 00:00:00 2001 From: timsolovev <7433083+timsolovev@users.noreply.github.com> Date: Fri, 3 Oct 2025 22:15:32 +0200 Subject: [PATCH 2/7] feat: initial list --format dot implementation --- commands/stack/list/list.go | 86 ++++++++++++++++++++++++++++++++----- ui/tui/cli_spec.go | 2 +- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/commands/stack/list/list.go b/commands/stack/list/list.go index be64e680d..7209bcec7 100644 --- a/commands/stack/list/list.go +++ b/commands/stack/list/list.go @@ -9,12 +9,14 @@ import ( "encoding/json" "fmt" + "github.com/emicklei/dot" "github.com/terramate-io/terramate/cloud/api/status" "github.com/terramate-io/terramate/config" "github.com/terramate-io/terramate/engine" "github.com/terramate-io/terramate/errors" "github.com/terramate-io/terramate/printer" "github.com/terramate-io/terramate/run" + "github.com/terramate-io/terramate/run/dag" "github.com/terramate-io/terramate/stack" ) @@ -108,6 +110,10 @@ func (s *Spec) printStacksList(allStacks []stack.Entry) error { return s.printStacksListJSON(stacks, filteredStacks) } + if s.Format == "dot" { + return s.printStacksListDot(stacks, filteredStacks) + } + if s.RunOrder { var failReason string var err error @@ -135,7 +141,9 @@ func (s *Spec) printStacksList(allStacks []stack.Entry) error { return nil } -func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) error { +// buildStacksDAG builds a DAG from the given stacks and creates an entry map. +// Reused across different output formatters. +func (s *Spec) buildStacksDAG(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) (*dag.DAG[*config.SortableStack], map[string]stack.Entry, error) { // Create a map from stack ID to Entry for quick lookup entryMap := make(map[string]stack.Entry) for _, entry := range filteredStacks { @@ -148,7 +156,16 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi func(s *config.SortableStack) *config.Stack { return s.Stack }, ) if err != nil { - return errors.E(err, "Invalid stack configuration: "+reason) + return nil, nil, errors.E(err, "Invalid stack configuration: "+reason) + } + + return d, entryMap, nil +} + +func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) error { + d, entryMap, err := s.buildStacksDAG(stacks, filteredStacks) + if err != nil { + return err } stackInfos := make(map[string]StackInfo) @@ -159,7 +176,7 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi return errors.E(err, "getting node from DAG") } - dir := st.Dir().String() + dir := st.Stack.Dir.String() friendlyDir, ok := s.Engine.FriendlyFmtDir(dir) if !ok { return errors.E("unable to format stack dir %s", dir) @@ -173,7 +190,7 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi return errors.E(err, "getting ancestor node from DAG") } - ancestorDir := ancestorStack.Dir().String() + ancestorDir := ancestorStack.Stack.Dir.String() friendlyAncestorDir, ok := s.Engine.FriendlyFmtDir(ancestorDir) if !ok { return errors.E("unable to format stack dir %s", ancestorDir) @@ -181,7 +198,7 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi deps = append(deps, friendlyAncestorDir) } - entry, hasEntry := entryMap[st.ID] + entry, hasEntry := entryMap[st.Stack.ID] reasonStr := "" if hasEntry { reasonStr = entry.Reason @@ -189,13 +206,13 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi info := StackInfo{ Path: friendlyDir, - ID: st.ID, - Name: st.Name, - Description: st.Description, - Tags: st.Tags, + ID: st.Stack.ID, + Name: st.Stack.Name, + Description: st.Stack.Description, + Tags: st.Stack.Tags, Dependencies: deps, Reason: reasonStr, - IsChanged: st.IsChanged, + IsChanged: st.Stack.IsChanged, } stackInfos[friendlyDir] = info @@ -209,3 +226,52 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi s.Printers.Stdout.Println(string(jsonData)) return nil } + +func (s *Spec) printStacksListDot(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) error { + d, _, err := s.buildStacksDAG(stacks, filteredStacks) + if err != nil { + return err + } + + dotGraph := dot.NewGraph(dot.Directed) + + for _, id := range d.IDs() { + st, err := d.Node(id) + if err != nil { + return errors.E(err, "getting node from DAG") + } + + dir := st.Stack.Dir.String() + friendlyDir, ok := s.Engine.FriendlyFmtDir(dir) + if !ok { + return errors.E("unable to format stack dir %s", dir) + } + + descendant := dotGraph.Node(friendlyDir) + + ancestors := d.DirectAncestorsOf(id) + for _, ancestorID := range ancestors { + ancestorStack, err := d.Node(ancestorID) + if err != nil { + return errors.E(err, "getting ancestor node from DAG") + } + + ancestorDir := ancestorStack.Stack.Dir.String() + friendlyAncestorDir, ok := s.Engine.FriendlyFmtDir(ancestorDir) + if !ok { + return errors.E("unable to format stack dir %s", ancestorDir) + } + + ancestorNode := dotGraph.Node(friendlyAncestorDir) + + // check if edge already exists to avoid duplicates + edges := dotGraph.FindEdges(ancestorNode, descendant) + if len(edges) == 0 { + dotGraph.Edge(ancestorNode, descendant) + } + } + } + + s.Printers.Stdout.Println(dotGraph.String()) + return nil +} diff --git a/ui/tui/cli_spec.go b/ui/tui/cli_spec.go index cdf2f4fb6..3d25db63b 100644 --- a/ui/tui/cli_spec.go +++ b/ui/tui/cli_spec.go @@ -46,7 +46,7 @@ type FlagSpec struct { List struct { Why bool `help:"Shows the reason why the stack has changed."` - Format string `default:"text" enum:"text,json" help:"Output format (text or json)"` + Format string `default:"text" enum:"text,json,dot" help:"Output format (text, json, or dot)"` cloudFilterFlags Target string `help:"Select the deployment target of the filtered stacks."` From d6e7d609de659c6d22837d8ae8420d933234368b Mon Sep 17 00:00:00 2001 From: timsolovev <7433083+timsolovev@users.noreply.github.com> Date: Sat, 4 Oct 2025 13:30:42 +0200 Subject: [PATCH 3/7] feat: list --label stack.{id,name,dir} implementation --- commands/stack/list/list.go | 59 ++++++++++++++++++++++++++++++++++--- test/sandbox/sandbox.go | 2 ++ ui/tui/cli_handler.go | 2 ++ ui/tui/cli_spec.go | 1 + 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/commands/stack/list/list.go b/commands/stack/list/list.go index 7209bcec7..d2e9c9c34 100644 --- a/commands/stack/list/list.go +++ b/commands/stack/list/list.go @@ -29,6 +29,7 @@ type Spec struct { StatusFilters StatusFilters RunOrder bool Format string + Label string Tags []string NoTags []string Printers printer.Printers @@ -56,12 +57,30 @@ type StackInfo struct { // Name returns the name of the command. func (s *Spec) Name() string { return "list" } +// getLabel extracts the appropriate label from a stack based on the Label field. +func (s *Spec) getLabel(st *config.Stack, friendlyDir string) (string, error) { + switch s.Label { + case "stack.name": + return st.Name, nil + case "stack.id": + return st.ID, nil + case "stack.dir": + return friendlyDir, nil + default: + return "", errors.E(`--label expects the values "stack.name", "stack.id", or "stack.dir"`) + } +} + // Exec executes the list command. func (s *Spec) Exec(_ context.Context) error { if s.Reason && !s.GitFilter.IsChanged { return errors.E("the --why flag must be used together with --changed") } + if s.Format == "json" && s.Label == "stack.name" { + return errors.E("--format json cannot be used with --label stack.name (stack names are not guaranteed to be unique)") + } + err := s.Engine.CheckTargetsConfiguration(s.Target, "", func(isTargetSet bool) error { isStatusSet := s.StatusFilters.StackStatus != "" isDeploymentStatusSet := s.StatusFilters.DeploymentStatus != "" @@ -132,10 +151,16 @@ func (s *Spec) printStacksList(allStacks []stack.Entry) error { printer.Stdout.Println(dir) continue } + + label, err := s.getLabel(st.Stack, friendlyDir) + if err != nil { + return err + } + if s.Reason { - printer.Stdout.Println(fmt.Sprintf("%s - %s", friendlyDir, reasons[st.ID])) + printer.Stdout.Println(fmt.Sprintf("%s - %s", label, reasons[st.ID])) } else { - printer.Stdout.Println(friendlyDir) + printer.Stdout.Println(label) } } return nil @@ -182,6 +207,11 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi return errors.E("unable to format stack dir %s", dir) } + label, err := s.getLabel(st.Stack, friendlyDir) + if err != nil { + return err + } + ancestors := d.DirectAncestorsOf(id) deps := make([]string, 0, len(ancestors)) for _, ancestorID := range ancestors { @@ -195,7 +225,12 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi if !ok { return errors.E("unable to format stack dir %s", ancestorDir) } - deps = append(deps, friendlyAncestorDir) + + ancestorLabel, err := s.getLabel(ancestorStack.Stack, friendlyAncestorDir) + if err != nil { + return err + } + deps = append(deps, ancestorLabel) } entry, hasEntry := entryMap[st.Stack.ID] @@ -215,7 +250,7 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi IsChanged: st.Stack.IsChanged, } - stackInfos[friendlyDir] = info + stackInfos[label] = info } jsonData, err := json.MarshalIndent(stackInfos, "", " ") @@ -247,7 +282,15 @@ func (s *Spec) printStacksListDot(stacks config.List[*config.SortableStack], fil return errors.E("unable to format stack dir %s", dir) } + label, err := s.getLabel(st.Stack, friendlyDir) + if err != nil { + return err + } + descendant := dotGraph.Node(friendlyDir) + if label != friendlyDir { + descendant.Attr("label", label) + } ancestors := d.DirectAncestorsOf(id) for _, ancestorID := range ancestors { @@ -262,7 +305,15 @@ func (s *Spec) printStacksListDot(stacks config.List[*config.SortableStack], fil return errors.E("unable to format stack dir %s", ancestorDir) } + ancestorLabel, err := s.getLabel(ancestorStack.Stack, friendlyAncestorDir) + if err != nil { + return err + } + ancestorNode := dotGraph.Node(friendlyAncestorDir) + if ancestorLabel != friendlyAncestorDir { + ancestorNode.Attr("label", ancestorLabel) + } // check if edge already exists to avoid duplicates edges := dotGraph.FindEdges(ancestorNode, descendant) diff --git a/test/sandbox/sandbox.go b/test/sandbox/sandbox.go index 1b6a530a9..4247f68a3 100644 --- a/test/sandbox/sandbox.go +++ b/test/sandbox/sandbox.go @@ -690,6 +690,8 @@ func buildTree(t testing.TB, root *config.Root, environ []string, layout []strin switch name { case "id": cfg.Stack.ID = value + case "name": + cfg.Stack.Name = value case "after": cfg.Stack.After = parseListSpec(t, name, value) case "before": diff --git a/ui/tui/cli_handler.go b/ui/tui/cli_handler.go index 99a2dfd18..86543377f 100644 --- a/ui/tui/cli_handler.go +++ b/ui/tui/cli_handler.go @@ -323,6 +323,7 @@ func DefaultAfterConfigHandler(ctx context.Context, c *CLI) (commands.Executor, tel.StringFlag("filter-target", parsedArgs.List.Target), tel.BoolFlag("run-order", parsedArgs.List.RunOrder), tel.StringFlag("format", parsedArgs.List.Format), + tel.StringFlag("label", parsedArgs.List.Label), ) expStatus := parsedArgs.List.ExperimentalStatus cloudStatus := parsedArgs.List.Status @@ -357,6 +358,7 @@ func DefaultAfterConfigHandler(ctx context.Context, c *CLI) (commands.Executor, }, RunOrder: parsedArgs.List.RunOrder, Format: parsedArgs.List.Format, + Label: parsedArgs.List.Label, Tags: parsedArgs.Tags, NoTags: parsedArgs.NoTags, Printers: c.printers, diff --git a/ui/tui/cli_spec.go b/ui/tui/cli_spec.go index 3d25db63b..531e46f0c 100644 --- a/ui/tui/cli_spec.go +++ b/ui/tui/cli_spec.go @@ -47,6 +47,7 @@ type FlagSpec struct { List struct { Why bool `help:"Shows the reason why the stack has changed."` Format string `default:"text" enum:"text,json,dot" help:"Output format (text, json, or dot)"` + Label string `short:"l" default:"stack.dir" enum:"stack.id,stack.name,stack.dir" help:"Label used in output (stack.id, stack.name, or stack.dir)"` cloudFilterFlags Target string `help:"Select the deployment target of the filtered stacks."` From 8edf482c994793a2795f0f52f4d4db47c51bf2f8 Mon Sep 17 00:00:00 2001 From: timsolovev <7433083+timsolovev@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:04:02 +0200 Subject: [PATCH 4/7] perf(dag): faster transitive reduction through ancestor caching --- commands/stack/list/list.go | 146 ++++++++++++++++-------------------- run/dag/dag.go | 58 +++++++++++--- 2 files changed, 111 insertions(+), 93 deletions(-) diff --git a/commands/stack/list/list.go b/commands/stack/list/list.go index d2e9c9c34..93f48eff9 100644 --- a/commands/stack/list/list.go +++ b/commands/stack/list/list.go @@ -54,6 +54,15 @@ type StackInfo struct { IsChanged bool `json:"is_changed"` } +// stackMetadata holds processed stack information for output formatting. +type stackMetadata struct { + Stack *config.SortableStack + FriendlyDir string + Label string + Entry stack.Entry + AncestorIDs []dag.ID +} + // Name returns the name of the command. func (s *Spec) Name() string { return "list" } @@ -166,9 +175,8 @@ func (s *Spec) printStacksList(allStacks []stack.Entry) error { return nil } -// buildStacksDAG builds a DAG from the given stacks and creates an entry map. -// Reused across different output formatters. -func (s *Spec) buildStacksDAG(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) (*dag.DAG[*config.SortableStack], map[string]stack.Entry, error) { +// buildStackMetadata processes stacks and collects metadata to be formatted. +func (s *Spec) buildStackMetadata(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) (map[dag.ID]*stackMetadata, *dag.DAG[*config.SortableStack], error) { // Create a map from stack ID to Entry for quick lookup entryMap := make(map[string]stack.Entry) for _, entry := range filteredStacks { @@ -184,73 +192,74 @@ func (s *Spec) buildStacksDAG(stacks config.List[*config.SortableStack], filtere return nil, nil, errors.E(err, "Invalid stack configuration: "+reason) } - return d, entryMap, nil -} - -func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) error { - d, entryMap, err := s.buildStacksDAG(stacks, filteredStacks) - if err != nil { - return err - } - - stackInfos := make(map[string]StackInfo) + metadata := make(map[dag.ID]*stackMetadata) for _, id := range d.IDs() { st, err := d.Node(id) if err != nil { - return errors.E(err, "getting node from DAG") + return nil, nil, errors.E(err, "getting node from DAG") } dir := st.Stack.Dir.String() friendlyDir, ok := s.Engine.FriendlyFmtDir(dir) if !ok { - return errors.E("unable to format stack dir %s", dir) + return nil, nil, errors.E("unable to format stack dir %s", dir) } label, err := s.getLabel(st.Stack, friendlyDir) if err != nil { - return err + return nil, nil, err } ancestors := d.DirectAncestorsOf(id) - deps := make([]string, 0, len(ancestors)) - for _, ancestorID := range ancestors { - ancestorStack, err := d.Node(ancestorID) - if err != nil { - return errors.E(err, "getting ancestor node from DAG") - } - ancestorDir := ancestorStack.Stack.Dir.String() - friendlyAncestorDir, ok := s.Engine.FriendlyFmtDir(ancestorDir) - if !ok { - return errors.E("unable to format stack dir %s", ancestorDir) - } + entry, hasEntry := entryMap[st.Stack.ID] + if !hasEntry { + entry = stack.Entry{Stack: st.Stack} + } - ancestorLabel, err := s.getLabel(ancestorStack.Stack, friendlyAncestorDir) - if err != nil { - return err - } - deps = append(deps, ancestorLabel) + metadata[id] = &stackMetadata{ + Stack: st, + FriendlyDir: friendlyDir, + Label: label, + Entry: entry, + AncestorIDs: ancestors, } + } - entry, hasEntry := entryMap[st.Stack.ID] - reasonStr := "" - if hasEntry { - reasonStr = entry.Reason + return metadata, d, nil +} + +func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) error { + metadata, d, err := s.buildStackMetadata(stacks, filteredStacks) + if err != nil { + return err + } + + stackInfos := make(map[string]StackInfo) + + for _, id := range d.IDs() { + m := metadata[id] + + // Build dependency labels from ancestor IDs + deps := make([]string, 0, len(m.AncestorIDs)) + for _, ancestorID := range m.AncestorIDs { + ancestorMeta := metadata[ancestorID] + deps = append(deps, ancestorMeta.Label) } info := StackInfo{ - Path: friendlyDir, - ID: st.Stack.ID, - Name: st.Stack.Name, - Description: st.Stack.Description, - Tags: st.Stack.Tags, + Path: m.FriendlyDir, + ID: m.Stack.Stack.ID, + Name: m.Stack.Stack.Name, + Description: m.Stack.Stack.Description, + Tags: m.Stack.Stack.Tags, Dependencies: deps, - Reason: reasonStr, - IsChanged: st.Stack.IsChanged, + Reason: m.Entry.Reason, + IsChanged: m.Stack.Stack.IsChanged, } - stackInfos[label] = info + stackInfos[m.Label] = info } jsonData, err := json.MarshalIndent(stackInfos, "", " ") @@ -263,7 +272,7 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi } func (s *Spec) printStacksListDot(stacks config.List[*config.SortableStack], filteredStacks []stack.Entry) error { - d, _, err := s.buildStacksDAG(stacks, filteredStacks) + metadata, d, err := s.buildStackMetadata(stacks, filteredStacks) if err != nil { return err } @@ -271,48 +280,19 @@ func (s *Spec) printStacksListDot(stacks config.List[*config.SortableStack], fil dotGraph := dot.NewGraph(dot.Directed) for _, id := range d.IDs() { - st, err := d.Node(id) - if err != nil { - return errors.E(err, "getting node from DAG") - } - - dir := st.Stack.Dir.String() - friendlyDir, ok := s.Engine.FriendlyFmtDir(dir) - if !ok { - return errors.E("unable to format stack dir %s", dir) - } - - label, err := s.getLabel(st.Stack, friendlyDir) - if err != nil { - return err - } + m := metadata[id] - descendant := dotGraph.Node(friendlyDir) - if label != friendlyDir { - descendant.Attr("label", label) + descendant := dotGraph.Node(m.FriendlyDir) + if m.Label != m.FriendlyDir { + descendant.Attr("label", m.Label) } - ancestors := d.DirectAncestorsOf(id) - for _, ancestorID := range ancestors { - ancestorStack, err := d.Node(ancestorID) - if err != nil { - return errors.E(err, "getting ancestor node from DAG") - } - - ancestorDir := ancestorStack.Stack.Dir.String() - friendlyAncestorDir, ok := s.Engine.FriendlyFmtDir(ancestorDir) - if !ok { - return errors.E("unable to format stack dir %s", ancestorDir) - } - - ancestorLabel, err := s.getLabel(ancestorStack.Stack, friendlyAncestorDir) - if err != nil { - return err - } + for _, ancestorID := range m.AncestorIDs { + ancestorMeta := metadata[ancestorID] - ancestorNode := dotGraph.Node(friendlyAncestorDir) - if ancestorLabel != friendlyAncestorDir { - ancestorNode.Attr("label", ancestorLabel) + ancestorNode := dotGraph.Node(ancestorMeta.FriendlyDir) + if ancestorMeta.Label != ancestorMeta.FriendlyDir { + ancestorNode.Attr("label", ancestorMeta.Label) } // check if edge already exists to avoid duplicates diff --git a/run/dag/dag.go b/run/dag/dag.go index 2618b19eb..b2c8aeca2 100644 --- a/run/dag/dag.go +++ b/run/dag/dag.go @@ -227,22 +227,26 @@ func (d *DAG[V]) DirectAncestorsOf(id ID) []ID { return ancestors // Already minimal } - var directAncestors []ID + ancestorSet := make(map[ID]bool, len(ancestors)) + for _, a := range ancestors { + ancestorSet[a] = true + } + reachableAncestors := make(map[ID]map[ID]bool, len(ancestors)) for _, ancestorID := range ancestors { - isTransitive := false + reachableAncestors[ancestorID] = d.findReachableAncestors(ancestorID, ancestorSet) + } + var directAncestors []ID + for _, ancestorID := range ancestors { + isDirect := true for _, otherID := range ancestors { - if ancestorID == otherID { - continue - } - if d.hasPath(otherID, ancestorID) { - isTransitive = true + if ancestorID != otherID && reachableAncestors[otherID][ancestorID] { + isDirect = false break } } - - if !isTransitive { + if isDirect { directAncestors = append(directAncestors, ancestorID) } } @@ -250,10 +254,44 @@ func (d *DAG[V]) DirectAncestorsOf(id ID) []ID { return directAncestors } +// findReachableAncestors finds all ancestors reachable from 'from' that are in the targets set. +func (d *DAG[V]) findReachableAncestors(from ID, targets map[ID]bool) map[ID]bool { + reachable := make(map[ID]bool) + visited := make(map[ID]bool) + queue := make([]ID, 0, 16) // Pre-allocate reasonable capacity + queue = append(queue, from) + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + if visited[current] { + continue + } + visited[current] = true + + for _, ancestor := range d.AncestorsOf(current) { + if targets[ancestor] { + reachable[ancestor] = true + } + if !visited[ancestor] { + queue = append(queue, ancestor) + } + } + } + + return reachable +} + // hasPath checks if 'to' is reachable from 'from' via ancestors (BFS traversal). func (d *DAG[V]) hasPath(from, to ID) bool { + if from == to { + return true + } + visited := make(map[ID]bool) - queue := []ID{from} + queue := make([]ID, 0, 16) // bfs queue preallocation + queue = append(queue, from) for len(queue) > 0 { current := queue[0] From 4a3a3b963d5d42ce1afbe419ff38873eb44af7ba Mon Sep 17 00:00:00 2001 From: timsolovev <7433083+timsolovev@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:56:57 +0200 Subject: [PATCH 5/7] style: replace term 'path' with 'dir' for consistency --- commands/stack/list/list.go | 4 ++-- e2etests/core/list_json_test.go | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/commands/stack/list/list.go b/commands/stack/list/list.go index 93f48eff9..b797d169d 100644 --- a/commands/stack/list/list.go +++ b/commands/stack/list/list.go @@ -44,7 +44,7 @@ type StatusFilters struct { // StackInfo represents stack information for JSON output. type StackInfo struct { - Path string `json:"path"` + Dir string `json:"dir"` ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` @@ -249,7 +249,7 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi } info := StackInfo{ - Path: m.FriendlyDir, + Dir: m.FriendlyDir, ID: m.Stack.Stack.ID, Name: m.Stack.Stack.Name, Description: m.Stack.Stack.Description, diff --git a/e2etests/core/list_json_test.go b/e2etests/core/list_json_test.go index 8d4fbd316..8863e14c3 100644 --- a/e2etests/core/list_json_test.go +++ b/e2etests/core/list_json_test.go @@ -15,7 +15,7 @@ func TestListJSON(t *testing.T) { t.Parallel() type stackInfo struct { - Path string `json:"path"` + Dir string `json:"dir"` ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` @@ -28,7 +28,7 @@ func TestListJSON(t *testing.T) { type testcase struct { name string layout []string - want map[string][]string // map of stack path -> dependencies + want map[string][]string // map of stack dir -> dependencies } for _, tcase := range []testcase{ @@ -174,14 +174,14 @@ func TestListJSON(t *testing.T) { t.Fatalf("expected %d stacks, got %d", len(tc.want), len(got)) } - for stackPath, wantDeps := range tc.want { - info, ok := got[stackPath] + for stackDir, wantDeps := range tc.want { + info, ok := got[stackDir] if !ok { - t.Fatalf("stack %q not found in output", stackPath) + t.Fatalf("stack %q not found in output", stackDir) } - if info.Path != stackPath { - t.Fatalf("stack path mismatch: expected %q, got %q", stackPath, info.Path) + if info.Dir != stackDir { + t.Fatalf("stack dir mismatch: expected %q, got %q", stackDir, info.Dir) } // convert to maps, order doesn't matter for dependencies @@ -196,12 +196,12 @@ func TestListJSON(t *testing.T) { } if len(wantDepsMap) != len(gotDepsMap) { - t.Fatalf("stack %q: expected %d dependencies, got %d", stackPath, len(wantDeps), len(info.Dependencies)) + t.Fatalf("stack %q: expected %d dependencies, got %d", stackDir, len(wantDeps), len(info.Dependencies)) } for dep := range wantDepsMap { if !gotDepsMap[dep] { - t.Fatalf("stack %q: missing dependency %q", stackPath, dep) + t.Fatalf("stack %q: missing dependency %q", stackDir, dep) } } } From 0aa395556cc370fbc1325c0b0449b0d3098353ee Mon Sep 17 00:00:00 2001 From: timsolovev <7433083+timsolovev@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:23:02 +0200 Subject: [PATCH 6/7] fix: return empty tag list intead of null --- commands/stack/list/list.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/commands/stack/list/list.go b/commands/stack/list/list.go index b797d169d..912b33557 100644 --- a/commands/stack/list/list.go +++ b/commands/stack/list/list.go @@ -248,12 +248,17 @@ func (s *Spec) printStacksListJSON(stacks config.List[*config.SortableStack], fi deps = append(deps, ancestorMeta.Label) } + tags := m.Stack.Stack.Tags + if tags == nil { + tags = []string{} + } + info := StackInfo{ Dir: m.FriendlyDir, ID: m.Stack.Stack.ID, Name: m.Stack.Stack.Name, Description: m.Stack.Stack.Description, - Tags: m.Stack.Stack.Tags, + Tags: tags, Dependencies: deps, Reason: m.Entry.Reason, IsChanged: m.Stack.Stack.IsChanged, From 39da87699e842720d5b0bd27415d59595445e3dc Mon Sep 17 00:00:00 2001 From: timsolovev <7433083+timsolovev@users.noreply.github.com> Date: Mon, 6 Oct 2025 14:51:29 +0200 Subject: [PATCH 7/7] refactor: remove unused hasPath method Signed-off-by: timsolovev <7433083+timsolovev@users.noreply.github.com> --- run/dag/dag.go | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/run/dag/dag.go b/run/dag/dag.go index b2c8aeca2..269effec1 100644 --- a/run/dag/dag.go +++ b/run/dag/dag.go @@ -283,38 +283,6 @@ func (d *DAG[V]) findReachableAncestors(from ID, targets map[ID]bool) map[ID]boo return reachable } -// hasPath checks if 'to' is reachable from 'from' via ancestors (BFS traversal). -func (d *DAG[V]) hasPath(from, to ID) bool { - if from == to { - return true - } - - visited := make(map[ID]bool) - queue := make([]ID, 0, 16) // bfs queue preallocation - queue = append(queue, from) - - for len(queue) > 0 { - current := queue[0] - queue = queue[1:] - - if visited[current] { - continue - } - visited[current] = true - - for _, ancestor := range d.AncestorsOf(current) { - if ancestor == to { - return true - } - if !visited[ancestor] { - queue = append(queue, ancestor) - } - } - } - - return false -} - // HasCycle returns true if the DAG has a cycle. func (d *DAG[V]) HasCycle(id ID) bool { if !d.validated {