Repository: casey/just
Pull request #2798 (2025-06-29 21:19:44)

Add `--tempdir` command-line option


Patch:
diff --git a/README.md b/README.md
index 733615d0..7a8097e0 100644
--- a/README.md
+++ b/README.md
@@ -2891,6 +2891,46 @@ the final argument. For example, on Windows, if a recipe starts with `#! py`,
 the final command the OS runs will be something like
 `py C:\Temp\PATH_TO_SAVED_RECIPE_BODY`.
 
+### Script Recipes
+
+Recipes with a `[script(COMMAND)]`<sup>1.32.0</sup> attribute are run as
+scripts interpreted by `COMMAND`. This avoids some of the issues with shebang
+recipes, such as the use of `cygpath` on Windows, the need to use
+`/usr/bin/env`, inconsistencies in shebang line splitting across Unix OSs, and
+requiring a temporary directory from which files can be executed.
+
+Recipes with an empty `[script]` attribute are executed with the value of `set
+script-interpreter := […]`<sup>1.33.0</sup>, defaulting to `sh -eu`, and *not*
+the value of `set shell`.
+
+The body of the recipe is evaluated, written to disk in the temporary
+directory, and run by passing its path as an argument to `COMMAND`.
+
+The `[script(…)]` attribute is unstable, so you'll need to use `set unstable`,
+set the `JUST_UNSTABLE` environment variable, or pass `--unstable` on the
+command line.
+
+### Script and Shebang Recipe Temporary Files
+
+Both script and shebang recipes write the recipe body to a temporary file for
+execution. Script recipes execute that file by passing it to a command, while
+shebang recipes execute the file directly. Shebang recipe execution will fail
+if the filesystem containing the temporary file is mounted with `noexec` or is
+otherwise non-executable.
+
+The directory that `just` writes temporary files to may be configured in a
+number of ways, from highest to lowest precedence:
+
+- Globally with the `--tempdir` command-line option or the `JUST_TEMPDIR`
+  environment variable.
+
+- On a per-module basis with the `tempdir` setting.
+
+- Globally on Linux with the `XDG_RUNTIME_DIR` environment variable.
+
+- Falling back to the directory returned by
+  [std::env::temp_dir](https://doc.rust-lang.org/std/env/fn.temp_dir.html).
+
 ### Python Recipes with `uv`
 
 [`uv`](https://github.com/astral-sh/uv) is an excellent cross-platform python
@@ -2926,23 +2966,6 @@ hello:
   print("Hello from Python!")
 ```
 
-### Script Recipes
-
-Recipes with a `[script(COMMAND)]`<sup>1.32.0</sup> attribute are run as
-scripts interpreted by `COMMAND`. This avoids some of the issues with shebang
-recipes, such as the use of `cygpath` on Windows, the need to use
-`/usr/bin/env`, and inconsistencies in shebang line splitting across Unix OSs.
-
-Recipes with an empty `[script]` attribute are executed with the value of `set
-script-interpreter := […]`<sup>1.33.0</sup>, defaulting to `sh -eu`, and *not*
-the value of `set shell`.
-
-The body of the recipe is evaluated, written to disk in the temporary
-directory, and run by passing its path as an argument to `COMMAND`.
-
-The `[script(…)]` attribute is unstable, so you'll need to use `set unstable`,
-set the `JUST_UNSTABLE` environment variable, or pass `--unstable` on the
-command line.
 
 ### Safer Bash Shebang Recipes
 
diff --git a/src/config.rs b/src/config.rs
index 3e17ed0c..48e4a232 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -36,6 +36,7 @@ pub(crate) struct Config {
   pub(crate) shell_args: Option<Vec<String>>,
   pub(crate) shell_command: bool,
   pub(crate) subcommand: Subcommand,
+  pub(crate) tempdir: Option<PathBuf>,
   pub(crate) timestamp: bool,
   pub(crate) timestamp_format: String,
   pub(crate) unsorted: bool,
@@ -116,6 +117,7 @@ mod arg {
   pub(crate) const SHELL: &str = "SHELL";
   pub(crate) const SHELL_ARG: &str = "SHELL-ARG";
   pub(crate) const SHELL_COMMAND: &str = "SHELL-COMMAND";
+  pub(crate) const TEMPDIR: &str = "TEMPDIR";
   pub(crate) const TIMESTAMP: &str = "TIMESTAMP";
   pub(crate) const TIMESTAMP_FORMAT: &str = "TIMESTAMP-FORMAT";
   pub(crate) const UNSORTED: &str = "UNSORTED";
@@ -373,6 +375,14 @@ impl Config {
           .action(ArgAction::SetTrue)
           .help("Invoke <COMMAND> with the shell used to run recipe lines and backticks"),
       )
+      .arg(
+        Arg::new(arg::TEMPDIR)
+          .action(ArgAction::Set)
+          .env("JUST_TEMPDIR")
+          .long("tempdir")
+          .value_parser(value_parser!(PathBuf))
+          .help("Save temporary files to <TEMPDIR>."),
+      )
       .arg(
         Arg::new(arg::TIMESTAMP)
           .action(ArgAction::SetTrue)
@@ -792,6 +802,7 @@ impl Config {
       },
       shell_command: matches.get_flag(arg::SHELL_COMMAND),
       subcommand,
+      tempdir: matches.get_one::<PathBuf>(arg::TEMPDIR).map(Into::into),
       timestamp: matches.get_flag(arg::TIMESTAMP),
       timestamp_format: matches
         .get_one::<String>(arg::TIMESTAMP_FORMAT)
diff --git a/src/execution_context.rs b/src/execution_context.rs
index bd39ecfc..d9caae43 100644
--- a/src/execution_context.rs
+++ b/src/execution_context.rs
@@ -10,6 +10,36 @@ pub(crate) struct ExecutionContext<'src: 'run, 'run> {
 }
 
 impl<'src: 'run, 'run> ExecutionContext<'src, 'run> {
+  pub(crate) fn tempdir<D>(&self, recipe: &Recipe<'src, D>) -> RunResult<'src, TempDir> {
+    let mut tempdir_builder = tempfile::Builder::new();
+
+    tempdir_builder.prefix("just-");
+
+    if let Some(tempdir) = &self.config.tempdir {
+      tempdir_builder.tempdir_in(self.search.working_directory.join(tempdir))
+    } else {
+      match &self.module.settings.tempdir {
+        Some(tempdir) => tempdir_builder.tempdir_in(self.search.working_directory.join(tempdir)),
+        None => {
+          if let Some(runtime_dir) = dirs::runtime_dir() {
+            let path = runtime_dir.join("just");
+            fs::create_dir_all(&path).map_err(|io_error| Error::RuntimeDirIo {
+              io_error,
+              path: path.clone(),
+            })?;
+            tempdir_builder.tempdir_in(path)
+          } else {
+            tempdir_builder.tempdir()
+          }
+        }
+      }
+    }
+    .map_err(|error| Error::TempdirIo {
+      recipe: recipe.name(),
+      io_error: error,
+    })
+  }
+
   pub(crate) fn working_directory(&self) -> PathBuf {
     let base = if self.module.is_submodule() {
       &self.module.working_directory
diff --git a/src/lib.rs b/src/lib.rs
index 81722a2a..b38f2291 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -133,7 +133,7 @@ pub(crate) use {
     vec,
   },
   strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr},
-  tempfile::tempfile,
+  tempfile::{tempfile, TempDir},
   typed_arena::Arena,
   unicode_width::{UnicodeWidthChar, UnicodeWidthStr},
 };
diff --git a/src/recipe.rs b/src/recipe.rs
index 74f501ca..7b11b1a8 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -390,27 +390,8 @@ impl<'src, D> Recipe<'src, D> {
       Executor::Shebang(shebang)
     };
 
-    let mut tempdir_builder = tempfile::Builder::new();
-    tempdir_builder.prefix("just-");
-    let tempdir = match &context.module.settings.tempdir {
-      Some(tempdir) => tempdir_builder.tempdir_in(context.search.working_directory.join(tempdir)),
-      None => {
-        if let Some(runtime_dir) = dirs::runtime_dir() {
-          let path = runtime_dir.join("just");
-          fs::create_dir_all(&path).map_err(|io_error| Error::RuntimeDirIo {
-            io_error,
-            path: path.clone(),
-          })?;
-          tempdir_builder.tempdir_in(path)
-        } else {
-          tempdir_builder.tempdir()
-        }
-      }
-    }
-    .map_err(|error| Error::TempdirIo {
-      recipe: self.name(),
-      io_error: error,
-    })?;
+    let tempdir = context.tempdir(self)?;
+
     let mut path = tempdir.path().to_path_buf();
 
     let extension = self.attributes.iter().find_map(|attribute| {


Test patch:
diff --git a/tests/tempdir.rs b/tests/tempdir.rs
index 63b07a7d..e0ad82a0 100644
--- a/tests/tempdir.rs
+++ b/tests/tempdir.rs
@@ -16,7 +16,7 @@ pub(crate) fn tempdir() -> TempDir {
 }
 
 #[test]
-fn test_tempdir_is_set() {
+fn setting() {
   Test::new()
     .justfile(
       "
@@ -28,10 +28,46 @@ fn test_tempdir_is_set() {
     )
     .shell(false)
     .tree(tree! {
-      foo: {
+      bar: {
       }
     })
-    .current_dir("foo")
+    .current_dir("bar")
+    .stdout(if cfg!(windows) {
+      "
+
+
+
+      cat just*/foo
+      "
+    } else {
+      "
+      #!/usr/bin/env bash
+
+
+      cat just*/foo
+      "
+    })
+    .run();
+}
+
+#[test]
+fn argument_overrides_setting() {
+  Test::new()
+    .args(["--tempdir", "."])
+    .justfile(
+      "
+      set tempdir := 'hello'
+      foo:
+          #!/usr/bin/env bash
+          cat just*/foo
+      ",
+    )
+    .shell(false)
+    .tree(tree! {
+      bar: {
+      }
+    })
+    .current_dir("bar")
     .stdout(if cfg!(windows) {
       "