Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
filesystem's behavior, but this can be overridden manually by setting
`working-copy.exec-bit-change = "respect" | "ignore"`.

* Per-repo and per-workspace config is now stored outside the repo, for security
reasons. This is not a breaking change because we automatically migrate
legacy repos to this new format. `.jj/repo/config.toml` and
`.jj/workspace-config.toml` should no longer be used.

### Fixed bugs

## [0.36.0] - 2025-12-03
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ gix = { version = "0.75.0", default-features = false, features = [
] }
globset = "0.4.18"
hashbrown = { version = "0.16.1", default-features = false, features = ["inline-more"] }
hex = "0.4.3"
ignore = "0.4.25"
indexmap = { version = "2.12.1", features = ["serde"] }
indoc = "2.0.7"
Expand Down
2 changes: 1 addition & 1 deletion cli/src/cli_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ impl CommandHelper {
///
/// This may be different from the settings for new workspace created by
/// e.g. `jj git init`. There may be conditional variables and repo config
/// `.jj/repo/config.toml` loaded for the cwd workspace.
/// loaded for the cwd workspace.
pub fn settings(&self) -> &UserSettings {
&self.data.settings
}
Expand Down
3 changes: 3 additions & 0 deletions cli/src/command_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,9 @@ impl From<ConfigLoadError> for CommandError {
ConfigLoadError::Parse { source_path, .. } => source_path
.as_ref()
.map(|path| format!("Check the config file: {}", path.display())),
ConfigLoadError::SecureConfigError(e) => {
Some(format!("Failed to load secure config: {e}"))
}
};
let mut cmd_err = config_error(err);
cmd_err.extend_hints(hint);
Expand Down
13 changes: 8 additions & 5 deletions cli/src/commands/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ mod path;
mod set;
mod unset;

use std::path::Path;
use std::path::PathBuf;

use itertools::Itertools as _;
use jj_lib::config::ConfigFile;
Expand Down Expand Up @@ -73,21 +73,24 @@ impl ConfigLevelArgs {
}
}

