use std::{
fs::{File, OpenOptions, read_to_string, write},
io::Write,
path::Path,
process::Command,
};
use colored::Colorize;
use crate::{
errors::{GitError, Result, RonaError},
git::branch::{format_branch_name, get_current_branch},
};
use super::{
files::get_ignore_patterns,
get_top_level_path,
status::{process_deleted_files_for_commit_message, process_git_status},
};
pub const COMMIT_MESSAGE_FILE_PATH: &str = "commit_message.md";
pub const COMMIT_TYPES: [&str; 4] = ["chore", "feat", "fix", "test"];
pub fn get_current_commit_nb() -> Result<u32> {
let output = Command::new("git")
.args(["rev-list", "--count", "HEAD"])
.output()
.map_err(RonaError::Io)?;
if !output.status.success() {
return Ok(0);
}
let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
count_str.parse::<u32>().map_err(|_| {
RonaError::Git(GitError::InvalidStatus {
output: format!("Failed to parse commit count: {count_str}"),
})
})
}
#[must_use]
pub fn is_gpg_signing_available() -> bool {
let output = Command::new("git")
.args(["config", "--get", "user.signingkey"])
.output();
match output {
Ok(out) => out.status.success() && !String::from_utf8_lossy(&out.stdout).trim().is_empty(),
Err(_) => false,
}
}
fn handle_dry_run_output(
file_content: &str,
unsigned: bool,
filtered_args: &[String],
is_amend: bool,
) {
println!("Would commit with message:");
println!("---");
println!("{}", file_content.trim());
println!("---");
if is_amend {
println!("Would amend the previous commit");
}
let gpg_available = is_gpg_signing_available();
let would_sign = !unsigned && gpg_available;
if unsigned {
println!("Would create unsigned commit");
} else if would_sign {
println!("Would sign commit with GPG");
} else {
println!("Would create unsigned commit (GPG signing not available)");
if !gpg_available {
println!(
"{} GPG signing not available or not configured.",
"WARNING:".yellow().bold()
);
println!(" To suppress this warning, use the --unsigned (-u) flag.");
}
}
if !filtered_args.is_empty() {
println!("With additional args: {filtered_args:?}");
}
}
#[tracing::instrument(skip_all)]
pub fn git_commit(args: &[String], unsigned: bool, dry_run: bool) -> Result<()> {
tracing::debug!(unsigned, dry_run, "Committing files...");
let project_root = get_top_level_path()?;
let commit_file_path = project_root.join(COMMIT_MESSAGE_FILE_PATH);
if !commit_file_path.exists() {
return Err(RonaError::Git(GitError::CommitMessageNotFound));
}
let file_content = read_to_string(&commit_file_path)?;
let is_amend = args.iter().any(|arg| arg == "--amend");
let filtered_args: Vec<String> = args
.iter()
.filter(|arg| !arg.starts_with("-c") && !arg.starts_with("--commit") && *arg != "--amend")
.cloned()
.collect();
if dry_run {
handle_dry_run_output(&file_content, unsigned, &filtered_args, is_amend);
return Ok(());
}
if !unsigned && !is_gpg_signing_available() {
println!(
"{} GPG signing not available or not configured. Creating unsigned commit.",
"WARNING:".yellow().bold()
);
println!(" To suppress this warning, use the --unsigned (-u) flag.");
}
let commit_file_str = commit_file_path.to_str().ok_or_else(|| {
RonaError::Git(GitError::CommandFailed {
command: "commit".to_string(),
output: "Invalid path to commit message file".to_string(),
})
})?;
let mut cmd = Command::new("git");
cmd.arg("commit");
if is_amend {
cmd.arg("--amend");
}
if unsigned {
cmd.arg("--no-gpg-sign");
}
cmd.args(["-F", commit_file_str]);
let status = cmd.status().map_err(RonaError::Io)?;
if !status.success() {
return Err(RonaError::Git(GitError::CommandFailed {
command: "commit".to_string(),
output: "git commit failed".to_string(),
}));
}
tracing::debug!("commit successful!");
Ok(())
}
#[tracing::instrument(skip_all)]
pub fn generate_commit_message(commit_type: &str, no_commit_number: bool) -> Result<()> {
let project_root = get_top_level_path()?;
let commit_message_path = project_root.join(COMMIT_MESSAGE_FILE_PATH);
if commit_message_path.exists() {
write(&commit_message_path, "")?;
}
let modified_files = process_git_status()?;
let deleted_files = process_deleted_files_for_commit_message()?;
let mut commit_file = OpenOptions::new()
.append(true)
.create(true)
.open(&commit_message_path)?;
write_commit_header(&mut commit_file, commit_type, no_commit_number)?;
let ignore_patterns = get_ignore_patterns()?;
for file in modified_files {
if !should_ignore_file(&file, &ignore_patterns)? {
writeln!(commit_file, "- `{file}`:\n\n\t\n")?;
}
}
for file in deleted_files {
writeln!(commit_file, "- `{file}`: deleted\n")?;
}
commit_file.flush()?;
tracing::debug!("{} created", commit_message_path.display());
Ok(())
}
fn write_commit_header(
commit_file: &mut File,
commit_type: &str,
no_commit_number: bool,
) -> Result<()> {
let branch_name = format_branch_name(&COMMIT_TYPES, &get_current_branch()?);
if no_commit_number {
writeln!(commit_file, "({commit_type} on {branch_name})\n\n")?;
} else {
let commit_number = get_current_commit_nb()? + 1;
writeln!(
commit_file,
"[{commit_number}] ({commit_type} on {branch_name})\n\n"
)?;
}
Ok(())
}
fn should_ignore_file(file: &str, ignore_patterns: &[String]) -> Result<bool> {
use crate::utils::check_for_file_in_folder;
if ignore_patterns.contains(&file.to_string()) {
return Ok(true);
}
let file_path = Path::new(file);
for item in ignore_patterns {
let item_path = Path::new(item);
if check_for_file_in_folder(file_path, item_path)? {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use tempfile::TempDir;
static DIR_MUTEX: Mutex<()> = Mutex::new(());
#[cfg(unix)]
fn init_git_repo(
path: &std::path::Path,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
for args in [
vec!["init"],
vec!["config", "user.email", "test@example.com"],
vec!["config", "user.name", "Test"],
] {
Command::new("git").current_dir(path).args(&args).output()?;
}
Ok(())
}
#[test]
fn test_gpg_signing_available() {
let _result = is_gpg_signing_available();
}
#[test]
fn test_git_commit_dry_run_with_unsigned() -> std::result::Result<(), Box<dyn std::error::Error>>
{
let _guard = DIR_MUTEX.lock().map_err(|e| e.to_string())?;
let temp_dir = TempDir::new()?;
let temp_path = temp_dir.path();
Command::new("git")
.current_dir(temp_path)
.arg("init")
.output()?;
let commit_msg = "[1] (test on main)\n\n- `test.txt`:\n\n\t\n";
write(temp_path.join("commit_message.md"), commit_msg)?;
let original_dir = std::env::current_dir()?;
std::env::set_current_dir(temp_path)?;
let result = git_commit(&[], true, true);
std::env::set_current_dir(original_dir)?;
assert!(result.is_ok());
Ok(())
}
#[test]
#[cfg(unix)]
fn test_pre_commit_hook_fires() -> std::result::Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
let _guard = DIR_MUTEX.lock().map_err(|e| e.to_string())?;
let temp_dir = TempDir::new()?;
let temp_path = temp_dir.path();
init_git_repo(temp_path)?;
write(temp_path.join("test.txt"), "hello")?;
Command::new("git")
.current_dir(temp_path)
.args(["add", "test.txt"])
.output()?;
let hooks_dir = temp_path.join(".git/hooks");
std::fs::create_dir_all(&hooks_dir)?;
let hook_path = hooks_dir.join("pre-commit");
write(&hook_path, "#!/bin/sh\ntouch HOOK_FIRED\n")?;
let mut perms = std::fs::metadata(&hook_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms)?;
write(
temp_path.join("commit_message.md"),
"(test on main)\n\n- `test.txt`:\n\n\t\n",
)?;
let original_dir = std::env::current_dir()?;
std::env::set_current_dir(temp_path)?;
let result = git_commit(&[], true, false);
std::env::set_current_dir(&original_dir)?;
assert!(result.is_ok(), "commit failed: {result:?}");
assert!(
temp_path.join("HOOK_FIRED").exists(),
"pre-commit hook did not fire"
);
Ok(())
}
#[test]
#[cfg(unix)]
fn test_pre_commit_hook_blocks_commit() -> std::result::Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
let _guard = DIR_MUTEX.lock().map_err(|e| e.to_string())?;
let temp_dir = TempDir::new()?;
let temp_path = temp_dir.path();
init_git_repo(temp_path)?;
write(temp_path.join("test.txt"), "hello")?;
Command::new("git")
.current_dir(temp_path)
.args(["add", "test.txt"])
.output()?;
let hooks_dir = temp_path.join(".git/hooks");
std::fs::create_dir_all(&hooks_dir)?;
let hook_path = hooks_dir.join("pre-commit");
write(&hook_path, "#!/bin/sh\nexit 1\n")?;
let mut perms = std::fs::metadata(&hook_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms)?;
write(
temp_path.join("commit_message.md"),
"(test on main)\n\n- `test.txt`:\n\n\t\n",
)?;
let original_dir = std::env::current_dir()?;
std::env::set_current_dir(temp_path)?;
let result = git_commit(&[], true, false);
std::env::set_current_dir(&original_dir)?;
assert!(
result.is_err(),
"commit should have been blocked by the pre-commit hook"
);
Ok(())
}
}