Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
43 changes: 26 additions & 17 deletions internal/executor/dependency_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@ 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 {
Chain []string
Err error
}

const treePrefix = "lets: "

func (e *DependencyError) Error() string { return e.Err.Error() }
func (e *DependencyError) Unwrap() error { return e.Err }

Expand All @@ -40,6 +42,27 @@ 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')
builder.WriteString(strings.Repeat(dependencyTreeIndent, i+1))
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 {
Expand All @@ -55,19 +78,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())
}
52 changes: 24 additions & 28 deletions internal/executor/dependency_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 + dependencyTreeJoint + "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])
}
}
})

Expand All @@ -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 + dependencyTreeJoint + "deploy",
strings.Repeat(dependencyTreeIndent, 2) + dependencyTreeJoint + "build",
strings.Repeat(dependencyTreeIndent, 3) + 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])
}
}
})
Expand Down
3 changes: 2 additions & 1 deletion tests/command_cmd.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
24 changes: 14 additions & 10 deletions tests/command_options.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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=<kv-opt>] [--bool-opt] [--attr=<attr>...] [<args>...]"
assert_line --index 4 "Options:"
assert_line --index 5 " <args>... Positional args in the end"
assert_line --index 6 " --bool-opt, -b Boolean opt"
assert_line --index 7 " --kv-opt=<kv-opt>, -K Key value opt"
assert_line --index 8 " --attr=<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=<kv-opt>] [--bool-opt] [--attr=<attr>...] [<args>...]"
assert_line --index 5 "Options:"
assert_line --index 6 " <args>... Positional args in the end"
assert_line --index 7 " --bool-opt, -b Boolean opt"
assert_line --index 8 " --kv-opt=<kv-opt>, -K Key value opt"
assert_line --index 9 " --attr=<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" {
Expand Down
6 changes: 3 additions & 3 deletions tests/default_env.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
16 changes: 8 additions & 8 deletions tests/dependency_failure_tree.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading