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::resolve_openrouter_api_key;
4use crate::openrouter::{complete_user_prompt, DEFAULT_MODEL};
5use crate::utils::command_exists;
6use colored::Colorize;
7use once_cell::sync::Lazy;
8use regex::Regex;
9use serde::Deserialize;
10use std::collections::{BTreeMap, BTreeSet};
11use std::env;
12use std::fs;
13use std::path::{Path, PathBuf};
14use tokio::process::Command;
15use uuid::Uuid;
16
17static RUST_FUNCTION_RE: Lazy<Regex> = Lazy::new(|| {
18    Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)")
19        .expect("valid rust function regex")
20});
21static JS_FUNCTION_RE: Lazy<Regex> = Lazy::new(|| {
22    Regex::new(
23        r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)",
24    )
25    .expect("valid js function regex")
26});
27static JS_ARROW_RE: Lazy<Regex> = Lazy::new(|| {
28    Regex::new(
29        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*=>",
30    )
31    .expect("valid js arrow regex")
32});
33static METHOD_CONTEXT_RE: Lazy<Regex> = Lazy::new(|| {
34    Regex::new(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(").expect("valid method context regex")
35});
36static RUST_TYPE_RE: Lazy<Regex> = Lazy::new(|| {
37    Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(struct|enum|trait|type)\s+([A-Za-z_][A-Za-z0-9_]*)")
38        .expect("valid rust type regex")
39});
40static TS_TYPE_RE: Lazy<Regex> = Lazy::new(|| {
41    Regex::new(r"^\s*(?:export\s+)?(interface|type|enum|class)\s+([A-Za-z_$][A-Za-z0-9_$]*)")
42        .expect("valid ts type regex")
43});
44
45const AI_DIFF_CHAR_LIMIT: usize = 12_000;
46const MAX_PROMPT_FILES: usize = 40;
47const MAX_PROMPT_SYMBOLS: usize = 12;
48const MAX_PROMPT_CONTEXTS: usize = 12;
49const MAX_PRINT_SYMBOLS: usize = 8;
50const CONVENTIONAL_TYPES: &[&str] = &[
51    "feat", "fix", "docs", "refactor", "chore", "test", "build", "ci", "perf", "style", "revert",
52];
53
54#[derive(Debug, Clone)]
55pub struct CommitArgs {
56    pub dry_run: bool,
57    pub no_ai: bool,
58    pub model: String,
59    pub scope: Option<String>,
60}
61
62#[derive(Debug, Clone)]
63struct WorktreeAnalysis {
64    repo_root: PathBuf,
65    repo_name: String,
66    branch: Option<String>,
67    status_entries: Vec<StatusEntry>,
68    files: Vec<FileChangeSummary>,
69    total_additions: u32,
70    total_deletions: u32,
71    diff_text: String,
72    new_functions: Vec<String>,
73    changed_functions: Vec<String>,
74    removed_functions: Vec<String>,
75    new_types: Vec<String>,
76    changed_types: Vec<String>,
77    removed_types: Vec<String>,
78    hunk_contexts: Vec<String>,
79}
80
81#[derive(Debug, Clone)]
82struct StatusEntry {
83    code: String,
84    path: String,
85}
86
87#[derive(Debug, Clone)]
88struct FileChangeSummary {
89    path: String,
90    status: String,
91    additions: u32,
92    deletions: u32,
93}
94
95#[derive(Debug, Clone)]
96struct SymbolSummary {
97    new_functions: Vec<String>,
98    changed_functions: Vec<String>,
99    removed_functions: Vec<String>,
100    new_types: Vec<String>,
101    changed_types: Vec<String>,
102    removed_types: Vec<String>,
103    hunk_contexts: Vec<String>,
104}
105
106#[derive(Debug, Clone, Copy, Eq, PartialEq)]
107enum SymbolKind {
108    Function,
109    Type,
110}
111
112#[derive(Debug, Clone)]
113struct CommitMessagePlan {
114    commit: ConventionalCommit,
115    generation_mode: GenerationMode,
116}
117
118#[derive(Debug, Clone)]
119struct ConventionalCommit {
120    commit_type: String,
121    scope: Option<String>,
122    description: String,
123    body: Vec<String>,
124    breaking_change: Option<String>,
125    footers: Vec<String>,
126}
127
128#[derive(Debug, Clone, Copy)]
129enum GenerationMode {
130    OpenRouter,
131    Heuristic,
132}
133
134#[derive(Debug, Deserialize)]
135struct AiCommitPayload {
136    #[serde(rename = "type")]
137    commit_type: String,
138    scope: Option<String>,
139    description: String,
140    #[serde(default)]
141    body: Vec<String>,
142    #[serde(default)]
143    breaking_change: bool,
144    breaking_description: Option<String>,
145    #[serde(default)]
146    footers: Vec<String>,
147}
148
149pub async fn run_commit(args: CommitArgs) -> Result<(), String> {
150    if !command_exists("git") {
151        return Err("Git is not installed on this machine.".to_string());
152    }
153
154    let invocation_dir =
155        env::current_dir().map_err(|e| format!("Failed to read current directory: {}", e))?;
156    let analysis = analyze_worktree(&invocation_dir).await?;
157    let auto_push_after_commit = resolve_auto_push_on_commit().await;
158    let commit_plan = generate_commit_plan(&analysis, &args).await;
159    let rendered_message = render_commit_message(&commit_plan.commit);
160
161    print_analysis_summary(&analysis, &commit_plan, &rendered_message);
162
163    if args.dry_run {
164        println!(
165            "\n{}",
166            "Dry run only. No git commit was created."
167                .bright_yellow()
168                .bold()
169        );
170        return Ok(());
171    }
172
173    stage_and_commit(&analysis.repo_root, &rendered_message).await?;
174
175    let short_sha = git_output(&analysis.repo_root, &["rev-parse", "--short", "HEAD"]).await?;
176    let full_sha = git_output(&analysis.repo_root, &["rev-parse", "HEAD"]).await?;
177
178    println!(
179        "\n{} {} {}",
180        "Committed".bright_green().bold(),
181        short_sha.bright_white().bold(),
182        format!("({})", full_sha).dimmed()
183    );
184
185    if auto_push_after_commit {
186        match push_current_branch(&analysis.repo_root).await {
187            Ok(Some(outcome)) => print_push_summary(&outcome),
188            Ok(None) => println!(
189                "{}",
190                "Push skipped because the current HEAD is detached.".bright_yellow()
191            ),
192            Err(error) => {
193                return Err(format!(
194                    "Created local commit {} but failed to push it: {}",
195                    short_sha, error
196                ));
197            }
198        }
199    } else {
200        println!(
201            "{}",
202            "Auto-push disabled by xbp config (`github.auto_push_on_commit: false`)."
203                .bright_yellow()
204        );
205    }
206
207    Ok(())
208}
209
210async fn resolve_auto_push_on_commit() -> bool {
211    load_xbp_config_with_root()
212        .await
213        .map(|(_, config)| config.auto_push_on_commit_enabled())
214        .unwrap_or(true)
215}
216
217async fn analyze_worktree(invocation_dir: &Path) -> Result<WorktreeAnalysis, String> {
218    let repo_root = PathBuf::from(
219        git_output(invocation_dir, &["rev-parse", "--show-toplevel"])
220            .await
221            .map_err(|_| "Current directory is not inside a git repository.".to_string())?,
222    );
223
224    let repo_name = repo_root
225        .file_name()
226        .and_then(|value| value.to_str())
227        .filter(|value| !value.trim().is_empty())
228        .unwrap_or("repository")
229        .to_string();
230
231    let branch = git_output(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
232        .await
233        .ok()
234        .filter(|value| !value.is_empty() && value != "HEAD");
235
236    let status_output = git_output(
237        &repo_root,
238        &["status", "--porcelain=v1", "--untracked-files=all"],
239    )
240    .await?;
241    let status_entries = parse_status_entries(&status_output);
242    if status_entries.is_empty() {
243        return Err("No worktree changes were found to commit.".to_string());
244    }
245
246    let temp_index = prepare_temporary_index(&repo_root).await?;
247    let temp_index_path = temp_index.path.clone();
248    let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
249
250    git_output_with_env(&repo_root, &["add", "--all"], &git_env).await?;
251    let name_status_output = git_output_with_env(
252        &repo_root,
253        &["diff", "--cached", "--name-status", "--find-renames"],
254        &git_env,
255    )
256    .await?;
257    let numstat_output = git_output_with_env(
258        &repo_root,
259        &["diff", "--cached", "--numstat", "--find-renames"],
260        &git_env,
261    )
262    .await?;
263    let diff_text = git_output_with_env(
264        &repo_root,
265        &[
266            "diff",
267            "--cached",
268            "--unified=0",
269            "--no-color",
270            "--no-ext-diff",
271            "--find-renames",
272        ],
273        &git_env,
274    )
275    .await?;
276
277    if diff_text.trim().is_empty() {
278        return Err("No staged diff could be produced from the current worktree.".to_string());
279    }
280
281    let files = merge_file_summaries(&name_status_output, &numstat_output);
282    let total_additions = files.iter().map(|entry| entry.additions).sum();
283    let total_deletions = files.iter().map(|entry| entry.deletions).sum();
284    let symbols = summarize_symbols(&diff_text);
285
286    Ok(WorktreeAnalysis {
287        repo_root,
288        repo_name,
289        branch,
290        status_entries,
291        files,
292        total_additions,
293        total_deletions,
294        diff_text,
295        new_functions: symbols.new_functions,
296        changed_functions: symbols.changed_functions,
297        removed_functions: symbols.removed_functions,
298        new_types: symbols.new_types,
299        changed_types: symbols.changed_types,
300        removed_types: symbols.removed_types,
301        hunk_contexts: symbols.hunk_contexts,
302    })
303}
304
305async fn prepare_temporary_index(repo_root: &Path) -> Result<TemporaryIndex, String> {
306    let real_index_path =
307        PathBuf::from(git_output(repo_root, &["rev-parse", "--git-path", "index"]).await?);
308    let temp_index_path = env::temp_dir().join(format!("xbp-commit-index-{}.tmp", Uuid::new_v4()));
309
310    if real_index_path.exists() {
311        fs::copy(&real_index_path, &temp_index_path).map_err(|e| {
312            format!(
313                "Failed to prepare temporary git index {}: {}",
314                temp_index_path.display(),
315                e
316            )
317        })?;
318    } else {
319        let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
320        let _ = git_output_with_env(repo_root, &["read-tree", "HEAD"], &git_env).await;
321    }
322
323    Ok(TemporaryIndex {
324        path: temp_index_path,
325    })
326}
327
328async fn generate_commit_plan(analysis: &WorktreeAnalysis, args: &CommitArgs) -> CommitMessagePlan {
329    let forced_scope = sanitize_scope(args.scope.as_deref());
330    let heuristic = build_heuristic_commit(analysis, forced_scope.clone());
331
332    if args.no_ai {
333        return CommitMessagePlan {
334            commit: heuristic,
335            generation_mode: GenerationMode::Heuristic,
336        };
337    }
338
339    let Some(api_key) = resolve_openrouter_api_key() else {
340        return CommitMessagePlan {
341            commit: heuristic,
342            generation_mode: GenerationMode::Heuristic,
343        };
344    };
345
346    let prompt = build_commit_prompt(analysis, forced_scope.as_deref(), &heuristic);
347    let model = if args.model.trim().is_empty() {
348        DEFAULT_MODEL
349    } else {
350        args.model.trim()
351    };
352
353    let ai_message = complete_user_prompt(&api_key, model, &prompt, Some("XBP Commit Generator"))
354        .await
355        .and_then(|raw| parse_ai_commit_payload(&raw, forced_scope.as_deref()));
356
357    if let Some(commit) = ai_message {
358        CommitMessagePlan {
359            commit,
360            generation_mode: GenerationMode::OpenRouter,
361        }
362    } else {
363        CommitMessagePlan {
364            commit: heuristic,
365            generation_mode: GenerationMode::Heuristic,
366        }
367    }
368}
369
370fn build_commit_prompt(
371    analysis: &WorktreeAnalysis,
372    forced_scope: Option<&str>,
373    heuristic: &ConventionalCommit,
374) -> String {
375    let mut lines = Vec::new();
376    lines.push("You are generating one git commit message for the current worktree.".to_string());
377    lines.push(String::new());
378    lines.push("Return strict JSON only. No markdown, no code fences, no explanation.".to_string());
379    lines.push(String::new());
380    lines.push("Schema:".to_string());
381    lines.push("{".to_string());
382    lines.push(
383        r#"  "type": "feat|fix|docs|refactor|chore|test|build|ci|perf|style|revert","#.to_string(),
384    );
385    lines.push(r#"  "scope": "short lowercase scope or null","#.to_string());
386    lines.push(
387        r#"  "description": "imperative summary under 72 chars, no trailing period","#.to_string(),
388    );
389    lines.push(r#"  "body": ["optional paragraph", "optional paragraph"],"#.to_string());
390    lines.push(r#"  "breaking_change": false,"#.to_string());
391    lines.push(
392        r#"  "breaking_description": "required only when breaking_change=true","#.to_string(),
393    );
394    lines.push(r#"  "footers": ["optional footer line"]"#.to_string());
395    lines.push("}".to_string());
396    lines.push(String::new());
397    lines.push("Rules:".to_string());
398    lines.push("- Follow Conventional Commits 1.0.0 exactly.".to_string());
399    lines.push("- Prefer feat for new capabilities, fix for bug repair, docs for docs-only changes, refactor for internal restructuring without behavior change, and ci/build/test/perf/style/chore when clearly better.".to_string());
400    lines.push("- Use a scope only when it is clearly anchored in the codebase.".to_string());
401    lines.push(
402        "- The description must be specific to the changed behavior or module, not generic."
403            .to_string(),
404    );
405    lines.push("- Mention a breaking change only when the diff clearly changes a public or operator-facing contract.".to_string());
406    lines.push("- Use the file list, symbol list, and diff excerpt. Do not invent files, functions, types, or breaking changes.".to_string());
407    lines.push(String::new());
408    lines.push(format!("Repository: {}", analysis.repo_name));
409    if let Some(branch) = &analysis.branch {
410        lines.push(format!("Branch: {}", branch));
411    }
412    if let Some(scope) = forced_scope {
413        lines.push(format!("Forced scope: {}", scope));
414    } else if let Some(scope) = heuristic.scope.as_deref() {
415        lines.push(format!("Suggested scope: {}", scope));
416    }
417    lines.push(format!(
418        "Heuristic fallback subject: {}",
419        render_commit_subject(heuristic)
420    ));
421    lines.push(String::new());
422    lines.push("Worktree stats:".to_string());
423    lines.push(format!("- files changed: {}", analysis.files.len()));
424    lines.push(format!(
425        "- lines: +{} -{}",
426        analysis.total_additions, analysis.total_deletions
427    ));
428    if !analysis.status_entries.is_empty() {
429        lines.push("- worktree statuses:".to_string());
430        for entry in analysis.status_entries.iter().take(MAX_PROMPT_FILES) {
431            lines.push(format!("  - {} {}", entry.code, entry.path));
432        }
433    }
434    if !analysis.files.is_empty() {
435        lines.push("- file diffs:".to_string());
436        for file in analysis.files.iter().take(MAX_PROMPT_FILES) {
437            lines.push(format!(
438                "  - [{}] {} (+{} -{})",
439                file.status, file.path, file.additions, file.deletions
440            ));
441        }
442    }
443    if !analysis.new_functions.is_empty() {
444        lines.push(format!(
445            "- new functions: {}",
446            analysis
447                .new_functions
448                .iter()
449                .take(MAX_PROMPT_SYMBOLS)
450                .cloned()
451                .collect::<Vec<_>>()
452                .join(", ")
453        ));
454    }
455    if !analysis.changed_functions.is_empty() {
456        lines.push(format!(
457            "- changed functions: {}",
458            analysis
459                .changed_functions
460                .iter()
461                .take(MAX_PROMPT_SYMBOLS)
462                .cloned()
463                .collect::<Vec<_>>()
464                .join(", ")
465        ));
466    }
467    if !analysis.new_types.is_empty() {
468        lines.push(format!(
469            "- new types: {}",
470            analysis
471                .new_types
472                .iter()
473                .take(MAX_PROMPT_SYMBOLS)
474                .cloned()
475                .collect::<Vec<_>>()
476                .join(", ")
477        ));
478    }
479    if !analysis.changed_types.is_empty() {
480        lines.push(format!(
481            "- changed types: {}",
482            analysis
483                .changed_types
484                .iter()
485                .take(MAX_PROMPT_SYMBOLS)
486                .cloned()
487                .collect::<Vec<_>>()
488                .join(", ")
489        ));
490    }
491    if !analysis.hunk_contexts.is_empty() {
492        lines.push(format!(
493            "- hunk contexts: {}",
494            analysis
495                .hunk_contexts
496                .iter()
497                .take(MAX_PROMPT_CONTEXTS)
498                .cloned()
499                .collect::<Vec<_>>()
500                .join(", ")
501        ));
502    }
503    lines.push(String::new());
504    lines.push("Diff excerpt:".to_string());
505    lines.push(truncate_for_prompt(&analysis.diff_text, AI_DIFF_CHAR_LIMIT));
506    lines.join("\n")
507}
508
509fn parse_ai_commit_payload(raw: &str, forced_scope: Option<&str>) -> Option<ConventionalCommit> {
510    let payload: AiCommitPayload = serde_json::from_str(raw).ok()?;
511    let commit_type = sanitize_commit_type(&payload.commit_type)?;
512    let description = sanitize_description(&payload.description)?;
513    let mut body = payload
514        .body
515        .into_iter()
516        .map(|entry| entry.trim().to_string())
517        .filter(|entry| !entry.is_empty())
518        .collect::<Vec<_>>();
519    if body.len() > 3 {
520        body.truncate(3);
521    }
522
523    let breaking_change = if payload.breaking_change {
524        payload
525            .breaking_description
526            .as_deref()
527            .and_then(sanitize_footer_value)
528    } else {
529        None
530    };
531
532    let mut footers = payload
533        .footers
534        .into_iter()
535        .map(|entry| entry.trim().to_string())
536        .filter(|entry| !entry.is_empty())
537        .collect::<Vec<_>>();
538    if breaking_change.is_some()
539        && !footers
540            .iter()
541            .any(|entry| entry.starts_with("BREAKING CHANGE:"))
542    {
543        if let Some(breaking) = &breaking_change {
544            footers.push(format!("BREAKING CHANGE: {}", breaking));
545        }
546    }
547
548    Some(ConventionalCommit {
549        commit_type,
550        scope: forced_scope
551            .and_then(|scope| sanitize_scope(Some(scope)))
552            .or_else(|| sanitize_scope(payload.scope.as_deref())),
553        description,
554        body,
555        breaking_change,
556        footers,
557    })
558}
559
560fn build_heuristic_commit(
561    analysis: &WorktreeAnalysis,
562    forced_scope: Option<String>,
563) -> ConventionalCommit {
564    let commit_type = infer_commit_type(analysis).to_string();
565    let scope = forced_scope.or_else(|| infer_scope(analysis));
566    let description = infer_description(analysis, &commit_type, scope.as_deref());
567    let body = build_heuristic_body(analysis);
568
569    ConventionalCommit {
570        commit_type,
571        scope,
572        description,
573        body,
574        breaking_change: None,
575        footers: Vec::new(),
576    }
577}
578
579fn infer_commit_type(analysis: &WorktreeAnalysis) -> &'static str {
580    let lowered_paths = analysis
581        .files
582        .iter()
583        .map(|file| file.path.to_ascii_lowercase())
584        .collect::<Vec<_>>();
585
586    let docs_only = !lowered_paths.is_empty()
587        && lowered_paths.iter().all(|path| {
588            path.ends_with(".md")
589                || path.ends_with(".mdx")
590                || path.ends_with(".txt")
591                || path.starts_with("docs/")
592        });
593    if docs_only {
594        return "docs";
595    }
596
597    let test_only = !lowered_paths.is_empty()
598        && lowered_paths.iter().all(|path| {
599            path.contains("/tests/")
600                || path.contains("\\tests\\")
601                || path.contains(".test.")
602                || path.contains(".spec.")
603        });
604    if test_only {
605        return "test";
606    }
607
608    let ci_only = !lowered_paths.is_empty()
609        && lowered_paths.iter().all(|path| {
610            path.starts_with(".github/")
611                || path.contains("workflow")
612                || path.ends_with(".yml")
613                || path.ends_with(".yaml")
614        });
615    if ci_only {
616        return "ci";
617    }
618
619    let build_only = !lowered_paths.is_empty()
620        && lowered_paths.iter().all(|path| {
621            path.ends_with("cargo.toml")
622                || path.ends_with("cargo.lock")
623                || path.ends_with("package.json")
624                || path.ends_with("pnpm-lock.yaml")
625                || path.ends_with("package-lock.json")
626                || path.ends_with("wrangler.toml")
627                || path.ends_with(".nix")
628        });
629    if build_only {
630        return "build";
631    }
632
633    let has_new_files = analysis.files.iter().any(|file| file.status == "added");
634    let has_new_symbols = !analysis.new_functions.is_empty() || !analysis.new_types.is_empty();
635    if has_new_files || has_new_symbols {
636        return "feat";
637    }
638
639    if !analysis.changed_functions.is_empty()
640        || !analysis.changed_types.is_empty()
641        || analysis.total_additions >= analysis.total_deletions
642    {
643        return "fix";
644    }
645
646    "chore"
647}
648
649fn infer_scope(analysis: &WorktreeAnalysis) -> Option<String> {
650    let mut scopes = BTreeSet::new();
651
652    for file in &analysis.files {
653        let path = file.path.replace('\\', "/");
654        let inferred = if path.starts_with("crates/cli/") {
655            Some("cli")
656        } else if path.starts_with("crates/api/") {
657            Some("api")
658        } else if path.starts_with("crates/github/") {
659            Some("github")
660        } else if path.starts_with("crates/runtime/") {
661            Some("runtime")
662        } else if path.starts_with("apps/web/") {
663            Some("web")
664        } else if path.starts_with("docs/") {
665            Some("docs")
666        } else if path.starts_with(".github/") {
667            Some("ci")
668        } else {
669            None
670        };
671
672        if let Some(value) = inferred {
673            scopes.insert(value.to_string());
674        }
675    }
676
677    if scopes.len() == 1 {
678        scopes.into_iter().next()
679    } else if scopes.is_empty() {
680        None
681    } else if scopes.contains("cli") {
682        Some("cli".to_string())
683    } else {
684        None
685    }
686}
687
688fn infer_description(
689    analysis: &WorktreeAnalysis,
690    commit_type: &str,
691    scope: Option<&str>,
692) -> String {
693    let interesting_names = analysis
694        .files
695        .iter()
696        .filter_map(|file| interesting_file_stem(&file.path))
697        .collect::<Vec<_>>();
698
699    if interesting_names.iter().any(|name| name == "commit") {
700        return match scope {
701            Some("cli") => "add worktree commit generator".to_string(),
702            _ => "add conventional commit generator".to_string(),
703        };
704    }
705
706    if commit_type == "docs" {
707        if let Some(name) = interesting_names.first() {
708            return format!("document {}", name.replace('_', "-"));
709        }
710        return "update command documentation".to_string();
711    }
712
713    if let Some(name) = interesting_names.first() {
714        return match commit_type {
715            "feat" => format!("add {}", name.replace('_', "-")),
716            "fix" => format!("improve {}", name.replace('_', "-")),
717            "refactor" => format!("refactor {}", name.replace('_', "-")),
718            "test" => format!("cover {}", name.replace('_', "-")),
719            "ci" => format!("update {}", name.replace('_', "-")),
720            "build" => format!("adjust {}", name.replace('_', "-")),
721            _ => format!("update {}", name.replace('_', "-")),
722        };
723    }
724
725    match commit_type {
726        "feat" => "add worktree change support".to_string(),
727        "fix" => "improve worktree handling".to_string(),
728        "docs" => "update documentation".to_string(),
729        _ => "update repository changes".to_string(),
730    }
731}
732
733fn build_heuristic_body(analysis: &WorktreeAnalysis) -> Vec<String> {
734    let mut paragraphs = Vec::new();
735    paragraphs.push(format!(
736        "Touches {} file{} with +{} and -{} lines across the current worktree.",
737        analysis.files.len(),
738        if analysis.files.len() == 1 { "" } else { "s" },
739        analysis.total_additions,
740        analysis.total_deletions
741    ));
742
743    let mut detail_parts = Vec::new();
744    if !analysis.new_functions.is_empty() {
745        detail_parts.push(format!(
746            "new functions: {}",
747            analysis
748                .new_functions
749                .iter()
750                .take(MAX_PRINT_SYMBOLS)
751                .cloned()
752                .collect::<Vec<_>>()
753                .join(", ")
754        ));
755    }
756    if !analysis.changed_functions.is_empty() {
757        detail_parts.push(format!(
758            "changed functions: {}",
759            analysis
760                .changed_functions
761                .iter()
762                .take(MAX_PRINT_SYMBOLS)
763                .cloned()
764                .collect::<Vec<_>>()
765                .join(", ")
766        ));
767    }
768    if !analysis.removed_functions.is_empty() {
769        detail_parts.push(format!(
770            "removed functions: {}",
771            analysis
772                .removed_functions
773                .iter()
774                .take(MAX_PRINT_SYMBOLS)
775                .cloned()
776                .collect::<Vec<_>>()
777                .join(", ")
778        ));
779    }
780    if !analysis.new_types.is_empty() {
781        detail_parts.push(format!(
782            "new types: {}",
783            analysis
784                .new_types
785                .iter()
786                .take(MAX_PRINT_SYMBOLS)
787                .cloned()
788                .collect::<Vec<_>>()
789                .join(", ")
790        ));
791    }
792    if !analysis.changed_types.is_empty() {
793        detail_parts.push(format!(
794            "changed types: {}",
795            analysis
796                .changed_types
797                .iter()
798                .take(MAX_PRINT_SYMBOLS)
799                .cloned()
800                .collect::<Vec<_>>()
801                .join(", ")
802        ));
803    }
804    if !analysis.removed_types.is_empty() {
805        detail_parts.push(format!(
806            "removed types: {}",
807            analysis
808                .removed_types
809                .iter()
810                .take(MAX_PRINT_SYMBOLS)
811                .cloned()
812                .collect::<Vec<_>>()
813                .join(", ")
814        ));
815    }
816    if !detail_parts.is_empty() {
817        paragraphs.push(detail_parts.join("; "));
818    }
819
820    paragraphs
821}
822
823fn render_commit_message(commit: &ConventionalCommit) -> String {
824    let mut sections = vec![render_commit_subject(commit)];
825
826    if !commit.body.is_empty() {
827        sections.push(commit.body.join("\n\n"));
828    }
829
830    let mut footer_lines = commit
831        .footers
832        .iter()
833        .map(|entry| entry.trim().to_string())
834        .filter(|entry| !entry.is_empty())
835        .collect::<Vec<_>>();
836    if let Some(breaking_change) = &commit.breaking_change {
837        if !footer_lines
838            .iter()
839            .any(|entry| entry.starts_with("BREAKING CHANGE:"))
840        {
841            footer_lines.push(format!("BREAKING CHANGE: {}", breaking_change));
842        }
843    }
844    if !footer_lines.is_empty() {
845        sections.push(footer_lines.join("\n"));
846    }
847
848    sections.join("\n\n")
849}
850
851fn render_commit_subject(commit: &ConventionalCommit) -> String {
852    let scope = commit
853        .scope
854        .as_deref()
855        .map(|value| format!("({})", value))
856        .unwrap_or_default();
857    let bang = if commit.breaking_change.is_some() {
858        "!"
859    } else {
860        ""
861    };
862    format!(
863        "{}{}{}: {}",
864        commit.commit_type, scope, bang, commit.description
865    )
866}
867
868fn print_analysis_summary(
869    analysis: &WorktreeAnalysis,
870    commit_plan: &CommitMessagePlan,
871    rendered_message: &str,
872) {
873    let branch = analysis
874        .branch
875        .as_deref()
876        .map(|value| format!(" on {}", value.bright_blue()))
877        .unwrap_or_default();
878    println!(
879        "{} {}{}",
880        "Commit".bright_green().bold(),
881        analysis.repo_name.bright_white().bold(),
882        branch
883    );
884    println!(
885        "  {} {}",
886        "Mode".bright_cyan().bold(),
887        match commit_plan.generation_mode {
888            GenerationMode::OpenRouter => "OpenRouter".bright_white(),
889            GenerationMode::Heuristic => "Heuristic fallback".bright_white(),
890        }
891    );
892    println!(
893        "  {} {} file{} (+{} -{})",
894        "Diff".bright_cyan().bold(),
895        analysis.files.len(),
896        if analysis.files.len() == 1 { "" } else { "s" },
897        analysis.total_additions,
898        analysis.total_deletions
899    );
900
901    if !analysis.new_functions.is_empty() {
902        println!(
903            "  {} {}",
904            "New fn".bright_cyan().bold(),
905            analysis
906                .new_functions
907                .iter()
908                .take(MAX_PRINT_SYMBOLS)
909                .cloned()
910                .collect::<Vec<_>>()
911                .join(", ")
912        );
913    }
914    if !analysis.changed_functions.is_empty() {
915        println!(
916            "  {} {}",
917            "Changed fn".bright_cyan().bold(),
918            analysis
919                .changed_functions
920                .iter()
921                .take(MAX_PRINT_SYMBOLS)
922                .cloned()
923                .collect::<Vec<_>>()
924                .join(", ")
925        );
926    }
927    if !analysis.new_types.is_empty() {
928        println!(
929            "  {} {}",
930            "New types".bright_cyan().bold(),
931            analysis
932                .new_types
933                .iter()
934                .take(MAX_PRINT_SYMBOLS)
935                .cloned()
936                .collect::<Vec<_>>()
937                .join(", ")
938        );
939    }
940    if !analysis.changed_types.is_empty() {
941        println!(
942            "  {} {}",
943            "Changed types".bright_cyan().bold(),
944            analysis
945                .changed_types
946                .iter()
947                .take(MAX_PRINT_SYMBOLS)
948                .cloned()
949                .collect::<Vec<_>>()
950                .join(", ")
951        );
952    }
953    if !analysis.removed_functions.is_empty() {
954        println!(
955            "  {} {}",
956            "Removed fn".bright_cyan().bold(),
957            analysis
958                .removed_functions
959                .iter()
960                .take(MAX_PRINT_SYMBOLS)
961                .cloned()
962                .collect::<Vec<_>>()
963                .join(", ")
964        );
965    }
966    if !analysis.removed_types.is_empty() {
967        println!(
968            "  {} {}",
969            "Removed types".bright_cyan().bold(),
970            analysis
971                .removed_types
972                .iter()
973                .take(MAX_PRINT_SYMBOLS)
974                .cloned()
975                .collect::<Vec<_>>()
976                .join(", ")
977        );
978    }
979
980    println!("\n{}", "Generated message".bright_magenta().bold());
981    println!("{}", "─".repeat(72).bright_black());
982    println!("{}", rendered_message);
983    println!("{}", "─".repeat(72).bright_black());
984}
985
986async fn stage_and_commit(repo_root: &Path, message: &str) -> Result<(), String> {
987    git_output(repo_root, &["add", "--all"]).await?;
988
989    let message_path = env::temp_dir().join(format!("xbp-commit-message-{}.txt", Uuid::new_v4()));
990    fs::write(&message_path, message).map_err(|e| {
991        format!(
992            "Failed to write temporary commit message {}: {}",
993            message_path.display(),
994            e
995        )
996    })?;
997
998    let result = git_output(
999        repo_root,
1000        &["commit", "--file", &message_path.to_string_lossy()],
1001    )
1002    .await;
1003    let _ = fs::remove_file(&message_path);
1004    result.map(|_| ())
1005}
1006
1007fn parse_status_entries(output: &str) -> Vec<StatusEntry> {
1008    output
1009        .lines()
1010        .filter_map(|line| {
1011            if line.len() < 4 {
1012                return None;
1013            }
1014            let code = line[..2].trim().to_string();
1015            let path = line[3..].trim().replace('\\', "/");
1016            if path.is_empty() {
1017                None
1018            } else {
1019                Some(StatusEntry { code, path })
1020            }
1021        })
1022        .collect()
1023}
1024
1025fn merge_file_summaries(name_status: &str, numstat: &str) -> Vec<FileChangeSummary> {
1026    let mut status_map = BTreeMap::new();
1027    let mut display_path_map = BTreeMap::new();
1028
1029    for raw_line in name_status
1030        .lines()
1031        .map(str::trim)
1032        .filter(|line| !line.is_empty())
1033    {
1034        let parts = raw_line.split('\t').collect::<Vec<_>>();
1035        if parts.is_empty() {
1036            continue;
1037        }
1038        let status = normalize_name_status(parts[0]);
1039        match parts.as_slice() {
1040            [_, path] => {
1041                let key = normalize_path_key(path);
1042                display_path_map.insert(key.clone(), normalize_display_path(path));
1043                status_map.insert(key, status);
1044            }
1045            [_, old_path, new_path] => {
1046                let key = normalize_path_key(new_path);
1047                display_path_map.insert(
1048                    key.clone(),
1049                    format!(
1050                        "{} -> {}",
1051                        normalize_display_path(old_path),
1052                        normalize_display_path(new_path)
1053                    ),
1054                );
1055                status_map.insert(key, status);
1056            }
1057            _ => {}
1058        }
1059    }
1060
1061    let mut files = Vec::new();
1062    for raw_line in numstat
1063        .lines()
1064        .map(str::trim)
1065        .filter(|line| !line.is_empty())
1066    {
1067        let parts = raw_line.split('\t').collect::<Vec<_>>();
1068        if parts.len() < 3 {
1069            continue;
1070        }
1071        let additions = parts[0].parse::<u32>().unwrap_or(0);
1072        let deletions = parts[1].parse::<u32>().unwrap_or(0);
1073        let raw_path = parts[2..].join("\t");
1074        let key = normalize_path_key(&raw_path);
1075        let path = display_path_map
1076            .get(&key)
1077            .cloned()
1078            .unwrap_or_else(|| normalize_display_path(&raw_path));
1079        let status = status_map
1080            .get(&key)
1081            .cloned()
1082            .unwrap_or_else(|| "modified".to_string());
1083        files.push(FileChangeSummary {
1084            path,
1085            status,
1086            additions,
1087            deletions,
1088        });
1089    }
1090
1091    if files.is_empty() {
1092        for (key, status) in status_map {
1093            files.push(FileChangeSummary {
1094                path: display_path_map
1095                    .get(&key)
1096                    .cloned()
1097                    .unwrap_or_else(|| key.clone()),
1098                status,
1099                additions: 0,
1100                deletions: 0,
1101            });
1102        }
1103    }
1104
1105    files
1106}
1107
1108fn summarize_symbols(diff_text: &str) -> SymbolSummary {
1109    let mut current_file = String::new();
1110    let mut added_functions = BTreeSet::new();
1111    let mut removed_functions = BTreeSet::new();
1112    let mut added_types = BTreeSet::new();
1113    let mut removed_types = BTreeSet::new();
1114    let mut changed_functions = BTreeSet::new();
1115    let mut changed_types = BTreeSet::new();
1116    let mut hunk_contexts = BTreeSet::new();
1117
1118    for line in diff_text.lines() {
1119        if let Some(rest) = line.strip_prefix("+++ b/") {
1120            current_file = rest.trim().replace('\\', "/");
1121            continue;
1122        }
1123        if line.starts_with("@@") {
1124            if let Some(context) = parse_hunk_context(line) {
1125                if is_code_path(&current_file) {
1126                    if let Some((symbol, kind)) = extract_context_symbol(&context) {
1127                        match kind {
1128                            SymbolKind::Function => {
1129                                changed_functions.insert(symbol);
1130                            }
1131                            SymbolKind::Type => {
1132                                changed_types.insert(symbol);
1133                            }
1134                        }
1135                    }
1136                }
1137                hunk_contexts.insert(context);
1138            }
1139            continue;
1140        }
1141
1142        if current_file.is_empty() || line.starts_with("+++") || line.starts_with("---") {
1143            continue;
1144        }
1145
1146        if let Some(source) = line.strip_prefix('+') {
1147            if let Some(name) = extract_function_name(source) {
1148                added_functions.insert(format!("{} ({})", name, current_file));
1149            }
1150            if let Some(name) = extract_type_name(source) {
1151                added_types.insert(format!("{} ({})", name, current_file));
1152            }
1153        } else if let Some(source) = line.strip_prefix('-') {
1154            if let Some(name) = extract_function_name(source) {
1155                removed_functions.insert(format!("{} ({})", name, current_file));
1156            }
1157            if let Some(name) = extract_type_name(source) {
1158                removed_types.insert(format!("{} ({})", name, current_file));
1159            }
1160        }
1161    }
1162
1163    let changed_functions_from_dupes = added_functions
1164        .intersection(&removed_functions)
1165        .cloned()
1166        .collect::<BTreeSet<_>>();
1167    let changed_types_from_dupes = added_types
1168        .intersection(&removed_types)
1169        .cloned()
1170        .collect::<BTreeSet<_>>();
1171
1172    for entry in &changed_functions_from_dupes {
1173        changed_functions.insert(entry.clone());
1174    }
1175    for entry in &changed_types_from_dupes {
1176        changed_types.insert(entry.clone());
1177    }
1178
1179    let new_functions = added_functions
1180        .difference(&changed_functions_from_dupes)
1181        .cloned()
1182        .collect::<Vec<_>>();
1183    let removed_functions = removed_functions
1184        .difference(&changed_functions_from_dupes)
1185        .cloned()
1186        .collect::<Vec<_>>();
1187    let new_types = added_types
1188        .difference(&changed_types_from_dupes)
1189        .cloned()
1190        .collect::<Vec<_>>();
1191    let removed_types = removed_types
1192        .difference(&changed_types_from_dupes)
1193        .cloned()
1194        .collect::<Vec<_>>();
1195
1196    SymbolSummary {
1197        new_functions,
1198        changed_functions: changed_functions.into_iter().collect(),
1199        removed_functions,
1200        new_types,
1201        changed_types: changed_types.into_iter().collect(),
1202        removed_types,
1203        hunk_contexts: hunk_contexts.into_iter().collect(),
1204    }
1205}
1206
1207fn parse_hunk_context(line: &str) -> Option<String> {
1208    let parts = line.split("@@").collect::<Vec<_>>();
1209    if parts.len() < 3 {
1210        return None;
1211    }
1212    let context = parts[2].trim();
1213    if context.is_empty() {
1214        None
1215    } else {
1216        Some(context.to_string())
1217    }
1218}
1219
1220fn extract_context_symbol(context: &str) -> Option<(String, SymbolKind)> {
1221    let trimmed = context.trim();
1222    if trimmed.is_empty() {
1223        return None;
1224    }
1225
1226    for capture in [
1227        RUST_FUNCTION_RE.captures(trimmed),
1228        JS_FUNCTION_RE.captures(trimmed),
1229    ]
1230    .into_iter()
1231    .flatten()
1232    {
1233        if let Some(name) = capture.get(1) {
1234            let symbol = name.as_str().to_string();
1235            if is_noise_symbol(&symbol) {
1236                return None;
1237            }
1238            return Some((symbol, SymbolKind::Function));
1239        }
1240    }
1241
1242    if let Some(capture) = JS_ARROW_RE.captures(trimmed) {
1243        if let Some(name) = capture.get(1) {
1244            let symbol = name.as_str().to_string();
1245            if is_noise_symbol(&symbol) {
1246                return None;
1247            }
1248            return Some((symbol, SymbolKind::Function));
1249        }
1250    }
1251
1252    for capture in [RUST_TYPE_RE.captures(trimmed), TS_TYPE_RE.captures(trimmed)]
1253        .into_iter()
1254        .flatten()
1255    {
1256        if let Some(name) = capture.get(2) {
1257            let symbol = name.as_str().to_string();
1258            if is_noise_symbol(&symbol) {
1259                return None;
1260            }
1261            return Some((symbol, SymbolKind::Type));
1262        }
1263    }
1264
1265    if let Some(capture) = METHOD_CONTEXT_RE.captures(trimmed) {
1266        if let Some(name) = capture.get(1) {
1267            let symbol = name.as_str().to_string();
1268            if is_noise_symbol(&symbol) {
1269                return None;
1270            }
1271            return Some((symbol, SymbolKind::Function));
1272        }
1273    }
1274
1275    None
1276}
1277
1278fn extract_function_name(source: &str) -> Option<String> {
1279    for capture in [
1280        RUST_FUNCTION_RE.captures(source),
1281        JS_FUNCTION_RE.captures(source),
1282        JS_ARROW_RE.captures(source),
1283    ]
1284    .into_iter()
1285    .flatten()
1286    {
1287        if let Some(name) = capture.get(1) {
1288            return Some(name.as_str().to_string());
1289        }
1290    }
1291    None
1292}
1293
1294fn extract_type_name(source: &str) -> Option<String> {
1295    for capture in [RUST_TYPE_RE.captures(source), TS_TYPE_RE.captures(source)]
1296        .into_iter()
1297        .flatten()
1298    {
1299        if let Some(name) = capture.get(2) {
1300            return Some(name.as_str().to_string());
1301        }
1302    }
1303    None
1304}
1305
1306fn is_code_path(path: &str) -> bool {
1307    let normalized = path.to_ascii_lowercase();
1308    [
1309        ".rs", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".java", ".kt", ".swift",
1310    ]
1311    .iter()
1312    .any(|extension| normalized.ends_with(extension))
1313}
1314
1315fn is_noise_symbol(symbol: &str) -> bool {
1316    matches!(
1317        symbol,
1318        "fn" | "pub"
1319            | "impl"
1320            | "mod"
1321            | "use"
1322            | "struct"
1323            | "enum"
1324            | "trait"
1325            | "type"
1326            | "class"
1327            | "interface"
1328            | "const"
1329            | "let"
1330            | "var"
1331            | "async"
1332            | "await"
1333            | "match"
1334            | "if"
1335            | "else"
1336            | "for"
1337            | "while"
1338            | "loop"
1339    )
1340}
1341
1342fn sanitize_commit_type(raw: &str) -> Option<String> {
1343    let normalized = raw.trim().to_ascii_lowercase();
1344    if CONVENTIONAL_TYPES.contains(&normalized.as_str()) {
1345        Some(normalized)
1346    } else {
1347        None
1348    }
1349}
1350
1351fn sanitize_scope(raw: Option<&str>) -> Option<String> {
1352    let raw = raw?.trim();
1353    if raw.is_empty() {
1354        return None;
1355    }
1356    let sanitized = raw
1357        .chars()
1358        .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/'))
1359        .collect::<String>()
1360        .to_ascii_lowercase();
1361    if sanitized.is_empty() {
1362        None
1363    } else {
1364        Some(sanitized)
1365    }
1366}
1367
1368fn sanitize_description(raw: &str) -> Option<String> {
1369    let trimmed = raw.trim().trim_matches('"').trim();
1370    if trimmed.is_empty() {
1371        return None;
1372    }
1373    let normalized = trimmed.trim_end_matches('.').trim();
1374    if normalized.is_empty() {
1375        None
1376    } else {
1377        Some(normalized.to_string())
1378    }
1379}
1380
1381fn sanitize_footer_value(raw: &str) -> Option<String> {
1382    let trimmed = raw.trim();
1383    if trimmed.is_empty() {
1384        None
1385    } else {
1386        Some(trimmed.to_string())
1387    }
1388}
1389
1390fn interesting_file_stem(path: &str) -> Option<String> {
1391    let stem = Path::new(path)
1392        .file_stem()
1393        .and_then(|value| value.to_str())
1394        .map(|value| value.trim().to_ascii_lowercase())?;
1395    if stem.is_empty()
1396        || matches!(
1397            stem.as_str(),
1398            "mod" | "lib" | "main" | "readme" | "index" | "commands" | "router"
1399        )
1400    {
1401        None
1402    } else {
1403        Some(stem)
1404    }
1405}
1406
1407fn normalize_name_status(raw: &str) -> String {
1408    let code = raw.chars().next().unwrap_or('M');
1409    match code {
1410        'A' => "added",
1411        'D' => "deleted",
1412        'R' => "renamed",
1413        'C' => "copied",
1414        'T' => "typechange",
1415        'U' => "unmerged",
1416        'M' => "modified",
1417        _ => "modified",
1418    }
1419    .to_string()
1420}
1421
1422fn normalize_display_path(raw: &str) -> String {
1423    raw.trim().replace('\\', "/")
1424}
1425
1426fn normalize_path_key(raw: &str) -> String {
1427    let cleaned = raw.trim().replace(['{', '}'], "").replace('\\', "/");
1428    if let Some((_, tail)) = cleaned.split_once("=>") {
1429        tail.trim().to_string()
1430    } else {
1431        cleaned
1432    }
1433}
1434
1435fn truncate_for_prompt(text: &str, limit: usize) -> String {
1436    if text.chars().count() <= limit {
1437        return text.to_string();
1438    }
1439
1440    let truncated = text.chars().take(limit).collect::<String>();
1441    format!(
1442        "{}\n\n[diff truncated after {} characters]",
1443        truncated, limit
1444    )
1445}
1446
1447async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
1448    git_output_with_env(project_root, args, &[]).await
1449}
1450
1451async fn git_output_with_env(
1452    project_root: &Path,
1453    args: &[&str],
1454    envs: &[(&str, &std::ffi::OsStr)],
1455) -> Result<String, String> {
1456    let mut command = Command::new("git");
1457    command.current_dir(project_root).args(args);
1458    for (key, value) in envs {
1459        command.env(key, value);
1460    }
1461
1462    let output = command
1463        .output()
1464        .await
1465        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
1466
1467    if !output.status.success() {
1468        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1469        if stderr.is_empty() {
1470            return Err(format!(
1471                "`git {}` failed with status {}",
1472                args.join(" "),
1473                output.status
1474            ));
1475        }
1476        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
1477    }
1478
1479    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1480}
1481
1482struct TemporaryIndex {
1483    path: PathBuf,
1484}
1485
1486impl Drop for TemporaryIndex {
1487    fn drop(&mut self) {
1488        let _ = fs::remove_file(&self.path);
1489    }
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494    use super::{
1495        infer_commit_type, parse_ai_commit_payload, render_commit_message, sanitize_scope,
1496        summarize_symbols, ConventionalCommit, FileChangeSummary, StatusEntry, WorktreeAnalysis,
1497    };
1498    use std::path::PathBuf;
1499
1500    #[test]
1501    fn symbol_summary_tracks_new_and_changed_symbols() {
1502        let diff = r#"
1503diff --git a/crates/cli/src/commands/commit.rs b/crates/cli/src/commands/commit.rs
1504index 1111111..2222222 100644
1505--- a/crates/cli/src/commands/commit.rs
1506+++ b/crates/cli/src/commands/commit.rs
1507@@ -0,0 +1,3 @@
1508+pub struct CommitArgs {
1509+}
1510+pub async fn run_commit() {}
1511@@ -10,1 +12,1 @@ pub async fn old_name() {}
1512-pub async fn old_name() {}
1513+pub async fn old_name() {}
1514"#;
1515        let summary = summarize_symbols(diff);
1516        assert!(summary
1517            .new_functions
1518            .iter()
1519            .any(|item| item.contains("run_commit")));
1520        assert!(summary
1521            .new_types
1522            .iter()
1523            .any(|item| item.contains("CommitArgs")));
1524        assert!(summary
1525            .changed_functions
1526            .iter()
1527            .any(|item| item.contains("old_name")));
1528    }
1529
1530    #[test]
1531    fn ai_payload_respects_forced_scope() {
1532        let raw = r#"{
1533  "type": "feat",
1534  "scope": "wrong",
1535  "description": "add commit command",
1536  "body": ["Summarize the worktree."],
1537  "breaking_change": false,
1538  "footers": []
1539}"#;
1540
1541        let commit = parse_ai_commit_payload(raw, Some("cli")).expect("parse");
1542        assert_eq!(commit.scope.as_deref(), Some("cli"));
1543        assert_eq!(commit.commit_type, "feat");
1544    }
1545
1546    #[test]
1547    fn renders_breaking_change_footer_once() {
1548        let rendered = render_commit_message(&ConventionalCommit {
1549            commit_type: "feat".to_string(),
1550            scope: Some("cli".to_string()),
1551            description: "add commit command".to_string(),
1552            body: vec!["Summarize the worktree.".to_string()],
1553            breaking_change: Some("existing automation must call xbp commit".to_string()),
1554            footers: Vec::new(),
1555        });
1556
1557        assert!(rendered.contains("feat(cli)!: add commit command"));
1558        assert!(rendered.contains("BREAKING CHANGE: existing automation must call xbp commit"));
1559    }
1560
1561    #[test]
1562    fn infers_feat_for_new_symbols() {
1563        let analysis = WorktreeAnalysis {
1564            repo_root: PathBuf::from("C:/repo"),
1565            repo_name: "xbp".to_string(),
1566            branch: Some("main".to_string()),
1567            status_entries: vec![StatusEntry {
1568                code: "??".to_string(),
1569                path: "crates/cli/src/commands/commit.rs".to_string(),
1570            }],
1571            files: vec![FileChangeSummary {
1572                path: "crates/cli/src/commands/commit.rs".to_string(),
1573                status: "added".to_string(),
1574                additions: 120,
1575                deletions: 0,
1576            }],
1577            total_additions: 120,
1578            total_deletions: 0,
1579            diff_text: String::new(),
1580            new_functions: vec!["run_commit (crates/cli/src/commands/commit.rs)".to_string()],
1581            changed_functions: Vec::new(),
1582            removed_functions: Vec::new(),
1583            new_types: vec!["CommitArgs (crates/cli/src/commands/commit.rs)".to_string()],
1584            changed_types: Vec::new(),
1585            removed_types: Vec::new(),
1586            hunk_contexts: Vec::new(),
1587        };
1588
1589        assert_eq!(infer_commit_type(&analysis), "feat");
1590    }
1591
1592    #[test]
1593    fn scope_sanitizer_keeps_valid_tokens() {
1594        assert_eq!(
1595            sanitize_scope(Some("CLI/tools")),
1596            Some("cli/tools".to_string())
1597        );
1598        assert_eq!(sanitize_scope(Some("  ")), None);
1599    }
1600}