Skip to content

Commit fe970fc

Browse files
committed
Format dependency failure tree output
1 parent c259676 commit fe970fc

5 files changed

Lines changed: 66 additions & 55 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: 32 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,33 @@ 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+
indentLevel := i
54+
if indentLevel == 0 {
55+
indentLevel = 1
56+
}
57+
builder.WriteString(strings.Repeat(dependencyTreeIndent, indentLevel))
58+
if i > 0 {
59+
builder.WriteString(dependencyTreeJoint)
60+
}
61+
builder.WriteString(name)
62+
63+
if i == len(e.Chain)-1 {
64+
builder.WriteString(dependencyTreeIndent)
65+
builder.WriteString(red("<-- failed here"))
66+
}
67+
}
68+
69+
return builder.String()
70+
}
71+
4372
// prependToChain prepends name to the chain in err if err is already a *DependencyError,
4473
// otherwise wraps err in a new single-element DependencyError.
4574
func prependToChain(name string, err error) error {
@@ -55,19 +84,5 @@ func prependToChain(name string, err error) error {
5584
// The failing node (last in chain) is annotated in red.
5685
// Respects NO_COLOR automatically via fatih/color.
5786
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-
}
87+
fmt.Fprintln(w, e.TreeMessage())
7388
}

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 + "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 + "deploy",
183+
dependencyTreeIndent + dependencyTreeJoint + "build",
184+
strings.Repeat(dependencyTreeIndent, 2) + 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/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)