Skip to content

Commit eddf099

Browse files
committed
Evaluate env entries sequentially with global env context
- pass resolved env into `sh` evaluation so later keys can use earlier ones - allow command-level `env` scripts to read already-resolved global env - document order-dependent behavior and add unit + bats coverage
1 parent 9aa7f75 commit eddf099

8 files changed

Lines changed: 173 additions & 5 deletions

File tree

docs/docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ title: Changelog
88
* `[Added]` Show similar command suggestions on typos.
99
* `[Changed]` Exit code 2 on unknown command.
1010
* `[Removed]` Drop deprecated `eval_env` directive. Use `env` with `sh` execution mode instead.
11+
* `[Fixed]` Evaluate `env` entries sequentially so `sh` values can reference previously resolved env keys (including global env for command-level env).
1112

1213
## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59)
1314

docs/docs/config.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ Specify global env for all commands.
7777

7878
Env can be declared as static value or with execution mode:
7979

80+
Env entries are evaluated sequentially in declaration order. `sh` can reference variables that are declared earlier in the same `env` block.
81+
8082
Example:
8183

8284
```yaml
@@ -653,6 +655,8 @@ Env is as simple as it sounds. Define additional env for a command:
653655

654656
Env can be declared as static value or with execution mode:
655657

658+
Command `env` entries are also evaluated sequentially in declaration order. During command env evaluation, values from global `env` are available too.
659+
656660
Example:
657661

658662
```yaml

internal/config/config/command.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ func (c *Command) UnmarshalYAML(unmarshal func(interface{}) error) error {
127127
}
128128

