use anyhow::{Context, Result};
use clap::Parser;
use tracing::debug;
use super::parse_beta_header;
use crate::data::amendments::AmendmentFile;
use crate::data::RepositoryView;
#[derive(Parser)]
pub struct TwiddleCommand {
#[arg(value_name = "COMMIT_RANGE")]
pub commit_range: Option<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long, value_name = "KEY:VALUE")]
pub beta_header: Option<String>,
#[arg(long)]
pub auto_apply: bool,
#[arg(long, value_name = "FILE")]
pub save_only: Option<String>,
#[arg(long, default_value = "true")]
pub use_context: bool,
#[arg(long)]
pub context_dir: Option<std::path::PathBuf>,
#[arg(long)]
pub work_context: Option<String>,
#[arg(long)]
pub branch_context: Option<String>,
#[arg(long)]
pub no_context: bool,
#[arg(long, default_value = "4")]
pub concurrency: usize,
#[arg(long, hide = true)]
pub batch_size: Option<usize>,
#[arg(long)]
pub no_coherence: bool,
#[arg(long)]
pub no_ai: bool,
#[arg(long, conflicts_with = "refine")]
pub fresh: bool,
#[arg(long, conflicts_with = "fresh")]
pub refine: bool,
#[arg(long)]
pub check: bool,
#[arg(long)]
pub quiet: bool,
}
impl TwiddleCommand {
fn is_fresh(&self) -> bool {
!self.refine
}
pub async fn execute(mut self) -> Result<()> {
if let Some(bs) = self.batch_size {
eprintln!("warning: --batch-size is deprecated; use --concurrency instead");
self.concurrency = bs;
}
if self.no_ai {
return self.execute_no_ai().await;
}
let ai_info = crate::utils::check_ai_command_prerequisites(self.model.as_deref())?;
println!(
"✓ {} credentials verified (model: {})",
ai_info.provider, ai_info.model
);
crate::utils::preflight::check_working_directory_clean()?;
println!("✓ Working directory is clean");
let use_contextual = self.use_context && !self.no_context;
if use_contextual {
println!(
"🪄 Starting AI-powered commit message improvement with contextual intelligence..."
);
} else {
println!("🪄 Starting AI-powered commit message improvement...");
}
let mut full_repo_view = self.generate_repository_view().await?;
if full_repo_view.commits.len() > 1 {
return self
.execute_with_map_reduce(use_contextual, full_repo_view)
.await;
}
let context = if use_contextual {
Some(self.collect_context(&full_repo_view).await?)
} else {
None
};
let scope_defs = match &context {
Some(ctx) => ctx.project.valid_scopes.clone(),
None => self.load_check_scopes(),
};
for commit in &mut full_repo_view.commits {
commit.analysis.refine_scope(&scope_defs);
}
if let Some(ref ctx) = context {
self.show_context_summary(ctx)?;
}
let beta = self
.beta_header
.as_deref()
.map(parse_beta_header)
.transpose()?;
let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
self.show_model_info_from_client(&claude_client)?;
if self.refine {
println!("🔄 Refine mode: using existing commit messages as starting point...");
}
if use_contextual && context.is_some() {
println!("🤖 Analyzing commits with enhanced contextual intelligence...");
} else {
println!("🤖 Analyzing commits with Claude AI...");
}
let mut amendments = if let Some(ctx) = context {
claude_client
.generate_contextual_amendments_with_options(&full_repo_view, &ctx, self.is_fresh())
.await?
} else {
claude_client
.generate_amendments_with_options(&full_repo_view, self.is_fresh())
.await?
};
refine_amendment_scopes(&mut amendments, &full_repo_view, &scope_defs);
if let Some(save_path) = self.save_only {
amendments.save_to_file(save_path)?;
println!("💾 Amendments saved to file");
return Ok(());
}
if !amendments.amendments.is_empty() {
let temp_dir = tempfile::tempdir()?;
let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
amendments.save_to_file(&amendments_file)?;
{
use std::io::IsTerminal;
if !self.auto_apply
&& !self.handle_amendments_file(
&amendments_file,
&amendments,
std::io::stdin().is_terminal(),
&mut std::io::BufReader::new(std::io::stdin()),
)?
{
println!("❌ Amendment cancelled by user");
return Ok(());
}
}
self.apply_amendments_from_file(&amendments_file).await?;
println!("✅ Commit messages improved successfully!");
if self.check {
self.run_post_twiddle_check().await?;
}
} else {
println!("✨ No commits found to process!");
}
Ok(())
}
async fn execute_with_map_reduce(
&self,
use_contextual: bool,
mut full_repo_view: crate::data::RepositoryView,
) -> Result<()> {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use crate::claude::batch;
use crate::claude::token_budget;
let concurrency = self.concurrency;
let beta = self
.beta_header
.as_deref()
.map(parse_beta_header)
.transpose()?;
let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
self.show_model_info_from_client(&claude_client)?;
if self.refine {
println!("🔄 Refine mode: using existing commit messages as starting point...");
}
let total_commits = full_repo_view.commits.len();
println!(
"🔄 Processing {total_commits} commits in parallel (concurrency: {concurrency})..."
);
let context = if use_contextual {
Some(self.collect_context(&full_repo_view).await?)
} else {
None
};
if let Some(ref ctx) = context {
self.show_context_summary(ctx)?;
}
let scope_defs = match &context {
Some(ctx) => ctx.project.valid_scopes.clone(),
None => self.load_check_scopes(),
};
for commit in &mut full_repo_view.commits {
commit.analysis.refine_scope(&scope_defs);
}
let metadata = claude_client.get_ai_client_metadata();
let system_prompt_tokens = if let Some(ref ctx) = context {
let prompt_style = metadata.prompt_style();
let system_prompt =
crate::claude::prompts::generate_contextual_system_prompt_for_provider(
ctx,
prompt_style,
);
token_budget::estimate_tokens(&system_prompt)
} else {
token_budget::estimate_tokens(crate::claude::prompts::SYSTEM_PROMPT)
};
let batch_plan =
batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
if batch_plan.batches.len() < total_commits {
println!(
" 📦 Grouped {} commits into {} batches by token budget",
total_commits,
batch_plan.batches.len()
);
}
let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
let completed = Arc::new(AtomicUsize::new(0));
let repo_ref = &full_repo_view;
let client_ref = &claude_client;
let context_ref = &context;
let fresh = self.is_fresh();
let futs: Vec<_> = batch_plan
.batches
.iter()
.map(|batch| {
let sem = semaphore.clone();
let completed = completed.clone();
let batch_indices = &batch.commit_indices;
async move {
let _permit = sem
.acquire()
.await
.map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
let batch_size = batch_indices.len();
let batch_view = if batch_size == 1 {
repo_ref.single_commit_view(&repo_ref.commits[batch_indices[0]])
} else {
let commits: Vec<_> = batch_indices
.iter()
.map(|&i| &repo_ref.commits[i])
.collect();
repo_ref.multi_commit_view(&commits)
};
let result = if let Some(ref ctx) = context_ref {
client_ref
.generate_contextual_amendments_with_options(&batch_view, ctx, fresh)
.await
} else {
client_ref
.generate_amendments_with_options(&batch_view, fresh)
.await
};
match result {
Ok(amendment_file) => {
let done =
completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
println!(" ✅ {done}/{total_commits} commits processed");
let items: Vec<_> = amendment_file
.amendments
.into_iter()
.map(|a| {
let summary = a.summary.clone().unwrap_or_default();
(a, summary)
})
.collect();
Ok::<_, anyhow::Error>((items, vec![]))
}
Err(e) if batch_size > 1 => {
eprintln!(
"warning: batch of {batch_size} failed, retrying individually: {e}"
);
let mut items = Vec::new();
let mut failed_indices = Vec::new();
for &idx in batch_indices {
let single_view =
repo_ref.single_commit_view(&repo_ref.commits[idx]);
let single_result = if let Some(ref ctx) = context_ref {
client_ref
.generate_contextual_amendments_with_options(
&single_view,
ctx,
fresh,
)
.await
} else {
client_ref
.generate_amendments_with_options(&single_view, fresh)
.await
};
match single_result {
Ok(af) => {
if let Some(a) = af.amendments.into_iter().next() {
let summary = a.summary.clone().unwrap_or_default();
items.push((a, summary));
}
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
println!(" ✅ {done}/{total_commits} commits processed");
}
Err(e) => {
eprintln!("warning: failed to process commit: {e}");
for (i, cause) in e.chain().skip(1).enumerate() {
eprintln!(" caused by [{i}]: {cause}");
}
failed_indices.push(idx);
println!(" ❌ commit processing failed");
}
}
}
Ok((items, failed_indices))
}
Err(e) => {
let idx = batch_indices[0];
eprintln!("warning: failed to process commit: {e}");
for (i, cause) in e.chain().skip(1).enumerate() {
eprintln!(" caused by [{i}]: {cause}");
}
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
println!(" ❌ {done}/{total_commits} commits processed (failed)");
Ok((vec![], vec![idx]))
}
}
}
})
.collect();
let results = futures::future::join_all(futs).await;
let mut successes: Vec<(crate::data::amendments::Amendment, String)> = Vec::new();
let mut failed_indices: Vec<usize> = Vec::new();
for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
match result {
Ok((items, failed)) => {
successes.extend(items);
failed_indices.extend(failed);
}
Err(e) => {
eprintln!("warning: batch processing error: {e}");
failed_indices.extend(&batch.commit_indices);
}
}
}
if !failed_indices.is_empty() && !self.quiet {
use std::io::IsTerminal;
self.run_interactive_retry_generate_amendments(
&mut failed_indices,
&full_repo_view,
&claude_client,
context.as_ref(),
fresh,
&mut successes,
std::io::stdin().is_terminal(),
&mut std::io::BufReader::new(std::io::stdin()),
)
.await?;
} else if !failed_indices.is_empty() {
eprintln!(
"warning: {} commit(s) failed to process",
failed_indices.len()
);
}
if !failed_indices.is_empty() {
eprintln!(
"warning: {} commit(s) ultimately failed to process",
failed_indices.len()
);
}
if successes.is_empty() {
anyhow::bail!("All commits failed to process");
}
let single_batch = batch_plan.batches.len() <= 1;
let mut all_amendments = if !self.no_coherence && !single_batch && successes.len() >= 2 {
println!("🔗 Running cross-commit coherence pass...");
match claude_client.refine_amendments_coherence(&successes).await {
Ok(refined) => refined,
Err(e) => {
eprintln!("warning: coherence pass failed, using individual results: {e}");
AmendmentFile {
amendments: successes.into_iter().map(|(a, _)| a).collect(),
}
}
}
} else {
AmendmentFile {
amendments: successes.into_iter().map(|(a, _)| a).collect(),
}
};
refine_amendment_scopes(&mut all_amendments, &full_repo_view, &scope_defs);
println!(
"✅ All commits processed! Found {} amendments.",
all_amendments.amendments.len()
);
if let Some(save_path) = &self.save_only {
all_amendments.save_to_file(save_path)?;
println!("💾 Amendments saved to file");
return Ok(());
}
if !all_amendments.amendments.is_empty() {
let temp_dir = tempfile::tempdir()?;
let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
all_amendments.save_to_file(&amendments_file)?;
{
use std::io::IsTerminal;
if !self.auto_apply
&& !self.handle_amendments_file(
&amendments_file,
&all_amendments,
std::io::stdin().is_terminal(),
&mut std::io::BufReader::new(std::io::stdin()),
)?
{
println!("❌ Amendment cancelled by user");
return Ok(());
}
}
self.apply_amendments_from_file(&amendments_file).await?;
println!("✅ Commit messages improved successfully!");
if self.check {
self.run_post_twiddle_check().await?;
}
} else {
println!("✨ No commits found to process!");
}
Ok(())
}
async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
use crate::data::{
AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
WorkingDirectoryInfo,
};
use crate::git::{GitRepository, RemoteInfo};
use crate::utils::ai_scratch;
let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
let repo = GitRepository::open()
.context("Failed to open git repository. Make sure you're in a git repository.")?;
let current_branch = repo
.get_current_branch()
.unwrap_or_else(|_| "HEAD".to_string());
let wd_status = repo.get_working_directory_status()?;
let working_directory = WorkingDirectoryInfo {
clean: wd_status.clean,
untracked_changes: wd_status
.untracked_changes
.into_iter()
.map(|fs| FileStatusInfo {
status: fs.status,
file: fs.file,
})
.collect(),
};
let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
let commits = repo.get_commits_in_range(commit_range)?;
let versions = Some(VersionInfo {
omni_dev: env!("CARGO_PKG_VERSION").to_string(),
});
let ai_scratch_path =
ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
let ai_info = AiInfo {
scratch: ai_scratch_path.to_string_lossy().to_string(),
};
let mut repo_view = RepositoryView {
versions,
explanation: FieldExplanation::default(),
working_directory,
remotes,
ai: ai_info,
branch_info: Some(BranchInfo {
branch: current_branch,
}),
pr_template: None,
pr_template_location: None,
branch_prs: None,
commits,
};
repo_view.update_field_presence();
Ok(repo_view)
}
fn handle_amendments_file(
&self,
amendments_file: &std::path::Path,
amendments: &crate::data::amendments::AmendmentFile,
is_terminal: bool,
reader: &mut (dyn std::io::BufRead + Send),
) -> Result<bool> {
use std::io::{self, Write};
println!(
"\n📝 Found {} commits that could be improved.",
amendments.amendments.len()
);
println!("💾 Amendments saved to: {}", amendments_file.display());
println!();
if !is_terminal {
eprintln!("warning: stdin is not interactive, cannot prompt for amendments");
return Ok(false);
}
loop {
print!("❓ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
io::stdout().flush()?;
let Some(input) = super::read_interactive_line(reader)? else {
eprintln!("warning: stdin closed, cancelling amendments");
return Ok(false);
};
match input.trim().to_lowercase().as_str() {
"a" | "apply" | "" => return Ok(true),
"s" | "show" => {
self.show_amendments_file(amendments_file)?;
println!();
}
"e" | "edit" => {
self.edit_amendments_file(amendments_file)?;
println!();
}
"q" | "quit" => return Ok(false),
_ => {
println!(
"Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
);
}
}
}
}
fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
use std::fs;
println!("\n📄 Amendments file contents:");
println!("─────────────────────────────");
let contents =
fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
println!("{contents}");
println!("─────────────────────────────");
Ok(())
}
fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
use std::env;
use std::io::{self, Write};
use std::process::Command;
let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
e
} else {
println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
print!("Please enter the command to use as your editor: ");
io::stdout().flush().context("Failed to flush stdout")?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("Failed to read user input")?;
input.trim().to_string()
};
if editor.is_empty() {
println!("❌ No editor specified. Returning to menu.");
return Ok(());
}
println!("📝 Opening amendments file in editor: {editor}");
let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
let mut command = Command::new(editor_cmd);
command.args(args);
command.arg(amendments_file.to_string_lossy().as_ref());
match command.status() {
Ok(status) => {
if status.success() {
println!("✅ Editor session completed.");
} else {
println!(
"⚠️ Editor exited with non-zero status: {:?}",
status.code()
);
}
}
Err(e) => {
println!("❌ Failed to execute editor '{editor}': {e}");
println!(" Please check that the editor command is correct and available in your PATH.");
}
}
Ok(())
}
async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
use crate::git::AmendmentHandler;
let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
handler
.apply_amendments(&amendments_file.to_string_lossy())
.context("Failed to apply amendments")?;
Ok(())
}
async fn collect_context(
&self,
repo_view: &crate::data::RepositoryView,
) -> Result<crate::data::context::CommitContext> {
use crate::claude::context::{
BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
};
use crate::data::context::CommitContext;
let mut context = CommitContext::new();
let (context_dir, dir_source) =
crate::claude::context::resolve_context_dir_with_source(self.context_dir.as_deref());
let repo_root = std::path::PathBuf::from(".");
let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
debug!(context_dir = ?context_dir, "Using context directory");
match discovery.discover() {
Ok(project_context) => {
debug!("Discovery successful");
self.show_guidance_files_status(&project_context, &context_dir, &dir_source)?;
context.project = project_context;
}
Err(e) => {
debug!(error = %e, "Discovery failed");
context.project = crate::data::context::ProjectContext::default();
}
}
if let Some(branch_info) = &repo_view.branch_info {
context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
} else {
use crate::git::GitRepository;
let repo = GitRepository::open()?;
let current_branch = repo
.get_current_branch()
.unwrap_or_else(|_| "HEAD".to_string());
context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
}
if !repo_view.commits.is_empty() {
context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
}
if !repo_view.commits.is_empty() {
context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
}
if let Some(ref work_ctx) = self.work_context {
context.user_provided = Some(work_ctx.clone());
}
if let Some(ref branch_ctx) = self.branch_context {
context.branch.description.clone_from(branch_ctx);
}
Ok(context)
}
fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
println!("🔍 Context Analysis:");
if !context.project.valid_scopes.is_empty() {
println!(
" 📁 Valid scopes: {}",
format_scope_list(&context.project.valid_scopes)
);
}
if context.branch.is_feature_branch {
println!(
" 🌿 Branch: {} ({})",
context.branch.description, context.branch.work_type
);
if let Some(ref ticket) = context.branch.ticket_id {
println!(" 🎫 Ticket: {ticket}");
}
}
if let Some(label) = format_work_pattern(&context.range.work_pattern) {
println!(" {label}");
}
if let Some(label) = super::formatting::format_file_analysis(&context.files) {
println!(" {label}");
}
println!(
" {}",
format_verbosity_level(context.suggested_verbosity())
);
if let Some(ref user_ctx) = context.user_provided {
println!(" 👤 User context: {user_ctx}");
}
println!();
Ok(())
}
fn show_model_info_from_client(
&self,
client: &crate::claude::client::ClaudeClient,
) -> Result<()> {
use crate::claude::model_config::get_model_registry;
println!("🤖 AI Model Configuration:");
let metadata = client.get_ai_client_metadata();
let registry = get_model_registry();
if let Some(spec) = registry.get_model_spec(&metadata.model) {
if metadata.model != spec.api_identifier {
println!(
" 📡 Model: {} → \x1b[33m{}\x1b[0m",
metadata.model, spec.api_identifier
);
} else {
println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
}
println!(" 🏷️ Provider: {}", spec.provider);
println!(" 📊 Generation: {}", spec.generation);
println!(" ⭐ Tier: {} ({})", spec.tier, {
if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
&tier_info.description
} else {
"No description available"
}
});
println!(" 📤 Max output tokens: {}", metadata.max_response_length);
println!(" 📥 Input context: {}", metadata.max_context_length);
if let Some((ref key, ref value)) = metadata.active_beta {
println!(" 🔬 Beta header: {key}: {value}");
}
if spec.legacy {
println!(" ⚠️ Legacy model (consider upgrading to newer version)");
}
} else {
println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
println!(" 🏷️ Provider: {}", metadata.provider);
println!(" ⚠️ Model not found in registry, using client metadata:");
println!(" 📤 Max output tokens: {}", metadata.max_response_length);
println!(" 📥 Input context: {}", metadata.max_context_length);
}
println!();
Ok(())
}
fn show_guidance_files_status(
&self,
project_context: &crate::data::context::ProjectContext,
context_dir: &std::path::Path,
dir_source: &crate::claude::context::ConfigDirSource,
) -> Result<()> {
use crate::claude::context::{config_source_label, ConfigSourceLabel};
println!("📋 Project guidance files status:");
println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
let guidelines_source = if project_context.commit_guidelines.is_some() {
match config_source_label(context_dir, "commit-guidelines.md") {
ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
label => format!("✅ {label}"),
}
} else {
"❌ None found".to_string()
};
println!(" 📝 Commit guidelines: {guidelines_source}");
let scopes_count = project_context.valid_scopes.len();
let scopes_source = if scopes_count > 0 {
match config_source_label(context_dir, "scopes.yaml") {
ConfigSourceLabel::NotFound => {
format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
}
label => format!("✅ {label} ({scopes_count} scopes)"),
}
} else {
"❌ None found".to_string()
};
println!(" 🎯 Valid scopes: {scopes_source}");
println!();
Ok(())
}
async fn execute_no_ai(&self) -> Result<()> {
use crate::data::amendments::{Amendment, AmendmentFile};
println!("📋 Generating amendments YAML without AI processing...");
let repo_view = self.generate_repository_view().await?;
let amendments: Vec<Amendment> = repo_view
.commits
.iter()
.map(|commit| Amendment {
commit: commit.hash.clone(),
message: commit.original_message.clone(),
summary: None,
})
.collect();
let amendment_file = AmendmentFile { amendments };
if let Some(save_path) = &self.save_only {
amendment_file.save_to_file(save_path)?;
println!("💾 Amendments saved to file");
return Ok(());
}
if !amendment_file.amendments.is_empty() {
let temp_dir = tempfile::tempdir()?;
let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
amendment_file.save_to_file(&amendments_file)?;
{
use std::io::IsTerminal;
if !self.auto_apply
&& !self.handle_amendments_file(
&amendments_file,
&amendment_file,
std::io::stdin().is_terminal(),
&mut std::io::BufReader::new(std::io::stdin()),
)?
{
println!("❌ Amendment cancelled by user");
return Ok(());
}
}
self.apply_amendments_from_file(&amendments_file).await?;
println!("✅ Commit messages applied successfully!");
if self.check {
self.run_post_twiddle_check().await?;
}
} else {
println!("✨ No commits found to process!");
}
Ok(())
}
async fn run_post_twiddle_check(&self) -> Result<()> {
const MAX_CHECK_RETRIES: u32 = 3;
let guidelines = self.load_check_guidelines()?;
let valid_scopes = self.load_check_scopes();
let beta = self
.beta_header
.as_deref()
.map(parse_beta_header)
.transpose()?;
let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
for attempt in 0..=MAX_CHECK_RETRIES {
println!();
if attempt == 0 {
println!("🔍 Running commit message validation...");
} else {
println!("🔍 Re-checking commit messages (retry {attempt}/{MAX_CHECK_RETRIES})...");
}
let mut repo_view = self.generate_repository_view().await?;
if repo_view.commits.is_empty() {
println!("⚠️ No commits to check");
return Ok(());
}
println!("📊 Checking {} commits", repo_view.commits.len());
for commit in &mut repo_view.commits {
commit.analysis.refine_scope(&valid_scopes);
}
if attempt == 0 {
self.show_check_guidance_files_status(&guidelines, &valid_scopes);
}
let report = if repo_view.commits.len() > 1 {
println!(
"🔄 Checking {} commits in parallel...",
repo_view.commits.len()
);
self.check_commits_map_reduce(
&claude_client,
&repo_view,
guidelines.as_deref(),
&valid_scopes,
)
.await?
} else {
println!("🤖 Analyzing commits with AI...");
claude_client
.check_commits_with_scopes(
&repo_view,
guidelines.as_deref(),
&valid_scopes,
true,
)
.await?
};
self.output_check_text_report(&report)?;
if !report.has_errors() {
if report.has_warnings() {
println!("ℹ️ Some commit messages have minor warnings");
} else {
println!("✅ All commit messages pass validation");
}
return Ok(());
}
if attempt == MAX_CHECK_RETRIES {
println!(
"⚠️ Some commit messages still have issues after {MAX_CHECK_RETRIES} retries"
);
return Ok(());
}
let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
if amendments.is_empty() {
println!(
"⚠️ Some commit messages have issues but no suggestions available to retry"
);
return Ok(());
}
println!(
"🔄 Applying {} suggested fix(es) and re-checking...",
amendments.len()
);
let amendment_file = AmendmentFile { amendments };
let temp_file = tempfile::NamedTempFile::new()
.context("Failed to create temp file for retry amendments")?;
amendment_file
.save_to_file(temp_file.path())
.context("Failed to save retry amendments")?;
self.apply_amendments_from_file(temp_file.path()).await?;
}
Ok(())
}
fn build_amendments_from_suggestions(
&self,
report: &crate::data::check::CheckReport,
repo_view: &crate::data::RepositoryView,
) -> Vec<crate::data::amendments::Amendment> {
use crate::data::amendments::Amendment;
let candidate_hashes: Vec<String> =
repo_view.commits.iter().map(|c| c.hash.clone()).collect();
report
.commits
.iter()
.filter(|r| !r.passes)
.filter_map(|r| {
let suggestion = r.suggestion.as_ref()?;
let full_hash = super::formatting::resolve_short_hash(&r.hash, &candidate_hashes)?;
Some(Amendment::new(
full_hash.to_string(),
suggestion.message.clone(),
))
})
.collect()
}
fn load_check_guidelines(&self) -> Result<Option<String>> {
let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")
}
fn load_check_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."))
}
fn show_check_guidance_files_status(
&self,
guidelines: &Option<String>,
valid_scopes: &[crate::data::context::ScopeDefinition],
) {
use crate::claude::context::{
config_source_label, resolve_context_dir_with_source, ConfigSourceLabel,
};
let (context_dir, dir_source) =
resolve_context_dir_with_source(self.context_dir.as_deref());
println!("📋 Project guidance files status:");
println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
let guidelines_source = if guidelines.is_some() {
match config_source_label(&context_dir, "commit-guidelines.md") {
ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
label => format!("✅ {label}"),
}
} else {
"⚪ Using defaults".to_string()
};
println!(" 📝 Commit guidelines: {guidelines_source}");
let scopes_count = valid_scopes.len();
let scopes_source = if scopes_count > 0 {
match config_source_label(&context_dir, "scopes.yaml") {
ConfigSourceLabel::NotFound => {
format!("✅ (source unknown) ({scopes_count} scopes)")
}
label => format!("✅ {label} ({scopes_count} scopes)"),
}
} else {
"⚪ None found (any scope accepted)".to_string()
};
println!(" 🎯 Valid scopes: {scopes_source}");
println!();
}
async fn check_commits_map_reduce(
&self,
claude_client: &crate::claude::client::ClaudeClient,
full_repo_view: &crate::data::RepositoryView,
guidelines: Option<&str>,
valid_scopes: &[crate::data::context::ScopeDefinition],
) -> Result<crate::data::check::CheckReport> {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use crate::claude::batch;
use crate::claude::token_budget;
use crate::data::check::{CheckReport, CommitCheckResult};
let total_commits = full_repo_view.commits.len();
let metadata = claude_client.get_ai_client_metadata();
let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
guidelines,
valid_scopes,
);
let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
let batch_plan =
batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
if batch_plan.batches.len() < total_commits {
println!(
" 📦 Grouped {} commits into {} batches by token budget",
total_commits,
batch_plan.batches.len()
);
}
let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
let completed = Arc::new(AtomicUsize::new(0));
let futs: Vec<_> = batch_plan
.batches
.iter()
.map(|batch| {
let sem = semaphore.clone();
let completed = completed.clone();
let batch_indices = &batch.commit_indices;
async move {
let _permit = sem
.acquire()
.await
.map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
let batch_size = batch_indices.len();
let batch_view = if batch_size == 1 {
full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
} else {
let commits: Vec<_> = batch_indices
.iter()
.map(|&i| &full_repo_view.commits[i])
.collect();
full_repo_view.multi_commit_view(&commits)
};
let result = claude_client
.check_commits_with_scopes(&batch_view, guidelines, valid_scopes, true)
.await;
match result {
Ok(report) => {
let done =
completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
println!(" ✅ {done}/{total_commits} commits checked");
let items: Vec<_> = report
.commits
.into_iter()
.map(|r| {
let summary = r.summary.clone().unwrap_or_default();
(r, summary)
})
.collect();
Ok::<_, anyhow::Error>((items, vec![]))
}
Err(e) if batch_size > 1 => {
eprintln!(
"warning: batch of {batch_size} failed, retrying individually: {e}"
);
let mut items = Vec::new();
let mut failed_indices = Vec::new();
for &idx in batch_indices {
let single_view =
full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
let single_result = claude_client
.check_commits_with_scopes(
&single_view,
guidelines,
valid_scopes,
true,
)
.await;
match single_result {
Ok(report) => {
if let Some(r) = report.commits.into_iter().next() {
let summary = r.summary.clone().unwrap_or_default();
items.push((r, summary));
}
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
println!(" ✅ {done}/{total_commits} commits checked");
}
Err(e) => {
eprintln!("warning: failed to check commit: {e}");
failed_indices.push(idx);
println!(" ❌ commit check failed");
}
}
}
Ok((items, failed_indices))
}
Err(e) => {
let idx = batch_indices[0];
eprintln!("warning: failed to check commit: {e}");
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
println!(" ❌ {done}/{total_commits} commits checked (failed)");
Ok((vec![], vec![idx]))
}
}
}
})
.collect();
let results = futures::future::join_all(futs).await;
let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
let mut failed_indices: Vec<usize> = Vec::new();
for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
match result {
Ok((items, failed)) => {
successes.extend(items);
failed_indices.extend(failed);
}
Err(e) => {
eprintln!("warning: batch processing error: {e}");
failed_indices.extend(&batch.commit_indices);
}
}
}
if !failed_indices.is_empty() && !self.quiet {
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
self.run_interactive_retry_twiddle_check(
&mut failed_indices,
full_repo_view,
claude_client,
guidelines,
valid_scopes,
&mut successes,
&mut std::io::BufReader::new(std::io::stdin()),
)
.await?;
} else {
eprintln!(
"warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
failed_indices.len()
);
}
} else if !failed_indices.is_empty() {
eprintln!(
"warning: {} commit(s) failed to check",
failed_indices.len()
);
}
if !failed_indices.is_empty() {
eprintln!(
"warning: {} commit(s) ultimately failed to check",
failed_indices.len()
);
}
if successes.is_empty() {
anyhow::bail!("All commits failed to check");
}
let single_batch = batch_plan.batches.len() <= 1;
if !self.no_coherence && !single_batch && successes.len() >= 2 {
println!("🔗 Running cross-commit coherence pass...");
match claude_client
.refine_checks_coherence(&successes, full_repo_view)
.await
{
Ok(refined) => return Ok(refined),
Err(e) => {
eprintln!("warning: coherence pass failed, using individual results: {e}");
}
}
}
Ok(CheckReport::new(
successes.into_iter().map(|(r, _)| r).collect(),
))
}
#[allow(clippy::too_many_arguments)]
async fn run_interactive_retry_twiddle_check(
&self,
failed_indices: &mut Vec<usize>,
full_repo_view: &crate::data::RepositoryView,
claude_client: &crate::claude::client::ClaudeClient,
guidelines: Option<&str>,
valid_scopes: &[crate::data::context::ScopeDefinition],
successes: &mut Vec<(crate::data::check::CommitCheckResult, String)>,
reader: &mut (dyn std::io::BufRead + Send),
) -> Result<()> {
use std::io::Write as _;
println!("\n⚠️ {} commit(s) failed to check:", failed_indices.len());
for &idx in failed_indices.iter() {
let commit = &full_repo_view.commits[idx];
let subject = commit
.original_message
.lines()
.next()
.unwrap_or("(no message)");
println!(" - {}: {}", &commit.hash[..8], subject);
}
loop {
print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
std::io::stdout().flush()?;
let Some(input) = super::read_interactive_line(reader)? else {
eprintln!("warning: stdin closed, skipping failed commit(s)");
break;
};
match input.trim().to_lowercase().as_str() {
"r" | "retry" | "" => {
let mut still_failed = Vec::new();
for &idx in failed_indices.iter() {
let single_view =
full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
match claude_client
.check_commits_with_scopes(&single_view, guidelines, valid_scopes, true)
.await
{
Ok(report) => {
if let Some(r) = report.commits.into_iter().next() {
let summary = r.summary.clone().unwrap_or_default();
successes.push((r, summary));
}
}
Err(e) => {
eprintln!("warning: still failed: {e}");
still_failed.push(idx);
}
}
}
*failed_indices = still_failed;
if failed_indices.is_empty() {
println!("✅ All retried commits succeeded.");
break;
}
println!("\n⚠️ {} commit(s) still failed:", failed_indices.len());
for &idx in failed_indices.iter() {
let commit = &full_repo_view.commits[idx];
let subject = commit
.original_message
.lines()
.next()
.unwrap_or("(no message)");
println!(" - {}: {}", &commit.hash[..8], subject);
}
}
"s" | "skip" => {
println!("Skipping {} failed commit(s).", failed_indices.len());
break;
}
_ => println!("Please enter 'r' to retry or 's' to skip."),
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_interactive_retry_generate_amendments(
&self,
failed_indices: &mut Vec<usize>,
full_repo_view: &crate::data::RepositoryView,
claude_client: &crate::claude::client::ClaudeClient,
context: Option<&crate::data::context::CommitContext>,
fresh: bool,
successes: &mut Vec<(crate::data::amendments::Amendment, String)>,
is_terminal: bool,
reader: &mut (dyn std::io::BufRead + Send),
) -> Result<()> {
use std::io::Write as _;
println!(
"\n⚠️ {} commit(s) failed to process:",
failed_indices.len()
);
for &idx in failed_indices.iter() {
let commit = &full_repo_view.commits[idx];
let subject = commit
.original_message
.lines()
.next()
.unwrap_or("(no message)");
println!(" - {}: {}", &commit.hash[..8], subject);
}
if !is_terminal {
eprintln!(
"warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
failed_indices.len()
);
return Ok(());
}
loop {
print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
std::io::stdout().flush()?;
let Some(input) = super::read_interactive_line(reader)? else {
eprintln!("warning: stdin closed, skipping failed commit(s)");
break;
};
match input.trim().to_lowercase().as_str() {
"r" | "retry" | "" => {
let mut still_failed = Vec::new();
for &idx in failed_indices.iter() {
let single_view =
full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
let result = if let Some(ctx) = context {
claude_client
.generate_contextual_amendments_with_options(
&single_view,
ctx,
fresh,
)
.await
} else {
claude_client
.generate_amendments_with_options(&single_view, fresh)
.await
};
match result {
Ok(af) => {
if let Some(a) = af.amendments.into_iter().next() {
let summary = a.summary.clone().unwrap_or_default();
successes.push((a, summary));
}
}
Err(e) => {
eprintln!("warning: still failed: {e}");
still_failed.push(idx);
}
}
}
*failed_indices = still_failed;
if failed_indices.is_empty() {
println!("✅ All retried commits succeeded.");
break;
}
println!("\n⚠️ {} commit(s) still failed:", failed_indices.len());
for &idx in failed_indices.iter() {
let commit = &full_repo_view.commits[idx];
let subject = commit
.original_message
.lines()
.next()
.unwrap_or("(no message)");
println!(" - {}: {}", &commit.hash[..8], subject);
}
}
"s" | "skip" => {
println!("Skipping {} failed commit(s).", failed_indices.len());
break;
}
_ => println!("Please enter 'r' to retry or 's' to skip."),
}
}
Ok(())
}
fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
println!();
for result in &report.commits {
if result.passes {
continue;
}
let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
let short_hash = super::formatting::truncate_hash(&result.hash);
println!("{} {} - \"{}\"", icon, short_hash, result.message);
for issue in &result.issues {
let severity_str = super::formatting::format_severity_label(issue.severity);
println!(
" {} [{}] {}",
severity_str, issue.section, issue.explanation
);
}
if let Some(suggestion) = &result.suggestion {
println!();
println!(" Suggested message:");
for line in suggestion.message.lines() {
println!(" {line}");
}
}
println!();
}
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("Summary: {} commits checked", report.summary.total_commits);
println!(
" {} errors, {} warnings",
report.summary.error_count, report.summary.warning_count
);
println!(
" {} passed, {} with issues",
report.summary.passing_commits, report.summary.failing_commits
);
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct TwiddleOutcome {
pub amendments_yaml: String,
pub applied: bool,
pub amendment_count: usize,
}
pub async fn run_twiddle(
range: Option<&str>,
model: Option<String>,
dry_run: bool,
repo_path: Option<&std::path::Path>,
) -> Result<TwiddleOutcome> {
let _cwd_guard = match repo_path {
Some(p) => Some(super::CwdGuard::enter(p).await?),
None => None,
};
crate::utils::check_ai_command_prerequisites(model.as_deref())?;
if !dry_run {
crate::utils::preflight::check_working_directory_clean()?;
}
let claude_client = crate::claude::create_default_claude_client(model, None)?;
run_twiddle_with_client(range, dry_run, &claude_client).await
}
pub(crate) async fn run_twiddle_with_client(
range: Option<&str>,
dry_run: bool,
claude_client: &crate::claude::client::ClaudeClient,
) -> Result<TwiddleOutcome> {
use crate::data::{
AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
WorkingDirectoryInfo,
};
use crate::git::{GitRepository, RemoteInfo};
use crate::utils::ai_scratch;
let resolved_range = range.unwrap_or("HEAD~5..HEAD");
let repo = GitRepository::open()
.context("Failed to open git repository. Make sure you're in a git repository.")?;
let current_branch = repo
.get_current_branch()
.unwrap_or_else(|_| "HEAD".to_string());
let wd_status = repo.get_working_directory_status()?;
let working_directory = WorkingDirectoryInfo {
clean: wd_status.clean,
untracked_changes: wd_status
.untracked_changes
.into_iter()
.map(|fs| FileStatusInfo {
status: fs.status,
file: fs.file,
})
.collect(),
};
let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
let commits = repo.get_commits_in_range(resolved_range)?;
if commits.is_empty() {
let empty_file = AmendmentFile { amendments: vec![] };
let yaml =
crate::data::to_yaml(&empty_file).context("Failed to serialise empty AmendmentFile")?;
return Ok(TwiddleOutcome {
amendments_yaml: yaml,
applied: false,
amendment_count: 0,
});
}
let ai_scratch_path =
ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
let ai_info = AiInfo {
scratch: ai_scratch_path.to_string_lossy().to_string(),
};
let mut repo_view = RepositoryView {
versions: Some(VersionInfo {
omni_dev: env!("CARGO_PKG_VERSION").to_string(),
}),
explanation: FieldExplanation::default(),
working_directory,
remotes,
ai: ai_info,
branch_info: Some(BranchInfo {
branch: current_branch,
}),
pr_template: None,
pr_template_location: None,
branch_prs: None,
commits,
};
repo_view.update_field_presence();
let mut amendments = claude_client
.generate_amendments_with_options(&repo_view, true)
.await?;
let context_dir = crate::claude::context::resolve_context_dir(None);
let scope_defs =
crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."));
refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
let amendments_yaml =
crate::data::to_yaml(&amendments).context("Failed to serialise AmendmentFile")?;
let amendment_count = amendments.amendments.len();
if dry_run || amendment_count == 0 {
return Ok(TwiddleOutcome {
amendments_yaml,
applied: false,
amendment_count,
});
}
let temp_dir = tempfile::tempdir().context("Failed to create temp dir")?;
let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
amendments
.save_to_file(&amendments_file)
.context("Failed to save amendments")?;
let handler =
crate::git::AmendmentHandler::new().context("Failed to initialise amendment handler")?;
handler
.apply_amendments(&amendments_file.to_string_lossy())
.context("Failed to apply amendments")?;
Ok(TwiddleOutcome {
amendments_yaml,
applied: true,
amendment_count,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod run_twiddle_tests {
use super::*;
use crate::claude::client::ClaudeClient;
use crate::claude::test_utils::ConfigurableMockAiClient;
use git2::{Repository, Signature};
#[tokio::test]
async fn run_twiddle_invalid_repo_path_errors_before_ai() {
let err = run_twiddle(
None,
None,
true,
Some(std::path::Path::new("/no/such/path/exists")),
)
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.to_lowercase().contains("set_current_dir")
|| msg.to_lowercase().contains("no such")
|| msg.to_lowercase().contains("directory"),
"expected cwd-related error, got: {msg}"
);
}
fn init_test_repo_with_commit() -> (tempfile::TempDir, String) {
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
std::fs::create_dir_all(&tmp_root).unwrap();
let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
}
let signature = Signature::now("Test", "test@example.com").unwrap();
std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
let mut idx = repo.index().unwrap();
idx.add_path(std::path::Path::new("f.txt")).unwrap();
idx.write().unwrap();
let tree_id = idx.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let oid = repo
.commit(
Some("HEAD"),
&signature,
&signature,
"feat: original",
&tree,
&[],
)
.unwrap();
(temp_dir, oid.to_string())
}
fn amendment_yaml(hash: &str, msg: &str) -> String {
format!("amendments:\n - commit: {hash}\n message: '{msg}'\n")
}
#[tokio::test]
async fn run_twiddle_with_client_dry_run_returns_amendments() {
let (temp_dir, hash) = init_test_repo_with_commit();
let _guard = super::super::CwdGuard::enter(temp_dir.path())
.await
.unwrap();
let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
&hash,
"feat(cli): better subject",
))]);
let client = ClaudeClient::new(Box::new(mock));
let outcome = run_twiddle_with_client(Some("HEAD"), true, &client)
.await
.unwrap();
assert!(!outcome.applied, "dry_run must not apply");
assert_eq!(outcome.amendment_count, 1);
assert!(outcome.amendments_yaml.contains("amendments:"));
}
#[tokio::test]
async fn run_twiddle_with_client_empty_range_returns_empty() {
let (temp_dir, _hash) = init_test_repo_with_commit();
let _guard = super::super::CwdGuard::enter(temp_dir.path())
.await
.unwrap();
let mock = ConfigurableMockAiClient::new(vec![]);
let client = ClaudeClient::new(Box::new(mock));
let outcome = run_twiddle_with_client(Some("HEAD..HEAD"), true, &client)
.await
.unwrap();
assert_eq!(outcome.amendment_count, 0);
assert!(!outcome.applied);
}
#[tokio::test]
async fn run_twiddle_with_client_ai_failure_errors() {
let (temp_dir, _hash) = init_test_repo_with_commit();
let _guard = super::super::CwdGuard::enter(temp_dir.path())
.await
.unwrap();
let mock = ConfigurableMockAiClient::new(vec![]);
let client = ClaudeClient::new(Box::new(mock));
let err = run_twiddle_with_client(Some("HEAD"), true, &client)
.await
.unwrap_err();
let _ = err;
}
#[tokio::test]
async fn run_twiddle_with_client_default_range_errors_on_sparse_repo() {
let (temp_dir, _hash) = init_test_repo_with_commit();
let _guard = super::super::CwdGuard::enter(temp_dir.path())
.await
.unwrap();
let mock = ConfigurableMockAiClient::new(vec![]);
let client = ClaudeClient::new(Box::new(mock));
let err = run_twiddle_with_client(None, true, &client)
.await
.unwrap_err();
assert!(
format!("{err:#}").contains("HEAD~5")
|| format!("{err:#}").to_lowercase().contains("not found"),
"expected HEAD~5 resolution error"
);
}
#[test]
fn twiddle_outcome_clone_and_debug() {
let outcome = TwiddleOutcome {
amendments_yaml: "x".to_string(),
applied: true,
amendment_count: 2,
};
let cloned = outcome.clone();
assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
}
#[tokio::test]
async fn run_twiddle_with_client_applies_head_amendment() {
let (temp_dir, hash) = init_test_repo_with_commit();
let _guard = super::super::CwdGuard::enter(temp_dir.path())
.await
.unwrap();
let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
&hash,
"feat(cli): much better subject",
))]);
let client = ClaudeClient::new(Box::new(mock));
let outcome = run_twiddle_with_client(Some("HEAD"), false, &client)
.await
.unwrap();
assert!(outcome.applied, "dry_run=false must apply amendments");
assert_eq!(outcome.amendment_count, 1);
let repo = git2::Repository::open(temp_dir.path()).unwrap();
let head_msg = repo
.head()
.unwrap()
.peel_to_commit()
.unwrap()
.message()
.unwrap()
.to_string();
assert!(
head_msg.contains("much better subject"),
"HEAD message should be rewritten: {head_msg}"
);
}
}
fn format_work_pattern(pattern: &crate::data::context::WorkPattern) -> Option<&'static str> {
use crate::data::context::WorkPattern;
match pattern {
WorkPattern::Sequential => Some("\u{1f504} Pattern: Sequential development"),
WorkPattern::Refactoring => Some("\u{1f9f9} Pattern: Refactoring work"),
WorkPattern::BugHunt => Some("\u{1f41b} Pattern: Bug investigation"),
WorkPattern::Documentation => Some("\u{1f4d6} Pattern: Documentation updates"),
WorkPattern::Configuration => Some("\u{2699}\u{fe0f} Pattern: Configuration changes"),
WorkPattern::Unknown => None,
}
}
fn format_verbosity_level(level: crate::data::context::VerbosityLevel) -> &'static str {
use crate::data::context::VerbosityLevel;
match level {
VerbosityLevel::Comprehensive => {
"\u{1f4dd} Detail level: Comprehensive (significant changes detected)"
}
VerbosityLevel::Detailed => "\u{1f4dd} Detail level: Detailed",
VerbosityLevel::Concise => "\u{1f4dd} Detail level: Concise",
}
}
fn format_scope_list(scopes: &[crate::data::context::ScopeDefinition]) -> String {
scopes
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
fn refine_amendment_scopes(
amendments: &mut AmendmentFile,
repo_view: &RepositoryView,
scope_defs: &[crate::data::context::ScopeDefinition],
) {
for amendment in &mut amendments.amendments {
if let Some(commit) = repo_view
.commits
.iter()
.find(|c| c.hash == amendment.commit)
{
let files: Vec<&str> = commit
.analysis
.file_changes
.file_list
.iter()
.map(|f| f.file.as_str())
.collect();
amendment.message =
crate::git::refine_message_scope(&amendment.message, &files, scope_defs);
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::data::context::{ScopeDefinition, VerbosityLevel, WorkPattern};
#[test]
fn work_pattern_sequential() {
let result = format_work_pattern(&WorkPattern::Sequential);
assert!(result.is_some());
assert!(result.unwrap().contains("Sequential development"));
}
#[test]
fn work_pattern_refactoring() {
let result = format_work_pattern(&WorkPattern::Refactoring);
assert!(result.is_some());
assert!(result.unwrap().contains("Refactoring work"));
}
#[test]
fn work_pattern_bug_hunt() {
let result = format_work_pattern(&WorkPattern::BugHunt);
assert!(result.is_some());
assert!(result.unwrap().contains("Bug investigation"));
}
#[test]
fn work_pattern_docs() {
let result = format_work_pattern(&WorkPattern::Documentation);
assert!(result.is_some());
assert!(result.unwrap().contains("Documentation updates"));
}
#[test]
fn work_pattern_config() {
let result = format_work_pattern(&WorkPattern::Configuration);
assert!(result.is_some());
assert!(result.unwrap().contains("Configuration changes"));
}
#[test]
fn work_pattern_unknown() {
assert!(format_work_pattern(&WorkPattern::Unknown).is_none());
}
#[test]
fn verbosity_comprehensive() {
let label = format_verbosity_level(VerbosityLevel::Comprehensive);
assert!(label.contains("Comprehensive"));
assert!(label.contains("significant changes"));
}
#[test]
fn verbosity_detailed() {
let label = format_verbosity_level(VerbosityLevel::Detailed);
assert!(label.contains("Detailed"));
}
#[test]
fn verbosity_concise() {
let label = format_verbosity_level(VerbosityLevel::Concise);
assert!(label.contains("Concise"));
}
#[test]
fn scope_list_single() {
let scopes = vec![ScopeDefinition {
name: "cli".to_string(),
description: String::new(),
examples: vec![],
file_patterns: vec![],
}];
assert_eq!(format_scope_list(&scopes), "cli");
}
#[test]
fn scope_list_multiple() {
let scopes = vec![
ScopeDefinition {
name: "cli".to_string(),
description: String::new(),
examples: vec![],
file_patterns: vec![],
},
ScopeDefinition {
name: "git".to_string(),
description: String::new(),
examples: vec![],
file_patterns: vec![],
},
ScopeDefinition {
name: "docs".to_string(),
description: String::new(),
examples: vec![],
file_patterns: vec![],
},
];
assert_eq!(format_scope_list(&scopes), "cli, git, docs");
}
#[test]
fn context_dir_default() {
let result = crate::claude::context::resolve_context_dir(None);
assert!(
result.ends_with(".omni-dev"),
"expected path ending in .omni-dev, got {result:?}"
);
}
#[test]
fn context_dir_override() {
let custom = std::path::PathBuf::from("custom-dir");
let result = crate::claude::context::resolve_context_dir(Some(&custom));
assert_eq!(result, custom);
}
fn parse_twiddle(args: &[&str]) -> TwiddleCommand {
let mut full_args = vec!["twiddle"];
full_args.extend_from_slice(args);
TwiddleCommand::try_parse_from(full_args).unwrap()
}
#[test]
fn default_is_fresh() {
let cmd = parse_twiddle(&[]);
assert!(cmd.is_fresh(), "default should be fresh mode");
}
#[test]
fn refine_disables_fresh() {
let cmd = parse_twiddle(&["--refine"]);
assert!(!cmd.is_fresh(), "--refine should disable fresh mode");
}
#[test]
fn explicit_fresh_is_fresh() {
let cmd = parse_twiddle(&["--fresh"]);
assert!(cmd.is_fresh(), "--fresh should be fresh mode");
}
#[test]
fn fresh_and_refine_conflict() {
let result = TwiddleCommand::try_parse_from(["twiddle", "--fresh", "--refine"]);
assert!(result.is_err(), "--fresh and --refine should conflict");
}
fn make_twiddle_cmd() -> TwiddleCommand {
TwiddleCommand {
commit_range: None,
model: None,
beta_header: None,
auto_apply: false,
save_only: None,
use_context: false,
context_dir: None,
work_context: None,
branch_context: None,
no_context: true,
concurrency: 4,
batch_size: None,
no_coherence: true,
no_ai: false,
fresh: false,
refine: false,
check: false,
quiet: false,
}
}
fn make_twiddle_commit(hash: &str) -> (crate::git::CommitInfo, tempfile::NamedTempFile) {
use crate::git::commit::FileChanges;
use crate::git::{CommitAnalysis, CommitInfo};
let tmp = tempfile::NamedTempFile::new().unwrap();
let commit = CommitInfo {
hash: hash.to_string(),
author: "Test <test@test.com>".to_string(),
date: chrono::Utc::now().fixed_offset(),
original_message: format!("feat: commit {hash}"),
in_main_branches: vec![],
analysis: CommitAnalysis {
detected_type: "feat".to_string(),
detected_scope: String::new(),
proposed_message: format!("feat: commit {hash}"),
file_changes: FileChanges {
total_files: 0,
files_added: 0,
files_deleted: 0,
file_list: vec![],
},
diff_summary: String::new(),
diff_file: tmp.path().to_string_lossy().to_string(),
file_diffs: Vec::new(),
},
};
(commit, tmp)
}
fn make_twiddle_repo_view(commits: Vec<crate::git::CommitInfo>) -> crate::data::RepositoryView {
use crate::data::{AiInfo, FieldExplanation, RepositoryView, WorkingDirectoryInfo};
RepositoryView {
versions: None,
explanation: FieldExplanation::default(),
working_directory: WorkingDirectoryInfo {
clean: true,
untracked_changes: vec![],
},
remotes: vec![],
ai: AiInfo {
scratch: String::new(),
},
branch_info: None,
pr_template: None,
pr_template_location: None,
branch_prs: None,
commits,
}
}
fn twiddle_check_yaml(hash: &str) -> String {
format!("checks:\n - commit: {hash}\n passes: true\n issues: []\n")
}
fn make_mock_client(
responses: Vec<anyhow::Result<String>>,
) -> crate::claude::client::ClaudeClient {
crate::claude::client::ClaudeClient::new(Box::new(
crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
))
}
#[tokio::test]
async fn check_commits_map_reduce_single_commit_succeeds() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
let result = cmd
.check_commits_map_reduce(&client, &repo_view, None, &[])
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap().commits.len(), 1);
}
#[tokio::test]
async fn check_commits_map_reduce_batch_fails_split_retry_both_succeed() {
let (c1, _t1) = make_twiddle_commit("abc00000");
let (c2, _t2) = make_twiddle_commit("def00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![c1, c2]);
let mut responses: Vec<anyhow::Result<String>> =
(0..3).map(|_| Err(anyhow::anyhow!("batch fail"))).collect();
responses.push(Ok(twiddle_check_yaml("abc00000")));
responses.push(Ok(twiddle_check_yaml("def00000")));
let client = make_mock_client(responses);
let result = cmd
.check_commits_map_reduce(&client, &repo_view, None, &[])
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap().commits.len(), 2);
}
#[tokio::test]
async fn interactive_retry_twiddle_skip_immediately() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![]);
let mut failed = vec![0usize];
let mut successes = vec![];
let mut stdin = std::io::Cursor::new(b"s\n" as &[u8]);
cmd.run_interactive_retry_twiddle_check(
&mut failed,
&repo_view,
&client,
None,
&[],
&mut successes,
&mut stdin,
)
.await
.unwrap();
assert_eq!(
failed,
vec![0],
"skip should leave failed_indices unchanged"
);
assert!(successes.is_empty());
}
#[tokio::test]
async fn interactive_retry_twiddle_retry_succeeds() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
let mut failed = vec![0usize];
let mut successes = vec![];
let mut stdin = std::io::Cursor::new(b"r\n" as &[u8]);
cmd.run_interactive_retry_twiddle_check(
&mut failed,
&repo_view,
&client,
None,
&[],
&mut successes,
&mut stdin,
)
.await
.unwrap();
assert!(
failed.is_empty(),
"retry succeeded → failed_indices cleared"
);
assert_eq!(successes.len(), 1);
}
#[tokio::test]
async fn interactive_retry_twiddle_default_input_retries() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
let mut failed = vec![0usize];
let mut successes = vec![];
let mut stdin = std::io::Cursor::new(b"\n" as &[u8]);
cmd.run_interactive_retry_twiddle_check(
&mut failed,
&repo_view,
&client,
None,
&[],
&mut successes,
&mut stdin,
)
.await
.unwrap();
assert!(failed.is_empty());
assert_eq!(successes.len(), 1);
}
#[tokio::test]
async fn interactive_retry_twiddle_still_fails_then_skip() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let responses = (0..3).map(|_| Err(anyhow::anyhow!("mock fail"))).collect();
let client = make_mock_client(responses);
let mut failed = vec![0usize];
let mut successes = vec![];
let mut stdin = std::io::Cursor::new(b"r\ns\n" as &[u8]);
cmd.run_interactive_retry_twiddle_check(
&mut failed,
&repo_view,
&client,
None,
&[],
&mut successes,
&mut stdin,
)
.await
.unwrap();
assert_eq!(failed, vec![0], "commit still failed after retry");
assert!(successes.is_empty());
}
#[tokio::test]
async fn interactive_retry_twiddle_invalid_input_then_skip() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![]);
let mut failed = vec![0usize];
let mut successes = vec![];
let mut stdin = std::io::Cursor::new(b"x\ns\n" as &[u8]);
cmd.run_interactive_retry_twiddle_check(
&mut failed,
&repo_view,
&client,
None,
&[],
&mut successes,
&mut stdin,
)
.await
.unwrap();
assert_eq!(failed, vec![0]);
assert!(successes.is_empty());
}
#[tokio::test]
async fn interactive_retry_twiddle_eof_breaks_immediately() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![]); let mut failed = vec![0usize];
let mut successes = vec![];
let mut stdin = std::io::Cursor::new(b"" as &[u8]);
cmd.run_interactive_retry_twiddle_check(
&mut failed,
&repo_view,
&client,
None,
&[],
&mut successes,
&mut stdin,
)
.await
.unwrap();
assert_eq!(failed, vec![0], "EOF should leave failed_indices unchanged");
assert!(successes.is_empty());
}
fn make_amendment_file() -> crate::data::amendments::AmendmentFile {
crate::data::amendments::AmendmentFile {
amendments: vec![crate::data::amendments::Amendment {
commit: "abc0000000000000000000000000000000000001".to_string(),
message: "feat: improved commit message".to_string(),
summary: None,
}],
}
}
#[test]
fn handle_amendments_file_non_terminal_returns_false() {
let cmd = make_twiddle_cmd();
let amendments = make_amendment_file();
let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
let mut reader = std::io::Cursor::new(b"" as &[u8]);
let result = cmd
.handle_amendments_file(dummy_path, &amendments, false, &mut reader)
.unwrap();
assert!(!result, "non-terminal should return false");
}
#[test]
fn handle_amendments_file_eof_returns_false() {
let cmd = make_twiddle_cmd();
let amendments = make_amendment_file();
let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
let mut reader = std::io::Cursor::new(b"" as &[u8]);
let result = cmd
.handle_amendments_file(dummy_path, &amendments, true, &mut reader)
.unwrap();
assert!(!result, "EOF should return false");
}
#[test]
fn handle_amendments_file_quit_returns_false() {
let cmd = make_twiddle_cmd();
let amendments = make_amendment_file();
let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
let mut reader = std::io::Cursor::new(b"q\n" as &[u8]);
let result = cmd
.handle_amendments_file(dummy_path, &amendments, true, &mut reader)
.unwrap();
assert!(!result, "quit should return false");
}
#[test]
fn handle_amendments_file_apply_returns_true() {
let cmd = make_twiddle_cmd();
let amendments = make_amendment_file();
let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
let mut reader = std::io::Cursor::new(b"a\n" as &[u8]);
let result = cmd
.handle_amendments_file(dummy_path, &amendments, true, &mut reader)
.unwrap();
assert!(result, "apply should return true");
}
#[test]
fn handle_amendments_file_invalid_then_quit_returns_false() {
let cmd = make_twiddle_cmd();
let amendments = make_amendment_file();
let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
let mut reader = std::io::Cursor::new(b"x\nq\n" as &[u8]);
let result = cmd
.handle_amendments_file(dummy_path, &amendments, true, &mut reader)
.unwrap();
assert!(!result, "invalid then quit should return false");
}
const HASH_40: &str = "abc0000000000000000000000000000000000000";
fn twiddle_amendment_yaml(hash: &str) -> String {
format!("amendments:\n - commit: \"{hash}\"\n message: \"feat: improved message\"\n")
}
#[tokio::test]
async fn retry_generate_amendments_non_terminal_returns_immediately() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![]); let mut failed = vec![0usize];
let mut successes = vec![];
let mut reader = std::io::Cursor::new(b"" as &[u8]);
cmd.run_interactive_retry_generate_amendments(
&mut failed,
&repo_view,
&client,
None,
false,
&mut successes,
false, &mut reader,
)
.await
.unwrap();
assert_eq!(
failed,
vec![0],
"non-terminal should leave failed unchanged"
);
assert!(successes.is_empty());
}
#[tokio::test]
async fn retry_generate_amendments_eof_breaks_immediately() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![]); let mut failed = vec![0usize];
let mut successes = vec![];
let mut reader = std::io::Cursor::new(b"" as &[u8]);
cmd.run_interactive_retry_generate_amendments(
&mut failed,
&repo_view,
&client,
None,
false,
&mut successes,
true, &mut reader,
)
.await
.unwrap();
assert_eq!(failed, vec![0], "EOF should leave failed unchanged");
assert!(successes.is_empty());
}
#[tokio::test]
async fn retry_generate_amendments_skip_breaks_immediately() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![]); let mut failed = vec![0usize];
let mut successes = vec![];
let mut reader = std::io::Cursor::new(b"s\n" as &[u8]);
cmd.run_interactive_retry_generate_amendments(
&mut failed,
&repo_view,
&client,
None,
false,
&mut successes,
true,
&mut reader,
)
.await
.unwrap();
assert_eq!(failed, vec![0], "skip should leave failed unchanged");
assert!(successes.is_empty());
}
#[tokio::test]
async fn retry_generate_amendments_invalid_then_skip() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![]);
let mut failed = vec![0usize];
let mut successes = vec![];
let mut reader = std::io::Cursor::new(b"x\ns\n" as &[u8]);
cmd.run_interactive_retry_generate_amendments(
&mut failed,
&repo_view,
&client,
None,
false,
&mut successes,
true,
&mut reader,
)
.await
.unwrap();
assert_eq!(failed, vec![0]);
assert!(successes.is_empty());
}
#[tokio::test]
async fn retry_generate_amendments_retry_fails_then_skip() {
let (commit, _tmp) = make_twiddle_commit("abc00000");
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![Err(anyhow::anyhow!("mock fail"))]);
let mut failed = vec![0usize];
let mut successes = vec![];
let mut reader = std::io::Cursor::new(b"r\ns\n" as &[u8]);
cmd.run_interactive_retry_generate_amendments(
&mut failed,
&repo_view,
&client,
None,
false,
&mut successes,
true,
&mut reader,
)
.await
.unwrap();
assert_eq!(failed, vec![0], "commit still failed after retry");
assert!(successes.is_empty());
}
#[tokio::test]
async fn retry_generate_amendments_retry_succeeds() {
let (commit, _tmp) = make_twiddle_commit(HASH_40);
let cmd = make_twiddle_cmd();
let repo_view = make_twiddle_repo_view(vec![commit]);
let client = make_mock_client(vec![Ok(twiddle_amendment_yaml(HASH_40))]);
let mut failed = vec![0usize];
let mut successes = vec![];
let mut reader = std::io::Cursor::new(b"r\n" as &[u8]);
cmd.run_interactive_retry_generate_amendments(
&mut failed,
&repo_view,
&client,
None,
false,
&mut successes,
true,
&mut reader,
)
.await
.unwrap();
assert!(failed.is_empty(), "retry succeeded → failed cleared");
assert_eq!(successes.len(), 1);
}
#[test]
fn refine_amendment_scopes_replaces_scope_from_file_patterns() {
use crate::data::amendments::Amendment;
use crate::data::context::ScopeDefinition;
use crate::git::commit::FileChange;
let (mut commit, _tmp) = make_twiddle_commit("aaa00000");
commit.analysis.file_changes.file_list = vec![FileChange {
status: "M".to_string(),
file: "src/cli/git/twiddle.rs".to_string(),
}];
let repo_view = make_twiddle_repo_view(vec![commit]);
let scope_defs = vec![ScopeDefinition {
name: "cli".to_string(),
description: "CLI commands".to_string(),
examples: vec![],
file_patterns: vec!["src/cli/**".to_string()],
}];
let mut amendments = AmendmentFile {
amendments: vec![Amendment {
commit: "aaa00000".to_string(),
message: "fix(wrong-scope): tweak something".to_string(),
summary: None,
}],
};
refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
assert_eq!(
amendments.amendments[0].message,
"fix(cli): tweak something",
);
}
#[test]
fn refine_amendment_scopes_no_match_leaves_message_unchanged() {
use crate::data::amendments::Amendment;
let (commit, _tmp) = make_twiddle_commit("bbb00000");
let repo_view = make_twiddle_repo_view(vec![commit]);
let mut amendments = AmendmentFile {
amendments: vec![Amendment {
commit: "bbb00000".to_string(),
message: "feat(stuff): add feature".to_string(),
summary: None,
}],
};
refine_amendment_scopes(&mut amendments, &repo_view, &[]);
assert_eq!(amendments.amendments[0].message, "feat(stuff): add feature",);
}
#[test]
fn refine_amendment_scopes_skips_unknown_commits() {
use crate::data::amendments::Amendment;
use crate::data::context::ScopeDefinition;
let (commit, _tmp) = make_twiddle_commit("ccc00000");
let repo_view = make_twiddle_repo_view(vec![commit]);
let scope_defs = vec![ScopeDefinition {
name: "cli".to_string(),
description: "CLI".to_string(),
examples: vec![],
file_patterns: vec!["src/cli/**".to_string()],
}];
let mut amendments = AmendmentFile {
amendments: vec![Amendment {
commit: "unknown_hash".to_string(),
message: "fix(wrong): something".to_string(),
summary: None,
}],
};
refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
assert_eq!(amendments.amendments[0].message, "fix(wrong): something",);
}
}