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(¤t_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}