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