From e396f4d06fa2087c93afd400d2cd202d5a644d6c Mon Sep 17 00:00:00 2001 From: Bevan Arps Date: Tue, 26 Jul 2022 10:10:16 +1200 Subject: [PATCH] Resolve relative include paths relative to the including Taskfile Closes #823 Closes #822 --- CHANGELOG.md | 2 + docs/docs/api_reference.md | 4 +- docs/docs/usage.md | 2 + task_test.go | 52 ++++++++++++++++--- taskfile/included_taskfile.go | 35 ++++++++++++- taskfile/read/taskfile.go | 45 ++++++++++++---- testdata/includes_rel_path/Taskfile.yml | 10 ++++ .../includes_rel_path/common/Taskfile.yml | 4 ++ .../includes_rel_path/included/Taskfile.yml | 6 +++ 9 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 testdata/includes_rel_path/Taskfile.yml create mode 100644 testdata/includes_rel_path/common/Taskfile.yml create mode 100644 testdata/includes_rel_path/included/Taskfile.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff23ba0..e6f06411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Always resolve relative include paths relative to the including Taskfile + ([#822](https://github.com/go-task/task/issues/822), [#823](https://github.com/go-task/task/pull/823)). - Fix ZSH and PowerShell completions to consider all tasks instead of just the public ones (those with descriptions) ([#803](https://github.com/go-task/task/pull/803)). diff --git a/docs/docs/api_reference.md b/docs/docs/api_reference.md index 47e8e4de..6cfcd9cc 100644 --- a/docs/docs/api_reference.md +++ b/docs/docs/api_reference.md @@ -81,7 +81,7 @@ Some environment variables can be overriden to adjust Task behavior. | Attribute | Type | Default | Description | | - | - | - | - | -| `taskfile` | `string` | | The path for the Taskfile or directory to be included. If a directory, Task will look for files named `Taskfile.yml` or `Taskfile.yaml` inside that directory. | +| `taskfile` | `string` | | The path for the Taskfile or directory to be included. If a directory, Task will look for files named `Taskfile.yml` or `Taskfile.yaml` inside that directory. If a relative path, resolved relative to the directory containing the including Taskfile. | | `dir` | `string` | The parent Taskfile directory | The working directory of the included tasks when run. | | `optional` | `bool` | `false` | If `true`, no errors will be thrown if the specified file does not exist. | @@ -129,7 +129,7 @@ tasks: foobar: - echo "foo" - echo "bar" - + baz: cmd: echo "baz" ``` diff --git a/docs/docs/usage.md b/docs/docs/usage.md index a32179ba..e6dbe7fa 100644 --- a/docs/docs/usage.md +++ b/docs/docs/usage.md @@ -136,6 +136,8 @@ namespace. So, you'd call `task docs:serve` to run the `serve` task from `documentation/Taskfile.yml` or `task docker:build` to run the `build` task from the `DockerTasks.yml` file. +Relative paths are resolved relative to the directory containing the including Taskfile. + ### OS-specific Taskfiles With `version: '2'`, task automatically includes any `Taskfile_{{OS}}.yml` diff --git a/task_test.go b/task_test.go index a01b0b8a..137544ad 100644 --- a/task_test.go +++ b/task_test.go @@ -52,13 +52,14 @@ func (fct fileContentTest) Run(t *testing.T) { for name, expectContent := range fct.Files { t.Run(fct.name(name), func(t *testing.T) { - b, err := os.ReadFile(filepath.Join(fct.Dir, name)) + path := filepath.Join(fct.Dir, name) + b, err := os.ReadFile(path) assert.NoError(t, err, "Error reading file") s := string(b) if fct.TrimSpace { s = strings.TrimSpace(s) } - assert.Equal(t, expectContent, s, "unexpected file content") + assert.Equal(t, expectContent, s, "unexpected file content in %s", path) }) } } @@ -774,7 +775,12 @@ func TestIncludesMultiLevel(t *testing.T) { func TestIncludeCycle(t *testing.T) { const dir = "testdata/includes_cycle" - expectedError := "task: include cycle detected between testdata/includes_cycle/Taskfile.yml <--> testdata/includes_cycle/one/two/Taskfile.yml" + + wd, err := os.Getwd() + assert.Nil(t, err) + + message := "task: include cycle detected between %s/%s/one/Taskfile.yml <--> %s/%s/Taskfile.yml" + expectedError := fmt.Sprintf(message, wd, dir, wd, dir) var buff bytes.Buffer e := task.Executor{ @@ -852,27 +858,39 @@ func TestIncludesOptional(t *testing.T) { } func TestIncludesOptionalImplicitFalse(t *testing.T) { + const dir = "testdata/includes_optional_implicit_false" + wd, _ := os.Getwd() + + message := "stat %s/%s/TaskfileOptional.yml: no such file or directory" + expected := fmt.Sprintf(message, wd, dir) + e := task.Executor{ - Dir: "testdata/includes_optional_implicit_false", + Dir: dir, Stdout: io.Discard, Stderr: io.Discard, } err := e.Setup() assert.Error(t, err) - assert.Equal(t, "stat testdata/includes_optional_implicit_false/TaskfileOptional.yml: no such file or directory", err.Error()) + assert.Equal(t, expected, err.Error()) } func TestIncludesOptionalExplicitFalse(t *testing.T) { + const dir = "testdata/includes_optional_explicit_false" + wd, _ := os.Getwd() + + message := "stat %s/%s/TaskfileOptional.yml: no such file or directory" + expected := fmt.Sprintf(message, wd, dir) + e := task.Executor{ - Dir: "testdata/includes_optional_explicit_false", + Dir: dir, Stdout: io.Discard, Stderr: io.Discard, } err := e.Setup() assert.Error(t, err) - assert.Equal(t, "stat testdata/includes_optional_explicit_false/TaskfileOptional.yml: no such file or directory", err.Error()) + assert.Equal(t, expected, err.Error()) } func TestIncludesFromCustomTaskfile(t *testing.T) { @@ -890,6 +908,26 @@ func TestIncludesFromCustomTaskfile(t *testing.T) { tt.Run(t) } +func TestIncludesRelativePath(t *testing.T) { + const dir = "testdata/includes_rel_path" + + var buff bytes.Buffer + e := task.Executor{ + Dir: dir, + Stdout: &buff, + Stderr: &buff, + } + + assert.NoError(t, e.Setup()) + + assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "common:pwd"})) + assert.Contains(t, buff.String(), "testdata/includes_rel_path/common") + + buff.Reset() + assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "included:common:pwd"})) + assert.Contains(t, buff.String(), "testdata/includes_rel_path/common") +} + func TestSupportedFileNames(t *testing.T) { fileNames := []string{ "Taskfile.yml", diff --git a/taskfile/included_taskfile.go b/taskfile/included_taskfile.go index 744dca02..2a4c8d53 100644 --- a/taskfile/included_taskfile.go +++ b/taskfile/included_taskfile.go @@ -2,17 +2,22 @@ package taskfile import ( "errors" + "fmt" + "path/filepath" + + "github.com/go-task/task/v3/internal/execext" "gopkg.in/yaml.v3" ) -// IncludedTaskfile represents information about included tasksfile +// IncludedTaskfile represents information about included taskfiles type IncludedTaskfile struct { Taskfile string Dir string Optional bool AdvancedImport bool Vars *Vars + BaseDir string // The directory from which the including taskfile was loaded; used to resolve relative paths } // IncludedTaskfiles represents information about included tasksfiles @@ -107,3 +112,31 @@ func (it *IncludedTaskfile) UnmarshalYAML(unmarshal func(interface{}) error) err it.Vars = includedTaskfile.Vars return nil } + +// FullTaskfilePath returns the fully qualified path to the included taskfile +func (it *IncludedTaskfile) FullTaskfilePath() (string, error) { + return it.resolvePath(it.Taskfile) +} + +// FullDirPath returns the fully qualified path to the included taskfile's working directory +func (it *IncludedTaskfile) FullDirPath() (string, error) { + return it.resolvePath(it.Dir) +} + +func (it *IncludedTaskfile) resolvePath(path string) (string, error) { + path, err := execext.Expand(path) + if err != nil { + return "", err + } + + if filepath.IsAbs(path) { + return path, nil + } + + result, err := filepath.Abs(filepath.Join(it.BaseDir, path)) + if err != nil { + return "", fmt.Errorf("task: error resolving path %s relative to %s: %w", path, it.BaseDir, err) + } + + return result, nil +} diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index f53a60a9..95348ee8 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -9,7 +9,6 @@ import ( "gopkg.in/yaml.v3" - "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile" ) @@ -44,6 +43,7 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { } readerNode.Dir = d } + path, err := exists(filepath.Join(readerNode.Dir, readerNode.Entrypoint)) if err != nil { return nil, err @@ -60,6 +60,16 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { return nil, err } + // Annotate any included Taskfile reference with a base directory for resolving relative paths + _ = t.Includes.Range(func(key string, includedFile taskfile.IncludedTaskfile) error { + // Set the base directory for resolving relative paths, but only if not already set + if includedFile.BaseDir == "" { + includedFile.BaseDir = readerNode.Dir + t.Includes.Set(key, includedFile) + } + return nil + }) + err = t.Includes.Range(func(namespace string, includedTask taskfile.IncludedTaskfile) error { if v >= 3.0 { tr := templater.Templater{Vars: &taskfile.Vars{}, RemoveNoValue: true} @@ -69,19 +79,18 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { Optional: includedTask.Optional, AdvancedImport: includedTask.AdvancedImport, Vars: includedTask.Vars, + BaseDir: includedTask.BaseDir, } if err := tr.Err(); err != nil { return err } } - path, err := execext.Expand(includedTask.Taskfile) + path, err := includedTask.FullTaskfilePath() if err != nil { return err } - if !filepath.IsAbs(path) { - path = filepath.Join(readerNode.Dir, path) - } + path, err = exists(path) if err != nil { if includedTask.Optional { @@ -114,21 +123,27 @@ func Taskfile(readerNode *ReaderNode) (*taskfile.Taskfile, error) { } if includedTask.AdvancedImport { + dir, err := includedTask.FullDirPath() + if err != nil { + return err + } + for k, v := range includedTaskfile.Vars.Mapping { o := v - o.Dir = filepath.Join(readerNode.Dir, includedTask.Dir) + o.Dir = dir includedTaskfile.Vars.Mapping[k] = o } for k, v := range includedTaskfile.Env.Mapping { o := v - o.Dir = filepath.Join(readerNode.Dir, includedTask.Dir) + o.Dir = dir includedTaskfile.Env.Mapping[k] = o } for _, task := range includedTaskfile.Tasks { if !filepath.IsAbs(task.Dir) { - task.Dir = filepath.Join(includedTask.Dir, task.Dir) + task.Dir = filepath.Join(dir, task.Dir) } + task.IncludeVars = includedTask.Vars task.IncludedTaskfileVars = includedTaskfile.Vars } @@ -176,19 +191,29 @@ func readTaskfile(file string) (*taskfile.Taskfile, error) { return &t, yaml.NewDecoder(f).Decode(&t) } +// exists finds a Taskfile at the stated location, returning a fully qualified path to the file func exists(path string) (string, error) { fi, err := os.Stat(path) if err != nil { return "", err } if fi.Mode().IsRegular() { - return path, nil + // File exists, return a fully qualified path + result, err := filepath.Abs(path) + if err != nil { + return "", err + } + return result, nil } for _, n := range defaultTaskfiles { fpath := filepath.Join(path, n) if _, err := os.Stat(fpath); err == nil { - return fpath, nil + result, err := filepath.Abs(fpath) + if err != nil { + return "", err + } + return result, nil } } diff --git a/testdata/includes_rel_path/Taskfile.yml b/testdata/includes_rel_path/Taskfile.yml new file mode 100644 index 00000000..18fa09de --- /dev/null +++ b/testdata/includes_rel_path/Taskfile.yml @@ -0,0 +1,10 @@ +version: '3' + +includes: + included: + taskfile: ./included + dir: ./included + + common: + taskfile: ./common + dir: ./common diff --git a/testdata/includes_rel_path/common/Taskfile.yml b/testdata/includes_rel_path/common/Taskfile.yml new file mode 100644 index 00000000..1c456628 --- /dev/null +++ b/testdata/includes_rel_path/common/Taskfile.yml @@ -0,0 +1,4 @@ +version: '3' + +tasks: + pwd: pwd diff --git a/testdata/includes_rel_path/included/Taskfile.yml b/testdata/includes_rel_path/included/Taskfile.yml new file mode 100644 index 00000000..4d9fc225 --- /dev/null +++ b/testdata/includes_rel_path/included/Taskfile.yml @@ -0,0 +1,6 @@ +version: '3' + +includes: + common: + taskfile: ../common + dir: ../common