Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 80 additions & 4 deletions taskfile/dotenv.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package taskfile

import (
"bufio"
"fmt"
"os"
"strings"

"github.com/joho/godotenv"

Expand All @@ -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}
Expand All @@ -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})
}
}
}
Expand Down
148 changes: 148 additions & 0 deletions taskfile/dotenv_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 5 additions & 6 deletions variables.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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})
}
}
}
Expand Down