feat: set message for multiple revsets

Allows to set the revision message of multiple revisions by passing
them as arguments. This only supports simple revisions, such as `@`,
`@-`, `xs`, and so on. Comple revisions such as `@..@-` are not
supported.

Fixes: #5
This commit is contained in:
2026-04-05 23:02:14 +02:00
parent e965a728a1
commit 1bab78cb20
17 changed files with 438 additions and 168 deletions

3
Cargo.lock generated
View File

@@ -237,8 +237,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys",
"num-traits", "num-traits",
"serde", "serde",
"wasm-bindgen",
"windows-link", "windows-link",
] ]
@@ -1655,6 +1657,7 @@ dependencies = [
"assert_cmd", "assert_cmd",
"assert_fs", "assert_fs",
"async-trait", "async-trait",
"chrono",
"clap", "clap",
"etcetera", "etcetera",
"git-conventional", "git-conventional",

View File

@@ -33,6 +33,7 @@ thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
textwrap = "0.16.2" textwrap = "0.16.2"
unicode-width = "0.2.2" unicode-width = "0.2.2"
chrono = "0.4.44"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.1.2" assert_cmd = "2.1.2"

View File

@@ -24,6 +24,16 @@ jj-cz
The tool detects whether you're in a Jujutsu repository, guides you The tool detects whether you're in a Jujutsu repository, guides you
through the commit message, and applies it to your current change. through the commit message, and applies it to your current change.
You can also set the revision message of a few revisions at once, or
target a single revision other than the current one.
```sh
jj-cz @- xs develop
```
No explicit revision is simply the equivalent of `jj-cz @`, like
`jj desc`.
## Requirements ## Requirements
- A Jujutsu repository - A Jujutsu repository
@@ -41,8 +51,44 @@ what `jj-cz` alone would be good for without `jj`.
| 130 | Interrupted | | 130 | Interrupted |
## Installation ## Installation
### From crates.io
Simply run the following command:
You can install jj-cz with Cargo by building it from source. ```
cargo install jj-cz
```
Done! `jj-cz` is now available!
### With Nix Flakes
Notice how theres a `flake.nix` file? This means you can run the
project using this repository as one of your flakes inputs. In fact,
thats how I install it in my own NixOS configuration! Add this
repository to your configuration:
```nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
jj-cz = {
url = "git+https://labs.phundrak.com/phundrak/jj-cz";
inputs.nixpkgs.follows = "nixpkgs";
};
};
}
```
And tadah! you can now install
`inputs.jj-cz.packages.${pkgs.stdenv.hostPlatform.system}.default`
among your other packages. Take a look at my
[`jujutsu.nix`](https://labs.phundrak.com/phundrak/nix-config/src/branch/main/users/modules/dev/vcs/jujutsu.nix)
module if you need some inspiration.
### From source
You can also install `jj-cz` with Cargo by building it from source.
Just make sure Rust is available on your machine (duh!).
```sh ```sh
cargo install --path . cargo install --path .

View File

@@ -14,4 +14,19 @@ use clap::Parser;
Jujutsu repository.\n\n\ Jujutsu repository.\n\n\
This tool requires an interactive terminal (TTY)." This tool requires an interactive terminal (TTY)."
)] )]
pub struct Cli; pub struct Cli {
/// The revision(s) whose description to edit (default: @)
#[arg(value_name = "REVSETS")]
revsets: Vec<String>,
}
impl Cli {
/// Returns the revsets to operate on, defaulting to `["@"]` if none provided
pub fn revsets(&self) -> Vec<&str> {
if self.revsets.is_empty() {
vec!["@"]
} else {
self.revsets.iter().map(|s| s.as_str()).collect()
}
}
}

View File

