Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
374 changes: 374 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ derive-where = "1.2.7"
dirs = "6.0.0"
dotenvy = "0.15"
edit-distance = "2.0.0"
git2 = "0.19"
heck = "0.5.0"
is_executable = "1.0.4"
lexiclean = "0.0.1"
Expand Down
59 changes: 56 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3619,8 +3619,8 @@ A
```

The `import` path can be absolute or relative to the location of the justfile
containing it. A leading `~/` in the import path is replaced with the current
users home directory.
containing it, or it can be a Git repository URL for remote imports.
A leading `~/` in the import path is replaced with the current users home directory.

Justfiles are insensitive to order, so included files can reference variables
and recipes defined after the `import` statement.
Expand Down Expand Up @@ -4319,7 +4319,60 @@ bar_unix := shell('cygpath --unix $1', bar_windows)

### Remote Justfiles

If you wish to include a `mod` or `import` source file in many `justfiles`
You can import `justfiles` directly from Git repositories using `import` statements with Git URLs. Just automatically clones the repository to a stable cache directory and imports the specified file.

#### Git Repository Imports

Import a justfile from a Git repository:

`import 'https://github.com/user/repo.git'`

Import a specific file from a Git repository by appending `@path/to/file`:

`import 'https://github.com/user/repo.git@path/to/recipes.just'`

Pin to a specific commit using SHA after `#`:

`import 'https://github.com/user/repo.git#abc123def456'`

Combine SHA pinning with specific file paths:

`import 'https://github.com/user/repo.git#abc123def456@path/to/recipes.just'`

Supported URL formats:
- `https://github.com/user/repo.git` - Latest default branch
- `https://github.com/user/repo.git#commit-sha` - Specific commit
- `[email protected]:user/repo.git#commit-sha` - SSH with commit pinning
- `https://gitlab.com/user/repo#commit-sha@path/file.just` - All features combined

Optional imports work with Git repositories too:

`import? 'https://github.com/user/repo.git#commit-sha'`

#### Version Pinning

SHA pinning ensures reproducible builds by locking imports to specific commits:

Use full 40-character SHA hashes for maximum security, or shorter hashes for convenience.

#### Authentication

For private repositories, `just` uses your system's Git credentials:
- SSH keys for `git@` URLs
- Git credential helper for HTTPS URLs
- Environment variables `GIT_USERNAME` and `GIT_PASSWORD` as fallback

#### Error Handling

Git import errors provide clear context:
- **Repository not found (404)**: Check the URL is correct
- **Authentication failed**: Verify your SSH keys or credentials
- **Network error**: Check your internet connection
- **File not found**: Verify the file path exists in the repository

#### Manual Remote Justfiles

Alternatively, if you wish to include a `mod` or `import` source file in many `justfiles`
without needing to duplicate it, you can use an optional `mod` or `import`,
along with a recipe to fetch the module source:

