From 950ec6292fffa73300224b313d513a35093b0730 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 9 Sep 2026 21:31:49 +0200 Subject: [PATCH 1/5] feat: scope included dotenv to its taskfile descendants --- compiler.go | 7 ++ dotenv_include_test.go | 178 +++++++++++++++++++++++++++++++++ setup.go | 62 ++++++++++++ task_test.go | 5 +- taskfile/ast/dotenv.go | 27 +++++ taskfile/ast/graph.go | 23 +++++ taskfile/ast/task.go | 4 + taskfile/ast/taskfile.go | 10 +- taskfile/ast/tasks.go | 11 ++ taskfile/dotenv.go | 3 + variables.go | 1 + website/src/next/docs/guide.md | 16 ++- 12 files changed, 335 insertions(+), 12 deletions(-) create mode 100644 dotenv_include_test.go create mode 100644 taskfile/ast/dotenv.go diff --git a/compiler.go b/compiler.go index 2c2e56f632..cb09084f6e 100644 --- a/compiler.go +++ b/compiler.go @@ -110,6 +110,13 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* return nil, err } } + if t != nil { + for k, v := range t.IncludedDotenvEnv.All() { + if err := rangeFunc(k, v); err != nil { + return nil, err + } + } + } for k, v := range c.TaskfileVars.All() { if err := rangeFunc(k, v); err != nil { return nil, err diff --git a/dotenv_include_test.go b/dotenv_include_test.go new file mode 100644 index 0000000000..c23a1df5dc --- /dev/null +++ b/dotenv_include_test.go @@ -0,0 +1,178 @@ +package task_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" +) + +func TestIncludedDotenvScopes(t *testing.T) { + t.Parallel() + files := map[string]string{ + "Taskfile.yml": `version: '3' +dotenv: [root.env] +env: + DOTENV_EXPLICIT: explicit +includes: + app: + taskfile: app/Taskfile.yml + dir: work + sibling: sibling/Taskfile.yml + a: + taskfile: shared/Taskfile.yml + vars: {FLAVOR: a} + b: + taskfile: shared/Taskfile.yml + vars: {FLAVOR: b} + flat: + taskfile: flat/Taskfile.yml + flatten: true +tasks: + inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' +`, + "root.env": "DOTENV_CHOICE=root\nDOTENV_ROOT=root\n", + "without-root.yml": "version: '3'\nincludes:\n app: app/Taskfile.yml\n", + "app/Taskfile.yml": `version: '3' +vars: + CONFIG: config +env: + CONFIG_ENV: config +dotenv: [override.env, '{{.CONFIG}}/base.env', '{{.CONFIG_ENV}}/env-path.env', missing.env] +includes: + child: child/Taskfile.yml + common: ../common/Taskfile.yml +tasks: + inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + task-env: + env: {DOTENV_CHOICE: task} + cmds: ['echo "$DOTENV_CHOICE"'] + task-dotenv: + dotenv: ['{{.TASKFILE_DIR}}/task.env'] + cmds: ['echo "$DOTENV_CHOICE"'] +`, + "app/override.env": "DOTENV_CHOICE=app\nDOTENV_APP=app\nDOTENV_EXPLICIT=dotenv\nDOTENV_NEXT=child\n", + "app/config/base.env": "DOTENV_CHOICE=base\nDOTENV_BASE=base\n", + "app/config/env-path.env": "DOTENV_ENV_PATH=env-path\n", + "app/task.env": "DOTENV_CHOICE=task-dotenv\n", + "app/child/Taskfile.yml": `version: '3' +dotenv: ['{{.DOTENV_NEXT}}.env'] +tasks: + inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' +`, + "app/child/child.env": "DOTENV_CHOICE=child\n", + "common/Taskfile.yml": `version: '3' +tasks: + inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' +`, + "sibling/Taskfile.yml": `version: '3' +env: + CONFIG_ENV: nonexistent +dotenv: [sibling.env] +includes: + common: ../common/Taskfile.yml +tasks: + inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' +`, + "sibling/sibling.env": "DOTENV_CHOICE=sibling\n", + "shared/Taskfile.yml": `version: '3' +dotenv: ['{{.FLAVOR}}.env', '{{.FLAVOR}}.env'] +tasks: + inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' +`, + "shared/a.env": "DOTENV_CHOICE=a\n", + "shared/b.env": "DOTENV_CHOICE=b\n", + "flat/Taskfile.yml": `version: '3' +dotenv: ['{{.TASKFILE_DIR}}/flat.env'] +tasks: + flattened: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' +`, + "flat/flat.env": "DOTENV_CHOICE=flat\n", + "work/override.env": "DOTENV_CHOICE=wrong-directory\n", + } + dir := t.TempDir() + for name, contents := range files { + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + } + for _, test := range []struct { + name, dir, entrypoint, call, want string + }{ + {name: "root isolation", call: "inspect", want: "root|root|\n"}, + {name: "include directory and file order", call: "app:inspect", want: "app|app|app\n"}, + {name: "standalone", dir: "app", call: "inspect", want: "app|app|app\n"}, + {name: "without root dotenv", entrypoint: "without-root.yml", call: "app:inspect", want: "app|app|app\n"}, + {name: "nested override", call: "app:child:inspect", want: "child|child|app\n"}, + {name: "inherited by common", call: "app:common:inspect", want: "app|app|app\n"}, + {name: "sibling isolation", call: "sibling:inspect", want: "sibling|sibling|\n"}, + {name: "common via another parent", call: "sibling:common:inspect", want: "sibling|sibling|\n"}, + {name: "include vars a", call: "a:inspect", want: "a|a|\n"}, + {name: "include vars b", call: "b:inspect", want: "b|b|\n"}, + {name: "flatten", call: "flattened", want: "flat|flat|\n"}, + {name: "task env priority", call: "app:task-env", want: "task\n"}, + {name: "task dotenv priority", call: "app:task-dotenv", want: "task-dotenv\n"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var stdout bytes.Buffer + entrypoint := "" + if test.entrypoint != "" { + entrypoint = filepath.Join(dir, test.entrypoint) + } + e := task.NewExecutor( + task.WithDir(filepath.Join(dir, test.dir)), + task.WithEntrypoint(entrypoint), + task.WithSilent(true), + task.WithStdout(&stdout), + ) + require.NoError(t, e.Setup()) + call := &task.Call{Task: test.call} + require.NoError(t, e.Run(t.Context(), call)) + assert.Equal(t, test.want, stdout.String()) + compiled, err := e.CompiledTask(call) + require.NoError(t, err) + if test.call == "app:inspect" { + value, ok := compiled.Env.Get("DOTENV_ENV_PATH") + require.True(t, ok) + assert.Equal(t, "env-path", value.Value) + value, ok = compiled.Env.Get("DOTENV_BASE") + require.True(t, ok) + assert.Equal(t, "base", value.Value) + } + if test.dir == "" && test.entrypoint == "" { + value, ok := compiled.Env.Get("DOTENV_EXPLICIT") + require.True(t, ok) + assert.Equal(t, "explicit", value.Value) + value, ok = compiled.Env.Get("DOTENV_ROOT") + require.True(t, ok) + assert.Equal(t, "root", value.Value) + } + }) + } +} + +func TestIncludedDotenvErrors(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name, path, contents, want string + }{ + {name: "parse error", path: "broken.env", contents: "INVALID=\"unterminated", want: "broken.env"}, + {name: "template error", path: "{{fail \"invalid dotenv path\"}}", want: "invalid dotenv path"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte("version: '3'\nincludes:\n app: app.yml\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.yml"), []byte("version: '3'\ndotenv: ['"+test.path+"']\ntasks:\n test: echo test\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "broken.env"), []byte(test.contents), 0o600)) + e := task.NewExecutor(task.WithDir(dir)) + require.ErrorContains(t, e.Setup(), test.want) + }) + } +} diff --git a/setup.go b/setup.go index e92848417a..7a2103db1b 100644 --- a/setup.go +++ b/setup.go @@ -42,9 +42,13 @@ func (e *Executor) Setup() error { if err := e.setupCompiler(); err != nil { return err } + explicitEnv := e.Taskfile.Env.DeepCopy() if err := e.readDotEnvFiles(); err != nil { return err } + if err := e.readIncludedDotEnvFiles(explicitEnv); err != nil { + return err + } if err := e.doVersionChecks(); err != nil { return err } @@ -250,6 +254,64 @@ func (e *Executor) readDotEnvFiles() error { return err } +func (e *Executor) readIncludedDotEnvFiles(explicitEnv *ast.Vars) error { + if e.Taskfile.Version.LessThan(ast.V3) { + return nil + } + // Cache by include ancestry: two includes of the same file may supply + // different variables for its dotenv paths. + type scopeKey struct{ namespace, location string } + loadedScopes := make(map[scopeKey]*ast.Vars) + for t := range e.Taskfile.Tasks.Values(nil) { + if len(t.DotenvScopes) == 0 { + continue + } + compiler := &Compiler{ + Dir: e.Dir, + Entrypoint: e.Entrypoint, + UserWorkingDir: e.UserWorkingDir, + TaskfileEnv: e.Taskfile.Env.DeepCopy(), + TaskfileVars: e.Taskfile.Vars.DeepCopy(), + Logger: e.Logger, + } + t.IncludedDotenvEnv = ast.NewVars() + for _, scope := range t.DotenvScopes { + compiler.TaskfileEnv.Merge(scope.Env, nil) + dir := e.Dir + if !taskfile.IsRemoteEntrypoint(scope.Location) { + dir = filepath.Dir(scope.Location) + } + key := scopeKey{scope.Namespace, scope.Location} + loaded, ok := loadedScopes[key] + if !ok { + contextTask := &ast.Task{ + Dir: dir, + Location: &ast.Location{Taskfile: scope.Location}, + IncludeVars: scope.IncludeVars, + IncludedTaskfileVars: scope.Vars, + } + vars, err := compiler.GetVariables(contextTask, nil) + if err != nil { + return err + } + loaded, err = taskfile.Dotenv(vars, &ast.Taskfile{Dotenv: scope.Files}, dir) + if err != nil { + return err + } + loadedScopes[key] = loaded + } + for k, v := range loaded.All() { + if _, explicit := explicitEnv.Get(k); !explicit { + t.IncludedDotenvEnv.Set(k, v) + compiler.TaskfileEnv.Set(k, v) + } + } + compiler.TaskfileVars.Merge(scope.Vars, nil) + } + } + return nil +} + func (e *Executor) setupDefaults() { if e.Taskfile.Method == "" { e.Taskfile.Method = "checksum" diff --git a/task_test.go b/task_test.go index 540e7ba8de..884ba53041 100644 --- a/task_test.go +++ b/task_test.go @@ -2142,7 +2142,7 @@ func TestDotenvShouldIncludeAllEnvFiles(t *testing.T) { }) } -func TestDotenvShouldErrorWhenIncludingDependantDotenvs(t *testing.T) { +func TestDotenvShouldAllowIncludedDotenvs(t *testing.T) { t.Parallel() var buff bytes.Buffer @@ -2154,8 +2154,7 @@ func TestDotenvShouldErrorWhenIncludingDependantDotenvs(t *testing.T) { ) err := e.Setup() - require.Error(t, err) - assert.Contains(t, err.Error(), "move the dotenv") + require.NoError(t, err) } func TestDotenvShouldAllowMissingEnv(t *testing.T) { diff --git a/taskfile/ast/dotenv.go b/taskfile/ast/dotenv.go new file mode 100644 index 0000000000..4afcef5168 --- /dev/null +++ b/taskfile/ast/dotenv.go @@ -0,0 +1,27 @@ +package ast + +import "github.com/go-task/task/v3/internal/deepcopy" + +// DotenvScope preserves a declaration's context while Taskfiles are merged. +type DotenvScope struct { + Namespace string + Location string + Files []string + Vars *Vars + Env *Vars + IncludeVars *Vars +} + +func (d *DotenvScope) DeepCopy() *DotenvScope { + if d == nil { + return nil + } + return &DotenvScope{ + Namespace: d.Namespace, + Location: d.Location, + Files: deepcopy.Slice(d.Files), + Vars: d.Vars.DeepCopy(), + Env: d.Env.DeepCopy(), + IncludeVars: d.IncludeVars.DeepCopy(), + } +} diff --git a/taskfile/ast/graph.go b/taskfile/ast/graph.go index cb30093d88..7b87ae3145 100644 --- a/taskfile/ast/graph.go +++ b/taskfile/ast/graph.go @@ -56,6 +56,29 @@ func (tfg *TaskfileGraph) Merge() (*Taskfile, error) { return nil, err } + // Capture the declaring Taskfile's variables before merging. The root's + // dotenv is loaded separately by the executor and remains global. + for _, hash := range hashes[1:] { + vertex, err := tfg.Vertex(hash) + if err != nil { + return nil, err + } + tf := vertex.Taskfile + if len(tf.Dotenv) == 0 { + continue + } + tf.dotenvScope = &DotenvScope{ + Location: tf.Location, + Files: tf.Dotenv, + Vars: tf.Vars.DeepCopy(), + Env: tf.Env.DeepCopy(), + IncludeVars: NewVars(), + } + for task := range tf.Tasks.Values(nil) { + task.DotenvScopes = []*DotenvScope{tf.dotenvScope.DeepCopy()} + } + } + // Loop over each vertex in reverse topological order except for the root vertex. // This gives us a loop over every included Taskfile in an order which is safe to merge. for i := len(hashes) - 1; i > 0; i-- { diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 9465c77770..3d78167fbe 100644 --- a/taskfile/ast/task.go +++ b/taskfile/ast/task.go @@ -49,6 +49,8 @@ type Task struct { Namespace string `hash:"ignore"` IncludeVars *Vars IncludedTaskfileVars *Vars + DotenvScopes []*DotenvScope + IncludedDotenvEnv *Vars FullName string `hash:"ignore"` } @@ -246,6 +248,8 @@ func (t *Task) DeepCopy() *Task { Run: t.Run, IncludeVars: t.IncludeVars.DeepCopy(), IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(), + DotenvScopes: deepcopy.Slice(t.DotenvScopes), + IncludedDotenvEnv: t.IncludedDotenvEnv.DeepCopy(), Platforms: deepcopy.Slice(t.Platforms), If: t.If, Location: t.Location.DeepCopy(), diff --git a/taskfile/ast/taskfile.go b/taskfile/ast/taskfile.go index 20b4476cd5..89dda72c71 100644 --- a/taskfile/ast/taskfile.go +++ b/taskfile/ast/taskfile.go @@ -15,9 +15,6 @@ const NamespaceSeparator = ":" var V3 = semver.MustParse("3") -// ErrIncludedTaskfilesCantHaveDotenvs is returned when a included Taskfile contains dotenvs -var ErrIncludedTaskfilesCantHaveDotenvs = errors.New("task: Included Taskfiles can't have dotenv declarations. Please, move the dotenv declaration to the main Taskfile") - // Taskfile is the abstract syntax tree for a Taskfile type Taskfile struct { Location string @@ -35,6 +32,8 @@ type Taskfile struct { Run string Interval time.Duration UseGitignore *bool + + dotenvScope *DotenvScope } // Merge merges the second Taskfile into the first @@ -42,9 +41,6 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { if !t1.Version.Equal(t2.Version) { return fmt.Errorf(`task: Taskfiles versions should match. First is "%s" but second is "%s"`, t1.Version, t2.Version) } - if len(t2.Dotenv) > 0 { - return ErrIncludedTaskfilesCantHaveDotenvs - } if t2.Output.IsSet() { t1.Output = t2.Output } @@ -78,7 +74,7 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { } t1.Vars.Merge(t2.Vars, include) t1.Env.Merge(t2.Env, include) - return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) + return t1.Tasks.merge(t2.Tasks, include, t1.Vars, t1.dotenvScope) } func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { diff --git a/taskfile/ast/tasks.go b/taskfile/ast/tasks.go index 6ce298fd92..fb6ea713dc 100644 --- a/taskfile/ast/tasks.go +++ b/taskfile/ast/tasks.go @@ -119,12 +119,23 @@ func (t *Tasks) Values(sorter sort.Sorter) iter.Seq[*Task] { } func (t1 *Tasks) Merge(t2 *Tasks, include *Include, includedTaskfileVars *Vars) error { + return t1.merge(t2, include, includedTaskfileVars, nil) +} + +func (t1 *Tasks) merge(t2 *Tasks, include *Include, includedTaskfileVars *Vars, dotenvScope *DotenvScope) error { defer t2.mutex.RUnlock() t2.mutex.RLock() for name, v := range t2.All(nil) { // We do a deep copy of the task struct here to ensure that no data can // be changed elsewhere once the taskfile is merged. task := v.DeepCopy() + for _, scope := range task.DotenvScopes { + scope.Namespace = include.Namespace + NamespaceSeparator + scope.Namespace + scope.IncludeVars.ReverseMerge(include.Vars, nil) + } + if dotenvScope != nil { + task.DotenvScopes = append([]*DotenvScope{dotenvScope.DeepCopy()}, task.DotenvScopes...) + } // Set the task to internal if EITHER the included task or the included // taskfile are marked as internal task.Internal = task.Internal || (include != nil && include.Internal) diff --git a/taskfile/dotenv.go b/taskfile/dotenv.go index a86a9eed1e..b10dd6b731 100644 --- a/taskfile/dotenv.go +++ b/taskfile/dotenv.go @@ -17,6 +17,9 @@ func Dotenv(vars *ast.Vars, tf *ast.Taskfile, dir string) (*ast.Vars, error) { for _, dotEnvPath := range tf.Dotenv { dotEnvPath = templater.Replace(dotEnvPath, cache) + if err := cache.Err(); err != nil { + return nil, err + } if dotEnvPath == "" { continue } diff --git a/variables.go b/variables.go index c2085bd1ea..532bad07f9 100644 --- a/variables.go +++ b/variables.go @@ -191,6 +191,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err new.Env = ast.NewVars() new.Env.Merge(templater.ReplaceVars(e.Taskfile.Env, cache), nil) + new.Env.Merge(templater.ReplaceVars(origTask.IncludedDotenvEnv, cache), nil) new.Env.Merge(templater.ReplaceVars(dotenvEnvs, cache), nil) new.Env.Merge(templater.ReplaceVars(origTask.Env, cache), nil) if evaluateShVars { diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index f9243013d7..f45559325d 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -259,8 +259,20 @@ tasks: ::: info -Please note that you are not currently able to use the `dotenv` key inside -included Taskfiles. +Included Taskfiles can also declare `dotenv`. These variables are available to +their own tasks and tasks in their nested includes. The root Taskfile and sibling +includes keep their own dotenv values. A common Taskfile included through two +different parents inherits the dotenv values of the corresponding parent. + +Relative paths are resolved from the Taskfile declaring `dotenv`, independently +of the include's `dir`. Path templates can use that Taskfile's variables and the +variables supplied by its include. Remote Taskfiles read dotenv files locally +relative to the root execution directory. + +The closest Taskfile's dotenv values take precedence over its ancestors, and the +first file in each list takes precedence. Explicit Taskfile `env` values override +dotenv values; task-level `dotenv` and `env` retain their existing precedence. +The existing merge behavior of `vars` and `env` declarations is unchanged. ::: From 498d24c5f58cb33ecad8e91292a894f561b11523 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 9 Sep 2026 21:39:25 +0200 Subject: [PATCH 2/5] refactor: limit included dotenv scope to its defining taskfile --- dotenv_include_test.go | 14 ++++--- setup.go | 67 ++++++++++++++++------------------ taskfile/ast/graph.go | 4 +- taskfile/ast/task.go | 4 +- taskfile/ast/taskfile.go | 4 +- taskfile/ast/tasks.go | 9 +---- website/src/next/docs/guide.md | 16 ++++---- 7 files changed, 55 insertions(+), 63 deletions(-) diff --git a/dotenv_include_test.go b/dotenv_include_test.go index c23a1df5dc..5eae126d56 100644 --- a/dotenv_include_test.go +++ b/dotenv_include_test.go @@ -56,19 +56,19 @@ tasks: dotenv: ['{{.TASKFILE_DIR}}/task.env'] cmds: ['echo "$DOTENV_CHOICE"'] `, - "app/override.env": "DOTENV_CHOICE=app\nDOTENV_APP=app\nDOTENV_EXPLICIT=dotenv\nDOTENV_NEXT=child\n", + "app/override.env": "DOTENV_CHOICE=app\nDOTENV_APP=app\nDOTENV_EXPLICIT=dotenv\n", "app/config/base.env": "DOTENV_CHOICE=base\nDOTENV_BASE=base\n", "app/config/env-path.env": "DOTENV_ENV_PATH=env-path\n", "app/task.env": "DOTENV_CHOICE=task-dotenv\n", "app/child/Taskfile.yml": `version: '3' -dotenv: ['{{.DOTENV_NEXT}}.env'] +dotenv: [child.env] tasks: inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' `, "app/child/child.env": "DOTENV_CHOICE=child\n", "common/Taskfile.yml": `version: '3' tasks: - inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + inspect: 'echo "{{.DOTENV_CHOICE | default ""}}|$DOTENV_CHOICE|$DOTENV_APP"' `, "sibling/Taskfile.yml": `version: '3' env: @@ -107,11 +107,13 @@ tasks: {name: "root isolation", call: "inspect", want: "root|root|\n"}, {name: "include directory and file order", call: "app:inspect", want: "app|app|app\n"}, {name: "standalone", dir: "app", call: "inspect", want: "app|app|app\n"}, + {name: "standalone root remains global", dir: "app", call: "common:inspect", want: "app|app|app\n"}, + {name: "no dotenv inherited without root", entrypoint: "without-root.yml", call: "app:common:inspect", want: "||\n"}, {name: "without root dotenv", entrypoint: "without-root.yml", call: "app:inspect", want: "app|app|app\n"}, - {name: "nested override", call: "app:child:inspect", want: "child|child|app\n"}, - {name: "inherited by common", call: "app:common:inspect", want: "app|app|app\n"}, + {name: "nested dotenv is independent", call: "app:child:inspect", want: "child|child|\n"}, + {name: "common only inherits root", call: "app:common:inspect", want: "root|root|\n"}, {name: "sibling isolation", call: "sibling:inspect", want: "sibling|sibling|\n"}, - {name: "common via another parent", call: "sibling:common:inspect", want: "sibling|sibling|\n"}, + {name: "common via another parent", call: "sibling:common:inspect", want: "root|root|\n"}, {name: "include vars a", call: "a:inspect", want: "a|a|\n"}, {name: "include vars b", call: "b:inspect", want: "b|b|\n"}, {name: "flatten", call: "flattened", want: "flat|flat|\n"}, diff --git a/setup.go b/setup.go index 7a2103db1b..d44fd4182a 100644 --- a/setup.go +++ b/setup.go @@ -263,50 +263,47 @@ func (e *Executor) readIncludedDotEnvFiles(explicitEnv *ast.Vars) error { type scopeKey struct{ namespace, location string } loadedScopes := make(map[scopeKey]*ast.Vars) for t := range e.Taskfile.Tasks.Values(nil) { - if len(t.DotenvScopes) == 0 { + scope := t.DotenvScope + if scope == nil { continue } - compiler := &Compiler{ - Dir: e.Dir, - Entrypoint: e.Entrypoint, - UserWorkingDir: e.UserWorkingDir, - TaskfileEnv: e.Taskfile.Env.DeepCopy(), - TaskfileVars: e.Taskfile.Vars.DeepCopy(), - Logger: e.Logger, - } - t.IncludedDotenvEnv = ast.NewVars() - for _, scope := range t.DotenvScopes { + key := scopeKey{scope.Namespace, scope.Location} + loaded, ok := loadedScopes[key] + if !ok { + compiler := &Compiler{ + Dir: e.Dir, + Entrypoint: e.Entrypoint, + UserWorkingDir: e.UserWorkingDir, + TaskfileEnv: e.Taskfile.Env.DeepCopy(), + TaskfileVars: e.Taskfile.Vars, + Logger: e.Logger, + } compiler.TaskfileEnv.Merge(scope.Env, nil) dir := e.Dir if !taskfile.IsRemoteEntrypoint(scope.Location) { dir = filepath.Dir(scope.Location) } - key := scopeKey{scope.Namespace, scope.Location} - loaded, ok := loadedScopes[key] - if !ok { - contextTask := &ast.Task{ - Dir: dir, - Location: &ast.Location{Taskfile: scope.Location}, - IncludeVars: scope.IncludeVars, - IncludedTaskfileVars: scope.Vars, - } - vars, err := compiler.GetVariables(contextTask, nil) - if err != nil { - return err - } - loaded, err = taskfile.Dotenv(vars, &ast.Taskfile{Dotenv: scope.Files}, dir) - if err != nil { - return err - } - loadedScopes[key] = loaded + contextTask := &ast.Task{ + Dir: dir, + Location: &ast.Location{Taskfile: scope.Location}, + IncludeVars: scope.IncludeVars, + IncludedTaskfileVars: scope.Vars, + } + vars, err := compiler.GetVariables(contextTask, nil) + if err != nil { + return err } - for k, v := range loaded.All() { - if _, explicit := explicitEnv.Get(k); !explicit { - t.IncludedDotenvEnv.Set(k, v) - compiler.TaskfileEnv.Set(k, v) - } + loaded, err = taskfile.Dotenv(vars, &ast.Taskfile{Dotenv: scope.Files}, dir) + if err != nil { + return err + } + loadedScopes[key] = loaded + } + t.IncludedDotenvEnv = ast.NewVars() + for k, v := range loaded.All() { + if _, explicit := explicitEnv.Get(k); !explicit { + t.IncludedDotenvEnv.Set(k, v) } - compiler.TaskfileVars.Merge(scope.Vars, nil) } } return nil diff --git a/taskfile/ast/graph.go b/taskfile/ast/graph.go index 7b87ae3145..fbba3e8e32 100644 --- a/taskfile/ast/graph.go +++ b/taskfile/ast/graph.go @@ -67,7 +67,7 @@ func (tfg *TaskfileGraph) Merge() (*Taskfile, error) { if len(tf.Dotenv) == 0 { continue } - tf.dotenvScope = &DotenvScope{ + scope := &DotenvScope{ Location: tf.Location, Files: tf.Dotenv, Vars: tf.Vars.DeepCopy(), @@ -75,7 +75,7 @@ func (tfg *TaskfileGraph) Merge() (*Taskfile, error) { IncludeVars: NewVars(), } for task := range tf.Tasks.Values(nil) { - task.DotenvScopes = []*DotenvScope{tf.dotenvScope.DeepCopy()} + task.DotenvScope = scope.DeepCopy() } } diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 3d78167fbe..6c1d71accf 100644 --- a/taskfile/ast/task.go +++ b/taskfile/ast/task.go @@ -49,7 +49,7 @@ type Task struct { Namespace string `hash:"ignore"` IncludeVars *Vars IncludedTaskfileVars *Vars - DotenvScopes []*DotenvScope + DotenvScope *DotenvScope IncludedDotenvEnv *Vars FullName string `hash:"ignore"` @@ -248,7 +248,7 @@ func (t *Task) DeepCopy() *Task { Run: t.Run, IncludeVars: t.IncludeVars.DeepCopy(), IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(), - DotenvScopes: deepcopy.Slice(t.DotenvScopes), + DotenvScope: t.DotenvScope.DeepCopy(), IncludedDotenvEnv: t.IncludedDotenvEnv.DeepCopy(), Platforms: deepcopy.Slice(t.Platforms), If: t.If, diff --git a/taskfile/ast/taskfile.go b/taskfile/ast/taskfile.go index 89dda72c71..964b2dfa04 100644 --- a/taskfile/ast/taskfile.go +++ b/taskfile/ast/taskfile.go @@ -32,8 +32,6 @@ type Taskfile struct { Run string Interval time.Duration UseGitignore *bool - - dotenvScope *DotenvScope } // Merge merges the second Taskfile into the first @@ -74,7 +72,7 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { } t1.Vars.Merge(t2.Vars, include) t1.Env.Merge(t2.Env, include) - return t1.Tasks.merge(t2.Tasks, include, t1.Vars, t1.dotenvScope) + return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) } func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { diff --git a/taskfile/ast/tasks.go b/taskfile/ast/tasks.go index fb6ea713dc..ffa5462c79 100644 --- a/taskfile/ast/tasks.go +++ b/taskfile/ast/tasks.go @@ -119,23 +119,16 @@ func (t *Tasks) Values(sorter sort.Sorter) iter.Seq[*Task] { } func (t1 *Tasks) Merge(t2 *Tasks, include *Include, includedTaskfileVars *Vars) error { - return t1.merge(t2, include, includedTaskfileVars, nil) -} - -func (t1 *Tasks) merge(t2 *Tasks, include *Include, includedTaskfileVars *Vars, dotenvScope *DotenvScope) error { defer t2.mutex.RUnlock() t2.mutex.RLock() for name, v := range t2.All(nil) { // We do a deep copy of the task struct here to ensure that no data can // be changed elsewhere once the taskfile is merged. task := v.DeepCopy() - for _, scope := range task.DotenvScopes { + if scope := task.DotenvScope; scope != nil { scope.Namespace = include.Namespace + NamespaceSeparator + scope.Namespace scope.IncludeVars.ReverseMerge(include.Vars, nil) } - if dotenvScope != nil { - task.DotenvScopes = append([]*DotenvScope{dotenvScope.DeepCopy()}, task.DotenvScopes...) - } // Set the task to internal if EITHER the included task or the included // taskfile are marked as internal task.Internal = task.Internal || (include != nil && include.Internal) diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index f45559325d..854232b149 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -259,19 +259,21 @@ tasks: ::: info -Included Taskfiles can also declare `dotenv`. These variables are available to -their own tasks and tasks in their nested includes. The root Taskfile and sibling -includes keep their own dotenv values. A common Taskfile included through two -different parents inherits the dotenv values of the corresponding parent. +Included Taskfiles can also declare `dotenv`. These variables are available only +to tasks defined in that Taskfile. Nested includes use their own dotenv values. +For example, a common Taskfile included by an application does not inherit the +application's dotenv values. The root Taskfile's dotenv remains available to all +tasks for compatibility. Relative paths are resolved from the Taskfile declaring `dotenv`, independently of the include's `dir`. Path templates can use that Taskfile's variables and the variables supplied by its include. Remote Taskfiles read dotenv files locally relative to the root execution directory. -The closest Taskfile's dotenv values take precedence over its ancestors, and the -first file in each list takes precedence. Explicit Taskfile `env` values override -dotenv values; task-level `dotenv` and `env` retain their existing precedence. +An included Taskfile's dotenv values take precedence over the root's values for +its own tasks, and the first file in each list takes precedence. Explicit +Taskfile `env` values override dotenv values; task-level `dotenv` and `env` +retain their existing precedence. The existing merge behavior of `vars` and `env` declarations is unchanged. ::: From dadfe16b83123e3e958b4b6c3697832096403602 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 13 Sep 2026 14:09:54 +0200 Subject: [PATCH 3/5] refactor: capture dotenv context while reading taskfiles --- setup.go | 8 ++++---- taskfile/ast/dotenv.go | 2 -- taskfile/ast/graph.go | 23 ----------------------- taskfile/reader.go | 12 ++++++++++++ 4 files changed, 16 insertions(+), 29 deletions(-) diff --git a/setup.go b/setup.go index d44fd4182a..d407da4e45 100644 --- a/setup.go +++ b/setup.go @@ -267,7 +267,7 @@ func (e *Executor) readIncludedDotEnvFiles(explicitEnv *ast.Vars) error { if scope == nil { continue } - key := scopeKey{scope.Namespace, scope.Location} + key := scopeKey{scope.Namespace, t.Location.Taskfile} loaded, ok := loadedScopes[key] if !ok { compiler := &Compiler{ @@ -280,12 +280,12 @@ func (e *Executor) readIncludedDotEnvFiles(explicitEnv *ast.Vars) error { } compiler.TaskfileEnv.Merge(scope.Env, nil) dir := e.Dir - if !taskfile.IsRemoteEntrypoint(scope.Location) { - dir = filepath.Dir(scope.Location) + if !taskfile.IsRemoteEntrypoint(t.Location.Taskfile) { + dir = filepath.Dir(t.Location.Taskfile) } contextTask := &ast.Task{ Dir: dir, - Location: &ast.Location{Taskfile: scope.Location}, + Location: t.Location, IncludeVars: scope.IncludeVars, IncludedTaskfileVars: scope.Vars, } diff --git a/taskfile/ast/dotenv.go b/taskfile/ast/dotenv.go index 4afcef5168..be33ba98ba 100644 --- a/taskfile/ast/dotenv.go +++ b/taskfile/ast/dotenv.go @@ -5,7 +5,6 @@ import "github.com/go-task/task/v3/internal/deepcopy" // DotenvScope preserves a declaration's context while Taskfiles are merged. type DotenvScope struct { Namespace string - Location string Files []string Vars *Vars Env *Vars @@ -18,7 +17,6 @@ func (d *DotenvScope) DeepCopy() *DotenvScope { } return &DotenvScope{ Namespace: d.Namespace, - Location: d.Location, Files: deepcopy.Slice(d.Files), Vars: d.Vars.DeepCopy(), Env: d.Env.DeepCopy(), diff --git a/taskfile/ast/graph.go b/taskfile/ast/graph.go index fbba3e8e32..cb30093d88 100644 --- a/taskfile/ast/graph.go +++ b/taskfile/ast/graph.go @@ -56,29 +56,6 @@ func (tfg *TaskfileGraph) Merge() (*Taskfile, error) { return nil, err } - // Capture the declaring Taskfile's variables before merging. The root's - // dotenv is loaded separately by the executor and remains global. - for _, hash := range hashes[1:] { - vertex, err := tfg.Vertex(hash) - if err != nil { - return nil, err - } - tf := vertex.Taskfile - if len(tf.Dotenv) == 0 { - continue - } - scope := &DotenvScope{ - Location: tf.Location, - Files: tf.Dotenv, - Vars: tf.Vars.DeepCopy(), - Env: tf.Env.DeepCopy(), - IncludeVars: NewVars(), - } - for task := range tf.Tasks.Values(nil) { - task.DotenvScope = scope.DeepCopy() - } - } - // Loop over each vertex in reverse topological order except for the root vertex. // This gives us a loop over every included Taskfile in an order which is safe to merge. for i := len(hashes) - 1; i > 0; i-- { diff --git a/taskfile/reader.go b/taskfile/reader.go index fc5d6d30af..cabacce8cc 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -431,6 +431,17 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) // Set the taskfile/task's locations tf.Location = node.Location() + // Preserve the included Taskfile's context before merging. Root dotenv + // keeps its existing global scope. + var dotenvScope *ast.DotenvScope + if node.Parent() != nil && len(tf.Dotenv) > 0 { + dotenvScope = &ast.DotenvScope{ + Files: tf.Dotenv, + Vars: tf.Vars.DeepCopy(), + Env: tf.Env.DeepCopy(), + IncludeVars: ast.NewVars(), + } + } for task := range tf.Tasks.Values(nil) { // If the task is not defined, create a new one if task == nil { @@ -440,6 +451,7 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) if task.Location.Taskfile == "" { task.Location.Taskfile = tf.Location } + task.DotenvScope = dotenvScope.DeepCopy() } return &tf, nil From 90139ae91e36d9855cadb400790176f10fe19683 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 13 Sep 2026 14:38:10 +0200 Subject: [PATCH 4/5] perf: reuse dynamic variable cache for scoped dotenv --- compiler.go | 10 ++++---- dotenv_include_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++ setup.go | 14 +++-------- 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/compiler.go b/compiler.go index cb09084f6e..65d2686118 100644 --- a/compiler.go +++ b/compiler.go @@ -34,18 +34,18 @@ type Compiler struct { } func (c *Compiler) GetTaskfileVariables() (*ast.Vars, error) { - return c.getVariables(nil, nil, true) + return c.getVariables(nil, nil, c.TaskfileEnv, true) } func (c *Compiler) GetVariables(t *ast.Task, call *Call) (*ast.Vars, error) { - return c.getVariables(t, call, true) + return c.getVariables(t, call, c.TaskfileEnv, true) } func (c *Compiler) FastGetVariables(t *ast.Task, call *Call) (*ast.Vars, error) { - return c.getVariables(t, call, false) + return c.getVariables(t, call, c.TaskfileEnv, false) } -func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) { +func (c *Compiler) getVariables(t *ast.Task, call *Call, taskfileEnv *ast.Vars, evaluateShVars bool) (*ast.Vars, error) { result := env.GetEnviron() specialVars, err := c.getSpecialVars(t, call) if err != nil { @@ -105,7 +105,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* taskRangeFunc = getRangeFunc(dir) } - for k, v := range c.TaskfileEnv.All() { + for k, v := range taskfileEnv.All() { if err := rangeFunc(k, v); err != nil { return nil, err } diff --git a/dotenv_include_test.go b/dotenv_include_test.go index 5eae126d56..af44bfe91d 100644 --- a/dotenv_include_test.go +++ b/dotenv_include_test.go @@ -2,6 +2,7 @@ package task_test import ( "bytes" + "fmt" "os" "path/filepath" "testing" @@ -178,3 +179,59 @@ func TestIncludedDotenvErrors(t *testing.T) { }) } } + +func TestIncludedDotenvDynamicCache(t *testing.T) { + t.Parallel() + for _, rootDotenv := range []bool{false, true} { + t.Run(fmt.Sprintf("root_dotenv=%t", rootDotenv), func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + root := `version: '3' +vars: + CONFIG: + sh: 'echo run >> global-calls; echo config' +includes: + a: + taskfile: included.yml + vars: + FLAVOR: a + LOCAL: + sh: 'echo run >> {{.FLAVOR}}-calls; echo {{.FLAVOR}}' + b: + taskfile: included.yml + vars: + FLAVOR: b + LOCAL: + sh: 'echo run >> {{.FLAVOR}}-calls; echo {{.FLAVOR}}' +` + if rootDotenv { + root += "dotenv: [root.env]\n" + } + for name, contents := range map[string]string{ + "Taskfile.yml": root, + "root.env": "DOTENV_CACHE_ROOT=root\n", + "included.yml": `version: '3' +dotenv: ['{{.CONFIG}}-{{.LOCAL}}.env'] +tasks: + inspect: 'echo {{.DOTENV_CACHE_VALUE}}' +`, + "config-a.env": "DOTENV_CACHE_VALUE=a\n", + "config-b.env": "DOTENV_CACHE_VALUE=b\n", + } { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(contents), 0o600)) + } + e := task.NewExecutor(task.WithDir(dir)) + require.NoError(t, e.Setup()) + for _, flavor := range []string{"a", "b"} { + compiled, err := e.CompiledTask(&task.Call{Task: flavor + ":inspect"}) + require.NoError(t, err) + assert.Equal(t, "echo "+flavor, compiled.Cmds[0].Cmd) + } + for _, name := range []string{"global-calls", "a-calls", "b-calls"} { + contents, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err) + assert.Equal(t, "run\n", string(contents), "%s should be evaluated once during setup and compilation", name) + } + }) + } +} diff --git a/setup.go b/setup.go index d407da4e45..2db9c995da 100644 --- a/setup.go +++ b/setup.go @@ -270,15 +270,8 @@ func (e *Executor) readIncludedDotEnvFiles(explicitEnv *ast.Vars) error { key := scopeKey{scope.Namespace, t.Location.Taskfile} loaded, ok := loadedScopes[key] if !ok { - compiler := &Compiler{ - Dir: e.Dir, - Entrypoint: e.Entrypoint, - UserWorkingDir: e.UserWorkingDir, - TaskfileEnv: e.Taskfile.Env.DeepCopy(), - TaskfileVars: e.Taskfile.Vars, - Logger: e.Logger, - } - compiler.TaskfileEnv.Merge(scope.Env, nil) + env := e.Taskfile.Env.DeepCopy() + env.Merge(scope.Env, nil) dir := e.Dir if !taskfile.IsRemoteEntrypoint(t.Location.Taskfile) { dir = filepath.Dir(t.Location.Taskfile) @@ -289,7 +282,8 @@ func (e *Executor) readIncludedDotEnvFiles(explicitEnv *ast.Vars) error { IncludeVars: scope.IncludeVars, IncludedTaskfileVars: scope.Vars, } - vars, err := compiler.GetVariables(contextTask, nil) + // Reuse the executor's dynamic cache while resolving this scope's environment. + vars, err := e.Compiler.getVariables(contextTask, nil, env, true) if err != nil { return err } From 8cfae7b8badb03f77d119964f1e90a1cd22d8e7c Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 13 Sep 2026 15:02:49 +0200 Subject: [PATCH 5/5] test: use golden fixtures for included dotenv --- dotenv_include_test.go | 237 ------------------ executor_test.go | 71 ++++++ .../included_dynamic_cache/Taskfile.yml | 25 ++ .../included_dynamic_cache/config-a.env | 1 + .../included_dynamic_cache/config-b.env | 1 + .../included_dynamic_cache/included.yml | 6 + .../dotenv/included_dynamic_cache/root.env | 1 + ...udedDotenvDynamicCache-Taskfile.yml.golden | 5 + ...dedDotenvDynamicCache-with-root.yml.golden | 5 + .../included_dynamic_cache/with-root.yml | 27 ++ testdata/dotenv/included_errors/broken.env | 1 + .../dotenv/included_errors/parse-root.yml | 4 + testdata/dotenv/included_errors/parse.yml | 6 + .../dotenv/included_errors/template-root.yml | 4 + testdata/dotenv/included_errors/template.yml | 6 + ...ncludedDotenvErrors-parse-err-setup.golden | 1 + .../TestIncludedDotenvErrors-parse.golden | 0 ...udedDotenvErrors-template-err-setup.golden | 1 + .../TestIncludedDotenvErrors-template.golden | 0 testdata/dotenv/included_scopes/Taskfile.yml | 23 ++ .../dotenv/included_scopes/app/Taskfile.yml | 20 ++ .../included_scopes/app/child/Taskfile.yml | 7 + .../included_scopes/app/child/child.env | 1 + .../included_scopes/app/config/base.env | 2 + .../included_scopes/app/config/env-path.env | 1 + .../dotenv/included_scopes/app/override.env | 3 + testdata/dotenv/included_scopes/app/task.env | 1 + ...TestIncludedDotenvScopes-standalone.golden | 2 + ...opes-standalone_root_remains_global.golden | 2 + .../included_scopes/common/Taskfile.yml | 6 + .../dotenv/included_scopes/flat/Taskfile.yml | 7 + testdata/dotenv/included_scopes/flat/flat.env | 1 + testdata/dotenv/included_scopes/root.env | 2 + .../included_scopes/shared/Taskfile.yml | 7 + testdata/dotenv/included_scopes/shared/a.env | 1 + testdata/dotenv/included_scopes/shared/b.env | 1 + .../included_scopes/sibling/Taskfile.yml | 11 + .../included_scopes/sibling/sibling.env | 1 + ...envScopes-common_only_inherits_root.golden | 2 + ...envScopes-common_via_another_parent.golden | 2 + .../TestIncludedDotenvScopes-flatten.golden | 2 + ...es-include_directory_and_file_order.golden | 2 + ...IncludedDotenvScopes-include_vars_a.golden | 2 + ...IncludedDotenvScopes-include_vars_b.golden | 2 + ...Scopes-nested_dotenv_is_independent.golden | 2 + ...es-no_dotenv_inherited_without_root.golden | 2 + ...IncludedDotenvScopes-root_isolation.golden | 2 + ...ludedDotenvScopes-sibling_isolation.golden | 2 + ...edDotenvScopes-task_dotenv_priority.golden | 2 + ...ludedDotenvScopes-task_env_priority.golden | 2 + ...dedDotenvScopes-without_root_dotenv.golden | 2 + .../dotenv/included_scopes/without-root.yml | 3 + .../dotenv/included_scopes/work/override.env | 1 + 53 files changed, 294 insertions(+), 237 deletions(-) delete mode 100644 dotenv_include_test.go create mode 100644 testdata/dotenv/included_dynamic_cache/Taskfile.yml create mode 100644 testdata/dotenv/included_dynamic_cache/config-a.env create mode 100644 testdata/dotenv/included_dynamic_cache/config-b.env create mode 100644 testdata/dotenv/included_dynamic_cache/included.yml create mode 100644 testdata/dotenv/included_dynamic_cache/root.env create mode 100644 testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-Taskfile.yml.golden create mode 100644 testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-with-root.yml.golden create mode 100644 testdata/dotenv/included_dynamic_cache/with-root.yml create mode 100644 testdata/dotenv/included_errors/broken.env create mode 100644 testdata/dotenv/included_errors/parse-root.yml create mode 100644 testdata/dotenv/included_errors/parse.yml create mode 100644 testdata/dotenv/included_errors/template-root.yml create mode 100644 testdata/dotenv/included_errors/template.yml create mode 100644 testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse-err-setup.golden create mode 100644 testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse.golden create mode 100644 testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template-err-setup.golden create mode 100644 testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template.golden create mode 100644 testdata/dotenv/included_scopes/Taskfile.yml create mode 100644 testdata/dotenv/included_scopes/app/Taskfile.yml create mode 100644 testdata/dotenv/included_scopes/app/child/Taskfile.yml create mode 100644 testdata/dotenv/included_scopes/app/child/child.env create mode 100644 testdata/dotenv/included_scopes/app/config/base.env create mode 100644 testdata/dotenv/included_scopes/app/config/env-path.env create mode 100644 testdata/dotenv/included_scopes/app/override.env create mode 100644 testdata/dotenv/included_scopes/app/task.env create mode 100644 testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone.golden create mode 100644 testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone_root_remains_global.golden create mode 100644 testdata/dotenv/included_scopes/common/Taskfile.yml create mode 100644 testdata/dotenv/included_scopes/flat/Taskfile.yml create mode 100644 testdata/dotenv/included_scopes/flat/flat.env create mode 100644 testdata/dotenv/included_scopes/root.env create mode 100644 testdata/dotenv/included_scopes/shared/Taskfile.yml create mode 100644 testdata/dotenv/included_scopes/shared/a.env create mode 100644 testdata/dotenv/included_scopes/shared/b.env create mode 100644 testdata/dotenv/included_scopes/sibling/Taskfile.yml create mode 100644 testdata/dotenv/included_scopes/sibling/sibling.env create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_only_inherits_root.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_via_another_parent.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-flatten.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_directory_and_file_order.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_a.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_b.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-nested_dotenv_is_independent.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-no_dotenv_inherited_without_root.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-root_isolation.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-sibling_isolation.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_dotenv_priority.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_env_priority.golden create mode 100644 testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-without_root_dotenv.golden create mode 100644 testdata/dotenv/included_scopes/without-root.yml create mode 100644 testdata/dotenv/included_scopes/work/override.env diff --git a/dotenv_include_test.go b/dotenv_include_test.go deleted file mode 100644 index af44bfe91d..0000000000 --- a/dotenv_include_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package task_test - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/go-task/task/v3" -) - -func TestIncludedDotenvScopes(t *testing.T) { - t.Parallel() - files := map[string]string{ - "Taskfile.yml": `version: '3' -dotenv: [root.env] -env: - DOTENV_EXPLICIT: explicit -includes: - app: - taskfile: app/Taskfile.yml - dir: work - sibling: sibling/Taskfile.yml - a: - taskfile: shared/Taskfile.yml - vars: {FLAVOR: a} - b: - taskfile: shared/Taskfile.yml - vars: {FLAVOR: b} - flat: - taskfile: flat/Taskfile.yml - flatten: true -tasks: - inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' -`, - "root.env": "DOTENV_CHOICE=root\nDOTENV_ROOT=root\n", - "without-root.yml": "version: '3'\nincludes:\n app: app/Taskfile.yml\n", - "app/Taskfile.yml": `version: '3' -vars: - CONFIG: config -env: - CONFIG_ENV: config -dotenv: [override.env, '{{.CONFIG}}/base.env', '{{.CONFIG_ENV}}/env-path.env', missing.env] -includes: - child: child/Taskfile.yml - common: ../common/Taskfile.yml -tasks: - inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' - task-env: - env: {DOTENV_CHOICE: task} - cmds: ['echo "$DOTENV_CHOICE"'] - task-dotenv: - dotenv: ['{{.TASKFILE_DIR}}/task.env'] - cmds: ['echo "$DOTENV_CHOICE"'] -`, - "app/override.env": "DOTENV_CHOICE=app\nDOTENV_APP=app\nDOTENV_EXPLICIT=dotenv\n", - "app/config/base.env": "DOTENV_CHOICE=base\nDOTENV_BASE=base\n", - "app/config/env-path.env": "DOTENV_ENV_PATH=env-path\n", - "app/task.env": "DOTENV_CHOICE=task-dotenv\n", - "app/child/Taskfile.yml": `version: '3' -dotenv: [child.env] -tasks: - inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' -`, - "app/child/child.env": "DOTENV_CHOICE=child\n", - "common/Taskfile.yml": `version: '3' -tasks: - inspect: 'echo "{{.DOTENV_CHOICE | default ""}}|$DOTENV_CHOICE|$DOTENV_APP"' -`, - "sibling/Taskfile.yml": `version: '3' -env: - CONFIG_ENV: nonexistent -dotenv: [sibling.env] -includes: - common: ../common/Taskfile.yml -tasks: - inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' -`, - "sibling/sibling.env": "DOTENV_CHOICE=sibling\n", - "shared/Taskfile.yml": `version: '3' -dotenv: ['{{.FLAVOR}}.env', '{{.FLAVOR}}.env'] -tasks: - inspect: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' -`, - "shared/a.env": "DOTENV_CHOICE=a\n", - "shared/b.env": "DOTENV_CHOICE=b\n", - "flat/Taskfile.yml": `version: '3' -dotenv: ['{{.TASKFILE_DIR}}/flat.env'] -tasks: - flattened: 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' -`, - "flat/flat.env": "DOTENV_CHOICE=flat\n", - "work/override.env": "DOTENV_CHOICE=wrong-directory\n", - } - dir := t.TempDir() - for name, contents := range files { - path := filepath.Join(dir, name) - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) - require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) - } - for _, test := range []struct { - name, dir, entrypoint, call, want string - }{ - {name: "root isolation", call: "inspect", want: "root|root|\n"}, - {name: "include directory and file order", call: "app:inspect", want: "app|app|app\n"}, - {name: "standalone", dir: "app", call: "inspect", want: "app|app|app\n"}, - {name: "standalone root remains global", dir: "app", call: "common:inspect", want: "app|app|app\n"}, - {name: "no dotenv inherited without root", entrypoint: "without-root.yml", call: "app:common:inspect", want: "||\n"}, - {name: "without root dotenv", entrypoint: "without-root.yml", call: "app:inspect", want: "app|app|app\n"}, - {name: "nested dotenv is independent", call: "app:child:inspect", want: "child|child|\n"}, - {name: "common only inherits root", call: "app:common:inspect", want: "root|root|\n"}, - {name: "sibling isolation", call: "sibling:inspect", want: "sibling|sibling|\n"}, - {name: "common via another parent", call: "sibling:common:inspect", want: "root|root|\n"}, - {name: "include vars a", call: "a:inspect", want: "a|a|\n"}, - {name: "include vars b", call: "b:inspect", want: "b|b|\n"}, - {name: "flatten", call: "flattened", want: "flat|flat|\n"}, - {name: "task env priority", call: "app:task-env", want: "task\n"}, - {name: "task dotenv priority", call: "app:task-dotenv", want: "task-dotenv\n"}, - } { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - var stdout bytes.Buffer - entrypoint := "" - if test.entrypoint != "" { - entrypoint = filepath.Join(dir, test.entrypoint) - } - e := task.NewExecutor( - task.WithDir(filepath.Join(dir, test.dir)), - task.WithEntrypoint(entrypoint), - task.WithSilent(true), - task.WithStdout(&stdout), - ) - require.NoError(t, e.Setup()) - call := &task.Call{Task: test.call} - require.NoError(t, e.Run(t.Context(), call)) - assert.Equal(t, test.want, stdout.String()) - compiled, err := e.CompiledTask(call) - require.NoError(t, err) - if test.call == "app:inspect" { - value, ok := compiled.Env.Get("DOTENV_ENV_PATH") - require.True(t, ok) - assert.Equal(t, "env-path", value.Value) - value, ok = compiled.Env.Get("DOTENV_BASE") - require.True(t, ok) - assert.Equal(t, "base", value.Value) - } - if test.dir == "" && test.entrypoint == "" { - value, ok := compiled.Env.Get("DOTENV_EXPLICIT") - require.True(t, ok) - assert.Equal(t, "explicit", value.Value) - value, ok = compiled.Env.Get("DOTENV_ROOT") - require.True(t, ok) - assert.Equal(t, "root", value.Value) - } - }) - } -} - -func TestIncludedDotenvErrors(t *testing.T) { - t.Parallel() - for _, test := range []struct { - name, path, contents, want string - }{ - {name: "parse error", path: "broken.env", contents: "INVALID=\"unterminated", want: "broken.env"}, - {name: "template error", path: "{{fail \"invalid dotenv path\"}}", want: "invalid dotenv path"}, - } { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte("version: '3'\nincludes:\n app: app.yml\n"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "app.yml"), []byte("version: '3'\ndotenv: ['"+test.path+"']\ntasks:\n test: echo test\n"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "broken.env"), []byte(test.contents), 0o600)) - e := task.NewExecutor(task.WithDir(dir)) - require.ErrorContains(t, e.Setup(), test.want) - }) - } -} - -func TestIncludedDotenvDynamicCache(t *testing.T) { - t.Parallel() - for _, rootDotenv := range []bool{false, true} { - t.Run(fmt.Sprintf("root_dotenv=%t", rootDotenv), func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - root := `version: '3' -vars: - CONFIG: - sh: 'echo run >> global-calls; echo config' -includes: - a: - taskfile: included.yml - vars: - FLAVOR: a - LOCAL: - sh: 'echo run >> {{.FLAVOR}}-calls; echo {{.FLAVOR}}' - b: - taskfile: included.yml - vars: - FLAVOR: b - LOCAL: - sh: 'echo run >> {{.FLAVOR}}-calls; echo {{.FLAVOR}}' -` - if rootDotenv { - root += "dotenv: [root.env]\n" - } - for name, contents := range map[string]string{ - "Taskfile.yml": root, - "root.env": "DOTENV_CACHE_ROOT=root\n", - "included.yml": `version: '3' -dotenv: ['{{.CONFIG}}-{{.LOCAL}}.env'] -tasks: - inspect: 'echo {{.DOTENV_CACHE_VALUE}}' -`, - "config-a.env": "DOTENV_CACHE_VALUE=a\n", - "config-b.env": "DOTENV_CACHE_VALUE=b\n", - } { - require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(contents), 0o600)) - } - e := task.NewExecutor(task.WithDir(dir)) - require.NoError(t, e.Setup()) - for _, flavor := range []string{"a", "b"} { - compiled, err := e.CompiledTask(&task.Call{Task: flavor + ":inspect"}) - require.NoError(t, err) - assert.Equal(t, "echo "+flavor, compiled.Cmds[0].Cmd) - } - for _, name := range []string{"global-calls", "a-calls", "b-calls"} { - contents, err := os.ReadFile(filepath.Join(dir, name)) - require.NoError(t, err) - assert.Equal(t, "run\n", string(contents), "%s should be evaluated once during setup and compilation", name) - } - }) - } -} diff --git a/executor_test.go b/executor_test.go index e0ce4e2786..9ec528a502 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1289,3 +1289,74 @@ func TestIf(t *testing.T) { NewExecutorTest(t, opts...) } } + +func TestIncludedDotenvScopes(t *testing.T) { + t.Parallel() + + const dir = "testdata/dotenv/included_scopes" + for _, test := range []struct { + name, dir, entrypoint, task string + }{ + {name: "root isolation", task: "inspect"}, + {name: "include directory and file order", task: "app:inspect"}, + {name: "standalone", dir: "app", task: "inspect"}, + {name: "standalone root remains global", dir: "app", task: "common:inspect"}, + {name: "no dotenv inherited without root", entrypoint: "without-root.yml", task: "app:common:inspect"}, + {name: "without root dotenv", entrypoint: "without-root.yml", task: "app:inspect"}, + {name: "nested dotenv is independent", task: "app:child:inspect"}, + {name: "common only inherits root", task: "app:common:inspect"}, + {name: "sibling isolation", task: "sibling:inspect"}, + {name: "common via another parent", task: "sibling:common:inspect"}, + {name: "include vars a", task: "a:inspect"}, + {name: "include vars b", task: "b:inspect"}, + {name: "flatten", task: "flattened"}, + {name: "task env priority", task: "app:task-env"}, + {name: "task dotenv priority", task: "app:task-dotenv"}, + } { + entrypoint := "" + if test.entrypoint != "" { + entrypoint = filepath.Join(dir, test.entrypoint) + } + NewExecutorTest(t, + WithName(test.name), + WithExecutorOptions( + task.WithDir(filepath.Join(dir, test.dir)), + task.WithEntrypoint(entrypoint), + task.WithSilent(true), + ), + WithTask(test.task), + ) + } +} + +func TestIncludedDotenvErrors(t *testing.T) { + t.Parallel() + + for _, name := range []string{"parse", "template"} { + NewExecutorTest(t, + WithName(name), + WithExecutorOptions( + task.WithDir("testdata/dotenv/included_errors"), + task.WithEntrypoint(filepath.Join("testdata/dotenv/included_errors", name+"-root.yml")), + ), + WithSetupError(), + WithFixtureTemplating(), + ) + } +} + +func TestIncludedDotenvDynamicCache(t *testing.T) { + t.Parallel() + + for _, entrypoint := range []string{"Taskfile.yml", "with-root.yml"} { + NewExecutorTest(t, + WithName(entrypoint), + WithExecutorOptions( + task.WithDir("testdata/dotenv/included_dynamic_cache"), + task.WithEntrypoint(filepath.Join("testdata/dotenv/included_dynamic_cache", entrypoint)), + task.WithSilent(true), + ), + WithPostProcessFn(PPSortedLines), + ) + } +} diff --git a/testdata/dotenv/included_dynamic_cache/Taskfile.yml b/testdata/dotenv/included_dynamic_cache/Taskfile.yml new file mode 100644 index 0000000000..e55ebc6807 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/Taskfile.yml @@ -0,0 +1,25 @@ +version: '3' + +vars: + CONFIG: + sh: 'echo global-evaluated >&2; echo config' + +includes: + a: + taskfile: included.yml + vars: + FLAVOR: a + LOCAL: + sh: 'echo {{.FLAVOR}}-evaluated >&2; echo {{.FLAVOR}}' + b: + taskfile: included.yml + vars: + FLAVOR: b + LOCAL: + sh: 'echo {{.FLAVOR}}-evaluated >&2; echo {{.FLAVOR}}' + +tasks: + default: + cmds: + - task: a:inspect + - task: b:inspect diff --git a/testdata/dotenv/included_dynamic_cache/config-a.env b/testdata/dotenv/included_dynamic_cache/config-a.env new file mode 100644 index 0000000000..17161747ee --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/config-a.env @@ -0,0 +1 @@ +DOTENV_CACHE_VALUE=a diff --git a/testdata/dotenv/included_dynamic_cache/config-b.env b/testdata/dotenv/included_dynamic_cache/config-b.env new file mode 100644 index 0000000000..6b5a17ac66 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/config-b.env @@ -0,0 +1 @@ +DOTENV_CACHE_VALUE=b diff --git a/testdata/dotenv/included_dynamic_cache/included.yml b/testdata/dotenv/included_dynamic_cache/included.yml new file mode 100644 index 0000000000..3ded6d9d3e --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/included.yml @@ -0,0 +1,6 @@ +version: '3' + +dotenv: ['{{.CONFIG}}-{{.LOCAL}}.env'] + +tasks: + inspect: 'echo {{.DOTENV_CACHE_VALUE}}' diff --git a/testdata/dotenv/included_dynamic_cache/root.env b/testdata/dotenv/included_dynamic_cache/root.env new file mode 100644 index 0000000000..3ae2a1dc98 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/root.env @@ -0,0 +1 @@ +DOTENV_CACHE_ROOT=root diff --git a/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-Taskfile.yml.golden b/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-Taskfile.yml.golden new file mode 100644 index 0000000000..1495832f13 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-Taskfile.yml.golden @@ -0,0 +1,5 @@ +a +a-evaluated +b +b-evaluated +global-evaluated diff --git a/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-with-root.yml.golden b/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-with-root.yml.golden new file mode 100644 index 0000000000..1495832f13 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/testdata/TestIncludedDotenvDynamicCache-with-root.yml.golden @@ -0,0 +1,5 @@ +a +a-evaluated +b +b-evaluated +global-evaluated diff --git a/testdata/dotenv/included_dynamic_cache/with-root.yml b/testdata/dotenv/included_dynamic_cache/with-root.yml new file mode 100644 index 0000000000..41691ffb21 --- /dev/null +++ b/testdata/dotenv/included_dynamic_cache/with-root.yml @@ -0,0 +1,27 @@ +version: '3' + +dotenv: [root.env] + +vars: + CONFIG: + sh: 'echo global-evaluated >&2; echo config' + +includes: + a: + taskfile: included.yml + vars: + FLAVOR: a + LOCAL: + sh: 'echo {{.FLAVOR}}-evaluated >&2; echo {{.FLAVOR}}' + b: + taskfile: included.yml + vars: + FLAVOR: b + LOCAL: + sh: 'echo {{.FLAVOR}}-evaluated >&2; echo {{.FLAVOR}}' + +tasks: + default: + cmds: + - task: a:inspect + - task: b:inspect diff --git a/testdata/dotenv/included_errors/broken.env b/testdata/dotenv/included_errors/broken.env new file mode 100644 index 0000000000..ccee1ea8ee --- /dev/null +++ b/testdata/dotenv/included_errors/broken.env @@ -0,0 +1 @@ +INVALID="unterminated \ No newline at end of file diff --git a/testdata/dotenv/included_errors/parse-root.yml b/testdata/dotenv/included_errors/parse-root.yml new file mode 100644 index 0000000000..69255d339a --- /dev/null +++ b/testdata/dotenv/included_errors/parse-root.yml @@ -0,0 +1,4 @@ +version: '3' + +includes: + app: parse.yml diff --git a/testdata/dotenv/included_errors/parse.yml b/testdata/dotenv/included_errors/parse.yml new file mode 100644 index 0000000000..b83ccc1a75 --- /dev/null +++ b/testdata/dotenv/included_errors/parse.yml @@ -0,0 +1,6 @@ +version: '3' + +dotenv: [broken.env] + +tasks: + default: echo test diff --git a/testdata/dotenv/included_errors/template-root.yml b/testdata/dotenv/included_errors/template-root.yml new file mode 100644 index 0000000000..2fa170c71a --- /dev/null +++ b/testdata/dotenv/included_errors/template-root.yml @@ -0,0 +1,4 @@ +version: '3' + +includes: + app: template.yml diff --git a/testdata/dotenv/included_errors/template.yml b/testdata/dotenv/included_errors/template.yml new file mode 100644 index 0000000000..72177a2520 --- /dev/null +++ b/testdata/dotenv/included_errors/template.yml @@ -0,0 +1,6 @@ +version: '3' + +dotenv: ['{{fail "invalid dotenv path"}}'] + +tasks: + default: echo test diff --git a/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse-err-setup.golden b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse-err-setup.golden new file mode 100644 index 0000000000..ee4f679b1a --- /dev/null +++ b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse-err-setup.golden @@ -0,0 +1 @@ +error reading env file {{.TEST_DIR}}/testdata/dotenv/included_errors/broken.env: unterminated quoted value "unterminated \ No newline at end of file diff --git a/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse.golden b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-parse.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template-err-setup.golden b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template-err-setup.golden new file mode 100644 index 0000000000..464e72565a --- /dev/null +++ b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template-err-setup.golden @@ -0,0 +1 @@ +template: :1:2: executing "" at : error calling fail: invalid dotenv path \ No newline at end of file diff --git a/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template.golden b/testdata/dotenv/included_errors/testdata/TestIncludedDotenvErrors-template.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testdata/dotenv/included_scopes/Taskfile.yml b/testdata/dotenv/included_scopes/Taskfile.yml new file mode 100644 index 0000000000..280721853c --- /dev/null +++ b/testdata/dotenv/included_scopes/Taskfile.yml @@ -0,0 +1,23 @@ +version: '3' +dotenv: [root.env] +env: + DOTENV_EXPLICIT: explicit +includes: + app: + taskfile: app/Taskfile.yml + dir: work + sibling: sibling/Taskfile.yml + a: + taskfile: shared/Taskfile.yml + vars: {FLAVOR: a} + b: + taskfile: shared/Taskfile.yml + vars: {FLAVOR: b} + flat: + taskfile: flat/Taskfile.yml + flatten: true +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/app/Taskfile.yml b/testdata/dotenv/included_scopes/app/Taskfile.yml new file mode 100644 index 0000000000..0ef7f95c9e --- /dev/null +++ b/testdata/dotenv/included_scopes/app/Taskfile.yml @@ -0,0 +1,20 @@ +version: '3' +vars: + CONFIG: config +env: + CONFIG_ENV: config +dotenv: [override.env, '{{.CONFIG}}/base.env', '{{.CONFIG_ENV}}/env-path.env', missing.env] +includes: + child: child/Taskfile.yml + common: ../common/Taskfile.yml +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' + task-env: + env: {DOTENV_CHOICE: task} + cmds: ['echo "$DOTENV_CHOICE"', 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"'] + task-dotenv: + dotenv: ['{{.TASKFILE_DIR}}/task.env'] + cmds: ['echo "$DOTENV_CHOICE"', 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"'] diff --git a/testdata/dotenv/included_scopes/app/child/Taskfile.yml b/testdata/dotenv/included_scopes/app/child/Taskfile.yml new file mode 100644 index 0000000000..2d2f377890 --- /dev/null +++ b/testdata/dotenv/included_scopes/app/child/Taskfile.yml @@ -0,0 +1,7 @@ +version: '3' +dotenv: [child.env] +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/app/child/child.env b/testdata/dotenv/included_scopes/app/child/child.env new file mode 100644 index 0000000000..9a54ccf0cf --- /dev/null +++ b/testdata/dotenv/included_scopes/app/child/child.env @@ -0,0 +1 @@ +DOTENV_CHOICE=child diff --git a/testdata/dotenv/included_scopes/app/config/base.env b/testdata/dotenv/included_scopes/app/config/base.env new file mode 100644 index 0000000000..12aa8dbb68 --- /dev/null +++ b/testdata/dotenv/included_scopes/app/config/base.env @@ -0,0 +1,2 @@ +DOTENV_CHOICE=base +DOTENV_BASE=base diff --git a/testdata/dotenv/included_scopes/app/config/env-path.env b/testdata/dotenv/included_scopes/app/config/env-path.env new file mode 100644 index 0000000000..9083a19127 --- /dev/null +++ b/testdata/dotenv/included_scopes/app/config/env-path.env @@ -0,0 +1 @@ +DOTENV_ENV_PATH=env-path diff --git a/testdata/dotenv/included_scopes/app/override.env b/testdata/dotenv/included_scopes/app/override.env new file mode 100644 index 0000000000..00c4cddc84 --- /dev/null +++ b/testdata/dotenv/included_scopes/app/override.env @@ -0,0 +1,3 @@ +DOTENV_CHOICE=app +DOTENV_APP=app +DOTENV_EXPLICIT=dotenv diff --git a/testdata/dotenv/included_scopes/app/task.env b/testdata/dotenv/included_scopes/app/task.env new file mode 100644 index 0000000000..3a307bee53 --- /dev/null +++ b/testdata/dotenv/included_scopes/app/task.env @@ -0,0 +1 @@ +DOTENV_CHOICE=task-dotenv diff --git a/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone.golden b/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone.golden new file mode 100644 index 0000000000..53d31b5f9f --- /dev/null +++ b/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone.golden @@ -0,0 +1,2 @@ +app|app|app +explicit=dotenv root= base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone_root_remains_global.golden b/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone_root_remains_global.golden new file mode 100644 index 0000000000..53d31b5f9f --- /dev/null +++ b/testdata/dotenv/included_scopes/app/testdata/TestIncludedDotenvScopes-standalone_root_remains_global.golden @@ -0,0 +1,2 @@ +app|app|app +explicit=dotenv root= base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/common/Taskfile.yml b/testdata/dotenv/included_scopes/common/Taskfile.yml new file mode 100644 index 0000000000..98931c832f --- /dev/null +++ b/testdata/dotenv/included_scopes/common/Taskfile.yml @@ -0,0 +1,6 @@ +version: '3' +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE | default ""}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/flat/Taskfile.yml b/testdata/dotenv/included_scopes/flat/Taskfile.yml new file mode 100644 index 0000000000..b3360f9624 --- /dev/null +++ b/testdata/dotenv/included_scopes/flat/Taskfile.yml @@ -0,0 +1,7 @@ +version: '3' +dotenv: ['{{.TASKFILE_DIR}}/flat.env'] +tasks: + flattened: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/flat/flat.env b/testdata/dotenv/included_scopes/flat/flat.env new file mode 100644 index 0000000000..81bbaeb803 --- /dev/null +++ b/testdata/dotenv/included_scopes/flat/flat.env @@ -0,0 +1 @@ +DOTENV_CHOICE=flat diff --git a/testdata/dotenv/included_scopes/root.env b/testdata/dotenv/included_scopes/root.env new file mode 100644 index 0000000000..3f00fde7a8 --- /dev/null +++ b/testdata/dotenv/included_scopes/root.env @@ -0,0 +1,2 @@ +DOTENV_CHOICE=root +DOTENV_ROOT=root diff --git a/testdata/dotenv/included_scopes/shared/Taskfile.yml b/testdata/dotenv/included_scopes/shared/Taskfile.yml new file mode 100644 index 0000000000..be73751b0e --- /dev/null +++ b/testdata/dotenv/included_scopes/shared/Taskfile.yml @@ -0,0 +1,7 @@ +version: '3' +dotenv: ['{{.FLAVOR}}.env', '{{.FLAVOR}}.env'] +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/shared/a.env b/testdata/dotenv/included_scopes/shared/a.env new file mode 100644 index 0000000000..00ff1dde99 --- /dev/null +++ b/testdata/dotenv/included_scopes/shared/a.env @@ -0,0 +1 @@ +DOTENV_CHOICE=a diff --git a/testdata/dotenv/included_scopes/shared/b.env b/testdata/dotenv/included_scopes/shared/b.env new file mode 100644 index 0000000000..1659922416 --- /dev/null +++ b/testdata/dotenv/included_scopes/shared/b.env @@ -0,0 +1 @@ +DOTENV_CHOICE=b diff --git a/testdata/dotenv/included_scopes/sibling/Taskfile.yml b/testdata/dotenv/included_scopes/sibling/Taskfile.yml new file mode 100644 index 0000000000..d431b06bb7 --- /dev/null +++ b/testdata/dotenv/included_scopes/sibling/Taskfile.yml @@ -0,0 +1,11 @@ +version: '3' +env: + CONFIG_ENV: nonexistent +dotenv: [sibling.env] +includes: + common: ../common/Taskfile.yml +tasks: + inspect: + cmds: + - 'echo "{{.DOTENV_CHOICE}}|$DOTENV_CHOICE|$DOTENV_APP"' + - 'echo "explicit=$DOTENV_EXPLICIT root=$DOTENV_ROOT base=$DOTENV_BASE env-path=$DOTENV_ENV_PATH"' diff --git a/testdata/dotenv/included_scopes/sibling/sibling.env b/testdata/dotenv/included_scopes/sibling/sibling.env new file mode 100644 index 0000000000..5bdb48f1aa --- /dev/null +++ b/testdata/dotenv/included_scopes/sibling/sibling.env @@ -0,0 +1 @@ +DOTENV_CHOICE=sibling diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_only_inherits_root.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_only_inherits_root.golden new file mode 100644 index 0000000000..59ae49405c --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_only_inherits_root.golden @@ -0,0 +1,2 @@ +root|root| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_via_another_parent.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_via_another_parent.golden new file mode 100644 index 0000000000..59ae49405c --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-common_via_another_parent.golden @@ -0,0 +1,2 @@ +root|root| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-flatten.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-flatten.golden new file mode 100644 index 0000000000..b9dc53cf07 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-flatten.golden @@ -0,0 +1,2 @@ +flat|flat| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_directory_and_file_order.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_directory_and_file_order.golden new file mode 100644 index 0000000000..e06354d173 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_directory_and_file_order.golden @@ -0,0 +1,2 @@ +app|app|app +explicit=explicit root=root base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_a.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_a.golden new file mode 100644 index 0000000000..c1eeeda173 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_a.golden @@ -0,0 +1,2 @@ +a|a| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_b.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_b.golden new file mode 100644 index 0000000000..79d487c3df --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-include_vars_b.golden @@ -0,0 +1,2 @@ +b|b| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-nested_dotenv_is_independent.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-nested_dotenv_is_independent.golden new file mode 100644 index 0000000000..98913f7136 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-nested_dotenv_is_independent.golden @@ -0,0 +1,2 @@ +child|child| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-no_dotenv_inherited_without_root.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-no_dotenv_inherited_without_root.golden new file mode 100644 index 0000000000..88cfd6c5e0 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-no_dotenv_inherited_without_root.golden @@ -0,0 +1,2 @@ +|| +explicit= root= base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-root_isolation.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-root_isolation.golden new file mode 100644 index 0000000000..59ae49405c --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-root_isolation.golden @@ -0,0 +1,2 @@ +root|root| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-sibling_isolation.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-sibling_isolation.golden new file mode 100644 index 0000000000..76cd2d25f1 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-sibling_isolation.golden @@ -0,0 +1,2 @@ +sibling|sibling| +explicit=explicit root=root base= env-path= diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_dotenv_priority.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_dotenv_priority.golden new file mode 100644 index 0000000000..2bb2381c66 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_dotenv_priority.golden @@ -0,0 +1,2 @@ +task-dotenv +explicit=explicit root=root base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_env_priority.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_env_priority.golden new file mode 100644 index 0000000000..86d2bf47a0 --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-task_env_priority.golden @@ -0,0 +1,2 @@ +task +explicit=explicit root=root base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-without_root_dotenv.golden b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-without_root_dotenv.golden new file mode 100644 index 0000000000..53d31b5f9f --- /dev/null +++ b/testdata/dotenv/included_scopes/testdata/TestIncludedDotenvScopes-without_root_dotenv.golden @@ -0,0 +1,2 @@ +app|app|app +explicit=dotenv root= base=base env-path=env-path diff --git a/testdata/dotenv/included_scopes/without-root.yml b/testdata/dotenv/included_scopes/without-root.yml new file mode 100644 index 0000000000..6b3b9d85fb --- /dev/null +++ b/testdata/dotenv/included_scopes/without-root.yml @@ -0,0 +1,3 @@ +version: '3' +includes: + app: app/Taskfile.yml diff --git a/testdata/dotenv/included_scopes/work/override.env b/testdata/dotenv/included_scopes/work/override.env new file mode 100644 index 0000000000..a9eb0c992e --- /dev/null +++ b/testdata/dotenv/included_scopes/work/override.env @@ -0,0 +1 @@ +DOTENV_CHOICE=wrong-directory