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