cli: resolve conditional config scopes
Some checks are pending
binaries / Build binary artifacts (push) Waiting to run
nix / flake check (push) Waiting to run
build / build (, macos-13) (push) Waiting to run
build / build (, macos-14) (push) Waiting to run
build / build (, ubuntu-latest) (push) Waiting to run
build / build (, windows-latest) (push) Waiting to run
build / build (--all-features, ubuntu-latest) (push) Waiting to run
build / Build jj-lib without Git support (push) Waiting to run
build / Check protos (push) Waiting to run
build / Check formatting (push) Waiting to run
build / Check that MkDocs can build the docs (push) Waiting to run
build / Check that MkDocs can build the docs with latest Python and uv (push) Waiting to run
build / cargo-deny (advisories) (push) Waiting to run
build / cargo-deny (bans licenses sources) (push) Waiting to run
build / Clippy check (push) Waiting to run
Codespell / Codespell (push) Waiting to run
website / prerelease-docs-build-deploy (ubuntu-latest) (push) Waiting to run
Scorecards supply-chain security / Scorecards analysis (push) Waiting to run

This is an alternative way to achieve includeIf of Git without adding "include"
directive. Conditional include (or include in general) is a bit trickier to
implement than loading all files and filtering the contents.

Closes #616
This commit is contained in:
Yuya Nishihara 2024-12-17 16:53:38 +09:00
parent 1a0d3ae3d7
commit f5d450d7c3
7 changed files with 214 additions and 7 deletions

View file

