From 8a384c108634c65ede87600ea00c62d5a9dae4bd Mon Sep 17 00:00:00 2001 From: Aidan <77929240+Aidan-Wallace@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:48:45 -0500 Subject: [PATCH] feat: Load all environment files automatically --- .github/workflows/ci.yml | 2 +- README.md | 37 ++++++++++++++++ autoload/autoload.go | 2 +- fixtures/.env.production | 1 + fixtures/.env.testing | 1 + fixtures/config.local | 1 + fixtures/testing.env | 1 + godotenv.go | 89 ++++++++++++++++++++++++++++++++++++++ godotenv_test.go | 93 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 fixtures/.env.production create mode 100644 fixtures/.env.testing create mode 100644 fixtures/config.local create mode 100644 fixtures/testing.env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dbbdf6..c839b3c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - go: [ '1.20', '1.19', '1.18', '1.17', '1.16' ] + go: [ '1.25', '1.24', '1.23', '1.22', '1.21' ] os: [ ubuntu-latest, macOS-latest, windows-latest ] name: ${{ matrix.os }} Go ${{ matrix.go }} Tests steps: diff --git a/README.md b/README.md index bfbe66a..e7d77c3 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,43 @@ godotenv.Load() // The Original .env If you need to, you can also use `godotenv.Overload()` to defy this convention and overwrite existing envs instead of only supplanting them. Use with caution. +### Loading All Matching Files + +If you want to automatically load all files matching a pattern, you can use `godotenv.LoadAll()` or `godotenv.OverloadAll()`. + +By default, these functions will load all files matching the `.env` pattern in the current directory. This includes files like `.env`, `.env.local`, `testing.env`, etc. + +```go +// Load all .env files without overwriting existing environment variables +err := godotenv.LoadAll() +if err != nil { + log.Fatal("Error loading .env files") +} +``` + +You can also provide custom regex patterns to match specific files: + +```go +// Load only .env.production and config.* files +err := godotenv.LoadAll(`^\.env\.production$`, `^config\..*`) +if err != nil { + log.Fatal("Error loading env files") +} +``` + +If you need to overwrite existing environment variables, use `godotenv.OverloadAll()`: + +```go +// Load all .env files and overwrite existing environment variables +err := godotenv.OverloadAll() +if err != nil { + log.Fatal("Error loading .env files") +} + +// Or with custom patterns +err := godotenv.OverloadAll(`^\.env\.production$`, `^config\..*`) +``` + ### Command Mode Assuming you've installed the command as above and you've got `$GOPATH/bin` in your `$PATH` diff --git a/autoload/autoload.go b/autoload/autoload.go index fbcd2bd..e1dd7ab 100644 --- a/autoload/autoload.go +++ b/autoload/autoload.go @@ -11,5 +11,5 @@ package autoload import "github.com/joho/godotenv" func init() { - godotenv.Load() + godotenv.LoadAll() } diff --git a/fixtures/.env.production b/fixtures/.env.production new file mode 100644 index 0000000..25f8687 --- /dev/null +++ b/fixtures/.env.production @@ -0,0 +1 @@ +PROD_VAR=prodvalue diff --git a/fixtures/.env.testing b/fixtures/.env.testing new file mode 100644 index 0000000..50daa75 --- /dev/null +++ b/fixtures/.env.testing @@ -0,0 +1 @@ +LOCAL_VAR=localvalue diff --git a/fixtures/config.local b/fixtures/config.local new file mode 100644 index 0000000..9172ae3 --- /dev/null +++ b/fixtures/config.local @@ -0,0 +1 @@ +CONFIG_VAR=configvalue diff --git a/fixtures/testing.env b/fixtures/testing.env new file mode 100644 index 0000000..91f72fc --- /dev/null +++ b/fixtures/testing.env @@ -0,0 +1 @@ +TEST_VAR=testvalue diff --git a/godotenv.go b/godotenv.go index 61b0ebb..6942999 100644 --- a/godotenv.go +++ b/godotenv.go @@ -19,12 +19,14 @@ import ( "io" "os" "os/exec" + "regexp" "sort" "strconv" "strings" ) const doubleQuoteSpecialChars = "\\\n\r\"!$`" +const envRegex = `^.*\.env(\..+)?$` // Parse reads an env file from io.Reader, returning a map of keys and values. func Parse(r io.Reader) (map[string]string, error) { @@ -60,6 +62,26 @@ func Load(filenames ...string) (err error) { return } +// LoadAll will read all env files matching the pattern and load them into ENV for this process. +// +// Call this function as close as possible to the start of your program (ideally in main). +// +// If you call LoadAll without any args it will default to loading all files matching the .env pattern. +// This includes files like .env, .env.testing, testing.env, etc. +// +// You can otherwise provide custom regex patterns to match specific files like: +// +// godotenv.LoadAll(`^\.env\.production$`, `^config\..*`) +// +// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env files to set dev vars or sensible defaults. +func LoadAll(patterns ...string) (err error) { + filenames, err := allFilenames(patterns...) + if err != nil { + return err + } + return Load(filenames...) +} + // Overload will read your env file(s) and load them into ENV for this process. // // Call this function as close as possible to the start of your program (ideally in main). @@ -83,6 +105,26 @@ func Overload(filenames ...string) (err error) { return } +// OverloadAll will read all env files matching the pattern and load them into ENV for this process. +// +// Call this function as close as possible to the start of your program (ideally in main). +// +// If you call OverloadAll without any args it will default to loading all files matching the .env pattern. +// This includes files like .env, .env.testing, testing.env, etc. +// +// You can otherwise provide custom regex patterns to match specific files like: +// +// godotenv.OverloadAll(`^\.env\.production$`, `^config\..*`) +// +// It's important to note this WILL OVERRIDE an env variable that already exists - consider the .env files to forcefully set all vars. +func OverloadAll(patterns ...string) (err error) { + filenames, err := allFilenames(patterns...) + if err != nil { + return err + } + return Overload(filenames...) +} + // Read all env (with same file loading semantics as Load) but return values as // a map rather than automatically writing values into env func Read(filenames ...string) (envMap map[string]string, err error) { @@ -181,6 +223,53 @@ func filenamesOrDefault(filenames []string) []string { return filenames } +func allFilenames(patterns ...string) ([]string, error) { + if len(patterns) == 0 { + patterns = []string{envRegex} + } + + filenames, err := getEnvFiles(patterns...) + if err != nil { + return nil, err + } + + if len(filenames) == 0 { + return []string{".env"}, nil + } + + return filenames, nil +} + +func getEnvFiles(patterns ...string) ([]string, error) { + entries, err := os.ReadDir(".") + if err != nil { + return nil, err + } + + compiledPatterns := make([]*regexp.Regexp, len(patterns)) + for i, p := range patterns { + compiledPatterns[i] = regexp.MustCompile(p) + } + + var envFiles []string + for _, entry := range entries { + if !entry.IsDir() { + patternMatch(compiledPatterns, entry.Name(), &envFiles) + } + } + + return envFiles, nil +} + +func patternMatch(compiledPatterns []*regexp.Regexp, filename string, envFiles *[]string) { + for _, pattern := range compiledPatterns { + if pattern.MatchString(filename) { + *envFiles = append(*envFiles, filename) + break + } + } +} + func loadFile(filename string, overload bool) error { envMap, err := readFile(filename) if err != nil { diff --git a/godotenv_test.go b/godotenv_test.go index c07e6f3..55ed8bd 100644 --- a/godotenv_test.go +++ b/godotenv_test.go @@ -53,6 +53,51 @@ func TestLoadWithNoArgsLoadsDotEnv(t *testing.T) { } } +func TestLoadAllWithNoArgsLoadsDotEnv(t *testing.T) { + err := LoadAll() + pathError := err.(*os.PathError) + if pathError == nil || pathError.Op != "open" || pathError.Path != ".env" { + t.Errorf("Didn't try and open .env by default") + } +} + +func TestLoadAllWithCustomPattern(t *testing.T) { + os.Clearenv() + + originalDir, _ := os.Getwd() + os.Chdir("fixtures") + defer os.Chdir(originalDir) + + err := LoadAll(`^\.env\.testing$`) + if err != nil { + t.Errorf("Expected LoadAll to succeed, got error: %v", err) + } + + if os.Getenv("LOCAL_VAR") != "localvalue" { + t.Errorf("Expected LOCAL_VAR to be 'localvalue', got '%s'", os.Getenv("LOCAL_VAR")) + } +} + +func TestLoadAllWithMultiplePatterns(t *testing.T) { + os.Clearenv() + + originalDir, _ := os.Getwd() + os.Chdir("fixtures") + defer os.Chdir(originalDir) + + err := LoadAll(`^\.env\.production$`, `^testing\.env$`) + if err != nil { + t.Errorf("Expected LoadAll to succeed, got error: %v", err) + } + + if os.Getenv("PROD_VAR") != "prodvalue" { + t.Errorf("Expected PROD_VAR to be 'prodvalue', got '%s'", os.Getenv("PROD_VAR")) + } + if os.Getenv("TEST_VAR") != "testvalue" { + t.Errorf("Expected TEST_VAR to be 'testvalue', got '%s'", os.Getenv("TEST_VAR")) + } +} + func TestOverloadWithNoArgsOverloadsDotEnv(t *testing.T) { err := Overload() pathError := err.(*os.PathError) @@ -61,6 +106,54 @@ func TestOverloadWithNoArgsOverloadsDotEnv(t *testing.T) { } } +func TestOverloadAllWithNoArgsOverloadsDotEnv(t *testing.T) { + err := OverloadAll() + pathError := err.(*os.PathError) + if pathError == nil || pathError.Op != "open" || pathError.Path != ".env" { + t.Errorf("Didn't try and open .env by default") + } +} + +func TestOverloadAllWithCustomPattern(t *testing.T) { + os.Clearenv() + os.Setenv("LOCAL_VAR", "original") + + originalDir, _ := os.Getwd() + os.Chdir("fixtures") + defer os.Chdir(originalDir) + + err := OverloadAll(`^\.env\.testing$`) + if err != nil { + t.Errorf("Expected OverloadAll to succeed, got error: %v", err) + } + + if os.Getenv("LOCAL_VAR") != "localvalue" { + t.Errorf("Expected LOCAL_VAR to be overloaded to 'localvalue', got '%s'", os.Getenv("LOCAL_VAR")) + } +} + +func TestOverloadAllWithMultiplePatterns(t *testing.T) { + os.Clearenv() + os.Setenv("PROD_VAR", "original_prod") + os.Setenv("TEST_VAR", "original_test") + + originalDir, _ := os.Getwd() + os.Chdir("fixtures") + defer os.Chdir(originalDir) + + err := OverloadAll(`^\.env\.production$`, `^testing\.env$`) + if err != nil { + t.Errorf("Expected OverloadAll to succeed, got error: %v", err) + } + + if os.Getenv("PROD_VAR") != "prodvalue" { + t.Errorf("Expected PROD_VAR to be overloaded to 'prodvalue', got '%s'", os.Getenv("PROD_VAR")) + } + if os.Getenv("TEST_VAR") != "testvalue" { + t.Errorf("Expected TEST_VAR to be overloaded to 'testvalue', got '%s'", os.Getenv("TEST_VAR")) + } +} + func TestLoadFileNotFound(t *testing.T) { err := Load("somefilethatwillneverexistever.env") if err == nil {