Skip to content

Commit 128b569

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. See https://chromium-review.googlesource.com/c/chromium/src/+/6703030 for an example
1 parent 4bf268a commit 128b569

File tree

15 files changed

+376
-15
lines changed

15 files changed

+376
-15
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4242
* `jj fix` now buffers lines from subprocesses' stderr streams and emits them a
4343
complete line at a time. Each line is prepended with the file name.
4444

45+
* Introduced an additional config layer `repo-managed` between `user` and
46+
`repo`. This layer is stored in version control, and if it ever changes,
47+
we recommend to the user to review the changes (for security reasons) by
48+
running the new command `jj config review-managed`. This can be used to
49+
create config shared between all users who use a repository (eg.
50+
[fix commands](https://chromium-review.googlesource.com/c/chromium/src/+/6703030))
51+
4552
### Fixed bugs
4653

4754
### Packaging changes

cli/src/cli_util.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,9 @@ 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)?;
365367
let mut config = config_env.resolve_config(&raw_config)?;
366368
// No migration messages here, which would usually be emitted before.
@@ -3857,8 +3859,8 @@ impl<'a> CliRunner<'a> {
38573859
.map_err(|err| map_workspace_load_error(err, Some(".")));
38583860
config_env.reload_user_config(&mut raw_config)?;
38593861
if let Ok(loader) = &maybe_cwd_workspace_loader {
3860-
config_env.reset_repo_path(loader.repo_path());
3861-
config_env.reload_repo_config(&mut raw_config)?;
3862+
config_env.reset_repo_path(loader.repo_path(), loader.workspace_root());
3863+
config_env.reload_all_repo_config(ui, &mut raw_config)?;
38623864
}
38633865
let mut config = config_env.resolve_config(&raw_config)?;
38643866
migrate_config(&mut config)?;
@@ -3907,8 +3909,8 @@ impl<'a> CliRunner<'a> {
39073909
.workspace_loader_factory
39083910
.create(&abs_path)
39093911
.map_err(|err| map_workspace_load_error(err, Some(path)))?;
3910-
config_env.reset_repo_path(loader.repo_path());
3911-
config_env.reload_repo_config(&mut raw_config)?;
3912+
config_env.reset_repo_path(loader.repo_path(), loader.workspace_root());
3913+
config_env.reload_all_repo_config(ui, &mut raw_config)?;
39123914
Ok(loader)
39133915
} else {
39143916
maybe_cwd_workspace_loader
@@ -3925,6 +3927,7 @@ impl<'a> CliRunner<'a> {
39253927
ConfigSource::Default => "default-provided",
39263928
ConfigSource::EnvBase | ConfigSource::EnvOverrides => "environment-provided",
39273929
ConfigSource::User => "user-level",
3930+
ConfigSource::RepoManaged => "repo-managed-level",
39283931
ConfigSource::Repo => "repo-level",
39293932
ConfigSource::CommandArg => "CLI-provided",
39303933
};

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::cmd_config_list;
3435
use self::list::ConfigListArgs;
3536
use self::path::cmd_config_path;
3637
use self::path::ConfigPathArgs;
38+
use self::review_managed::cmd_review_managed;
39+
use self::review_managed::ConfigReviewManagedArgs;
3740
use self::set::cmd_config_set;
3841
use self::set::ConfigSetArgs;
3942
use self::unset::cmd_config_unset;
@@ -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: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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::borrow::Borrow as _;
16+
use std::borrow::Cow;
17+
18+
use tracing::instrument;
19+
20+
use crate::cli_util::CommandHelper;
21+
use crate::command_error::internal_error_with_message;
22+
use crate::command_error::user_error;
23+
use crate::command_error::CommandError;
24+
use crate::config::maybe_read_to_string;
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+
#[derive(clap::Args, Clone, Debug)]
31+
pub struct ConfigReviewManagedArgs {
32+
/// Trust the repository's config and skip review of it.
33+
/// Use this when you absolutely trust the repo config (eg. you're the only
34+
/// contributor).
35+
#[arg(long)]
36+
trust: bool,
37+
}
38+
39+
#[instrument(skip_all)]
40+
pub fn cmd_review_managed(
41+
ui: &mut Ui,
42+
command: &CommandHelper,
43+
args: &ConfigReviewManagedArgs,
44+
) -> Result<(), CommandError> {
45+
if let Some(paths) = command.config_env().repo_managed_config_paths() {
46+
// Treat an empty file the same as a nonexistent one.
47+
let vcs = maybe_read_to_string(&paths.managed)?.unwrap_or_else(String::new);
48+
let config = maybe_read_to_string(&paths.config)?.unwrap_or_else(String::new);
49+
50+
if vcs.is_empty() {
51+
// We don't need the user to review this since it's not a security issue.
52+
writeln!(
53+
ui.status(),
54+
"The config file has been removed from the VCS, so we have removed the local copy \
55+
too."
56+
)?;
57+
std::fs::remove_file(paths.config)?;
58+
std::fs::remove_file(paths.last_reviewed)?;
59+
return Ok(());
60+
}
61+
62+
if config == vcs {
63+
writeln!(ui.status(), "Your config file is already up to date")?;
64+
return Ok(());
65+
}
66+
67+
let new_config = if args.trust {
68+
vcs.clone()
69+
} else {
70+
let sections = make_diff_sections(&config, &vcs)
71+
.map_err(|e| internal_error_with_message("Failed to create diff sections", e))?;
72+
// Ideally we'd use the user's chosen diff selector, but that
73+
// heavily relies on jj's objects such as Tree and Store.
74+
let recorded = scm_record::Recorder::new(
75+
scm_record::RecordState {
76+
is_read_only: false,
77+
commits: vec![],
78+
files: vec![scm_record::File {
79+
old_path: None,
80+
path: std::borrow::Cow::Borrowed(&paths.managed),
81+
// This doesn't do anything.
82+
file_mode: scm_record::FileMode::Unix(0o777),
83+
sections,
84+
}],
85+
},
86+
&mut scm_record::helpers::CrosstermInput,
87+
)
88+
.run()
89+
.map_err(|_| user_error("Failed to select changes"))?;
90+
91+
// There's always precisely one file.
92+
reconstruct(&recorded.files[0].sections)
93+
};
94+
std::fs::write(paths.config, new_config)?;
95+
std::fs::write(paths.last_reviewed, vcs)?;
96+
writeln!(ui.status(), "Updated repo config file")?;
97+
Ok(())
98+
} else {
99+
Err(user_error(
100+
"Unable to detect location of config files. Are you in a repo?",
101+
))
102+
}
103+
}
104+
105+
fn reconstruct<'a>(sections: &[scm_record::Section<'a>]) -> String {
106+
let mut out: Vec<&Cow<'a, str>> = Default::default();
107+
for section in sections {
108+
match section {
109+
scm_record::Section::Unchanged { lines } => out.extend(lines),
110+
scm_record::Section::Changed { lines } => {
111+
for line in lines {
112+
if line.is_checked == (line.change_type == scm_record::ChangeType::Added) {
113+
out.push(&line.line);
114+
}
115+
}
116+
}
117+
_ => {}
118+
}
119+
}
120+
out.into_iter()
121+
.map(|s| s.borrow())
122+
.collect::<Vec<&str>>()
123+
.join("")
124+
}

cli/src/complete.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -867,8 +867,8 @@ fn get_jj_command() -> Result<(JjBuilder, UserSettings), CommandError> {
867867
let maybe_cwd_workspace_loader = DefaultWorkspaceLoaderFactory.create(find_workspace_dir(&cwd));
868868
let _ = config_env.reload_user_config(&mut raw_config);
869869
if let Ok(loader) = &maybe_cwd_workspace_loader {
870-
config_env.reset_repo_path(loader.repo_path());
871-
let _ = config_env.reload_repo_config(&mut raw_config);
870+
config_env.reset_repo_path(loader.repo_path(), loader.workspace_root());
871+
let _ = config_env.reload_all_repo_config(&ui, &mut raw_config);
872872
}
873873
let mut config = config_env.resolve_config(&raw_config)?;
874874
// skip 2 because of the clap_complete prelude: jj -- jj <actual args...>
@@ -885,8 +885,8 @@ fn get_jj_command() -> Result<(JjBuilder, UserSettings), CommandError> {
885885
if let Some(repository) = args.repository {
886886
// Try to update repo-specific config on a best-effort basis.
887887
if let Ok(loader) = DefaultWorkspaceLoaderFactory.create(&cwd.join(&repository)) {
888-
config_env.reset_repo_path(loader.repo_path());
889-
let _ = config_env.reload_repo_config(&mut raw_config);
888+
config_env.reset_repo_path(loader.repo_path(), loader.workspace_root());
889+
let _ = config_env.reload_all_repo_config(&ui, &mut raw_config);
890890
if let Ok(new_config) = config_env.resolve_config(&raw_config) {
891891
config = new_config;
892892
}

0 commit comments

Comments
 (0)