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