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.
|
|
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
use std::{
|
|
|
|
|
collections::HashMap,
|
|
|
|
|
path::{Path, PathBuf},
|
|
|
|
|
sync::{Arc, Mutex},
|
|
|
|
|
};
|
2026-03-07 00:53:13 +01:00
|
|
|
|
2026-03-08 17:42:31 +01:00
|
|
|
use etcetera::BaseStrategy;
|
2026-03-07 00:53:13 +01:00
|
|
|
use jj_lib::{
|
2026-04-05 23:02:14 +02:00
|
|
|
backend::CommitId,
|
2026-03-08 17:42:31 +01:00
|
|
|
config::{ConfigSource, StackedConfig},
|
2026-04-05 23:02:14 +02:00
|
|
|
fileset::FilesetAliasesMap,
|
|
|
|
|
ref_name::WorkspaceNameBuf,
|
|
|
|
|
repo::{ReadonlyRepo, Repo, StoreFactories},
|
|
|
|
|
repo_path::RepoPathUiConverter,
|
|
|
|
|
revset::{
|
|
|
|
|
self, RevsetAliasesMap, RevsetDiagnostics, RevsetExtensions, RevsetParseContext,
|
|
|
|
|
RevsetWorkspaceContext, SymbolResolver, SymbolResolverExtension,
|
|
|
|
|
},
|
2026-03-07 00:53:13 +01:00
|
|
|
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,
|
2026-04-05 23:02:14 +02:00
|
|
|
repo: Mutex<Arc<ReadonlyRepo>>,
|
|
|
|
|
workspace_name: WorkspaceNameBuf,
|
|
|
|
|
workspace_root: PathBuf,
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl JjLib {
|
|
|
|
|
/// Create a new JjLib instance using the current working directory
|
2026-04-05 23:02:14 +02:00
|
|
|
pub async fn new() -> Result<Self, Error> {
|
2026-03-07 00:53:13 +01:00
|
|
|
let working_dir = std::env::current_dir()?;
|
2026-04-05 23:02:14 +02:00
|
|
|
let (repo, workspace_name, workspace_root) = Self::load_repo(&working_dir).await?;
|
|
|
|
|
Ok(Self {
|
|
|
|
|
working_dir,
|
|
|
|
|
repo: repo.into(),
|
|
|
|
|
workspace_name,
|
|
|
|
|
workspace_root,
|
|
|
|
|
})
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create a new JjLib instance with a specific working directory
|
2026-04-05 23:02:14 +02:00
|
|
|
pub async fn with_working_dir(path: impl AsRef<Path>) -> Result<Self, Error> {
|
|
|
|
|
let (repo, workspace_name, workspace_root) = Self::load_repo(path.as_ref()).await?;
|
|
|
|
|
Ok(Self {
|
2026-03-07 00:53:13 +01:00
|
|
|
working_dir: path.as_ref().to_path_buf(),
|
2026-04-05 23:02:14 +02:00
|
|
|
repo: repo.into(),
|
|
|
|
|
workspace_name,
|
|
|
|
|
workspace_root,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load the repo from the given working directory
|
|
|
|
|
async fn load_repo(
|
|
|
|
|
working_dir: &Path,
|
|
|
|
|
) -> Result<(Arc<ReadonlyRepo>, 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(),
|
|
|
|
|
))
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_settings() -> Result<UserSettings, Error> {
|
2026-03-08 17:42:31 +01:00
|
|
|
let mut config = StackedConfig::with_defaults();
|
|
|
|
|
for path in Self::user_config_paths() {
|
|
|
|
|
if path.is_dir() {
|
2026-03-31 16:35:38 +02:00
|
|
|
config.load_dir(ConfigSource::User, &path).map_err(|e| {
|
|
|
|
|
Error::FailedReadingConfig {
|
|
|
|
|
context: e.to_string(),
|
|
|
|
|
}
|
|
|
|
|
})?;
|
2026-03-08 17:42:31 +01:00
|
|
|
} else if path.exists() {
|
2026-03-31 16:35:38 +02:00
|
|
|
config.load_file(ConfigSource::User, path).map_err(|e| {
|
|
|
|
|
Error::FailedReadingConfig {
|
|
|
|
|
context: e.to_string(),
|
|
|
|
|
}
|
|
|
|
|
})?;
|
2026-03-08 17:42:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 16:35:38 +02:00
|
|
|
UserSettings::from_config(config).map_err(|e| Error::FailedReadingConfig {
|
|
|
|
|
context: e.to_string(),
|
|
|
|
|
})
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|
2026-03-08 17:42:31 +01:00
|
|
|
|
|
|
|
|
/// 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<PathBuf> {
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-04-05 23:02:14 +02:00
|
|
|
|
|
|
|
|
/// Resolve a revset string to a commit ID
|
|
|
|
|
fn get_commit_id(&self, revset: &str) -> Result<CommitId, Error> {
|
|
|
|
|
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<dyn SymbolResolverExtension>; 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)
|
|
|
|
|
}
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
async fn describe(&self, revset: &str, message: &str) -> Result<(), Error> {
|
|
|
|
|
let commit_id = self.get_commit_id(revset)?;
|
|
|
|
|
let repo = self.repo.lock()?.clone();
|
2026-03-07 00:53:13 +01:00
|
|
|
let mut tx = repo.start_transaction();
|
2026-04-05 23:02:14 +02:00
|
|
|
let commit = tx
|
2026-03-07 00:53:13 +01:00
|
|
|
.repo()
|
2026-04-05 23:02:14 +02:00
|
|
|
.store()
|
|
|
|
|
.get_commit(&commit_id)
|
|
|
|
|
.map_err(|e| Error::JjOperation {
|
|
|
|
|
context: e.to_string(),
|
|
|
|
|
})?;
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
tx.repo_mut()
|
2026-04-05 23:02:14 +02:00
|
|
|
.rewrite_commit(&commit)
|
2026-03-07 00:53:13 +01:00
|
|
|
.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:?}"),
|
|
|
|
|
})?;
|
|
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
let new_repo = tx
|
|
|
|
|
.commit("jj-cz: update commit description")
|
2026-03-07 00:53:13 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| Error::JjOperation {
|
|
|
|
|
context: e.to_string(),
|
|
|
|
|
})?;
|
2026-04-05 23:02:14 +02:00
|
|
|
*self.repo.lock()? = new_repo;
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2026-04-05 23:02:14 +02:00
|
|
|
|
|
|
|
|
async fn get_description(&self, revset: &str) -> Result<String, Error> {
|
|
|
|
|
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())
|
|
|
|
|
}
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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
|
|
|
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()
|
2026-04-05 23:02:14 +02:00
|
|
|
.get_wc_commit_id(jj_lib::ref_name::WorkspaceName::DEFAULT)
|
2026-03-08 15:44:26 +01:00
|
|
|
.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
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
|
2026-03-07 00:53:13 +01:00
|
|
|
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();
|
|
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
let executor = JjLib::with_working_dir(temp_dir.path()).await;
|
|
|
|
|
assert!(executor.is_err());
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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";
|
2026-04-05 23:02:14 +02:00
|
|
|
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
|
2026-03-07 00:53:13 +01:00
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
let result = executor.describe("@", test_message).await;
|
2026-03-07 00:53:13 +01:00
|
|
|
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 !@#$%^&*()";
|
2026-04-05 23:02:14 +02:00
|
|
|
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
|
2026-03-07 00:53:13 +01:00
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
let result = executor.describe("@", test_message).await;
|
2026-03-07 00:53:13 +01:00
|
|
|
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 🎉 🚀";
|
2026-04-05 23:02:14 +02:00
|
|
|
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
|
2026-03-07 00:53:13 +01:00
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
let result = executor.describe("@", test_message).await;
|
2026-03-07 00:53:13 +01:00
|
|
|
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";
|
2026-04-05 23:02:14 +02:00
|
|
|
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
|
2026-03-07 00:53:13 +01:00
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
let result = executor.describe("@", test_message).await;
|
2026-03-07 00:53:13 +01:00
|
|
|
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();
|
|
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
// 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;
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
|
2026-03-07 00:53:13 +01:00
|
|
|
|
|
|
|
|
executor
|
2026-04-05 23:02:14 +02:00
|
|
|
.describe("@", "feat: first commit")
|
2026-03-07 00:53:13 +01:00
|
|
|
.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
|
2026-04-05 23:02:14 +02:00
|
|
|
.describe("@", "feat: updated commit")
|
2026-03-07 00:53:13 +01:00
|
|
|
.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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 23:02:14 +02:00
|
|
|
#[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;
|
2026-03-07 00:53:13 +01:00
|
|
|
fn accepts_executor(_: impl JjExecutor) {}
|
2026-04-05 23:02:14 +02:00
|
|
|
accepts_executor(lib.unwrap());
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|
2026-03-08 17:42:31 +01:00
|
|
|
|
|
|
|
|
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<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
|
|
|
|
|
|
|
|
|
/// RAII guard that restores an environment variable on drop.
|
|
|
|
|
struct EnvGuard {
|
|
|
|
|
key: &'static str,
|
|
|
|
|
original: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl EnvGuard {
|
|
|
|
|
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> 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")));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-07 00:53:13 +01:00
|
|
|
}
|