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];
59const TERMINAL_SESSION_DIR: &str = "terminals/";
60
61#[derive(Debug, Clone)]
62pub struct CommitArgs {
63 pub dry_run: bool,
64 pub push: bool,
65 pub no_ai: bool,
66 pub model: Option<String>,
67 pub scope: Option<String>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum CommitRunOutcome {
72 Completed,
73 NothingToCommit,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum StageAndCommitOutcome {
78 Committed,
79 NothingToCommit,
80}
81
82#[derive(Debug, Clone)]
83pub enum CommitError {
84 Operation(String),
85 PushFailed {
86 summary: String,
87 commit_sha: Option<String>,
88 },
89 TerminalSessionsBlocked(String),
90}
91
92#[derive(Debug, Clone)]
93struct RepoContext {
94 repo_root: PathBuf,
95 repo_name: String,
96 branch: Option<String>,
97}
98
99#[derive(Debug, Clone, Default)]
100struct BranchSyncStatus {
101 upstream: Option<String>,
102 ahead: usize,
103 behind: usize,
104}
105
106#[derive(Debug, Clone)]
107struct WorktreeAnalysis {
108 repo_root: PathBuf,
109 repo_name: String,
110 branch: Option<String>,
111 status_entries: Vec<StatusEntry>,
112 files: Vec<FileChangeSummary>,
113 total_additions: u32,
114 total_deletions: u32,
115 diff_text: String,
116 new_functions: Vec<String>,
117 changed_functions: Vec<String>,
118 removed_functions: Vec<String>,
119 new_types: Vec<String>,
120 changed_types: Vec<String>,
121 removed_types: Vec<String>,
122 hunk_contexts: Vec<String>,
123}
124
125impl WorktreeAnalysis {
126 fn repo_context(&self) -> RepoContext {
127 RepoContext {
128 repo_root: self.repo_root.clone(),
129 repo_name: self.repo_name.clone(),
130 branch: self.branch.clone(),
131 }
132 }
133}
134
135#[derive(Debug, Clone)]
136struct StatusEntry {
137 code: String,
138 path: String,
139}
140
141#[derive(Debug, Clone)]
142struct FileChangeSummary {
143 path: String,
144 status: String,
145 additions: u32,
146 deletions: u32,
147}
148
149#[derive(Debug, Clone)]
150struct SymbolSummary {
151 new_functions: Vec<String>,
152 changed_functions: Vec<String>,
153 removed_functions: Vec<String>,
154 new_types: Vec<String>,
155 changed_types: Vec<String>,
156 removed_types: Vec<String>,
157 hunk_contexts: Vec<String>,
158}
159
160#[derive(Debug, Clone, Copy, Eq, PartialEq)]
161enum SymbolKind {
162 Function,
163 Type,
164}
165
166#[derive(Debug, Clone)]
167struct CommitMessagePlan {
168 commit: ConventionalCommit,
169 generation_mode: GenerationMode,
170}
171
172#[derive(Debug, Clone)]
173struct ConventionalCommit {
174 commit_type: String,
175 scope: Option<String>,
176 description: String,
177 body: Vec<String>,
178 breaking_change: Option<String>,
179 footers: Vec<String>,
180}
181
182#[derive(Debug, Clone, Copy)]
183enum GenerationMode {
184 OpenRouter,
185 Heuristic,
186}
187
188#[derive(Debug, Deserialize)]
189struct AiCommitPayload {
190 #[serde(rename = "type")]
191 commit_type: String,
192 scope: Option<String>,
193 description: String,
194 #[serde(default)]
195 body: Vec<String>,
196 #[serde(default)]
197 breaking_change: bool,
198 breaking_description: Option<String>,
199 #[serde(default)]
200 footers: Vec<String>,
201}
202
203pub async fn run_commit(args: CommitArgs) -> Result<CommitRunOutcome, CommitError> {
204 if !command_exists("git") {
205 return Err(CommitError::Operation(
206 "Git is not installed on this machine.".to_string(),
207 ));
208 }
209
210 let invocation_dir = env::current_dir()
211 .map_err(|e| CommitError::Operation(format!("Failed to read current directory: {}", e)))?;
212 let repo = resolve_repo_context(&invocation_dir)
213 .await
214 .map_err(CommitError::Operation)?;
215 let status_output = git_output(
216 &repo.repo_root,
217 &["status", "--porcelain=v1", "--untracked-files=all"],
218 )
219 .await
220 .map_err(CommitError::Operation)?;
221 let status_entries = parse_status_entries(&status_output);
222
223 if status_entries.is_empty() {
224 let sync_status = resolve_branch_sync_status(&repo.repo_root)
225 .await
226 .unwrap_or_default();
227
228 if args.push {
229 return handle_clean_worktree_push(&repo, &sync_status)
230 .await
231 .map_err(|error| CommitError::PushFailed {
232 summary: error,
233 commit_sha: None,
234 })
235 .map(|()| CommitRunOutcome::Completed);
236 }
237
238 println!(
239 "{}",
240 render_clean_worktree_message(&repo, &sync_status).bright_yellow()
241 );
242 return Ok(CommitRunOutcome::NothingToCommit);
243 }
244
245 let repo_for_analysis = repo.clone();
246 let analysis = analyze_worktree(repo, status_entries, &[])
247 .await
248 .map_err(CommitError::Operation)?;
249 let excluded_terminal_paths = terminal_session_paths(&analysis);
250 if !excluded_terminal_paths.is_empty()
251 && analysis
252 .files
253 .iter()
254 .all(|file| is_terminal_session_path(&file.path))
255 {
256 return Err(CommitError::TerminalSessionsBlocked(
257 render_terminal_session_block_message(&excluded_terminal_paths),
258 ));
259 }
260
261 let commit_analysis = if excluded_terminal_paths.is_empty() {
262 analysis.clone()
263 } else {
264 println!(
265 "\n{} {}",
266 "Skipped".bright_yellow().bold(),
267 format!(
268 "excluding {} IDE terminal session file(s) from this commit: {}",
269 excluded_terminal_paths.len(),
270 excluded_terminal_paths.join(", ")
271 )
272 .bright_white()
273 );
274 analyze_worktree(
275 repo_for_analysis,
276 analysis.status_entries.clone(),
277 &excluded_terminal_paths,
278 )
279 .await
280 .map_err(CommitError::Operation)?
281 };
282
283 let auto_push_after_commit = resolve_auto_push_on_commit().await;
284 let commit_plan = generate_commit_plan(&commit_analysis, &args).await;
285 let rendered_message = render_commit_message(&commit_plan.commit);
286
287 print_analysis_summary(&commit_analysis, &commit_plan, &rendered_message);
288
289 if args.dry_run {
290 println!(
291 "\n{}",
292 "Dry run only. No git commit was created."
293 .bright_yellow()
294 .bold()
295 );
296 return Ok(CommitRunOutcome::Completed);
297 }
298
299 match stage_and_commit(
300 &commit_analysis.repo_root,
301 &rendered_message,
302 &excluded_terminal_paths,
303 )
304 .await
305 .map_err(CommitError::Operation)?
306 {
307 StageAndCommitOutcome::Committed => {}
308 StageAndCommitOutcome::NothingToCommit => {
309 let sync_status = resolve_branch_sync_status(&commit_analysis.repo_root)
310 .await
311 .unwrap_or_default();
312
313 if args.push {
314 handle_clean_worktree_push(&commit_analysis.repo_context(), &sync_status)
315 .await
316 .map_err(|error| CommitError::PushFailed {
317 summary: error,
318 commit_sha: None,
319 })?;
320 } else {
321 println!(
322 "{}",
323 render_clean_worktree_message(&commit_analysis.repo_context(), &sync_status,)
324 .bright_yellow()
325 );
326 }
327
328 return Ok(CommitRunOutcome::NothingToCommit);
329 }
330 }
331
332 let short_sha = git_output(
333 &commit_analysis.repo_root,
334 &["rev-parse", "--short", "HEAD"],
335 )
336 .await
337 .map_err(CommitError::Operation)?;
338 let full_sha = git_output(&commit_analysis.repo_root, &["rev-parse", "HEAD"])
339 .await
340 .map_err(CommitError::Operation)?;
341
342 println!(
343 "\n{} {} {}",
344 "Committed".bright_green().bold(),
345 short_sha.bright_white().bold(),
346 format!("({})", full_sha).dimmed()
347 );
348
349 if args.push || auto_push_after_commit {
350 match push_current_branch(&commit_analysis.repo_root).await {
351 Ok(Some(outcome)) => print_push_summary(&outcome),
352 Ok(None) => println!(
353 "{}",
354 "Push skipped because the current HEAD is detached.".bright_yellow()
355 ),
356 Err(error) => {
357 return Err(CommitError::PushFailed {
358 summary: error,
359 commit_sha: Some(short_sha),
360 });
361 }
362 }
363 } else {
364 println!(
365 "{}",
366 "Auto-push disabled by xbp config (`github.auto_push_on_commit: false`)."
367 .bright_yellow()
368 );
369 }
370
371 Ok(CommitRunOutcome::Completed)
372}
373
374async fn resolve_auto_push_on_commit() -> bool {
375 load_xbp_config_with_root()
376 .await
377 .map(|(_, config)| config.auto_push_on_commit_enabled())
378 .unwrap_or(true)
379}
380
381async fn resolve_repo_context(invocation_dir: &Path) -> Result<RepoContext, String> {
382 let repo_root = PathBuf::from(
383 git_output(invocation_dir, &["rev-parse", "--show-toplevel"])
384 .await
385 .map_err(|_| "Current directory is not inside a git repository.".to_string())?,
386 );
387 let repo_name = repo_name(&repo_root);
388 let branch = git_output(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
389 .await
390 .ok()
391 .filter(|value| !value.is_empty() && value != "HEAD");
392
393 Ok(RepoContext {
394 repo_root,
395 repo_name,
396 branch,
397 })
398}
399
400async fn resolve_branch_sync_status(repo_root: &Path) -> Result<BranchSyncStatus, String> {
401 let upstream = git_output(
402 repo_root,
403 &[
404 "rev-parse",
405 "--abbrev-ref",
406 "--symbolic-full-name",
407 "@{upstream}",
408 ],
409 )
410 .await
411 .ok()
412 .filter(|value| !value.is_empty());
413
414 let Some(upstream_name) = upstream else {
415 return Ok(BranchSyncStatus::default());
416 };
417
418 let counts = git_output(
419 repo_root,
420 &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
421 )
422 .await?;
423 let mut parts = counts.split_whitespace();
424 let ahead = parts
425 .next()
426 .and_then(|value| value.parse::<usize>().ok())
427 .unwrap_or(0);
428 let behind = parts
429 .next()
430 .and_then(|value| value.parse::<usize>().ok())
431 .unwrap_or(0);
432
433 Ok(BranchSyncStatus {
434 upstream: Some(upstream_name),
435 ahead,
436 behind,
437 })
438}
439
440async fn handle_clean_worktree_push(
441 repo: &RepoContext,
442 sync_status: &BranchSyncStatus,
443) -> Result<(), String> {
444 if repo.branch.is_none() {
445 return Err(render_clean_worktree_message(repo, sync_status));
446 }
447
448 if sync_status.behind > 0 && sync_status.ahead == 0 {
449 let branch = repo.branch.as_deref().unwrap_or("main");
450 let upstream = sync_status.upstream.as_deref().unwrap_or("origin/main");
451 return Err(format!(
452 "`{branch}` is behind `{upstream}` by {} commit(s). Run `git pull --rebase origin {branch}`, then push again.",
453 sync_status.behind
454 ));
455 }
456
457 if sync_status.ahead == 0 && sync_status.behind == 0 && sync_status.upstream.is_some() {
458 println!("{}", render_clean_worktree_message(repo, sync_status));
459 return Ok(());
460 }
461
462 println!("{}", render_clean_worktree_message(repo, sync_status));
463 match push_current_branch(&repo.repo_root).await {
464 Ok(Some(outcome)) => {
465 print_push_summary(&outcome);
466 Ok(())
467 }
468 Ok(None) => Err("Push skipped because the current HEAD is detached.".to_string()),
469 Err(error) => Err(error),
470 }
471}
472
473fn render_clean_worktree_message(repo: &RepoContext, sync_status: &BranchSyncStatus) -> String {
474 let branch_label = repo
475 .branch
476 .as_deref()
477 .map(|branch| format!(" on `{}`", branch))
478 .unwrap_or_default();
479
480 let sync_summary = match sync_status.upstream.as_deref() {
481 Some(upstream) if sync_status.ahead > 0 && sync_status.behind > 0 => format!(
482 "Working tree is clean{}, with {} commit(s) waiting to push and {} commit(s) to pull from `{}`.",
483 branch_label, sync_status.ahead, sync_status.behind, upstream
484 ),
485 Some(upstream) if sync_status.ahead > 0 => format!(
486 "Working tree is clean{}, with {} commit(s) waiting to push to `{}`.",
487 branch_label, sync_status.ahead, upstream
488 ),
489 Some(upstream) if sync_status.behind > 0 => format!(
490 "Working tree is clean{}, but `{}` is ahead by {} commit(s). Pull or rebase before pushing.",
491 branch_label, upstream, sync_status.behind
492 ),
493 Some(upstream) => format!(
494 "Working tree is clean{} and already in sync with `{}`.",
495 branch_label, upstream
496 ),
497 None if repo.branch.is_some() => format!(
498 "Working tree is clean{}, and no upstream branch is configured yet.",
499 branch_label
500 ),
501 None => "Working tree is clean, and HEAD is detached.".to_string(),
502 };
503
504 format!("Nothing to commit. {}", sync_summary)
505}
506
507async fn analyze_worktree(
508 repo: RepoContext,
509 status_entries: Vec<StatusEntry>,
510 exclude_paths: &[String],
511) -> Result<WorktreeAnalysis, String> {
512 if status_entries.is_empty() {
513 return Err("No worktree changes were found to commit.".to_string());
514 }
515
516 let temp_index = prepare_temporary_index(&repo.repo_root).await?;
517 let temp_index_path = temp_index.path.clone();
518 let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
519
520 git_output_with_env(&repo.repo_root, &["add", "--all"], &git_env).await?;
521 for path in exclude_paths {
522 git_output_with_env(
523 &repo.repo_root,
524 &["reset", "HEAD", "--", path.as_str()],
525 &git_env,
526 )
527 .await?;
528 }
529 let name_status_output = git_output_with_env(
530 &repo.repo_root,
531 &["diff", "--cached", "--name-status", "--find-renames"],
532 &git_env,
533 )
534 .await?;
535 let numstat_output = git_output_with_env(
536 &repo.repo_root,
537 &["diff", "--cached", "--numstat", "--find-renames"],
538 &git_env,
539 )
540 .await?;
541 let diff_text = git_output_with_env(
542 &repo.repo_root,
543 &[
544 "diff",
545 "--cached",
546 "--unified=0",
547 "--no-color",
548 "--no-ext-diff",
549 "--find-renames",
550 ],
551 &git_env,
552 )
553 .await?;
554
555 if diff_text.trim().is_empty() {
556 return Err("No staged diff could be produced from the current worktree.".to_string());
557 }
558
559 let files = merge_file_summaries(&name_status_output, &numstat_output);
560 let total_additions = files.iter().map(|entry| entry.additions).sum();
561 let total_deletions = files.iter().map(|entry| entry.deletions).sum();
562 let symbols = summarize_symbols(&diff_text);
563
564 Ok(WorktreeAnalysis {
565 repo_root: repo.repo_root,
566 repo_name: repo.repo_name,
567 branch: repo.branch,
568 status_entries,
569 files,
570 total_additions,
571 total_deletions,
572 diff_text,
573 new_functions: symbols.new_functions,
574 changed_functions: symbols.changed_functions,
575 removed_functions: symbols.removed_functions,
576 new_types: symbols.new_types,
577 changed_types: symbols.changed_types,
578 removed_types: symbols.removed_types,
579 hunk_contexts: symbols.hunk_contexts,
580 })
581}
582
583async fn prepare_temporary_index(repo_root: &Path) -> Result<TemporaryIndex, String> {
584 let real_index_path =
585 PathBuf::from(git_output(repo_root, &["rev-parse", "--git-path", "index"]).await?);
586 let temp_index_path = env::temp_dir().join(format!("xbp-commit-index-{}.tmp", Uuid::new_v4()));
587
588 if real_index_path.exists() {
589 fs::copy(&real_index_path, &temp_index_path).map_err(|e| {
590 format!(
591 "Failed to prepare temporary git index {}: {}",
592 temp_index_path.display(),
593 e
594 )
595 })?;
596 } else {
597 let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
598 let _ = git_output_with_env(repo_root, &["read-tree", "HEAD"], &git_env).await;
599 }
600
601 Ok(TemporaryIndex {
602 path: temp_index_path,
603 })
604}
605
606async fn generate_commit_plan(analysis: &WorktreeAnalysis, args: &CommitArgs) -> CommitMessagePlan {
607 let forced_scope = sanitize_scope(args.scope.as_deref());
608 let heuristic = build_heuristic_commit(analysis, forced_scope.clone());
609
610 if args.no_ai {
611 return CommitMessagePlan {
612 commit: heuristic,
613 generation_mode: GenerationMode::Heuristic,
614 };
615 }
616
617 let Some(api_key) = resolve_openrouter_api_key() else {
618 return CommitMessagePlan {
619 commit: heuristic,
620 generation_mode: GenerationMode::Heuristic,
621 };
622 };
623
624 let prompt = build_commit_prompt(analysis, forced_scope.as_deref(), &heuristic);
625 let model = args
626 .model
627 .as_deref()
628 .map(str::trim)
629 .filter(|value| !value.is_empty())
630 .map(str::to_string)
631 .unwrap_or_else(resolve_openrouter_commit_model);
632 let system_prompt = resolve_openrouter_commit_system_prompt();
633
634 let ai_message = complete_prompt(
635 &api_key,
636 &model,
637 Some(&system_prompt),
638 &prompt,
639 Some("XBP Commit Generator"),
640 )
641 .await
642 .and_then(|raw| parse_ai_commit_payload(&raw, forced_scope.as_deref()));
643
644 if let Some(mut commit) = ai_message {
645 if commit.body.is_empty() {
646 commit.body = heuristic.body.clone();
647 }
648 CommitMessagePlan {
649 commit,
650 generation_mode: GenerationMode::OpenRouter,
651 }
652 } else {
653 CommitMessagePlan {
654 commit: heuristic,
655 generation_mode: GenerationMode::Heuristic,
656 }
657 }
658}
659
660fn build_commit_prompt(
661 analysis: &WorktreeAnalysis,
662 forced_scope: Option<&str>,
663 heuristic: &ConventionalCommit,
664) -> String {
665 let focus_areas = summarize_focus_areas(analysis, MAX_PROMPT_AREAS);
666 let top_files = summarize_top_files(analysis, MAX_PROMPT_FILES);
667 let mut lines = vec![
668 "Worktree context for commit generation:".to_string(),
669 String::new(),
670 ];
671 lines.push(format!("Repository: {}", analysis.repo_name));
672 if let Some(branch) = &analysis.branch {
673 lines.push(format!("Branch: {}", branch));
674 }
675 if let Some(scope) = forced_scope {
676 lines.push(format!("Forced scope: {}", scope));
677 } else if let Some(scope) = heuristic.scope.as_deref() {
678 lines.push(format!("Suggested scope: {}", scope));
679 }
680 lines.push(format!(
681 "Heuristic fallback subject: {}",
682 render_commit_subject(heuristic)
683 ));
684 if !focus_areas.is_empty() {
685 lines.push(format!("Focus areas: {}", focus_areas.join(", ")));
686 }
687 if let Some(behavior_hint) = infer_behavior_hint(analysis) {
688 lines.push(format!("Behavior hint: {}", behavior_hint));
689 }
690 if analysis
691 .files
692 .iter()
693 .any(|file| is_terminal_session_path(&file.path))
694 {
695 lines.push(String::new());
696 lines.push("Important constraint:".to_string());
697 lines.push(
698 "- paths under terminals/ are IDE agent session logs, not product features; never describe them as flows or capabilities"
699 .to_string(),
700 );
701 lines.push(
702 "- terminals/.next-id is an IDE session counter, not app configuration; never describe updating it as a feature"
703 .to_string(),
704 );
705 }
706 lines.push(String::new());
707 lines.push("Worktree stats:".to_string());
708 lines.push(format!("- files changed: {}", analysis.files.len()));
709 lines.push(format!(
710 "- lines: +{} -{}",
711 analysis.total_additions, analysis.total_deletions
712 ));
713 if !top_files.is_empty() {
714 lines.push("- dominant files:".to_string());
715 for file in top_files {
716 lines.push(format!(" - {}", file));
717 }
718 }
719 if !analysis.status_entries.is_empty() {
720 lines.push("- worktree statuses:".to_string());
721 for entry in analysis.status_entries.iter().take(MAX_PROMPT_FILES) {
722 lines.push(format!(" - {} {}", entry.code, entry.path));
723 }
724 }
725 if !analysis.files.is_empty() {
726 lines.push("- file diffs:".to_string());
727 for file in analysis.files.iter().take(MAX_PROMPT_FILES) {
728 lines.push(format!(
729 " - [{}] {} (+{} -{})",
730 file.status, file.path, file.additions, file.deletions
731 ));
732 }
733 }
734 if !analysis.new_functions.is_empty() {
735 lines.push(format!(
736 "- new functions: {}",
737 analysis
738 .new_functions
739 .iter()
740 .take(MAX_PROMPT_SYMBOLS)
741 .cloned()
742 .collect::<Vec<_>>()
743 .join(", ")
744 ));
745 }
746 if !analysis.changed_functions.is_empty() {
747 lines.push(format!(
748 "- changed functions: {}",
749 analysis
750 .changed_functions
751 .iter()
752 .take(MAX_PROMPT_SYMBOLS)
753 .cloned()
754 .collect::<Vec<_>>()
755 .join(", ")
756 ));
757 }
758 if !analysis.new_types.is_empty() {
759 lines.push(format!(
760 "- new types: {}",
761 analysis
762 .new_types
763 .iter()
764 .take(MAX_PROMPT_SYMBOLS)
765 .cloned()
766 .collect::<Vec<_>>()
767 .join(", ")
768 ));
769 }
770 if !analysis.changed_types.is_empty() {
771 lines.push(format!(
772 "- changed types: {}",
773 analysis
774 .changed_types
775 .iter()
776 .take(MAX_PROMPT_SYMBOLS)
777 .cloned()
778 .collect::<Vec<_>>()
779 .join(", ")
780 ));
781 }
782 if !analysis.hunk_contexts.is_empty() {
783 lines.push(format!(
784 "- hunk contexts: {}",
785 analysis
786 .hunk_contexts
787 .iter()
788 .take(MAX_PROMPT_CONTEXTS)
789 .cloned()
790 .collect::<Vec<_>>()
791 .join(", ")
792 ));
793 }
794 lines.push(String::new());
795 lines.push("Diff excerpt:".to_string());
796 lines.push(truncate_for_prompt(&analysis.diff_text, AI_DIFF_CHAR_LIMIT));
797 lines.join("\n")
798}
799
800fn parse_ai_commit_payload(raw: &str, forced_scope: Option<&str>) -> Option<ConventionalCommit> {
801 let payload: AiCommitPayload = serde_json::from_str(raw).ok()?;
802 let commit_type = sanitize_commit_type(&payload.commit_type)?;
803 let description = sanitize_description(&payload.description)?;
804 let mut body = payload
805 .body
806 .into_iter()
807 .map(|entry| entry.trim().to_string())
808 .filter(|entry| !entry.is_empty())
809 .collect::<Vec<_>>();
810 if body.len() > 3 {
811 body.truncate(3);
812 }
813
814 let breaking_change = if payload.breaking_change {
815 payload
816 .breaking_description
817 .as_deref()
818 .and_then(sanitize_footer_value)
819 } else {
820 None
821 };
822
823 let mut footers = payload
824 .footers
825 .into_iter()
826 .map(|entry| entry.trim().to_string())
827 .filter(|entry| !entry.is_empty())
828 .collect::<Vec<_>>();
829 if breaking_change.is_some()
830 && !footers
831 .iter()
832 .any(|entry| entry.starts_with("BREAKING CHANGE:"))
833 {
834 if let Some(breaking) = &breaking_change {
835 footers.push(format!("BREAKING CHANGE: {}", breaking));
836 }
837 }
838
839 Some(ConventionalCommit {
840 commit_type,
841 scope: forced_scope
842 .and_then(|scope| sanitize_scope(Some(scope)))
843 .or_else(|| sanitize_scope(payload.scope.as_deref())),
844 description,
845 body,
846 breaking_change,
847 footers,
848 })
849}
850
851fn build_heuristic_commit(
852 analysis: &WorktreeAnalysis,
853 forced_scope: Option<String>,
854) -> ConventionalCommit {
855 let commit_type = infer_commit_type(analysis).to_string();
856 let scope = forced_scope.or_else(|| infer_scope(analysis));
857 let description = infer_description(analysis, &commit_type, scope.as_deref());
858 let body = build_heuristic_body(analysis);
859
860 ConventionalCommit {
861 commit_type,
862 scope,
863 description,
864 body,
865 breaking_change: None,
866 footers: Vec::new(),
867 }
868}
869
870fn infer_commit_type(analysis: &WorktreeAnalysis) -> &'static str {
871 let lowered_paths = analysis
872 .files
873 .iter()
874 .map(|file| file.path.to_ascii_lowercase())
875 .collect::<Vec<_>>();
876
877 if !lowered_paths.is_empty()
878 && lowered_paths
879 .iter()
880 .all(|path| is_terminal_session_path(path))
881 {
882 return "chore";
883 }
884
885 let docs_only = !lowered_paths.is_empty()
886 && lowered_paths.iter().all(|path| {
887 !is_terminal_session_path(path)
888 && (path.ends_with(".md")
889 || path.ends_with(".mdx")
890 || path.ends_with(".txt")
891 || path.starts_with("docs/"))
892 });
893 if docs_only {
894 return "docs";
895 }
896
897 let test_only = !lowered_paths.is_empty()
898 && lowered_paths.iter().all(|path| {
899 path.contains("/tests/")
900 || path.contains("\\tests\\")
901 || path.contains(".test.")
902 || path.contains(".spec.")
903 });
904 if test_only {
905 return "test";
906 }
907
908 let ci_only = !lowered_paths.is_empty()
909 && lowered_paths.iter().all(|path| {
910 path.starts_with(".github/")
911 || path.contains("workflow")
912 || path.ends_with(".yml")
913 || path.ends_with(".yaml")
914 });
915 if ci_only {
916 return "ci";
917 }
918
919 let build_only = !lowered_paths.is_empty()
920 && lowered_paths.iter().all(|path| {
921 path.ends_with("cargo.toml")
922 || path.ends_with("cargo.lock")
923 || path.ends_with("package.json")
924 || path.ends_with("pnpm-lock.yaml")
925 || path.ends_with("package-lock.json")
926 || path.ends_with("wrangler.toml")
927 || path.ends_with(".nix")
928 });
929 if build_only {
930 return "build";
931 }
932
933 let has_new_files = analysis.files.iter().any(|file| file.status == "added");
934 let has_new_symbols = !analysis.new_functions.is_empty() || !analysis.new_types.is_empty();
935 let has_existing_symbol_churn = !analysis.changed_functions.is_empty()
936 || !analysis.changed_types.is_empty()
937 || !analysis.removed_functions.is_empty()
938 || !analysis.removed_types.is_empty();
939 if has_new_files {
940 return "feat";
941 }
942 if has_new_symbols && has_existing_symbol_churn {
943 return "refactor";
944 }
945 if has_new_symbols {
946 return "feat";
947 }
948
949 if has_existing_symbol_churn || analysis.total_additions >= analysis.total_deletions {
950 return "fix";
951 }
952
953 "chore"
954}
955
956fn infer_scope(analysis: &WorktreeAnalysis) -> Option<String> {
957 let mut scopes = BTreeSet::new();
958
959 for file in &analysis.files {
960 let path = file.path.replace('\\', "/");
961 let inferred = if path.starts_with("crates/cli/") {
962 Some("cli")
963 } else if path.starts_with("crates/api/") {
964 Some("api")
965 } else if path.starts_with("crates/github/") {
966 Some("github")
967 } else if path.starts_with("crates/runtime/") {
968 Some("runtime")
969 } else if path.starts_with("apps/web/") {
970 Some("web")
971 } else if path.starts_with("docs/") {
972 Some("docs")
973 } else if path.starts_with(".github/") {
974 Some("ci")
975 } else {
976 None
977 };
978
979 if let Some(value) = inferred {
980 scopes.insert(value.to_string());
981 }
982 }
983
984 if scopes.len() == 1 {
985 scopes.into_iter().next()
986 } else if scopes.is_empty() {
987 None
988 } else if scopes.contains("cli") {
989 Some("cli".to_string())
990 } else {
991 None
992 }
993}
994
995fn infer_description(
996 analysis: &WorktreeAnalysis,
997 commit_type: &str,
998 scope: Option<&str>,
999) -> String {
1000 if !analysis.files.is_empty()
1001 && analysis
1002 .files
1003 .iter()
1004 .all(|file| is_terminal_session_path(&file.path))
1005 {
1006 return "ignore ide terminal session logs".to_string();
1007 }
1008
1009 let focus_areas = summarize_focus_areas(analysis, 2);
1010 let behavior_hint = infer_behavior_hint(analysis);
1011 let interesting_names = analysis
1012 .files
1013 .iter()
1014 .filter_map(|file| interesting_file_stem(&file.path))
1015 .collect::<Vec<_>>();
1016
1017 if interesting_names.iter().any(|name| name == "commit") {
1018 return match scope {
1019 Some("cli") => "add worktree commit generator".to_string(),
1020 _ => "add conventional commit generator".to_string(),
1021 };
1022 }
1023
1024 if commit_type == "docs" {
1025 if let Some(name) = interesting_names.first() {
1026 return format!("document {}", name.replace('_', "-"));
1027 }
1028 return "update command documentation".to_string();
1029 }
1030
1031 if let Some(area) = focus_areas.first() {
1032 let target = match behavior_hint {
1033 Some(hint) => format!("{} {}", area, hint),
1034 None => format!("{} flow", area),
1035 };
1036 return match commit_type {
1037 "feat" => format!("expand {}", target),
1038 "fix" => format!("improve {}", target),
1039 "refactor" => format!("refine {}", target),
1040 "test" => format!("cover {}", target),
1041 "ci" => format!("update {} workflow", area),
1042 "build" => format!("adjust {} build inputs", area),
1043 _ => format!("update {}", target),
1044 };
1045 }
1046
1047 if let Some(name) = interesting_names.first() {
1048 return match commit_type {
1049 "feat" => format!("add {}", name.replace('_', "-")),
1050 "fix" => format!("improve {}", name.replace('_', "-")),
1051 "refactor" => format!("refactor {}", name.replace('_', "-")),
1052 "test" => format!("cover {}", name.replace('_', "-")),
1053 "ci" => format!("update {}", name.replace('_', "-")),
1054 "build" => format!("adjust {}", name.replace('_', "-")),
1055 _ => format!("update {}", name.replace('_', "-")),
1056 };
1057 }
1058
1059 match commit_type {
1060 "feat" => "add worktree change support".to_string(),
1061 "fix" => "improve worktree handling".to_string(),
1062 "docs" => "update documentation".to_string(),
1063 _ => "update repository changes".to_string(),
1064 }
1065}
1066
1067fn build_heuristic_body(analysis: &WorktreeAnalysis) -> Vec<String> {
1068 let focus_areas = summarize_focus_areas(analysis, 3);
1069 let top_files = summarize_top_files(analysis, 3);
1070 let mut paragraphs = Vec::new();
1071 let overview_target = if focus_areas.is_empty() {
1072 format!(
1073 "{} file{}",
1074 analysis.files.len(),
1075 if analysis.files.len() == 1 { "" } else { "s" }
1076 )
1077 } else {
1078 format!(
1079 "{} across {} file{}",
1080 format_focus_area_list(&focus_areas),
1081 analysis.files.len(),
1082 if analysis.files.len() == 1 { "" } else { "s" }
1083 )
1084 };
1085 paragraphs.push(format!(
1086 "Touches {} with +{} and -{} lines in the current worktree.",
1087 overview_target, analysis.total_additions, analysis.total_deletions
1088 ));
1089
1090 let mut detail_parts = Vec::new();
1091 if !analysis.new_functions.is_empty() {
1092 detail_parts.push(format!(
1093 "adds {}",
1094 summarize_symbol_list(&analysis.new_functions, 4)
1095 ));
1096 }
1097 if !analysis.changed_functions.is_empty() {
1098 detail_parts.push(format!(
1099 "updates {}",
1100 summarize_symbol_list(&analysis.changed_functions, 4)
1101 ));
1102 }
1103 if !analysis.removed_functions.is_empty() {
1104 detail_parts.push(format!(
1105 "removes {}",
1106 summarize_symbol_list(&analysis.removed_functions, 3)
1107 ));
1108 }
1109 if !analysis.new_types.is_empty() {
1110 detail_parts.push(format!(
1111 "introduces {}",
1112 summarize_symbol_list(&analysis.new_types, 3)
1113 ));
1114 }
1115 if !analysis.changed_types.is_empty() {
1116 detail_parts.push(format!(
1117 "reshapes {}",
1118 summarize_symbol_list(&analysis.changed_types, 3)
1119 ));
1120 }
1121 if !analysis.removed_types.is_empty() {
1122 detail_parts.push(format!(
1123 "drops {}",
1124 summarize_symbol_list(&analysis.removed_types, 3)
1125 ));
1126 }
1127 if !top_files.is_empty() {
1128 detail_parts.push(format!(
1129 "with the biggest diffs in {}",
1130 top_files.join(", ")
1131 ));
1132 }
1133 if !detail_parts.is_empty() {
1134 paragraphs.push(format!("{}.", detail_parts.join("; ")));
1135 }
1136
1137 paragraphs
1138}
1139
1140fn summarize_focus_areas(analysis: &WorktreeAnalysis, limit: usize) -> Vec<String> {
1141 let mut area_scores = BTreeMap::<String, u32>::new();
1142
1143 for file in &analysis.files {
1144 let Some(area) = describe_focus_area(&file.path) else {
1145 continue;
1146 };
1147 let weight = (file.additions + file.deletions).max(1);
1148 *area_scores.entry(area).or_default() += weight;
1149 }
1150
1151 let mut ranked = area_scores.into_iter().collect::<Vec<_>>();
1152 ranked.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1153 ranked
1154 .into_iter()
1155 .map(|(area, _)| area)
1156 .take(limit)
1157 .collect()
1158}
1159
1160#[derive(Debug, Clone, PartialEq, Eq)]
1161struct TopFileGroup {
1162 label: String,
1163 file_count: usize,
1164 additions: u32,
1165 deletions: u32,
1166}
1167
1168fn summarize_top_files(analysis: &WorktreeAnalysis, limit: usize) -> Vec<String> {
1169 let groups = collapse_top_file_groups(analysis);
1170
1171 groups
1172 .into_iter()
1173 .take(limit)
1174 .map(|group| format_top_file_group(&group))
1175 .collect()
1176}
1177
1178fn collapse_top_file_groups(analysis: &WorktreeAnalysis) -> Vec<TopFileGroup> {
1179 let mut parent_counts = BTreeMap::<String, usize>::new();
1180 for file in &analysis.files {
1181 if let Some(parent) = parent_dir_key(&file.path) {
1182 *parent_counts.entry(parent).or_default() += 1;
1183 }
1184 }
1185
1186 let mut groups = BTreeMap::<String, TopFileGroup>::new();
1187 for file in &analysis.files {
1188 let label = top_file_group_label(&file.path, &parent_counts);
1189 let entry = groups.entry(label.clone()).or_insert(TopFileGroup {
1190 label,
1191 file_count: 0,
1192 additions: 0,
1193 deletions: 0,
1194 });
1195 entry.file_count += 1;
1196 entry.additions += file.additions;
1197 entry.deletions += file.deletions;
1198 }
1199
1200 let mut ranked = groups.into_values().collect::<Vec<_>>();
1201 ranked.sort_by(|left, right| {
1202 let left_weight = left.additions + left.deletions;
1203 let right_weight = right.additions + right.deletions;
1204 right_weight
1205 .cmp(&left_weight)
1206 .then_with(|| left.label.cmp(&right.label))
1207 });
1208 ranked
1209}
1210
1211fn top_file_group_label(path: &str, parent_counts: &BTreeMap<String, usize>) -> String {
1212 let normalized = normalize_display_path(path);
1213 if normalized.starts_with("terminals/") {
1214 return "terminals/".to_string();
1215 }
1216
1217 if let Some(parent) = parent_dir_key(&normalized) {
1218 if parent_counts.get(&parent).copied().unwrap_or(0) > 1 {
1219 return format!("{}/", parent);
1220 }
1221 }
1222
1223 normalized
1224}
1225
1226fn is_terminal_session_path(path: &str) -> bool {
1227 normalize_display_path(path)
1228 .to_ascii_lowercase()
1229 .starts_with(TERMINAL_SESSION_DIR)
1230}
1231
1232fn terminal_session_paths(analysis: &WorktreeAnalysis) -> Vec<String> {
1233 analysis
1234 .files
1235 .iter()
1236 .filter(|file| is_terminal_session_path(&file.path))
1237 .map(|file| file.path.clone())
1238 .collect()
1239}
1240
1241fn render_terminal_session_block_message(paths: &[String]) -> String {
1242 format!(
1243 "Refusing to commit IDE terminal session dumps under `{}`. These files are agent session logs, not project source. The `.next-id` file is an IDE session counter, not application configuration.\nAdd `/terminals` to `.gitignore` and remove tracked copies with `git rm -r --cached terminals/`.\nIgnored paths: {}",
1244 TERMINAL_SESSION_DIR.trim_end_matches('/'),
1245 paths.join(", ")
1246 )
1247}
1248
1249fn parent_dir_key(path: &str) -> Option<String> {
1250 let normalized = normalize_display_path(path);
1251 Path::new(&normalized)
1252 .parent()
1253 .and_then(|value| value.to_str())
1254 .map(normalize_display_path)
1255 .filter(|value| !value.is_empty())
1256}
1257
1258fn format_top_file_group(group: &TopFileGroup) -> String {
1259 if group.file_count > 1 {
1260 format!(
1261 "{} ({} files, +{} -{})",
1262 group.label, group.file_count, group.additions, group.deletions
1263 )
1264 } else {
1265 format!(
1266 "{} (+{} -{})",
1267 group.label, group.additions, group.deletions
1268 )
1269 }
1270}
1271
1272fn describe_focus_area(path: &str) -> Option<String> {
1273 let normalized = normalize_display_path(path);
1274 if normalized.starts_with("terminals/") {
1275 return Some("terminals".to_string());
1276 }
1277 if normalized.starts_with("crates/cli/src/commands/") {
1278 return Path::new(&normalized)
1279 .file_stem()
1280 .and_then(|value| value.to_str())
1281 .map(humanize_focus_area)
1282 .filter(|value| !value.is_empty());
1283 }
1284 if normalized.contains("/http-app.") {
1285 return Some("http".to_string());
1286 }
1287 if normalized.contains("/server.") {
1288 return Some("server".to_string());
1289 }
1290 if normalized.contains("/worker/") {
1291 return Some("worker".to_string());
1292 }
1293 if let Some(stem) = interesting_file_stem(&normalized) {
1294 return Some(humanize_focus_area(&stem));
1295 }
1296
1297 normalized
1298 .split('/')
1299 .rev()
1300 .find_map(normalize_focus_segment)
1301}
1302
1303fn humanize_focus_area(raw: &str) -> String {
1304 raw.trim().replace(['_', '-'], " ").to_ascii_lowercase()
1305}
1306
1307fn is_generic_path_segment(segment: &str) -> bool {
1308 matches!(
1309 segment.to_ascii_lowercase().as_str(),
1310 "src"
1311 | "crates"
1312 | "apps"
1313 | "packages"
1314 | "commands"
1315 | "tests"
1316 | "test"
1317 | "docs"
1318 | "lib"
1319 | "terminals"
1320 )
1321}
1322
1323fn is_weak_focus_label(label: &str) -> bool {
1324 let trimmed = label.trim();
1325 trimmed.is_empty() || trimmed.chars().all(|ch| ch.is_ascii_digit())
1326}
1327
1328fn normalize_focus_segment(segment: &str) -> Option<String> {
1329 let trimmed = segment.trim();
1330 if trimmed.is_empty() || is_generic_path_segment(trimmed) {
1331 return None;
1332 }
1333
1334 let label = trimmed.trim_start_matches('.');
1335 if is_weak_focus_label(label) {
1336 return None;
1337 }
1338
1339 let humanized = humanize_focus_area(label);
1340 if humanized.is_empty() {
1341 None
1342 } else {
1343 Some(humanized)
1344 }
1345}
1346
1347fn infer_behavior_hint(analysis: &WorktreeAnalysis) -> Option<&'static str> {
1348 let mut corpus = String::new();
1349 for file in &analysis.files {
1350 corpus.push_str(&file.path.to_ascii_lowercase());
1351 corpus.push(' ');
1352 }
1353 for context in &analysis.hunk_contexts {
1354 corpus.push_str(&context.to_ascii_lowercase());
1355 corpus.push(' ');
1356 }
1357
1358 if corpus.contains("request") || corpus.contains("response") || corpus.contains("http") {
1359 Some("request handling")
1360 } else if corpus.contains("auth") || corpus.contains("token") || corpus.contains("session") {
1361 Some("auth flow")
1362 } else if corpus.contains("env") || corpus.contains("config") || corpus.contains("setting") {
1363 Some("configuration loading")
1364 } else if corpus.contains("release") || corpus.contains("version") || corpus.contains("tag") {
1365 Some("release flow")
1366 } else if corpus.contains("docker") || corpus.contains("container") {
1367 Some("container setup")
1368 } else {
1369 None
1370 }
1371}
1372
1373fn format_focus_area_list(areas: &[String]) -> String {
1374 match areas {
1375 [] => "the current worktree".to_string(),
1376 [one] => one.clone(),
1377 [left, right] => format!("{} and {}", left, right),
1378 _ => {
1379 let mut items = areas.to_vec();
1380 let last = items.pop().unwrap_or_default();
1381 format!("{}, and {}", items.join(", "), last)
1382 }
1383 }
1384}
1385
1386fn summarize_symbol_list(entries: &[String], limit: usize) -> String {
1387 entries
1388 .iter()
1389 .take(limit)
1390 .map(|entry| {
1391 entry
1392 .split_once(" (")
1393 .map(|(name, _)| name.to_string())
1394 .unwrap_or_else(|| entry.clone())
1395 })
1396 .collect::<Vec<_>>()
1397 .join(", ")
1398}
1399
1400fn render_commit_message(commit: &ConventionalCommit) -> String {
1401 let mut sections = vec![render_commit_subject(commit)];
1402
1403 if !commit.body.is_empty() {
1404 sections.push(commit.body.join("\n\n"));
1405 }
1406
1407 let mut footer_lines = commit
1408 .footers
1409 .iter()
1410 .map(|entry| entry.trim().to_string())
1411 .filter(|entry| !entry.is_empty())
1412 .collect::<Vec<_>>();
1413 if let Some(breaking_change) = &commit.breaking_change {
1414 if !footer_lines
1415 .iter()
1416 .any(|entry| entry.starts_with("BREAKING CHANGE:"))
1417 {
1418 footer_lines.push(format!("BREAKING CHANGE: {}", breaking_change));
1419 }
1420 }
1421 if !footer_lines.is_empty() {
1422 sections.push(footer_lines.join("\n"));
1423 }
1424
1425 sections.join("\n\n")
1426}
1427
1428fn render_commit_subject(commit: &ConventionalCommit) -> String {
1429 let scope = commit
1430 .scope
1431 .as_deref()
1432 .map(|value| format!("({})", value))
1433 .unwrap_or_default();
1434 let bang = if commit.breaking_change.is_some() {
1435 "!"
1436 } else {
1437 ""
1438 };
1439 format!(
1440 "{}{}{}: {}",
1441 commit.commit_type, scope, bang, commit.description
1442 )
1443}
1444
1445fn print_analysis_summary(
1446 analysis: &WorktreeAnalysis,
1447 commit_plan: &CommitMessagePlan,
1448 rendered_message: &str,
1449) {
1450 let focus_areas = summarize_focus_areas(analysis, MAX_PRINT_AREAS);
1451 let top_files = summarize_top_files(analysis, MAX_PRINT_FILES);
1452 let branch = analysis
1453 .branch
1454 .as_deref()
1455 .map(|value| format!(" on {}", value.bright_blue()))
1456 .unwrap_or_default();
1457 println!(
1458 "{} {}{}",
1459 "Commit".bright_green().bold(),
1460 analysis.repo_name.bright_white().bold(),
1461 branch
1462 );
1463 println!(
1464 " {} {}",
1465 "Mode".bright_cyan().bold(),
1466 match commit_plan.generation_mode {
1467 GenerationMode::OpenRouter => "OpenRouter".bright_white(),
1468 GenerationMode::Heuristic => "Heuristic fallback".bright_white(),
1469 }
1470 );
1471 println!(
1472 " {} {}",
1473 "Subject".bright_cyan().bold(),
1474 render_commit_subject(&commit_plan.commit).bright_white()
1475 );
1476 println!(
1477 " {} {} file{} (+{} -{})",
1478 "Diff".bright_cyan().bold(),
1479 analysis.files.len(),
1480 if analysis.files.len() == 1 { "" } else { "s" },
1481 analysis.total_additions,
1482 analysis.total_deletions
1483 );
1484 if !focus_areas.is_empty() {
1485 println!(
1486 " {} {}",
1487 "Focus".bright_cyan().bold(),
1488 focus_areas.join(", ")
1489 );
1490 }
1491 if !top_files.is_empty() {
1492 println!(" {} {}", "Top".bright_cyan().bold(), top_files.join(", "));
1493 }
1494
1495 if !analysis.new_functions.is_empty() {
1496 println!(
1497 " {} {}",
1498 "+fn".bright_cyan().bold(),
1499 summarize_symbol_list(&analysis.new_functions, MAX_PRINT_SYMBOLS)
1500 );
1501 }
1502 if !analysis.changed_functions.is_empty() {
1503 println!(
1504 " {} {}",
1505 "~fn".bright_cyan().bold(),
1506 summarize_symbol_list(&analysis.changed_functions, MAX_PRINT_SYMBOLS)
1507 );
1508 }
1509 if !analysis.new_types.is_empty() {
1510 println!(
1511 " {} {}",
1512 "+type".bright_cyan().bold(),
1513 summarize_symbol_list(&analysis.new_types, MAX_PRINT_SYMBOLS)
1514 );
1515 }
1516 if !analysis.changed_types.is_empty() {
1517 println!(
1518 " {} {}",
1519 "~type".bright_cyan().bold(),
1520 summarize_symbol_list(&analysis.changed_types, MAX_PRINT_SYMBOLS)
1521 );
1522 }
1523 if !analysis.removed_functions.is_empty() {
1524 println!(
1525 " {} {}",
1526 "-fn".bright_cyan().bold(),
1527 summarize_symbol_list(&analysis.removed_functions, MAX_PRINT_SYMBOLS)
1528 );
1529 }
1530 if !analysis.removed_types.is_empty() {
1531 println!(
1532 " {} {}",
1533 "-type".bright_cyan().bold(),
1534 summarize_symbol_list(&analysis.removed_types, MAX_PRINT_SYMBOLS)
1535 );
1536 }
1537
1538 println!("\n{}", "Generated commit".bright_magenta().bold());
1539 println!("{}", "-".repeat(72).bright_black());
1540 println!("{}", rendered_message);
1541 println!("{}", "-".repeat(72).bright_black());
1542}
1543
1544async fn stage_and_commit(
1545 repo_root: &Path,
1546 message: &str,
1547 exclude_paths: &[String],
1548) -> Result<StageAndCommitOutcome, String> {
1549 git_output(repo_root, &["add", "--all"]).await?;
1550 for path in exclude_paths {
1551 git_output(repo_root, &["reset", "HEAD", "--", path.as_str()]).await?;
1552 }
1553
1554 if !git_has_staged_changes(repo_root).await? {
1555 return Ok(StageAndCommitOutcome::NothingToCommit);
1556 }
1557
1558 let message_path = env::temp_dir().join(format!("xbp-commit-message-{}.txt", Uuid::new_v4()));
1559 fs::write(&message_path, message).map_err(|e| {
1560 format!(
1561 "Failed to write temporary commit message {}: {}",
1562 message_path.display(),
1563 e
1564 )
1565 })?;
1566
1567 let result = git_output(
1568 repo_root,
1569 &["commit", "--file", &message_path.to_string_lossy()],
1570 )
1571 .await;
1572 let _ = fs::remove_file(&message_path);
1573 match result {
1574 Ok(_) => Ok(StageAndCommitOutcome::Committed),
1575 Err(error) if looks_like_nothing_to_commit_error(&error) => {
1576 if !git_has_staged_changes(repo_root).await? && git_worktree_is_clean(repo_root).await?
1577 {
1578 Ok(StageAndCommitOutcome::NothingToCommit)
1579 } else {
1580 Err(error)
1581 }
1582 }
1583 Err(error) => Err(error),
1584 }
1585}
1586
1587fn parse_status_entries(output: &str) -> Vec<StatusEntry> {
1588 output
1589 .lines()
1590 .filter_map(|line| {
1591 if line.len() < 4 {
1592 return None;
1593 }
1594 let code = line[..2].trim().to_string();
1595 let path = line[3..].trim().replace('\\', "/");
1596 if path.is_empty() {
1597 None
1598 } else {
1599 Some(StatusEntry { code, path })
1600 }
1601 })
1602 .collect()
1603}
1604
1605fn merge_file_summaries(name_status: &str, numstat: &str) -> Vec<FileChangeSummary> {
1606 let mut status_map = BTreeMap::new();
1607 let mut display_path_map = BTreeMap::new();
1608
1609 for raw_line in name_status
1610 .lines()
1611 .map(str::trim)
1612 .filter(|line| !line.is_empty())
1613 {
1614 let parts = raw_line.split('\t').collect::<Vec<_>>();
1615 if parts.is_empty() {
1616 continue;
1617 }
1618 let status = normalize_name_status(parts[0]);
1619 match parts.as_slice() {
1620 [_, path] => {
1621 let key = normalize_path_key(path);
1622 display_path_map.insert(key.clone(), normalize_display_path(path));
1623 status_map.insert(key, status);
1624 }
1625 [_, old_path, new_path] => {
1626 let key = normalize_path_key(new_path);
1627 display_path_map.insert(
1628 key.clone(),
1629 format!(
1630 "{} -> {}",
1631 normalize_display_path(old_path),
1632 normalize_display_path(new_path)
1633 ),
1634 );
1635 status_map.insert(key, status);
1636 }
1637 _ => {}
1638 }
1639 }
1640
1641 let mut files = Vec::new();
1642 for raw_line in numstat
1643 .lines()
1644 .map(str::trim)
1645 .filter(|line| !line.is_empty())
1646 {
1647 let parts = raw_line.split('\t').collect::<Vec<_>>();
1648 if parts.len() < 3 {
1649 continue;
1650 }
1651 let additions = parts[0].parse::<u32>().unwrap_or(0);
1652 let deletions = parts[1].parse::<u32>().unwrap_or(0);
1653 let raw_path = parts[2..].join("\t");
1654 let key = normalize_path_key(&raw_path);
1655 let path = display_path_map
1656 .get(&key)
1657 .cloned()
1658 .unwrap_or_else(|| normalize_display_path(&raw_path));
1659 let status = status_map
1660 .get(&key)
1661 .cloned()
1662 .unwrap_or_else(|| "modified".to_string());
1663 files.push(FileChangeSummary {
1664 path,
1665 status,
1666 additions,
1667 deletions,
1668 });
1669 }
1670
1671 if files.is_empty() {
1672 for (key, status) in status_map {
1673 files.push(FileChangeSummary {
1674 path: display_path_map
1675 .get(&key)
1676 .cloned()
1677 .unwrap_or_else(|| key.clone()),
1678 status,
1679 additions: 0,
1680 deletions: 0,
1681 });
1682 }
1683 }
1684
1685 files
1686}
1687
1688fn summarize_symbols(diff_text: &str) -> SymbolSummary {
1689 let mut current_file = String::new();
1690 let mut added_functions = BTreeSet::new();
1691 let mut removed_functions = BTreeSet::new();
1692 let mut added_types = BTreeSet::new();
1693 let mut removed_types = BTreeSet::new();
1694 let mut changed_functions = BTreeSet::new();
1695 let mut changed_types = BTreeSet::new();
1696 let mut hunk_contexts = BTreeSet::new();
1697
1698 for line in diff_text.lines() {
1699 if let Some(rest) = line.strip_prefix("+++ b/") {
1700 current_file = rest.trim().replace('\\', "/");
1701 continue;
1702 }
1703 if line.starts_with("@@") {
1704 if let Some(context) = parse_hunk_context(line) {
1705 if is_code_path(¤t_file) {
1706 if let Some((symbol, kind)) = extract_context_symbol(&context) {
1707 match kind {
1708 SymbolKind::Function => {
1709 changed_functions.insert(symbol);
1710 }
1711 SymbolKind::Type => {
1712 changed_types.insert(symbol);
1713 }
1714 }
1715 }
1716 }
1717 hunk_contexts.insert(context);
1718 }
1719 continue;
1720 }
1721
1722 if current_file.is_empty() || line.starts_with("+++") || line.starts_with("---") {
1723 continue;
1724 }
1725
1726 if let Some(source) = line.strip_prefix('+') {
1727 if let Some(name) = extract_function_name(source) {
1728 added_functions.insert(format!("{} ({})", name, current_file));
1729 }
1730 if let Some(name) = extract_type_name(source) {
1731 added_types.insert(format!("{} ({})", name, current_file));
1732 }
1733 } else if let Some(source) = line.strip_prefix('-') {
1734 if let Some(name) = extract_function_name(source) {
1735 removed_functions.insert(format!("{} ({})", name, current_file));
1736 }
1737 if let Some(name) = extract_type_name(source) {
1738 removed_types.insert(format!("{} ({})", name, current_file));
1739 }
1740 }
1741 }
1742
1743 let changed_functions_from_dupes = added_functions
1744 .intersection(&removed_functions)
1745 .cloned()
1746 .collect::<BTreeSet<_>>();
1747 let changed_types_from_dupes = added_types
1748 .intersection(&removed_types)
1749 .cloned()
1750 .collect::<BTreeSet<_>>();
1751
1752 for entry in &changed_functions_from_dupes {
1753 changed_functions.insert(entry.clone());
1754 }
1755 for entry in &changed_types_from_dupes {
1756 changed_types.insert(entry.clone());
1757 }
1758
1759 let new_functions = added_functions
1760 .difference(&changed_functions_from_dupes)
1761 .cloned()
1762 .collect::<Vec<_>>();
1763 let removed_functions = removed_functions
1764 .difference(&changed_functions_from_dupes)
1765 .cloned()
1766 .collect::<Vec<_>>();
1767 let new_types = added_types
1768 .difference(&changed_types_from_dupes)
1769 .cloned()
1770 .collect::<Vec<_>>();
1771 let removed_types = removed_types
1772 .difference(&changed_types_from_dupes)
1773 .cloned()
1774 .collect::<Vec<_>>();
1775
1776 SymbolSummary {
1777 new_functions,
1778 changed_functions: changed_functions.into_iter().collect(),
1779 removed_functions,
1780 new_types,
1781 changed_types: changed_types.into_iter().collect(),
1782 removed_types,
1783 hunk_contexts: hunk_contexts.into_iter().collect(),
1784 }
1785}
1786
1787fn parse_hunk_context(line: &str) -> Option<String> {
1788 let parts = line.split("@@").collect::<Vec<_>>();
1789 if parts.len() < 3 {
1790 return None;
1791 }
1792 let context = parts[2].trim();
1793 if context.is_empty() {
1794 None
1795 } else {
1796 Some(context.to_string())
1797 }
1798}
1799
1800fn extract_context_symbol(context: &str) -> Option<(String, SymbolKind)> {
1801 let trimmed = context.trim();
1802 if trimmed.is_empty() {
1803 return None;
1804 }
1805
1806 for capture in [
1807 RUST_FUNCTION_RE.captures(trimmed),
1808 JS_FUNCTION_RE.captures(trimmed),
1809 ]
1810 .into_iter()
1811 .flatten()
1812 {
1813 if let Some(name) = capture.get(1) {
1814 let symbol = name.as_str().to_string();
1815 if is_noise_symbol(&symbol) {
1816 return None;
1817 }
1818 return Some((symbol, SymbolKind::Function));
1819 }
1820 }
1821
1822 if let Some(capture) = JS_ARROW_RE.captures(trimmed) {
1823 if let Some(name) = capture.get(1) {
1824 let symbol = name.as_str().to_string();
1825 if is_noise_symbol(&symbol) {
1826 return None;
1827 }
1828 return Some((symbol, SymbolKind::Function));
1829 }
1830 }
1831
1832 for capture in [RUST_TYPE_RE.captures(trimmed), TS_TYPE_RE.captures(trimmed)]
1833 .into_iter()
1834 .flatten()
1835 {
1836 if let Some(name) = capture.get(2) {
1837 let symbol = name.as_str().to_string();
1838 if is_noise_symbol(&symbol) {
1839 return None;
1840 }
1841 return Some((symbol, SymbolKind::Type));
1842 }
1843 }
1844
1845 if let Some(capture) = METHOD_CONTEXT_RE.captures(trimmed) {
1846 if let Some(name) = capture.get(1) {
1847 let symbol = name.as_str().to_string();
1848 if is_noise_symbol(&symbol) {
1849 return None;
1850 }
1851 return Some((symbol, SymbolKind::Function));
1852 }
1853 }
1854
1855 None
1856}
1857
1858fn extract_function_name(source: &str) -> Option<String> {
1859 for capture in [
1860 RUST_FUNCTION_RE.captures(source),
1861 JS_FUNCTION_RE.captures(source),
1862 JS_ARROW_RE.captures(source),
1863 ]
1864 .into_iter()
1865 .flatten()
1866 {
1867 if let Some(name) = capture.get(1) {
1868 return Some(name.as_str().to_string());
1869 }
1870 }
1871 None
1872}
1873
1874fn extract_type_name(source: &str) -> Option<String> {
1875 for capture in [RUST_TYPE_RE.captures(source), TS_TYPE_RE.captures(source)]
1876 .into_iter()
1877 .flatten()
1878 {
1879 if let Some(name) = capture.get(2) {
1880 return Some(name.as_str().to_string());
1881 }
1882 }
1883 None
1884}
1885
1886fn is_code_path(path: &str) -> bool {
1887 let normalized = path.to_ascii_lowercase();
1888 [
1889 ".rs", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".java", ".kt", ".swift",
1890 ]
1891 .iter()
1892 .any(|extension| normalized.ends_with(extension))
1893}
1894
1895fn is_noise_symbol(symbol: &str) -> bool {
1896 matches!(
1897 symbol,
1898 "fn" | "pub"
1899 | "impl"
1900 | "mod"
1901 | "use"
1902 | "struct"
1903 | "enum"
1904 | "trait"
1905 | "type"
1906 | "class"
1907 | "interface"
1908 | "const"
1909 | "let"
1910 | "var"
1911 | "async"
1912 | "await"
1913 | "match"
1914 | "if"
1915 | "else"
1916 | "for"
1917 | "while"
1918 | "loop"
1919 )
1920}
1921
1922fn sanitize_commit_type(raw: &str) -> Option<String> {
1923 let normalized = raw.trim().to_ascii_lowercase();
1924 if CONVENTIONAL_TYPES.contains(&normalized.as_str()) {
1925 Some(normalized)
1926 } else {
1927 None
1928 }
1929}
1930
1931fn sanitize_scope(raw: Option<&str>) -> Option<String> {
1932 let raw = raw?.trim();
1933 if raw.is_empty() {
1934 return None;
1935 }
1936 let sanitized = raw
1937 .chars()
1938 .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/'))
1939 .collect::<String>()
1940 .to_ascii_lowercase();
1941 if sanitized.is_empty() {
1942 None
1943 } else {
1944 Some(sanitized)
1945 }
1946}
1947
1948fn sanitize_description(raw: &str) -> Option<String> {
1949 let trimmed = raw.trim().trim_matches('"').trim();
1950 if trimmed.is_empty() {
1951 return None;
1952 }
1953 let normalized = trimmed.trim_end_matches('.').trim();
1954 if normalized.is_empty() {
1955 None
1956 } else {
1957 Some(normalized.to_string())
1958 }
1959}
1960
1961fn sanitize_footer_value(raw: &str) -> Option<String> {
1962 let trimmed = raw.trim();
1963 if trimmed.is_empty() {
1964 None
1965 } else {
1966 Some(trimmed.to_string())
1967 }
1968}
1969
1970fn interesting_file_stem(path: &str) -> Option<String> {
1971 let stem = Path::new(path)
1972 .file_stem()
1973 .and_then(|value| value.to_str())
1974 .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase())?;
1975 if stem.is_empty()
1976 || is_weak_focus_label(&stem)
1977 || matches!(
1978 stem.as_str(),
1979 "mod" | "lib" | "main" | "readme" | "index" | "commands" | "router"
1980 )
1981 {
1982 None
1983 } else {
1984 Some(stem)
1985 }
1986}
1987
1988fn normalize_name_status(raw: &str) -> String {
1989 let code = raw.chars().next().unwrap_or('M');
1990 match code {
1991 'A' => "added",
1992 'D' => "deleted",
1993 'R' => "renamed",
1994 'C' => "copied",
1995 'T' => "typechange",
1996 'U' => "unmerged",
1997 'M' => "modified",
1998 _ => "modified",
1999 }
2000 .to_string()
2001}
2002
2003fn normalize_display_path(raw: &str) -> String {
2004 raw.trim().replace('\\', "/")
2005}
2006
2007fn normalize_path_key(raw: &str) -> String {
2008 let cleaned = raw.trim().replace(['{', '}'], "").replace('\\', "/");
2009 if let Some((_, tail)) = cleaned.split_once("=>") {
2010 tail.trim().to_string()
2011 } else {
2012 cleaned
2013 }
2014}
2015
2016fn repo_name(path: &Path) -> String {
2017 path.file_name()
2018 .and_then(|value| value.to_str())
2019 .filter(|value| !value.trim().is_empty())
2020 .unwrap_or("repository")
2021 .to_string()
2022}
2023
2024fn truncate_for_prompt(text: &str, limit: usize) -> String {
2025 if text.chars().count() <= limit {
2026 return text.to_string();
2027 }
2028
2029 let truncated = text.chars().take(limit).collect::<String>();
2030 format!(
2031 "{}\n\n[diff truncated after {} characters]",
2032 truncated, limit
2033 )
2034}
2035
2036async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
2037 git_output_with_env(project_root, args, &[]).await
2038}
2039
2040async fn git_has_staged_changes(project_root: &Path) -> Result<bool, String> {
2041 let output = Command::new("git")
2042 .current_dir(project_root)
2043 .args(["diff", "--cached", "--quiet", "--exit-code"])
2044 .output()
2045 .await
2046 .map_err(|e| {
2047 format!(
2048 "Failed to run `git diff --cached --quiet --exit-code`: {}",
2049 e
2050 )
2051 })?;
2052
2053 match output.status.code() {
2054 Some(0) => Ok(false),
2055 Some(1) => Ok(true),
2056 _ => {
2057 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2058 if stderr.is_empty() {
2059 Err(format!(
2060 "`git diff --cached --quiet --exit-code` failed with status {}",
2061 output.status
2062 ))
2063 } else {
2064 Err(format!(
2065 "`git diff --cached --quiet --exit-code` failed: {}",
2066 stderr
2067 ))
2068 }
2069 }
2070 }
2071}
2072
2073async fn git_worktree_is_clean(project_root: &Path) -> Result<bool, String> {
2074 let status = git_output(
2075 project_root,
2076 &["status", "--porcelain=v1", "--untracked-files=all"],
2077 )
2078 .await?;
2079 Ok(parse_status_entries(&status).is_empty())
2080}
2081
2082fn looks_like_nothing_to_commit_error(error: &str) -> bool {
2083 let lowered = error.to_ascii_lowercase();
2084 lowered.contains("nothing to commit")
2085 || lowered.contains("working tree clean")
2086 || lowered.contains("no changes added to commit")
2087}
2088
2089async fn git_output_with_env(
2090 project_root: &Path,
2091 args: &[&str],
2092 envs: &[(&str, &std::ffi::OsStr)],
2093) -> Result<String, String> {
2094 let mut command = Command::new("git");
2095 command.current_dir(project_root).args(args);
2096 for (key, value) in envs {
2097 command.env(key, value);
2098 }
2099
2100 let output = command
2101 .output()
2102 .await
2103 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
2104
2105 if !output.status.success() {
2106 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2107 if stderr.is_empty() {
2108 return Err(format!(
2109 "`git {}` failed with status {}",
2110 args.join(" "),
2111 output.status
2112 ));
2113 }
2114 return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
2115 }
2116
2117 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
2118}
2119
2120struct TemporaryIndex {
2121 path: PathBuf,
2122}
2123
2124impl Drop for TemporaryIndex {
2125 fn drop(&mut self) {
2126 let _ = fs::remove_file(&self.path);
2127 }
2128}
2129
2130#[cfg(test)]
2131mod tests {
2132 use super::{
2133 build_heuristic_body, build_heuristic_commit, describe_focus_area, infer_commit_type,
2134 infer_description, is_terminal_session_path, looks_like_nothing_to_commit_error,
2135 parse_ai_commit_payload, render_clean_worktree_message, render_commit_message,
2136 render_terminal_session_block_message, sanitize_scope, summarize_focus_areas,
2137 summarize_symbols, summarize_top_files, terminal_session_paths, BranchSyncStatus,
2138 ConventionalCommit, FileChangeSummary, RepoContext, StatusEntry, WorktreeAnalysis,
2139 };
2140 use std::path::PathBuf;
2141
2142 fn server_refactor_analysis() -> WorktreeAnalysis {
2143 WorktreeAnalysis {
2144 repo_root: PathBuf::from("C:/repo"),
2145 repo_name: "xbp".to_string(),
2146 branch: Some("feature/refine-server".to_string()),
2147 status_entries: vec![StatusEntry {
2148 code: "M".to_string(),
2149 path: "src/server.ts".to_string(),
2150 }],
2151 files: vec![
2152 FileChangeSummary {
2153 path: "src/server.ts".to_string(),
2154 status: "modified".to_string(),
2155 additions: 80,
2156 deletions: 24,
2157 },
2158 FileChangeSummary {
2159 path: "src/http-app.ts".to_string(),
2160 status: "modified".to_string(),
2161 additions: 42,
2162 deletions: 11,
2163 },
2164 FileChangeSummary {
2165 path: "src/worker/types.ts".to_string(),
2166 status: "modified".to_string(),
2167 additions: 12,
2168 deletions: 3,
2169 },
2170 ],
2171 total_additions: 134,
2172 total_deletions: 38,
2173 diff_text: String::new(),
2174 new_functions: vec![
2175 "buildEnvFromProcess (src/server.ts)".to_string(),
2176 "handleNodeRequest (src/server.ts)".to_string(),
2177 ],
2178 changed_functions: vec!["handleHttpRequest (src/http-app.ts)".to_string()],
2179 removed_functions: Vec::new(),
2180 new_types: vec!["ExecutionContextLike (src/http-app.ts)".to_string()],
2181 changed_types: vec!["Env (src/worker/types.ts)".to_string()],
2182 removed_types: Vec::new(),
2183 hunk_contexts: vec!["async function handleHttpRequest(request: Request)".to_string()],
2184 }
2185 }
2186
2187 #[test]
2188 fn symbol_summary_tracks_new_and_changed_symbols() {
2189 let diff = r#"
2190diff --git a/crates/cli/src/commands/commit.rs b/crates/cli/src/commands/commit.rs
2191index 1111111..2222222 100644
2192--- a/crates/cli/src/commands/commit.rs
2193+++ b/crates/cli/src/commands/commit.rs
2194@@ -0,0 +1,3 @@
2195+pub struct CommitArgs {
2196+}
2197+pub async fn run_commit() {}
2198@@ -10,1 +12,1 @@ pub async fn old_name() {}
2199-pub async fn old_name() {}
2200+pub async fn old_name() {}
2201"#;
2202 let summary = summarize_symbols(diff);
2203 assert!(summary
2204 .new_functions
2205 .iter()
2206 .any(|item| item.contains("run_commit")));
2207 assert!(summary
2208 .new_types
2209 .iter()
2210 .any(|item| item.contains("CommitArgs")));
2211 assert!(summary
2212 .changed_functions
2213 .iter()
2214 .any(|item| item.contains("old_name")));
2215 }
2216
2217 #[test]
2218 fn ai_payload_respects_forced_scope() {
2219 let raw = r#"{
2220 "type": "feat",
2221 "scope": "wrong",
2222 "description": "add commit command",
2223 "body": ["Summarize the worktree."],
2224 "breaking_change": false,
2225 "footers": []
2226}"#;
2227
2228 let commit = parse_ai_commit_payload(raw, Some("cli")).expect("parse");
2229 assert_eq!(commit.scope.as_deref(), Some("cli"));
2230 assert_eq!(commit.commit_type, "feat");
2231 }
2232
2233 #[test]
2234 fn renders_breaking_change_footer_once() {
2235 let rendered = render_commit_message(&ConventionalCommit {
2236 commit_type: "feat".to_string(),
2237 scope: Some("cli".to_string()),
2238 description: "add commit command".to_string(),
2239 body: vec!["Summarize the worktree.".to_string()],
2240 breaking_change: Some("existing automation must call xbp commit".to_string()),
2241 footers: Vec::new(),
2242 });
2243
2244 assert!(rendered.contains("feat(cli)!: add commit command"));
2245 assert!(rendered.contains("BREAKING CHANGE: existing automation must call xbp commit"));
2246 }
2247
2248 #[test]
2249 fn infers_feat_for_new_symbols() {
2250 let analysis = WorktreeAnalysis {
2251 repo_root: PathBuf::from("C:/repo"),
2252 repo_name: "xbp".to_string(),
2253 branch: Some("main".to_string()),
2254 status_entries: vec![StatusEntry {
2255 code: "??".to_string(),
2256 path: "crates/cli/src/commands/commit.rs".to_string(),
2257 }],
2258 files: vec![FileChangeSummary {
2259 path: "crates/cli/src/commands/commit.rs".to_string(),
2260 status: "added".to_string(),
2261 additions: 120,
2262 deletions: 0,
2263 }],
2264 total_additions: 120,
2265 total_deletions: 0,
2266 diff_text: String::new(),
2267 new_functions: vec!["run_commit (crates/cli/src/commands/commit.rs)".to_string()],
2268 changed_functions: Vec::new(),
2269 removed_functions: Vec::new(),
2270 new_types: vec!["CommitArgs (crates/cli/src/commands/commit.rs)".to_string()],
2271 changed_types: Vec::new(),
2272 removed_types: Vec::new(),
2273 hunk_contexts: Vec::new(),
2274 };
2275
2276 assert_eq!(infer_commit_type(&analysis), "feat");
2277 }
2278
2279 #[test]
2280 fn infers_refactor_for_new_symbols_inside_existing_modules() {
2281 let analysis = server_refactor_analysis();
2282 assert_eq!(infer_commit_type(&analysis), "refactor");
2283 }
2284
2285 #[test]
2286 fn description_prefers_focus_area_and_behavior_hint() {
2287 let analysis = server_refactor_analysis();
2288 assert_eq!(
2289 infer_description(&analysis, "refactor", None),
2290 "refine server request handling"
2291 );
2292 }
2293
2294 #[test]
2295 fn heuristic_body_reads_like_a_summary() {
2296 let analysis = server_refactor_analysis();
2297 let body = build_heuristic_body(&analysis);
2298 assert_eq!(body.len(), 2);
2299 assert!(body[0].contains("server"));
2300 assert!(body[1].contains("adds buildEnvFromProcess"));
2301 assert!(body[1].contains("src/ (2 files"));
2302 }
2303
2304 #[test]
2305 fn clean_worktree_message_calls_out_pending_pushes() {
2306 let repo = RepoContext {
2307 repo_root: PathBuf::from("C:/repo"),
2308 repo_name: "xbp".to_string(),
2309 branch: Some("main".to_string()),
2310 };
2311 let message = render_clean_worktree_message(
2312 &repo,
2313 &BranchSyncStatus {
2314 upstream: Some("origin/main".to_string()),
2315 ahead: 2,
2316 behind: 0,
2317 },
2318 );
2319
2320 assert!(message.contains("Nothing to commit."));
2321 assert!(message.contains("2 commit(s) waiting to push"));
2322 assert!(message.contains("origin/main"));
2323 }
2324
2325 #[test]
2326 fn clean_worktree_message_calls_out_synced_branch() {
2327 let repo = RepoContext {
2328 repo_root: PathBuf::from("C:/repo"),
2329 repo_name: "xbp".to_string(),
2330 branch: Some("main".to_string()),
2331 };
2332 let message = render_clean_worktree_message(
2333 &repo,
2334 &BranchSyncStatus {
2335 upstream: Some("origin/main".to_string()),
2336 ahead: 0,
2337 behind: 0,
2338 },
2339 );
2340
2341 assert!(message.contains("Nothing to commit."));
2342 assert!(message.contains("already in sync"));
2343 }
2344
2345 #[test]
2346 fn detects_nothing_to_commit_git_errors() {
2347 assert!(looks_like_nothing_to_commit_error(
2348 "`git commit --file msg.txt` failed: nothing to commit, working tree clean"
2349 ));
2350 assert!(looks_like_nothing_to_commit_error(
2351 "`git commit --file msg.txt` failed: no changes added to commit"
2352 ));
2353 assert!(!looks_like_nothing_to_commit_error(
2354 "`git commit --file msg.txt` failed: pre-commit hook exited with code 1"
2355 ));
2356 }
2357
2358 #[test]
2359 fn scope_sanitizer_keeps_valid_tokens() {
2360 assert_eq!(
2361 sanitize_scope(Some("CLI/tools")),
2362 Some("cli/tools".to_string())
2363 );
2364 assert_eq!(sanitize_scope(Some(" ")), None);
2365 }
2366
2367 #[test]
2368 fn terminal_session_paths_are_detected_and_blocked() {
2369 let analysis = WorktreeAnalysis {
2370 repo_root: PathBuf::from("C:/repo"),
2371 repo_name: "xbp".to_string(),
2372 branch: Some("main".to_string()),
2373 status_entries: Vec::new(),
2374 files: vec![
2375 FileChangeSummary {
2376 path: "terminals/5.txt".to_string(),
2377 status: "added".to_string(),
2378 additions: 388_613,
2379 deletions: 0,
2380 },
2381 FileChangeSummary {
2382 path: "terminals/.next-id".to_string(),
2383 status: "modified".to_string(),
2384 additions: 1,
2385 deletions: 1,
2386 },
2387 ],
2388 total_additions: 388_614,
2389 total_deletions: 1,
2390 diff_text: String::new(),
2391 new_functions: Vec::new(),
2392 changed_functions: Vec::new(),
2393 removed_functions: Vec::new(),
2394 new_types: Vec::new(),
2395 changed_types: Vec::new(),
2396 removed_types: Vec::new(),
2397 hunk_contexts: Vec::new(),
2398 };
2399
2400 assert!(is_terminal_session_path("terminals/5.txt"));
2401 assert!(!is_terminal_session_path(
2402 "crates/cli/src/commands/commit.rs"
2403 ));
2404 assert_eq!(
2405 terminal_session_paths(&analysis),
2406 vec![
2407 "terminals/5.txt".to_string(),
2408 "terminals/.next-id".to_string()
2409 ]
2410 );
2411 assert!(
2412 render_terminal_session_block_message(&terminal_session_paths(&analysis))
2413 .contains("Refusing to commit IDE terminal session dumps")
2414 );
2415 assert_eq!(infer_commit_type(&analysis), "chore");
2416 assert_eq!(
2417 infer_description(&analysis, "chore", None),
2418 "ignore ide terminal session logs"
2419 );
2420
2421 let heuristic = build_heuristic_commit(&analysis, None);
2422 assert_eq!(heuristic.commit_type, "chore");
2423 assert_eq!(heuristic.description, "ignore ide terminal session logs");
2424 assert!(!render_commit_message(&heuristic).contains("flow"));
2425 }
2426
2427 #[test]
2428 fn focus_area_groups_terminal_session_files() {
2429 assert_eq!(
2430 describe_focus_area("terminals/5.txt"),
2431 Some("terminals".to_string())
2432 );
2433 assert_eq!(
2434 describe_focus_area("terminals/.next-id"),
2435 Some("terminals".to_string())
2436 );
2437 assert_eq!(
2438 describe_focus_area(".gitignore"),
2439 Some("gitignore".to_string())
2440 );
2441 }
2442
2443 #[test]
2444 fn summarize_focus_areas_collapses_terminal_logs() {
2445 let analysis = WorktreeAnalysis {
2446 repo_root: PathBuf::from("C:/repo"),
2447 repo_name: "xbp".to_string(),
2448 branch: Some("main".to_string()),
2449 status_entries: Vec::new(),
2450 files: vec![
2451 FileChangeSummary {
2452 path: "terminals/5.txt".to_string(),
2453 status: "deleted".to_string(),
2454 additions: 0,
2455 deletions: 388_613,
2456 },
2457 FileChangeSummary {
2458 path: "terminals/3.txt".to_string(),
2459 status: "deleted".to_string(),
2460 additions: 0,
2461 deletions: 439,
2462 },
2463 FileChangeSummary {
2464 path: "terminals/.next-id".to_string(),
2465 status: "modified".to_string(),
2466 additions: 0,
2467 deletions: 1,
2468 },
2469 FileChangeSummary {
2470 path: ".gitignore".to_string(),
2471 status: "modified".to_string(),
2472 additions: 1,
2473 deletions: 0,
2474 },
2475 ],
2476 total_additions: 1,
2477 total_deletions: 389_053,
2478 diff_text: String::new(),
2479 new_functions: Vec::new(),
2480 changed_functions: Vec::new(),
2481 removed_functions: Vec::new(),
2482 new_types: Vec::new(),
2483 changed_types: Vec::new(),
2484 removed_types: Vec::new(),
2485 hunk_contexts: Vec::new(),
2486 };
2487
2488 assert_eq!(
2489 summarize_focus_areas(&analysis, 4),
2490 vec!["terminals".to_string(), "gitignore".to_string()]
2491 );
2492 assert_eq!(
2493 summarize_top_files(&analysis, 4),
2494 vec![
2495 "terminals/ (3 files, +0 -389053)".to_string(),
2496 ".gitignore (+1 -0)".to_string(),
2497 ]
2498 );
2499 }
2500}