//! 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::{ collections::HashMap, path::{Path, PathBuf}, sync::{Arc, Mutex}, }; use etcetera::BaseStrategy; use jj_lib::{ backend::CommitId, config::{ConfigSource, StackedConfig}, fileset::FilesetAliasesMap, ref_name::WorkspaceNameBuf, repo::{ReadonlyRepo, Repo, StoreFactories}, repo_path::RepoPathUiConverter, revset::{ self, RevsetAliasesMap, RevsetDiagnostics, RevsetExtensions, RevsetParseContext, RevsetWorkspaceContext, SymbolResolver, SymbolResolverExtension, }, 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, repo: Mutex>, workspace_name: WorkspaceNameBuf, workspace_root: PathBuf, } impl JjLib { /// Create a new JjLib instance using the current working directory pub async fn new() -> Result { let working_dir = std::env::current_dir()?; let (repo, workspace_name, workspace_root) = Self::load_repo(&working_dir).await?; Ok(Self { working_dir, repo: repo.into(), workspace_name, workspace_root, }) } /// Create a new JjLib instance with a specific working directory pub async fn with_working_dir(path: impl AsRef) -> Result { let (repo, workspace_name, workspace_root) = Self::load_repo(path.as_ref()).await?; Ok(Self { working_dir: path.as_ref().to_path_buf(), repo: repo.into(), workspace_name, workspace_root, }) } /// Load the repo from the given working directory async fn load_repo( working_dir: &Path, ) -> Result<(Arc, WorkspaceNameBuf, PathBuf), Error> { let settings = Self::load_settings()?; let store_factories = StoreFactories::default(); let wc_factories = default_working_copy_factories(); let workspace = Workspace::load(&settings, 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(), })?; Ok(( repo, workspace.workspace_name().to_owned(), workspace.workspace_root().to_path_buf(), )) } fn load_settings() -> Result { 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(|e| { Error::FailedReadingConfig { context: e.to_string(), } })?; } else if path.exists() { config.load_file(ConfigSource::User, path).map_err(|e| { Error::FailedReadingConfig { context: e.to_string(), } })?; } } UserSettings::from_config(config).map_err(|e| Error::FailedReadingConfig { context: e.to_string(), }) } /// 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 { 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 } /// Resolve a revset string to a commit ID fn get_commit_id(&self, revset: &str) -> Result { let context = RevsetParseContext { workspace: Some(RevsetWorkspaceContext { workspace_name: &self.workspace_name, path_converter: &RepoPathUiConverter::Fs { cwd: self.working_dir.clone(), base: self.workspace_root.clone(), }, }), aliases_map: &RevsetAliasesMap::new(), fileset_aliases_map: &FilesetAliasesMap::new(), local_variables: HashMap::new(), user_email: "", date_pattern_context: chrono::Local::now().into(), default_ignored_remote: None, use_glob_by_default: false, extensions: &RevsetExtensions::default(), }; let mut diagnostic = RevsetDiagnostics::new(); let repo = self.repo.lock()?.clone(); let symbol_resolver = SymbolResolver::new(&*repo, &([] as [Box; 0])); let revision = revset::parse(&mut diagnostic, revset, &context) .map_err(|e| Error::from_revset_parse_error(revset, e))? .resolve_user_expression(&*repo, &symbol_resolver) .map_err(|e| Error::from_revset_resolution_error(revset, e))? .evaluate(&*repo) .map_err(|e| Error::from_revset_evaluation_error(revset, e))?; let mut iter = revision.iter(); let commit_id = iter .next() .ok_or(Error::RevsetResolutionError { revset: revset.to_string(), context: "No matching revision".to_string(), })? .map_err(|e| Error::from_revset_evaluation_error(revset, e))?; if iter.next().is_some() { return Err(Error::MultipleRevisions { revset: revset.to_string(), }); } Ok(commit_id) } } #[async_trait::async_trait(?Send)] impl JjExecutor for JjLib { async fn is_repository(&self) -> Result { 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, revset: &str, message: &str) -> Result<(), Error> { let commit_id = self.get_commit_id(revset)?; let repo = self.repo.lock()?.clone(); let mut tx = repo.start_transaction(); let commit = tx .repo() .store() .get_commit(&commit_id) .map_err(|e| Error::JjOperation { context: e.to_string(), })?; tx.repo_mut() .rewrite_commit(&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:?}"), })?; let new_repo = tx .commit("jj-cz: update commit description") .await .map_err(|e| Error::JjOperation { context: e.to_string(), })?; *self.repo.lock()? = new_repo; Ok(()) } async fn get_description(&self, revset: &str) -> Result { let commit_id = self.get_commit_id(revset)?; let repo = self.repo.lock()?.clone(); let commit = repo .store() .get_commit(&commit_id) .map_err(|e| Error::JjOperation { context: e.to_string(), })?; Ok(commit.description().trim_end().to_string()) } } #[cfg(test)] mod tests { use super::*; /// 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}")) } async fn get_commit_description(dir: &Path) -> Result { 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(jj_lib::ref_name::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(); init_jj_repo(temp_dir.path()) .await .expect("Failed to init jj repo"); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap(); 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()).await; assert!(executor.is_err()); } #[tokio::test] async fn describe_updates_commit_description() { let temp_dir = assert_fs::TempDir::new().unwrap(); 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()).await.unwrap(); let result = executor.describe("@", test_message).await; assert!(result.is_ok(), "describe failed: {result:?}"); 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(); 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()).await.unwrap(); let result = executor.describe("@", test_message).await; assert!(result.is_ok()); 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(); 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()).await.unwrap(); let result = executor.describe("@", test_message).await; assert!(result.is_ok()); 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(); 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()).await.unwrap(); let result = executor.describe("@", test_message).await; assert!(result.is_ok()); 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(); // with_working_dir returns Err when not in a repo let executor = JjLib::with_working_dir(temp_dir.path()).await; assert!(executor.is_err()); // Use an executor from a valid repo and try to describe a non-existent revset let executor = JjLib::with_working_dir(std::path::Path::new(".")) .await .unwrap(); let result = executor .describe("this-bookmark-does-not-exist", "test: should fail") .await; assert!(result.is_err()); } #[tokio::test] async fn describe_can_be_called_multiple_times() { let temp_dir = assert_fs::TempDir::new().unwrap(); init_jj_repo(temp_dir.path()) .await .expect("Failed to init jj repo"); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap(); executor .describe("@", "feat: first commit") .await .expect("First describe failed"); 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"); let desc2 = get_commit_description(temp_dir.path()) .await .expect("Failed to get second description"); assert_eq!(desc2, "feat: updated commit"); } #[tokio::test] async fn get_description_returns_empty_for_fresh_commit() { let temp_dir = assert_fs::TempDir::new().unwrap(); init_jj_repo(temp_dir.path()) .await .expect("Failed to init jj repo"); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap(); let desc = executor .get_description("@") .await .expect("get_description failed"); assert_eq!(desc, ""); } #[tokio::test] async fn get_description_reflects_describe_on_same_executor() { let temp_dir = assert_fs::TempDir::new().unwrap(); init_jj_repo(temp_dir.path()) .await .expect("Failed to init jj repo"); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap(); let message = "feat: test get_description"; executor .describe("@", message) .await .expect("describe failed"); let desc = executor .get_description("@") .await .expect("get_description failed"); assert_eq!(desc, message); } #[tokio::test] async fn multiple_revisions_error_for_multi_commit_revset() { let temp_dir = assert_fs::TempDir::new().unwrap(); init_jj_repo(temp_dir.path()) .await .expect("Failed to init jj repo"); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap(); let result = executor.describe("@ | root()", "test").await; assert!(matches!(result, Err(Error::MultipleRevisions { .. }))); } #[tokio::test] async fn empty_revset_returns_resolution_error() { let temp_dir = assert_fs::TempDir::new().unwrap(); init_jj_repo(temp_dir.path()) .await .expect("Failed to init jj repo"); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap(); let result = executor.describe("none()", "test").await; assert!(matches!(result, Err(Error::RevsetResolutionError { .. }))); } #[tokio::test] async fn invalid_revset_syntax_returns_resolution_error() { let temp_dir = assert_fs::TempDir::new().unwrap(); init_jj_repo(temp_dir.path()) .await .expect("Failed to init jj repo"); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap(); let result = executor.describe("(((invalid", "test").await; assert!(matches!(result, Err(Error::RevsetResolutionError { .. }))); } #[tokio::test] async fn jj_lib_implements_jj_executor_trait() { let lib = JjLib::with_working_dir(std::path::Path::new(".")).await; fn accepts_executor(_: impl JjExecutor) {} accepts_executor(lib.unwrap()); } 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> = LazyLock::new(|| Mutex::new(())); /// RAII guard that restores an environment variable on drop. struct EnvGuard { key: &'static str, original: Option, } impl EnvGuard { fn set(key: &'static str, value: impl AsRef) -> 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"))); } } }