Repository: casey/just
Pull request #1846 (2024-01-14 10:30:03)

Global justfile
Allow the `-g` or `--global` flag to reference a global `justfile` in the user's home directory.

Resolves https://github.com/casey/just/issues/1648

neunenak: One thing that's probably worth discussing is whether the default justfile path should be `$HOME/justfile` , `$HOME/.justfile`, or `$XDG_CONFIG_DIR/just/justfile`. Using bare dotfiles in the homedir seems like it's falling out of fashion these days, and for good reason, so really it's just a choice between `$HOME/justfile` and `$XDG_CONFIG_DIR/just/justfile`.  Or if it should search for both (and in what order) and just use the first one it finds.
casey: I personally prefer dotfiles in my homedir, since it's easier to edit than the XDG config dir, which on a mac is in ~/Library/Application Support` which is very verbose. Also, the cost of checking an additional location is minimal. So I'd do `~/.justfile`, `~/justfile`, and then `$XDG_CONFIG_DIR/just/global.just`, although I'm not sure in which order.
neunenak: @casey this is ready for review
casey: Some notes:

- I removed the ability to initialize the global justfile. I think it has a bug, where if the config directory doesn't exist, it will throw an I/O error.

- This could use a test, which sets XDG_CONFIG_HOME to a tempdir with a justfile

- What do you think about calling the global justfile in the config dir `justfile` instead of `global.justfile`?

- Why not use `dirs::config_dir()` instead of looking up `XDG_CONFIG_HOME` manually?
neunenak: > Some notes:
> 
>     * I removed the ability to initialize the global justfile. I think it has a bug, where if the config directory doesn't exist, it will throw an I/O error.

Might be nice to have the ability to initialize a global justfile, but that's additional functionality that can come in a subsequent PR. 
> 
>     * This could use a test, which sets XDG_CONFIG_HOME to a tempdir with a justfile

Makes sense, I'll try to add this shortly (probably not tonight). 

>     * What do you think about calling the global justfile in the config dir `justfile` instead of `global.justfile`?

Fine by me.

> 
>     * Why not use `dirs::config_dir()` instead of looking up `XDG_CONFIG_HOME` manually?

Ah I didn't realize there was already code that this.
neunenak: @casey looks like the macos CI job is failing with no error message. I'm not sure if this is a problem with these changes or CI.
neunenak: > @casey looks like the macos CI job is failing with no error message. I'm not sure if this is a problem with these changes or CI.

I think what I figured out was that setting `XDG_CONFIG_DIR` in the test for this functionality only works on linux, so I disabled the test for other OSs. Not sure if there's a way to fake the configuration directory for those OSs.
casey: > I think what I figured out was that setting `XDG_CONFIG_DIR` in the test for this functionality only works on linux, so I disabled the test for other OSs. Not sure if there's a way to fake the configuration directory for those OSs.

I think it's fine not to test other OSs, since the only way something could go wrong is if there was a bug in dirs.
casey: LGTM! I made the flag `--global-justfile`, since there's already `-g` for short, and `--global` seemed ambiguous. I also made it conflict with `--justfile` and `--working-directory`. Support for `--working-directory` can be added in a future PR, since they could work together.

