Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
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
106 changes: 97 additions & 9 deletions executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Parsing error messages by string content is brittle.

Checking for 'exit status' and splitting by ': ' assumes a fixed error format, which may change. Use a more reliable extraction method or document the expected format.

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
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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
})
}

Expand Down
8 changes: 8 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
27 changes: 27 additions & 0 deletions tests/command_depends.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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' ⚠️"
}
27 changes: 27 additions & 0 deletions tests/command_depends/lets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
36 changes: 36 additions & 0 deletions tests/command_depends/test_dependency_tree.yaml
Original file line number Diff line number Diff line change
@@ -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!"
Loading