129129
func (c *Command) GetEnv(cfg Config) (map[string]string, error) {
130-
if err := c.Env.Execute(cfg); err != nil {
130+
if err := c.Env.Execute(cfg, cfg.GetEnv()); err != nil {
131131
return nil, err
132132
}
133133

internal/config/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ func (c *Config) GetEnv() map[string]string {
287287
// SetupEnv must be called once. It is not intended to be called
288288
// multiple times hence does not have mutex.
289289
func (c *Config) SetupEnv() error {
290-
if err := c.Env.Execute(*c); err != nil {
290+
if err := c.Env.Execute(*c, nil); err != nil {
291291
return err
292292
}
293293

internal/config/config/env.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"errors"
55
"fmt"
6+
"os"
67
"os/exec"
78
"slices"
89
"strings"
@@ -187,9 +188,25 @@ func (e *Envs) Set(key string, value Env) {
187188
e.Mapping[key] = value
188189
}
189190

191+
func convertEnvMapToList(envMap map[string]string) []string {
192+
if len(envMap) == 0 {
193+
return []string{}
194+
}
195+
196+
envList := make([]string, 0, len(envMap))
197+
for k, v := range envMap {
198+
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
199+
}
200+
201+
return envList
202+
}
203+
190204
// eval env value and trim result string.
191-
func executeScript(shell string, script string) (string, error) {
205+
func executeScript(shell string, script string, envMap map[string]string) (string, error) {
192206
cmd := exec.Command(shell, "-c", script)
207+
envList := os.Environ()
208+
envList = append(envList, convertEnvMapToList(envMap)...)
209+
cmd.Env = envList
193210

194211
out, err := cmd.Output()
195212
if err != nil {
@@ -202,7 +219,7 @@ func executeScript(shell string, script string) (string, error) {
202219

203220
// Execute executes env entries for sh scrips and calculate checksums
204221
// It is lazy and caches data on first call.
205-
func (e *Envs) Execute(cfg Config) error {
222+
func (e *Envs) Execute(cfg Config, baseEnv map[string]string) error {
206223
if e == nil {
207224
return nil
208225
}
@@ -211,10 +228,15 @@ func (e *Envs) Execute(cfg Config) error {
211228
return nil
212229
}
213230

231+
resolvedEnv := cloneMap(baseEnv)
232+
if resolvedEnv == nil {
233+
resolvedEnv = make(map[string]string)
234+
}
235+
214236
for _, key := range e.Keys {
215237
env := e.Mapping[key]
216238
if env.Sh != "" {
217-
result, err := executeScript(cfg.Shell, env.Sh)
239+
result, err := executeScript(cfg.Shell, env.Sh, resolvedEnv)
218240
if err != nil {
219241
return err
220242
}
@@ -229,6 +251,8 @@ func (e *Envs) Execute(cfg Config) error {
229251
env.Value = result
230252
e.Mapping[key] = env
231253
}
254+
255+
resolvedEnv[key] = env.Value
232256
}
233257
e.ready = true
234258

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package config
2+
3+
import "testing"
4+
5+
func TestEnvsExecute(t *testing.T) {
6+
cfg := Config{
7+
Shell: "bash",
8+
WorkDir: ".",
9+
}
10+
11+
t.Run("resolves env entries sequentially", func(t *testing.T) {
12+
envs := &Envs{}
13+
envs.Set("ENGINE", Env{Name: "ENGINE", Value: "docker"})
14+
envs.Set("COMPOSE", Env{Name: "COMPOSE", Sh: `echo "${ENGINE}-compose"`})
15+
16+
err := envs.Execute(cfg, nil)
17+
if err != nil {
18+
t.Fatalf("unexpected execute error: %s", err)
19+
}
20+
21+
if got := envs.Mapping["COMPOSE"].Value; got != "docker-compose" {
22+
t.Fatalf("expected COMPOSE=docker-compose, got %q", got)
23+
}
24+
})
25+
26+
t.Run("uses base env for sh evaluation", func(t *testing.T) {
27+
envs := &Envs{}
28+
envs.Set("COMPOSE", Env{Name: "COMPOSE", Sh: `echo "${ENGINE}-compose"`})
29+
30+
err := envs.Execute(cfg, map[string]string{"ENGINE": "docker"})
31+
if err != nil {
32+
t.Fatalf("unexpected execute error: %s", err)
33+
}
34+
35+
if got := envs.Mapping["COMPOSE"].Value; got != "docker-compose" {
36+
t.Fatalf("expected COMPOSE=docker-compose, got %q", got)
37+
}
38+
})
39+
40+
t.Run("resolved lets env overrides process env", func(t *testing.T) {
41+
t.Setenv("ENGINE", "podman")
42+
43+
envs := &Envs{}
44+
envs.Set("ENGINE", Env{Name: "ENGINE", Value: "docker"})
45+
envs.Set("COMPOSE", Env{Name: "COMPOSE", Sh: `echo "${ENGINE}-compose"`})
46+
47+
err := envs.Execute(cfg, nil)
48+
if err != nil {
49+
t.Fatalf("unexpected execute error: %s", err)
50+
}
51+
52+
if got := envs.Mapping["COMPOSE"].Value; got != "docker-compose" {
53+
t.Fatalf("expected COMPOSE=docker-compose, got %q", got)
54+
}
55+
})
56+
57+
t.Run("keeps cached values after first execution", func(t *testing.T) {
58+
envs := &Envs{}
59+
envs.Set("COMPOSE", Env{Name: "COMPOSE", Sh: `echo "${ENGINE}-compose"`})
60+
61+
err := envs.Execute(cfg, map[string]string{"ENGINE": "docker"})
62+
if err != nil {
63+
t.Fatalf("unexpected execute error: %s", err)
64+
}
65+
66+
err = envs.Execute(cfg, map[string]string{"ENGINE": "podman"})
67+
if err != nil {
68+
t.Fatalf("unexpected execute error: %s", err)
69+
}
70+
71+
if got := envs.Mapping["COMPOSE"].Value; got != "docker-compose" {
72+
t.Fatalf("expected cached COMPOSE=docker-compose, got %q", got)
73+
}
74+
})
75+
}

tests/env_dependency.bats

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
load test_helpers
2+
3+
setup() {
4+
load "${BATS_UTILS_PATH}/bats-support/load.bash"
5+
load "${BATS_UTILS_PATH}/bats-assert/load.bash"
6+
cd ./tests/env_dependency
7+
}
8+
9+
@test "env_dependency: global env sh can use previously resolved global env" {
10+
run lets global-env-dependency
11+
assert_success
12+
assert_line --index 0 "GLOBAL_COMPOSE=docker-compose"
13+
}
14+
15+
@test "env_dependency: command env sh can use previously resolved command env" {
16+
run lets command-env-dependency
17+
assert_success
18+
assert_line --index 0 "COMMAND_COMPOSE=podman-compose"
19+
}
20+
21+
@test "env_dependency: command env sh can use global env" {
22+
run lets command-env-uses-global
23+
assert_success
24+
assert_line --index 0 "COMMAND_COMPOSE=docker-compose"
25+
}
26+
27+
@test "env_dependency: forward references stay unresolved with sequential evaluation" {
28+
run env -u LETS_TEST_FORWARD_VAR lets command-forward-reference
29+
assert_success
30+
assert_line --index 0 "COMMAND_COMPOSE="
31+
assert_line --index 1 "LETS_TEST_FORWARD_VAR=from-command-env"
32+
}

tests/env_dependency/lets.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
shell: bash
2+
3+
env:
4+
ENGINE: docker
5+
GLOBAL_COMPOSE:
6+
sh: echo "${ENGINE}-compose"
7+
8+
commands:
9+
global-env-dependency:
10+
cmd: echo "GLOBAL_COMPOSE=${GLOBAL_COMPOSE}"
11+
12+
command-env-dependency:
13+
env:
14+
ENGINE: podman
15+
COMMAND_COMPOSE:
16+
sh: echo "${ENGINE}-compose"
17+
cmd: echo "COMMAND_COMPOSE=${COMMAND_COMPOSE}"
18+
19+
command-env-uses-global:
20+
env:
21+
COMMAND_COMPOSE:
22+
sh: echo "${ENGINE}-compose"
23+
cmd: echo "COMMAND_COMPOSE=${COMMAND_COMPOSE}"
24+
25+
command-forward-reference:
26+
env:
27+
COMMAND_COMPOSE:
28+
sh: echo "${LETS_TEST_FORWARD_VAR}"
29+
LETS_TEST_FORWARD_VAR: from-command-env
30+
cmd: |
31+
echo "COMMAND_COMPOSE=${COMMAND_COMPOSE}"
32+
echo "LETS_TEST_FORWARD_VAR=${LETS_TEST_FORWARD_VAR}"

0 commit comments

Comments
 (0)