fn config_paths<'a>(&self, config_env: &'a ConfigEnv) -> Result<Vec<&'a Path>, CommandError> {
fn config_paths(&self, config_env: &ConfigEnv) -> Result<Vec<PathBuf>, CommandError> {
if self.user {
let paths = config_env.user_config_paths().collect_vec();
let paths = config_env
.user_config_paths()
.map(|p| p.to_path_buf())
.collect_vec();
if paths.is_empty() {
return Err(user_error("No user config path found"));
}
Ok(paths)
} else if self.repo {
config_env
.repo_config_path()
.repo_config_path()?
.map(|p| vec![p])
.ok_or_else(|| user_error("No repo config path found"))
} else if self.workspace {
config_env
.workspace_config_path()
.workspace_config_path()?
.map(|p| vec![p])
.ok_or_else(|| user_error("No workspace config path found"))
} else {
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/config/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub fn cmd_config_path(
args: &ConfigPathArgs,
) -> Result<(), CommandError> {
for config_path in args.level.config_paths(command.config_env())? {
let path_bytes = file_util::path_to_bytes(config_path).map_err(user_error)?;
let path_bytes = file_util::path_to_bytes(&config_path).map_err(user_error)?;
ui.stdout().write_all(path_bytes)?;
writeln!(ui.stdout())?;
}
Expand Down
101 changes: 66 additions & 35 deletions cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use jj_lib::config::ConfigSource;
use jj_lib::config::ConfigValue;
use jj_lib::config::StackedConfig;
use jj_lib::dsl_util;
use jj_lib::secure_config::SecureConfig;
use regex::Captures;
use regex::Regex;
use serde::Serialize as _;
Expand Down Expand Up @@ -271,6 +272,10 @@ struct UnresolvedConfigEnv {
}

impl UnresolvedConfigEnv {
fn root_config_dir(&self) -> Option<PathBuf> {
self.config_dir.as_deref().map(|c| c.join("jj"))
}

fn resolve(self) -> Vec<ConfigPath> {
if let Some(paths) = self.jj_config {
return split_paths(&paths)
Expand Down Expand Up @@ -320,11 +325,12 @@ impl UnresolvedConfigEnv {
#[derive(Clone, Debug)]
pub struct ConfigEnv {
home_dir: Option<PathBuf>,
root_config_dir: Option<PathBuf>,
repo_path: Option<PathBuf>,
workspace_path: Option<PathBuf>,
user_config_paths: Vec<ConfigPath>,
repo_config_path: Option<ConfigPath>,
workspace_config_path: Option<ConfigPath>,
repo_config: Option<SecureConfig>,
workspace_config: Option<SecureConfig>,
command: Option<String>,
hostname: Option<String>,
}
Expand All @@ -349,11 +355,12 @@ impl ConfigEnv {
};
Self {
home_dir,
root_config_dir: env.root_config_dir(),
repo_path: None,
workspace_path: None,
user_config_paths: env.resolve(),
repo_config_path: None,
workspace_config_path: None,
repo_config: None,
workspace_config: None,
command: None,
hostname: whoami::fallible::hostname().ok(),
}
Expand Down Expand Up @@ -424,21 +431,33 @@ 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) {
if self.repo_path.as_deref() != Some(path)
&& let Some(root_config_dir) = self.root_config_dir.as_deref()
{
self.repo_config = Some(SecureConfig::new(
path.to_owned(),
root_config_dir.join("repos"),
"repo-config-id",
"config.toml",
));
}
self.repo_path = Some(path.to_owned());
self.repo_config_path = Some(ConfigPath::new(path.join("config.toml")));
}

/// Returns a path to the repo-specific config file.
pub fn repo_config_path(&self) -> Option<&Path> {
self.repo_config_path.as_ref().map(|p| p.as_path())
/// Returns a path to the existing repo-specific config file.
fn maybe_repo_config_path(&self) -> Result<Option<PathBuf>, ConfigLoadError> {
Ok(match &self.repo_config {
Some(config) => config.maybe_load_config()?.0,
None => None,
})
}

/// Returns a path to the existing repo-specific config file.
fn existing_repo_config_path(&self) -> Option<&Path> {
match self.repo_config_path {
Some(ref path) if path.exists() => Some(path.as_path()),
_ => None,
}
pub fn repo_config_path(&self) -> Result<Option<PathBuf>, ConfigLoadError> {
Ok(match &self.repo_config {
Some(config) => Some(config.load_config()?.0),
None => None,
})
}

/// Returns repo configuration files for modification. Instantiates one if
Expand All @@ -455,7 +474,7 @@ impl ConfigEnv {
}

fn new_repo_config_file(&self) -> Result<Option<ConfigFile>, ConfigLoadError> {
self.repo_config_path()
self.repo_config_path()?
// The path doesn't usually exist, but we shouldn't overwrite it
// with an empty config if it did exist.
.map(|path| ConfigFile::load_or_empty(ConfigSource::Repo, path))
Expand All @@ -467,32 +486,43 @@ impl ConfigEnv {
#[instrument]
pub fn reload_repo_config(&self, config: &mut RawConfig) -> Result<(), ConfigLoadError> {
config.as_mut().remove_layers(ConfigSource::Repo);
if let Some(path) = self.existing_repo_config_path() {
if let Some(path) = self.maybe_repo_config_path()? {
config.as_mut().load_file(ConfigSource::Repo, path)?;
}
Ok(())
}

/// Sets the directory for the workspace and the workspace-specific config
/// file.
/// Sets the directory where repo-specific config file is stored. The path
/// is usually `.jj/repo`.
pub fn reset_workspace_path(&mut self, path: &Path) {
if self.workspace_path.as_deref() != Some(path)
&& let Some(root_config_dir) = self.root_config_dir.as_deref()
{
self.workspace_config = Some(SecureConfig::new(
path.to_owned(),
root_config_dir.join("workspaces"),
"workspace-config-id",
"workspace-config.toml",
));
}
self.workspace_path = Some(path.to_owned());
self.workspace_config_path = Some(ConfigPath::new(
path.join(".jj").join("workspace-config.toml"),
));
}

/// Returns a path to the workspace-specific config file.
pub fn workspace_config_path(&self) -> Option<&Path> {
self.workspace_config_path.as_ref().map(|p| p.as_path())
/// Returns a path to the workspace-specific config file, if it exists.
fn maybe_workspace_config_path(&self) -> Result<Option<PathBuf>, ConfigLoadError> {
Ok(match &self.workspace_config {
Some(config) => config.maybe_load_config()?.0,
None => None,
})
}

/// Returns a path to the existing workspace-specific config file.
fn existing_workspace_config_path(&self) -> Option<&Path> {
match self.workspace_config_path {
Some(ref path) if path.exists() => Some(path.as_path()),
_ => None,
}
/// Returns a path to the existing workspace-specific config file, creating
/// a new one if it doesn't exist.
pub fn workspace_config_path(&self) -> Result<Option<PathBuf>, ConfigLoadError> {
Ok(match &self.workspace_config {
Some(config) => Some(config.load_config()?.0),
None => None,
})
}

/// Returns workspace configuration files for modification. Instantiates one
Expand All @@ -511,7 +541,7 @@ impl ConfigEnv {
}

fn new_workspace_config_file(&self) -> Result<Option<ConfigFile>, ConfigLoadError> {
self.workspace_config_path()
self.workspace_config_path()?
.map(|path| ConfigFile::load_or_empty(ConfigSource::Workspace, path))
.transpose()
}
Expand All @@ -521,7 +551,7 @@ impl ConfigEnv {
#[instrument]
pub fn reload_workspace_config(&self, config: &mut RawConfig) -> Result<(), ConfigLoadError> {
config.as_mut().remove_layers(ConfigSource::Workspace);
if let Some(path) = self.existing_workspace_config_path() {
if let Some(path) = self.maybe_workspace_config_path()? {
config.as_mut().load_file(ConfigSource::Workspace, path)?;
}
Ok(())
Expand Down Expand Up @@ -565,8 +595,8 @@ fn config_files_for(
/// 1. Default
/// 2. Base environment variables
/// 3. [User configs](https://docs.jj-vcs.dev/latest/config/)
/// 4. Repo config `.jj/repo/config.toml`
/// 5. Workspace config `.jj/workspace-config.toml`
/// 4. Repo config
/// 5. Workspace config
/// 6. Override environment variables
/// 7. Command-line arguments `--config` and `--config-file`
///
Expand Down Expand Up @@ -1762,11 +1792,12 @@ mod tests {
};
ConfigEnv {
home_dir,
root_config_dir: None,
repo_path: None,
workspace_path: None,
user_config_paths: env.resolve(),
repo_config_path: None,
workspace_config_path: None,
repo_config: None,
workspace_config: None,
command: None,
hostname: None,
}
Expand Down
10 changes: 10 additions & 0 deletions cli/tests/common/test_environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use std::ffi::OsStr;
use std::ffi::OsString;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;

use bstr::BString;
use indoc::formatdoc;
Expand All @@ -32,6 +33,10 @@ use super::fake_diff_editor_path;
use super::fake_editor_path;
use super::to_toml_value;

static CONFIG_NAME: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(\$TEST_ENV/home/.config/jj/(:?repos|workspaces))/[0-9a-f]+"#).unwrap()
});

pub struct TestEnvironment {
_temp_dir: TempDir,
env_root: PathBuf,
Expand Down Expand Up @@ -159,6 +164,8 @@ impl TestEnvironment {
if let Ok(tmp_var) = std::env::var("TEMP") {
cmd.env("TEMP", tmp_var);
}
// Ensure that our tests don't write to the real %APPDATA%.
cmd.env("APPDATA", self.home_dir.join(".config"));
}

cmd
Expand Down Expand Up @@ -284,6 +291,9 @@ impl TestEnvironment {
.to_string();
};
}
normalized = CONFIG_NAME
.replace_all(&normalized, "$1/$$CONFIG_ID")
.to_string();
CommandOutputString { raw, normalized }
}

Expand Down
Loading