Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- It's now possible to run Taskfiles from subdirectories! A new `USER_WORKING_DIR` special
variable was added to add even more flexibility for monorepos
([#289](https://github.com/go-task/task/issues/289), [#920](https://github.com/go-task/task/pull/920)).
- Add task-level `dotenv` support
([#389](https://github.com/go-task/task/issues/389), [#904](https://github.com/go-task/task/pull/904)).
- It's now possible to use global level variables on `includes`
Expand Down
1 change: 1 addition & 0 deletions docs/docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ There are some special variables that is available on the templating system:
| `TASK` | The name of the current task. |
| `ROOT_DIR` | The absolute path of the root Taskfile. |
| `TASKFILE_DIR` | The absolute path of the included Taskfile. |
| `USER_WORKING_DIR` | The absolute path of the directory `task` was called from. |
| `CHECKSUM` | The checksum of the files listed in `sources`. Only available within the `status` prop and if method is set to `checksum`. |
| `TIMESTAMP` | The date object of the greatest timestamp of the files listes in `sources`. Only available within the `status` prop and if method is set to `timestamp`. |

Expand Down
29 changes: 29 additions & 0 deletions docs/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,35 @@ committed version (`.dist`) while still allowing individual users to override
the Taskfile by adding an additional `Taskfile.yml` (which would be on
`.gitignore`).

### Running a Taskfile from a subdirectory

If a Taskfile cannot be found in the current working directory, it will walk up
the file tree until it finds one (similar to how `git` works). When running Task
from a subdirectory like this, it will behave as if you ran it from the
directory containing the Taskfile.

You can use this functionality along with the special `{{.USER_WORKING_DIR}}`
variable to create some very useful reusable tasks. For example, if you have a
monorepo with directories for each microservice, you can `cd` into a
microservice directory and run a task command to bring it up without having to
create multiple tasks or Taskfiles with identical content. For example:

```yaml
version: '3'

tasks:
up:
dir: '{{.USER_WORKING_DIR}}'
preconditions:
- test -f docker-compose.yml
cmds:
- docker-compose up -d
```

In this example, we can run `cd <service>` and `task up` and as long as the
`<service>` directory contains a `docker-compose.yml`, the Docker composition will be
brought up.

## Environment variables

### Task
Expand Down
10 changes: 6 additions & 4 deletions internal/compiler/v3/compiler_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import (
var _ compiler.Compiler = &CompilerV3{}

type CompilerV3 struct {
Dir string
Dir string
UserWorkingDir string

TaskfileEnv *taskfile.Vars
TaskfileVars *taskfile.Vars
Expand Down Expand Up @@ -179,9 +180,10 @@ func (c *CompilerV3) getSpecialVars(t *taskfile.Task) (map[string]string, error)
}

return map[string]string{
"TASK": t.Task,
"ROOT_DIR": c.Dir,
"TASKFILE_DIR": taskfileDir,
"TASK": t.Task,
"ROOT_DIR": c.Dir,
"TASKFILE_DIR": taskfileDir,
"USER_WORKING_DIR": c.UserWorkingDir,
}, nil
}

Expand Down
22 changes: 22 additions & 0 deletions internal/sysinfo/uid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build !windows

package sysinfo

import (
"os"
"syscall"
)

func Owner(path string) (int, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
var uid int
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
uid = int(stat.Uid)
} else {
uid = os.Getuid()
}
return uid, nil
}
9 changes: 9 additions & 0 deletions internal/sysinfo/uid_win.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build windows

package sysinfo

// NOTE: This always returns -1 since there is currently no easy way to get
// file owner information on Windows.
func Owner(path string) (int, error) {
return -1, nil
}
15 changes: 10 additions & 5 deletions setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func (e *Executor) setCurrentDir() error {

func (e *Executor) readTaskfile() error {
var err error
e.Taskfile, err = read.Taskfile(&read.ReaderNode{
e.Taskfile, e.Dir, err = read.Taskfile(&read.ReaderNode{
Dir: e.Dir,
Entrypoint: e.Entrypoint,
Parent: nil,
Expand Down Expand Up @@ -179,11 +179,16 @@ func (e *Executor) setupCompiler(v float64) error {
Logger: e.Logger,
}
} else {
userWorkingDir, err := os.Getwd()
if err != nil {
return err
}
e.Compiler = &compilerv3.CompilerV3{
Dir: e.Dir,
TaskfileEnv: e.Taskfile.Env,
TaskfileVars: e.Taskfile.Vars,
Logger: e.Logger,
Dir: e.Dir,
UserWorkingDir: userWorkingDir,
TaskfileEnv: e.Taskfile.Env,
TaskfileVars: e.Taskfile.Vars,
Logger: e.Logger,
}
}

Expand Down
49 changes: 49 additions & 0 deletions task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1703,3 +1703,52 @@ Hello, World!
err = os.RemoveAll(filepathext.SmartJoin(dir, "src"))
assert.NoError(t, err)
}

func TestTaskfileWalk(t *testing.T) {
tests := []struct {
name string
dir string
expected string
}{
{
name: "walk from root directory",
dir: "testdata/taskfile_walk",
expected: "foo\n",
}, {
name: "walk from sub directory",
dir: "testdata/taskfile_walk/foo",
expected: "foo\n",
}, {
name: "walk from sub sub directory",
dir: "testdata/taskfile_walk/foo/bar",
expected: "foo\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var buff bytes.Buffer
e := task.Executor{
Dir: test.dir,
Stdout: &buff,
Stderr: &buff,
}
assert.NoError(t, e.Setup())
assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "default"}))
assert.Equal(t, test.expected, buff.String())
})
}
}

func TestUserWorkingDirectory(t *testing.T) {
var buff bytes.Buffer
e := task.Executor{
Dir: "testdata/user_working_dir",
Stdout: &buff,
Stderr: &buff,
}
wd, err := os.Getwd()
assert.NoError(t, err)
assert.NoError(t, e.Setup())
assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "default"}))
assert.Equal(t, fmt.Sprintf("%s\n", wd), buff.String())
}
54 changes: 43 additions & 11 deletions taskfile/read/taskfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"gopkg.in/yaml.v3"

