use crate::cli::auto_commit::{print_push_summary, push_current_branch};
use crate::commands::service::load_xbp_config_with_root;
use crate::config::resolve_openrouter_api_key;
use crate::openrouter::{complete_user_prompt, DEFAULT_MODEL};
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::path::{Path, PathBuf};
use tokio::process::Command;
use uuid::Uuid;
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 AI_DIFF_CHAR_LIMIT: usize = 12_000;
const MAX_PROMPT_FILES: usize = 40;
const MAX_PROMPT_SYMBOLS: usize = 12;
const MAX_PROMPT_CONTEXTS: usize = 12;
const MAX_PRINT_SYMBOLS: usize = 8;
const CONVENTIONAL_TYPES: &[&str] = &[
"feat", "fix", "docs", "refactor", "chore", "test", "build", "ci", "perf", "style", "revert",
];
#[derive(Debug, Clone)]
pub struct CommitArgs {
pub dry_run: bool,
pub no_ai: bool,
pub model: String,
pub scope: Option<String>,
}
#[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>,
}
#[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<(), String> {
if !command_exists("git") {
return Err("Git is not installed on this machine.".to_string());
}
let invocation_dir =
env::current_dir().map_err(|e| format!("Failed to read current directory: {}", e))?;
let analysis = analyze_worktree(&invocation_dir).await?;
let auto_push_after_commit = resolve_auto_push_on_commit().await;
let commit_plan = generate_commit_plan(&analysis, &args).await;
let rendered_message = render_commit_message(&commit_plan.commit);
print_analysis_summary(&analysis, &commit_plan, &rendered_message);
if args.dry_run {
println!(
"\n{}",
"Dry run only. No git commit was created."
.bright_yellow()
.bold()
);
return Ok(());
}
stage_and_commit(&analysis.repo_root, &rendered_message).await?;
let short_sha = git_output(&analysis.repo_root, &["rev-parse", "--short", "HEAD"]).await?;
let full_sha = git_output(&analysis.repo_root, &["rev-parse", "HEAD"]).await?;
println!(
"\n{} {} {}",
"Committed".bright_green().bold(),
short_sha.bright_white().bold(),
format!("({})", full_sha).dimmed()
);
if auto_push_after_commit {
match push_current_branch(&analysis.repo_root).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(format!(
"Created local commit {} but failed to push it: {}",
short_sha, error
));
}
}
} else {
println!(
"{}",
"Auto-push disabled by xbp config (`github.auto_push_on_commit: false`)."
.bright_yellow()
);
}
Ok(())
}
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 analyze_worktree(invocation_dir: &Path) -> Result<WorktreeAnalysis, 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_root
.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.trim().is_empty())
.unwrap_or("repository")
.to_string();
let branch = git_output(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
.await
.ok()
.filter(|value| !value.is_empty() && value != "HEAD");
let status_output = git_output(
&repo_root,
&["status", "--porcelain=v1", "--untracked-files=all"],
)
.await?;
let status_entries = parse_status_entries(&status_output);
if status_entries.is_empty() {
return Err("No worktree changes were found to commit.".to_string());
}
let temp_index = prepare_temporary_index(&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_root, &["add", "--all"], &git_env).await?;
let name_status_output = git_output_with_env(
&repo_root,
&["diff", "--cached", "--name-status", "--find-renames"],
&git_env,
)
.await?;
let numstat_output = git_output_with_env(
&repo_root,
&["diff", "--cached", "--numstat", "--find-renames"],
&git_env,
)
.await?;
let diff_text = git_output_with_env(
&repo_root,
&[
"diff",
"--cached",
"--unified=0",
"--no-color",
"--no-ext-diff",
"--find-renames",
],
&git_env,
)
.await?;
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_name,
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 = if args.model.trim().is_empty() {
DEFAULT_MODEL
} else {
args.model.trim()
};
let ai_message = complete_user_prompt(&api_key, model, &prompt, Some("XBP Commit Generator"))
.await
.and_then(|raw| parse_ai_commit_payload(&raw, forced_scope.as_deref()));
if let Some(commit) = ai_message {
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 mut lines = Vec::new();
lines.push("You are generating one git commit message for the current worktree.".to_string());
lines.push(String::new());
lines.push("Return strict JSON only. No markdown, no code fences, no explanation.".to_string());
lines.push(String::new());
lines.push("Schema:".to_string());
lines.push("{".to_string());
lines.push(
r#" "type": "feat|fix|docs|refactor|chore|test|build|ci|perf|style|revert","#.to_string(),
);
lines.push(r#" "scope": "short lowercase scope or null","#.to_string());
lines.push(
r#" "description": "imperative summary under 72 chars, no trailing period","#.to_string(),
);
lines.push(r#" "body": ["optional paragraph", "optional paragraph"],"#.to_string());
lines.push(r#" "breaking_change": false,"#.to_string());
lines.push(
r#" "breaking_description": "required only when breaking_change=true","#.to_string(),
);
lines.push(r#" "footers": ["optional footer line"]"#.to_string());
lines.push("}".to_string());
lines.push(String::new());
lines.push("Rules:".to_string());
lines.push("- Follow Conventional Commits 1.0.0 exactly.".to_string());
lines.push("- Prefer feat for new capabilities, fix for bug repair, docs for docs-only changes, refactor for internal restructuring without behavior change, and ci/build/test/perf/style/chore when clearly better.".to_string());
lines.push("- Use a scope only when it is clearly anchored in the codebase.".to_string());
lines.push(
"- The description must be specific to the changed behavior or module, not generic."
.to_string(),
);
lines.push("- Mention a breaking change only when the diff clearly changes a public or operator-facing contract.".to_string());
lines.push("- Use the file list, symbol list, and diff excerpt. Do not invent files, functions, types, or breaking changes.".to_string());
lines.push(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)
));
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 !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(", ")
));
}
lines.push(String::new());
lines.push("Diff excerpt:".to_string());
lines.push(truncate_for_prompt(&analysis.diff_text, AI_DIFF_CHAR_LIMIT));
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<_>>();
let docs_only = !lowered_paths.is_empty()
&& lowered_paths.iter().all(|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();
if has_new_files || has_new_symbols {
return "feat";
}
if !analysis.changed_functions.is_empty()
|| !analysis.changed_types.is_empty()
|| 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 {
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(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 mut paragraphs = Vec::new();
paragraphs.push(format!(
"Touches {} file{} with +{} and -{} lines across the current worktree.",
analysis.files.len(),
if analysis.files.len() == 1 { "" } else { "s" },
analysis.total_additions,
analysis.total_deletions
));
let mut detail_parts = Vec::new();
if !analysis.new_functions.is_empty() {
detail_parts.push(format!(
"new functions: {}",
analysis
.new_functions
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !analysis.changed_functions.is_empty() {
detail_parts.push(format!(
"changed functions: {}",
analysis
.changed_functions
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !analysis.removed_functions.is_empty() {
detail_parts.push(format!(
"removed functions: {}",
analysis
.removed_functions
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !analysis.new_types.is_empty() {
detail_parts.push(format!(
"new types: {}",
analysis
.new_types
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !analysis.changed_types.is_empty() {
detail_parts.push(format!(
"changed types: {}",
analysis
.changed_types
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !analysis.removed_types.is_empty() {
detail_parts.push(format!(
"removed types: {}",
analysis
.removed_types
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if !detail_parts.is_empty() {
paragraphs.push(detail_parts.join("; "));
}
paragraphs
}
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 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!(
" {} {} file{} (+{} -{})",
"Diff".bright_cyan().bold(),
analysis.files.len(),
if analysis.files.len() == 1 { "" } else { "s" },
analysis.total_additions,
analysis.total_deletions
);
if !analysis.new_functions.is_empty() {
println!(
" {} {}",
"New fn".bright_cyan().bold(),
analysis
.new_functions
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
if !analysis.changed_functions.is_empty() {
println!(
" {} {}",
"Changed fn".bright_cyan().bold(),
analysis
.changed_functions
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
if !analysis.new_types.is_empty() {
println!(
" {} {}",
"New types".bright_cyan().bold(),
analysis
.new_types
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
if !analysis.changed_types.is_empty() {
println!(
" {} {}",
"Changed types".bright_cyan().bold(),
analysis
.changed_types
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
if !analysis.removed_functions.is_empty() {
println!(
" {} {}",
"Removed fn".bright_cyan().bold(),
analysis
.removed_functions
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
if !analysis.removed_types.is_empty() {
println!(
" {} {}",
"Removed types".bright_cyan().bold(),
analysis
.removed_types
.iter()
.take(MAX_PRINT_SYMBOLS)
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
println!("\n{}", "Generated message".bright_magenta().bold());
println!("{}", "─".repeat(72).bright_black());
println!("{}", rendered_message);
println!("{}", "─".repeat(72).bright_black());
}
async fn stage_and_commit(repo_root: &Path, message: &str) -> Result<(), String> {
git_output(repo_root, &["add", "--all"]).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
)
})?;
let result = git_output(
repo_root,
&["commit", "--file", &message_path.to_string_lossy()],
)
.await;
let _ = fs::remove_file(&message_path);
result.map(|_| ())
}
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('\\', "/");
if path.is_empty() {
None
} else {
Some(StatusEntry { code, path })
}
})
.collect()
}
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);
}
}
}
}
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 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().to_ascii_lowercase())?;
if stem.is_empty()
|| 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 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_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 output = command
.output()
.await
.map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), 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 {}",
args.join(" "),
output.status
));
}
return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
struct TemporaryIndex {
path: PathBuf,
}
impl Drop for TemporaryIndex {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::{
infer_commit_type, parse_ai_commit_payload, render_commit_message, sanitize_scope,
summarize_symbols, ConventionalCommit, FileChangeSummary, StatusEntry, WorktreeAnalysis,
};
use std::path::PathBuf;
#[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 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 scope_sanitizer_keeps_valid_tokens() {
assert_eq!(
sanitize_scope(Some("CLI/tools")),
Some("cli/tools".to_string())
);
assert_eq!(sanitize_scope(Some(" ")), None);
}
}