@@ -32,7 +32,7 @@ impl Body {
mod tests { mod tests {
use super::*; use super::*;
/// Default produces Body(None) no body /// Default produces Body(None) - no body
#[test] #[test]
fn default_produces_none() { fn default_produces_none() {
assert_eq!(Body::default(), Body(None)); assert_eq!(Body::default(), Body(None));
@@ -71,7 +71,7 @@ mod tests {
); );
} }
/// Leading and internal whitespace is preserved users may write /// Leading and internal whitespace is preserved - users may write
/// indented lists, ASCII art, file trees, etc. /// indented lists, ASCII art, file trees, etc.
#[test] #[test]
fn from_preserves_leading_whitespace() { fn from_preserves_leading_whitespace() {

View File

@@ -66,7 +66,7 @@ where
mod tests { mod tests {
use super::*; use super::*;
/// Empty string produces Yes(None) no footer, only '!' in the header /// Empty string produces Yes(None) - no footer, only '!' in the header
#[test] #[test]
fn from_empty_string_yields_yes_none() { fn from_empty_string_yields_yes_none() {
assert_eq!(BreakingChange::from(String::new()), BreakingChange::Yes); assert_eq!(BreakingChange::from(String::new()), BreakingChange::Yes);

View File

@@ -6,7 +6,7 @@ impl Description {
/// Soft limit for description length. /// Soft limit for description length.
/// ///
/// Descriptions over this length are warned about at the prompt layer but /// Descriptions over this length are warned about at the prompt layer but
/// are not rejected here the hard limit is the 72-character total first /// are not rejected here - the hard limit is the 72-character total first
/// line enforced by [`crate::ConventionalCommit`]. /// line enforced by [`crate::ConventionalCommit`].
pub const MAX_LENGTH: usize = 50; pub const MAX_LENGTH: usize = 50;

View File

@@ -10,7 +10,7 @@ pub enum CommitMessageError {
/// The formatted message is not parseable as a conventional commit /// The formatted message is not parseable as a conventional commit
/// ///
/// This should never occur in normal use it indicates a bug in the /// This should never occur in normal use - it indicates a bug in the
/// formatting logic. /// formatting logic.
#[error("output failed git-conventional validation: {reason}")] #[error("output failed git-conventional validation: {reason}")]
InvalidConventionalFormat { reason: String }, InvalidConventionalFormat { reason: String },
@@ -932,7 +932,7 @@ mod tests {
/// Breaking change footer does not count toward the 72-character first-line limit /// Breaking change footer does not count toward the 72-character first-line limit
#[test] #[test]
fn breaking_change_footer_does_not_count_toward_line_limit() { fn breaking_change_footer_does_not_count_toward_line_limit() {
// First line is short; the note itself is long should still be accepted. // First line is short; the note itself is long - should still be accepted.
let long_note = "x".repeat(200); let long_note = "x".repeat(200);
let result = ConventionalCommit::new( let result = ConventionalCommit::new(
CommitType::Fix, CommitType::Fix,

View File

@@ -1,3 +1,5 @@
use jj_lib::revset::{RevsetEvaluationError, RevsetParseError, RevsetResolutionError};
use crate::commit::types::{CommitMessageError, DescriptionError, ScopeError}; use crate::commit::types::{CommitMessageError, DescriptionError, ScopeError};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
@@ -25,6 +27,10 @@ pub enum Error {
Cancelled, Cancelled,
#[error("Non-interactive terminal detected")] #[error("Non-interactive terminal detected")]
NonInteractive, NonInteractive,
#[error("Failed to resolve revision '{revset}': {context}")]
RevsetResolutionError { revset: String, context: String },
#[error("Revision set '{revset}' resolves to multiple commits; specify a single revision")]
MultipleRevisions { revset: String },
} }
impl From<ScopeError> for Error { impl From<ScopeError> for Error {
@@ -46,7 +52,38 @@ impl From<CommitMessageError> for Error {
} }
impl From<std::io::Error> for Error { impl From<std::io::Error> for Error {
fn from(_value: std::io::Error) -> Self { fn from(_: std::io::Error) -> Self {
Self::FailedGettingCurrentDir Self::FailedGettingCurrentDir
} }
} }
impl<T> From<std::sync::PoisonError<T>> for Error {
fn from(_: std::sync::PoisonError<T>) -> Self {
Self::JjOperation {
context: "internal lock poisoned".to_string(),
}
}
}
impl Error {
pub fn from_revset_parse_error(revset: &str, error: RevsetParseError) -> Self {
Self::RevsetResolutionError {
revset: revset.to_string(),
context: error.to_string(),
}
}
pub fn from_revset_resolution_error(revset: &str, error: RevsetResolutionError) -> Self {
Self::RevsetResolutionError {
revset: revset.to_string(),
context: error.to_string(),
}
}
pub fn from_revset_evaluation_error(revset: &str, error: RevsetEvaluationError) -> Self {
Self::RevsetResolutionError {
revset: revset.to_string(),
context: error.to_string(),
}
}
}

View File

@@ -3,13 +3,24 @@
//! This implementation uses jj-lib 0.39.0 directly for repository detection //! This implementation uses jj-lib 0.39.0 directly for repository detection
//! and commit description, replacing the earlier shell-out approach. //! and commit description, replacing the earlier shell-out approach.
use std::path::{Path, PathBuf}; use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use etcetera::BaseStrategy; use etcetera::BaseStrategy;
use jj_lib::{ use jj_lib::{
backend::CommitId,
config::{ConfigSource, StackedConfig}, config::{ConfigSource, StackedConfig},
ref_name::WorkspaceName, fileset::FilesetAliasesMap,
repo::{Repo, StoreFactories}, ref_name::WorkspaceNameBuf,
repo::{ReadonlyRepo, Repo, StoreFactories},
repo_path::RepoPathUiConverter,
revset::{
self, RevsetAliasesMap, RevsetDiagnostics, RevsetExtensions, RevsetParseContext,
RevsetWorkspaceContext, SymbolResolver, SymbolResolverExtension,
},
settings::UserSettings, settings::UserSettings,
workspace::{Workspace, default_working_copy_factories}, workspace::{Workspace, default_working_copy_factories},
}; };
@@ -21,20 +32,58 @@ use crate::jj::JjExecutor;
#[derive(Debug)] #[derive(Debug)]
pub struct JjLib { pub struct JjLib {
working_dir: PathBuf, working_dir: PathBuf,
repo: Mutex<Arc<ReadonlyRepo>>,
workspace_name: WorkspaceNameBuf,
workspace_root: PathBuf,
} }
impl JjLib { impl JjLib {
/// Create a new JjLib instance using the current working directory /// Create a new JjLib instance using the current working directory
pub fn new() -> Result<Self, Error> { pub async fn new() -> Result<Self, Error> {
let working_dir = std::env::current_dir()?; let working_dir = std::env::current_dir()?;
Ok(Self { working_dir }) let (repo, workspace_name, workspace_root) = Self::load_repo(&working_dir).await?;
Ok(Self {
working_dir,
repo: repo.into(),
workspace_name,
workspace_root,
})
} }
/// Create a new JjLib instance with a specific working directory /// Create a new JjLib instance with a specific working directory
pub fn with_working_dir(path: impl AsRef<Path>) -> Self { pub async fn with_working_dir(path: impl AsRef<Path>) -> Result<Self, Error> {
Self { let (repo, workspace_name, workspace_root) = Self::load_repo(path.as_ref()).await?;
Ok(Self {
working_dir: path.as_ref().to_path_buf(), working_dir: path.as_ref().to_path_buf(),
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(),
))
} }
fn load_settings() -> Result<UserSettings, Error> { fn load_settings() -> Result<UserSettings, Error> {
@@ -100,6 +149,51 @@ impl JjLib {
paths paths
} }
/// 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)
}
} }
#[async_trait::async_trait(?Send)] #[async_trait::async_trait(?Send)]
@@ -117,49 +211,20 @@ impl JjExecutor for JjLib {
.is_ok()) .is_ok())
} }
async fn describe(&self, message: &str) -> Result<(), Error> { async fn describe(&self, revset: &str, message: &str) -> Result<(), Error> {
let settings = Self::load_settings()?; let commit_id = self.get_commit_id(revset)?;
let store_factories = StoreFactories::default(); let repo = self.repo.lock()?.clone();
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 mut tx = repo.start_transaction();
let commit = tx
let wc_commit_id = tx
.repo() .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() .store()
.get_commit(&wc_commit_id) .get_commit(&commit_id)
.map_err(|e| Error::JjOperation { .map_err(|e| Error::JjOperation {
context: e.to_string(), context: e.to_string(),
})?; })?;
tx.repo_mut() tx.repo_mut()
.rewrite_commit(&wc_commit) .rewrite_commit(&commit)
.set_description(message) .set_description(message)
.write() .write()
.await .await
@@ -174,14 +239,28 @@ impl JjExecutor for JjLib {
context: format!("{e:?}"), context: format!("{e:?}"),
})?; })?;
tx.commit("jj-cz: update commit description") let new_repo = tx
.commit("jj-cz: update commit description")
.await .await
.map_err(|e| Error::JjOperation { .map_err(|e| Error::JjOperation {
context: e.to_string(), context: e.to_string(),
})?; })?;
*self.repo.lock()? = new_repo;
Ok(()) Ok(())
} }
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())
}
} }
#[cfg(test)] #[cfg(test)]
@@ -197,7 +276,6 @@ mod tests {
.map_err(|e| format!("Failed to init jj repo: {e}")) .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<String, String> { async fn get_commit_description(dir: &Path) -> Result<String, String> {
let settings = JjLib::load_settings().map_err(|e| e.to_string())?; let settings = JjLib::load_settings().map_err(|e| e.to_string())?;
let store_factories = StoreFactories::default(); let store_factories = StoreFactories::default();
@@ -214,7 +292,7 @@ mod tests {
let wc_commit_id = repo let wc_commit_id = repo
.view() .view()
.get_wc_commit_id(WorkspaceName::DEFAULT) .get_wc_commit_id(jj_lib::ref_name::WorkspaceName::DEFAULT)
.ok_or_else(|| "No working copy commit found".to_string())? .ok_or_else(|| "No working copy commit found".to_string())?
.clone(); .clone();
@@ -233,7 +311,7 @@ mod tests {
.await .await
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.is_repository().await; let result = executor.is_repository().await;
assert!(result.is_ok()); assert!(result.is_ok());
@@ -244,11 +322,8 @@ mod tests {
async fn is_repository_returns_false_outside_jj_repo() { async fn is_repository_returns_false_outside_jj_repo() {
let temp_dir = assert_fs::TempDir::new().unwrap(); let temp_dir = assert_fs::TempDir::new().unwrap();
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await;
let result = executor.is_repository().await; assert!(executor.is_err());
assert!(result.is_ok());
assert!(!result.unwrap());
} }
#[tokio::test] #[tokio::test]
@@ -259,9 +334,9 @@ mod tests {
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let test_message = "test: initial commit"; let test_message = "test: initial commit";
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe(test_message).await; let result = executor.describe("@", test_message).await;
assert!(result.is_ok(), "describe failed: {result:?}"); assert!(result.is_ok(), "describe failed: {result:?}");
let actual = get_commit_description(temp_dir.path()) let actual = get_commit_description(temp_dir.path())
@@ -278,9 +353,9 @@ mod tests {
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let test_message = "feat: add feature with special chars !@#$%^&*()"; let test_message = "feat: add feature with special chars !@#$%^&*()";
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe(test_message).await; let result = executor.describe("@", test_message).await;
assert!(result.is_ok()); assert!(result.is_ok());
let actual = get_commit_description(temp_dir.path()) let actual = get_commit_description(temp_dir.path())
@@ -297,9 +372,9 @@ mod tests {
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let test_message = "docs: add unicode support 🎉 🚀"; let test_message = "docs: add unicode support 🎉 🚀";
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe(test_message).await; let result = executor.describe("@", test_message).await;
assert!(result.is_ok()); assert!(result.is_ok());
let actual = get_commit_description(temp_dir.path()) let actual = get_commit_description(temp_dir.path())
@@ -316,9 +391,9 @@ mod tests {
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let test_message = "feat: add feature\n\nThis is a multiline\ndescription"; let test_message = "feat: add feature\n\nThis is a multiline\ndescription";
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
let result = executor.describe(test_message).await; let result = executor.describe("@", test_message).await;
assert!(result.is_ok()); assert!(result.is_ok());
let actual = get_commit_description(temp_dir.path()) let actual = get_commit_description(temp_dir.path())
@@ -329,13 +404,21 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn describe_fails_outside_repo() { async fn describe_fails_outside_repo() {
// with_working_dir returns Err when not in a repo
let temp_dir = assert_fs::TempDir::new().unwrap(); let temp_dir = assert_fs::TempDir::new().unwrap();
let executor = JjLib::with_working_dir(temp_dir.path()).await;
assert!(executor.is_err());
let executor = JjLib::with_working_dir(temp_dir.path()); let valid_dir = assert_fs::TempDir::new().unwrap();
let result = executor.describe("test: should fail").await; init_jj_repo(valid_dir.path())
.await
.expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(valid_dir.path()).await.unwrap();
let result = executor
.describe("this-bookmark-does-not-exist", "test: should fail")
.await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotARepository));
} }
#[tokio::test] #[tokio::test]
@@ -345,10 +428,10 @@ mod tests {
.await .await
.expect("Failed to init jj repo"); .expect("Failed to init jj repo");
let executor = JjLib::with_working_dir(temp_dir.path()); let executor = JjLib::with_working_dir(temp_dir.path()).await.unwrap();
executor executor
.describe("feat: first commit") .describe("@", "feat: first commit")
.await .await
.expect("First describe failed"); .expect("First describe failed");
let desc1 = get_commit_description(temp_dir.path()) let desc1 = get_commit_description(temp_dir.path())
@@ -357,7 +440,7 @@ mod tests {
assert_eq!(desc1, "feat: first commit"); assert_eq!(desc1, "feat: first commit");
executor executor
.describe("feat: updated commit") .describe("@", "feat: updated commit")
.await .await
.expect("Second describe failed"); .expect("Second describe failed");
let desc2 = get_commit_description(temp_dir.path()) let desc2 = get_commit_description(temp_dir.path())
@@ -366,11 +449,87 @@ mod tests {
assert_eq!(desc2, "feat: updated commit"); assert_eq!(desc2, "feat: updated commit");
} }
#[test] #[tokio::test]
fn jj_lib_implements_jj_executor_trait() { async fn get_description_returns_empty_for_fresh_commit() {
let lib = JjLib::with_working_dir(std::path::Path::new(".")); let temp_dir = assert_fs::TempDir::new().unwrap();
fn accepts_executor(_: impl JjExecutor) {} init_jj_repo(temp_dir.path())
accepts_executor(lib); .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() {
fn assert_implements<T: JjExecutor>() {}
assert_implements::<JjLib>();
} }
mod user_config_paths_tests { mod user_config_paths_tests {

View File

@@ -15,6 +15,10 @@ pub struct MockJjExecutor {
is_repo_response: Result<bool, Error>, is_repo_response: Result<bool, Error>,
/// Response to return from describe() /// Response to return from describe()
describe_response: Result<(), Error>, describe_response: Result<(), Error>,
/// Track described revsets
described_revsets: Mutex<Vec<String>>,
/// Track response to return from get_description()
get_description_response: Result<String, Error>,
/// Track calls to is_repository() /// Track calls to is_repository()
is_repo_called: AtomicBool, is_repo_called: AtomicBool,
/// Track calls to describe() with the message passed /// Track calls to describe() with the message passed
@@ -26,6 +30,8 @@ impl Default for MockJjExecutor {
Self { Self {
is_repo_response: Ok(true), is_repo_response: Ok(true),
describe_response: Ok(()), describe_response: Ok(()),
described_revsets: Mutex::new(Vec::new()),
get_description_response: Ok(String::new()),
is_repo_called: AtomicBool::new(false), is_repo_called: AtomicBool::new(false),
describe_calls: Mutex::new(Vec::new()), describe_calls: Mutex::new(Vec::new()),
} }
@@ -50,6 +56,12 @@ impl MockJjExecutor {
self self
} }
/// Configure get_description() to return a specific value
pub fn with_get_description_response(mut self, response: Result<String, Error>) -> Self {
self.get_description_response = response;
self
}
/// Check if is_repository() was called /// Check if is_repository() was called
pub fn was_is_repo_called(&self) -> bool { pub fn was_is_repo_called(&self) -> bool {
self.is_repo_called self.is_repo_called
@@ -60,6 +72,11 @@ impl MockJjExecutor {
pub fn describe_messages(&self) -> Vec<String> { pub fn describe_messages(&self) -> Vec<String> {
self.describe_calls.lock().unwrap().clone() self.describe_calls.lock().unwrap().clone()
} }
/// Get all revsets visited
pub fn described_revsets(&self) -> Vec<String> {
self.described_revsets.lock().unwrap().clone()
}
} }
#[async_trait(?Send)] #[async_trait(?Send)]
@@ -73,7 +90,11 @@ impl JjExecutor for MockJjExecutor {
} }
} }
async fn describe(&self, message: &str) -> Result<(), Error> { async fn describe(&self, revset: &str, message: &str) -> Result<(), Error> {
self.described_revsets
.lock()
.unwrap()
.push(revset.to_string());
self.describe_calls self.describe_calls
.lock() .lock()
.unwrap() .unwrap()
@@ -83,6 +104,10 @@ impl JjExecutor for MockJjExecutor {
Err(e) => Err(e.clone()), Err(e) => Err(e.clone()),
} }
} }
async fn get_description(&self, _revset: &str) -> Result<String, Error> {
self.get_description_response.clone()
}
} }
#[cfg(test)] #[cfg(test)]
@@ -130,7 +155,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mock_describe_records_message() { async fn mock_describe_records_message() {
let mock = MockJjExecutor::new(); let mock = MockJjExecutor::new();
let result = mock.describe("test message").await; let result = mock.describe("@", "test message").await;
assert!(result.is_ok()); assert!(result.is_ok());
let messages = mock.describe_messages(); let messages = mock.describe_messages();
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
@@ -141,8 +166,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mock_describe_records_multiple_messages() { async fn mock_describe_records_multiple_messages() {
let mock = MockJjExecutor::new(); let mock = MockJjExecutor::new();
mock.describe("first message").await.unwrap(); mock.describe("@", "first message").await.unwrap();
mock.describe("second message").await.unwrap(); mock.describe("@", "second message").await.unwrap();
let messages = mock.describe_messages(); let messages = mock.describe_messages();
assert_eq!(messages.len(), 2); assert_eq!(messages.len(), 2);
assert_eq!(messages[0], "first message"); assert_eq!(messages[0], "first message");
@@ -153,7 +178,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mock_describe_returns_error() { async fn mock_describe_returns_error() {
let mock = MockJjExecutor::new().with_describe_response(Err(Error::RepositoryLocked)); let mock = MockJjExecutor::new().with_describe_response(Err(Error::RepositoryLocked));
let result = mock.describe("test").await; let result = mock.describe("@", "test").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::RepositoryLocked)); assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
} }
@@ -164,7 +189,7 @@ mod tests {
let mock = MockJjExecutor::new().with_describe_response(Err(Error::JjOperation { let mock = MockJjExecutor::new().with_describe_response(Err(Error::JjOperation {
context: "transaction failed".to_string(), context: "transaction failed".to_string(),
})); }));
let result = mock.describe("test").await; let result = mock.describe("@", "test").await;
assert!(result.is_err()); assert!(result.is_err());
match result.unwrap_err() { match result.unwrap_err() {
Error::JjOperation { context } => { Error::JjOperation { context } => {
@@ -208,7 +233,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mock_describe_accepts_empty_message() { async fn mock_describe_accepts_empty_message() {
let mock = MockJjExecutor::new(); let mock = MockJjExecutor::new();
let result = mock.describe("").await; let result = mock.describe("@", "").await;
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(mock.describe_messages()[0], ""); assert_eq!(mock.describe_messages()[0], "");
} }
@@ -218,7 +243,7 @@ mod tests {
async fn mock_describe_accepts_long_message() { async fn mock_describe_accepts_long_message() {
let mock = MockJjExecutor::new(); let mock = MockJjExecutor::new();
let long_message = "a".repeat(1000); let long_message = "a".repeat(1000);
let result = mock.describe(&long_message).await; let result = mock.describe("@", &long_message).await;
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(mock.describe_messages()[0].len(), 1000); assert_eq!(mock.describe_messages()[0].len(), 1000);
} }

View File

@@ -14,7 +14,13 @@ pub trait JjExecutor: Send + Sync {
async fn is_repository(&self) -> Result<bool, Error>; async fn is_repository(&self) -> Result<bool, Error>;
/// Set the description of the current change /// Set the description of the current change
async fn describe(&self, message: &str) -> Result<(), Error>; ///
/// The revset parameter should resolve to a single commit (e.g.,
/// `"@"`, `"xs"`, bookmark name)
async fn describe(&self, revset: &str, message: &str) -> Result<(), Error>;
/// Get the current description of a specific revision
async fn get_description(&self, revset: &str) -> Result<String, Error>;
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,4 +1,3 @@
mod cli;
mod commit; mod commit;
mod error; mod error;
mod jj; mod jj;

View File

@@ -22,6 +22,8 @@ fn error_to_exit_code(error: &Error) -> i32 {
Error::NonInteractive => EXIT_ERROR, Error::NonInteractive => EXIT_ERROR,
Error::FailedGettingCurrentDir => EXIT_ERROR, Error::FailedGettingCurrentDir => EXIT_ERROR,
Error::FailedReadingConfig { .. } => EXIT_ERROR, Error::FailedReadingConfig { .. } => EXIT_ERROR,
Error::RevsetResolutionError { .. } => EXIT_ERROR,
Error::MultipleRevisions { .. } => EXIT_ERROR,
} }
} }
@@ -33,35 +35,29 @@ fn is_interactive_terminal() -> bool {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Parse CLI arguments; --help and --version are handled automatically by clap let cli = cli::Cli::parse();
cli::Cli::parse();
if !is_interactive_terminal() { if !is_interactive_terminal() {
eprintln!("❌ Error: jj-cz requires an interactive terminal (TTY)"); eprintln!("❌ Error: jj-cz requires an interactive terminal (TTY)");
eprintln!(" This tool cannot be used in non-interactive mode or when piping input."); eprintln!(" This tool cannot be used in non-interactive mode or when piping input.");
eprintln!(" Use --help for usage information."); eprintln!(" Use --help for usage information.");
process::exit(EXIT_ERROR); process::exit(EXIT_ERROR);
} }
let executor = match JjLib::new().await {
// Create the jj executor
let executor = match JjLib::new() {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
eprintln!("❌ Error: {}", e); eprintln!("❌ Error: {}", e);
process::exit(EXIT_ERROR); process::exit(EXIT_ERROR);
} }
}; };
// Create and run the workflow
let workflow = CommitWorkflow::new(executor); let workflow = CommitWorkflow::new(executor);
let result = workflow.run().await; for revset in cli.revsets() {
let result = workflow.run_for_revset(revset).await;
// Handle the result handle_result(result);
match result {
Ok(()) => {
println!("✅ Commit message applied successfully!");
process::exit(EXIT_SUCCESS);
} }
fn handle_result(result: Result<(), Error>) {
match result {
Ok(()) => println!("✅ Commit message applied successfully!"),
Err(Error::Cancelled) => { Err(Error::Cancelled) => {
println!("🟡 Operation cancelled by user."); println!("🟡 Operation cancelled by user.");
process::exit(EXIT_CANCELLED); process::exit(EXIT_CANCELLED);
@@ -71,4 +67,6 @@ async fn main() {
process::exit(error_to_exit_code(&e)); process::exit(error_to_exit_code(&e));
} }
} }
}
process::exit(EXIT_SUCCESS);
} }

View File

@@ -107,7 +107,7 @@ impl Prompter for RealPrompts {
continue; continue;
} }
// parse() only fails on empty already handled above // parse() only fails on empty - already handled above
let Ok(desc) = Description::parse(trimmed) else { let Ok(desc) = Description::parse(trimmed) else {
println!("❌ Description cannot be empty. Please provide a description."); println!("❌ Description cannot be empty. Please provide a description.");
continue; continue;
@@ -269,7 +269,7 @@ mod tests {
} }
/// A single CJK character (display width 2) is padded as if it occupies 2 columns, /// A single CJK character (display width 2) is padded as if it occupies 2 columns,
/// not 1 so the right-hand padding is 70 spaces, not 71 /// not 1 - so the right-hand padding is 70 spaces, not 71
#[test] #[test]
fn format_message_box_single_cjk_char() { fn format_message_box_single_cjk_char() {
let result = format_message_box(""); let result = format_message_box("");

View File

@@ -54,10 +54,12 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
/// - User cancels the workflow /// - User cancels the workflow
/// - Repository operation fails /// - Repository operation fails
/// - Message validation fails /// - Message validation fails
pub async fn run(&self) -> Result<(), Error> { pub async fn run_for_revset(&self, revset: &str) -> Result<(), Error> {
if !self.executor.is_repository().await? { if !self.executor.is_repository().await? {
return Err(Error::NotARepository); return Err(Error::NotARepository);
} }
// For future reference
let _existing_desc = self.executor.get_description(revset).await.ok();
let commit_type = self.type_selection()?; let commit_type = self.type_selection()?;
loop { loop {
let scope = self.scope_input()?; let scope = self.scope_input()?;
@@ -67,7 +69,7 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
match self.preview_and_confirm(commit_type, scope, description, breaking_change, body) { match self.preview_and_confirm(commit_type, scope, description, breaking_change, body) {
Ok(conventional_commit) => { Ok(conventional_commit) => {
self.executor self.executor
.describe(&conventional_commit.to_string()) .describe(revset, &conventional_commit.to_string())
.await?; .await?;
return Ok(()); return Ok(());
} }
@@ -205,7 +207,7 @@ mod tests {
async fn workflow_returns_not_a_repository() { async fn workflow_returns_not_a_repository() {
let mock = MockJjExecutor::new().with_is_repo_response(Ok(false)); let mock = MockJjExecutor::new().with_is_repo_response(Ok(false));
let workflow = CommitWorkflow::new(mock); let workflow = CommitWorkflow::new(mock);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotARepository)); assert!(matches!(result.unwrap_err(), Error::NotARepository));
} }
@@ -215,7 +217,7 @@ mod tests {
async fn workflow_returns_repository_error() { async fn workflow_returns_repository_error() {
let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository)); let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository));
let workflow = CommitWorkflow::new(mock); let workflow = CommitWorkflow::new(mock);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotARepository)); assert!(matches!(result.unwrap_err(), Error::NotARepository));
} }
@@ -285,7 +287,7 @@ mod tests {
// Verify the mock behaves as expected // Verify the mock behaves as expected
assert!(mock.is_repository().await.is_ok()); assert!(mock.is_repository().await.is_ok());
assert!(mock.describe("test").await.is_err()); assert!(mock.describe("@", "test").await.is_err());
// Also test with a working mock // Also test with a working mock
let working_mock = MockJjExecutor::new(); let working_mock = MockJjExecutor::new();
@@ -323,7 +325,7 @@ mod tests {
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
// Run the workflow - should succeed // Run the workflow - should succeed
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -334,7 +336,7 @@ mod tests {
let mock_prompts = MockPrompts::new().with_error(Error::Cancelled); let mock_prompts = MockPrompts::new().with_error(Error::Cancelled);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::Cancelled)); assert!(matches!(result.unwrap_err(), Error::Cancelled));
@@ -353,7 +355,7 @@ mod tests {
.with_confirm(false); // User cancels at confirmation .with_confirm(false); // User cancels at confirmation
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::Cancelled)); assert!(matches!(result.unwrap_err(), Error::Cancelled));
@@ -384,7 +386,7 @@ mod tests {
// Clone before moving into workflow so we can inspect emitted messages after // Clone before moving into workflow so we can inspect emitted messages after
let mock_prompts_handle = mock_prompts.clone(); let mock_prompts_handle = mock_prompts.clone();
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
// Should succeed after the retry // Should succeed after the retry
assert!( assert!(
@@ -421,7 +423,7 @@ mod tests {
)); ));
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidScope(_))); assert!(matches!(result.unwrap_err(), Error::InvalidScope(_)));
@@ -440,7 +442,7 @@ mod tests {
)); ));
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidDescription(_))); assert!(matches!(result.unwrap_err(), Error::InvalidDescription(_)));
@@ -479,7 +481,7 @@ mod tests {
MockJjExecutor::new().with_is_repo_response(Ok(true)), MockJjExecutor::new().with_is_repo_response(Ok(true)),
mock_prompts, mock_prompts,
); );
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok(), "Failed for commit type: {:?}", commit_type); assert!(result.is_ok(), "Failed for commit type: {:?}", commit_type);
} }
} }
@@ -503,7 +505,7 @@ mod tests {
mock_prompts, mock_prompts,
); );
{ {
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok()); assert!(result.is_ok());
} }
@@ -521,7 +523,7 @@ mod tests {
mock_prompts, mock_prompts,
); );
{ {
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!(result.is_ok()); assert!(result.is_ok());
} }
} }
@@ -621,7 +623,7 @@ mod tests {
.with_confirm(true); .with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!( assert!(
result.is_ok(), result.is_ok(),
@@ -716,7 +718,7 @@ mod tests {
.with_confirm(true); .with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!( assert!(
result.is_ok(), result.is_ok(),
@@ -748,7 +750,7 @@ mod tests {
.with_confirm(true); .with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run_for_revset("@").await;
assert!( assert!(
result.is_ok(), result.is_ok(),

View File

@@ -1,28 +1,13 @@
use assert_fs::TempDir; use assert_fs::TempDir;
#[cfg(feature = "test-utils")] #[cfg(feature = "test-utils")]
use jj_cz::{Body, BreakingChange, CommitType, Description, MockPrompts, Scope}; use jj_cz::{Body, BreakingChange, CommitType, Description, MockJjExecutor, MockPrompts, Scope};
use jj_cz::{CommitWorkflow, Error, JjLib}; use jj_cz::{CommitWorkflow, Error, JjLib};
#[cfg(feature = "test-utils")]
use jj_lib::{config::StackedConfig, settings::UserSettings, workspace::Workspace};
/// Helper to initialize a temporary jj repository using jj-lib directly (no CLI required)
#[cfg(feature = "test-utils")]
async fn init_jj_repo(temp_dir: &TempDir) {
let config = StackedConfig::with_defaults();
let settings = UserSettings::from_config(config).expect("Failed to create settings");
Workspace::init_internal_git(&settings, temp_dir.path())
.await
.expect("Failed to initialize jj repository");
}
#[cfg(feature = "test-utils")] #[cfg(feature = "test-utils")]
#[tokio::test] #[tokio::test]
async fn test_happy_path_integration() { async fn test_happy_path_integration() {
// T037: Happy path integration test // T037: Happy path integration test
let temp_dir = TempDir::new().unwrap(); let mock_executor = MockJjExecutor::new();
init_jj_repo(&temp_dir).await;
// Create mock prompts that simulate a successful workflow
let mock_prompts = MockPrompts::new() let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Feat) .with_commit_type(CommitType::Feat)
.with_scope(Scope::empty()) .with_scope(Scope::empty())
@@ -31,13 +16,9 @@ async fn test_happy_path_integration() {
.with_body(Body::default()) .with_body(Body::default())
.with_confirm(true); .with_confirm(true);
// Create a mock executor that tracks calls let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let executor = JjLib::with_working_dir(temp_dir.path()); let result = workflow.run_for_revset("@").await;
let workflow = CommitWorkflow::with_prompts(executor, mock_prompts);
let result = workflow.run().await;
// The workflow should complete successfully
assert!( assert!(
result.is_ok(), result.is_ok(),
"Workflow should complete successfully: {:?}", "Workflow should complete successfully: {:?}",
@@ -47,17 +28,11 @@ async fn test_happy_path_integration() {
#[tokio::test] #[tokio::test]
async fn test_not_in_repo() { async fn test_not_in_repo() {
// T038: Not-in-repo integration test // T038: Not-in-repo integration test - with_working_dir itself returns the error
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
// Don't initialize jj repo
// Create executor with the temp directory (which is not a jj repo) let result = JjLib::with_working_dir(temp_dir.path()).await;
let executor = JjLib::with_working_dir(temp_dir.path());
let workflow = CommitWorkflow::new(executor);
let result = workflow.run().await;
// Should fail with NotARepository error
assert!(matches!(result, Err(Error::NotARepository))); assert!(matches!(result, Err(Error::NotARepository)));
} }
@@ -77,9 +52,13 @@ async fn test_cancellation() {
Ok(true) Ok(true)
} }
async fn describe(&self, _message: &str) -> Result<(), Error> { async fn describe(&self, _revset: &str, _message: &str) -> Result<(), Error> {
Err(Error::Cancelled) Err(Error::Cancelled)
} }
async fn get_description(&self, _revset: &str) -> Result<String, Error> {
Ok(String::new())
}
} }
let executor = CancelMock; let executor = CancelMock;
@@ -92,7 +71,7 @@ async fn test_cancellation() {
.with_confirm(true); .with_confirm(true);
let workflow = CommitWorkflow::with_prompts(executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(executor, mock_prompts);
let result = workflow.run().await; let result = workflow.run_for_revset("@").await;
// Should fail with Cancelled error // Should fail with Cancelled error
assert!(matches!(result, Err(Error::Cancelled))); assert!(matches!(result, Err(Error::Cancelled)));