From f45c1780ad7552873c280fa2a7ffdad058c65ea4 Mon Sep 17 00:00:00 2001 From: puneetdixit200 <236133619+puneetdixit200@users.noreply.github.com> Date: Fri, 22 May 2026 14:08:34 +0530 Subject: [PATCH] fix: preserve dotenv variable order --- task_test.go | 17 ++ taskfile/dotenv.go | 155 +++++++++++++++++- testdata/dotenv/nested_templates/.env | 8 + testdata/dotenv/nested_templates/Taskfile.yml | 8 + variables.go | 9 +- 5 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 testdata/dotenv/nested_templates/.env create mode 100644 testdata/dotenv/nested_templates/Taskfile.yml diff --git a/task_test.go b/task_test.go index 80915c2c47..6f55b528b0 100644 --- a/task_test.go +++ b/task_test.go @@ -1896,6 +1896,23 @@ func TestDotenvHasEnvVarInPath(t *testing.T) { // nolint:paralleltest // cannot tt.Run(t) } +func TestDotenvNestedTemplatesAreEvaluatedInFileOrder(t *testing.T) { + t.Parallel() + + tt := fileContentTest{ + Dir: "testdata/dotenv/nested_templates", + Target: "default", + TrimSpace: true, + Files: map[string]string{ + "path.txt": "/home/user/l1/l2/l3/l4/l5/l6/file.txt", + }, + } + t.Run("", func(t *testing.T) { + t.Parallel() + tt.Run(t) + }) +} + func TestTaskDotenvParseErrorMessage(t *testing.T) { t.Parallel() diff --git a/taskfile/dotenv.go b/taskfile/dotenv.go index a86a9eed1e..9b46a2544d 100644 --- a/taskfile/dotenv.go +++ b/taskfile/dotenv.go @@ -1,8 +1,12 @@ package taskfile import ( + "bytes" "fmt" "os" + "sort" + "strings" + "unicode" "github.com/joho/godotenv" @@ -26,16 +30,161 @@ func Dotenv(vars *ast.Vars, tf *ast.Taskfile, dir string) (*ast.Vars, error) { continue } - envs, err := godotenv.Read(dotEnvPath) + envs, err := ReadDotenv(dotEnvPath) if err != nil { return nil, fmt.Errorf("error reading env file %s: %w", dotEnvPath, err) } - for key, value := range envs { + for key, value := range envs.All() { if _, ok := env.Get(key); !ok { - env.Set(key, ast.Var{Value: value}) + env.Set(key, value) } } } return env, nil } + +// ReadDotenv reads a dotenv file while preserving the variable order from the file. +func ReadDotenv(filename string) (*ast.Vars, error) { + src, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + envMap, err := godotenv.UnmarshalBytes(src) + if err != nil { + return nil, err + } + + env := ast.NewVars() + seen := make(map[string]bool, len(envMap)) + for _, key := range dotenvKeyOrder(src) { + value, ok := envMap[key] + if !ok || seen[key] { + continue + } + seen[key] = true + env.Set(key, ast.Var{Value: value}) + } + + if len(seen) < len(envMap) { + missing := make([]string, 0, len(envMap)-len(seen)) + for key := range envMap { + if !seen[key] { + missing = append(missing, key) + } + } + sort.Strings(missing) + for _, key := range missing { + env.Set(key, ast.Var{Value: envMap[key]}) + } + } + + return env, nil +} + +func dotenvKeyOrder(src []byte) []string { + src = bytes.ReplaceAll(src, []byte("\r\n"), []byte("\n")) + keys := []string{} + + for { + src = dotenvStatementStart(src) + if src == nil { + return keys + } + + key, rest := dotenvLocateKey(src) + if key != "" { + keys = append(keys, key) + } + src = dotenvSkipValue(rest) + } +} + +func dotenvStatementStart(src []byte) []byte { + for { + pos := bytes.IndexFunc(src, func(r rune) bool { + return !unicode.IsSpace(r) + }) + if pos == -1 { + return nil + } + + src = src[pos:] + if src[0] != '#' { + return src + } + + pos = bytes.IndexFunc(src, dotenvIsLineEnd) + if pos == -1 { + return nil + } + src = src[pos:] + } +} + +func dotenvLocateKey(src []byte) (string, []byte) { + src = bytes.TrimLeftFunc(src, dotenvIsSpace) + if bytes.HasPrefix(src, []byte("export")) { + trimmed := bytes.TrimPrefix(src, []byte("export")) + if bytes.IndexFunc(trimmed, dotenvIsSpace) == 0 { + src = bytes.TrimLeftFunc(trimmed, dotenvIsSpace) + } + } + + for i, char := range src { + rchar := rune(char) + if dotenvIsSpace(rchar) { + continue + } + + switch char { + case '=', ':': + key := strings.TrimRightFunc(string(src[:i]), unicode.IsSpace) + return key, bytes.TrimLeftFunc(src[i+1:], dotenvIsSpace) + case '_': + default: + if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) || rchar == '.' { + continue + } + return "", dotenvSkipValue(src) + } + } + + return "", nil +} + +func dotenvSkipValue(src []byte) []byte { + if len(src) == 0 { + return nil + } + + switch quote := src[0]; quote { + case '\'', '"': + for i := 1; i < len(src); i++ { + if src[i] == quote && src[i-1] != '\\' { + return src[i+1:] + } + } + return nil + default: + pos := bytes.IndexFunc(src, dotenvIsLineEnd) + if pos == -1 { + return nil + } + return src[pos:] + } +} + +func dotenvIsSpace(r rune) bool { + switch r { + case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0: + return true + default: + return false + } +} + +func dotenvIsLineEnd(r rune) bool { + return r == '\n' || r == '\r' +} diff --git a/testdata/dotenv/nested_templates/.env b/testdata/dotenv/nested_templates/.env new file mode 100644 index 0000000000..cf9a1e5ff3 --- /dev/null +++ b/testdata/dotenv/nested_templates/.env @@ -0,0 +1,8 @@ +BASE_DIR=/home/user +LEVEL_1={{.BASE_DIR}}/l1 +LEVEL_2={{.LEVEL_1}}/l2 +LEVEL_3={{.LEVEL_2}}/l3 +LEVEL_4={{.LEVEL_3}}/l4 +LEVEL_5={{.LEVEL_4}}/l5 +LEVEL_6={{.LEVEL_5}}/l6 +FULL_PATH={{.LEVEL_6}}/file.txt diff --git a/testdata/dotenv/nested_templates/Taskfile.yml b/testdata/dotenv/nested_templates/Taskfile.yml new file mode 100644 index 0000000000..df9a53c78a --- /dev/null +++ b/testdata/dotenv/nested_templates/Taskfile.yml @@ -0,0 +1,8 @@ +version: '3' + +dotenv: ['.env'] + +tasks: + default: + cmds: + - echo "$FULL_PATH" > path.txt diff --git a/variables.go b/variables.go index c7c6cc8493..fe27f83332 100644 --- a/variables.go +++ b/variables.go @@ -7,8 +7,6 @@ import ( "path/filepath" "strings" - "github.com/joho/godotenv" - "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/deepcopy" "github.com/go-task/task/v3/internal/env" @@ -16,6 +14,7 @@ import ( "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/fingerprint" "github.com/go-task/task/v3/internal/templater" + "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/ast" ) @@ -161,13 +160,13 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err if _, err := os.Stat(dotEnvPath); os.IsNotExist(err) { continue } - envs, err := godotenv.Read(dotEnvPath) + envs, err := taskfile.ReadDotenv(dotEnvPath) if err != nil { return nil, err } - for key, value := range envs { + for key, value := range envs.All() { if _, ok := dotenvEnvs.Get(key); !ok { - dotenvEnvs.Set(key, ast.Var{Value: value}) + dotenvEnvs.Set(key, value) } } }