"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/sysinfo"
"github.com/go-task/task/v3/internal/templater"
"github.com/go-task/task/v3/taskfile"
)
Expand All @@ -36,29 +37,30 @@ type ReaderNode struct {
// Taskfile reads a Taskfile for a given directory
// Uses current dir when dir is left empty. Uses Taskfile.yml
// or Taskfile.yaml when entrypoint is left empty
func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) {
func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, string, error) {
if readerNode.Dir == "" {
d, err := os.Getwd()
if err != nil {
return nil, err
return nil, "", err
}
readerNode.Dir = d
}

path, err := exists(filepathext.SmartJoin(readerNode.Dir, readerNode.Entrypoint))
path, err := existsWalk(filepathext.SmartJoin(readerNode.Dir, readerNode.Entrypoint))
if err != nil {
return nil, err
return nil, "", err
}
readerNode.Dir = filepath.Dir(path)
readerNode.Entrypoint = filepath.Base(path)

t, err := readTaskfile(path)
if err != nil {
return nil, err
return nil, "", err
}

v, err := t.ParsedVersion()
if err != nil {
return nil, err
return nil, "", err
}

// Annotate any included Taskfile reference with a base directory for resolving relative paths
Expand Down Expand Up @@ -113,7 +115,7 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) {
return err
}

includedTaskfile, err := Taskfile(includeReaderNode)
includedTaskfile, _, err := Taskfile(includeReaderNode)
if err != nil {
if includedTask.Optional {
return nil
Expand Down Expand Up @@ -163,18 +165,18 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) {
return nil
})
if err != nil {
return nil, err
return nil, "", err
}

if v < 3.0 {
path = filepathext.SmartJoin(readerNode.Dir, fmt.Sprintf("Taskfile_%s.yml", runtime.GOOS))
if _, err = os.Stat(path); err == nil {
osTaskfile, err := readTaskfile(path)
if err != nil {
return nil, err
return nil, "", err
}
if err = taskfile.Merge(t, osTaskfile, nil); err != nil {
return nil, err
return nil, "", err
}
}
}
Expand All @@ -187,7 +189,7 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) {
task.Task = name
}

return t, nil
return t, readerNode.Dir, nil
}

func readTaskfile(file string) (*taskfile.Taskfile, error) {
Expand Down Expand Up @@ -221,6 +223,36 @@ func exists(path string) (string, error) {
return "", fmt.Errorf(`task: No Taskfile found in "%s". Use "task --init" to create a new one`, path)
}

func existsWalk(path string) (string, error) {
origPath := path
owner, err := sysinfo.Owner(path)
if err != nil {
return "", err
}
for {
fpath, err := exists(path)
if err == nil {
return fpath, nil
}

// Get the parent path/user id
parentPath := filepath.Dir(path)
parentOwner, err := sysinfo.Owner(parentPath)
if err != nil {
return "", err
}

// Error if we reached the root directory and still haven't found a file
// OR if the user id of the directory changes
if path == parentPath || (parentOwner != owner) {
return "", fmt.Errorf(`task: No Taskfile found in "%s" (or any of the parent directories). Use "task --init" to create a new one`, origPath)
}

owner = parentOwner
path = parentPath
}
}

func checkCircularIncludes(node *ReaderNode) error {
if node == nil {
return errors.New("task: failed to check for include cycle: node was nil")
Expand Down
7 changes: 7 additions & 0 deletions testdata/taskfile_walk/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version: '3'

tasks:
default:
cmds:
- echo 'foo'
silent: true
Empty file.
7 changes: 7 additions & 0 deletions testdata/user_working_dir/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version: '3'

tasks:
default:
cmds:
- echo '{{.USER_WORKING_DIR}}'
silent: true