Skip to main content

xbp_cli/commands/
commit.rs

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