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