Skip to main content

xbp_cli/commands/
commit.rs

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