Skip to content

Commit

Permalink
cli: resolve conditional config scopes
Browse files Browse the repository at this point in the history
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 jj-vcs#616
  • Loading branch information
yuja committed Dec 18, 2024
1 parent ef1c472 commit 216f54c
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 7 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions cli/src/cli_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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() {
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions cli/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand All @@ -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);
Expand Down
28 changes: 26 additions & 2 deletions cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -247,19 +249,27 @@ impl UnresolvedConfigEnv {

#[derive(Clone, Debug)]
pub struct ConfigEnv {
home_dir: Option<PathBuf>,
repo_path: Option<PathBuf>,
user_config_path: ConfigPath,
repo_config_path: ConfigPath,
}

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,
})
Expand Down Expand Up @@ -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")));
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1188,16 +1209,19 @@ 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
.as_ref()
.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,
})
Expand Down
101 changes: 101 additions & 0 deletions cli/tests/test_config_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
53 changes: 53 additions & 0 deletions cli/tests/test_global_opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1219,3 +1219,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 = "[email protected]"

# override user.email if the repository is located under ~/oss
[[--scope]]
--when.repositories = ["~/oss"]
[--scope.user]
email = "[email protected]"
```

Condition keys:

* `--when.repositories`: List of paths to match the repository path prefix.

If no conditions are specified, table is always enabled.

0 comments on commit 216f54c

Please sign in to comment.