diff --git a/taskfile/dotenv.go b/taskfile/dotenv.go index a86a9eed1e..5b52b00045 100644 --- a/taskfile/dotenv.go +++ b/taskfile/dotenv.go @@ -1,8 +1,10 @@ package taskfile import ( + "bufio" "fmt" "os" + "strings" "github.com/joho/godotenv" @@ -11,6 +13,80 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) +// DotenvKeyValue represents a key-value pair from a dotenv file. +type DotenvKeyValue struct { + Key string + Value string +} + +// ReadDotenvOrdered reads a dotenv file and returns key-value pairs in the +// order they appear in the file. This is important because Go maps have +// non-deterministic iteration order, which can cause race conditions when +// variables reference each other with template syntax like {{.VAR}}. +func ReadDotenvOrdered(path string) ([]DotenvKeyValue, error) { + // Use godotenv to parse the file (handles quotes, escaping, etc.) + envMap, err := godotenv.Read(path) + if err != nil { + return nil, err + } + + // Read the file again to get keys in order + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var orderedKeys []string + seenKeys := make(map[string]bool) + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Handle "export VAR=value" syntax + if strings.HasPrefix(line, "export ") { + line = strings.TrimPrefix(line, "export ") + line = strings.TrimSpace(line) + } + + // Extract the key (before = or :) + var key string + if idx := strings.IndexAny(line, "=:"); idx > 0 { + key = strings.TrimSpace(line[:idx]) + } + + // Only add if it's a valid key we found in godotenv's output + // and we haven't seen it before (first occurrence wins) + if key != "" && !seenKeys[key] { + if _, exists := envMap[key]; exists { + orderedKeys = append(orderedKeys, key) + seenKeys[key] = true + } + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + // Build ordered result using godotenv's parsed values + result := make([]DotenvKeyValue, 0, len(orderedKeys)) + for _, key := range orderedKeys { + result = append(result, DotenvKeyValue{ + Key: key, + Value: envMap[key], + }) + } + + return result, nil +} + func Dotenv(vars *ast.Vars, tf *ast.Taskfile, dir string) (*ast.Vars, error) { env := ast.NewVars() cache := &templater.Cache{Vars: vars} @@ -26,13 +102,13 @@ func Dotenv(vars *ast.Vars, tf *ast.Taskfile, dir string) (*ast.Vars, error) { continue } - envs, err := godotenv.Read(dotEnvPath) + envs, err := ReadDotenvOrdered(dotEnvPath) if err != nil { return nil, fmt.Errorf("error reading env file %s: %w", dotEnvPath, err) } - for key, value := range envs { - if _, ok := env.Get(key); !ok { - env.Set(key, ast.Var{Value: value}) + for _, kv := range envs { + if _, ok := env.Get(kv.Key); !ok { + env.Set(kv.Key, ast.Var{Value: kv.Value}) } } } diff --git a/taskfile/dotenv_test.go b/taskfile/dotenv_test.go new file mode 100644 index 0000000000..d834844d10 --- /dev/null +++ b/taskfile/dotenv_test.go @@ -0,0 +1,148 @@ +package taskfile + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadDotenvOrdered(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + expected []DotenvKeyValue + }{ + { + name: "maintains order of variables", + content: `VAR_A=first +VAR_B=second +VAR_C=third +VAR_D=fourth`, + expected: []DotenvKeyValue{ + {Key: "VAR_A", Value: "first"}, + {Key: "VAR_B", Value: "second"}, + {Key: "VAR_C", Value: "third"}, + {Key: "VAR_D", Value: "fourth"}, + }, + }, + { + name: "handles comments and empty lines", + content: `# This is a comment +VAR_A=first + +# Another comment +VAR_B=second +`, + expected: []DotenvKeyValue{ + {Key: "VAR_A", Value: "first"}, + {Key: "VAR_B", Value: "second"}, + }, + }, + { + name: "handles export prefix", + content: `export VAR_A=first +VAR_B=second +export VAR_C=third`, + expected: []DotenvKeyValue{ + {Key: "VAR_A", Value: "first"}, + {Key: "VAR_B", Value: "second"}, + {Key: "VAR_C", Value: "third"}, + }, + }, + { + name: "handles quoted values", + content: `VAR_A="quoted value" +VAR_B='single quoted' +VAR_C=unquoted`, + expected: []DotenvKeyValue{ + {Key: "VAR_A", Value: "quoted value"}, + {Key: "VAR_B", Value: "single quoted"}, + {Key: "VAR_C", Value: "unquoted"}, + }, + }, + { + name: "first occurrence wins for duplicates", + content: `VAR_A=first +VAR_A=second`, + expected: []DotenvKeyValue{ + {Key: "VAR_A", Value: "second"}, // godotenv takes last value + }, + }, + { + name: "handles colon separator", + content: `VAR_A: first +VAR_B=second`, + expected: []DotenvKeyValue{ + {Key: "VAR_A", Value: "first"}, + {Key: "VAR_B", Value: "second"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create a temporary file with the test content + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") + err := os.WriteFile(envFile, []byte(tt.content), 0o644) + require.NoError(t, err) + + // Read the file + result, err := ReadDotenvOrdered(envFile) + require.NoError(t, err) + + // Verify the result + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestReadDotenvOrderedConsistency(t *testing.T) { + t.Parallel() + + // Create a file with many variables to ensure order is consistent + content := `VAR_01=value01 +VAR_02=value02 +VAR_03=value03 +VAR_04=value04 +VAR_05=value05 +VAR_06=value06 +VAR_07=value07 +VAR_08=value08 +VAR_09=value09 +VAR_10=value10` + + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") + err := os.WriteFile(envFile, []byte(content), 0o644) + require.NoError(t, err) + + // Read multiple times and ensure the order is always the same + var firstResult []DotenvKeyValue + for i := 0; i < 100; i++ { + result, err := ReadDotenvOrdered(envFile) + require.NoError(t, err) + + if firstResult == nil { + firstResult = result + } else { + assert.Equal(t, firstResult, result, "Order should be consistent across reads (iteration %d)", i) + } + } + + // Verify expected order + expectedKeys := []string{ + "VAR_01", "VAR_02", "VAR_03", "VAR_04", "VAR_05", + "VAR_06", "VAR_07", "VAR_08", "VAR_09", "VAR_10", + } + for i, kv := range firstResult { + assert.Equal(t, expectedKeys[i], kv.Key) + } +} diff --git a/variables.go b/variables.go index 9e40edb2b3..56b10c166d 100644 --- a/variables.go +++ b/variables.go @@ -7,14 +7,13 @@ import ( "path/filepath" "strings" - "github.com/joho/godotenv" - "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/execext" "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" ) @@ -148,13 +147,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.ReadDotenvOrdered(dotEnvPath) if err != nil { return nil, err } - for key, value := range envs { - if _, ok := dotenvEnvs.Get(key); !ok { - dotenvEnvs.Set(key, ast.Var{Value: value}) + for _, kv := range envs { + if _, ok := dotenvEnvs.Get(kv.Key); !ok { + dotenvEnvs.Set(kv.Key, ast.Var{Value: kv.Value}) } } }