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 hash_alternatives: Vec<&str> =
(4..=full_hash.len()).map(|len| &full_hash[..len]).collect();
let hash_pattern = hash_alternatives.join("|");
let seq_script = format!(
"#!/bin/sh\nsed -E -i.bak 's/^pick ({})([[:space:]]|$)/reword \\1\\2/' \"$1\"\nif ! grep -q '^reword ' \"$1\"; then\n echo \"octocode: failed to mark {} for reword\" >&2\n exit 1\nfi",
hash_pattern, full_hash
);
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 prompt_context = CommitPromptContext {
file_count,
additions,
deletions,
guidance_section,
docs_restriction: docs_restriction.to_string(),
files_section: file_status_section(&diff),
};
let chunks = diff_chunker::chunk_diff(&diff);
if chunks.len() == 1 {
let prompt = create_commit_prompt(
&chunks[0].content,
&prompt_context,
&prompt_context.files_section,
);
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, &prompt_context, 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 prompt_context = CommitPromptContext {
file_count,
additions,
deletions,
guidance_section,
docs_restriction: docs_restriction.to_string(),
files_section: file_status_section(diff),
};
let chunks = diff_chunker::chunk_diff(diff);
if chunks.len() == 1 {
let prompt = create_commit_prompt(
&chunks[0].content,
&prompt_context,
&prompt_context.files_section,
);
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, &prompt_context, 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 file_status_section(diff: &str) -> String {
let mut lines = Vec::new();
let mut current: Option<(String, char)> = None;
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("diff --git ") {
if let Some((path, status)) = current.take() {
lines.push(format!("{} {}", status, path));
}
let path = rest.split(" b/").nth(1).unwrap_or(rest).to_string();
current = Some((path, 'M'));
} else if let Some((_, status)) = current.as_mut() {
if line.starts_with("new file mode") {
*status = 'A';
} else if line.starts_with("deleted file mode") {
*status = 'D';
} else if line.starts_with("rename from") {
*status = 'R';
}
}
}
if let Some((path, status)) = current {
lines.push(format!("{} {}", status, path));
}
if lines.is_empty() {
String::new()
} else {
format!(
"File status (A=added, M=modified, D=deleted, R=renamed):\n{}\n\n",
lines.join("\n")
)
}
}
#[derive(Clone)]
struct CommitPromptContext {
file_count: usize,
additions: usize,
deletions: usize,
guidance_section: String,
docs_restriction: String,
files_section: String,
}
fn create_commit_prompt(
diff_content: &str,
ctx: &CommitPromptContext,
files_section: &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 vague subjects ('update files', 'various changes'); name the specific behavior that changed
Focus on WHAT functionality changed, not implementation details
---
ACCURACY RULES (CRITICAL):
Describe ONLY what is visible in the diff; never infer intent or claim behavior beyond the changed lines
Use 'add', 'implement', 'introduce' ONLY for new files (status A) or entirely new functions/types
Changes inside existing code are modifications: use extend, rework, improve, update, or fix
Never present a change to existing functionality as adding that functionality
---
COMMIT TYPE GUIDE:
feat: NEW user-visible capability (new files, new functions, new behavior)
fix: CORRECTING bugs/errors/broken functionality
refactor: IMPROVING or reworking code without changing user-visible behavior
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{}
---
FEAT vs FIX vs MODIFICATION: completely new capability = feat, correcting broken behavior = fix,
reworking existing behavior = refactor (feat only if it adds a NEW user-visible capability)
Examples: fix(auth): resolve token validation error, feat(auth): add OAuth2 support, refactor(auth): rework token refresh flow
When in doubt: prefer fix or refactor over feat
---
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:",
ctx.docs_restriction,
ctx.guidance_section,
files_section,
ctx.file_count,
ctx.additions,
ctx.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],
ctx: &CommitPromptContext,
config: &Config,
) -> Vec<String> {
let total_chunks = chunks.len();
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(
diff_chunker::MAX_PARALLEL_CHUNKS,
));
let mut join_set = JoinSet::new();
for (i, chunk) in chunks.iter().enumerate() {
let chunk_content = chunk.content.clone();
let chunk_summary = chunk.file_summary.clone();
let config = config.clone();
let ctx = ctx.clone();
let semaphore = semaphore.clone();
join_set.spawn(async move {
let _permit = semaphore
.acquire_owned()
.await
.expect("semaphore never closed");
println!(
" Processing chunk {}/{}: {}",
i + 1,
total_chunks,
chunk_summary
);
let chunk_note = format!(
"NOTE: This is chunk {}/{} of a larger diff and shows only PART of the changes.\n\
The file status list and totals below cover the WHOLE commit, not just this chunk.\n\
Added lines inside this chunk may belong to files marked M (modified) - do not assume they are new functionality.\n\n{}",
i + 1,
total_chunks,
ctx.files_section
);
let chunk_prompt = create_commit_prompt(&chunk_content, &ctx, &chunk_note);
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, total_chunks).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. Do NOT upgrade claims: if a summary says code was modified/reworked/improved, never restate it as added or implemented
9. Describe something as new/added ONLY if a chunk summary explicitly says it is new
10. 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(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_status_section() {
let diff = "diff --git a/src/existing.rs b/src/existing.rs\n\
index 123..456 100644\n\
--- a/src/existing.rs\n\
+++ b/src/existing.rs\n\
@@ -1,2 +1,3 @@\n\
+new line\n\
diff --git a/src/brand_new.rs b/src/brand_new.rs\n\
new file mode 100644\n\
--- /dev/null\n\
+++ b/src/brand_new.rs\n\
diff --git a/src/gone.rs b/src/gone.rs\n\
deleted file mode 100644\n";
let section = file_status_section(diff);
assert!(section.contains("M src/existing.rs"));
assert!(section.contains("A src/brand_new.rs"));
assert!(section.contains("D src/gone.rs"));
assert!(file_status_section("").is_empty());
}
}