Repository: casey/just (2025-06-20T13:01:15Z)

Issue:
Option for .env variables to override existing
I have run into a use case were I would like the variables defined in a `.env` file to override
any already defined variables.

The case is in my nix dotfiles configuration. I would like the option to override the `$USER` env
variable when I build and install the configuration depending on the machine I am installing it on.

Having a setting in the justfile would be helpful

```
# Controls whether the variables defined in a dotenv file will override already defined variables
set dotenv-override := true
```

(name `dotenv-override` in similar ideas in #469)


Discussion:
I think this would definitely be a welcome feature.

Another way this could be implemented, which would allow a little more granularity, is by adding a `dotenv(…)` function, so you could do:

```
export USER := dotenv('USER')
```
Having a function like `dotenv()` would be useful. I think it would work nicely with `env_var()`. Would my assumption be
correct about the order of priority:

`dotenv` would use the value defined in `.env` first and then the user's env while `env_var` would use the users env
first then check the `.env` file.

If so that would be an easy way for the user to decide what should take priority.

Yup, that's right.
I started to look into the code to see how to implement this and see that if this change was made it would break the current behaviour of `env_var`. Currently `env_var` checks `.env` first then `std::env`. Current function for reference:

```rust
fn env_var(context: &FunctionContext, key: &str) -> Result<String, String> {
  use std::env::VarError::*;

  if let Some(value) = context.dotenv.get(key) {
    return Ok(value.clone());
  }

  match env::var(key) {
    Err(NotPresent) => Err(format!("environment variable `{}` not present", key)),
    Err(NotUnicode(os_string)) => Err(format!(
      "environment variable `{}` not unicode: {:?}",
      key, os_string
    )),
    Ok(value) => Ok(value),
  }
}
```
Ah, okay. I guess then you could use `env_var` to do what you want to do:

```
export USER := env_var('USER') # will check .env first
```
I created a test to see if this would work. It looks like this does not work. Might have something to do with the order of `env_var` and `load_dotenv.rs`.

```sh
➜ echo $USER
eden

➜ cat .env
USER=other

➜ cat justfile
export USER := env_var('USER')
check:
  @echo $USER

➜ just check
eden
```
Ah yeah, that's right. If you look at `load_dotenv`, it doesn't load variables that are already in the environment: 

```rust
use crate::common::*;

pub(crate) fn load_dotenv(
  working_directory: &Path,
) -> RunResult<'static, BTreeMap<String, String>> {
  // `dotenv::from_path_iter` should eventually be un-deprecated, see:
  // https://github.com/dotenv-rs/dotenv/issues/13
  #![allow(deprecated)]
  for directory in working_directory.ancestors() {
    let path = directory.join(".env");

    if path.is_file() {
      let iter = dotenv::from_path_iter(&path)?;
      let mut dotenv = BTreeMap::new();
      for result in iter {
        let (key, value) = result?;
        if env::var_os(&key).is_none() {
          dotenv.insert(key, value);
        }
      }
      return Ok(dotenv);
    }
  }

  Ok(BTreeMap::new())
}
```
Something that could be related is if `just` loaded `.env.*` files, like `.env.production` or `.env.local` or `.env.production.local` files, which overwrite the existing .env configuration:
https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
> load_dotenv, it doesn't load variables that are already in the environment:

Is this actually desired behavior? I use .env specifically to override some default variables. My workaround is to `source .env` in almost every one of my recipes.
As I recall the general consensus in such things is this order of importance

1. Command line options
2. Environment Variables
3. Config file
4. `.env` type file support

The thinking being that `.env` files and similar systems are often stored in a repo and act as reasonable defaults for developers that don't want to pollute their local environment with projects specific settings that may change often and should be consistent for a given release version or branch.

Config files may, but rarely do, exist in a repo except as an example config which doesn't actually get used. This can change depending on the needs of the application as a default config file might need to be more important than variables already defined in the environment. But this is not common.

System environment variables that are project specific should have high priority as the are, generally, only expected to be set in testing or production environment systems.

Command line options that match against config or env vars have the highest priority as they are expected to only be used in development or for very specific situations. Such as when a config values or env vars are expected to be the cause of problems or for A/B testing.
Right, of course that makes sense now. it does seem to be a common thing people ask for, but seems like an edge case.
What seems like an edge case, the need for such a setup, or the need to override local system variables with those stored in a file or else where? In actuality both are edge cases depending on the type of projects you work on.

The four layered approach I mention is very common in server development, especially when it involves deployment to the cloud or intranet cloud services where env vars are the main form of node specific configuration (instance name, IP address, etc.) and YAML or JSON are the main form of config file.

Many languages have facilities to setup a project environment using unique env vars. If you're programming in Python you can use the built-in [venv](https://docs.python.org/3/library/venv.html) or [Pipenv](https://pipenv.pypa.io/) module to setup a virtual environment. I believe Node allows for setting up variables per project. Plus there are many tools like as [direnv](https://direnv.net/) which is commonly used for such things, and [LocalStatus](https://github.com/tylermumford/localstatus) which looks very promising, etc. And of course Just will read a `.env` if you have <code>[set dotenv-load](https://just.systems/man/en/chapter_26.html)</code> in your Justfile.

If you expand in even a fraction of what all that programming can cover edge cases high everywhere. There is no ESCAPE! AHAHAHAHAHAHAHAH hahddhash... ha? Yeah, something like that. 😇
😂 umm yeah, I was referring to overriding already set variables being an edge case. I’m not clear on what the other “such a setup” is, but I don’t need that explanation. I already use the 4 layer dip approach except I don’t like tomatoes or guacamole so I don’t use config files, I also only use .env for local development. Those are never deployed for my projects. Don’t forget about the corner cases 🤓



Patch:
diff --git a/README.md b/README.md
index 908eadf961..2d298a08d6 100644
--- a/README.md
+++ b/README.md
@@ -989,6 +989,7 @@ foo:
 | `allow-duplicate-variables` | boolean | `false` | Allow variables appearing later in a `justfile` to override earlier variables with the same name. |
 | `dotenv-filename` | string | - | Load a `.env` file with a custom name, if present. |
 | `dotenv-load` | boolean | `false` | Load a `.env` file, if present. |
+| `dotenv-override` | boolean | `false` | Override existing environment variables with values from the `.env` file. |
 | `dotenv-path` | string | - | Load a `.env` file from a custom path and error if not present. Overrides `dotenv-filename`. |
 | `dotenv-required` | boolean | `false` | Error if a `.env` file isn't found. |
 | `export` | boolean | `false` | Export all variables as environment variables. |
@@ -1060,8 +1061,9 @@ bar
 
 #### Dotenv Settings
 
-If any of `dotenv-load`, `dotenv-filename`, `dotenv-path`, or `dotenv-required`
-are set, `just` will try to load environment variables from a file.
+If any of `dotenv-load`, `dotenv-filename`, `dotenv-override`, `dotenv-path`,
+or `dotenv-required` are set, `just` will try to load environment variables
+from a file.
 
 If `dotenv-path` is set, `just` will look for a file at the given path, which
 may be absolute, or relative to the working directory.
@@ -1086,6 +1088,9 @@ It is not an error if an environment file is not found, unless
 The loaded variables are environment variables, not `just` variables, and so
 must be accessed using `$VARIABLE_NAME` in recipes and backticks.
 
+If `dotenv-override` is set, variables from the environment file will override
+existing environment variables.
+
 For example, if your `.env` file contains:
 
 ```console
@@ -4452,7 +4457,9 @@ and checking the program's stdout, stderr, and exit code .
 
 5. Implement the feature.
 
-6. Run `just ci` to make sure that all tests, lints, and checks pass.
+6. Run `just ci` to make sure that all tests, lints, and checks pass. Requires
+   [mdBook](https://github.com/rust-lang/mdBook) and
+   [mdbook-linkcheck](https://github.com/Michael-F-Bryan/mdbook-linkcheck).
 
 7. Open a PR with the new code that is editable by maintainers. PRs often
    require rebasing and minor tweaks. If the PR is not editable by maintainers,
diff --git a/src/keyword.rs b/src/keyword.rs
index 41c9aaa7c8..7cae3606fa 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -9,6 +9,7 @@ pub(crate) enum Keyword {
   Assert,
   DotenvFilename,
   DotenvLoad,
+  DotenvOverride,
   DotenvPath,
   DotenvRequired,
   Else,
diff --git a/src/load_dotenv.rs b/src/load_dotenv.rs
index 29b31b2433..5988eb79fa 100644
--- a/src/load_dotenv.rs
+++ b/src/load_dotenv.rs
@@ -16,9 +16,10 @@ pub(crate) fn load_dotenv(
     .or(settings.dotenv_path.as_ref());
 
   if !settings.dotenv_load
+    && !settings.dotenv_override
+    && !settings.dotenv_required
     && dotenv_filename.is_none()
     && dotenv_path.is_none()
-    && !settings.dotenv_required
   {
     return Ok(BTreeMap::new());
   }
@@ -26,7 +27,7 @@ pub(crate) fn load_dotenv(
   if let Some(path) = dotenv_path {
     let path = working_directory.join(path);
     if path.is_file() {
-      return load_from_file(&path);
+      return load_from_file(&path, settings);
     }
   }
 
@@ -35,7 +36,7 @@ pub(crate) fn load_dotenv(
   for directory in working_directory.ancestors() {
     let path = directory.join(filename);
     if path.is_file() {
-      return load_from_file(&path);
+      return load_from_file(&path, settings);
     }
   }
 
@@ -46,12 +47,15 @@ pub(crate) fn load_dotenv(
   }
 }
 
-fn load_from_file(path: &Path) -> RunResult<'static, BTreeMap<String, String>> {
+fn load_from_file(
+  path: &Path,
+  settings: &Settings,
+) -> RunResult<'static, BTreeMap<String, String>> {
   let iter = dotenvy::from_path_iter(path)?;
   let mut dotenv = BTreeMap::new();
   for result in iter {
     let (key, value) = result?;
-    if env::var_os(&key).is_none() {
+    if settings.dotenv_override || env::var_os(&key).is_none() {
       dotenv.insert(key, value);
     }
   }
diff --git a/src/node.rs b/src/node.rs
index 6460782497..2a1db7053d 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -303,6 +303,7 @@ impl<'src> Node<'src> for Set<'src> {
       Setting::AllowDuplicateRecipes(value)
       | Setting::AllowDuplicateVariables(value)
       | Setting::DotenvLoad(value)
+      | Setting::DotenvOverride(value)
       | Setting::DotenvRequired(value)
       | Setting::Export(value)
       | Setting::Fallback(value)
diff --git a/src/parser.rs b/src/parser.rs
index fe00a336b0..3d0e3848da 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -1119,6 +1119,7 @@ impl<'run, 'src> Parser<'run, 'src> {
         Some(Setting::AllowDuplicateVariables(self.parse_set_bool()?))
       }
       Keyword::DotenvLoad => Some(Setting::DotenvLoad(self.parse_set_bool()?)),
+      Keyword::DotenvOverride => Some(Setting::DotenvOverride(self.parse_set_bool()?)),
       Keyword::DotenvRequired => Some(Setting::DotenvRequired(self.parse_set_bool()?)),
       Keyword::Export => Some(Setting::Export(self.parse_set_bool()?)),
       Keyword::Fallback => Some(Setting::Fallback(self.parse_set_bool()?)),
diff --git a/src/setting.rs b/src/setting.rs
index f187f41897..211ba9d319 100644
--- a/src/setting.rs
+++ b/src/setting.rs
@@ -6,6 +6,7 @@ pub(crate) enum Setting<'src> {
   AllowDuplicateVariables(bool),
   DotenvFilename(StringLiteral<'src>),
   DotenvLoad(bool),
+  DotenvOverride(bool),
   DotenvPath(StringLiteral<'src>),
   DotenvRequired(bool),
   Export(bool),
@@ -29,6 +30,7 @@ impl Display for Setting<'_> {
       Self::AllowDuplicateRecipes(value)
       | Self::AllowDuplicateVariables(value)
       | Self::DotenvLoad(value)
+      | Self::DotenvOverride(value)
       | Self::DotenvRequired(value)
       | Self::Export(value)
       | Self::Fallback(value)
diff --git a/src/settings.rs b/src/settings.rs
index 6ab3335398..1b352d61f6 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -11,6 +11,7 @@ pub(crate) struct Settings<'src> {
   pub(crate) allow_duplicate_variables: bool,
   pub(crate) dotenv_filename: Option<String>,
   pub(crate) dotenv_load: bool,
+  pub(crate) dotenv_override: bool,
   pub(crate) dotenv_path: Option<PathBuf>,
   pub(crate) dotenv_required: bool,
   pub(crate) export: bool,
@@ -50,6 +51,9 @@ impl<'src> Settings<'src> {
         Setting::DotenvPath(path) => {
           settings.dotenv_path = Some(PathBuf::from(path.cooked));
         }
+        Setting::DotenvOverride(dotenv_overrride) => {
+          settings.dotenv_override = dotenv_overrride;
+        }
         Setting::DotenvRequired(dotenv_required) => {
           settings.dotenv_required = dotenv_required;
         }


Test patch:
diff --git a/tests/dotenv.rs b/tests/dotenv.rs
index 08c4ed7a9e..4b45a561d4 100644
--- a/tests/dotenv.rs
+++ b/tests/dotenv.rs
@@ -362,7 +362,7 @@ fn no_dotenv() {
 }
 
 #[test]
-fn dotenv_env_var_override() {
+fn dotenv_env_var_default_no_override() {
   Test::new()
     .justfile(
       "
@@ -377,6 +377,41 @@ fn dotenv_env_var_override() {
     .run();
 }
 
+#[test]
+fn dotenv_env_var_override() {
+  Test::new()
+    .justfile(
+      "
+        set dotenv-load
+        set dotenv-override := true
+        echo:
+          echo $DOTENV_KEY
+      ",
+    )
+    .write(".env", "DOTENV_KEY=dotenv-value")
+    .env("DOTENV_KEY", "not-the-dotenv-value")
+    .stdout("dotenv-value\n")
+    .stderr("echo $DOTENV_KEY\n")
+    .run();
+}
+
+#[test]
+fn dotenv_env_var_override_no_load() {
+  Test::new()
+    .justfile(
+      "
+        set dotenv-override := true
+        echo:
+          echo $DOTENV_KEY
+      ",
+    )
+    .write(".env", "DOTENV_KEY=dotenv-value")
+    .env("DOTENV_KEY", "not-the-dotenv-value")
+    .stdout("dotenv-value\n")
+    .stderr("echo $DOTENV_KEY\n")
+    .run();
+}
+
 #[test]
 fn dotenv_path_usable_from_subdir() {
   Test::new()
diff --git a/tests/json.rs b/tests/json.rs
index 30f713bea8..879c6bb21a 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -79,6 +79,7 @@ struct Settings<'a> {
   allow_duplicate_variables: bool,
   dotenv_filename: Option<&'a str>,
   dotenv_load: bool,
+  dotenv_override: bool,
   dotenv_path: Option<&'a str>,
   dotenv_required: bool,
   export: bool,