diff --git a/task.go b/task.go index 54cda92762..6c8b23f04b 100644 --- a/task.go +++ b/task.go @@ -392,6 +392,14 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in return nil } + dir := t.Dir + if cmd.Dir != "" { + dir = cmd.Dir + if err := os.MkdirAll(dir, 0o755); err != nil { + e.Logger.Errf(logger.Red, "task: cannot make command directory %q: %v\n", dir, err) + } + } + if e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) { e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.Cmd) } @@ -413,7 +421,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in err = execext.RunCommand(ctx, &execext.RunCommandOptions{ Command: cmd.Cmd, - Dir: t.Dir, + Dir: dir, Env: env.Get(t), PosixOpts: slicesext.UniqueJoin(e.Taskfile.Set, t.Set, cmd.Set), BashOpts: slicesext.UniqueJoin(e.Taskfile.Shopt, t.Shopt, cmd.Shopt), diff --git a/task_test.go b/task_test.go index 9d54af9740..5cdac2c9ec 100644 --- a/task_test.go +++ b/task_test.go @@ -2,6 +2,7 @@ package task_test import ( "bytes" + "context" "fmt" "io" "io/fs" @@ -1527,6 +1528,23 @@ func TestWhenDirAttributeItCreatesMissingAndRunsInThatDir(t *testing.T) { _ = os.RemoveAll(toBeCreated) } +func TestCommandDirRunsInCommandDir(t *testing.T) { + t.Parallel() + const dir = "testdata/command_dir" + var out bytes.Buffer + e := &task.Executor{ + Dir: dir, + Stdout: &out, + Stderr: &out, + } + + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(context.Background(), &task.Call{Task: "default"})) + + got := filepath.Base(strings.TrimSpace(out.String())) + assert.Equal(t, "subdir", got, "Mismatch in the command working directory") +} + func TestDynamicVariablesRunOnTheNewCreatedDir(t *testing.T) { t.Parallel() diff --git a/taskfile/ast/cmd.go b/taskfile/ast/cmd.go index 6f7a577de6..f91620ce68 100644 --- a/taskfile/ast/cmd.go +++ b/taskfile/ast/cmd.go @@ -11,6 +11,7 @@ import ( type Cmd struct { Cmd string Task string + Dir string For *For If string Silent bool @@ -29,6 +30,7 @@ func (c *Cmd) DeepCopy() *Cmd { return &Cmd{ Cmd: c.Cmd, Task: c.Task, + Dir: c.Dir, For: c.For.DeepCopy(), If: c.If, Silent: c.Silent, @@ -56,6 +58,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { var cmdStruct struct { Cmd string Task string + Dir string For *For If string Silent bool @@ -104,6 +107,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { // A command with additional options if cmdStruct.Cmd != "" { c.Cmd = cmdStruct.Cmd + c.Dir = cmdStruct.Dir c.For = cmdStruct.For c.If = cmdStruct.If c.Silent = cmdStruct.Silent diff --git a/testdata/command_dir/Taskfile.yml b/testdata/command_dir/Taskfile.yml new file mode 100644 index 0000000000..1866cecfe7 --- /dev/null +++ b/testdata/command_dir/Taskfile.yml @@ -0,0 +1,8 @@ +version: '3' + +tasks: + default: + cmds: + - cmd: pwd + dir: subdir + silent: true diff --git a/testdata/command_dir/subdir/.keep b/testdata/command_dir/subdir/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/variables.go b/variables.go index c10bfcd576..0d80842a61 100644 --- a/variables.go +++ b/variables.go @@ -142,6 +142,18 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err if new.Prefix == "" { new.Prefix = new.Task } + resolveCmdDir := func(dir string) (string, error) { + if dir == "" { + return "", nil + } + + dir, err := execext.ExpandLiteral(dir) + if err != nil { + return "", err + } + + return filepathext.SmartJoin(new.Dir, dir), nil + } dotenvEnvs := ast.NewVars() if len(new.Dotenv) > 0 { @@ -231,7 +243,14 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err newCmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) newCmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) newCmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra) + newCmd.Dir = templater.ReplaceWithExtra(cmd.Dir, cache, extra) newCmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra) + if newCmd.Dir != "" { + newCmd.Dir, err = resolveCmdDir(newCmd.Dir) + if err != nil { + return nil, err + } + } new.Cmds = append(new.Cmds, newCmd) } continue @@ -246,7 +265,14 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err newCmd.Cmd = templater.Replace(cmd.Cmd, cache) newCmd.Task = templater.Replace(cmd.Task, cache) newCmd.If = templater.Replace(cmd.If, cache) + newCmd.Dir = templater.Replace(cmd.Dir, cache) newCmd.Vars = templater.ReplaceVars(cmd.Vars, cache) + if newCmd.Dir != "" { + newCmd.Dir, err = resolveCmdDir(newCmd.Dir) + if err != nil { + return nil, err + } + } new.Cmds = append(new.Cmds, newCmd) } } diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index 6c3eb912bf..6691a3b4b7 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -528,6 +528,22 @@ tasks: If the directory does not exist, `task` creates it. +You can also change the working directory for a specific command without +affecting the rest of the task by setting `dir` on the command itself. Relative +paths are resolved from the task directory and are created if missing: + +```yaml +version: '3' + +tasks: + test: + dir: backend + cmds: + - cmd: npm test + dir: frontend + - cmd: go test ./... +``` + ## Task dependencies > Dependencies run in parallel, so dependencies of a task should not depend one diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index 9566ba699a..2d3905915d 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -736,6 +736,7 @@ tasks: example: cmds: - cmd: echo "Hello World" + dir: subdir silent: true ignore_error: false platforms: [linux, darwin] @@ -743,6 +744,11 @@ tasks: shopt: [globstar] ``` + +#### `dir` + +Working directory for the command. Relative paths resolve from the task directory and are created if missing. + ### Task References ```yaml diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 28ae66110b..745705b1f6 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -352,6 +352,10 @@ "description": "Command to run", "type": "string" }, + "dir": { + "description": "Working directory for the command. Relative paths resolve from the task directory and are created if missing.", + "type": "string" + }, "silent": { "description": "Silent mode disables echoing of command before Task runs it", "type": "boolean"