Skip to content

Commit 507083f

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 9b86159 commit 507083f

File tree

13 files changed

+345
-13
lines changed

13 files changed

+345
-13
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 `jj config review-managed`. This can be used to create config shared
49+
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/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
}

cli/src/config.rs

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use std::collections::HashMap;
1818
use std::env;
1919
use std::env::split_paths;
2020
use std::fmt;
21+
use std::io;
2122
use std::path::Path;
2223
use std::path::PathBuf;
2324
use std::process::Command;
@@ -36,6 +37,8 @@ use jj_lib::config::ConfigSource;
3637
use jj_lib::config::ConfigValue;
3738
use jj_lib::config::StackedConfig;
3839
use jj_lib::dsl_util;
40+
use jj_lib::file_util::IoResultExt as _;
41+
use jj_lib::file_util::PathError;
3942
use regex::Captures;
4043
use regex::Regex;
4144
use tracing::instrument;
@@ -326,11 +329,31 @@ impl UnresolvedConfigEnv {
326329
pub struct ConfigEnv {
327330
home_dir: Option<PathBuf>,
328331
repo_path: Option<PathBuf>,
332+
workspace_root: Option<PathBuf>,
329333
user_config_paths: Vec<ConfigPath>,
330334
repo_config_path: Option<ConfigPath>,
331335
command: Option<String>,
332336
}
333337

338+
pub struct RepoManagedConfigPaths {
339+
/// The path to the file managed by the repo.
340+
pub managed: PathBuf,
341+
/// The path to the managed config that is actually in use.
342+
pub config: PathBuf,
343+
/// The path to the content of the file that was last reviewed.
344+
pub last_reviewed: PathBuf,
345+
}
346+
347+
// The same as std::fs::read_to_string, but returns None if the file was not
348+
// found.
349+
pub fn maybe_read_to_string(path: &Path) -> io::Result<Option<String>> {
350+
match std::fs::read_to_string(path) {
351+
Ok(val) => Ok(Some(val)),
352+
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
353+
Err(e) => Err(e),
354+
}
355+
}
356+
334357
impl ConfigEnv {
335358
/// Initializes configuration loader based on environment variables.
336359
pub fn from_environment(ui: &Ui) -> Self {
@@ -371,6 +394,7 @@ impl ConfigEnv {
371394
ConfigEnv {
372395
home_dir,
373396
repo_path: None,
397+
workspace_root: None,
374398
user_config_paths: env.resolve(ui),
375399
repo_config_path: None,
376400
command: None,
@@ -441,9 +465,22 @@ impl ConfigEnv {
441465

442466
/// Sets the directory where repo-specific config file is stored. The path
443467
/// is usually `.jj/repo`.
444-
pub fn reset_repo_path(&mut self, path: &Path) {
445-
self.repo_path = Some(path.to_owned());
446-
self.repo_config_path = Some(ConfigPath::new(path.join("config.toml")));
468+
pub fn reset_repo_path(&mut self, repo_path: &Path, workspace_root: &Path) {
469+
self.repo_path = Some(repo_path.to_owned());
470+
self.workspace_root = Some(workspace_root.to_owned());
471+
self.repo_config_path = Some(ConfigPath::new(repo_path.join("config.toml")));
472+
}
473+
474+
/// Returns all paths associated with the repo-managed configuration.
475+
pub fn repo_managed_config_paths(&self) -> Option<RepoManagedConfigPaths> {
476+
match (&self.repo_path, &self.workspace_root) {
477+
(Some(repo_path), Some(workspace_root)) => Some(RepoManagedConfigPaths {
478+
config: repo_path.join("generated_repo_config.toml"),
479+
last_reviewed: repo_path.join("last_reviewed_repo_config.toml"),
480+
managed: workspace_root.join(".config/jj/config.toml"),
481+
}),
482+
_ => None,
483+
}
447484
}
448485

449486
/// Returns a path to the repo-specific config file.
@@ -480,6 +517,55 @@ impl ConfigEnv {
480517
.transpose()
481518
}
482519

520+
/// Loads repo-managed config file for the user into the given `config`.
521+
/// The old repo-managed config layer will be replaced if any.
522+
fn reload_repo_managed_config(
523+
&self,
524+
ui: &Ui,
525+
config: &mut RawConfig,
526+
) -> Result<(), ConfigLoadError> {
527+
if !self.reload_repo_managed_config_internal(config)? {
528+
writeln!(
529+
ui.warning_default(),
530+
"Your repo-managed config is out of date"
531+
)
532+
.context(PathBuf::from("stderr"))
533+
.map_err(ConfigLoadError::Read)?;
534+
writeln!(ui.hint_default(), "Run `jj config review-managed`")
535+
.context(PathBuf::from("stderr"))
536+
.map_err(ConfigLoadError::Read)?;
537+
}
538+
Ok(())
539+
}
540+
541+
// When we pass UI it can't be instrumented (since it doesn't implement
542+
// std::fmt::Debug), so we split this up into two functions.
543+
#[instrument]
544+
fn reload_repo_managed_config_internal(
545+
&self,
546+
config: &mut RawConfig,
547+
) -> Result<bool, ConfigLoadError> {
548+
let mut_config = config.as_mut();
549+
mut_config.remove_layers(ConfigSource::RepoManaged);
550+
Ok(if let Some(paths) = self.repo_managed_config_paths() {
551+
match mut_config.load_file(ConfigSource::RepoManaged, paths.config) {
552+
Ok(()) => {}
553+
Err(ConfigLoadError::Read(PathError { error, .. }))
554+
if error.kind() == io::ErrorKind::NotFound => {}
555+
e => e?,
556+
}
557+
let vcs_content = maybe_read_to_string(&paths.managed)
558+
.context(paths.managed)
559+
.map_err(ConfigLoadError::Read)?;
560+
let last_reviewed_content = maybe_read_to_string(&paths.last_reviewed)
561+
.context(paths.last_reviewed)
562+
.map_err(ConfigLoadError::Read)?;
563+
vcs_content == last_reviewed_content
564+
} else {
565+
true
566+
})
567+
}
568+
483569
/// Loads repo-specific config file into the given `config`. The old
484570
/// repo-config layer will be replaced if any.
485571
#[instrument]
@@ -491,6 +577,16 @@ impl ConfigEnv {
491577
Ok(())
492578
}
493579

580+
/// Reloads both the repo-config and the repo-managed config.
581+
pub fn reload_all_repo_config(
582+
&self,
583+
ui: &Ui,
584+
config: &mut RawConfig,
585+
) -> Result<(), ConfigLoadError> {
586+
self.reload_repo_managed_config(ui, config)?;
587+
self.reload_repo_config(config)
588+
}
589+
494590
/// Resolves conditional scopes within the current environment. Returns new
495591
/// resolved config.
496592
pub fn resolve_config(&self, config: &RawConfig) -> Result<StackedConfig, ConfigGetError> {
@@ -1767,6 +1863,7 @@ mod tests {
17671863
ConfigEnv {
17681864
home_dir,
17691865
repo_path: None,
1866+
workspace_root: None,
17701867
user_config_paths: env.resolve(&Ui::null()),
17711868
repo_config_path: None,
17721869
command: None,

0 commit comments

Comments
 (0)