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