Skip to main content

xbp_cli/commands/
commit.rs

1use crate::cli::auto_commit::{print_push_summary, push_current_branch_with_name};
2use crate::commands::service::load_xbp_config_with_root;
3use crate::config::{
4    resolve_openrouter_api_key, resolve_openrouter_commit_model,
5    resolve_openrouter_commit_system_prompt,
6};
7use crate::openrouter::{complete_prompt_with_options, CompletionOptions};
8use crate::utils::command_exists;
9use colored::Colorize;
10use once_cell::sync::Lazy;
11use regex::Regex;
12use serde::Deserialize;
13use std::collections::{BTreeMap, BTreeSet};
14use std::env;
15use std::fs;
16use std::io::{self, Write as _};
17use std::path::{Path, PathBuf};
18use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
19use tokio::process::Command;
20use uuid::Uuid;
21
22#[path = "commit_prompt_preprocess.rs"]
23mod commit_prompt_preprocess;
24
25use self::commit_prompt_preprocess::{build_prompt_diff_excerpt, prompt_diff_omit_reason};
26
27static RUST_FUNCTION_RE: Lazy<Regex> = Lazy::new(|| {
28    Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)")
29        .expect("valid rust function regex")
30});
31static JS_FUNCTION_RE: Lazy<Regex> = Lazy::new(|| {
32    Regex::new(
33        r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)",
34    )
35    .expect("valid js function regex")
36});
37static JS_ARROW_RE: Lazy<Regex> = Lazy::new(|| {
38    Regex::new(
39        r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>",
40    )
41    .expect("valid js arrow regex")
42});
43static METHOD_CONTEXT_RE: Lazy<Regex> = Lazy::new(|| {
44    Regex::new(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(").expect("valid method context regex")
45});
46static RUST_TYPE_RE: Lazy<Regex> = Lazy::new(|| {
47    Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(struct|enum|trait|type)\s+([A-Za-z_][A-Za-z0-9_]*)")
48        .expect("valid rust type regex")
49});
50static TS_TYPE_RE: Lazy<Regex> = Lazy::new(|| {
51    Regex::new(r"^\s*(?:export\s+)?(interface|type|enum|class)\s+([A-Za-z_$][A-Za-z0-9_$]*)")
52        .expect("valid ts type regex")
53});
54
55const MAX_PROMPT_FILES: usize = 24;
56const MAX_PROMPT_SYMBOLS: usize = 12;
57const MAX_PROMPT_CONTEXTS: usize = 12;
58const MAX_PROMPT_AREAS: usize = 4;
59const MAX_PROMPT_OMITTED_DIFFS: usize = 12;
60const MAX_PROMPT_DIFF_SUMMARIES: usize = 16;
61const MAX_PRINT_SYMBOLS: usize = 8;
62const MAX_PRINT_AREAS: usize = 4;
63const MAX_PRINT_FILES: usize = 4;
64const CONVENTIONAL_TYPES: &[&str] = &[
65    "feat", "fix", "docs", "refactor", "chore", "test", "build", "ci", "perf", "style", "revert",
66];
67const TERMINAL_SESSION_DIR: &str = "terminals/";
68/// Leave headroom under Windows CreateProcess limit (~8191 chars) and common shells.
69/// Kept for regression tests after staging switched to bulk `git add --all` only.
70#[cfg(test)]
71const SAFE_GIT_ARGV_CHAR_BUDGET: usize = 6_000;
72/// Prefer bulk `git add --all` once pathspec lists grow past this size.
73#[cfg(test)]
74const SAFE_GIT_PATHSPEC_COUNT: usize = 64;
75const MAX_GIT_ERROR_ARG_CHARS: usize = 240;
76const COMMIT_AI_MAX_TOKENS: u32 = 320;
77
78#[derive(Debug, Clone)]
79pub struct CommitArgs {
80    pub dry_run: bool,
81    pub push: bool,
82    pub no_ai: bool,
83    pub model: Option<String>,
84    pub scope: Option<String>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum CommitRunOutcome {
89    Completed,
90    NothingToCommit,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94enum StageAndCommitOutcome {
95    Committed,
96    NothingToCommit,
97}
98
99#[derive(Debug, Clone)]
100pub enum CommitError {
101    Operation(String),
102    PushFailed {
103        summary: String,
104        commit_sha: Option<String>,
105    },
106    TerminalSessionsBlocked(String),
107}
108
109#[derive(Debug, Clone)]
110struct RepoContext {
111    repo_root: PathBuf,
112    repo_name: String,
113    branch: Option<String>,
114}
115
116#[derive(Debug, Clone, Default)]
117struct BranchSyncStatus {
118    upstream: Option<String>,
119    ahead: usize,
120    behind: usize,
121}
122
123#[derive(Debug, Clone)]
124struct WorktreeAnalysis {
125    repo_root: PathBuf,
126    repo_name: String,
127    branch: Option<String>,
128    status_entries: Vec<StatusEntry>,
129    files: Vec<FileChangeSummary>,
130    total_additions: u32,
131    total_deletions: u32,
132    diff_text: String,
133    new_functions: Vec<String>,
134    changed_functions: Vec<String>,
135    removed_functions: Vec<String>,
136    new_types: Vec<String>,
137    changed_types: Vec<String>,
138    removed_types: Vec<String>,
139    hunk_contexts: Vec<String>,
140}
141
142impl WorktreeAnalysis {
143    fn repo_context(&self) -> RepoContext {
144        RepoContext {
145            repo_root: self.repo_root.clone(),
146            repo_name: self.repo_name.clone(),
147            branch: self.branch.clone(),
148        }
149    }
150}
151
152#[derive(Debug, Clone)]
153struct StatusEntry {
154    code: String,
155    path: String,
156}
157
158#[derive(Debug, Clone)]
159struct FileChangeSummary {
160    path: String,
161    status: String,
162    additions: u32,
163    deletions: u32,
164}
165
166#[derive(Debug, Clone)]
167struct SymbolSummary {
168    new_functions: Vec<String>,
169    changed_functions: Vec<String>,
170    removed_functions: Vec<String>,
171    new_types: Vec<String>,
172    changed_types: Vec<String>,
173    removed_types: Vec<String>,
174    hunk_contexts: Vec<String>,
175}
176
177#[derive(Debug, Clone, Copy, Eq, PartialEq)]
178enum SymbolKind {
179    Function,
180    Type,
181}
182
183#[derive(Debug, Clone)]
184struct CommitMessagePlan {
185    commit: ConventionalCommit,
186    generation_mode: GenerationMode,
187}
188
189#[derive(Debug, Clone)]
190struct ConventionalCommit {
191    commit_type: String,
192    scope: Option<String>,
193    description: String,
194    body: Vec<String>,
195    breaking_change: Option<String>,
196    footers: Vec<String>,
197}
198
199#[derive(Debug, Clone, Copy)]
200enum GenerationMode {
201    OpenRouter,
202    Heuristic,
203}
204
205#[derive(Debug, Deserialize)]
206struct AiCommitPayload {
207    #[serde(rename = "type")]
208    commit_type: String,
209    scope: Option<String>,
210    description: String,
211    #[serde(default)]
212    body: Vec<String>,
213    #[serde(default)]
214    breaking_change: bool,
215    breaking_description: Option<String>,
216    #[serde(default)]
217    footers: Vec<String>,
218}
219
220pub async fn run_commit(args: CommitArgs) -> Result<CommitRunOutcome, CommitError> {
221    if !command_exists("git") {
222        return Err(CommitError::Operation(
223            "Git is not installed on this machine.".to_string(),
224        ));
225    }
226
227    let invocation_dir = env::current_dir()
228        .map_err(|e| CommitError::Operation(format!("Failed to read current directory: {}", e)))?;
229    let repo = resolve_repo_context(&invocation_dir)
230        .await
231        .map_err(CommitError::Operation)?;
232    let status_output = git_output(
233        &repo.repo_root,
234        &["status", "--porcelain=v1", "--untracked-files=all"],
235    )
236    .await
237    .map_err(CommitError::Operation)?;
238    let status_entries = parse_status_entries(&status_output);
239    let status_entries = filter_gitignored_status_entries(&repo.repo_root, status_entries)
240        .await
241        .map_err(CommitError::Operation)?;
242
243    if status_entries.is_empty() {
244        let sync_status = resolve_branch_sync_status(&repo.repo_root)
245            .await
246            .unwrap_or_default();
247
248        if args.push {
249            return handle_clean_worktree_push(&repo, &sync_status)
250                .await
251                .map_err(|error| CommitError::PushFailed {
252                    summary: error,
253                    commit_sha: None,
254                })
255                .map(|()| CommitRunOutcome::Completed);
256        }
257
258        println!(
259            "{}",
260            render_clean_worktree_message(&repo, &sync_status).bright_yellow()
261        );
262        return Ok(CommitRunOutcome::NothingToCommit);
263    }
264
265    let repo_for_analysis = repo.clone();
266    let analysis = analyze_worktree(repo, status_entries, &[])
267        .await
268        .map_err(CommitError::Operation)?;
269    let excluded_terminal_paths = terminal_session_paths(&analysis);
270    if !excluded_terminal_paths.is_empty()
271        && analysis
272            .files
273            .iter()
274            .all(|file| is_terminal_session_path(&file.path))
275    {
276        return Err(CommitError::TerminalSessionsBlocked(
277            render_terminal_session_block_message(&excluded_terminal_paths),
278        ));
279    }
280
281    let commit_analysis = if excluded_terminal_paths.is_empty() {
282        analysis.clone()
283    } else {
284        println!(
285            "\n{} {}",
286            "Skipped".bright_yellow().bold(),
287            format!(
288                "excluding {} IDE terminal session file(s) from this commit: {}",
289                excluded_terminal_paths.len(),
290                excluded_terminal_paths.join(", ")
291            )
292            .bright_white()
293        );
294        analyze_worktree(
295            repo_for_analysis,
296            analysis.status_entries.clone(),
297            &excluded_terminal_paths,
298        )
299        .await
300        .map_err(CommitError::Operation)?
301    };
302
303    let (auto_push_after_commit, commit_plan) = tokio::join!(
304        resolve_auto_push_on_commit(),
305        generate_commit_plan(&commit_analysis, &args)
306    );
307    let rendered_message = render_commit_message(&commit_plan.commit);
308
309    print_analysis_summary(&commit_analysis, &commit_plan, &rendered_message);
310
311    if args.dry_run {
312        println!(
313            "\n{}",
314            "Dry run only. No git commit was created."
315                .bright_yellow()
316                .bold()
317        );
318        return Ok(CommitRunOutcome::Completed);
319    }
320
321    match stage_and_commit(
322        &commit_analysis.repo_root,
323        &rendered_message,
324        &commit_analysis.status_entries,
325        &excluded_terminal_paths,
326    )
327    .await
328    .map_err(CommitError::Operation)?
329    {
330        StageAndCommitOutcome::Committed => {}
331        StageAndCommitOutcome::NothingToCommit => {
332            let sync_status = resolve_branch_sync_status(&commit_analysis.repo_root)
333                .await
334                .unwrap_or_default();
335
336            if args.push {
337                handle_clean_worktree_push(&commit_analysis.repo_context(), &sync_status)
338                    .await
339                    .map_err(|error| CommitError::PushFailed {
340                        summary: error,
341                        commit_sha: None,
342                    })?;
343            } else {
344                println!(
345                    "{}",
346                    render_clean_worktree_message(&commit_analysis.repo_context(), &sync_status,)
347                        .bright_yellow()
348                );
349            }
350
351            return Ok(CommitRunOutcome::NothingToCommit);
352        }
353    }
354
355    println!("{}", "Resolving created commit...".bright_black());
356    let full_sha = git_output(&commit_analysis.repo_root, &["rev-parse", "HEAD"])
357        .await
358        .map_err(CommitError::Operation)?;
359    let short_sha = full_sha.chars().take(8).collect::<String>();
360
361    println!(
362        "\n{} {} {}",
363        "Committed".bright_green().bold(),
364        short_sha.bright_white().bold(),
365        format!("({})", full_sha).dimmed()
366    );
367
368    if args.push || auto_push_after_commit {
369        println!("{}", "Pushing current branch...".bright_black());
370        match push_current_branch_with_name(
371            &commit_analysis.repo_root,
372            commit_analysis.branch.as_deref(),
373        )
374        .await
375        {
376            Ok(Some(outcome)) => print_push_summary(&outcome),
377            Ok(None) => println!(
378                "{}",
379                "Push skipped because the current HEAD is detached.".bright_yellow()
380            ),
381            Err(error) => {
382                return Err(CommitError::PushFailed {
383                    summary: error,
384                    commit_sha: Some(short_sha),
385                });
386            }
387        }
388    } else {
389        println!(
390            "{}",
391            "Auto-push disabled by xbp config (`github.auto_push_on_commit: false`)."
392                .bright_yellow()
393        );
394    }
395
396    Ok(CommitRunOutcome::Completed)
397}
398
399async fn resolve_auto_push_on_commit() -> bool {
400    load_xbp_config_with_root()
401        .await
402        .map(|(_, config)| config.auto_push_on_commit_enabled())
403        .unwrap_or(true)
404}
405
406async fn resolve_repo_context(invocation_dir: &Path) -> Result<RepoContext, String> {
407    let repo_root = PathBuf::from(
408        git_output(invocation_dir, &["rev-parse", "--show-toplevel"])
409            .await
410            .map_err(|_| "Current directory is not inside a git repository.".to_string())?,
411    );
412    let repo_name = repo_name(&repo_root);
413    let branch = git_output(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
414        .await
415        .ok()
416        .filter(|value| !value.is_empty() && value != "HEAD");
417
418    Ok(RepoContext {
419        repo_root,
420        repo_name,
421        branch,
422    })
423}
424
425async fn resolve_branch_sync_status(repo_root: &Path) -> Result<BranchSyncStatus, String> {
426    let upstream = git_output(
427        repo_root,
428        &[
429            "rev-parse",
430            "--abbrev-ref",
431            "--symbolic-full-name",
432            "@{upstream}",
433        ],
434    )
435    .await
436    .ok()
437    .filter(|value| !value.is_empty());
438
439    let Some(upstream_name) = upstream else {
440        return Ok(BranchSyncStatus::default());
441    };
442
443    let counts = git_output(
444        repo_root,
445        &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
446    )
447    .await?;
448    let mut parts = counts.split_whitespace();
449    let ahead = parts
450        .next()
451        .and_then(|value| value.parse::<usize>().ok())
452        .unwrap_or(0);
453    let behind = parts
454        .next()
455        .and_then(|value| value.parse::<usize>().ok())
456        .unwrap_or(0);
457
458    Ok(BranchSyncStatus {
459        upstream: Some(upstream_name),
460        ahead,
461        behind,
462    })
463}
464
465async fn handle_clean_worktree_push(
466    repo: &RepoContext,
467    sync_status: &BranchSyncStatus,
468) -> Result<(), String> {
469    if repo.branch.is_none() {
470        return Err(render_clean_worktree_message(repo, sync_status));
471    }
472
473    if sync_status.behind > 0 && sync_status.ahead == 0 {
474        let branch = repo.branch.as_deref().unwrap_or("main");
475        let upstream = sync_status.upstream.as_deref().unwrap_or("origin/main");
476        return Err(format!(
477            "`{branch}` is behind `{upstream}` by {} commit(s). Run `git pull --rebase origin {branch}`, then push again.",
478            sync_status.behind
479        ));
480    }
481
482    if sync_status.ahead == 0 && sync_status.behind == 0 && sync_status.upstream.is_some() {
483        println!("{}", render_clean_worktree_message(repo, sync_status));
484        return Ok(());
485    }
486
487    println!("{}", render_clean_worktree_message(repo, sync_status));
488    match push_current_branch_with_name(&repo.repo_root, repo.branch.as_deref()).await {
489        Ok(Some(outcome)) => {
490            print_push_summary(&outcome);
491            Ok(())
492        }
493        Ok(None) => Err("Push skipped because the current HEAD is detached.".to_string()),
494        Err(error) => Err(error),
495    }
496}
497
498fn render_clean_worktree_message(repo: &RepoContext, sync_status: &BranchSyncStatus) -> String {
499    let branch_label = repo
500        .branch
501        .as_deref()
502        .map(|branch| format!(" on `{}`", branch))
503        .unwrap_or_default();
504
505    let sync_summary = match sync_status.upstream.as_deref() {
506        Some(upstream) if sync_status.ahead > 0 && sync_status.behind > 0 => format!(
507            "Working tree is clean{}, with {} commit(s) waiting to push and {} commit(s) to pull from `{}`.",
508            branch_label, sync_status.ahead, sync_status.behind, upstream
509        ),
510        Some(upstream) if sync_status.ahead > 0 => format!(
511            "Working tree is clean{}, with {} commit(s) waiting to push to `{}`.",
512            branch_label, sync_status.ahead, upstream
513        ),
514        Some(upstream) if sync_status.behind > 0 => format!(
515            "Working tree is clean{}, but `{}` is ahead by {} commit(s). Pull or rebase before pushing.",
516            branch_label, upstream, sync_status.behind
517        ),
518        Some(upstream) => format!(
519            "Working tree is clean{} and already in sync with `{}`.",
520            branch_label, upstream
521        ),
522        None if repo.branch.is_some() => format!(
523            "Working tree is clean{}, and no upstream branch is configured yet.",
524            branch_label
525        ),
526        None => "Working tree is clean, and HEAD is detached.".to_string(),
527    };
528
529    format!("Nothing to commit. {}", sync_summary)
530}
531
532async fn analyze_worktree(
533    repo: RepoContext,
534    status_entries: Vec<StatusEntry>,
535    exclude_paths: &[String],
536) -> Result<WorktreeAnalysis, String> {
537    if status_entries.is_empty() {
538        return Err("No worktree changes were found to commit.".to_string());
539    }
540
541    let temp_index = prepare_temporary_index(&repo.repo_root).await?;
542    let temp_index_path = temp_index.path.clone();
543    let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
544
545    git_output_with_env(&repo.repo_root, &["add", "--all"], &git_env).await?;
546    for path in exclude_paths {
547        git_output_with_env(
548            &repo.repo_root,
549            &["reset", "HEAD", "--", path.as_str()],
550            &git_env,
551        )
552        .await?;
553    }
554    let (name_status_output, numstat_output, diff_text) = tokio::try_join!(
555        git_output_with_env(
556            &repo.repo_root,
557            &["diff", "--cached", "--name-status", "--find-renames"],
558            &git_env,
559        ),
560        git_output_with_env(
561            &repo.repo_root,
562            &["diff", "--cached", "--numstat", "--find-renames"],
563            &git_env,
564        ),
565        git_output_with_env(
566            &repo.repo_root,
567            &[
568                "diff",
569                "--cached",
570                "--unified=0",
571                "--no-color",
572                "--no-ext-diff",
573                "--find-renames",
574            ],
575            &git_env,
576        ),
577    )?;
578
579    if diff_text.trim().is_empty() {
580        return Err("No staged diff could be produced from the current worktree.".to_string());
581    }
582
583    let files = merge_file_summaries(&name_status_output, &numstat_output);
584    let total_additions = files.iter().map(|entry| entry.additions).sum();
585    let total_deletions = files.iter().map(|entry| entry.deletions).sum();
586    let symbols = summarize_symbols(&diff_text);
587
588    Ok(WorktreeAnalysis {
589        repo_root: repo.repo_root,
590        repo_name: repo.repo_name,
591        branch: repo.branch,
592        status_entries,
593        files,
594        total_additions,
595        total_deletions,
596        diff_text,
597        new_functions: symbols.new_functions,
598        changed_functions: symbols.changed_functions,
599        removed_functions: symbols.removed_functions,
600        new_types: symbols.new_types,
601        changed_types: symbols.changed_types,
602        removed_types: symbols.removed_types,
603        hunk_contexts: symbols.hunk_contexts,
604    })
605}
606
607async fn prepare_temporary_index(repo_root: &Path) -> Result<TemporaryIndex, String> {
608    let real_index_path =
609        PathBuf::from(git_output(repo_root, &["rev-parse", "--git-path", "index"]).await?);
610    let temp_index_path = env::temp_dir().join(format!("xbp-commit-index-{}.tmp", Uuid::new_v4()));
611
612    if real_index_path.exists() {
613        fs::copy(&real_index_path, &temp_index_path).map_err(|e| {
614            format!(
615                "Failed to prepare temporary git index {}: {}",
616                temp_index_path.display(),
617                e
618            )
619        })?;
620    } else {
621        let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
622        let _ = git_output_with_env(repo_root, &["read-tree", "HEAD"], &git_env).await;
623    }
624
625    Ok(TemporaryIndex {
626        path: temp_index_path,
627    })
628}
629
630async fn generate_commit_plan(analysis: &WorktreeAnalysis, args: &CommitArgs) -> CommitMessagePlan {
631    let forced_scope = sanitize_scope(args.scope.as_deref());
632    let heuristic = build_heuristic_commit(analysis, forced_scope.clone());
633
634    if args.no_ai {
635        return CommitMessagePlan {
636            commit: heuristic,
637            generation_mode: GenerationMode::Heuristic,
638        };
639    }
640
641    let Some(api_key) = resolve_openrouter_api_key() else {
642        return CommitMessagePlan {
643            commit: heuristic,
644            generation_mode: GenerationMode::Heuristic,
645        };
646    };
647
648    let prompt = build_commit_prompt(analysis, forced_scope.as_deref(), &heuristic);
649    let model = args
650        .model
651        .as_deref()
652        .map(str::trim)
653        .filter(|value| !value.is_empty())
654        .map(str::to_string)
655        .unwrap_or_else(resolve_openrouter_commit_model);
656    let system_prompt = resolve_openrouter_commit_system_prompt();
657
658    let ai_message = complete_prompt_with_options(
659        &api_key,
660        &model,
661        Some(&system_prompt),
662        &prompt,
663        Some("XBP Commit Generator"),
664        CompletionOptions::fast_json(COMMIT_AI_MAX_TOKENS),
665    )
666    .await
667    .and_then(|raw| parse_ai_commit_payload(&raw, forced_scope.as_deref()));
668
669    if let Some(mut commit) = ai_message {
670        if commit.body.is_empty() {
671            commit.body = heuristic.body.clone();
672        }
673        CommitMessagePlan {
674            commit,
675            generation_mode: GenerationMode::OpenRouter,
676        }
677    } else {
678        CommitMessagePlan {
679            commit: heuristic,
680            generation_mode: GenerationMode::Heuristic,
681        }
682    }
683}
684
685fn build_commit_prompt(
686    analysis: &WorktreeAnalysis,
687    forced_scope: Option<&str>,
688    heuristic: &ConventionalCommit,
689) -> String {
690    let focus_areas = summarize_focus_areas(analysis, MAX_PROMPT_AREAS);
691    let top_files = summarize_top_files(analysis, MAX_PROMPT_FILES);
692    let prompt_diff = build_prompt_diff_excerpt(analysis);
693    let mut lines = vec![
694        "Worktree context for commit generation:".to_string(),
695        String::new(),
696    ];
697    lines.push(format!("Repository: {}", analysis.repo_name));
698    if let Some(branch) = &analysis.branch {
699        lines.push(format!("Branch: {}", branch));
700    }
701    if let Some(scope) = forced_scope {
702        lines.push(format!("Forced scope: {}", scope));
703    } else if let Some(scope) = heuristic.scope.as_deref() {
704        lines.push(format!("Suggested scope: {}", scope));
705    }
706    lines.push(format!(
707        "Heuristic fallback subject: {}",
708        render_commit_subject(heuristic)
709    ));
710    if !focus_areas.is_empty() {
711        lines.push(format!("Focus areas: {}", focus_areas.join(", ")));
712    }
713    if let Some(behavior_hint) = infer_behavior_hint(analysis) {
714        lines.push(format!("Behavior hint: {}", behavior_hint));
715    }
716    if analysis
717        .files
718        .iter()
719        .any(|file| is_terminal_session_path(&file.path))
720    {
721        lines.push(String::new());
722        lines.push("Important constraint:".to_string());
723        lines.push(
724            "- paths under terminals/ are IDE agent session logs, not product features; never describe them as flows or capabilities"
725                .to_string(),
726        );
727        lines.push(
728            "- terminals/.next-id is an IDE session counter, not app configuration; never describe updating it as a feature"
729                .to_string(),
730        );
731    }
732    lines.push(String::new());
733    lines.push("Worktree stats:".to_string());
734    lines.push(format!("- files changed: {}", analysis.files.len()));
735    lines.push(format!(
736        "- lines: +{} -{}",
737        analysis.total_additions, analysis.total_deletions
738    ));
739    if !top_files.is_empty() {
740        lines.push("- dominant files:".to_string());
741        for file in top_files {
742            lines.push(format!("  - {}", file));
743        }
744    }
745    if !analysis.status_entries.is_empty() {
746        lines.push("- worktree statuses:".to_string());
747        for entry in analysis.status_entries.iter().take(MAX_PROMPT_FILES) {
748            lines.push(format!("  - {} {}", entry.code, entry.path));
749        }
750    }
751    if !analysis.files.is_empty() {
752        lines.push("- file diffs:".to_string());
753        for file in analysis.files.iter().take(MAX_PROMPT_FILES) {
754            lines.push(format!(
755                "  - [{}] {} (+{} -{})",
756                file.status, file.path, file.additions, file.deletions
757            ));
758        }
759    }
760    if !analysis.new_functions.is_empty() {
761        lines.push(format!(
762            "- new functions: {}",
763            analysis
764                .new_functions
765                .iter()
766                .take(MAX_PROMPT_SYMBOLS)
767                .cloned()
768                .collect::<Vec<_>>()
769                .join(", ")
770        ));
771    }
772    if !analysis.changed_functions.is_empty() {
773        lines.push(format!(
774            "- changed functions: {}",
775            analysis
776                .changed_functions
777                .iter()
778                .take(MAX_PROMPT_SYMBOLS)
779                .cloned()
780                .collect::<Vec<_>>()
781                .join(", ")
782        ));
783    }
784    if !analysis.new_types.is_empty() {
785        lines.push(format!(
786            "- new types: {}",
787            analysis
788                .new_types
789                .iter()
790                .take(MAX_PROMPT_SYMBOLS)
791                .cloned()
792                .collect::<Vec<_>>()
793                .join(", ")
794        ));
795    }
796    if !analysis.changed_types.is_empty() {
797        lines.push(format!(
798            "- changed types: {}",
799            analysis
800                .changed_types
801                .iter()
802                .take(MAX_PROMPT_SYMBOLS)
803                .cloned()
804                .collect::<Vec<_>>()
805                .join(", ")
806        ));
807    }
808    if !analysis.hunk_contexts.is_empty() {
809        lines.push(format!(
810            "- hunk contexts: {}",
811            analysis
812                .hunk_contexts
813                .iter()
814                .take(MAX_PROMPT_CONTEXTS)
815                .cloned()
816                .collect::<Vec<_>>()
817                .join(", ")
818        ));
819    }
820    if !prompt_diff.omitted_files.is_empty() {
821        lines.push("- omitted low-signal diffs:".to_string());
822        for file in prompt_diff
823            .omitted_files
824            .iter()
825            .take(MAX_PROMPT_OMITTED_DIFFS)
826        {
827            lines.push(format!("  - {}", file));
828        }
829        if prompt_diff.omitted_files.len() > MAX_PROMPT_OMITTED_DIFFS {
830            lines.push(format!(
831                "  - ... {} more omitted",
832                prompt_diff.omitted_files.len() - MAX_PROMPT_OMITTED_DIFFS
833            ));
834        }
835    }
836    if !prompt_diff.summaries.is_empty() {
837        lines.push("- preprocessed diff summaries:".to_string());
838        for summary in prompt_diff.summaries.iter().take(MAX_PROMPT_DIFF_SUMMARIES) {
839            lines.push(format!("  - {}", summary));
840        }
841        if prompt_diff.summaries.len() > MAX_PROMPT_DIFF_SUMMARIES {
842            lines.push(format!(
843                "  - ... {} more summaries omitted",
844                prompt_diff.summaries.len() - MAX_PROMPT_DIFF_SUMMARIES
845            ));
846        }
847    }
848    lines.push(String::new());
849    if prompt_diff.excerpt.trim().is_empty() {
850        lines.push("Diff excerpt: omitted".to_string());
851        lines.push(
852            "[all available file diffs were low-signal generated or lockfile changes; use the structured stats above]"
853                .to_string(),
854        );
855    } else if prompt_diff.omitted_files.is_empty() {
856        lines.push("Diff excerpt:".to_string());
857        lines.push(prompt_diff.excerpt);
858    } else {
859        lines.push("Diff excerpt (low-signal generated and lockfile diffs omitted):".to_string());
860        lines.push(prompt_diff.excerpt);
861    }
862    lines.join("\n")
863}
864
865fn parse_ai_commit_payload(raw: &str, forced_scope: Option<&str>) -> Option<ConventionalCommit> {
866    let payload: AiCommitPayload = serde_json::from_str(raw).ok()?;
867    let commit_type = sanitize_commit_type(&payload.commit_type)?;
868    let description = sanitize_description(&payload.description)?;
869    let mut body = payload
870        .body
871        .into_iter()
872        .map(|entry| entry.trim().to_string())
873        .filter(|entry| !entry.is_empty())
874        .collect::<Vec<_>>();
875    if body.len() > 3 {
876        body.truncate(3);
877    }
878
879    let breaking_change = if payload.breaking_change {
880        payload
881            .breaking_description
882            .as_deref()
883            .and_then(sanitize_footer_value)
884    } else {
885        None
886    };
887
888    let mut footers = payload
889        .footers
890        .into_iter()
891        .map(|entry| entry.trim().to_string())
892        .filter(|entry| !entry.is_empty())
893        .collect::<Vec<_>>();
894    if breaking_change.is_some()
895        && !footers
896            .iter()
897            .any(|entry| entry.starts_with("BREAKING CHANGE:"))
898    {
899        if let Some(breaking) = &breaking_change {
900            footers.push(format!("BREAKING CHANGE: {}", breaking));
901        }
902    }
903
904    Some(ConventionalCommit {
905        commit_type,
906        scope: forced_scope
907            .and_then(|scope| sanitize_scope(Some(scope)))
908            .or_else(|| sanitize_scope(payload.scope.as_deref())),
909        description,
910        body,
911        breaking_change,
912        footers,
913    })
914}
915
916fn build_heuristic_commit(
917    analysis: &WorktreeAnalysis,
918    forced_scope: Option<String>,
919) -> ConventionalCommit {
920    let commit_type = infer_commit_type(analysis).to_string();
921    let scope = forced_scope.or_else(|| infer_scope(analysis));
922    let description = infer_description(analysis, &commit_type, scope.as_deref());
923    let body = build_heuristic_body(analysis);
924
925    ConventionalCommit {
926        commit_type,
927        scope,
928        description,
929        body,
930        breaking_change: None,
931        footers: Vec::new(),
932    }
933}
934
935fn infer_commit_type(analysis: &WorktreeAnalysis) -> &'static str {
936    let lowered_paths = analysis
937        .files
938        .iter()
939        .map(|file| file.path.to_ascii_lowercase())
940        .collect::<Vec<_>>();
941
942    if !lowered_paths.is_empty()
943        && lowered_paths
944            .iter()
945            .all(|path| is_terminal_session_path(path))
946    {
947        return "chore";
948    }
949
950    let docs_only = !lowered_paths.is_empty()
951        && lowered_paths.iter().all(|path| {
952            !is_terminal_session_path(path)
953                && (path.ends_with(".md")
954                    || path.ends_with(".mdx")
955                    || path.ends_with(".txt")
956                    || path.starts_with("docs/"))
957        });
958    if docs_only {
959        return "docs";
960    }
961
962    let test_only = !lowered_paths.is_empty()
963        && lowered_paths.iter().all(|path| {
964            path.contains("/tests/")
965                || path.contains("\\tests\\")
966                || path.contains(".test.")
967                || path.contains(".spec.")
968        });
969    if test_only {
970        return "test";
971    }
972
973    let ci_only = !lowered_paths.is_empty()
974        && lowered_paths.iter().all(|path| {
975            path.starts_with(".github/")
976                || path.contains("workflow")
977                || path.ends_with(".yml")
978                || path.ends_with(".yaml")
979        });
980    if ci_only {
981        return "ci";
982    }
983
984    let build_only = !lowered_paths.is_empty()
985        && lowered_paths.iter().all(|path| {
986            path.ends_with("cargo.toml")
987                || path.ends_with("cargo.lock")
988                || path.ends_with("package.json")
989                || path.ends_with("pnpm-lock.yaml")
990                || path.ends_with("package-lock.json")
991                || path.ends_with("wrangler.toml")
992                || path.ends_with(".nix")
993        });
994    if build_only {
995        return "build";
996    }
997
998    let has_new_files = analysis.files.iter().any(|file| file.status == "added");
999    let has_new_symbols = !analysis.new_functions.is_empty() || !analysis.new_types.is_empty();
1000    let has_existing_symbol_churn = !analysis.changed_functions.is_empty()
1001        || !analysis.changed_types.is_empty()
1002        || !analysis.removed_functions.is_empty()
1003        || !analysis.removed_types.is_empty();
1004    if has_new_files {
1005        return "feat";
1006    }
1007    if has_new_symbols && has_existing_symbol_churn {
1008        return "refactor";
1009    }
1010    if has_new_symbols {
1011        return "feat";
1012    }
1013
1014    if has_existing_symbol_churn || analysis.total_additions >= analysis.total_deletions {
1015        return "fix";
1016    }
1017
1018    "chore"
1019}
1020
1021fn infer_scope(analysis: &WorktreeAnalysis) -> Option<String> {
1022    let mut scopes = BTreeSet::new();
1023
1024    for file in &analysis.files {
1025        let path = file.path.replace('\\', "/");
1026        let inferred = if path.starts_with("crates/cli/") {
1027            Some("cli")
1028        } else if path.starts_with("crates/api/") {
1029            Some("api")
1030        } else if path.starts_with("crates/github/") {
1031            Some("github")
1032        } else if path.starts_with("crates/runtime/") {
1033            Some("runtime")
1034        } else if path.starts_with("apps/web/") {
1035            Some("web")
1036        } else if path.starts_with("docs/") {
1037            Some("docs")
1038        } else if path.starts_with(".github/") {
1039            Some("ci")
1040        } else {
1041            None
1042        };
1043
1044        if let Some(value) = inferred {
1045            scopes.insert(value.to_string());
1046        }
1047    }
1048
1049    if scopes.len() == 1 {
1050        scopes.into_iter().next()
1051    } else if scopes.is_empty() {
1052        None
1053    } else if scopes.contains("cli") {
1054        Some("cli".to_string())
1055    } else {
1056        None
1057    }
1058}
1059
1060fn infer_description(
1061    analysis: &WorktreeAnalysis,
1062    commit_type: &str,
1063    scope: Option<&str>,
1064) -> String {
1065    if !analysis.files.is_empty()
1066        && analysis
1067            .files
1068            .iter()
1069            .all(|file| is_terminal_session_path(&file.path))
1070    {
1071        return "ignore ide terminal session logs".to_string();
1072    }
1073
1074    let focus_areas = summarize_focus_areas(analysis, 2);
1075    let behavior_hint = infer_behavior_hint(analysis);
1076    let interesting_names = analysis
1077        .files
1078        .iter()
1079        .filter_map(|file| interesting_file_stem(&file.path))
1080        .collect::<Vec<_>>();
1081
1082    if interesting_names.iter().any(|name| name == "commit") {
1083        return match scope {
1084            Some("cli") => "add worktree commit generator".to_string(),
1085            _ => "add conventional commit generator".to_string(),
1086        };
1087    }
1088
1089    if commit_type == "docs" {
1090        if let Some(name) = interesting_names.first() {
1091            return format!("document {}", name.replace('_', "-"));
1092        }
1093        return "update command documentation".to_string();
1094    }
1095
1096    if let Some(area) = focus_areas.first() {
1097        let target = match behavior_hint {
1098            Some(hint) => format!("{} {}", area, hint),
1099            None => format!("{} flow", area),
1100        };
1101        return match commit_type {
1102            "feat" => format!("expand {}", target),
1103            "fix" => format!("improve {}", target),
1104            "refactor" => format!("refine {}", target),
1105            "test" => format!("cover {}", target),
1106            "ci" => format!("update {} workflow", area),
1107            "build" => format!("adjust {} build inputs", area),
1108            _ => format!("update {}", target),
1109        };
1110    }
1111
1112    if let Some(name) = interesting_names.first() {
1113        return match commit_type {
1114            "feat" => format!("add {}", name.replace('_', "-")),
1115            "fix" => format!("improve {}", name.replace('_', "-")),
1116            "refactor" => format!("refactor {}", name.replace('_', "-")),
1117            "test" => format!("cover {}", name.replace('_', "-")),
1118            "ci" => format!("update {}", name.replace('_', "-")),
1119            "build" => format!("adjust {}", name.replace('_', "-")),
1120            _ => format!("update {}", name.replace('_', "-")),
1121        };
1122    }
1123
1124    match commit_type {
1125        "feat" => "add worktree change support".to_string(),
1126        "fix" => "improve worktree handling".to_string(),
1127        "docs" => "update documentation".to_string(),
1128        _ => "update repository changes".to_string(),
1129    }
1130}
1131
1132fn build_heuristic_body(analysis: &WorktreeAnalysis) -> Vec<String> {
1133    let focus_areas = summarize_focus_areas(analysis, 3);
1134    let top_files = summarize_top_files(analysis, 3);
1135    let mut paragraphs = Vec::new();
1136    let overview_target = if focus_areas.is_empty() {
1137        format!(
1138            "{} file{}",
1139            analysis.files.len(),
1140            if analysis.files.len() == 1 { "" } else { "s" }
1141        )
1142    } else {
1143        format!(
1144            "{} across {} file{}",
1145            format_focus_area_list(&focus_areas),
1146            analysis.files.len(),
1147            if analysis.files.len() == 1 { "" } else { "s" }
1148        )
1149    };
1150    paragraphs.push(format!(
1151        "Touches {} with +{} and -{} lines in the current worktree.",
1152        overview_target, analysis.total_additions, analysis.total_deletions
1153    ));
1154
1155    let mut detail_parts = Vec::new();
1156    if !analysis.new_functions.is_empty() {
1157        detail_parts.push(format!(
1158            "adds {}",
1159            summarize_symbol_list(&analysis.new_functions, 4)
1160        ));
1161    }
1162    if !analysis.changed_functions.is_empty() {
1163        detail_parts.push(format!(
1164            "updates {}",
1165            summarize_symbol_list(&analysis.changed_functions, 4)
1166        ));
1167    }
1168    if !analysis.removed_functions.is_empty() {
1169        detail_parts.push(format!(
1170            "removes {}",
1171            summarize_symbol_list(&analysis.removed_functions, 3)
1172        ));
1173    }
1174    if !analysis.new_types.is_empty() {
1175        detail_parts.push(format!(
1176            "introduces {}",
1177            summarize_symbol_list(&analysis.new_types, 3)
1178        ));
1179    }
1180    if !analysis.changed_types.is_empty() {
1181        detail_parts.push(format!(
1182            "reshapes {}",
1183            summarize_symbol_list(&analysis.changed_types, 3)
1184        ));
1185    }
1186    if !analysis.removed_types.is_empty() {
1187        detail_parts.push(format!(
1188            "drops {}",
1189            summarize_symbol_list(&analysis.removed_types, 3)
1190        ));
1191    }
1192    if !top_files.is_empty() {
1193        detail_parts.push(format!(
1194            "with the biggest diffs in {}",
1195            top_files.join(", ")
1196        ));
1197    }
1198    if !detail_parts.is_empty() {
1199        paragraphs.push(format!("{}.", detail_parts.join("; ")));
1200    }
1201
1202    paragraphs
1203}
1204
1205fn summarize_focus_areas(analysis: &WorktreeAnalysis, limit: usize) -> Vec<String> {
1206    let mut area_scores = BTreeMap::<String, u32>::new();
1207
1208    for file in &analysis.files {
1209        let Some(area) = describe_focus_area(&file.path) else {
1210            continue;
1211        };
1212        let weight = (file.additions + file.deletions).max(1);
1213        *area_scores.entry(area).or_default() += weight;
1214    }
1215
1216    let mut ranked = area_scores.into_iter().collect::<Vec<_>>();
1217    ranked.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1218    ranked
1219        .into_iter()
1220        .map(|(area, _)| area)
1221        .take(limit)
1222        .collect()
1223}
1224
1225#[derive(Debug, Clone, PartialEq, Eq)]
1226struct TopFileGroup {
1227    label: String,
1228    file_count: usize,
1229    additions: u32,
1230    deletions: u32,
1231}
1232
1233fn summarize_top_files(analysis: &WorktreeAnalysis, limit: usize) -> Vec<String> {
1234    let groups = collapse_top_file_groups(analysis);
1235
1236    groups
1237        .into_iter()
1238        .take(limit)
1239        .map(|group| format_top_file_group(&group))
1240        .collect()
1241}
1242
1243fn collapse_top_file_groups(analysis: &WorktreeAnalysis) -> Vec<TopFileGroup> {
1244    let mut parent_counts = BTreeMap::<String, usize>::new();
1245    for file in &analysis.files {
1246        if let Some(parent) = parent_dir_key(&file.path) {
1247            *parent_counts.entry(parent).or_default() += 1;
1248        }
1249    }
1250
1251    let mut groups = BTreeMap::<String, TopFileGroup>::new();
1252    for file in &analysis.files {
1253        let label = top_file_group_label(&file.path, &parent_counts);
1254        let entry = groups.entry(label.clone()).or_insert(TopFileGroup {
1255            label,
1256            file_count: 0,
1257            additions: 0,
1258            deletions: 0,
1259        });
1260        entry.file_count += 1;
1261        entry.additions += file.additions;
1262        entry.deletions += file.deletions;
1263    }
1264
1265    let mut ranked = groups.into_values().collect::<Vec<_>>();
1266    ranked.sort_by(|left, right| {
1267        let left_weight = left.additions + left.deletions;
1268        let right_weight = right.additions + right.deletions;
1269        right_weight
1270            .cmp(&left_weight)
1271            .then_with(|| left.label.cmp(&right.label))
1272    });
1273    ranked
1274}
1275
1276fn top_file_group_label(path: &str, parent_counts: &BTreeMap<String, usize>) -> String {
1277    let normalized = normalize_display_path(path);
1278    if normalized.starts_with("terminals/") {
1279        return "terminals/".to_string();
1280    }
1281
1282    if let Some(parent) = parent_dir_key(&normalized) {
1283        if parent_counts.get(&parent).copied().unwrap_or(0) > 1 {
1284            return format!("{}/", parent);
1285        }
1286    }
1287
1288    normalized
1289}
1290
1291fn is_terminal_session_path(path: &str) -> bool {
1292    normalize_display_path(path)
1293        .to_ascii_lowercase()
1294        .starts_with(TERMINAL_SESSION_DIR)
1295}
1296
1297fn terminal_session_paths(analysis: &WorktreeAnalysis) -> Vec<String> {
1298    analysis
1299        .files
1300        .iter()
1301        .filter(|file| is_terminal_session_path(&file.path))
1302        .map(|file| file.path.clone())
1303        .collect()
1304}
1305
1306fn render_terminal_session_block_message(paths: &[String]) -> String {
1307    format!(
1308        "Refusing to commit IDE terminal session dumps under `{}`. These files are agent session logs, not project source. The `.next-id` file is an IDE session counter, not application configuration.\nAdd `/terminals` to `.gitignore` and remove tracked copies with `git rm -r --cached terminals/`.\nIgnored paths: {}",
1309        TERMINAL_SESSION_DIR.trim_end_matches('/'),
1310        paths.join(", ")
1311    )
1312}
1313
1314fn parent_dir_key(path: &str) -> Option<String> {
1315    let normalized = normalize_display_path(path);
1316    Path::new(&normalized)
1317        .parent()
1318        .and_then(|value| value.to_str())
1319        .map(normalize_display_path)
1320        .filter(|value| !value.is_empty())
1321}
1322
1323fn format_top_file_group(group: &TopFileGroup) -> String {
1324    if group.file_count > 1 {
1325        format!(
1326            "{} ({} files, +{} -{})",
1327            group.label, group.file_count, group.additions, group.deletions
1328        )
1329    } else {
1330        format!(
1331            "{} (+{} -{})",
1332            group.label, group.additions, group.deletions
1333        )
1334    }
1335}
1336
1337fn color_commit_type(commit_type: &str) -> colored::ColoredString {
1338    match commit_type {
1339        "feat" => commit_type.bright_green().bold(),
1340        "fix" => commit_type.bright_red().bold(),
1341        "docs" => commit_type.bright_blue().bold(),
1342        "refactor" => commit_type.bright_magenta().bold(),
1343        "chore" => commit_type.bright_black().bold(),
1344        "test" => commit_type.bright_cyan().bold(),
1345        "build" => commit_type.bright_yellow().bold(),
1346        "ci" => commit_type.cyan().bold(),
1347        "perf" => commit_type.bright_yellow().bold(),
1348        "style" => commit_type.white().bold(),
1349        "revert" => commit_type.red().bold(),
1350        _ => commit_type.bright_white().bold(),
1351    }
1352}
1353
1354fn render_colored_commit_subject(commit: &ConventionalCommit) -> String {
1355    let scope = commit
1356        .scope
1357        .as_deref()
1358        .map(|value| format!("({})", value.magenta()))
1359        .unwrap_or_default();
1360    let bang = if commit.breaking_change.is_some() {
1361        "!".bright_red().to_string()
1362    } else {
1363        String::new()
1364    };
1365    let description = commit.description.bright_white();
1366
1367    format!(
1368        "{}{}{}: {}",
1369        color_commit_type(&commit.commit_type),
1370        scope,
1371        bang,
1372        description
1373    )
1374}
1375
1376fn render_commit_message_preview(commit: &ConventionalCommit) -> String {
1377    let mut sections = vec![render_colored_commit_subject(commit)];
1378
1379    if !commit.body.is_empty() {
1380        sections.push(commit.body.join("\n\n"));
1381    }
1382
1383    let mut footer_lines = commit
1384        .footers
1385        .iter()
1386        .map(|entry| entry.trim().to_string())
1387        .filter(|entry| !entry.is_empty())
1388        .collect::<Vec<_>>();
1389    if let Some(breaking_change) = &commit.breaking_change {
1390        if !footer_lines
1391            .iter()
1392            .any(|entry| entry.starts_with("BREAKING CHANGE:"))
1393        {
1394            footer_lines.push(format!("BREAKING CHANGE: {}", breaking_change));
1395        }
1396    }
1397    if !footer_lines.is_empty() {
1398        sections.push(footer_lines.join("\n"));
1399    }
1400
1401    sections.join("\n\n")
1402}
1403
1404fn format_colored_change_counts(additions: u32, deletions: u32) -> String {
1405    format!(
1406        "(+{} -{})",
1407        additions.to_string().bright_green(),
1408        deletions.to_string().bright_red()
1409    )
1410}
1411
1412fn format_colored_top_file_group(group: &TopFileGroup) -> String {
1413    let change_counts = format_colored_change_counts(group.additions, group.deletions);
1414    if group.file_count > 1 {
1415        format!(
1416            "{} ({} files, {})",
1417            group.label.bright_white(),
1418            group.file_count.to_string().bright_white(),
1419            change_counts
1420        )
1421    } else {
1422        format!("{} {}", group.label.bright_white(), change_counts)
1423    }
1424}
1425
1426fn describe_focus_area(path: &str) -> Option<String> {
1427    let normalized = normalize_display_path(path);
1428    if normalized.starts_with("terminals/") {
1429        return Some("terminals".to_string());
1430    }
1431    if normalized.starts_with("crates/cli/src/commands/") {
1432        return Path::new(&normalized)
1433            .file_stem()
1434            .and_then(|value| value.to_str())
1435            .map(humanize_focus_area)
1436            .filter(|value| !value.is_empty());
1437    }
1438    if normalized.contains("/http-app.") {
1439        return Some("http".to_string());
1440    }
1441    if normalized.contains("/server.") {
1442        return Some("server".to_string());
1443    }
1444    if normalized.contains("/worker/") {
1445        return Some("worker".to_string());
1446    }
1447    if let Some(stem) = interesting_file_stem(&normalized) {
1448        return Some(humanize_focus_area(&stem));
1449    }
1450
1451    normalized
1452        .split('/')
1453        .rev()
1454        .find_map(normalize_focus_segment)
1455}
1456
1457fn humanize_focus_area(raw: &str) -> String {
1458    raw.trim().replace(['_', '-'], " ").to_ascii_lowercase()
1459}
1460
1461fn is_generic_path_segment(segment: &str) -> bool {
1462    matches!(
1463        segment.to_ascii_lowercase().as_str(),
1464        "src"
1465            | "crates"
1466            | "apps"
1467            | "packages"
1468            | "commands"
1469            | "tests"
1470            | "test"
1471            | "docs"
1472            | "lib"
1473            | "terminals"
1474    )
1475}
1476
1477fn is_weak_focus_label(label: &str) -> bool {
1478    let trimmed = label.trim();
1479    trimmed.is_empty() || trimmed.chars().all(|ch| ch.is_ascii_digit())
1480}
1481
1482fn normalize_focus_segment(segment: &str) -> Option<String> {
1483    let trimmed = segment.trim();
1484    if trimmed.is_empty() || is_generic_path_segment(trimmed) {
1485        return None;
1486    }
1487
1488    let label = trimmed.trim_start_matches('.');
1489    if is_weak_focus_label(label) {
1490        return None;
1491    }
1492
1493    let humanized = humanize_focus_area(label);
1494    if humanized.is_empty() {
1495        None
1496    } else {
1497        Some(humanized)
1498    }
1499}
1500
1501fn infer_behavior_hint(analysis: &WorktreeAnalysis) -> Option<&'static str> {
1502    let mut corpus = String::new();
1503    for file in &analysis.files {
1504        corpus.push_str(&file.path.to_ascii_lowercase());
1505        corpus.push(' ');
1506    }
1507    for context in &analysis.hunk_contexts {
1508        corpus.push_str(&context.to_ascii_lowercase());
1509        corpus.push(' ');
1510    }
1511
1512    if corpus.contains("request") || corpus.contains("response") || corpus.contains("http") {
1513        Some("request handling")
1514    } else if corpus.contains("auth") || corpus.contains("token") || corpus.contains("session") {
1515        Some("auth flow")
1516    } else if corpus.contains("env") || corpus.contains("config") || corpus.contains("setting") {
1517        Some("configuration loading")
1518    } else if corpus.contains("release") || corpus.contains("version") || corpus.contains("tag") {
1519        Some("release flow")
1520    } else if corpus.contains("docker") || corpus.contains("container") {
1521        Some("container setup")
1522    } else {
1523        None
1524    }
1525}
1526
1527fn format_focus_area_list(areas: &[String]) -> String {
1528    match areas {
1529        [] => "the current worktree".to_string(),
1530        [one] => one.clone(),
1531        [left, right] => format!("{} and {}", left, right),
1532        _ => {
1533            let mut items = areas.to_vec();
1534            let last = items.pop().unwrap_or_default();
1535            format!("{}, and {}", items.join(", "), last)
1536        }
1537    }
1538}
1539
1540fn summarize_symbol_list(entries: &[String], limit: usize) -> String {
1541    entries
1542        .iter()
1543        .take(limit)
1544        .map(|entry| {
1545            entry
1546                .split_once(" (")
1547                .map(|(name, _)| name.to_string())
1548                .unwrap_or_else(|| entry.clone())
1549        })
1550        .collect::<Vec<_>>()
1551        .join(", ")
1552}
1553
1554fn render_commit_message(commit: &ConventionalCommit) -> String {
1555    let mut sections = vec![render_commit_subject(commit)];
1556
1557    if !commit.body.is_empty() {
1558        sections.push(commit.body.join("\n\n"));
1559    }
1560
1561    let mut footer_lines = commit
1562        .footers
1563        .iter()
1564        .map(|entry| entry.trim().to_string())
1565        .filter(|entry| !entry.is_empty())
1566        .collect::<Vec<_>>();
1567    if let Some(breaking_change) = &commit.breaking_change {
1568        if !footer_lines
1569            .iter()
1570            .any(|entry| entry.starts_with("BREAKING CHANGE:"))
1571        {
1572            footer_lines.push(format!("BREAKING CHANGE: {}", breaking_change));
1573        }
1574    }
1575    if !footer_lines.is_empty() {
1576        sections.push(footer_lines.join("\n"));
1577    }
1578
1579    sections.join("\n\n")
1580}
1581
1582fn render_commit_subject(commit: &ConventionalCommit) -> String {
1583    let scope = commit
1584        .scope
1585        .as_deref()
1586        .map(|value| format!("({})", value))
1587        .unwrap_or_default();
1588    let bang = if commit.breaking_change.is_some() {
1589        "!"
1590    } else {
1591        ""
1592    };
1593    format!(
1594        "{}{}{}: {}",
1595        commit.commit_type, scope, bang, commit.description
1596    )
1597}
1598
1599fn print_analysis_summary(
1600    analysis: &WorktreeAnalysis,
1601    commit_plan: &CommitMessagePlan,
1602    _rendered_message: &str,
1603) {
1604    let focus_areas = summarize_focus_areas(analysis, MAX_PRINT_AREAS);
1605    let top_files = collapse_top_file_groups(analysis);
1606    let branch = analysis
1607        .branch
1608        .as_deref()
1609        .map(|value| format!(" on {}", value.bright_blue()))
1610        .unwrap_or_default();
1611    println!(
1612        "{} {}{}",
1613        "Commit".bright_green().bold(),
1614        analysis.repo_name.bright_white().bold(),
1615        branch
1616    );
1617    println!(
1618        "  {} {}",
1619        "Mode".bright_cyan().bold(),
1620        match commit_plan.generation_mode {
1621            GenerationMode::OpenRouter => "OpenRouter".bright_white(),
1622            GenerationMode::Heuristic => "Heuristic fallback".bright_white(),
1623        }
1624    );
1625    println!(
1626        "  {} {}",
1627        "Subject".bright_cyan().bold(),
1628        render_colored_commit_subject(&commit_plan.commit)
1629    );
1630    println!(
1631        "  {} {} file{} (+{} -{})",
1632        "Diff".bright_cyan().bold(),
1633        analysis.files.len().to_string().bright_white(),
1634        if analysis.files.len() == 1 { "" } else { "s" },
1635        analysis.total_additions.to_string().bright_green(),
1636        analysis.total_deletions.to_string().bright_red()
1637    );
1638    if !focus_areas.is_empty() {
1639        println!(
1640            "  {} {}",
1641            "Focus".bright_cyan().bold(),
1642            focus_areas.join(", ")
1643        );
1644    }
1645    if !top_files.is_empty() {
1646        let top_files = top_files
1647            .into_iter()
1648            .take(MAX_PRINT_FILES)
1649            .map(|group| format_colored_top_file_group(&group))
1650            .collect::<Vec<_>>();
1651        println!("  {} {}", "Top".bright_cyan().bold(), top_files.join(", "));
1652    }
1653
1654    if !analysis.new_functions.is_empty() {
1655        println!(
1656            "  {} {}",
1657            "+fn".bright_cyan().bold(),
1658            summarize_symbol_list(&analysis.new_functions, MAX_PRINT_SYMBOLS)
1659        );
1660    }
1661    if !analysis.changed_functions.is_empty() {
1662        println!(
1663            "  {} {}",
1664            "~fn".bright_cyan().bold(),
1665            summarize_symbol_list(&analysis.changed_functions, MAX_PRINT_SYMBOLS)
1666        );
1667    }
1668    if !analysis.new_types.is_empty() {
1669        println!(
1670            "  {} {}",
1671            "+type".bright_cyan().bold(),
1672            summarize_symbol_list(&analysis.new_types, MAX_PRINT_SYMBOLS)
1673        );
1674    }
1675    if !analysis.changed_types.is_empty() {
1676        println!(
1677            "  {} {}",
1678            "~type".bright_cyan().bold(),
1679            summarize_symbol_list(&analysis.changed_types, MAX_PRINT_SYMBOLS)
1680        );
1681    }
1682    if !analysis.removed_functions.is_empty() {
1683        println!(
1684            "  {} {}",
1685            "-fn".bright_cyan().bold(),
1686            summarize_symbol_list(&analysis.removed_functions, MAX_PRINT_SYMBOLS)
1687        );
1688    }
1689    if !analysis.removed_types.is_empty() {
1690        println!(
1691            "  {} {}",
1692            "-type".bright_cyan().bold(),
1693            summarize_symbol_list(&analysis.removed_types, MAX_PRINT_SYMBOLS)
1694        );
1695    }
1696
1697    println!("\n{}", "Generated commit".bright_magenta().bold());
1698    println!("{}", "-".repeat(72).bright_black());
1699    println!("{}", render_commit_message_preview(&commit_plan.commit));
1700    println!("{}", "-".repeat(72).bright_black());
1701}
1702
1703async fn stage_and_commit(
1704    repo_root: &Path,
1705    message: &str,
1706    status_entries: &[StatusEntry],
1707    exclude_paths: &[String],
1708) -> Result<StageAndCommitOutcome, String> {
1709    stage_commit_paths(repo_root, status_entries, exclude_paths).await?;
1710
1711    let message_path = env::temp_dir().join(format!("xbp-commit-message-{}.txt", Uuid::new_v4()));
1712    fs::write(&message_path, message).map_err(|e| {
1713        format!(
1714            "Failed to write temporary commit message {}: {}",
1715            message_path.display(),
1716            e
1717        )
1718    })?;
1719
1720    println!("{}", "Creating git commit...".bright_black());
1721    let result = git_output_streaming(
1722        repo_root,
1723        &["commit", "--file", &message_path.to_string_lossy()],
1724    )
1725    .await;
1726    let _ = fs::remove_file(&message_path);
1727    match result {
1728        Ok(_) => Ok(StageAndCommitOutcome::Committed),
1729        Err(error) if looks_like_nothing_to_commit_error(&error) => {
1730            if !git_has_staged_changes(repo_root).await? && git_worktree_is_clean(repo_root).await?
1731            {
1732                Ok(StageAndCommitOutcome::NothingToCommit)
1733            } else {
1734                Err(error)
1735            }
1736        }
1737        Err(error) => Err(error),
1738    }
1739}
1740
1741async fn stage_commit_paths(
1742    repo_root: &Path,
1743    status_entries: &[StatusEntry],
1744    exclude_paths: &[String],
1745) -> Result<(), String> {
1746    let stage_paths = stage_pathspecs(status_entries);
1747    let StageStrategy::BulkAddAll { path_count } =
1748        choose_stage_strategy(&stage_paths, exclude_paths);
1749    // Always bulk-stage. Explicit pathspecs are fragile on Windows (argv limits) and
1750    // unnecessary: commit stages the whole worktree minus explicit excludes.
1751    // `git add --all` also respects .gitignore.
1752    if path_count == 0 {
1753        println!("{}", "Staging worktree changes...".bright_black());
1754    } else {
1755        println!(
1756            "{}",
1757            format!("Staging {} changed path(s)...", path_count).bright_black()
1758        );
1759    }
1760    git_output(repo_root, &["add", "--all"]).await?;
1761
1762    for path in exclude_paths {
1763        println!(
1764            "{} {}",
1765            "Keeping excluded path unstaged:".bright_black(),
1766            path.bright_white()
1767        );
1768        git_output(repo_root, &["reset", "HEAD", "--", path.as_str()]).await?;
1769    }
1770    Ok(())
1771}
1772
1773#[derive(Debug, Clone, PartialEq, Eq)]
1774enum StageStrategy {
1775    /// Stage via `git add --all` (ignore-aware, no argv length risk).
1776    BulkAddAll { path_count: usize },
1777}
1778
1779fn choose_stage_strategy(stage_paths: &[String], _exclude_paths: &[String]) -> StageStrategy {
1780    StageStrategy::BulkAddAll {
1781        path_count: stage_paths.len(),
1782    }
1783}
1784
1785#[cfg(test)]
1786fn pathspecs_exceed_safe_argv_budget(paths: &[String]) -> bool {
1787    if paths.len() > SAFE_GIT_PATHSPEC_COUNT {
1788        return true;
1789    }
1790    // Historical bulk-add threshold: long explicit pathspec argv lists are unsafe.
1791    let fixed = "git add --all --".len();
1792    let paths_chars: usize = paths.iter().map(|path| path.len().saturating_add(1)).sum();
1793    fixed.saturating_add(paths_chars) > SAFE_GIT_ARGV_CHAR_BUDGET
1794}
1795
1796fn stage_pathspecs(status_entries: &[StatusEntry]) -> Vec<String> {
1797    let mut paths = BTreeSet::new();
1798    for entry in status_entries {
1799        let path = entry.path.trim();
1800        if path.is_empty() || path.contains(" -> ") {
1801            return Vec::new();
1802        }
1803        // Never feed gitignored-looking path markers into explicit pathspecs.
1804        // Porcelain without --ignored does not list untracked ignored files; this is a
1805        // defensive guard if status output is ever assembled from other sources.
1806        if path.ends_with('/') && path.contains(".git/") {
1807            continue;
1808        }
1809        paths.insert(path.to_string());
1810    }
1811    paths.into_iter().collect()
1812}
1813
1814/// Drop untracked paths that match `.gitignore` / exclude rules so they are neither
1815/// analyzed for the commit message nor staged. Tracked files that later match ignore
1816/// rules still appear in status (git continues to track them until untracked).
1817async fn filter_gitignored_status_entries(
1818    repo_root: &Path,
1819    status_entries: Vec<StatusEntry>,
1820) -> Result<Vec<StatusEntry>, String> {
1821    let untracked: Vec<&StatusEntry> = status_entries
1822        .iter()
1823        .filter(|entry| entry.code.contains('?'))
1824        .collect();
1825    if untracked.is_empty() {
1826        return Ok(status_entries);
1827    }
1828
1829    let ignored = git_check_ignore_paths(
1830        repo_root,
1831        &untracked
1832            .iter()
1833            .map(|entry| entry.path.as_str())
1834            .collect::<Vec<_>>(),
1835    )
1836    .await?;
1837
1838    if ignored.is_empty() {
1839        return Ok(status_entries);
1840    }
1841
1842    println!(
1843        "{} {}",
1844        "Skipped".bright_yellow().bold(),
1845        format!(
1846            "excluding {} path(s) matching .gitignore / exclude rules from commit analysis and staging",
1847            ignored.len()
1848        )
1849        .bright_white()
1850    );
1851
1852    Ok(status_entries
1853        .into_iter()
1854        .filter(|entry| !(entry.code.contains('?') && ignored.contains(&entry.path)))
1855        .collect())
1856}
1857
1858async fn git_check_ignore_paths(
1859    repo_root: &Path,
1860    paths: &[&str],
1861) -> Result<BTreeSet<String>, String> {
1862    if paths.is_empty() {
1863        return Ok(BTreeSet::new());
1864    }
1865
1866    let mut command = Command::new("git");
1867    command
1868        .current_dir(repo_root)
1869        .args(["check-ignore", "--stdin"])
1870        .stdin(std::process::Stdio::piped())
1871        .stdout(std::process::Stdio::piped())
1872        .stderr(std::process::Stdio::piped());
1873
1874    let mut child = command
1875        .spawn()
1876        .map_err(|e| format!("Failed to run `git check-ignore`: {}", e))?;
1877
1878    if let Some(mut stdin) = child.stdin.take() {
1879        use tokio::io::AsyncWriteExt;
1880        let body = paths.join("\n");
1881        stdin
1882            .write_all(body.as_bytes())
1883            .await
1884            .map_err(|e| format!("Failed to write paths to `git check-ignore`: {}", e))?;
1885        // Close stdin so check-ignore can finish.
1886        drop(stdin);
1887    }
1888
1889    let output = child
1890        .wait_with_output()
1891        .await
1892        .map_err(|e| format!("Failed to wait for `git check-ignore`: {}", e))?;
1893
1894    // Exit 0: some paths ignored; 1: none ignored. Both are success outcomes.
1895    match output.status.code() {
1896        Some(0) | Some(1) => {}
1897        _ => {
1898            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1899            if !stderr.is_empty() {
1900                return Err(format!("`git check-ignore` failed: {}", stderr));
1901            }
1902            return Err(format!(
1903                "`git check-ignore` failed with status {}",
1904                output.status
1905            ));
1906        }
1907    }
1908
1909    let ignored = String::from_utf8_lossy(&output.stdout)
1910        .lines()
1911        .map(str::trim)
1912        .filter(|line| !line.is_empty())
1913        .map(|line| line.replace('\\', "/"))
1914        .collect();
1915    Ok(ignored)
1916}
1917
1918fn format_git_args_for_error(args: &[&str]) -> String {
1919    let joined = args.join(" ");
1920    if joined.chars().count() <= MAX_GIT_ERROR_ARG_CHARS {
1921        return joined;
1922    }
1923    let truncated: String = joined.chars().take(MAX_GIT_ERROR_ARG_CHARS).collect();
1924    format!(
1925        "{}… [{} args, truncated for display]",
1926        truncated,
1927        args.len()
1928    )
1929}
1930
1931fn parse_status_entries(output: &str) -> Vec<StatusEntry> {
1932    output
1933        .lines()
1934        .filter_map(|line| {
1935            // Porcelain v1: fixed two-column XY status, then a space, then the path.
1936            // Do not trim the line first — leading space is a valid X status (e.g. ` M`).
1937            if line.len() < 4 {
1938                return None;
1939            }
1940            let code = line[..2].trim().to_string();
1941            let path = line[3..].trim().replace('\\', "/");
1942            // Unquote git's C-style quoted paths when present.
1943            let path = unquote_porcelain_path(&path);
1944            if path.is_empty() {
1945                None
1946            } else {
1947                Some(StatusEntry { code, path })
1948            }
1949        })
1950        .collect()
1951}
1952
1953fn unquote_porcelain_path(path: &str) -> String {
1954    let trimmed = path.trim();
1955    if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
1956        // Minimal unescape for common git quoted paths; keep contents otherwise.
1957        trimmed[1..trimmed.len() - 1]
1958            .replace("\\\\", "\\")
1959            .replace("\\\"", "\"")
1960            .replace("\\t", "\t")
1961            .replace("\\n", "\n")
1962            .replace("\\r", "\r")
1963    } else {
1964        trimmed.to_string()
1965    }
1966}
1967
1968fn merge_file_summaries(name_status: &str, numstat: &str) -> Vec<FileChangeSummary> {
1969    let mut status_map = BTreeMap::new();
1970    let mut display_path_map = BTreeMap::new();
1971
1972    for raw_line in name_status
1973        .lines()
1974        .map(str::trim)
1975        .filter(|line| !line.is_empty())
1976    {
1977        let parts = raw_line.split('\t').collect::<Vec<_>>();
1978        if parts.is_empty() {
1979            continue;
1980        }
1981        let status = normalize_name_status(parts[0]);
1982        match parts.as_slice() {
1983            [_, path] => {
1984                let key = normalize_path_key(path);
1985                display_path_map.insert(key.clone(), normalize_display_path(path));
1986                status_map.insert(key, status);
1987            }
1988            [_, old_path, new_path] => {
1989                let key = normalize_path_key(new_path);
1990                display_path_map.insert(
1991                    key.clone(),
1992                    format!(
1993                        "{} -> {}",
1994                        normalize_display_path(old_path),
1995                        normalize_display_path(new_path)
1996                    ),
1997                );
1998                status_map.insert(key, status);
1999            }
2000            _ => {}
2001        }
2002    }
2003
2004    let mut files = Vec::new();
2005    for raw_line in numstat
2006        .lines()
2007        .map(str::trim)
2008        .filter(|line| !line.is_empty())
2009    {
2010        let parts = raw_line.split('\t').collect::<Vec<_>>();
2011        if parts.len() < 3 {
2012            continue;
2013        }
2014        let additions = parts[0].parse::<u32>().unwrap_or(0);
2015        let deletions = parts[1].parse::<u32>().unwrap_or(0);
2016        let raw_path = parts[2..].join("\t");
2017        let key = normalize_path_key(&raw_path);
2018        let path = display_path_map
2019            .get(&key)
2020            .cloned()
2021            .unwrap_or_else(|| normalize_display_path(&raw_path));
2022        let status = status_map
2023            .get(&key)
2024            .cloned()
2025            .unwrap_or_else(|| "modified".to_string());
2026        files.push(FileChangeSummary {
2027            path,
2028            status,
2029            additions,
2030            deletions,
2031        });
2032    }
2033
2034    if files.is_empty() {
2035        for (key, status) in status_map {
2036            files.push(FileChangeSummary {
2037                path: display_path_map
2038                    .get(&key)
2039                    .cloned()
2040                    .unwrap_or_else(|| key.clone()),
2041                status,
2042                additions: 0,
2043                deletions: 0,
2044            });
2045        }
2046    }
2047
2048    files
2049}
2050
2051fn summarize_symbols(diff_text: &str) -> SymbolSummary {
2052    let mut current_file = String::new();
2053    let mut added_functions = BTreeSet::new();
2054    let mut removed_functions = BTreeSet::new();
2055    let mut added_types = BTreeSet::new();
2056    let mut removed_types = BTreeSet::new();
2057    let mut changed_functions = BTreeSet::new();
2058    let mut changed_types = BTreeSet::new();
2059    let mut hunk_contexts = BTreeSet::new();
2060
2061    for line in diff_text.lines() {
2062        if let Some(rest) = line.strip_prefix("+++ b/") {
2063            current_file = rest.trim().replace('\\', "/");
2064            continue;
2065        }
2066        if line.starts_with("@@") {
2067            if let Some(context) = parse_hunk_context(line) {
2068                if is_code_path(&current_file) {
2069                    if let Some((symbol, kind)) = extract_context_symbol(&context) {
2070                        match kind {
2071                            SymbolKind::Function => {
2072                                changed_functions.insert(symbol);
2073                            }
2074                            SymbolKind::Type => {
2075                                changed_types.insert(symbol);
2076                            }
2077                        }
2078                    }
2079                }
2080                if prompt_diff_omit_reason(&current_file).is_none()
2081                    && !is_low_signal_hunk_context(&context)
2082                {
2083                    hunk_contexts.insert(context);
2084                }
2085            }
2086            continue;
2087        }
2088
2089        if current_file.is_empty() || line.starts_with("+++") || line.starts_with("---") {
2090            continue;
2091        }
2092
2093        if let Some(source) = line.strip_prefix('+') {
2094            if let Some(name) = extract_function_name(source) {
2095                added_functions.insert(format!("{} ({})", name, current_file));
2096            }
2097            if let Some(name) = extract_type_name(source) {
2098                added_types.insert(format!("{} ({})", name, current_file));
2099            }
2100        } else if let Some(source) = line.strip_prefix('-') {
2101            if let Some(name) = extract_function_name(source) {
2102                removed_functions.insert(format!("{} ({})", name, current_file));
2103            }
2104            if let Some(name) = extract_type_name(source) {
2105                removed_types.insert(format!("{} ({})", name, current_file));
2106            }
2107        }
2108    }
2109
2110    let changed_functions_from_dupes = added_functions
2111        .intersection(&removed_functions)
2112        .cloned()
2113        .collect::<BTreeSet<_>>();
2114    let changed_types_from_dupes = added_types
2115        .intersection(&removed_types)
2116        .cloned()
2117        .collect::<BTreeSet<_>>();
2118
2119    for entry in &changed_functions_from_dupes {
2120        changed_functions.insert(entry.clone());
2121    }
2122    for entry in &changed_types_from_dupes {
2123        changed_types.insert(entry.clone());
2124    }
2125
2126    let new_functions = added_functions
2127        .difference(&changed_functions_from_dupes)
2128        .cloned()
2129        .collect::<Vec<_>>();
2130    let removed_functions = removed_functions
2131        .difference(&changed_functions_from_dupes)
2132        .cloned()
2133        .collect::<Vec<_>>();
2134    let new_types = added_types
2135        .difference(&changed_types_from_dupes)
2136        .cloned()
2137        .collect::<Vec<_>>();
2138    let removed_types = removed_types
2139        .difference(&changed_types_from_dupes)
2140        .cloned()
2141        .collect::<Vec<_>>();
2142
2143    SymbolSummary {
2144        new_functions,
2145        changed_functions: changed_functions.into_iter().collect(),
2146        removed_functions,
2147        new_types,
2148        changed_types: changed_types.into_iter().collect(),
2149        removed_types,
2150        hunk_contexts: hunk_contexts.into_iter().collect(),
2151    }
2152}
2153
2154fn parse_hunk_context(line: &str) -> Option<String> {
2155    let parts = line.split("@@").collect::<Vec<_>>();
2156    if parts.len() < 3 {
2157        return None;
2158    }
2159    let context = parts[2].trim();
2160    if context.is_empty() {
2161        None
2162    } else {
2163        Some(context.to_string())
2164    }
2165}
2166
2167fn is_low_signal_hunk_context(context: &str) -> bool {
2168    let normalized = context.trim().to_ascii_lowercase();
2169    normalized.is_empty()
2170        || matches!(
2171            normalized.as_str(),
2172            "importers:" | "packages:" | "snapshots:" | "[[package]]"
2173        )
2174        || normalized.starts_with("name = ")
2175        || normalized.starts_with("version = ")
2176        || normalized.starts_with("source = ")
2177        || normalized.starts_with("checksum = ")
2178}
2179
2180fn extract_context_symbol(context: &str) -> Option<(String, SymbolKind)> {
2181    let trimmed = context.trim();
2182    if trimmed.is_empty() {
2183        return None;
2184    }
2185
2186    for capture in [
2187        RUST_FUNCTION_RE.captures(trimmed),
2188        JS_FUNCTION_RE.captures(trimmed),
2189    ]
2190    .into_iter()
2191    .flatten()
2192    {
2193        if let Some(name) = capture.get(1) {
2194            let symbol = name.as_str().to_string();
2195            if is_noise_symbol(&symbol) {
2196                return None;
2197            }
2198            return Some((symbol, SymbolKind::Function));
2199        }
2200    }
2201
2202    if let Some(capture) = JS_ARROW_RE.captures(trimmed) {
2203        if let Some(name) = capture.get(1) {
2204            let symbol = name.as_str().to_string();
2205            if is_noise_symbol(&symbol) {
2206                return None;
2207            }
2208            return Some((symbol, SymbolKind::Function));
2209        }
2210    }
2211
2212    for capture in [RUST_TYPE_RE.captures(trimmed), TS_TYPE_RE.captures(trimmed)]
2213        .into_iter()
2214        .flatten()
2215    {
2216        if let Some(name) = capture.get(2) {
2217            let symbol = name.as_str().to_string();
2218            if is_noise_symbol(&symbol) {
2219                return None;
2220            }
2221            return Some((symbol, SymbolKind::Type));
2222        }
2223    }
2224
2225    if let Some(capture) = METHOD_CONTEXT_RE.captures(trimmed) {
2226        if let Some(name) = capture.get(1) {
2227            let symbol = name.as_str().to_string();
2228            if is_noise_symbol(&symbol) {
2229                return None;
2230            }
2231            return Some((symbol, SymbolKind::Function));
2232        }
2233    }
2234
2235    None
2236}
2237
2238fn extract_function_name(source: &str) -> Option<String> {
2239    for capture in [
2240        RUST_FUNCTION_RE.captures(source),
2241        JS_FUNCTION_RE.captures(source),
2242        JS_ARROW_RE.captures(source),
2243    ]
2244    .into_iter()
2245    .flatten()
2246    {
2247        if let Some(name) = capture.get(1) {
2248            return Some(name.as_str().to_string());
2249        }
2250    }
2251    None
2252}
2253
2254fn extract_type_name(source: &str) -> Option<String> {
2255    for capture in [RUST_TYPE_RE.captures(source), TS_TYPE_RE.captures(source)]
2256        .into_iter()
2257        .flatten()
2258    {
2259        if let Some(name) = capture.get(2) {
2260            return Some(name.as_str().to_string());
2261        }
2262    }
2263    None
2264}
2265
2266fn is_code_path(path: &str) -> bool {
2267    let normalized = path.to_ascii_lowercase();
2268    [
2269        ".rs", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".java", ".kt", ".swift",
2270    ]
2271    .iter()
2272    .any(|extension| normalized.ends_with(extension))
2273}
2274
2275fn is_noise_symbol(symbol: &str) -> bool {
2276    matches!(
2277        symbol,
2278        "fn" | "pub"
2279            | "impl"
2280            | "mod"
2281            | "use"
2282            | "struct"
2283            | "enum"
2284            | "trait"
2285            | "type"
2286            | "class"
2287            | "interface"
2288            | "const"
2289            | "let"
2290            | "var"
2291            | "async"
2292            | "await"
2293            | "match"
2294            | "if"
2295            | "else"
2296            | "for"
2297            | "while"
2298            | "loop"
2299    )
2300}
2301
2302fn sanitize_commit_type(raw: &str) -> Option<String> {
2303    let normalized = raw.trim().to_ascii_lowercase();
2304    if CONVENTIONAL_TYPES.contains(&normalized.as_str()) {
2305        Some(normalized)
2306    } else {
2307        None
2308    }
2309}
2310
2311fn sanitize_scope(raw: Option<&str>) -> Option<String> {
2312    let raw = raw?.trim();
2313    if raw.is_empty() {
2314        return None;
2315    }
2316    let sanitized = raw
2317        .chars()
2318        .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/'))
2319        .collect::<String>()
2320        .to_ascii_lowercase();
2321    if sanitized.is_empty() {
2322        None
2323    } else {
2324        Some(sanitized)
2325    }
2326}
2327
2328fn sanitize_description(raw: &str) -> Option<String> {
2329    let trimmed = raw.trim().trim_matches('"').trim();
2330    if trimmed.is_empty() {
2331        return None;
2332    }
2333    let normalized = trimmed.trim_end_matches('.').trim();
2334    if normalized.is_empty() {
2335        None
2336    } else {
2337        Some(normalized.to_string())
2338    }
2339}
2340
2341fn sanitize_footer_value(raw: &str) -> Option<String> {
2342    let trimmed = raw.trim();
2343    if trimmed.is_empty() {
2344        None
2345    } else {
2346        Some(trimmed.to_string())
2347    }
2348}
2349
2350fn interesting_file_stem(path: &str) -> Option<String> {
2351    let stem = Path::new(path)
2352        .file_stem()
2353        .and_then(|value| value.to_str())
2354        .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase())?;
2355    if stem.is_empty()
2356        || is_weak_focus_label(&stem)
2357        || matches!(
2358            stem.as_str(),
2359            "mod" | "lib" | "main" | "readme" | "index" | "commands" | "router"
2360        )
2361    {
2362        None
2363    } else {
2364        Some(stem)
2365    }
2366}
2367
2368fn normalize_name_status(raw: &str) -> String {
2369    let code = raw.chars().next().unwrap_or('M');
2370    match code {
2371        'A' => "added",
2372        'D' => "deleted",
2373        'R' => "renamed",
2374        'C' => "copied",
2375        'T' => "typechange",
2376        'U' => "unmerged",
2377        'M' => "modified",
2378        _ => "modified",
2379    }
2380    .to_string()
2381}
2382
2383fn normalize_display_path(raw: &str) -> String {
2384    raw.trim().replace('\\', "/")
2385}
2386
2387fn normalize_path_key(raw: &str) -> String {
2388    let cleaned = raw.trim().replace(['{', '}'], "").replace('\\', "/");
2389    if let Some((_, tail)) = cleaned.split_once("=>") {
2390        tail.trim().to_string()
2391    } else {
2392        cleaned
2393    }
2394}
2395
2396fn repo_name(path: &Path) -> String {
2397    path.file_name()
2398        .and_then(|value| value.to_str())
2399        .filter(|value| !value.trim().is_empty())
2400        .unwrap_or("repository")
2401        .to_string()
2402}
2403
2404fn truncate_for_prompt(text: &str, limit: usize) -> String {
2405    if text.chars().count() <= limit {
2406        return text.to_string();
2407    }
2408
2409    let truncated = text.chars().take(limit).collect::<String>();
2410    format!(
2411        "{}\n\n[diff truncated after {} characters]",
2412        truncated, limit
2413    )
2414}
2415
2416async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
2417    git_output_with_env(project_root, args, &[]).await
2418}
2419
2420async fn git_output_streaming(project_root: &Path, args: &[&str]) -> Result<String, String> {
2421    git_output_with_env_streaming(project_root, args, &[]).await
2422}
2423
2424async fn git_has_staged_changes(project_root: &Path) -> Result<bool, String> {
2425    let output = Command::new("git")
2426        .current_dir(project_root)
2427        .args(["diff", "--cached", "--quiet", "--exit-code"])
2428        .output()
2429        .await
2430        .map_err(|e| {
2431            format!(
2432                "Failed to run `git diff --cached --quiet --exit-code`: {}",
2433                e
2434            )
2435        })?;
2436
2437    match output.status.code() {
2438        Some(0) => Ok(false),
2439        Some(1) => Ok(true),
2440        _ => {
2441            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2442            if stderr.is_empty() {
2443                Err(format!(
2444                    "`git diff --cached --quiet --exit-code` failed with status {}",
2445                    output.status
2446                ))
2447            } else {
2448                Err(format!(
2449                    "`git diff --cached --quiet --exit-code` failed: {}",
2450                    stderr
2451                ))
2452            }
2453        }
2454    }
2455}
2456
2457async fn git_worktree_is_clean(project_root: &Path) -> Result<bool, String> {
2458    let status = git_output(
2459        project_root,
2460        &["status", "--porcelain=v1", "--untracked-files=all"],
2461    )
2462    .await?;
2463    Ok(parse_status_entries(&status).is_empty())
2464}
2465
2466fn looks_like_nothing_to_commit_error(error: &str) -> bool {
2467    let lowered = error.to_ascii_lowercase();
2468    lowered.contains("nothing to commit")
2469        || lowered.contains("working tree clean")
2470        || lowered.contains("no changes added to commit")
2471}
2472
2473async fn git_output_with_env(
2474    project_root: &Path,
2475    args: &[&str],
2476    envs: &[(&str, &std::ffi::OsStr)],
2477) -> Result<String, String> {
2478    let mut command = Command::new("git");
2479    command.current_dir(project_root).args(args);
2480    for (key, value) in envs {
2481        command.env(key, value);
2482    }
2483
2484    let display_args = format_git_args_for_error(args);
2485    let output = command
2486        .output()
2487        .await
2488        .map_err(|e| format!("Failed to run `git {}`: {}", display_args, e))?;
2489
2490    if !output.status.success() {
2491        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2492        if stderr.is_empty() {
2493            return Err(format!(
2494                "`git {}` failed with status {}",
2495                display_args, output.status
2496            ));
2497        }
2498        return Err(format!("`git {}` failed: {}", display_args, stderr));
2499    }
2500
2501    // Use trim_end only. Full trim() eats the leading space on porcelain
2502    // status lines like ` M path` (worktree-only changes), which shifts the
2503    // XY/path columns and corrupts paths (e.g. `.xbp/foo` → `xbp/foo`,
2504    // `src/bin/x.rs` → `rc/bin/x.rs`).
2505    Ok(String::from_utf8_lossy(&output.stdout)
2506        .trim_end()
2507        .to_string())
2508}
2509
2510async fn git_output_with_env_streaming(
2511    project_root: &Path,
2512    args: &[&str],
2513    envs: &[(&str, &std::ffi::OsStr)],
2514) -> Result<String, String> {
2515    let mut command = Command::new("git");
2516    command
2517        .current_dir(project_root)
2518        .args(args)
2519        .stdout(std::process::Stdio::piped())
2520        .stderr(std::process::Stdio::piped());
2521    for (key, value) in envs {
2522        command.env(key, value);
2523    }
2524
2525    let display_args = format_git_args_for_error(args);
2526    let mut child = command
2527        .spawn()
2528        .map_err(|e| format!("Failed to run `git {}`: {}", display_args, e))?;
2529
2530    let stdout = child
2531        .stdout
2532        .take()
2533        .ok_or_else(|| format!("Failed to capture stdout for `git {}`", display_args))?;
2534    let stderr = child
2535        .stderr
2536        .take()
2537        .ok_or_else(|| format!("Failed to capture stderr for `git {}`", display_args))?;
2538
2539    let stdout_task = tokio::spawn(stream_child_output(stdout, false));
2540    let stderr_task = tokio::spawn(stream_child_output(stderr, true));
2541
2542    let status = child
2543        .wait()
2544        .await
2545        .map_err(|e| format!("Failed to wait for `git {}`: {}", display_args, e))?;
2546
2547    let stdout = stdout_task
2548        .await
2549        .map_err(|e| {
2550            format!(
2551                "Failed to join stdout task for `git {}`: {}",
2552                display_args, e
2553            )
2554        })?
2555        .map_err(|e| format!("Failed to read stdout for `git {}`: {}", display_args, e))?;
2556    let stderr = stderr_task
2557        .await
2558        .map_err(|e| {
2559            format!(
2560                "Failed to join stderr task for `git {}`: {}",
2561                display_args, e
2562            )
2563        })?
2564        .map_err(|e| format!("Failed to read stderr for `git {}`: {}", display_args, e))?;
2565
2566    if !status.success() {
2567        let stderr = String::from_utf8_lossy(&stderr).trim().to_string();
2568        if stderr.is_empty() {
2569            return Err(format!(
2570                "`git {}` failed with status {}",
2571                display_args, status
2572            ));
2573        }
2574        return Err(format!("`git {}` failed: {}", display_args, stderr));
2575    }
2576
2577    Ok(String::from_utf8_lossy(&stdout).trim_end().to_string())
2578}
2579
2580async fn stream_child_output<R>(reader: R, stderr: bool) -> io::Result<Vec<u8>>
2581where
2582    R: AsyncRead + Unpin,
2583{
2584    let mut reader = BufReader::new(reader);
2585    let mut collected = Vec::new();
2586    let mut chunk = Vec::new();
2587
2588    loop {
2589        chunk.clear();
2590        let bytes_read = reader.read_until(b'\n', &mut chunk).await?;
2591        if bytes_read == 0 {
2592            break;
2593        }
2594
2595        if stderr {
2596            let mut handle = io::stderr().lock();
2597            handle.write_all(&chunk)?;
2598            handle.flush()?;
2599        } else {
2600            let mut handle = io::stdout().lock();
2601            handle.write_all(&chunk)?;
2602            handle.flush()?;
2603        }
2604
2605        collected.extend_from_slice(&chunk);
2606    }
2607
2608    Ok(collected)
2609}
2610
2611struct TemporaryIndex {
2612    path: PathBuf,
2613}
2614
2615impl Drop for TemporaryIndex {
2616    fn drop(&mut self) {
2617        let _ = fs::remove_file(&self.path);
2618    }
2619}
2620
2621#[cfg(test)]
2622mod tests {
2623    use super::{
2624        build_commit_prompt, build_heuristic_body, build_heuristic_commit,
2625        build_prompt_diff_excerpt, choose_stage_strategy, describe_focus_area,
2626        format_colored_change_counts, format_colored_top_file_group, format_git_args_for_error,
2627        infer_commit_type, infer_description, is_terminal_session_path,
2628        looks_like_nothing_to_commit_error, parse_ai_commit_payload, parse_status_entries,
2629        pathspecs_exceed_safe_argv_budget, render_clean_worktree_message, render_commit_message,
2630        render_commit_message_preview, render_commit_subject, render_terminal_session_block_message,
2631        sanitize_scope, stage_pathspecs, summarize_focus_areas, summarize_symbols,
2632        summarize_top_files, terminal_session_paths, BranchSyncStatus, ConventionalCommit,
2633        FileChangeSummary, RepoContext, StageStrategy, StatusEntry, TopFileGroup, WorktreeAnalysis,
2634        SAFE_GIT_ARGV_CHAR_BUDGET, SAFE_GIT_PATHSPEC_COUNT,
2635    };
2636    use std::path::PathBuf;
2637
2638    fn server_refactor_analysis() -> WorktreeAnalysis {
2639        WorktreeAnalysis {
2640            repo_root: PathBuf::from("C:/repo"),
2641            repo_name: "xbp".to_string(),
2642            branch: Some("feature/refine-server".to_string()),
2643            status_entries: vec![StatusEntry {
2644                code: "M".to_string(),
2645                path: "src/server.ts".to_string(),
2646            }],
2647            files: vec![
2648                FileChangeSummary {
2649                    path: "src/server.ts".to_string(),
2650                    status: "modified".to_string(),
2651                    additions: 80,
2652                    deletions: 24,
2653                },
2654                FileChangeSummary {
2655                    path: "src/http-app.ts".to_string(),
2656                    status: "modified".to_string(),
2657                    additions: 42,
2658                    deletions: 11,
2659                },
2660                FileChangeSummary {
2661                    path: "src/worker/types.ts".to_string(),
2662                    status: "modified".to_string(),
2663                    additions: 12,
2664                    deletions: 3,
2665                },
2666            ],
2667            total_additions: 134,
2668            total_deletions: 38,
2669            diff_text: String::new(),
2670            new_functions: vec![
2671                "buildEnvFromProcess (src/server.ts)".to_string(),
2672                "handleNodeRequest (src/server.ts)".to_string(),
2673            ],
2674            changed_functions: vec!["handleHttpRequest (src/http-app.ts)".to_string()],
2675            removed_functions: Vec::new(),
2676            new_types: vec!["ExecutionContextLike (src/http-app.ts)".to_string()],
2677            changed_types: vec!["Env (src/worker/types.ts)".to_string()],
2678            removed_types: Vec::new(),
2679            hunk_contexts: vec!["async function handleHttpRequest(request: Request)".to_string()],
2680        }
2681    }
2682
2683    #[test]
2684    fn symbol_summary_tracks_new_and_changed_symbols() {
2685        let diff = r#"
2686diff --git a/crates/cli/src/commands/commit.rs b/crates/cli/src/commands/commit.rs
2687index 1111111..2222222 100644
2688--- a/crates/cli/src/commands/commit.rs
2689+++ b/crates/cli/src/commands/commit.rs
2690@@ -0,0 +1,3 @@
2691+pub struct CommitArgs {
2692+}
2693+pub async fn run_commit() {}
2694@@ -10,1 +12,1 @@ pub async fn old_name() {}
2695-pub async fn old_name() {}
2696+pub async fn old_name() {}
2697"#;
2698        let summary = summarize_symbols(diff);
2699        assert!(summary
2700            .new_functions
2701            .iter()
2702            .any(|item| item.contains("run_commit")));
2703        assert!(summary
2704            .new_types
2705            .iter()
2706            .any(|item| item.contains("CommitArgs")));
2707        assert!(summary
2708            .changed_functions
2709            .iter()
2710            .any(|item| item.contains("old_name")));
2711    }
2712
2713    #[test]
2714    fn ai_payload_respects_forced_scope() {
2715        let raw = r#"{
2716  "type": "feat",
2717  "scope": "wrong",
2718  "description": "add commit command",
2719  "body": ["Summarize the worktree."],
2720  "breaking_change": false,
2721  "footers": []
2722}"#;
2723
2724        let commit = parse_ai_commit_payload(raw, Some("cli")).expect("parse");
2725        assert_eq!(commit.scope.as_deref(), Some("cli"));
2726        assert_eq!(commit.commit_type, "feat");
2727    }
2728
2729    #[test]
2730    fn renders_breaking_change_footer_once() {
2731        let rendered = render_commit_message(&ConventionalCommit {
2732            commit_type: "feat".to_string(),
2733            scope: Some("cli".to_string()),
2734            description: "add commit command".to_string(),
2735            body: vec!["Summarize the worktree.".to_string()],
2736            breaking_change: Some("existing automation must call xbp commit".to_string()),
2737            footers: Vec::new(),
2738        });
2739
2740        assert!(rendered.contains("feat(cli)!: add commit command"));
2741        assert!(rendered.contains("BREAKING CHANGE: existing automation must call xbp commit"));
2742    }
2743
2744    #[test]
2745    fn plain_commit_renderers_stay_uncolored() {
2746        let commit = ConventionalCommit {
2747            commit_type: "fix".to_string(),
2748            scope: Some("cli".to_string()),
2749            description: "tighten summary output".to_string(),
2750            body: vec!["Keep the git payload plain.".to_string()],
2751            breaking_change: None,
2752            footers: vec!["Reviewed-by: ops".to_string()],
2753        };
2754
2755        let subject = render_commit_subject(&commit);
2756        let message = render_commit_message(&commit);
2757
2758        assert_eq!(subject, "fix(cli): tighten summary output");
2759        assert!(!subject.contains('\u{1b}'));
2760        assert!(!message.contains('\u{1b}'));
2761    }
2762
2763    #[test]
2764    fn preview_subject_colors_type_and_scope_independently() {
2765        let commit = ConventionalCommit {
2766            commit_type: "feat".to_string(),
2767            scope: Some("docs".to_string()),
2768            description: "refresh operator docs".to_string(),
2769            body: Vec::new(),
2770            breaking_change: None,
2771            footers: Vec::new(),
2772        };
2773
2774        colored::control::set_override(true);
2775        let preview = render_commit_message_preview(&commit);
2776        colored::control::set_override(false);
2777
2778        assert!(preview.contains('\u{1b}'));
2779        assert!(preview.contains("feat"));
2780        assert!(preview.contains("docs"));
2781        assert!(preview.contains("refresh operator docs"));
2782    }
2783
2784    #[test]
2785    fn diff_summary_colors_additions_and_deletions_independently() {
2786        colored::control::set_override(true);
2787        let summary = format_colored_change_counts(29_948, 2_740);
2788        colored::control::set_override(false);
2789        assert!(summary.contains('\u{1b}'));
2790        assert!(summary.contains("29948"));
2791        assert!(summary.contains("2740"));
2792    }
2793
2794    #[test]
2795    fn top_file_groups_color_nested_change_counts() {
2796        colored::control::set_override(true);
2797        let group = TopFileGroup {
2798            label: "apps/docs/generated/".to_string(),
2799            file_count: 3,
2800            additions: 22_125,
2801            deletions: 0,
2802        };
2803
2804        let summary = format_colored_top_file_group(&group);
2805        colored::control::set_override(false);
2806        assert!(summary.contains("apps/docs/generated/"));
2807        assert!(summary.contains("files"));
2808        assert!(summary.contains('\u{1b}'));
2809        assert!(summary.contains("22125"));
2810    }
2811
2812    #[test]
2813    fn infers_feat_for_new_symbols() {
2814        let analysis = WorktreeAnalysis {
2815            repo_root: PathBuf::from("C:/repo"),
2816            repo_name: "xbp".to_string(),
2817            branch: Some("main".to_string()),
2818            status_entries: vec![StatusEntry {
2819                code: "??".to_string(),
2820                path: "crates/cli/src/commands/commit.rs".to_string(),
2821            }],
2822            files: vec![FileChangeSummary {
2823                path: "crates/cli/src/commands/commit.rs".to_string(),
2824                status: "added".to_string(),
2825                additions: 120,
2826                deletions: 0,
2827            }],
2828            total_additions: 120,
2829            total_deletions: 0,
2830            diff_text: String::new(),
2831            new_functions: vec!["run_commit (crates/cli/src/commands/commit.rs)".to_string()],
2832            changed_functions: Vec::new(),
2833            removed_functions: Vec::new(),
2834            new_types: vec!["CommitArgs (crates/cli/src/commands/commit.rs)".to_string()],
2835            changed_types: Vec::new(),
2836            removed_types: Vec::new(),
2837            hunk_contexts: Vec::new(),
2838        };
2839
2840        assert_eq!(infer_commit_type(&analysis), "feat");
2841    }
2842
2843    #[test]
2844    fn infers_refactor_for_new_symbols_inside_existing_modules() {
2845        let analysis = server_refactor_analysis();
2846        assert_eq!(infer_commit_type(&analysis), "refactor");
2847    }
2848
2849    #[test]
2850    fn description_prefers_focus_area_and_behavior_hint() {
2851        let analysis = server_refactor_analysis();
2852        assert_eq!(
2853            infer_description(&analysis, "refactor", None),
2854            "refine server request handling"
2855        );
2856    }
2857
2858    #[test]
2859    fn heuristic_body_reads_like_a_summary() {
2860        let analysis = server_refactor_analysis();
2861        let body = build_heuristic_body(&analysis);
2862        assert_eq!(body.len(), 2);
2863        assert!(body[0].contains("server"));
2864        assert!(body[1].contains("adds buildEnvFromProcess"));
2865        assert!(body[1].contains("src/ (2 files"));
2866    }
2867
2868    #[test]
2869    fn prompt_omits_lockfile_diff_body_but_keeps_structured_summary() {
2870        let diff = r#"
2871diff --git a/package.json b/package.json
2872index b25b17db..d30f02ee 100644
2873--- a/package.json
2874+++ b/package.json
2875@@ -86 +86,2 @@
2876-    "yaml": "^2.9.0"
2877+    "yaml": "^2.9.0",
2878+    "nx": "23.0.1"
2879diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
2880index 4916450e..758d7f5e 100644
2881--- a/pnpm-lock.yaml
2882+++ b/pnpm-lock.yaml
2883@@ -998,0 +1005,6 @@ packages:
2884+  '@emnapi/runtime@1.4.5':
2885+    resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==}
2886+  open@8.4.2:
2887+    resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
2888"#;
2889        let symbols = summarize_symbols(diff);
2890        let analysis = WorktreeAnalysis {
2891            repo_root: PathBuf::from("C:/repo"),
2892            repo_name: "speedrun-formations".to_string(),
2893            branch: Some("main".to_string()),
2894            status_entries: vec![
2895                StatusEntry {
2896                    code: "M".to_string(),
2897                    path: "package.json".to_string(),
2898                },
2899                StatusEntry {
2900                    code: "M".to_string(),
2901                    path: "pnpm-lock.yaml".to_string(),
2902                },
2903            ],
2904            files: vec![
2905                FileChangeSummary {
2906                    path: "package.json".to_string(),
2907                    status: "modified".to_string(),
2908                    additions: 4,
2909                    deletions: 2,
2910                },
2911                FileChangeSummary {
2912                    path: "pnpm-lock.yaml".to_string(),
2913                    status: "modified".to_string(),
2914                    additions: 539,
2915                    deletions: 17,
2916                },
2917            ],
2918            total_additions: 543,
2919            total_deletions: 19,
2920            diff_text: diff.to_string(),
2921            new_functions: symbols.new_functions,
2922            changed_functions: symbols.changed_functions,
2923            removed_functions: symbols.removed_functions,
2924            new_types: symbols.new_types,
2925            changed_types: symbols.changed_types,
2926            removed_types: symbols.removed_types,
2927            hunk_contexts: symbols.hunk_contexts,
2928        };
2929
2930        let heuristic = build_heuristic_commit(&analysis, None);
2931        let prompt = build_commit_prompt(&analysis, None, &heuristic);
2932
2933        assert!(prompt.contains("pnpm-lock.yaml (+539 -17; lockfile)"));
2934        assert!(prompt.contains("\"nx\": \"23.0.1\""));
2935        assert!(prompt.contains("package entries added: @emnapi/runtime@1.4.5"));
2936        assert!(!prompt.contains("sha512-++LApOtY0pEEz1zrd9vy1"));
2937        assert!(!prompt.contains("packages:"));
2938    }
2939
2940    #[test]
2941    fn diff_excerpt_reports_when_all_records_are_low_signal() {
2942        let diff = r#"
2943diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
2944index 4916450e..758d7f5e 100644
2945--- a/pnpm-lock.yaml
2946+++ b/pnpm-lock.yaml
2947@@ -998,0 +1005,2 @@ packages:
2948+  open@8.4.2:
2949+    resolution: {integrity: sha512-7x81NCL719oNbsq}
2950"#;
2951        let analysis = WorktreeAnalysis {
2952            repo_root: PathBuf::from("C:/repo"),
2953            repo_name: "speedrun-formations".to_string(),
2954            branch: Some("main".to_string()),
2955            status_entries: Vec::new(),
2956            files: vec![FileChangeSummary {
2957                path: "pnpm-lock.yaml".to_string(),
2958                status: "modified".to_string(),
2959                additions: 539,
2960                deletions: 17,
2961            }],
2962            total_additions: 539,
2963            total_deletions: 17,
2964            diff_text: diff.to_string(),
2965            new_functions: Vec::new(),
2966            changed_functions: Vec::new(),
2967            removed_functions: Vec::new(),
2968            new_types: Vec::new(),
2969            changed_types: Vec::new(),
2970            removed_types: Vec::new(),
2971            hunk_contexts: Vec::new(),
2972        };
2973
2974        let excerpt = build_prompt_diff_excerpt(&analysis);
2975        assert!(excerpt.excerpt.trim().is_empty());
2976        assert_eq!(
2977            excerpt.omitted_files,
2978            vec!["pnpm-lock.yaml (+539 -17; lockfile)".to_string()]
2979        );
2980        assert_eq!(
2981            excerpt.summaries,
2982            vec![
2983                "pnpm-lock.yaml (+539 -17; lockfile)".to_string(),
2984                "package entries added: open@8.4.2".to_string()
2985            ]
2986        );
2987    }
2988
2989    #[test]
2990    fn lockfile_preprocessing_summarizes_repeated_package_churn() {
2991        let diff = r#"
2992diff --git a/services/athena-auth/Cargo.lock b/services/athena-auth/Cargo.lock
2993index 1111111..2222222 100644
2994--- a/services/athena-auth/Cargo.lock
2995+++ b/services/athena-auth/Cargo.lock
2996@@ -10,5 +10,0 @@ name = "aead"
2997-name = "aead"
2998-version = "0.5.2"
2999@@ -20,0 +20,5 @@ name = "aes"
3000+name = "aes"
3001+version = "0.8.4"
3002diff --git a/apps/docs/pnpm-lock.yaml b/apps/docs/pnpm-lock.yaml
3003index f96b24fc..c3b8250d 100644
3004--- a/apps/docs/pnpm-lock.yaml
3005+++ b/apps/docs/pnpm-lock.yaml
3006@@ -21,2 +21,2 @@ importers:
3007-        specifier: 16.6.5
3008-        version: 16.6.5(@types/react@19.2.10)(next@16.1.5(react@19.2.4))
3009+        specifier: 16.7.13
3010+        version: 16.7.13(@types/react@19.2.10)(next@16.1.5(react@19.2.4))
3011@@ -3113,2 +3140,2 @@ packages:
3012-  fumadocs-core@16.6.5:
3013-    resolution: {integrity: sha512-old}
3014+  fumadocs-core@16.7.13:
3015+    resolution: {integrity: sha512-new}
3016"#;
3017        let analysis = WorktreeAnalysis {
3018            repo_root: PathBuf::from("C:/repo"),
3019            repo_name: "athena".to_string(),
3020            branch: Some("main".to_string()),
3021            status_entries: Vec::new(),
3022            files: vec![
3023                FileChangeSummary {
3024                    path: "services/athena-auth/Cargo.lock".to_string(),
3025                    status: "modified".to_string(),
3026                    additions: 3403,
3027                    deletions: 1366,
3028                },
3029                FileChangeSummary {
3030                    path: "apps/docs/pnpm-lock.yaml".to_string(),
3031                    status: "modified".to_string(),
3032                    additions: 131,
3033                    deletions: 53,
3034                },
3035            ],
3036            total_additions: 3534,
3037            total_deletions: 1419,
3038            diff_text: diff.to_string(),
3039            new_functions: Vec::new(),
3040            changed_functions: Vec::new(),
3041            removed_functions: Vec::new(),
3042            new_types: Vec::new(),
3043            changed_types: Vec::new(),
3044            removed_types: Vec::new(),
3045            hunk_contexts: summarize_symbols(diff).hunk_contexts,
3046        };
3047
3048        let prompt = build_commit_prompt(&analysis, None, &build_heuristic_commit(&analysis, None));
3049
3050        assert!(prompt.contains("services/athena-auth/Cargo.lock (+3403 -1366; lockfile)"));
3051        assert!(prompt.contains("apps/docs/pnpm-lock.yaml (+131 -53; lockfile)"));
3052        assert!(prompt.contains("cargo packages added: aes@0.8.4"));
3053        assert!(prompt.contains("cargo packages removed: aead@0.5.2"));
3054        assert!(prompt.contains("version shifts: 16.6.5 -> 16.7.13"));
3055        assert!(prompt.contains("package entries added: fumadocs-core@16.7.13"));
3056        assert!(!prompt.contains("sha512-old"));
3057        assert!(!prompt.contains("name = \"aead\""));
3058        assert!(!prompt.contains("hunk contexts:"));
3059    }
3060
3061    #[test]
3062    fn retained_diff_preprocessing_compacts_repeated_long_lines() {
3063        let repeated = format!(
3064            "+        version: 10.3.11({})(tailwindcss@4.1.18)",
3065            "@types/react@19.2.10".repeat(12)
3066        );
3067        let diff = format!(
3068            "diff --git a/package.json b/package.json\n--- a/package.json\n+++ b/package.json\n@@ -1,0 +1,3 @@\n{0}\n{0}\n+    \"nx\": \"23.0.1\"\n",
3069            repeated
3070        );
3071        let analysis = WorktreeAnalysis {
3072            repo_root: PathBuf::from("C:/repo"),
3073            repo_name: "speedrun-formations".to_string(),
3074            branch: Some("main".to_string()),
3075            status_entries: Vec::new(),
3076            files: vec![FileChangeSummary {
3077                path: "package.json".to_string(),
3078                status: "modified".to_string(),
3079                additions: 3,
3080                deletions: 0,
3081            }],
3082            total_additions: 3,
3083            total_deletions: 0,
3084            diff_text: diff,
3085            new_functions: Vec::new(),
3086            changed_functions: Vec::new(),
3087            removed_functions: Vec::new(),
3088            new_types: Vec::new(),
3089            changed_types: Vec::new(),
3090            removed_types: Vec::new(),
3091            hunk_contexts: Vec::new(),
3092        };
3093
3094        let excerpt = build_prompt_diff_excerpt(&analysis).excerpt;
3095        assert!(excerpt.contains("(...peer deps omitted)"));
3096        assert!(excerpt.contains("duplicate long diff line(s) omitted"));
3097        assert!(excerpt.contains("\"nx\": \"23.0.1\""));
3098    }
3099
3100    #[test]
3101    fn clean_worktree_message_calls_out_pending_pushes() {
3102        let repo = RepoContext {
3103            repo_root: PathBuf::from("C:/repo"),
3104            repo_name: "xbp".to_string(),
3105            branch: Some("main".to_string()),
3106        };
3107        let message = render_clean_worktree_message(
3108            &repo,
3109            &BranchSyncStatus {
3110                upstream: Some("origin/main".to_string()),
3111                ahead: 2,
3112                behind: 0,
3113            },
3114        );
3115
3116        assert!(message.contains("Nothing to commit."));
3117        assert!(message.contains("2 commit(s) waiting to push"));
3118        assert!(message.contains("origin/main"));
3119    }
3120
3121    #[test]
3122    fn clean_worktree_message_calls_out_synced_branch() {
3123        let repo = RepoContext {
3124            repo_root: PathBuf::from("C:/repo"),
3125            repo_name: "xbp".to_string(),
3126            branch: Some("main".to_string()),
3127        };
3128        let message = render_clean_worktree_message(
3129            &repo,
3130            &BranchSyncStatus {
3131                upstream: Some("origin/main".to_string()),
3132                ahead: 0,
3133                behind: 0,
3134            },
3135        );
3136
3137        assert!(message.contains("Nothing to commit."));
3138        assert!(message.contains("already in sync"));
3139    }
3140
3141    #[test]
3142    fn detects_nothing_to_commit_git_errors() {
3143        assert!(looks_like_nothing_to_commit_error(
3144            "`git commit --file msg.txt` failed: nothing to commit, working tree clean"
3145        ));
3146        assert!(looks_like_nothing_to_commit_error(
3147            "`git commit --file msg.txt` failed: no changes added to commit"
3148        ));
3149        assert!(!looks_like_nothing_to_commit_error(
3150            "`git commit --file msg.txt` failed: pre-commit hook exited with code 1"
3151        ));
3152    }
3153
3154    #[test]
3155    fn stage_pathspecs_use_changed_paths_and_fall_back_for_renames() {
3156        let paths = stage_pathspecs(&[
3157            StatusEntry {
3158                code: "M".to_string(),
3159                path: "src/lib.rs".to_string(),
3160            },
3161            StatusEntry {
3162                code: "??".to_string(),
3163                path: "docs/new.md".to_string(),
3164            },
3165            StatusEntry {
3166                code: "M".to_string(),
3167                path: "src/lib.rs".to_string(),
3168            },
3169        ]);
3170        assert_eq!(
3171            paths,
3172            vec!["docs/new.md".to_string(), "src/lib.rs".to_string()]
3173        );
3174
3175        let rename_paths = stage_pathspecs(&[StatusEntry {
3176            code: "R".to_string(),
3177            path: "old.rs -> new.rs".to_string(),
3178        }]);
3179        assert!(rename_paths.is_empty());
3180    }
3181
3182    #[test]
3183    fn large_worktree_pathspecs_use_bulk_add_to_avoid_windows_argv_truncation() {
3184        // Staging always uses bulk `git add --all` so large worktrees never build an
3185        // argv that Windows CreateProcess truncates mid-pathspec.
3186        let long_paths: Vec<String> = (0..437)
3187            .map(|i| {
3188                format!(
3189                    "foundry/instagram-downloads/100500818017217/messages/audio_attachments/{:04}.mp3",
3190                    i
3191                )
3192            })
3193            .collect();
3194
3195        assert!(pathspecs_exceed_safe_argv_budget(&long_paths));
3196        assert!(long_paths.len() > SAFE_GIT_PATHSPEC_COUNT);
3197
3198        let strategy = choose_stage_strategy(&long_paths, &[]);
3199        assert_eq!(
3200            strategy,
3201            StageStrategy::BulkAddAll {
3202                path_count: long_paths.len()
3203            }
3204        );
3205
3206        let small = vec!["src/lib.rs".to_string(), "README.md".to_string()];
3207        assert!(!pathspecs_exceed_safe_argv_budget(&small));
3208        assert_eq!(
3209            choose_stage_strategy(&small, &[]),
3210            StageStrategy::BulkAddAll { path_count: 2 }
3211        );
3212        assert_eq!(
3213            choose_stage_strategy(&small, &["terminals/5.txt".to_string()]),
3214            StageStrategy::BulkAddAll { path_count: 2 }
3215        );
3216        assert_eq!(
3217            choose_stage_strategy(&[], &[]),
3218            StageStrategy::BulkAddAll { path_count: 0 }
3219        );
3220
3221        let almost_budget = vec!["a".repeat(SAFE_GIT_ARGV_CHAR_BUDGET)];
3222        assert!(pathspecs_exceed_safe_argv_budget(&almost_budget));
3223    }
3224
3225    #[test]
3226    fn porcelain_status_preserves_dot_prefixed_paths_and_leading_status_space() {
3227        // Regression: `git_output` used to `.trim()` the whole status blob, turning
3228        // ` M .xbp/foo` into `M .xbp/foo` and then parse_status_entries into path
3229        // `xbp/foo` (leading `.` eaten as the XY separator column).
3230        let raw =
3231            " M .xbp/releases/service-dexter-sentinel/1.4.0.yaml\n?? src/bin/dexter-import.rs\n";
3232        let entries = parse_status_entries(raw);
3233        assert_eq!(entries.len(), 2);
3234        assert_eq!(entries[0].code, "M");
3235        assert_eq!(
3236            entries[0].path,
3237            ".xbp/releases/service-dexter-sentinel/1.4.0.yaml"
3238        );
3239        assert_eq!(entries[1].code, "??");
3240        assert_eq!(entries[1].path, "src/bin/dexter-import.rs");
3241
3242        // Simulate the historical full-string trim bug.
3243        let corrupted = raw.trim();
3244        let broken = parse_status_entries(corrupted);
3245        assert_eq!(
3246            broken[0].path, "xbp/releases/service-dexter-sentinel/1.4.0.yaml",
3247            "documenting the failure mode that full trim() produces"
3248        );
3249    }
3250
3251    #[test]
3252    fn git_error_args_are_truncated_for_readable_failures() {
3253        let many: Vec<String> = (0..80).map(|i| format!("path/to/file-{i}.bin")).collect();
3254        let refs: Vec<&str> = many.iter().map(String::as_str).collect();
3255        let mut args = vec!["add", "--all", "--"];
3256        args.extend(refs.iter().copied());
3257
3258        let display = format_git_args_for_error(&args);
3259        assert!(display.contains("truncated for display"));
3260        assert!(display.len() < 400);
3261        assert!(!display.contains("path/to/file-79.bin") || display.contains("…"));
3262    }
3263
3264    #[tokio::test]
3265    async fn stage_commit_paths_bulk_add_respects_gitignore_and_excludes() {
3266        let root =
3267            std::env::temp_dir().join(format!("xbp-commit-stage-test-{}", uuid::Uuid::new_v4()));
3268        let _ = std::fs::remove_dir_all(&root);
3269        std::fs::create_dir_all(&root).expect("temp root");
3270
3271        let git = |args: &[&str]| {
3272            std::process::Command::new("git")
3273                .current_dir(&root)
3274                .args(args)
3275                .output()
3276                .expect("git")
3277        };
3278
3279        assert!(git(&["init"]).status.success());
3280        assert!(git(&["config", "user.email", "test@example.com"])
3281            .status
3282            .success());
3283        assert!(git(&["config", "user.name", "Test"]).status.success());
3284        // Avoid platform-specific default branch surprises in CI.
3285        let _ = git(&["checkout", "-b", "main"]);
3286
3287        std::fs::write(root.join(".gitignore"), "ignored-dir/\n*.tmp\n").unwrap();
3288        std::fs::write(root.join("keep.rs"), "fn main() {}\n").unwrap();
3289        std::fs::create_dir_all(root.join("ignored-dir")).unwrap();
3290        std::fs::write(root.join("ignored-dir/secret.bin"), "nope").unwrap();
3291        std::fs::write(root.join("noise.tmp"), "tmp").unwrap();
3292        std::fs::create_dir_all(root.join("terminals")).unwrap();
3293        std::fs::write(root.join("terminals/session.txt"), "session").unwrap();
3294
3295        // Seed an initial commit so reset HEAD works for excludes.
3296        assert!(git(&["add", ".gitignore", "keep.rs"]).status.success());
3297        assert!(git(&["commit", "-m", "init"]).status.success());
3298
3299        std::fs::write(root.join("keep.rs"), "fn main() { /* changed */ }\n").unwrap();
3300        std::fs::write(root.join("feature.rs"), "pub fn f() {}\n").unwrap();
3301        std::fs::write(root.join("ignored-dir/secret.bin"), "still ignored").unwrap();
3302        std::fs::write(root.join("noise.tmp"), "still tmp").unwrap();
3303        std::fs::write(root.join("terminals/session.txt"), "session v2").unwrap();
3304
3305        let status_entries = vec![
3306            StatusEntry {
3307                code: " M".to_string(),
3308                path: "keep.rs".to_string(),
3309            },
3310            StatusEntry {
3311                code: "??".to_string(),
3312                path: "feature.rs".to_string(),
3313            },
3314            StatusEntry {
3315                code: "??".to_string(),
3316                path: "terminals/session.txt".to_string(),
3317            },
3318        ];
3319
3320        // Build a path list large enough that the strategy chooses bulk add.
3321        let mut bloated_status = status_entries.clone();
3322        for i in 0..100 {
3323            bloated_status.push(StatusEntry {
3324                code: "??".to_string(),
3325                path: format!(
3326                    "foundry/instagram-downloads/user/messages/audio_attachments/{:04}.mp3",
3327                    i
3328                ),
3329            });
3330        }
3331
3332        super::stage_commit_paths(
3333            &root,
3334            &bloated_status,
3335            &["terminals/session.txt".to_string()],
3336        )
3337        .await
3338        .expect("stage should succeed without pathspec truncation");
3339
3340        let cached = git(&["diff", "--cached", "--name-only"]);
3341        assert!(cached.status.success());
3342        let staged = String::from_utf8_lossy(&cached.stdout).replace('\\', "/");
3343        assert!(
3344            staged.contains("keep.rs"),
3345            "expected keep.rs staged, got:\n{}",
3346            staged
3347        );
3348        assert!(
3349            staged.contains("feature.rs"),
3350            "expected feature.rs staged, got:\n{}",
3351            staged
3352        );
3353        assert!(
3354            !staged.contains("ignored-dir"),
3355            "gitignore paths must not be staged:\n{}",
3356            staged
3357        );
3358        assert!(
3359            !staged.contains("noise.tmp"),
3360            "*.tmp gitignore must not be staged:\n{}",
3361            staged
3362        );
3363        assert!(
3364            !staged.contains("terminals/session.txt"),
3365            "excluded terminal path must stay unstaged:\n{}",
3366            staged
3367        );
3368
3369        // check-ignore filter drops untracked ignored paths when they appear in status.
3370        let filtered = super::filter_gitignored_status_entries(
3371            &root,
3372            vec![
3373                StatusEntry {
3374                    code: "??".to_string(),
3375                    path: "feature.rs".to_string(),
3376                },
3377                StatusEntry {
3378                    code: "??".to_string(),
3379                    path: "noise.tmp".to_string(),
3380                },
3381                StatusEntry {
3382                    code: "??".to_string(),
3383                    path: "ignored-dir/secret.bin".to_string(),
3384                },
3385                StatusEntry {
3386                    code: " M".to_string(),
3387                    path: "keep.rs".to_string(),
3388                },
3389            ],
3390        )
3391        .await
3392        .expect("filter");
3393
3394        let filtered_paths: Vec<&str> = filtered.iter().map(|e| e.path.as_str()).collect();
3395        assert_eq!(filtered_paths, vec!["feature.rs", "keep.rs"]);
3396
3397        let _ = std::fs::remove_dir_all(&root);
3398    }
3399
3400    #[test]
3401    fn scope_sanitizer_keeps_valid_tokens() {
3402        assert_eq!(
3403            sanitize_scope(Some("CLI/tools")),
3404            Some("cli/tools".to_string())
3405        );
3406        assert_eq!(sanitize_scope(Some("  ")), None);
3407    }
3408
3409    #[test]
3410    fn terminal_session_paths_are_detected_and_blocked() {
3411        let analysis = WorktreeAnalysis {
3412            repo_root: PathBuf::from("C:/repo"),
3413            repo_name: "xbp".to_string(),
3414            branch: Some("main".to_string()),
3415            status_entries: Vec::new(),
3416            files: vec![
3417                FileChangeSummary {
3418                    path: "terminals/5.txt".to_string(),
3419                    status: "added".to_string(),
3420                    additions: 388_613,
3421                    deletions: 0,
3422                },
3423                FileChangeSummary {
3424                    path: "terminals/.next-id".to_string(),
3425                    status: "modified".to_string(),
3426                    additions: 1,
3427                    deletions: 1,
3428                },
3429            ],
3430            total_additions: 388_614,
3431            total_deletions: 1,
3432            diff_text: String::new(),
3433            new_functions: Vec::new(),
3434            changed_functions: Vec::new(),
3435            removed_functions: Vec::new(),
3436            new_types: Vec::new(),
3437            changed_types: Vec::new(),
3438            removed_types: Vec::new(),
3439            hunk_contexts: Vec::new(),
3440        };
3441
3442        assert!(is_terminal_session_path("terminals/5.txt"));
3443        assert!(!is_terminal_session_path(
3444            "crates/cli/src/commands/commit.rs"
3445        ));
3446        assert_eq!(
3447            terminal_session_paths(&analysis),
3448            vec![
3449                "terminals/5.txt".to_string(),
3450                "terminals/.next-id".to_string()
3451            ]
3452        );
3453        assert!(
3454            render_terminal_session_block_message(&terminal_session_paths(&analysis))
3455                .contains("Refusing to commit IDE terminal session dumps")
3456        );
3457        assert_eq!(infer_commit_type(&analysis), "chore");
3458        assert_eq!(
3459            infer_description(&analysis, "chore", None),
3460            "ignore ide terminal session logs"
3461        );
3462
3463        let heuristic = build_heuristic_commit(&analysis, None);
3464        assert_eq!(heuristic.commit_type, "chore");
3465        assert_eq!(heuristic.description, "ignore ide terminal session logs");
3466        assert!(!render_commit_message(&heuristic).contains("flow"));
3467    }
3468
3469    #[test]
3470    fn focus_area_groups_terminal_session_files() {
3471        assert_eq!(
3472            describe_focus_area("terminals/5.txt"),
3473            Some("terminals".to_string())
3474        );
3475        assert_eq!(
3476            describe_focus_area("terminals/.next-id"),
3477            Some("terminals".to_string())
3478        );
3479        assert_eq!(
3480            describe_focus_area(".gitignore"),
3481            Some("gitignore".to_string())
3482        );
3483    }
3484
3485    #[test]
3486    fn summarize_focus_areas_collapses_terminal_logs() {
3487        let analysis = WorktreeAnalysis {
3488            repo_root: PathBuf::from("C:/repo"),
3489            repo_name: "xbp".to_string(),
3490            branch: Some("main".to_string()),
3491            status_entries: Vec::new(),
3492            files: vec![
3493                FileChangeSummary {
3494                    path: "terminals/5.txt".to_string(),
3495                    status: "deleted".to_string(),
3496                    additions: 0,
3497                    deletions: 388_613,
3498                },
3499                FileChangeSummary {
3500                    path: "terminals/3.txt".to_string(),
3501                    status: "deleted".to_string(),
3502                    additions: 0,
3503                    deletions: 439,
3504                },
3505                FileChangeSummary {
3506                    path: "terminals/.next-id".to_string(),
3507                    status: "modified".to_string(),
3508                    additions: 0,
3509                    deletions: 1,
3510                },
3511                FileChangeSummary {
3512                    path: ".gitignore".to_string(),
3513                    status: "modified".to_string(),
3514                    additions: 1,
3515                    deletions: 0,
3516                },
3517            ],
3518            total_additions: 1,
3519            total_deletions: 389_053,
3520            diff_text: String::new(),
3521            new_functions: Vec::new(),
3522            changed_functions: Vec::new(),
3523            removed_functions: Vec::new(),
3524            new_types: Vec::new(),
3525            changed_types: Vec::new(),
3526            removed_types: Vec::new(),
3527            hunk_contexts: Vec::new(),
3528        };
3529
3530        assert_eq!(
3531            summarize_focus_areas(&analysis, 4),
3532            vec!["terminals".to_string(), "gitignore".to_string()]
3533        );
3534        assert_eq!(
3535            summarize_top_files(&analysis, 4),
3536            vec![
3537                "terminals/ (3 files, +0 -389053)".to_string(),
3538                ".gitignore (+1 -0)".to_string(),
3539            ]
3540        );
3541    }
3542}