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