Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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