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