Files
jj-cz/src/jj/lib_executor.rs

521 lines
17 KiB
Rust
Raw Normal View History

//! jj-lib implementation of JjExecutor
//!
//! This implementation uses jj-lib 0.39.0 directly for repository detection
//! and commit description, replacing the earlier shell-out approach.
use std::path::{Path, PathBuf};
2026-03-08 17:42:31 +01:00
use etcetera::BaseStrategy;
use jj_lib::{
2026-03-08 17:42:31 +01:00
config::{ConfigSource, StackedConfig},
ref_name::WorkspaceName,
repo::{Repo, StoreFactories},
settings::UserSettings,
workspace::{Workspace, default_working_copy_factories},
};
use crate::error::Error;
use crate::jj::JjExecutor;
/// JjLib provides jj repository operations via jj-lib 0.39.0
#[derive(Debug)]
pub struct JjLib {
working_dir: PathBuf,
}
impl JjLib {
/// Create a new JjLib instance using the current working directory
pub fn new() -> Result<Self, Error> {
let working_dir = std::env::current_dir()?;
Ok(Self { working_dir })
}
/// Create a new JjLib instance with a specific working directory
pub fn with_working_dir(path: impl AsRef<Path>) -> Self {
Self {
working_dir: path.as_ref().to_path_buf(),
}
}
fn load_settings() -> Result<UserSettings, Error> {
2026-03-08 17:42:31 +01:00
let mut config = StackedConfig::with_defaults();
for path in Self::user_config_paths() {
if path.is_dir() {
config
.load_dir(ConfigSource::User, &path)
.map_err(|_| Error::FailedReadingConfig)?;
} else if path.exists() {
config
.load_file(ConfigSource::User, path)
.map_err(|_| Error::FailedReadingConfig)?;
}
}
UserSettings::from_config(config).map_err(|_| Error::FailedReadingConfig)
}
2026-03-08 17:42:31 +01:00
/// Resolves user config file paths following the same logic as the jj CLI:
/// 1. `$JJ_CONFIG` (colon-separated list), if set
/// 2. `~/.jjconfig.toml` (if it exists, or no platform config dir is available)
/// 3. Platform config dir `/jj/config.toml` (e.g. `~/.config/jj/config.toml`)
/// 4. Platform config dir `/jj/conf.d/` (if it exists)
fn user_config_paths() -> Vec<PathBuf> {
if let Ok(paths) = std::env::var("JJ_CONFIG") {
return std::env::split_paths(&paths)
.filter(|p| !p.as_os_str().is_empty())
.collect();
}
let home_dir = etcetera::home_dir().ok();
let config_dir = etcetera::choose_base_strategy()
.ok()
.map(|s| s.config_dir());
let home_config = home_dir.as_ref().map(|h| h.join(".jjconfig.toml"));
let platform_config = config_dir
.as_ref()
.map(|c| c.join("jj").join("config.toml"));
let platform_conf_d = config_dir.as_ref().map(|c| c.join("jj").join("conf.d"));
let mut paths = Vec::new();
if let Some(path) = home_config
&& (path.exists() || platform_config.is_none())
{
paths.push(path);
}
if let Some(path) = platform_config {
paths.push(path);
}
if let Some(path) = platform_conf_d
&& path.exists()
{
paths.push(path);
}
paths
}
}
#[async_trait::async_trait(?Send)]
impl JjExecutor for JjLib {
async fn is_repository(&self) -> Result<bool, Error> {
let settings = Self::load_settings()?;
let store_factories = StoreFactories::default();
let wc_factories = default_working_copy_factories();
Ok(Workspace::load(
&settings,
&self.working_dir,
&store_factories,
&wc_factories,
)
.is_ok())
}
async fn describe(&self, message: &str) -> Result<(), Error> {
let settings = Self::load_settings()?;
let store_factories = StoreFactories::default();
let wc_factories = default_working_copy_factories();
let workspace = Workspace::load(
&settings,
&self.working_dir,
&store_factories,
&wc_factories,
)
.map_err(|_| Error::NotARepository)?;
let repo =
workspace
.repo_loader()
.load_at_head()
.await
.map_err(|e| Error::JjOperation {
context: e.to_string(),
})?;
let mut tx = repo.start_transaction();
let wc_commit_id = tx
.repo()
.view()
.get_wc_commit_id(WorkspaceName::DEFAULT)
.ok_or_else(|| Error::JjOperation {
context: "No working copy commit found".to_string(),
})?
.clone();
let wc_commit =
tx.repo()
.store()
.get_commit(&wc_commit_id)
.map_err(|e| Error::JjOperation {
context: e.to_string(),
})?;
tx.repo_mut()
.rewrite_commit(&wc_commit)
.set_description(message)
.write()
.await
.map_err(|e| Error::JjOperation {
context: e.to_string(),
})?;
tx.repo_mut()
.rebase_descendants()
.await
.map_err(|e| Error::JjOperation {
context: format!("{e:?}"),
})?;
tx.commit("jj-cz: update commit description")
.await
.map_err(|e| Error::JjOperation {
context: e.to_string(),
})?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
2026-03-08 15:44:26 +01:00
/// Initialize a jj repository in the given directory using jj-lib directly
async fn init_jj_repo(dir: &Path) -> Result<(), String> {
let settings = JjLib::load_settings().map_err(|e| e.to_string())?;
Workspace::init_internal_git(&settings, dir)
.await
.map(|_| ())
.map_err(|e| format!("Failed to init jj repo: {e}"))
}
2026-03-08 15:44:26 +01:00
/// Get the current commit description from a jj repository using jj-lib
async fn get_commit_description(dir: &Path) -> Result<String, String> {
let settings = JjLib::load_settings().map_err(|e| e.to_string())?;
let store_factories = StoreFactories::default();
let wc_factories = default_working_copy_factories();
let workspace = Workspace::load(&settings, dir, &store_factories, &wc_factories)
.map_err(|e| format!("Failed to load workspace: {e}"))?;
let repo = workspace
.repo_loader()
.load_at_head()
.await
.map_err(|e| format!("Failed to load repo: {e}"))?;
let wc_commit_id = repo
.view()
.get_wc_commit_id(WorkspaceName::DEFAULT)
.ok_or_else(|| "No working copy commit found".to_string())?
.clone();
let wc_commit = repo
.store()
.get_commit(&wc_commit_id)
.map_err(|e| format!("Failed to get commit: {e}"))?;
Ok(wc_commit.description().trim_end().to_string())
}
#[tokio::test]
async fn is_repository_returns_true_inside_jj_repo() {
let temp_dir = assert_fs::TempDir::new().unwrap();
2026-03-08 15:44:26 +01:00
init_jj_repo(temp_dir.path())
.await
.expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path());
let result = executor.is_repository().await;
assert!(result.is_ok());
assert!(result.unwrap());
}
#[tokio::test]
async fn is_repository_returns_false_outside_jj_repo() {
let temp_dir = assert_fs::TempDir::new().unwrap();
let executor = JjLib::with_working_dir(temp_dir.path());
let result = executor.is_repository().await;
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[tokio::test]
async fn describe_updates_commit_description() {
let temp_dir = assert_fs::TempDir::new().unwrap();
2026-03-08 15:44:26 +01:00
init_jj_repo(temp_dir.path())
.await
.expect("Failed to init jj repo");
let test_message = "test: initial commit";
let executor = JjLib::with_working_dir(temp_dir.path());
let result = executor.describe(test_message).await;
assert!(result.is_ok(), "describe failed: {result:?}");
2026-03-08 15:44:26 +01:00
let actual = get_commit_description(temp_dir.path())
.await
.expect("Failed to get description");
assert_eq!(actual, test_message);
}
#[tokio::test]
async fn describe_handles_special_characters() {
let temp_dir = assert_fs::TempDir::new().unwrap();
2026-03-08 15:44:26 +01:00
init_jj_repo(temp_dir.path())
.await
.expect("Failed to init jj repo");
let test_message = "feat: add feature with special chars !@#$%^&*()";
let executor = JjLib::with_working_dir(temp_dir.path());
let result = executor.describe(test_message).await;
assert!(result.is_ok());
2026-03-08 15:44:26 +01:00
let actual = get_commit_description(temp_dir.path())
.await
.expect("Failed to get description");
assert_eq!(actual, test_message);
}
#[tokio::test]
async fn describe_handles_unicode() {
let temp_dir = assert_fs::TempDir::new().unwrap();
2026-03-08 15:44:26 +01:00
init_jj_repo(temp_dir.path())
.await
.expect("Failed to init jj repo");
let test_message = "docs: add unicode support 🎉 🚀";
let executor = JjLib::with_working_dir(temp_dir.path());
let result = executor.describe(test_message).await;
assert!(result.is_ok());
2026-03-08 15:44:26 +01:00
let actual = get_commit_description(temp_dir.path())
.await
.expect("Failed to get description");
assert_eq!(actual, test_message);
}
#[tokio::test]
async fn describe_handles_multiline_message() {
let temp_dir = assert_fs::TempDir::new().unwrap();
2026-03-08 15:44:26 +01:00
init_jj_repo(temp_dir.path())
.await
.expect("Failed to init jj repo");
let test_message = "feat: add feature\n\nThis is a multiline\ndescription";
let executor = JjLib::with_working_dir(temp_dir.path());
let result = executor.describe(test_message).await;
assert!(result.is_ok());
2026-03-08 15:44:26 +01:00
let actual = get_commit_description(temp_dir.path())
.await
.expect("Failed to get description");
assert_eq!(actual, test_message);
}
#[tokio::test]
async fn describe_fails_outside_repo() {
let temp_dir = assert_fs::TempDir::new().unwrap();
let executor = JjLib::with_working_dir(temp_dir.path());
let result = executor.describe("test: should fail").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotARepository));
}
#[tokio::test]
async fn describe_can_be_called_multiple_times() {
let temp_dir = assert_fs::TempDir::new().unwrap();
2026-03-08 15:44:26 +01:00
init_jj_repo(temp_dir.path())
.await
.expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path());
executor
.describe("feat: first commit")
.await
.expect("First describe failed");
2026-03-08 15:44:26 +01:00
let desc1 = get_commit_description(temp_dir.path())
.await
.expect("Failed to get first description");
assert_eq!(desc1, "feat: first commit");
executor
.describe("feat: updated commit")
.await
.expect("Second describe failed");
2026-03-08 15:44:26 +01:00
let desc2 = get_commit_description(temp_dir.path())
.await
.expect("Failed to get second description");
assert_eq!(desc2, "feat: updated commit");
}
#[test]
fn jj_lib_implements_jj_executor_trait() {
let lib = JjLib::with_working_dir(std::path::Path::new("."));
fn accepts_executor(_: impl JjExecutor) {}
accepts_executor(lib);
}
2026-03-08 17:42:31 +01:00
mod user_config_paths_tests {
use super::*;
use std::sync::{LazyLock, Mutex};
/// Serialize all tests that mutate environment variables to prevent
/// data races: env vars are process-global in Rust's test runner.
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
/// RAII guard that restores an environment variable on drop.
struct EnvGuard {
key: &'static str,
original: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let original = std::env::var(key).ok();
// SAFETY: tests hold ENV_LOCK, so no concurrent env mutation
unsafe { std::env::set_var(key, value) };
Self { key, original }
}
fn remove(key: &'static str) -> Self {
let original = std::env::var(key).ok();
// SAFETY: tests hold ENV_LOCK, so no concurrent env mutation
unsafe { std::env::remove_var(key) };
Self { key, original }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.original {
// SAFETY: tests hold ENV_LOCK, so no concurrent env mutation
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
#[test]
fn jj_config_single_path_returned_directly() {
let _lock = ENV_LOCK.lock().unwrap();
let temp = assert_fs::TempDir::new().unwrap();
let config_path = temp.path().join("config.toml");
let _jj = EnvGuard::set("JJ_CONFIG", &config_path);
assert_eq!(JjLib::user_config_paths(), vec![config_path]);
}
#[test]
fn jj_config_multiple_paths_all_returned() {
let _lock = ENV_LOCK.lock().unwrap();
let temp = assert_fs::TempDir::new().unwrap();
let path_a = temp.path().join("a.toml");
let path_b = temp.path().join("b.toml");
let combined = std::env::join_paths([&path_a, &path_b]).unwrap();
let _jj = EnvGuard::set("JJ_CONFIG", &combined);
assert_eq!(JjLib::user_config_paths(), vec![path_a, path_b]);
}
#[test]
fn jj_config_empty_segments_are_filtered_out() {
let _lock = ENV_LOCK.lock().unwrap();
let temp = assert_fs::TempDir::new().unwrap();
let config_path = temp.path().join("config.toml");
// Wrap the path with empty segments (Unix colon separators)
let value = format!(":{}:", config_path.display());
let _jj = EnvGuard::set("JJ_CONFIG", &value);
assert_eq!(JjLib::user_config_paths(), vec![config_path]);
}
#[test]
fn platform_config_toml_always_included_when_xdg_set() {
let _lock = ENV_LOCK.lock().unwrap();
let temp = assert_fs::TempDir::new().unwrap();
let _jj = EnvGuard::remove("JJ_CONFIG");
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", temp.path());
let paths = JjLib::user_config_paths();
assert!(paths.contains(&temp.path().join("jj").join("config.toml")));
}
#[test]
fn home_config_included_when_it_exists() {
let _lock = ENV_LOCK.lock().unwrap();
let temp = assert_fs::TempDir::new().unwrap();
let home_config = temp.path().join(".jjconfig.toml");
std::fs::write(&home_config, "").unwrap();
let _jj = EnvGuard::remove("JJ_CONFIG");
let _home = EnvGuard::set("HOME", temp.path());
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", temp.path().join("xdg"));
let paths = JjLib::user_config_paths();
assert!(paths.contains(&home_config));
}
#[test]
fn home_config_excluded_when_missing_and_platform_config_is_available() {
let _lock = ENV_LOCK.lock().unwrap();
let temp = assert_fs::TempDir::new().unwrap();
// ~/.jjconfig.toml intentionally not created
let _jj = EnvGuard::remove("JJ_CONFIG");
let _home = EnvGuard::set("HOME", temp.path());
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", temp.path().join("xdg"));
let paths = JjLib::user_config_paths();
assert!(!paths.contains(&temp.path().join(".jjconfig.toml")));
}
#[test]
fn conf_d_included_when_directory_exists() {
let _lock = ENV_LOCK.lock().unwrap();
let temp = assert_fs::TempDir::new().unwrap();
let conf_d = temp.path().join("jj").join("conf.d");
std::fs::create_dir_all(&conf_d).unwrap();
let _jj = EnvGuard::remove("JJ_CONFIG");
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", temp.path());
let paths = JjLib::user_config_paths();
assert!(paths.contains(&conf_d));
}
#[test]
fn conf_d_excluded_when_directory_is_missing() {
let _lock = ENV_LOCK.lock().unwrap();
let temp = assert_fs::TempDir::new().unwrap();
// conf.d intentionally not created
let _jj = EnvGuard::remove("JJ_CONFIG");
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", temp.path());
let paths = JjLib::user_config_paths();
assert!(!paths.contains(&temp.path().join("jj").join("conf.d")));
}
}
}