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