1use crate::cli::auto_commit::{
2 print_push_summary, pull_rebase_current_branch, push_current_branch_with_name,
3 should_push_after_auto_commit,
4};
5use crate::config::{
6 resolve_openrouter_api_key, resolve_openrouter_commit_model,
7 resolve_openrouter_commit_system_prompt,
8};
9use crate::openrouter::{complete_prompt_with_options, CompletionOptions};
10use crate::utils::command_exists;
11use colored::Colorize;
12use once_cell::sync::Lazy;
13use regex::Regex;
14use serde::Deserialize;
15use std::collections::{BTreeMap, BTreeSet};
16use std::env;
17use std::fs;
18use std::io::{self, Write as _};
19use std::path::{Path, PathBuf};
20use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
21use tokio::process::Command;
22use uuid::Uuid;
23
24#[path = "commit_prompt_preprocess.rs"]
25mod commit_prompt_preprocess;
26
27use self::commit_prompt_preprocess::{build_prompt_diff_excerpt, prompt_diff_omit_reason};
28
29static RUST_FUNCTION_RE: Lazy<Regex> = Lazy::new(|| {
30 Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)")
31 .expect("valid rust function regex")
32});
33static JS_FUNCTION_RE: Lazy<Regex> = Lazy::new(|| {
34 Regex::new(
35 r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)",
36 )
37 .expect("valid js function regex")
38});
39static JS_ARROW_RE: Lazy<Regex> = Lazy::new(|| {
40 Regex::new(
41 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*=>",
42 )
43 .expect("valid js arrow regex")
44});
45static METHOD_CONTEXT_RE: Lazy<Regex> = Lazy::new(|| {
46 Regex::new(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(").expect("valid method context regex")
47});
48static RUST_TYPE_RE: Lazy<Regex> = Lazy::new(|| {
49 Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(struct|enum|trait|type)\s+([A-Za-z_][A-Za-z0-9_]*)")
50 .expect("valid rust type regex")
51});
52static TS_TYPE_RE: Lazy<Regex> = Lazy::new(|| {
53 Regex::new(r"^\s*(?:export\s+)?(interface|type|enum|class)\s+([A-Za-z_$][A-Za-z0-9_$]*)")
54 .expect("valid ts type regex")
55});
56
57const MAX_PROMPT_FILES: usize = 24;
58const MAX_PROMPT_SYMBOLS: usize = 12;
59const MAX_PROMPT_CONTEXTS: usize = 12;
60const MAX_PROMPT_AREAS: usize = 4;
61const MAX_PROMPT_OMITTED_DIFFS: usize = 12;
62const MAX_PROMPT_DIFF_SUMMARIES: usize = 16;
63const MAX_PRINT_SYMBOLS: usize = 8;
64const MAX_PRINT_AREAS: usize = 4;
65const MAX_PRINT_FILES: usize = 4;
66const CONVENTIONAL_TYPES: &[&str] = &[
67 "feat", "fix", "docs", "refactor", "chore", "test", "build", "ci", "perf", "style", "revert",
68];
69const TERMINAL_SESSION_DIR: &str = "terminals/";
70#[cfg(test)]
73const SAFE_GIT_ARGV_CHAR_BUDGET: usize = 6_000;
74#[cfg(test)]
76const SAFE_GIT_PATHSPEC_COUNT: usize = 64;
77const MAX_GIT_ERROR_ARG_CHARS: usize = 240;
78const COMMIT_AI_MAX_TOKENS: u32 = 320;
79
80#[derive(Debug, Clone)]
81pub struct CommitArgs {
82 pub dry_run: bool,
83 pub push: bool,
84 pub no_ai: bool,
85 pub model: Option<String>,
86 pub scope: Option<String>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum CommitRunOutcome {
91 Completed,
92 NothingToCommit,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96enum StageAndCommitOutcome {
97 Committed,
98 NothingToCommit,
99}
100
101#[derive(Debug, Clone)]
102pub enum CommitError {
103 Operation(String),
104 PushFailed {
105 summary: String,
106 commit_sha: Option<String>,
107 },
108 TerminalSessionsBlocked(String),
109}
110
111#[derive(Debug, Clone)]
112struct RepoContext {
113 repo_root: PathBuf,
114 repo_name: String,
115 branch: Option<String>,
116}
117
118#[derive(Debug, Clone, Default)]
119struct BranchSyncStatus {
120 upstream: Option<String>,
121 ahead: usize,
122 behind: usize,
123}
124
125#[derive(Debug, Clone)]
126struct WorktreeAnalysis {
127 repo_root: PathBuf,
128 repo_name: String,
129 branch: Option<String>,
130 status_entries: Vec<StatusEntry>,
131 files: Vec<FileChangeSummary>,
132 total_additions: u32,
133 total_deletions: u32,
134 diff_text: String,
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
144impl WorktreeAnalysis {
145 fn repo_context(&self) -> RepoContext {
146 RepoContext {
147 repo_root: self.repo_root.clone(),
148 repo_name: self.repo_name.clone(),
149 branch: self.branch.clone(),
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
155struct StatusEntry {
156 code: String,
157 path: String,
158}
159
160#[derive(Debug, Clone)]
161struct FileChangeSummary {
162 path: String,
163 status: String,
164 additions: u32,
165 deletions: u32,
166}
167
168#[derive(Debug, Clone)]
169struct SymbolSummary {
170 new_functions: Vec<String>,
171 changed_functions: Vec<String>,
172 removed_functions: Vec<String>,
173 new_types: Vec<String>,
174 changed_types: Vec<String>,
175 removed_types: Vec<String>,
176 hunk_contexts: Vec<String>,
177}
178
179#[derive(Debug, Clone, Copy, Eq, PartialEq)]
180enum SymbolKind {
181 Function,
182 Type,
183}
184
185#[derive(Debug, Clone)]
186struct CommitMessagePlan {
187 commit: ConventionalCommit,
188 generation_mode: GenerationMode,
189}
190
191#[derive(Debug, Clone)]
192struct ConventionalCommit {
193 commit_type: String,
194 scope: Option<String>,
195 description: String,
196 body: Vec<String>,
197 breaking_change: Option<String>,
198 footers: Vec<String>,
199}
200
201#[derive(Debug, Clone, Copy)]
202enum GenerationMode {
203 OpenRouter,
204 Heuristic,
205}
206
207#[derive(Debug, Deserialize)]
208struct AiCommitPayload {
209 #[serde(rename = "type")]
210 commit_type: String,
211 scope: Option<String>,
212 description: String,
213 #[serde(default)]
214 body: Vec<String>,
215 #[serde(default)]
216 breaking_change: bool,
217 breaking_description: Option<String>,
218 #[serde(default)]
219 footers: Vec<String>,
220}
221
222pub async fn run_commit(args: CommitArgs) -> Result<CommitRunOutcome, CommitError> {
223 if !command_exists("git") {
224 return Err(CommitError::Operation(
225 "Git is not installed on this machine.".to_string(),
226 ));
227 }
228
229 let invocation_dir = env::current_dir()
230 .map_err(|e| CommitError::Operation(format!("Failed to read current directory: {}", e)))?;
231 let repo = resolve_repo_context(&invocation_dir)
232 .await
233 .map_err(CommitError::Operation)?;
234 let status_output = git_output(
235 &repo.repo_root,
236 &["status", "--porcelain=v1", "--untracked-files=all"],
237 )
238 .await
239 .map_err(CommitError::Operation)?;
240 let status_entries = parse_status_entries(&status_output);
241 let status_entries = filter_gitignored_status_entries(&repo.repo_root, status_entries)
242 .await
243 .map_err(CommitError::Operation)?;
244
245 if status_entries.is_empty() {
246 let sync_status = resolve_branch_sync_status(&repo.repo_root)
247 .await
248 .unwrap_or_default();
249
250 if args.push {
251 return handle_clean_worktree_push(&repo, &sync_status)
252 .await
253 .map_err(|error| CommitError::PushFailed {
254 summary: error,
255 commit_sha: None,
256 })
257 .map(|()| CommitRunOutcome::Completed);
258 }
259
260 println!(
261 "{}",
262 render_clean_worktree_message(&repo, &sync_status).bright_yellow()
263 );
264 return Ok(CommitRunOutcome::NothingToCommit);
265 }
266
267 let repo_for_analysis = repo.clone();
268 let analysis = analyze_worktree(repo, status_entries, &[])
269 .await
270 .map_err(CommitError::Operation)?;
271 let excluded_terminal_paths = terminal_session_paths(&analysis);
272 if !excluded_terminal_paths.is_empty()
273 && analysis
274 .files
275 .iter()
276 .all(|file| is_terminal_session_path(&file.path))
277 {
278 return Err(CommitError::TerminalSessionsBlocked(
279 render_terminal_session_block_message(&excluded_terminal_paths),
280 ));
281 }
282
283 let commit_analysis = if excluded_terminal_paths.is_empty() {
284 analysis.clone()
285 } else {
286 println!(
287 "\n{} {}",
288 "Skipped".bright_yellow().bold(),
289 format!(
290 "excluding {} IDE terminal session file(s) from this commit: {}",
291 excluded_terminal_paths.len(),
292 excluded_terminal_paths.join(", ")
293 )
294 .bright_white()
295 );
296 analyze_worktree(
297 repo_for_analysis,
298 analysis.status_entries.clone(),
299 &excluded_terminal_paths,
300 )
301 .await
302 .map_err(CommitError::Operation)?
303 };
304
305 let (should_push, commit_plan) = tokio::join!(
306 should_push_after_auto_commit(args.push),
307 generate_commit_plan(&commit_analysis, &args)
308 );
309 let rendered_message = render_commit_message(&commit_plan.commit);
310
311 print_analysis_summary(&commit_analysis, &commit_plan, &rendered_message);
312
313 if args.dry_run {
314 println!(
315 "\n{}",
316 "Dry run only. No git commit was created."
317 .bright_yellow()
318 .bold()
319 );
320 return Ok(CommitRunOutcome::Completed);
321 }
322
323 match stage_and_commit(
324 &commit_analysis.repo_root,
325 &rendered_message,
326 &commit_analysis.status_entries,
327 &excluded_terminal_paths,
328 )
329 .await
330 .map_err(CommitError::Operation)?
331 {
332 StageAndCommitOutcome::Committed => {}
333 StageAndCommitOutcome::NothingToCommit => {
334 let sync_status = resolve_branch_sync_status(&commit_analysis.repo_root)
335 .await
336 .unwrap_or_default();
337
338 if args.push {
339 handle_clean_worktree_push(&commit_analysis.repo_context(), &sync_status)
340 .await
341 .map_err(|error| CommitError::PushFailed {
342 summary: error,
343 commit_sha: None,
344 })?;
345 } else {
346 println!(
347 "{}",
348 render_clean_worktree_message(&commit_analysis.repo_context(), &sync_status,)
349 .bright_yellow()
350 );
351 }
352
353 return Ok(CommitRunOutcome::NothingToCommit);
354 }
355 }
356
357 println!("{}", "Resolving created commit...".bright_black());
358 let full_sha = git_output(&commit_analysis.repo_root, &["rev-parse", "HEAD"])
359 .await
360 .map_err(CommitError::Operation)?;
361 let short_sha = full_sha.chars().take(8).collect::<String>();
362
363 println!(
364 "\n{} {} {}",
365 "Committed".bright_green().bold(),
366 short_sha.bright_white().bold(),
367 format!("({})", full_sha).dimmed()
368 );
369
370 let mut pushed = false;
371 if should_push {
372 println!("{}", "Pushing current branch...".bright_black());
373 match push_current_branch_with_name(
374 &commit_analysis.repo_root,
375 commit_analysis.branch.as_deref(),
376 )
377 .await
378 {
379 Ok(Some(outcome)) => {
380 print_push_summary(&outcome);
381 pushed = true;
382 }
383 Ok(None) => println!(
384 "{}",
385 "Push skipped because the current HEAD is detached.".bright_yellow()
386 ),
387 Err(error) => {
388 return Err(CommitError::PushFailed {
389 summary: error,
390 commit_sha: Some(short_sha),
391 });
392 }
393 }
394 } else {
395 println!(
396 "{}",
397 "Auto-push disabled. Pass `--push` or set `github.auto_push_on_commit: true` in project config."
398 .bright_yellow()
399 );
400 }
401
402 {
403 let subject = rendered_message
404 .lines()
405 .next()
406 .unwrap_or(rendered_message.as_str())
407 .trim();
408 let xbp_config = crate::strategies::DeploymentConfig::load_xbp_config(None)
409 .await
410 .ok();
411 crate::commands::discord_notify::notify_discord_for_project(
412 &commit_analysis.repo_root,
413 xbp_config.as_ref(),
414 None,
415 crate::commands::discord_notify::commit_notification(
416 &short_sha,
417 subject,
418 commit_analysis.branch.as_deref(),
419 pushed,
420 ),
421 )
422 .await;
423 }
424
425 Ok(CommitRunOutcome::Completed)
426}
427
428async fn resolve_repo_context(invocation_dir: &Path) -> Result<RepoContext, String> {
429 let repo_root = PathBuf::from(
430 git_output(invocation_dir, &["rev-parse", "--show-toplevel"])
431 .await
432 .map_err(|_| "Current directory is not inside a git repository.".to_string())?,
433 );
434 let repo_name = repo_name(&repo_root);
435 let branch = git_output(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
436 .await
437 .ok()
438 .filter(|value| !value.is_empty() && value != "HEAD");
439
440 Ok(RepoContext {
441 repo_root,
442 repo_name,
443 branch,
444 })
445}
446
447async fn resolve_branch_sync_status(repo_root: &Path) -> Result<BranchSyncStatus, String> {
448 let upstream = git_output(
449 repo_root,
450 &[
451 "rev-parse",
452 "--abbrev-ref",
453 "--symbolic-full-name",
454 "@{upstream}",
455 ],
456 )
457 .await
458 .ok()
459 .filter(|value| !value.is_empty());
460
461 let Some(upstream_name) = upstream else {
462 return Ok(BranchSyncStatus::default());
463 };
464
465 let counts = git_output(
466 repo_root,
467 &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
468 )
469 .await?;
470 let mut parts = counts.split_whitespace();
471 let ahead = parts
472 .next()
473 .and_then(|value| value.parse::<usize>().ok())
474 .unwrap_or(0);
475 let behind = parts
476 .next()
477 .and_then(|value| value.parse::<usize>().ok())
478 .unwrap_or(0);
479
480 Ok(BranchSyncStatus {
481 upstream: Some(upstream_name),
482 ahead,
483 behind,
484 })
485}
486
487async fn handle_clean_worktree_push(
488 repo: &RepoContext,
489 sync_status: &BranchSyncStatus,
490) -> Result<(), String> {
491 if repo.branch.is_none() {
492 return Err(render_clean_worktree_message(repo, sync_status));
493 }
494
495 if sync_status.ahead == 0 && sync_status.behind == 0 && sync_status.upstream.is_some() {
496 println!("{}", render_clean_worktree_message(repo, sync_status));
497 return Ok(());
498 }
499
500 if sync_status.behind > 0 && sync_status.ahead == 0 {
502 println!("{}", render_clean_worktree_message(repo, sync_status));
503 match pull_rebase_current_branch(&repo.repo_root).await {
504 Ok(Some(outcome)) => {
505 println!(
506 "{} {}",
507 "Rebased".bright_green().bold(),
508 format!("{}/{}", outcome.remote, outcome.branch).bright_white()
509 );
510 println!(
511 "{}",
512 "Working tree is clean and now matches the remote.".bright_green()
513 );
514 Ok(())
515 }
516 Ok(None) => Err("Rebase skipped because the current HEAD is detached.".to_string()),
517 Err(error) => Err(error),
518 }
519 } else {
520 println!("{}", render_clean_worktree_message(repo, sync_status));
522 match push_current_branch_with_name(&repo.repo_root, repo.branch.as_deref()).await {
523 Ok(Some(outcome)) => {
524 print_push_summary(&outcome);
525 Ok(())
526 }
527 Ok(None) => Err("Push skipped because the current HEAD is detached.".to_string()),
528 Err(error) => Err(error),
529 }
530 }
531}
532
533fn render_clean_worktree_message(repo: &RepoContext, sync_status: &BranchSyncStatus) -> String {
534 let branch_label = repo
535 .branch
536 .as_deref()
537 .map(|branch| format!(" on `{}`", branch))
538 .unwrap_or_default();
539
540 let sync_summary = match sync_status.upstream.as_deref() {
541 Some(upstream) if sync_status.ahead > 0 && sync_status.behind > 0 => format!(
542 "Working tree is clean{}, with {} commit(s) waiting to push and {} commit(s) to pull from `{}`.",
543 branch_label, sync_status.ahead, sync_status.behind, upstream
544 ),
545 Some(upstream) if sync_status.ahead > 0 => format!(
546 "Working tree is clean{}, with {} commit(s) waiting to push to `{}`.",
547 branch_label, sync_status.ahead, upstream
548 ),
549 Some(upstream) if sync_status.behind > 0 => format!(
550 "Working tree is clean{}, but `{}` is ahead by {} commit(s). Pull or rebase before pushing.",
551 branch_label, upstream, sync_status.behind
552 ),
553 Some(upstream) => format!(
554 "Working tree is clean{} and already in sync with `{}`.",
555 branch_label, upstream
556 ),
557 None if repo.branch.is_some() => format!(
558 "Working tree is clean{}, and no upstream branch is configured yet.",
559 branch_label
560 ),
561 None => "Working tree is clean, and HEAD is detached.".to_string(),
562 };
563
564 format!("Nothing to commit. {}", sync_summary)
565}
566
567async fn analyze_worktree(
568 repo: RepoContext,
569 status_entries: Vec<StatusEntry>,
570 exclude_paths: &[String],
571) -> Result<WorktreeAnalysis, String> {
572 if status_entries.is_empty() {
573 return Err("No worktree changes were found to commit.".to_string());
574 }
575
576 let temp_index = prepare_temporary_index(&repo.repo_root).await?;
577 let temp_index_path = temp_index.path.clone();
578 let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
579
580 git_output_with_env(&repo.repo_root, &["add", "--all"], &git_env).await?;
581 for path in exclude_paths {
582 git_output_with_env(
583 &repo.repo_root,
584 &["reset", "HEAD", "--", path.as_str()],
585 &git_env,
586 )
587 .await?;
588 }
589 let (name_status_output, numstat_output, diff_text) = tokio::try_join!(
590 git_output_with_env(
591 &repo.repo_root,
592 &["diff", "--cached", "--name-status", "--find-renames"],
593 &git_env,
594 ),
595 git_output_with_env(
596 &repo.repo_root,
597 &["diff", "--cached", "--numstat", "--find-renames"],
598 &git_env,
599 ),
600 git_output_with_env(
601 &repo.repo_root,
602 &[
603 "diff",
604 "--cached",
605 "--unified=0",
606 "--no-color",
607 "--no-ext-diff",
608 "--find-renames",
609 ],
610 &git_env,
611 ),
612 )?;
613
614 if diff_text.trim().is_empty() {
615 return Err("No staged diff could be produced from the current worktree.".to_string());
616 }
617
618 let files = merge_file_summaries(&name_status_output, &numstat_output);
619 let total_additions = files.iter().map(|entry| entry.additions).sum();
620 let total_deletions = files.iter().map(|entry| entry.deletions).sum();
621 let symbols = summarize_symbols(&diff_text);
622
623 Ok(WorktreeAnalysis {
624 repo_root: repo.repo_root,
625 repo_name: repo.repo_name,
626 branch: repo.branch,
627 status_entries,
628 files,
629 total_additions,
630 total_deletions,
631 diff_text,
632 new_functions: symbols.new_functions,
633 changed_functions: symbols.changed_functions,
634 removed_functions: symbols.removed_functions,
635 new_types: symbols.new_types,
636 changed_types: symbols.changed_types,
637 removed_types: symbols.removed_types,
638 hunk_contexts: symbols.hunk_contexts,
639 })
640}
641
642async fn prepare_temporary_index(repo_root: &Path) -> Result<TemporaryIndex, String> {
643 let real_index_path =
644 PathBuf::from(git_output(repo_root, &["rev-parse", "--git-path", "index"]).await?);
645 let temp_index_path = env::temp_dir().join(format!("xbp-commit-index-{}.tmp", Uuid::new_v4()));
646
647 if real_index_path.exists() {
648 fs::copy(&real_index_path, &temp_index_path).map_err(|e| {
649 format!(
650 "Failed to prepare temporary git index {}: {}",
651 temp_index_path.display(),
652 e
653 )
654 })?;
655 } else {
656 let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
657 let _ = git_output_with_env(repo_root, &["read-tree", "HEAD"], &git_env).await;
658 }
659
660 Ok(TemporaryIndex {
661 path: temp_index_path,
662 })
663}
664
665async fn generate_commit_plan(analysis: &WorktreeAnalysis, args: &CommitArgs) -> CommitMessagePlan {
666 let forced_scope = sanitize_scope(args.scope.as_deref());
667 let heuristic = build_heuristic_commit(analysis, forced_scope.clone());
668
669 if args.no_ai {
670 return CommitMessagePlan {
671 commit: heuristic,
672 generation_mode: GenerationMode::Heuristic,
673 };
674 }
675
676 let Some(api_key) = resolve_openrouter_api_key() else {
677 return CommitMessagePlan {
678 commit: heuristic,
679 generation_mode: GenerationMode::Heuristic,
680 };
681 };
682
683 let prompt = build_commit_prompt(analysis, forced_scope.as_deref(), &heuristic);
684 let model = args
685 .model
686 .as_deref()
687 .map(str::trim)
688 .filter(|value| !value.is_empty())
689 .map(str::to_string)
690 .unwrap_or_else(resolve_openrouter_commit_model);
691 let system_prompt = resolve_openrouter_commit_system_prompt();
692
693 let ai_message = complete_prompt_with_options(
694 &api_key,
695 &model,
696 Some(&system_prompt),
697 &prompt,
698 Some("XBP Commit Generator"),
699 CompletionOptions::fast_json(COMMIT_AI_MAX_TOKENS),
700 )
701 .await
702 .and_then(|raw| parse_ai_commit_payload(&raw, forced_scope.as_deref()));
703
704 if let Some(mut commit) = ai_message {
705 if commit.body.is_empty() {
706 commit.body = heuristic.body.clone();
707 }
708 CommitMessagePlan {
709 commit,
710 generation_mode: GenerationMode::OpenRouter,
711 }
712 } else {
713 CommitMessagePlan {
714 commit: heuristic,
715 generation_mode: GenerationMode::Heuristic,
716 }
717 }
718}
719
720fn build_commit_prompt(
721 analysis: &WorktreeAnalysis,
722 forced_scope: Option<&str>,
723 heuristic: &ConventionalCommit,
724) -> String {
725 let focus_areas = summarize_focus_areas(analysis, MAX_PROMPT_AREAS);
726 let top_files = summarize_top_files(analysis, MAX_PROMPT_FILES);
727 let prompt_diff = build_prompt_diff_excerpt(analysis);
728 let mut lines = vec![
729 "Worktree context for commit generation:".to_string(),
730 String::new(),
731 ];
732 lines.push(format!("Repository: {}", analysis.repo_name));
733 if let Some(branch) = &analysis.branch {
734 lines.push(format!("Branch: {}", branch));
735 }
736 if let Some(scope) = forced_scope {
737 lines.push(format!("Forced scope: {}", scope));
738 } else if let Some(scope) = heuristic.scope.as_deref() {
739 lines.push(format!("Suggested scope: {}", scope));
740 }
741 lines.push(format!(
742 "Heuristic fallback subject: {}",
743 render_commit_subject(heuristic)
744 ));
745 if !focus_areas.is_empty() {
746 lines.push(format!("Focus areas: {}", focus_areas.join(", ")));
747 }
748 if let Some(behavior_hint) = infer_behavior_hint(analysis) {
749 lines.push(format!("Behavior hint: {}", behavior_hint));
750 }
751 if analysis
752 .files
753 .iter()
754 .any(|file| is_terminal_session_path(&file.path))
755 {
756 lines.push(String::new());
757 lines.push("Important constraint:".to_string());
758 lines.push(
759 "- paths under terminals/ are IDE agent session logs, not product features; never describe them as flows or capabilities"
760 .to_string(),
761 );
762 lines.push(
763 "- terminals/.next-id is an IDE session counter, not app configuration; never describe updating it as a feature"
764 .to_string(),
765 );
766 }
767 lines.push(String::new());
768 lines.push("Worktree stats:".to_string());
769 lines.push(format!("- files changed: {}", analysis.files.len()));
770 lines.push(format!(
771 "- lines: +{} -{}",
772 analysis.total_additions, analysis.total_deletions
773 ));
774 if !top_files.is_empty() {
775 lines.push("- dominant files:".to_string());
776 for file in top_files {
777 lines.push(format!(" - {}", file));
778 }
779 }
780 if !analysis.status_entries.is_empty() {
781 lines.push("- worktree statuses:".to_string());
782 for entry in analysis.status_entries.iter().take(MAX_PROMPT_FILES) {
783 lines.push(format!(" - {} {}", entry.code, entry.path));
784 }
785 }
786 if !analysis.files.is_empty() {
787 lines.push("- file diffs:".to_string());
788 for file in analysis.files.iter().take(MAX_PROMPT_FILES) {
789 lines.push(format!(
790 " - [{}] {} (+{} -{})",
791 file.status, file.path, file.additions, file.deletions
792 ));
793 }
794 }
795 if !analysis.new_functions.is_empty() {
796 lines.push(format!(
797 "- new functions: {}",
798 analysis
799 .new_functions
800 .iter()
801 .take(MAX_PROMPT_SYMBOLS)
802 .cloned()
803 .collect::<Vec<_>>()
804 .join(", ")
805 ));
806 }
807 if !analysis.changed_functions.is_empty() {
808 lines.push(format!(
809 "- changed functions: {}",
810 analysis
811 .changed_functions
812 .iter()
813 .take(MAX_PROMPT_SYMBOLS)
814 .cloned()
815 .collect::<Vec<_>>()
816 .join(", ")
817 ));
818 }
819 if !analysis.new_types.is_empty() {
820 lines.push(format!(
821 "- new types: {}",
822 analysis
823 .new_types
824 .iter()
825 .take(MAX_PROMPT_SYMBOLS)
826 .cloned()
827 .collect::<Vec<_>>()
828 .join(", ")
829 ));
830 }
831 if !analysis.changed_types.is_empty() {
832 lines.push(format!(
833 "- changed types: {}",
834 analysis
835 .changed_types
836 .iter()
837 .take(MAX_PROMPT_SYMBOLS)
838 .cloned()
839 .collect::<Vec<_>>()
840 .join(", ")
841 ));
842 }
843 if !analysis.hunk_contexts.is_empty() {
844 lines.push(format!(
845 "- hunk contexts: {}",
846 analysis
847 .hunk_contexts
848 .iter()
849 .take(MAX_PROMPT_CONTEXTS)
850 .cloned()
851 .collect::<Vec<_>>()
852 .join(", ")
853 ));
854 }
855 if !prompt_diff.omitted_files.is_empty() {
856 lines.push("- omitted low-signal diffs:".to_string());
857 for file in prompt_diff
858 .omitted_files
859 .iter()
860 .take(MAX_PROMPT_OMITTED_DIFFS)
861 {
862 lines.push(format!(" - {}", file));
863 }
864 if prompt_diff.omitted_files.len() > MAX_PROMPT_OMITTED_DIFFS {
865 lines.push(format!(
866 " - ... {} more omitted",
867 prompt_diff.omitted_files.len() - MAX_PROMPT_OMITTED_DIFFS
868 ));
869 }
870 }
871 if !prompt_diff.summaries.is_empty() {
872 lines.push("- preprocessed diff summaries:".to_string());
873 for summary in prompt_diff.summaries.iter().take(MAX_PROMPT_DIFF_SUMMARIES) {
874 lines.push(format!(" - {}", summary));
875 }
876 if prompt_diff.summaries.len() > MAX_PROMPT_DIFF_SUMMARIES {
877 lines.push(format!(
878 " - ... {} more summaries omitted",
879 prompt_diff.summaries.len() - MAX_PROMPT_DIFF_SUMMARIES
880 ));
881 }
882 }
883 lines.push(String::new());
884 if prompt_diff.excerpt.trim().is_empty() {
885 lines.push("Diff excerpt: omitted".to_string());
886 lines.push(
887 "[all available file diffs were low-signal generated or lockfile changes; use the structured stats above]"
888 .to_string(),
889 );
890 } else if prompt_diff.omitted_files.is_empty() {
891 lines.push("Diff excerpt:".to_string());
892 lines.push(prompt_diff.excerpt);
893 } else {
894 lines.push("Diff excerpt (low-signal generated and lockfile diffs omitted):".to_string());
895 lines.push(prompt_diff.excerpt);
896 }
897 lines.join("\n")
898}
899
900fn parse_ai_commit_payload(raw: &str, forced_scope: Option<&str>) -> Option<ConventionalCommit> {
901 let payload: AiCommitPayload = serde_json::from_str(raw).ok()?;
902 let commit_type = sanitize_commit_type(&payload.commit_type)?;
903 let description = sanitize_description(&payload.description)?;
904 let mut body = payload
905 .body
906 .into_iter()
907 .map(|entry| entry.trim().to_string())
908 .filter(|entry| !entry.is_empty())
909 .collect::<Vec<_>>();
910 if body.len() > 3 {
911 body.truncate(3);
912 }
913
914 let breaking_change = if payload.breaking_change {
915 payload
916 .breaking_description
917 .as_deref()
918 .and_then(sanitize_footer_value)
919 } else {
920 None
921 };
922
923 let mut footers = payload
924 .footers
925 .into_iter()
926 .map(|entry| entry.trim().to_string())
927 .filter(|entry| !entry.is_empty())
928 .collect::<Vec<_>>();
929 if breaking_change.is_some()
930 && !footers
931 .iter()
932 .any(|entry| entry.starts_with("BREAKING CHANGE:"))
933 {
934 if let Some(breaking) = &breaking_change {
935 footers.push(format!("BREAKING CHANGE: {}", breaking));
936 }
937 }
938
939 Some(ConventionalCommit {
940 commit_type,
941 scope: forced_scope
942 .and_then(|scope| sanitize_scope(Some(scope)))
943 .or_else(|| sanitize_scope(payload.scope.as_deref())),
944 description,
945 body,
946 breaking_change,
947 footers,
948 })
949}
950
951fn build_heuristic_commit(
952 analysis: &WorktreeAnalysis,
953 forced_scope: Option<String>,
954) -> ConventionalCommit {
955 let commit_type = infer_commit_type(analysis).to_string();
956 let scope = forced_scope.or_else(|| infer_scope(analysis));
957 let description = infer_description(analysis, &commit_type, scope.as_deref());
958 let body = build_heuristic_body(analysis);
959
960 ConventionalCommit {
961 commit_type,
962 scope,
963 description,
964 body,
965 breaking_change: None,
966 footers: Vec::new(),
967 }
968}
969
970fn infer_commit_type(analysis: &WorktreeAnalysis) -> &'static str {
971 let lowered_paths = analysis
972 .files
973 .iter()
974 .map(|file| file.path.to_ascii_lowercase())
975 .collect::<Vec<_>>();
976
977 if !lowered_paths.is_empty()
978 && lowered_paths
979 .iter()
980 .all(|path| is_terminal_session_path(path))
981 {
982 return "chore";
983 }
984
985 let docs_only = !lowered_paths.is_empty()
986 && lowered_paths.iter().all(|path| {
987 !is_terminal_session_path(path)
988 && (path.ends_with(".md")
989 || path.ends_with(".mdx")
990 || path.ends_with(".txt")
991 || path.starts_with("docs/"))
992 });
993 if docs_only {
994 return "docs";
995 }
996
997 let test_only = !lowered_paths.is_empty()
998 && lowered_paths.iter().all(|path| {
999 path.contains("/tests/")
1000 || path.contains("\\tests\\")
1001 || path.contains(".test.")
1002 || path.contains(".spec.")
1003 });
1004 if test_only {
1005 return "test";
1006 }
1007
1008 let ci_only = !lowered_paths.is_empty()
1009 && lowered_paths.iter().all(|path| {
1010 path.starts_with(".github/")
1011 || path.contains("workflow")
1012 || path.ends_with(".yml")
1013 || path.ends_with(".yaml")
1014 });
1015 if ci_only {
1016 return "ci";
1017 }
1018
1019 let build_only = !lowered_paths.is_empty()
1020 && lowered_paths.iter().all(|path| {
1021 path.ends_with("cargo.toml")
1022 || path.ends_with("cargo.lock")
1023 || path.ends_with("package.json")
1024 || path.ends_with("pnpm-lock.yaml")
1025 || path.ends_with("package-lock.json")
1026 || path.ends_with("wrangler.toml")
1027 || path.ends_with(".nix")
1028 });
1029 if build_only {
1030 return "build";
1031 }
1032
1033 let has_new_files = analysis.files.iter().any(|file| file.status == "added");
1034 let has_new_symbols = !analysis.new_functions.is_empty() || !analysis.new_types.is_empty();
1035 let has_existing_symbol_churn = !analysis.changed_functions.is_empty()
1036 || !analysis.changed_types.is_empty()
1037 || !analysis.removed_functions.is_empty()
1038 || !analysis.removed_types.is_empty();
1039 if has_new_files {
1040 return "feat";
1041 }
1042 if has_new_symbols && has_existing_symbol_churn {
1043 return "refactor";
1044 }
1045 if has_new_symbols {
1046 return "feat";
1047 }
1048
1049 if has_existing_symbol_churn || analysis.total_additions >= analysis.total_deletions {
1050 return "fix";
1051 }
1052
1053 "chore"
1054}
1055
1056fn infer_scope(analysis: &WorktreeAnalysis) -> Option<String> {
1057 let mut scopes = BTreeSet::new();
1058
1059 for file in &analysis.files {
1060 let path = file.path.replace('\\', "/");
1061 let inferred = if path.starts_with("crates/cli/") {
1062 Some("cli")
1063 } else if path.starts_with("crates/api/") {
1064 Some("api")
1065 } else if path.starts_with("crates/github/") {
1066 Some("github")
1067 } else if path.starts_with("crates/runtime/") {
1068 Some("runtime")
1069 } else if path.starts_with("apps/web/") {
1070 Some("web")
1071 } else if path.starts_with("docs/") {
1072 Some("docs")
1073 } else if path.starts_with(".github/") {
1074 Some("ci")
1075 } else {
1076 None
1077 };
1078
1079 if let Some(value) = inferred {
1080 scopes.insert(value.to_string());
1081 }
1082 }
1083
1084 if scopes.len() == 1 {
1085 scopes.into_iter().next()
1086 } else if scopes.is_empty() {
1087 None
1088 } else if scopes.contains("cli") {
1089 Some("cli".to_string())
1090 } else {
1091 None
1092 }
1093}
1094
1095fn infer_description(
1096 analysis: &WorktreeAnalysis,
1097 commit_type: &str,
1098 scope: Option<&str>,
1099) -> String {
1100 if !analysis.files.is_empty()
1101 && analysis
1102 .files
1103 .iter()
1104 .all(|file| is_terminal_session_path(&file.path))
1105 {
1106 return "ignore ide terminal session logs".to_string();
1107 }
1108
1109 let focus_areas = summarize_focus_areas(analysis, 2);
1110 let behavior_hint = infer_behavior_hint(analysis);
1111 let interesting_names = analysis
1112 .files
1113 .iter()
1114 .filter_map(|file| interesting_file_stem(&file.path))
1115 .collect::<Vec<_>>();
1116
1117 if interesting_names.iter().any(|name| name == "commit") {
1118 return match scope {
1119 Some("cli") => "add worktree commit generator".to_string(),
1120 _ => "add conventional commit generator".to_string(),
1121 };
1122 }
1123
1124 if commit_type == "docs" {
1125 if let Some(name) = interesting_names.first() {
1126 return format!("document {}", name.replace('_', "-"));
1127 }
1128 return "update command documentation".to_string();
1129 }
1130
1131 if let Some(area) = focus_areas.first() {
1132 let target = match behavior_hint {
1133 Some(hint) => format!("{} {}", area, hint),
1134 None => format!("{} flow", area),
1135 };
1136 return match commit_type {
1137 "feat" => format!("expand {}", target),
1138 "fix" => format!("improve {}", target),
1139 "refactor" => format!("refine {}", target),
1140 "test" => format!("cover {}", target),
1141 "ci" => format!("update {} workflow", area),
1142 "build" => format!("adjust {} build inputs", area),
1143 _ => format!("update {}", target),
1144 };
1145 }
1146
1147 if let Some(name) = interesting_names.first() {
1148 return match commit_type {
1149 "feat" => format!("add {}", name.replace('_', "-")),
1150 "fix" => format!("improve {}", name.replace('_', "-")),
1151 "refactor" => format!("refactor {}", name.replace('_', "-")),
1152 "test" => format!("cover {}", name.replace('_', "-")),
1153 "ci" => format!("update {}", name.replace('_', "-")),
1154 "build" => format!("adjust {}", name.replace('_', "-")),
1155 _ => format!("update {}", name.replace('_', "-")),
1156 };
1157 }
1158
1159 match commit_type {
1160 "feat" => "add worktree change support".to_string(),
1161 "fix" => "improve worktree handling".to_string(),
1162 "docs" => "update documentation".to_string(),
1163 _ => "update repository changes".to_string(),
1164 }
1165}
1166
1167fn build_heuristic_body(analysis: &WorktreeAnalysis) -> Vec<String> {
1168 let focus_areas = summarize_focus_areas(analysis, 3);
1169 let top_files = summarize_top_files(analysis, 3);
1170 let mut paragraphs = Vec::new();
1171 let overview_target = if focus_areas.is_empty() {
1172 format!(
1173 "{} file{}",
1174 analysis.files.len(),
1175 if analysis.files.len() == 1 { "" } else { "s" }
1176 )
1177 } else {
1178 format!(
1179 "{} across {} file{}",
1180 format_focus_area_list(&focus_areas),
1181 analysis.files.len(),
1182 if analysis.files.len() == 1 { "" } else { "s" }
1183 )
1184 };
1185 paragraphs.push(format!(
1186 "Touches {} with +{} and -{} lines in the current worktree.",
1187 overview_target, analysis.total_additions, analysis.total_deletions
1188 ));
1189
1190 let mut detail_parts = Vec::new();
1191 if !analysis.new_functions.is_empty() {
1192 detail_parts.push(format!(
1193 "adds {}",
1194 summarize_symbol_list(&analysis.new_functions, 4)
1195 ));
1196 }
1197 if !analysis.changed_functions.is_empty() {
1198 detail_parts.push(format!(
1199 "updates {}",
1200 summarize_symbol_list(&analysis.changed_functions, 4)
1201 ));
1202 }
1203 if !analysis.removed_functions.is_empty() {
1204 detail_parts.push(format!(
1205 "removes {}",
1206 summarize_symbol_list(&analysis.removed_functions, 3)
1207 ));
1208 }
1209 if !analysis.new_types.is_empty() {
1210 detail_parts.push(format!(
1211 "introduces {}",
1212 summarize_symbol_list(&analysis.new_types, 3)
1213 ));
1214 }
1215 if !analysis.changed_types.is_empty() {
1216 detail_parts.push(format!(
1217 "reshapes {}",
1218 summarize_symbol_list(&analysis.changed_types, 3)
1219 ));
1220 }
1221 if !analysis.removed_types.is_empty() {
1222 detail_parts.push(format!(
1223 "drops {}",
1224 summarize_symbol_list(&analysis.removed_types, 3)
1225 ));
1226 }
1227 if !top_files.is_empty() {
1228 detail_parts.push(format!(
1229 "with the biggest diffs in {}",
1230 top_files.join(", ")
1231 ));
1232 }
1233 if !detail_parts.is_empty() {
1234 paragraphs.push(format!("{}.", detail_parts.join("; ")));
1235 }
1236
1237 paragraphs
1238}
1239
1240fn summarize_focus_areas(analysis: &WorktreeAnalysis, limit: usize) -> Vec<String> {
1241 let mut area_scores = BTreeMap::<String, u32>::new();
1242
1243 for file in &analysis.files {
1244 let Some(area) = describe_focus_area(&file.path) else {
1245 continue;
1246 };
1247 let weight = (file.additions + file.deletions).max(1);
1248 *area_scores.entry(area).or_default() += weight;
1249 }
1250
1251 let mut ranked = area_scores.into_iter().collect::<Vec<_>>();
1252 ranked.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1253 ranked
1254 .into_iter()
1255 .map(|(area, _)| area)
1256 .take(limit)
1257 .collect()
1258}
1259
1260#[derive(Debug, Clone, PartialEq, Eq)]
1261struct TopFileGroup {
1262 label: String,
1263 file_count: usize,
1264 additions: u32,
1265 deletions: u32,
1266}
1267
1268fn summarize_top_files(analysis: &WorktreeAnalysis, limit: usize) -> Vec<String> {
1269 let groups = collapse_top_file_groups(analysis);
1270
1271 groups
1272 .into_iter()
1273 .take(limit)
1274 .map(|group| format_top_file_group(&group))
1275 .collect()
1276}
1277
1278fn collapse_top_file_groups(analysis: &WorktreeAnalysis) -> Vec<TopFileGroup> {
1279 let mut parent_counts = BTreeMap::<String, usize>::new();
1280 for file in &analysis.files {
1281 if let Some(parent) = parent_dir_key(&file.path) {
1282 *parent_counts.entry(parent).or_default() += 1;
1283 }
1284 }
1285
1286 let mut groups = BTreeMap::<String, TopFileGroup>::new();
1287 for file in &analysis.files {
1288 let label = top_file_group_label(&file.path, &parent_counts);
1289 let entry = groups.entry(label.clone()).or_insert(TopFileGroup {
1290 label,
1291 file_count: 0,
1292 additions: 0,
1293 deletions: 0,
1294 });
1295 entry.file_count += 1;
1296 entry.additions += file.additions;
1297 entry.deletions += file.deletions;
1298 }
1299
1300 let mut ranked = groups.into_values().collect::<Vec<_>>();
1301 ranked.sort_by(|left, right| {
1302 let left_weight = left.additions + left.deletions;
1303 let right_weight = right.additions + right.deletions;
1304 right_weight
1305 .cmp(&left_weight)
1306 .then_with(|| left.label.cmp(&right.label))
1307 });
1308 ranked
1309}
1310
1311fn top_file_group_label(path: &str, parent_counts: &BTreeMap<String, usize>) -> String {
1312 let normalized = normalize_display_path(path);
1313 if normalized.starts_with("terminals/") {
1314 return "terminals/".to_string();
1315 }
1316
1317 if let Some(parent) = parent_dir_key(&normalized) {
1318 if parent_counts.get(&parent).copied().unwrap_or(0) > 1 {
1319 return format!("{}/", parent);
1320 }
1321 }
1322
1323 normalized
1324}
1325
1326fn is_terminal_session_path(path: &str) -> bool {
1327 normalize_display_path(path)
1328 .to_ascii_lowercase()
1329 .starts_with(TERMINAL_SESSION_DIR)
1330}
1331
1332fn terminal_session_paths(analysis: &WorktreeAnalysis) -> Vec<String> {
1333 analysis
1334 .files
1335 .iter()
1336 .filter(|file| is_terminal_session_path(&file.path))
1337 .map(|file| file.path.clone())
1338 .collect()
1339}
1340
1341fn render_terminal_session_block_message(paths: &[String]) -> String {
1342 format!(
1343 "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: {}",
1344 TERMINAL_SESSION_DIR.trim_end_matches('/'),
1345 paths.join(", ")
1346 )
1347}
1348
1349fn parent_dir_key(path: &str) -> Option<String> {
1350 let normalized = normalize_display_path(path);
1351 Path::new(&normalized)
1352 .parent()
1353 .and_then(|value| value.to_str())
1354 .map(normalize_display_path)
1355 .filter(|value| !value.is_empty())
1356}
1357
1358fn format_top_file_group(group: &TopFileGroup) -> String {
1359 if group.file_count > 1 {
1360 format!(
1361 "{} ({} files, +{} -{})",
1362 group.label, group.file_count, group.additions, group.deletions
1363 )
1364 } else {
1365 format!(
1366 "{} (+{} -{})",
1367 group.label, group.additions, group.deletions
1368 )
1369 }
1370}
1371
1372fn color_commit_type(commit_type: &str) -> colored::ColoredString {
1373 match commit_type {
1374 "feat" => commit_type.bright_green().bold(),
1375 "fix" => commit_type.bright_red().bold(),
1376 "docs" => commit_type.bright_blue().bold(),
1377 "refactor" => commit_type.bright_magenta().bold(),
1378 "chore" => commit_type.bright_black().bold(),
1379 "test" => commit_type.bright_cyan().bold(),
1380 "build" => commit_type.bright_yellow().bold(),
1381 "ci" => commit_type.cyan().bold(),
1382 "perf" => commit_type.bright_yellow().bold(),
1383 "style" => commit_type.white().bold(),
1384 "revert" => commit_type.red().bold(),
1385 _ => commit_type.bright_white().bold(),
1386 }
1387}
1388
1389fn render_colored_commit_subject(commit: &ConventionalCommit) -> String {
1390 let scope = commit
1391 .scope
1392 .as_deref()
1393 .map(|value| format!("({})", value.magenta()))
1394 .unwrap_or_default();
1395 let bang = if commit.breaking_change.is_some() {
1396 "!".bright_red().to_string()
1397 } else {
1398 String::new()
1399 };
1400 let description = commit.description.bright_white();
1401
1402 format!(
1403 "{}{}{}: {}",
1404 color_commit_type(&commit.commit_type),
1405 scope,
1406 bang,
1407 description
1408 )
1409}
1410
1411fn render_commit_message_preview(commit: &ConventionalCommit) -> String {
1412 let mut sections = vec![render_colored_commit_subject(commit)];
1413
1414 if !commit.body.is_empty() {
1415 sections.push(commit.body.join("\n\n"));
1416 }
1417
1418 let mut footer_lines = commit
1419 .footers
1420 .iter()
1421 .map(|entry| entry.trim().to_string())
1422 .filter(|entry| !entry.is_empty())
1423 .collect::<Vec<_>>();
1424 if let Some(breaking_change) = &commit.breaking_change {
1425 if !footer_lines
1426 .iter()
1427 .any(|entry| entry.starts_with("BREAKING CHANGE:"))
1428 {
1429 footer_lines.push(format!("BREAKING CHANGE: {}", breaking_change));
1430 }
1431 }
1432 if !footer_lines.is_empty() {
1433 sections.push(footer_lines.join("\n"));
1434 }
1435
1436 sections.join("\n\n")
1437}
1438
1439fn format_colored_change_counts(additions: u32, deletions: u32) -> String {
1440 format!(
1441 "(+{} -{})",
1442 additions.to_string().bright_green(),
1443 deletions.to_string().bright_red()
1444 )
1445}
1446
1447fn format_colored_top_file_group(group: &TopFileGroup) -> String {
1448 let change_counts = format_colored_change_counts(group.additions, group.deletions);
1449 if group.file_count > 1 {
1450 format!(
1451 "{} ({} files, {})",
1452 group.label.bright_white(),
1453 group.file_count.to_string().bright_white(),
1454 change_counts
1455 )
1456 } else {
1457 format!("{} {}", group.label.bright_white(), change_counts)
1458 }
1459}
1460
1461fn describe_focus_area(path: &str) -> Option<String> {
1462 let normalized = normalize_display_path(path);
1463 if normalized.starts_with("terminals/") {
1464 return Some("terminals".to_string());
1465 }
1466 if normalized.starts_with("crates/cli/src/commands/") {
1467 return Path::new(&normalized)
1468 .file_stem()
1469 .and_then(|value| value.to_str())
1470 .map(humanize_focus_area)
1471 .filter(|value| !value.is_empty());
1472 }
1473 if normalized.contains("/http-app.") {
1474 return Some("http".to_string());
1475 }
1476 if normalized.contains("/server.") {
1477 return Some("server".to_string());
1478 }
1479 if normalized.contains("/worker/") {
1480 return Some("worker".to_string());
1481 }
1482 if let Some(stem) = interesting_file_stem(&normalized) {
1483 return Some(humanize_focus_area(&stem));
1484 }
1485
1486 normalized
1487 .split('/')
1488 .rev()
1489 .find_map(normalize_focus_segment)
1490}
1491
1492fn humanize_focus_area(raw: &str) -> String {
1493 raw.trim().replace(['_', '-'], " ").to_ascii_lowercase()
1494}
1495
1496fn is_generic_path_segment(segment: &str) -> bool {
1497 matches!(
1498 segment.to_ascii_lowercase().as_str(),
1499 "src"
1500 | "crates"
1501 | "apps"
1502 | "packages"
1503 | "commands"
1504 | "tests"
1505 | "test"
1506 | "docs"
1507 | "lib"
1508 | "terminals"
1509 )
1510}
1511
1512fn is_weak_focus_label(label: &str) -> bool {
1513 let trimmed = label.trim();
1514 trimmed.is_empty() || trimmed.chars().all(|ch| ch.is_ascii_digit())
1515}
1516
1517fn normalize_focus_segment(segment: &str) -> Option<String> {
1518 let trimmed = segment.trim();
1519 if trimmed.is_empty() || is_generic_path_segment(trimmed) {
1520 return None;
1521 }
1522
1523 let label = trimmed.trim_start_matches('.');
1524 if is_weak_focus_label(label) {
1525 return None;
1526 }
1527
1528 let humanized = humanize_focus_area(label);
1529 if humanized.is_empty() {
1530 None
1531 } else {
1532 Some(humanized)
1533 }
1534}
1535
1536fn infer_behavior_hint(analysis: &WorktreeAnalysis) -> Option<&'static str> {
1537 let mut corpus = String::new();
1538 for file in &analysis.files {
1539 corpus.push_str(&file.path.to_ascii_lowercase());
1540 corpus.push(' ');
1541 }
1542 for context in &analysis.hunk_contexts {
1543 corpus.push_str(&context.to_ascii_lowercase());
1544 corpus.push(' ');
1545 }
1546
1547 if corpus.contains("request") || corpus.contains("response") || corpus.contains("http") {
1548 Some("request handling")
1549 } else if corpus.contains("auth") || corpus.contains("token") || corpus.contains("session") {
1550 Some("auth flow")
1551 } else if corpus.contains("env") || corpus.contains("config") || corpus.contains("setting") {
1552 Some("configuration loading")
1553 } else if corpus.contains("release") || corpus.contains("version") || corpus.contains("tag") {
1554 Some("release flow")
1555 } else if corpus.contains("docker") || corpus.contains("container") {
1556 Some("container setup")
1557 } else {
1558 None
1559 }
1560}
1561
1562fn format_focus_area_list(areas: &[String]) -> String {
1563 match areas {
1564 [] => "the current worktree".to_string(),
1565 [one] => one.clone(),
1566 [left, right] => format!("{} and {}", left, right),
1567 _ => {
1568 let mut items = areas.to_vec();
1569 let last = items.pop().unwrap_or_default();
1570 format!("{}, and {}", items.join(", "), last)
1571 }
1572 }
1573}
1574
1575fn summarize_symbol_list(entries: &[String], limit: usize) -> String {
1576 entries
1577 .iter()
1578 .take(limit)
1579 .map(|entry| {
1580 entry
1581 .split_once(" (")
1582 .map(|(name, _)| name.to_string())
1583 .unwrap_or_else(|| entry.clone())
1584 })
1585 .collect::<Vec<_>>()
1586 .join(", ")
1587}
1588
1589fn render_commit_message(commit: &ConventionalCommit) -> String {
1590 let mut sections = vec![render_commit_subject(commit)];
1591
1592 if !commit.body.is_empty() {
1593 sections.push(commit.body.join("\n\n"));
1594 }
1595
1596 let mut footer_lines = commit
1597 .footers
1598 .iter()
1599 .map(|entry| entry.trim().to_string())
1600 .filter(|entry| !entry.is_empty())
1601 .collect::<Vec<_>>();
1602 if let Some(breaking_change) = &commit.breaking_change {
1603 if !footer_lines
1604 .iter()
1605 .any(|entry| entry.starts_with("BREAKING CHANGE:"))
1606 {
1607 footer_lines.push(format!("BREAKING CHANGE: {}", breaking_change));
1608 }
1609 }
1610 if !footer_lines.is_empty() {
1611 sections.push(footer_lines.join("\n"));
1612 }
1613
1614 sections.join("\n\n")
1615}
1616
1617fn render_commit_subject(commit: &ConventionalCommit) -> String {
1618 let scope = commit
1619 .scope
1620 .as_deref()
1621 .map(|value| format!("({})", value))
1622 .unwrap_or_default();
1623 let bang = if commit.breaking_change.is_some() {
1624 "!"
1625 } else {
1626 ""
1627 };
1628 format!(
1629 "{}{}{}: {}",
1630 commit.commit_type, scope, bang, commit.description
1631 )
1632}
1633
1634fn print_analysis_summary(
1635 analysis: &WorktreeAnalysis,
1636 commit_plan: &CommitMessagePlan,
1637 _rendered_message: &str,
1638) {
1639 let focus_areas = summarize_focus_areas(analysis, MAX_PRINT_AREAS);
1640 let top_files = collapse_top_file_groups(analysis);
1641 let branch = analysis
1642 .branch
1643 .as_deref()
1644 .map(|value| format!(" on {}", value.bright_blue()))
1645 .unwrap_or_default();
1646 println!(
1647 "{} {}{}",
1648 "Commit".bright_green().bold(),
1649 analysis.repo_name.bright_white().bold(),
1650 branch
1651 );
1652 println!(
1653 " {} {}",
1654 "Mode".bright_cyan().bold(),
1655 match commit_plan.generation_mode {
1656 GenerationMode::OpenRouter => "OpenRouter".bright_white(),
1657 GenerationMode::Heuristic => "Heuristic fallback".bright_white(),
1658 }
1659 );
1660 println!(
1661 " {} {}",
1662 "Subject".bright_cyan().bold(),
1663 render_colored_commit_subject(&commit_plan.commit)
1664 );
1665 println!(
1666 " {} {} file{} (+{} -{})",
1667 "Diff".bright_cyan().bold(),
1668 analysis.files.len().to_string().bright_white(),
1669 if analysis.files.len() == 1 { "" } else { "s" },
1670 analysis.total_additions.to_string().bright_green(),
1671 analysis.total_deletions.to_string().bright_red()
1672 );
1673 if !focus_areas.is_empty() {
1674 println!(
1675 " {} {}",
1676 "Focus".bright_cyan().bold(),
1677 focus_areas.join(", ")
1678 );
1679 }
1680 if !top_files.is_empty() {
1681 let top_files = top_files
1682 .into_iter()
1683 .take(MAX_PRINT_FILES)
1684 .map(|group| format_colored_top_file_group(&group))
1685 .collect::<Vec<_>>();
1686 println!(" {} {}", "Top".bright_cyan().bold(), top_files.join(", "));
1687 }
1688
1689 if !analysis.new_functions.is_empty() {
1690 println!(
1691 " {} {}",
1692 "+fn".bright_cyan().bold(),
1693 summarize_symbol_list(&analysis.new_functions, MAX_PRINT_SYMBOLS)
1694 );
1695 }
1696 if !analysis.changed_functions.is_empty() {
1697 println!(
1698 " {} {}",
1699 "~fn".bright_cyan().bold(),
1700 summarize_symbol_list(&analysis.changed_functions, MAX_PRINT_SYMBOLS)
1701 );
1702 }
1703 if !analysis.new_types.is_empty() {
1704 println!(
1705 " {} {}",
1706 "+type".bright_cyan().bold(),
1707 summarize_symbol_list(&analysis.new_types, MAX_PRINT_SYMBOLS)
1708 );
1709 }
1710 if !analysis.changed_types.is_empty() {
1711 println!(
1712 " {} {}",
1713 "~type".bright_cyan().bold(),
1714 summarize_symbol_list(&analysis.changed_types, MAX_PRINT_SYMBOLS)
1715 );
1716 }
1717 if !analysis.removed_functions.is_empty() {
1718 println!(
1719 " {} {}",
1720 "-fn".bright_cyan().bold(),
1721 summarize_symbol_list(&analysis.removed_functions, MAX_PRINT_SYMBOLS)
1722 );
1723 }
1724 if !analysis.removed_types.is_empty() {
1725 println!(
1726 " {} {}",
1727 "-type".bright_cyan().bold(),
1728 summarize_symbol_list(&analysis.removed_types, MAX_PRINT_SYMBOLS)
1729 );
1730 }
1731
1732 println!("\n{}", "Generated commit".bright_magenta().bold());
1733 println!("{}", "-".repeat(72).bright_black());
1734 println!("{}", render_commit_message_preview(&commit_plan.commit));
1735 println!("{}", "-".repeat(72).bright_black());
1736}
1737
1738async fn stage_and_commit(
1739 repo_root: &Path,
1740 message: &str,
1741 status_entries: &[StatusEntry],
1742 exclude_paths: &[String],
1743) -> Result<StageAndCommitOutcome, String> {
1744 stage_commit_paths(repo_root, status_entries, exclude_paths).await?;
1745
1746 let message_path = env::temp_dir().join(format!("xbp-commit-message-{}.txt", Uuid::new_v4()));
1747 fs::write(&message_path, message).map_err(|e| {
1748 format!(
1749 "Failed to write temporary commit message {}: {}",
1750 message_path.display(),
1751 e
1752 )
1753 })?;
1754
1755 println!("{}", "Creating git commit...".bright_black());
1756 let result = git_output_streaming(
1757 repo_root,
1758 &["commit", "--file", &message_path.to_string_lossy()],
1759 )
1760 .await;
1761 let _ = fs::remove_file(&message_path);
1762 match result {
1763 Ok(_) => Ok(StageAndCommitOutcome::Committed),
1764 Err(error) if looks_like_nothing_to_commit_error(&error) => {
1765 if !git_has_staged_changes(repo_root).await? && git_worktree_is_clean(repo_root).await?
1766 {
1767 Ok(StageAndCommitOutcome::NothingToCommit)
1768 } else {
1769 Err(error)
1770 }
1771 }
1772 Err(error) => Err(error),
1773 }
1774}
1775
1776async fn stage_commit_paths(
1777 repo_root: &Path,
1778 status_entries: &[StatusEntry],
1779 exclude_paths: &[String],
1780) -> Result<(), String> {
1781 let stage_paths = stage_pathspecs(status_entries);
1782 let StageStrategy::BulkAddAll { path_count } =
1783 choose_stage_strategy(&stage_paths, exclude_paths);
1784 if path_count == 0 {
1788 println!("{}", "Staging worktree changes...".bright_black());
1789 } else {
1790 println!(
1791 "{}",
1792 format!("Staging {} changed path(s)...", path_count).bright_black()
1793 );
1794 }
1795 git_output(repo_root, &["add", "--all"]).await?;
1796
1797 for path in exclude_paths {
1798 println!(
1799 "{} {}",
1800 "Keeping excluded path unstaged:".bright_black(),
1801 path.bright_white()
1802 );
1803 git_output(repo_root, &["reset", "HEAD", "--", path.as_str()]).await?;
1804 }
1805 Ok(())
1806}
1807
1808#[derive(Debug, Clone, PartialEq, Eq)]
1809enum StageStrategy {
1810 BulkAddAll { path_count: usize },
1812}
1813
1814fn choose_stage_strategy(stage_paths: &[String], _exclude_paths: &[String]) -> StageStrategy {
1815 StageStrategy::BulkAddAll {
1816 path_count: stage_paths.len(),
1817 }
1818}
1819
1820#[cfg(test)]
1821fn pathspecs_exceed_safe_argv_budget(paths: &[String]) -> bool {
1822 if paths.len() > SAFE_GIT_PATHSPEC_COUNT {
1823 return true;
1824 }
1825 let fixed = "git add --all --".len();
1827 let paths_chars: usize = paths.iter().map(|path| path.len().saturating_add(1)).sum();
1828 fixed.saturating_add(paths_chars) > SAFE_GIT_ARGV_CHAR_BUDGET
1829}
1830
1831fn stage_pathspecs(status_entries: &[StatusEntry]) -> Vec<String> {
1832 let mut paths = BTreeSet::new();
1833 for entry in status_entries {
1834 let path = entry.path.trim();
1835 if path.is_empty() || path.contains(" -> ") {
1836 return Vec::new();
1837 }
1838 if path.ends_with('/') && path.contains(".git/") {
1842 continue;
1843 }
1844 paths.insert(path.to_string());
1845 }
1846 paths.into_iter().collect()
1847}
1848
1849async fn filter_gitignored_status_entries(
1853 repo_root: &Path,
1854 status_entries: Vec<StatusEntry>,
1855) -> Result<Vec<StatusEntry>, String> {
1856 let untracked: Vec<&StatusEntry> = status_entries
1857 .iter()
1858 .filter(|entry| entry.code.contains('?'))
1859 .collect();
1860 if untracked.is_empty() {
1861 return Ok(status_entries);
1862 }
1863
1864 let ignored = git_check_ignore_paths(
1865 repo_root,
1866 &untracked
1867 .iter()
1868 .map(|entry| entry.path.as_str())
1869 .collect::<Vec<_>>(),
1870 )
1871 .await?;
1872
1873 if ignored.is_empty() {
1874 return Ok(status_entries);
1875 }
1876
1877 println!(
1878 "{} {}",
1879 "Skipped".bright_yellow().bold(),
1880 format!(
1881 "excluding {} path(s) matching .gitignore / exclude rules from commit analysis and staging",
1882 ignored.len()
1883 )
1884 .bright_white()
1885 );
1886
1887 Ok(status_entries
1888 .into_iter()
1889 .filter(|entry| !(entry.code.contains('?') && ignored.contains(&entry.path)))
1890 .collect())
1891}
1892
1893async fn git_check_ignore_paths(
1894 repo_root: &Path,
1895 paths: &[&str],
1896) -> Result<BTreeSet<String>, String> {
1897 if paths.is_empty() {
1898 return Ok(BTreeSet::new());
1899 }
1900
1901 let mut command = Command::new("git");
1902 command
1903 .current_dir(repo_root)
1904 .args(["check-ignore", "--stdin"])
1905 .stdin(std::process::Stdio::piped())
1906 .stdout(std::process::Stdio::piped())
1907 .stderr(std::process::Stdio::piped());
1908
1909 let mut child = command
1910 .spawn()
1911 .map_err(|e| format!("Failed to run `git check-ignore`: {}", e))?;
1912
1913 if let Some(mut stdin) = child.stdin.take() {
1914 use tokio::io::AsyncWriteExt;
1915 let body = paths.join("\n");
1916 stdin
1917 .write_all(body.as_bytes())
1918 .await
1919 .map_err(|e| format!("Failed to write paths to `git check-ignore`: {}", e))?;
1920 drop(stdin);
1922 }
1923
1924 let output = child
1925 .wait_with_output()
1926 .await
1927 .map_err(|e| format!("Failed to wait for `git check-ignore`: {}", e))?;
1928
1929 match output.status.code() {
1931 Some(0) | Some(1) => {}
1932 _ => {
1933 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1934 if !stderr.is_empty() {
1935 return Err(format!("`git check-ignore` failed: {}", stderr));
1936 }
1937 return Err(format!(
1938 "`git check-ignore` failed with status {}",
1939 output.status
1940 ));
1941 }
1942 }
1943
1944 let ignored = String::from_utf8_lossy(&output.stdout)
1945 .lines()
1946 .map(str::trim)
1947 .filter(|line| !line.is_empty())
1948 .map(|line| line.replace('\\', "/"))
1949 .collect();
1950 Ok(ignored)
1951}
1952
1953fn format_git_args_for_error(args: &[&str]) -> String {
1954 let joined = args.join(" ");
1955 if joined.chars().count() <= MAX_GIT_ERROR_ARG_CHARS {
1956 return joined;
1957 }
1958 let truncated: String = joined.chars().take(MAX_GIT_ERROR_ARG_CHARS).collect();
1959 format!(
1960 "{}… [{} args, truncated for display]",
1961 truncated,
1962 args.len()
1963 )
1964}
1965
1966fn parse_status_entries(output: &str) -> Vec<StatusEntry> {
1967 output
1968 .lines()
1969 .filter_map(|line| {
1970 if line.len() < 4 {
1973 return None;
1974 }
1975 let code = line[..2].trim().to_string();
1976 let path = line[3..].trim().replace('\\', "/");
1977 let path = unquote_porcelain_path(&path);
1979 if path.is_empty() {
1980 None
1981 } else {
1982 Some(StatusEntry { code, path })
1983 }
1984 })
1985 .collect()
1986}
1987
1988fn unquote_porcelain_path(path: &str) -> String {
1989 let trimmed = path.trim();
1990 if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
1991 trimmed[1..trimmed.len() - 1]
1993 .replace("\\\\", "\\")
1994 .replace("\\\"", "\"")
1995 .replace("\\t", "\t")
1996 .replace("\\n", "\n")
1997 .replace("\\r", "\r")
1998 } else {
1999 trimmed.to_string()
2000 }
2001}
2002
2003fn merge_file_summaries(name_status: &str, numstat: &str) -> Vec<FileChangeSummary> {
2004 let mut status_map = BTreeMap::new();
2005 let mut display_path_map = BTreeMap::new();
2006
2007 for raw_line in name_status
2008 .lines()
2009 .map(str::trim)
2010 .filter(|line| !line.is_empty())
2011 {
2012 let parts = raw_line.split('\t').collect::<Vec<_>>();
2013 if parts.is_empty() {
2014 continue;
2015 }
2016 let status = normalize_name_status(parts[0]);
2017 match parts.as_slice() {
2018 [_, path] => {
2019 let key = normalize_path_key(path);
2020 display_path_map.insert(key.clone(), normalize_display_path(path));
2021 status_map.insert(key, status);
2022 }
2023 [_, old_path, new_path] => {
2024 let key = normalize_path_key(new_path);
2025 display_path_map.insert(
2026 key.clone(),
2027 format!(
2028 "{} -> {}",
2029 normalize_display_path(old_path),
2030 normalize_display_path(new_path)
2031 ),
2032 );
2033 status_map.insert(key, status);
2034 }
2035 _ => {}
2036 }
2037 }
2038
2039 let mut files = Vec::new();
2040 for raw_line in numstat
2041 .lines()
2042 .map(str::trim)
2043 .filter(|line| !line.is_empty())
2044 {
2045 let parts = raw_line.split('\t').collect::<Vec<_>>();
2046 if parts.len() < 3 {
2047 continue;
2048 }
2049 let additions = parts[0].parse::<u32>().unwrap_or(0);
2050 let deletions = parts[1].parse::<u32>().unwrap_or(0);
2051 let raw_path = parts[2..].join("\t");
2052 let key = normalize_path_key(&raw_path);
2053 let path = display_path_map
2054 .get(&key)
2055 .cloned()
2056 .unwrap_or_else(|| normalize_display_path(&raw_path));
2057 let status = status_map
2058 .get(&key)
2059 .cloned()
2060 .unwrap_or_else(|| "modified".to_string());
2061 files.push(FileChangeSummary {
2062 path,
2063 status,
2064 additions,
2065 deletions,
2066 });
2067 }
2068
2069 if files.is_empty() {
2070 for (key, status) in status_map {
2071 files.push(FileChangeSummary {
2072 path: display_path_map
2073 .get(&key)
2074 .cloned()
2075 .unwrap_or_else(|| key.clone()),
2076 status,
2077 additions: 0,
2078 deletions: 0,
2079 });
2080 }
2081 }
2082
2083 files
2084}
2085
2086fn summarize_symbols(diff_text: &str) -> SymbolSummary {
2087 let mut current_file = String::new();
2088 let mut added_functions = BTreeSet::new();
2089 let mut removed_functions = BTreeSet::new();
2090 let mut added_types = BTreeSet::new();
2091 let mut removed_types = BTreeSet::new();
2092 let mut changed_functions = BTreeSet::new();
2093 let mut changed_types = BTreeSet::new();
2094 let mut hunk_contexts = BTreeSet::new();
2095
2096 for line in diff_text.lines() {
2097 if let Some(rest) = line.strip_prefix("+++ b/") {
2098 current_file = rest.trim().replace('\\', "/");
2099 continue;
2100 }
2101 if line.starts_with("@@") {
2102 if let Some(context) = parse_hunk_context(line) {
2103 if is_code_path(¤t_file) {
2104 if let Some((symbol, kind)) = extract_context_symbol(&context) {
2105 match kind {
2106 SymbolKind::Function => {
2107 changed_functions.insert(symbol);
2108 }
2109 SymbolKind::Type => {
2110 changed_types.insert(symbol);
2111 }
2112 }
2113 }
2114 }
2115 if prompt_diff_omit_reason(¤t_file).is_none()
2116 && !is_low_signal_hunk_context(&context)
2117 {
2118 hunk_contexts.insert(context);
2119 }
2120 }
2121 continue;
2122 }
2123
2124 if current_file.is_empty() || line.starts_with("+++") || line.starts_with("---") {
2125 continue;
2126 }
2127
2128 if let Some(source) = line.strip_prefix('+') {
2129 if let Some(name) = extract_function_name(source) {
2130 added_functions.insert(format!("{} ({})", name, current_file));
2131 }
2132 if let Some(name) = extract_type_name(source) {
2133 added_types.insert(format!("{} ({})", name, current_file));
2134 }
2135 } else if let Some(source) = line.strip_prefix('-') {
2136 if let Some(name) = extract_function_name(source) {
2137 removed_functions.insert(format!("{} ({})", name, current_file));
2138 }
2139 if let Some(name) = extract_type_name(source) {
2140 removed_types.insert(format!("{} ({})", name, current_file));
2141 }
2142 }
2143 }
2144
2145 let changed_functions_from_dupes = added_functions
2146 .intersection(&removed_functions)
2147 .cloned()
2148 .collect::<BTreeSet<_>>();
2149 let changed_types_from_dupes = added_types
2150 .intersection(&removed_types)
2151 .cloned()
2152 .collect::<BTreeSet<_>>();
2153
2154 for entry in &changed_functions_from_dupes {
2155 changed_functions.insert(entry.clone());
2156 }
2157 for entry in &changed_types_from_dupes {
2158 changed_types.insert(entry.clone());
2159 }
2160
2161 let new_functions = added_functions
2162 .difference(&changed_functions_from_dupes)
2163 .cloned()
2164 .collect::<Vec<_>>();
2165 let removed_functions = removed_functions
2166 .difference(&changed_functions_from_dupes)
2167 .cloned()
2168 .collect::<Vec<_>>();
2169 let new_types = added_types
2170 .difference(&changed_types_from_dupes)
2171 .cloned()
2172 .collect::<Vec<_>>();
2173 let removed_types = removed_types
2174 .difference(&changed_types_from_dupes)
2175 .cloned()
2176 .collect::<Vec<_>>();
2177
2178 SymbolSummary {
2179 new_functions,
2180 changed_functions: changed_functions.into_iter().collect(),
2181 removed_functions,
2182 new_types,
2183 changed_types: changed_types.into_iter().collect(),
2184 removed_types,
2185 hunk_contexts: hunk_contexts.into_iter().collect(),
2186 }
2187}
2188
2189fn parse_hunk_context(line: &str) -> Option<String> {
2190 let parts = line.split("@@").collect::<Vec<_>>();
2191 if parts.len() < 3 {
2192 return None;
2193 }
2194 let context = parts[2].trim();
2195 if context.is_empty() {
2196 None
2197 } else {
2198 Some(context.to_string())
2199 }
2200}
2201
2202fn is_low_signal_hunk_context(context: &str) -> bool {
2203 let normalized = context.trim().to_ascii_lowercase();
2204 normalized.is_empty()
2205 || matches!(
2206 normalized.as_str(),
2207 "importers:" | "packages:" | "snapshots:" | "[[package]]"
2208 )
2209 || normalized.starts_with("name = ")
2210 || normalized.starts_with("version = ")
2211 || normalized.starts_with("source = ")
2212 || normalized.starts_with("checksum = ")
2213}
2214
2215fn extract_context_symbol(context: &str) -> Option<(String, SymbolKind)> {
2216 let trimmed = context.trim();
2217 if trimmed.is_empty() {
2218 return None;
2219 }
2220
2221 for capture in [
2222 RUST_FUNCTION_RE.captures(trimmed),
2223 JS_FUNCTION_RE.captures(trimmed),
2224 ]
2225 .into_iter()
2226 .flatten()
2227 {
2228 if let Some(name) = capture.get(1) {
2229 let symbol = name.as_str().to_string();
2230 if is_noise_symbol(&symbol) {
2231 return None;
2232 }
2233 return Some((symbol, SymbolKind::Function));
2234 }
2235 }
2236
2237 if let Some(capture) = JS_ARROW_RE.captures(trimmed) {
2238 if let Some(name) = capture.get(1) {
2239 let symbol = name.as_str().to_string();
2240 if is_noise_symbol(&symbol) {
2241 return None;
2242 }
2243 return Some((symbol, SymbolKind::Function));
2244 }
2245 }
2246
2247 for capture in [RUST_TYPE_RE.captures(trimmed), TS_TYPE_RE.captures(trimmed)]
2248 .into_iter()
2249 .flatten()
2250 {
2251 if let Some(name) = capture.get(2) {
2252 let symbol = name.as_str().to_string();
2253 if is_noise_symbol(&symbol) {
2254 return None;
2255 }
2256 return Some((symbol, SymbolKind::Type));
2257 }
2258 }
2259
2260 if let Some(capture) = METHOD_CONTEXT_RE.captures(trimmed) {
2261 if let Some(name) = capture.get(1) {
2262 let symbol = name.as_str().to_string();
2263 if is_noise_symbol(&symbol) {
2264 return None;
2265 }
2266 return Some((symbol, SymbolKind::Function));
2267 }
2268 }
2269
2270 None
2271}
2272
2273fn extract_function_name(source: &str) -> Option<String> {
2274 for capture in [
2275 RUST_FUNCTION_RE.captures(source),
2276 JS_FUNCTION_RE.captures(source),
2277 JS_ARROW_RE.captures(source),
2278 ]
2279 .into_iter()
2280 .flatten()
2281 {
2282 if let Some(name) = capture.get(1) {
2283 return Some(name.as_str().to_string());
2284 }
2285 }
2286 None
2287}
2288
2289fn extract_type_name(source: &str) -> Option<String> {
2290 for capture in [RUST_TYPE_RE.captures(source), TS_TYPE_RE.captures(source)]
2291 .into_iter()
2292 .flatten()
2293 {
2294 if let Some(name) = capture.get(2) {
2295 return Some(name.as_str().to_string());
2296 }
2297 }
2298 None
2299}
2300
2301fn is_code_path(path: &str) -> bool {
2302 let normalized: String = path.to_ascii_lowercase();
2303 [
2304 ".rs", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".java", ".kt", ".swift",
2305 ]
2306 .iter()
2307 .any(|extension| normalized.ends_with(extension))
2308}
2309
2310fn is_noise_symbol(symbol: &str) -> bool {
2311 matches!(
2312 symbol,
2313 "fn" | "pub"
2314 | "impl"
2315 | "mod"
2316 | "use"
2317 | "struct"
2318 | "enum"
2319 | "trait"
2320 | "type"
2321 | "class"
2322 | "interface"
2323 | "const"
2324 | "let"
2325 | "var"
2326 | "async"
2327 | "await"
2328 | "match"
2329 | "if"
2330 | "else"
2331 | "for"
2332 | "while"
2333 | "loop"
2334 )
2335}
2336
2337fn sanitize_commit_type(raw: &str) -> Option<String> {
2338 let normalized: String = raw.trim().to_ascii_lowercase();
2339 if CONVENTIONAL_TYPES.contains(&normalized.as_str()) {
2340 Some(normalized)
2341 } else {
2342 None
2343 }
2344}
2345
2346fn sanitize_scope(raw: Option<&str>) -> Option<String> {
2347 let raw: &str = raw?.trim();
2348 if raw.is_empty() {
2349 return None;
2350 }
2351 let sanitized: String = raw
2352 .chars()
2353 .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/'))
2354 .collect::<String>()
2355 .to_ascii_lowercase();
2356 if sanitized.is_empty() {
2357 None
2358 } else {
2359 Some(sanitized)
2360 }
2361}
2362
2363fn sanitize_description(raw: &str) -> Option<String> {
2364 let trimmed: &str = raw.trim().trim_matches('"').trim();
2365 if trimmed.is_empty() {
2366 return None;
2367 }
2368 let normalized: &str = trimmed.trim_end_matches('.').trim();
2369 if normalized.is_empty() {
2370 None
2371 } else {
2372 Some(normalized.to_string())
2373 }
2374}
2375
2376fn sanitize_footer_value(raw: &str) -> Option<String> {
2377 let trimmed: &str = raw.trim();
2378 if trimmed.is_empty() {
2379 None
2380 } else {
2381 Some(trimmed.to_string())
2382 }
2383}
2384
2385fn interesting_file_stem(path: &str) -> Option<String> {
2386 let stem: String = Path::new(path)
2387 .file_stem()
2388 .and_then(|value| value.to_str())
2389 .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase())?;
2390 if stem.is_empty()
2391 || is_weak_focus_label(&stem)
2392 || matches!(
2393 stem.as_str(),
2394 "mod" | "lib" | "main" | "readme" | "index" | "commands" | "router"
2395 )
2396 {
2397 None
2398 } else {
2399 Some(stem)
2400 }
2401}
2402
2403fn normalize_name_status(raw: &str) -> String {
2404 let code: char = raw.chars().next().unwrap_or('M');
2405 match code {
2406 'A' => "added",
2407 'D' => "deleted",
2408 'R' => "renamed",
2409 'C' => "copied",
2410 'T' => "typechange",
2411 'U' => "unmerged",
2412 'M' => "modified",
2413 _ => "modified",
2414 }
2415 .to_string()
2416}
2417
2418fn normalize_display_path(raw: &str) -> String {
2419 raw.trim().replace('\\', "/")
2420}
2421
2422fn normalize_path_key(raw: &str) -> String {
2423 let cleaned: String = raw.trim().replace(['{', '}'], "").replace('\\', "/");
2424 if let Some((_, tail)) = cleaned.split_once("=>") {
2425 tail.trim().to_string()
2426 } else {
2427 cleaned
2428 }
2429}
2430
2431fn repo_name(path: &Path) -> String {
2432 path.file_name()
2433 .and_then(|value| value.to_str())
2434 .filter(|value| !value.trim().is_empty())
2435 .unwrap_or("repository")
2436 .to_string()
2437}
2438
2439fn truncate_for_prompt(text: &str, limit: usize) -> String {
2440 if text.chars().count() <= limit {
2441 return text.to_string();
2442 }
2443
2444 let truncated: String = text.chars().take(limit).collect::<String>();
2445 format!(
2446 "{}\n\n[diff truncated after {} characters]",
2447 truncated, limit
2448 )
2449}
2450
2451async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
2452 git_output_with_env(project_root, args, &[]).await
2453}
2454
2455async fn git_output_streaming(project_root: &Path, args: &[&str]) -> Result<String, String> {
2456 git_output_with_env_streaming(project_root, args, &[]).await
2457}
2458
2459async fn git_has_staged_changes(project_root: &Path) -> Result<bool, String> {
2460 let output: std::process::Output = Command::new("git")
2461 .current_dir(project_root)
2462 .args(["diff", "--cached", "--quiet", "--exit-code"])
2463 .output()
2464 .await
2465 .map_err(|e| {
2466 format!(
2467 "Failed to run `git diff --cached --quiet --exit-code`: {}",
2468 e
2469 )
2470 })?;
2471
2472 match output.status.code() {
2473 Some(0) => Ok(false),
2474 Some(1) => Ok(true),
2475 _ => {
2476 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
2477 if stderr.is_empty() {
2478 Err(format!(
2479 "`git diff --cached --quiet --exit-code` failed with status {}",
2480 output.status
2481 ))
2482 } else {
2483 Err(format!(
2484 "`git diff --cached --quiet --exit-code` failed: {}",
2485 stderr
2486 ))
2487 }
2488 }
2489 }
2490}
2491
2492async fn git_worktree_is_clean(project_root: &Path) -> Result<bool, String> {
2493 let status: String = git_output(
2494 project_root,
2495 &["status", "--porcelain=v1", "--untracked-files=all"],
2496 )
2497 .await?;
2498 Ok(parse_status_entries(&status).is_empty())
2499}
2500
2501fn looks_like_nothing_to_commit_error(error: &str) -> bool {
2502 let lowered: String = error.to_ascii_lowercase();
2503 lowered.contains("nothing to commit")
2504 || lowered.contains("working tree clean")
2505 || lowered.contains("no changes added to commit")
2506}
2507
2508async fn git_output_with_env(
2509 project_root: &Path,
2510 args: &[&str],
2511 envs: &[(&str, &std::ffi::OsStr)],
2512) -> Result<String, String> {
2513 let mut command: Command = Command::new("git");
2514 command.current_dir(project_root).args(args);
2515 for (key, value) in envs {
2516 command.env(key, value);
2517 }
2518
2519 let display_args: String = format_git_args_for_error(args);
2520 let output: std::process::Output = command
2521 .output()
2522 .await
2523 .map_err(|e| format!("Failed to run `git {}`: {}", display_args, e))?;
2524
2525 if !output.status.success() {
2526 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
2527 if stderr.is_empty() {
2528 return Err(format!(
2529 "`git {}` failed with status {}",
2530 display_args, output.status
2531 ));
2532 }
2533 return Err(format!("`git {}` failed: {}", display_args, stderr));
2534 }
2535
2536 Ok(String::from_utf8_lossy(&output.stdout)
2541 .trim_end()
2542 .to_string())
2543}
2544
2545async fn git_output_with_env_streaming(
2546 project_root: &Path,
2547 args: &[&str],
2548 envs: &[(&str, &std::ffi::OsStr)],
2549) -> Result<String, String> {
2550 let mut command: Command = Command::new("git");
2551 command
2552 .current_dir(project_root)
2553 .args(args)
2554 .stdout(std::process::Stdio::piped())
2555 .stderr(std::process::Stdio::piped());
2556 for (key, value) in envs {
2557 command.env(key, value);
2558 }
2559
2560 let display_args: String = format_git_args_for_error(args);
2561 let mut child: tokio::process::Child = command
2562 .spawn()
2563 .map_err(|e| format!("Failed to run `git {}`: {}", display_args, e))?;
2564
2565 let stdout: tokio::process::ChildStdout = child
2566 .stdout
2567 .take()
2568 .ok_or_else(|| format!("Failed to capture stdout for `git {}`", display_args))?;
2569 let stderr: tokio::process::ChildStderr = child
2570 .stderr
2571 .take()
2572 .ok_or_else(|| format!("Failed to capture stderr for `git {}`", display_args))?;
2573
2574 let stdout_task: tokio::task::JoinHandle<Result<Vec<u8>, io::Error>> =
2575 tokio::spawn(stream_child_output(stdout, false));
2576 let stderr_task: tokio::task::JoinHandle<Result<Vec<u8>, io::Error>> =
2577 tokio::spawn(stream_child_output(stderr, true));
2578
2579 let status: std::process::ExitStatus = child
2580 .wait()
2581 .await
2582 .map_err(|e| format!("Failed to wait for `git {}`: {}", display_args, e))?;
2583
2584 let stdout: Vec<u8> = stdout_task
2585 .await
2586 .map_err(|e| {
2587 format!(
2588 "Failed to join stdout task for `git {}`: {}",
2589 display_args, e
2590 )
2591 })?
2592 .map_err(|e| format!("Failed to read stdout for `git {}`: {}", display_args, e))?;
2593 let stderr: Vec<u8> = stderr_task
2594 .await
2595 .map_err(|e| {
2596 format!(
2597 "Failed to join stderr task for `git {}`: {}",
2598 display_args, e
2599 )
2600 })?
2601 .map_err(|e| format!("Failed to read stderr for `git {}`: {}", display_args, e))?;
2602
2603 if !status.success() {
2604 let stderr = String::from_utf8_lossy(&stderr).trim().to_string();
2605 if stderr.is_empty() {
2606 return Err(format!(
2607 "`git {}` failed with status {}",
2608 display_args, status
2609 ));
2610 }
2611 return Err(format!("`git {}` failed: {}", display_args, stderr));
2612 }
2613
2614 Ok(String::from_utf8_lossy(&stdout).trim_end().to_string())
2615}
2616
2617async fn stream_child_output<R>(reader: R, stderr: bool) -> io::Result<Vec<u8>>
2619where
2620 R: AsyncRead + Unpin,
2621{
2622 let mut reader: BufReader<R> = BufReader::new(reader);
2623 let mut collected: Vec<u8> = Vec::new();
2624 let mut chunk: Vec<u8> = Vec::new();
2625
2626 loop {
2627 chunk.clear();
2628 let bytes_read: usize = reader.read_until(b'\n', &mut chunk).await?;
2629 if bytes_read == 0 {
2630 break;
2631 }
2632
2633 if stderr {
2634 let mut handle = io::stderr().lock();
2635 handle.write_all(&chunk)?;
2636 handle.flush()?;
2637 } else {
2638 let mut handle = io::stdout().lock();
2639 handle.write_all(&chunk)?;
2640 handle.flush()?;
2641 }
2642
2643 collected.extend_from_slice(&chunk);
2644 }
2645
2646 Ok(collected)
2647}
2648
2649struct TemporaryIndex {
2650 path: PathBuf,
2651}
2652
2653impl Drop for TemporaryIndex {
2654 fn drop(&mut self) {
2655 let _ = fs::remove_file(&self.path);
2656 }
2657}
2658
2659#[cfg(test)]
2660mod tests {
2661 use super::{
2662 build_commit_prompt, build_heuristic_body, build_heuristic_commit,
2663 build_prompt_diff_excerpt, choose_stage_strategy, describe_focus_area,
2664 format_colored_change_counts, format_colored_top_file_group, format_git_args_for_error,
2665 infer_commit_type, infer_description, is_terminal_session_path,
2666 looks_like_nothing_to_commit_error, parse_ai_commit_payload, parse_status_entries,
2667 pathspecs_exceed_safe_argv_budget, render_clean_worktree_message, render_commit_message,
2668 render_commit_message_preview, render_commit_subject,
2669 render_terminal_session_block_message, sanitize_scope, stage_pathspecs,
2670 summarize_focus_areas, summarize_symbols, summarize_top_files, terminal_session_paths,
2671 BranchSyncStatus, ConventionalCommit, FileChangeSummary, RepoContext, StageStrategy,
2672 StatusEntry, TopFileGroup, WorktreeAnalysis, SAFE_GIT_ARGV_CHAR_BUDGET,
2673 SAFE_GIT_PATHSPEC_COUNT,
2674 };
2675 use std::path::PathBuf;
2676
2677 fn server_refactor_analysis() -> WorktreeAnalysis {
2678 WorktreeAnalysis {
2679 repo_root: PathBuf::from("C:/repo"),
2680 repo_name: "xbp".to_string(),
2681 branch: Some("feature/refine-server".to_string()),
2682 status_entries: vec![StatusEntry {
2683 code: "M".to_string(),
2684 path: "src/server.ts".to_string(),
2685 }],
2686 files: vec![
2687 FileChangeSummary {
2688 path: "src/server.ts".to_string(),
2689 status: "modified".to_string(),
2690 additions: 80,
2691 deletions: 24,
2692 },
2693 FileChangeSummary {
2694 path: "src/http-app.ts".to_string(),
2695 status: "modified".to_string(),
2696 additions: 42,
2697 deletions: 11,
2698 },
2699 FileChangeSummary {
2700 path: "src/worker/types.ts".to_string(),
2701 status: "modified".to_string(),
2702 additions: 12,
2703 deletions: 3,
2704 },
2705 ],
2706 total_additions: 134,
2707 total_deletions: 38,
2708 diff_text: String::new(),
2709 new_functions: vec![
2710 "buildEnvFromProcess (src/server.ts)".to_string(),
2711 "handleNodeRequest (src/server.ts)".to_string(),
2712 ],
2713 changed_functions: vec!["handleHttpRequest (src/http-app.ts)".to_string()],
2714 removed_functions: Vec::new(),
2715 new_types: vec!["ExecutionContextLike (src/http-app.ts)".to_string()],
2716 changed_types: vec!["Env (src/worker/types.ts)".to_string()],
2717 removed_types: Vec::new(),
2718 hunk_contexts: vec!["async function handleHttpRequest(request: Request)".to_string()],
2719 }
2720 }
2721
2722 #[test]
2723 fn symbol_summary_tracks_new_and_changed_symbols() {
2724 let diff = r#"
2725diff --git a/crates/cli/src/commands/commit.rs b/crates/cli/src/commands/commit.rs
2726index 1111111..2222222 100644
2727--- a/crates/cli/src/commands/commit.rs
2728+++ b/crates/cli/src/commands/commit.rs
2729@@ -0,0 +1,3 @@
2730+pub struct CommitArgs {
2731+}
2732+pub async fn run_commit() {}
2733@@ -10,1 +12,1 @@ pub async fn old_name() {}
2734-pub async fn old_name() {}
2735+pub async fn old_name() {}
2736"#;
2737 let summary = summarize_symbols(diff);
2738 assert!(summary
2739 .new_functions
2740 .iter()
2741 .any(|item| item.contains("run_commit")));
2742 assert!(summary
2743 .new_types
2744 .iter()
2745 .any(|item| item.contains("CommitArgs")));
2746 assert!(summary
2747 .changed_functions
2748 .iter()
2749 .any(|item| item.contains("old_name")));
2750 }
2751
2752 #[test]
2753 fn ai_payload_respects_forced_scope() {
2754 let raw = r#"{
2755 "type": "feat",
2756 "scope": "wrong",
2757 "description": "add commit command",
2758 "body": ["Summarize the worktree."],
2759 "breaking_change": false,
2760 "footers": []
2761}"#;
2762
2763 let commit = parse_ai_commit_payload(raw, Some("cli")).expect("parse");
2764 assert_eq!(commit.scope.as_deref(), Some("cli"));
2765 assert_eq!(commit.commit_type, "feat");
2766 }
2767
2768 #[test]
2769 fn renders_breaking_change_footer_once() {
2770 let rendered = render_commit_message(&ConventionalCommit {
2771 commit_type: "feat".to_string(),
2772 scope: Some("cli".to_string()),
2773 description: "add commit command".to_string(),
2774 body: vec!["Summarize the worktree.".to_string()],
2775 breaking_change: Some("existing automation must call xbp commit".to_string()),
2776 footers: Vec::new(),
2777 });
2778
2779 assert!(rendered.contains("feat(cli)!: add commit command"));
2780 assert!(rendered.contains("BREAKING CHANGE: existing automation must call xbp commit"));
2781 }
2782
2783 #[test]
2784 fn plain_commit_renderers_stay_uncolored() {
2785 let commit = ConventionalCommit {
2786 commit_type: "fix".to_string(),
2787 scope: Some("cli".to_string()),
2788 description: "tighten summary output".to_string(),
2789 body: vec!["Keep the git payload plain.".to_string()],
2790 breaking_change: None,
2791 footers: vec!["Reviewed-by: ops".to_string()],
2792 };
2793
2794 let subject = render_commit_subject(&commit);
2795 let message = render_commit_message(&commit);
2796
2797 assert_eq!(subject, "fix(cli): tighten summary output");
2798 assert!(!subject.contains('\u{1b}'));
2799 assert!(!message.contains('\u{1b}'));
2800 }
2801
2802 #[test]
2803 fn preview_subject_colors_type_and_scope_independently() {
2804 let commit = ConventionalCommit {
2805 commit_type: "feat".to_string(),
2806 scope: Some("docs".to_string()),
2807 description: "refresh operator docs".to_string(),
2808 body: Vec::new(),
2809 breaking_change: None,
2810 footers: Vec::new(),
2811 };
2812
2813 colored::control::set_override(true);
2814 let preview = render_commit_message_preview(&commit);
2815 colored::control::set_override(false);
2816
2817 assert!(preview.contains('\u{1b}'));
2818 assert!(preview.contains("feat"));
2819 assert!(preview.contains("docs"));
2820 assert!(preview.contains("refresh operator docs"));
2821 }
2822
2823 #[test]
2824 fn diff_summary_colors_additions_and_deletions_independently() {
2825 colored::control::set_override(true);
2826 let summary = format_colored_change_counts(29_948, 2_740);
2827 colored::control::set_override(false);
2828 assert!(summary.contains('\u{1b}'));
2829 assert!(summary.contains("29948"));
2830 assert!(summary.contains("2740"));
2831 }
2832
2833 #[test]
2834 fn top_file_groups_color_nested_change_counts() {
2835 colored::control::set_override(true);
2836 let group = TopFileGroup {
2837 label: "apps/docs/generated/".to_string(),
2838 file_count: 3,
2839 additions: 22_125,
2840 deletions: 0,
2841 };
2842
2843 let summary = format_colored_top_file_group(&group);
2844 colored::control::set_override(false);
2845 assert!(summary.contains("apps/docs/generated/"));
2846 assert!(summary.contains("files"));
2847 assert!(summary.contains('\u{1b}'));
2848 assert!(summary.contains("22125"));
2849 }
2850
2851 #[test]
2852 fn infers_feat_for_new_symbols() {
2853 let analysis = WorktreeAnalysis {
2854 repo_root: PathBuf::from("C:/repo"),
2855 repo_name: "xbp".to_string(),
2856 branch: Some("main".to_string()),
2857 status_entries: vec![StatusEntry {
2858 code: "??".to_string(),
2859 path: "crates/cli/src/commands/commit.rs".to_string(),
2860 }],
2861 files: vec![FileChangeSummary {
2862 path: "crates/cli/src/commands/commit.rs".to_string(),
2863 status: "added".to_string(),
2864 additions: 120,
2865 deletions: 0,
2866 }],
2867 total_additions: 120,
2868 total_deletions: 0,
2869 diff_text: String::new(),
2870 new_functions: vec!["run_commit (crates/cli/src/commands/commit.rs)".to_string()],
2871 changed_functions: Vec::new(),
2872 removed_functions: Vec::new(),
2873 new_types: vec!["CommitArgs (crates/cli/src/commands/commit.rs)".to_string()],
2874 changed_types: Vec::new(),
2875 removed_types: Vec::new(),
2876 hunk_contexts: Vec::new(),
2877 };
2878
2879 assert_eq!(infer_commit_type(&analysis), "feat");
2880 }
2881
2882 #[test]
2883 fn infers_refactor_for_new_symbols_inside_existing_modules() {
2884 let analysis = server_refactor_analysis();
2885 assert_eq!(infer_commit_type(&analysis), "refactor");
2886 }
2887
2888 #[test]
2889 fn description_prefers_focus_area_and_behavior_hint() {
2890 let analysis = server_refactor_analysis();
2891 assert_eq!(
2892 infer_description(&analysis, "refactor", None),
2893 "refine server request handling"
2894 );
2895 }
2896
2897 #[test]
2898 fn heuristic_body_reads_like_a_summary() {
2899 let analysis = server_refactor_analysis();
2900 let body = build_heuristic_body(&analysis);
2901 assert_eq!(body.len(), 2);
2902 assert!(body[0].contains("server"));
2903 assert!(body[1].contains("adds buildEnvFromProcess"));
2904 assert!(body[1].contains("src/ (2 files"));
2905 }
2906
2907 #[test]
2908 fn prompt_omits_lockfile_diff_body_but_keeps_structured_summary() {
2909 let diff = r#"
2910diff --git a/package.json b/package.json
2911index b25b17db..d30f02ee 100644
2912--- a/package.json
2913+++ b/package.json
2914@@ -86 +86,2 @@
2915- "yaml": "^2.9.0"
2916+ "yaml": "^2.9.0",
2917+ "nx": "23.0.1"
2918diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
2919index 4916450e..758d7f5e 100644
2920--- a/pnpm-lock.yaml
2921+++ b/pnpm-lock.yaml
2922@@ -998,0 +1005,6 @@ packages:
2923+ '@emnapi/runtime@1.4.5':
2924+ resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==}
2925+ open@8.4.2:
2926+ resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
2927"#;
2928 let symbols = summarize_symbols(diff);
2929 let analysis = WorktreeAnalysis {
2930 repo_root: PathBuf::from("C:/repo"),
2931 repo_name: "speedrun-formations".to_string(),
2932 branch: Some("main".to_string()),
2933 status_entries: vec![
2934 StatusEntry {
2935 code: "M".to_string(),
2936 path: "package.json".to_string(),
2937 },
2938 StatusEntry {
2939 code: "M".to_string(),
2940 path: "pnpm-lock.yaml".to_string(),
2941 },
2942 ],
2943 files: vec![
2944 FileChangeSummary {
2945 path: "package.json".to_string(),
2946 status: "modified".to_string(),
2947 additions: 4,
2948 deletions: 2,
2949 },
2950 FileChangeSummary {
2951 path: "pnpm-lock.yaml".to_string(),
2952 status: "modified".to_string(),
2953 additions: 539,
2954 deletions: 17,
2955 },
2956 ],
2957 total_additions: 543,
2958 total_deletions: 19,
2959 diff_text: diff.to_string(),
2960 new_functions: symbols.new_functions,
2961 changed_functions: symbols.changed_functions,
2962 removed_functions: symbols.removed_functions,
2963 new_types: symbols.new_types,
2964 changed_types: symbols.changed_types,
2965 removed_types: symbols.removed_types,
2966 hunk_contexts: symbols.hunk_contexts,
2967 };
2968
2969 let heuristic = build_heuristic_commit(&analysis, None);
2970 let prompt = build_commit_prompt(&analysis, None, &heuristic);
2971
2972 assert!(prompt.contains("pnpm-lock.yaml (+539 -17; lockfile)"));
2973 assert!(prompt.contains("\"nx\": \"23.0.1\""));
2974 assert!(prompt.contains("package entries added: @emnapi/runtime@1.4.5"));
2975 assert!(!prompt.contains("sha512-++LApOtY0pEEz1zrd9vy1"));
2976 assert!(!prompt.contains("packages:"));
2977 }
2978
2979 #[test]
2980 fn diff_excerpt_reports_when_all_records_are_low_signal() {
2981 let diff = r#"
2982diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
2983index 4916450e..758d7f5e 100644
2984--- a/pnpm-lock.yaml
2985+++ b/pnpm-lock.yaml
2986@@ -998,0 +1005,2 @@ packages:
2987+ open@8.4.2:
2988+ resolution: {integrity: sha512-7x81NCL719oNbsq}
2989"#;
2990 let analysis = WorktreeAnalysis {
2991 repo_root: PathBuf::from("C:/repo"),
2992 repo_name: "speedrun-formations".to_string(),
2993 branch: Some("main".to_string()),
2994 status_entries: Vec::new(),
2995 files: vec![FileChangeSummary {
2996 path: "pnpm-lock.yaml".to_string(),
2997 status: "modified".to_string(),
2998 additions: 539,
2999 deletions: 17,
3000 }],
3001 total_additions: 539,
3002 total_deletions: 17,
3003 diff_text: diff.to_string(),
3004 new_functions: Vec::new(),
3005 changed_functions: Vec::new(),
3006 removed_functions: Vec::new(),
3007 new_types: Vec::new(),
3008 changed_types: Vec::new(),
3009 removed_types: Vec::new(),
3010 hunk_contexts: Vec::new(),
3011 };
3012
3013 let excerpt = build_prompt_diff_excerpt(&analysis);
3014 assert!(excerpt.excerpt.trim().is_empty());
3015 assert_eq!(
3016 excerpt.omitted_files,
3017 vec!["pnpm-lock.yaml (+539 -17; lockfile)".to_string()]
3018 );
3019 assert_eq!(
3020 excerpt.summaries,
3021 vec![
3022 "pnpm-lock.yaml (+539 -17; lockfile)".to_string(),
3023 "package entries added: open@8.4.2".to_string()
3024 ]
3025 );
3026 }
3027
3028 #[test]
3029 fn lockfile_preprocessing_summarizes_repeated_package_churn() {
3030 let diff = r#"
3031diff --git a/services/athena-auth/Cargo.lock b/services/athena-auth/Cargo.lock
3032index 1111111..2222222 100644
3033--- a/services/athena-auth/Cargo.lock
3034+++ b/services/athena-auth/Cargo.lock
3035@@ -10,5 +10,0 @@ name = "aead"
3036-name = "aead"
3037-version = "0.5.2"
3038@@ -20,0 +20,5 @@ name = "aes"
3039+name = "aes"
3040+version = "0.8.4"
3041diff --git a/apps/docs/pnpm-lock.yaml b/apps/docs/pnpm-lock.yaml
3042index f96b24fc..c3b8250d 100644
3043--- a/apps/docs/pnpm-lock.yaml
3044+++ b/apps/docs/pnpm-lock.yaml
3045@@ -21,2 +21,2 @@ importers:
3046- specifier: 16.6.5
3047- version: 16.6.5(@types/react@19.2.10)(next@16.1.5(react@19.2.4))
3048+ specifier: 16.7.13
3049+ version: 16.7.13(@types/react@19.2.10)(next@16.1.5(react@19.2.4))
3050@@ -3113,2 +3140,2 @@ packages:
3051- fumadocs-core@16.6.5:
3052- resolution: {integrity: sha512-old}
3053+ fumadocs-core@16.7.13:
3054+ resolution: {integrity: sha512-new}
3055"#;
3056 let analysis = WorktreeAnalysis {
3057 repo_root: PathBuf::from("C:/repo"),
3058 repo_name: "athena".to_string(),
3059 branch: Some("main".to_string()),
3060 status_entries: Vec::new(),
3061 files: vec![
3062 FileChangeSummary {
3063 path: "services/athena-auth/Cargo.lock".to_string(),
3064 status: "modified".to_string(),
3065 additions: 3403,
3066 deletions: 1366,
3067 },
3068 FileChangeSummary {
3069 path: "apps/docs/pnpm-lock.yaml".to_string(),
3070 status: "modified".to_string(),
3071 additions: 131,
3072 deletions: 53,
3073 },
3074 ],
3075 total_additions: 3534,
3076 total_deletions: 1419,
3077 diff_text: diff.to_string(),
3078 new_functions: Vec::new(),
3079 changed_functions: Vec::new(),
3080 removed_functions: Vec::new(),
3081 new_types: Vec::new(),
3082 changed_types: Vec::new(),
3083 removed_types: Vec::new(),
3084 hunk_contexts: summarize_symbols(diff).hunk_contexts,
3085 };
3086
3087 let prompt = build_commit_prompt(&analysis, None, &build_heuristic_commit(&analysis, None));
3088
3089 assert!(prompt.contains("services/athena-auth/Cargo.lock (+3403 -1366; lockfile)"));
3090 assert!(prompt.contains("apps/docs/pnpm-lock.yaml (+131 -53; lockfile)"));
3091 assert!(prompt.contains("cargo packages added: aes@0.8.4"));
3092 assert!(prompt.contains("cargo packages removed: aead@0.5.2"));
3093 assert!(prompt.contains("version shifts: 16.6.5 -> 16.7.13"));
3094 assert!(prompt.contains("package entries added: fumadocs-core@16.7.13"));
3095 assert!(!prompt.contains("sha512-old"));
3096 assert!(!prompt.contains("name = \"aead\""));
3097 assert!(!prompt.contains("hunk contexts:"));
3098 }
3099
3100 #[test]
3101 fn retained_diff_preprocessing_compacts_repeated_long_lines() {
3102 let repeated = format!(
3103 "+ version: 10.3.11({})(tailwindcss@4.1.18)",
3104 "@types/react@19.2.10".repeat(12)
3105 );
3106 let diff = format!(
3107 "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",
3108 repeated
3109 );
3110 let analysis = WorktreeAnalysis {
3111 repo_root: PathBuf::from("C:/repo"),
3112 repo_name: "speedrun-formations".to_string(),
3113 branch: Some("main".to_string()),
3114 status_entries: Vec::new(),
3115 files: vec![FileChangeSummary {
3116 path: "package.json".to_string(),
3117 status: "modified".to_string(),
3118 additions: 3,
3119 deletions: 0,
3120 }],
3121 total_additions: 3,
3122 total_deletions: 0,
3123 diff_text: diff,
3124 new_functions: Vec::new(),
3125 changed_functions: Vec::new(),
3126 removed_functions: Vec::new(),
3127 new_types: Vec::new(),
3128 changed_types: Vec::new(),
3129 removed_types: Vec::new(),
3130 hunk_contexts: Vec::new(),
3131 };
3132
3133 let excerpt = build_prompt_diff_excerpt(&analysis).excerpt;
3134 assert!(excerpt.contains("(...peer deps omitted)"));
3135 assert!(excerpt.contains("duplicate long diff line(s) omitted"));
3136 assert!(excerpt.contains("\"nx\": \"23.0.1\""));
3137 }
3138
3139 #[test]
3140 fn clean_worktree_message_calls_out_pending_pushes() {
3141 let repo = RepoContext {
3142 repo_root: PathBuf::from("C:/repo"),
3143 repo_name: "xbp".to_string(),
3144 branch: Some("main".to_string()),
3145 };
3146 let message = render_clean_worktree_message(
3147 &repo,
3148 &BranchSyncStatus {
3149 upstream: Some("origin/main".to_string()),
3150 ahead: 2,
3151 behind: 0,
3152 },
3153 );
3154
3155 assert!(message.contains("Nothing to commit."));
3156 assert!(message.contains("2 commit(s) waiting to push"));
3157 assert!(message.contains("origin/main"));
3158 }
3159
3160 #[test]
3161 fn clean_worktree_message_calls_out_synced_branch() {
3162 let repo = RepoContext {
3163 repo_root: PathBuf::from("C:/repo"),
3164 repo_name: "xbp".to_string(),
3165 branch: Some("main".to_string()),
3166 };
3167 let message = render_clean_worktree_message(
3168 &repo,
3169 &BranchSyncStatus {
3170 upstream: Some("origin/main".to_string()),
3171 ahead: 0,
3172 behind: 0,
3173 },
3174 );
3175
3176 assert!(message.contains("Nothing to commit."));
3177 assert!(message.contains("already in sync"));
3178 }
3179
3180 #[test]
3181 fn detects_nothing_to_commit_git_errors() {
3182 assert!(looks_like_nothing_to_commit_error(
3183 "`git commit --file msg.txt` failed: nothing to commit, working tree clean"
3184 ));
3185 assert!(looks_like_nothing_to_commit_error(
3186 "`git commit --file msg.txt` failed: no changes added to commit"
3187 ));
3188 assert!(!looks_like_nothing_to_commit_error(
3189 "`git commit --file msg.txt` failed: pre-commit hook exited with code 1"
3190 ));
3191 }
3192
3193 #[test]
3194 fn stage_pathspecs_use_changed_paths_and_fall_back_for_renames() {
3195 let paths = stage_pathspecs(&[
3196 StatusEntry {
3197 code: "M".to_string(),
3198 path: "src/lib.rs".to_string(),
3199 },
3200 StatusEntry {
3201 code: "??".to_string(),
3202 path: "docs/new.md".to_string(),
3203 },
3204 StatusEntry {
3205 code: "M".to_string(),
3206 path: "src/lib.rs".to_string(),
3207 },
3208 ]);
3209 assert_eq!(
3210 paths,
3211 vec!["docs/new.md".to_string(), "src/lib.rs".to_string()]
3212 );
3213
3214 let rename_paths = stage_pathspecs(&[StatusEntry {
3215 code: "R".to_string(),
3216 path: "old.rs -> new.rs".to_string(),
3217 }]);
3218 assert!(rename_paths.is_empty());
3219 }
3220
3221 #[test]
3222 fn large_worktree_pathspecs_use_bulk_add_to_avoid_windows_argv_truncation() {
3223 let long_paths: Vec<String> = (0..437)
3226 .map(|i| {
3227 format!(
3228 "foundry/instagram-downloads/100500818017217/messages/audio_attachments/{:04}.mp3",
3229 i
3230 )
3231 })
3232 .collect();
3233
3234 assert!(pathspecs_exceed_safe_argv_budget(&long_paths));
3235 assert!(long_paths.len() > SAFE_GIT_PATHSPEC_COUNT);
3236
3237 let strategy = choose_stage_strategy(&long_paths, &[]);
3238 assert_eq!(
3239 strategy,
3240 StageStrategy::BulkAddAll {
3241 path_count: long_paths.len()
3242 }
3243 );
3244
3245 let small = vec!["src/lib.rs".to_string(), "README.md".to_string()];
3246 assert!(!pathspecs_exceed_safe_argv_budget(&small));
3247 assert_eq!(
3248 choose_stage_strategy(&small, &[]),
3249 StageStrategy::BulkAddAll { path_count: 2 }
3250 );
3251 assert_eq!(
3252 choose_stage_strategy(&small, &["terminals/5.txt".to_string()]),
3253 StageStrategy::BulkAddAll { path_count: 2 }
3254 );
3255 assert_eq!(
3256 choose_stage_strategy(&[], &[]),
3257 StageStrategy::BulkAddAll { path_count: 0 }
3258 );
3259
3260 let almost_budget = vec!["a".repeat(SAFE_GIT_ARGV_CHAR_BUDGET)];
3261 assert!(pathspecs_exceed_safe_argv_budget(&almost_budget));
3262 }
3263
3264 #[test]
3265 fn porcelain_status_preserves_dot_prefixed_paths_and_leading_status_space() {
3266 let raw =
3270 " M .xbp/releases/service-dexter-sentinel/1.4.0.yaml\n?? src/bin/dexter-import.rs\n";
3271 let entries = parse_status_entries(raw);
3272 assert_eq!(entries.len(), 2);
3273 assert_eq!(entries[0].code, "M");
3274 assert_eq!(
3275 entries[0].path,
3276 ".xbp/releases/service-dexter-sentinel/1.4.0.yaml"
3277 );
3278 assert_eq!(entries[1].code, "??");
3279 assert_eq!(entries[1].path, "src/bin/dexter-import.rs");
3280
3281 let corrupted = raw.trim();
3283 let broken = parse_status_entries(corrupted);
3284 assert_eq!(
3285 broken[0].path, "xbp/releases/service-dexter-sentinel/1.4.0.yaml",
3286 "documenting the failure mode that full trim() produces"
3287 );
3288 }
3289
3290 #[test]
3291 fn git_error_args_are_truncated_for_readable_failures() {
3292 let many: Vec<String> = (0..80).map(|i| format!("path/to/file-{i}.bin")).collect();
3293 let refs: Vec<&str> = many.iter().map(String::as_str).collect();
3294 let mut args = vec!["add", "--all", "--"];
3295 args.extend(refs.iter().copied());
3296
3297 let display = format_git_args_for_error(&args);
3298 assert!(display.contains("truncated for display"));
3299 assert!(display.len() < 400);
3300 assert!(!display.contains("path/to/file-79.bin") || display.contains("…"));
3301 }
3302
3303 #[tokio::test]
3304 async fn stage_commit_paths_bulk_add_respects_gitignore_and_excludes() {
3305 let root = std::env::temp_dir().join(format!(
3308 "xbp-commit-stage-test-{}-{}",
3309 std::process::id(),
3310 uuid::Uuid::new_v4()
3311 ));
3312 let _ = std::fs::remove_dir_all(&root);
3313 std::fs::create_dir_all(&root).unwrap_or_else(|e| {
3314 panic!("create temp root {}: {e}", root.display());
3315 });
3316 assert!(
3317 root.is_dir(),
3318 "temp root missing after create_dir_all: {}",
3319 root.display()
3320 );
3321
3322 let git_home = root.join("_git_home");
3324 std::fs::create_dir_all(&git_home).expect("git home");
3325 let git = |args: &[&str]| {
3326 std::process::Command::new("git")
3327 .current_dir(&root)
3328 .env("HOME", &git_home)
3329 .env("XDG_CONFIG_HOME", git_home.join(".config"))
3330 .env("GIT_CONFIG_GLOBAL", git_home.join(".gitconfig"))
3331 .env("GIT_CONFIG_NOSYSTEM", "1")
3332 .args(args)
3333 .output()
3334 .unwrap_or_else(|e| {
3335 panic!(
3336 "failed to spawn `git {:?}` in {}: {e}",
3337 args,
3338 root.display()
3339 )
3340 })
3341 };
3342
3343 let init = git(&["init"]);
3344 assert!(
3345 init.status.success(),
3346 "git init failed: status={:?} stderr={}",
3347 init.status,
3348 String::from_utf8_lossy(&init.stderr)
3349 );
3350 assert!(git(&["config", "user.email", "test@example.com"])
3351 .status
3352 .success());
3353 assert!(git(&["config", "user.name", "Test"]).status.success());
3354 let _ = git(&["checkout", "-b", "main"]);
3356
3357 std::fs::write(root.join(".gitignore"), "ignored-dir/\n*.tmp\n").unwrap();
3358 std::fs::write(root.join("keep.rs"), "fn main() {}\n").unwrap();
3359 std::fs::create_dir_all(root.join("ignored-dir")).unwrap();
3360 std::fs::write(root.join("ignored-dir/secret.bin"), "nope").unwrap();
3361 std::fs::write(root.join("noise.tmp"), "tmp").unwrap();
3362 std::fs::create_dir_all(root.join("terminals")).unwrap();
3363 std::fs::write(root.join("terminals/session.txt"), "session").unwrap();
3364
3365 assert!(git(&["add", ".gitignore", "keep.rs"]).status.success());
3367 assert!(git(&["commit", "-m", "init"]).status.success());
3368
3369 std::fs::write(root.join("keep.rs"), "fn main() { /* changed */ }\n").unwrap();
3370 std::fs::write(root.join("feature.rs"), "pub fn f() {}\n").unwrap();
3371 std::fs::write(root.join("ignored-dir/secret.bin"), "still ignored").unwrap();
3372 std::fs::write(root.join("noise.tmp"), "still tmp").unwrap();
3373 std::fs::write(root.join("terminals/session.txt"), "session v2").unwrap();
3374
3375 let status_entries = vec![
3376 StatusEntry {
3377 code: " M".to_string(),
3378 path: "keep.rs".to_string(),
3379 },
3380 StatusEntry {
3381 code: "??".to_string(),
3382 path: "feature.rs".to_string(),
3383 },
3384 StatusEntry {
3385 code: "??".to_string(),
3386 path: "terminals/session.txt".to_string(),
3387 },
3388 ];
3389
3390 let mut bloated_status = status_entries.clone();
3392 for i in 0..100 {
3393 bloated_status.push(StatusEntry {
3394 code: "??".to_string(),
3395 path: format!(
3396 "foundry/instagram-downloads/user/messages/audio_attachments/{:04}.mp3",
3397 i
3398 ),
3399 });
3400 }
3401
3402 super::stage_commit_paths(
3403 &root,
3404 &bloated_status,
3405 &["terminals/session.txt".to_string()],
3406 )
3407 .await
3408 .expect("stage should succeed without pathspec truncation");
3409
3410 let cached = git(&["diff", "--cached", "--name-only"]);
3411 assert!(cached.status.success());
3412 let staged = String::from_utf8_lossy(&cached.stdout).replace('\\', "/");
3413 assert!(
3414 staged.contains("keep.rs"),
3415 "expected keep.rs staged, got:\n{}",
3416 staged
3417 );
3418 assert!(
3419 staged.contains("feature.rs"),
3420 "expected feature.rs staged, got:\n{}",
3421 staged
3422 );
3423 assert!(
3424 !staged.contains("ignored-dir"),
3425 "gitignore paths must not be staged:\n{}",
3426 staged
3427 );
3428 assert!(
3429 !staged.contains("noise.tmp"),
3430 "*.tmp gitignore must not be staged:\n{}",
3431 staged
3432 );
3433 assert!(
3434 !staged.contains("terminals/session.txt"),
3435 "excluded terminal path must stay unstaged:\n{}",
3436 staged
3437 );
3438
3439 let filtered = super::filter_gitignored_status_entries(
3441 &root,
3442 vec![
3443 StatusEntry {
3444 code: "??".to_string(),
3445 path: "feature.rs".to_string(),
3446 },
3447 StatusEntry {
3448 code: "??".to_string(),
3449 path: "noise.tmp".to_string(),
3450 },
3451 StatusEntry {
3452 code: "??".to_string(),
3453 path: "ignored-dir/secret.bin".to_string(),
3454 },
3455 StatusEntry {
3456 code: " M".to_string(),
3457 path: "keep.rs".to_string(),
3458 },
3459 ],
3460 )
3461 .await
3462 .expect("filter");
3463
3464 let filtered_paths: Vec<&str> = filtered.iter().map(|e| e.path.as_str()).collect();
3465 assert_eq!(filtered_paths, vec!["feature.rs", "keep.rs"]);
3466
3467 let _ = std::fs::remove_dir_all(&root);
3468 }
3469
3470 #[test]
3471 fn scope_sanitizer_keeps_valid_tokens() {
3472 assert_eq!(
3473 sanitize_scope(Some("CLI/tools")),
3474 Some("cli/tools".to_string())
3475 );
3476 assert_eq!(sanitize_scope(Some(" ")), None);
3477 }
3478
3479 #[test]
3480 fn terminal_session_paths_are_detected_and_blocked() {
3481 let analysis = WorktreeAnalysis {
3482 repo_root: PathBuf::from("C:/repo"),
3483 repo_name: "xbp".to_string(),
3484 branch: Some("main".to_string()),
3485 status_entries: Vec::new(),
3486 files: vec![
3487 FileChangeSummary {
3488 path: "terminals/5.txt".to_string(),
3489 status: "added".to_string(),
3490 additions: 388_613,
3491 deletions: 0,
3492 },
3493 FileChangeSummary {
3494 path: "terminals/.next-id".to_string(),
3495 status: "modified".to_string(),
3496 additions: 1,
3497 deletions: 1,
3498 },
3499 ],
3500 total_additions: 388_614,
3501 total_deletions: 1,
3502 diff_text: String::new(),
3503 new_functions: Vec::new(),
3504 changed_functions: Vec::new(),
3505 removed_functions: Vec::new(),
3506 new_types: Vec::new(),
3507 changed_types: Vec::new(),
3508 removed_types: Vec::new(),
3509 hunk_contexts: Vec::new(),
3510 };
3511
3512 assert!(is_terminal_session_path("terminals/5.txt"));
3513 assert!(!is_terminal_session_path(
3514 "crates/cli/src/commands/commit.rs"
3515 ));
3516 assert_eq!(
3517 terminal_session_paths(&analysis),
3518 vec![
3519 "terminals/5.txt".to_string(),
3520 "terminals/.next-id".to_string()
3521 ]
3522 );
3523 assert!(
3524 render_terminal_session_block_message(&terminal_session_paths(&analysis))
3525 .contains("Refusing to commit IDE terminal session dumps")
3526 );
3527 assert_eq!(infer_commit_type(&analysis), "chore");
3528 assert_eq!(
3529 infer_description(&analysis, "chore", None),
3530 "ignore ide terminal session logs"
3531 );
3532
3533 let heuristic = build_heuristic_commit(&analysis, None);
3534 assert_eq!(heuristic.commit_type, "chore");
3535 assert_eq!(heuristic.description, "ignore ide terminal session logs");
3536 assert!(!render_commit_message(&heuristic).contains("flow"));
3537 }
3538
3539 #[test]
3540 fn focus_area_groups_terminal_session_files() {
3541 assert_eq!(
3542 describe_focus_area("terminals/5.txt"),
3543 Some("terminals".to_string())
3544 );
3545 assert_eq!(
3546 describe_focus_area("terminals/.next-id"),
3547 Some("terminals".to_string())
3548 );
3549 assert_eq!(
3550 describe_focus_area(".gitignore"),
3551 Some("gitignore".to_string())
3552 );
3553 }
3554
3555 #[test]
3556 fn summarize_focus_areas_collapses_terminal_logs() {
3557 let analysis = WorktreeAnalysis {
3558 repo_root: PathBuf::from("C:/repo"),
3559 repo_name: "xbp".to_string(),
3560 branch: Some("main".to_string()),
3561 status_entries: Vec::new(),
3562 files: vec![
3563 FileChangeSummary {
3564 path: "terminals/5.txt".to_string(),
3565 status: "deleted".to_string(),
3566 additions: 0,
3567 deletions: 388_613,
3568 },
3569 FileChangeSummary {
3570 path: "terminals/3.txt".to_string(),
3571 status: "deleted".to_string(),
3572 additions: 0,
3573 deletions: 439,
3574 },
3575 FileChangeSummary {
3576 path: "terminals/.next-id".to_string(),
3577 status: "modified".to_string(),
3578 additions: 0,
3579 deletions: 1,
3580 },
3581 FileChangeSummary {
3582 path: ".gitignore".to_string(),
3583 status: "modified".to_string(),
3584 additions: 1,
3585 deletions: 0,
3586 },
3587 ],
3588 total_additions: 1,
3589 total_deletions: 389_053,
3590 diff_text: String::new(),
3591 new_functions: Vec::new(),
3592 changed_functions: Vec::new(),
3593 removed_functions: Vec::new(),
3594 new_types: Vec::new(),
3595 changed_types: Vec::new(),
3596 removed_types: Vec::new(),
3597 hunk_contexts: Vec::new(),
3598 };
3599
3600 assert_eq!(
3601 summarize_focus_areas(&analysis, 4),
3602 vec!["terminals".to_string(), "gitignore".to_string()]
3603 );
3604 assert_eq!(
3605 summarize_top_files(&analysis, 4),
3606 vec![
3607 "terminals/ (3 files, +0 -389053)".to_string(),
3608 ".gitignore (+1 -0)".to_string(),
3609 ]
3610 );
3611 }
3612}