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