Skip to main content

xbp_cli/commands/
commit.rs

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/// Leave headroom under Windows CreateProcess limit (~8191 chars) and common shells.
67/// Kept for regression tests after staging switched to bulk `git add --all` only.
68#[cfg(test)]
69const SAFE_GIT_ARGV_CHAR_BUDGET: usize = 6_000;
70/// Prefer bulk `git add --all` once pathspec lists grow past this size.
71#[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    // Always bulk-stage. Explicit pathspecs are fragile on Windows (argv limits) and
1651    // unnecessary: commit stages the whole worktree minus explicit excludes.
1652    // `git add --all` also respects .gitignore.
1653    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    /// Stage via `git add --all` (ignore-aware, no argv length risk).
1677    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    // Historical bulk-add threshold: long explicit pathspec argv lists are unsafe.
1692    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        // Never feed gitignored-looking path markers into explicit pathspecs.
1705        // Porcelain without --ignored does not list untracked ignored files; this is a
1706        // defensive guard if status output is ever assembled from other sources.
1707        if path.ends_with('/') && path.contains(".git/") {
1708            continue;
1709        }
1710        paths.insert(path.to_string());
1711    }
1712    paths.into_iter().collect()
1713}
1714
1715/// Drop untracked paths that match `.gitignore` / exclude rules so they are neither
1716/// analyzed for the commit message nor staged. Tracked files that later match ignore
1717/// rules still appear in status (git continues to track them until untracked).
1718async 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        // Close stdin so check-ignore can finish.
1787        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    // Exit 0: some paths ignored; 1: none ignored. Both are success outcomes.
1796    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            // Porcelain v1: fixed two-column XY status, then a space, then the path.
1837            // Do not trim the line first — leading space is a valid X status (e.g. ` M`).
1838            if line.len() < 4 {
1839                return None;
1840            }
1841            let code = line[..2].trim().to_string();
1842            let path = line[3..].trim().replace('\\', "/");
1843            // Unquote git's C-style quoted paths when present.
1844            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        // Minimal unescape for common git quoted paths; keep contents otherwise.
1858        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(&current_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(&current_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    // Use trim_end only. Full trim() eats the leading space on porcelain
2399    // status lines like ` M path` (worktree-only changes), which shifts the
2400    // XY/path columns and corrupts paths (e.g. `.xbp/foo` → `xbp/foo`,
2401    // `src/bin/x.rs` → `rc/bin/x.rs`).
2402    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        // Staging always uses bulk `git add --all` so large worktrees never build an
2911        // argv that Windows CreateProcess truncates mid-pathspec.
2912        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        // Regression: `git_output` used to `.trim()` the whole status blob, turning
2954        // ` M .xbp/foo` into `M .xbp/foo` and then parse_status_entries into path
2955        // `xbp/foo` (leading `.` eaten as the XY separator column).
2956        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        // Simulate the historical full-string trim bug.
2969        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        // Avoid platform-specific default branch surprises in CI.
3011        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        // Seed an initial commit so reset HEAD works for excludes.
3022        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        // Build a path list large enough that the strategy chooses bulk add.
3047        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        // check-ignore filter drops untracked ignored paths when they appear in status.
3096        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}