feat(JjLib): JjLib implementation

This commit is contained in:
2026-02-09 20:55:40 +01:00
parent e9142237a3
commit bd20747bae
5 changed files with 713 additions and 26 deletions

View File

@@ -16,6 +16,10 @@ pub enum Error {
JjOperation { context: String },
#[error("Repository is locked by another process")]
RepositoryLocked,
#[error("Could not get current directory")]
FailedGettingCurrentDir,
#[error("Could not load Jujutsu configuration")]
FailedReadingConfig,
// Application errors
#[error("Operation cancelled by user")]
Cancelled,
@@ -40,3 +44,15 @@ impl From<CommitMessageError> for Error {
Self::InvalidCommitMessage(value.to_string())
}
}
impl From<std::io::Error> for Error {
fn from(_value: std::io::Error) -> Self {
Self::FailedGettingCurrentDir
}
}
impl From<jj_lib::config::ConfigGetError> for Error {
fn from(_: jj_lib::config::ConfigGetError) -> Self {
Self::FailedReadingConfig
}
}

382
src/jj/executor.rs Normal file
View File

@@ -0,0 +1,382 @@
use std::path::Path;
use jj_lib::config::StackedConfig;
use jj_lib::settings::UserSettings;
use jj_lib::workspace::{Workspace, default_working_copy_factories};
use jj_lib::repo::{Repo, StoreFactories};
use crate::error::Error;
use crate::jj::JjExecutor;
/// JjLib provides jj repository operations using jj-lib
///
/// This implementation uses the jj-lib crate directly for all operations,
/// providing native Rust integration with Jujutsu repositories.
pub struct JjLib {
/// The working directory path for repository operations
working_dir: std::path::PathBuf,
}
impl JjLib {
/// Create a new JjLib instance using the current working directory
pub fn new() -> Self {
Self {
working_dir: std::env::current_dir().unwrap_or_default(),
}
}
/// 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(),
}
}
}
impl Default for JjLib {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl JjExecutor for JjLib {
async fn is_repository(&self) -> Result<bool, Error> {
let config = StackedConfig::with_defaults();
let settings = UserSettings::from_config(config)?;
let store_factories = StoreFactories::default();
let wc_factories = default_working_copy_factories();
match Workspace::load(&settings, &std::env::current_dir()?, &store_factories, &wc_factories) {
Ok(_) => Ok(true),
Err(_) => Ok(false)
}
}
async fn describe(&self, _message: &str) -> Result<(), Error> {
// TODO: T018/T019 - Implement using jj-lib transactions
todo!("T018/T019: Implement describe() using jj-lib")
}
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::prelude::*;
use std::process::Command;
/// Initialize a jj repository in the given directory using `jj git init`
fn init_jj_repo(dir: &std::path::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::new(
std::io::ErrorKind::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: &std::path::Path) -> std::io::Result<String> {
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::new(
std::io::ErrorKind::Other,
format!(
"jj log failed: {}",
String::from_utf8_lossy(&output.stderr)
),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Test that is_repository() returns true inside a jj repository
///
/// This test:
/// 1. Creates a temporary directory
/// 2. Initializes a jj repository with `jj git init`
/// 3. Verifies is_repository() returns Ok(true)
#[tokio::test]
async fn is_repository_returns_true_inside_jj_repo() {
// Create a temporary directory
let temp_dir = assert_fs::TempDir::new().unwrap();
// Initialize a jj repository
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
// Create JjLib pointing to the temp directory
let jj_lib = JjLib::with_working_dir(temp_dir.path());
// Verify is_repository returns true
let result = jj_lib.is_repository().await;
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
assert!(
result.unwrap(),
"Expected true for directory inside jj repo"
);
}
/// Test that is_repository() returns true from a subdirectory of a jj repo
///
/// This verifies that jj-lib correctly walks up the directory tree
/// to find the repository root.
#[tokio::test]
async fn is_repository_returns_true_from_subdirectory() {
// Create a temporary directory with a subdirectory
let temp_dir = assert_fs::TempDir::new().unwrap();
let sub_dir = temp_dir.child("subdir/nested");
sub_dir.create_dir_all().unwrap();
// Initialize jj repo at the root
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
// Create JjLib pointing to the subdirectory
let jj_lib = JjLib::with_working_dir(sub_dir.path());
// Verify is_repository returns true from subdirectory
let result = jj_lib.is_repository().await;
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
assert!(
result.unwrap(),
"Expected true for subdirectory inside jj repo"
);
}
/// Test that is_repository() returns false outside a jj repository
///
/// This test:
/// 1. Creates an empty temporary directory (no jj init)
/// 2. Verifies is_repository() returns Ok(false)
#[tokio::test]
async fn is_repository_returns_false_outside_jj_repo() {
// Create an empty temporary directory (not a jj repo)
let temp_dir = assert_fs::TempDir::new().unwrap();
// Create JjLib pointing to the temp directory
let jj_lib = JjLib::with_working_dir(temp_dir.path());
// Verify is_repository returns false
let result = jj_lib.is_repository().await;
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
assert!(
!result.unwrap(),
"Expected false for directory outside jj repo"
);
}
/// Test that is_repository() returns false for non-existent directory
///
/// This verifies graceful handling of invalid paths
#[tokio::test]
async fn is_repository_returns_false_for_nonexistent_directory() {
// Create a path that doesn't exist
let nonexistent = std::path::PathBuf::from("/tmp/jj_cz_nonexistent_test_dir_12345");
// Make sure it doesn't exist
if nonexistent.exists() {
std::fs::remove_dir_all(&nonexistent).ok();
}
let jj_lib = JjLib::with_working_dir(&nonexistent);
// Verify is_repository returns false (not an error)
let result = jj_lib.is_repository().await;
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
assert!(
!result.unwrap(),
"Expected false for non-existent directory"
);
}
/// Test that describe() updates the commit description
///
/// This test:
/// 1. Creates a temp jj repo
/// 2. Calls describe() with a test message
/// 3. Verifies the commit description was updated via `jj log`
#[tokio::test]
async fn describe_updates_commit_description() {
// Create a temporary directory and init jj repo
let temp_dir = assert_fs::TempDir::new().unwrap();
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
// Create JjLib pointing to the temp directory
let jj_lib = JjLib::with_working_dir(temp_dir.path());
// Call describe with a test message
let test_message = "feat(scope): add new feature";
let result = jj_lib.describe(test_message).await;
assert!(result.is_ok(), "describe() should succeed, got {:?}", result);
// Verify the commit description was updated
let actual_description =
get_commit_description(temp_dir.path()).expect("Failed to get commit description");
assert_eq!(
actual_description, test_message,
"Commit description should match the message passed to describe()"
);
}
/// Test that describe() handles multiline messages
#[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 jj_lib = JjLib::with_working_dir(temp_dir.path());
let multiline_message = "feat: add feature\n\nThis is the body of the commit.\nIt spans multiple lines.";
let result = jj_lib.describe(multiline_message).await;
assert!(result.is_ok(), "describe() should succeed with multiline message");
let actual_description = get_commit_description(temp_dir.path()).unwrap();
assert_eq!(actual_description, multiline_message);
}
/// Test that describe() handles empty message (clears description)
#[tokio::test]
async fn describe_handles_empty_message() {
let temp_dir = assert_fs::TempDir::new().unwrap();
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
let jj_lib = JjLib::with_working_dir(temp_dir.path());
// First set a description
jj_lib
.describe("initial message")
.await
.expect("First describe should succeed");
// Then clear it with empty message
let result = jj_lib.describe("").await;
assert!(result.is_ok(), "describe() should succeed with empty message");
let actual_description = get_commit_description(temp_dir.path()).unwrap();
assert_eq!(actual_description, "", "Description should be cleared");
}
/// Test that describe() handles special characters in 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 jj_lib = JjLib::with_working_dir(temp_dir.path());
let special_message = "fix: handle \"quotes\" and 'apostrophes' & <brackets>";
let result = jj_lib.describe(special_message).await;
assert!(result.is_ok(), "describe() should handle special characters");
let actual_description = get_commit_description(temp_dir.path()).unwrap();
assert_eq!(actual_description, special_message);
}
/// Test that describe() handles unicode characters
#[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 jj_lib = JjLib::with_working_dir(temp_dir.path());
let unicode_message = "feat: support émojis 🚀 and ünïcödé characters 中文";
let result = jj_lib.describe(unicode_message).await;
assert!(result.is_ok(), "describe() should handle unicode");
let actual_description = get_commit_description(temp_dir.path()).unwrap();
assert_eq!(actual_description, unicode_message);
}
/// Test that describe() returns NotARepository error outside jj repo
#[tokio::test]
async fn describe_fails_outside_repository() {
// Create an empty temp directory (not a jj repo)
let temp_dir = assert_fs::TempDir::new().unwrap();
let jj_lib = JjLib::with_working_dir(temp_dir.path());
let result = jj_lib.describe("test message").await;
assert!(result.is_err(), "describe() should fail outside repository");
assert!(
matches!(result.unwrap_err(), Error::NotARepository),
"Expected NotARepository error"
);
}
/// Test that describe() can be called multiple times
#[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 jj_lib = JjLib::with_working_dir(temp_dir.path());
// Call describe multiple times
jj_lib.describe("first").await.unwrap();
jj_lib.describe("second").await.unwrap();
jj_lib.describe("third").await.unwrap();
// Only the last description should persist
let actual_description = get_commit_description(temp_dir.path()).unwrap();
assert_eq!(actual_description, "third");
}
/// Test that JjLib::new() creates instance with current directory
#[test]
fn new_uses_current_directory() {
let jj_lib = JjLib::new();
let current_dir = std::env::current_dir().unwrap();
assert_eq!(jj_lib.working_dir, current_dir);
}
/// Test that JjLib::with_working_dir() uses specified directory
#[test]
fn with_working_dir_uses_specified_directory() {
let custom_path = std::path::PathBuf::from("/tmp/custom");
let jj_lib = JjLib::with_working_dir(&custom_path);
assert_eq!(jj_lib.working_dir, custom_path);
}
/// Test that JjLib implements Default
#[test]
fn jjlib_implements_default() {
let jj_lib = JjLib::default();
let current_dir = std::env::current_dir().unwrap();
assert_eq!(jj_lib.working_dir, current_dir);
}
/// Test that JjLib implements JjExecutor trait
#[test]
fn jjlib_implements_jj_executor() {
fn _accepts_executor<E: JjExecutor>(_e: E) {}
let jj_lib = JjLib::new();
_accepts_executor(jj_lib);
}
/// Test that JjLib is Send + Sync
#[test]
fn jjlib_is_send_sync() {
fn _assert_send<T: Send>() {}
fn _assert_sync<T: Sync>() {}
_assert_send::<JjLib>();
_assert_sync::<JjLib>();
}
}

