From b3627fcb182aaa11abbf960c252878bf5a80685a Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Tue, 6 Dec 2022 00:58:20 +0000 Subject: [PATCH] Run Taskfiles from sub/child directories (#920) --- CHANGELOG.md | 3 ++ docs/docs/api_reference.md | 1 + docs/docs/usage.md | 29 +++++++++++++ internal/compiler/v3/compiler_v3.go | 10 +++-- internal/sysinfo/uid.go | 22 ++++++++++ internal/sysinfo/uid_win.go | 9 +++++ setup.go | 15 ++++--- task_test.go | 49 ++++++++++++++++++++++ taskfile/read/taskfile.go | 54 ++++++++++++++++++++----- testdata/taskfile_walk/Taskfile.yml | 7 ++++ testdata/taskfile_walk/foo/bar/.gitkeep | 0 testdata/user_working_dir/Taskfile.yml | 7 ++++ 12 files changed, 186 insertions(+), 20 deletions(-) create mode 100644 internal/sysinfo/uid.go create mode 100644 internal/sysinfo/uid_win.go create mode 100644 testdata/taskfile_walk/Taskfile.yml create mode 100644 testdata/taskfile_walk/foo/bar/.gitkeep create mode 100644 testdata/user_working_dir/Taskfile.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e66853e..0febd392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/docs/docs/api_reference.md b/docs/docs/api_reference.md index a356085b..c5a5047a 100644 --- a/docs/docs/api_reference.md +++ b/docs/docs/api_reference.md @@ -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`. | diff --git a/docs/docs/usage.md b/docs/docs/usage.md index dd7b5eb6..d444b01d 100644 --- a/docs/docs/usage.md +++ b/docs/docs/usage.md @@ -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 ` and `task up` and as long as the +`` directory contains a `docker-compose.yml`, the Docker composition will be +brought up. + ## Environment variables ### Task diff --git a/internal/compiler/v3/compiler_v3.go b/internal/compiler/v3/compiler_v3.go index 5c33b1cc..71e6c977 100644 --- a/internal/compiler/v3/compiler_v3.go +++ b/internal/compiler/v3/compiler_v3.go @@ -18,7 +18,8 @@ import ( var _ compiler.Compiler = &CompilerV3{} type CompilerV3 struct { - Dir string + Dir string + UserWorkingDir string TaskfileEnv *taskfile.Vars TaskfileVars *taskfile.Vars @@ -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 } diff --git a/internal/sysinfo/uid.go b/internal/sysinfo/uid.go new file mode 100644 index 00000000..e67907a9 --- /dev/null +++ b/internal/sysinfo/uid.go @@ -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 +} diff --git a/internal/sysinfo/uid_win.go b/internal/sysinfo/uid_win.go new file mode 100644 index 00000000..ca85b60b --- /dev/null +++ b/internal/sysinfo/uid_win.go @@ -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 +} diff --git a/setup.go b/setup.go index b9414b8a..65615431 100644 --- a/setup.go +++ b/setup.go @@ -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, @@ -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, } } diff --git a/task_test.go b/task_test.go index 8132a00c..7094423a 100644 --- a/task_test.go +++ b/task_test.go @@ -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()) +} diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index 4553e542..024f968a 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -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" ) @@ -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 @@ -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 @@ -163,7 +165,7 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { return nil }) if err != nil { - return nil, err + return nil, "", err } if v < 3.0 { @@ -171,10 +173,10 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { 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 } } } @@ -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) { @@ -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") diff --git a/testdata/taskfile_walk/Taskfile.yml b/testdata/taskfile_walk/Taskfile.yml new file mode 100644 index 00000000..65715cf1 --- /dev/null +++ b/testdata/taskfile_walk/Taskfile.yml @@ -0,0 +1,7 @@ +version: '3' + +tasks: + default: + cmds: + - echo 'foo' + silent: true diff --git a/testdata/taskfile_walk/foo/bar/.gitkeep b/testdata/taskfile_walk/foo/bar/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/testdata/user_working_dir/Taskfile.yml b/testdata/user_working_dir/Taskfile.yml new file mode 100644 index 00000000..b35ea461 --- /dev/null +++ b/testdata/user_working_dir/Taskfile.yml @@ -0,0 +1,7 @@ +version: '3' + +tasks: + default: + cmds: + - echo '{{.USER_WORKING_DIR}}' + silent: true