From 8cc3fdef792eb92633847068b81226c420429300 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Jul 2025 16:23:57 +0000 Subject: [PATCH] Add dependency error handling with detailed failure tree visualization Co-authored-by: kindritskiy.m --- executor/executor.go | 106 ++++++++++++++++-- main.go | 8 ++ tests/command_depends.bats | 27 +++++ tests/command_depends/lets.yaml | 27 +++++ .../command_depends/test_dependency_tree.yaml | 36 ++++++ 5 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 tests/command_depends/test_dependency_tree.yaml diff --git a/executor/executor.go b/executor/executor.go index 8714f65e..36877dcc 100644 --- a/executor/executor.go +++ b/executor/executor.go @@ -36,6 +36,63 @@ func (e *ExecuteError) ExitCode() int { return 1 // default error code } +// DependencyError represents an error that occurred in a dependency chain +type DependencyError struct { + rootCommand string + failedCommand string + dependencyPath []string + underlyingErr error + exitCode int +} + +func (e *DependencyError) Error() string { + if len(e.dependencyPath) <= 1 { + // No dependency chain, use original error format + return e.underlyingErr.Error() + } + + // Build dependency tree visualization + var builder strings.Builder + + // Show the failed command + builder.WriteString(fmt.Sprintf("'%s' failed: %s\n\n", e.failedCommand, e.getUnderlyingErrorMessage())) + + // Show the dependency chain + builder.WriteString(fmt.Sprintf("'%s' ->", e.rootCommand)) + for i := 1; i < len(e.dependencyPath); i++ { + builder.WriteString(fmt.Sprintf("\n '%s'", e.dependencyPath[i])) + if e.dependencyPath[i] == e.failedCommand { + builder.WriteString(" ⚠️") + } + } + + return builder.String() +} + +func (e *DependencyError) getUnderlyingErrorMessage() string { + if e.underlyingErr == nil { + return fmt.Sprintf("exit status %d", e.exitCode) + } + + // Extract just the exit status from the underlying error + errStr := e.underlyingErr.Error() + if strings.Contains(errStr, "exit status") { + parts := strings.Split(errStr, ": ") + if len(parts) > 0 { + return parts[len(parts)-1] + } + } + return errStr +} + +func (e *DependencyError) ExitCode() int { + return e.exitCode +} + +func (e *DependencyError) Unwrap() error { + return e.underlyingErr +} + type Executor struct { cfg *config.Config out io.Writer @@ -50,23 +107,31 @@ func NewExecutor(cfg *config.Config, out io.Writer) *Executor { } type Context struct { - ctx context.Context - command *config.Command - logger *logging.ExecLogger + ctx context.Context + command *config.Command + logger *logging.ExecLogger + dependencyPath []string } func NewExecutorCtx(ctx context.Context, command *config.Command) *Context { return &Context{ - ctx: ctx, - command: command, - logger: logging.NewExecLogger().Child(command.Name), + ctx: ctx, + command: command, + logger: logging.NewExecLogger().Child(command.Name), + dependencyPath: []string{command.Name}, } } func ChildExecutorCtx(ctx *Context, command *config.Command) *Context { + dependencyPath := make([]string, len(ctx.dependencyPath)+1) + copy(dependencyPath, ctx.dependencyPath) + dependencyPath[len(ctx.dependencyPath)] = command.Name + return &Context{ - command: command, - logger: ctx.logger.Child(command.Name), + ctx: ctx.ctx, + command: command, + logger: ctx.logger.Child(command.Name), + dependencyPath: dependencyPath, } } @@ -309,7 +374,30 @@ func (e *Executor) executeDepends(ctx *Context) error { ctx.logger.Debug("dependency env overridden: '%s'", cmd.Env.Dump()) } - return e.Execute(ChildExecutorCtx(ctx, cmd)) + if err := e.Execute(ChildExecutorCtx(ctx, cmd)); err != nil { + // Wrap error with dependency context if it's not already a DependencyError + var depErr *DependencyError + if !errors.As(err, &depErr) { + // Extract exit code from ExecuteError or use default + exitCode := 1 + var execErr *ExecuteError + if errors.As(err, &execErr) { + exitCode = execErr.ExitCode() + } + + return &DependencyError{ + rootCommand: ctx.dependencyPath[0], + failedCommand: cmd.Name, + dependencyPath: append(ctx.dependencyPath, cmd.Name), + underlyingErr: err, + exitCode: exitCode, + } + } + // If it's already a DependencyError, just return it + return err + } + + return nil }) } diff --git a/main.go b/main.go index d8db9301..241f71a2 100644 --- a/main.go +++ b/main.go @@ -127,6 +127,14 @@ func main() { exitCode = execErr.ExitCode() } + // Check if it's a DependencyError (need to import executor package types) + type ExitCoder interface { + ExitCode() int + } + if exitCoder, ok := err.(ExitCoder); ok { + exitCode = exitCoder.ExitCode() + } + os.Exit(exitCode) } } diff --git a/tests/command_depends.bats b/tests/command_depends.bats index 18d1666f..65caba7e 100644 --- a/tests/command_depends.bats +++ b/tests/command_depends.bats @@ -48,4 +48,31 @@ setup() { LETS_CONFIG=lets-parallel-in-depends.yaml run lets parallel-in-depends assert_failure assert_line --index 0 "lets: config error: command 'parallel-in-depends' depends on command 'parallel', but parallel cmd is not allowed in depends yet" +} + +@test "command_depends: should show dependency tree on failure" { + run lets run-with-failing-dep + assert_failure 1 + assert_output --partial "'fail-command' failed: exit status 1" + assert_output --partial "'run-with-failing-dep' ->" + assert_output --partial "'fail-command' ⚠️" +} + +@test "command_depends: should show dependency tree with multiple levels" { + run lets level2-dep + assert_failure 1 + assert_output --partial "'fail-command' failed: exit status 1" + assert_output --partial "'level2-dep' ->" + assert_output --partial "'run-with-failing-dep'" + assert_output --partial "'fail-command' ⚠️" +} + +@test "command_depends: should run successful deps before showing failure tree" { + run lets multiple-deps-one-fail + assert_failure 1 + assert_output --partial "Hello World with level INFO" + assert_output --partial "Bar" + assert_output --partial "'fail-command' failed: exit status 1" + assert_output --partial "'multiple-deps-one-fail' ->" + assert_output --partial "'fail-command' ⚠️" } \ No newline at end of file diff --git a/tests/command_depends/lets.yaml b/tests/command_depends/lets.yaml index c75e7d06..7d08a7bf 100644 --- a/tests/command_depends/lets.yaml +++ b/tests/command_depends/lets.yaml @@ -52,3 +52,30 @@ commands: - name: greet-foo args: Bar cmd: echo I have ref in depends + + # Test commands for dependency failure tree + fail-command: + description: Command that always fails + cmd: exit 1 + + run-with-failing-dep: + description: Command that depends on a failing command + depends: + - fail-command + cmd: echo "This should not run" + + # More complex dependency chain test + level2-dep: + description: Command that depends on a failing command through multiple levels + depends: + - run-with-failing-dep + cmd: echo "This should also not run" + + # Test multiple dependencies with one failing + multiple-deps-one-fail: + description: Command with multiple dependencies where one fails + depends: + - greet + - bar + - fail-command + cmd: echo "This should not run" diff --git a/tests/command_depends/test_dependency_tree.yaml b/tests/command_depends/test_dependency_tree.yaml new file mode 100644 index 00000000..2f4665b4 --- /dev/null +++ b/tests/command_depends/test_dependency_tree.yaml @@ -0,0 +1,36 @@ +shell: bash + +commands: + # Basic failing command + build-app: + description: Simulates a failing build + cmd: | + echo "Building app..." + sleep 1 + echo "Build failed!" + exit 130 + + # Command that depends on the failing build + run-app: + description: Runs the app (depends on build) + depends: [build-app] + cmd: | + echo "Starting application..." + echo "App is running!" + + # More complex example with multiple levels + deploy-app: + description: Deploys the app (depends on run-app, which depends on build-app) + depends: [run-app] + cmd: | + echo "Deploying to production..." + echo "Deployment complete!" + + # Example with multiple dependencies where one fails + integration-test: + description: Runs integration tests + depends: + - build-app + cmd: | + echo "Running integration tests..." + echo "All tests passed!"