Skip to main content

omni_dev/cli/git/
twiddle.rs

1//! Twiddle command — AI-powered commit message improvement.
2
3use anyhow::{Context, Result};
4use clap::Parser;
5use tracing::debug;
6
7use crate::data::amendments::AmendmentFile;
8use crate::data::RepositoryView;
9
10/// Twiddle command options.
11#[derive(Parser)]
12pub struct TwiddleCommand {
13    /// Commit range to analyze and improve (e.g., HEAD~3..HEAD, abc123..def456).
14    #[arg(value_name = "COMMIT_RANGE")]
15    pub commit_range: Option<String>,
16
17    /// Skips confirmation prompt and applies amendments automatically.
18    #[arg(long)]
19    pub auto_apply: bool,
20
21    /// Allows amending commits that already exist in remote main branches (rewrites published history).
22    #[arg(long)]
23    pub allow_pushed: bool,
24
25    /// Saves generated amendments to file without applying.
26    #[arg(long, value_name = "FILE")]
27    pub save_only: Option<String>,
28
29    /// Uses additional project context for better suggestions (Phase 3).
30    #[arg(long, default_value = "true")]
31    pub use_context: bool,
32
33    /// Path to custom context directory (defaults to .omni-dev/).
34    #[arg(long)]
35    pub context_dir: Option<std::path::PathBuf>,
36
37    /// Specifies work context (e.g., "feature: user authentication").
38    #[arg(long)]
39    pub work_context: Option<String>,
40
41    /// Overrides detected branch context.
42    #[arg(long)]
43    pub branch_context: Option<String>,
44
45    /// Disables contextual analysis (uses basic prompting only).
46    #[arg(long)]
47    pub no_context: bool,
48
49    /// Maximum number of concurrent AI requests (default: 4).
50    #[arg(long, default_value = "4")]
51    pub concurrency: usize,
52
53    /// Deprecated: use --concurrency instead.
54    #[arg(long, hide = true)]
55    pub batch_size: Option<usize>,
56
57    /// Disables the cross-commit coherence pass.
58    #[arg(long)]
59    pub no_coherence: bool,
60
61    /// Skips AI processing and amends commits to a deterministic suggestion
62    /// (detected type and scope, corrected against scopes.yaml).
63    #[arg(long)]
64    pub no_ai: bool,
65
66    /// Ignores existing commit messages and generates fresh ones based solely on diffs.
67    /// This is the default behavior.
68    #[arg(long, conflicts_with = "refine")]
69    pub fresh: bool,
70
71    /// Uses existing commit messages as a starting point for AI refinement
72    /// instead of generating fresh messages from scratch.
73    #[arg(long, conflicts_with = "fresh")]
74    pub refine: bool,
75
76    /// Runs commit message validation after applying amendments.
77    #[arg(long)]
78    pub check: bool,
79
80    /// Only shows errors/warnings, suppresses info-level output.
81    #[arg(long)]
82    pub quiet: bool,
83
84    /// AI backend selection (`--ai-backend`, `--model`, …).
85    #[command(flatten)]
86    pub ai: crate::cli::ai_backend_args::AiBackendArgs,
87}
88
89impl TwiddleCommand {
90    /// Returns true when existing messages should be hidden from the AI.
91    /// Fresh is the default; `--refine` overrides it.
92    fn is_fresh(&self) -> bool {
93        !self.refine
94    }
95
96    /// Executes the twiddle command with contextual intelligence.
97    pub async fn execute(mut self, repo: Option<&std::path::Path>) -> Result<()> {
98        self.ai.apply();
99
100        // Resolve deprecated --batch-size into --concurrency
101        if let Some(bs) = self.batch_size {
102            eprintln!("warning: --batch-size is deprecated; use --concurrency instead");
103            self.concurrency = bs;
104        }
105
106        // Resolve the repo root once; every git, config, and scratch read below
107        // anchors to it (the CWD is the default when no path is injected). Resolve
108        // the default to an absolute path via `current_dir` — matching the sibling
109        // commands — so walk-ups from a subdirectory work and echoed paths are
110        // absolute.
111        let repo_root = match repo {
112            Some(p) => p.to_path_buf(),
113            None => std::env::current_dir().context("Failed to determine current directory")?,
114        };
115        let repo_root = repo_root.as_path();
116
117        // If --no-ai flag is set, skip AI processing and amend deterministically
118        if self.no_ai {
119            return self.execute_no_ai(repo_root).await;
120        }
121
122        // Preflight check: validate AI credentials before any processing.
123        // Model/beta-header selection uses this command's `--model`/
124        // `--beta-header` flags (propagated as OMNI_DEV_MODEL/
125        // OMNI_DEV_BETA_HEADER by `self.ai.apply()`) and the per-backend env
126        // chain.
127        let ai_info = crate::utils::check_ai_command_prerequisites(None, repo_root)?;
128        println!(
129            "✓ {} credentials verified (model: {})",
130            ai_info.provider, ai_info.model
131        );
132
133        // Preflight check: ensure working directory is clean before expensive operations
134        crate::utils::check_working_directory_clean_at(repo_root)?;
135        println!("✓ Working directory is clean");
136
137        // Initialize Claude client
138        let claude_client = crate::claude::create_default_claude_client(None, None).await?;
139
140        self.execute_with_client(repo_root, claude_client).await
141    }
142
143    /// Test-injectable inner core of [`Self::execute`].
144    ///
145    /// Caller is responsible for any preflight (AI credentials, clean
146    /// working directory) and for resolving the deprecated `--batch-size`
147    /// alias into `--concurrency`. The `--no-ai` branch is handled by the
148    /// outer [`Self::execute`] before this is reached.
149    pub(crate) async fn execute_with_client(
150        self,
151        repo_root: &std::path::Path,
152        claude_client: crate::claude::client::ClaudeClient,
153    ) -> Result<()> {
154        // Determine if contextual analysis should be used
155        let use_contextual = self.use_context && !self.no_context;
156
157        if use_contextual {
158            println!(
159                "🪄 Starting AI-powered commit message improvement with contextual intelligence..."
160            );
161        } else {
162            println!("🪄 Starting AI-powered commit message improvement...");
163        }
164
165        // 1. Generate repository view to get all commits
166        let mut full_repo_view = self.generate_repository_view(repo_root)?;
167
168        // 2. Use parallel map-reduce for multiple commits
169        if full_repo_view.commits.len() > 1 {
170            return self
171                .execute_with_map_reduce(repo_root, use_contextual, full_repo_view, claude_client)
172                .await;
173        }
174
175        // 3. Collect contextual information (Phase 3)
176        let context = if use_contextual {
177            Some(self.collect_context(repo_root, &full_repo_view)?)
178        } else {
179            None
180        };
181
182        // Refine detected scopes using file_patterns from scope definitions
183        let scope_defs = match &context {
184            Some(ctx) => ctx.project.valid_scopes.clone(),
185            None => self.load_check_scopes(repo_root),
186        };
187        for commit in &mut full_repo_view.commits {
188            commit.analysis.refine_scope(&scope_defs);
189        }
190
191        // 4. Show context summary if available
192        if let Some(ref ctx) = context {
193            self.show_context_summary(ctx)?;
194        }
195
196        // Show model information
197        self.show_model_info_from_client(&claude_client)?;
198
199        // 6. Generate amendments via Claude API with context
200        if self.refine {
201            println!("🔄 Refine mode: using existing commit messages as starting point...");
202        }
203        if use_contextual && context.is_some() {
204            println!("🤖 Analyzing commits with enhanced contextual intelligence...");
205        } else {
206            println!("🤖 Analyzing commits with Claude AI...");
207        }
208
209        let mut amendments = if let Some(ctx) = context {
210            claude_client
211                .generate_contextual_amendments_with_options(&full_repo_view, &ctx, self.is_fresh())
212                .await?
213        } else {
214            claude_client
215                .generate_amendments_with_options(&full_repo_view, self.is_fresh())
216                .await?
217        };
218
219        refine_amendment_scopes(&mut amendments, &full_repo_view, &scope_defs);
220        {
221            use std::io::IsTerminal;
222            resolve_duplicate_amendments(
223                &mut amendments,
224                self.auto_apply,
225                std::io::stdin().is_terminal(),
226                &mut std::io::BufReader::new(std::io::stdin()),
227            )?;
228        }
229
230        // 6. Handle different output modes
231        if let Some(save_path) = self.save_only {
232            amendments.save_to_file(save_path)?;
233            println!("💾 Amendments saved to file");
234            return Ok(());
235        }
236
237        // 7. Handle amendments
238        if !amendments.amendments.is_empty() {
239            // Create temporary file for amendments
240            let temp_dir = tempfile::tempdir()?;
241            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
242            amendments.save_to_file(&amendments_file)?;
243
244            // Show file path and get user choice
245            {
246                use std::io::IsTerminal;
247                if !self.auto_apply
248                    && !self.handle_amendments_file(
249                        &amendments_file,
250                        &amendments,
251                        std::io::stdin().is_terminal(),
252                        &mut std::io::BufReader::new(std::io::stdin()),
253                    )?
254                {
255                    println!("❌ Amendment cancelled by user");
256                    return Ok(());
257                }
258            }
259
260            // 8. Apply amendments (re-read from file to capture any user edits)
261            self.apply_amendments_from_file(repo_root, &amendments_file)?;
262            println!("✅ Commit messages improved successfully!");
263
264            // 9. Run post-twiddle check if --check flag is set
265            if self.check {
266                self.run_post_twiddle_check(repo_root).await?;
267            }
268        } else {
269            println!("✨ No commits found to process!");
270        }
271
272        Ok(())
273    }
274
275    /// Executes the twiddle command with batched parallel map-reduce for multiple commits.
276    ///
277    /// Commits are grouped into token-budget-aware batches (map phase),
278    /// then an optional coherence pass refines results across all commits
279    /// (reduce phase). Coherence is skipped when all commits fit in a
280    /// single batch since the AI already saw them together.
281    async fn execute_with_map_reduce(
282        &self,
283        repo_root: &std::path::Path,
284        use_contextual: bool,
285        mut full_repo_view: crate::data::RepositoryView,
286        claude_client: crate::claude::client::ClaudeClient,
287    ) -> Result<()> {
288        use std::sync::atomic::{AtomicUsize, Ordering};
289        use std::sync::Arc;
290
291        use crate::claude::batch;
292        use crate::claude::token_budget;
293
294        let concurrency = self.concurrency;
295
296        // Show model information
297        self.show_model_info_from_client(&claude_client)?;
298
299        if self.refine {
300            println!("🔄 Refine mode: using existing commit messages as starting point...");
301        }
302
303        let total_commits = full_repo_view.commits.len();
304        println!(
305            "🔄 Processing {total_commits} commits in parallel (concurrency: {concurrency})..."
306        );
307
308        // Collect context once (shared across all commits)
309        let context = if use_contextual {
310            Some(self.collect_context(repo_root, &full_repo_view)?)
311        } else {
312            None
313        };
314
315        if let Some(ref ctx) = context {
316            self.show_context_summary(ctx)?;
317        }
318
319        // Refine scopes on all commits upfront
320        let scope_defs = match &context {
321            Some(ctx) => ctx.project.valid_scopes.clone(),
322            None => self.load_check_scopes(repo_root),
323        };
324        for commit in &mut full_repo_view.commits {
325            commit.analysis.refine_scope(&scope_defs);
326        }
327
328        // Plan batches based on token budget
329        let metadata = claude_client.get_ai_client_metadata();
330        let system_prompt_tokens = if let Some(ref ctx) = context {
331            let prompt_style = metadata.prompt_style();
332            let system_prompt =
333                crate::claude::prompts::generate_contextual_system_prompt_for_provider(
334                    ctx,
335                    prompt_style,
336                );
337            token_budget::estimate_tokens(&system_prompt)
338        } else {
339            token_budget::estimate_tokens(crate::claude::prompts::SYSTEM_PROMPT)
340        };
341        let batch_plan =
342            batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
343
344        if batch_plan.batches.len() < total_commits {
345            println!(
346                "   📦 Grouped {} commits into {} batches by token budget",
347                total_commits,
348                batch_plan.batches.len()
349            );
350        }
351
352        // Map phase: process batches in parallel
353        let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
354        let completed = Arc::new(AtomicUsize::new(0));
355
356        let repo_ref = &full_repo_view;
357        let client_ref = &claude_client;
358        let context_ref = &context;
359        let fresh = self.is_fresh();
360
361        let futs: Vec<_> = batch_plan
362            .batches
363            .iter()
364            .map(|batch| {
365                let sem = semaphore.clone();
366                let completed = completed.clone();
367                let batch_indices = &batch.commit_indices;
368
369                async move {
370                    let _permit = sem
371                        .acquire()
372                        .await
373                        .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
374
375                    let batch_size = batch_indices.len();
376
377                    // Create view for this batch
378                    let batch_view = if batch_size == 1 {
379                        repo_ref.single_commit_view(&repo_ref.commits[batch_indices[0]])
380                    } else {
381                        let commits: Vec<_> = batch_indices
382                            .iter()
383                            .map(|&i| &repo_ref.commits[i])
384                            .collect();
385                        repo_ref.multi_commit_view(&commits)
386                    };
387
388                    // Generate amendments for the batch
389                    let result = if let Some(ref ctx) = context_ref {
390                        client_ref
391                            .generate_contextual_amendments_with_options(&batch_view, ctx, fresh)
392                            .await
393                    } else {
394                        client_ref
395                            .generate_amendments_with_options(&batch_view, fresh)
396                            .await
397                    };
398
399                    match result {
400                        Ok(amendment_file) => {
401                            let done =
402                                completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
403                            println!("   ✅ {done}/{total_commits} commits processed");
404
405                            let items: Vec<_> = amendment_file
406                                .amendments
407                                .into_iter()
408                                .map(|a| {
409                                    let summary = a.summary.clone();
410                                    (a, summary)
411                                })
412                                .collect();
413                            Ok::<_, anyhow::Error>((items, vec![]))
414                        }
415                        Err(e) if batch_size > 1 => {
416                            // Split-and-retry: fall back to individual commits
417                            eprintln!(
418                                "warning: batch of {batch_size} failed, retrying individually: {e}"
419                            );
420                            let mut items = Vec::new();
421                            let mut failed_indices = Vec::new();
422                            for &idx in batch_indices {
423                                let single_view =
424                                    repo_ref.single_commit_view(&repo_ref.commits[idx]);
425                                let single_result = if let Some(ref ctx) = context_ref {
426                                    client_ref
427                                        .generate_contextual_amendments_with_options(
428                                            &single_view,
429                                            ctx,
430                                            fresh,
431                                        )
432                                        .await
433                                } else {
434                                    client_ref
435                                        .generate_amendments_with_options(&single_view, fresh)
436                                        .await
437                                };
438                                match single_result {
439                                    Ok(af) => {
440                                        if let Some(a) = af.amendments.into_iter().next() {
441                                            let summary = a.summary.clone();
442                                            items.push((a, summary));
443                                        }
444                                        let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
445                                        println!("   ✅ {done}/{total_commits} commits processed");
446                                    }
447                                    Err(e) => {
448                                        eprintln!("warning: failed to process commit: {e}");
449                                        // Print the full error chain for debugging using anyhow's chain()
450                                        for (i, cause) in e.chain().skip(1).enumerate() {
451                                            eprintln!("  caused by [{i}]: {cause}");
452                                        }
453                                        failed_indices.push(idx);
454                                        println!("   ❌ commit processing failed");
455                                    }
456                                }
457                            }
458                            Ok((items, failed_indices))
459                        }
460                        Err(e) => {
461                            // Single-commit batch failed; record the index so the user can retry
462                            let idx = batch_indices[0];
463                            eprintln!("warning: failed to process commit: {e}");
464                            // Print the full error chain for debugging using anyhow's chain()
465                            for (i, cause) in e.chain().skip(1).enumerate() {
466                                eprintln!("  caused by [{i}]: {cause}");
467                            }
468                            let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
469                            println!("   ❌ {done}/{total_commits} commits processed (failed)");
470                            Ok((vec![], vec![idx]))
471                        }
472                    }
473                }
474            })
475            .collect();
476
477        let results = futures::future::join_all(futs).await;
478
479        // Flatten batch results
480        let mut successes: Vec<(crate::data::amendments::Amendment, String)> = Vec::new();
481        let mut failed_indices: Vec<usize> = Vec::new();
482
483        for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
484            match result {
485                Ok((items, failed)) => {
486                    successes.extend(items);
487                    failed_indices.extend(failed);
488                }
489                Err(e) => {
490                    eprintln!("warning: batch processing error: {e}");
491                    failed_indices.extend(&batch.commit_indices);
492                }
493            }
494        }
495
496        // Offer interactive retry for commits that failed
497        if !failed_indices.is_empty() && !self.quiet {
498            use std::io::IsTerminal;
499            self.run_interactive_retry_generate_amendments(
500                &mut failed_indices,
501                &full_repo_view,
502                &claude_client,
503                context.as_ref(),
504                fresh,
505                &mut successes,
506                std::io::stdin().is_terminal(),
507                &mut std::io::BufReader::new(std::io::stdin()),
508            )
509            .await?;
510        } else if !failed_indices.is_empty() {
511            eprintln!(
512                "warning: {} commit(s) failed to process",
513                failed_indices.len()
514            );
515        }
516
517        if !failed_indices.is_empty() {
518            eprintln!(
519                "warning: {} commit(s) ultimately failed to process",
520                failed_indices.len()
521            );
522        }
523
524        if successes.is_empty() {
525            anyhow::bail!("All commits failed to process");
526        }
527
528        // Reduce phase: optional coherence pass
529        // Skip when all commits were in a single batch (AI already saw them together)
530        let single_batch = batch_plan.batches.len() <= 1;
531        let mut all_amendments = if !self.no_coherence && !single_batch && successes.len() >= 2 {
532            println!("🔗 Running cross-commit coherence pass...");
533            match claude_client.refine_amendments_coherence(&successes).await {
534                Ok(refined) => refined,
535                Err(e) => {
536                    eprintln!("warning: coherence pass failed, using individual results: {e}");
537                    AmendmentFile {
538                        amendments: successes.into_iter().map(|(a, _)| a).collect(),
539                    }
540                }
541            }
542        } else {
543            AmendmentFile {
544                amendments: successes.into_iter().map(|(a, _)| a).collect(),
545            }
546        };
547
548        refine_amendment_scopes(&mut all_amendments, &full_repo_view, &scope_defs);
549        {
550            use std::io::IsTerminal;
551            resolve_duplicate_amendments(
552                &mut all_amendments,
553                self.auto_apply,
554                std::io::stdin().is_terminal(),
555                &mut std::io::BufReader::new(std::io::stdin()),
556            )?;
557        }
558
559        println!(
560            "✅ All commits processed! Found {} amendments.",
561            all_amendments.amendments.len()
562        );
563
564        // Handle different output modes
565        if let Some(save_path) = &self.save_only {
566            all_amendments.save_to_file(save_path)?;
567            println!("💾 Amendments saved to file");
568            return Ok(());
569        }
570
571        // Handle amendments
572        if !all_amendments.amendments.is_empty() {
573            let temp_dir = tempfile::tempdir()?;
574            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
575            all_amendments.save_to_file(&amendments_file)?;
576
577            {
578                use std::io::IsTerminal;
579                if !self.auto_apply
580                    && !self.handle_amendments_file(
581                        &amendments_file,
582                        &all_amendments,
583                        std::io::stdin().is_terminal(),
584                        &mut std::io::BufReader::new(std::io::stdin()),
585                    )?
586                {
587                    println!("❌ Amendment cancelled by user");
588                    return Ok(());
589                }
590            }
591
592            self.apply_amendments_from_file(repo_root, &amendments_file)?;
593            println!("✅ Commit messages improved successfully!");
594
595            if self.check {
596                self.run_post_twiddle_check(repo_root).await?;
597            }
598        } else {
599            println!("✨ No commits found to process!");
600        }
601
602        Ok(())
603    }
604
605    /// Generates the repository view (reuses ViewCommand logic).
606    fn generate_repository_view(
607        &self,
608        repo_root: &std::path::Path,
609    ) -> Result<crate::data::RepositoryView> {
610        use crate::data::{
611            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
612            WorkingDirectoryInfo,
613        };
614        use crate::git::{GitRepository, RemoteInfo};
615        use crate::utils::ai_scratch;
616
617        let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
618
619        // Open git repository
620        let repo = GitRepository::open_at(repo_root)
621            .context("Failed to open git repository at the given path")?;
622
623        // Get current branch name
624        let current_branch = repo
625            .get_current_branch()
626            .unwrap_or_else(|_| "HEAD".to_string());
627
628        // Get working directory status
629        let wd_status = repo.get_working_directory_status()?;
630        let working_directory = WorkingDirectoryInfo {
631            clean: wd_status.clean,
632            untracked_changes: wd_status
633                .untracked_changes
634                .into_iter()
635                .map(|fs| FileStatusInfo {
636                    status: fs.status,
637                    file: fs.file,
638                })
639                .collect(),
640        };
641
642        // Get remote information
643        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
644
645        // Parse commit range and get commits
646        let commits = repo.get_commits_in_range(commit_range)?;
647
648        // Create version information
649        let versions = Some(VersionInfo {
650            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
651        });
652
653        // Get AI scratch directory
654        let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
655            .context("Failed to determine AI scratch directory")?;
656        let ai_info = AiInfo {
657            scratch: ai_scratch_path.to_string_lossy().to_string(),
658        };
659
660        // Build repository view with branch info
661        let mut repo_view = RepositoryView {
662            versions,
663            explanation: FieldExplanation::default(),
664            working_directory,
665            remotes,
666            ai: ai_info,
667            branch_info: Some(BranchInfo {
668                branch: current_branch,
669            }),
670            pr_template: None,
671            pr_template_location: None,
672            branch_prs: None,
673            commits,
674        };
675
676        // Update field presence based on actual data
677        repo_view.update_field_presence();
678
679        Ok(repo_view)
680    }
681
682    /// Handles the amendments file by showing the path and getting the user choice.
683    ///
684    /// `is_terminal` and `reader` are injected so tests can drive the function
685    /// without blocking on real stdin.
686    fn handle_amendments_file(
687        &self,
688        amendments_file: &std::path::Path,
689        amendments: &crate::data::amendments::AmendmentFile,
690        is_terminal: bool,
691        reader: &mut (dyn std::io::BufRead + Send),
692    ) -> Result<bool> {
693        use std::io::{self, Write};
694
695        println!(
696            "\n📝 Found {} commits that could be improved.",
697            amendments.amendments.len()
698        );
699        println!("💾 Amendments saved to: {}", amendments_file.display());
700        println!();
701
702        if !is_terminal {
703            eprintln!("warning: stdin is not interactive, cannot prompt for amendments");
704            return Ok(false);
705        }
706
707        loop {
708            print!("❓ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
709            io::stdout().flush()?;
710
711            let Some(input) = super::read_interactive_line(reader)? else {
712                eprintln!("warning: stdin closed, cancelling amendments");
713                return Ok(false);
714            };
715
716            match input.trim().to_lowercase().as_str() {
717                "a" | "apply" | "" => return Ok(true),
718                "s" | "show" => {
719                    self.show_amendments_file(amendments_file)?;
720                    println!();
721                }
722                "e" | "edit" => {
723                    self.edit_amendments_file(amendments_file)?;
724                    println!();
725                }
726                "q" | "quit" => return Ok(false),
727                _ => {
728                    println!(
729                        "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
730                    );
731                }
732            }
733        }
734    }
735
736    /// Shows the contents of the amendments file.
737    fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
738        use std::fs;
739
740        println!("\n📄 Amendments file contents:");
741        println!("─────────────────────────────");
742
743        let contents =
744            fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
745
746        println!("{contents}");
747        println!("─────────────────────────────");
748
749        Ok(())
750    }
751
752    /// Opens the amendments file in an external editor.
753    fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
754        use std::env;
755        use std::io::{self, Write};
756        use std::process::Command;
757
758        // Try to get editor from environment variables
759        let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
760            e
761        } else {
762            // Prompt user for editor if neither environment variable is set
763            println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
764            print!("Please enter the command to use as your editor: ");
765            io::stdout().flush().context("Failed to flush stdout")?;
766
767            let mut input = String::new();
768            io::stdin()
769                .read_line(&mut input)
770                .context("Failed to read user input")?;
771            input.trim().to_string()
772        };
773
774        if editor.is_empty() {
775            println!("❌ No editor specified. Returning to menu.");
776            return Ok(());
777        }
778
779        println!("📝 Opening amendments file in editor: {editor}");
780
781        let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
782
783        let mut command = Command::new(editor_cmd);
784        command.args(args);
785        command.arg(amendments_file.to_string_lossy().as_ref());
786
787        match command.status() {
788            Ok(status) => {
789                if status.success() {
790                    println!("✅ Editor session completed.");
791                } else {
792                    println!(
793                        "⚠️  Editor exited with non-zero status: {:?}",
794                        status.code()
795                    );
796                }
797            }
798            Err(e) => {
799                println!("❌ Failed to execute editor '{editor}': {e}");
800                println!("   Please check that the editor command is correct and available in your PATH.");
801            }
802        }
803
804        Ok(())
805    }
806
807    /// Applies amendments from a file path (re-reads from disk to capture user edits).
808    fn apply_amendments_from_file(
809        &self,
810        repo_root: &std::path::Path,
811        amendments_file: &std::path::Path,
812    ) -> Result<()> {
813        use crate::git::AmendmentHandler;
814
815        // Use AmendmentHandler to apply amendments directly from file, anchored
816        // to the injected repo root.
817        let handler = AmendmentHandler::new(repo_root)
818            .context("Failed to initialize amendment handler")?
819            .with_allow_pushed(self.allow_pushed);
820        handler
821            .apply_amendments(&amendments_file.to_string_lossy())
822            .context("Failed to apply amendments")?;
823
824        Ok(())
825    }
826
827    /// Collects contextual information for enhanced commit message generation.
828    fn collect_context(
829        &self,
830        repo_root: &std::path::Path,
831        repo_view: &crate::data::RepositoryView,
832    ) -> Result<crate::data::context::CommitContext> {
833        use crate::claude::context::{
834            BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
835        };
836        use crate::data::context::CommitContext;
837
838        let mut context = CommitContext::new();
839
840        // 1. Discover project context
841        let (context_dir, dir_source) = crate::claude::context::resolve_context_dir_with_source_at(
842            self.context_dir.as_deref(),
843            repo_root,
844        );
845
846        // ProjectDiscovery takes repo root and context directory
847        let discovery = ProjectDiscovery::new(repo_root.to_path_buf(), context_dir.clone());
848        debug!(context_dir = ?context_dir, "Using context directory");
849        match discovery.discover() {
850            Ok(project_context) => {
851                debug!("Discovery successful");
852
853                // Show diagnostic information about loaded guidance files
854                self.show_guidance_files_status(&project_context, &context_dir, &dir_source)?;
855
856                context.project = project_context;
857            }
858            Err(e) => {
859                debug!(error = %e, "Discovery failed");
860                context.project = crate::data::context::ProjectContext::default();
861            }
862        }
863
864        // 2. Analyze current branch from repository view
865        if let Some(branch_info) = &repo_view.branch_info {
866            context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
867        } else {
868            // Fallback to getting current branch directly if not in repo view
869            use crate::git::GitRepository;
870            let repo = GitRepository::open_at(repo_root)?;
871            let current_branch = repo
872                .get_current_branch()
873                .unwrap_or_else(|_| "HEAD".to_string());
874            context.branch = BranchAnalyzer::analyze(&current_branch).unwrap_or_default();
875        }
876
877        // 3. Analyze commit range patterns
878        if !repo_view.commits.is_empty() {
879            context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
880        }
881
882        // 3.5. Analyze file-level context
883        if !repo_view.commits.is_empty() {
884            context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
885        }
886
887        // 4. Apply user-provided context overrides
888        if let Some(ref work_ctx) = self.work_context {
889            context.user_provided = Some(work_ctx.clone());
890        }
891
892        if let Some(ref branch_ctx) = self.branch_context {
893            context.branch.description.clone_from(branch_ctx);
894        }
895
896        Ok(context)
897    }
898
899    /// Shows the context summary to the user.
900    fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
901        println!("🔍 Context Analysis:");
902
903        // Project context
904        if !context.project.valid_scopes.is_empty() {
905            println!(
906                "   📁 Valid scopes: {}",
907                format_scope_list(&context.project.valid_scopes)
908            );
909        }
910
911        // Branch context
912        if context.branch.is_feature_branch {
913            println!(
914                "   🌿 Branch: {} ({})",
915                context.branch.description, context.branch.work_type
916            );
917            if let Some(ref ticket) = context.branch.ticket_id {
918                println!("   🎫 Ticket: {ticket}");
919            }
920        }
921
922        // Work pattern
923        if let Some(label) = format_work_pattern(&context.range.work_pattern) {
924            println!("   {label}");
925        }
926
927        // File analysis
928        if let Some(label) = super::formatting::format_file_analysis(&context.files) {
929            println!("   {label}");
930        }
931
932        // Verbosity level
933        println!(
934            "   {}",
935            format_verbosity_level(context.suggested_verbosity())
936        );
937
938        // User context
939        if let Some(ref user_ctx) = context.user_provided {
940            println!("   👤 User context: {user_ctx}");
941        }
942
943        println!();
944        Ok(())
945    }
946
947    /// Shows model information from the actual AI client.
948    fn show_model_info_from_client(
949        &self,
950        client: &crate::claude::client::ClaudeClient,
951    ) -> Result<()> {
952        use crate::claude::model_config::get_model_registry;
953
954        println!("🤖 AI Model Configuration:");
955
956        // Get actual metadata from the client
957        let metadata = client.get_ai_client_metadata();
958        // NOTE (#967): this `--verbose` diagnostic banner reads the process-wide
959        // model catalog (`get_model_registry` → CWD-relative project models.yaml),
960        // not a `--repo`-scoped catalog. It is informational only and does not
961        // affect the twiddle output, so it is left CWD-scoped until the
962        // repo-aware `ModelRegistry::load_at` foundation lands (first used by
963        // `create pr`).
964        let registry = get_model_registry();
965
966        if let Some(spec) = registry.get_model_spec(&metadata.model) {
967            // Highlight the API identifier portion in yellow
968            if metadata.model != spec.api_identifier {
969                println!(
970                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
971                    metadata.model, spec.api_identifier
972                );
973            } else {
974                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
975            }
976
977            println!("   🏷️  Provider: {}", spec.provider);
978            println!("   📊 Generation: {}", spec.generation);
979            println!("   ⭐ Tier: {} ({})", spec.tier, {
980                if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
981                    &tier_info.description
982                } else {
983                    "No description available"
984                }
985            });
986            println!("   📤 Max output tokens: {}", metadata.max_response_length);
987            println!("   📥 Input context: {}", metadata.max_context_length);
988
989            if let Some((ref key, ref value)) = metadata.active_beta {
990                println!("   🔬 Beta header: {key}: {value}");
991            }
992
993            if spec.legacy {
994                println!("   ⚠️  Legacy model (consider upgrading to newer version)");
995            }
996        } else {
997            // Fallback to client metadata if not in registry
998            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
999            println!("   🏷️  Provider: {}", metadata.provider);
1000            println!("   ⚠️  Model not found in registry, using client metadata:");
1001            println!("   📤 Max output tokens: {}", metadata.max_response_length);
1002            println!("   📥 Input context: {}", metadata.max_context_length);
1003        }
1004
1005        println!();
1006        Ok(())
1007    }
1008
1009    /// Shows diagnostic information about loaded guidance files.
1010    fn show_guidance_files_status(
1011        &self,
1012        project_context: &crate::data::context::ProjectContext,
1013        context_dir: &std::path::Path,
1014        dir_source: &crate::claude::context::ConfigDirSource,
1015    ) -> Result<()> {
1016        use crate::claude::context::{config_source_label, ConfigSourceLabel};
1017
1018        println!("📋 Project guidance files status:");
1019        println!("   📂 Config dir: {} ({dir_source})", context_dir.display());
1020
1021        // Check commit guidelines
1022        let guidelines_source = if project_context.commit_guidelines.is_some() {
1023            match config_source_label(context_dir, "commit-guidelines.md") {
1024                ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
1025                label => format!("✅ {label}"),
1026            }
1027        } else {
1028            "❌ None found".to_string()
1029        };
1030        println!("   📝 Commit guidelines: {guidelines_source}");
1031
1032        // Check scopes
1033        let scopes_count = project_context.valid_scopes.len();
1034        let scopes_source = if scopes_count > 0 {
1035            match config_source_label(context_dir, "scopes.yaml") {
1036                ConfigSourceLabel::NotFound => {
1037                    format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
1038                }
1039                label => format!("✅ {label} ({scopes_count} scopes)"),
1040            }
1041        } else {
1042            "❌ None found".to_string()
1043        };
1044        println!("   🎯 Valid scopes: {scopes_source}");
1045
1046        println!();
1047        Ok(())
1048    }
1049
1050    /// Executes the twiddle command without AI, amending each commit to a
1051    /// deterministic suggestion (#1566).
1052    ///
1053    /// Commits whose suggestion equals their current message are left out, so
1054    /// already-conformant commits are never rewritten.
1055    async fn execute_no_ai(&self, repo_root: &std::path::Path) -> Result<()> {
1056        use crate::data::amendments::AmendmentFile;
1057
1058        println!("📋 Generating deterministic amendments without AI processing...");
1059
1060        // Generate repository view to get all commits
1061        let repo_view = self.generate_repository_view(repo_root)?;
1062        let scope_defs = self.load_check_scopes(repo_root);
1063
1064        let amendment_file = AmendmentFile {
1065            amendments: deterministic_amendments(&repo_view, &scope_defs),
1066        };
1067
1068        // Handle different output modes (an empty file is still written, so
1069        // scripts reading it can tell "nothing to do" from a failure)
1070        if let Some(save_path) = &self.save_only {
1071            amendment_file.save_to_file(save_path)?;
1072            println!("💾 Amendments saved to file");
1073            return Ok(());
1074        }
1075
1076        if repo_view.commits.is_empty() {
1077            println!("✨ No commits found to process!");
1078            return Ok(());
1079        }
1080        if amendment_file.amendments.is_empty() {
1081            println!("✨ No changes needed: every commit message already conforms.");
1082            return Ok(());
1083        }
1084
1085        // Handle amendments using the same flow as the AI-powered version
1086        // Create temporary file for amendments
1087        let temp_dir = tempfile::tempdir()?;
1088        let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1089        amendment_file.save_to_file(&amendments_file)?;
1090
1091        // Show file path and get user choice
1092        {
1093            use std::io::IsTerminal;
1094            if !self.auto_apply
1095                && !self.handle_amendments_file(
1096                    &amendments_file,
1097                    &amendment_file,
1098                    std::io::stdin().is_terminal(),
1099                    &mut std::io::BufReader::new(std::io::stdin()),
1100                )?
1101            {
1102                println!("❌ Amendment cancelled by user");
1103                return Ok(());
1104            }
1105        }
1106
1107        // Apply amendments (re-read from file to capture any user edits)
1108        self.apply_amendments_from_file(repo_root, &amendments_file)?;
1109        println!("✅ Commit messages applied successfully!");
1110
1111        // Run post-twiddle check if --check flag is set
1112        if self.check {
1113            self.run_post_twiddle_check(repo_root).await?;
1114        }
1115
1116        Ok(())
1117    }
1118
1119    /// Runs commit message validation after twiddle amendments are applied.
1120    /// If the check finds errors with suggestions, automatically applies the
1121    /// suggestions and re-checks, up to 3 retries.
1122    async fn run_post_twiddle_check(&self, repo_root: &std::path::Path) -> Result<()> {
1123        const MAX_CHECK_RETRIES: u32 = 3;
1124
1125        // Load guidelines, scopes, and Claude client once (they don't change between retries)
1126        let guidelines = self.load_check_guidelines(repo_root)?;
1127        let valid_scopes = self.load_check_scopes(repo_root);
1128        let claude_client = crate::claude::create_default_claude_client(None, None).await?;
1129
1130        for attempt in 0..=MAX_CHECK_RETRIES {
1131            println!();
1132            if attempt == 0 {
1133                println!("🔍 Running commit message validation...");
1134            } else {
1135                println!("🔍 Re-checking commit messages (retry {attempt}/{MAX_CHECK_RETRIES})...");
1136            }
1137
1138            // Generate fresh repository view to get updated commit messages
1139            let mut repo_view = self.generate_repository_view(repo_root)?;
1140
1141            if repo_view.commits.is_empty() {
1142                println!("⚠️  No commits to check");
1143                return Ok(());
1144            }
1145
1146            println!("📊 Checking {} commits", repo_view.commits.len());
1147
1148            // Refine detected scopes using file_patterns from scope definitions
1149            for commit in &mut repo_view.commits {
1150                commit.analysis.refine_scope(&valid_scopes);
1151            }
1152
1153            if attempt == 0 {
1154                self.show_check_guidance_files_status(repo_root, &guidelines, &valid_scopes);
1155            }
1156
1157            // Run check
1158            let report = if repo_view.commits.len() > 1 {
1159                println!(
1160                    "🔄 Checking {} commits in parallel...",
1161                    repo_view.commits.len()
1162                );
1163                self.check_commits_map_reduce(
1164                    &claude_client,
1165                    &repo_view,
1166                    guidelines.as_deref(),
1167                    &valid_scopes,
1168                )
1169                .await?
1170            } else {
1171                println!("🤖 Analyzing commits with AI...");
1172                claude_client
1173                    .check_commits_with_scopes(
1174                        &repo_view,
1175                        guidelines.as_deref(),
1176                        &valid_scopes,
1177                        true,
1178                    )
1179                    .await?
1180            };
1181
1182            // Output text report
1183            self.output_check_text_report(&report)?;
1184
1185            // If no errors, we're done
1186            if !report.has_errors() {
1187                if report.has_warnings() {
1188                    println!("ℹ️  Some commit messages have minor warnings");
1189                } else {
1190                    println!("✅ All commit messages pass validation");
1191                }
1192                return Ok(());
1193            }
1194
1195            // If we've exhausted retries, report and stop
1196            if attempt == MAX_CHECK_RETRIES {
1197                println!(
1198                    "⚠️  Some commit messages still have issues after {MAX_CHECK_RETRIES} retries"
1199                );
1200                return Ok(());
1201            }
1202
1203            // Build amendments from suggestions for failing commits
1204            let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
1205
1206            if amendments.is_empty() {
1207                println!(
1208                    "⚠️  Some commit messages have issues but no suggestions available to retry"
1209                );
1210                return Ok(());
1211            }
1212
1213            // Apply the suggested amendments
1214            println!(
1215                "🔄 Applying {} suggested fix(es) and re-checking...",
1216                amendments.len()
1217            );
1218            let amendment_file = AmendmentFile { amendments };
1219            let temp_file = tempfile::NamedTempFile::new()
1220                .context("Failed to create temp file for retry amendments")?;
1221            amendment_file
1222                .save_to_file(temp_file.path())
1223                .context("Failed to save retry amendments")?;
1224            self.apply_amendments_from_file(repo_root, temp_file.path())?;
1225        }
1226
1227        Ok(())
1228    }
1229
1230    /// Builds amendments from check report suggestions for failing commits.
1231    /// Resolves short hashes from the AI response to full 40-char hashes
1232    /// from the repository view.
1233    fn build_amendments_from_suggestions(
1234        &self,
1235        report: &crate::data::check::CheckReport,
1236        repo_view: &crate::data::RepositoryView,
1237    ) -> Vec<crate::data::amendments::Amendment> {
1238        use crate::data::amendments::Amendment;
1239
1240        let candidate_hashes: Vec<String> =
1241            repo_view.commits.iter().map(|c| c.hash.clone()).collect();
1242
1243        report
1244            .commits
1245            .iter()
1246            .filter(|r| !r.passes)
1247            .filter_map(|r| {
1248                let suggestion = r.suggestion.as_ref()?;
1249                let full_hash = super::formatting::resolve_short_hash(&r.hash, &candidate_hashes)?;
1250                Some(Amendment::new(
1251                    full_hash.to_string(),
1252                    suggestion.message.clone(),
1253                ))
1254            })
1255            .collect()
1256    }
1257
1258    /// Loads commit guidelines for check via the standard resolution chain.
1259    fn load_check_guidelines(&self, repo_root: &std::path::Path) -> Result<Option<String>> {
1260        let context_dir =
1261            crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
1262        crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")
1263    }
1264
1265    /// Loads valid scopes for check with ecosystem defaults.
1266    fn load_check_scopes(
1267        &self,
1268        repo_root: &std::path::Path,
1269    ) -> Vec<crate::data::context::ScopeDefinition> {
1270        let context_dir =
1271            crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
1272        crate::claude::context::load_project_scopes(&context_dir, repo_root)
1273    }
1274
1275    /// Shows guidance files status for check.
1276    fn show_check_guidance_files_status(
1277        &self,
1278        repo_root: &std::path::Path,
1279        guidelines: &Option<String>,
1280        valid_scopes: &[crate::data::context::ScopeDefinition],
1281    ) {
1282        use crate::claude::context::{
1283            config_source_label, resolve_context_dir_with_source_at, ConfigSourceLabel,
1284        };
1285
1286        let (context_dir, dir_source) =
1287            resolve_context_dir_with_source_at(self.context_dir.as_deref(), repo_root);
1288
1289        println!("📋 Project guidance files status:");
1290        println!("   📂 Config dir: {} ({dir_source})", context_dir.display());
1291
1292        // Check commit guidelines
1293        let guidelines_source = if guidelines.is_some() {
1294            match config_source_label(&context_dir, "commit-guidelines.md") {
1295                ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
1296                label => format!("✅ {label}"),
1297            }
1298        } else {
1299            "⚪ Using defaults".to_string()
1300        };
1301        println!("   📝 Commit guidelines: {guidelines_source}");
1302
1303        // Check scopes
1304        let scopes_count = valid_scopes.len();
1305        let scopes_source = if scopes_count > 0 {
1306            match config_source_label(&context_dir, "scopes.yaml") {
1307                ConfigSourceLabel::NotFound => {
1308                    format!("✅ (source unknown) ({scopes_count} scopes)")
1309                }
1310                label => format!("✅ {label} ({scopes_count} scopes)"),
1311            }
1312        } else {
1313            "⚪ None found (any scope accepted)".to_string()
1314        };
1315        println!("   🎯 Valid scopes: {scopes_source}");
1316
1317        println!();
1318    }
1319
1320    /// Checks commits using batched parallel map-reduce.
1321    async fn check_commits_map_reduce(
1322        &self,
1323        claude_client: &crate::claude::client::ClaudeClient,
1324        full_repo_view: &crate::data::RepositoryView,
1325        guidelines: Option<&str>,
1326        valid_scopes: &[crate::data::context::ScopeDefinition],
1327    ) -> Result<crate::data::check::CheckReport> {
1328        use std::sync::atomic::{AtomicUsize, Ordering};
1329        use std::sync::Arc;
1330
1331        use crate::claude::batch;
1332        use crate::claude::token_budget;
1333        use crate::data::check::{CheckReport, CommitCheckResult};
1334
1335        let total_commits = full_repo_view.commits.len();
1336
1337        // Plan batches based on token budget
1338        let metadata = claude_client.get_ai_client_metadata();
1339        let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
1340            guidelines,
1341            valid_scopes,
1342        );
1343        let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
1344        let batch_plan =
1345            batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
1346
1347        if batch_plan.batches.len() < total_commits {
1348            println!(
1349                "   📦 Grouped {} commits into {} batches by token budget",
1350                total_commits,
1351                batch_plan.batches.len()
1352            );
1353        }
1354
1355        let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
1356        let completed = Arc::new(AtomicUsize::new(0));
1357
1358        let futs: Vec<_> = batch_plan
1359            .batches
1360            .iter()
1361            .map(|batch| {
1362                let sem = semaphore.clone();
1363                let completed = completed.clone();
1364                let batch_indices = &batch.commit_indices;
1365
1366                async move {
1367                    let _permit = sem
1368                        .acquire()
1369                        .await
1370                        .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
1371
1372                    let batch_size = batch_indices.len();
1373
1374                    let batch_view = if batch_size == 1 {
1375                        full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
1376                    } else {
1377                        let commits: Vec<_> = batch_indices
1378                            .iter()
1379                            .map(|&i| &full_repo_view.commits[i])
1380                            .collect();
1381                        full_repo_view.multi_commit_view(&commits)
1382                    };
1383
1384                    let result = claude_client
1385                        .check_commits_with_scopes(&batch_view, guidelines, valid_scopes, true)
1386                        .await;
1387
1388                    match result {
1389                        Ok(report) => {
1390                            let done =
1391                                completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
1392                            println!("   ✅ {done}/{total_commits} commits checked");
1393
1394                            let items: Vec<_> = report
1395                                .commits
1396                                .into_iter()
1397                                .map(|r| {
1398                                    let summary = r.summary.clone().unwrap_or_default();
1399                                    (r, summary)
1400                                })
1401                                .collect();
1402                            Ok::<_, anyhow::Error>((items, vec![]))
1403                        }
1404                        Err(e) if batch_size > 1 => {
1405                            eprintln!(
1406                                "warning: batch of {batch_size} failed, retrying individually: {e}"
1407                            );
1408                            let mut items = Vec::new();
1409                            let mut failed_indices = Vec::new();
1410                            for &idx in batch_indices {
1411                                let single_view =
1412                                    full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1413                                let single_result = claude_client
1414                                    .check_commits_with_scopes(
1415                                        &single_view,
1416                                        guidelines,
1417                                        valid_scopes,
1418                                        true,
1419                                    )
1420                                    .await;
1421                                match single_result {
1422                                    Ok(report) => {
1423                                        if let Some(r) = report.commits.into_iter().next() {
1424                                            let summary = r.summary.clone().unwrap_or_default();
1425                                            items.push((r, summary));
1426                                        }
1427                                        let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1428                                        println!("   ✅ {done}/{total_commits} commits checked");
1429                                    }
1430                                    Err(e) => {
1431                                        eprintln!("warning: failed to check commit: {e}");
1432                                        failed_indices.push(idx);
1433                                        println!("   ❌ commit check failed");
1434                                    }
1435                                }
1436                            }
1437                            Ok((items, failed_indices))
1438                        }
1439                        Err(e) => {
1440                            // Single-commit batch failed; record the index so the user can retry
1441                            let idx = batch_indices[0];
1442                            eprintln!("warning: failed to check commit: {e}");
1443                            let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1444                            println!("   ❌ {done}/{total_commits} commits checked (failed)");
1445                            Ok((vec![], vec![idx]))
1446                        }
1447                    }
1448                }
1449            })
1450            .collect();
1451
1452        let results = futures::future::join_all(futs).await;
1453
1454        let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
1455        let mut failed_indices: Vec<usize> = Vec::new();
1456
1457        for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
1458            match result {
1459                Ok((items, failed)) => {
1460                    successes.extend(items);
1461                    failed_indices.extend(failed);
1462                }
1463                Err(e) => {
1464                    eprintln!("warning: batch processing error: {e}");
1465                    failed_indices.extend(&batch.commit_indices);
1466                }
1467            }
1468        }
1469
1470        // Offer interactive retry for commits that failed
1471        if !failed_indices.is_empty() && !self.quiet {
1472            use std::io::IsTerminal;
1473            if std::io::stdin().is_terminal() {
1474                self.run_interactive_retry_twiddle_check(
1475                    &mut failed_indices,
1476                    full_repo_view,
1477                    claude_client,
1478                    guidelines,
1479                    valid_scopes,
1480                    &mut successes,
1481                    &mut std::io::BufReader::new(std::io::stdin()),
1482                )
1483                .await?;
1484            } else {
1485                eprintln!(
1486                    "warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
1487                    failed_indices.len()
1488                );
1489            }
1490        } else if !failed_indices.is_empty() {
1491            eprintln!(
1492                "warning: {} commit(s) failed to check",
1493                failed_indices.len()
1494            );
1495        }
1496
1497        if !failed_indices.is_empty() {
1498            eprintln!(
1499                "warning: {} commit(s) ultimately failed to check",
1500                failed_indices.len()
1501            );
1502        }
1503
1504        if successes.is_empty() {
1505            anyhow::bail!("All commits failed to check");
1506        }
1507
1508        // Coherence pass: skip when all commits were in a single batch
1509        let single_batch = batch_plan.batches.len() <= 1;
1510        if !self.no_coherence && !single_batch && successes.len() >= 2 {
1511            println!("🔗 Running cross-commit coherence pass...");
1512            match claude_client
1513                .refine_checks_coherence(&successes, full_repo_view)
1514                .await
1515            {
1516                Ok(refined) => return Ok(refined),
1517                Err(e) => {
1518                    eprintln!("warning: coherence pass failed, using individual results: {e}");
1519                }
1520            }
1521        }
1522
1523        Ok(CheckReport::new(
1524            successes.into_iter().map(|(r, _)| r).collect(),
1525        ))
1526    }
1527
1528    /// Prompts the user to retry or skip failed commits, updating `failed_indices` and `successes`.
1529    ///
1530    /// Accepts `reader` for stdin injection so the interactive loop can be unit-tested.
1531    #[allow(clippy::too_many_arguments)]
1532    async fn run_interactive_retry_twiddle_check(
1533        &self,
1534        failed_indices: &mut Vec<usize>,
1535        full_repo_view: &crate::data::RepositoryView,
1536        claude_client: &crate::claude::client::ClaudeClient,
1537        guidelines: Option<&str>,
1538        valid_scopes: &[crate::data::context::ScopeDefinition],
1539        successes: &mut Vec<(crate::data::check::CommitCheckResult, String)>,
1540        reader: &mut (dyn std::io::BufRead + Send),
1541    ) -> Result<()> {
1542        use std::io::Write as _;
1543        println!("\n⚠️  {} commit(s) failed to check:", failed_indices.len());
1544        for &idx in failed_indices.iter() {
1545            let commit = &full_repo_view.commits[idx];
1546            let subject = commit
1547                .original_message
1548                .lines()
1549                .next()
1550                .unwrap_or("(no message)");
1551            println!("  - {}: {}", &commit.hash[..8], subject);
1552        }
1553        loop {
1554            print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
1555            std::io::stdout().flush()?;
1556            let Some(input) = super::read_interactive_line(reader)? else {
1557                eprintln!("warning: stdin closed, skipping failed commit(s)");
1558                break;
1559            };
1560            match input.trim().to_lowercase().as_str() {
1561                "r" | "retry" | "" => {
1562                    let mut still_failed = Vec::new();
1563                    for &idx in failed_indices.iter() {
1564                        let single_view =
1565                            full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1566                        match claude_client
1567                            .check_commits_with_scopes(&single_view, guidelines, valid_scopes, true)
1568                            .await
1569                        {
1570                            Ok(report) => {
1571                                if let Some(r) = report.commits.into_iter().next() {
1572                                    let summary = r.summary.clone().unwrap_or_default();
1573                                    successes.push((r, summary));
1574                                }
1575                            }
1576                            Err(e) => {
1577                                eprintln!("warning: still failed: {e}");
1578                                still_failed.push(idx);
1579                            }
1580                        }
1581                    }
1582                    *failed_indices = still_failed;
1583                    if failed_indices.is_empty() {
1584                        println!("✅ All retried commits succeeded.");
1585                        break;
1586                    }
1587                    println!("\n⚠️  {} commit(s) still failed:", failed_indices.len());
1588                    for &idx in failed_indices.iter() {
1589                        let commit = &full_repo_view.commits[idx];
1590                        let subject = commit
1591                            .original_message
1592                            .lines()
1593                            .next()
1594                            .unwrap_or("(no message)");
1595                        println!("  - {}: {}", &commit.hash[..8], subject);
1596                    }
1597                }
1598                "s" | "skip" => {
1599                    println!("Skipping {} failed commit(s).", failed_indices.len());
1600                    break;
1601                }
1602                _ => println!("Please enter 'r' to retry or 's' to skip."),
1603            }
1604        }
1605        Ok(())
1606    }
1607
1608    /// Prompts the user to retry or skip commits that failed amendment generation,
1609    /// updating `failed_indices` and `successes` in place.
1610    ///
1611    /// `is_terminal` and `reader` are injected so tests can drive the function
1612    /// without blocking on real stdin.
1613    #[allow(clippy::too_many_arguments)]
1614    async fn run_interactive_retry_generate_amendments(
1615        &self,
1616        failed_indices: &mut Vec<usize>,
1617        full_repo_view: &crate::data::RepositoryView,
1618        claude_client: &crate::claude::client::ClaudeClient,
1619        context: Option<&crate::data::context::CommitContext>,
1620        fresh: bool,
1621        successes: &mut Vec<(crate::data::amendments::Amendment, String)>,
1622        is_terminal: bool,
1623        reader: &mut (dyn std::io::BufRead + Send),
1624    ) -> Result<()> {
1625        use std::io::Write as _;
1626        println!(
1627            "\n⚠️  {} commit(s) failed to process:",
1628            failed_indices.len()
1629        );
1630        for &idx in failed_indices.iter() {
1631            let commit = &full_repo_view.commits[idx];
1632            let subject = commit
1633                .original_message
1634                .lines()
1635                .next()
1636                .unwrap_or("(no message)");
1637            println!("  - {}: {}", &commit.hash[..8], subject);
1638        }
1639        if !is_terminal {
1640            eprintln!(
1641                "warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
1642                failed_indices.len()
1643            );
1644            return Ok(());
1645        }
1646        loop {
1647            print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
1648            std::io::stdout().flush()?;
1649            let Some(input) = super::read_interactive_line(reader)? else {
1650                eprintln!("warning: stdin closed, skipping failed commit(s)");
1651                break;
1652            };
1653            match input.trim().to_lowercase().as_str() {
1654                "r" | "retry" | "" => {
1655                    let mut still_failed = Vec::new();
1656                    for &idx in failed_indices.iter() {
1657                        let single_view =
1658                            full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1659                        let result = if let Some(ctx) = context {
1660                            claude_client
1661                                .generate_contextual_amendments_with_options(
1662                                    &single_view,
1663                                    ctx,
1664                                    fresh,
1665                                )
1666                                .await
1667                        } else {
1668                            claude_client
1669                                .generate_amendments_with_options(&single_view, fresh)
1670                                .await
1671                        };
1672                        match result {
1673                            Ok(af) => {
1674                                if let Some(a) = af.amendments.into_iter().next() {
1675                                    let summary = a.summary.clone();
1676                                    successes.push((a, summary));
1677                                }
1678                            }
1679                            Err(e) => {
1680                                eprintln!("warning: still failed: {e}");
1681                                still_failed.push(idx);
1682                            }
1683                        }
1684                    }
1685                    *failed_indices = still_failed;
1686                    if failed_indices.is_empty() {
1687                        println!("✅ All retried commits succeeded.");
1688                        break;
1689                    }
1690                    println!("\n⚠️  {} commit(s) still failed:", failed_indices.len());
1691                    for &idx in failed_indices.iter() {
1692                        let commit = &full_repo_view.commits[idx];
1693                        let subject = commit
1694                            .original_message
1695                            .lines()
1696                            .next()
1697                            .unwrap_or("(no message)");
1698                        println!("  - {}: {}", &commit.hash[..8], subject);
1699                    }
1700                }
1701                "s" | "skip" => {
1702                    println!("Skipping {} failed commit(s).", failed_indices.len());
1703                    break;
1704                }
1705                _ => println!("Please enter 'r' to retry or 's' to skip."),
1706            }
1707        }
1708        Ok(())
1709    }
1710
1711    /// Outputs the text format check report (mirrors `CheckCommand::output_text_report`).
1712    fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
1713        println!();
1714
1715        for result in &report.commits {
1716            // Skip passing commits
1717            if result.passes {
1718                continue;
1719            }
1720
1721            let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
1722            let short_hash = super::formatting::truncate_hash(&result.hash);
1723
1724            println!("{} {} - \"{}\"", icon, short_hash, result.message);
1725
1726            // Print issues
1727            for issue in &result.issues {
1728                let severity_str = super::formatting::format_severity_label(issue.severity);
1729
1730                println!(
1731                    "   {} [{}] {}",
1732                    severity_str, issue.section, issue.explanation
1733                );
1734            }
1735
1736            // Print suggestion if available
1737            if let Some(suggestion) = &result.suggestion {
1738                println!();
1739                println!("   Suggested message:");
1740                for line in suggestion.message.lines() {
1741                    println!("      {line}");
1742                }
1743            }
1744
1745            println!();
1746        }
1747
1748        // Print summary
1749        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1750        println!("Summary: {} commits checked", report.summary.total_commits);
1751        println!(
1752            "  {} errors, {} warnings",
1753            report.summary.error_count, report.summary.warning_count
1754        );
1755        println!(
1756            "  {} passed, {} with issues",
1757            report.summary.passing_commits, report.summary.failing_commits
1758        );
1759
1760        Ok(())
1761    }
1762}
1763
1764/// Structured output from [`run_twiddle`] for programmatic consumers (MCP).
1765#[derive(Debug, Clone)]
1766pub struct TwiddleOutcome {
1767    /// YAML serialisation of the generated [`AmendmentFile`].
1768    pub amendments_yaml: String,
1769    /// `true` when amendments were applied to the repository; `false` for a
1770    /// dry-run or when no amendments were generated.
1771    pub applied: bool,
1772    /// Number of amendments generated.
1773    pub amendment_count: usize,
1774}
1775
1776/// Non-interactive core for `omni-dev git commit message twiddle`.
1777///
1778/// Shared by the CLI (wrapped by [`TwiddleCommand::execute`] for the
1779/// interactive flow) and the MCP server. The MCP tool boundary is
1780/// non-interactive, so this entry point forces `--auto-apply` semantics when
1781/// `dry_run` is false and never opens an editor. When `dry_run` is true,
1782/// proposed amendments are returned as YAML without being applied.
1783///
1784/// `repo_path` selects the repository to twiddle (`None` defaults to the
1785/// current working directory). It is resolved once here and threaded explicitly
1786/// into [`run_twiddle_with_client`], so git, context-discovery, AI-scratch, and
1787/// amendment-apply paths anchor to the target repo without changing the process
1788/// working directory.
1789pub async fn run_twiddle(
1790    range: Option<&str>,
1791    model: Option<String>,
1792    dry_run: bool,
1793    repo_path: Option<&std::path::Path>,
1794) -> Result<TwiddleOutcome> {
1795    let repo_root = match repo_path {
1796        Some(p) => p.to_path_buf(),
1797        None => std::env::current_dir().context("Failed to determine current directory")?,
1798    };
1799    let repo_root = repo_root.as_path();
1800
1801    crate::utils::check_ai_command_prerequisites(model.as_deref(), repo_root)?;
1802
1803    if !dry_run {
1804        crate::utils::check_working_directory_clean_at(repo_root)?;
1805    }
1806
1807    let claude_client = crate::claude::create_default_claude_client(model, None).await?;
1808    run_twiddle_with_client(range, dry_run, repo_root, &claude_client).await
1809}
1810
1811/// Non-credential-gated inner core of [`run_twiddle`] for unit tests.
1812///
1813/// Extracted so tests can inject a [`crate::claude::client::ClaudeClient`]
1814/// backed by the in-crate mock AI client and exercise the full flow without
1815/// real credentials. `repo_root` selects the repository; callers run preflight
1816/// themselves.
1817pub(crate) async fn run_twiddle_with_client(
1818    range: Option<&str>,
1819    dry_run: bool,
1820    repo_root: &std::path::Path,
1821    claude_client: &crate::claude::client::ClaudeClient,
1822) -> Result<TwiddleOutcome> {
1823    use crate::data::{
1824        AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1825        WorkingDirectoryInfo,
1826    };
1827    use crate::git::{GitRepository, RemoteInfo};
1828    use crate::utils::ai_scratch;
1829
1830    let resolved_range = range.unwrap_or("HEAD~5..HEAD");
1831
1832    let repo = GitRepository::open_at(repo_root)
1833        .context("Failed to open git repository at the given path")?;
1834
1835    let current_branch = repo
1836        .get_current_branch()
1837        .unwrap_or_else(|_| "HEAD".to_string());
1838
1839    let wd_status = repo.get_working_directory_status()?;
1840    let working_directory = WorkingDirectoryInfo {
1841        clean: wd_status.clean,
1842        untracked_changes: wd_status
1843            .untracked_changes
1844            .into_iter()
1845            .map(|fs| FileStatusInfo {
1846                status: fs.status,
1847                file: fs.file,
1848            })
1849            .collect(),
1850    };
1851
1852    let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1853    let commits = repo.get_commits_in_range(resolved_range)?;
1854
1855    if commits.is_empty() {
1856        let empty_file = AmendmentFile { amendments: vec![] };
1857        let yaml =
1858            crate::data::to_yaml(&empty_file).context("Failed to serialise empty AmendmentFile")?;
1859        return Ok(TwiddleOutcome {
1860            amendments_yaml: yaml,
1861            applied: false,
1862            amendment_count: 0,
1863        });
1864    }
1865
1866    let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
1867        .context("Failed to determine AI scratch directory")?;
1868    let ai_info = AiInfo {
1869        scratch: ai_scratch_path.to_string_lossy().to_string(),
1870    };
1871
1872    let mut repo_view = RepositoryView {
1873        versions: Some(VersionInfo {
1874            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1875        }),
1876        explanation: FieldExplanation::default(),
1877        working_directory,
1878        remotes,
1879        ai: ai_info,
1880        branch_info: Some(BranchInfo {
1881            branch: current_branch,
1882        }),
1883        pr_template: None,
1884        pr_template_location: None,
1885        branch_prs: None,
1886        commits,
1887    };
1888    repo_view.update_field_presence();
1889
1890    let mut amendments = claude_client
1891        .generate_amendments_with_options(&repo_view, true)
1892        .await?;
1893
1894    let context_dir = crate::claude::context::resolve_context_dir_at(None, repo_root);
1895    let scope_defs = crate::claude::context::load_project_scopes(&context_dir, repo_root);
1896    refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
1897
1898    let amendments_yaml =
1899        crate::data::to_yaml(&amendments).context("Failed to serialise AmendmentFile")?;
1900    let amendment_count = amendments.amendments.len();
1901
1902    if dry_run || amendment_count == 0 {
1903        return Ok(TwiddleOutcome {
1904            amendments_yaml,
1905            applied: false,
1906            amendment_count,
1907        });
1908    }
1909
1910    let temp_dir = tempfile::tempdir().context("Failed to create temp dir")?;
1911    let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1912    amendments
1913        .save_to_file(&amendments_file)
1914        .context("Failed to save amendments")?;
1915    let handler = crate::git::AmendmentHandler::new(repo_root)
1916        .context("Failed to initialise amendment handler")?;
1917    handler
1918        .apply_amendments(&amendments_file.to_string_lossy())
1919        .context("Failed to apply amendments")?;
1920
1921    Ok(TwiddleOutcome {
1922        amendments_yaml,
1923        applied: true,
1924        amendment_count,
1925    })
1926}
1927
1928#[cfg(test)]
1929#[allow(clippy::unwrap_used, clippy::expect_used)]
1930mod run_twiddle_tests {
1931    use super::*;
1932    use crate::claude::client::ClaudeClient;
1933    use crate::claude::test_utils::ConfigurableMockAiClient;
1934    use git2::{Repository, Signature};
1935
1936    /// `run_twiddle` opens the injected repo via `open_at` with no dependence
1937    /// on the process working directory, so an invalid path errors with a
1938    /// git/repository error (the `GitRepository::open_at` context) before any
1939    /// AI call.
1940    #[tokio::test]
1941    async fn run_twiddle_invalid_repo_path_errors_before_ai() {
1942        let err = run_twiddle(
1943            None,
1944            None,
1945            true,
1946            Some(std::path::Path::new("/no/such/path/exists")),
1947        )
1948        .await
1949        .unwrap_err();
1950        let msg = format!("{err:#}");
1951        assert!(
1952            msg.to_lowercase().contains("git") || msg.to_lowercase().contains("repository"),
1953            "expected git/repository error, got: {msg}"
1954        );
1955    }
1956
1957    fn init_test_repo_with_commit() -> (tempfile::TempDir, String) {
1958        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1959        std::fs::create_dir_all(&tmp_root).unwrap();
1960        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1961        let repo = Repository::init(temp_dir.path()).unwrap();
1962        {
1963            let mut cfg = repo.config().unwrap();
1964            cfg.set_str("user.name", "Test").unwrap();
1965            cfg.set_str("user.email", "test@example.com").unwrap();
1966            // Disable commit signing in *local* config (overrides any ambient
1967            // global `commit.gpgsign=true`) so the `git commit --amend`
1968            // subprocess in the apply path is hermetic — it must not depend on
1969            // the process's `HOME`, which concurrent tests mutate (issue #950).
1970            cfg.set_bool("commit.gpgsign", false).unwrap();
1971        }
1972        let signature = Signature::now("Test", "test@example.com").unwrap();
1973        std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
1974        let mut idx = repo.index().unwrap();
1975        idx.add_path(std::path::Path::new("f.txt")).unwrap();
1976        idx.write().unwrap();
1977        let tree_id = idx.write_tree().unwrap();
1978        let tree = repo.find_tree(tree_id).unwrap();
1979        let oid = repo
1980            .commit(
1981                Some("HEAD"),
1982                &signature,
1983                &signature,
1984                "feat: original",
1985                &tree,
1986                &[],
1987            )
1988            .unwrap();
1989        (temp_dir, oid.to_string())
1990    }
1991
1992    fn amendment_yaml(hash: &str, msg: &str) -> String {
1993        format!("amendments:\n  - commit: {hash}\n    message: '{msg}'\n")
1994    }
1995
1996    #[tokio::test]
1997    async fn run_twiddle_with_client_dry_run_returns_amendments() {
1998        let (temp_dir, hash) = init_test_repo_with_commit();
1999
2000        let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
2001            &hash,
2002            "feat(cli): better subject",
2003        ))]);
2004        let client = ClaudeClient::new(Box::new(mock));
2005
2006        let outcome = run_twiddle_with_client(Some("HEAD"), true, temp_dir.path(), &client)
2007            .await
2008            .unwrap();
2009        assert!(!outcome.applied, "dry_run must not apply");
2010        assert_eq!(outcome.amendment_count, 1);
2011        assert!(outcome.amendments_yaml.contains("amendments:"));
2012    }
2013
2014    #[tokio::test]
2015    async fn run_twiddle_with_client_empty_range_returns_empty() {
2016        let (temp_dir, _hash) = init_test_repo_with_commit();
2017
2018        let mock = ConfigurableMockAiClient::new(vec![]);
2019        let client = ClaudeClient::new(Box::new(mock));
2020
2021        let outcome = run_twiddle_with_client(Some("HEAD..HEAD"), true, temp_dir.path(), &client)
2022            .await
2023            .unwrap();
2024        assert_eq!(outcome.amendment_count, 0);
2025        assert!(!outcome.applied);
2026    }
2027
2028    #[tokio::test]
2029    async fn run_twiddle_with_client_ai_failure_errors() {
2030        let (temp_dir, _hash) = init_test_repo_with_commit();
2031
2032        let mock = ConfigurableMockAiClient::new(vec![]);
2033        let client = ClaudeClient::new(Box::new(mock));
2034        let err = run_twiddle_with_client(Some("HEAD"), true, temp_dir.path(), &client)
2035            .await
2036            .unwrap_err();
2037        let _ = err;
2038    }
2039
2040    #[tokio::test]
2041    async fn run_twiddle_with_client_default_range_errors_on_sparse_repo() {
2042        let (temp_dir, _hash) = init_test_repo_with_commit();
2043
2044        // Default range HEAD~5..HEAD cannot resolve HEAD~5 in a repo with
2045        // only one commit — get_commits_in_range returns an error, which
2046        // propagates. This still exercises the default-range code path.
2047        let mock = ConfigurableMockAiClient::new(vec![]);
2048        let client = ClaudeClient::new(Box::new(mock));
2049
2050        let err = run_twiddle_with_client(None, true, temp_dir.path(), &client)
2051            .await
2052            .unwrap_err();
2053        assert!(
2054            format!("{err:#}").contains("HEAD~5")
2055                || format!("{err:#}").to_lowercase().contains("not found"),
2056            "expected HEAD~5 resolution error"
2057        );
2058    }
2059
2060    #[test]
2061    fn twiddle_outcome_clone_and_debug() {
2062        let outcome = TwiddleOutcome {
2063            amendments_yaml: "x".to_string(),
2064            applied: true,
2065            amendment_count: 2,
2066        };
2067        let cloned = outcome.clone();
2068        assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
2069    }
2070
2071    /// Exercises the apply-amendments path (`dry_run = false`). The amendment
2072    /// targets the repo's HEAD, so `AmendmentHandler` takes the fast
2073    /// `amend_head_commit` branch rather than interactive rebase.
2074    #[tokio::test]
2075    async fn run_twiddle_with_client_applies_head_amendment() {
2076        let (temp_dir, hash) = init_test_repo_with_commit();
2077
2078        let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
2079            &hash,
2080            "feat(cli): much better subject",
2081        ))]);
2082        let client = ClaudeClient::new(Box::new(mock));
2083
2084        let outcome = run_twiddle_with_client(Some("HEAD"), false, temp_dir.path(), &client)
2085            .await
2086            .unwrap();
2087        assert!(outcome.applied, "dry_run=false must apply amendments");
2088        assert_eq!(outcome.amendment_count, 1);
2089
2090        // Confirm the commit message was actually rewritten on HEAD.
2091        let repo = git2::Repository::open(temp_dir.path()).unwrap();
2092        let head_msg = repo
2093            .head()
2094            .unwrap()
2095            .peel_to_commit()
2096            .unwrap()
2097            .message()
2098            .unwrap()
2099            .to_string();
2100        assert!(
2101            head_msg.contains("much better subject"),
2102            "HEAD message should be rewritten: {head_msg}"
2103        );
2104    }
2105
2106    /// "No silent mix" guard: `run_twiddle_with_client` must amend the INJECTED
2107    /// repo, not the process CWD (the omni-dev checkout). We build a temp repo
2108    /// with a rewritable HEAD, leave the process CWD pointed at the omni-dev
2109    /// checkout, and assert the temp repo's HEAD was rewritten — proving the
2110    /// amendment anchored to the injected path rather than ambient CWD.
2111    #[tokio::test]
2112    async fn run_twiddle_with_client_targets_injected_repo_not_cwd() {
2113        let (temp_dir, hash) = init_test_repo_with_commit();
2114
2115        // Sanity-check: the process CWD is NOT the temp repo, so an amendment
2116        // that leaked to the ambient CWD would target the omni-dev checkout.
2117        let cwd = std::env::current_dir().unwrap();
2118        assert_ne!(
2119            cwd.canonicalize().unwrap(),
2120            temp_dir.path().canonicalize().unwrap(),
2121            "test precondition: process CWD must differ from the injected repo"
2122        );
2123
2124        let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
2125            &hash,
2126            "feat(cli): injected-repo subject",
2127        ))]);
2128        let client = ClaudeClient::new(Box::new(mock));
2129
2130        let outcome = run_twiddle_with_client(Some("HEAD"), false, temp_dir.path(), &client)
2131            .await
2132            .unwrap();
2133        assert!(outcome.applied, "dry_run=false must apply amendments");
2134        assert_eq!(outcome.amendment_count, 1);
2135
2136        // The injected repo's HEAD must carry the rewritten message.
2137        let repo = git2::Repository::open(temp_dir.path()).unwrap();
2138        let head_msg = repo
2139            .head()
2140            .unwrap()
2141            .peel_to_commit()
2142            .unwrap()
2143            .message()
2144            .unwrap()
2145            .to_string();
2146        assert!(
2147            head_msg.contains("injected-repo subject"),
2148            "injected repo HEAD must be rewritten: {head_msg}"
2149        );
2150    }
2151}
2152
2153#[cfg(test)]
2154#[allow(clippy::unwrap_used, clippy::expect_used)]
2155mod execute_tests {
2156    use super::*;
2157    use crate::claude::client::ClaudeClient;
2158    use crate::claude::test_utils::ConfigurableMockAiClient;
2159    use git2::{Repository, Signature};
2160
2161    /// Creates a tempdir-backed git repo with `n` commits on a linear
2162    /// history. Each commit writes a distinct file so diffs are non-empty.
2163    /// Returns the tempdir and the list of commit hashes (oldest-first).
2164    fn init_test_repo_with_n_commits(n: usize) -> (tempfile::TempDir, Vec<String>) {
2165        assert!(n >= 1);
2166        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
2167        std::fs::create_dir_all(&tmp_root).unwrap();
2168        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
2169        let repo = Repository::init(temp_dir.path()).unwrap();
2170        {
2171            let mut cfg = repo.config().unwrap();
2172            cfg.set_str("user.name", "Test").unwrap();
2173            cfg.set_str("user.email", "test@example.com").unwrap();
2174            // Disable commit signing in *local* config (overrides any ambient
2175            // global `commit.gpgsign=true`) so the `git commit --amend`
2176            // subprocess in the apply path is hermetic — it must not depend on
2177            // the process's `HOME`, which concurrent tests mutate (issue #950).
2178            cfg.set_bool("commit.gpgsign", false).unwrap();
2179        }
2180        let signature = Signature::now("Test", "test@example.com").unwrap();
2181
2182        let mut hashes = Vec::with_capacity(n);
2183        let mut parent_oid: Option<git2::Oid> = None;
2184
2185        for i in 0..n {
2186            let file = format!("f{i}.txt");
2187            std::fs::write(temp_dir.path().join(&file), format!("contents {i}")).unwrap();
2188            let mut idx = repo.index().unwrap();
2189            idx.add_path(std::path::Path::new(&file)).unwrap();
2190            idx.write().unwrap();
2191            let tree_id = idx.write_tree().unwrap();
2192            let tree = repo.find_tree(tree_id).unwrap();
2193            let msg = format!("feat: original commit {i}");
2194
2195            let oid = if let Some(parent) = parent_oid {
2196                let parent_commit = repo.find_commit(parent).unwrap();
2197                repo.commit(
2198                    Some("HEAD"),
2199                    &signature,
2200                    &signature,
2201                    &msg,
2202                    &tree,
2203                    &[&parent_commit],
2204                )
2205                .unwrap()
2206            } else {
2207                repo.commit(Some("HEAD"), &signature, &signature, &msg, &tree, &[])
2208                    .unwrap()
2209            };
2210            parent_oid = Some(oid);
2211            hashes.push(oid.to_string());
2212        }
2213
2214        (temp_dir, hashes)
2215    }
2216
2217    /// Creates a single-commit repo for the `execute_no_ai` test. Mirrors
2218    /// the helper in `run_twiddle_tests` so the two test modules stay
2219    /// self-contained.
2220    fn init_test_repo_with_commit() -> (tempfile::TempDir, String) {
2221        let (temp_dir, hashes) = init_test_repo_with_n_commits(1);
2222        (temp_dir, hashes.into_iter().next().unwrap())
2223    }
2224
2225    /// Builds a `TwiddleCommand` with all flags at sensible defaults for
2226    /// the AI-dispatch tests: non-contextual, save-only output, quiet.
2227    fn make_cmd(commit_range: &str, save_path: std::path::PathBuf) -> TwiddleCommand {
2228        TwiddleCommand {
2229            commit_range: Some(commit_range.to_string()),
2230            auto_apply: false,
2231            allow_pushed: false,
2232            save_only: Some(save_path.to_string_lossy().into_owned()),
2233            use_context: false,
2234            context_dir: None,
2235            work_context: None,
2236            branch_context: None,
2237            no_context: true,
2238            concurrency: 1,
2239            batch_size: None,
2240            no_coherence: true,
2241            no_ai: false,
2242            fresh: false,
2243            refine: false,
2244            check: false,
2245            quiet: true,
2246            ai: crate::cli::ai_backend_args::AiBackendArgs::default(),
2247        }
2248    }
2249
2250    /// Builds an amendments YAML block containing one entry per (hash, message, summary)
2251    /// triple. Mirrors what a successful AI batch response looks like.
2252    fn batch_amendment_yaml(entries: &[(&str, &str, &str)]) -> String {
2253        let mut out = String::from("amendments:\n");
2254        for (hash, msg, summary) in entries {
2255            out.push_str(&format!(
2256                "  - commit: {hash}\n    message: '{msg}'\n    summary: '{summary}'\n"
2257            ));
2258        }
2259        out
2260    }
2261
2262    /// Test 1 — happy path: a single batch response succeeds for a
2263    /// 2-commit range, exercising the success branch of
2264    /// `execute_with_map_reduce` (the `let summary = a.summary.clone();`
2265    /// at line 392 in the original `twiddle.rs`).
2266    #[tokio::test]
2267    async fn execute_with_client_multi_commit_batch_success_covers_line_392() {
2268        let (temp_dir, hashes) = init_test_repo_with_n_commits(3);
2269
2270        // Range yields the 2 most recent commits (oldest excluded).
2271        let h_mid = &hashes[1];
2272        let h_new = &hashes[2];
2273
2274        let yaml = batch_amendment_yaml(&[
2275            (h_mid, "feat: improved mid", "improved mid summary"),
2276            (h_new, "feat: improved new", "improved new summary"),
2277        ]);
2278        let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
2279        let response_handle = mock.response_handle();
2280        let prompt_handle = mock.prompt_handle();
2281        let client = ClaudeClient::new(Box::new(mock));
2282
2283        let save_path = temp_dir.path().join("amendments.yaml");
2284        let cmd = make_cmd("HEAD~2..HEAD", save_path.clone());
2285
2286        cmd.execute_with_client(temp_dir.path(), client)
2287            .await
2288            .unwrap();
2289
2290        // Single batch dispatch, single AI request.
2291        assert_eq!(response_handle.remaining(), 0);
2292        assert_eq!(prompt_handle.request_count(), 1);
2293
2294        // Verify the saved file contains both amendments with summaries
2295        // intact (proving line 392 ran for each amendment).
2296        let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2297        assert_eq!(saved.amendments.len(), 2);
2298        let summaries: Vec<&str> = saved
2299            .amendments
2300            .iter()
2301            .map(|a| a.summary.as_str())
2302            .collect();
2303        assert!(
2304            summaries.contains(&"improved mid summary"),
2305            "summaries: {summaries:?}"
2306        );
2307        assert!(
2308            summaries.contains(&"improved new summary"),
2309            "summaries: {summaries:?}"
2310        );
2311    }
2312
2313    /// Test 2 — split-and-retry: the initial multi-commit batch fails
2314    /// (consuming 3 mock responses to exhaust `AMENDMENT_PARSE_MAX_RETRIES`),
2315    /// then per-commit retries succeed. Exercises the `batch_size > 1`
2316    /// failure branch (the `let summary = a.summary.clone();` at line 424
2317    /// in the original `twiddle.rs`).
2318    #[tokio::test]
2319    async fn execute_with_client_multi_commit_split_retry_covers_line_424() {
2320        let (temp_dir, hashes) = init_test_repo_with_n_commits(3);
2321
2322        let h_mid = &hashes[1];
2323        let h_new = &hashes[2];
2324
2325        // 3 Errs exhaust the batch retry loop, then one Ok per individual
2326        // retry. Each retry has its own retry budget but succeeds on first
2327        // attempt, so it consumes one response.
2328        let mock = ConfigurableMockAiClient::new(vec![
2329            Err(anyhow::anyhow!("simulated batch failure 1")),
2330            Err(anyhow::anyhow!("simulated batch failure 2")),
2331            Err(anyhow::anyhow!("simulated batch failure 3")),
2332            Ok(batch_amendment_yaml(&[(
2333                h_mid,
2334                "feat: solo mid",
2335                "solo mid summary",
2336            )])),
2337            Ok(batch_amendment_yaml(&[(
2338                h_new,
2339                "feat: solo new",
2340                "solo new summary",
2341            )])),
2342        ]);
2343        let response_handle = mock.response_handle();
2344        let prompt_handle = mock.prompt_handle();
2345        let client = ClaudeClient::new(Box::new(mock));
2346
2347        let save_path = temp_dir.path().join("amendments.yaml");
2348        let cmd = make_cmd("HEAD~2..HEAD", save_path.clone());
2349
2350        cmd.execute_with_client(temp_dir.path(), client)
2351            .await
2352            .unwrap();
2353
2354        // 3 batch attempts + 2 individual retries = 5 AI requests.
2355        assert_eq!(response_handle.remaining(), 0);
2356        assert_eq!(prompt_handle.request_count(), 5);
2357
2358        let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2359        assert_eq!(saved.amendments.len(), 2);
2360        let summaries: Vec<&str> = saved
2361            .amendments
2362            .iter()
2363            .map(|a| a.summary.as_str())
2364            .collect();
2365        assert!(
2366            summaries.contains(&"solo mid summary"),
2367            "summaries: {summaries:?}"
2368        );
2369        assert!(
2370            summaries.contains(&"solo new summary"),
2371            "summaries: {summaries:?}"
2372        );
2373    }
2374
2375    /// Builds a `--no-ai`, save-only command over `HEAD`.
2376    fn make_no_ai_cmd(save_path: &std::path::Path) -> TwiddleCommand {
2377        TwiddleCommand {
2378            no_ai: true,
2379            ..make_cmd("HEAD", save_path.to_path_buf())
2380        }
2381    }
2382
2383    /// Rewrites `HEAD`'s message in place and returns the new commit hash.
2384    fn set_head_message(repo_root: &std::path::Path, message: &str) -> String {
2385        let repo = Repository::open(repo_root).unwrap();
2386        let head = repo.head().unwrap().peel_to_commit().unwrap();
2387        head.amend(Some("HEAD"), None, None, None, Some(message), None)
2388            .unwrap()
2389            .to_string()
2390    }
2391
2392    /// Test 3 — `--no-ai` suggests a deterministic message instead of
2393    /// re-saving the original (#1566): a non-conventional subject gains the
2394    /// detected type, the scope from `scopes.yaml`, and keeps its body.
2395    #[tokio::test]
2396    async fn execute_no_ai_save_only_suggests_type_and_scope() {
2397        let (temp_dir, _) = init_test_repo_with_commit();
2398        let hash = set_head_message(temp_dir.path(), "add widget\n\nWhy it matters.\n");
2399        let context_dir = temp_dir.path().join(".omni-dev");
2400        std::fs::create_dir_all(&context_dir).unwrap();
2401        std::fs::write(
2402            context_dir.join("scopes.yaml"),
2403            "scopes:\n  - name: widgets\n    description: Widgets\n    examples: []\n    file_patterns:\n      - f0.txt\n",
2404        )
2405        .unwrap();
2406
2407        let save_path = temp_dir.path().join("amendments.yaml");
2408        make_no_ai_cmd(&save_path)
2409            .execute(Some(temp_dir.path()))
2410            .await
2411            .unwrap();
2412
2413        let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2414        assert_eq!(saved.amendments.len(), 1);
2415        let amendment = &saved.amendments[0];
2416        assert_eq!(amendment.commit, hash);
2417        assert_eq!(
2418            amendment.message,
2419            "chore(widgets): add widget\n\nWhy it matters.\n"
2420        );
2421        assert_eq!(amendment.summary, "");
2422    }
2423
2424    /// Test 4 — `--no-ai` leaves a conformant commit alone: `--save-only`
2425    /// writes an empty amendments file, and an applying run does not
2426    /// rewrite the commit.
2427    #[tokio::test]
2428    async fn execute_no_ai_skips_conforming_commits() {
2429        let (temp_dir, hash) = init_test_repo_with_commit();
2430        let save_path = temp_dir.path().join("amendments.yaml");
2431
2432        make_no_ai_cmd(&save_path)
2433            .execute(Some(temp_dir.path()))
2434            .await
2435            .unwrap();
2436        let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2437        assert!(saved.amendments.is_empty());
2438
2439        let apply = TwiddleCommand {
2440            auto_apply: true,
2441            save_only: None,
2442            ..make_no_ai_cmd(&save_path)
2443        };
2444        apply.execute(Some(temp_dir.path())).await.unwrap();
2445
2446        let repo = Repository::open(temp_dir.path()).unwrap();
2447        let head = repo.head().unwrap().peel_to_commit().unwrap();
2448        assert_eq!(head.id().to_string(), hash, "HEAD must not be rewritten");
2449    }
2450
2451    /// Test 5 — `deterministic_amendments` keeps only the commits whose
2452    /// suggestion differs from their current message.
2453    #[test]
2454    fn deterministic_amendments_filters_unchanged_commits() {
2455        let (temp_dir, hashes) = init_test_repo_with_n_commits(2);
2456        let changed = set_head_message(temp_dir.path(), "tidy things");
2457        let cmd = make_cmd("HEAD~1..HEAD", temp_dir.path().join("unused.yaml"));
2458        let repo_view = cmd.generate_repository_view(temp_dir.path()).unwrap();
2459
2460        let amendments = deterministic_amendments(&repo_view, &[]);
2461
2462        assert_eq!(amendments.len(), 1, "hashes: {hashes:?}");
2463        assert_eq!(amendments[0].commit, changed);
2464        assert_eq!(amendments[0].message, "chore: tidy things");
2465    }
2466}
2467
2468// --- Extracted pure functions ---
2469
2470/// Formats a work pattern as a display label with emoji.
2471///
2472/// Returns `None` for `WorkPattern::Unknown` since it should not be displayed.
2473fn format_work_pattern(pattern: &crate::data::context::WorkPattern) -> Option<&'static str> {
2474    use crate::data::context::WorkPattern;
2475    match pattern {
2476        WorkPattern::Sequential => Some("\u{1f504} Pattern: Sequential development"),
2477        WorkPattern::Refactoring => Some("\u{1f9f9} Pattern: Refactoring work"),
2478        WorkPattern::BugHunt => Some("\u{1f41b} Pattern: Bug investigation"),
2479        WorkPattern::Documentation => Some("\u{1f4d6} Pattern: Documentation updates"),
2480        WorkPattern::Configuration => Some("\u{2699}\u{fe0f}  Pattern: Configuration changes"),
2481        WorkPattern::Unknown => None,
2482    }
2483}
2484
2485/// Formats a verbosity level as a display label with emoji.
2486fn format_verbosity_level(level: crate::data::context::VerbosityLevel) -> &'static str {
2487    use crate::data::context::VerbosityLevel;
2488    match level {
2489        VerbosityLevel::Comprehensive => {
2490            "\u{1f4dd} Detail level: Comprehensive (significant changes detected)"
2491        }
2492        VerbosityLevel::Detailed => "\u{1f4dd} Detail level: Detailed",
2493        VerbosityLevel::Concise => "\u{1f4dd} Detail level: Concise",
2494    }
2495}
2496
2497/// Formats a list of scope definitions as a comma-separated string of names.
2498fn format_scope_list(scopes: &[crate::data::context::ScopeDefinition]) -> String {
2499    scopes
2500        .iter()
2501        .map(|s| s.name.as_str())
2502        .collect::<Vec<_>>()
2503        .join(", ")
2504}
2505
2506/// Resolves duplicate amendments (same commit hash) by prompting the user, or
2507/// silently picking the first occurrence when `auto_pick` is set.
2508///
2509/// Models occasionally return the same amendment twice with slight formatting
2510/// variations (issue #697). The apply path can't tolerate duplicates: the
2511/// first amendment rewrites the commit, leaving subsequent amendments
2512/// pointing at a hash that no longer exists in branch history.
2513fn resolve_duplicate_amendments(
2514    amendments: &mut AmendmentFile,
2515    auto_pick: bool,
2516    is_terminal: bool,
2517    reader: &mut (dyn std::io::BufRead + Send),
2518) -> Result<()> {
2519    use std::collections::hash_map::Entry;
2520    use std::collections::{HashMap, HashSet};
2521    use std::io::{self, Write};
2522
2523    if amendments.amendments.len() < 2 {
2524        return Ok(());
2525    }
2526
2527    let mut order: Vec<String> = Vec::new();
2528    let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
2529    for (i, a) in amendments.amendments.iter().enumerate() {
2530        match groups.entry(a.commit.clone()) {
2531            Entry::Vacant(slot) => {
2532                order.push(a.commit.clone());
2533                slot.insert(vec![i]);
2534            }
2535            Entry::Occupied(mut slot) => {
2536                slot.get_mut().push(i);
2537            }
2538        }
2539    }
2540
2541    if groups.values().all(|v| v.len() <= 1) {
2542        return Ok(());
2543    }
2544
2545    let mut drop_indices: HashSet<usize> = HashSet::new();
2546
2547    for hash in &order {
2548        let Some(idxs) = groups.get(hash) else {
2549            continue;
2550        };
2551        if idxs.len() <= 1 {
2552            continue;
2553        }
2554
2555        let short = &hash[..crate::git::SHORT_HASH_LEN.min(hash.len())];
2556
2557        let chosen = if auto_pick {
2558            eprintln!(
2559                "warning: model returned {} duplicate amendments for commit {short}; \
2560                 keeping the first.",
2561                idxs.len()
2562            );
2563            idxs[0]
2564        } else if !is_terminal {
2565            eprintln!(
2566                "warning: model returned {} duplicate amendments for commit {short}; \
2567                 stdin not interactive — keeping the first.",
2568                idxs.len()
2569            );
2570            idxs[0]
2571        } else {
2572            println!(
2573                "\n⚠️  Model returned {} duplicate amendments for commit {short}:",
2574                idxs.len()
2575            );
2576            for (n, &i) in idxs.iter().enumerate() {
2577                println!("\n  [{}] -----", n + 1);
2578                for line in amendments.amendments[i].message.lines() {
2579                    println!("      {line}");
2580                }
2581            }
2582            println!();
2583
2584            loop {
2585                print!(
2586                    "❓ Which amendment to apply? [1-{}] (default 1) ",
2587                    idxs.len()
2588                );
2589                io::stdout().flush()?;
2590
2591                let Some(input) = super::read_interactive_line(reader)? else {
2592                    eprintln!("warning: stdin closed; keeping the first amendment.");
2593                    break idxs[0];
2594                };
2595
2596                let trimmed = input.trim();
2597                if trimmed.is_empty() {
2598                    break idxs[0];
2599                }
2600                match trimmed.parse::<usize>() {
2601                    Ok(n) if (1..=idxs.len()).contains(&n) => break idxs[n - 1],
2602                    _ => println!(
2603                        "Invalid choice. Please enter a number between 1 and {}.",
2604                        idxs.len()
2605                    ),
2606                }
2607            }
2608        };
2609
2610        for &i in idxs {
2611            if i != chosen {
2612                drop_indices.insert(i);
2613            }
2614        }
2615    }
2616
2617    let kept: Vec<_> = std::mem::take(&mut amendments.amendments)
2618        .into_iter()
2619        .enumerate()
2620        .filter(|(i, _)| !drop_indices.contains(i))
2621        .map(|(_, a)| a)
2622        .collect();
2623    amendments.amendments = kept;
2624
2625    Ok(())
2626}
2627
2628/// Builds the `--no-ai` amendments: one per commit whose deterministic
2629/// suggestion differs from its current message.
2630fn deterministic_amendments(
2631    repo_view: &RepositoryView,
2632    scope_defs: &[crate::data::context::ScopeDefinition],
2633) -> Vec<crate::data::amendments::Amendment> {
2634    repo_view
2635        .commits
2636        .iter()
2637        .filter_map(|commit| {
2638            let message = commit
2639                .analysis
2640                .suggest_message(&commit.original_message, scope_defs);
2641            (message.trim_end() != commit.original_message.trim_end()).then(|| {
2642                crate::data::amendments::Amendment {
2643                    commit: commit.hash.clone(),
2644                    message,
2645                    summary: String::new(),
2646                }
2647            })
2648        })
2649        .collect()
2650}
2651
2652/// Refine scopes in generated amendment messages using the same deterministic
2653/// file-pattern logic the checker uses, so generator and checker agree.
2654fn refine_amendment_scopes(
2655    amendments: &mut AmendmentFile,
2656    repo_view: &RepositoryView,
2657    scope_defs: &[crate::data::context::ScopeDefinition],
2658) {
2659    for amendment in &mut amendments.amendments {
2660        if let Some(commit) = repo_view
2661            .commits
2662            .iter()
2663            .find(|c| c.hash == amendment.commit)
2664        {
2665            let files: Vec<&str> = commit
2666                .analysis
2667                .file_changes
2668                .file_list
2669                .iter()
2670                .map(|f| f.file.as_str())
2671                .collect();
2672            amendment.message =
2673                crate::git::refine_message_scope(&amendment.message, &files, scope_defs);
2674        }
2675    }
2676}
2677
2678#[cfg(test)]
2679#[allow(clippy::unwrap_used, clippy::expect_used)]
2680mod tests {
2681    use super::*;
2682    use crate::data::context::{ScopeDefinition, VerbosityLevel, WorkPattern};
2683
2684    // --- format_work_pattern ---
2685
2686    #[test]
2687    fn work_pattern_sequential() {
2688        let result = format_work_pattern(&WorkPattern::Sequential);
2689        assert!(result.is_some());
2690        assert!(result.unwrap().contains("Sequential development"));
2691    }
2692
2693    #[test]
2694    fn work_pattern_refactoring() {
2695        let result = format_work_pattern(&WorkPattern::Refactoring);
2696        assert!(result.is_some());
2697        assert!(result.unwrap().contains("Refactoring work"));
2698    }
2699
2700    #[test]
2701    fn work_pattern_bug_hunt() {
2702        let result = format_work_pattern(&WorkPattern::BugHunt);
2703        assert!(result.is_some());
2704        assert!(result.unwrap().contains("Bug investigation"));
2705    }
2706
2707    #[test]
2708    fn work_pattern_docs() {
2709        let result = format_work_pattern(&WorkPattern::Documentation);
2710        assert!(result.is_some());
2711        assert!(result.unwrap().contains("Documentation updates"));
2712    }
2713
2714    #[test]
2715    fn work_pattern_config() {
2716        let result = format_work_pattern(&WorkPattern::Configuration);
2717        assert!(result.is_some());
2718        assert!(result.unwrap().contains("Configuration changes"));
2719    }
2720
2721    #[test]
2722    fn work_pattern_unknown() {
2723        assert!(format_work_pattern(&WorkPattern::Unknown).is_none());
2724    }
2725
2726    // --- format_verbosity_level ---
2727
2728    #[test]
2729    fn verbosity_comprehensive() {
2730        let label = format_verbosity_level(VerbosityLevel::Comprehensive);
2731        assert!(label.contains("Comprehensive"));
2732        assert!(label.contains("significant changes"));
2733    }
2734
2735    #[test]
2736    fn verbosity_detailed() {
2737        let label = format_verbosity_level(VerbosityLevel::Detailed);
2738        assert!(label.contains("Detailed"));
2739    }
2740
2741    #[test]
2742    fn verbosity_concise() {
2743        let label = format_verbosity_level(VerbosityLevel::Concise);
2744        assert!(label.contains("Concise"));
2745    }
2746
2747    // --- format_scope_list ---
2748
2749    #[test]
2750    fn scope_list_single() {
2751        let scopes = vec![ScopeDefinition {
2752            name: "cli".to_string(),
2753            description: String::new(),
2754            examples: vec![],
2755            file_patterns: vec![],
2756        }];
2757        assert_eq!(format_scope_list(&scopes), "cli");
2758    }
2759
2760    #[test]
2761    fn scope_list_multiple() {
2762        let scopes = vec![
2763            ScopeDefinition {
2764                name: "cli".to_string(),
2765                description: String::new(),
2766                examples: vec![],
2767                file_patterns: vec![],
2768            },
2769            ScopeDefinition {
2770                name: "git".to_string(),
2771                description: String::new(),
2772                examples: vec![],
2773                file_patterns: vec![],
2774            },
2775            ScopeDefinition {
2776                name: "docs".to_string(),
2777                description: String::new(),
2778                examples: vec![],
2779                file_patterns: vec![],
2780            },
2781        ];
2782        assert_eq!(format_scope_list(&scopes), "cli, git, docs");
2783    }
2784
2785    // --- resolve_context_dir ---
2786
2787    #[test]
2788    fn context_dir_default() {
2789        let result = crate::claude::context::resolve_context_dir(None);
2790        // Walk-up may find .omni-dev in the real repo, or fall back to ".omni-dev"
2791        assert!(
2792            result.ends_with(".omni-dev"),
2793            "expected path ending in .omni-dev, got {result:?}"
2794        );
2795    }
2796
2797    #[test]
2798    fn context_dir_override() {
2799        let custom = std::path::PathBuf::from("custom-dir");
2800        let result = crate::claude::context::resolve_context_dir(Some(&custom));
2801        assert_eq!(result, custom);
2802    }
2803
2804    // --- is_fresh ---
2805
2806    fn parse_twiddle(args: &[&str]) -> TwiddleCommand {
2807        let mut full_args = vec!["twiddle"];
2808        full_args.extend_from_slice(args);
2809        TwiddleCommand::try_parse_from(full_args).unwrap()
2810    }
2811
2812    #[test]
2813    fn default_is_fresh() {
2814        let cmd = parse_twiddle(&[]);
2815        assert!(cmd.is_fresh(), "default should be fresh mode");
2816    }
2817
2818    #[test]
2819    fn refine_disables_fresh() {
2820        let cmd = parse_twiddle(&["--refine"]);
2821        assert!(!cmd.is_fresh(), "--refine should disable fresh mode");
2822    }
2823
2824    #[test]
2825    fn explicit_fresh_is_fresh() {
2826        let cmd = parse_twiddle(&["--fresh"]);
2827        assert!(cmd.is_fresh(), "--fresh should be fresh mode");
2828    }
2829
2830    #[test]
2831    fn fresh_and_refine_conflict() {
2832        let result = TwiddleCommand::try_parse_from(["twiddle", "--fresh", "--refine"]);
2833        assert!(result.is_err(), "--fresh and --refine should conflict");
2834    }
2835
2836    // --- check_commits_map_reduce (success paths via mock client) ---
2837
2838    fn make_twiddle_cmd() -> TwiddleCommand {
2839        TwiddleCommand {
2840            commit_range: None,
2841            auto_apply: false,
2842            allow_pushed: false,
2843            save_only: None,
2844            use_context: false,
2845            context_dir: None,
2846            work_context: None,
2847            branch_context: None,
2848            no_context: true,
2849            concurrency: 4,
2850            batch_size: None,
2851            no_coherence: true,
2852            no_ai: false,
2853            fresh: false,
2854            refine: false,
2855            check: false,
2856            quiet: false,
2857            ai: crate::cli::ai_backend_args::AiBackendArgs::default(),
2858        }
2859    }
2860
2861    fn make_twiddle_commit(hash: &str) -> (crate::git::CommitInfo, tempfile::NamedTempFile) {
2862        use crate::git::commit::FileChanges;
2863        use crate::git::{CommitAnalysis, CommitInfo};
2864        let tmp = tempfile::NamedTempFile::new().unwrap();
2865        let commit = CommitInfo {
2866            hash: hash.to_string(),
2867            author: "Test <test@test.com>".to_string(),
2868            date: chrono::Utc::now().fixed_offset(),
2869            original_message: format!("feat: commit {hash}"),
2870            in_main_branches: vec![],
2871            analysis: CommitAnalysis {
2872                detected_type: "feat".to_string(),
2873                detected_scope: String::new(),
2874                proposed_message: format!("feat: commit {hash}"),
2875                file_changes: FileChanges {
2876                    total_files: 0,
2877                    files_added: 0,
2878                    files_deleted: 0,
2879                    file_list: vec![],
2880                },
2881                diff_summary: String::new(),
2882                diff_file: tmp.path().to_string_lossy().to_string(),
2883                file_diffs: Vec::new(),
2884            },
2885        };
2886        (commit, tmp)
2887    }
2888
2889    fn make_twiddle_repo_view(commits: Vec<crate::git::CommitInfo>) -> crate::data::RepositoryView {
2890        use crate::data::{AiInfo, FieldExplanation, RepositoryView, WorkingDirectoryInfo};
2891        RepositoryView {
2892            versions: None,
2893            explanation: FieldExplanation::default(),
2894            working_directory: WorkingDirectoryInfo {
2895                clean: true,
2896                untracked_changes: vec![],
2897            },
2898            remotes: vec![],
2899            ai: AiInfo {
2900                scratch: String::new(),
2901            },
2902            branch_info: None,
2903            pr_template: None,
2904            pr_template_location: None,
2905            branch_prs: None,
2906            commits,
2907        }
2908    }
2909
2910    fn twiddle_check_yaml(hash: &str) -> String {
2911        format!("checks:\n  - commit: {hash}\n    passes: true\n    issues: []\n")
2912    }
2913
2914    fn make_mock_client(
2915        responses: Vec<anyhow::Result<String>>,
2916    ) -> crate::claude::client::ClaudeClient {
2917        crate::claude::client::ClaudeClient::new(Box::new(
2918            crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2919        ))
2920    }
2921
2922    #[tokio::test]
2923    async fn check_commits_map_reduce_single_commit_succeeds() {
2924        // Happy path: one commit, batch succeeds on first attempt.
2925        let (commit, _tmp) = make_twiddle_commit("abc00000");
2926        let cmd = make_twiddle_cmd();
2927        let repo_view = make_twiddle_repo_view(vec![commit]);
2928        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2929        let result = cmd
2930            .check_commits_map_reduce(&client, &repo_view, None, &[])
2931            .await;
2932        assert!(result.is_ok());
2933        assert_eq!(result.unwrap().commits.len(), 1);
2934    }
2935
2936    #[tokio::test]
2937    async fn check_commits_map_reduce_batch_fails_split_retry_both_succeed() {
2938        // Two commits in one batch. Batch fails (3 retries), then each commit
2939        // succeeds individually via split-and-retry. No stdin interaction since
2940        // failed_indices stays empty after both retries succeed.
2941        let (c1, _t1) = make_twiddle_commit("abc00000");
2942        let (c2, _t2) = make_twiddle_commit("def00000");
2943        let cmd = make_twiddle_cmd();
2944        let repo_view = make_twiddle_repo_view(vec![c1, c2]);
2945        let mut responses: Vec<anyhow::Result<String>> =
2946            (0..3).map(|_| Err(anyhow::anyhow!("batch fail"))).collect();
2947        responses.push(Ok(twiddle_check_yaml("abc00000")));
2948        responses.push(Ok(twiddle_check_yaml("def00000")));
2949        let client = make_mock_client(responses);
2950        let result = cmd
2951            .check_commits_map_reduce(&client, &repo_view, None, &[])
2952            .await;
2953        assert!(result.is_ok());
2954        assert_eq!(result.unwrap().commits.len(), 2);
2955    }
2956
2957    // --- run_interactive_retry_twiddle_check ---
2958
2959    #[tokio::test]
2960    async fn interactive_retry_twiddle_skip_immediately() {
2961        // "s" input → loop exits without calling the AI client at all.
2962        let (commit, _tmp) = make_twiddle_commit("abc00000");
2963        let cmd = make_twiddle_cmd();
2964        let repo_view = make_twiddle_repo_view(vec![commit]);
2965        let client = make_mock_client(vec![]);
2966        let mut failed = vec![0usize];
2967        let mut successes = vec![];
2968        let mut stdin = std::io::Cursor::new(b"s\n" as &[u8]);
2969        cmd.run_interactive_retry_twiddle_check(
2970            &mut failed,
2971            &repo_view,
2972            &client,
2973            None,
2974            &[],
2975            &mut successes,
2976            &mut stdin,
2977        )
2978        .await
2979        .unwrap();
2980        assert_eq!(
2981            failed,
2982            vec![0],
2983            "skip should leave failed_indices unchanged"
2984        );
2985        assert!(successes.is_empty());
2986    }
2987
2988    #[tokio::test]
2989    async fn interactive_retry_twiddle_retry_succeeds() {
2990        // "r" input → retries the failed commit, which succeeds.
2991        let (commit, _tmp) = make_twiddle_commit("abc00000");
2992        let cmd = make_twiddle_cmd();
2993        let repo_view = make_twiddle_repo_view(vec![commit]);
2994        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2995        let mut failed = vec![0usize];
2996        let mut successes = vec![];
2997        let mut stdin = std::io::Cursor::new(b"r\n" as &[u8]);
2998        cmd.run_interactive_retry_twiddle_check(
2999            &mut failed,
3000            &repo_view,
3001            &client,
3002            None,
3003            &[],
3004            &mut successes,
3005            &mut stdin,
3006        )
3007        .await
3008        .unwrap();
3009        assert!(
3010            failed.is_empty(),
3011            "retry succeeded → failed_indices cleared"
3012        );
3013        assert_eq!(successes.len(), 1);
3014    }
3015
3016    #[tokio::test]
3017    async fn interactive_retry_twiddle_default_input_retries() {
3018        // Empty input (just Enter) is treated as "r" (retry).
3019        let (commit, _tmp) = make_twiddle_commit("abc00000");
3020        let cmd = make_twiddle_cmd();
3021        let repo_view = make_twiddle_repo_view(vec![commit]);
3022        let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
3023        let mut failed = vec![0usize];
3024        let mut successes = vec![];
3025        let mut stdin = std::io::Cursor::new(b"\n" as &[u8]);
3026        cmd.run_interactive_retry_twiddle_check(
3027            &mut failed,
3028            &repo_view,
3029            &client,
3030            None,
3031            &[],
3032            &mut successes,
3033            &mut stdin,
3034        )
3035        .await
3036        .unwrap();
3037        assert!(failed.is_empty());
3038        assert_eq!(successes.len(), 1);
3039    }
3040
3041    #[tokio::test]
3042    async fn interactive_retry_twiddle_still_fails_then_skip() {
3043        // "r" → retry fails → still in failed_indices → "s" → skip.
3044        let (commit, _tmp) = make_twiddle_commit("abc00000");
3045        let cmd = make_twiddle_cmd();
3046        let repo_view = make_twiddle_repo_view(vec![commit]);
3047        // Retry attempt hits max_retries=2 (3 total attempts).
3048        let responses = (0..3).map(|_| Err(anyhow::anyhow!("mock fail"))).collect();
3049        let client = make_mock_client(responses);
3050        let mut failed = vec![0usize];
3051        let mut successes = vec![];
3052        let mut stdin = std::io::Cursor::new(b"r\ns\n" as &[u8]);
3053        cmd.run_interactive_retry_twiddle_check(
3054            &mut failed,
3055            &repo_view,
3056            &client,
3057            None,
3058            &[],
3059            &mut successes,
3060            &mut stdin,
3061        )
3062        .await
3063        .unwrap();
3064        assert_eq!(failed, vec![0], "commit still failed after retry");
3065        assert!(successes.is_empty());
3066    }
3067
3068    #[tokio::test]
3069    async fn interactive_retry_twiddle_invalid_input_then_skip() {
3070        // Unrecognised input → "please enter r or s" message → "s" exits.
3071        let (commit, _tmp) = make_twiddle_commit("abc00000");
3072        let cmd = make_twiddle_cmd();
3073        let repo_view = make_twiddle_repo_view(vec![commit]);
3074        let client = make_mock_client(vec![]);
3075        let mut failed = vec![0usize];
3076        let mut successes = vec![];
3077        let mut stdin = std::io::Cursor::new(b"x\ns\n" as &[u8]);
3078        cmd.run_interactive_retry_twiddle_check(
3079            &mut failed,
3080            &repo_view,
3081            &client,
3082            None,
3083            &[],
3084            &mut successes,
3085            &mut stdin,
3086        )
3087        .await
3088        .unwrap();
3089        assert_eq!(failed, vec![0]);
3090        assert!(successes.is_empty());
3091    }
3092
3093    #[tokio::test]
3094    async fn interactive_retry_twiddle_eof_breaks_immediately() {
3095        // EOF (empty reader) → read_line returns Ok(0) → loop breaks without
3096        // calling the AI client. failed_indices stays unchanged.
3097        let (commit, _tmp) = make_twiddle_commit("abc00000");
3098        let cmd = make_twiddle_cmd();
3099        let repo_view = make_twiddle_repo_view(vec![commit]);
3100        let client = make_mock_client(vec![]); // no responses consumed
3101        let mut failed = vec![0usize];
3102        let mut successes = vec![];
3103        let mut stdin = std::io::Cursor::new(b"" as &[u8]);
3104        cmd.run_interactive_retry_twiddle_check(
3105            &mut failed,
3106            &repo_view,
3107            &client,
3108            None,
3109            &[],
3110            &mut successes,
3111            &mut stdin,
3112        )
3113        .await
3114        .unwrap();
3115        assert_eq!(failed, vec![0], "EOF should leave failed_indices unchanged");
3116        assert!(successes.is_empty());
3117    }
3118
3119    // --- handle_amendments_file ---
3120
3121    fn make_amendment_file() -> crate::data::amendments::AmendmentFile {
3122        crate::data::amendments::AmendmentFile {
3123            amendments: vec![crate::data::amendments::Amendment {
3124                commit: "abc0000000000000000000000000000000000001".to_string(),
3125                message: "feat: improved commit message".to_string(),
3126                summary: String::new(),
3127            }],
3128        }
3129    }
3130
3131    #[test]
3132    fn handle_amendments_file_non_terminal_returns_false() {
3133        // is_terminal=false → non-interactive warning, returns Ok(false) immediately.
3134        let cmd = make_twiddle_cmd();
3135        let amendments = make_amendment_file();
3136        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3137        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3138        let result = cmd
3139            .handle_amendments_file(dummy_path, &amendments, false, &mut reader)
3140            .unwrap();
3141        assert!(!result, "non-terminal should return false");
3142    }
3143
3144    #[test]
3145    fn handle_amendments_file_eof_returns_false() {
3146        // is_terminal=true, EOF reader → read_line returns 0, returns Ok(false).
3147        let cmd = make_twiddle_cmd();
3148        let amendments = make_amendment_file();
3149        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3150        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3151        let result = cmd
3152            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3153            .unwrap();
3154        assert!(!result, "EOF should return false");
3155    }
3156
3157    #[test]
3158    fn handle_amendments_file_quit_returns_false() {
3159        // is_terminal=true, "q\n" → user quits, returns Ok(false).
3160        let cmd = make_twiddle_cmd();
3161        let amendments = make_amendment_file();
3162        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3163        let mut reader = std::io::Cursor::new(b"q\n" as &[u8]);
3164        let result = cmd
3165            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3166            .unwrap();
3167        assert!(!result, "quit should return false");
3168    }
3169
3170    #[test]
3171    fn handle_amendments_file_apply_returns_true() {
3172        // is_terminal=true, "a\n" → user applies, returns Ok(true).
3173        let cmd = make_twiddle_cmd();
3174        let amendments = make_amendment_file();
3175        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3176        let mut reader = std::io::Cursor::new(b"a\n" as &[u8]);
3177        let result = cmd
3178            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3179            .unwrap();
3180        assert!(result, "apply should return true");
3181    }
3182
3183    #[test]
3184    fn handle_amendments_file_invalid_then_quit_returns_false() {
3185        // is_terminal=true, invalid input then "q\n" → prints error, then user quits.
3186        let cmd = make_twiddle_cmd();
3187        let amendments = make_amendment_file();
3188        let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3189        let mut reader = std::io::Cursor::new(b"x\nq\n" as &[u8]);
3190        let result = cmd
3191            .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3192            .unwrap();
3193        assert!(!result, "invalid then quit should return false");
3194    }
3195
3196    // --- run_interactive_retry_generate_amendments ---
3197
3198    /// Full 40-char hex hash used for amendment retry tests (validation requires ≥40 chars).
3199    const HASH_40: &str = "abc0000000000000000000000000000000000000";
3200
3201    fn twiddle_amendment_yaml(hash: &str) -> String {
3202        format!("amendments:\n  - commit: \"{hash}\"\n    message: \"feat: improved message\"\n")
3203    }
3204
3205    #[tokio::test]
3206    async fn retry_generate_amendments_non_terminal_returns_immediately() {
3207        // is_terminal=false → warning printed, returns Ok(()) without prompting.
3208        let (commit, _tmp) = make_twiddle_commit("abc00000");
3209        let cmd = make_twiddle_cmd();
3210        let repo_view = make_twiddle_repo_view(vec![commit]);
3211        let client = make_mock_client(vec![]); // no calls expected
3212        let mut failed = vec![0usize];
3213        let mut successes = vec![];
3214        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3215        cmd.run_interactive_retry_generate_amendments(
3216            &mut failed,
3217            &repo_view,
3218            &client,
3219            None,
3220            false,
3221            &mut successes,
3222            false, // is_terminal
3223            &mut reader,
3224        )
3225        .await
3226        .unwrap();
3227        assert_eq!(
3228            failed,
3229            vec![0],
3230            "non-terminal should leave failed unchanged"
3231        );
3232        assert!(successes.is_empty());
3233    }
3234
3235    #[tokio::test]
3236    async fn retry_generate_amendments_eof_breaks_immediately() {
3237        // is_terminal=true, EOF → read_line returns 0 → breaks without AI calls.
3238        let (commit, _tmp) = make_twiddle_commit("abc00000");
3239        let cmd = make_twiddle_cmd();
3240        let repo_view = make_twiddle_repo_view(vec![commit]);
3241        let client = make_mock_client(vec![]); // no calls expected
3242        let mut failed = vec![0usize];
3243        let mut successes = vec![];
3244        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3245        cmd.run_interactive_retry_generate_amendments(
3246            &mut failed,
3247            &repo_view,
3248            &client,
3249            None,
3250            false,
3251            &mut successes,
3252            true, // is_terminal
3253            &mut reader,
3254        )
3255        .await
3256        .unwrap();
3257        assert_eq!(failed, vec![0], "EOF should leave failed unchanged");
3258        assert!(successes.is_empty());
3259    }
3260
3261    #[tokio::test]
3262    async fn retry_generate_amendments_skip_breaks_immediately() {
3263        // is_terminal=true, "s\n" → user skips, failed stays unchanged.
3264        let (commit, _tmp) = make_twiddle_commit("abc00000");
3265        let cmd = make_twiddle_cmd();
3266        let repo_view = make_twiddle_repo_view(vec![commit]);
3267        let client = make_mock_client(vec![]); // no calls expected
3268        let mut failed = vec![0usize];
3269        let mut successes = vec![];
3270        let mut reader = std::io::Cursor::new(b"s\n" as &[u8]);
3271        cmd.run_interactive_retry_generate_amendments(
3272            &mut failed,
3273            &repo_view,
3274            &client,
3275            None,
3276            false,
3277            &mut successes,
3278            true,
3279            &mut reader,
3280        )
3281        .await
3282        .unwrap();
3283        assert_eq!(failed, vec![0], "skip should leave failed unchanged");
3284        assert!(successes.is_empty());
3285    }
3286
3287    #[tokio::test]
3288    async fn retry_generate_amendments_invalid_then_skip() {
3289        // Unrecognised input → "please enter r or s" message → "s" exits.
3290        let (commit, _tmp) = make_twiddle_commit("abc00000");
3291        let cmd = make_twiddle_cmd();
3292        let repo_view = make_twiddle_repo_view(vec![commit]);
3293        let client = make_mock_client(vec![]);
3294        let mut failed = vec![0usize];
3295        let mut successes = vec![];
3296        let mut reader = std::io::Cursor::new(b"x\ns\n" as &[u8]);
3297        cmd.run_interactive_retry_generate_amendments(
3298            &mut failed,
3299            &repo_view,
3300            &client,
3301            None,
3302            false,
3303            &mut successes,
3304            true,
3305            &mut reader,
3306        )
3307        .await
3308        .unwrap();
3309        assert_eq!(failed, vec![0]);
3310        assert!(successes.is_empty());
3311    }
3312
3313    #[tokio::test]
3314    async fn retry_generate_amendments_retry_fails_then_skip() {
3315        // "r" → AI call fails → still in failed → "s" → skips.
3316        let (commit, _tmp) = make_twiddle_commit("abc00000");
3317        let cmd = make_twiddle_cmd();
3318        let repo_view = make_twiddle_repo_view(vec![commit]);
3319        let client = make_mock_client(vec![Err(anyhow::anyhow!("mock fail"))]);
3320        let mut failed = vec![0usize];
3321        let mut successes = vec![];
3322        let mut reader = std::io::Cursor::new(b"r\ns\n" as &[u8]);
3323        cmd.run_interactive_retry_generate_amendments(
3324            &mut failed,
3325            &repo_view,
3326            &client,
3327            None,
3328            false,
3329            &mut successes,
3330            true,
3331            &mut reader,
3332        )
3333        .await
3334        .unwrap();
3335        assert_eq!(failed, vec![0], "commit still failed after retry");
3336        assert!(successes.is_empty());
3337    }
3338
3339    #[tokio::test]
3340    async fn retry_generate_amendments_retry_succeeds() {
3341        // "r" → AI returns valid amendment → failed cleared, success recorded.
3342        let (commit, _tmp) = make_twiddle_commit(HASH_40);
3343        let cmd = make_twiddle_cmd();
3344        let repo_view = make_twiddle_repo_view(vec![commit]);
3345        let client = make_mock_client(vec![Ok(twiddle_amendment_yaml(HASH_40))]);
3346        let mut failed = vec![0usize];
3347        let mut successes = vec![];
3348        let mut reader = std::io::Cursor::new(b"r\n" as &[u8]);
3349        cmd.run_interactive_retry_generate_amendments(
3350            &mut failed,
3351            &repo_view,
3352            &client,
3353            None,
3354            false,
3355            &mut successes,
3356            true,
3357            &mut reader,
3358        )
3359        .await
3360        .unwrap();
3361        assert!(failed.is_empty(), "retry succeeded → failed cleared");
3362        assert_eq!(successes.len(), 1);
3363    }
3364
3365    #[test]
3366    fn refine_amendment_scopes_replaces_scope_from_file_patterns() {
3367        use crate::data::amendments::Amendment;
3368        use crate::data::context::ScopeDefinition;
3369        use crate::git::commit::FileChange;
3370
3371        // Build a commit whose files match the "cli" scope pattern.
3372        let (mut commit, _tmp) = make_twiddle_commit("aaa00000");
3373        commit.analysis.file_changes.file_list = vec![FileChange {
3374            status: "M".to_string(),
3375            file: "src/cli/git/twiddle.rs".to_string(),
3376        }];
3377
3378        let repo_view = make_twiddle_repo_view(vec![commit]);
3379
3380        let scope_defs = vec![ScopeDefinition {
3381            name: "cli".to_string(),
3382            description: "CLI commands".to_string(),
3383            examples: vec![],
3384            file_patterns: vec!["src/cli/**".to_string()],
3385        }];
3386
3387        let mut amendments = AmendmentFile {
3388            amendments: vec![Amendment {
3389                commit: "aaa00000".to_string(),
3390                message: "fix(wrong-scope): tweak something".to_string(),
3391                summary: String::new(),
3392            }],
3393        };
3394
3395        refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
3396
3397        assert_eq!(
3398            amendments.amendments[0].message,
3399            "fix(cli): tweak something",
3400        );
3401    }
3402
3403    #[test]
3404    fn refine_amendment_scopes_no_match_leaves_message_unchanged() {
3405        use crate::data::amendments::Amendment;
3406
3407        let (commit, _tmp) = make_twiddle_commit("bbb00000");
3408        let repo_view = make_twiddle_repo_view(vec![commit]);
3409
3410        let mut amendments = AmendmentFile {
3411            amendments: vec![Amendment {
3412                commit: "bbb00000".to_string(),
3413                message: "feat(stuff): add feature".to_string(),
3414                summary: String::new(),
3415            }],
3416        };
3417
3418        // No scope defs → no refinement.
3419        refine_amendment_scopes(&mut amendments, &repo_view, &[]);
3420
3421        assert_eq!(amendments.amendments[0].message, "feat(stuff): add feature",);
3422    }
3423
3424    // --- resolve_duplicate_amendments ---
3425
3426    fn dup_hash(byte: char) -> String {
3427        std::iter::repeat_n(byte, 40).collect()
3428    }
3429
3430    fn dup_amendments(items: &[(&str, &str)]) -> AmendmentFile {
3431        use crate::data::amendments::Amendment;
3432        AmendmentFile {
3433            amendments: items
3434                .iter()
3435                .map(|(hash, msg)| Amendment {
3436                    commit: (*hash).to_string(),
3437                    message: (*msg).to_string(),
3438                    summary: String::new(),
3439                })
3440                .collect(),
3441        }
3442    }
3443
3444    #[test]
3445    fn resolve_duplicates_empty_is_noop() {
3446        let mut af = AmendmentFile { amendments: vec![] };
3447        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3448        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3449        assert!(af.amendments.is_empty());
3450    }
3451
3452    #[test]
3453    fn resolve_duplicates_single_is_noop() {
3454        let h = dup_hash('a');
3455        let mut af = dup_amendments(&[(&h, "feat: only")]);
3456        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3457        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3458        assert_eq!(af.amendments.len(), 1);
3459        assert_eq!(af.amendments[0].message, "feat: only");
3460    }
3461
3462    #[test]
3463    fn resolve_duplicates_no_dups_unchanged() {
3464        let h_a = dup_hash('a');
3465        let h_b = dup_hash('b');
3466        let mut af = dup_amendments(&[(&h_a, "feat: a"), (&h_b, "feat: b")]);
3467        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3468        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3469        assert_eq!(af.amendments.len(), 2);
3470        assert_eq!(af.amendments[0].message, "feat: a");
3471        assert_eq!(af.amendments[1].message, "feat: b");
3472    }
3473
3474    #[test]
3475    fn resolve_duplicates_auto_pick_keeps_first() {
3476        let h = dup_hash('a');
3477        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3478        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3479        resolve_duplicate_amendments(&mut af, true, true, &mut reader).unwrap();
3480        assert_eq!(af.amendments.len(), 1);
3481        assert_eq!(af.amendments[0].message, "feat: first");
3482    }
3483
3484    #[test]
3485    fn resolve_duplicates_non_terminal_keeps_first() {
3486        let h = dup_hash('a');
3487        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3488        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3489        resolve_duplicate_amendments(&mut af, false, false, &mut reader).unwrap();
3490        assert_eq!(af.amendments.len(), 1);
3491        assert_eq!(af.amendments[0].message, "feat: first");
3492    }
3493
3494    #[test]
3495    fn resolve_duplicates_prompt_picks_second() {
3496        let h = dup_hash('a');
3497        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3498        let mut reader = std::io::Cursor::new(b"2\n" as &[u8]);
3499        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3500        assert_eq!(af.amendments.len(), 1);
3501        assert_eq!(af.amendments[0].message, "feat: second");
3502    }
3503
3504    #[test]
3505    fn resolve_duplicates_prompt_default_picks_first() {
3506        let h = dup_hash('a');
3507        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3508        let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
3509        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3510        assert_eq!(af.amendments.len(), 1);
3511        assert_eq!(af.amendments[0].message, "feat: first");
3512    }
3513
3514    #[test]
3515    fn resolve_duplicates_prompt_invalid_then_valid() {
3516        let h = dup_hash('a');
3517        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3518        let mut reader = std::io::Cursor::new(b"x\n9\n2\n" as &[u8]);
3519        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3520        assert_eq!(af.amendments.len(), 1);
3521        assert_eq!(af.amendments[0].message, "feat: second");
3522    }
3523
3524    #[test]
3525    fn resolve_duplicates_prompt_eof_keeps_first() {
3526        let h = dup_hash('a');
3527        let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3528        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3529        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3530        assert_eq!(af.amendments.len(), 1);
3531        assert_eq!(af.amendments[0].message, "feat: first");
3532    }
3533
3534    #[test]
3535    fn resolve_duplicates_preserves_unique_amendments_order() {
3536        let h_a = dup_hash('a');
3537        let h_b = dup_hash('b');
3538        let h_c = dup_hash('c');
3539        let mut af = dup_amendments(&[
3540            (&h_a, "feat: a1"),
3541            (&h_b, "feat: b"),
3542            (&h_a, "feat: a2"),
3543            (&h_c, "feat: c"),
3544        ]);
3545        let mut reader = std::io::Cursor::new(b"" as &[u8]);
3546        resolve_duplicate_amendments(&mut af, true, true, &mut reader).unwrap();
3547        assert_eq!(af.amendments.len(), 3);
3548        assert_eq!(af.amendments[0].commit, h_a);
3549        assert_eq!(af.amendments[0].message, "feat: a1");
3550        assert_eq!(af.amendments[1].commit, h_b);
3551        assert_eq!(af.amendments[2].commit, h_c);
3552    }
3553
3554    #[test]
3555    fn resolve_duplicates_three_way_picks_third() {
3556        let h = dup_hash('a');
3557        let mut af = dup_amendments(&[
3558            (&h, "feat: first"),
3559            (&h, "feat: second"),
3560            (&h, "feat: third"),
3561        ]);
3562        let mut reader = std::io::Cursor::new(b"3\n" as &[u8]);
3563        resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3564        assert_eq!(af.amendments.len(), 1);
3565        assert_eq!(af.amendments[0].message, "feat: third");
3566    }
3567
3568    #[test]
3569    fn refine_amendment_scopes_skips_unknown_commits() {
3570        use crate::data::amendments::Amendment;
3571        use crate::data::context::ScopeDefinition;
3572
3573        let (commit, _tmp) = make_twiddle_commit("ccc00000");
3574        let repo_view = make_twiddle_repo_view(vec![commit]);
3575
3576        let scope_defs = vec![ScopeDefinition {
3577            name: "cli".to_string(),
3578            description: "CLI".to_string(),
3579            examples: vec![],
3580            file_patterns: vec!["src/cli/**".to_string()],
3581        }];
3582
3583        let mut amendments = AmendmentFile {
3584            amendments: vec![Amendment {
3585                commit: "unknown_hash".to_string(),
3586                message: "fix(wrong): something".to_string(),
3587                summary: String::new(),
3588            }],
3589        };
3590
3591        refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
3592
3593        // Message unchanged because commit wasn't found in repo_view.
3594        assert_eq!(amendments.amendments[0].message, "fix(wrong): something",);
3595    }
3596}