Patch:
diff --git a/README.md b/README.md
index cb643ce5..97b7d54c 100644
--- a/README.md
+++ b/README.md
@@ -3268,13 +3268,30 @@ Before `just` was a fancy Rust program it was a tiny shell script that called
 `make`. You can find the old version in
 [contrib/just.sh](https://github.com/casey/just/blob/master/contrib/just.sh).
 
-### User `justfile`s
+### Global and User `justfile`s
 
 If you want some recipes to be available everywhere, you have a few options.
 
-First, create a `justfile` in `~/.user.justfile` with some recipes.
+#### Global Justfile
 
-#### Recipe Aliases
+`just --global-justfile`, or `just -g` for short, searches the following paths,
+in-order, for a justfile:
+
+- `$XDG_CONFIG_HOME/just/justfile`
+- `$HOME/.config/just/justfile`
+- `$HOME/justfile`
+- `$HOME/.justfile`
+
+You can put recipes that are used across many projects in a global justfile to
+easily invoke them from any directory.
+
+#### User justfile tips
+
+You can also adopt some of the following workflows. These tips assume you've
+created a `justfile` at `~/.user.justfile`, but you can put this `justfile`
+at any convenient path on your system.
+
+##### Recipe Aliases
 
 If you want to call the recipes in `~/.user.justfile` by name, and don't mind
 creating an alias for every recipe, add the following to your shell's
@@ -3293,7 +3310,7 @@ It took me way too long to realize that you could create recipe aliases like
 this. Notwithstanding my tardiness, I am very pleased to bring you this major
 advance in `justfile` technology.
 
-#### Forwarding Alias
+##### Forwarding Alias
 
 If you'd rather not create aliases for every recipe, you can create a single alias:
 
@@ -3308,7 +3325,7 @@ I'm pretty sure that nobody actually uses this feature, but it's there.
 
 ¯\\\_(ツ)\_/¯
 
-#### Customization
+##### Customization
 
 You can customize the above aliases with additional options. For example, if
 you'd prefer to have the recipes in your `justfile` run in your home directory,
diff --git a/completions/just.bash b/completions/just.bash
index 7006dba8..abfa86de 100644
--- a/completions/just.bash
+++ b/completions/just.bash
@@ -30,7 +30,7 @@ _just() {
 
     case "${cmd}" in
         "$1")
-            opts="-n -f -q -u -v -d -c -e -l -s -E -h -V --check --chooser --color --command-color --yes --dry-run --dump-format --highlight --list-heading --list-prefix --no-aliases --no-deps --no-dotenv --no-highlight --justfile --quiet --set --shell --shell-arg --shell-command --clear-shell-args --unsorted --unstable --verbose --working-directory --changelog --choose --command --completions --dump --edit --evaluate --fmt --init --list --man --show --summary --variables --dotenv-filename --dotenv-path --help --version [ARGUMENTS]..."
+            opts="-n -f -q -u -v -d -c -e -l -s -E -g -h -V --check --chooser --color --command-color --yes --dry-run --dump-format --highlight --list-heading --list-prefix --no-aliases --no-deps --no-dotenv --no-highlight --justfile --quiet --set --shell --shell-arg --shell-command --clear-shell-args --unsorted --unstable --verbose --working-directory --changelog --choose --command --completions --dump --edit --evaluate --fmt --init --list --man --show --summary --variables --dotenv-filename --dotenv-path --global-justfile --help --version [ARGUMENTS]..."
                 if [[ ${cur} == -* ]] ; then
                     COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
                     return 0
diff --git a/completions/just.elvish b/completions/just.elvish
index 461b75ae..e1f29d9c 100644
--- a/completions/just.elvish
+++ b/completions/just.elvish
@@ -69,6 +69,8 @@ set edit:completion:arg-completer[just] = {|@words|
             cand --man 'Print man page'
             cand --summary 'List names of available recipes'
             cand --variables 'List names of variables'
+            cand -g 'Use global justfile'
+            cand --global-justfile 'Use global justfile'
             cand -h 'Print help'
             cand --help 'Print help'
             cand -V 'Print version'
diff --git a/completions/just.fish b/completions/just.fish
index 073820af..8ff8f0c9 100644
--- a/completions/just.fish
+++ b/completions/just.fish
@@ -76,5 +76,6 @@ complete -c just -s l -l list -d 'List available recipes and their arguments'
 complete -c just -l man -d 'Print man page'
 complete -c just -l summary -d 'List names of available recipes'
 complete -c just -l variables -d 'List names of variables'
+complete -c just -s g -l global-justfile -d 'Use global justfile'
 complete -c just -s h -l help -d 'Print help'
 complete -c just -s V -l version -d 'Print version'
diff --git a/completions/just.powershell b/completions/just.powershell
index 2b02718f..e19f9fb5 100644
--- a/completions/just.powershell
+++ b/completions/just.powershell
@@ -72,6 +72,8 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock {
             [CompletionResult]::new('--man', 'man', [CompletionResultType]::ParameterName, 'Print man page')
             [CompletionResult]::new('--summary', 'summary', [CompletionResultType]::ParameterName, 'List names of available recipes')
             [CompletionResult]::new('--variables', 'variables', [CompletionResultType]::ParameterName, 'List names of variables')
+            [CompletionResult]::new('-g', 'g', [CompletionResultType]::ParameterName, 'Use global justfile')
+            [CompletionResult]::new('--global-justfile', 'global-justfile', [CompletionResultType]::ParameterName, 'Use global justfile')
             [CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help')
             [CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help')
             [CompletionResult]::new('-V', 'V ', [CompletionResultType]::ParameterName, 'Print version')
diff --git a/completions/just.zsh b/completions/just.zsh
index 0b79adba..715f5b5b 100644
--- a/completions/just.zsh
+++ b/completions/just.zsh
@@ -67,6 +67,8 @@ _just() {
 '--man[Print man page]' \
 '--summary[List names of available recipes]' \
 '--variables[List names of variables]' \
+'(-f --justfile -d --working-directory)-g[Use global justfile]' \
+'(-f --justfile -d --working-directory)--global-justfile[Use global justfile]' \
 '-h[Print help]' \
 '--help[Print help]' \
 '-V[Print version]' \
diff --git a/src/config.rs b/src/config.rs
index 53b76aac..b657b18d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -94,6 +94,7 @@ mod arg {
   pub(crate) const DOTENV_PATH: &str = "DOTENV-PATH";
   pub(crate) const DRY_RUN: &str = "DRY-RUN";
   pub(crate) const DUMP_FORMAT: &str = "DUMP-FORMAT";
+  pub(crate) const GLOBAL_JUSTFILE: &str = "GLOBAL_JUSTFILE";
   pub(crate) const HIGHLIGHT: &str = "HIGHLIGHT";
   pub(crate) const JUSTFILE: &str = "JUSTFILE";
   pub(crate) const LIST_HEADING: &str = "LIST-HEADING";
@@ -465,6 +466,15 @@ impl Config {
           .action(ArgAction::Append)
           .help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"),
       )
+    .arg(
+      Arg::new(arg::GLOBAL_JUSTFILE)
+      .action(ArgAction::SetTrue)
+      .long("global-justfile")
+      .short('g')
+      .conflicts_with(arg::JUSTFILE)
+      .conflicts_with(arg::WORKING_DIRECTORY)
+      .help("Use global justfile")
+    )
   }
 
   fn color_from_matches(matches: &ArgMatches) -> ConfigResult<Color> {
@@ -520,6 +530,39 @@ impl Config {
     }
   }
 
+  fn search_config(matches: &ArgMatches, positional: &Positional) -> ConfigResult<SearchConfig> {
+    if matches.get_flag(arg::GLOBAL_JUSTFILE) {
+      return Ok(SearchConfig::GlobalJustfile);
+    }
+
+    let justfile = matches.get_one::<PathBuf>(arg::JUSTFILE).map(Into::into);
+
+    let working_directory = matches
+      .get_one::<PathBuf>(arg::WORKING_DIRECTORY)
+      .map(Into::into);
+
+    if let Some(search_directory) = positional.search_directory.as_ref().map(PathBuf::from) {
+      if justfile.is_some() || working_directory.is_some() {
+        return Err(ConfigError::SearchDirConflict);
+      }
+      Ok(SearchConfig::FromSearchDirectory { search_directory })
+    } else {
+      match (justfile, working_directory) {
+        (None, None) => Ok(SearchConfig::FromInvocationDirectory),
+        (Some(justfile), None) => Ok(SearchConfig::WithJustfile { justfile }),
+        (Some(justfile), Some(working_directory)) => {
+          Ok(SearchConfig::WithJustfileAndWorkingDirectory {
+            justfile,
+            working_directory,
+          })
+        }
+        (None, Some(_)) => Err(ConfigError::internal(
+          "--working-directory set without --justfile",
+        )),
+      }
+    }
+  }
+
   pub(crate) fn from_matches(matches: &ArgMatches) -> ConfigResult<Self> {
     let invocation_directory = env::current_dir().context(config_error::CurrentDirContext)?;
 
@@ -545,39 +588,11 @@ impl Config {
         .map(|s| s.map(String::as_str)),
     );
 
-    for (name, value) in positional.overrides {
+    for (name, value) in &positional.overrides {
       overrides.insert(name.clone(), value.clone());
     }
 
-    let search_config = {
-      let justfile = matches.get_one::<PathBuf>(arg::JUSTFILE).map(Into::into);
-      let working_directory = matches
-        .get_one::<PathBuf>(arg::WORKING_DIRECTORY)
-        .map(Into::into);
-
-      if let Some(search_directory) = positional.search_directory.map(PathBuf::from) {
-        if justfile.is_some() || working_directory.is_some() {
-          return Err(ConfigError::SearchDirConflict);
-        }
-        SearchConfig::FromSearchDirectory { search_directory }
-      } else {
-        match (justfile, working_directory) {
-          (None, None) => SearchConfig::FromInvocationDirectory,
-          (Some(justfile), None) => SearchConfig::WithJustfile { justfile },
-          (Some(justfile), Some(working_directory)) => {
-            SearchConfig::WithJustfileAndWorkingDirectory {
-              justfile,
-              working_directory,
-            }
-          }
-          (None, Some(_)) => {
-            return Err(ConfigError::internal(
-              "--working-directory set without --justfile",
-            ))
-          }
-        }
-      }
-    };
+    let search_config = Self::search_config(matches, &positional)?;
 
     for subcommand in cmd::ARGLESS {
       if matches.get_flag(subcommand) {
diff --git a/src/search.rs b/src/search.rs
index c14eb553..50854195 100644
--- a/src/search.rs
+++ b/src/search.rs
@@ -1,7 +1,7 @@
 use {super::*, std::path::Component};
 
 const DEFAULT_JUSTFILE_NAME: &str = JUSTFILE_NAMES[0];
-pub(crate) const JUSTFILE_NAMES: &[&str] = &["justfile", ".justfile"];
+pub(crate) const JUSTFILE_NAMES: [&str; 2] = ["justfile", ".justfile"];
 const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"];
 
 pub(crate) struct Search {
@@ -10,6 +10,29 @@ pub(crate) struct Search {
 }
 
 impl Search {
+  fn global_justfile_paths() -> Vec<PathBuf> {
+    let mut paths = Vec::new();
+
+    if let Some(config_dir) = dirs::config_dir() {
+      paths.push(config_dir.join("just").join(DEFAULT_JUSTFILE_NAME));
+    }
+
+    if let Some(home_dir) = dirs::home_dir() {
+      paths.push(
+        home_dir
+          .join(".config")
+          .join("just")
+          .join(DEFAULT_JUSTFILE_NAME),
+      );
+
+      for justfile_name in JUSTFILE_NAMES {
+        paths.push(home_dir.join(justfile_name));
+      }
+    }
+
+    paths
+  }
+
   pub(crate) fn find(
     search_config: &SearchConfig,
     invocation_directory: &Path,
@@ -18,21 +41,24 @@ impl Search {
       SearchConfig::FromInvocationDirectory => Self::find_next(invocation_directory),
       SearchConfig::FromSearchDirectory { search_directory } => {
         let search_directory = Self::clean(invocation_directory, search_directory);
-
         let justfile = Self::justfile(&search_directory)?;
-
         let working_directory = Self::working_directory_from_justfile(&justfile)?;
-
         Ok(Self {
           justfile,
           working_directory,
         })
       }
+      SearchConfig::GlobalJustfile => Ok(Self {
+        justfile: Self::global_justfile_paths()
+          .iter()
+          .find(|path| path.exists())
+          .cloned()
+          .ok_or(SearchError::GlobalJustfileNotFound)?,
+        working_directory: Self::project_root(invocation_directory)?,
+      }),
       SearchConfig::WithJustfile { justfile } => {
         let justfile = Self::clean(invocation_directory, justfile);
-
         let working_directory = Self::working_directory_from_justfile(&justfile)?;
-
         Ok(Self {
           justfile,
           working_directory,
@@ -50,9 +76,7 @@ impl Search {
 
   pub(crate) fn find_next(starting_dir: &Path) -> SearchResult<Self> {
     let justfile = Self::justfile(starting_dir)?;
-
     let working_directory = Self::working_directory_from_justfile(&justfile)?;
-
     Ok(Self {
       justfile,
       working_directory,
@@ -66,39 +90,30 @@ impl Search {
     match search_config {
       SearchConfig::FromInvocationDirectory => {
         let working_directory = Self::project_root(invocation_directory)?;
-
         let justfile = working_directory.join(DEFAULT_JUSTFILE_NAME);
-
         Ok(Self {
           justfile,
           working_directory,
         })
       }
-
       SearchConfig::FromSearchDirectory { search_directory } => {
         let search_directory = Self::clean(invocation_directory, search_directory);
-
         let working_directory = Self::project_root(&search_directory)?;
-
         let justfile = working_directory.join(DEFAULT_JUSTFILE_NAME);
-
         Ok(Self {
           justfile,
           working_directory,
         })
       }
-
+      SearchConfig::GlobalJustfile => Err(SearchError::GlobalJustfileInit),
       SearchConfig::WithJustfile { justfile } => {
         let justfile = Self::clean(invocation_directory, justfile);
-
         let working_directory = Self::working_directory_from_justfile(&justfile)?;
-
         Ok(Self {
           justfile,
           working_directory,
         })
       }
-
       SearchConfig::WithJustfileAndWorkingDirectory {
         justfile,
         working_directory,
diff --git a/src/search_config.rs b/src/search_config.rs
index 3af178a3..fa447e4d 100644
--- a/src/search_config.rs
+++ b/src/search_config.rs
@@ -9,6 +9,8 @@ pub(crate) enum SearchConfig {
   FromInvocationDirectory,
   /// As in `Invocation`, but start from `search_directory`.
   FromSearchDirectory { search_directory: PathBuf },
+  /// Search for global justfile
+  GlobalJustfile,
   /// Use user-specified justfile, with the working directory set to the
   /// directory that contains it.
   WithJustfile { justfile: PathBuf },
diff --git a/src/search_error.rs b/src/search_error.rs
index 3ee783ec..988703df 100644
--- a/src/search_error.rs
+++ b/src/search_error.rs
@@ -3,6 +3,10 @@ use super::*;
 #[derive(Debug, Snafu)]
 #[snafu(visibility(pub(crate)))]
 pub(crate) enum SearchError {
+  #[snafu(display("Cannot initialize global justfile"))]
+  GlobalJustfileInit,
+  #[snafu(display("Global justfile not found"))]
+  GlobalJustfileNotFound,
   #[snafu(display(
     "I/O error reading directory `{}`: {}",
     directory.display(),
diff --git a/src/source.rs b/src/source.rs
index 8826b4d5..1da7a5f7 100644
--- a/src/source.rs
+++ b/src/source.rs
@@ -1,5 +1,6 @@
 use super::*;
 
+#[derive(Debug)]
 pub(crate) struct Source<'src> {
   pub(crate) file_depth: u32,
   pub(crate) namepath: Namepath<'src>,


Test patch:
diff --git a/tests/global.rs b/tests/global.rs
new file mode 100644
index 00000000..b6e7caf1
--- /dev/null
+++ b/tests/global.rs
@@ -0,0 +1,65 @@
+use super::*;
+
+#[test]
+#[cfg(target_os = "macos")]
+fn macos() {
+  let tempdir = tempdir();
+
+  let path = tempdir.path().to_owned();
+
+  Test::with_tempdir(tempdir)
+    .no_justfile()
+    .test_round_trip(false)
+    .write(
+      "Library/Application Support/just/justfile",
+      "@default:\n  echo foo",
+    )
+    .env("HOME", path.to_str().unwrap())
+    .args(["--global-justfile"])
+    .stdout("foo\n")
+    .run();
+}
+
+#[test]
+#[cfg(all(unix, not(target_os = "macos")))]
+fn not_macos() {
+  let tempdir = tempdir();
+
+  let path = tempdir.path().to_owned();
+
+  Test::with_tempdir(tempdir)
+    .no_justfile()
+    .test_round_trip(false)
+    .write("just/justfile", "@default:\n  echo foo")
+    .env("XDG_CONFIG_HOME", path.to_str().unwrap())
+    .args(["--global-justfile"])
+    .stdout("foo\n")
+    .run();
+}
+
+#[test]
+#[cfg(unix)]
+fn unix() {
+  let tempdir = tempdir();
+
+  let path = tempdir.path().to_owned();
+
+  let tempdir = Test::with_tempdir(tempdir)
+    .no_justfile()
+    .test_round_trip(false)
+    .write("justfile", "@default:\n  echo foo")
+    .env("HOME", path.to_str().unwrap())
+    .args(["--global-justfile"])
+    .stdout("foo\n")
+    .run()
+    .tempdir;
+
+  Test::with_tempdir(tempdir)
+    .no_justfile()
+    .test_round_trip(false)
+    .write(".config/just/justfile", "@default:\n  echo bar")
+    .env("HOME", path.to_str().unwrap())
+    .args(["--global-justfile"])
+    .stdout("bar\n")
+    .run();
+}
diff --git a/tests/lib.rs b/tests/lib.rs
index 14401172..806cdca6 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -59,6 +59,8 @@ mod export;
 mod fallback;
 mod fmt;
 mod functions;
+#[cfg(unix)]
+mod global;
 mod ignore_comments;
 mod imports;
 mod init;