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