Repository: casey/just
Pull request #2283 (2024-07-29 04:10:18)

Add `workdir` setting
Implement #2273 - an ability to set workdir for the entire justfile

casey: Nice! Check out `Recipe::working_directory`. If `self.change_directory()` is true, first use the `workdir` setting, and if it isn't present, Use the existing logic.
nyurik: thx @casey, seems to work, ready for review
casey: Nice! I did some refactoring, and renamed the setting `working-directory` for clarity, since i try to avoid too many abbreviations. We could eventually also allow `working-dir` as an alias.

This also made me realize that there's a bug! backticks do not execute in the correct directory if they're in a submodule. I'll fix that in another PR.

Patch:
diff --git a/GRAMMAR.md b/GRAMMAR.md
index c7752d3f..683b5a65 100644
--- a/GRAMMAR.md
+++ b/GRAMMAR.md
@@ -80,6 +80,7 @@ setting       : 'allow-duplicate-recipes' boolean?
               | 'unstable' boolean?
               | 'windows-powershell' boolean?
               | 'windows-shell' ':=' string_list
+              | 'working-directory' ':=' string
 
 boolean       : ':=' ('true' | 'false')
 
diff --git a/README.md b/README.md
index 8de9796d..275da4ff 100644
--- a/README.md
+++ b/README.md
@@ -824,6 +824,7 @@ foo:
 | `unstable`<sup>1.31.0</sup> | boolean | `false` | Enable unstable features. |
 | `windows-powershell` | boolean | `false` | Use PowerShell on Windows as default shell. (Deprecated. Use `windows-shell` instead. |
 | `windows-shell` | `[COMMAND, ARGS…]` | - | Set the command used to invoke recipes and evaluate backticks. |
+| `working-directory`<sup>master</sup> | string | - | Set the working directory for recipes and backticks, relative to the default working directory. |
 
 Boolean settings can be written as:
 
diff --git a/src/evaluator.rs b/src/evaluator.rs
index 82260b84..191b7120 100644
--- a/src/evaluator.rs
+++ b/src/evaluator.rs
@@ -239,7 +239,17 @@ impl<'src, 'run> Evaluator<'src, 'run> {
     let mut cmd = self.context.settings.shell_command(self.context.config);
     cmd.arg(command);
     cmd.args(args);
-    cmd.current_dir(&self.context.search.working_directory);
+    if let Some(working_directory) = &self.context.settings.working_directory {
+      cmd.current_dir(
+        self
+          .context
+          .search
+          .working_directory
+          .join(working_directory),
+      )
+    } else {
+      cmd.current_dir(&self.context.search.working_directory)
+    };
     cmd.export(
       self.context.settings,
       self.context.dotenv,
diff --git a/src/keyword.rs b/src/keyword.rs
index 1461c0c9..ff06c39d 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -30,6 +30,7 @@ pub(crate) enum Keyword {
   Unstable,
   WindowsPowershell,
   WindowsShell,
+  WorkingDirectory,
   X,
 }
 
diff --git a/src/node.rs b/src/node.rs
index bd5585df..3ccf862d 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -307,7 +307,10 @@ impl<'src> Node<'src> for Set<'src> {
           set.push_mut(Tree::string(&argument.cooked));
         }
       }
-      Setting::DotenvFilename(value) | Setting::DotenvPath(value) | Setting::Tempdir(value) => {
+      Setting::DotenvFilename(value)
+      | Setting::DotenvPath(value)
+      | Setting::Tempdir(value)
+      | Setting::WorkingDirectory(value) => {
         set.push_mut(Tree::string(&value.cooked));
       }
     }
diff --git a/src/parser.rs b/src/parser.rs
index d9985a32..a7b6243e 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -967,6 +967,7 @@ impl<'run, 'src> Parser<'run, 'src> {
       Keyword::Shell => Some(Setting::Shell(self.parse_interpreter()?)),
       Keyword::Tempdir => Some(Setting::Tempdir(self.parse_string_literal()?)),
       Keyword::WindowsShell => Some(Setting::WindowsShell(self.parse_interpreter()?)),
+      Keyword::WorkingDirectory => Some(Setting::WorkingDirectory(self.parse_string_literal()?)),
       _ => None,
     };
 
@@ -2146,6 +2147,12 @@ mod tests {
     tree: (justfile (set windows_powershell false)),
   }
 
+  test! {
+    name: set_working_directory,
+    text: "set working-directory := 'foo'",
+    tree: (justfile (set working_directory "foo")),
+  }
+
   test! {
     name: conditional,
     text: "a := if b == c { d } else { e }",
diff --git a/src/recipe.rs b/src/recipe.rs
index 7ae9641a..0d40307e 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -136,15 +136,21 @@ impl<'src, D> Recipe<'src, D> {
     !self.attributes.contains(&Attribute::NoExitMessage)
   }
 
-  fn working_directory<'a>(&'a self, search: &'a Search) -> Option<&Path> {
-    if self.change_directory() {
-      Some(if self.submodule_depth > 0 {
-        &self.working_directory
-      } else {
-        &search.working_directory
-      })
+  fn working_directory<'a>(&'a self, context: &'a ExecutionContext) -> Option<PathBuf> {
+    if !self.change_directory() {
+      return None;
+    }
+
+    let base = if self.submodule_depth > 0 {
+      &self.working_directory
+    } else {
+      &context.search.working_directory
+    };
+
+    if let Some(setting) = &context.settings.working_directory {
+      Some(base.join(setting))
     } else {
-      None
+      Some(base.into())
     }
   }
 
@@ -265,7 +271,7 @@ impl<'src, D> Recipe<'src, D> {
 
       let mut cmd = context.settings.shell_command(config);
 
-      if let Some(working_directory) = self.working_directory(context.search) {
+      if let Some(working_directory) = self.working_directory(context) {
         cmd.current_dir(working_directory);
       }
 
@@ -408,8 +414,11 @@ impl<'src, D> Recipe<'src, D> {
       io_error: error,
     })?;
 
-    let mut command =
-      executor.command(&path, self.name(), self.working_directory(context.search))?;
+    let mut command = executor.command(
+      &path,
+      self.name(),
+      self.working_directory(context).as_deref(),
+    )?;
 
     if self.takes_positional_arguments(context.settings) {
       command.args(positional);
diff --git a/src/setting.rs b/src/setting.rs
index 342cd878..2b687135 100644
--- a/src/setting.rs
+++ b/src/setting.rs
@@ -19,6 +19,7 @@ pub(crate) enum Setting<'src> {
   Unstable(bool),
   WindowsPowerShell(bool),
   WindowsShell(Interpreter<'src>),
+  WorkingDirectory(StringLiteral<'src>),
 }
 
 impl<'src> Display for Setting<'src> {
@@ -38,7 +39,10 @@ impl<'src> Display for Setting<'src> {
       Self::ScriptInterpreter(shell) | Self::Shell(shell) | Self::WindowsShell(shell) => {
         write!(f, "[{shell}]")
       }
-      Self::DotenvFilename(value) | Self::DotenvPath(value) | Self::Tempdir(value) => {
+      Self::DotenvFilename(value)
+      | Self::DotenvPath(value)
+      | Self::Tempdir(value)
+      | Self::WorkingDirectory(value) => {
         write!(f, "{value}")
       }
     }
diff --git a/src/settings.rs b/src/settings.rs
index 7ea64dba..795ddebd 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -25,6 +25,7 @@ pub(crate) struct Settings<'src> {
   pub(crate) unstable: bool,
   pub(crate) windows_powershell: bool,
   pub(crate) windows_shell: Option<Interpreter<'src>>,
+  pub(crate) working_directory: Option<PathBuf>,
 }
 
 impl<'src> Settings<'src> {
@@ -84,6 +85,9 @@ impl<'src> Settings<'src> {
         Setting::Tempdir(tempdir) => {
           settings.tempdir = Some(tempdir.cooked);
         }
+        Setting::WorkingDirectory(working_directory) => {
+          settings.working_directory = Some(working_directory.cooked.into());
+        }
       }
     }
 


Test patch:
diff --git a/tests/json.rs b/tests/json.rs
index 19135aca..961bdfa4 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -62,6 +62,7 @@ fn alias() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -105,6 +106,7 @@ fn assignment() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -162,6 +164,7 @@ fn body() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -231,6 +234,7 @@ fn dependencies() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -338,6 +342,7 @@ fn dependency_argument() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -407,6 +412,7 @@ fn duplicate_recipes() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -454,6 +460,7 @@ fn duplicate_variables() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -504,6 +511,7 @@ fn doc_comment() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -540,6 +548,7 @@ fn empty_justfile() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -697,6 +706,7 @@ fn parameters() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -787,6 +797,7 @@ fn priors() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -837,6 +848,7 @@ fn private() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -887,6 +899,7 @@ fn quiet() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -952,6 +965,7 @@ fn settings() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -1005,6 +1019,7 @@ fn shebang() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -1055,6 +1070,7 @@ fn simple() {
         "unstable": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -1108,6 +1124,7 @@ fn attribute() {
         "ignore_comments": false,
         "windows_powershell": false,
         "windows_shell": null,
+        "working_directory" : null,
       },
       "unexports": [],
       "warnings": [],
@@ -1176,6 +1193,7 @@ fn module() {
               "ignore_comments": false,
               "windows_powershell": false,
               "windows_shell": null,
+              "working_directory" : null,
             },
             "unexports": [],
             "warnings": [],
@@ -1199,6 +1217,7 @@ fn module() {
           "ignore_comments": false,
           "windows_powershell": false,
           "windows_shell": null,
+          "working_directory" : null,
         },
         "unexports": [],
         "warnings": [],
@@ -1269,6 +1288,7 @@ fn module_group() {
               "ignore_comments": false,
               "windows_powershell": false,
               "windows_shell": null,
+              "working_directory" : null,
             },
             "unexports": [],
             "warnings": [],
@@ -1292,6 +1312,7 @@ fn module_group() {
           "ignore_comments": false,
           "windows_powershell": false,
           "windows_shell": null,
+          "working_directory" : null,
         },
         "unexports": [],
         "warnings": [],
diff --git a/tests/working_directory.rs b/tests/working_directory.rs
index 02296967..eb6618c6 100644
--- a/tests/working_directory.rs
+++ b/tests/working_directory.rs
@@ -181,3 +181,93 @@ fn search_dir_parent() -> Result<(), Box<dyn Error>> {
 
   Ok(())
 }
+
+#[test]
+fn setting() {
+  Test::new()
+    .justfile(
+      r#"
+      set working-directory := 'bar'
+
+      print1:
+        echo "$(basename "$PWD")"
+
+      [no-cd]
+      print2:
+        echo "$(basename "$PWD")"
+    "#,
+    )
+    .current_dir("foo")
+    .tree(tree! {
+      foo: {},
+      bar: {}
+    })
+    .args(["print1", "print2"])
+    .stderr(
+      r#"echo "$(basename "$PWD")"
+echo "$(basename "$PWD")"
+"#,
+    )
+    .stdout("bar\nfoo\n")
+    .run();
+}
+
+#[test]
+fn no_cd_overrides_setting() {
+  Test::new()
+    .justfile(
+      "
+      set working-directory := 'bar'
+
+      [no-cd]
+      foo:
+        cat bar
+    ",
+    )
+    .current_dir("foo")
+    .tree(tree! {
+      foo: {
+        bar: "hello",
+      }
+    })
+    .stderr("cat bar\n")
+    .stdout("hello")
+    .run();
+}
+
+#[test]
+fn working_dir_in_submodule_is_relative_to_module_path() {
+  Test::new()
+    .write(
+      "foo/mod.just",
+      "
+set working-directory := 'bar'
+
+@foo:
+  cat file.txt
+",
+    )
+    .justfile("mod foo")
+    .write("foo/bar/file.txt", "FILE")
+    .arg("foo")
+    .stdout("FILE")
+    .run();
+}
+
+#[test]
+fn working_dir_applies_to_backticks() {
+  Test::new()
+    .justfile(
+      "
+        set working-directory := 'foo'
+
+        file := `cat file.txt`
+
+        @foo:
+          echo {{ file }}
+      ",
+    )
+    .write("foo/file.txt", "FILE")
+    .stdout("FILE\n")
+    .run();
+}