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