use colored::Colorize;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use tokio::process::Command;
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(),
));
}
git_output(
request.project_root,
&["commit", "-m", request.message.as_str()],
)
.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| {
format!(
"Git push failed (`git push`: {}; `git push -u origin {}`: {})",
push_error, branch, 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())
}
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
}
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::normalize_commit_paths;
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()]
);
}
}