Skip to content

Commit 9bc1efb

Browse files
authored
feat: add conditional execution for tasks and commands (#2564)
1 parent da7eb0c commit 9bc1efb

28 files changed

Lines changed: 444 additions & 0 deletions

executor_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,3 +1105,65 @@ func TestFailfast(t *testing.T) {
11051105
)
11061106
})
11071107
}
1108+
1109+
func TestIf(t *testing.T) {
1110+
t.Parallel()
1111+
1112+
tests := []struct {
1113+
name string
1114+
task string
1115+
vars map[string]any
1116+
verbose bool
1117+
}{
1118+
// Basic command-level if
1119+
{name: "cmd-if-true", task: "cmd-if-true"},
1120+
{name: "cmd-if-false", task: "cmd-if-false"},
1121+
1122+
// Task-level if
1123+
{name: "task-if-true", task: "task-if-true"},
1124+
{name: "task-if-false", task: "task-if-false", verbose: true},
1125+
1126+
// Task call with if
1127+
{name: "task-call-if-true", task: "task-call-if-true"},
1128+
{name: "task-call-if-false", task: "task-call-if-false", verbose: true},
1129+
1130+
// Go template conditions
1131+
{name: "template-eq-true", task: "template-eq-true"},
1132+
{name: "template-eq-false", task: "template-eq-false", verbose: true},
1133+
{name: "template-ne", task: "template-ne"},
1134+
{name: "template-bool-true", task: "template-bool-true"},
1135+
{name: "template-bool-false", task: "template-bool-false"},
1136+
{name: "template-direct-true", task: "template-direct-true"},
1137+
{name: "template-direct-false", task: "template-direct-false"},
1138+
{name: "template-and", task: "template-and"},
1139+
{name: "template-or", task: "template-or"},
1140+
1141+
// CLI variable override
1142+
{name: "template-cli-var", task: "template-cli-var", vars: map[string]any{"MY_VAR": "yes"}},
1143+
1144+
// Task-level if with template
1145+
{name: "task-level-template", task: "task-level-template"},
1146+
{name: "task-level-template-false", task: "task-level-template-false", verbose: true},
1147+
1148+
// For loop with if
1149+
{name: "if-in-for-loop", task: "if-in-for-loop", verbose: true},
1150+
}
1151+
1152+
for _, test := range tests {
1153+
opts := []ExecutorTestOption{
1154+
WithName(test.name),
1155+
WithExecutorOptions(
1156+
task.WithDir("testdata/if"),
1157+
task.WithSilent(true),
1158+
task.WithVerbose(test.verbose),
1159+
),
1160+
WithTask(test.task),
1161+
}
1162+
if test.vars != nil {
1163+
for k, v := range test.vars {
1164+
opts = append(opts, WithVar(k, v))
1165+
}
1166+
}
1167+
NewExecutorTest(t, opts...)
1168+
}
1169+
}

task.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"runtime"
88
"slices"
9+
"strings"
910
"sync/atomic"
1011

1112
"golang.org/x/sync/errgroup"
@@ -129,6 +130,17 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
129130
return nil
130131
}
131132

133+
if strings.TrimSpace(t.If) != "" {
134+
if err := execext.RunCommand(ctx, &execext.RunCommandOptions{
135+
Command: t.If,
136+
Dir: t.Dir,
137+
Env: env.Get(t),
138+
}); err != nil {
139+
e.Logger.VerboseOutf(logger.Yellow, "task: if condition not met - skipped: %q\n", call.Task)
140+
return nil
141+
}
142+
}
143+
132144
if err := e.areTaskRequiredVarsSet(t); err != nil {
133145
return err
134146
}
@@ -299,6 +311,7 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d
299311

300312
cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra)
301313
cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra)
314+
cmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra)
302315
cmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra)
303316

