Skip to content

Commit caa90c6

Browse files
authored
Merge pull request #324 from lets-cli/codex/use-two-spaces-for-tree
Format dependency failure output as an indented tree
2 parents c259676 + 8b6f636 commit caa90c6

8 files changed

Lines changed: 79 additions & 69 deletions

File tree

docs/docs/changelog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ title: Changelog
1111
* `[Added]` Expose `LETS_OS` and `LETS_ARCH` environment variables at command runtime.
1212
* `[Removed]` Drop deprecated `eval_env` directive. Use `env` with `sh` execution mode instead.
1313
* `[Added]` When a command or its `depends` chain fails, print an indented tree to stderr showing the full chain with the failing command highlighted
14-
* `[Changed]` Format command failure output as a `lets:`-prefixed tree plus a separate final status line such as `lets: exit status 1`.
14+
* `[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`.
1515
* `[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`.
1616
* `[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.
1717
* `[Refactoring]` Move CLI startup flow from `cmd/lets/main.go` into `internal/cli/cli.go`, keeping `main.go` as a thin launcher.

internal/cli/cli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func Main(version string, buildDate string) int {
134134
if err := rootCmd.ExecuteContext(ctx); err != nil {
135135
var depErr *executor.DependencyError
136136
if errors.As(err, &depErr) {
137-
executor.PrintDependencyTree(depErr, os.Stderr)
137+
log.Errorf("%s", depErr.TreeMessage())
138138
log.Errorf("%s", depErr.FailureMessage())
139139
return getExitCode(err, 1)
140140
}

internal/executor/dependency_error.go

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@ import (
99
"github.com/fatih/color"
1010
)
1111

12+
const dependencyTreeIndent = " "
13+
const dependencyTreeHeader = "command failed:"
14+
const dependencyTreeJoint = "└─ "
15+
1216
// DependencyError carries the full dependency chain when a command fails.
1317
// Chain is outermost-first (e.g., ["deploy", "build", "lint"]).
1418
type DependencyError struct {
1519
Chain []string
1620
Err error
1721
}
1822

19-
const treePrefix = "lets: "
20-
2123
func (e *DependencyError) Error() string { return e.Err.Error() }
2224
func (e *DependencyError) Unwrap() error { return e.Err }
2325

@@ -40,6 +42,27 @@ func (e *DependencyError) FailureMessage() string {
4042
return e.Err.Error()
4143
}
4244

45+
func (e *DependencyError) TreeMessage() string {
46+
red := color.New(color.FgRed).SprintFunc()
47+
var builder strings.Builder
48+
49+
builder.WriteString(dependencyTreeHeader)
50+
51+
for i, name := range e.Chain {
52+
builder.WriteByte('\n')
53+
builder.WriteString(strings.Repeat(dependencyTreeIndent, i+1))
54+
builder.WriteString(dependencyTreeJoint)
55+
builder.WriteString(name)
56+
57+
if i == len(e.Chain)-1 {
58+
builder.WriteString(dependencyTreeIndent)
59+
builder.WriteString(red("<-- failed here"))
60+
}
61+
}
62+
63+
return builder.String()
64+
}
65+
4366
// prependToChain prepends name to the chain in err if err is already a *DependencyError,
4467
// otherwise wraps err in a new single-element DependencyError.
4568
func prependToChain(name string, err error) error {
@@ -55,19 +78,5 @@ func prependToChain(name string, err error) error {
5578
// The failing node (last in chain) is annotated in red.
5679
// Respects NO_COLOR automatically via fatih/color.
5780
func PrintDependencyTree(e *DependencyError, w io.Writer) {
58-
red := color.New(color.FgRed).SprintFunc()
59-
treeIndent := strings.Repeat(" ", len(treePrefix))
60-
61-
for i, name := range e.Chain {
62-
indent := treeIndent + strings.Repeat(" ", i+1)
63-
if i == 0 {
64-
indent = treePrefix
65-
}
66-
67-
if i == len(e.Chain)-1 {
68-
fmt.Fprintf(w, "%s%s %s\n", indent, name, red("<-- failed here"))
69-
} else {
70-
fmt.Fprintf(w, "%s%s\n", indent, name)
71-
}
72-
}
81+
fmt.Fprintln(w, e.TreeMessage())
7382
}

internal/executor/dependency_error_test.go

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,18 @@ func TestPrintDependencyTree(t *testing.T) {
150150
PrintDependencyTree(depErr, &buf)
151151
out := buf.String()
152152
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
153-
if len(lines) != 1 {
154-
t.Fatalf("expected 1 line, got %d: %v", len(lines), lines)
153+
if len(lines) != 2 {
154+
t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines)
155155
}
156-
if !strings.HasPrefix(lines[0], "lets: lint") {
157-
t.Errorf("expected line to start with 'lets: lint', got: %q", lines[0])
156+
want := []string{
157+
dependencyTreeHeader,
158+
dependencyTreeIndent + dependencyTreeJoint + "lint" + dependencyTreeIndent + "<-- failed here",
158159
}
159-
if !strings.Contains(out, "failed here") {
160-
t.Errorf("expected 'failed here' annotation on lint line, got: %q", out)
160+
161+
for i := range want {
162+
if lines[i] != want[i] {
163+
t.Errorf("line %d: want %q, got %q", i, want[i], lines[i])
164+
}
161165
}
162166
})
163167

@@ -170,28 +174,20 @@ func TestPrintDependencyTree(t *testing.T) {
170174
PrintDependencyTree(depErr, &buf)
171175
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
172176

173-
if len(lines) != 3 {
174-
t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines)
175-
}
176-
// index 0 = 2 spaces, index 1 = 4 spaces, index 2 = 6 spaces (outermost first)
177-
checks := []struct {
178-
prefix string
179-
name string
180-
hasFailed bool
181-
}{
182-
{"lets: ", "deploy", false},
183-
{" ", "build", false},
184-
{" ", "lint", true},
185-
}
186-
for i, c := range checks {
187-
if !strings.HasPrefix(lines[i], c.prefix+c.name) {
188-
t.Errorf("line %d: want prefix %q + name %q, got %q", i, c.prefix, c.name, lines[i])
189-
}
190-
if c.hasFailed && !strings.Contains(lines[i], "failed here") {
191-
t.Errorf("line %d: expected 'failed here' annotation, got %q", i, lines[i])
192-
}
193-
if !c.hasFailed && strings.Contains(lines[i], "failed here") {
194-
t.Errorf("line %d: unexpected 'failed here' annotation on non-failing node, got %q", i, lines[i])
177+
if len(lines) != 4 {
178+
t.Fatalf("expected 4 lines, got %d: %v", len(lines), lines)
179+
}
180+
want := []string{
181+
dependencyTreeHeader,
182+
dependencyTreeIndent + dependencyTreeJoint + "deploy",
183+
strings.Repeat(dependencyTreeIndent, 2) + dependencyTreeJoint + "build",
184+
strings.Repeat(dependencyTreeIndent, 3) + dependencyTreeJoint + "lint" +
185+
dependencyTreeIndent + "<-- failed here",
186+
}
187+
188+
for i := range want {
189+
if lines[i] != want[i] {
190+
t.Errorf("line %d: want %q, got %q", i, want[i], lines[i])
195191
}
196192
}
197193
})

tests/command_cmd.bats

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ setup() {
4343
# as there is no guarantee in which order cmds runs
4444
# we can not guarantee that all commands will run and complete.
4545
# But error message must be in the output.
46-
assert_output --partial "lets: cmd-as-map-error"
46+
assert_output --partial "lets: command failed:"
47+
assert_output --partial "└─ cmd-as-map-error <-- failed here"
4748
assert_output --partial "lets: exit status 2"
4849
}
4950

tests/command_options.bats

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,22 +108,26 @@ setup() {
108108
run lets test-options --kv-opt
109109

110110
assert_failure
111-
assert_line --index 1 "lets: failed to parse docopt options for cmd test-options: --kv-opt requires argument"
112-
assert_line --index 2 "Usage:"
113-
assert_line --index 3 " lets test-options [--kv-opt=<kv-opt>] [--bool-opt] [--attr=<attr>...] [<args>...]"
114-
assert_line --index 4 "Options:"
115-
assert_line --index 5 " <args>... Positional args in the end"
116-
assert_line --index 6 " --bool-opt, -b Boolean opt"
117-
assert_line --index 7 " --kv-opt=<kv-opt>, -K Key value opt"
118-
assert_line --index 8 " --attr=<attr>... Repeated kv args"
111+
assert_line --index 0 "lets: command failed:"
112+
assert_line --index 1 " └─ test-options <-- failed here"
113+
assert_line --index 2 "lets: failed to parse docopt options for cmd test-options: --kv-opt requires argument"
114+
assert_line --index 3 "Usage:"
115+
assert_line --index 4 " lets test-options [--kv-opt=<kv-opt>] [--bool-opt] [--attr=<attr>...] [<args>...]"
116+
assert_line --index 5 "Options:"
117+
assert_line --index 6 " <args>... Positional args in the end"
118+
assert_line --index 7 " --bool-opt, -b Boolean opt"
119+
assert_line --index 8 " --kv-opt=<kv-opt>, -K Key value opt"
120+
assert_line --index 9 " --attr=<attr>... Repeated kv args"
119121
}
120122

121123
@test "command_options: wrong usage" {
122124
run lets options-wrong-usage
123125

124126
assert_failure
125-
assert_line --index 1 "lets: failed to parse docopt options for cmd options-wrong-usage: unknown option or argument: options-wrong-usage"
126-
assert_line --index 2 "Usage: lets options-wrong-usage-xxx"
127+
assert_line --index 0 "lets: command failed:"
128+
assert_line --index 1 " └─ options-wrong-usage <-- failed here"
129+
assert_line --index 2 "lets: failed to parse docopt options for cmd options-wrong-usage: unknown option or argument: options-wrong-usage"
130+
assert_line --index 3 "Usage: lets options-wrong-usage-xxx"
127131
}
128132

129133
@test "command_options: should not break json argument" {

tests/default_env.bats

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ setup() {
6161
LETS_CONFIG_DIR=./a run lets print-workdir
6262

6363
assert_failure
64-
assert_line --index 0 --partial "lets: print-workdir"
65-
assert_line --index 0 --partial "failed here"
66-
assert_line --index 1 "lets: chdir ${TEST_DIR}/b: no such file or directory"
64+
assert_line --index 0 "lets: command failed:"
65+
assert_line --index 1 " └─ print-workdir <-- failed here"
66+
assert_line --index 2 "lets: chdir ${TEST_DIR}/b: no such file or directory"
6767
}

tests/dependency_failure_tree.bats

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,17 @@ setup() {
99
@test "dependency_failure_tree: shows full 3-level chain on failure" {
1010
run env NO_COLOR=1 lets deploy
1111
assert_failure
12-
assert_line --index 0 "lets: deploy"
13-
assert_line --index 1 " build"
14-
assert_line --index 2 --partial " lint"
15-
assert_line --index 2 --partial "failed here"
16-
assert_line --index 3 "lets: exit status 1"
12+
assert_line --index 0 "lets: command failed:"
13+
assert_line --index 1 " └─ deploy"
14+
assert_line --index 2 " └─ build"
15+
assert_line --index 3 " └─ lint <-- failed here"
16+
assert_line --index 4 "lets: exit status 1"
1717
}
1818

1919
@test "dependency_failure_tree: single node when no depends" {
2020
run env NO_COLOR=1 lets lint
2121
assert_failure
22-
assert_line --index 0 --partial "lets: lint"
23-
assert_line --index 0 --partial "failed here"
24-
assert_line --index 1 "lets: exit status 1"
22+
assert_line --index 0 "lets: command failed:"
23+
assert_line --index 1 " └─ lint <-- failed here"
24+
assert_line --index 2 "lets: exit status 1"
2525
}

0 commit comments

Comments
 (0)