2026-03-07 00:53:13 +01:00
|
|
|
//! 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<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> {
|
|
|
|
|
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<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-07 00:53:13 +01:00
|
|
|
}
|
|
|
|
|
|
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())
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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");
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
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");
|
2026-03-07 00:53:13 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|