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