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