Skip to content

Commit 95d5c51

Browse files
committed
configs: Add support for repo-level configs for jj
This can be used to add shared configuration across a repository. For example, all users should have the same `jj fix` within a given repository. This commit adds a new command, `jj config review-managed`, which is a mechanism to approve configuration checked in to the repo. See https://chromium-review.googlesource.com/c/chromium/src/+/6703030 for an example
1 parent 5656417 commit 95d5c51

File tree

15 files changed

+510
-20
lines changed

15 files changed

+510
-20
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
200200
* `jj fix` commands now replace `$root` with the workspace's root path. This is
201201
useful for tools stored inside the workspace.
202202

203+
* Introduced an additional config layer `repo-managed` between `user` and
204+
`repo`. This layer is stored in version control, and if it ever changes,
205+
we recommend to the user to review the changes (for security reasons) by
206+
running the new command `jj config review-managed`. This can be used to
207+
create config shared between all users who use a repository (eg.
208+
[fix commands](https://chromium-review.googlesource.com/c/chromium/src/+/6703030))
209+
203210
### Fixed bugs
204211

205212
* Fixed an error in `jj util gc` caused by the empty blob being missing from

cli/src/cli_util.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -360,9 +360,11 @@ impl CommandHelper {
360360
let mut config_env = self.data.config_env.clone();
361361
let mut raw_config = self.data.raw_config.clone();
362362
let repo_path = workspace_root.join(".jj").join("repo");
363-
config_env.reset_repo_path(&repo_path);
363+
config_env.reset_repo_path(&repo_path, workspace_root);
364+
// Since it's a new workspace, we don't need to bother loading the
365+
// repo-managed config.
364366
config_env.reload_repo_config(&mut raw_config)?;
365-
let mut config = config_env.resolve_config(&raw_config)?;
367+
let mut config = config_env.resolve_config_without_repo_managed(&raw_config)?;
366368
// No migration messages here, which would usually be emitted before.
367369
jj_lib::config::migrate(&mut config, &self.data.config_migrations)?;
368370
Ok(self.data.settings.with_new_config(config)?)
@@ -3848,10 +3850,10 @@ impl<'a> CliRunner<'a> {
38483850
.map_err(|err| map_workspace_load_error(err, Some(".")));
38493851
config_env.reload_user_config(&mut raw_config)?;
38503852
if let Ok(loader) = &maybe_cwd_workspace_loader {
3851-
config_env.reset_repo_path(loader.repo_path());
3853+
config_env.reset_repo_path(loader.repo_path(), loader.workspace_root());
38523854
config_env.reload_repo_config(&mut raw_config)?;
38533855
}
3854-
let mut config = config_env.resolve_config(&raw_config)?;
3856+
let mut config = config_env.resolve_config(ui, &mut raw_config)?;
38553857
migrate_config(&mut config)?;
38563858
ui.reset(&config)?;
38573859

@@ -3863,7 +3865,7 @@ impl<'a> CliRunner<'a> {
38633865
let (args, config_layers) = parse_early_args(&self.app, &string_args)?;
38643866
if !config_layers.is_empty() {
38653867
raw_config.as_mut().extend_layers(config_layers);
3866-
config = config_env.resolve_config(&raw_config)?;
3868+
config = config_env.resolve_config(ui, &mut raw_config)?;
38673869
migrate_config(&mut config)?;
38683870
ui.reset(&config)?;
38693871
}
@@ -3892,15 +3894,15 @@ impl<'a> CliRunner<'a> {
38923894
.workspace_loader_factory
38933895
.create(&abs_path)
38943896
.map_err(|err| map_workspace_load_error(err, Some(path)))?;
3895-
config_env.reset_repo_path(loader.repo_path());
3897+
config_env.reset_repo_path(loader.repo_path(), loader.workspace_root());
38963898
config_env.reload_repo_config(&mut raw_config)?;
38973899
Ok(loader)
38983900
} else {
38993901
maybe_cwd_workspace_loader
39003902
};
39013903

39023904
// Apply workspace configs, --config arguments, and --when.commands.
3903-
config = config_env.resolve_config(&raw_config)?;
3905+
config = config_env.resolve_config(ui, &mut raw_config)?;
39043906
migrate_config(&mut config)?;
39053907
ui.reset(&config)?;
39063908

@@ -3910,6 +3912,7 @@ impl<'a> CliRunner<'a> {
39103912
ConfigSource::Default => "default-provided",
39113913
ConfigSource::EnvBase | ConfigSource::EnvOverrides => "environment-provided",
39123914
ConfigSource::User => "user-level",
3915+
ConfigSource::RepoManaged => "repo-managed-level",
39133916
ConfigSource::Repo => "repo-level",
39143917
ConfigSource::CommandArg => "CLI-provided",
39153918
};

cli/src/command_error.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ impl From<ConfigLoadError> for CommandError {
271271
fn from(err: ConfigLoadError) -> Self {
272272
let hint = match &err {
273273
ConfigLoadError::Read(_) => None,
274+
ConfigLoadError::Get { .. } => None,
274275
ConfigLoadError::Parse { source_path, .. } => source_path
275276
.as_ref()
276277
.map(|path| format!("Check the config file: {}", path.display())),

cli/src/commands/config/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod edit;
1616
mod get;
1717
mod list;
1818
mod path;
19+
mod review_managed;
1920
mod set;
2021
mod unset;
2122

@@ -34,6 +35,8 @@ use self::list::ConfigListArgs;
3435
use self::list::cmd_config_list;
3536
use self::path::ConfigPathArgs;
3637
use self::path::cmd_config_path;
38+
use self::review_managed::ConfigReviewManagedArgs;
39+
use self::review_managed::cmd_review_managed;
3740
use self::set::ConfigSetArgs;
3841
use self::set::cmd_config_set;
3942
use self::unset::ConfigUnsetArgs;
@@ -146,6 +149,8 @@ pub(crate) enum ConfigCommand {
146149
Set(ConfigSetArgs),
147150
#[command(visible_alias("u"))]
148151
Unset(ConfigUnsetArgs),
152+
#[command()]
153+
ReviewManaged(ConfigReviewManagedArgs),
149154
}
150155

151156
#[instrument(skip_all)]
@@ -161,5 +166,6 @@ pub(crate) fn cmd_config(
161166
ConfigCommand::Path(args) => cmd_config_path(ui, command, args),
162167
ConfigCommand::Set(args) => cmd_config_set(ui, command, args),
163168
ConfigCommand::Unset(args) => cmd_config_unset(ui, command, args),
169+
ConfigCommand::ReviewManaged(args) => cmd_review_managed(ui, command, args),
164170
}
165171
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright 2025 The Jujutsu Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use std::path::PathBuf;
16+
17+
use tracing::instrument;
18+
19+
use crate::cli_util::CommandHelper;
20+
use crate::command_error::CommandError;
21+
use crate::command_error::internal_error_with_message;
22+
use crate::command_error::user_error;
23+
use crate::command_error::user_error_with_message;
24+
use crate::config::maybe_read;
25+
use crate::merge_tools::make_diff_sections;
26+
use crate::ui::Ui;
27+
28+
/// Reviews and updates configuration stored in version control.
29+
/// You should never need to run this command unless jj tells you to.
30+
/// This command needs to be run when the config checked in to the repo is
31+
/// changed, and allows you to approve or reject said changes on a line-by-line
32+
/// basis.
33+
#[derive(clap::Args, Clone, Debug)]
34+
pub struct ConfigReviewManagedArgs {
35+
/// Trust the repository's config and skip review of it.
36+
/// Use this when you absolutely trust the repo config (eg. you're the only
37+
/// contributor).
38+
#[arg(long)]
39+
trust: bool,
40+
}
41+
42+
#[instrument(skip_all)]
43+
pub fn cmd_review_managed(
44+
ui: &mut Ui,
45+
command: &CommandHelper,
46+
args: &ConfigReviewManagedArgs,
47+
) -> Result<(), CommandError> {
48+
// It'd be nice if we were able to just error out here.
49+
// But in the event that a user disables their repo-managed-config in their
50+
// repo-managed-config, that would leave no way to re-enable it easily.
51+
if !command
52+
.raw_config()
53+
.as_ref()
54+
.get("repo-managed-config.enabled")?
55+
{
56+
writeln!(ui.warning_default(), "repo-managed-config is disabled.")?;
57+
writeln!(
58+
ui.hint_default(),
59+
"Enable it with `jj config set <--user|--repo> repo-managed-config.enabled true`"
60+
)?;
61+
}
62+
if let Some(paths) = command.config_env().repo_managed_config_paths() {
63+
let workspace_command = command.workspace_helper(ui)?;
64+
let path_converter = workspace_command.path_converter();
65+
let vcs = maybe_read(
66+
&paths
67+
.managed
68+
.to_fs_path(workspace_command.workspace_root())
69+
.map_err(|e| {
70+
internal_error_with_message("Managed path is not a valid FS path", e)
71+
})?,
72+
)?
73+
.unwrap_or_default();
74+
let config = maybe_read(&paths.config)?.unwrap_or_default();
75+
76+
if vcs.is_empty() {
77+
// We don't need the user to review this since it's not a security issue.
78+
writeln!(
79+
ui.status(),
80+
"The config file has been removed from the VCS, so we have removed the local copy \
81+
too."
82+
)?;
83+
std::fs::remove_file(paths.config)?;
84+
std::fs::remove_file(paths.last_reviewed)?;
85+
return Ok(());
86+
}
87+
88+
if config == vcs {
89+
writeln!(ui.status(), "Your config file is already up to date")?;
90+
return Ok(());
91+
}
92+
93+
let new_config = if args.trust {
94+
vcs.clone()
95+
} else {
96+
let sections = make_diff_sections(
97+
&String::from_utf8(config).map_err(|e| {
98+
user_error_with_message("Currently applied config was not utf-8", e)
99+
})?,
100+
&String::from_utf8(vcs.clone()).map_err(|e| {
101+
user_error_with_message("Config stored in VCS was not utf-8", e)
102+
})?,
103+
)
104+
.map_err(|e| internal_error_with_message("Failed to create diff sections", e))?;
105+
// Ideally we'd use the user's chosen diff selector, but that
106+
// heavily relies on jj's objects such as Tree and Store.
107+
let managed_path = PathBuf::from(path_converter.format_file_path(&paths.managed));
108+
let recorded = scm_record::Recorder::new(
109+
scm_record::RecordState {
110+
is_read_only: false,
111+
commits: vec![],
112+
files: vec![scm_record::File {
113+
old_path: None,
114+
path: std::borrow::Cow::Borrowed(&managed_path),
115+
// This doesn't do anything.
116+
file_mode: scm_record::FileMode::Unix(0o777),
117+
sections,
118+
}],
119+
},
120+
&mut scm_record::helpers::CrosstermInput,
121+
)
122+
.run()
123+
.map_err(|_| user_error("Failed to select changes"))?;
124+
125+
// There's always precisely one file.
126+
reconstruct(&recorded.files[0].sections).into_bytes()
127+
};
128+
std::fs::write(paths.config, new_config)?;
129+
std::fs::write(paths.last_reviewed, vcs)?;
130+
writeln!(ui.status(), "Updated repo config file")?;
131+
Ok(())
132+
} else {
133+
Err(user_error(
134+
"Unable to detect location of config files. Are you in a repo?",
135+
))
136+
}
137+
}
138+
139+
fn reconstruct(sections: &[scm_record::Section]) -> String {
140+
let mut out: Vec<&str> = Default::default();
141+
for section in sections {
142+
match section {
143+
scm_record::Section::Unchanged { lines } => out.extend(lines.iter().map(AsRef::as_ref)),
144+
scm_record::Section::Changed { lines } => {
145+
for line in lines {
146+
if line.is_checked == (line.change_type == scm_record::ChangeType::Added) {
147+
out.push(&line.line);
148+
}
149+
}
150+
}
151+
_ => {}
152+
}
153+
}
154+
out.join("")
155+
}

cli/src/complete.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -935,10 +935,10 @@ fn get_jj_command() -> Result<(JjBuilder, UserSettings), CommandError> {
935935
let maybe_cwd_workspace_loader = DefaultWorkspaceLoaderFactory.create(find_workspace_dir(&cwd));
936936
let _ = config_env.reload_user_config(&mut raw_config);
937937
if let Ok(loader) = &maybe_cwd_workspace_loader {
938-
config_env.reset_repo_path(loader.repo_path());
938+
config_env.reset_repo_path(loader.repo_path(), loader.workspace_root());
939939
let _ = config_env.reload_repo_config(&mut raw_config);
940940
}
941-
let mut config = config_env.resolve_config(&raw_config)?;
941+
let mut config = config_env.resolve_config(&ui, &mut raw_config)?;
942942
// skip 2 because of the clap_complete prelude: jj -- jj <actual args...>
943943
let args = std::env::args_os().skip(2);
944944
let args = expand_args(&ui, &app, args, &config)?;
@@ -953,9 +953,9 @@ fn get_jj_command() -> Result<(JjBuilder, UserSettings), CommandError> {
953953
if let Some(repository) = args.repository {
954954
// Try to update repo-specific config on a best-effort basis.
955955
if let Ok(loader) = DefaultWorkspaceLoaderFactory.create(&cwd.join(&repository)) {
956-
config_env.reset_repo_path(loader.repo_path());
956+
config_env.reset_repo_path(loader.repo_path(), loader.workspace_root());
957957
let _ = config_env.reload_repo_config(&mut raw_config);
958-
if let Ok(new_config) = config_env.resolve_config(&raw_config) {
958+
if let Ok(new_config) = config_env.resolve_config(&ui, &mut raw_config) {
959959
config = new_config;
960960
}
961961
}

0 commit comments

Comments
 (0)