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