View File

@@ -1 +1,301 @@
use crate::error::Error;
pub mod executor;
/// Trait for executing jj operations
///
/// All methods are async for native jj-lib compatibility.
#[async_trait::async_trait]
pub trait JjExecutor: Send + Sync {
/// Check if current directory is within a jj repository
async fn is_repository(&self) -> Result<bool, Error>;
/// Set the description of the current change
async fn describe(&self, message: &str) -> Result<(), Error>;
}
#[cfg(test)]
mod tests {
use super::*;
/// Mock implementation of JjExecutor for testing
///
/// This mock allows configuring responses for each method
/// and tracks method calls for verification.
struct MockJjExecutor {
/// Response to return from is_repository()
is_repo_response: Result<bool, Error>,
/// Response to return from describe()
describe_response: Result<(), Error>,
/// Track calls to is_repository()
is_repo_called: std::sync::atomic::AtomicBool,
/// Track calls to describe() with the message passed
describe_calls: std::sync::Mutex<Vec<String>>,
}
impl MockJjExecutor {
/// Create a new mock with default success responses
fn new() -> Self {
Self {
is_repo_response: Ok(true),
describe_response: Ok(()),
is_repo_called: std::sync::atomic::AtomicBool::new(false),
describe_calls: std::sync::Mutex::new(Vec::new()),
}
}
/// Configure is_repository() to return a specific value
fn with_is_repo_response(mut self, response: Result<bool, Error>) -> Self {
self.is_repo_response = response;
self
}
/// Configure describe() to return a specific value
fn with_describe_response(mut self, response: Result<(), Error>) -> Self {
self.describe_response = response;
self
}
/// Check if is_repository() was called
fn was_is_repo_called(&self) -> bool {
self.is_repo_called.load(std::sync::atomic::Ordering::SeqCst)
}
/// Get all messages passed to describe()
fn describe_messages(&self) -> Vec<String> {
self.describe_calls.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl JjExecutor for MockJjExecutor {
async fn is_repository(&self) -> Result<bool, Error> {
self.is_repo_called
.store(true, std::sync::atomic::Ordering::SeqCst);
match &self.is_repo_response {
Ok(v) => Ok(*v),
Err(e) => Err(match e {
Error::NotARepository => Error::NotARepository,
Error::JjOperation { context } => Error::JjOperation {
context: context.clone(),
},
Error::RepositoryLocked => Error::RepositoryLocked,
_ => Error::JjOperation {
context: "mock error".to_string(),
},
}),
}
}
async fn describe(&self, message: &str) -> Result<(), Error> {
self.describe_calls
.lock()
.unwrap()
.push(message.to_string());
match &self.describe_response {
Ok(()) => Ok(()),
Err(e) => Err(match e {
Error::NotARepository => Error::NotARepository,
Error::JjOperation { context } => Error::JjOperation {
context: context.clone(),
},
Error::RepositoryLocked => Error::RepositoryLocked,
_ => Error::JjOperation {
context: "mock error".to_string(),
},
}),
}
}
}
/// Test that JjExecutor trait definition compiles
///
/// This test verifies:
/// - The trait is properly defined with async methods
/// - The trait has correct Send + Sync bounds
/// - The trait can be used as a type bound
#[test]
fn trait_definition_compiles() {
// If this compiles, the trait definition is valid
fn _accepts_executor<E: JjExecutor>(_executor: E) {}
// Verify trait bounds compile
fn _accepts_executor_ref<E: JjExecutor>(_executor: &E) {}
fn _accepts_executor_box(_executor: Box<dyn JjExecutor>) {}
}
/// Test that JjExecutor can be used with trait objects
///
/// This verifies the trait is object-safe
#[test]
fn trait_is_object_safe() {
// This compiles only if the trait is object-safe
let _: Option<Box<dyn JjExecutor>> = None;
let _: Option<&dyn JjExecutor> = None;
}
/// Test that JjExecutor requires Send + Sync bounds
///
/// This verifies implementors must be thread-safe
#[test]
fn trait_requires_send_sync() {
fn _assert_send<T: Send>() {}
fn _assert_sync<T: Sync>() {}
// MockJjExecutor implements the trait, so it must satisfy Send + Sync
_assert_send::<MockJjExecutor>();
_assert_sync::<MockJjExecutor>();
}
/// Test that mock can implement JjExecutor trait
///
/// This is a compile-time check that the mock properly implements the trait
#[test]
fn mock_implements_trait() {
let mock = MockJjExecutor::new();
// If this compiles, the mock implements the trait
fn _accepts_executor(_e: impl JjExecutor) {}
_accepts_executor(mock);
}
/// Test mock is_repository() returns configured true response
#[tokio::test]
async fn mock_is_repository_returns_true() {
let mock = MockJjExecutor::new().with_is_repo_response(Ok(true));
let result = mock.is_repository().await;
assert!(result.is_ok());
assert!(result.unwrap());
assert!(mock.was_is_repo_called());
}
/// Test mock is_repository() returns configured false response
#[tokio::test]
async fn mock_is_repository_returns_false() {
let mock = MockJjExecutor::new().with_is_repo_response(Ok(false));
let result = mock.is_repository().await;
assert!(result.is_ok());
assert!(!result.unwrap());
}
/// Test mock is_repository() returns configured error
#[tokio::test]
async fn mock_is_repository_returns_error() {
let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository));
let result = mock.is_repository().await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotARepository));
}
/// Test mock describe() records the message
#[tokio::test]
async fn mock_describe_records_message() {
let mock = MockJjExecutor::new();
let result = mock.describe("test message").await;
assert!(result.is_ok());
let messages = mock.describe_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "test message");
}
/// Test mock describe() records multiple messages
#[tokio::test]
async fn mock_describe_records_multiple_messages() {
let mock = MockJjExecutor::new();
mock.describe("first message").await.unwrap();
mock.describe("second message").await.unwrap();
let messages = mock.describe_messages();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0], "first message");
assert_eq!(messages[1], "second message");
}
/// Test mock describe() returns configured error
#[tokio::test]
async fn mock_describe_returns_error() {
let mock = MockJjExecutor::new().with_describe_response(Err(Error::RepositoryLocked));
let result = mock.describe("test").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
}
/// Test mock describe() returns JjOperation error with context
#[tokio::test]
async fn mock_describe_returns_jj_operation_error() {
let mock = MockJjExecutor::new().with_describe_response(Err(Error::JjOperation {
context: "transaction failed".to_string(),
}));
let result = mock.describe("test").await;
assert!(result.is_err());
match result.unwrap_err() {
Error::JjOperation { context } => {
assert_eq!(context, "transaction failed");
}
_ => panic!("Expected JjOperation error"),
}
}
/// Test mock can be used through trait object
#[tokio::test]
async fn mock_works_as_trait_object() {
let mock = MockJjExecutor::new();
let executor: Box<dyn JjExecutor> = Box::new(mock);
let result = executor.is_repository().await;
assert!(result.is_ok());
}
/// Test mock can be used through trait reference
#[tokio::test]
async fn mock_works_as_trait_reference() {
let mock = MockJjExecutor::new();
let executor: &dyn JjExecutor = &mock;
let result = executor.is_repository().await;
assert!(result.is_ok());
}
/// Test mock satisfies Send + Sync for concurrent use
#[tokio::test]
async fn mock_is_thread_safe() {
use std::sync::Arc;
let mock = Arc::new(MockJjExecutor::new());
let mock_clone = Arc::clone(&mock);
// Spawn a task that uses the mock
let handle = tokio::spawn(async move { mock_clone.is_repository().await });
// Use the mock from the main task
let result1 = mock.is_repository().await;
let result2 = handle.await.unwrap();
assert!(result1.is_ok());
assert!(result2.is_ok());
}
/// Test that empty message can be passed to describe
#[tokio::test]
async fn mock_describe_accepts_empty_message() {
let mock = MockJjExecutor::new();
let result = mock.describe("").await;
assert!(result.is_ok());
assert_eq!(mock.describe_messages()[0], "");
}
/// Test that long message can be passed to describe
#[tokio::test]
async fn mock_describe_accepts_long_message() {
let mock = MockJjExecutor::new();
let long_message = "a".repeat(1000);
let result = mock.describe(&long_message).await;
assert!(result.is_ok());
assert_eq!(mock.describe_messages()[0].len(), 1000);
}
/// Test mock tracks is_repository calls
#[tokio::test]
async fn mock_tracks_is_repository_call() {
let mock = MockJjExecutor::new();
assert!(!mock.was_is_repo_called());
mock.is_repository().await.unwrap();
assert!(mock.was_is_repo_called());
}
}