Implement .env file preload (#867)

* implement .env parsing and expanding

* implement .env parsing and expanding

* reload env on reload/rebuild as well

* default to empty env file. remove cyclical dependency stuff

* default to empty env file. remove cyclical dependency stuff

* use godotenv to parse, dont override global vars

* implement multiple env file support

* update readme and example config

* update readme some more

* fix typo

* dce in util.go

* Avoid loading env files on cancelled runs

* Clean up env load linting

---------

Co-authored-by: sirkostya009 <kosta.tovstik@gmail.com>
This commit is contained in:
xiantang
2026-01-19 22:10:51 +08:00
committed by GitHub
parent 404e2a83c9
commit f7485ca491
7 changed files with 150 additions and 0 deletions
+12
View File
@@ -25,6 +25,7 @@ Note: This tool has nothing to do with hot-deploy for production.
- Support excluding subdirectories
- Allow watching new directories after Air started
- Better building process
- Automatic `.env` file loading
### Overwrite specify configuration from arguments
@@ -227,6 +228,17 @@ entrypoint = [
]
```
### Environment Files
Air can automatically load environment variables from `.env` files before both building and running.
```toml
# Loads .env.development and then .env files.
# Values in the lattermost file overwrite any preceding ones.
# Does not overwrite variables that were present before running air.
env_files = [".env.development", ".env"]
```
### Docker Compose
```yaml
+4
View File
@@ -7,6 +7,10 @@
root = "."
tmp_dir = "tmp"
# Remove to not load any files whatsoever
# Non-existing files are safely ignored
env_files = [".env"]
[build]
# Array of commands to run before each build
pre_cmd = ["echo 'hello air' > pre_cmd.txt"]
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/fatih/color v1.18.0
github.com/fsnotify/fsnotify v1.9.0
github.com/gohugoio/hugo v0.149.1
github.com/joho/godotenv v1.5.1
github.com/pelletier/go-toml v1.9.5
github.com/stretchr/testify v1.11.1
)
+2
View File
@@ -94,6 +94,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+2
View File
@@ -31,6 +31,7 @@ type Config struct {
Root string `toml:"root" usage:"Working directory, . or absolute path, please note that the directories following must be under root"`
TmpDir string `toml:"tmp_dir" usage:"Temporary directory for air"`
TestDataDir string `toml:"testdata_dir"`
EnvFiles []string `toml:"env_files" usage:"Paths to .env files to load before build/run"`
Build cfgBuild `toml:"build"`
Color cfgColor `toml:"color"`
Log cfgLog `toml:"log"`
@@ -323,6 +324,7 @@ func defaultConfig() Config {
Root: ".",
TmpDir: "tmp",
TestDataDir: "testdata",
EnvFiles: []string{},
Build: build,
Color: color,
Log: log,
+76
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/gohugoio/hugo/watcher/filenotify"
"github.com/joho/godotenv"
)
// Engine ...
@@ -47,6 +48,12 @@ type Engine struct {
fileChecksums *checksumMap
ll sync.Mutex // lock for logger
// globalEnv stores the original env values before air modified them
// key:original value (empty string means it was unset)
globalEnv map[string]*string
// loadedEnv tracks env values that were set by the last env file load
loadedEnv map[string]string
}
// NewEngineWithConfig ...
@@ -79,6 +86,7 @@ func NewEngineWithConfig(cfg *Config, debugMode bool) (*Engine, error) {
exitCh: make(chan bool),
fileChecksums: &checksumMap{m: make(map[string]string)},
watchers: 0,
globalEnv: map[string]*string{},
}
return &e, nil
@@ -426,6 +434,72 @@ func (e *Engine) start() {
}
}
func (e *Engine) loadEnvFile() {
if len(e.config.EnvFiles) == 0 {
return
}
// assume refreshed env is as big as the loaded env
newEnv := make(map[string]string, len(e.loadedEnv))
for _, envPath := range e.config.EnvFiles {
if !filepath.IsAbs(envPath) {
envPath = filepath.Join(e.config.Root, envPath)
}
file, err := os.Open(envPath)
if err != nil {
if os.IsNotExist(err) {
e.mainDebug("env file %q does not exist, skipping", envPath)
} else {
e.runnerLog("failed to open env file %q: %s", envPath, err.Error())
}
continue
}
defer file.Close()
fileEnv, err := godotenv.Parse(file)
if err != nil {
e.runnerLog("failed to parse env file %q: %s", envPath, err.Error())
return
}
for k, v := range fileEnv {
if v, tracked := e.globalEnv[k]; !tracked {
origVal, exists := os.LookupEnv(k)
if exists {
// not used yet, but might be useful for a future "override" feature
e.globalEnv[k] = &origVal
continue // untracked env values are likely global - don't override them
}
// on first encounter of a key, if no global value exists, mark as nil so
// that on next load of .env file, globalEnv map value will not be overwritten
e.globalEnv[k] = nil
} else if tracked && v != nil {
// only set values from file if not already present in the environment
e.mainDebug("key %q already exists in the environment, skipping", k)
continue
}
if err := os.Setenv(k, v); err != nil {
e.runnerLog("failed to set env key %q: %s", k, err.Error())
}
newEnv[k] = v
}
}
// unset any keys that were removed from .env file,
// but ignore those that were set before air was run
for k := range e.loadedEnv {
if _, exists := newEnv[k]; !exists {
if orig := e.globalEnv[k]; orig == nil {
if err := os.Unsetenv(k); err != nil {
e.runnerLog("failed to restore env key %q: %s", k, err.Error())
}
}
}
}
e.loadedEnv = newEnv
}
func (e *Engine) buildRun() {
// Create this build's unique stop channel
myStopCh := make(chan struct{})
@@ -446,6 +520,8 @@ func (e *Engine) buildRun() {
default:
}
e.loadEnvFile()
var err error
if err = e.runPreCmd(); err != nil {
e.runnerLog("failed to execute pre_cmd: %s", err.Error())
+53
View File
@@ -1334,3 +1334,56 @@ func TestBuildRunRaceConditionRapidChanges(t *testing.T) {
// Clean up
<-e.buildRunCh
}
func TestEngineLoadEnvFile(t *testing.T) {
tmpDir := t.TempDir()
envPath := filepath.Join(tmpDir, ".env")
originalValue := "original_global_value"
t.Setenv("TEST_GLOBAL_VAR", originalValue)
const initialEnv = `TEST_VAR1=value1
TEST_VAR2=value2
TEST_GLOBAL_VAR=overridden_value
`
err := os.WriteFile(envPath, []byte(initialEnv), 0o644)
require.NoError(t, err)
cfg := defaultConfig()
cfg.Root = tmpDir
cfg.EnvFiles = []string{".env"}
engine, err := NewEngineWithConfig(&cfg, false)
require.NoError(t, err)
engine.loadEnvFile()
assert.Equal(t, "value1", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be set")
assert.Equal(t, "value2", os.Getenv("TEST_VAR2"), "TEST_VAR2 should be set")
assert.Equal(t, "original_global_value", os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should NOT be overridden")
// remove TEST_VAR2
const updatedEnv = `TEST_VAR1=updated_value1
TEST_GLOBAL_VAR=still_overridden
`
err = os.WriteFile(envPath, []byte(updatedEnv), 0o644)
require.NoError(t, err)
engine.loadEnvFile()
assert.Equal(t, "updated_value1", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be updated")
// since TEST_VAR2 only exists in environment thanks to air, it should get unset on removal
_, exists := os.LookupEnv("TEST_VAR2")
assert.False(t, exists, "TEST_VAR2 should be unset after removal from .env")
assert.Equal(t, "original_global_value", os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should NOT be overridden")
const finalEnv = `TEST_VAR1=final_value`
err = os.WriteFile(envPath, []byte(finalEnv), 0o644)
require.NoError(t, err)
engine.loadEnvFile()
assert.Equal(t, "final_value", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be final")
assert.Equal(t, originalValue, os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should be restored to original value")
}