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