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