@ -48,6 +48,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`snapshot.max-new-file-size` config option. It will print a warning and large
files will be left untracked.
* Configuration files now support [conditional
variables](docs/config.md#conditional-variables).
* New command options `--config=NAME=VALUE` and `--config-file=PATH` to set
string value without quoting and to load additional configuration from files.

View file

@ -3650,7 +3650,7 @@ impl CliRunner {
config_env.reset_repo_path(loader.repo_path());
config_env.reload_repo_config(&mut raw_config)?;
}
let mut config = raw_config.as_ref().clone(); // TODO
let mut config = config_env.resolve_config(&raw_config)?;
ui.reset(&config)?;
if env::var_os("COMPLETE").is_some() {
@ -3661,7 +3661,7 @@ impl CliRunner {
let (args, config_layers) = parse_early_args(&self.app, &string_args)?;
if !config_layers.is_empty() {
raw_config.as_mut().extend_layers(config_layers);
config = raw_config.as_ref().clone(); // TODO
config = config_env.resolve_config(&raw_config)?;
ui.reset(&config)?;
}
if !args.config_toml.is_empty() {
@ -3687,7 +3687,7 @@ impl CliRunner {
.map_err(|err| map_workspace_load_error(err, Some(path)))?;
config_env.reset_repo_path(loader.repo_path());
config_env.reload_repo_config(&mut raw_config)?;
config = raw_config.as_ref().clone(); // TODO
config = config_env.resolve_config(&raw_config)?;
Ok(loader)
} else {
maybe_cwd_workspace_loader

View file

@ -693,7 +693,7 @@ fn get_jj_command() -> Result<(JjBuilder, UserSettings), CommandError> {
config_env.reset_repo_path(loader.repo_path());
let _ = config_env.reload_repo_config(&mut raw_config);
}
let mut config = raw_config.as_ref().clone(); /* TODO */
let mut config = config_env.resolve_config(&raw_config)?;
// skip 2 because of the clap_complete prelude: jj -- jj <actual args...>
let args = std::env::args_os().skip(2);
let args = expand_args(&ui, &app, args, &config)?;
@ -710,7 +710,9 @@ fn get_jj_command() -> Result<(JjBuilder, UserSettings), CommandError> {
if let Ok(loader) = DefaultWorkspaceLoaderFactory.create(&cwd.join(&repository)) {
config_env.reset_repo_path(loader.repo_path());
let _ = config_env.reload_repo_config(&mut raw_config);
config = raw_config.as_ref().clone(); // TODO
if let Ok(new_config) = config_env.resolve_config(&raw_config) {
config = new_config;
}
}
cmd_args.push("--repository".into());
cmd_args.push(repository);

View file

@ -23,9 +23,11 @@ use std::process::Command;
use itertools::Itertools;
use jj_lib::config::ConfigFile;
use jj_lib::config::ConfigGetError;
use jj_lib::config::ConfigLayer;
use jj_lib::config::ConfigLoadError;
use jj_lib::config::ConfigNamePathBuf;
use jj_lib::config::ConfigResolutionContext;
use jj_lib::config::ConfigSource;
use jj_lib::config::ConfigValue;
use jj_lib::config::StackedConfig;
@ -247,6 +249,8 @@ impl UnresolvedConfigEnv {
#[derive(Clone, Debug)]
pub struct ConfigEnv {
home_dir: Option<PathBuf>,
repo_path: Option<PathBuf>,
user_config_path: ConfigPath,
repo_config_path: ConfigPath,
}
@ -254,12 +258,18 @@ pub struct ConfigEnv {
impl ConfigEnv {
/// Initializes configuration loader based on environment variables.
pub fn from_environment() -> Result<Self, ConfigEnvError> {
// Canonicalize home as we do canonicalize cwd in CliRunner.
// TODO: Maybe better to switch to dunce::canonicalize() and remove
// canonicalization of cwd, home, etc.?
let home_dir = dirs::home_dir().map(|path| path.canonicalize().unwrap_or(path));
let env = UnresolvedConfigEnv {
config_dir: dirs::config_dir(),
home_dir: dirs::home_dir(),
home_dir: home_dir.clone(),
jj_config: env::var("JJ_CONFIG").ok(),
};
Ok(ConfigEnv {
home_dir,
repo_path: None,
user_config_path: env.resolve()?,
repo_config_path: ConfigPath::Unavailable,
})
@ -324,6 +334,7 @@ impl ConfigEnv {
/// Sets the directory where repo-specific config file is stored. The path
/// is usually `.jj/repo`.
pub fn reset_repo_path(&mut self, path: &Path) {
self.repo_path = Some(path.to_owned());
self.repo_config_path = ConfigPath::new(Some(path.join("config.toml")));
}
@ -371,6 +382,16 @@ impl ConfigEnv {
}
Ok(())
}
/// Resolves conditional scopes within the current environment. Returns new
/// resolved config.
pub fn resolve_config(&self, config: &RawConfig) -> Result<StackedConfig, ConfigGetError> {
let context = ConfigResolutionContext {
home_dir: self.home_dir.as_deref(),
repo_path: self.repo_path.as_deref(),
};
jj_lib::config::resolve(config.as_ref(), &context)
}
}
fn config_files_for(
@ -1188,9 +1209,10 @@ mod tests {
impl TestCase {
fn resolve(&self, root: &Path) -> Result<ConfigEnv, ConfigEnvError> {
let home_dir = self.env.home_dir.as_ref().map(|p| root.join(p));
let env = UnresolvedConfigEnv {
config_dir: self.env.config_dir.as_ref().map(|p| root.join(p)),
home_dir: self.env.home_dir.as_ref().map(|p| root.join(p)),
home_dir: home_dir.clone(),
jj_config: self
.env
.jj_config
@ -1198,6 +1220,8 @@ mod tests {
.map(|p| root.join(p).to_str().unwrap().to_string()),
};
Ok(ConfigEnv {
home_dir,
repo_path: None,
user_config_path: env.resolve()?,
repo_config_path: ConfigPath::Unavailable,
})

View file

@ -1047,6 +1047,107 @@ fn test_config_path_syntax() {
"###);
}
#[test]
#[cfg_attr(windows, ignore = "dirs::home_dir() can't be overridden by $HOME")] // TODO
fn test_config_conditional() {
let mut test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.home_dir(), &["git", "init", "repo1"]);
test_env.jj_cmd_ok(test_env.home_dir(), &["git", "init", "repo2"]);
let repo1_path = test_env.home_dir().join("repo1");
let repo2_path = test_env.home_dir().join("repo2");
// Test with fresh new config file
let user_config_path = test_env.env_root().join("config.toml");
test_env.set_config_path(user_config_path.clone());
std::fs::write(
&user_config_path,
indoc! {"
foo = 'global'
[[--scope]]
--when.repositories = ['~/repo1']
foo = 'repo1'
[[--scope]]
--when.repositories = ['~/repo2']
foo = 'repo2'
"},
)
.unwrap();
// get and list should refer to the resolved config
let stdout = test_env.jj_cmd_success(test_env.env_root(), &["config", "get", "foo"]);
insta::assert_snapshot!(stdout, @"global");
let stdout = test_env.jj_cmd_success(&repo1_path, &["config", "get", "foo"]);
insta::assert_snapshot!(stdout, @"repo1");
let stdout = test_env.jj_cmd_success(test_env.env_root(), &["config", "list", "--user"]);
insta::assert_snapshot!(stdout, @"foo = 'global'");
let stdout = test_env.jj_cmd_success(&repo1_path, &["config", "list", "--user"]);
insta::assert_snapshot!(stdout, @"foo = 'repo1'");
let stdout = test_env.jj_cmd_success(&repo2_path, &["config", "list", "--user"]);
insta::assert_snapshot!(stdout, @"foo = 'repo2'");
// relative workspace path
let stdout = test_env.jj_cmd_success(&repo2_path, &["config", "list", "--user", "-R../repo1"]);
insta::assert_snapshot!(stdout, @"foo = 'repo1'");
// set and unset should refer to the source config
// (there's no option to update scoped table right now.)
let (_stdout, stderr) = test_env.jj_cmd_ok(
test_env.env_root(),
&["config", "set", "--user", "bar", "new value"],
);
insta::assert_snapshot!(stderr, @"");
insta::assert_snapshot!(std::fs::read_to_string(&user_config_path).unwrap(), @r#"
foo = 'global'
bar = "new value"
[[--scope]]
--when.repositories = ['~/repo1']
foo = 'repo1'
[[--scope]]
--when.repositories = ['~/repo2']
foo = 'repo2'
"#);
let (_stdout, stderr) = test_env.jj_cmd_ok(&repo1_path, &["config", "unset", "--user", "foo"]);
insta::assert_snapshot!(stderr, @"");
insta::assert_snapshot!(std::fs::read_to_string(&user_config_path).unwrap(), @r#"
bar = "new value"
[[--scope]]
--when.repositories = ['~/repo1']
foo = 'repo1'
[[--scope]]
--when.repositories = ['~/repo2']
foo = 'repo2'
"#);
}
// Minimal test for Windows where the home directory can't be switched.
// (Can be removed if test_config_conditional() is enabled on Windows.)
#[test]
fn test_config_conditional_without_home_dir() {
let mut test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]);
let repo_path = test_env.env_root().join("repo");
// Test with fresh new config file
let user_config_path = test_env.env_root().join("config.toml");
test_env.set_config_path(user_config_path.clone());
std::fs::write(
&user_config_path,
format!(
indoc! {"
foo = 'global'
[[--scope]]
--when.repositories = [{repo_path}]
foo = 'repo'
"},
repo_path = toml_edit::Value::from(repo_path.to_str().unwrap())
),
)
.unwrap();
let stdout = test_env.jj_cmd_success(test_env.env_root(), &["config", "get", "foo"]);
insta::assert_snapshot!(stdout, @"global");
let stdout = test_env.jj_cmd_success(&repo_path, &["config", "get", "foo"]);
insta::assert_snapshot!(stdout, @"repo");
}
#[test]
fn test_config_show_paths() {
let test_env = TestEnvironment::default();

View file

@ -721,6 +721,59 @@ fn test_invalid_config_value() {
");
}
#[test]
#[cfg_attr(windows, ignore = "dirs::home_dir() can't be overridden by $HOME")] // TODO
fn test_conditional_config() {
let test_env = TestEnvironment::default();
test_env.jj_cmd_ok(test_env.home_dir(), &["git", "init", "repo1"]);
test_env.jj_cmd_ok(test_env.home_dir(), &["git", "init", "repo2"]);
test_env.add_config(indoc! {"
aliases.foo = ['new', 'root()', '-mglobal']
[[--scope]]
--when.repositories = ['~']
aliases.foo = ['new', 'root()', '-mhome']
[[--scope]]
--when.repositories = ['~/repo1']
aliases.foo = ['new', 'root()', '-mrepo1']
"});
// Sanity check
let stdout = test_env.jj_cmd_success(
test_env.env_root(),
&["config", "list", "--include-overridden", "aliases"],
);
insta::assert_snapshot!(stdout, @"aliases.foo = ['new', 'root()', '-mglobal']");
let stdout = test_env.jj_cmd_success(
&test_env.home_dir().join("repo1"),
&["config", "list", "--include-overridden", "aliases"],
);
insta::assert_snapshot!(stdout, @r"
# aliases.foo = ['new', 'root()', '-mglobal']
# aliases.foo = ['new', 'root()', '-mhome']
aliases.foo = ['new', 'root()', '-mrepo1']
");
let stdout = test_env.jj_cmd_success(
&test_env.home_dir().join("repo2"),
&["config", "list", "--include-overridden", "aliases"],
);
insta::assert_snapshot!(stdout, @r"
# aliases.foo = ['new', 'root()', '-mglobal']
aliases.foo = ['new', 'root()', '-mhome']
");
// Aliases can be expanded by using the conditional tables
let (_stdout, stderr) = test_env.jj_cmd_ok(&test_env.home_dir().join("repo1"), &["foo"]);
insta::assert_snapshot!(stderr, @r"
Working copy now at: royxmykx 82899b03 (empty) repo1
Parent commit : zzzzzzzz 00000000 (empty) (no description set)
");
let (_stdout, stderr) = test_env.jj_cmd_ok(&test_env.home_dir().join("repo2"), &["foo"]);
insta::assert_snapshot!(stderr, @r"
Working copy now at: yqosqzyt 3bd315a9 (empty) home
Parent commit : zzzzzzzz 00000000 (empty) (no description set)
");
}
#[test]
fn test_no_user_configured() {
// Test that the user is reminded if they haven't configured their name or email

View file

@ -1233,3 +1233,27 @@ To load an entire TOML document, use `--config-file`:
```shell
jj --config-file=extra-config.toml log
```
### Conditional variables
You can conditionally enable config variables by using `--when` and
`[[--scope]]` tables. Variables defined in `[[--scope]]` tables are expanded to
the root table. `--when` specifies the condition to enable the scope table.
```toml
[user]
name = "YOUR NAME"
email = "YOUR_DEFAULT_EMAIL@example.com"
# override user.email if the repository is located under ~/oss
[[--scope]]
--when.repositories = ["~/oss"]
[--scope.user]
email = "YOUR_OSS_EMAIL@example.org"
```
Condition keys:
* `--when.repositories`: List of paths to match the repository path prefix.
If no conditions are specified, table is always enabled.