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