From 35b9e862756d385194ad136c73756df0db3ab9ed Mon Sep 17 00:00:00 2001 From: Hans Donner Date: Thu, 16 Apr 2026 23:04:59 +0200 Subject: [PATCH 01/18] add setting no-cd --- README.md | 7 +++++ src/analyzer.rs | 24 +++++++++++++++ src/compile_error.rs | 11 +++++++ src/compile_error_kind.rs | 5 ++++ src/evaluator.rs | 3 ++ src/execution_context.rs | 11 +++++-- src/keyword.rs | 1 + src/node.rs | 1 + src/parser.rs | 7 +++++ src/recipe.rs | 31 ++++++++++++------- src/setting.rs | 3 ++ src/settings.rs | 1 + tests/json.rs | 1 + tests/modules.rs | 22 ++++++++++++++ tests/no_cd.rs | 61 ++++++++++++++++++++++++++++++++++++++ tests/settings.rs | 37 +++++++++++++++++++++++ tests/working_directory.rs | 15 ++++++++++ 17 files changed, 228 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1ebd7e2f33..b57f200ba4 100644 --- a/README.md +++ b/README.md @@ -950,6 +950,12 @@ $ just bar /subdir ``` +To apply the same behavior to every recipe in a module, use `set no-cd := true`. +This setting is module-local, so imported modules choose their own default, and +it can't appear alongside `set working-directory` in the same `justfile`. +Recipe-level attributes still take precedence: `[working-directory(...)]` +overrides both, and `[no-cd]` on a recipe overrides `set working-directory`. + You can override the working directory for all recipes with `set working-directory := '…'`: @@ -1043,6 +1049,7 @@ foo: | `ignore-comments` | boolean | `false` | Ignore recipe lines beginning with `#`. | | `no-exit-message`1.39.0 | boolean | `false` | Don't print exit messages if recipes fail. | | `lazy`1.47.0 | boolean | `false` | Don't evaluate unused variables. | +| `no-cd` | boolean | `false` | Don't change directory before executing recipes and evaluating backticks, unless overridden by recipe attributes. | | `positional-arguments` | boolean | `false` | Pass positional arguments. | | `quiet` | boolean | `false` | Disable echoing recipe lines before executing. | | `script-interpreter`1.33.0 | `[COMMAND, ARGS…]` | `['sh', '-eu']` | Set command used to invoke recipes with empty `[script]` attribute. | diff --git a/src/analyzer.rs b/src/analyzer.rs index e98f41b701..1f475c5c7f 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -417,6 +417,30 @@ impl<'run, 'src> Analyzer<'run, 'src> { })); } + if let Some(keyword) = Keyword::from_lexeme(set.name.lexeme()) { + match keyword { + Keyword::NoCd => { + if let Some(conflict) = self.sets.get(Keyword::WorkingDirectory.lexeme()) { + return Err(set.name.error(NoCdAndWorkingDirectorySetting { + first: Keyword::WorkingDirectory, + first_line: conflict.name.line, + second: keyword, + })); + } + } + Keyword::WorkingDirectory => { + if let Some(conflict) = self.sets.get(Keyword::NoCd.lexeme()) { + return Err(set.name.error(NoCdAndWorkingDirectorySetting { + first: Keyword::NoCd, + first_line: conflict.name.line, + second: keyword, + })); + } + } + _ => {} + } + } + Ok(()) } diff --git a/src/compile_error.rs b/src/compile_error.rs index b72cdca7d9..50c8179117 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -262,6 +262,17 @@ impl Display for CompileError<'_> { f, "Recipe `{recipe}` has both `[no-cd]` and `[working-directory]` attributes" ), + NoCdAndWorkingDirectorySetting { + first, + first_line, + second, + } => write!( + f, + "Setting `{}` first set on line {} is incompatible with setting `{}`", + first.lexeme(), + first_line.ordinal(), + second.lexeme() + ), OptionNameContainsEqualSign { parameter } => { write!( f, diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index a65bf953bb..1e14b6e152 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -120,6 +120,11 @@ pub(crate) enum CompileErrorKind<'src> { OptionNameEmpty { parameter: String, }, + NoCdAndWorkingDirectorySetting { + first: Keyword, + first_line: usize, + second: Keyword, + }, ParameterFollowsVariadicParameter { parameter: &'src str, }, diff --git a/src/evaluator.rs b/src/evaluator.rs index 9f03f0295c..a018251c53 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -86,6 +86,9 @@ impl<'src, 'run> Evaluator<'src, 'run> { Setting::Lazy(value) => { settings.lazy = value; } + Setting::NoCd(value) => { + settings.no_cd = value; + } Setting::NoExitMessage(value) => { settings.no_exit_message = value; } diff --git a/src/execution_context.rs b/src/execution_context.rs index 6097a54056..77c7afeab0 100644 --- a/src/execution_context.rs +++ b/src/execution_context.rs @@ -41,15 +41,20 @@ impl<'src: 'run, 'run> ExecutionContext<'src, 'run> { pub(crate) fn working_directory(&self) -> PathBuf { let base = if self.module.is_submodule() { - &self.module.working_directory + self + .module + .source + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| self.search.working_directory.clone()) } else { - &self.search.working_directory + self.search.working_directory.clone() }; if let Some(setting) = &self.module.settings.working_directory { base.join(setting) } else { - base.into() + base } } } diff --git a/src/keyword.rs b/src/keyword.rs index b2b1d52967..7b75d0a3a3 100644 --- a/src/keyword.rs +++ b/src/keyword.rs @@ -24,6 +24,7 @@ pub(crate) enum Keyword { Import, Lazy, Mod, + NoCd, NoExitMessage, PositionalArguments, Quiet, diff --git a/src/node.rs b/src/node.rs index a60a80f661..188353014a 100644 --- a/src/node.rs +++ b/src/node.rs @@ -283,6 +283,7 @@ impl<'src> Node<'src> for Set<'src> { | Setting::Guards(value) | Setting::IgnoreComments(value) | Setting::Lazy(value) + | Setting::NoCd(value) | Setting::NoExitMessage(value) | Setting::PositionalArguments(value) | Setting::Quiet(value) diff --git a/src/parser.rs b/src/parser.rs index 423e891b5a..c2b81a9c4f 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1416,6 +1416,7 @@ impl<'run, 'src> Parser<'run, 'src> { Keyword::Guards => Some(Setting::Guards(self.parse_set_bool()?)), Keyword::IgnoreComments => Some(Setting::IgnoreComments(self.parse_set_bool()?)), Keyword::Lazy => Some(Setting::Lazy(self.parse_set_bool()?)), + Keyword::NoCd => Some(Setting::NoCd(self.parse_set_bool()?)), Keyword::NoExitMessage => Some(Setting::NoExitMessage(self.parse_set_bool()?)), Keyword::PositionalArguments => Some(Setting::PositionalArguments(self.parse_set_bool()?)), Keyword::Quiet => Some(Setting::Quiet(self.parse_set_bool()?)), @@ -2650,6 +2651,12 @@ mod tests { tree: (justfile (set quiet false)), } + test! { + name: set_no_cd, + text: "set no-cd := true", + tree: (justfile (set no_cd true)), + } + test! { name: set_positional_arguments_false, text: "set positional-arguments := false", diff --git a/src/recipe.rs b/src/recipe.rs index 5be57e6fb9..1b56ab01e4 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -169,8 +169,19 @@ impl<'src> Recipe<'src> { .contains(AttributeDiscriminant::PositionalArguments) } - pub(crate) fn change_directory(&self) -> bool { - !self.attributes.contains(AttributeDiscriminant::NoCd) + pub(crate) fn change_directory(&self, settings: &Settings) -> bool { + if self + .attributes + .contains(AttributeDiscriminant::WorkingDirectory) + { + return true; + } + + if self.attributes.contains(AttributeDiscriminant::NoCd) { + return false; + } + + !settings.no_cd } fn print_exit_message(&self, settings: &Settings) -> bool { @@ -185,20 +196,20 @@ impl<'src> Recipe<'src> { } } - fn working_directory<'a>(&'a self, context: &'a ExecutionContext) -> Option { - if !self.change_directory() { - return None; - } - - let working_directory = context.working_directory(); + pub(crate) fn working_directory<'a>(&'a self, context: &'a ExecutionContext) -> Option { + let module_working_directory = context.working_directory(); for attribute in &self.attributes { if let Attribute::WorkingDirectory(dir) = attribute { - return Some(working_directory.join(&dir.cooked)); + return Some(module_working_directory.join(&dir.cooked)); } } - Some(working_directory) + if !self.change_directory(&context.module.settings) { + return None; + } + + Some(module_working_directory) } fn no_quiet(&self) -> bool { diff --git a/src/setting.rs b/src/setting.rs index d0de410cbb..58c8ed10fb 100644 --- a/src/setting.rs +++ b/src/setting.rs @@ -14,6 +14,7 @@ pub(crate) enum Setting<'src> { Guards(bool), IgnoreComments(bool), Lazy(bool), + NoCd(bool), NoExitMessage(bool), PositionalArguments(bool), Quiet(bool), @@ -39,6 +40,7 @@ impl<'src> Setting<'src> { | Self::Guards(value) | Self::IgnoreComments(value) | Self::Lazy(value) + | Self::NoCd(value) | Self::NoExitMessage(value) | Self::PositionalArguments(value) | Self::Quiet(value) @@ -88,6 +90,7 @@ impl Display for Setting<'_> { | Self::Guards(value) | Self::IgnoreComments(value) | Self::Lazy(value) + | Self::NoCd(value) | Self::NoExitMessage(value) | Self::PositionalArguments(value) | Self::Quiet(value) diff --git a/src/settings.rs b/src/settings.rs index bdea9adda8..460061b160 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -19,6 +19,7 @@ pub(crate) struct Settings { pub(crate) guards: bool, pub(crate) ignore_comments: bool, pub(crate) lazy: bool, + pub(crate) no_cd: bool, pub(crate) no_exit_message: bool, pub(crate) positional_arguments: bool, pub(crate) quiet: bool, diff --git a/tests/json.rs b/tests/json.rs index 3702c28d94..ad7ae55e84 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -94,6 +94,7 @@ struct Settings<'a> { guards: bool, ignore_comments: bool, lazy: bool, + no_cd: bool, no_exit_message: bool, positional_arguments: bool, quiet: bool, diff --git a/tests/modules.rs b/tests/modules.rs index 10430d23db..708b20378e 100644 --- a/tests/modules.rs +++ b/tests/modules.rs @@ -215,6 +215,28 @@ foo: .failure(); } +#[test] +fn modules_do_not_inherit_no_cd_setting() { + Test::new() + .write( + "foo.just", + "bar: + @cat data.txt +", + ) + .write("data.txt", "MODULE\n") + .justfile( + " + set no-cd := true + + mod foo + ", + ) + .args(["foo", "bar"]) + .stdout("MODULE\n") + .success(); +} + #[test] fn modules_conflict_with_recipes() { Test::new() diff --git a/tests/no_cd.rs b/tests/no_cd.rs index d389359b46..395e0d1762 100644 --- a/tests/no_cd.rs +++ b/tests/no_cd.rs @@ -41,3 +41,64 @@ fn shebang() { .stdout("hello") .success(); } + +#[test] +fn setting_applies_to_recipes() { + Test::new() + .justfile( + " + set no-cd := true + + foo: + cat bar + ", + ) + .current_dir("child") + .tree(tree! { + bar: "root", + child: { + bar: "child", + } + }) + .stderr("cat bar\n") + .stdout("child") + .success(); +} + +#[test] +fn working_directory_attribute_overrides_setting() { + Test::new() + .justfile( + " + set no-cd := true + + [working-directory('workspace')] + foo: + cat data.txt + ", + ) + .write("workspace/data.txt", "WORKSPACE") + .stderr("cat data.txt\n") + .stdout("WORKSPACE") + .success(); +} + +#[test] +fn paths_stay_module_dir_without_strict() { + Test::new() + .justfile( + r#" + set no-cd := true + + file := `cat data.txt` + + @foo: + echo {{file}} + "#, + ) + .current_dir("inv") + .write("data.txt", "MODULE") + .write("inv/data.txt", "INVOCATION") + .stdout("MODULE\n") + .success(); +} diff --git a/tests/settings.rs b/tests/settings.rs index 5f2611017f..fa444c3660 100644 --- a/tests/settings.rs +++ b/tests/settings.rs @@ -262,6 +262,43 @@ fn variable() { } #[test] +fn no_cd_setting_conflicts_with_working_directory_setting() { + Test::new() + .justfile( + " + set no-cd := true + set working-directory := 'bar' + ", + ) + .stderr_regex( + "error: Setting `no-cd` first set on line 1 is incompatible with setting `working-directory`\n[\\s\\S]*", + ) + .failure(); +} + +#[test] +fn no_cd_setting_changes_default_recipe_execution() { + Test::new() + .justfile( + " + set no-cd := true + + foo: + cat bar + ", + ) + .current_dir("child") + .tree(tree! { + bar: "root", + child: { + bar: "child", + } + }) + .stderr("cat bar\n") + .stdout("child") + .success(); +} + fn unused_non_const_assignments() { Test::new() .justfile( diff --git a/tests/working_directory.rs b/tests/working_directory.rs index 2a63991152..0b1eaaf259 100644 --- a/tests/working_directory.rs +++ b/tests/working_directory.rs @@ -235,6 +235,21 @@ fn no_cd_overrides_setting() { .success(); } +#[test] +fn working_directory_setting_conflicts_with_no_cd_setting() { + Test::new() + .justfile( + " + set working-directory := 'bar' + set no-cd := true + ", + ) + .stderr( + "error: Setting `working-directory` first set on line 1 is incompatible with setting `no-cd`\n ——▶ justfile:2:5\n │\n2 │ set no-cd := true\n │ ^^^^^\n", + ) + .failure(); +} + #[test] fn working_dir_in_submodule_is_relative_to_module_path() { Test::new() From 035244a36e88a0c524ee71f036caa9dac71dbc0b Mon Sep 17 00:00:00 2001 From: Hans Donner Date: Thu, 23 Apr 2026 13:38:30 +0200 Subject: [PATCH 02/18] Revert lingering previous changes --- README.md | 2 +- src/execution_context.rs | 11 +++-------- tests/settings.rs | 1 + 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b57f200ba4..a2e3ffc9a5 100644 --- a/README.md +++ b/README.md @@ -1049,7 +1049,7 @@ foo: | `ignore-comments` | boolean | `false` | Ignore recipe lines beginning with `#`. | | `no-exit-message`1.39.0 | boolean | `false` | Don't print exit messages if recipes fail. | | `lazy`1.47.0 | boolean | `false` | Don't evaluate unused variables. | -| `no-cd` | boolean | `false` | Don't change directory before executing recipes and evaluating backticks, unless overridden by recipe attributes. | +| `no-cd` | boolean | `false` | Don't change directory before executing recipes, unless overridden by recipe attributes. | | `positional-arguments` | boolean | `false` | Pass positional arguments. | | `quiet` | boolean | `false` | Disable echoing recipe lines before executing. | | `script-interpreter`1.33.0 | `[COMMAND, ARGS…]` | `['sh', '-eu']` | Set command used to invoke recipes with empty `[script]` attribute. | diff --git a/src/execution_context.rs b/src/execution_context.rs index 77c7afeab0..6097a54056 100644 --- a/src/execution_context.rs +++ b/src/execution_context.rs @@ -41,20 +41,15 @@ impl<'src: 'run, 'run> ExecutionContext<'src, 'run> { pub(crate) fn working_directory(&self) -> PathBuf { let base = if self.module.is_submodule() { - self - .module - .source - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| self.search.working_directory.clone()) + &self.module.working_directory } else { - self.search.working_directory.clone() + &self.search.working_directory }; if let Some(setting) = &self.module.settings.working_directory { base.join(setting) } else { - base + base.into() } } } diff --git a/tests/settings.rs b/tests/settings.rs index fa444c3660..cbf0cd631a 100644 --- a/tests/settings.rs +++ b/tests/settings.rs @@ -299,6 +299,7 @@ fn no_cd_setting_changes_default_recipe_execution() { .success(); } +#[test] fn unused_non_const_assignments() { Test::new() .justfile( From e0d12c01fac4396de8c64f1a416c79c61c60590f Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:23:34 -0700 Subject: [PATCH 03/18] Sort compile error variants --- src/compile_error_kind.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index 1e14b6e152..456778ba78 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -114,17 +114,17 @@ pub(crate) enum CompileErrorKind<'src> { NoCdAndWorkingDirectoryAttribute { recipe: &'src str, }, + NoCdAndWorkingDirectorySetting { + first: Keyword, + first_line: usize, + second: Keyword, + }, OptionNameContainsEqualSign { parameter: String, }, OptionNameEmpty { parameter: String, }, - NoCdAndWorkingDirectorySetting { - first: Keyword, - first_line: usize, - second: Keyword, - }, ParameterFollowsVariadicParameter { parameter: &'src str, }, From a5a407ef3dbc1400bc363373fd3b6999dec4cbd8 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:26:01 -0700 Subject: [PATCH 04/18] keyword -> second --- src/analyzer.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/analyzer.rs b/src/analyzer.rs index ef73f25337..f52e098b51 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -417,14 +417,14 @@ impl<'run, 'src> Analyzer<'run, 'src> { })); } - if let Some(keyword) = Keyword::from_lexeme(set.name.lexeme()) { - match keyword { + if let Some(second) = Keyword::from_lexeme(set.name.lexeme()) { + match second { Keyword::NoCd => { if let Some(conflict) = self.sets.get(Keyword::WorkingDirectory.lexeme()) { return Err(set.name.error(NoCdAndWorkingDirectorySetting { first: Keyword::WorkingDirectory, first_line: conflict.name.line, - second: keyword, + second, })); } } @@ -433,7 +433,7 @@ impl<'run, 'src> Analyzer<'run, 'src> { return Err(set.name.error(NoCdAndWorkingDirectorySetting { first: Keyword::NoCd, first_line: conflict.name.line, - second: keyword, + second, })); } } From e6c26dc0ea0b68e46a66b465a14606cf4657c4b4 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:30:22 -0700 Subject: [PATCH 05/18] Reform --- src/analyzer.rs | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/analyzer.rs b/src/analyzer.rs index f52e098b51..1125d29247 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -418,26 +418,20 @@ impl<'run, 'src> Analyzer<'run, 'src> { } if let Some(second) = Keyword::from_lexeme(set.name.lexeme()) { - match second { - Keyword::NoCd => { - if let Some(conflict) = self.sets.get(Keyword::WorkingDirectory.lexeme()) { - return Err(set.name.error(NoCdAndWorkingDirectorySetting { - first: Keyword::WorkingDirectory, - first_line: conflict.name.line, - second, - })); - } - } - Keyword::WorkingDirectory => { - if let Some(conflict) = self.sets.get(Keyword::NoCd.lexeme()) { - return Err(set.name.error(NoCdAndWorkingDirectorySetting { - first: Keyword::NoCd, - first_line: conflict.name.line, - second, - })); - } + let first = match second { + Keyword::NoCd => Keyword::WorkingDirectory, + Keyword::WorkingDirectory => Keyword::NoCd, + _ => { + return Ok(()); } - _ => {} + }; + + if let Some(conflict) = self.sets.get(first.lexeme()) { + return Err(set.name.error(NoCdAndWorkingDirectorySetting { + first, + first_line: conflict.name.line, + second, + })); } } From 93ddc89297fda3719928babd1b6b6a7245a2b172 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:33:16 -0700 Subject: [PATCH 06/18] Tweak --- src/recipe.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/recipe.rs b/src/recipe.rs index f8799820fa..995448ce95 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -196,7 +196,7 @@ impl<'src> Recipe<'src> { } } - pub(crate) fn working_directory<'a>(&'a self, context: &'a ExecutionContext) -> Option { + fn working_directory<'a>(&'a self, context: &'a ExecutionContext) -> Option { let module_working_directory = context.working_directory(); for attribute in &self.attributes { From 9017f5e00e4d6d520842b57b3540d9a8aa0a40b4 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:33:54 -0700 Subject: [PATCH 07/18] Revert name change --- src/recipe.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/recipe.rs b/src/recipe.rs index 995448ce95..f5f12ad299 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -197,11 +197,11 @@ impl<'src> Recipe<'src> { } fn working_directory<'a>(&'a self, context: &'a ExecutionContext) -> Option { - let module_working_directory = context.working_directory(); + let working_directory = context.working_directory(); for attribute in &self.attributes { if let Attribute::WorkingDirectory(dir) = attribute { - return Some(module_working_directory.join(&dir.cooked)); + return Some(working_directory.join(&dir.cooked)); } } @@ -209,7 +209,7 @@ impl<'src> Recipe<'src> { return None; } - Some(module_working_directory) + Some(working_directory) } fn no_quiet(&self) -> bool { From 4e6bf7eef882244cd27a10dd25909600e34337b7 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:35:08 -0700 Subject: [PATCH 08/18] Revert another no-op change --- src/recipe.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/recipe.rs b/src/recipe.rs index f5f12ad299..f897558d1a 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -197,6 +197,10 @@ impl<'src> Recipe<'src> { } fn working_directory<'a>(&'a self, context: &'a ExecutionContext) -> Option { + if !self.change_directory(&context.module.settings) { + return None; + } + let working_directory = context.working_directory(); for attribute in &self.attributes { @@ -205,10 +209,6 @@ impl<'src> Recipe<'src> { } } - if !self.change_directory(&context.module.settings) { - return None; - } - Some(working_directory) } From 3d220dc9892fdf7d4c5f8f248fc1966d590ebc8f Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:37:49 -0700 Subject: [PATCH 09/18] Fix modules_do_not_inherit_no_cd_setting --- tests/modules.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/modules.rs b/tests/modules.rs index 0253376dcf..6dc1bf9cbe 100644 --- a/tests/modules.rs +++ b/tests/modules.rs @@ -219,12 +219,12 @@ foo: fn modules_do_not_inherit_no_cd_setting() { Test::new() .write( - "foo.just", + "foo/mod.just", "bar: @cat data.txt ", ) - .write("data.txt", "MODULE\n") + .write("foo/data.txt", "MODULE\n") .justfile( " set no-cd := true From c3f4de5f260c486ca48b396ed8da1316982b8f64 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:38:07 -0700 Subject: [PATCH 10/18] Revise --- tests/modules.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/modules.rs b/tests/modules.rs index 6dc1bf9cbe..af746534a0 100644 --- a/tests/modules.rs +++ b/tests/modules.rs @@ -216,7 +216,7 @@ foo: } #[test] -fn modules_do_not_inherit_no_cd_setting() { +fn submodules_do_not_inherit_no_cd_setting() { Test::new() .write( "foo/mod.just", From 1f5d98d69dace9ae584ad32d204434b78e1602b3 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:39:21 -0700 Subject: [PATCH 11/18] Adjust --- tests/no_cd.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/no_cd.rs b/tests/no_cd.rs index 395e0d1762..aed08f3cf8 100644 --- a/tests/no_cd.rs +++ b/tests/no_cd.rs @@ -87,14 +87,14 @@ fn working_directory_attribute_overrides_setting() { fn paths_stay_module_dir_without_strict() { Test::new() .justfile( - r#" - set no-cd := true + " + set no-cd := true - file := `cat data.txt` + file := `cat data.txt` - @foo: - echo {{file}} - "#, + @foo: + echo {{file}} + ", ) .current_dir("inv") .write("data.txt", "MODULE") From 16a6784980cb16124542fc9f851ecc079b9461b2 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:39:43 -0700 Subject: [PATCH 12/18] Enhance --- tests/no_cd.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/no_cd.rs b/tests/no_cd.rs index aed08f3cf8..144b2b4f3b 100644 --- a/tests/no_cd.rs +++ b/tests/no_cd.rs @@ -47,11 +47,11 @@ fn setting_applies_to_recipes() { Test::new() .justfile( " - set no-cd := true + set no-cd := true - foo: - cat bar - ", + foo: + cat bar + ", ) .current_dir("child") .tree(tree! { @@ -70,12 +70,12 @@ fn working_directory_attribute_overrides_setting() { Test::new() .justfile( " - set no-cd := true + set no-cd := true - [working-directory('workspace')] - foo: - cat data.txt - ", + [working-directory('workspace')] + foo: + cat data.txt + ", ) .write("workspace/data.txt", "WORKSPACE") .stderr("cat data.txt\n") From 864ad9ac52c1dd793095798c40e5cf34c5cef756 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:41:14 -0700 Subject: [PATCH 13/18] Tweak --- tests/no_cd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/no_cd.rs b/tests/no_cd.rs index 144b2b4f3b..1df3336193 100644 --- a/tests/no_cd.rs +++ b/tests/no_cd.rs @@ -84,7 +84,7 @@ fn working_directory_attribute_overrides_setting() { } #[test] -fn paths_stay_module_dir_without_strict() { +fn evaluator_paths_ignore_setting() { Test::new() .justfile( " From ef931fafa3685fc4a7ca54e35c118b1081ca8518 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:41:50 -0700 Subject: [PATCH 14/18] Adapt --- tests/settings.rs | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/tests/settings.rs b/tests/settings.rs index 249c0130a8..fc1dbe7c9f 100644 --- a/tests/settings.rs +++ b/tests/settings.rs @@ -276,29 +276,6 @@ fn no_cd_setting_conflicts_with_working_directory_setting() { .failure(); } -#[test] -fn no_cd_setting_changes_default_recipe_execution() { - Test::new() - .justfile( - " - set no-cd := true - - foo: - cat bar - ", - ) - .current_dir("child") - .tree(tree! { - bar: "root", - child: { - bar: "child", - } - }) - .stderr("cat bar\n") - .stdout("child") - .success(); -} - #[test] fn unused_non_const_assignments() { Test::new() From 40f9339a518ec850931fd3a0bc1d6ad4cbc068fb Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:44:24 -0700 Subject: [PATCH 15/18] Modify --- src/compile_error.rs | 2 +- tests/settings.rs | 10 ++++++++-- tests/working_directory.rs | 8 +++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/compile_error.rs b/src/compile_error.rs index 721d18a79a..1dd15ed4f0 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -260,7 +260,7 @@ impl Display for CompileError<'_> { second, } => write!( f, - "Setting `{}` first set on line {} is incompatible with setting `{}`", + "`{}` set on line {} is incompatible with `{}`", first.lexeme(), first_line.ordinal(), second.lexeme() diff --git a/tests/settings.rs b/tests/settings.rs index fc1dbe7c9f..80eaa662a2 100644 --- a/tests/settings.rs +++ b/tests/settings.rs @@ -270,8 +270,14 @@ fn no_cd_setting_conflicts_with_working_directory_setting() { set working-directory := 'bar' ", ) - .stderr_regex( - "error: Setting `no-cd` first set on line 1 is incompatible with setting `working-directory`\n[\\s\\S]*", + .stderr( + " + error: `no-cd` set on line 1 is incompatible with `working-directory` + ——▶ justfile:2:5 + │ + 2 │ set working-directory := 'bar' + │ ^^^^^^^^^^^^^^^^^ + ", ) .failure(); } diff --git a/tests/working_directory.rs b/tests/working_directory.rs index 155d6662c2..ff94bb36be 100644 --- a/tests/working_directory.rs +++ b/tests/working_directory.rs @@ -245,7 +245,13 @@ fn working_directory_setting_conflicts_with_no_cd_setting() { ", ) .stderr( - "error: Setting `working-directory` first set on line 1 is incompatible with setting `no-cd`\n ——▶ justfile:2:5\n │\n2 │ set no-cd := true\n │ ^^^^^\n", + " + error: `working-directory` set on line 1 is incompatible with `no-cd` + ——▶ justfile:2:5 + │ + 2 │ set no-cd := true + │ ^^^^^ + ", ) .failure(); } From ff3d1e7c406211e7cfe49700587d6349a7b9860f Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:45:26 -0700 Subject: [PATCH 16/18] Enhance --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 07a5dd0805..61b39db316 100644 --- a/README.md +++ b/README.md @@ -1049,7 +1049,7 @@ foo: | `ignore-comments` | boolean | `false` | Ignore recipe lines beginning with `#`. | | `no-exit-message`1.39.0 | boolean | `false` | Don't print exit messages if recipes fail. | | `lazy`1.47.0 | boolean | `false` | Don't evaluate unused variables. | -| `no-cd` | boolean | `false` | Don't change directory before executing recipes, unless overridden by recipe attributes. | +| `no-cd`master | boolean | `false` | Don't change directory when executing recipes by recipe attribute. | | `positional-arguments` | boolean | `false` | Pass positional arguments. | | `quiet` | boolean | `false` | Disable echoing recipe lines before executing. | | `script-interpreter`1.33.0 | `[COMMAND, ARGS…]` | `['sh', '-eu']` | Set command used to invoke recipes with empty `[script]` attribute. | From 84594fb283a45d695c887ae59c619f2e81fcbb51 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:48:04 -0700 Subject: [PATCH 17/18] Amend --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 61b39db316..40824d8cfc 100644 --- a/README.md +++ b/README.md @@ -950,11 +950,11 @@ $ just bar /subdir ``` -To apply the same behavior to every recipe in a module, use `set no-cd := true`. -This setting is module-local, so imported modules choose their own default, and -it can't appear alongside `set working-directory` in the same `justfile`. -Recipe-level attributes still take precedence: `[working-directory(...)]` -overrides both, and `[no-cd]` on a recipe overrides `set working-directory`. +Use `set no-cd`master to make all recipes in the current module +default to the same behavior. + +`set no-cd` and `set working-directory` can be overridden on a per-recipe basis +with the `[no-cd]` and `[working-directory]` attributes. You can override the working directory for all recipes with `set working-directory := '…'`: From b78c1e2196db72eddac9f00c4b55567aec2b475c Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Apr 2026 19:50:06 -0700 Subject: [PATCH 18/18] Tweak --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 40824d8cfc..06786e247b 100644 --- a/README.md +++ b/README.md @@ -1049,7 +1049,7 @@ foo: | `ignore-comments` | boolean | `false` | Ignore recipe lines beginning with `#`. | | `no-exit-message`1.39.0 | boolean | `false` | Don't print exit messages if recipes fail. | | `lazy`1.47.0 | boolean | `false` | Don't evaluate unused variables. | -| `no-cd`master | boolean | `false` | Don't change directory when executing recipes by recipe attribute. | +| `no-cd`master | boolean | `false` | Don't change directory when executing recipes by recipe attribute. | | `positional-arguments` | boolean | `false` | Pass positional arguments. | | `quiet` | boolean | `false` | Disable echoing recipe lines before executing. | | `script-interpreter`1.33.0 | `[COMMAND, ARGS…]` | `['sh', '-eu']` | Set command used to invoke recipes with empty `[script]` attribute. |