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