use anyhow::Result;
use clap::Args;
use std::io::{self, Write};
use std::process::Command;
use tokio::task::JoinSet;
use octocode::config::Config;
use octocode::indexer::git_utils::GitUtils;
use octocode::utils::diff_chunker;
const MAX_RETRIES: usize = 2;
const RETRY_DELAY_MS: u64 = 1000;
async fn call_llm_with_retry<F, Fut>(operation: F, context: &str) -> Result<String>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<String>>,
{
let mut last_error = None;
for attempt in 1..=MAX_RETRIES + 1 {
match operation().await {
Ok(response) => return Ok(response),
Err(e) => {
last_error = Some(e);
if attempt <= MAX_RETRIES {
eprintln!(
"Warning: {} attempt {} failed, retrying in {}ms...",
context, attempt, RETRY_DELAY_MS
);
tokio::time::sleep(tokio::time::Duration::from_millis(RETRY_DELAY_MS)).await;
}
}
}
}
if let Some(e) = last_error {
Err(anyhow::anyhow!(
"{} failed after {} attempts: {}",
context,
MAX_RETRIES + 1,
e
))
} else {
Err(anyhow::anyhow!("{} failed with unknown error", context))
}
}
#[derive(Args, Debug)]
pub struct CommitArgs {
#[arg(short, long)]
pub all: bool,
#[arg(short, long)]
pub message: Option<String>,
#[arg(short, long)]
pub yes: bool,
#[arg(short, long)]
pub no_verify: bool,
#[arg(short, long, value_name = "HASH")]
pub commit: Option<String>,
}
pub async fn execute(config: &Config, args: &CommitArgs) -> Result<()> {
let current_dir = std::env::current_dir()?;
let git_root = GitUtils::find_git_root(¤t_dir)
.ok_or_else(|| anyhow::anyhow!("â Not in a git repository!"))?;
let current_dir = git_root;
if let Some(ref hash) = args.commit {
return rewrite_commit_message(
¤t_dir,
config,
hash,
args.message.as_deref(),
args.yes,
)
.await;
}
if args.all {
println!("đ Adding all changes...");
let output = Command::new("git")
.args(["add", "."])
.current_dir(¤t_dir)
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to add files: {}",
String::from_utf8_lossy(&output.stderr)
));
}
}
let output = Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(¤t_dir)
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to check staged changes: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let staged_files = String::from_utf8(output.stdout)?;
if staged_files.trim().is_empty() {
return Err(anyhow::anyhow!(
"â No staged changes to commit. Use 'git add' or --all flag."
));
}
println!("đ Staged files:");
for file in staged_files.lines() {
println!(" âĸ {}", file);
}
if !args.no_verify {
let originally_staged_files: Vec<String> =
staged_files.lines().map(|s| s.to_string()).collect();
run_precommit_hooks(¤t_dir, args.all, &originally_staged_files).await?;
}
let output = Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(¤t_dir)
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to check staged changes after pre-commit: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let final_staged_files = String::from_utf8(output.stdout)?;
if final_staged_files.trim().is_empty() {
return Err(anyhow::anyhow!(
"â No staged changes remaining after pre-commit hooks."
));
}
if final_staged_files != staged_files {
println!("\nđ Updated staged files after pre-commit:");
for file in final_staged_files.lines() {
println!(" âĸ {}", file);
}
}
println!("\nđ¤ Generating commit message...");
let commit_message =
generate_commit_message_chunked(¤t_dir, config, args.message.as_deref()).await?;
println!("\nđ Generated commit message:");
println!("âââââââââââââââââââââââââââââââââââ");
println!("{}", commit_message);
println!("âââââââââââââââââââââââââââââââââââ");
if !args.yes {
print!("\nProceed with this commit? [y/N] ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().to_lowercase().starts_with('y') {
println!("â Commit cancelled.");
return Ok(());
}
}
println!("đž Committing changes...");
let mut git_args = vec!["commit", "-m", &commit_message];
if args.no_verify {
git_args.push("--no-verify");
}
let output = Command::new("git")
.args(&git_args)
.current_dir(¤t_dir)
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to commit: {}",
String::from_utf8_lossy(&output.stderr)
));
}
println!("â
Successfully committed changes!");
let output = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(¤t_dir)
.output()?;
if output.status.success() {
let commit_info = String::from_utf8_lossy(&output.stdout);
println!("đ Commit: {}", commit_info.trim());
}
Ok(())
}
async fn rewrite_commit_message(
repo_path: &std::path::Path,
config: &Config,
hash: &str,
extra_context: Option<&str>,
skip_confirm: bool,
) -> Result<()> {
let resolved = Command::new("git")
.args(["rev-parse", hash])
.current_dir(repo_path)
.output()?;
if !resolved.status.success() {
return Err(anyhow::anyhow!(
"â Cannot resolve commit '{}': {}",
hash,
String::from_utf8_lossy(&resolved.stderr).trim()
));
}
let full_hash = String::from_utf8(resolved.stdout)?.trim().to_string();
let head = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo_path)
.output()?;
let head_hash = if head.status.success() {
String::from_utf8(head.stdout)?.trim().to_string()
} else {
String::new()
};
let is_head = full_hash == head_hash;
println!("đ Analysing commit {}...", &full_hash[..8]);
let diff_output = Command::new("git")
.args(["show", &full_hash, "--format=", "-p"])
.current_dir(repo_path)
.output()?;
if !diff_output.status.success() {
return Err(anyhow::anyhow!(
"â Failed to get diff for commit '{}': {}",
hash,
String::from_utf8_lossy(&diff_output.stderr).trim()
));
}
let diff = String::from_utf8(diff_output.stdout)?;
if diff.trim().is_empty() {
return Err(anyhow::anyhow!(
"â Commit '{}' has no diff (merge commit or empty commit).",
hash
));
}
let files_output = Command::new("git")
.args(["show", &full_hash, "--name-only", "--format="])
.current_dir(repo_path)
.output()?;
if files_output.status.success() {
let files = String::from_utf8_lossy(&files_output.stdout);
println!("đ Files in commit:");
for f in files.lines().filter(|l| !l.is_empty()) {
println!(" âĸ {}", f);
}
}
println!("\nđ¤ Generating commit message...");
let commit_message = generate_commit_message_from_diff(&diff, config, extra_context).await?;
println!("\nđ Generated commit message:");
println!("âââââââââââââââââââââââââââââââââââ");
println!("{}", commit_message);
println!("âââââââââââââââââââââââââââââââââââ");
if !skip_confirm {
print!("\nReplace message of commit {}? [y/N] ", &full_hash[..8]);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().to_lowercase().starts_with('y') {
println!("â Cancelled.");
return Ok(());
}
}
if is_head {
println!("âī¸ Amending HEAD commit message...");
let output = Command::new("git")
.args(["commit", "--amend", "-m", &commit_message, "--no-edit"])
.current_dir(repo_path)
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to amend commit: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
} else {
println!("âī¸ Rewriting commit {} via rebase...", &full_hash[..8]);
let msg_file = repo_path.join(".git").join("OCTOCODE_REWORD_MSG");
std::fs::write(&msg_file, &commit_message)?;
let editor_script = format!("#!/bin/sh\ncp '{}' \"$1\"", msg_file.display());
let editor_file = repo_path.join(".git").join("octocode_reword_editor.sh");
std::fs::write(&editor_file, &editor_script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&editor_file, std::fs::Permissions::from_mode(0o755))?;
}
let seq_script = format!(
"#!/bin/sh\nsed -i.bak 's/^pick {}/reword {}/' \"$1\"",
&full_hash[..7],
&full_hash[..7]
);
let seq_file = repo_path.join(".git").join("octocode_seq_editor.sh");
std::fs::write(&seq_file, &seq_script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&seq_file, std::fs::Permissions::from_mode(0o755))?;
}
let output = Command::new("git")
.args(["rebase", "-i", &format!("{}^", full_hash)])
.env("GIT_SEQUENCE_EDITOR", &seq_file)
.env("GIT_EDITOR", &editor_file)
.current_dir(repo_path)
.output()?;
let _ = std::fs::remove_file(&msg_file);
let _ = std::fs::remove_file(&editor_file);
let _ = std::fs::remove_file(&seq_file);
let _ = std::fs::remove_file(repo_path.join(".git").join("octocode_reword_editor.sh.bak"));
let _ = std::fs::remove_file(repo_path.join(".git").join("octocode_seq_editor.sh.bak"));
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to rebase: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
}
println!("â
Commit message replaced successfully!");
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(repo_path)
.output()?;
if log.status.success() {
println!("đ Commit: {}", String::from_utf8_lossy(&log.stdout).trim());
}
Ok(())
}
async fn generate_commit_message_chunked(
repo_path: &std::path::Path,
config: &Config,
extra_context: Option<&str>,
) -> Result<String> {
let output = Command::new("git")
.args(["diff", "--cached"])
.current_dir(repo_path)
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to get diff: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let diff = String::from_utf8(output.stdout)?;
if diff.trim().is_empty() {
return Err(anyhow::anyhow!("No staged changes found"));
}
let staged_files = GitUtils::get_staged_files(repo_path)?;
let changed_files = staged_files.join("\n");
let has_markdown_files = changed_files
.lines()
.any(|file| file.ends_with(".md") || file.ends_with(".markdown") || file.ends_with(".rst"));
let has_non_markdown_files = changed_files.lines().any(|file| {
!file.ends_with(".md")
&& !file.ends_with(".markdown")
&& !file.ends_with(".rst")
&& !file.trim().is_empty()
});
let file_count = diff.matches("diff --git").count();
let additions = diff
.matches("\n+")
.count()
.saturating_sub(diff.matches("\n+++").count());
let deletions = diff
.matches("\n-")
.count()
.saturating_sub(diff.matches("\n---").count());
let mut guidance_section = String::new();
if let Some(context) = extra_context {
guidance_section = format!("\n\nIMPORTANT - USER'S DESCRIPTION OF CHANGES (incorporate this into your commit message):\n{}", context);
}
let docs_restriction = if has_non_markdown_files && !has_markdown_files {
"\n\nCRITICAL - DOCS TYPE RESTRICTION:\n\
- NEVER use 'docs(...)' when only non-markdown files are changed\n\
- Current changes include ONLY non-markdown files (.rs, .js, .py, .toml, etc.)\n\
- Use 'fix', 'feat', 'refactor', 'chore', etc. instead of 'docs'\n\
- 'docs' is ONLY for .md, .markdown, .rst files or documentation-only changes"
} else if has_non_markdown_files && has_markdown_files {
"\n\nDOCS TYPE GUIDANCE:\n\
- Use 'docs(...)' ONLY if the primary change is documentation\n\
- If code changes are the main focus, use appropriate code type (fix, feat, refactor)\n\
- Mixed changes: prioritize the most significant change type"
} else {
""
};
let chunks = diff_chunker::chunk_diff(&diff);
if chunks.len() == 1 {
let prompt = create_commit_prompt(
&chunks[0].content,
file_count,
additions,
deletions,
&guidance_section,
docs_restriction,
);
return call_llm_with_retry(
|| call_llm_for_commit_message(&prompt, config),
"Single chunk commit message",
)
.await;
}
println!(
"đ Processing large diff in {} chunks in parallel...",
chunks.len()
);
let responses = process_commit_chunks_parallel(
&chunks,
file_count,
additions,
deletions,
&guidance_section,
docs_restriction,
config,
)
.await;
if responses.is_empty() {
return Ok("chore: update files".to_string());
}
let combined = diff_chunker::combine_commit_messages(responses);
println!("đ¯ Refining commit message with AI...");
match refine_commit_message_with_ai(&combined, config).await {
Ok(refined) => Ok(refined),
Err(e) => {
eprintln!(
"Warning: AI refinement failed ({}), using combined message",
e
);
Ok(combined)
}
}
}
async fn generate_commit_message_from_diff(
diff: &str,
config: &Config,
extra_context: Option<&str>,
) -> Result<String> {
let changed_files: String = diff
.lines()
.filter(|l| l.starts_with("diff --git "))
.filter_map(|l| l.split(" b/").nth(1))
.collect::<Vec<_>>()
.join("\n");
let has_markdown_files = changed_files
.lines()
.any(|f| f.ends_with(".md") || f.ends_with(".markdown") || f.ends_with(".rst"));
let has_non_markdown_files = changed_files.lines().any(|f| {
!f.ends_with(".md")
&& !f.ends_with(".markdown")
&& !f.ends_with(".rst")
&& !f.trim().is_empty()
});
let file_count = diff.matches("diff --git").count();
let additions = diff
.matches("\n+")
.count()
.saturating_sub(diff.matches("\n+++").count());
let deletions = diff
.matches("\n-")
.count()
.saturating_sub(diff.matches("\n---").count());
let guidance_section = extra_context
.map(|c| format!("\n\nIMPORTANT - USER'S DESCRIPTION OF CHANGES (incorporate this into your commit message):\n{}", c))
.unwrap_or_default();
let docs_restriction = if has_non_markdown_files && !has_markdown_files {
"\n\nCRITICAL - DOCS TYPE RESTRICTION:\n\
- NEVER use 'docs(...)' when only non-markdown files are changed\n\
- Current changes include ONLY non-markdown files (.rs, .js, .py, .toml, etc.)\n\
- Use 'fix', 'feat', 'refactor', 'chore', etc. instead of 'docs'\n\
- 'docs' is ONLY for .md, .markdown, .rst files or documentation-only changes"
} else if has_non_markdown_files && has_markdown_files {
"\n\nDOCS TYPE GUIDANCE:\n\
- Use 'docs(...)' ONLY if the primary change is documentation\n\
- If code changes are the main focus, use appropriate code type (fix, feat, refactor)\n\
- Mixed changes: prioritize the most significant change type"
} else {
""
};
let chunks = diff_chunker::chunk_diff(diff);
if chunks.len() == 1 {
let prompt = create_commit_prompt(
&chunks[0].content,
file_count,
additions,
deletions,
&guidance_section,
docs_restriction,
);
return call_llm_with_retry(
|| call_llm_for_commit_message(&prompt, config),
"Single chunk commit message",
)
.await;
}
println!(
"đ Processing large diff in {} chunks in parallel...",
chunks.len()
);
let responses = process_commit_chunks_parallel(
&chunks,
file_count,
additions,
deletions,
&guidance_section,
docs_restriction,
config,
)
.await;
if responses.is_empty() {
return Ok("chore: update files".to_string());
}
let combined = diff_chunker::combine_commit_messages(responses);
println!("đ¯ Refining commit message with AI...");
match refine_commit_message_with_ai(&combined, config).await {
Ok(refined) => Ok(refined),
Err(e) => {
eprintln!(
"Warning: AI refinement failed ({}), using combined message",
e
);
Ok(combined)
}
}
}
fn create_commit_prompt(
diff_content: &str,
file_count: usize,
additions: usize,
deletions: usize,
guidance_section: &str,
docs_restriction: &str,
) -> String {
format!(
"STRICT FORMAT: Plain text commit message, NO markdown, NO backticks, NO code blocks.
type(scope): description under 50 chars
Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
Use imperative mood (add not added, fix not fixed)
Avoid generic words: update, change, modify, various, several
Focus on WHAT functionality changed, not implementation details
---
COMMIT TYPE GUIDE:
feat: NEW functionality being added
fix: CORRECTING bugs/errors/broken functionality
refactor: IMPROVING code without changing functionality
perf: OPTIMIZING performance
docs: .md/.markdown/.rst files ONLY
test: ADDING or fixing tests
style: formatting/whitespace (no logic changes)
chore: maintenance (dependencies, build, tooling)
ci: workflows/pipelines
build: Cargo.toml, package.json, Makefile{}
---
FEATURE vs FIX: Working code with bugs = fix, completely new = feat
Examples: fix(auth): resolve token validation error, feat(auth): add OAuth2 support
When in doubt: choose fix if addressing problems, feat if adding new
---
BREAKING CHANGES: Function/API signature changes, removed public methods, interface/trait changes
Library code: mark any public interface changes as breaking
Application code: internal changes dont need marker unless affects config/user-facing
Use type! format and add BREAKING CHANGE footer if detected
---
BODY NEEDED if: 4+ files OR 25+ lines OR multiple change types OR complex refactoring OR breaking changes
Body format (plain text):
- Blank line after subject
- Each point starts with dash space: -
- Focus on key changes and purpose
- Keep bullets concise (1 line max)
- For breaking: add BREAKING CHANGE: description
---
OUTPUT FORMAT: Plain text only
Subject: type(scope): description
If body: blank line then dash bullets
If breaking: BREAKING CHANGE: line
NO code blocks, NO backticks, NO markdown
---{}
Changes: {} files (+{} -{} lines)
Git diff:
{}
Generate commit message:",
docs_restriction,
guidance_section,
file_count,
additions,
deletions,
diff_content
)
}
async fn collect_ordered_responses(
mut join_set: JoinSet<Result<(usize, String)>>,
expected_count: usize,
) -> Vec<String> {
let mut ordered_responses = vec![None; expected_count];
while let Some(result) = join_set.join_next().await {
match result {
Ok(Ok((index, response))) => {
ordered_responses[index] = Some(response);
}
Ok(Err(_)) => {
}
Err(e) => {
eprintln!("Warning: Task join error: {}", e);
}
}
}
ordered_responses.into_iter().flatten().collect()
}
async fn process_commit_chunks_parallel(
chunks: &[diff_chunker::DiffChunk],
file_count: usize,
additions: usize,
deletions: usize,
guidance_section: &str,
docs_restriction: &str,
config: &Config,
) -> Vec<String> {
let chunk_limit = std::cmp::min(chunks.len(), diff_chunker::MAX_PARALLEL_CHUNKS);
let mut join_set = JoinSet::new();
for (i, chunk) in chunks.iter().take(chunk_limit).enumerate() {
let chunk_content = chunk.content.clone();
let chunk_summary = chunk.file_summary.clone();
let config = config.clone();
let guidance_section = guidance_section.to_string();
let docs_restriction = docs_restriction.to_string();
join_set.spawn(async move {
println!(
" Processing chunk {}/{}: {}",
i + 1,
chunk_limit,
chunk_summary
);
let chunk_prompt = create_commit_prompt(
&chunk_content,
file_count,
additions,
deletions,
&guidance_section,
&docs_restriction,
);
match call_llm_for_commit_message(&chunk_prompt, &config).await {
Ok(response) => Ok((i, response)),
Err(e) => {
eprintln!("Warning: Chunk {} failed ({})", i + 1, e);
Err(e)
}
}
});
}
collect_ordered_responses(join_set, chunk_limit).await
}
async fn call_llm_for_commit_message(prompt: &str, config: &Config) -> Result<String> {
use octocode::llm::{LlmClient, Message};
let client = LlmClient::from_config(config)?;
let messages = vec![Message::user(prompt)];
let response = client
.chat_completion_with_temperature(messages, 0.1)
.await?;
Ok(response)
}
async fn refine_commit_message_with_ai(verbose_message: &str, config: &Config) -> Result<String> {
let refinement_prompt = format!(
"SYNTHESISE COMMIT MESSAGE - CRITICAL: Output must be PLAIN TEXT ONLY, NO MARKDOWN, NO backticks, NO code blocks.
The diff was too large to process at once, so it was split into chunks and each chunk was summarised separately.
Below are the per-chunk summaries. Your job is to synthesise them into ONE accurate commit message that covers ALL changes across ALL chunks.
PER-CHUNK SUMMARIES:
{}
SYNTHESIS REQUIREMENTS:
1. Read ALL chunk summaries before writing anything
2. Identify every distinct change across all chunks
3. Choose a single conventional commit type that best represents the overall change set
4. Subject line: type(scope): description â max 50 chars, imperative mood
5. Body: list every meaningful change as a dash-space bullet, one per line, max 72 chars each
6. Group related changes together; remove duplication
7. Do NOT omit changes just because they appear in a later chunk
8. Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
OUTPUT FORMAT (PLAIN TEXT ONLY):
feat(agents): add multi-jurisdiction lawyer specialists
- Add Australian, Canadian, French, German, Indian, Singaporean, UK, US specialists
- Include federal Acts knowledge base per jurisdiction
- Enable legal query handling for each region
Return ONLY the synthesised commit message as plain text, nothing else.",
verbose_message
);
call_llm_with_retry(
|| call_llm_for_commit_message(&refinement_prompt, config),
"AI commit message refinement",
)
.await
}
fn is_precommit_available() -> bool {
Command::new("pre-commit")
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn has_precommit_config(repo_path: &std::path::Path) -> bool {
repo_path.join(".pre-commit-config.yaml").exists()
|| repo_path.join(".pre-commit-config.yml").exists()
}
async fn run_precommit_hooks(
repo_path: &std::path::Path,
run_all: bool,
originally_staged_files: &[String],
) -> Result<()> {
if !is_precommit_available() {
return Ok(());
}
if !has_precommit_config(repo_path) {
return Ok(());
}
println!("đ§ Running pre-commit hooks...");
let pre_commit_args = if run_all {
vec!["run", "--all-files"]
} else {
vec!["run"]
};
let output = Command::new("pre-commit")
.args(&pre_commit_args)
.current_dir(repo_path)
.output()?;
match output.status.code() {
Some(0) => {
println!("â
Pre-commit hooks passed successfully");
}
Some(1) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.is_empty() {
println!("đ Pre-commit output:\n{}", stdout);
}
let modified_output = Command::new("git")
.args(["diff", "--name-only"])
.current_dir(repo_path)
.output()?;
if modified_output.status.success() {
let all_modified_files = String::from_utf8_lossy(&modified_output.stdout);
let all_modified_set: std::collections::HashSet<&str> =
all_modified_files.lines().collect();
let staged_and_modified: Vec<&String> = originally_staged_files
.iter()
.filter(|file| all_modified_set.contains(file.as_str()))
.collect();
if !staged_and_modified.is_empty() {
println!("đ Pre-commit hooks modified originally staged files:");
for file in &staged_and_modified {
println!(" âĸ {}", file);
}
println!("đ Re-staging modified files...");
for file in &staged_and_modified {
let add_output = Command::new("git")
.args(["add", file.trim()])
.current_dir(repo_path)
.output()?;
if !add_output.status.success() {
eprintln!(
"â ī¸ Warning: Failed to re-stage {}: {}",
file,
String::from_utf8_lossy(&add_output.stderr)
);
}
}
println!("â
Modified files re-staged successfully");
}
}
if !stderr.is_empty() && stderr.contains("FAILED") {
println!("â ī¸ Some pre-commit hooks failed:\n{}", stderr);
}
}
Some(3) => {
println!("âšī¸ No pre-commit hooks configured to run");
}
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("â ī¸ Pre-commit exited with code {}: {}", code, stderr);
}
None => {
println!("â ī¸ Pre-commit was terminated by signal");
}
}
Ok(())
}