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
1806 let oversized =
1808 crate::cli::auto_commit::unstage_github_oversized_paths(repo_root).await?;
1809 if !oversized.is_empty() {
1810 println!(
1811 "{} {}",
1812 "Large files".bright_yellow().bold(),
1813 format!(
1814 "left unstaged (GitHub max 100 MiB): {}",
1815 oversized
1816 .iter()
1817 .map(|item| format!("{} ({:.2} MiB)", item.path, item.size_bytes as f64 / (1024.0 * 1024.0)))
1818 .collect::<Vec<_>>()
1819 .join(", ")
1820 )
1821 .bright_white()
1822 );
1823 }
1824 Ok(())
1825}
1826
1827#[derive(Debug, Clone, PartialEq, Eq)]
1828enum StageStrategy {
1829 BulkAddAll { path_count: usize },
1831}
1832
1833fn choose_stage_strategy(stage_paths: &[String], _exclude_paths: &[String]) -> StageStrategy {
1834 StageStrategy::BulkAddAll {
1835 path_count: stage_paths.len(),
1836 }
1837}
1838
1839#[cfg(test)]
1840fn pathspecs_exceed_safe_argv_budget(paths: &[String]) -> bool {
1841 if paths.len() > SAFE_GIT_PATHSPEC_COUNT {
1842 return true;
1843 }
1844 let fixed = "git add --all --".len();
1846 let paths_chars: usize = paths.iter().map(|path| path.len().saturating_add(1)).sum();
1847 fixed.saturating_add(paths_chars) > SAFE_GIT_ARGV_CHAR_BUDGET
1848}
1849
1850fn stage_pathspecs(status_entries: &[StatusEntry]) -> Vec<String> {
1851 let mut paths = BTreeSet::new();
1852 for entry in status_entries {
1853 let path = entry.path.trim();
1854 if path.is_empty() || path.contains(" -> ") {
1855 return Vec::new();
1856 }
1857 if path.ends_with('/') && path.contains(".git/") {
1861 continue;
1862 }
1863 paths.insert(path.to_string());
1864 }
1865 paths.into_iter().collect()
1866}
1867
1868async fn filter_gitignored_status_entries(
1872 repo_root: &Path,
1873 status_entries: Vec<StatusEntry>,
1874) -> Result<Vec<StatusEntry>, String> {
1875 let untracked: Vec<&StatusEntry> = status_entries
1876 .iter()
1877 .filter(|entry| entry.code.contains('?'))
1878 .collect();
1879 if untracked.is_empty() {
1880 return Ok(status_entries);
1881 }
1882
1883 let ignored = git_check_ignore_paths(
1884 repo_root,
1885 &untracked
1886 .iter()
1887 .map(|entry| entry.path.as_str())
1888 .collect::<Vec<_>>(),
1889 )
1890 .await?;
1891
1892 if ignored.is_empty() {
1893 return Ok(status_entries);
1894 }
1895
1896 println!(
1897 "{} {}",
1898 "Skipped".bright_yellow().bold(),
1899 format!(
1900 "excluding {} path(s) matching .gitignore / exclude rules from commit analysis and staging",
1901 ignored.len()
1902 )
1903 .bright_white()
1904 );
1905
1906 Ok(status_entries
1907 .into_iter()
1908 .filter(|entry| !(entry.code.contains('?') && ignored.contains(&entry.path)))
1909 .collect())
1910}
1911
1912async fn git_check_ignore_paths(
1913 repo_root: &Path,
1914 paths: &[&str],
1915) -> Result<BTreeSet<String>, String> {
1916 if paths.is_empty() {
1917 return Ok(BTreeSet::new());
1918 }
1919
1920 let mut command = Command::new("git");
1921 command
1922 .current_dir(repo_root)
1923 .args(["check-ignore", "--stdin"])
1924 .stdin(std::process::Stdio::piped())
1925 .stdout(std::process::Stdio::piped())
1926 .stderr(std::process::Stdio::piped());
1927
1928 let mut child = command
1929 .spawn()
1930 .map_err(|e| format!("Failed to run `git check-ignore`: {}", e))?;
1931
1932 if let Some(mut stdin) = child.stdin.take() {
1933 use tokio::io::AsyncWriteExt;
1934 let body = paths.join("\n");
1935 stdin
1936 .write_all(body.as_bytes())
1937 .await
1938 .map_err(|e| format!("Failed to write paths to `git check-ignore`: {}", e))?;
1939 drop(stdin);
1941 }
1942
1943 let output = child
1944 .wait_with_output()
1945 .await
1946 .map_err(|e| format!("Failed to wait for `git check-ignore`: {}", e))?;
1947
1948 match output.status.code() {
1950 Some(0) | Some(1) => {}
1951 _ => {
1952 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1953 if !stderr.is_empty() {
1954 return Err(format!("`git check-ignore` failed: {}", stderr));
1955 }
1956 return Err(format!(
1957 "`git check-ignore` failed with status {}",
1958 output.status
1959 ));
1960 }
1961 }
1962
1963 let ignored = String::from_utf8_lossy(&output.stdout)
1964 .lines()
1965 .map(str::trim)
1966 .filter(|line| !line.is_empty())
1967 .map(|line| line.replace('\\', "/"))
1968 .collect();
1969 Ok(ignored)
1970}
1971
1972fn format_git_args_for_error(args: &[&str]) -> String {
1973 let joined = args.join(" ");
1974 if joined.chars().count() <= MAX_GIT_ERROR_ARG_CHARS {
1975 return joined;
1976 }
1977 let truncated: String = joined.chars().take(MAX_GIT_ERROR_ARG_CHARS).collect();
1978 format!(
1979 "{}… [{} args, truncated for display]",
1980 truncated,
1981 args.len()
1982 )
1983}
1984
1985fn parse_status_entries(output: &str) -> Vec<StatusEntry> {
1986 output
1987 .lines()
1988 .filter_map(|line| {
1989 if line.len() < 4 {
1992 return None;
1993 }
1994 let code = line[..2].trim().to_string();
1995 let path = line[3..].trim().replace('\\', "/");
1996 let path = unquote_porcelain_path(&path);
1998 if path.is_empty() {
1999 None
2000 } else {
2001 Some(StatusEntry { code, path })
2002 }
2003 })
2004 .collect()
2005}
2006
2007fn unquote_porcelain_path(path: &str) -> String {
2008 let trimmed = path.trim();
2009 if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
2010 trimmed[1..trimmed.len() - 1]
2012 .replace("\\\\", "\\")
2013 .replace("\\\"", "\"")
2014 .replace("\\t", "\t")
2015 .replace("\\n", "\n")
2016 .replace("\\r", "\r")
2017 } else {
2018 trimmed.to_string()
2019 }
2020}
2021
2022fn merge_file_summaries(name_status: &str, numstat: &str) -> Vec<FileChangeSummary> {
2023 let mut status_map = BTreeMap::new();
2024 let mut display_path_map = BTreeMap::new();
2025
2026 for raw_line in name_status
2027 .lines()
2028 .map(str::trim)
2029 .filter(|line| !line.is_empty())
2030 {
2031 let parts = raw_line.split('\t').collect::<Vec<_>>();
2032 if parts.is_empty() {
2033 continue;
2034 }
2035 let status = normalize_name_status(parts[0]);
2036 match parts.as_slice() {
2037 [_, path] => {
2038 let key = normalize_path_key(path);
2039 display_path_map.insert(key.clone(), normalize_display_path(path));
2040 status_map.insert(key, status);
2041 }
2042 [_, old_path, new_path] => {
2043 let key = normalize_path_key(new_path);
2044 display_path_map.insert(
2045 key.clone(),
2046 format!(
2047 "{} -> {}",
2048 normalize_display_path(old_path),
2049 normalize_display_path(new_path)
2050 ),
2051 );
2052 status_map.insert(key, status);
2053 }
2054 _ => {}
2055 }
2056 }
2057
2058 let mut files = Vec::new();
2059 for raw_line in numstat
2060 .lines()
2061 .map(str::trim)
2062 .filter(|line| !line.is_empty())
2063 {
2064 let parts = raw_line.split('\t').collect::<Vec<_>>();
2065 if parts.len() < 3 {
2066 continue;
2067 }
2068 let additions = parts[0].parse::<u32>().unwrap_or(0);
2069 let deletions = parts[1].parse::<u32>().unwrap_or(0);
2070 let raw_path = parts[2..].join("\t");
2071 let key = normalize_path_key(&raw_path);
2072 let path = display_path_map
2073 .get(&key)
2074 .cloned()
2075 .unwrap_or_else(|| normalize_display_path(&raw_path));
2076 let status = status_map
2077 .get(&key)
2078 .cloned()
2079 .unwrap_or_else(|| "modified".to_string());
2080 files.push(FileChangeSummary {
2081 path,
2082 status,
2083 additions,
2084 deletions,
2085 });
2086 }
2087
2088 if files.is_empty() {
2089 for (key, status) in status_map {
2090 files.push(FileChangeSummary {
2091 path: display_path_map
2092 .get(&key)
2093 .cloned()
2094 .unwrap_or_else(|| key.clone()),
2095 status,
2096 additions: 0,
2097 deletions: 0,
2098 });
2099 }
2100 }
2101
2102 files
2103}
2104
2105fn summarize_symbols(diff_text: &str) -> SymbolSummary {
2106 let mut current_file = String::new();
2107 let mut added_functions = BTreeSet::new();
2108 let mut removed_functions = BTreeSet::new();
2109 let mut added_types = BTreeSet::new();
2110 let mut removed_types = BTreeSet::new();
2111 let mut changed_functions = BTreeSet::new();
2112 let mut changed_types = BTreeSet::new();
2113 let mut hunk_contexts = BTreeSet::new();
2114
2115 for line in diff_text.lines() {
2116 if let Some(rest) = line.strip_prefix("+++ b/") {
2117 current_file = rest.trim().replace('\\', "/");
2118 continue;
2119 }
2120 if line.starts_with("@@") {
2121 if let Some(context) = parse_hunk_context(line) {
2122 if is_code_path(¤t_file) {
2123 if let Some((symbol, kind)) = extract_context_symbol(&context) {
2124 match kind {
2125 SymbolKind::Function => {
2126 changed_functions.insert(symbol);
2127 }
2128 SymbolKind::Type => {
2129 changed_types.insert(symbol);
2130 }
2131 }
2132 }
2133 }
2134 if prompt_diff_omit_reason(¤t_file).is_none()
2135 && !is_low_signal_hunk_context(&context)
2136 {
2137 hunk_contexts.insert(context);
2138 }
2139 }
2140 continue;
2141 }
2142
2143 if current_file.is_empty() || line.starts_with("+++") || line.starts_with("---") {
2144 continue;
2145 }
2146
2147 if let Some(source) = line.strip_prefix('+') {
2148 if let Some(name) = extract_function_name(source) {
2149 added_functions.insert(format!("{} ({})", name, current_file));
2150 }
2151 if let Some(name) = extract_type_name(source) {
2152 added_types.insert(format!("{} ({})", name, current_file));
2153 }
2154 } else if let Some(source) = line.strip_prefix('-') {
2155 if let Some(name) = extract_function_name(source) {
2156 removed_functions.insert(format!("{} ({})", name, current_file));
2157 }
2158 if let Some(name) = extract_type_name(source) {
2159 removed_types.insert(format!("{} ({})", name, current_file));
2160 }
2161 }
2162 }
2163
2164 let changed_functions_from_dupes = added_functions
2165 .intersection(&removed_functions)
2166 .cloned()
2167 .collect::<BTreeSet<_>>();
2168 let changed_types_from_dupes = added_types
2169 .intersection(&removed_types)
2170 .cloned()
2171 .collect::<BTreeSet<_>>();
2172
2173 for entry in &changed_functions_from_dupes {
2174 changed_functions.insert(entry.clone());
2175 }
2176 for entry in &changed_types_from_dupes {
2177 changed_types.insert(entry.clone());
2178 }
2179
2180 let new_functions = added_functions
2181 .difference(&changed_functions_from_dupes)
2182 .cloned()
2183 .collect::<Vec<_>>();
2184 let removed_functions = removed_functions
2185 .difference(&changed_functions_from_dupes)
2186 .cloned()
2187 .collect::<Vec<_>>();
2188 let new_types = added_types
2189 .difference(&changed_types_from_dupes)
2190 .cloned()
2191 .collect::<Vec<_>>();
2192 let removed_types = removed_types
2193 .difference(&changed_types_from_dupes)
2194 .cloned()
2195 .collect::<Vec<_>>();
2196
2197 SymbolSummary {
2198 new_functions,
2199 changed_functions: changed_functions.into_iter().collect(),
2200 removed_functions,
2201 new_types,
2202 changed_types: changed_types.into_iter().collect(),
2203 removed_types,
2204 hunk_contexts: hunk_contexts.into_iter().collect(),
2205 }
2206}
2207
2208fn parse_hunk_context(line: &str) -> Option<String> {
2209 let parts = line.split("@@").collect::<Vec<_>>();
2210 if parts.len() < 3 {
2211 return None;
2212 }
2213 let context = parts[2].trim();
2214 if context.is_empty() {
2215 None
2216 } else {
2217 Some(context.to_string())
2218 }
2219}
2220
2221fn is_low_signal_hunk_context(context: &str) -> bool {
2222 let normalized = context.trim().to_ascii_lowercase();
2223 normalized.is_empty()
2224 || matches!(
2225 normalized.as_str(),
2226 "importers:" | "packages:" | "snapshots:" | "[[package]]"
2227 )
2228 || normalized.starts_with("name = ")
2229 || normalized.starts_with("version = ")
2230 || normalized.starts_with("source = ")
2231 || normalized.starts_with("checksum = ")
2232}
2233
2234fn extract_context_symbol(context: &str) -> Option<(String, SymbolKind)> {
2235 let trimmed = context.trim();
2236 if trimmed.is_empty() {
2237 return None;
2238 }
2239
2240 for capture in [
2241 RUST_FUNCTION_RE.captures(trimmed),
2242 JS_FUNCTION_RE.captures(trimmed),
2243 ]
2244 .into_iter()
2245 .flatten()
2246 {
2247 if let Some(name) = capture.get(1) {
2248 let symbol = name.as_str().to_string();
2249 if is_noise_symbol(&symbol) {
2250 return None;
2251 }
2252 return Some((symbol, SymbolKind::Function));
2253 }
2254 }
2255
2256 if let Some(capture) = JS_ARROW_RE.captures(trimmed) {
2257 if let Some(name) = capture.get(1) {
2258 let symbol = name.as_str().to_string();
2259 if is_noise_symbol(&symbol) {
2260 return None;
2261 }
2262 return Some((symbol, SymbolKind::Function));
2263 }
2264 }
2265
2266 for capture in [RUST_TYPE_RE.captures(trimmed), TS_TYPE_RE.captures(trimmed)]
2267 .into_iter()
2268 .flatten()
2269 {
2270 if let Some(name) = capture.get(2) {
2271 let symbol = name.as_str().to_string();
2272 if is_noise_symbol(&symbol) {
2273 return None;
2274 }
2275 return Some((symbol, SymbolKind::Type));
2276 }
2277 }
2278
2279 if let Some(capture) = METHOD_CONTEXT_RE.captures(trimmed) {
2280 if let Some(name) = capture.get(1) {
2281 let symbol = name.as_str().to_string();
2282 if is_noise_symbol(&symbol) {
2283 return None;
2284 }
2285 return Some((symbol, SymbolKind::Function));
2286 }
2287 }
2288
2289 None
2290}
2291
2292fn extract_function_name(source: &str) -> Option<String> {
2293 for capture in [
2294 RUST_FUNCTION_RE.captures(source),
2295 JS_FUNCTION_RE.captures(source),
2296 JS_ARROW_RE.captures(source),
2297 ]
2298 .into_iter()
2299 .flatten()
2300 {
2301 if let Some(name) = capture.get(1) {
2302 return Some(name.as_str().to_string());
2303 }
2304 }
2305 None
2306}
2307
2308fn extract_type_name(source: &str) -> Option<String> {
2309 for capture in [RUST_TYPE_RE.captures(source), TS_TYPE_RE.captures(source)]
2310 .into_iter()
2311 .flatten()
2312 {
2313 if let Some(name) = capture.get(2) {
2314 return Some(name.as_str().to_string());
2315 }
2316 }
2317 None
2318}
2319
2320fn is_code_path(path: &str) -> bool {
2321 let normalized: String = path.to_ascii_lowercase();
2322 [
2323 ".rs", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".java", ".kt", ".swift",
2324 ]
2325 .iter()
2326 .any(|extension| normalized.ends_with(extension))
2327}
2328
2329fn is_noise_symbol(symbol: &str) -> bool {
2330 matches!(
2331 symbol,
2332 "fn" | "pub"
2333 | "impl"
2334 | "mod"
2335 | "use"
2336 | "struct"
2337 | "enum"
2338 | "trait"
2339 | "type"
2340 | "class"
2341 | "interface"
2342 | "const"
2343 | "let"
2344 | "var"
2345 | "async"
2346 | "await"
2347 | "match"
2348 | "if"
2349 | "else"
2350 | "for"
2351 | "while"
2352 | "loop"
2353 )
2354}
2355
2356fn sanitize_commit_type(raw: &str) -> Option<String> {
2357 let normalized: String = raw.trim().to_ascii_lowercase();
2358 if CONVENTIONAL_TYPES.contains(&normalized.as_str()) {
2359 Some(normalized)
2360 } else {
2361 None
2362 }
2363}
2364
2365fn sanitize_scope(raw: Option<&str>) -> Option<String> {
2366 let raw: &str = raw?.trim();
2367 if raw.is_empty() {
2368 return None;
2369 }
2370 let sanitized: String = raw
2371 .chars()
2372 .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/'))
2373 .collect::<String>()
2374 .to_ascii_lowercase();
2375 if sanitized.is_empty() {
2376 None
2377 } else {
2378 Some(sanitized)
2379 }
2380}
2381
2382fn sanitize_description(raw: &str) -> Option<String> {
2383 let trimmed: &str = raw.trim().trim_matches('"').trim();
2384 if trimmed.is_empty() {
2385 return None;
2386 }
2387 let normalized: &str = trimmed.trim_end_matches('.').trim();
2388 if normalized.is_empty() {
2389 None
2390 } else {
2391 Some(normalized.to_string())
2392 }
2393}
2394
2395fn sanitize_footer_value(raw: &str) -> Option<String> {
2396 let trimmed: &str = raw.trim();
2397 if trimmed.is_empty() {
2398 None
2399 } else {
2400 Some(trimmed.to_string())
2401 }
2402}
2403
2404fn interesting_file_stem(path: &str) -> Option<String> {
2405 let stem: String = Path::new(path)
2406 .file_stem()
2407 .and_then(|value| value.to_str())
2408 .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase())?;
2409 if stem.is_empty()
2410 || is_weak_focus_label(&stem)
2411 || matches!(
2412 stem.as_str(),
2413 "mod" | "lib" | "main" | "readme" | "index" | "commands" | "router"
2414 )
2415 {
2416 None
2417 } else {
2418 Some(stem)
2419 }
2420}
2421
2422fn normalize_name_status(raw: &str) -> String {
2423 let code: char = raw.chars().next().unwrap_or('M');
2424 match code {
2425 'A' => "added",
2426 'D' => "deleted",
2427 'R' => "renamed",
2428 'C' => "copied",
2429 'T' => "typechange",
2430 'U' => "unmerged",
2431 'M' => "modified",
2432 _ => "modified",
2433 }
2434 .to_string()
2435}
2436
2437fn normalize_display_path(raw: &str) -> String {
2438 raw.trim().replace('\\', "/")
2439}
2440
2441fn normalize_path_key(raw: &str) -> String {
2442 let cleaned: String = raw.trim().replace(['{', '}'], "").replace('\\', "/");
2443 if let Some((_, tail)) = cleaned.split_once("=>") {
2444 tail.trim().to_string()
2445 } else {
2446 cleaned
2447 }
2448}
2449
2450fn repo_name(path: &Path) -> String {
2451 path.file_name()
2452 .and_then(|value| value.to_str())
2453 .filter(|value| !value.trim().is_empty())
2454 .unwrap_or("repository")
2455 .to_string()
2456}
2457
2458fn truncate_for_prompt(text: &str, limit: usize) -> String {
2459 if text.chars().count() <= limit {
2460 return text.to_string();
2461 }
2462
2463 let truncated: String = text.chars().take(limit).collect::<String>();
2464 format!(
2465 "{}\n\n[diff truncated after {} characters]",
2466 truncated, limit
2467 )
2468}
2469
2470async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
2471 git_output_with_env(project_root, args, &[]).await
2472}
2473
2474async fn git_output_streaming(project_root: &Path, args: &[&str]) -> Result<String, String> {
2475 git_output_with_env_streaming(project_root, args, &[]).await
2476}
2477
2478async fn git_has_staged_changes(project_root: &Path) -> Result<bool, String> {
2479 let output: std::process::Output = Command::new("git")
2480 .current_dir(project_root)
2481 .args(["diff", "--cached", "--quiet", "--exit-code"])
2482 .output()
2483 .await
2484 .map_err(|e| {
2485 format!(
2486 "Failed to run `git diff --cached --quiet --exit-code`: {}",
2487 e
2488 )
2489 })?;
2490
2491 match output.status.code() {
2492 Some(0) => Ok(false),
2493 Some(1) => Ok(true),
2494 _ => {
2495 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
2496 if stderr.is_empty() {
2497 Err(format!(
2498 "`git diff --cached --quiet --exit-code` failed with status {}",
2499 output.status
2500 ))
2501 } else {
2502 Err(format!(
2503 "`git diff --cached --quiet --exit-code` failed: {}",
2504 stderr
2505 ))
2506 }
2507 }
2508 }
2509}
2510
2511async fn git_worktree_is_clean(project_root: &Path) -> Result<bool, String> {
2512 let status: String = git_output(
2513 project_root,
2514 &["status", "--porcelain=v1", "--untracked-files=all"],
2515 )
2516 .await?;
2517 Ok(parse_status_entries(&status).is_empty())
2518}
2519
2520fn looks_like_nothing_to_commit_error(error: &str) -> bool {
2521 let lowered: String = error.to_ascii_lowercase();
2522 lowered.contains("nothing to commit")
2523 || lowered.contains("working tree clean")
2524 || lowered.contains("no changes added to commit")
2525}
2526
2527async fn git_output_with_env(
2528 project_root: &Path,
2529 args: &[&str],
2530 envs: &[(&str, &std::ffi::OsStr)],
2531) -> Result<String, String> {
2532 let mut command: Command = Command::new("git");
2533 command.current_dir(project_root).args(args);
2534 for (key, value) in envs {
2535 command.env(key, value);
2536 }
2537
2538 let display_args: String = format_git_args_for_error(args);
2539 let output: std::process::Output = command
2540 .output()
2541 .await
2542 .map_err(|e| format!("Failed to run `git {}`: {}", display_args, e))?;
2543
2544 if !output.status.success() {
2545 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
2546 if stderr.is_empty() {
2547 return Err(format!(
2548 "`git {}` failed with status {}",
2549 display_args, output.status
2550 ));
2551 }
2552 return Err(format!("`git {}` failed: {}", display_args, stderr));
2553 }
2554
2555 Ok(String::from_utf8_lossy(&output.stdout)
2560 .trim_end()
2561 .to_string())
2562}
2563
2564async fn git_output_with_env_streaming(
2565 project_root: &Path,
2566 args: &[&str],
2567 envs: &[(&str, &std::ffi::OsStr)],
2568) -> Result<String, String> {
2569 let mut command: Command = Command::new("git");
2570 command
2571 .current_dir(project_root)
2572 .args(args)
2573 .stdout(std::process::Stdio::piped())
2574 .stderr(std::process::Stdio::piped());
2575 for (key, value) in envs {
2576 command.env(key, value);
2577 }
2578
2579 let display_args: String = format_git_args_for_error(args);
2580 let mut child: tokio::process::Child = command
2581 .spawn()
2582 .map_err(|e| format!("Failed to run `git {}`: {}", display_args, e))?;
2583
2584 let stdout: tokio::process::ChildStdout = child
2585 .stdout
2586 .take()
2587 .ok_or_else(|| format!("Failed to capture stdout for `git {}`", display_args))?;
2588 let stderr: tokio::process::ChildStderr = child
2589 .stderr
2590 .take()
2591 .ok_or_else(|| format!("Failed to capture stderr for `git {}`", display_args))?;
2592
2593 let stdout_task: tokio::task::JoinHandle<Result<Vec<u8>, io::Error>> =
2594 tokio::spawn(stream_child_output(stdout, false));
2595 let stderr_task: tokio::task::JoinHandle<Result<Vec<u8>, io::Error>> =
2596 tokio::spawn(stream_child_output(stderr, true));
2597
2598 let status: std::process::ExitStatus = child
2599 .wait()
2600 .await
2601 .map_err(|e| format!("Failed to wait for `git {}`: {}", display_args, e))?;
2602
2603 let stdout: Vec<u8> = stdout_task
2604 .await
2605 .map_err(|e| {
2606 format!(
2607 "Failed to join stdout task for `git {}`: {}",
2608 display_args, e
2609 )
2610 })?
2611 .map_err(|e| format!("Failed to read stdout for `git {}`: {}", display_args, e))?;
2612 let stderr: Vec<u8> = stderr_task
2613 .await
2614 .map_err(|e| {
2615 format!(
2616 "Failed to join stderr task for `git {}`: {}",
2617 display_args, e
2618 )
2619 })?
2620 .map_err(|e| format!("Failed to read stderr for `git {}`: {}", display_args, e))?;
2621
2622 if !status.success() {
2623 let stderr = String::from_utf8_lossy(&stderr).trim().to_string();
2624 if stderr.is_empty() {
2625 return Err(format!(
2626 "`git {}` failed with status {}",
2627 display_args, status
2628 ));
2629 }
2630 return Err(format!("`git {}` failed: {}", display_args, stderr));
2631 }
2632
2633 Ok(String::from_utf8_lossy(&stdout).trim_end().to_string())
2634}
2635
2636async fn stream_child_output<R>(reader: R, stderr: bool) -> io::Result<Vec<u8>>
2638where
2639 R: AsyncRead + Unpin,
2640{
2641 let mut reader: BufReader<R> = BufReader::new(reader);
2642 let mut collected: Vec<u8> = Vec::new();
2643 let mut chunk: Vec<u8> = Vec::new();
2644
2645 loop {
2646 chunk.clear();
2647 let bytes_read: usize = reader.read_until(b'\n', &mut chunk).await?;
2648 if bytes_read == 0 {
2649 break;
2650 }
2651
2652 if stderr {
2653 let mut handle = io::stderr().lock();
2654 handle.write_all(&chunk)?;
2655 handle.flush()?;
2656 } else {
2657 let mut handle = io::stdout().lock();
2658 handle.write_all(&chunk)?;
2659 handle.flush()?;
2660 }
2661
2662 collected.extend_from_slice(&chunk);
2663 }
2664
2665 Ok(collected)
2666}
2667
2668struct TemporaryIndex {
2669 path: PathBuf,
2670}
2671
2672impl Drop for TemporaryIndex {
2673 fn drop(&mut self) {
2674 let _ = fs::remove_file(&self.path);
2675 }
2676}
2677
2678#[cfg(test)]
2679mod tests {
2680 use super::{
2681 build_commit_prompt, build_heuristic_body, build_heuristic_commit,
2682 build_prompt_diff_excerpt, choose_stage_strategy, describe_focus_area,
2683 format_colored_change_counts, format_colored_top_file_group, format_git_args_for_error,
2684 infer_commit_type, infer_description, is_terminal_session_path,
2685 looks_like_nothing_to_commit_error, parse_ai_commit_payload, parse_status_entries,
2686 pathspecs_exceed_safe_argv_budget, render_clean_worktree_message, render_commit_message,
2687 render_commit_message_preview, render_commit_subject,
2688 render_terminal_session_block_message, sanitize_scope, stage_pathspecs,
2689 summarize_focus_areas, summarize_symbols, summarize_top_files, terminal_session_paths,
2690 BranchSyncStatus, ConventionalCommit, FileChangeSummary, RepoContext, StageStrategy,
2691 StatusEntry, TopFileGroup, WorktreeAnalysis, SAFE_GIT_ARGV_CHAR_BUDGET,
2692 SAFE_GIT_PATHSPEC_COUNT,
2693 };
2694 use std::path::PathBuf;
2695
2696 fn server_refactor_analysis() -> WorktreeAnalysis {
2697 WorktreeAnalysis {
2698 repo_root: PathBuf::from("C:/repo"),
2699 repo_name: "xbp".to_string(),
2700 branch: Some("feature/refine-server".to_string()),
2701 status_entries: vec![StatusEntry {
2702 code: "M".to_string(),
2703 path: "src/server.ts".to_string(),
2704 }],
2705 files: vec![
2706 FileChangeSummary {
2707 path: "src/server.ts".to_string(),
2708 status: "modified".to_string(),
2709 additions: 80,
2710 deletions: 24,
2711 },
2712 FileChangeSummary {
2713 path: "src/http-app.ts".to_string(),
2714 status: "modified".to_string(),
2715 additions: 42,
2716 deletions: 11,
2717 },
2718 FileChangeSummary {
2719 path: "src/worker/types.ts".to_string(),
2720 status: "modified".to_string(),
2721 additions: 12,
2722 deletions: 3,
2723 },
2724 ],
2725 total_additions: 134,
2726 total_deletions: 38,
2727 diff_text: String::new(),
2728 new_functions: vec![
2729 "buildEnvFromProcess (src/server.ts)".to_string(),
2730 "handleNodeRequest (src/server.ts)".to_string(),
2731 ],
2732 changed_functions: vec!["handleHttpRequest (src/http-app.ts)".to_string()],
2733 removed_functions: Vec::new(),
2734 new_types: vec!["ExecutionContextLike (src/http-app.ts)".to_string()],
2735 changed_types: vec!["Env (src/worker/types.ts)".to_string()],
2736 removed_types: Vec::new(),
2737 hunk_contexts: vec!["async function handleHttpRequest(request: Request)".to_string()],
2738 }
2739 }
2740
2741 #[test]
2742 fn symbol_summary_tracks_new_and_changed_symbols() {
2743 let diff = r#"
2744diff --git a/crates/cli/src/commands/commit.rs b/crates/cli/src/commands/commit.rs
2745index 1111111..2222222 100644
2746--- a/crates/cli/src/commands/commit.rs
2747+++ b/crates/cli/src/commands/commit.rs
2748@@ -0,0 +1,3 @@
2749+pub struct CommitArgs {
2750+}
2751+pub async fn run_commit() {}
2752@@ -10,1 +12,1 @@ pub async fn old_name() {}
2753-pub async fn old_name() {}
2754+pub async fn old_name() {}
2755"#;
2756 let summary = summarize_symbols(diff);
2757 assert!(summary
2758 .new_functions
2759 .iter()
2760 .any(|item| item.contains("run_commit")));
2761 assert!(summary
2762 .new_types
2763 .iter()
2764 .any(|item| item.contains("CommitArgs")));
2765 assert!(summary
2766 .changed_functions
2767 .iter()
2768 .any(|item| item.contains("old_name")));
2769 }
2770
2771 #[test]
2772 fn ai_payload_respects_forced_scope() {
2773 let raw = r#"{
2774 "type": "feat",
2775 "scope": "wrong",
2776 "description": "add commit command",
2777 "body": ["Summarize the worktree."],
2778 "breaking_change": false,
2779 "footers": []
2780}"#;
2781
2782 let commit = parse_ai_commit_payload(raw, Some("cli")).expect("parse");
2783 assert_eq!(commit.scope.as_deref(), Some("cli"));
2784 assert_eq!(commit.commit_type, "feat");
2785 }
2786
2787 #[test]
2788 fn renders_breaking_change_footer_once() {
2789 let rendered = render_commit_message(&ConventionalCommit {
2790 commit_type: "feat".to_string(),
2791 scope: Some("cli".to_string()),
2792 description: "add commit command".to_string(),
2793 body: vec!["Summarize the worktree.".to_string()],
2794 breaking_change: Some("existing automation must call xbp commit".to_string()),
2795 footers: Vec::new(),
2796 });
2797
2798 assert!(rendered.contains("feat(cli)!: add commit command"));
2799 assert!(rendered.contains("BREAKING CHANGE: existing automation must call xbp commit"));
2800 }
2801
2802 #[test]
2803 fn plain_commit_renderers_stay_uncolored() {
2804 let commit = ConventionalCommit {
2805 commit_type: "fix".to_string(),
2806 scope: Some("cli".to_string()),
2807 description: "tighten summary output".to_string(),
2808 body: vec!["Keep the git payload plain.".to_string()],
2809 breaking_change: None,
2810 footers: vec!["Reviewed-by: ops".to_string()],
2811 };
2812
2813 let subject = render_commit_subject(&commit);
2814 let message = render_commit_message(&commit);
2815
2816 assert_eq!(subject, "fix(cli): tighten summary output");
2817 assert!(!subject.contains('\u{1b}'));
2818 assert!(!message.contains('\u{1b}'));
2819 }
2820
2821 #[test]
2822 fn preview_subject_colors_type_and_scope_independently() {
2823 let commit = ConventionalCommit {
2824 commit_type: "feat".to_string(),
2825 scope: Some("docs".to_string()),
2826 description: "refresh operator docs".to_string(),
2827 body: Vec::new(),
2828 breaking_change: None,
2829 footers: Vec::new(),
2830 };
2831
2832 colored::control::set_override(true);
2833 let preview = render_commit_message_preview(&commit);
2834 colored::control::set_override(false);
2835
2836 assert!(preview.contains('\u{1b}'));
2837 assert!(preview.contains("feat"));
2838 assert!(preview.contains("docs"));
2839 assert!(preview.contains("refresh operator docs"));
2840 }
2841
2842 #[test]
2843 fn diff_summary_colors_additions_and_deletions_independently() {
2844 colored::control::set_override(true);
2845 let summary = format_colored_change_counts(29_948, 2_740);
2846 colored::control::set_override(false);
2847 assert!(summary.contains('\u{1b}'));
2848 assert!(summary.contains("29948"));
2849 assert!(summary.contains("2740"));
2850 }
2851
2852 #[test]
2853 fn top_file_groups_color_nested_change_counts() {
2854 colored::control::set_override(true);
2855 let group = TopFileGroup {
2856 label: "apps/docs/generated/".to_string(),
2857 file_count: 3,
2858 additions: 22_125,
2859 deletions: 0,
2860 };
2861
2862 let summary = format_colored_top_file_group(&group);
2863 colored::control::set_override(false);
2864 assert!(summary.contains("apps/docs/generated/"));
2865 assert!(summary.contains("files"));
2866 assert!(summary.contains('\u{1b}'));
2867 assert!(summary.contains("22125"));
2868 }
2869
2870 #[test]
2871 fn infers_feat_for_new_symbols() {
2872 let analysis = WorktreeAnalysis {
2873 repo_root: PathBuf::from("C:/repo"),
2874 repo_name: "xbp".to_string(),
2875 branch: Some("main".to_string()),
2876 status_entries: vec![StatusEntry {
2877 code: "??".to_string(),
2878 path: "crates/cli/src/commands/commit.rs".to_string(),
2879 }],
2880 files: vec![FileChangeSummary {
2881 path: "crates/cli/src/commands/commit.rs".to_string(),
2882 status: "added".to_string(),
2883 additions: 120,
2884 deletions: 0,
2885 }],
2886 total_additions: 120,
2887 total_deletions: 0,
2888 diff_text: String::new(),
2889 new_functions: vec!["run_commit (crates/cli/src/commands/commit.rs)".to_string()],
2890 changed_functions: Vec::new(),
2891 removed_functions: Vec::new(),
2892 new_types: vec!["CommitArgs (crates/cli/src/commands/commit.rs)".to_string()],
2893 changed_types: Vec::new(),
2894 removed_types: Vec::new(),
2895 hunk_contexts: Vec::new(),
2896 };
2897
2898 assert_eq!(infer_commit_type(&analysis), "feat");
2899 }
2900
2901 #[test]
2902 fn infers_refactor_for_new_symbols_inside_existing_modules() {
2903 let analysis = server_refactor_analysis();
2904 assert_eq!(infer_commit_type(&analysis), "refactor");
2905 }
2906
2907 #[test]
2908 fn description_prefers_focus_area_and_behavior_hint() {
2909 let analysis = server_refactor_analysis();
2910 assert_eq!(
2911 infer_description(&analysis, "refactor", None),
2912 "refine server request handling"
2913 );
2914 }
2915
2916 #[test]
2917 fn heuristic_body_reads_like_a_summary() {
2918 let analysis = server_refactor_analysis();
2919 let body = build_heuristic_body(&analysis);
2920 assert_eq!(body.len(), 2);
2921 assert!(body[0].contains("server"));
2922 assert!(body[1].contains("adds buildEnvFromProcess"));
2923 assert!(body[1].contains("src/ (2 files"));
2924 }
2925
2926 #[test]
2927 fn prompt_omits_lockfile_diff_body_but_keeps_structured_summary() {
2928 let diff = r#"
2929diff --git a/package.json b/package.json
2930index b25b17db..d30f02ee 100644
2931--- a/package.json
2932+++ b/package.json
2933@@ -86 +86,2 @@
2934- "yaml": "^2.9.0"
2935+ "yaml": "^2.9.0",
2936+ "nx": "23.0.1"
2937diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
2938index 4916450e..758d7f5e 100644
2939--- a/pnpm-lock.yaml
2940+++ b/pnpm-lock.yaml
2941@@ -998,0 +1005,6 @@ packages:
2942+ '@emnapi/runtime@1.4.5':
2943+ resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==}
2944+ open@8.4.2:
2945+ resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
2946"#;
2947 let symbols = summarize_symbols(diff);
2948 let analysis = WorktreeAnalysis {
2949 repo_root: PathBuf::from("C:/repo"),
2950 repo_name: "speedrun-formations".to_string(),
2951 branch: Some("main".to_string()),
2952 status_entries: vec![
2953 StatusEntry {
2954 code: "M".to_string(),
2955 path: "package.json".to_string(),
2956 },
2957 StatusEntry {
2958 code: "M".to_string(),
2959 path: "pnpm-lock.yaml".to_string(),
2960 },
2961 ],
2962 files: vec![
2963 FileChangeSummary {
2964 path: "package.json".to_string(),
2965 status: "modified".to_string(),
2966 additions: 4,
2967 deletions: 2,
2968 },
2969 FileChangeSummary {
2970 path: "pnpm-lock.yaml".to_string(),
2971 status: "modified".to_string(),
2972 additions: 539,
2973 deletions: 17,
2974 },
2975 ],
2976 total_additions: 543,
2977 total_deletions: 19,
2978 diff_text: diff.to_string(),
2979 new_functions: symbols.new_functions,
2980 changed_functions: symbols.changed_functions,
2981 removed_functions: symbols.removed_functions,
2982 new_types: symbols.new_types,
2983 changed_types: symbols.changed_types,
2984 removed_types: symbols.removed_types,
2985 hunk_contexts: symbols.hunk_contexts,
2986 };
2987
2988 let heuristic = build_heuristic_commit(&analysis, None);
2989 let prompt = build_commit_prompt(&analysis, None, &heuristic);
2990
2991 assert!(prompt.contains("pnpm-lock.yaml (+539 -17; lockfile)"));
2992 assert!(prompt.contains("\"nx\": \"23.0.1\""));
2993 assert!(prompt.contains("package entries added: @emnapi/runtime@1.4.5"));
2994 assert!(!prompt.contains("sha512-++LApOtY0pEEz1zrd9vy1"));
2995 assert!(!prompt.contains("packages:"));
2996 }
2997
2998 #[test]
2999 fn diff_excerpt_reports_when_all_records_are_low_signal() {
3000 let diff = r#"
3001diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
3002index 4916450e..758d7f5e 100644
3003--- a/pnpm-lock.yaml
3004+++ b/pnpm-lock.yaml
3005@@ -998,0 +1005,2 @@ packages:
3006+ open@8.4.2:
3007+ resolution: {integrity: sha512-7x81NCL719oNbsq}
3008"#;
3009 let analysis = WorktreeAnalysis {
3010 repo_root: PathBuf::from("C:/repo"),
3011 repo_name: "speedrun-formations".to_string(),
3012 branch: Some("main".to_string()),
3013 status_entries: Vec::new(),
3014 files: vec![FileChangeSummary {
3015 path: "pnpm-lock.yaml".to_string(),
3016 status: "modified".to_string(),
3017 additions: 539,
3018 deletions: 17,
3019 }],
3020 total_additions: 539,
3021 total_deletions: 17,
3022 diff_text: diff.to_string(),
3023 new_functions: Vec::new(),
3024 changed_functions: Vec::new(),
3025 removed_functions: Vec::new(),
3026 new_types: Vec::new(),
3027 changed_types: Vec::new(),
3028 removed_types: Vec::new(),
3029 hunk_contexts: Vec::new(),
3030 };
3031
3032 let excerpt = build_prompt_diff_excerpt(&analysis);
3033 assert!(excerpt.excerpt.trim().is_empty());
3034 assert_eq!(
3035 excerpt.omitted_files,
3036 vec!["pnpm-lock.yaml (+539 -17; lockfile)".to_string()]
3037 );
3038 assert_eq!(
3039 excerpt.summaries,
3040 vec![
3041 "pnpm-lock.yaml (+539 -17; lockfile)".to_string(),
3042 "package entries added: open@8.4.2".to_string()
3043 ]
3044 );
3045 }
3046
3047 #[test]
3048 fn lockfile_preprocessing_summarizes_repeated_package_churn() {
3049 let diff = r#"
3050diff --git a/services/athena-auth/Cargo.lock b/services/athena-auth/Cargo.lock
3051index 1111111..2222222 100644
3052--- a/services/athena-auth/Cargo.lock
3053+++ b/services/athena-auth/Cargo.lock
3054@@ -10,5 +10,0 @@ name = "aead"
3055-name = "aead"
3056-version = "0.5.2"
3057@@ -20,0 +20,5 @@ name = "aes"
3058+name = "aes"
3059+version = "0.8.4"
3060diff --git a/apps/docs/pnpm-lock.yaml b/apps/docs/pnpm-lock.yaml
3061index f96b24fc..c3b8250d 100644
3062--- a/apps/docs/pnpm-lock.yaml
3063+++ b/apps/docs/pnpm-lock.yaml
3064@@ -21,2 +21,2 @@ importers:
3065- specifier: 16.6.5
3066- version: 16.6.5(@types/react@19.2.10)(next@16.1.5(react@19.2.4))
3067+ specifier: 16.7.13
3068+ version: 16.7.13(@types/react@19.2.10)(next@16.1.5(react@19.2.4))
3069@@ -3113,2 +3140,2 @@ packages:
3070- fumadocs-core@16.6.5:
3071- resolution: {integrity: sha512-old}
3072+ fumadocs-core@16.7.13:
3073+ resolution: {integrity: sha512-new}
3074"#;
3075 let analysis = WorktreeAnalysis {
3076 repo_root: PathBuf::from("C:/repo"),
3077 repo_name: "athena".to_string(),
3078 branch: Some("main".to_string()),
3079 status_entries: Vec::new(),
3080 files: vec![
3081 FileChangeSummary {
3082 path: "services/athena-auth/Cargo.lock".to_string(),
3083 status: "modified".to_string(),
3084 additions: 3403,
3085 deletions: 1366,
3086 },
3087 FileChangeSummary {
3088 path: "apps/docs/pnpm-lock.yaml".to_string(),
3089 status: "modified".to_string(),
3090 additions: 131,
3091 deletions: 53,
3092 },
3093 ],
3094 total_additions: 3534,
3095 total_deletions: 1419,
3096 diff_text: diff.to_string(),
3097 new_functions: Vec::new(),
3098 changed_functions: Vec::new(),
3099 removed_functions: Vec::new(),
3100 new_types: Vec::new(),
3101 changed_types: Vec::new(),
3102 removed_types: Vec::new(),
3103 hunk_contexts: summarize_symbols(diff).hunk_contexts,
3104 };
3105
3106 let prompt = build_commit_prompt(&analysis, None, &build_heuristic_commit(&analysis, None));
3107
3108 assert!(prompt.contains("services/athena-auth/Cargo.lock (+3403 -1366; lockfile)"));
3109 assert!(prompt.contains("apps/docs/pnpm-lock.yaml (+131 -53; lockfile)"));
3110 assert!(prompt.contains("cargo packages added: aes@0.8.4"));
3111 assert!(prompt.contains("cargo packages removed: aead@0.5.2"));
3112 assert!(prompt.contains("version shifts: 16.6.5 -> 16.7.13"));
3113 assert!(prompt.contains("package entries added: fumadocs-core@16.7.13"));
3114 assert!(!prompt.contains("sha512-old"));
3115 assert!(!prompt.contains("name = \"aead\""));
3116 assert!(!prompt.contains("hunk contexts:"));
3117 }
3118
3119 #[test]
3120 fn retained_diff_preprocessing_compacts_repeated_long_lines() {
3121 let repeated = format!(
3122 "+ version: 10.3.11({})(tailwindcss@4.1.18)",
3123 "@types/react@19.2.10".repeat(12)
3124 );
3125 let diff = format!(
3126 "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",
3127 repeated
3128 );
3129 let analysis = WorktreeAnalysis {
3130 repo_root: PathBuf::from("C:/repo"),
3131 repo_name: "speedrun-formations".to_string(),
3132 branch: Some("main".to_string()),
3133 status_entries: Vec::new(),
3134 files: vec![FileChangeSummary {
3135 path: "package.json".to_string(),
3136 status: "modified".to_string(),
3137 additions: 3,
3138 deletions: 0,
3139 }],
3140 total_additions: 3,
3141 total_deletions: 0,
3142 diff_text: diff,
3143 new_functions: Vec::new(),
3144 changed_functions: Vec::new(),
3145 removed_functions: Vec::new(),
3146 new_types: Vec::new(),
3147 changed_types: Vec::new(),
3148 removed_types: Vec::new(),
3149 hunk_contexts: Vec::new(),
3150 };
3151
3152 let excerpt = build_prompt_diff_excerpt(&analysis).excerpt;
3153 assert!(excerpt.contains("(...peer deps omitted)"));
3154 assert!(excerpt.contains("duplicate long diff line(s) omitted"));
3155 assert!(excerpt.contains("\"nx\": \"23.0.1\""));
3156 }
3157
3158 #[test]
3159 fn clean_worktree_message_calls_out_pending_pushes() {
3160 let repo = RepoContext {
3161 repo_root: PathBuf::from("C:/repo"),
3162 repo_name: "xbp".to_string(),
3163 branch: Some("main".to_string()),
3164 };
3165 let message = render_clean_worktree_message(
3166 &repo,
3167 &BranchSyncStatus {
3168 upstream: Some("origin/main".to_string()),
3169 ahead: 2,
3170 behind: 0,
3171 },
3172 );
3173
3174 assert!(message.contains("Nothing to commit."));
3175 assert!(message.contains("2 commit(s) waiting to push"));
3176 assert!(message.contains("origin/main"));
3177 }
3178
3179 #[test]
3180 fn clean_worktree_message_calls_out_synced_branch() {
3181 let repo = RepoContext {
3182 repo_root: PathBuf::from("C:/repo"),
3183 repo_name: "xbp".to_string(),
3184 branch: Some("main".to_string()),
3185 };
3186 let message = render_clean_worktree_message(
3187 &repo,
3188 &BranchSyncStatus {
3189 upstream: Some("origin/main".to_string()),
3190 ahead: 0,
3191 behind: 0,
3192 },
3193 );
3194
3195 assert!(message.contains("Nothing to commit."));
3196 assert!(message.contains("already in sync"));
3197 }
3198
3199 #[test]
3200 fn detects_nothing_to_commit_git_errors() {
3201 assert!(looks_like_nothing_to_commit_error(
3202 "`git commit --file msg.txt` failed: nothing to commit, working tree clean"
3203 ));
3204 assert!(looks_like_nothing_to_commit_error(
3205 "`git commit --file msg.txt` failed: no changes added to commit"
3206 ));
3207 assert!(!looks_like_nothing_to_commit_error(
3208 "`git commit --file msg.txt` failed: pre-commit hook exited with code 1"
3209 ));
3210 }
3211
3212 #[test]
3213 fn stage_pathspecs_use_changed_paths_and_fall_back_for_renames() {
3214 let paths = stage_pathspecs(&[
3215 StatusEntry {
3216 code: "M".to_string(),
3217 path: "src/lib.rs".to_string(),
3218 },
3219 StatusEntry {
3220 code: "??".to_string(),
3221 path: "docs/new.md".to_string(),
3222 },
3223 StatusEntry {
3224 code: "M".to_string(),
3225 path: "src/lib.rs".to_string(),
3226 },
3227 ]);
3228 assert_eq!(
3229 paths,
3230 vec!["docs/new.md".to_string(), "src/lib.rs".to_string()]
3231 );
3232
3233 let rename_paths = stage_pathspecs(&[StatusEntry {
3234 code: "R".to_string(),
3235 path: "old.rs -> new.rs".to_string(),
3236 }]);
3237 assert!(rename_paths.is_empty());
3238 }
3239
3240 #[test]
3241 fn large_worktree_pathspecs_use_bulk_add_to_avoid_windows_argv_truncation() {
3242 let long_paths: Vec<String> = (0..437)
3245 .map(|i| {
3246 format!(
3247 "foundry/instagram-downloads/100500818017217/messages/audio_attachments/{:04}.mp3",
3248 i
3249 )
3250 })
3251 .collect();
3252
3253 assert!(pathspecs_exceed_safe_argv_budget(&long_paths));
3254 assert!(long_paths.len() > SAFE_GIT_PATHSPEC_COUNT);
3255
3256 let strategy = choose_stage_strategy(&long_paths, &[]);
3257 assert_eq!(
3258 strategy,
3259 StageStrategy::BulkAddAll {
3260 path_count: long_paths.len()
3261 }
3262 );
3263
3264 let small = vec!["src/lib.rs".to_string(), "README.md".to_string()];
3265 assert!(!pathspecs_exceed_safe_argv_budget(&small));
3266 assert_eq!(
3267 choose_stage_strategy(&small, &[]),
3268 StageStrategy::BulkAddAll { path_count: 2 }
3269 );
3270 assert_eq!(
3271 choose_stage_strategy(&small, &["terminals/5.txt".to_string()]),
3272 StageStrategy::BulkAddAll { path_count: 2 }
3273 );
3274 assert_eq!(
3275 choose_stage_strategy(&[], &[]),
3276 StageStrategy::BulkAddAll { path_count: 0 }
3277 );
3278
3279 let almost_budget = vec!["a".repeat(SAFE_GIT_ARGV_CHAR_BUDGET)];
3280 assert!(pathspecs_exceed_safe_argv_budget(&almost_budget));
3281 }
3282
3283 #[test]
3284 fn porcelain_status_preserves_dot_prefixed_paths_and_leading_status_space() {
3285 let raw =
3289 " M .xbp/releases/service-dexter-sentinel/1.4.0.yaml\n?? src/bin/dexter-import.rs\n";
3290 let entries = parse_status_entries(raw);
3291 assert_eq!(entries.len(), 2);
3292 assert_eq!(entries[0].code, "M");
3293 assert_eq!(
3294 entries[0].path,
3295 ".xbp/releases/service-dexter-sentinel/1.4.0.yaml"
3296 );
3297 assert_eq!(entries[1].code, "??");
3298 assert_eq!(entries[1].path, "src/bin/dexter-import.rs");
3299
3300 let corrupted = raw.trim();
3302 let broken = parse_status_entries(corrupted);
3303 assert_eq!(
3304 broken[0].path, "xbp/releases/service-dexter-sentinel/1.4.0.yaml",
3305 "documenting the failure mode that full trim() produces"
3306 );
3307 }
3308
3309 #[test]
3310 fn git_error_args_are_truncated_for_readable_failures() {
3311 let many: Vec<String> = (0..80).map(|i| format!("path/to/file-{i}.bin")).collect();
3312 let refs: Vec<&str> = many.iter().map(String::as_str).collect();
3313 let mut args = vec!["add", "--all", "--"];
3314 args.extend(refs.iter().copied());
3315
3316 let display = format_git_args_for_error(&args);
3317 assert!(display.contains("truncated for display"));
3318 assert!(display.len() < 400);
3319 assert!(!display.contains("path/to/file-79.bin") || display.contains("…"));
3320 }
3321
3322 #[tokio::test]
3323 async fn stage_commit_paths_bulk_add_respects_gitignore_and_excludes() {
3324 let root = std::env::temp_dir().join(format!(
3327 "xbp-commit-stage-test-{}-{}",
3328 std::process::id(),
3329 uuid::Uuid::new_v4()
3330 ));
3331 let _ = std::fs::remove_dir_all(&root);
3332 std::fs::create_dir_all(&root).unwrap_or_else(|e| {
3333 panic!("create temp root {}: {e}", root.display());
3334 });
3335 assert!(
3336 root.is_dir(),
3337 "temp root missing after create_dir_all: {}",
3338 root.display()
3339 );
3340
3341 let git_home = root.join("_git_home");
3343 std::fs::create_dir_all(&git_home).expect("git home");
3344 let git = |args: &[&str]| {
3345 std::process::Command::new("git")
3346 .current_dir(&root)
3347 .env("HOME", &git_home)
3348 .env("XDG_CONFIG_HOME", git_home.join(".config"))
3349 .env("GIT_CONFIG_GLOBAL", git_home.join(".gitconfig"))
3350 .env("GIT_CONFIG_NOSYSTEM", "1")
3351 .args(args)
3352 .output()
3353 .unwrap_or_else(|e| {
3354 panic!(
3355 "failed to spawn `git {:?}` in {}: {e}",
3356 args,
3357 root.display()
3358 )
3359 })
3360 };
3361
3362 let init = git(&["init"]);
3363 assert!(
3364 init.status.success(),
3365 "git init failed: status={:?} stderr={}",
3366 init.status,
3367 String::from_utf8_lossy(&init.stderr)
3368 );
3369 assert!(git(&["config", "user.email", "test@example.com"])
3370 .status
3371 .success());
3372 assert!(git(&["config", "user.name", "Test"]).status.success());
3373 let _ = git(&["checkout", "-b", "main"]);
3375
3376 std::fs::write(root.join(".gitignore"), "ignored-dir/\n*.tmp\n").unwrap();
3377 std::fs::write(root.join("keep.rs"), "fn main() {}\n").unwrap();
3378 std::fs::create_dir_all(root.join("ignored-dir")).unwrap();
3379 std::fs::write(root.join("ignored-dir/secret.bin"), "nope").unwrap();
3380 std::fs::write(root.join("noise.tmp"), "tmp").unwrap();
3381 std::fs::create_dir_all(root.join("terminals")).unwrap();
3382 std::fs::write(root.join("terminals/session.txt"), "session").unwrap();
3383
3384 assert!(git(&["add", ".gitignore", "keep.rs"]).status.success());
3386 assert!(git(&["commit", "-m", "init"]).status.success());
3387
3388 std::fs::write(root.join("keep.rs"), "fn main() { /* changed */ }\n").unwrap();
3389 std::fs::write(root.join("feature.rs"), "pub fn f() {}\n").unwrap();
3390 std::fs::write(root.join("ignored-dir/secret.bin"), "still ignored").unwrap();
3391 std::fs::write(root.join("noise.tmp"), "still tmp").unwrap();
3392 std::fs::write(root.join("terminals/session.txt"), "session v2").unwrap();
3393
3394 let status_entries = vec![
3395 StatusEntry {
3396 code: " M".to_string(),
3397 path: "keep.rs".to_string(),
3398 },
3399 StatusEntry {
3400 code: "??".to_string(),
3401 path: "feature.rs".to_string(),
3402 },
3403 StatusEntry {
3404 code: "??".to_string(),
3405 path: "terminals/session.txt".to_string(),
3406 },
3407 ];
3408
3409 let mut bloated_status = status_entries.clone();
3411 for i in 0..100 {
3412 bloated_status.push(StatusEntry {
3413 code: "??".to_string(),
3414 path: format!(
3415 "foundry/instagram-downloads/user/messages/audio_attachments/{:04}.mp3",
3416 i
3417 ),
3418 });
3419 }
3420
3421 super::stage_commit_paths(
3422 &root,
3423 &bloated_status,
3424 &["terminals/session.txt".to_string()],
3425 )
3426 .await
3427 .expect("stage should succeed without pathspec truncation");
3428
3429 let cached = git(&["diff", "--cached", "--name-only"]);
3430 assert!(cached.status.success());
3431 let staged = String::from_utf8_lossy(&cached.stdout).replace('\\', "/");
3432 assert!(
3433 staged.contains("keep.rs"),
3434 "expected keep.rs staged, got:\n{}",
3435 staged
3436 );
3437 assert!(
3438 staged.contains("feature.rs"),
3439 "expected feature.rs staged, got:\n{}",
3440 staged
3441 );
3442 assert!(
3443 !staged.contains("ignored-dir"),
3444 "gitignore paths must not be staged:\n{}",
3445 staged
3446 );
3447 assert!(
3448 !staged.contains("noise.tmp"),
3449 "*.tmp gitignore must not be staged:\n{}",
3450 staged
3451 );
3452 assert!(
3453 !staged.contains("terminals/session.txt"),
3454 "excluded terminal path must stay unstaged:\n{}",
3455 staged
3456 );
3457
3458 let filtered = super::filter_gitignored_status_entries(
3460 &root,
3461 vec![
3462 StatusEntry {
3463 code: "??".to_string(),
3464 path: "feature.rs".to_string(),
3465 },
3466 StatusEntry {
3467 code: "??".to_string(),
3468 path: "noise.tmp".to_string(),
3469 },
3470 StatusEntry {
3471 code: "??".to_string(),
3472 path: "ignored-dir/secret.bin".to_string(),
3473 },
3474 StatusEntry {
3475 code: " M".to_string(),
3476 path: "keep.rs".to_string(),
3477 },
3478 ],
3479 )
3480 .await
3481 .expect("filter");
3482
3483 let filtered_paths: Vec<&str> = filtered.iter().map(|e| e.path.as_str()).collect();
3484 assert_eq!(filtered_paths, vec!["feature.rs", "keep.rs"]);
3485
3486 let _ = std::fs::remove_dir_all(&root);
3487 }
3488
3489 #[test]
3490 fn scope_sanitizer_keeps_valid_tokens() {
3491 assert_eq!(
3492 sanitize_scope(Some("CLI/tools")),
3493 Some("cli/tools".to_string())
3494 );
3495 assert_eq!(sanitize_scope(Some(" ")), None);
3496 }
3497
3498 #[test]
3499 fn terminal_session_paths_are_detected_and_blocked() {
3500 let analysis = WorktreeAnalysis {
3501 repo_root: PathBuf::from("C:/repo"),
3502 repo_name: "xbp".to_string(),
3503 branch: Some("main".to_string()),
3504 status_entries: Vec::new(),
3505 files: vec![
3506 FileChangeSummary {
3507 path: "terminals/5.txt".to_string(),
3508 status: "added".to_string(),
3509 additions: 388_613,
3510 deletions: 0,
3511 },
3512 FileChangeSummary {
3513 path: "terminals/.next-id".to_string(),
3514 status: "modified".to_string(),
3515 additions: 1,
3516 deletions: 1,
3517 },
3518 ],
3519 total_additions: 388_614,
3520 total_deletions: 1,
3521 diff_text: String::new(),
3522 new_functions: Vec::new(),
3523 changed_functions: Vec::new(),
3524 removed_functions: Vec::new(),
3525 new_types: Vec::new(),
3526 changed_types: Vec::new(),
3527 removed_types: Vec::new(),
3528 hunk_contexts: Vec::new(),
3529 };
3530
3531 assert!(is_terminal_session_path("terminals/5.txt"));
3532 assert!(!is_terminal_session_path(
3533 "crates/cli/src/commands/commit.rs"
3534 ));
3535 assert_eq!(
3536 terminal_session_paths(&analysis),
3537 vec![
3538 "terminals/5.txt".to_string(),
3539 "terminals/.next-id".to_string()
3540 ]
3541 );
3542 assert!(
3543 render_terminal_session_block_message(&terminal_session_paths(&analysis))
3544 .contains("Refusing to commit IDE terminal session dumps")
3545 );
3546 assert_eq!(infer_commit_type(&analysis), "chore");
3547 assert_eq!(
3548 infer_description(&analysis, "chore", None),
3549 "ignore ide terminal session logs"
3550 );
3551
3552 let heuristic = build_heuristic_commit(&analysis, None);
3553 assert_eq!(heuristic.commit_type, "chore");
3554 assert_eq!(heuristic.description, "ignore ide terminal session logs");
3555 assert!(!render_commit_message(&heuristic).contains("flow"));
3556 }
3557
3558 #[test]
3559 fn focus_area_groups_terminal_session_files() {
3560 assert_eq!(
3561 describe_focus_area("terminals/5.txt"),
3562 Some("terminals".to_string())
3563 );
3564 assert_eq!(
3565 describe_focus_area("terminals/.next-id"),
3566 Some("terminals".to_string())
3567 );
3568 assert_eq!(
3569 describe_focus_area(".gitignore"),
3570 Some("gitignore".to_string())
3571 );
3572 }
3573
3574 #[test]
3575 fn summarize_focus_areas_collapses_terminal_logs() {
3576 let analysis = WorktreeAnalysis {
3577 repo_root: PathBuf::from("C:/repo"),
3578 repo_name: "xbp".to_string(),
3579 branch: Some("main".to_string()),
3580 status_entries: Vec::new(),
3581 files: vec![
3582 FileChangeSummary {
3583 path: "terminals/5.txt".to_string(),
3584 status: "deleted".to_string(),
3585 additions: 0,
3586 deletions: 388_613,
3587 },
3588 FileChangeSummary {
3589 path: "terminals/3.txt".to_string(),
3590 status: "deleted".to_string(),
3591 additions: 0,
3592 deletions: 439,
3593 },
3594 FileChangeSummary {
3595 path: "terminals/.next-id".to_string(),
3596 status: "modified".to_string(),
3597 additions: 0,
3598 deletions: 1,
3599 },
3600 FileChangeSummary {
3601 path: ".gitignore".to_string(),
3602 status: "modified".to_string(),
3603 additions: 1,
3604 deletions: 0,
3605 },
3606 ],
3607 total_additions: 1,
3608 total_deletions: 389_053,
3609 diff_text: String::new(),
3610 new_functions: Vec::new(),
3611 changed_functions: Vec::new(),
3612 removed_functions: Vec::new(),
3613 new_types: Vec::new(),
3614 changed_types: Vec::new(),
3615 removed_types: Vec::new(),
3616 hunk_contexts: Vec::new(),
3617 };
3618
3619 assert_eq!(
3620 summarize_focus_areas(&analysis, 4),
3621 vec!["terminals".to_string(), "gitignore".to_string()]
3622 );
3623 assert_eq!(
3624 summarize_top_files(&analysis, 4),
3625 vec![
3626 "terminals/ (3 files, +0 -389053)".to_string(),
3627 ".gitignore (+1 -0)".to_string(),
3628 ]
3629 );
3630 }
3631}