From a8ea02b770bbb5a11b3e35b50c6563965da2e597 Mon Sep 17 00:00:00 2001 From: Vaghinak Vardanyan Date: Tue, 18 Aug 2026 13:31:32 +0000 Subject: [PATCH 1/2] cli: allow targeting specific file in {edit,set,unset} with --file Allow targeting a specific config file path with `--file` in `jj config {edit,set,unset}`. This enables explicit targeting of specific configuration files (such as files in `conf.d/` or custom files loaded via `--config-file`) and avoids interactive prompts when multiple config files exist. The `--file` option validates that the target path is a recognized `jj` configuration location to prevent creating untracked or arbitrary files. Fixes #9541 --- CHANGELOG.md | 5 + cli/src/commands/config/edit.rs | 6 +- cli/src/commands/config/mod.rs | 41 ++++- cli/src/commands/config/set.rs | 6 +- cli/src/commands/config/unset.rs | 6 +- cli/src/config.rs | 89 +++++++++++ cli/tests/cli-reference@.md.snap | 28 +++- cli/tests/test_config_command.rs | 266 +++++++++++++++++++++++++++++-- docs/config.md | 15 ++ 9 files changed, 439 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e71a32f05e3..4a88a6a967f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). back to prompting the user if the heuristics are inconclusive. It can also run in non-interactive mode, which aborts if prompting would be needed. +* `jj config {edit,set,unset}` now support a `--file ` option to + target a specific configuration file (such as files inside a `conf.d/` + directory or loaded via `--config-file`). This allows precise file targeting + and avoids interactive prompts when multiple config files exist. + ### Fixed bugs * `jj arrange` now scrolls the viewport to keep the selected commit visible diff --git a/cli/src/commands/config/edit.rs b/cli/src/commands/config/edit.rs index 6d7cd7779fc..3c25d3fe211 100644 --- a/cli/src/commands/config/edit.rs +++ b/cli/src/commands/config/edit.rs @@ -15,7 +15,7 @@ use jj_lib::config::ConfigLayer; use tracing::instrument; -use super::ConfigLevelArgs; +use super::ConfigTargetArgs; use crate::cli_util::CommandHelper; use crate::command_error::CommandError; use crate::command_error::print_error_sources; @@ -28,7 +28,7 @@ use crate::ui::Ui; #[derive(clap::Args, Clone, Debug)] pub struct ConfigEditArgs { #[command(flatten)] - pub level: ConfigLevelArgs, + pub target: ConfigTargetArgs, } #[instrument(skip_all)] @@ -38,7 +38,7 @@ pub async fn cmd_config_edit( args: &ConfigEditArgs, ) -> Result<(), CommandError> { let editor = command.text_editor()?; - let file = args.level.edit_config_file(ui, command)?; + let file = args.target.edit_config_file(ui, command)?; if !file.path().exists() { file.save()?; } diff --git a/cli/src/commands/config/mod.rs b/cli/src/commands/config/mod.rs index 25b70d796a9..114a680be10 100644 --- a/cli/src/commands/config/mod.rs +++ b/cli/src/commands/config/mod.rs @@ -100,7 +100,41 @@ impl ConfigLevelArgs { panic!("No config_level provided") } } +} + +#[derive(clap::Args, Clone, Debug)] +#[group(id = "config_target", multiple = false, required = true)] +pub(crate) struct ConfigTargetArgs { + /// Target the user-level config + #[arg(long)] + user: bool, + + /// Target the repo-level config + #[arg(long)] + repo: bool, + + /// Target the workspace-level config + #[arg(long)] + workspace: bool, + /// Target the config file specified by the given path + /// + /// The path must point to a valid configuration file location recognized + /// by Jujutsu (such as a user/repo/workspace config, a file inside a + /// `conf.d/` directory, or any file loaded via system configs, `$JJ_CONFIG`, + /// or `--config-file`). + /// + /// Unlike the global `--config-file` option (which loads an extra config + /// file when running commands), this option specifies which file to + /// inspect, edit, or modify on disk. + /// + /// If the file does not exist, commands like `set` and `edit` will create + /// it and any missing parent directories. + #[arg(long, value_name = "PATH", value_hint = clap::ValueHint::FilePath)] + file: Option, +} + +impl ConfigTargetArgs { fn edit_config_file( &self, ui: &Ui, @@ -123,7 +157,10 @@ impl ConfigLevelArgs { } files.pop().ok_or_else(|| user_error(not_found_error)) }; - if self.user { + if let Some(file) = &self.file { + let path = command.cwd().join(file); + config_env.resolve_file_to_edit(ui, config, &path) + } else if self.user { pick_one( config_env.user_config_files(config)?, "No user config path found to edit", @@ -139,7 +176,7 @@ impl ConfigLevelArgs { "No workspace config path found to edit", ) } else { - panic!("No config_level provided") + panic!("No config_target provided") } } } diff --git a/cli/src/commands/config/set.rs b/cli/src/commands/config/set.rs index 0b32dc57698..441a707425d 100644 --- a/cli/src/commands/config/set.rs +++ b/cli/src/commands/config/set.rs @@ -21,7 +21,7 @@ use jj_lib::config::ConfigValue; use jj_lib::repo::Repo as _; use tracing::instrument; -use super::ConfigLevelArgs; +use super::ConfigTargetArgs; use crate::cli_util::CommandHelper; use crate::cli_util::WorkspaceCommandHelper; use crate::command_error::CommandError; @@ -53,7 +53,7 @@ pub struct ConfigSetArgs { value: ConfigValue, #[command(flatten)] - level: ConfigLevelArgs, + target: ConfigTargetArgs, } /// Denotes a type of author change @@ -68,7 +68,7 @@ pub async fn cmd_config_set( command: &CommandHelper, args: &ConfigSetArgs, ) -> Result<(), CommandError> { - let mut file = args.level.edit_config_file(ui, command)?; + let mut file = args.target.edit_config_file(ui, command)?; // If the user is trying to change the author config, we should warn them that // it won't affect the working copy author diff --git a/cli/src/commands/config/unset.rs b/cli/src/commands/config/unset.rs index 8cfead5e9da..88f3b679b15 100644 --- a/cli/src/commands/config/unset.rs +++ b/cli/src/commands/config/unset.rs @@ -16,7 +16,7 @@ use clap_complete::ArgValueCandidates; use jj_lib::config::ConfigNamePathBuf; use tracing::instrument; -use super::ConfigLevelArgs; +use super::ConfigTargetArgs; use crate::cli_util::CommandHelper; use crate::command_error::CommandError; use crate::command_error::user_error; @@ -32,7 +32,7 @@ pub struct ConfigUnsetArgs { name: ConfigNamePathBuf, #[command(flatten)] - level: ConfigLevelArgs, + target: ConfigTargetArgs, } #[instrument(skip_all)] @@ -41,7 +41,7 @@ pub async fn cmd_config_unset( command: &CommandHelper, args: &ConfigUnsetArgs, ) -> Result<(), CommandError> { - let mut file = args.level.edit_config_file(ui, command)?; + let mut file = args.target.edit_config_file(ui, command)?; let old_value = file .delete_value(&args.name) .map_err(|err| user_error_with_message(format!("Failed to unset {}", args.name), err))?; diff --git a/cli/src/config.rs b/cli/src/config.rs index a4d82d964d1..0b5e3756c8a 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -51,6 +51,7 @@ use tracing::instrument; use crate::command_error::CommandError; use crate::command_error::config_error; use crate::command_error::config_error_with_message; +use crate::command_error::user_error; use crate::ui::Ui; // TODO(#879): Consider generating entire schema dynamically vs. static file. @@ -664,6 +665,78 @@ impl ConfigEnv { Ok(()) } + /// Returns the configuration file at the specified `path` for modification. + /// + /// Validates that the path is a recognized Jujutsu configuration location + /// (such as a loaded layer, a user config path or file in `conf.d/`, + /// or a repo/workspace config path). If the file is already loaded in + /// `config`, its existing [`ConfigFile`] representation is reused; + /// otherwise, a new [`ConfigFile`] is initialized. + pub fn resolve_file_to_edit( + &self, + ui: &Ui, + config: &RawConfig, + path: &Path, + ) -> Result { + let canonical_path = dunce::canonicalize(path).ok(); + let matches_path = |p: &Path| p == path || Some(p) == canonical_path.as_deref(); + + // 1. Check if it matches an already loaded layer (user, system, repo, + // workspace, --config-file, JJ_CONFIG, etc.) + for layer in config.as_ref().layers() { + if let Some(layer_path) = layer.path.as_ref() + && matches_path(layer_path) + && let Ok(file) = ConfigFile::from_layer(layer.clone()) + { + return Ok(file); + } + } + + // 2. Check repo config path (even if not yet created on disk) + if let Ok(Some(repo_path)) = self.repo_config_path(ui) + && matches_path(&repo_path) + { + if let Some(parent) = path.parent() { + create_dir_all(parent).ok(); + } + return Ok(ConfigFile::load_or_empty(ConfigSource::Repo, path)?); + } + + // 3. Check workspace config path (even if not yet created on disk) + if let Ok(Some(workspace_path)) = self.workspace_config_path(ui) + && matches_path(&workspace_path) + { + if let Some(parent) = path.parent() { + create_dir_all(parent).ok(); + } + return Ok(ConfigFile::load_or_empty( + ConfigSource::Workspace, + path, + )?); + } + + // 4. Check user config paths (e.g. ~/.config/jj/config.toml, + // ~/.jjconfig.toml, or files in conf.d) + for user_path in self.user_config_paths() { + if matches_path(user_path) || is_file_in_config_dir(path, user_path) { + if let Some(parent) = path.parent() { + create_dir_all(parent).ok(); + } + return Ok(ConfigFile::load_or_empty(ConfigSource::User, path)?); + } + } + + Err(user_error(format!( + "Configuration file '{}' is not a valid jj configuration file location", + path.display() + )) + .hinted( + "Valid config locations include user configs (`~/.config/jj/config.toml` or \ + `conf.d/*.toml`), repo/workspace configs, or files loaded with the global flag \ + `--config-file `.", + )) + } + /// Resolves conditional scopes within the current environment. Returns new /// resolved config. pub fn resolve_config(&self, config: &RawConfig) -> Result { @@ -707,6 +780,22 @@ fn config_files_for( Ok(files) } +fn is_file_in_config_dir(file_path: &Path, dir_path: &Path) -> bool { + if file_path.extension() != Some("toml".as_ref()) { + return false; + } + if dir_path.is_file() { + return false; + } + let Some(parent) = file_path.parent() else { + return false; + }; + if parent == dir_path { + return true; + } + dunce::canonicalize(parent).ok().as_deref() == Some(dir_path) +} + /// Initializes stacked config with the given `default_layers` and infallible /// sources. /// diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 38a5d3d03a1..875c682c85b 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -2,6 +2,7 @@ source: cli/tests/test_generate_md_cli_help.rs description: "AUTO-GENERATED FILE, DO NOT EDIT. This cli reference is generated by a test as an `insta` snapshot. MkDocs includes this snapshot from docs/cli-reference.md." --- + # Command-Line Help for `jj` @@ -773,7 +774,7 @@ Start an editor on a jj config file. Creates the file if it doesn't already exist regardless of what the editor does. -**Usage:** `jj config edit <--user|--repo|--workspace>` +**Usage:** `jj config edit <--user|--repo|--workspace|--file >` **Command Alias:** `e` @@ -782,6 +783,13 @@ Creates the file if it doesn't already exist regardless of what the editor does. * `--user` — Target the user-level config * `--repo` — Target the repo-level config * `--workspace` — Target the workspace-level config +* `--file ` — Target the config file specified by the given path + + The path must point to a valid configuration file location recognized by Jujutsu (such as a user/repo/workspace config, a file inside a `conf.d/` directory, or any file loaded via system configs, `$JJ_CONFIG`, or `--config-file`). + + Unlike the global `--config-file` option (which loads an extra config file when running commands), this option specifies which file to inspect, edit, or modify on disk. + + If the file does not exist, commands like `set` and `edit` will create it and any missing parent directories. @@ -883,7 +891,7 @@ See `jj config edit` if you'd like to immediately edit a file. Update a config file to set the given option to a given value -**Usage:** `jj config set <--user|--repo|--workspace> ` +**Usage:** `jj config set <--user|--repo|--workspace|--file > ` **Command Alias:** `s` @@ -901,6 +909,13 @@ Update a config file to set the given option to a given value * `--user` — Target the user-level config * `--repo` — Target the repo-level config * `--workspace` — Target the workspace-level config +* `--file ` — Target the config file specified by the given path + + The path must point to a valid configuration file location recognized by Jujutsu (such as a user/repo/workspace config, a file inside a `conf.d/` directory, or any file loaded via system configs, `$JJ_CONFIG`, or `--config-file`). + + Unlike the global `--config-file` option (which loads an extra config file when running commands), this option specifies which file to inspect, edit, or modify on disk. + + If the file does not exist, commands like `set` and `edit` will create it and any missing parent directories. @@ -908,7 +923,7 @@ Update a config file to set the given option to a given value Update a config file to unset the given option -**Usage:** `jj config unset <--user|--repo|--workspace> ` +**Usage:** `jj config unset <--user|--repo|--workspace|--file > ` **Command Alias:** `u` @@ -921,6 +936,13 @@ Update a config file to unset the given option * `--user` — Target the user-level config * `--repo` — Target the repo-level config * `--workspace` — Target the workspace-level config +* `--file ` — Target the config file specified by the given path + + The path must point to a valid configuration file location recognized by Jujutsu (such as a user/repo/workspace config, a file inside a `conf.d/` directory, or any file loaded via system configs, `$JJ_CONFIG`, or `--config-file`). + + Unlike the global `--config-file` option (which loads an extra config file when running commands), this option specifies which file to inspect, edit, or modify on disk. + + If the file does not exist, commands like `set` and `edit` will create it and any missing parent directories. diff --git a/cli/tests/test_config_command.rs b/cli/tests/test_config_command.rs index bebd66a029c..98c3b355356 100644 --- a/cli/tests/test_config_command.rs +++ b/cli/tests/test_config_command.rs @@ -583,11 +583,11 @@ fn test_config_set_bad_opts() { insta::assert_snapshot!(output, @" ------- stderr ------- error: the following required arguments were not provided: - <--user|--repo|--workspace> + <--user|--repo|--workspace|--file > - Usage: jj config set <--user|--repo|--workspace> + Usage: jj config set <--user|--repo|--workspace|--file > For more information, try '--help'. [EOF] @@ -778,6 +778,88 @@ fn test_config_set_for_workspace() { "#); } +#[test] +fn test_config_set_for_file() -> TestResult { + let test_env = TestEnvironment::default(); + + // Create a second config file + test_env.add_config(""); + let first_path = test_env.first_config_file_path(); + let last_path = test_env.last_config_file_path(); + + // Setting a key with --file shouldn't prompt even if multiple files exist + let output = test_env.run_jj_in( + ".", + [ + "config", + "set", + "--file", + last_path.to_str().unwrap(), + "test-key", + "test-val", + ], + ); + insta::assert_snapshot!(output, @""); + + insta::assert_snapshot!( + std::fs::read_to_string(last_path)?, + @r#"test-key = "test-val""#); + + // Verify the first file remained unchanged + insta::assert_snapshot!( + std::fs::read_to_string(first_path)?, + @r#" + + [template-aliases] + 'format_time_range(time_range)' = 'time_range.start() ++ " - " ++ time_range.end()' + + [git] + colocate = false + "#); + + Ok(()) +} + +#[test] +fn test_config_set_file_with_existing_scopes() -> TestResult { + let mut test_env = TestEnvironment::default(); + let conf_d = test_env.env_root().join("conf.d"); + std::fs::create_dir_all(&conf_d)?; + let user_config_path = join_paths([test_env.config_path(), &conf_d])?; + test_env.set_config_path(&user_config_path); + + let work_conf = conf_d.join("work.toml"); + std::fs::write( + &work_conf, + indoc! {" + --when.repositories = ['some/path'] + work_user = 'Work User' + "}, + )?; + let output = test_env.run_jj_in( + ".", + [ + "config", + "set", + "--file", + work_conf.to_str().unwrap(), + "shared_key", + "shared_val", + ], + ); + insta::assert_snapshot!(output, @""); + insta::assert_snapshot!( + std::fs::read_to_string(&work_conf)?, + @r#" + --when.repositories = ['some/path'] + work_user = 'Work User' + shared_key = "shared_val" + "# + ); + + Ok(()) +} + #[test] fn test_config_set_toml_types() -> TestResult { let mut test_env = TestEnvironment::default(); @@ -1020,6 +1102,45 @@ fn test_config_unset_for_workspace() { insta::assert_snapshot!(workspace_config, @""); } +#[test] +fn test_config_unset_for_file() -> TestResult { + let test_env = TestEnvironment::default(); + test_env.add_config(""); + let last_path = test_env.last_config_file_path(); + + test_env + .run_jj_in( + ".", + [ + "config", + "set", + "--file", + last_path.to_str().unwrap(), + "foo", + "true", + ], + ) + .success(); + + let output = test_env.run_jj_in( + ".", + [ + "config", + "unset", + "--file", + last_path.to_str().unwrap(), + "foo", + ], + ); + insta::assert_snapshot!(output, @""); + + insta::assert_snapshot!( + std::fs::read_to_string(last_path)?, + @""); + + Ok(()) +} + #[test] fn test_config_edit_missing_opt() { let test_env = TestEnvironment::default(); @@ -1027,9 +1148,9 @@ fn test_config_edit_missing_opt() { insta::assert_snapshot!(output, @" ------- stderr ------- error: the following required arguments were not provided: - <--user|--repo|--workspace> + <--user|--repo|--workspace|--file > - Usage: jj config edit <--user|--repo|--workspace> + Usage: jj config edit <--user|--repo|--workspace|--file > For more information, try '--help'. [EOF] @@ -1075,6 +1196,26 @@ fn test_config_edit_user_new_file() { ); } +#[test] +fn test_config_edit_file() -> TestResult { + let mut test_env = TestEnvironment::default(); + test_env.add_config(""); + let last_path = test_env.last_config_file_path(); + let edit_script = test_env.set_up_fake_editor(); + + std::fs::write(edit_script, "dump-path path")?; + test_env + .run_jj_in( + ".", + ["config", "edit", "--file", last_path.to_str().unwrap()], + ) + .success(); + + let edited_path = PathBuf::from(std::fs::read_to_string(test_env.env_root().join("path"))?); + assert_eq!(edited_path, dunce::simplified(&last_path)); + Ok(()) +} + #[test] fn test_config_edit_repo() -> TestResult { let mut test_env = TestEnvironment::default(); @@ -1889,7 +2030,7 @@ fn test_config_gc_no_repos_dir() { let test_env = TestEnvironment::default(); // No repo config dir created at all. let output = test_env.run_jj_in(".", ["config", "gc"]); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @" ------- stderr ------- Missing repo configs (repo path no longer exists): (none) @@ -1903,7 +2044,7 @@ fn test_config_gc_all_existing() -> TestResult { create_repo_with_config(&mut test_env, "repo")?; let output = test_env.run_jj_in(".", ["config", "gc"]); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @" ------- stderr ------- Missing repo configs (repo path no longer exists): (none) @@ -1921,7 +2062,7 @@ fn test_config_gc_missing_default_no() -> TestResult { // Non-interactive: the prompt auto-answers with the default ("no"). let output = test_env.run_jj_in(".", ["config", "gc"]); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @" ------- stderr ------- Missing repo configs (repo path no longer exists): $TEST_ENV/home/.config/jj/repos/8e4fac809cbb3b162c95 @@ -1946,7 +2087,7 @@ fn test_config_gc_missing_confirmed() -> TestResult { .args(["config", "gc"]) .write_stdin("y\n") }); - insta::assert_snapshot!(output, @r" + insta::assert_snapshot!(output, @" ------- stderr ------- Missing repo configs (repo path no longer exists): $TEST_ENV/home/.config/jj/repos/8e4fac809cbb3b162c95 @@ -1981,7 +2122,7 @@ fn test_config_gc_missing_with_extra_file() -> TestResult { .unwrap() .replace_all(&s, "$1: ") .into_owned() - }), @r" + }), @" ------- stderr ------- Missing repo configs (repo path no longer exists): $TEST_ENV/home/.config/jj/repos/8e4fac809cbb3b162c95 @@ -1998,3 +2139,110 @@ fn test_config_gc_missing_with_extra_file() -> TestResult { assert!(!config_dir.join("metadata.binpb").exists()); Ok(()) } + +#[test] +fn test_config_file_validation() -> TestResult { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Rejects random non-jj paths + let output = work_dir.run_jj(["config", "set", "--file", "abc/random/path.toml", "k", "v"]); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Error: Configuration file '$TEST_ENV/repo/abc/random/path.toml' is not a valid jj configuration file location + Hint: Valid config locations include user configs (`~/.config/jj/config.toml` or `conf.d/*.toml`), repo/workspace configs, or files loaded with the global flag `--config-file `. + [EOF] + [exit status: 1] + "); + + // Rejects typos like .config/jj/config.toml relative to cwd + let output = work_dir.run_jj([ + "config", + "set", + "--file", + ".config/jj/config.toml", + "k", + "v", + ]); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Error: Configuration file '$TEST_ENV/repo/.config/jj/config.toml' is not a valid jj configuration file location + Hint: Valid config locations include user configs (`~/.config/jj/config.toml` or `conf.d/*.toml`), repo/workspace configs, or files loaded with the global flag `--config-file `. + [EOF] + [exit status: 1] + "); + + // Rejects non-toml files even under a valid config directory + let invalid_ext_file = test_env.config_path().join("notes.txt"); + let output = work_dir.run_jj([ + "config", + "set", + "--file", + invalid_ext_file.to_str().unwrap(), + "k", + "v", + ]); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Error: Configuration file '$TEST_ENV/config/notes.txt' is not a valid jj configuration file location + Hint: Valid config locations include user configs (`~/.config/jj/config.toml` or `conf.d/*.toml`), repo/workspace configs, or files loaded with the global flag `--config-file `. + [EOF] + [exit status: 1] + "); + + // Allows creating a new .toml file under a valid config directory + let valid_file = test_env.config_path().join("custom.toml"); + let output = work_dir.run_jj([ + "config", + "set", + "--file", + valid_file.to_str().unwrap(), + "custom-key", + "custom-val", + ]); + insta::assert_snapshot!(output, @""); + assert!(valid_file.exists()); + + // Allows targeting a valid config file via relative path + let output = work_dir.run_jj([ + "config", + "set", + "--file", + "../config/relative.toml", + "rel-key", + "rel-val", + ]); + insta::assert_snapshot!(output, @""); + insta::assert_snapshot!( + std::fs::read_to_string(test_env.config_path().join("relative.toml"))?, + @r#" + #:schema https://docs.jj-vcs.dev/latest/config-schema.json + + rel-key = "rel-val" + "# + ); + + // Files loaded via the global --config-file option are also recognized as + // loaded layers and can be targeted with --file. This behavior can be + // removed if necessary. + let custom_file = test_env.env_root().join("outside.toml"); + std::fs::write(&custom_file, "")?; + let output = work_dir.run_jj([ + "--config-file", + custom_file.to_str().unwrap(), + "config", + "set", + "--file", + custom_file.to_str().unwrap(), + "outside-key", + "outside-val", + ]); + insta::assert_snapshot!(output, @""); + assert_eq!( + std::fs::read_to_string(&custom_file)?, + "outside-key = \"outside-val\"\n" + ); + + Ok(()) +} diff --git a/docs/config.md b/docs/config.md index 2a8cf2beb68..6628f7b7742 100644 --- a/docs/config.md +++ b/docs/config.md @@ -27,6 +27,9 @@ These are listed in the order they are loaded; the settings from earlier items in the list are overridden by the settings from later items if they disagree. Every type of config except for the built-in settings is optional. +Individual config files can also be targeted with +`jj config {edit,set,unset} --file `. + You can enable JSON Schema validation in your editor by adding a `#:schema` reference at the top of your TOML config files. See [JSON Schema Support] for details. @@ -2281,6 +2284,9 @@ the following precedence order (with later configs overriding earlier ones). These configs can be overridden by [the user config files], and will be disabled in favor of the `JJ_CONFIG` environment variable if it is set. +Existing system config files loaded by `jj` can be edited using +`jj config {edit,set,unset} --file `. + ### JSON Schema Support Many popular editors support TOML file syntax highlighting and validation. To @@ -2411,6 +2417,15 @@ work = "heads(::@ ~ description(''))::" wip = ["log", "-r", "work"] ``` +You can modify specific configuration files such as those in +`conf.d/` directly using the `--file` option (the file will be created if it +doesn't already exist): + +```shell +jj config set --file ~/.config/jj/conf.d/work.toml user.email "YOUR_WORK_EMAIL@workplace.com" +jj config edit --file ~/.config/jj/conf.d/work.toml +``` + #### Available condition keys * `--when.repositories`: List of paths to match the repository path prefix. From 8edf26896e88a87604cd06042d0204d2580c0151 Mon Sep 17 00:00:00 2001 From: Vaghinak Vardanyan Date: Mon, 24 Aug 2026 07:03:50 +0000 Subject: [PATCH 2/2] cli: target primary config file for {edit,set,unset} --user When modifying user configuration (`jj config {edit,set,unset} --user`), always target the primary user configuration file (`~/.config/jj/config.toml` or `~/.jjconfig.toml`) rather than prompting when drop-in files exist in `conf.d/`. Specific drop-in files in `conf.d/` can now be targeted explicitly with the `--file` option. Fixes #9541 --- CHANGELOG.md | 5 +++++ cli/src/commands/config/mod.rs | 29 ++++++++++------------------- cli/src/commands/config/set.rs | 4 ++-- cli/tests/test_config_command.rs | 11 +---------- docs/config.md | 5 ++++- 5 files changed, 22 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a88a6a967f..505de99fa7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * `jj git import` in non-colocated repositories no longer imports commits from a detached Git HEAD branch. +* `jj config {edit,set,unset} --user` now targets the first loaded user + configuration file (e.g. `~/.config/jj/config.toml` or the first file in + `conf.d/`) instead of prompting interactively when multiple files exist. + Use `--file ` to target a specific config file. + ### Deprecations ### New features diff --git a/cli/src/commands/config/mod.rs b/cli/src/commands/config/mod.rs index 114a680be10..c0577480955 100644 --- a/cli/src/commands/config/mod.rs +++ b/cli/src/commands/config/mod.rs @@ -121,8 +121,8 @@ pub(crate) struct ConfigTargetArgs { /// /// The path must point to a valid configuration file location recognized /// by Jujutsu (such as a user/repo/workspace config, a file inside a - /// `conf.d/` directory, or any file loaded via system configs, `$JJ_CONFIG`, - /// or `--config-file`). + /// `conf.d/` directory, or any file loaded via system configs, + /// `$JJ_CONFIG`, or `--config-file`). /// /// Unlike the global `--config-file` option (which loads an extra config /// file when running commands), this option specifies which file to @@ -142,36 +142,27 @@ impl ConfigTargetArgs { ) -> Result { let config_env = command.config_env(); let config = command.raw_config(); - let pick_one = |mut files: Vec, not_found_error: &str| { - if files.len() > 1 { - let mut choices = vec![]; - let mut formatter = ui.stderr_formatter(); - for (i, file) in files.iter().enumerate() { - writeln!(formatter, "{}: {}", i + 1, file.path().display())?; - choices.push((i + 1).to_string()); - } - drop(formatter); - let index = - ui.prompt_choice("Choose a config file (default 1)", &choices, Some(0))?; - return Ok(files[index].clone()); - } - files.pop().ok_or_else(|| user_error(not_found_error)) + let pick_first = |files: Vec, not_found_error: &str| { + files + .into_iter() + .next() + .ok_or_else(|| user_error(not_found_error)) }; if let Some(file) = &self.file { let path = command.cwd().join(file); config_env.resolve_file_to_edit(ui, config, &path) } else if self.user { - pick_one( + pick_first( config_env.user_config_files(config)?, "No user config path found to edit", ) } else if self.repo { - pick_one( + pick_first( config_env.repo_config_files(ui, config)?, "No repo config path found to edit", ) } else if self.workspace { - pick_one( + pick_first( config_env.workspace_config_files(ui, config)?, "No workspace config path found to edit", ) diff --git a/cli/src/commands/config/set.rs b/cli/src/commands/config/set.rs index 441a707425d..310c4b1a3aa 100644 --- a/cli/src/commands/config/set.rs +++ b/cli/src/commands/config/set.rs @@ -70,8 +70,8 @@ pub async fn cmd_config_set( ) -> Result<(), CommandError> { let mut file = args.target.edit_config_file(ui, command)?; - // If the user is trying to change the author config, we should warn them that - // it won't affect the working copy author + // If the user is trying to change the author config, we should warn them + // that it won't affect the working copy author if args.name == ConfigNamePathBuf::from_iter(vec!["user", "name"]) { check_wc_author(ui, command, &args.value, AuthorChange::Name).await?; } else if args.name == ConfigNamePathBuf::from_iter(vec!["user", "email"]) { diff --git a/cli/tests/test_config_command.rs b/cli/tests/test_config_command.rs index 98c3b355356..61a489a1113 100644 --- a/cli/tests/test_config_command.rs +++ b/cli/tests/test_config_command.rs @@ -683,13 +683,7 @@ fn test_config_set_for_user_directory() -> TestResult { ".", ["config", "set", "--user", "test-key", "test-other-val"], ); - insta::assert_snapshot!(output, @" - ------- stderr ------- - 1: $TEST_ENV/config/config0001.toml - 2: $TEST_ENV/config/config0002.toml - Choose a config file (default 1): 1 - [EOF] - "); + insta::assert_snapshot!(output, @""); insta::assert_snapshot!( std::fs::read_to_string(test_env.first_config_file_path())?, @@ -1393,9 +1387,6 @@ fn test_config_only_loads_toml_files() -> TestResult { std::fs::File::create(test_env.config_path().join("is-not.loaded"))?; insta::assert_snapshot!(test_env.run_jj_in(".", ["config", "edit", "--user"]), @" ------- stderr ------- - 1: $TEST_ENV/config/config0001.toml - 2: $TEST_ENV/config/config0002.toml - Choose a config file (default 1): 1 Editing file: $TEST_ENV/config/config0001.toml [EOF] "); diff --git a/docs/config.md b/docs/config.md index 6628f7b7742..56e26198cfc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2242,7 +2242,10 @@ recommended for better integration with platform services. The files in the `conf.d` directory are loaded in lexicographic order. This allows configs to be split across multiple files and combines well with -[Conditional Variables](#conditional-variables). +[Conditional Variables](#conditional-variables). Modifying user configuration +with `jj config {edit,set,unset} --user` targets the primary user config file +(or the first loaded file in `conf.d/`). Individual files in `conf.d/` can be +targeted with `--file `. | Platform | Location of `` dir | Example config file location | | :-------------- | :------------------------------------ | :-------------------------------------------------------- |