Skip to content

Commit ef0f861

Browse files
authored
feat(foreachdirectory): allow skip if not exist (#16)
Adds additional field to foreachdirectory `skip_on_not_exist: true` This intentionally skips the command on the `os.ErrNotExist` error.
1 parent c5c2536 commit ef0f861

10 files changed

Lines changed: 195 additions & 24 deletions

File tree

DEVELOPER.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
# Developer Guide for Porch
32

43
## Extending Porch
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: "For Each Directory Example"
2+
description: "Example showing nested serial and parallel commands"
3+
commands:
4+
- type: "foreachdirectory"
5+
name: "For Each Directory OK"
6+
working_directory: "./cmd"
7+
mode: "parallel"
8+
working_directory_strategy: "item_relative"
9+
depth: 1
10+
commands:
11+
- type: "shell"
12+
name: "echo pwd"
13+
command_line: "echo $(pwd)"
14+
- type: "shell"
15+
name: skip test
16+
command_line: "exit 2"
17+
skip_exit_codes:
18+
- 2
19+
- type: "shell"
20+
name: should not run
21+
command_line: "echo should not run"
22+
23+
- type: "foreachdirectory"
24+
name: "For Each Directory Does Not Exist"
25+
working_directory: "./does-not-exist"
26+
skip_on_not_exist: true
27+
mode: "parallel"
28+
working_directory_strategy: "item_relative"
29+
depth: 1
30+
commands:
31+
- type: "shell"
32+
name: "echo pwd"
33+
command_line: "echo $(pwd)"
34+
35+
- type: "foreachdirectory"
36+
name: "For Each Directory Does Not Exist Fail"
37+
working_directory: "./does-not-exist"
38+
runs_on_condition: always
39+
skip_on_not_exist: false
40+
mode: "parallel"
41+
working_directory_strategy: "item_relative"
42+
depth: 1
43+
commands:
44+
- type: "shell"
45+
name: "echo pwd"
46+
command_line: "echo $(pwd)"

internal/commands/foreachdirectory/commander.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"io"
11+
"os"
1112

1213
"github.com/goccy/go-yaml"
1314
"github.com/matt-FFFFFF/porch/internal/commands"
@@ -66,11 +67,19 @@ func (c *Commander) Create(
6667
return nil, fmt.Errorf("failed to parse working directory strategy: %q %w", def.WorkingDirectoryStrategy, err)
6768
}
6869

70+
itemsSkipOnErrors := []error{}
71+
if def.SkipOnNotExist {
72+
itemsSkipOnErrors = append(itemsSkipOnErrors, os.ErrNotExist)
73+
}
74+
6975
forEachCommand := &runbatch.ForEachCommand{
70-
BaseCommand: base,
71-
ItemsProvider: foreachproviders.ListDirectoriesDepth(def.Depth, foreachproviders.IncludeHidden(def.IncludeHidden)),
72-
Mode: mode,
73-
CwdStrategy: strat,
76+
BaseCommand: base,
77+
ItemsProvider: foreachproviders.ListDirectoriesDepth(
78+
def.Depth, foreachproviders.IncludeHidden(def.IncludeHidden),
79+
),
80+
Mode: mode,
81+
CwdStrategy: strat,
82+
ItemsSkipOnErrors: itemsSkipOnErrors,
7483
}
7584

7685
// Determine which commands to use
@@ -149,6 +158,7 @@ func (c *Commander) GetExampleDefinition() interface{} {
149158
Depth: 2, //nolint:mnd
150159
IncludeHidden: false,
151160
WorkingDirectoryStrategy: "item_relative",
161+
SkipOnNotExist: false,
152162
Commands: []any{
153163
map[string]any{
154164
"type": "shellcommand",

internal/commands/foreachdirectory/definition.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ type Definition struct {
3232
Commands []any `yaml:"commands,omitempty" docdesc:"List of commands to execute in each directory"`
3333
// CommandGroup is a reference to a named command group
3434
CommandGroup string `yaml:"command_group,omitempty" docdesc:"Reference to a named command group"`
35+
// SkipOnNotExist specifies whether to skip directories that do not exist.
36+
SkipOnNotExist bool `yaml:"skip_on_not_exist" docdesc:"Whether to skip directories that do not exist"`
3537
}
3638

3739
// Validate ensures that commands and command_group are not both specified,

internal/commands/foreachdirectory/foreachdirectory_integration_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package foreachdirectory
55

66
import (
77
"fmt"
8+
"os"
89
"testing"
910

1011
"github.com/matt-FFFFFF/porch/internal/commandregistry"
@@ -129,3 +130,41 @@ commands:
129130
})
130131
}
131132
}
133+
134+
func TestForEachDirectorySkipNotExist(t *testing.T) {
135+
yamlPayload := `type: "foreachdirectory"
136+
name: "For Each Directory"
137+
working_directory: "./does-not-exist"
138+
mode: serial
139+
depth: 1
140+
skip_on_not_exist: true
141+
include_hidden: false
142+
commands:
143+
- type: "shell"
144+
name: "echo item var"
145+
command_line: "echo $ITEM"
146+
`
147+
commander := &Commander{}
148+
f := commandregistry.New(
149+
serialcommand.Register,
150+
parallelcommand.Register,
151+
shellcommand.Register,
152+
copycwdtotemp.Register,
153+
Register,
154+
)
155+
156+
runnable, err := commander.Create(t.Context(), f, []byte(yamlPayload))
157+
require.NoError(t, err)
158+
require.NotNil(t, runnable)
159+
forEachCommand, ok := runnable.(*runbatch.ForEachCommand)
160+
require.True(t, ok, "Expected ForEachCommand, got %T", runnable)
161+
assert.Equal(t, forEachCommand.ItemsSkipOnErrors[0], os.ErrNotExist, "Expected skip error to be os.ErrNotExist")
162+
163+
results := runnable.Run(t.Context())
164+
require.NotNil(t, results)
165+
require.Len(t, results, 1, "Expected 1 result for foreach command")
166+
assert.Equal(t, "For Each Directory", results[0].Label)
167+
assert.Equal(t, runbatch.ResultStatusSkipped, results[0].Status,
168+
"Expected result to be skipped due to non-existent working directory",
169+
)
170+
}

internal/runbatch/foreachCommand.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ type ForEachCommand struct {
116116
Mode ForEachMode
117117
// CwdStrategy is for modifying the current working directory for each item
118118
CwdStrategy ForEachCwdStrategy
119+
// ItemsSkipOnErrors is a list of errors that will not cause the foreach items provider to fail.
120+
// Must be a list of errors that can be used with errors.Is.
121+
ItemsSkipOnErrors []error
119122
}
120123

121124
// ParseForEachMode converts a string to a ForEachMode.
@@ -143,6 +146,15 @@ func (f *ForEachCommand) Run(ctx context.Context) Results {
143146
// Get the items to iterate over
144147
items, err := f.ItemsProvider(ctx, f.Cwd)
145148
if err != nil {
149+
for _, skipErr := range f.ItemsSkipOnErrors {
150+
// If the error is in the skip list, treat it as a skipped result.
151+
if errors.Is(err, skipErr) {
152+
result.Status = ResultStatusSkipped
153+
return Results{result}
154+
}
155+
}
156+
157+
// If the error is not in the skip list, return an error result.
146158
return Results{{
147159
Label: f.Label,
148160
ExitCode: -1,

internal/runbatch/progressiveForEachCommand.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ package runbatch
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
910
"maps"
1011
"path/filepath"
12+
"time"
1113

1214
"github.com/matt-FFFFFF/porch/internal/progress"
1315
)
@@ -42,9 +44,42 @@ func (f *ForEachCommand) runWithProgressiveChildren(ctx context.Context, reporte
4244
// Get the items to iterate over
4345
items, err := f.ItemsProvider(ctx, f.Cwd)
4446
if err != nil {
45-
result.Error = err
46-
result.ExitCode = -1
47+
for _, skipErr := range f.ItemsSkipOnErrors {
48+
// If the error is in the skip list, treat it as a skipped result.
49+
if errors.Is(err, skipErr) {
50+
result.Status = ResultStatusSkipped
51+
result.Error = ErrSkipIntentional
52+
reporter.Report(progress.Event{
53+
CommandPath: []string{f.Label},
54+
Type: progress.EventSkipped,
55+
Message: result.Error.Error(),
56+
Timestamp: time.Now(),
57+
Data: progress.EventData{
58+
ExitCode: result.ExitCode,
59+
Error: result.Error,
60+
OutputLine: fmt.Sprintf("%v: %v", ErrSkipIntentional, err),
61+
},
62+
})
63+
64+
return Results{result}
65+
}
66+
}
67+
68+
// If the error is not in the skip list, return an error result.
69+
result.Error = fmt.Errorf("%w: %v", ErrItemsProviderFailed, err)
4770
result.Status = ResultStatusError
71+
result.ExitCode = -1
72+
73+
reporter.Report(progress.Event{
74+
CommandPath: []string{f.Label},
75+
Type: progress.EventFailed,
76+
Message: result.Error.Error(),
77+
Timestamp: time.Now(),
78+
Data: progress.EventData{
79+
ExitCode: result.ExitCode,
80+
Error: result.Error,
81+
},
82+
})
4883

4984
return Results{result}
5085
}

internal/runbatch/progressiveSerialBatch.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ OuterLoop:
6565
Type: progress.EventSkipped,
6666
Message: "Command skipped intentionally",
6767
Timestamp: time.Now(),
68+
Data: progress.EventData{
69+
Error: ErrSkipIntentional,
70+
},
6871
})
6972

7073
results = append(results, &Result{
@@ -81,6 +84,9 @@ OuterLoop:
8184
Type: progress.EventSkipped,
8285
Message: "Command skipped due to previous error",
8386
Timestamp: time.Now(),
87+
Data: progress.EventData{
88+
Error: ErrSkipOnError,
89+
},
8490
})
8591

8692
results = append(results, &Result{

internal/tui/model.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ const (
3434
StatusSuccess
3535
// StatusFailed indicates the command failed.
3636
StatusFailed
37+
// StatusSkipped indicates the command was skipped.
38+
StatusSkipped
3739
)
3840

3941
const (
@@ -188,6 +190,7 @@ type Styles struct {
188190
Pending lipgloss.Style
189191
Running lipgloss.Style
190192
Success lipgloss.Style
193+
Skipped lipgloss.Style
191194
Failed lipgloss.Style
192195
Output lipgloss.Style
193196
Error lipgloss.Style
@@ -211,6 +214,9 @@ func NewStyles() *Styles {
211214
Bold(true),
212215
Success: lipgloss.NewStyle().
213216
Foreground(lipgloss.Color("10")),
217+
Skipped: lipgloss.NewStyle().
218+
Foreground(lipgloss.Color("14")).
219+
Italic(true),
214220
Failed: lipgloss.NewStyle().
215221
Foreground(lipgloss.Color("9")),
216222
Output: lipgloss.NewStyle().
@@ -396,10 +402,23 @@ func (m *Model) processProgressEvent(event progress.Event) tea.Cmd {
396402

397403
case progress.EventSkipped:
398404
node := m.getOrCreateNode(event.CommandPath, commandName)
399-
node.UpdateStatus(StatusPending) // Keep as pending for skipped commands
405+
node.UpdateStatus(StatusSkipped)
406+
407+
if event.Data.OutputLine != "" {
408+
node.UpdateOutput(event.Data.OutputLine)
409+
}
410+
411+
// Set error message from either stderr output or error message (for skip reasons)
412+
if event.Data.OutputLine != "" {
413+
// Prefer stderr output line if available
414+
node.UpdateError(event.Data.OutputLine)
415+
} else if event.Data.Error != nil {
416+
// Fall back to error message if no stderr output
417+
node.UpdateError(event.Data.Error.Error())
418+
}
400419
}
401420

402-
// Trigger immediate UI update on failure
421+
// Trigger immediate UI update
403422
return tea.Tick(teaTickInterval, func(_ time.Time) tea.Msg {
404423
return tea.WindowSizeMsg{Width: m.width, Height: m.height}
405424
})
@@ -586,7 +605,7 @@ func (m *Model) updateNodeErrorsFromResults(basePath []string, results runbatch.
586605
pathKey := pathToString(commandPath)
587606
if node, exists := m.nodeMap[pathKey]; exists {
588607
// Update error message if this result has a specific error
589-
if result.Error != nil && result.Status == runbatch.ResultStatusError {
608+
if result.Error != nil && (result.Status == runbatch.ResultStatusError) {
590609
// Only update if we have a more specific error than the generic one
591610
if !errors.Is(result.Error, runbatch.ErrResultChildrenHasError) {
592611
node.UpdateError(result.Error.Error())

internal/tui/update.go

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,9 @@ func (m *Model) renderCommandNode(b *strings.Builder, node *CommandNode, prefix
372372
case StatusFailed:
373373
statusIcon = "❌"
374374
styledName = m.styles.Failed.Render(name)
375+
case StatusSkipped:
376+
statusIcon = "⏩"
377+
styledName = m.styles.Skipped.Render(name)
375378
default:
376379
statusIcon = "❓"
377380
styledName = m.styles.Pending.Render(name)
@@ -391,7 +394,7 @@ func (m *Model) renderCommandNode(b *strings.Builder, node *CommandNode, prefix
391394
elapsed = endTime.Sub(*startTime)
392395
}
393396

394-
durStr := "(" + elapsed.Round(commandDurationRounding).String() + ")"
397+
durStr := " (" + elapsed.Round(commandDurationRounding).String() + ")"
395398
leftColumn += m.styles.Output.Render(durStr)
396399
}
397400

@@ -417,19 +420,19 @@ func (m *Model) renderCommandNode(b *strings.Builder, node *CommandNode, prefix
417420
// Build the right column (output or error)
418421
var rightColumn string
419422

420-
if errorMsg != "" && status == StatusFailed {
421-
rightColumn = m.styles.Error.Render(fmt.Sprintf("Error: %s", errorMsg))
422-
} else {
423-
switch status {
424-
case StatusFailed:
425-
rightColumn = m.styles.Error.Render(
426-
formatColumn(output, rightWidth),
427-
)
428-
case StatusRunning:
429-
rightColumn = m.styles.Output.Render(
430-
formatColumn(output, rightWidth),
431-
)
432-
}
423+
switch status {
424+
case StatusFailed:
425+
rightColumn = m.styles.Error.Render(
426+
formatColumn(errorMsg, rightWidth),
427+
)
428+
case StatusSkipped:
429+
rightColumn = m.styles.Skipped.Render(
430+
formatColumn(errorMsg, rightWidth),
431+
)
432+
case StatusRunning:
433+
rightColumn = m.styles.Output.Render(
434+
formatColumn(output, rightWidth),
435+
)
433436
}
434437

435438
// Add the row to the table

0 commit comments

Comments
 (0)