//! 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::*; use std::process::Command; /// Initialize a jj repository in the given directory using `jj git init` fn init_jj_repo(dir: &Path) -> std::io::Result<()> { let output = Command::new("jj") .args(["git", "init"]) .current_dir(dir) .output()?; if !output.status.success() { return Err(std::io::Error::other(format!( "jj git init failed: {}", String::from_utf8_lossy(&output.stderr) ))); } Ok(()) } /// Get the current commit description from a jj repository fn get_commit_description(dir: &Path) -> std::io::Result { let output = Command::new("jj") .args(["log", "-r", "@", "--no-graph", "-T", "description"]) .current_dir(dir) .output()?; if !output.status.success() { return Err(std::io::Error::other(format!( "jj log failed: {}", String::from_utf8_lossy(&output.stderr) ))); } Ok(String::from_utf8_lossy(&output.stdout).trim().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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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); } }