Skip to main content

xbp_cli/commands/
commit.rs

1use crate::cli::auto_commit::{print_push_summary, push_current_branch};
2use crate::commands::service::load_xbp_config_with_root;
3use crate::config::{
4    resolve_openrouter_api_key, resolve_openrouter_commit_model,
5    resolve_openrouter_commit_system_prompt,
6};
7use crate::openrouter::complete_prompt;
8use crate::utils::command_exists;
9use colored::Colorize;
10use once_cell::sync::Lazy;
11use regex::Regex;
12use serde::Deserialize;
13use std::collections::{BTreeMap, BTreeSet};
14use std::env;
15use std::fs;
16use std::path::{Path, PathBuf};
17use tokio::process::Command;
18use uuid::Uuid;
19
20static RUST_FUNCTION_RE: Lazy<Regex> = Lazy::new(|| {
21    Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)")
22        .expect("valid rust function regex")
23});
24static JS_FUNCTION_RE: Lazy<Regex> = Lazy::new(|| {
25    Regex::new(
26        r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)",
27    )
28    .expect("valid js function regex")
29});
30static JS_ARROW_RE: Lazy<Regex> = Lazy::new(|| {
31    Regex::new(
32        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*=>",
33    )
34    .expect("valid js arrow regex")
35});
36static METHOD_CONTEXT_RE: Lazy<Regex> = Lazy::new(|| {
37    Regex::new(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(").expect("valid method context regex")
38});
39static RUST_TYPE_RE: Lazy<Regex> = Lazy::new(|| {
40    Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(struct|enum|trait|type)\s+([A-Za-z_][A-Za-z0-9_]*)")
41        .expect("valid rust type regex")
42});
43static TS_TYPE_RE: Lazy<Regex> = Lazy::new(|| {
44    Regex::new(r"^\s*(?:export\s+)?(interface|type|enum|class)\s+([A-Za-z_$][A-Za-z0-9_$]*)")
45        .expect("valid ts type regex")
46});
47
48const AI_DIFF_CHAR_LIMIT: usize = 12_000;
49const MAX_PROMPT_FILES: usize = 40;
50const MAX_PROMPT_SYMBOLS: usize = 12;
51const MAX_PROMPT_CONTEXTS: usize = 12;
52const MAX_PROMPT_AREAS: usize = 4;
53const MAX_PRINT_SYMBOLS: usize = 8;
54const MAX_PRINT_AREAS: usize = 4;
55const MAX_PRINT_FILES: usize = 4;
56const CONVENTIONAL_TYPES: &[&str] = &[
57    "feat", "fix", "docs", "refactor", "chore", "test", "build", "ci", "perf", "style", "revert",
58];
59const TERMINAL_SESSION_DIR: &str = "terminals/";
60
61#[derive(Debug, Clone)]
62pub struct CommitArgs {
63    pub dry_run: bool,
64    pub push: bool,
65    pub no_ai: bool,
66    pub model: Option<String>,
67    pub scope: Option<String>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum CommitRunOutcome {
72    Completed,
73    NothingToCommit,
74}
75
76#[derive(Debug, Clone)]
77pub enum CommitError {
78    Operation(String),
79    PushFailed {
80        summary: String,
81        commit_sha: Option<String>,
82    },
83    TerminalSessionsBlocked(String),
84}
85
86#[derive(Debug, Clone)]
87struct RepoContext {
88    repo_root: PathBuf,
89    repo_name: String,
90    branch: Option<String>,
91}
92
93#[derive(Debug, Clone, Default)]
94struct BranchSyncStatus {
95    upstream: Option<String>,
96    ahead: usize,
97    behind: usize,
98}
99
100#[derive(Debug, Clone)]
101struct WorktreeAnalysis {
102    repo_root: PathBuf,
103    repo_name: String,
104    branch: Option<String>,
105    status_entries: Vec<StatusEntry>,
106    files: Vec<FileChangeSummary>,
107    total_additions: u32,
108    total_deletions: u32,
109    diff_text: String,
110    new_functions: Vec<String>,
111    changed_functions: Vec<String>,
112    removed_functions: Vec<String>,
113    new_types: Vec<String>,
114    changed_types: Vec<String>,
115    removed_types: Vec<String>,
116    hunk_contexts: Vec<String>,
117}
118
119#[derive(Debug, Clone)]
120struct StatusEntry {
121    code: String,
122    path: String,
123}
124
125#[derive(Debug, Clone)]
126struct FileChangeSummary {
127    path: String,
128    status: String,
129    additions: u32,
130    deletions: u32,
131}
132
133#[derive(Debug, Clone)]
134struct SymbolSummary {
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
144#[derive(Debug, Clone, Copy, Eq, PartialEq)]
145enum SymbolKind {
146    Function,
147    Type,
148}
149
150#[derive(Debug, Clone)]
151struct CommitMessagePlan {
152    commit: ConventionalCommit,
153    generation_mode: GenerationMode,
154}
155
156#[derive(Debug, Clone)]
157struct ConventionalCommit {
158    commit_type: String,
159    scope: Option<String>,
160    description: String,
161    body: Vec<String>,
162    breaking_change: Option<String>,
163    footers: Vec<String>,
164}
165
166#[derive(Debug, Clone, Copy)]
167enum GenerationMode {
168    OpenRouter,
169    Heuristic,
170}
171
172#[derive(Debug, Deserialize)]
173struct AiCommitPayload {
174    #[serde(rename = "type")]
175    commit_type: String,
176    scope: Option<String>,
177    description: String,
178    #[serde(default)]
179    body: Vec<String>,
180    #[serde(default)]
181    breaking_change: bool,
182    breaking_description: Option<String>,
183    #[serde(default)]
184    footers: Vec<String>,
185}
186
187pub async fn run_commit(args: CommitArgs) -> Result<CommitRunOutcome, CommitError> {
188    if !command_exists("git") {
189        return Err(CommitError::Operation(
190            "Git is not installed on this machine.".to_string(),
191        ));
192    }
193
194    let invocation_dir = env::current_dir().map_err(|e| {
195        CommitError::Operation(format!("Failed to read current directory: {}", e))
196    })?;
197    let repo = resolve_repo_context(&invocation_dir)
198        .await
199        .map_err(CommitError::Operation)?;
200    let status_output = git_output(
201        &repo.repo_root,
202        &["status", "--porcelain=v1", "--untracked-files=all"],
203    )
204    .await
205    .map_err(CommitError::Operation)?;
206    let status_entries = parse_status_entries(&status_output);
207
208    if status_entries.is_empty() {
209        let sync_status = resolve_branch_sync_status(&repo.repo_root)
210            .await
211            .unwrap_or_default();
212
213        if args.push {
214            return handle_clean_worktree_push(&repo, &sync_status)
215                .await
216                .map_err(|error| CommitError::PushFailed {
217                    summary: error,
218                    commit_sha: None,
219                })
220                .map(|()| CommitRunOutcome::Completed);
221        }
222
223        println!(
224            "{}",
225            render_clean_worktree_message(&repo, &sync_status).bright_yellow()
226        );
227        return Ok(CommitRunOutcome::NothingToCommit);
228    }
229
230    let repo_for_analysis = repo.clone();
231    let analysis = analyze_worktree(repo, status_entries, &[])
232        .await
233        .map_err(CommitError::Operation)?;
234    let excluded_terminal_paths = terminal_session_paths(&analysis);
235    if !excluded_terminal_paths.is_empty()
236        && analysis
237            .files
238            .iter()
239            .all(|file| is_terminal_session_path(&file.path))
240    {
241        return Err(CommitError::TerminalSessionsBlocked(
242            render_terminal_session_block_message(&excluded_terminal_paths),
243        ));
244    }
245
246    let commit_analysis = if excluded_terminal_paths.is_empty() {
247        analysis.clone()
248    } else {
249        println!(
250            "\n{} {}",
251            "Skipped".bright_yellow().bold(),
252            format!(
253                "excluding {} IDE terminal session file(s) from this commit: {}",
254                excluded_terminal_paths.len(),
255                excluded_terminal_paths.join(", ")
256            )
257            .bright_white()
258        );
259        analyze_worktree(
260            repo_for_analysis,
261            analysis.status_entries.clone(),
262            &excluded_terminal_paths,
263        )
264        .await
265        .map_err(CommitError::Operation)?
266    };
267
268    let auto_push_after_commit = resolve_auto_push_on_commit().await;
269    let commit_plan = generate_commit_plan(&commit_analysis, &args).await;
270    let rendered_message = render_commit_message(&commit_plan.commit);
271
272    print_analysis_summary(&commit_analysis, &commit_plan, &rendered_message);
273
274    if args.dry_run {
275        println!(
276            "\n{}",
277            "Dry run only. No git commit was created."
278                .bright_yellow()
279                .bold()
280        );
281        return Ok(CommitRunOutcome::Completed);
282    }
283
284    stage_and_commit(
285        &commit_analysis.repo_root,
286        &rendered_message,
287        &excluded_terminal_paths,
288    )
289    .await
290    .map_err(CommitError::Operation)?;
291
292    let short_sha = git_output(
293        &commit_analysis.repo_root,
294        &["rev-parse", "--short", "HEAD"],
295    )
296    .await
297    .map_err(CommitError::Operation)?;
298    let full_sha = git_output(&commit_analysis.repo_root, &["rev-parse", "HEAD"])
299        .await
300        .map_err(CommitError::Operation)?;
301
302    println!(
303        "\n{} {} {}",
304        "Committed".bright_green().bold(),
305        short_sha.bright_white().bold(),
306        format!("({})", full_sha).dimmed()
307    );
308
309    if args.push || auto_push_after_commit {
310        match push_current_branch(&commit_analysis.repo_root).await {
311            Ok(Some(outcome)) => print_push_summary(&outcome),
312            Ok(None) => println!(
313                "{}",
314                "Push skipped because the current HEAD is detached.".bright_yellow()
315            ),
316            Err(error) => {
317                return Err(CommitError::PushFailed {
318                    summary: error,
319                    commit_sha: Some(short_sha),
320                });
321            }
322        }
323    } else {
324        println!(
325            "{}",
326            "Auto-push disabled by xbp config (`github.auto_push_on_commit: false`)."
327                .bright_yellow()
328        );
329    }
330
331    Ok(CommitRunOutcome::Completed)
332}
333
334async fn resolve_auto_push_on_commit() -> bool {
335    load_xbp_config_with_root()
336        .await
337        .map(|(_, config)| config.auto_push_on_commit_enabled())
338        .unwrap_or(true)
339}
340
341async fn resolve_repo_context(invocation_dir: &Path) -> Result<RepoContext, String> {
342    let repo_root = PathBuf::from(
343        git_output(invocation_dir, &["rev-parse", "--show-toplevel"])
344            .await
345            .map_err(|_| "Current directory is not inside a git repository.".to_string())?,
346    );
347    let repo_name = repo_name(&repo_root);
348    let branch = git_output(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
349        .await
350        .ok()
351        .filter(|value| !value.is_empty() && value != "HEAD");
352
353    Ok(RepoContext {
354        repo_root,
355        repo_name,
356        branch,
357    })
358}
359
360async fn resolve_branch_sync_status(repo_root: &Path) -> Result<BranchSyncStatus, String> {
361    let upstream = git_output(
362        repo_root,
363        &[
364            "rev-parse",
365            "--abbrev-ref",
366            "--symbolic-full-name",
367            "@{upstream}",
368        ],
369    )
370    .await
371    .ok()
372    .filter(|value| !value.is_empty());
373
374    let Some(upstream_name) = upstream else {
375        return Ok(BranchSyncStatus::default());
376    };
377
378    let counts = git_output(
379        repo_root,
380        &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
381    )
382    .await?;
383    let mut parts = counts.split_whitespace();
384    let ahead = parts
385        .next()
386        .and_then(|value| value.parse::<usize>().ok())
387        .unwrap_or(0);
388    let behind = parts
389        .next()
390        .and_then(|value| value.parse::<usize>().ok())
391        .unwrap_or(0);
392
393    Ok(BranchSyncStatus {
394        upstream: Some(upstream_name),
395        ahead,
396        behind,
397    })
398}
399
400async fn handle_clean_worktree_push(
401    repo: &RepoContext,
402    sync_status: &BranchSyncStatus,
403) -> Result<(), String> {
404    if repo.branch.is_none() {
405        return Err(render_clean_worktree_message(repo, sync_status));
406    }
407
408    if sync_status.behind > 0 && sync_status.ahead == 0 {
409        let branch = repo.branch.as_deref().unwrap_or("main");
410        let upstream = sync_status
411            .upstream
412            .as_deref()
413            .unwrap_or("origin/main");
414        return Err(format!(
415            "`{branch}` is behind `{upstream}` by {} commit(s). Run `git pull --rebase origin {branch}`, then push again.",
416            sync_status.behind
417        ));
418    }
419
420    if sync_status.ahead == 0 && sync_status.behind == 0 && sync_status.upstream.is_some() {
421        println!("{}", render_clean_worktree_message(repo, sync_status));
422        return Ok(());
423    }
424
425    println!("{}", render_clean_worktree_message(repo, sync_status));
426    match push_current_branch(&repo.repo_root).await {
427        Ok(Some(outcome)) => {
428            print_push_summary(&outcome);
429            Ok(())
430        }
431        Ok(None) => Err("Push skipped because the current HEAD is detached.".to_string()),
432        Err(error) => Err(error),
433    }
434}
435
436fn render_clean_worktree_message(repo: &RepoContext, sync_status: &BranchSyncStatus) -> String {
437    let branch_label = repo
438        .branch
439        .as_deref()
440        .map(|branch| format!(" on `{}`", branch))
441        .unwrap_or_default();
442
443    let sync_summary = match sync_status.upstream.as_deref() {
444        Some(upstream) if sync_status.ahead > 0 && sync_status.behind > 0 => format!(
445            "Working tree is clean{}, with {} commit(s) waiting to push and {} commit(s) to pull from `{}`.",
446            branch_label, sync_status.ahead, sync_status.behind, upstream
447        ),
448        Some(upstream) if sync_status.ahead > 0 => format!(
449            "Working tree is clean{}, with {} commit(s) waiting to push to `{}`.",
450            branch_label, sync_status.ahead, upstream
451        ),
452        Some(upstream) if sync_status.behind > 0 => format!(
453            "Working tree is clean{}, but `{}` is ahead by {} commit(s). Pull or rebase before pushing.",
454            branch_label, upstream, sync_status.behind
455        ),
456        Some(upstream) => format!(
457            "Working tree is clean{} and already in sync with `{}`.",
458            branch_label, upstream
459        ),
460        None if repo.branch.is_some() => format!(
461            "Working tree is clean{}, and no upstream branch is configured yet.",
462            branch_label
463        ),
464        None => "Working tree is clean, and HEAD is detached.".to_string(),
465    };
466
467    format!("Nothing to commit. {}", sync_summary)
468}
469
470async fn analyze_worktree(
471    repo: RepoContext,
472    status_entries: Vec<StatusEntry>,
473    exclude_paths: &[String],
474) -> Result<WorktreeAnalysis, String> {
475    if status_entries.is_empty() {
476        return Err("No worktree changes were found to commit.".to_string());
477    }
478
479    let temp_index = prepare_temporary_index(&repo.repo_root).await?;
480    let temp_index_path = temp_index.path.clone();
481    let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
482
483    git_output_with_env(&repo.repo_root, &["add", "--all"], &git_env).await?;
484    for path in exclude_paths {
485        git_output_with_env(
486            &repo.repo_root,
487            &["reset", "HEAD", "--", path.as_str()],
488            &git_env,
489        )
490        .await?;
491    }
492    let name_status_output = git_output_with_env(
493        &repo.repo_root,
494        &["diff", "--cached", "--name-status", "--find-renames"],
495        &git_env,
496    )
497    .await?;
498    let numstat_output = git_output_with_env(
499        &repo.repo_root,
500        &["diff", "--cached", "--numstat", "--find-renames"],
501        &git_env,
502    )
503    .await?;
504    let diff_text = git_output_with_env(
505        &repo.repo_root,
506        &[
507            "diff",
508            "--cached",
509            "--unified=0",
510            "--no-color",
511            "--no-ext-diff",
512            "--find-renames",
513        ],
514        &git_env,
515    )
516    .await?;
517
518    if diff_text.trim().is_empty() {
519        return Err("No staged diff could be produced from the current worktree.".to_string());
520    }
521
522    let files = merge_file_summaries(&name_status_output, &numstat_output);
523    let total_additions = files.iter().map(|entry| entry.additions).sum();
524    let total_deletions = files.iter().map(|entry| entry.deletions).sum();
525    let symbols = summarize_symbols(&diff_text);
526
527    Ok(WorktreeAnalysis {
528        repo_root: repo.repo_root,
529        repo_name: repo.repo_name,
530        branch: repo.branch,
531        status_entries,
532        files,
533        total_additions,
534        total_deletions,
535        diff_text,
536        new_functions: symbols.new_functions,
537        changed_functions: symbols.changed_functions,
538        removed_functions: symbols.removed_functions,
539        new_types: symbols.new_types,
540        changed_types: symbols.changed_types,
541        removed_types: symbols.removed_types,
542        hunk_contexts: symbols.hunk_contexts,
543    })
544}
545
546async fn prepare_temporary_index(repo_root: &Path) -> Result<TemporaryIndex, String> {
547    let real_index_path =
548        PathBuf::from(git_output(repo_root, &["rev-parse", "--git-path", "index"]).await?);
549    let temp_index_path = env::temp_dir().join(format!("xbp-commit-index-{}.tmp", Uuid::new_v4()));
550
551    if real_index_path.exists() {
552        fs::copy(&real_index_path, &temp_index_path).map_err(|e| {
553            format!(
554                "Failed to prepare temporary git index {}: {}",
555                temp_index_path.display(),
556                e
557            )
558        })?;
559    } else {
560        let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
561        let _ = git_output_with_env(repo_root, &["read-tree", "HEAD"], &git_env).await;
562    }
563
564    Ok(TemporaryIndex {
565        path: temp_index_path,
566    })
567}
568
569async fn generate_commit_plan(analysis: &WorktreeAnalysis, args: &CommitArgs) -> CommitMessagePlan {
570    let forced_scope = sanitize_scope(args.scope.as_deref());
571    let heuristic = build_heuristic_commit(analysis, forced_scope.clone());
572
573    if args.no_ai {
574        return CommitMessagePlan {
575            commit: heuristic,
576            generation_mode: GenerationMode::Heuristic,
577        };
578    }
579
580    let Some(api_key) = resolve_openrouter_api_key() else {
581        return CommitMessagePlan {
582            commit: heuristic,
583            generation_mode: GenerationMode::Heuristic,
584        };
585    };
586
587    let prompt = build_commit_prompt(analysis, forced_scope.as_deref(), &heuristic);
588    let model = args
589        .model
590        .as_deref()
591        .map(str::trim)
592        .filter(|value| !value.is_empty())
593        .map(str::to_string)
594        .unwrap_or_else(resolve_openrouter_commit_model);
595    let system_prompt = resolve_openrouter_commit_system_prompt();
596
597    let ai_message = complete_prompt(
598        &api_key,
599        &model,
600        Some(&system_prompt),
601        &prompt,
602        Some("XBP Commit Generator"),
603    )
604    .await
605    .and_then(|raw| parse_ai_commit_payload(&raw, forced_scope.as_deref()));
606
607    if let Some(mut commit) = ai_message {
608        if commit.body.is_empty() {
609            commit.body = heuristic.body.clone();
610        }
611        CommitMessagePlan {
612            commit,
613            generation_mode: GenerationMode::OpenRouter,
614        }
615    } else {
616        CommitMessagePlan {
617            commit: heuristic,
618            generation_mode: GenerationMode::Heuristic,
619        }
620    }
621}
622
623fn build_commit_prompt(
624    analysis: &WorktreeAnalysis,
625    forced_scope: Option<&str>,
626    heuristic: &ConventionalCommit,
627) -> String {
628    let focus_areas = summarize_focus_areas(analysis, MAX_PROMPT_AREAS);
629    let top_files = summarize_top_files(analysis, MAX_PROMPT_FILES);
630    let mut lines = vec![
631        "Worktree context for commit generation:".to_string(),
632        String::new(),
633    ];
634    lines.push(format!("Repository: {}", analysis.repo_name));
635    if let Some(branch) = &analysis.branch {
636        lines.push(format!("Branch: {}", branch));
637    }
638    if let Some(scope) = forced_scope {
639        lines.push(format!("Forced scope: {}", scope));
640    } else if let Some(scope) = heuristic.scope.as_deref() {
641        lines.push(format!("Suggested scope: {}", scope));
642    }
643    lines.push(format!(
644        "Heuristic fallback subject: {}",
645        render_commit_subject(heuristic)
646    ));
647    if !focus_areas.is_empty() {
648        lines.push(format!("Focus areas: {}", focus_areas.join(", ")));
649    }
650    if let Some(behavior_hint) = infer_behavior_hint(analysis) {
651        lines.push(format!("Behavior hint: {}", behavior_hint));
652    }
653    if analysis
654        .files
655        .iter()
656        .any(|file| is_terminal_session_path(&file.path))
657    {
658        lines.push(String::new());
659        lines.push("Important constraint:".to_string());
660        lines.push(
661            "- paths under terminals/ are IDE agent session logs, not product features; never describe them as flows or capabilities"
662                .to_string(),
663        );
664        lines.push(
665            "- terminals/.next-id is an IDE session counter, not app configuration; never describe updating it as a feature"
666                .to_string(),
667        );
668    }
669    lines.push(String::new());
670    lines.push("Worktree stats:".to_string());
671    lines.push(format!("- files changed: {}", analysis.files.len()));
672    lines.push(format!(
673        "- lines: +{} -{}",
674        analysis.total_additions, analysis.total_deletions
675    ));
676    if !top_files.is_empty() {
677        lines.push("- dominant files:".to_string());
678        for file in top_files {
679            lines.push(format!("  - {}", file));
680        }
681    }
682    if !analysis.status_entries.is_empty() {
683        lines.push("- worktree statuses:".to_string());
684        for entry in analysis.status_entries.iter().take(MAX_PROMPT_FILES) {
685            lines.push(format!("  - {} {}", entry.code, entry.path));
686        }
687    }
688    if !analysis.files.is_empty() {
689        lines.push("- file diffs:".to_string());
690        for file in analysis.files.iter().take(MAX_PROMPT_FILES) {
691            lines.push(format!(
692                "  - [{}] {} (+{} -{})",
693                file.status, file.path, file.additions, file.deletions
694            ));
695        }
696    }
697    if !analysis.new_functions.is_empty() {
698        lines.push(format!(
699            "- new functions: {}",
700            analysis
701                .new_functions
702                .iter()
703                .take(MAX_PROMPT_SYMBOLS)
704                .cloned()
705                .collect::<Vec<_>>()
706                .join(", ")
707        ));
708    }
709    if !analysis.changed_functions.is_empty() {
710        lines.push(format!(
711            "- changed functions: {}",
712            analysis
713                .changed_functions
714                .iter()
715                .take(MAX_PROMPT_SYMBOLS)
716                .cloned()
717                .collect::<Vec<_>>()
718                .join(", ")
719        ));
720    }
721    if !analysis.new_types.is_empty() {
722        lines.push(format!(
723            "- new types: {}",
724            analysis
725                .new_types
726                .iter()
727                .take(MAX_PROMPT_SYMBOLS)
728                .cloned()
729                .collect::<Vec<_>>()
730                .join(", ")
731        ));
732    }
733    if !analysis.changed_types.is_empty() {
734        lines.push(format!(
735            "- changed types: {}",
736            analysis
737                .changed_types
738                .iter()
739                .take(MAX_PROMPT_SYMBOLS)
740                .cloned()
741                .collect::<Vec<_>>()
742                .join(", ")
743        ));
744    }
745    if !analysis.hunk_contexts.is_empty() {
746        lines.push(format!(
747            "- hunk contexts: {}",
748            analysis
749                .hunk_contexts
750                .iter()
751                .take(MAX_PROMPT_CONTEXTS)
752                .cloned()
753                .collect::<Vec<_>>()
754                .join(", ")
755        ));
756    }
757    lines.push(String::new());
758    lines.push("Diff excerpt:".to_string());
759    lines.push(truncate_for_prompt(&analysis.diff_text, AI_DIFF_CHAR_LIMIT));
760    lines.join("\n")
761}
762
763fn parse_ai_commit_payload(raw: &str, forced_scope: Option<&str>) -> Option<ConventionalCommit> {
764    let payload: AiCommitPayload = serde_json::from_str(raw).ok()?;
765    let commit_type = sanitize_commit_type(&payload.commit_type)?;
766    let description = sanitize_description(&payload.description)?;
767    let mut body = payload
768        .body
769        .into_iter()
770        .map(|entry| entry.trim().to_string())
771        .filter(|entry| !entry.is_empty())
772        .collect::<Vec<_>>();
773    if body.len() > 3 {
774        body.truncate(3);
775    }
776
777    let breaking_change = if payload.breaking_change {
778        payload
779            .breaking_description
780            .as_deref()
781            .and_then(sanitize_footer_value)
782    } else {
783        None
784    };
785
786    let mut footers = payload
787        .footers
788        .into_iter()
789        .map(|entry| entry.trim().to_string())
790        .filter(|entry| !entry.is_empty())
791        .collect::<Vec<_>>();
792    if breaking_change.is_some()
793        && !footers
794            .iter()
795            .any(|entry| entry.starts_with("BREAKING CHANGE:"))
796    {
797        if let Some(breaking) = &breaking_change {
798            footers.push(format!("BREAKING CHANGE: {}", breaking));
799        }
800    }
801
802    Some(ConventionalCommit {
803        commit_type,
804        scope: forced_scope
805            .and_then(|scope| sanitize_scope(Some(scope)))
806            .or_else(|| sanitize_scope(payload.scope.as_deref())),
807        description,
808        body,
809        breaking_change,
810        footers,
811    })
812}
813
814fn build_heuristic_commit(
815    analysis: &WorktreeAnalysis,
816    forced_scope: Option<String>,
817) -> ConventionalCommit {
818    let commit_type = infer_commit_type(analysis).to_string();
819    let scope = forced_scope.or_else(|| infer_scope(analysis));
820    let description = infer_description(analysis, &commit_type, scope.as_deref());
821    let body = build_heuristic_body(analysis);
822
823    ConventionalCommit {
824        commit_type,
825        scope,
826        description,
827        body,
828        breaking_change: None,
829        footers: Vec::new(),
830    }
831}
832
833fn infer_commit_type(analysis: &WorktreeAnalysis) -> &'static str {
834    let lowered_paths = analysis
835        .files
836        .iter()
837        .map(|file| file.path.to_ascii_lowercase())
838        .collect::<Vec<_>>();
839
840    if !lowered_paths.is_empty()
841        && lowered_paths
842            .iter()
843            .all(|path| is_terminal_session_path(path))
844    {
845        return "chore";
846    }
847
848    let docs_only = !lowered_paths.is_empty()
849        && lowered_paths.iter().all(|path| {
850            !is_terminal_session_path(path)
851                && (path.ends_with(".md")
852                    || path.ends_with(".mdx")
853                    || path.ends_with(".txt")
854                    || path.starts_with("docs/"))
855        });
856    if docs_only {
857        return "docs";
858    }
859
860    let test_only = !lowered_paths.is_empty()
861        && lowered_paths.iter().all(|path| {
862            path.contains("/tests/")
863                || path.contains("\\tests\\")
864                || path.contains(".test.")
865                || path.contains(".spec.")
866        });
867    if test_only {
868        return "test";
869    }
870
871    let ci_only = !lowered_paths.is_empty()
872        && lowered_paths.iter().all(|path| {
873            path.starts_with(".github/")
874                || path.contains("workflow")
875                || path.ends_with(".yml")
876                || path.ends_with(".yaml")
877        });
878    if ci_only {
879        return "ci";
880    }
881
882    let build_only = !lowered_paths.is_empty()
883        && lowered_paths.iter().all(|path| {
884            path.ends_with("cargo.toml")
885                || path.ends_with("cargo.lock")
886                || path.ends_with("package.json")
887                || path.ends_with("pnpm-lock.yaml")
888                || path.ends_with("package-lock.json")
889                || path.ends_with("wrangler.toml")
890                || path.ends_with(".nix")
891        });
892    if build_only {
893        return "build";
894    }
895
896    let has_new_files = analysis.files.iter().any(|file| file.status == "added");
897    let has_new_symbols = !analysis.new_functions.is_empty() || !analysis.new_types.is_empty();
898    let has_existing_symbol_churn = !analysis.changed_functions.is_empty()
899        || !analysis.changed_types.is_empty()
900        || !analysis.removed_functions.is_empty()
901        || !analysis.removed_types.is_empty();
902    if has_new_files {
903        return "feat";
904    }
905    if has_new_symbols && has_existing_symbol_churn {
906        return "refactor";
907    }
908    if has_new_symbols {
909        return "feat";
910    }
911
912    if has_existing_symbol_churn || analysis.total_additions >= analysis.total_deletions {
913        return "fix";
914    }
915
916    "chore"
917}
918
919fn infer_scope(analysis: &WorktreeAnalysis) -> Option<String> {
920    let mut scopes = BTreeSet::new();
921
922    for file in &analysis.files {
923        let path = file.path.replace('\\', "/");
924        let inferred = if path.starts_with("crates/cli/") {
925            Some("cli")
926        } else if path.starts_with("crates/api/") {
927            Some("api")
928        } else if path.starts_with("crates/github/") {
929            Some("github")
930        } else if path.starts_with("crates/runtime/") {
931            Some("runtime")
932        } else if path.starts_with("apps/web/") {
933            Some("web")
934        } else if path.starts_with("docs/") {
935            Some("docs")
936        } else if path.starts_with(".github/") {
937            Some("ci")
938        } else {
939            None
940        };
941
942        if let Some(value) = inferred {
943            scopes.insert(value.to_string());
944        }
945    }
946
947    if scopes.len() == 1 {
948        scopes.into_iter().next()
949    } else if scopes.is_empty() {
950        None
951    } else if scopes.contains("cli") {
952        Some("cli".to_string())
953    } else {
954        None
955    }
956}
957
958fn infer_description(
959    analysis: &WorktreeAnalysis,
960    commit_type: &str,
961    scope: Option<&str>,
962) -> String {
963    if !analysis.files.is_empty()
964        && analysis
965            .files
966            .iter()
967            .all(|file| is_terminal_session_path(&file.path))
968    {
969        return "ignore ide terminal session logs".to_string();
970    }
971
972    let focus_areas = summarize_focus_areas(analysis, 2);
973    let behavior_hint = infer_behavior_hint(analysis);
974    let interesting_names = analysis
975        .files
976        .iter()
977        .filter_map(|file| interesting_file_stem(&file.path))
978        .collect::<Vec<_>>();
979
980    if interesting_names.iter().any(|name| name == "commit") {
981        return match scope {
982            Some("cli") => "add worktree commit generator".to_string(),
983            _ => "add conventional commit generator".to_string(),
984        };
985    }
986
987    if commit_type == "docs" {
988        if let Some(name) = interesting_names.first() {
989            return format!("document {}", name.replace('_', "-"));
990        }
991        return "update command documentation".to_string();
992    }
993
994    if let Some(area) = focus_areas.first() {
995        let target = match behavior_hint {
996            Some(hint) => format!("{} {}", area, hint),
997            None => format!("{} flow", area),
998        };
999        return match commit_type {
1000            "feat" => format!("expand {}", target),
1001            "fix" => format!("improve {}", target),
1002            "refactor" => format!("refine {}", target),
1003            "test" => format!("cover {}", target),
1004            "ci" => format!("update {} workflow", area),
1005            "build" => format!("adjust {} build inputs", area),
1006            _ => format!("update {}", target),
1007        };
1008    }
1009
1010    if let Some(name) = interesting_names.first() {
1011        return match commit_type {
1012            "feat" => format!("add {}", name.replace('_', "-")),
1013            "fix" => format!("improve {}", name.replace('_', "-")),
1014            "refactor" => format!("refactor {}", name.replace('_', "-")),
1015            "test" => format!("cover {}", name.replace('_', "-")),
1016            "ci" => format!("update {}", name.replace('_', "-")),
1017            "build" => format!("adjust {}", name.replace('_', "-")),
1018            _ => format!("update {}", name.replace('_', "-")),
1019        };
1020    }
1021
1022    match commit_type {
1023        "feat" => "add worktree change support".to_string(),
1024        "fix" => "improve worktree handling".to_string(),
1025        "docs" => "update documentation".to_string(),
1026        _ => "update repository changes".to_string(),
1027    }
1028}
1029
1030fn build_heuristic_body(analysis: &WorktreeAnalysis) -> Vec<String> {
1031    let focus_areas = summarize_focus_areas(analysis, 3);
1032    let top_files = summarize_top_files(analysis, 3);
1033    let mut paragraphs = Vec::new();
1034    let overview_target = if focus_areas.is_empty() {
1035        format!(
1036            "{} file{}",
1037            analysis.files.len(),
1038            if analysis.files.len() == 1 { "" } else { "s" }
1039        )
1040    } else {
1041        format!(
1042            "{} across {} file{}",
1043            format_focus_area_list(&focus_areas),
1044            analysis.files.len(),
1045            if analysis.files.len() == 1 { "" } else { "s" }
1046        )
1047    };
1048    paragraphs.push(format!(
1049        "Touches {} with +{} and -{} lines in the current worktree.",
1050        overview_target, analysis.total_additions, analysis.total_deletions
1051    ));
1052
1053    let mut detail_parts = Vec::new();
1054    if !analysis.new_functions.is_empty() {
1055        detail_parts.push(format!(
1056            "adds {}",
1057            summarize_symbol_list(&analysis.new_functions, 4)
1058        ));
1059    }
1060    if !analysis.changed_functions.is_empty() {
1061        detail_parts.push(format!(
1062            "updates {}",
1063            summarize_symbol_list(&analysis.changed_functions, 4)
1064        ));
1065    }
1066    if !analysis.removed_functions.is_empty() {
1067        detail_parts.push(format!(
1068            "removes {}",
1069            summarize_symbol_list(&analysis.removed_functions, 3)
1070        ));
1071    }
1072    if !analysis.new_types.is_empty() {
1073        detail_parts.push(format!(
1074            "introduces {}",
1075            summarize_symbol_list(&analysis.new_types, 3)
1076        ));
1077    }
1078    if !analysis.changed_types.is_empty() {
1079        detail_parts.push(format!(
1080            "reshapes {}",
1081            summarize_symbol_list(&analysis.changed_types, 3)
1082        ));
1083    }
1084    if !analysis.removed_types.is_empty() {
1085        detail_parts.push(format!(
1086            "drops {}",
1087            summarize_symbol_list(&analysis.removed_types, 3)
1088        ));
1089    }
1090    if !top_files.is_empty() {
1091        detail_parts.push(format!(
1092            "with the biggest diffs in {}",
1093            top_files.join(", ")
1094        ));
1095    }
1096    if !detail_parts.is_empty() {
1097        paragraphs.push(format!("{}.", detail_parts.join("; ")));
1098    }
1099
1100    paragraphs
1101}
1102
1103fn summarize_focus_areas(analysis: &WorktreeAnalysis, limit: usize) -> Vec<String> {
1104    let mut area_scores = BTreeMap::<String, u32>::new();
1105
1106    for file in &analysis.files {
1107        let Some(area) = describe_focus_area(&file.path) else {
1108            continue;
1109        };
1110        let weight = (file.additions + file.deletions).max(1);
1111        *area_scores.entry(area).or_default() += weight;
1112    }
1113
1114    let mut ranked = area_scores.into_iter().collect::<Vec<_>>();
1115    ranked.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1116    ranked
1117        .into_iter()
1118        .map(|(area, _)| area)
1119        .take(limit)
1120        .collect()
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Eq)]
1124struct TopFileGroup {
1125    label: String,
1126    file_count: usize,
1127    additions: u32,
1128    deletions: u32,
1129}
1130
1131fn summarize_top_files(analysis: &WorktreeAnalysis, limit: usize) -> Vec<String> {
1132    let groups = collapse_top_file_groups(analysis);
1133
1134    groups
1135        .into_iter()
1136        .take(limit)
1137        .map(|group| format_top_file_group(&group))
1138        .collect()
1139}
1140
1141fn collapse_top_file_groups(analysis: &WorktreeAnalysis) -> Vec<TopFileGroup> {
1142    let mut parent_counts = BTreeMap::<String, usize>::new();
1143    for file in &analysis.files {
1144        if let Some(parent) = parent_dir_key(&file.path) {
1145            *parent_counts.entry(parent).or_default() += 1;
1146        }
1147    }
1148
1149    let mut groups = BTreeMap::<String, TopFileGroup>::new();
1150    for file in &analysis.files {
1151        let label = top_file_group_label(&file.path, &parent_counts);
1152        let entry = groups.entry(label.clone()).or_insert(TopFileGroup {
1153            label,
1154            file_count: 0,
1155            additions: 0,
1156            deletions: 0,
1157        });
1158        entry.file_count += 1;
1159        entry.additions += file.additions;
1160        entry.deletions += file.deletions;
1161    }
1162
1163    let mut ranked = groups.into_values().collect::<Vec<_>>();
1164    ranked.sort_by(|left, right| {
1165        let left_weight = left.additions + left.deletions;
1166        let right_weight = right.additions + right.deletions;
1167        right_weight
1168            .cmp(&left_weight)
1169            .then_with(|| left.label.cmp(&right.label))
1170    });
1171    ranked
1172}
1173
1174fn top_file_group_label(path: &str, parent_counts: &BTreeMap<String, usize>) -> String {
1175    let normalized = normalize_display_path(path);
1176    if normalized.starts_with("terminals/") {
1177        return "terminals/".to_string();
1178    }
1179
1180    if let Some(parent) = parent_dir_key(&normalized) {
1181        if parent_counts.get(&parent).copied().unwrap_or(0) > 1 {
1182            return format!("{}/", parent);
1183        }
1184    }
1185
1186    normalized
1187}
1188
1189fn is_terminal_session_path(path: &str) -> bool {
1190    normalize_display_path(path)
1191        .to_ascii_lowercase()
1192        .starts_with(TERMINAL_SESSION_DIR)
1193}
1194
1195fn terminal_session_paths(analysis: &WorktreeAnalysis) -> Vec<String> {
1196    analysis
1197        .files
1198        .iter()
1199        .filter(|file| is_terminal_session_path(&file.path))
1200        .map(|file| file.path.clone())
1201        .collect()
1202}
1203
1204fn render_terminal_session_block_message(paths: &[String]) -> String {
1205    format!(
1206        "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: {}",
1207        TERMINAL_SESSION_DIR.trim_end_matches('/'),
1208        paths.join(", ")
1209    )
1210}
1211
1212fn parent_dir_key(path: &str) -> Option<String> {
1213    let normalized = normalize_display_path(path);
1214    Path::new(&normalized)
1215        .parent()
1216        .and_then(|value| value.to_str())
1217        .map(normalize_display_path)
1218        .filter(|value| !value.is_empty())
1219}
1220
1221fn format_top_file_group(group: &TopFileGroup) -> String {
1222    if group.file_count > 1 {
1223        format!(
1224            "{} ({} files, +{} -{})",
1225            group.label, group.file_count, group.additions, group.deletions
1226        )
1227    } else {
1228        format!(
1229            "{} (+{} -{})",
1230            group.label, group.additions, group.deletions
1231        )
1232    }
1233}
1234
1235fn describe_focus_area(path: &str) -> Option<String> {
1236    let normalized = normalize_display_path(path);
1237    if normalized.starts_with("terminals/") {
1238        return Some("terminals".to_string());
1239    }
1240    if normalized.starts_with("crates/cli/src/commands/") {
1241        return Path::new(&normalized)
1242            .file_stem()
1243            .and_then(|value| value.to_str())
1244            .map(humanize_focus_area)
1245            .filter(|value| !value.is_empty());
1246    }
1247    if normalized.contains("/http-app.") {
1248        return Some("http".to_string());
1249    }
1250    if normalized.contains("/server.") {
1251        return Some("server".to_string());
1252    }
1253    if normalized.contains("/worker/") {
1254        return Some("worker".to_string());
1255    }
1256    if let Some(stem) = interesting_file_stem(&normalized) {
1257        return Some(humanize_focus_area(&stem));
1258    }
1259
1260    normalized
1261        .split('/')
1262        .rev()
1263        .find_map(normalize_focus_segment)
1264}
1265
1266fn humanize_focus_area(raw: &str) -> String {
1267    raw.trim().replace(['_', '-'], " ").to_ascii_lowercase()
1268}
1269
1270fn is_generic_path_segment(segment: &str) -> bool {
1271    matches!(
1272        segment.to_ascii_lowercase().as_str(),
1273        "src" | "crates" | "apps" | "packages" | "commands" | "tests" | "test" | "docs" | "lib"
1274            | "terminals"
1275    )
1276}
1277
1278fn is_weak_focus_label(label: &str) -> bool {
1279    let trimmed = label.trim();
1280    trimmed.is_empty() || trimmed.chars().all(|ch| ch.is_ascii_digit())
1281}
1282
1283fn normalize_focus_segment(segment: &str) -> Option<String> {
1284    let trimmed = segment.trim();
1285    if trimmed.is_empty() || is_generic_path_segment(trimmed) {
1286        return None;
1287    }
1288
1289    let label = trimmed.trim_start_matches('.');
1290    if is_weak_focus_label(label) {
1291        return None;
1292    }
1293
1294    let humanized = humanize_focus_area(label);
1295    if humanized.is_empty() {
1296        None
1297    } else {
1298        Some(humanized)
1299    }
1300}
1301
1302fn infer_behavior_hint(analysis: &WorktreeAnalysis) -> Option<&'static str> {
1303    let mut corpus = String::new();
1304    for file in &analysis.files {
1305        corpus.push_str(&file.path.to_ascii_lowercase());
1306        corpus.push(' ');
1307    }
1308    for context in &analysis.hunk_contexts {
1309        corpus.push_str(&context.to_ascii_lowercase());
1310        corpus.push(' ');
1311    }
1312
1313    if corpus.contains("request") || corpus.contains("response") || corpus.contains("http") {
1314        Some("request handling")
1315    } else if corpus.contains("auth") || corpus.contains("token") || corpus.contains("session") {
1316        Some("auth flow")
1317    } else if corpus.contains("env") || corpus.contains("config") || corpus.contains("setting") {
1318        Some("configuration loading")
1319    } else if corpus.contains("release") || corpus.contains("version") || corpus.contains("tag") {
1320        Some("release flow")
1321    } else if corpus.contains("docker") || corpus.contains("container") {
1322        Some("container setup")
1323    } else {
1324        None
1325    }
1326}
1327
1328fn format_focus_area_list(areas: &[String]) -> String {
1329    match areas {
1330        [] => "the current worktree".to_string(),
1331        [one] => one.clone(),
1332        [left, right] => format!("{} and {}", left, right),
1333        _ => {
1334            let mut items = areas.to_vec();
1335            let last = items.pop().unwrap_or_default();
1336            format!("{}, and {}", items.join(", "), last)
1337        }
1338    }
1339}
1340
1341fn summarize_symbol_list(entries: &[String], limit: usize) -> String {
1342    entries
1343        .iter()
1344        .take(limit)
1345        .map(|entry| {
1346            entry
1347                .split_once(" (")
1348                .map(|(name, _)| name.to_string())
1349                .unwrap_or_else(|| entry.clone())
1350        })
1351        .collect::<Vec<_>>()
1352        .join(", ")
1353}
1354
1355fn render_commit_message(commit: &ConventionalCommit) -> String {
1356    let mut sections = vec![render_commit_subject(commit)];
1357
1358    if !commit.body.is_empty() {
1359        sections.push(commit.body.join("\n\n"));
1360    }
1361
1362    let mut footer_lines = commit
1363        .footers
1364        .iter()
1365        .map(|entry| entry.trim().to_string())
1366        .filter(|entry| !entry.is_empty())
1367        .collect::<Vec<_>>();
1368    if let Some(breaking_change) = &commit.breaking_change {
1369        if !footer_lines
1370            .iter()
1371            .any(|entry| entry.starts_with("BREAKING CHANGE:"))
1372        {
1373            footer_lines.push(format!("BREAKING CHANGE: {}", breaking_change));
1374        }
1375    }
1376    if !footer_lines.is_empty() {
1377        sections.push(footer_lines.join("\n"));
1378    }
1379
1380    sections.join("\n\n")
1381}
1382
1383fn render_commit_subject(commit: &ConventionalCommit) -> String {
1384    let scope = commit
1385        .scope
1386        .as_deref()
1387        .map(|value| format!("({})", value))
1388        .unwrap_or_default();
1389    let bang = if commit.breaking_change.is_some() {
1390        "!"
1391    } else {
1392        ""
1393    };
1394    format!(
1395        "{}{}{}: {}",
1396        commit.commit_type, scope, bang, commit.description
1397    )
1398}
1399
1400fn print_analysis_summary(
1401    analysis: &WorktreeAnalysis,
1402    commit_plan: &CommitMessagePlan,
1403    rendered_message: &str,
1404) {
1405    let focus_areas = summarize_focus_areas(analysis, MAX_PRINT_AREAS);
1406    let top_files = summarize_top_files(analysis, MAX_PRINT_FILES);
1407    let branch = analysis
1408        .branch
1409        .as_deref()
1410        .map(|value| format!(" on {}", value.bright_blue()))
1411        .unwrap_or_default();
1412    println!(
1413        "{} {}{}",
1414        "Commit".bright_green().bold(),
1415        analysis.repo_name.bright_white().bold(),
1416        branch
1417    );
1418    println!(
1419        "  {} {}",
1420        "Mode".bright_cyan().bold(),
1421        match commit_plan.generation_mode {
1422            GenerationMode::OpenRouter => "OpenRouter".bright_white(),
1423            GenerationMode::Heuristic => "Heuristic fallback".bright_white(),
1424        }
1425    );
1426    println!(
1427        "  {} {}",
1428        "Subject".bright_cyan().bold(),
1429        render_commit_subject(&commit_plan.commit).bright_white()
1430    );
1431    println!(
1432        "  {} {} file{} (+{} -{})",
1433        "Diff".bright_cyan().bold(),
1434        analysis.files.len(),
1435        if analysis.files.len() == 1 { "" } else { "s" },
1436        analysis.total_additions,
1437        analysis.total_deletions
1438    );
1439    if !focus_areas.is_empty() {
1440        println!(
1441            "  {} {}",
1442            "Focus".bright_cyan().bold(),
1443            focus_areas.join(", ")
1444        );
1445    }
1446    if !top_files.is_empty() {
1447        println!("  {} {}", "Top".bright_cyan().bold(), top_files.join(", "));
1448    }
1449
1450    if !analysis.new_functions.is_empty() {
1451        println!(
1452            "  {} {}",
1453            "+fn".bright_cyan().bold(),
1454            summarize_symbol_list(&analysis.new_functions, MAX_PRINT_SYMBOLS)
1455        );
1456    }
1457    if !analysis.changed_functions.is_empty() {
1458        println!(
1459            "  {} {}",
1460            "~fn".bright_cyan().bold(),
1461            summarize_symbol_list(&analysis.changed_functions, MAX_PRINT_SYMBOLS)
1462        );
1463    }
1464    if !analysis.new_types.is_empty() {
1465        println!(
1466            "  {} {}",
1467            "+type".bright_cyan().bold(),
1468            summarize_symbol_list(&analysis.new_types, MAX_PRINT_SYMBOLS)
1469        );
1470    }
1471    if !analysis.changed_types.is_empty() {
1472        println!(
1473            "  {} {}",
1474            "~type".bright_cyan().bold(),
1475            summarize_symbol_list(&analysis.changed_types, MAX_PRINT_SYMBOLS)
1476        );
1477    }
1478    if !analysis.removed_functions.is_empty() {
1479        println!(
1480            "  {} {}",
1481            "-fn".bright_cyan().bold(),
1482            summarize_symbol_list(&analysis.removed_functions, MAX_PRINT_SYMBOLS)
1483        );
1484    }
1485    if !analysis.removed_types.is_empty() {
1486        println!(
1487            "  {} {}",
1488            "-type".bright_cyan().bold(),
1489            summarize_symbol_list(&analysis.removed_types, MAX_PRINT_SYMBOLS)
1490        );
1491    }
1492
1493    println!("\n{}", "Generated commit".bright_magenta().bold());
1494    println!("{}", "-".repeat(72).bright_black());
1495    println!("{}", rendered_message);
1496    println!("{}", "-".repeat(72).bright_black());
1497}
1498
1499async fn stage_and_commit(
1500    repo_root: &Path,
1501    message: &str,
1502    exclude_paths: &[String],
1503) -> Result<(), String> {
1504    git_output(repo_root, &["add", "--all"]).await?;
1505    for path in exclude_paths {
1506        git_output(repo_root, &["reset", "HEAD", "--", path.as_str()]).await?;
1507    }
1508
1509    let message_path = env::temp_dir().join(format!("xbp-commit-message-{}.txt", Uuid::new_v4()));
1510    fs::write(&message_path, message).map_err(|e| {
1511        format!(
1512            "Failed to write temporary commit message {}: {}",
1513            message_path.display(),
1514            e
1515        )
1516    })?;
1517
1518    let result = git_output(
1519        repo_root,
1520        &["commit", "--file", &message_path.to_string_lossy()],
1521    )
1522    .await;
1523    let _ = fs::remove_file(&message_path);
1524    result.map(|_| ())
1525}
1526
1527fn parse_status_entries(output: &str) -> Vec<StatusEntry> {
1528    output
1529        .lines()
1530        .filter_map(|line| {
1531            if line.len() < 4 {
1532                return None;
1533            }
1534            let code = line[..2].trim().to_string();
1535            let path = line[3..].trim().replace('\\', "/");
1536            if path.is_empty() {
1537                None
1538            } else {
1539                Some(StatusEntry { code, path })
1540            }
1541        })
1542        .collect()
1543}
1544
1545fn merge_file_summaries(name_status: &str, numstat: &str) -> Vec<FileChangeSummary> {
1546    let mut status_map = BTreeMap::new();
1547    let mut display_path_map = BTreeMap::new();
1548
1549    for raw_line in name_status
1550        .lines()
1551        .map(str::trim)
1552        .filter(|line| !line.is_empty())
1553    {
1554        let parts = raw_line.split('\t').collect::<Vec<_>>();
1555        if parts.is_empty() {
1556            continue;
1557        }
1558        let status = normalize_name_status(parts[0]);
1559        match parts.as_slice() {
1560            [_, path] => {
1561                let key = normalize_path_key(path);
1562                display_path_map.insert(key.clone(), normalize_display_path(path));
1563                status_map.insert(key, status);
1564            }
1565            [_, old_path, new_path] => {
1566                let key = normalize_path_key(new_path);
1567                display_path_map.insert(
1568                    key.clone(),
1569                    format!(
1570                        "{} -> {}",
1571                        normalize_display_path(old_path),
1572                        normalize_display_path(new_path)
1573                    ),
1574                );
1575                status_map.insert(key, status);
1576            }
1577            _ => {}
1578        }
1579    }
1580
1581    let mut files = Vec::new();
1582    for raw_line in numstat
1583        .lines()
1584        .map(str::trim)
1585        .filter(|line| !line.is_empty())
1586    {
1587        let parts = raw_line.split('\t').collect::<Vec<_>>();
1588        if parts.len() < 3 {
1589            continue;
1590        }
1591        let additions = parts[0].parse::<u32>().unwrap_or(0);
1592        let deletions = parts[1].parse::<u32>().unwrap_or(0);
1593        let raw_path = parts[2..].join("\t");
1594        let key = normalize_path_key(&raw_path);
1595        let path = display_path_map
1596            .get(&key)
1597            .cloned()
1598            .unwrap_or_else(|| normalize_display_path(&raw_path));
1599        let status = status_map
1600            .get(&key)
1601            .cloned()
1602            .unwrap_or_else(|| "modified".to_string());
1603        files.push(FileChangeSummary {
1604            path,
1605            status,
1606            additions,
1607            deletions,
1608        });
1609    }
1610
1611    if files.is_empty() {
1612        for (key, status) in status_map {
1613            files.push(FileChangeSummary {
1614                path: display_path_map
1615                    .get(&key)
1616                    .cloned()
1617                    .unwrap_or_else(|| key.clone()),
1618                status,
1619                additions: 0,
1620                deletions: 0,
1621            });
1622        }
1623    }
1624
1625    files
1626}
1627
1628fn summarize_symbols(diff_text: &str) -> SymbolSummary {
1629    let mut current_file = String::new();
1630    let mut added_functions = BTreeSet::new();
1631    let mut removed_functions = BTreeSet::new();
1632    let mut added_types = BTreeSet::new();
1633    let mut removed_types = BTreeSet::new();
1634    let mut changed_functions = BTreeSet::new();
1635    let mut changed_types = BTreeSet::new();
1636    let mut hunk_contexts = BTreeSet::new();
1637
1638    for line in diff_text.lines() {
1639        if let Some(rest) = line.strip_prefix("+++ b/") {
1640            current_file = rest.trim().replace('\\', "/");
1641            continue;
1642        }
1643        if line.starts_with("@@") {
1644            if let Some(context) = parse_hunk_context(line) {
1645                if is_code_path(&current_file) {
1646                    if let Some((symbol, kind)) = extract_context_symbol(&context) {
1647                        match kind {
1648                            SymbolKind::Function => {
1649                                changed_functions.insert(symbol);
1650                            }
1651                            SymbolKind::Type => {
1652                                changed_types.insert(symbol);
1653                            }
1654                        }
1655                    }
1656                }
1657                hunk_contexts.insert(context);
1658            }
1659            continue;
1660        }
1661
1662        if current_file.is_empty() || line.starts_with("+++") || line.starts_with("---") {
1663            continue;
1664        }
1665
1666        if let Some(source) = line.strip_prefix('+') {
1667            if let Some(name) = extract_function_name(source) {
1668                added_functions.insert(format!("{} ({})", name, current_file));
1669            }
1670            if let Some(name) = extract_type_name(source) {
1671                added_types.insert(format!("{} ({})", name, current_file));
1672            }
1673        } else if let Some(source) = line.strip_prefix('-') {
1674            if let Some(name) = extract_function_name(source) {
1675                removed_functions.insert(format!("{} ({})", name, current_file));
1676            }
1677            if let Some(name) = extract_type_name(source) {
1678                removed_types.insert(format!("{} ({})", name, current_file));
1679            }
1680        }
1681    }
1682
1683    let changed_functions_from_dupes = added_functions
1684        .intersection(&removed_functions)
1685        .cloned()
1686        .collect::<BTreeSet<_>>();
1687    let changed_types_from_dupes = added_types
1688        .intersection(&removed_types)
1689        .cloned()
1690        .collect::<BTreeSet<_>>();
1691
1692    for entry in &changed_functions_from_dupes {
1693        changed_functions.insert(entry.clone());
1694    }
1695    for entry in &changed_types_from_dupes {
1696        changed_types.insert(entry.clone());
1697    }
1698
1699    let new_functions = added_functions
1700        .difference(&changed_functions_from_dupes)
1701        .cloned()
1702        .collect::<Vec<_>>();
1703    let removed_functions = removed_functions
1704        .difference(&changed_functions_from_dupes)
1705        .cloned()
1706        .collect::<Vec<_>>();
1707    let new_types = added_types
1708        .difference(&changed_types_from_dupes)
1709        .cloned()
1710        .collect::<Vec<_>>();
1711    let removed_types = removed_types
1712        .difference(&changed_types_from_dupes)
1713        .cloned()
1714        .collect::<Vec<_>>();
1715
1716    SymbolSummary {
1717        new_functions,
1718        changed_functions: changed_functions.into_iter().collect(),
1719        removed_functions,
1720        new_types,
1721        changed_types: changed_types.into_iter().collect(),
1722        removed_types,
1723        hunk_contexts: hunk_contexts.into_iter().collect(),
1724    }
1725}
1726
1727fn parse_hunk_context(line: &str) -> Option<String> {
1728    let parts = line.split("@@").collect::<Vec<_>>();
1729    if parts.len() < 3 {
1730        return None;
1731    }
1732    let context = parts[2].trim();
1733    if context.is_empty() {
1734        None
1735    } else {
1736        Some(context.to_string())
1737    }
1738}
1739
1740fn extract_context_symbol(context: &str) -> Option<(String, SymbolKind)> {
1741    let trimmed = context.trim();
1742    if trimmed.is_empty() {
1743        return None;
1744    }
1745
1746    for capture in [
1747        RUST_FUNCTION_RE.captures(trimmed),
1748        JS_FUNCTION_RE.captures(trimmed),
1749    ]
1750    .into_iter()
1751    .flatten()
1752    {
1753        if let Some(name) = capture.get(1) {
1754            let symbol = name.as_str().to_string();
1755            if is_noise_symbol(&symbol) {
1756                return None;
1757            }
1758            return Some((symbol, SymbolKind::Function));
1759        }
1760    }
1761
1762    if let Some(capture) = JS_ARROW_RE.captures(trimmed) {
1763        if let Some(name) = capture.get(1) {
1764            let symbol = name.as_str().to_string();
1765            if is_noise_symbol(&symbol) {
1766                return None;
1767            }
1768            return Some((symbol, SymbolKind::Function));
1769        }
1770    }
1771
1772    for capture in [RUST_TYPE_RE.captures(trimmed), TS_TYPE_RE.captures(trimmed)]
1773        .into_iter()
1774        .flatten()
1775    {
1776        if let Some(name) = capture.get(2) {
1777            let symbol = name.as_str().to_string();
1778            if is_noise_symbol(&symbol) {
1779                return None;
1780            }
1781            return Some((symbol, SymbolKind::Type));
1782        }
1783    }
1784
1785    if let Some(capture) = METHOD_CONTEXT_RE.captures(trimmed) {
1786        if let Some(name) = capture.get(1) {
1787            let symbol = name.as_str().to_string();
1788            if is_noise_symbol(&symbol) {
1789                return None;
1790            }
1791            return Some((symbol, SymbolKind::Function));
1792        }
1793    }
1794
1795    None
1796}
1797
1798fn extract_function_name(source: &str) -> Option<String> {
1799    for capture in [
1800        RUST_FUNCTION_RE.captures(source),
1801        JS_FUNCTION_RE.captures(source),
1802        JS_ARROW_RE.captures(source),
1803    ]
1804    .into_iter()
1805    .flatten()
1806    {
1807        if let Some(name) = capture.get(1) {
1808            return Some(name.as_str().to_string());
1809        }
1810    }
1811    None
1812}
1813
1814fn extract_type_name(source: &str) -> Option<String> {
1815    for capture in [RUST_TYPE_RE.captures(source), TS_TYPE_RE.captures(source)]
1816        .into_iter()
1817        .flatten()
1818    {
1819        if let Some(name) = capture.get(2) {
1820            return Some(name.as_str().to_string());
1821        }
1822    }
1823    None
1824}
1825
1826fn is_code_path(path: &str) -> bool {
1827    let normalized = path.to_ascii_lowercase();
1828    [
1829        ".rs", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".java", ".kt", ".swift",
1830    ]
1831    .iter()
1832    .any(|extension| normalized.ends_with(extension))
1833}
1834
1835fn is_noise_symbol(symbol: &str) -> bool {
1836    matches!(
1837        symbol,
1838        "fn" | "pub"
1839            | "impl"
1840            | "mod"
1841            | "use"
1842            | "struct"
1843            | "enum"
1844            | "trait"
1845            | "type"
1846            | "class"
1847            | "interface"
1848            | "const"
1849            | "let"
1850            | "var"
1851            | "async"
1852            | "await"
1853            | "match"
1854            | "if"
1855            | "else"
1856            | "for"
1857            | "while"
1858            | "loop"
1859    )
1860}
1861
1862fn sanitize_commit_type(raw: &str) -> Option<String> {
1863    let normalized = raw.trim().to_ascii_lowercase();
1864    if CONVENTIONAL_TYPES.contains(&normalized.as_str()) {
1865        Some(normalized)
1866    } else {
1867        None
1868    }
1869}
1870
1871fn sanitize_scope(raw: Option<&str>) -> Option<String> {
1872    let raw = raw?.trim();
1873    if raw.is_empty() {
1874        return None;
1875    }
1876    let sanitized = raw
1877        .chars()
1878        .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/'))
1879        .collect::<String>()
1880        .to_ascii_lowercase();
1881    if sanitized.is_empty() {
1882        None
1883    } else {
1884        Some(sanitized)
1885    }
1886}
1887
1888fn sanitize_description(raw: &str) -> Option<String> {
1889    let trimmed = raw.trim().trim_matches('"').trim();
1890    if trimmed.is_empty() {
1891        return None;
1892    }
1893    let normalized = trimmed.trim_end_matches('.').trim();
1894    if normalized.is_empty() {
1895        None
1896    } else {
1897        Some(normalized.to_string())
1898    }
1899}
1900
1901fn sanitize_footer_value(raw: &str) -> Option<String> {
1902    let trimmed = raw.trim();
1903    if trimmed.is_empty() {
1904        None
1905    } else {
1906        Some(trimmed.to_string())
1907    }
1908}
1909
1910fn interesting_file_stem(path: &str) -> Option<String> {
1911    let stem = Path::new(path)
1912        .file_stem()
1913        .and_then(|value| value.to_str())
1914        .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase())?;
1915    if stem.is_empty()
1916        || is_weak_focus_label(&stem)
1917        || matches!(
1918            stem.as_str(),
1919            "mod" | "lib" | "main" | "readme" | "index" | "commands" | "router"
1920        )
1921    {
1922        None
1923    } else {
1924        Some(stem)
1925    }
1926}
1927
1928fn normalize_name_status(raw: &str) -> String {
1929    let code = raw.chars().next().unwrap_or('M');
1930    match code {
1931        'A' => "added",
1932        'D' => "deleted",
1933        'R' => "renamed",
1934        'C' => "copied",
1935        'T' => "typechange",
1936        'U' => "unmerged",
1937        'M' => "modified",
1938        _ => "modified",
1939    }
1940    .to_string()
1941}
1942
1943fn normalize_display_path(raw: &str) -> String {
1944    raw.trim().replace('\\', "/")
1945}
1946
1947fn normalize_path_key(raw: &str) -> String {
1948    let cleaned = raw.trim().replace(['{', '}'], "").replace('\\', "/");
1949    if let Some((_, tail)) = cleaned.split_once("=>") {
1950        tail.trim().to_string()
1951    } else {
1952        cleaned
1953    }
1954}
1955
1956fn repo_name(path: &Path) -> String {
1957    path.file_name()
1958        .and_then(|value| value.to_str())
1959        .filter(|value| !value.trim().is_empty())
1960        .unwrap_or("repository")
1961        .to_string()
1962}
1963
1964fn truncate_for_prompt(text: &str, limit: usize) -> String {
1965    if text.chars().count() <= limit {
1966        return text.to_string();
1967    }
1968
1969    let truncated = text.chars().take(limit).collect::<String>();
1970    format!(
1971        "{}\n\n[diff truncated after {} characters]",
1972        truncated, limit
1973    )
1974}
1975
1976async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
1977    git_output_with_env(project_root, args, &[]).await
1978}
1979
1980async fn git_output_with_env(
1981    project_root: &Path,
1982    args: &[&str],
1983    envs: &[(&str, &std::ffi::OsStr)],
1984) -> Result<String, String> {
1985    let mut command = Command::new("git");
1986    command.current_dir(project_root).args(args);
1987    for (key, value) in envs {
1988        command.env(key, value);
1989    }
1990
1991    let output = command
1992        .output()
1993        .await
1994        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
1995
1996    if !output.status.success() {
1997        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1998        if stderr.is_empty() {
1999            return Err(format!(
2000                "`git {}` failed with status {}",
2001                args.join(" "),
2002                output.status
2003            ));
2004        }
2005        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
2006    }
2007
2008    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
2009}
2010
2011struct TemporaryIndex {
2012    path: PathBuf,
2013}
2014
2015impl Drop for TemporaryIndex {
2016    fn drop(&mut self) {
2017        let _ = fs::remove_file(&self.path);
2018    }
2019}
2020
2021#[cfg(test)]
2022mod tests {
2023    use super::{
2024        build_heuristic_body, build_heuristic_commit, describe_focus_area, infer_commit_type,
2025        infer_description, is_terminal_session_path, parse_ai_commit_payload,
2026        render_clean_worktree_message, render_commit_message, render_terminal_session_block_message,
2027        sanitize_scope, summarize_focus_areas, summarize_top_files, summarize_symbols,
2028        terminal_session_paths, BranchSyncStatus,
2029        ConventionalCommit, FileChangeSummary, RepoContext, StatusEntry, WorktreeAnalysis,
2030    };
2031    use std::path::PathBuf;
2032
2033    fn server_refactor_analysis() -> WorktreeAnalysis {
2034        WorktreeAnalysis {
2035            repo_root: PathBuf::from("C:/repo"),
2036            repo_name: "xbp".to_string(),
2037            branch: Some("feature/refine-server".to_string()),
2038            status_entries: vec![StatusEntry {
2039                code: "M".to_string(),
2040                path: "src/server.ts".to_string(),
2041            }],
2042            files: vec![
2043                FileChangeSummary {
2044                    path: "src/server.ts".to_string(),
2045                    status: "modified".to_string(),
2046                    additions: 80,
2047                    deletions: 24,
2048                },
2049                FileChangeSummary {
2050                    path: "src/http-app.ts".to_string(),
2051                    status: "modified".to_string(),
2052                    additions: 42,
2053                    deletions: 11,
2054                },
2055                FileChangeSummary {
2056                    path: "src/worker/types.ts".to_string(),
2057                    status: "modified".to_string(),
2058                    additions: 12,
2059                    deletions: 3,
2060                },
2061            ],
2062            total_additions: 134,
2063            total_deletions: 38,
2064            diff_text: String::new(),
2065            new_functions: vec![
2066                "buildEnvFromProcess (src/server.ts)".to_string(),
2067                "handleNodeRequest (src/server.ts)".to_string(),
2068            ],
2069            changed_functions: vec!["handleHttpRequest (src/http-app.ts)".to_string()],
2070            removed_functions: Vec::new(),
2071            new_types: vec!["ExecutionContextLike (src/http-app.ts)".to_string()],
2072            changed_types: vec!["Env (src/worker/types.ts)".to_string()],
2073            removed_types: Vec::new(),
2074            hunk_contexts: vec!["async function handleHttpRequest(request: Request)".to_string()],
2075        }
2076    }
2077
2078    #[test]
2079    fn symbol_summary_tracks_new_and_changed_symbols() {
2080        let diff = r#"
2081diff --git a/crates/cli/src/commands/commit.rs b/crates/cli/src/commands/commit.rs
2082index 1111111..2222222 100644
2083--- a/crates/cli/src/commands/commit.rs
2084+++ b/crates/cli/src/commands/commit.rs
2085@@ -0,0 +1,3 @@
2086+pub struct CommitArgs {
2087+}
2088+pub async fn run_commit() {}
2089@@ -10,1 +12,1 @@ pub async fn old_name() {}
2090-pub async fn old_name() {}
2091+pub async fn old_name() {}
2092"#;
2093        let summary = summarize_symbols(diff);
2094        assert!(summary
2095            .new_functions
2096            .iter()
2097            .any(|item| item.contains("run_commit")));
2098        assert!(summary
2099            .new_types
2100            .iter()
2101            .any(|item| item.contains("CommitArgs")));
2102        assert!(summary
2103            .changed_functions
2104            .iter()
2105            .any(|item| item.contains("old_name")));
2106    }
2107
2108    #[test]
2109    fn ai_payload_respects_forced_scope() {
2110        let raw = r#"{
2111  "type": "feat",
2112  "scope": "wrong",
2113  "description": "add commit command",
2114  "body": ["Summarize the worktree."],
2115  "breaking_change": false,
2116  "footers": []
2117}"#;
2118
2119        let commit = parse_ai_commit_payload(raw, Some("cli")).expect("parse");
2120        assert_eq!(commit.scope.as_deref(), Some("cli"));
2121        assert_eq!(commit.commit_type, "feat");
2122    }
2123
2124    #[test]
2125    fn renders_breaking_change_footer_once() {
2126        let rendered = render_commit_message(&ConventionalCommit {
2127            commit_type: "feat".to_string(),
2128            scope: Some("cli".to_string()),
2129            description: "add commit command".to_string(),
2130            body: vec!["Summarize the worktree.".to_string()],
2131            breaking_change: Some("existing automation must call xbp commit".to_string()),
2132            footers: Vec::new(),
2133        });
2134
2135        assert!(rendered.contains("feat(cli)!: add commit command"));
2136        assert!(rendered.contains("BREAKING CHANGE: existing automation must call xbp commit"));
2137    }
2138
2139    #[test]
2140    fn infers_feat_for_new_symbols() {
2141        let analysis = WorktreeAnalysis {
2142            repo_root: PathBuf::from("C:/repo"),
2143            repo_name: "xbp".to_string(),
2144            branch: Some("main".to_string()),
2145            status_entries: vec![StatusEntry {
2146                code: "??".to_string(),
2147                path: "crates/cli/src/commands/commit.rs".to_string(),
2148            }],
2149            files: vec![FileChangeSummary {
2150                path: "crates/cli/src/commands/commit.rs".to_string(),
2151                status: "added".to_string(),
2152                additions: 120,
2153                deletions: 0,
2154            }],
2155            total_additions: 120,
2156            total_deletions: 0,
2157            diff_text: String::new(),
2158            new_functions: vec!["run_commit (crates/cli/src/commands/commit.rs)".to_string()],
2159            changed_functions: Vec::new(),
2160            removed_functions: Vec::new(),
2161            new_types: vec!["CommitArgs (crates/cli/src/commands/commit.rs)".to_string()],
2162            changed_types: Vec::new(),
2163            removed_types: Vec::new(),
2164            hunk_contexts: Vec::new(),
2165        };
2166
2167        assert_eq!(infer_commit_type(&analysis), "feat");
2168    }
2169
2170    #[test]
2171    fn infers_refactor_for_new_symbols_inside_existing_modules() {
2172        let analysis = server_refactor_analysis();
2173        assert_eq!(infer_commit_type(&analysis), "refactor");
2174    }
2175
2176    #[test]
2177    fn description_prefers_focus_area_and_behavior_hint() {
2178        let analysis = server_refactor_analysis();
2179        assert_eq!(
2180            infer_description(&analysis, "refactor", None),
2181            "refine server request handling"
2182        );
2183    }
2184
2185    #[test]
2186    fn heuristic_body_reads_like_a_summary() {
2187        let analysis = server_refactor_analysis();
2188        let body = build_heuristic_body(&analysis);
2189        assert_eq!(body.len(), 2);
2190        assert!(body[0].contains("server"));
2191        assert!(body[1].contains("adds buildEnvFromProcess"));
2192        assert!(body[1].contains("src/ (2 files"));
2193    }
2194
2195    #[test]
2196    fn clean_worktree_message_calls_out_pending_pushes() {
2197        let repo = RepoContext {
2198            repo_root: PathBuf::from("C:/repo"),
2199            repo_name: "xbp".to_string(),
2200            branch: Some("main".to_string()),
2201        };
2202        let message = render_clean_worktree_message(
2203            &repo,
2204            &BranchSyncStatus {
2205                upstream: Some("origin/main".to_string()),
2206                ahead: 2,
2207                behind: 0,
2208            },
2209        );
2210
2211        assert!(message.contains("Nothing to commit."));
2212        assert!(message.contains("2 commit(s) waiting to push"));
2213        assert!(message.contains("origin/main"));
2214    }
2215
2216    #[test]
2217    fn clean_worktree_message_calls_out_synced_branch() {
2218        let repo = RepoContext {
2219            repo_root: PathBuf::from("C:/repo"),
2220            repo_name: "xbp".to_string(),
2221            branch: Some("main".to_string()),
2222        };
2223        let message = render_clean_worktree_message(
2224            &repo,
2225            &BranchSyncStatus {
2226                upstream: Some("origin/main".to_string()),
2227                ahead: 0,
2228                behind: 0,
2229            },
2230        );
2231
2232        assert!(message.contains("Nothing to commit."));
2233        assert!(message.contains("already in sync"));
2234    }
2235
2236    #[test]
2237    fn scope_sanitizer_keeps_valid_tokens() {
2238        assert_eq!(
2239            sanitize_scope(Some("CLI/tools")),
2240            Some("cli/tools".to_string())
2241        );
2242        assert_eq!(sanitize_scope(Some("  ")), None);
2243    }
2244
2245    #[test]
2246    fn terminal_session_paths_are_detected_and_blocked() {
2247        let analysis = WorktreeAnalysis {
2248            repo_root: PathBuf::from("C:/repo"),
2249            repo_name: "xbp".to_string(),
2250            branch: Some("main".to_string()),
2251            status_entries: Vec::new(),
2252            files: vec![
2253                FileChangeSummary {
2254                    path: "terminals/5.txt".to_string(),
2255                    status: "added".to_string(),
2256                    additions: 388_613,
2257                    deletions: 0,
2258                },
2259                FileChangeSummary {
2260                    path: "terminals/.next-id".to_string(),
2261                    status: "modified".to_string(),
2262                    additions: 1,
2263                    deletions: 1,
2264                },
2265            ],
2266            total_additions: 388_614,
2267            total_deletions: 1,
2268            diff_text: String::new(),
2269            new_functions: Vec::new(),
2270            changed_functions: Vec::new(),
2271            removed_functions: Vec::new(),
2272            new_types: Vec::new(),
2273            changed_types: Vec::new(),
2274            removed_types: Vec::new(),
2275            hunk_contexts: Vec::new(),
2276        };
2277
2278        assert!(is_terminal_session_path("terminals/5.txt"));
2279        assert!(!is_terminal_session_path("crates/cli/src/commands/commit.rs"));
2280        assert_eq!(
2281            terminal_session_paths(&analysis),
2282            vec!["terminals/5.txt".to_string(), "terminals/.next-id".to_string()]
2283        );
2284        assert!(render_terminal_session_block_message(&terminal_session_paths(&analysis))
2285            .contains("Refusing to commit IDE terminal session dumps"));
2286        assert_eq!(infer_commit_type(&analysis), "chore");
2287        assert_eq!(
2288            infer_description(&analysis, "chore", None),
2289            "ignore ide terminal session logs"
2290        );
2291
2292        let heuristic = build_heuristic_commit(&analysis, None);
2293        assert_eq!(heuristic.commit_type, "chore");
2294        assert_eq!(heuristic.description, "ignore ide terminal session logs");
2295        assert!(!render_commit_message(&heuristic).contains("flow"));
2296    }
2297
2298    #[test]
2299    fn focus_area_groups_terminal_session_files() {
2300        assert_eq!(
2301            describe_focus_area("terminals/5.txt"),
2302            Some("terminals".to_string())
2303        );
2304        assert_eq!(
2305            describe_focus_area("terminals/.next-id"),
2306            Some("terminals".to_string())
2307        );
2308        assert_eq!(
2309            describe_focus_area(".gitignore"),
2310            Some("gitignore".to_string())
2311        );
2312    }
2313
2314    #[test]
2315    fn summarize_focus_areas_collapses_terminal_logs() {
2316        let analysis = WorktreeAnalysis {
2317            repo_root: PathBuf::from("C:/repo"),
2318            repo_name: "xbp".to_string(),
2319            branch: Some("main".to_string()),
2320            status_entries: Vec::new(),
2321            files: vec![
2322                FileChangeSummary {
2323                    path: "terminals/5.txt".to_string(),
2324                    status: "deleted".to_string(),
2325                    additions: 0,
2326                    deletions: 388_613,
2327                },
2328                FileChangeSummary {
2329                    path: "terminals/3.txt".to_string(),
2330                    status: "deleted".to_string(),
2331                    additions: 0,
2332                    deletions: 439,
2333                },
2334                FileChangeSummary {
2335                    path: "terminals/.next-id".to_string(),
2336                    status: "modified".to_string(),
2337                    additions: 0,
2338                    deletions: 1,
2339                },
2340                FileChangeSummary {
2341                    path: ".gitignore".to_string(),
2342                    status: "modified".to_string(),
2343                    additions: 1,
2344                    deletions: 0,
2345                },
2346            ],
2347            total_additions: 1,
2348            total_deletions: 389_053,
2349            diff_text: String::new(),
2350            new_functions: Vec::new(),
2351            changed_functions: Vec::new(),
2352            removed_functions: Vec::new(),
2353            new_types: Vec::new(),
2354            changed_types: Vec::new(),
2355            removed_types: Vec::new(),
2356            hunk_contexts: Vec::new(),
2357        };
2358
2359        assert_eq!(
2360            summarize_focus_areas(&analysis, 4),
2361            vec!["terminals".to_string(), "gitignore".to_string()]
2362        );
2363        assert_eq!(
2364            summarize_top_files(&analysis, 4),
2365            vec![
2366                "terminals/ (3 files, +0 -389053)".to_string(),
2367                ".gitignore (+1 -0)".to_string(),
2368            ]
2369        );
2370    }
2371}