//! 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}; use jj_lib::{ config::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 { 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) -> Self { Self { working_dir: path.as_ref().to_path_buf(), } } fn load_settings() -> Result { let config = StackedConfig::with_defaults(); UserSettings::from_config(config).map_err(|_| Error::FailedReadingConfig) } } #[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, 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::*; /// 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}")) } /// Get the current commit description from a jj repository using jj-lib 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(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()); 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(); 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:?}"); 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()); 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()); 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()); 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(); 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(); 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"); 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"); } #[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); } }