Expand Down
28 changes: 21 additions & 7 deletions src/argument_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,9 @@ mod tests {
fs::write(&path, "mod foo").unwrap();
fs::create_dir(tempdir.path().join("foo")).unwrap();
fs::write(tempdir.path().join("foo/mod.just"), "bar:").unwrap();
let compilation = Compiler::compile(&loader, &path).unwrap();
let compiler = Compiler::new().unwrap();
let config = Config::default_for_tests();
let compilation = compiler.compile(&config, &loader, &path).unwrap();

assert_eq!(
ArgumentParser::parse_arguments(&compilation.justfile, &["foo", "bar"]).unwrap(),
Expand All @@ -271,7 +273,9 @@ mod tests {
fs::write(&path, "mod foo").unwrap();
fs::create_dir(tempdir.path().join("foo")).unwrap();
fs::write(tempdir.path().join("foo/mod.just"), "bar:").unwrap();
let compilation = Compiler::compile(&loader, &path).unwrap();
let compiler = Compiler::new().unwrap();
let config = Config::default_for_tests();
let compilation = compiler.compile(&config, &loader, &path).unwrap();

assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &["foo", "zzz"]).unwrap_err(),
Expand All @@ -289,7 +293,9 @@ mod tests {
tempdir.write("foo.just", "bar:");

let loader = Loader::new();
let compilation = Compiler::compile(&loader, &tempdir.path().join("justfile")).unwrap();
let compiler = Compiler::new().unwrap();
let config = Config::default_for_tests();
let compilation = compiler.compile(&config, &loader, &tempdir.path().join("justfile")).unwrap();

assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &["foo::zzz"]).unwrap_err(),
Expand All @@ -307,7 +313,9 @@ mod tests {
tempdir.write("foo.just", "bar:");

let loader = Loader::new();
let compilation = Compiler::compile(&loader, &tempdir.path().join("justfile")).unwrap();
let compiler = Compiler::new().unwrap();
let config = Config::default_for_tests();
let compilation = compiler.compile(&config, &loader, &tempdir.path().join("justfile")).unwrap();

assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &["foo::bar::baz"]).unwrap_err(),
Expand All @@ -323,7 +331,9 @@ mod tests {
tempdir.write("justfile", "");

let loader = Loader::new();
let compilation = Compiler::compile(&loader, &tempdir.path().join("justfile")).unwrap();
let compiler = Compiler::new().unwrap();
let config = Config::default_for_tests();
let compilation = compiler.compile(&config, &loader, &tempdir.path().join("justfile")).unwrap();

assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
Expand All @@ -337,7 +347,9 @@ mod tests {
tempdir.write("justfile", "foo bar:");

let loader = Loader::new();
let compilation = Compiler::compile(&loader, &tempdir.path().join("justfile")).unwrap();
let compiler = Compiler::new().unwrap();
let config = Config::default_for_tests();
let compilation = compiler.compile(&config, &loader, &tempdir.path().join("justfile")).unwrap();

assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
Expand All @@ -355,7 +367,9 @@ mod tests {
tempdir.write("foo.just", "bar:");

let loader = Loader::new();
let compilation = Compiler::compile(&loader, &tempdir.path().join("justfile")).unwrap();
let compiler = Compiler::new().unwrap();
let config = Config::default_for_tests();
let compilation = compiler.compile(&config, &loader, &tempdir.path().join("justfile")).unwrap();

assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
Expand Down
83 changes: 63 additions & 20 deletions src/compiler.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
use super::*;
use crate::git_repository::GitRepositoryManager;
use std::cell::RefCell;

pub(crate) struct Compiler;
pub(crate) struct Compiler {
git_manager: RefCell<GitRepositoryManager>,
}

impl Compiler {
pub(crate) fn new() -> RunResult<'static, Self> {
Ok(Self {
git_manager: RefCell::new(GitRepositoryManager::new()?),
})
}

pub(crate) fn compile<'src>(
&self,
config: &Config,
loader: &'src Loader,
root: &Path,
) -> RunResult<'src, Compilation<'src>> {
Expand Down Expand Up @@ -67,24 +79,51 @@ impl Compiler {
optional,
path,
} => {
let import = current
.path
.parent()
.unwrap()
.join(Self::expand_tilde(&relative.cooked)?)
.lexiclean();

if import.is_file() {
if current.file_path.contains(&import) {
return Err(Error::CircularImport {
current: current.path,
import,
});
let expanded_path = Self::expand_tilde(&relative.cooked)?;
let path_str = expanded_path.to_string_lossy();

// Check if this is a Git repository URL
if GitRepositoryManager::is_git_url(&path_str) {
// Handle remote Git repository import
let (git_url, sha, file_path) = GitRepositoryManager::parse_git_url(&path_str);
match self.git_manager.borrow().clone_and_get_path(&git_url, sha.as_deref(), file_path.as_deref(), config.force_reclone) {
Ok(import_path) => {
if current.file_path.contains(&import_path) {
return Err(Error::CircularImport {
current: current.path,
import: import_path,
});
}
*absolute = Some(import_path.clone());
stack.push(current.import(import_path, path.offset));
}
Err(err) => {
if !*optional {
return Err(err);
}
}
}
} else {
// Handle local file import (existing logic)
let import = current
.path
.parent()
.unwrap()
.join(expanded_path)
.lexiclean();

if import.is_file() {
if current.file_path.contains(&import) {
return Err(Error::CircularImport {
current: current.path,
import,
});
}
*absolute = Some(import.clone());
stack.push(current.import(import, path.offset));
} else if !*optional {
return Err(Error::MissingImportFile { path: *path });
}
*absolute = Some(import.clone());
stack.push(current.import(import, path.offset));
} else if !*optional {
return Err(Error::MissingImportFile { path: *path });
}
}
_ => {}
Expand Down Expand Up @@ -259,7 +298,9 @@ recipe_b: recipe_c
let loader = Loader::new();

let justfile_a_path = tmp.path().join("justfile");
let compilation = Compiler::compile(&loader, &justfile_a_path).unwrap();
let compiler = Compiler::new().unwrap();
let config = Config::default_for_tests();
let compilation = compiler.compile(&config, &loader, &justfile_a_path).unwrap();

assert_eq!(compilation.root_src(), justfile_a);
}
Expand All @@ -276,7 +317,9 @@ recipe_b: recipe_c
let loader = Loader::new();

let justfile_a_path = tmp.path().join("justfile");
let loader_output = Compiler::compile(&loader, &justfile_a_path).unwrap_err();
let compiler = Compiler::new().unwrap();
let config = Config::default_for_tests();
let loader_output = compiler.compile(&config, &loader, &justfile_a_path).unwrap_err();

assert_matches!(loader_output, Error::CircularImport { current, import }
if current == tmp.path().join("subdir").join("b").lexiclean() &&
Expand Down
50 changes: 50 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub(crate) struct Config {
pub(crate) dry_run: bool,
pub(crate) dump_format: DumpFormat,
pub(crate) explain: bool,
pub(crate) force_reclone: bool,
pub(crate) highlight: bool,
pub(crate) invocation_directory: PathBuf,
pub(crate) list_heading: String,
Expand Down Expand Up @@ -103,6 +104,7 @@ mod arg {
pub(crate) const DRY_RUN: &str = "DRY-RUN";
pub(crate) const DUMP_FORMAT: &str = "DUMP-FORMAT";
pub(crate) const EXPLAIN: &str = "EXPLAIN";
pub(crate) const FORCE_RECLONE: &str = "FORCE-RECLONE";
pub(crate) const GLOBAL_JUSTFILE: &str = "GLOBAL-JUSTFILE";
pub(crate) const HIGHLIGHT: &str = "HIGHLIGHT";
pub(crate) const JUSTFILE: &str = "JUSTFILE";
Expand Down Expand Up @@ -450,6 +452,13 @@ impl Config {
.action(ArgAction::SetTrue)
.help("Automatically confirm all recipes."),
)
.arg(
Arg::new(arg::FORCE_RECLONE)
.long("force-reclone")
.env("JUST_FORCE_RECLONE")
.action(ArgAction::SetTrue)
.help("Force re-cloning of recipe dependencies"),
)
.arg(
Arg::new(cmd::CHANGELOG)
.long("changelog")
Expand Down Expand Up @@ -794,6 +803,7 @@ impl Config {
.unwrap()
.clone(),
explain,
force_reclone: matches.get_flag(arg::FORCE_RECLONE),
highlight: !matches.get_flag(arg::NO_HIGHLIGHT),
invocation_directory: env::current_dir().context(config_error::CurrentDirContext)?,
list_heading: matches.get_one::<String>(arg::LIST_HEADING).unwrap().into(),
Expand Down Expand Up @@ -842,6 +852,46 @@ impl Config {
Err(Error::UnstableFeature { unstable_feature })
}
}

/// Create a default config for internal use (tests, etc.)
pub(crate) fn default_for_tests() -> Self {
use std::env;
Self {
alias_style: AliasStyle::Right,
allow_missing: false,
check: false,
color: Color::auto(),
command_color: None,
cygpath: PathBuf::from("cygpath"),
dotenv_filename: None,
dotenv_path: None,
dry_run: false,
dump_format: DumpFormat::Just,
explain: false,
force_reclone: false,
highlight: true,
invocation_directory: env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
list_heading: "Available recipes:\n".to_string(),
list_prefix: " ".to_string(),
list_submodules: false,
load_dotenv: true,
no_aliases: false,
no_dependencies: false,
one: false,
search_config: SearchConfig::FromInvocationDirectory,
shell: None,
shell_args: None,
shell_command: false,
subcommand: Subcommand::Summary,
tempdir: None,
timestamp: false,
timestamp_format: "%H:%M:%S".to_string(),
unsorted: false,
unstable: false,
verbosity: Verbosity::Quiet,
yes: false,
}
}
}

#[cfg(test)]
Expand Down
28 changes: 28 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,22 @@ pub(crate) enum Error<'src> {
path: PathBuf,
io_error: io::Error,
},
GitClone {
url: String,
git_error: git2::Error,
},
GitAuth {
url: String,
message: String,
},
GitNetwork {
url: String,
message: String,
},
GitRepository {
url: String,
message: String,
},
MissingImportFile {
path: Token<'src>,
},
Expand Down Expand Up @@ -448,6 +464,18 @@ impl ColorDisplay for Error<'_> {
Load { io_error, path } => {
write!(f, "Failed to read justfile at `{}`: {io_error}", path.display())?;
}
GitClone { url, git_error } => {
write!(f, "Failed to clone git repository `{url}`: {git_error}")?;
}
GitAuth { url, message } => {
write!(f, "Authentication failed for git repository `{url}`: {message}")?;
}
GitNetwork { url, message } => {
write!(f, "Network error accessing git repository `{url}`: {message}")?;
}
GitRepository { url, message } => {
write!(f, "Git repository error for `{url}`: {message}")?;
}
MissingImportFile { .. } => write!(f, "Could not find source file for import.")?,
MissingModuleFile { module } => write!(f, "Could not find source file for module `{module}`.")?,
NoChoosableRecipes => write!(f, "Justfile contains no choosable recipes.")?,
Expand Down
Loading