use crate::cli::auto_commit::{print_push_summary, push_current_branch_with_name};
use crate::commands::service::load_xbp_config_with_root;
use crate::config::{
resolve_openrouter_api_key, resolve_openrouter_commit_model,
resolve_openrouter_commit_system_prompt,
};
use crate::openrouter::{complete_prompt_with_options, CompletionOptions};
use crate::utils::command_exists;
use colored::Colorize;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::process::Command;
use uuid::Uuid;
#[path = "commit_prompt_preprocess.rs"]
mod commit_prompt_preprocess;
use self::commit_prompt_preprocess::{build_prompt_diff_excerpt, prompt_diff_omit_reason};
static RUST_FUNCTION_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)")
.expect("valid rust function regex")
});
static JS_FUNCTION_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)",
)
.expect("valid js function regex")
});
static JS_ARROW_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
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*=>",
)
.expect("valid js arrow regex")
});
static METHOD_CONTEXT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(").expect("valid method context regex")
});
static RUST_TYPE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(struct|enum|trait|type)\s+([A-Za-z_][A-Za-z0-9_]*)")
.expect("valid rust type regex")
});
static TS_TYPE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^\s*(?:export\s+)?(interface|type|enum|class)\s+([A-Za-z_$][A-Za-z0-9_$]*)")
.expect("valid ts type regex")
});
const MAX_PROMPT_FILES: usize = 24;
const MAX_PROMPT_SYMBOLS: usize = 12;
const MAX_PROMPT_CONTEXTS: usize = 12;
const MAX_PROMPT_AREAS: usize = 4;
const MAX_PROMPT_OMITTED_DIFFS: usize = 12;
const MAX_PROMPT_DIFF_SUMMARIES: usize = 16;
const MAX_PRINT_SYMBOLS: usize = 8;
const MAX_PRINT_AREAS: usize = 4;
const MAX_PRINT_FILES: usize = 4;
const CONVENTIONAL_TYPES: &[&str] = &[
"feat", "fix", "docs", "refactor", "chore", "test", "build", "ci", "perf", "style", "revert",
];
const TERMINAL_SESSION_DIR: &str = "terminals/";
#[cfg(test)]
const SAFE_GIT_ARGV_CHAR_BUDGET: usize = 6_000;
#[cfg(test)]
const SAFE_GIT_PATHSPEC_COUNT: usize = 64;
const MAX_GIT_ERROR_ARG_CHARS: usize = 240;
const COMMIT_AI_MAX_TOKENS: u32 = 320;
#[derive(Debug, Clone)]
pub struct CommitArgs {
pub dry_run: bool,
pub push: bool,
pub no_ai: bool,
pub model: Option<String>,
pub scope: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommitRunOutcome {
Completed,
NothingToCommit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StageAndCommitOutcome {
Committed,
NothingToCommit,
}
#[derive(Debug, Clone)]
pub enum CommitError {
Operation(String),
PushFailed {
summary: String,
commit_sha: Option<String>,
},
TerminalSessionsBlocked(String),
}
#[derive(Debug, Clone)]
struct RepoContext {
repo_root: PathBuf,
repo_name: String,
branch: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct BranchSyncStatus {
upstream: Option<String>,
ahead: usize,
behind: usize,
}
#[derive(Debug, Clone)]
struct WorktreeAnalysis {
repo_root: PathBuf,
repo_name: String,
branch: Option<String>,
status_entries: Vec<StatusEntry>,
files: Vec<FileChangeSummary>,
total_additions: u32,
total_deletions: u32,
diff_text: String,
new_functions: Vec<String>,
changed_functions: Vec<String>,
removed_functions: Vec<String>,
new_types: Vec<String>,
changed_types: Vec<String>,
removed_types: Vec<String>,
hunk_contexts: Vec<String>,
}
impl WorktreeAnalysis {
fn repo_context(&self) -> RepoContext {
RepoContext {
repo_root: self.repo_root.clone(),
repo_name: self.repo_name.clone(),
branch: self.branch.clone(),
}
}
}
#[derive(Debug, Clone)]
struct StatusEntry {
code: String,
path: String,
}
#[derive(Debug, Clone)]
struct FileChangeSummary {
path: String,
status: String,
additions: u32,
deletions: u32,
}
#[derive(Debug, Clone)]
struct SymbolSummary {
new_functions: Vec<String>,
changed_functions: Vec<String>,
removed_functions: Vec<String>,
new_types: Vec<String>,
changed_types: Vec<String>,
removed_types: Vec<String>,
hunk_contexts: Vec<String>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum SymbolKind {
Function,
Type,
}
#[derive(Debug, Clone)]
struct CommitMessagePlan {
commit: ConventionalCommit,
generation_mode: GenerationMode,
}
#[derive(Debug, Clone)]
struct ConventionalCommit {
commit_type: String,
scope: Option<String>,
description: String,
body: Vec<String>,
breaking_change: Option<String>,
footers: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
enum GenerationMode {
OpenRouter,
Heuristic,
}
#[derive(Debug, Deserialize)]
struct AiCommitPayload {
#[serde(rename = "type")]
commit_type: String,
scope: Option<String>,
description: String,
#[serde(default)]
body: Vec<String>,
#[serde(default)]
breaking_change: bool,
breaking_description: Option<String>,
#[serde(default)]
footers: Vec<String>,
}
pub async fn run_commit(args: CommitArgs) -> Result<CommitRunOutcome, CommitError> {
if !command_exists("git") {
return Err(CommitError::Operation(
"Git is not installed on this machine.".to_string(),
));
}
let invocation_dir = env::current_dir()
.map_err(|e| CommitError::Operation(format!("Failed to read current directory: {}", e)))?;
let repo = resolve_repo_context(&invocation_dir)
.await
.map_err(CommitError::Operation)?;
let status_output = git_output(
&repo.repo_root,
&["status", "--porcelain=v1", "--untracked-files=all"],
)
.await
.map_err(CommitError::Operation)?;
let status_entries = parse_status_entries(&status_output);
let status_entries = filter_gitignored_status_entries(&repo.repo_root, status_entries)
.await
.map_err(CommitError::Operation)?;
if status_entries.is_empty() {
let sync_status = resolve_branch_sync_status(&repo.repo_root)
.await
.unwrap_or_default();
if args.push {
return handle_clean_worktree_push(&repo, &sync_status)
.await
.map_err(|error| CommitError::PushFailed {
summary: error,
commit_sha: None,
})
.map(|()| CommitRunOutcome::Completed);
}
println!(
"{}",
render_clean_worktree_message(&repo, &sync_status).bright_yellow()
);
return Ok(CommitRunOutcome::NothingToCommit);
}
let repo_for_analysis = repo.clone();
let analysis = analyze_worktree(repo, status_entries, &[])
.await
.map_err(CommitError::Operation)?;
let excluded_terminal_paths = terminal_session_paths(&analysis);
if !excluded_terminal_paths.is_empty()
&& analysis
.files
.iter()
.all(|file| is_terminal_session_path(&file.path))
{
return Err(CommitError::TerminalSessionsBlocked(
render_terminal_session_block_message(&excluded_terminal_paths),
));
}
let commit_analysis = if excluded_terminal_paths.is_empty() {
analysis.clone()
} else {
println!(
"\n{} {}",
"Skipped".bright_yellow().bold(),
format!(
"excluding {} IDE terminal session file(s) from this commit: {}",
excluded_terminal_paths.len(),
excluded_terminal_paths.join(", ")
)
.bright_white()
);
analyze_worktree(
repo_for_analysis,
analysis.status_entries.clone(),
&excluded_terminal_paths,
)
.await
.map_err(CommitError::Operation)?
};
let (auto_push_after_commit, commit_plan) = tokio::join!(
resolve_auto_push_on_commit(),
generate_commit_plan(&commit_analysis, &args)
);
let rendered_message = render_commit_message(&commit_plan.commit);
print_analysis_summary(&commit_analysis, &commit_plan, &rendered_message);
if args.dry_run {
println!(
"\n{}",
"Dry run only. No git commit was created."
.bright_yellow()
.bold()
);
return Ok(CommitRunOutcome::Completed);
}
match stage_and_commit(
&commit_analysis.repo_root,
&rendered_message,
&commit_analysis.status_entries,
&excluded_terminal_paths,
)
.await
.map_err(CommitError::Operation)?
{
StageAndCommitOutcome::Committed => {}
StageAndCommitOutcome::NothingToCommit => {
let sync_status = resolve_branch_sync_status(&commit_analysis.repo_root)
.await
.unwrap_or_default();
if args.push {
handle_clean_worktree_push(&commit_analysis.repo_context(), &sync_status)
.await
.map_err(|error| CommitError::PushFailed {
summary: error,
commit_sha: None,
})?;
} else {
println!(
"{}",
render_clean_worktree_message(&commit_analysis.repo_context(), &sync_status,)
.bright_yellow()
);
}
return Ok(CommitRunOutcome::NothingToCommit);
}
}
println!("{}", "Resolving created commit...".bright_black());
let full_sha = git_output(&commit_analysis.repo_root, &["rev-parse", "HEAD"])
.await
.map_err(CommitError::Operation)?;
let short_sha = full_sha.chars().take(8).collect::<String>();
println!(
"\n{} {} {}",
"Committed".bright_green().bold(),
short_sha.bright_white().bold(),
format!("({})", full_sha).dimmed()
);
if args.push || auto_push_after_commit {
println!("{}", "Pushing current branch...".bright_black());
match push_current_branch_with_name(
&commit_analysis.repo_root,
commit_analysis.branch.as_deref(),
)
.await
{
Ok(Some(outcome)) => print_push_summary(&outcome),
Ok(None) => println!(
"{}",
"Push skipped because the current HEAD is detached.".bright_yellow()
),
Err(error) => {
return Err(CommitError::PushFailed {
summary: error,
commit_sha: Some(short_sha),
});
}
}
} else {
println!(
"{}",
"Auto-push disabled by xbp config (`github.auto_push_on_commit: false`)."
.bright_yellow()
);
}
Ok(CommitRunOutcome::Completed)
}
async fn resolve_auto_push_on_commit() -> bool {
load_xbp_config_with_root()
.await
.map(|(_, config)| config.auto_push_on_commit_enabled())
.unwrap_or(true)
}
async fn resolve_repo_context(invocation_dir: &Path) -> Result<RepoContext, String> {
let repo_root = PathBuf::from(
git_output(invocation_dir, &["rev-parse", "--show-toplevel"])
.await
.map_err(|_| "Current directory is not inside a git repository.".to_string())?,
);
let repo_name = repo_name(&repo_root);
let branch = git_output(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
.await
.ok()
.filter(|value| !value.is_empty() && value != "HEAD");
Ok(RepoContext {
repo_root,
repo_name,
branch,
})
}
async fn resolve_branch_sync_status(repo_root: &Path) -> Result<BranchSyncStatus, String> {
let upstream = git_output(
repo_root,
&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
)
.await
.ok()
.filter(|value| !value.is_empty());
let Some(upstream_name) = upstream else {
return Ok(BranchSyncStatus::default());
};
let counts = git_output(
repo_root,
&["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
)
.await?;
let mut parts = counts.split_whitespace();
let ahead = parts
.next()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
let behind = parts
.next()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
Ok(BranchSyncStatus {
upstream: Some(upstream_name),
ahead,
behind,
})
}
async fn handle_clean_worktree_push(
repo: &RepoContext,
sync_status: &BranchSyncStatus,
) -> Result<(), String> {
if repo.branch.is_none() {
return Err(render_clean_worktree_message(repo, sync_status));
}
if sync_status.behind > 0 && sync_status.ahead == 0 {
let branch = repo.branch.as_deref().unwrap_or("main");
let upstream = sync_status.upstream.as_deref().unwrap_or("origin/main");
return Err(format!(
"`{branch}` is behind `{upstream}` by {} commit(s). Run `git pull --rebase origin {branch}`, then push again.",
sync_status.behind
));
}
if sync_status.ahead == 0 && sync_status.behind == 0 && sync_status.upstream.is_some() {
println!("{}", render_clean_worktree_message(repo, sync_status));
return Ok(());
}
println!("{}", render_clean_worktree_message(repo, sync_status));
match push_current_branch_with_name(&repo.repo_root, repo.branch.as_deref()).await {
Ok(Some(outcome)) => {
print_push_summary(&outcome);
Ok(())
}
Ok(None) => Err("Push skipped because the current HEAD is detached.".to_string()),
Err(error) => Err(error),
}
}
fn render_clean_worktree_message(repo: &RepoContext, sync_status: &BranchSyncStatus) -> String {
let branch_label = repo
.branch
.as_deref()
.map(|branch| format!(" on `{}`", branch))
.unwrap_or_default();
let sync_summary = match sync_status.upstream.as_deref() {
Some(upstream) if sync_status.ahead > 0 && sync_status.behind > 0 => format!(
"Working tree is clean{}, with {} commit(s) waiting to push and {} commit(s) to pull from `{}`.",
branch_label, sync_status.ahead, sync_status.behind, upstream
),
Some(upstream) if sync_status.ahead > 0 => format!(
"Working tree is clean{}, with {} commit(s) waiting to push to `{}`.",
branch_label, sync_status.ahead, upstream
),
Some(upstream) if sync_status.behind > 0 => format!(
"Working tree is clean{}, but `{}` is ahead by {} commit(s). Pull or rebase before pushing.",
branch_label, upstream, sync_status.behind
),
Some(upstream) => format!(
"Working tree is clean{} and already in sync with `{}`.",
branch_label, upstream
),
None if repo.branch.is_some() => format!(
"Working tree is clean{}, and no upstream branch is configured yet.",
branch_label
),
None => "Working tree is clean, and HEAD is detached.".to_string(),
};
format!("Nothing to commit. {}", sync_summary)
}
async fn analyze_worktree(
repo: RepoContext,
status_entries: Vec<StatusEntry>,
exclude_paths: &[String],
) -> Result<WorktreeAnalysis, String> {
if status_entries.is_empty() {
return Err("No worktree changes were found to commit.".to_string());
}
let temp_index = prepare_temporary_index(&repo.repo_root).await?;
let temp_index_path = temp_index.path.clone();
let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
git_output_with_env(&repo.repo_root, &["add", "--all"], &git_env).await?;
for path in exclude_paths {
git_output_with_env(
&repo.repo_root,
&["reset", "HEAD", "--", path.as_str()],
&git_env,
)
.await?;
}
let (name_status_output, numstat_output, diff_text) = tokio::try_join!(
git_output_with_env(
&repo.repo_root,
&["diff", "--cached", "--name-status", "--find-renames"],
&git_env,
),
git_output_with_env(
&repo.repo_root,
&["diff", "--cached", "--numstat", "--find-renames"],
&git_env,
),
git_output_with_env(
&repo.repo_root,
&[
"diff",
"--cached",
"--unified=0",
"--no-color",
"--no-ext-diff",
"--find-renames",
],
&git_env,
),
)?;
if diff_text.trim().is_empty() {
return Err("No staged diff could be produced from the current worktree.".to_string());
}
let files = merge_file_summaries(&name_status_output, &numstat_output);
let total_additions = files.iter().map(|entry| entry.additions).sum();
let total_deletions = files.iter().map(|entry| entry.deletions).sum();
let symbols = summarize_symbols(&diff_text);
Ok(WorktreeAnalysis {
repo_root: repo.repo_root,
repo_name: repo.repo_name,
branch: repo.branch,
status_entries,
files,
total_additions,
total_deletions,
diff_text,
new_functions: symbols.new_functions,
changed_functions: symbols.changed_functions,
removed_functions: symbols.removed_functions,
new_types: symbols.new_types,
changed_types: symbols.changed_types,
removed_types: symbols.removed_types,
hunk_contexts: symbols.hunk_contexts,
})
}
async fn prepare_temporary_index(repo_root: &Path) -> Result<TemporaryIndex, String> {
let real_index_path =
PathBuf::from(git_output(repo_root, &["rev-parse", "--git-path", "index"]).await?);
let temp_index_path = env::temp_dir().join(format!("xbp-commit-index-{}.tmp", Uuid::new_v4()));
if real_index_path.exists() {
fs::copy(&real_index_path, &temp_index_path).map_err(|e| {
format!(
"Failed to prepare temporary git index {}: {}",
temp_index_path.display(),
e
)
})?;
} else {
let git_env = [("GIT_INDEX_FILE", temp_index_path.as_os_str())];
let _ = git_output_with_env(repo_root, &["read-tree", "HEAD"], &git_env).await;
}
Ok(TemporaryIndex {
path: temp_index_path,
})
}
async fn generate_commit_plan(analysis: &WorktreeAnalysis, args: &CommitArgs) -> CommitMessagePlan {
let forced_scope = sanitize_scope(args.scope.as_deref());
let heuristic = build_heuristic_commit(analysis, forced_scope.clone());
if args.no_ai {
return CommitMessagePlan {
commit: heuristic,
generation_mode: GenerationMode::Heuristic,
};
}
let Some(api_key) = resolve_openrouter_api_key() else {
return CommitMessagePlan {
commit: heuristic,
generation_mode: GenerationMode::Heuristic,
};
};
let prompt = build_commit_prompt(analysis, forced_scope.as_deref(), &heuristic);
let model = args
.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(resolve_openrouter_commit_model);
let system_prompt = resolve_openrouter_commit_system_prompt();
let ai_message = complete_prompt_with_options(
&api_key,
&model,
Some(&system_prompt),
&prompt,
Some("XBP Commit Generator"),
CompletionOptions::fast_json(COMMIT_AI_MAX_TOKENS),
)
.await
.and_then(|raw| parse_ai_commit_payload(&raw, forced_scope.as_deref()));
if let Some(mut commit) = ai_message {
if commit.body.is_empty() {
commit.body = heuristic.body.clone();
}
CommitMessagePlan {
commit,
generation_mode: GenerationMode::OpenRouter,
}
} else {
CommitMessagePlan {
commit: heuristic,
generation_mode: GenerationMode::Heuristic,
}
}
}
fn build_commit_prompt(
analysis: &WorktreeAnalysis,
forced_scope: Option<&str>,
heuristic: &ConventionalCommit,
) -> String {
let focus_areas = summarize_focus_areas(analysis, MAX_PROMPT_AREAS);
let top_files = summarize_top_files(analysis, MAX_PROMPT_FILES);
let prompt_diff = build_prompt_diff_excerpt(analysis);
let mut lines = vec![
"Worktree context for commit generation:".to_string(),
String::new(),
];
lines.push(format!("Repository: {}", analysis.repo_name));
if let Some(branch) = &analysis.branch {
lines.push(format!("Branch: {}", branch));
}
if let Some(scope) = forced_scope {
lines.push(format!("Forced scope: {}", scope));
} else if let Some(scope) = heuristic.scope.as_deref() {
lines.push(format!("Suggested scope: {}", scope));
}
lines.push(format!(
"Heuristic fallback subject: {}",
render_commit_subject(heuristic)
));
if !focus_areas.is_empty() {
lines.push(format!("Focus areas: {}", focus_areas.join(", ")));
}
if let Some(behavior_hint) = infer_behavior_hint(analysis) {
lines.push(format!("Behavior hint: {}", behavior_hint));
}
if analysis
.files
.iter()
.any(|file| is_terminal_session_path(&file.path))
{
lines.push(String::new());
lines.push("Important constraint:".to_string());
lines.push(
"- paths under terminals/ are IDE agent session logs, not product features; never describe them as flows or capabilities"
.to_string(),
);
lines.push(
"- terminals/.next-id is an IDE session counter, not app configuration; never describe updating it as a feature"
.to_string(),
);
}
lines.push(String::new());
lines.push("Worktree stats:".to_string());
lines.push(format!("- files changed: {}", analysis.files.len()));
lines.push(format!(
"- lines: +{} -{}",
analysis.total_additions, analysis.total_deletions
));
if !top_files.is_empty() {
lines.push("- dominant files:".to_string());
for file in top_files {
lines.push(format!(" - {}", file));
}
}
if !analysis.status_entries.is_empty() {
lines.push("- worktree statuses:".to_string());
for entry in analysis.status_entries.iter().take(MAX_PROMPT_FILES) {
lines.push(format!(" - {} {}", entry.code, entry.path));
}
}
if !analysis.files.is_empty() {
lines.push("- file diffs:".to_string());
for file in analysis.files.iter().take(MAX_PROMPT_FILES) {
lines.push(format!(
" - [{}] {} (+{} -{})",
file.status, file.path, file.additions, file.deletions
));
}
}
if !analysis.new_functions.is_empty() {
lines.push(format!(
"- new functions: {}",
analysis
.new_functions
.iter()
.take(MAX_PROMPT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !analysis.changed_functions.is_empty() {
lines.push(format!(
"- changed functions: {}",
analysis
.changed_functions
.iter()
.take(MAX_PROMPT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !analysis.new_types.is_empty() {
lines.push(format!(
"- new types: {}",
analysis
.new_types
.iter()
.take(MAX_PROMPT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !analysis.changed_types.is_empty() {
lines.push(format!(
"- changed types: {}",
analysis
.changed_types
.iter()
.take(MAX_PROMPT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !analysis.hunk_contexts.is_empty() {
lines.push(format!(
"- hunk contexts: {}",
analysis
.hunk_contexts
.iter()
.take(MAX_PROMPT_CONTEXTS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !prompt_diff.omitted_files.is_empty() {
lines.push("- omitted low-signal diffs:".to_string());
for file in prompt_diff
.omitted_files
.iter()
.take(MAX_PROMPT_OMITTED_DIFFS)
{
lines.push(format!(" - {}", file));
}
if prompt_diff.omitted_files.len() > MAX_PROMPT_OMITTED_DIFFS {
lines.push(format!(
" - ... {} more omitted",
prompt_diff.omitted_files.len() - MAX_PROMPT_OMITTED_DIFFS
));
}
}
if !prompt_diff.summaries.is_empty() {
lines.push("- preprocessed diff summaries:".to_string());
for summary in prompt_diff.summaries.iter().take(MAX_PROMPT_DIFF_SUMMARIES) {
lines.push(format!(" - {}", summary));
}
if prompt_diff.summaries.len() > MAX_PROMPT_DIFF_SUMMARIES {
lines.push(format!(
" - ... {} more summaries omitted",
prompt_diff.summaries.len() - MAX_PROMPT_DIFF_SUMMARIES
));
}
}
lines.push(String::new());
if prompt_diff.excerpt.trim().is_empty() {
lines.push("Diff excerpt: omitted".to_string());
lines.push(
"[all available file diffs were low-signal generated or lockfile changes; use the structured stats above]"
.to_string(),
);
} else if prompt_diff.omitted_files.is_empty() {
lines.push("Diff excerpt:".to_string());
lines.push(prompt_diff.excerpt);
} else {
lines.push("Diff excerpt (low-signal generated and lockfile diffs omitted):".to_string());
lines.push(prompt_diff.excerpt);
}
lines.join("\n")
}
fn parse_ai_commit_payload(raw: &str, forced_scope: Option<&str>) -> Option<ConventionalCommit> {
let payload: AiCommitPayload = serde_json::from_str(raw).ok()?;
let commit_type = sanitize_commit_type(&payload.commit_type)?;
let description = sanitize_description(&payload.description)?;
let mut body = payload
.body
.into_iter()
.map(|entry| entry.trim().to_string())
.filter(|entry| !entry.is_empty())
.collect::<Vec<_>>();
if body.len() > 3 {
body.truncate(3);
}
let breaking_change = if payload.breaking_change {
payload
.breaking_description
.as_deref()
.and_then(sanitize_footer_value)
} else {
None
};
let mut footers = payload
.footers
.into_iter()
.map(|entry| entry.trim().to_string())
.filter(|entry| !entry.is_empty())
.collect::<Vec<_>>();
if breaking_change.is_some()
&& !footers
.iter()
.any(|entry| entry.starts_with("BREAKING CHANGE:"))
{
if let Some(breaking) = &breaking_change {
footers.push(format!("BREAKING CHANGE: {}", breaking));
}
}
Some(ConventionalCommit {
commit_type,
scope: forced_scope
.and_then(|scope| sanitize_scope(Some(scope)))
.or_else(|| sanitize_scope(payload.scope.as_deref())),
description,
body,
breaking_change,
footers,
})
}
fn build_heuristic_commit(
analysis: &WorktreeAnalysis,
forced_scope: Option<String>,
) -> ConventionalCommit {
let commit_type = infer_commit_type(analysis).to_string();
let scope = forced_scope.or_else(|| infer_scope(analysis));
let description = infer_description(analysis, &commit_type, scope.as_deref());
let body = build_heuristic_body(analysis);
ConventionalCommit {
commit_type,
scope,
description,
body,
breaking_change: None,
footers: Vec::new(),
}
}
fn infer_commit_type(analysis: &WorktreeAnalysis) -> &'static str {
let lowered_paths = analysis
.files
.iter()
.map(|file| file.path.to_ascii_lowercase())
.collect::<Vec<_>>();
if !lowered_paths.is_empty()
&& lowered_paths
.iter()
.all(|path| is_terminal_session_path(path))
{
return "chore";
}
let docs_only = !lowered_paths.is_empty()
&& lowered_paths.iter().all(|path| {
!is_terminal_session_path(path)
&& (path.ends_with(".md")
|| path.ends_with(".mdx")
|| path.ends_with(".txt")
|| path.starts_with("docs/"))
});
if docs_only {
return "docs";
}
let test_only = !lowered_paths.is_empty()
&& lowered_paths.iter().all(|path| {
path.contains("/tests/")
|| path.contains("\\tests\\")
|| path.contains(".test.")
|| path.contains(".spec.")
});
if test_only {
return "test";
}
let ci_only = !lowered_paths.is_empty()
&& lowered_paths.iter().all(|path| {
path.starts_with(".github/")
|| path.contains("workflow")
|| path.ends_with(".yml")
|| path.ends_with(".yaml")
});
if ci_only {
return "ci";
}
let build_only = !lowered_paths.is_empty()
&& lowered_paths.iter().all(|path| {
path.ends_with("cargo.toml")
|| path.ends_with("cargo.lock")
|| path.ends_with("package.json")
|| path.ends_with("pnpm-lock.yaml")
|| path.ends_with("package-lock.json")
|| path.ends_with("wrangler.toml")
|| path.ends_with(".nix")
});
if build_only {
return "build";
}
let has_new_files = analysis.files.iter().any(|file| file.status == "added");
let has_new_symbols = !analysis.new_functions.is_empty() || !analysis.new_types.is_empty();
let has_existing_symbol_churn = !analysis.changed_functions.is_empty()
|| !analysis.changed_types.is_empty()
|| !analysis.removed_functions.is_empty()
|| !analysis.removed_types.is_empty();
if has_new_files {
return "feat";
}
if has_new_symbols && has_existing_symbol_churn {
return "refactor";
}
if has_new_symbols {
return "feat";
}
if has_existing_symbol_churn || analysis.total_additions >= analysis.total_deletions {
return "fix";
}
"chore"
}
fn infer_scope(analysis: &WorktreeAnalysis) -> Option<String> {
let mut scopes = BTreeSet::new();
for file in &analysis.files {
let path = file.path.replace('\\', "/");
let inferred = if path.starts_with("crates/cli/") {
Some("cli")
} else if path.starts_with("crates/api/") {
Some("api")
} else if path.starts_with("crates/github/") {
Some("github")
} else if path.starts_with("crates/runtime/") {
Some("runtime")
} else if path.starts_with("apps/web/") {
Some("web")
} else if path.starts_with("docs/") {
Some("docs")
} else if path.starts_with(".github/") {
Some("ci")
} else {
None
};
if let Some(value) = inferred {
scopes.insert(value.to_string());
}
}
if scopes.len() == 1 {
scopes.into_iter().next()
} else if scopes.is_empty() {
None
} else if scopes.contains("cli") {
Some("cli".to_string())
} else {
None
}
}
fn infer_description(
analysis: &WorktreeAnalysis,
commit_type: &str,
scope: Option<&str>,
) -> String {
if !analysis.files.is_empty()
&& analysis
.files
.iter()
.all(|file| is_terminal_session_path(&file.path))
{
return "ignore ide terminal session logs".to_string();
}
let focus_areas = summarize_focus_areas(analysis, 2);
let behavior_hint = infer_behavior_hint(analysis);
let interesting_names = analysis
.files
.iter()
.filter_map(|file| interesting_file_stem(&file.path))
.collect::<Vec<_>>();
if interesting_names.iter().any(|name| name == "commit") {
return match scope {
Some("cli") => "add worktree commit generator".to_string(),
_ => "add conventional commit generator".to_string(),
};
}
if commit_type == "docs" {
if let Some(name) = interesting_names.first() {
return format!("document {}", name.replace('_', "-"));
}
return "update command documentation".to_string();
}
if let Some(area) = focus_areas.first() {
let target = match behavior_hint {
Some(hint) => format!("{} {}", area, hint),
None => format!("{} flow", area),
};
return match commit_type {
"feat" => format!("expand {}", target),
"fix" => format!("improve {}", target),
"refactor" => format!("refine {}", target),
"test" => format!("cover {}", target),
"ci" => format!("update {} workflow", area),
"build" => format!("adjust {} build inputs", area),
_ => format!("update {}", target),
};
}
if let Some(name) = interesting_names.first() {
return match commit_type {
"feat" => format!("add {}", name.replace('_', "-")),
"fix" => format!("improve {}", name.replace('_', "-")),
"refactor" => format!("refactor {}", name.replace('_', "-")),
"test" => format!("cover {}", name.replace('_', "-")),
"ci" => format!("update {}", name.replace('_', "-")),
"build" => format!("adjust {}", name.replace('_', "-")),
_ => format!("update {}", name.replace('_', "-")),
};
}
match commit_type {
"feat" => "add worktree change support".to_string(),
"fix" => "improve worktree handling".to_string(),
"docs" => "update documentation".to_string(),
_ => "update repository changes".to_string(),
}
}
fn build_heuristic_body(analysis: &WorktreeAnalysis) -> Vec<String> {
let focus_areas = summarize_focus_areas(analysis, 3);
let top_files = summarize_top_files(analysis, 3);
let mut paragraphs = Vec::new();
let overview_target = if focus_areas.is_empty() {
format!(
"{} file{}",
analysis.files.len(),
if analysis.files.len() == 1 { "" } else { "s" }
)
} else {
format!(
"{} across {} file{}",
format_focus_area_list(&focus_areas),
analysis.files.len(),
if analysis.files.len() == 1 { "" } else { "s" }
)
};
paragraphs.push(format!(
"Touches {} with +{} and -{} lines in the current worktree.",
overview_target, analysis.total_additions, analysis.total_deletions
));
let mut detail_parts = Vec::new();
if !analysis.new_functions.is_empty() {
detail_parts.push(format!(
"adds {}",
summarize_symbol_list(&analysis.new_functions, 4)
));
}
if !analysis.changed_functions.is_empty() {
detail_parts.push(format!(
"updates {}",
summarize_symbol_list(&analysis.changed_functions, 4)
));
}
if !analysis.removed_functions.is_empty() {
detail_parts.push(format!(
"removes {}",
summarize_symbol_list(&analysis.removed_functions, 3)
));
}
if !analysis.new_types.is_empty() {
detail_parts.push(format!(
"introduces {}",
summarize_symbol_list(&analysis.new_types, 3)
));
}
if !analysis.changed_types.is_empty() {
detail_parts.push(format!(
"reshapes {}",
summarize_symbol_list(&analysis.changed_types, 3)
));
}
if !analysis.removed_types.is_empty() {
detail_parts.push(format!(
"drops {}",
summarize_symbol_list(&analysis.removed_types, 3)
));
}
if !top_files.is_empty() {
detail_parts.push(format!(
"with the biggest diffs in {}",
top_files.join(", ")
));
}
if !detail_parts.is_empty() {
paragraphs.push(format!("{}.", detail_parts.join("; ")));
}
paragraphs
}
fn summarize_focus_areas(analysis: &WorktreeAnalysis, limit: usize) -> Vec<String> {
let mut area_scores = BTreeMap::<String, u32>::new();
for file in &analysis.files {
let Some(area) = describe_focus_area(&file.path) else {
continue;
};
let weight = (file.additions + file.deletions).max(1);
*area_scores.entry(area).or_default() += weight;
}
let mut ranked = area_scores.into_iter().collect::<Vec<_>>();
ranked.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
ranked
.into_iter()
.map(|(area, _)| area)
.take(limit)
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TopFileGroup {
label: String,
file_count: usize,
additions: u32,
deletions: u32,
}
fn summarize_top_files(analysis: &WorktreeAnalysis, limit: usize) -> Vec<String> {
let groups = collapse_top_file_groups(analysis);
groups
.into_iter()
.take(limit)
.map(|group| format_top_file_group(&group))
.collect()
}
fn collapse_top_file_groups(analysis: &WorktreeAnalysis) -> Vec<TopFileGroup> {
let mut parent_counts = BTreeMap::<String, usize>::new();
for file in &analysis.files {
if let Some(parent) = parent_dir_key(&file.path) {
*parent_counts.entry(parent).or_default() += 1;
}
}
let mut groups = BTreeMap::<String, TopFileGroup>::new();
for file in &analysis.files {
let label = top_file_group_label(&file.path, &parent_counts);
let entry = groups.entry(label.clone()).or_insert(TopFileGroup {
label,
file_count: 0,
additions: 0,
deletions: 0,
});
entry.file_count += 1;
entry.additions += file.additions;
entry.deletions += file.deletions;
}
let mut ranked = groups.into_values().collect::<Vec<_>>();
ranked.sort_by(|left, right| {
let left_weight = left.additions + left.deletions;
let right_weight = right.additions + right.deletions;
right_weight
.cmp(&left_weight)
.then_with(|| left.label.cmp(&right.label))
});
ranked
}
fn top_file_group_label(path: &str, parent_counts: &BTreeMap<String, usize>) -> String {
let normalized = normalize_display_path(path);
if normalized.starts_with("terminals/") {
return "terminals/".to_string();
}
if let Some(parent) = parent_dir_key(&normalized) {
if parent_counts.get(&parent).copied().unwrap_or(0) > 1 {
return format!("{}/", parent);
}
}
normalized
}
fn is_terminal_session_path(path: &str) -> bool {
normalize_display_path(path)
.to_ascii_lowercase()
.starts_with(TERMINAL_SESSION_DIR)
}
fn terminal_session_paths(analysis: &WorktreeAnalysis) -> Vec<String> {
analysis
.files
.iter()
.filter(|file| is_terminal_session_path(&file.path))
.map(|file| file.path.clone())
.collect()
}
fn render_terminal_session_block_message(paths: &[String]) -> String {
format!(
"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: {}",
TERMINAL_SESSION_DIR.trim_end_matches('/'),
paths.join(", ")
)
}
fn parent_dir_key(path: &str) -> Option<String> {
let normalized = normalize_display_path(path);
Path::new(&normalized)
.parent()
.and_then(|value| value.to_str())
.map(normalize_display_path)
.filter(|value| !value.is_empty())
}
fn format_top_file_group(group: &TopFileGroup) -> String {
if group.file_count > 1 {
format!(
"{} ({} files, +{} -{})",
group.label, group.file_count, group.additions, group.deletions
)
} else {
format!(
"{} (+{} -{})",
group.label, group.additions, group.deletions
)
}
}
fn color_commit_type(commit_type: &str) -> colored::ColoredString {
match commit_type {
"feat" => commit_type.bright_green().bold(),
"fix" => commit_type.bright_red().bold(),
"docs" => commit_type.bright_blue().bold(),
"refactor" => commit_type.bright_magenta().bold(),
"chore" => commit_type.bright_black().bold(),
"test" => commit_type.bright_cyan().bold(),
"build" => commit_type.bright_yellow().bold(),
"ci" => commit_type.cyan().bold(),
"perf" => commit_type.bright_yellow().bold(),
"style" => commit_type.white().bold(),
"revert" => commit_type.red().bold(),
_ => commit_type.bright_white().bold(),
}
}
fn render_colored_commit_subject(commit: &ConventionalCommit) -> String {
let scope = commit
.scope
.as_deref()
.map(|value| format!("({})", value.magenta()))
.unwrap_or_default();
let bang = if commit.breaking_change.is_some() {
"!".bright_red().to_string()
} else {
String::new()
};
let description = commit.description.bright_white();
format!(
"{}{}{}: {}",
color_commit_type(&commit.commit_type),
scope,
bang,
description
)
}
fn render_commit_message_preview(commit: &ConventionalCommit) -> String {
let mut sections = vec![render_colored_commit_subject(commit)];
if !commit.body.is_empty() {
sections.push(commit.body.join("\n\n"));
}
let mut footer_lines = commit
.footers
.iter()
.map(|entry| entry.trim().to_string())
.filter(|entry| !entry.is_empty())
.collect::<Vec<_>>();
if let Some(breaking_change) = &commit.breaking_change {
if !footer_lines
.iter()
.any(|entry| entry.starts_with("BREAKING CHANGE:"))
{
footer_lines.push(format!("BREAKING CHANGE: {}", breaking_change));
}
}
if !footer_lines.is_empty() {
sections.push(footer_lines.join("\n"));
}
sections.join("\n\n")
}
fn format_colored_change_counts(additions: u32, deletions: u32) -> String {
format!(
"(+{} -{})",
additions.to_string().bright_green(),
deletions.to_string().bright_red()
)
}
fn format_colored_top_file_group(group: &TopFileGroup) -> String {
let change_counts = format_colored_change_counts(group.additions, group.deletions);
if group.file_count > 1 {
format!(
"{} ({} files, {})",
group.label.bright_white(),
group.file_count.to_string().bright_white(),
change_counts
)
} else {
format!("{} {}", group.label.bright_white(), change_counts)
}
}
fn describe_focus_area(path: &str) -> Option<String> {
let normalized = normalize_display_path(path);
if normalized.starts_with("terminals/") {
return Some("terminals".to_string());
}
if normalized.starts_with("crates/cli/src/commands/") {
return Path::new(&normalized)
.file_stem()
.and_then(|value| value.to_str())
.map(humanize_focus_area)
.filter(|value| !value.is_empty());
}
if normalized.contains("/http-app.") {
return Some("http".to_string());
}
if normalized.contains("/server.") {
return Some("server".to_string());
}
if normalized.contains("/worker/") {
return Some("worker".to_string());
}
if let Some(stem) = interesting_file_stem(&normalized) {
return Some(humanize_focus_area(&stem));
}
normalized
.split('/')
.rev()
.find_map(normalize_focus_segment)
}
fn humanize_focus_area(raw: &str) -> String {
raw.trim().replace(['_', '-'], " ").to_ascii_lowercase()
}
fn is_generic_path_segment(segment: &str) -> bool {
matches!(
segment.to_ascii_lowercase().as_str(),
"src"
| "crates"
| "apps"
| "packages"
| "commands"
| "tests"
| "test"
| "docs"
| "lib"
| "terminals"
)
}
fn is_weak_focus_label(label: &str) -> bool {
let trimmed = label.trim();
trimmed.is_empty() || trimmed.chars().all(|ch| ch.is_ascii_digit())
}
fn normalize_focus_segment(segment: &str) -> Option<String> {
let trimmed = segment.trim();
if trimmed.is_empty() || is_generic_path_segment(trimmed) {
return None;
}
let label = trimmed.trim_start_matches('.');
if is_weak_focus_label(label) {
return None;
}
let humanized = humanize_focus_area(label);
if humanized.is_empty() {
None
} else {
Some(humanized)
}
}
fn infer_behavior_hint(analysis: &WorktreeAnalysis) -> Option<&'static str> {
let mut corpus = String::new();
for file in &analysis.files {
corpus.push_str(&file.path.to_ascii_lowercase());
corpus.push(' ');
}
for context in &analysis.hunk_contexts {
corpus.push_str(&context.to_ascii_lowercase());
corpus.push(' ');
}
if corpus.contains("request") || corpus.contains("response") || corpus.contains("http") {
Some("request handling")
} else if corpus.contains("auth") || corpus.contains("token") || corpus.contains("session") {
Some("auth flow")
} else if corpus.contains("env") || corpus.contains("config") || corpus.contains("setting") {
Some("configuration loading")
} else if corpus.contains("release") || corpus.contains("version") || corpus.contains("tag") {
Some("release flow")
} else if corpus.contains("docker") || corpus.contains("container") {
Some("container setup")
} else {
None
}
}
fn format_focus_area_list(areas: &[String]) -> String {
match areas {
[] => "the current worktree".to_string(),
[one] => one.clone(),
[left, right] => format!("{} and {}", left, right),
_ => {
let mut items = areas.to_vec();
let last = items.pop().unwrap_or_default();
format!("{}, and {}", items.join(", "), last)
}
}
}
fn summarize_symbol_list(entries: &[String], limit: usize) -> String {
entries
.iter()
.take(limit)
.map(|entry| {
entry
.split_once(" (")
.map(|(name, _)| name.to_string())
.unwrap_or_else(|| entry.clone())
})
.collect::<Vec<_>>()
.join(", ")
}
fn render_commit_message(commit: &ConventionalCommit) -> String {
let mut sections = vec![render_commit_subject(commit)];
if !commit.body.is_empty() {
sections.push(commit.body.join("\n\n"));
}
let mut footer_lines = commit
.footers
.iter()
.map(|entry| entry.trim().to_string())
.filter(|entry| !entry.is_empty())
.collect::<Vec<_>>();
if let Some(breaking_change) = &commit.breaking_change {
if !footer_lines
.iter()
.any(|entry| entry.starts_with("BREAKING CHANGE:"))
{
footer_lines.push(format!("BREAKING CHANGE: {}", breaking_change));
}
}
if !footer_lines.is_empty() {
sections.push(footer_lines.join("\n"));
}
sections.join("\n\n")
}
fn render_commit_subject(commit: &ConventionalCommit) -> String {
let scope = commit
.scope
.as_deref()
.map(|value| format!("({})", value))
.unwrap_or_default();
let bang = if commit.breaking_change.is_some() {
"!"
} else {
""
};
format!(
"{}{}{}: {}",
commit.commit_type, scope, bang, commit.description
)
}
fn print_analysis_summary(
analysis: &WorktreeAnalysis,
commit_plan: &CommitMessagePlan,
_rendered_message: &str,
) {
let focus_areas = summarize_focus_areas(analysis, MAX_PRINT_AREAS);
let top_files = collapse_top_file_groups(analysis);
let branch = analysis
.branch
.as_deref()
.map(|value| format!(" on {}", value.bright_blue()))
.unwrap_or_default();
println!(
"{} {}{}",
"Commit".bright_green().bold(),
analysis.repo_name.bright_white().bold(),
branch
);
println!(
" {} {}",
"Mode".bright_cyan().bold(),
match commit_plan.generation_mode {
GenerationMode::OpenRouter => "OpenRouter".bright_white(),
GenerationMode::Heuristic => "Heuristic fallback".bright_white(),
}
);
println!(
" {} {}",
"Subject".bright_cyan().bold(),
render_colored_commit_subject(&commit_plan.commit)
);
println!(
" {} {} file{} (+{} -{})",
"Diff".bright_cyan().bold(),
analysis.files.len().to_string().bright_white(),
if analysis.files.len() == 1 { "" } else { "s" },
analysis.total_additions.to_string().bright_green(),
analysis.total_deletions.to_string().bright_red()
);
if !focus_areas.is_empty() {
println!(
" {} {}",
"Focus".bright_cyan().bold(),
focus_areas.join(", ")
);
}
if !top_files.is_empty() {
let top_files = top_files
.into_iter()
.take(MAX_PRINT_FILES)
.map(|group| format_colored_top_file_group(&group))
.collect::<Vec<_>>();
println!(" {} {}", "Top".bright_cyan().bold(), top_files.join(", "));
}
if !analysis.new_functions.is_empty() {
println!(
" {} {}",
"+fn".bright_cyan().bold(),
summarize_symbol_list(&analysis.new_functions, MAX_PRINT_SYMBOLS)
);
}
if !analysis.changed_functions.is_empty() {
println!(
" {} {}",
"~fn".bright_cyan().bold(),
summarize_symbol_list(&analysis.changed_functions, MAX_PRINT_SYMBOLS)
);
}
if !analysis.new_types.is_empty() {
println!(
" {} {}",
"+type".bright_cyan().bold(),
summarize_symbol_list(&analysis.new_types, MAX_PRINT_SYMBOLS)
);
}
if !analysis.changed_types.is_empty() {
println!(
" {} {}",
"~type".bright_cyan().bold(),
summarize_symbol_list(&analysis.changed_types, MAX_PRINT_SYMBOLS)
);
}
if !analysis.removed_functions.is_empty() {
println!(
" {} {}",
"-fn".bright_cyan().bold(),
summarize_symbol_list(&analysis.removed_functions, MAX_PRINT_SYMBOLS)
);
}
if !analysis.removed_types.is_empty() {
println!(
" {} {}",
"-type".bright_cyan().bold(),
summarize_symbol_list(&analysis.removed_types, MAX_PRINT_SYMBOLS)
);
}
println!("\n{}", "Generated commit".bright_magenta().bold());
println!("{}", "-".repeat(72).bright_black());
println!("{}", render_commit_message_preview(&commit_plan.commit));
println!("{}", "-".repeat(72).bright_black());
}
async fn stage_and_commit(
repo_root: &Path,
message: &str,
status_entries: &[StatusEntry],
exclude_paths: &[String],
) -> Result<StageAndCommitOutcome, String> {
stage_commit_paths(repo_root, status_entries, exclude_paths).await?;
let message_path = env::temp_dir().join(format!("xbp-commit-message-{}.txt", Uuid::new_v4()));
fs::write(&message_path, message).map_err(|e| {
format!(
"Failed to write temporary commit message {}: {}",
message_path.display(),
e
)
})?;
println!("{}", "Creating git commit...".bright_black());
let result = git_output_streaming(
repo_root,
&["commit", "--file", &message_path.to_string_lossy()],
)
.await;
let _ = fs::remove_file(&message_path);
match result {
Ok(_) => Ok(StageAndCommitOutcome::Committed),
Err(error) if looks_like_nothing_to_commit_error(&error) => {
if !git_has_staged_changes(repo_root).await? && git_worktree_is_clean(repo_root).await?
{
Ok(StageAndCommitOutcome::NothingToCommit)
} else {
Err(error)
}
}
Err(error) => Err(error),
}
}
async fn stage_commit_paths(
repo_root: &Path,
status_entries: &[StatusEntry],
exclude_paths: &[String],
) -> Result<(), String> {
let stage_paths = stage_pathspecs(status_entries);
let StageStrategy::BulkAddAll { path_count } =
choose_stage_strategy(&stage_paths, exclude_paths);
if path_count == 0 {
println!("{}", "Staging worktree changes...".bright_black());
} else {
println!(
"{}",
format!("Staging {} changed path(s)...", path_count).bright_black()
);
}
git_output(repo_root, &["add", "--all"]).await?;
for path in exclude_paths {
println!(
"{} {}",
"Keeping excluded path unstaged:".bright_black(),
path.bright_white()
);
git_output(repo_root, &["reset", "HEAD", "--", path.as_str()]).await?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum StageStrategy {
BulkAddAll { path_count: usize },
}
fn choose_stage_strategy(stage_paths: &[String], _exclude_paths: &[String]) -> StageStrategy {
StageStrategy::BulkAddAll {
path_count: stage_paths.len(),
}
}
#[cfg(test)]
fn pathspecs_exceed_safe_argv_budget(paths: &[String]) -> bool {
if paths.len() > SAFE_GIT_PATHSPEC_COUNT {
return true;
}
let fixed = "git add --all --".len();
let paths_chars: usize = paths.iter().map(|path| path.len().saturating_add(1)).sum();
fixed.saturating_add(paths_chars) > SAFE_GIT_ARGV_CHAR_BUDGET
}
fn stage_pathspecs(status_entries: &[StatusEntry]) -> Vec<String> {
let mut paths = BTreeSet::new();
for entry in status_entries {
let path = entry.path.trim();
if path.is_empty() || path.contains(" -> ") {
return Vec::new();
}
if path.ends_with('/') && path.contains(".git/") {
continue;
}
paths.insert(path.to_string());
}
paths.into_iter().collect()
}
async fn filter_gitignored_status_entries(
repo_root: &Path,
status_entries: Vec<StatusEntry>,
) -> Result<Vec<StatusEntry>, String> {
let untracked: Vec<&StatusEntry> = status_entries
.iter()
.filter(|entry| entry.code.contains('?'))
.collect();
if untracked.is_empty() {
return Ok(status_entries);
}
let ignored = git_check_ignore_paths(
repo_root,
&untracked
.iter()
.map(|entry| entry.path.as_str())
.collect::<Vec<_>>(),
)
.await?;
if ignored.is_empty() {
return Ok(status_entries);
}
println!(
"{} {}",
"Skipped".bright_yellow().bold(),
format!(
"excluding {} path(s) matching .gitignore / exclude rules from commit analysis and staging",
ignored.len()
)
.bright_white()
);
Ok(status_entries
.into_iter()
.filter(|entry| !(entry.code.contains('?') && ignored.contains(&entry.path)))
.collect())
}
async fn git_check_ignore_paths(
repo_root: &Path,
paths: &[&str],
) -> Result<BTreeSet<String>, String> {
if paths.is_empty() {
return Ok(BTreeSet::new());
}
let mut command = Command::new("git");
command
.current_dir(repo_root)
.args(["check-ignore", "--stdin"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| format!("Failed to run `git check-ignore`: {}", e))?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
let body = paths.join("\n");
stdin
.write_all(body.as_bytes())
.await
.map_err(|e| format!("Failed to write paths to `git check-ignore`: {}", e))?;
drop(stdin);
}
let output = child
.wait_with_output()
.await
.map_err(|e| format!("Failed to wait for `git check-ignore`: {}", e))?;
match output.status.code() {
Some(0) | Some(1) => {}
_ => {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
return Err(format!("`git check-ignore` failed: {}", stderr));
}
return Err(format!(
"`git check-ignore` failed with status {}",
output.status
));
}
}
let ignored = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| line.replace('\\', "/"))
.collect();
Ok(ignored)
}
fn format_git_args_for_error(args: &[&str]) -> String {
let joined = args.join(" ");
if joined.chars().count() <= MAX_GIT_ERROR_ARG_CHARS {
return joined;
}
let truncated: String = joined.chars().take(MAX_GIT_ERROR_ARG_CHARS).collect();
format!(
"{}… [{} args, truncated for display]",
truncated,
args.len()
)
}
fn parse_status_entries(output: &str) -> Vec<StatusEntry> {
output
.lines()
.filter_map(|line| {
if line.len() < 4 {
return None;
}
let code = line[..2].trim().to_string();
let path = line[3..].trim().replace('\\', "/");
let path = unquote_porcelain_path(&path);
if path.is_empty() {
None
} else {
Some(StatusEntry { code, path })
}
})
.collect()
}
fn unquote_porcelain_path(path: &str) -> String {
let trimmed = path.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
trimmed[1..trimmed.len() - 1]
.replace("\\\\", "\\")
.replace("\\\"", "\"")
.replace("\\t", "\t")
.replace("\\n", "\n")
.replace("\\r", "\r")
} else {
trimmed.to_string()
}
}
fn merge_file_summaries(name_status: &str, numstat: &str) -> Vec<FileChangeSummary> {
let mut status_map = BTreeMap::new();
let mut display_path_map = BTreeMap::new();
for raw_line in name_status
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let parts = raw_line.split('\t').collect::<Vec<_>>();
if parts.is_empty() {
continue;
}
let status = normalize_name_status(parts[0]);
match parts.as_slice() {
[_, path] => {
let key = normalize_path_key(path);
display_path_map.insert(key.clone(), normalize_display_path(path));
status_map.insert(key, status);
}
[_, old_path, new_path] => {
let key = normalize_path_key(new_path);
display_path_map.insert(
key.clone(),
format!(
"{} -> {}",
normalize_display_path(old_path),
normalize_display_path(new_path)
),
);
status_map.insert(key, status);
}
_ => {}
}
}
let mut files = Vec::new();
for raw_line in numstat
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let parts = raw_line.split('\t').collect::<Vec<_>>();
if parts.len() < 3 {
continue;
}
let additions = parts[0].parse::<u32>().unwrap_or(0);
let deletions = parts[1].parse::<u32>().unwrap_or(0);
let raw_path = parts[2..].join("\t");
let key = normalize_path_key(&raw_path);
let path = display_path_map
.get(&key)
.cloned()
.unwrap_or_else(|| normalize_display_path(&raw_path));
let status = status_map
.get(&key)
.cloned()
.unwrap_or_else(|| "modified".to_string());
files.push(FileChangeSummary {
path,
status,
additions,
deletions,
});
}
if files.is_empty() {
for (key, status) in status_map {
files.push(FileChangeSummary {
path: display_path_map
.get(&key)
.cloned()
.unwrap_or_else(|| key.clone()),
status,
additions: 0,
deletions: 0,
});
}
}
files
}
fn summarize_symbols(diff_text: &str) -> SymbolSummary {
let mut current_file = String::new();
let mut added_functions = BTreeSet::new();
let mut removed_functions = BTreeSet::new();
let mut added_types = BTreeSet::new();
let mut removed_types = BTreeSet::new();
let mut changed_functions = BTreeSet::new();
let mut changed_types = BTreeSet::new();
let mut hunk_contexts = BTreeSet::new();
for line in diff_text.lines() {
if let Some(rest) = line.strip_prefix("+++ b/") {
current_file = rest.trim().replace('\\', "/");
continue;
}
if line.starts_with("@@") {
if let Some(context) = parse_hunk_context(line) {
if is_code_path(¤t_file) {
if let Some((symbol, kind)) = extract_context_symbol(&context) {
match kind {
SymbolKind::Function => {
changed_functions.insert(symbol);
}
SymbolKind::Type => {
changed_types.insert(symbol);
}
}
}
}
if prompt_diff_omit_reason(¤t_file).is_none()
&& !is_low_signal_hunk_context(&context)
{
hunk_contexts.insert(context);
}
}
continue;
}
if current_file.is_empty() || line.starts_with("+++") || line.starts_with("---") {
continue;
}
if let Some(source) = line.strip_prefix('+') {
if let Some(name) = extract_function_name(source) {
added_functions.insert(format!("{} ({})", name, current_file));
}
if let Some(name) = extract_type_name(source) {
added_types.insert(format!("{} ({})", name, current_file));
}
} else if let Some(source) = line.strip_prefix('-') {
if let Some(name) = extract_function_name(source) {
removed_functions.insert(format!("{} ({})", name, current_file));
}
if let Some(name) = extract_type_name(source) {
removed_types.insert(format!("{} ({})", name, current_file));
}
}
}
let changed_functions_from_dupes = added_functions
.intersection(&removed_functions)
.cloned()
.collect::<BTreeSet<_>>();
let changed_types_from_dupes = added_types
.intersection(&removed_types)
.cloned()
.collect::<BTreeSet<_>>();
for entry in &changed_functions_from_dupes {
changed_functions.insert(entry.clone());
}
for entry in &changed_types_from_dupes {
changed_types.insert(entry.clone());
}
let new_functions = added_functions
.difference(&changed_functions_from_dupes)
.cloned()
.collect::<Vec<_>>();
let removed_functions = removed_functions
.difference(&changed_functions_from_dupes)
.cloned()
.collect::<Vec<_>>();
let new_types = added_types
.difference(&changed_types_from_dupes)
.cloned()
.collect::<Vec<_>>();
let removed_types = removed_types
.difference(&changed_types_from_dupes)
.cloned()
.collect::<Vec<_>>();
SymbolSummary {
new_functions,
changed_functions: changed_functions.into_iter().collect(),
removed_functions,
new_types,
changed_types: changed_types.into_iter().collect(),
removed_types,
hunk_contexts: hunk_contexts.into_iter().collect(),
}
}
fn parse_hunk_context(line: &str) -> Option<String> {
let parts = line.split("@@").collect::<Vec<_>>();
if parts.len() < 3 {
return None;
}
let context = parts[2].trim();
if context.is_empty() {
None
} else {
Some(context.to_string())
}
}
fn is_low_signal_hunk_context(context: &str) -> bool {
let normalized = context.trim().to_ascii_lowercase();
normalized.is_empty()
|| matches!(
normalized.as_str(),
"importers:" | "packages:" | "snapshots:" | "[[package]]"
)
|| normalized.starts_with("name = ")
|| normalized.starts_with("version = ")
|| normalized.starts_with("source = ")
|| normalized.starts_with("checksum = ")
}
fn extract_context_symbol(context: &str) -> Option<(String, SymbolKind)> {
let trimmed = context.trim();
if trimmed.is_empty() {
return None;
}
for capture in [
RUST_FUNCTION_RE.captures(trimmed),
JS_FUNCTION_RE.captures(trimmed),
]
.into_iter()
.flatten()
{
if let Some(name) = capture.get(1) {
let symbol = name.as_str().to_string();
if is_noise_symbol(&symbol) {
return None;
}
return Some((symbol, SymbolKind::Function));
}
}
if let Some(capture) = JS_ARROW_RE.captures(trimmed) {
if let Some(name) = capture.get(1) {
let symbol = name.as_str().to_string();
if is_noise_symbol(&symbol) {
return None;
}
return Some((symbol, SymbolKind::Function));
}
}
for capture in [RUST_TYPE_RE.captures(trimmed), TS_TYPE_RE.captures(trimmed)]
.into_iter()
.flatten()
{
if let Some(name) = capture.get(2) {
let symbol = name.as_str().to_string();
if is_noise_symbol(&symbol) {
return None;
}
return Some((symbol, SymbolKind::Type));
}
}
if let Some(capture) = METHOD_CONTEXT_RE.captures(trimmed) {
if let Some(name) = capture.get(1) {
let symbol = name.as_str().to_string();
if is_noise_symbol(&symbol) {
return None;
}
return Some((symbol, SymbolKind::Function));
}
}
None
}
fn extract_function_name(source: &str) -> Option<String> {
for capture in [
RUST_FUNCTION_RE.captures(source),
JS_FUNCTION_RE.captures(source),
JS_ARROW_RE.captures(source),
]
.into_iter()
.flatten()
{
if let Some(name) = capture.get(1) {
return Some(name.as_str().to_string());
}
}
None
}
fn extract_type_name(source: &str) -> Option<String> {
for capture in [RUST_TYPE_RE.captures(source), TS_TYPE_RE.captures(source)]
.into_iter()
.flatten()
{
if let Some(name) = capture.get(2) {
return Some(name.as_str().to_string());
}
}
None
}
fn is_code_path(path: &str) -> bool {
let normalized = path.to_ascii_lowercase();
[
".rs", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".java", ".kt", ".swift",
]
.iter()
.any(|extension| normalized.ends_with(extension))
}
fn is_noise_symbol(symbol: &str) -> bool {
matches!(
symbol,
"fn" | "pub"
| "impl"
| "mod"
| "use"
| "struct"
| "enum"
| "trait"
| "type"
| "class"
| "interface"
| "const"
| "let"
| "var"
| "async"
| "await"
| "match"
| "if"
| "else"
| "for"
| "while"
| "loop"
)
}
fn sanitize_commit_type(raw: &str) -> Option<String> {
let normalized = raw.trim().to_ascii_lowercase();
if CONVENTIONAL_TYPES.contains(&normalized.as_str()) {
Some(normalized)
} else {
None
}
}
fn sanitize_scope(raw: Option<&str>) -> Option<String> {
let raw = raw?.trim();
if raw.is_empty() {
return None;
}
let sanitized = raw
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/'))
.collect::<String>()
.to_ascii_lowercase();
if sanitized.is_empty() {
None
} else {
Some(sanitized)
}
}
fn sanitize_description(raw: &str) -> Option<String> {
let trimmed = raw.trim().trim_matches('"').trim();
if trimmed.is_empty() {
return None;
}
let normalized = trimmed.trim_end_matches('.').trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
fn sanitize_footer_value(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn interesting_file_stem(path: &str) -> Option<String> {
let stem = Path::new(path)
.file_stem()
.and_then(|value| value.to_str())
.map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase())?;
if stem.is_empty()
|| is_weak_focus_label(&stem)
|| matches!(
stem.as_str(),
"mod" | "lib" | "main" | "readme" | "index" | "commands" | "router"
)
{
None
} else {
Some(stem)
}
}
fn normalize_name_status(raw: &str) -> String {
let code = raw.chars().next().unwrap_or('M');
match code {
'A' => "added",
'D' => "deleted",
'R' => "renamed",
'C' => "copied",
'T' => "typechange",
'U' => "unmerged",
'M' => "modified",
_ => "modified",
}
.to_string()
}
fn normalize_display_path(raw: &str) -> String {
raw.trim().replace('\\', "/")
}
fn normalize_path_key(raw: &str) -> String {
let cleaned = raw.trim().replace(['{', '}'], "").replace('\\', "/");
if let Some((_, tail)) = cleaned.split_once("=>") {
tail.trim().to_string()
} else {
cleaned
}
}
fn repo_name(path: &Path) -> String {
path.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.trim().is_empty())
.unwrap_or("repository")
.to_string()
}
fn truncate_for_prompt(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
let truncated = text.chars().take(limit).collect::<String>();
format!(
"{}\n\n[diff truncated after {} characters]",
truncated, limit
)
}
async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
git_output_with_env(project_root, args, &[]).await
}
async fn git_output_streaming(project_root: &Path, args: &[&str]) -> Result<String, String> {
git_output_with_env_streaming(project_root, args, &[]).await
}
async fn git_has_staged_changes(project_root: &Path) -> Result<bool, String> {
let output = Command::new("git")
.current_dir(project_root)
.args(["diff", "--cached", "--quiet", "--exit-code"])
.output()
.await
.map_err(|e| {
format!(
"Failed to run `git diff --cached --quiet --exit-code`: {}",
e
)
})?;
match output.status.code() {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!(
"`git diff --cached --quiet --exit-code` failed with status {}",
output.status
))
} else {
Err(format!(
"`git diff --cached --quiet --exit-code` failed: {}",
stderr
))
}
}
}
}
async fn git_worktree_is_clean(project_root: &Path) -> Result<bool, String> {
let status = git_output(
project_root,
&["status", "--porcelain=v1", "--untracked-files=all"],
)
.await?;
Ok(parse_status_entries(&status).is_empty())
}
fn looks_like_nothing_to_commit_error(error: &str) -> bool {
let lowered = error.to_ascii_lowercase();
lowered.contains("nothing to commit")
|| lowered.contains("working tree clean")
|| lowered.contains("no changes added to commit")
}
async fn git_output_with_env(
project_root: &Path,
args: &[&str],
envs: &[(&str, &std::ffi::OsStr)],
) -> Result<String, String> {
let mut command = Command::new("git");
command.current_dir(project_root).args(args);
for (key, value) in envs {
command.env(key, value);
}
let display_args = format_git_args_for_error(args);
let output = command
.output()
.await
.map_err(|e| format!("Failed to run `git {}`: {}", display_args, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!(
"`git {}` failed with status {}",
display_args, output.status
));
}
return Err(format!("`git {}` failed: {}", display_args, stderr));
}
Ok(String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_string())
}
async fn git_output_with_env_streaming(
project_root: &Path,
args: &[&str],
envs: &[(&str, &std::ffi::OsStr)],
) -> Result<String, String> {
let mut command = Command::new("git");
command
.current_dir(project_root)
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
for (key, value) in envs {
command.env(key, value);
}
let display_args = format_git_args_for_error(args);
let mut child = command
.spawn()
.map_err(|e| format!("Failed to run `git {}`: {}", display_args, e))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| format!("Failed to capture stdout for `git {}`", display_args))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| format!("Failed to capture stderr for `git {}`", display_args))?;
let stdout_task = tokio::spawn(stream_child_output(stdout, false));
let stderr_task = tokio::spawn(stream_child_output(stderr, true));
let status = child
.wait()
.await
.map_err(|e| format!("Failed to wait for `git {}`: {}", display_args, e))?;
let stdout = stdout_task
.await
.map_err(|e| {
format!(
"Failed to join stdout task for `git {}`: {}",
display_args, e
)
})?
.map_err(|e| format!("Failed to read stdout for `git {}`: {}", display_args, e))?;
let stderr = stderr_task
.await
.map_err(|e| {
format!(
"Failed to join stderr task for `git {}`: {}",
display_args, e
)
})?
.map_err(|e| format!("Failed to read stderr for `git {}`: {}", display_args, e))?;
if !status.success() {
let stderr = String::from_utf8_lossy(&stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!(
"`git {}` failed with status {}",
display_args, status
));
}
return Err(format!("`git {}` failed: {}", display_args, stderr));
}
Ok(String::from_utf8_lossy(&stdout).trim_end().to_string())
}
async fn stream_child_output<R>(reader: R, stderr: bool) -> io::Result<Vec<u8>>
where
R: AsyncRead + Unpin,
{
let mut reader = BufReader::new(reader);
let mut collected = Vec::new();
let mut chunk = Vec::new();
loop {
chunk.clear();
let bytes_read = reader.read_until(b'\n', &mut chunk).await?;
if bytes_read == 0 {
break;
}
if stderr {
let mut handle = io::stderr().lock();
handle.write_all(&chunk)?;
handle.flush()?;
} else {
let mut handle = io::stdout().lock();
handle.write_all(&chunk)?;
handle.flush()?;
}
collected.extend_from_slice(&chunk);
}
Ok(collected)
}
struct TemporaryIndex {
path: PathBuf,
}
impl Drop for TemporaryIndex {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::{
build_commit_prompt, build_heuristic_body, build_heuristic_commit,
build_prompt_diff_excerpt, choose_stage_strategy, describe_focus_area,
format_colored_change_counts, format_colored_top_file_group, format_git_args_for_error,
infer_commit_type, infer_description, is_terminal_session_path,
looks_like_nothing_to_commit_error, parse_ai_commit_payload, parse_status_entries,
pathspecs_exceed_safe_argv_budget, render_clean_worktree_message, render_commit_message,
render_commit_message_preview, render_commit_subject, render_terminal_session_block_message,
sanitize_scope, stage_pathspecs, summarize_focus_areas, summarize_symbols,
summarize_top_files, terminal_session_paths, BranchSyncStatus, ConventionalCommit,
FileChangeSummary, RepoContext, StageStrategy, StatusEntry, TopFileGroup, WorktreeAnalysis,
SAFE_GIT_ARGV_CHAR_BUDGET, SAFE_GIT_PATHSPEC_COUNT,
};
use std::path::PathBuf;
fn server_refactor_analysis() -> WorktreeAnalysis {
WorktreeAnalysis {
repo_root: PathBuf::from("C:/repo"),
repo_name: "xbp".to_string(),
branch: Some("feature/refine-server".to_string()),
status_entries: vec![StatusEntry {
code: "M".to_string(),
path: "src/server.ts".to_string(),
}],
files: vec![
FileChangeSummary {
path: "src/server.ts".to_string(),
status: "modified".to_string(),
additions: 80,
deletions: 24,
},
FileChangeSummary {
path: "src/http-app.ts".to_string(),
status: "modified".to_string(),
additions: 42,
deletions: 11,
},
FileChangeSummary {
path: "src/worker/types.ts".to_string(),
status: "modified".to_string(),
additions: 12,
deletions: 3,
},
],
total_additions: 134,
total_deletions: 38,
diff_text: String::new(),
new_functions: vec![
"buildEnvFromProcess (src/server.ts)".to_string(),
"handleNodeRequest (src/server.ts)".to_string(),
],
changed_functions: vec!["handleHttpRequest (src/http-app.ts)".to_string()],
removed_functions: Vec::new(),
new_types: vec!["ExecutionContextLike (src/http-app.ts)".to_string()],
changed_types: vec!["Env (src/worker/types.ts)".to_string()],
removed_types: Vec::new(),
hunk_contexts: vec!["async function handleHttpRequest(request: Request)".to_string()],
}
}
#[test]
fn symbol_summary_tracks_new_and_changed_symbols() {
let diff = r#"
diff --git a/crates/cli/src/commands/commit.rs b/crates/cli/src/commands/commit.rs
index 1111111..2222222 100644
--- a/crates/cli/src/commands/commit.rs
+++ b/crates/cli/src/commands/commit.rs
@@ -0,0 +1,3 @@
+pub struct CommitArgs {
+}
+pub async fn run_commit() {}
@@ -10,1 +12,1 @@ pub async fn old_name() {}
-pub async fn old_name() {}
+pub async fn old_name() {}
"#;
let summary = summarize_symbols(diff);
assert!(summary
.new_functions
.iter()
.any(|item| item.contains("run_commit")));
assert!(summary
.new_types
.iter()
.any(|item| item.contains("CommitArgs")));
assert!(summary
.changed_functions
.iter()
.any(|item| item.contains("old_name")));
}
#[test]
fn ai_payload_respects_forced_scope() {
let raw = r#"{
"type": "feat",
"scope": "wrong",
"description": "add commit command",
"body": ["Summarize the worktree."],
"breaking_change": false,
"footers": []
}"#;
let commit = parse_ai_commit_payload(raw, Some("cli")).expect("parse");
assert_eq!(commit.scope.as_deref(), Some("cli"));
assert_eq!(commit.commit_type, "feat");
}
#[test]
fn renders_breaking_change_footer_once() {
let rendered = render_commit_message(&ConventionalCommit {
commit_type: "feat".to_string(),
scope: Some("cli".to_string()),
description: "add commit command".to_string(),
body: vec!["Summarize the worktree.".to_string()],
breaking_change: Some("existing automation must call xbp commit".to_string()),
footers: Vec::new(),
});
assert!(rendered.contains("feat(cli)!: add commit command"));
assert!(rendered.contains("BREAKING CHANGE: existing automation must call xbp commit"));
}
#[test]
fn plain_commit_renderers_stay_uncolored() {
let commit = ConventionalCommit {
commit_type: "fix".to_string(),
scope: Some("cli".to_string()),
description: "tighten summary output".to_string(),
body: vec!["Keep the git payload plain.".to_string()],
breaking_change: None,
footers: vec!["Reviewed-by: ops".to_string()],
};
let subject = render_commit_subject(&commit);
let message = render_commit_message(&commit);
assert_eq!(subject, "fix(cli): tighten summary output");
assert!(!subject.contains('\u{1b}'));
assert!(!message.contains('\u{1b}'));
}
#[test]
fn preview_subject_colors_type_and_scope_independently() {
let commit = ConventionalCommit {
commit_type: "feat".to_string(),
scope: Some("docs".to_string()),
description: "refresh operator docs".to_string(),
body: Vec::new(),
breaking_change: None,
footers: Vec::new(),
};
colored::control::set_override(true);
let preview = render_commit_message_preview(&commit);
colored::control::set_override(false);
assert!(preview.contains('\u{1b}'));
assert!(preview.contains("feat"));
assert!(preview.contains("docs"));
assert!(preview.contains("refresh operator docs"));
}
#[test]
fn diff_summary_colors_additions_and_deletions_independently() {
colored::control::set_override(true);
let summary = format_colored_change_counts(29_948, 2_740);
colored::control::set_override(false);
assert!(summary.contains('\u{1b}'));
assert!(summary.contains("29948"));
assert!(summary.contains("2740"));
}
#[test]
fn top_file_groups_color_nested_change_counts() {
colored::control::set_override(true);
let group = TopFileGroup {
label: "apps/docs/generated/".to_string(),
file_count: 3,
additions: 22_125,
deletions: 0,
};
let summary = format_colored_top_file_group(&group);
colored::control::set_override(false);
assert!(summary.contains("apps/docs/generated/"));
assert!(summary.contains("files"));
assert!(summary.contains('\u{1b}'));
assert!(summary.contains("22125"));
}
#[test]
fn infers_feat_for_new_symbols() {
let analysis = WorktreeAnalysis {
repo_root: PathBuf::from("C:/repo"),
repo_name: "xbp".to_string(),
branch: Some("main".to_string()),
status_entries: vec![StatusEntry {
code: "??".to_string(),
path: "crates/cli/src/commands/commit.rs".to_string(),
}],
files: vec![FileChangeSummary {
path: "crates/cli/src/commands/commit.rs".to_string(),
status: "added".to_string(),
additions: 120,
deletions: 0,
}],
total_additions: 120,
total_deletions: 0,
diff_text: String::new(),
new_functions: vec!["run_commit (crates/cli/src/commands/commit.rs)".to_string()],
changed_functions: Vec::new(),
removed_functions: Vec::new(),
new_types: vec!["CommitArgs (crates/cli/src/commands/commit.rs)".to_string()],
changed_types: Vec::new(),
removed_types: Vec::new(),
hunk_contexts: Vec::new(),
};
assert_eq!(infer_commit_type(&analysis), "feat");
}
#[test]
fn infers_refactor_for_new_symbols_inside_existing_modules() {
let analysis = server_refactor_analysis();
assert_eq!(infer_commit_type(&analysis), "refactor");
}
#[test]
fn description_prefers_focus_area_and_behavior_hint() {
let analysis = server_refactor_analysis();
assert_eq!(
infer_description(&analysis, "refactor", None),
"refine server request handling"
);
}
#[test]
fn heuristic_body_reads_like_a_summary() {
let analysis = server_refactor_analysis();
let body = build_heuristic_body(&analysis);
assert_eq!(body.len(), 2);
assert!(body[0].contains("server"));
assert!(body[1].contains("adds buildEnvFromProcess"));
assert!(body[1].contains("src/ (2 files"));
}
#[test]
fn prompt_omits_lockfile_diff_body_but_keeps_structured_summary() {
let diff = r#"
diff --git a/package.json b/package.json
index b25b17db..d30f02ee 100644
--- a/package.json
+++ b/package.json
@@ -86 +86,2 @@
- "yaml": "^2.9.0"
+ "yaml": "^2.9.0",
+ "nx": "23.0.1"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4916450e..758d7f5e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -998,0 +1005,6 @@ packages:
+ '@emnapi/runtime@1.4.5':
+ resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==}
+ open@8.4.2:
+ resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
"#;
let symbols = summarize_symbols(diff);
let analysis = WorktreeAnalysis {
repo_root: PathBuf::from("C:/repo"),
repo_name: "speedrun-formations".to_string(),
branch: Some("main".to_string()),
status_entries: vec![
StatusEntry {
code: "M".to_string(),
path: "package.json".to_string(),
},
StatusEntry {
code: "M".to_string(),
path: "pnpm-lock.yaml".to_string(),
},
],
files: vec![
FileChangeSummary {
path: "package.json".to_string(),
status: "modified".to_string(),
additions: 4,
deletions: 2,
},
FileChangeSummary {
path: "pnpm-lock.yaml".to_string(),
status: "modified".to_string(),
additions: 539,
deletions: 17,
},
],
total_additions: 543,
total_deletions: 19,
diff_text: diff.to_string(),
new_functions: symbols.new_functions,
changed_functions: symbols.changed_functions,
removed_functions: symbols.removed_functions,
new_types: symbols.new_types,
changed_types: symbols.changed_types,
removed_types: symbols.removed_types,
hunk_contexts: symbols.hunk_contexts,
};
let heuristic = build_heuristic_commit(&analysis, None);
let prompt = build_commit_prompt(&analysis, None, &heuristic);
assert!(prompt.contains("pnpm-lock.yaml (+539 -17; lockfile)"));
assert!(prompt.contains("\"nx\": \"23.0.1\""));
assert!(prompt.contains("package entries added: @emnapi/runtime@1.4.5"));
assert!(!prompt.contains("sha512-++LApOtY0pEEz1zrd9vy1"));
assert!(!prompt.contains("packages:"));
}
#[test]
fn diff_excerpt_reports_when_all_records_are_low_signal() {
let diff = r#"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4916450e..758d7f5e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -998,0 +1005,2 @@ packages:
+ open@8.4.2:
+ resolution: {integrity: sha512-7x81NCL719oNbsq}
"#;
let analysis = WorktreeAnalysis {
repo_root: PathBuf::from("C:/repo"),
repo_name: "speedrun-formations".to_string(),
branch: Some("main".to_string()),
status_entries: Vec::new(),
files: vec![FileChangeSummary {
path: "pnpm-lock.yaml".to_string(),
status: "modified".to_string(),
additions: 539,
deletions: 17,
}],
total_additions: 539,
total_deletions: 17,
diff_text: diff.to_string(),
new_functions: Vec::new(),
changed_functions: Vec::new(),
removed_functions: Vec::new(),
new_types: Vec::new(),
changed_types: Vec::new(),
removed_types: Vec::new(),
hunk_contexts: Vec::new(),
};
let excerpt = build_prompt_diff_excerpt(&analysis);
assert!(excerpt.excerpt.trim().is_empty());
assert_eq!(
excerpt.omitted_files,
vec!["pnpm-lock.yaml (+539 -17; lockfile)".to_string()]
);
assert_eq!(
excerpt.summaries,
vec![
"pnpm-lock.yaml (+539 -17; lockfile)".to_string(),
"package entries added: open@8.4.2".to_string()
]
);
}
#[test]
fn lockfile_preprocessing_summarizes_repeated_package_churn() {
let diff = r#"
diff --git a/services/athena-auth/Cargo.lock b/services/athena-auth/Cargo.lock
index 1111111..2222222 100644
--- a/services/athena-auth/Cargo.lock
+++ b/services/athena-auth/Cargo.lock
@@ -10,5 +10,0 @@ name = "aead"
-name = "aead"
-version = "0.5.2"
@@ -20,0 +20,5 @@ name = "aes"
+name = "aes"
+version = "0.8.4"
diff --git a/apps/docs/pnpm-lock.yaml b/apps/docs/pnpm-lock.yaml
index f96b24fc..c3b8250d 100644
--- a/apps/docs/pnpm-lock.yaml
+++ b/apps/docs/pnpm-lock.yaml
@@ -21,2 +21,2 @@ importers:
- specifier: 16.6.5
- version: 16.6.5(@types/react@19.2.10)(next@16.1.5(react@19.2.4))
+ specifier: 16.7.13
+ version: 16.7.13(@types/react@19.2.10)(next@16.1.5(react@19.2.4))
@@ -3113,2 +3140,2 @@ packages:
- fumadocs-core@16.6.5:
- resolution: {integrity: sha512-old}
+ fumadocs-core@16.7.13:
+ resolution: {integrity: sha512-new}
"#;
let analysis = WorktreeAnalysis {
repo_root: PathBuf::from("C:/repo"),
repo_name: "athena".to_string(),
branch: Some("main".to_string()),
status_entries: Vec::new(),
files: vec![
FileChangeSummary {
path: "services/athena-auth/Cargo.lock".to_string(),
status: "modified".to_string(),
additions: 3403,
deletions: 1366,
},
FileChangeSummary {
path: "apps/docs/pnpm-lock.yaml".to_string(),
status: "modified".to_string(),
additions: 131,
deletions: 53,
},
],
total_additions: 3534,
total_deletions: 1419,
diff_text: diff.to_string(),
new_functions: Vec::new(),
changed_functions: Vec::new(),
removed_functions: Vec::new(),
new_types: Vec::new(),
changed_types: Vec::new(),
removed_types: Vec::new(),
hunk_contexts: summarize_symbols(diff).hunk_contexts,
};
let prompt = build_commit_prompt(&analysis, None, &build_heuristic_commit(&analysis, None));
assert!(prompt.contains("services/athena-auth/Cargo.lock (+3403 -1366; lockfile)"));
assert!(prompt.contains("apps/docs/pnpm-lock.yaml (+131 -53; lockfile)"));
assert!(prompt.contains("cargo packages added: aes@0.8.4"));
assert!(prompt.contains("cargo packages removed: aead@0.5.2"));
assert!(prompt.contains("version shifts: 16.6.5 -> 16.7.13"));
assert!(prompt.contains("package entries added: fumadocs-core@16.7.13"));
assert!(!prompt.contains("sha512-old"));
assert!(!prompt.contains("name = \"aead\""));
assert!(!prompt.contains("hunk contexts:"));
}
#[test]
fn retained_diff_preprocessing_compacts_repeated_long_lines() {
let repeated = format!(
"+ version: 10.3.11({})(tailwindcss@4.1.18)",
"@types/react@19.2.10".repeat(12)
);
let diff = format!(
"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",
repeated
);
let analysis = WorktreeAnalysis {
repo_root: PathBuf::from("C:/repo"),
repo_name: "speedrun-formations".to_string(),
branch: Some("main".to_string()),
status_entries: Vec::new(),
files: vec![FileChangeSummary {
path: "package.json".to_string(),
status: "modified".to_string(),
additions: 3,
deletions: 0,
}],
total_additions: 3,
total_deletions: 0,
diff_text: diff,
new_functions: Vec::new(),
changed_functions: Vec::new(),
removed_functions: Vec::new(),
new_types: Vec::new(),
changed_types: Vec::new(),
removed_types: Vec::new(),
hunk_contexts: Vec::new(),
};
let excerpt = build_prompt_diff_excerpt(&analysis).excerpt;
assert!(excerpt.contains("(...peer deps omitted)"));
assert!(excerpt.contains("duplicate long diff line(s) omitted"));
assert!(excerpt.contains("\"nx\": \"23.0.1\""));
}
#[test]
fn clean_worktree_message_calls_out_pending_pushes() {
let repo = RepoContext {
repo_root: PathBuf::from("C:/repo"),
repo_name: "xbp".to_string(),
branch: Some("main".to_string()),
};
let message = render_clean_worktree_message(
&repo,
&BranchSyncStatus {
upstream: Some("origin/main".to_string()),
ahead: 2,
behind: 0,
},
);
assert!(message.contains("Nothing to commit."));
assert!(message.contains("2 commit(s) waiting to push"));
assert!(message.contains("origin/main"));
}
#[test]
fn clean_worktree_message_calls_out_synced_branch() {
let repo = RepoContext {
repo_root: PathBuf::from("C:/repo"),
repo_name: "xbp".to_string(),
branch: Some("main".to_string()),
};
let message = render_clean_worktree_message(
&repo,
&BranchSyncStatus {
upstream: Some("origin/main".to_string()),
ahead: 0,
behind: 0,
},
);
assert!(message.contains("Nothing to commit."));
assert!(message.contains("already in sync"));
}
#[test]
fn detects_nothing_to_commit_git_errors() {
assert!(looks_like_nothing_to_commit_error(
"`git commit --file msg.txt` failed: nothing to commit, working tree clean"
));
assert!(looks_like_nothing_to_commit_error(
"`git commit --file msg.txt` failed: no changes added to commit"
));
assert!(!looks_like_nothing_to_commit_error(
"`git commit --file msg.txt` failed: pre-commit hook exited with code 1"
));
}
#[test]
fn stage_pathspecs_use_changed_paths_and_fall_back_for_renames() {
let paths = stage_pathspecs(&[
StatusEntry {
code: "M".to_string(),
path: "src/lib.rs".to_string(),
},
StatusEntry {
code: "??".to_string(),
path: "docs/new.md".to_string(),
},
StatusEntry {
code: "M".to_string(),
path: "src/lib.rs".to_string(),
},
]);
assert_eq!(
paths,
vec!["docs/new.md".to_string(), "src/lib.rs".to_string()]
);
let rename_paths = stage_pathspecs(&[StatusEntry {
code: "R".to_string(),
path: "old.rs -> new.rs".to_string(),
}]);
assert!(rename_paths.is_empty());
}
#[test]
fn large_worktree_pathspecs_use_bulk_add_to_avoid_windows_argv_truncation() {
let long_paths: Vec<String> = (0..437)
.map(|i| {
format!(
"foundry/instagram-downloads/100500818017217/messages/audio_attachments/{:04}.mp3",
i
)
})
.collect();
assert!(pathspecs_exceed_safe_argv_budget(&long_paths));
assert!(long_paths.len() > SAFE_GIT_PATHSPEC_COUNT);
let strategy = choose_stage_strategy(&long_paths, &[]);
assert_eq!(
strategy,
StageStrategy::BulkAddAll {
path_count: long_paths.len()
}
);
let small = vec!["src/lib.rs".to_string(), "README.md".to_string()];
assert!(!pathspecs_exceed_safe_argv_budget(&small));
assert_eq!(
choose_stage_strategy(&small, &[]),
StageStrategy::BulkAddAll { path_count: 2 }
);
assert_eq!(
choose_stage_strategy(&small, &["terminals/5.txt".to_string()]),
StageStrategy::BulkAddAll { path_count: 2 }
);
assert_eq!(
choose_stage_strategy(&[], &[]),
StageStrategy::BulkAddAll { path_count: 0 }
);
let almost_budget = vec!["a".repeat(SAFE_GIT_ARGV_CHAR_BUDGET)];
assert!(pathspecs_exceed_safe_argv_budget(&almost_budget));
}
#[test]
fn porcelain_status_preserves_dot_prefixed_paths_and_leading_status_space() {
let raw =
" M .xbp/releases/service-dexter-sentinel/1.4.0.yaml\n?? src/bin/dexter-import.rs\n";
let entries = parse_status_entries(raw);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].code, "M");
assert_eq!(
entries[0].path,
".xbp/releases/service-dexter-sentinel/1.4.0.yaml"
);
assert_eq!(entries[1].code, "??");
assert_eq!(entries[1].path, "src/bin/dexter-import.rs");
let corrupted = raw.trim();
let broken = parse_status_entries(corrupted);
assert_eq!(
broken[0].path, "xbp/releases/service-dexter-sentinel/1.4.0.yaml",
"documenting the failure mode that full trim() produces"
);
}
#[test]
fn git_error_args_are_truncated_for_readable_failures() {
let many: Vec<String> = (0..80).map(|i| format!("path/to/file-{i}.bin")).collect();
let refs: Vec<&str> = many.iter().map(String::as_str).collect();
let mut args = vec!["add", "--all", "--"];
args.extend(refs.iter().copied());
let display = format_git_args_for_error(&args);
assert!(display.contains("truncated for display"));
assert!(display.len() < 400);
assert!(!display.contains("path/to/file-79.bin") || display.contains("…"));
}
#[tokio::test]
async fn stage_commit_paths_bulk_add_respects_gitignore_and_excludes() {
let root =
std::env::temp_dir().join(format!("xbp-commit-stage-test-{}", uuid::Uuid::new_v4()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("temp root");
let git = |args: &[&str]| {
std::process::Command::new("git")
.current_dir(&root)
.args(args)
.output()
.expect("git")
};
assert!(git(&["init"]).status.success());
assert!(git(&["config", "user.email", "test@example.com"])
.status
.success());
assert!(git(&["config", "user.name", "Test"]).status.success());
let _ = git(&["checkout", "-b", "main"]);
std::fs::write(root.join(".gitignore"), "ignored-dir/\n*.tmp\n").unwrap();
std::fs::write(root.join("keep.rs"), "fn main() {}\n").unwrap();
std::fs::create_dir_all(root.join("ignored-dir")).unwrap();
std::fs::write(root.join("ignored-dir/secret.bin"), "nope").unwrap();
std::fs::write(root.join("noise.tmp"), "tmp").unwrap();
std::fs::create_dir_all(root.join("terminals")).unwrap();
std::fs::write(root.join("terminals/session.txt"), "session").unwrap();
assert!(git(&["add", ".gitignore", "keep.rs"]).status.success());
assert!(git(&["commit", "-m", "init"]).status.success());
std::fs::write(root.join("keep.rs"), "fn main() { /* changed */ }\n").unwrap();
std::fs::write(root.join("feature.rs"), "pub fn f() {}\n").unwrap();
std::fs::write(root.join("ignored-dir/secret.bin"), "still ignored").unwrap();
std::fs::write(root.join("noise.tmp"), "still tmp").unwrap();
std::fs::write(root.join("terminals/session.txt"), "session v2").unwrap();
let status_entries = vec![
StatusEntry {
code: " M".to_string(),
path: "keep.rs".to_string(),
},
StatusEntry {
code: "??".to_string(),
path: "feature.rs".to_string(),
},
StatusEntry {
code: "??".to_string(),
path: "terminals/session.txt".to_string(),
},
];
let mut bloated_status = status_entries.clone();
for i in 0..100 {
bloated_status.push(StatusEntry {
code: "??".to_string(),
path: format!(
"foundry/instagram-downloads/user/messages/audio_attachments/{:04}.mp3",
i
),
});
}
super::stage_commit_paths(
&root,
&bloated_status,
&["terminals/session.txt".to_string()],
)
.await
.expect("stage should succeed without pathspec truncation");
let cached = git(&["diff", "--cached", "--name-only"]);
assert!(cached.status.success());
let staged = String::from_utf8_lossy(&cached.stdout).replace('\\', "/");
assert!(
staged.contains("keep.rs"),
"expected keep.rs staged, got:\n{}",
staged
);
assert!(
staged.contains("feature.rs"),
"expected feature.rs staged, got:\n{}",
staged
);
assert!(
!staged.contains("ignored-dir"),
"gitignore paths must not be staged:\n{}",
staged
);
assert!(
!staged.contains("noise.tmp"),
"*.tmp gitignore must not be staged:\n{}",
staged
);
assert!(
!staged.contains("terminals/session.txt"),
"excluded terminal path must stay unstaged:\n{}",
staged
);
let filtered = super::filter_gitignored_status_entries(
&root,
vec![
StatusEntry {
code: "??".to_string(),
path: "feature.rs".to_string(),
},
StatusEntry {
code: "??".to_string(),
path: "noise.tmp".to_string(),
},
StatusEntry {
code: "??".to_string(),
path: "ignored-dir/secret.bin".to_string(),
},
StatusEntry {
code: " M".to_string(),
path: "keep.rs".to_string(),
},
],
)
.await
.expect("filter");
let filtered_paths: Vec<&str> = filtered.iter().map(|e| e.path.as_str()).collect();
assert_eq!(filtered_paths, vec!["feature.rs", "keep.rs"]);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn scope_sanitizer_keeps_valid_tokens() {
assert_eq!(
sanitize_scope(Some("CLI/tools")),
Some("cli/tools".to_string())
);
assert_eq!(sanitize_scope(Some(" ")), None);
}
#[test]
fn terminal_session_paths_are_detected_and_blocked() {
let analysis = WorktreeAnalysis {
repo_root: PathBuf::from("C:/repo"),
repo_name: "xbp".to_string(),
branch: Some("main".to_string()),
status_entries: Vec::new(),
files: vec![
FileChangeSummary {
path: "terminals/5.txt".to_string(),
status: "added".to_string(),
additions: 388_613,
deletions: 0,
},
FileChangeSummary {
path: "terminals/.next-id".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
},
],
total_additions: 388_614,
total_deletions: 1,
diff_text: String::new(),
new_functions: Vec::new(),
changed_functions: Vec::new(),
removed_functions: Vec::new(),
new_types: Vec::new(),
changed_types: Vec::new(),
removed_types: Vec::new(),
hunk_contexts: Vec::new(),
};
assert!(is_terminal_session_path("terminals/5.txt"));
assert!(!is_terminal_session_path(
"crates/cli/src/commands/commit.rs"
));
assert_eq!(
terminal_session_paths(&analysis),
vec![
"terminals/5.txt".to_string(),
"terminals/.next-id".to_string()
]
);
assert!(
render_terminal_session_block_message(&terminal_session_paths(&analysis))
.contains("Refusing to commit IDE terminal session dumps")
);
assert_eq!(infer_commit_type(&analysis), "chore");
assert_eq!(
infer_description(&analysis, "chore", None),
"ignore ide terminal session logs"
);
let heuristic = build_heuristic_commit(&analysis, None);
assert_eq!(heuristic.commit_type, "chore");
assert_eq!(heuristic.description, "ignore ide terminal session logs");
assert!(!render_commit_message(&heuristic).contains("flow"));
}
#[test]
fn focus_area_groups_terminal_session_files() {
assert_eq!(
describe_focus_area("terminals/5.txt"),
Some("terminals".to_string())
);
assert_eq!(
describe_focus_area("terminals/.next-id"),
Some("terminals".to_string())
);
assert_eq!(
describe_focus_area(".gitignore"),
Some("gitignore".to_string())
);
}
#[test]
fn summarize_focus_areas_collapses_terminal_logs() {
let analysis = WorktreeAnalysis {
repo_root: PathBuf::from("C:/repo"),
repo_name: "xbp".to_string(),
branch: Some("main".to_string()),
status_entries: Vec::new(),
files: vec![
FileChangeSummary {
path: "terminals/5.txt".to_string(),
status: "deleted".to_string(),
additions: 0,
deletions: 388_613,
},
FileChangeSummary {
path: "terminals/3.txt".to_string(),
status: "deleted".to_string(),
additions: 0,
deletions: 439,
},
FileChangeSummary {
path: "terminals/.next-id".to_string(),
status: "modified".to_string(),
additions: 0,
deletions: 1,
},
FileChangeSummary {
path: ".gitignore".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
},
],
total_additions: 1,
total_deletions: 389_053,
diff_text: String::new(),
new_functions: Vec::new(),
changed_functions: Vec::new(),
removed_functions: Vec::new(),
new_types: Vec::new(),
changed_types: Vec::new(),
removed_types: Vec::new(),
hunk_contexts: Vec::new(),
};
assert_eq!(
summarize_focus_areas(&analysis, 4),
vec!["terminals".to_string(), "gitignore".to_string()]
);
assert_eq!(
summarize_top_files(&analysis, 4),
vec![
"terminals/ (3 files, +0 -389053)".to_string(),
".gitignore (+1 -0)".to_string(),
]
);
}
}