From fe970fcdd643c1b56a2eb6b2be5a33d5f4b2858d Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Fri, 27 Mar 2026 19:30:56 +0200 Subject: [PATCH 1/3] Format dependency failure tree output --- docs/docs/changelog.md | 2 +- internal/cli/cli.go | 2 +- internal/executor/dependency_error.go | 49 +++++++++++++------- internal/executor/dependency_error_test.go | 52 ++++++++++------------ tests/dependency_failure_tree.bats | 16 +++---- 5 files changed, 66 insertions(+), 55 deletions(-) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 561211b..bdd196c 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -11,7 +11,7 @@ title: Changelog * `[Added]` Expose `LETS_OS` and `LETS_ARCH` environment variables at command runtime. * `[Removed]` Drop deprecated `eval_env` directive. Use `env` with `sh` execution mode instead. * `[Added]` When a command or its `depends` chain fails, print an indented tree to stderr showing the full chain with the failing command highlighted -* `[Changed]` Format command failure output as a `lets:`-prefixed tree plus a separate final status line such as `lets: exit status 1`. +* `[Changed]` Format command failure output as a logger-prefixed `command failed:` block followed by the dependency tree, while preserving the final status line such as `lets: exit status 1`. * `[Added]` Support `env_file` in global config and commands. File names are expanded after `env` is resolved, and values loaded from env files override values from `env`. * `[Changed]` Migrate the LSP YAML parser from the CGO-based tree-sitter bindings to pure-Go [`gotreesitter`](https://github.com/odvcencio/gotreesitter), removing the C toolchain requirement from normal builds and release packaging. * `[Refactoring]` Move CLI startup flow from `cmd/lets/main.go` into `internal/cli/cli.go`, keeping `main.go` as a thin launcher. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b7d5a68..8063b4e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -134,7 +134,7 @@ func Main(version string, buildDate string) int { if err := rootCmd.ExecuteContext(ctx); err != nil { var depErr *executor.DependencyError if errors.As(err, &depErr) { - executor.PrintDependencyTree(depErr, os.Stderr) + log.Errorf("%s", depErr.TreeMessage()) log.Errorf("%s", depErr.FailureMessage()) return getExitCode(err, 1) } diff --git a/internal/executor/dependency_error.go b/internal/executor/dependency_error.go index b1d327a..39d5319 100644 --- a/internal/executor/dependency_error.go +++ b/internal/executor/dependency_error.go @@ -9,6 +9,10 @@ import ( "github.com/fatih/color" ) +const dependencyTreeIndent = " " +const dependencyTreeHeader = "command failed:" +const dependencyTreeJoint = "└─ " + // DependencyError carries the full dependency chain when a command fails. // Chain is outermost-first (e.g., ["deploy", "build", "lint"]). type DependencyError struct { @@ -16,8 +20,6 @@ type DependencyError struct { Err error } -const treePrefix = "lets: " - func (e *DependencyError) Error() string { return e.Err.Error() } func (e *DependencyError) Unwrap() error { return e.Err } @@ -40,6 +42,33 @@ func (e *DependencyError) FailureMessage() string { return e.Err.Error() } +func (e *DependencyError) TreeMessage() string { + red := color.New(color.FgRed).SprintFunc() + var builder strings.Builder + + builder.WriteString(dependencyTreeHeader) + + for i, name := range e.Chain { + builder.WriteByte('\n') + indentLevel := i + if indentLevel == 0 { + indentLevel = 1 + } + builder.WriteString(strings.Repeat(dependencyTreeIndent, indentLevel)) + if i > 0 { + builder.WriteString(dependencyTreeJoint) + } + builder.WriteString(name) + + if i == len(e.Chain)-1 { + builder.WriteString(dependencyTreeIndent) + builder.WriteString(red("<-- failed here")) + } + } + + return builder.String() +} + // prependToChain prepends name to the chain in err if err is already a *DependencyError, // otherwise wraps err in a new single-element DependencyError. func prependToChain(name string, err error) error { @@ -55,19 +84,5 @@ func prependToChain(name string, err error) error { // The failing node (last in chain) is annotated in red. // Respects NO_COLOR automatically via fatih/color. func PrintDependencyTree(e *DependencyError, w io.Writer) { - red := color.New(color.FgRed).SprintFunc() - treeIndent := strings.Repeat(" ", len(treePrefix)) - - for i, name := range e.Chain { - indent := treeIndent + strings.Repeat(" ", i+1) - if i == 0 { - indent = treePrefix - } - - if i == len(e.Chain)-1 { - fmt.Fprintf(w, "%s%s %s\n", indent, name, red("<-- failed here")) - } else { - fmt.Fprintf(w, "%s%s\n", indent, name) - } - } + fmt.Fprintln(w, e.TreeMessage()) } diff --git a/internal/executor/dependency_error_test.go b/internal/executor/dependency_error_test.go index 11d24f2..af8ac01 100644 --- a/internal/executor/dependency_error_test.go +++ b/internal/executor/dependency_error_test.go @@ -150,14 +150,18 @@ func TestPrintDependencyTree(t *testing.T) { PrintDependencyTree(depErr, &buf) out := buf.String() lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - if len(lines) != 1 { - t.Fatalf("expected 1 line, got %d: %v", len(lines), lines) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) } - if !strings.HasPrefix(lines[0], "lets: lint") { - t.Errorf("expected line to start with 'lets: lint', got: %q", lines[0]) + want := []string{ + dependencyTreeHeader, + dependencyTreeIndent + "lint" + dependencyTreeIndent + "<-- failed here", } - if !strings.Contains(out, "failed here") { - t.Errorf("expected 'failed here' annotation on lint line, got: %q", out) + + for i := range want { + if lines[i] != want[i] { + t.Errorf("line %d: want %q, got %q", i, want[i], lines[i]) + } } }) @@ -170,28 +174,20 @@ func TestPrintDependencyTree(t *testing.T) { PrintDependencyTree(depErr, &buf) lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") - if len(lines) != 3 { - t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines) - } - // index 0 = 2 spaces, index 1 = 4 spaces, index 2 = 6 spaces (outermost first) - checks := []struct { - prefix string - name string - hasFailed bool - }{ - {"lets: ", "deploy", false}, - {" ", "build", false}, - {" ", "lint", true}, - } - for i, c := range checks { - if !strings.HasPrefix(lines[i], c.prefix+c.name) { - t.Errorf("line %d: want prefix %q + name %q, got %q", i, c.prefix, c.name, lines[i]) - } - if c.hasFailed && !strings.Contains(lines[i], "failed here") { - t.Errorf("line %d: expected 'failed here' annotation, got %q", i, lines[i]) - } - if !c.hasFailed && strings.Contains(lines[i], "failed here") { - t.Errorf("line %d: unexpected 'failed here' annotation on non-failing node, got %q", i, lines[i]) + if len(lines) != 4 { + t.Fatalf("expected 4 lines, got %d: %v", len(lines), lines) + } + want := []string{ + dependencyTreeHeader, + dependencyTreeIndent + "deploy", + dependencyTreeIndent + dependencyTreeJoint + "build", + strings.Repeat(dependencyTreeIndent, 2) + dependencyTreeJoint + "lint" + + dependencyTreeIndent + "<-- failed here", + } + + for i := range want { + if lines[i] != want[i] { + t.Errorf("line %d: want %q, got %q", i, want[i], lines[i]) } } }) diff --git a/tests/dependency_failure_tree.bats b/tests/dependency_failure_tree.bats index 6126f21..1a526f9 100644 --- a/tests/dependency_failure_tree.bats +++ b/tests/dependency_failure_tree.bats @@ -9,17 +9,17 @@ setup() { @test "dependency_failure_tree: shows full 3-level chain on failure" { run env NO_COLOR=1 lets deploy assert_failure - assert_line --index 0 "lets: deploy" - assert_line --index 1 " build" - assert_line --index 2 --partial " lint" - assert_line --index 2 --partial "failed here" - assert_line --index 3 "lets: exit status 1" + assert_line --index 0 "lets: command failed:" + assert_line --index 1 " deploy" + assert_line --index 2 " └─ build" + assert_line --index 3 " └─ lint <-- failed here" + assert_line --index 4 "lets: exit status 1" } @test "dependency_failure_tree: single node when no depends" { run env NO_COLOR=1 lets lint assert_failure - assert_line --index 0 --partial "lets: lint" - assert_line --index 0 --partial "failed here" - assert_line --index 1 "lets: exit status 1" + assert_line --index 0 "lets: command failed:" + assert_line --index 1 " lint <-- failed here" + assert_line --index 2 "lets: exit status 1" } From b7a848398adaffa633649252d2bd7987e2760387 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Fri, 27 Mar 2026 19:34:22 +0200 Subject: [PATCH 2/3] Add joint to root command in failure tree --- internal/executor/dependency_error.go | 10 ++-------- internal/executor/dependency_error_test.go | 8 ++++---- tests/dependency_failure_tree.bats | 8 ++++---- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/internal/executor/dependency_error.go b/internal/executor/dependency_error.go index 39d5319..0b2ed75 100644 --- a/internal/executor/dependency_error.go +++ b/internal/executor/dependency_error.go @@ -50,14 +50,8 @@ func (e *DependencyError) TreeMessage() string { for i, name := range e.Chain { builder.WriteByte('\n') - indentLevel := i - if indentLevel == 0 { - indentLevel = 1 - } - builder.WriteString(strings.Repeat(dependencyTreeIndent, indentLevel)) - if i > 0 { - builder.WriteString(dependencyTreeJoint) - } + builder.WriteString(strings.Repeat(dependencyTreeIndent, i+1)) + builder.WriteString(dependencyTreeJoint) builder.WriteString(name) if i == len(e.Chain)-1 { diff --git a/internal/executor/dependency_error_test.go b/internal/executor/dependency_error_test.go index af8ac01..2e20195 100644 --- a/internal/executor/dependency_error_test.go +++ b/internal/executor/dependency_error_test.go @@ -155,7 +155,7 @@ func TestPrintDependencyTree(t *testing.T) { } want := []string{ dependencyTreeHeader, - dependencyTreeIndent + "lint" + dependencyTreeIndent + "<-- failed here", + dependencyTreeIndent + dependencyTreeJoint + "lint" + dependencyTreeIndent + "<-- failed here", } for i := range want { @@ -179,9 +179,9 @@ func TestPrintDependencyTree(t *testing.T) { } want := []string{ dependencyTreeHeader, - dependencyTreeIndent + "deploy", - dependencyTreeIndent + dependencyTreeJoint + "build", - strings.Repeat(dependencyTreeIndent, 2) + dependencyTreeJoint + "lint" + + dependencyTreeIndent + dependencyTreeJoint + "deploy", + strings.Repeat(dependencyTreeIndent, 2) + dependencyTreeJoint + "build", + strings.Repeat(dependencyTreeIndent, 3) + dependencyTreeJoint + "lint" + dependencyTreeIndent + "<-- failed here", } diff --git a/tests/dependency_failure_tree.bats b/tests/dependency_failure_tree.bats index 1a526f9..3da1955 100644 --- a/tests/dependency_failure_tree.bats +++ b/tests/dependency_failure_tree.bats @@ -10,9 +10,9 @@ setup() { run env NO_COLOR=1 lets deploy assert_failure assert_line --index 0 "lets: command failed:" - assert_line --index 1 " deploy" - assert_line --index 2 " └─ build" - assert_line --index 3 " └─ lint <-- failed here" + assert_line --index 1 " └─ deploy" + assert_line --index 2 " └─ build" + assert_line --index 3 " └─ lint <-- failed here" assert_line --index 4 "lets: exit status 1" } @@ -20,6 +20,6 @@ setup() { run env NO_COLOR=1 lets lint assert_failure assert_line --index 0 "lets: command failed:" - assert_line --index 1 " lint <-- failed here" + assert_line --index 1 " └─ lint <-- failed here" assert_line --index 2 "lets: exit status 1" } From 8b6f636ea235e98724985d8015ae83bd29b784e5 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Fri, 27 Mar 2026 19:45:19 +0200 Subject: [PATCH 3/3] Update bats tests for command failure tree output --- tests/command_cmd.bats | 3 ++- tests/command_options.bats | 24 ++++++++++++++---------- tests/default_env.bats | 6 +++--- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/tests/command_cmd.bats b/tests/command_cmd.bats index 19db1b0..9533ddc 100644 --- a/tests/command_cmd.bats +++ b/tests/command_cmd.bats @@ -43,7 +43,8 @@ setup() { # as there is no guarantee in which order cmds runs # we can not guarantee that all commands will run and complete. # But error message must be in the output. - assert_output --partial "lets: cmd-as-map-error" + assert_output --partial "lets: command failed:" + assert_output --partial "└─ cmd-as-map-error <-- failed here" assert_output --partial "lets: exit status 2" } diff --git a/tests/command_options.bats b/tests/command_options.bats index 8bcfe3e..2ca800d 100644 --- a/tests/command_options.bats +++ b/tests/command_options.bats @@ -108,22 +108,26 @@ setup() { run lets test-options --kv-opt assert_failure - assert_line --index 1 "lets: failed to parse docopt options for cmd test-options: --kv-opt requires argument" - assert_line --index 2 "Usage:" - assert_line --index 3 " lets test-options [--kv-opt=] [--bool-opt] [--attr=...] [...]" - assert_line --index 4 "Options:" - assert_line --index 5 " ... Positional args in the end" - assert_line --index 6 " --bool-opt, -b Boolean opt" - assert_line --index 7 " --kv-opt=, -K Key value opt" - assert_line --index 8 " --attr=... Repeated kv args" + assert_line --index 0 "lets: command failed:" + assert_line --index 1 " └─ test-options <-- failed here" + assert_line --index 2 "lets: failed to parse docopt options for cmd test-options: --kv-opt requires argument" + assert_line --index 3 "Usage:" + assert_line --index 4 " lets test-options [--kv-opt=] [--bool-opt] [--attr=...] [...]" + assert_line --index 5 "Options:" + assert_line --index 6 " ... Positional args in the end" + assert_line --index 7 " --bool-opt, -b Boolean opt" + assert_line --index 8 " --kv-opt=, -K Key value opt" + assert_line --index 9 " --attr=... Repeated kv args" } @test "command_options: wrong usage" { run lets options-wrong-usage assert_failure - assert_line --index 1 "lets: failed to parse docopt options for cmd options-wrong-usage: unknown option or argument: options-wrong-usage" - assert_line --index 2 "Usage: lets options-wrong-usage-xxx" + assert_line --index 0 "lets: command failed:" + assert_line --index 1 " └─ options-wrong-usage <-- failed here" + assert_line --index 2 "lets: failed to parse docopt options for cmd options-wrong-usage: unknown option or argument: options-wrong-usage" + assert_line --index 3 "Usage: lets options-wrong-usage-xxx" } @test "command_options: should not break json argument" { diff --git a/tests/default_env.bats b/tests/default_env.bats index 829ce4f..c21014c 100644 --- a/tests/default_env.bats +++ b/tests/default_env.bats @@ -61,7 +61,7 @@ setup() { LETS_CONFIG_DIR=./a run lets print-workdir assert_failure - assert_line --index 0 --partial "lets: print-workdir" - assert_line --index 0 --partial "failed here" - assert_line --index 1 "lets: chdir ${TEST_DIR}/b: no such file or directory" + assert_line --index 0 "lets: command failed:" + assert_line --index 1 " └─ print-workdir <-- failed here" + assert_line --index 2 "lets: chdir ${TEST_DIR}/b: no such file or directory" }