304317
if err := e.runCommand(ctx, t, call, i); err != nil {
@@ -309,6 +322,18 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d
309322
func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i int) error {
310323
cmd := t.Cmds[i]
311324

325+
// Check if condition for any command type
326+
if strings.TrimSpace(cmd.If) != "" {
327+
if err := execext.RunCommand(ctx, &execext.RunCommandOptions{
328+
Command: cmd.If,
329+
Dir: t.Dir,
330+
Env: env.Get(t),
331+
}); err != nil {
332+
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] if condition not met - skipped\n", t.Name())
333+
return nil
334+
}
335+
}
336+
312337
switch {
313338
case cmd.Task != "":
314339
reacquire := e.releaseConcurrencyLimit()

taskfile/ast/cmd.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type Cmd struct {
1212
Cmd string
1313
Task string
1414
For *For
15+
If string
1516
Silent bool
1617
Set []string
1718
Shopt []string
@@ -29,6 +30,7 @@ func (c *Cmd) DeepCopy() *Cmd {
2930
Cmd: c.Cmd,
3031
Task: c.Task,
3132
For: c.For.DeepCopy(),
33+
If: c.If,
3234
Silent: c.Silent,
3335
Set: deepcopy.Slice(c.Set),
3436
Shopt: deepcopy.Slice(c.Shopt),
@@ -55,6 +57,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
5557
Cmd string
5658
Task string
5759
For *For
60+
If string
5861
Silent bool
5962
Set []string
6063
Shopt []string
@@ -92,6 +95,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
9295
c.Task = cmdStruct.Task
9396
c.Vars = cmdStruct.Vars
9497
c.For = cmdStruct.For
98+
c.If = cmdStruct.If
9599
c.Silent = cmdStruct.Silent
96100
c.IgnoreError = cmdStruct.IgnoreError
97101
return nil
@@ -101,6 +105,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
101105
if cmdStruct.Cmd != "" {
102106
c.Cmd = cmdStruct.Cmd
103107
c.For = cmdStruct.For
108+
c.If = cmdStruct.If
104109
c.Silent = cmdStruct.Silent
105110
c.Set = cmdStruct.Set
106111
c.Shopt = cmdStruct.Shopt

taskfile/ast/task.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type Task struct {
4040
IgnoreError bool
4141
Run string
4242
Platforms []*Platform
43+
If string
4344
Watch bool
4445
Location *Location
4546
Failfast bool
@@ -145,6 +146,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
145146
IgnoreError bool `yaml:"ignore_error"`
146147
Run string
147148
Platforms []*Platform
149+
If string
148150
Requires *Requires
149151
Watch bool
150152
Failfast bool
@@ -184,6 +186,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
184186
t.IgnoreError = task.IgnoreError
185187
t.Run = task.Run
186188
t.Platforms = task.Platforms
189+
t.If = task.If
187190
t.Requires = task.Requires
188191
t.Watch = task.Watch
189192
t.Failfast = task.Failfast
@@ -228,6 +231,7 @@ func (t *Task) DeepCopy() *Task {
228231
IncludeVars: t.IncludeVars.DeepCopy(),
229232
IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(),
230233
Platforms: deepcopy.Slice(t.Platforms),
234+
If: t.If,
231235
Location: t.Location.DeepCopy(),
232236
Requires: t.Requires.DeepCopy(),
233237
Namespace: t.Namespace,

testdata/if/Taskfile.yml

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
version: '3'
2+
3+
vars:
4+
SHOULD_RUN: "yes"
5+
ENV: "prod"
6+
FEATURE_ENABLED: "true"
7+
FEATURE_DISABLED: "false"
8+
9+
tasks:
10+
# Basic command-level if (condition met)
11+
cmd-if-true:
12+
cmds:
13+
- cmd: echo "executed"
14+
if: "true"
15+
16+
# Basic command-level if (condition not met)
17+
cmd-if-false:
18+
cmds:
19+
- cmd: echo "should not appear"
20+
if: "false"
21+
- echo "this runs"
22+
23+
# Task-level if (condition met)
24+
task-if-true:
25+
if: "true"
26+
cmds:
27+
- echo "task executed"
28+
29+
# Task-level if (condition not met)
30+
task-if-false:
31+
if: "false"
32+
cmds:
33+
- echo "should not appear"
34+
35+
# With template variables
36+
if-with-template:
37+
cmds:
38+
- cmd: echo "Running because SHOULD_RUN={{.SHOULD_RUN}}"
39+
if: '[ "{{.SHOULD_RUN}}" = "yes" ]'
40+
41+
# If inside for loop
42+
if-in-for-loop:
43+
cmds:
44+
- for: ["a", "b", "c"]
45+
cmd: echo "processing {{.ITEM}}"
46+
if: '[ "{{.ITEM}}" != "b" ]'
47+
48+
# If on task call
49+
if-on-task-call:
50+
cmds:
51+
- task: subtask
52+
if: "true"
53+
54+
subtask:
55+
internal: true
56+
cmds:
57+
- echo "subtask ran"
58+
59+
# If combined with platforms (both must pass)
60+
if-with-platforms:
61+
cmds:
62+
- cmd: echo "condition and platform met"
63+
platforms: [linux, darwin, windows]
64+
if: "true"
65+
66+
# Skip task call
67+
skip-task-call:
68+
cmds:
69+
- task: subtask
70+
if: "false"
71+
- echo "after skipped task call"
72+
73+
# Task call in cmds with if condition met
74+
task-call-if-true:
75+
cmds:
76+
- task: subtask
77+
if: "true"
78+
- echo "after task call"
79+
80+
# Task call in cmds with if condition not met
81+
task-call-if-false:
82+
cmds:
83+
- task: subtask
84+
if: "false"
85+
- echo "continues after skipped task"
86+
87+
# Template eq - condition met
88+
template-eq-true:
89+
cmds:
90+
- cmd: echo "env is prod"
91+
if: '{{ eq .ENV "prod" }}'
92+
93+
# Template eq - condition not met
94+
template-eq-false:
95+
cmds:
96+
- cmd: echo "should not appear"
97+
if: '{{ eq .ENV "dev" }}'
98+
- echo "this runs"
99+
100+
# Template ne (not equal)
101+
template-ne:
102+
cmds:
103+
- cmd: echo "env is not dev"
104+
if: '{{ ne .ENV "dev" }}'
105+
106+
# Template with boolean-like variable
107+
template-bool-true:
108+
cmds:
109+
- cmd: echo "feature enabled"
110+
if: '{{ eq .FEATURE_ENABLED "true" }}'
111+
112+
# Template with boolean-like variable (false)
113+
template-bool-false:
114+
cmds:
115+
- cmd: echo "should not appear"
116+
if: '{{ eq .FEATURE_DISABLED "true" }}'
117+
- echo "feature was disabled"
118+
119+
# Direct true/false from template
120+
template-direct-true:
121+
cmds:
122+
- cmd: echo "direct true works"
123+
if: '{{ .FEATURE_ENABLED }}'
124+
125+
# Direct true/false from template (false case)
126+
template-direct-false:
127+
cmds:
128+
- cmd: echo "should not appear"
129+
if: '{{ .FEATURE_DISABLED }}'
130+
- echo "direct false skipped correctly"
131+
132+
# Template with CLI variable override
133+
template-cli-var:
134+
cmds:
135+
- cmd: echo "MY_VAR is yes"
136+
if: '{{ eq .MY_VAR "yes" }}'
137+
138+
# Combined template conditions with and
139+
template-and:
140+
cmds:
141+
- cmd: echo "both conditions met"
142+
if: '{{ and (eq .ENV "prod") (eq .FEATURE_ENABLED "true") }}'
143+
144+
# Combined template conditions with or
145+
template-or:
146+
cmds:
147+
- cmd: echo "at least one condition met"
148+
if: '{{ or (eq .ENV "dev") (eq .ENV "prod") }}'
149+
150+
# Task-level if with template
151+
task-level-template:
152+
if: '{{ eq .ENV "prod" }}'
153+
cmds:
154+
- echo "task runs in prod"
155+
156+
# Task-level if with template (not met)
157+
task-level-template-false:
158+
if: '{{ eq .ENV "dev" }}'
159+
cmds:
160+
- echo "should not appear"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
this runs
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
executed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
task: "if-in-for-loop" started
2+
task: [if-in-for-loop] echo "processing a"
3+
processing a
4+
task: [if-in-for-loop] if condition not met - skipped
5+
task: [if-in-for-loop] echo "processing c"
6+
processing c
7+
task: "if-in-for-loop" finished
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
task: "task-call-if-false" started
2+
task: [task-call-if-false] if condition not met - skipped
3+
task: [task-call-if-false] echo "continues after skipped task"
4+
continues after skipped task
5+
task: "task-call-if-false" finished
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
subtask ran
2+
after task call

0 commit comments

Comments
 (0)