From 0f2d77c60956c52892fc1b2241724002d909d9fc Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 12 Aug 2026 16:04:48 -0700 Subject: [PATCH 1/4] Add `[cache(environment)]` attribute key --- src/attribute.rs | 7 +++++++ src/cache_key.rs | 2 +- src/error.rs | 11 +++++++++++ src/recipe.rs | 32 +++++++++++++++++++++++++++++++- src/unresolved_recipe.rs | 8 ++++++++ 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index c88b2a3d7b..c56754775a 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -38,6 +38,7 @@ pub(crate) enum Attribute<'src> { value: Option>, }, Cache { + environment: Option>, extra: Option>, inputs: Option>, outputs: Option>, @@ -228,6 +229,8 @@ impl<'src> Attribute<'src> { AttributeKind::Arg => Self::new_arg(name, arguments, &mut keyword_arguments)?, AttributeKind::Android => Self::Android, AttributeKind::Cache => Self::Cache { + environment: Self::remove_required(&mut keyword_arguments, "environment")? + .map(|(_key, expression)| expression), extra: Self::remove_required(&mut keyword_arguments, "extra")? .map(|(_key, expression)| expression), inputs: Self::remove_required(&mut keyword_arguments, "inputs")? @@ -602,11 +605,15 @@ impl Display for Attribute<'_> { | Self::Unix | Self::Windows => {} Self::Cache { + environment, extra, inputs, outputs, } => { let mut arguments = Vec::new(); + if let Some(environment) = environment { + arguments.push(format!("environment={environment}")); + } if let Some(extra) = extra { arguments.push(format!("extra={extra}")); } diff --git a/src/cache_key.rs b/src/cache_key.rs index 9639eb78cc..c5e4f1932e 100644 --- a/src/cache_key.rs +++ b/src/cache_key.rs @@ -3,7 +3,7 @@ use super::*; #[derive(Serialize)] pub(crate) struct CacheKey<'a> { pub(crate) body: &'a [String], - pub(crate) environment: &'a Environment, + pub(crate) environment: BTreeMap>, pub(crate) executor: &'a Executor<'a>, pub(crate) extension: Option<&'a str>, pub(crate) extra: Option, diff --git a/src/error.rs b/src/error.rs index 91176964c4..f2ba895dc2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -145,6 +145,10 @@ pub(crate) enum Error<'src> { parameter: &'src str, recipe: &'src str, }, + EnvVarUnicode { + name: String, + value: OsString, + }, EvalUnknownSubmodule { component: String, suggestion: Option>, @@ -772,6 +776,13 @@ impl ColorDisplay for Error<'_> { let editor = editor.to_string_lossy(); write!(f, "editor `{editor}` failed: {status}")?; } + EnvVarUnicode { name, value } => { + write!( + f, + "environment variable `{name}` not unicode: `{}`", + value.to_string_lossy(), + )?; + } EmptyListArgument { parameter, recipe } => { write!( f, diff --git a/src/recipe.rs b/src/recipe.rs index b0238821a3..345b4acad5 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -568,6 +568,7 @@ impl<'src> Recipe<'src> { let (cache_lock, outputs) = if !config.no_cache && let Some(Attribute::Cache { + environment: environment_attribute, extra, inputs, outputs, @@ -578,6 +579,35 @@ impl<'src> Recipe<'src> { None => env::current_dir().map_err(|source| Error::CurrentDirectory { source })?, }; + let environment_attribute = environment_attribute + .as_ref() + .map(|environment| evaluator.evaluate_value(environment)) + .transpose()?; + + let environment = if let Some(names) = environment_attribute { + let mut variables = BTreeMap::new(); + + for name in names { + let value = if let Some(value) = environment.variables.get(&name) { + value.clone() + } else { + match env::var(&name) { + Err(env::VarError::NotPresent) => None, + Err(env::VarError::NotUnicode(value)) => { + return Err(Error::EnvVarUnicode { name, value }); + } + Ok(value) => Some(value), + } + }; + + variables.insert(name, value); + } + + variables + } else { + environment.variables.clone() + }; + let extra = extra .as_ref() .map(|extra| evaluator.evaluate_value(extra)) @@ -608,7 +638,7 @@ impl<'src> Recipe<'src> { let key = CacheKey { body: &evaluated_lines, - environment: &environment, + environment, executor: &executor, extension, extra, diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs index 38afd26ea1..a59fe5e62a 100644 --- a/src/unresolved_recipe.rs +++ b/src/unresolved_recipe.rs @@ -75,10 +75,18 @@ impl<'src> UnresolvedRecipe<'src> { } } Attribute::Cache { + environment, extra, inputs, outputs, } => { + if let Some(environment) = environment { + variable_resolver.resolve_expression( + environment, + ¶meters, + &mut variable_references, + )?; + } if let Some(extra) = extra { variable_resolver.resolve_expression(extra, ¶meters, &mut variable_references)?; } From a3de79ed4e92a79068133523ee8167561d6a7817 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 12 Aug 2026 16:07:11 -0700 Subject: [PATCH 2/4] Test that environment variables may be removed from cache key --- tests/cache.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/cache.rs b/tests/cache.rs index 0fb666ae48..5fadf0a8d2 100644 --- a/tests/cache.rs +++ b/tests/cache.rs @@ -213,6 +213,31 @@ fn unexported_variable_does_not_invalidate_cache() { .success(); } +#[test] +fn environment_varaibles_may_be_removed_from_cache_key() { + Test::new() + .justfile( + " + set lists + + export value := 'default' + + [cache(environment=[])] + [script] + foo: + echo $value + ", + ) + .unstable() + .args(["value=bar", "foo"]) + .stdout("bar\n") + .success() + .test() + .unstable() + .args(["value=baz", "foo"]) + .success(); +} + #[test] fn interpreter_invalidates_cache() { Test::new() From 3b095f0558d8f11faacc42e6bf07286bfdb49351 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 12 Aug 2026 16:11:28 -0700 Subject: [PATCH 3/4] Test that variables may be removed from cache key --- tests/cache.rs | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/tests/cache.rs b/tests/cache.rs index 5fadf0a8d2..56a1f47925 100644 --- a/tests/cache.rs +++ b/tests/cache.rs @@ -180,12 +180,12 @@ fn environment_invalidates_cache() { ", ) .unstable() - .args(["value=bar", "foo"]) + .arg("value=bar") .stdout("bar\n") .success() .test() .unstable() - .args(["value=baz", "foo"]) + .arg("value=baz") .stdout("baz\n") .success(); } @@ -204,17 +204,17 @@ fn unexported_variable_does_not_invalidate_cache() { ", ) .unstable() - .args(["value=bar", "foo"]) + .arg("value=bar") .stdout("bar\n") .success() .test() .unstable() - .args(["value=baz", "foo"]) + .arg("value=baz") .success(); } #[test] -fn environment_varaibles_may_be_removed_from_cache_key() { +fn environment_variables_may_be_removed_from_cache_key() { Test::new() .justfile( " @@ -229,12 +229,35 @@ fn environment_varaibles_may_be_removed_from_cache_key() { ", ) .unstable() - .args(["value=bar", "foo"]) + .arg("value=bar") .stdout("bar\n") .success() .test() .unstable() - .args(["value=baz", "foo"]) + .arg("value=baz") + .success(); +} + +#[test] +fn environment_variables_may_be_added_to_cache_key() { + Test::new() + .justfile( + " + set lists + [cache(environment=['value'])] + [script] + foo: + echo $value + ", + ) + .unstable() + .env("value", "bar") + .stdout("bar\n") + .success() + .test() + .unstable() + .env("value", "baz") + .stdout("baz\n") .success(); } @@ -346,16 +369,16 @@ fn extra_invalidates_cache() { ", ) .unstable() - .args(["value=a", "foo"]) + .arg("value=a") .stdout("bar\n") .success() .test() .unstable() - .args(["value=a", "foo"]) + .arg("value=a") .success() .test() .unstable() - .args(["value=b", "foo"]) + .arg("value=b") .stdout("bar\n") .success(); } From bb9dc1e7ebffd448587c19de5b1b22525b3170ed Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 12 Aug 2026 16:18:31 -0700 Subject: [PATCH 4/4] Test that environment value may be expression --- tests/cache.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/cache.rs b/tests/cache.rs index 56a1f47925..90635266a7 100644 --- a/tests/cache.rs +++ b/tests/cache.rs @@ -261,6 +261,30 @@ fn environment_variables_may_be_added_to_cache_key() { .success(); } +#[test] +fn environment_may_be_expression() { + Test::new() + .justfile( + " + set lists + name := 'value' + [cache(environment=[name])] + [script] + foo: + echo $value + ", + ) + .unstable() + .env("value", "bar") + .stdout("bar\n") + .success() + .test() + .unstable() + .env("value", "baz") + .stdout("baz\n") + .success(); +} + #[test] fn interpreter_invalidates_cache() { Test::new() @@ -1179,6 +1203,30 @@ fn prints_cache_key() { .success(); } +#[test] +fn cache_environment_variables_are_resolved() { + Test::new() + .justfile( + " + [cache(environment = undefined)] + [script('sh')] + foo: + echo bar + ", + ) + .unstable() + .stderr( + " + error: variable `undefined` not defined + ——▶ justfile:1:22 + │ + 1 │ [cache(environment = undefined)] + │ ^^^^^^^^^ + ", + ) + .failure(); +} + #[test] fn cache_extra_variables_are_resolved() { Test::new()