Skip to content

Commit 425e1e3

Browse files
authored
Merge pull request #321 from lets-cli/codex/update-failure-log-format
Fix command failure output formatting
2 parents 94747fb + c14f3ef commit 425e1e3

8 files changed

Lines changed: 91 additions & 13 deletions

File tree

docs/docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +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`.
1415
* `[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`.
1516
* `[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.
1617
* `[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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ func Main(version string, buildDate string) int {
135135
var depErr *executor.DependencyError
136136
if errors.As(err, &depErr) {
137137
executor.PrintDependencyTree(depErr, os.Stderr)
138+
log.Errorf("%s", depErr.FailureMessage())
139+
return getExitCode(err, 1)
138140
}
139141

140142
log.Errorf("%s", err.Error())

internal/executor/dependency_error.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ type DependencyError struct {
1616
Err error
1717
}
1818

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

@@ -29,6 +31,15 @@ func (e *DependencyError) ExitCode() int {
2931
return 1
3032
}
3133

34+
func (e *DependencyError) FailureMessage() string {
35+
var executeErr *ExecuteError
36+
if errors.As(e.Err, &executeErr) {
37+
return executeErr.Cause().Error()
38+
}
39+
40+
return e.Err.Error()
41+
}
42+
3243
// prependToChain prepends name to the chain in err if err is already a *DependencyError,
3344
// otherwise wraps err in a new single-element DependencyError.
3445
func prependToChain(name string, err error) error {
@@ -45,9 +56,14 @@ func prependToChain(name string, err error) error {
4556
// Respects NO_COLOR automatically via fatih/color.
4657
func PrintDependencyTree(e *DependencyError, w io.Writer) {
4758
red := color.New(color.FgRed).SprintFunc()
59+
treeIndent := strings.Repeat(" ", len(treePrefix))
4860

4961
for i, name := range e.Chain {
50-
indent := strings.Repeat(" ", i+1)
62+
indent := treeIndent + strings.Repeat(" ", i+1)
63+
if i == 0 {
64+
indent = treePrefix
65+
}
66+
5167
if i == len(e.Chain)-1 {
5268
fmt.Fprintf(w, "%s%s %s\n", indent, name, red("<-- failed here"))
5369
} else {

internal/executor/dependency_error_test.go

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package executor
33
import (
44
"bytes"
55
"fmt"
6+
"os"
67
"os/exec"
78
"strings"
9+
"syscall"
810
"testing"
911
)
1012

@@ -101,6 +103,46 @@ func TestDependencyErrorError(t *testing.T) {
101103
}
102104
}
103105

106+
func TestDependencyErrorFailureMessage(t *testing.T) {
107+
t.Run("returns root cause for execute errors", func(t *testing.T) {
108+
depErr := &DependencyError{
109+
Chain: []string{"lint"},
110+
Err: &ExecuteError{err: fmt.Errorf("failed to run command 'lint': %w", fmt.Errorf("exit status 1"))},
111+
}
112+
113+
if got := depErr.FailureMessage(); got != "exit status 1" {
114+
t.Fatalf("expected root cause message, got %q", got)
115+
}
116+
})
117+
118+
t.Run("keeps non execute errors intact", func(t *testing.T) {
119+
depErr := &DependencyError{
120+
Chain: []string{"lint"},
121+
Err: fmt.Errorf("failed to calculate checksum for command 'lint': missing file"),
122+
}
123+
124+
if got := depErr.FailureMessage(); got != "failed to calculate checksum for command 'lint': missing file" {
125+
t.Fatalf("expected original message, got %q", got)
126+
}
127+
})
128+
129+
t.Run("keeps path context for execute errors", func(t *testing.T) {
130+
depErr := &DependencyError{
131+
Chain: []string{"lint"},
132+
Err: &ExecuteError{
133+
err: fmt.Errorf(
134+
"failed to run command 'lint': %w",
135+
&os.PathError{Op: "chdir", Path: "/tmp/missing", Err: syscall.ENOENT},
136+
),
137+
},
138+
}
139+
140+
if got := depErr.FailureMessage(); got != "chdir /tmp/missing: no such file or directory" {
141+
t.Fatalf("expected path-aware message, got %q", got)
142+
}
143+
})
144+
}
145+
104146
func TestPrintDependencyTree(t *testing.T) {
105147
t.Run("single node", func(t *testing.T) {
106148
depErr := &DependencyError{Chain: []string{"lint"}, Err: fmt.Errorf("fail")}
@@ -111,8 +153,8 @@ func TestPrintDependencyTree(t *testing.T) {
111153
if len(lines) != 1 {
112154
t.Fatalf("expected 1 line, got %d: %v", len(lines), lines)
113155
}
114-
if !strings.HasPrefix(lines[0], " lint") {
115-
t.Errorf("expected line to start with ' lint', got: %q", lines[0])
156+
if !strings.HasPrefix(lines[0], "lets: lint") {
157+
t.Errorf("expected line to start with 'lets: lint', got: %q", lines[0])
116158
}
117159
if !strings.Contains(out, "failed here") {
118160
t.Errorf("expected 'failed here' annotation on lint line, got: %q", out)
@@ -137,9 +179,9 @@ func TestPrintDependencyTree(t *testing.T) {
137179
name string
138180
hasFailed bool
139181
}{
140-
{" ", "deploy", false},
141-
{" ", "build", false},
142-
{" ", "lint", true},
182+
{"lets: ", "deploy", false},
183+
{" ", "build", false},
184+
{" ", "lint", true},
143185
}
144186
for i, c := range checks {
145187
if !strings.HasPrefix(lines[i], c.prefix+c.name) {

internal/executor/executor.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ func (e *ExecuteError) Error() string {
2525
return e.err.Error()
2626
}
2727

28+
func (e *ExecuteError) Unwrap() error {
29+
return e.err
30+
}
31+
32+
func (e *ExecuteError) Cause() error {
33+
if err := errors.Unwrap(e.err); err != nil {
34+
return err
35+
}
36+
37+
return e.err
38+
}
39+
2840
// ExitCode will return exit code from underlying ExitError or returns default error code.
2941
func (e *ExecuteError) ExitCode() int {
3042
var exitErr *exec.ExitError

tests/command_cmd.bats

Lines changed: 3 additions & 2 deletions
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 "failed to run command 'cmd-as-map-error': exit status 2"
46+
assert_output --partial "lets: cmd-as-map-error"
47+
assert_output --partial "lets: exit status 2"
4748
}
4849

4950
@test "command_cmd: cmd-as-map must propagate env" {
@@ -83,4 +84,4 @@ setup() {
8384

8485
assert_success
8586
assert_line --index 0 "Hello from short"
86-
}
87+
}

tests/default_env.bats

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

6363
assert_failure
64-
assert_line "lets: failed to run command 'print-workdir': chdir ${TEST_DIR}/b: no such file or directory"
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"
6567
}

tests/dependency_failure_tree.bats

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +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 " deploy"
13-
assert_line --index 1 " build"
14-
assert_line --index 2 --partial " lint"
12+
assert_line --index 0 "lets: deploy"
13+
assert_line --index 1 " build"
14+
assert_line --index 2 --partial " lint"
1515
assert_line --index 2 --partial "failed here"
16+
assert_line --index 3 "lets: exit status 1"
1617
}
1718

1819
@test "dependency_failure_tree: single node when no depends" {
1920
run env NO_COLOR=1 lets lint
2021
assert_failure
21-
assert_line --index 0 --partial " lint"
22+
assert_line --index 0 --partial "lets: lint"
2223
assert_line --index 0 --partial "failed here"
24+
assert_line --index 1 "lets: exit status 1"
2325
}

0 commit comments

Comments
 (0)