Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
49 changes: 32 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,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 {
Expand All @@ -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())
}
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 + "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 + "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])
}
}
})
Expand Down
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