Skip to content

Commit cccbc25

Browse files
authored
Merge pull request #295 from lets-cli/feature/fix/env-sequential-eval
Fix sequential env evaluation and global context for command env
2 parents 2236cea + 709dccb commit cccbc25

8 files changed

Lines changed: 196 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: 26 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
@@ -89,6 +91,16 @@ env:
8991
checksum: [Readme.md, package.json]
9092
```
9193
94+
Reference previously declared env:
95+
96+
```yaml
97+
shell: bash
98+
env:
99+
ENGINE: docker
100+
COMPOSE:
101+
sh: echo "${ENGINE}-compose"
102+
```
103+
92104
### Global before
93105
94106
`key: before`
@@ -653,6 +665,8 @@ Env is as simple as it sounds. Define additional env for a command:
653665

654666
Env can be declared as static value or with execution mode:
655667

668+
Command `env` entries are also evaluated sequentially in declaration order. During command env evaluation, values from global `env` are available too.
669+
656670
Example:
657671

658672
```yaml
@@ -669,6 +683,18 @@ commands:
669683
cmd: go build -o lets *.go
670684
```
671685
686+
Reference previously declared command env:
687+
688+
```yaml
689+
commands:
690+
up:
691+
env:
692+
ENGINE: docker
693+
COMPOSE:
694+
sh: echo "${ENGINE}-compose"
695+
cmd: ${COMPOSE} up
696+
```
697+
672698
673699
### `checksum`
674700

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: 28 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,26 @@ 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+
// Append resolved env last so it overrides process env keys (Go 1.21+ cmd.Env dedup: last value wins).
209+
envList = append(envList, convertEnvMapToList(envMap)...)
210+
cmd.Env = envList
193211

194212
out, err := cmd.Output()
195213
if err != nil {
@@ -202,7 +220,7 @@ func executeScript(shell string, script string) (string, error) {
202220

203221
// Execute executes env entries for sh scrips and calculate checksums
204222
// It is lazy and caches data on first call.
205-
func (e *Envs) Execute(cfg Config) error {
223+
func (e *Envs) Execute(cfg Config, baseEnv map[string]string) error {
206224
if e == nil {
207225
return nil
208226
}
@@ -211,10 +229,15 @@ func (e *Envs) Execute(cfg Config) error {
211229
return nil
212230
}
213231

232+
resolvedEnv := cloneMap(baseEnv)
233+
if resolvedEnv == nil {
234+
resolvedEnv = make(map[string]string)
235+
}
236+
214237
for _, key := range e.Keys {
215238
env := e.Mapping[key]
216239
if env.Sh != "" {
217-
result, err := executeScript(cfg.Shell, env.Sh)
240+
result, err := executeScript(cfg.Shell, env.Sh, resolvedEnv)
218241
if err != nil {
219242
return err
220243
}
@@ -229,6 +252,8 @@ func (e *Envs) Execute(cfg Config) error {
229252
env.Value = result
230253
e.Mapping[key] = env
231254
}
255+
256+
resolvedEnv[key] = env.Value
232257
}
233258
e.ready = true
234259

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)