use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
use std::collections::BTreeSet;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use crate::config::SshConfig;
use crate::utils::command_exists;
pub struct AutoCommitRequest<'a> {
pub project_root: &'a Path,
pub paths: Vec<PathBuf>,
pub message: String,
pub action_label: &'a str,
}
pub enum AutoCommitResult {
Committed(AutoCommitOutcome),
Skipped(String),
}
pub struct AutoCommitOutcome {
pub repo_name: String,
pub repo_root: PathBuf,
pub branch: Option<String>,
pub commit_sha: String,
pub short_sha: String,
pub message: String,
pub committed_files: Vec<String>,
}
pub struct PushOutcome {
pub branch: String,
}
pub async fn commit_paths(request: AutoCommitRequest<'_>) -> Result<AutoCommitResult, String> {
if !command_exists("git") {
return Ok(AutoCommitResult::Skipped(
"Git is not installed on this machine.".to_string(),
));
}
let repo_root = match git_output(request.project_root, &["rev-parse", "--show-toplevel"]).await
{
Ok(root) => PathBuf::from(root),
Err(_) => {
return Ok(AutoCommitResult::Skipped(
"Current project is not inside a git repository.".to_string(),
));
}
};
let normalized_paths = normalize_commit_paths(request.project_root, &request.paths);
if normalized_paths.is_empty() {
return Ok(AutoCommitResult::Skipped(
"No generated or updated files were provided for auto-commit.".to_string(),
));
}
let mut add_args = vec!["add".to_string(), "--all".to_string(), "--".to_string()];
add_args.extend(normalized_paths.iter().cloned());
git_output_owned(request.project_root, add_args).await?;
let mut diff_args = vec![
"diff".to_string(),
"--cached".to_string(),
"--name-only".to_string(),
"--".to_string(),
];
diff_args.extend(normalized_paths.iter().cloned());
let committed_files = git_output_owned(request.project_root, diff_args)
.await?
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| line.replace('\\', "/"))
.collect::<Vec<_>>();
if committed_files.is_empty() {
return Ok(AutoCommitResult::Skipped(
"Target files did not produce any staged git diff.".to_string(),
));
}
ensure_git_commit_identity(request.project_root).await?;
if let Err(error) =
commit_with_optional_hook_retry(request.project_root, &request.message).await
{
return Err(format_git_commit_failure(request.project_root, &error).await);
}
let commit_sha = git_output(request.project_root, &["rev-parse", "HEAD"]).await?;
let short_sha = git_output(request.project_root, &["rev-parse", "--short", "HEAD"]).await?;
let branch = git_output(request.project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
.await
.ok()
.filter(|value| !value.is_empty() && value != "HEAD");
let outcome = AutoCommitOutcome {
repo_name: repo_name(&repo_root),
repo_root,
branch,
commit_sha,
short_sha,
message: request.message,
committed_files,
};
print_commit_summary(request.action_label, &outcome);
Ok(AutoCommitResult::Committed(outcome))
}
pub async fn push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
if !command_exists("git") {
return Ok(None);
}
let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
.await
.ok()
.filter(|value| !value.is_empty() && value != "HEAD");
let Some(branch) = branch else {
return Ok(None);
};
match git_output(project_root, &["push"]).await {
Ok(_) => {}
Err(push_error) => {
git_output(project_root, &["push", "-u", "origin", branch.as_str()])
.await
.map_err(|fallback_error| {
summarize_git_push_error(&branch, &push_error, &fallback_error)
})?;
}
}
Ok(Some(PushOutcome { branch }))
}
pub fn print_skip(action_label: &str, reason: &str) {
println!(
"{} {} {}",
"Auto-commit".bright_yellow().bold(),
format!("skipped for {}", action_label).bright_white(),
format!("({})", reason).dimmed()
);
}
pub fn print_push_summary(outcome: &PushOutcome) {
println!(
"{} {}",
"Pushed".bright_green().bold(),
format!("origin/{}", outcome.branch).bright_white()
);
}
fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
let branch = outcome
.branch
.as_deref()
.map(|value| format!(" on {}", value.bright_blue()))
.unwrap_or_default();
let files = if outcome.committed_files.is_empty() {
"(none)".dimmed().to_string()
} else {
outcome
.committed_files
.iter()
.map(|value| value.bright_white().to_string())
.collect::<Vec<_>>()
.join(", ")
};
println!(
"{} {}{}",
"Auto-commit".bright_green().bold(),
format!("created for {}", action_label).bright_white(),
branch
);
println!(
" {} {} {}",
"Repo".bright_cyan().bold(),
outcome.repo_name.bright_white().bold(),
format!("({})", outcome.repo_root.display()).dimmed()
);
println!(
" {} {} {}",
"Commit".bright_cyan().bold(),
outcome.short_sha.bright_green().bold(),
format!("({})", outcome.commit_sha).dimmed()
);
println!(
" {} {}",
"Message".bright_cyan().bold(),
outcome.message.bright_magenta()
);
println!(" {} {}", "Files".bright_cyan().bold(), files);
}
async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
let owned_args = args
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>();
git_output_owned(project_root, owned_args).await
}
async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
let output = Command::new("git")
.current_dir(project_root)
.args(&args)
.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())
}
async fn commit_with_optional_hook_retry(project_root: &Path, message: &str) -> Result<(), String> {
match git_output(project_root, &["commit", "-m", message]).await {
Ok(_) => Ok(()),
Err(error) if is_missing_lefthook_error(&error) => {
if std::io::stdin().is_terminal() {
let retry = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(
"Commit hooks failed to resolve lefthook. Retry once with hooks disabled?",
)
.default(true)
.interact()
.map_err(|e| format!("Failed to read retry choice: {}", e))?;
if retry {
run_commit_with_hooks_disabled(project_root, message).await
} else {
Err(error)
}
} else {
Err(error)
}
}
Err(error) => Err(error),
}
}
async fn run_commit_with_hooks_disabled(project_root: &Path, message: &str) -> Result<(), String> {
let output = Command::new("git")
.current_dir(project_root)
.env("LEFTHOOK", "0")
.args(["commit", "-m", message])
.output()
.await
.map_err(|e| format!("Failed to run `git commit -m {}`: {}", message, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!(
"`git commit -m {}` failed with status {}",
message, output.status
));
}
return Err(format!("`git commit -m {}` failed: {}", message, stderr));
}
Ok(())
}
async fn ensure_git_commit_identity(project_root: &Path) -> Result<(), String> {
let current_name = git_output(project_root, &["config", "--get", "user.name"])
.await
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let current_email = git_output(project_root, &["config", "--get", "user.email"])
.await
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if current_name.is_some() && current_email.is_some() {
return Ok(());
}
let suggested = SshConfig::load()
.ok()
.and_then(|config| config.cli_auth)
.and_then(|auth| match (auth.user_name, auth.user_email) {
(Some(name), Some(email)) if !name.trim().is_empty() && !email.trim().is_empty() => {
Some((name, email))
}
_ => None,
});
if !std::io::stdin().is_terminal() {
return Err(identity_setup_hint(
current_name.as_deref(),
current_email.as_deref(),
suggested
.as_ref()
.map(|(name, email)| (name.as_str(), email.as_str())),
));
}
let prefill_name = suggested
.as_ref()
.map(|(name, _)| name.clone())
.or_else(|| current_name.clone())
.unwrap_or_default();
let prefill_email = suggested
.as_ref()
.map(|(_, email)| email.clone())
.or_else(|| current_email.clone())
.unwrap_or_default();
let mut configured_name = current_name;
let mut configured_email = current_email;
if configured_name.is_none() {
let name: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Git commit author name")
.with_initial_text(prefill_name)
.interact_text()
.map_err(|e| format!("Failed to read git author name: {}", e))?;
let trimmed = name.trim().to_string();
if trimmed.is_empty() {
return Err("Git commit author name cannot be empty.".to_string());
}
git_output(project_root, &["config", "user.name", trimmed.as_str()]).await?;
configured_name = Some(trimmed);
}
if configured_email.is_none() {
let email: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Git commit author email")
.with_initial_text(prefill_email)
.interact_text()
.map_err(|e| format!("Failed to read git author email: {}", e))?;
let trimmed = email.trim().to_string();
if trimmed.is_empty() {
return Err("Git commit author email cannot be empty.".to_string());
}
git_output(project_root, &["config", "user.email", trimmed.as_str()]).await?;
configured_email = Some(trimmed);
}
if configured_name.is_some() && configured_email.is_some() {
Ok(())
} else {
Err("Git commit identity is still incomplete after prompting.".to_string())
}
}
async fn format_git_commit_failure(project_root: &Path, error: &str) -> String {
if is_missing_git_identity_error(error) {
let suggested = SshConfig::load()
.ok()
.and_then(|config| config.cli_auth)
.and_then(|auth| match (auth.user_name, auth.user_email) {
(Some(name), Some(email))
if !name.trim().is_empty() && !email.trim().is_empty() =>
{
Some((name, email))
}
_ => None,
});
return identity_setup_hint(
git_output(project_root, &["config", "--get", "user.name"])
.await
.ok()
.as_deref(),
git_output(project_root, &["config", "--get", "user.email"])
.await
.ok()
.as_deref(),
suggested
.as_ref()
.map(|(name, email)| (name.as_str(), email.as_str())),
);
}
if is_missing_lefthook_error(error) {
return format!(
"{}\n{}\n{}\n{}",
"The commit hook tried to load lefthook from the current repo but the module was missing."
.to_string(),
"Run `pnpm install` or `npm install` in the repo that owns the hook, or fix the hook path so it resolves from the current checkout."
.to_string(),
"If you want to keep moving, rerun the commit with hooks disabled by setting `LEFTHOOK=0`."
.to_string(),
format!("Raw error: {}", error)
);
}
format!("Git commit failed: {}", error)
}
fn identity_setup_hint(
current_name: Option<&str>,
current_email: Option<&str>,
suggested: Option<(&str, &str)>,
) -> String {
let mut lines = vec![
"Git blocked the commit because your author identity is not configured in this environment."
.to_string(),
];
if current_name.is_none() {
lines.push("Missing `user.name`.".to_string());
}
if current_email.is_none() {
lines.push("Missing `user.email`.".to_string());
}
if let Some((name, email)) = suggested {
lines.push(format!(
"XBP found a likely identity to prefill: {} <{}>",
name, email
));
lines.push(format!("Run `git config --local user.name \"{}\"`", name));
lines.push(format!("Run `git config --local user.email \"{}\"`", email));
} else {
lines.push("Set them with `git config --global user.name \"Floris\"` and `git config --global user.email \"you@example.com\"`."
.to_string());
lines.push(
"Use `--global` for all repos, or omit it for the current repo only.".to_string(),
);
}
lines.join("\n")
}
fn is_missing_git_identity_error(error: &str) -> bool {
error.contains("Author identity unknown")
|| error.contains("empty ident name")
|| error.contains("Please tell me who you are")
}
fn is_missing_lefthook_error(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
(lower.contains("lefthook") && lower.contains("module not found"))
|| (lower.contains("lefthook") && lower.contains("cannot find module"))
}
fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
let mut deduped = BTreeSet::new();
for path in paths {
if path.as_os_str().is_empty() {
continue;
}
let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
relative.to_path_buf()
} else if let Some(relative) = strip_project_root_prefix(project_root, path) {
relative
} else {
path.to_path_buf()
};
let rendered = normalized.to_string_lossy().replace('\\', "/");
let trimmed = rendered.trim();
if !trimmed.is_empty() && trimmed != "." {
deduped.insert(trimmed.to_string());
}
}
deduped.into_iter().collect()
}
fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
let root = project_root
.to_string_lossy()
.replace('\\', "/")
.trim_end_matches('/')
.to_string();
let candidate = path.to_string_lossy().replace('\\', "/");
if candidate.len() <= root.len() {
return None;
}
let (prefix, suffix) = candidate.split_at(root.len());
if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
return Some(PathBuf::from(suffix.trim_start_matches('/')));
}
None
}
pub fn summarize_git_push_error(
branch: &str,
primary_error: &str,
fallback_error: &str,
) -> String {
let corpus = format!("{primary_error}\n{fallback_error}").to_ascii_lowercase();
if corpus.contains("non-fast-forward")
|| corpus.contains("tip of your current branch is behind")
|| corpus.contains("failed to push some refs")
{
return format!(
"`{branch}` is behind its remote counterpart. Run `git pull --rebase origin {branch}`, then `git push`."
);
}
if corpus.contains("authentication failed")
|| corpus.contains("could not read username")
|| corpus.contains("403")
|| corpus.contains("401")
{
return "Git authentication failed while pushing. Refresh your GitHub credentials and try again."
.to_string();
}
let lines = dedupe_git_error_lines(primary_error, fallback_error);
lines
.into_iter()
.last()
.unwrap_or_else(|| "git push failed".to_string())
}
fn dedupe_git_error_lines(primary_error: &str, fallback_error: &str) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut lines = Vec::new();
for line in primary_error.lines().chain(fallback_error.lines()) {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("hint:") {
continue;
}
let key = trimmed.to_ascii_lowercase();
if seen.insert(key) {
lines.push(trimmed.to_string());
}
}
lines
}
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()
}
#[cfg(test)]
mod tests {
use super::{
is_missing_git_identity_error, is_missing_lefthook_error, normalize_commit_paths,
summarize_git_push_error,
};
use std::path::{Path, PathBuf};
#[test]
fn normalizes_commit_paths_relative_to_project_root() {
let project_root = Path::new("C:/repo");
let paths = vec![
PathBuf::from("C:/repo/.xbp/xbp.yaml"),
PathBuf::from("C:/repo/.xbp/xbp.yaml"),
PathBuf::from("CHANGELOG.md"),
];
let normalized = normalize_commit_paths(project_root, &paths);
assert_eq!(
normalized,
vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
);
}
#[test]
fn detects_missing_git_identity_errors() {
assert!(is_missing_git_identity_error(
"Author identity unknown\n*** Please tell me who you are."
));
}
#[test]
fn detects_lefthook_module_errors() {
assert!(is_missing_lefthook_error(
"Error: Cannot find module 'C:\\\\repo\\\\node_modules\\\\lefthook\\\\bin\\\\index.js'"
));
}
#[test]
fn summarizes_non_fast_forward_push_errors_without_git_hints() {
let primary = "! [rejected] main -> main (non-fast-forward)";
let fallback = r#"error: failed to push some refs to 'https://github.com/xylex-group/xbp.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again."#;
let summary = summarize_git_push_error("main", primary, fallback);
assert!(summary.contains("behind its remote counterpart"));
assert!(summary.contains("git pull --rebase origin main"));
assert!(!summary.contains("hint:"));
assert!(!summary.contains("git push -u origin main"));
}
}