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