use anyhow::{Context, Result};
use clap::Parser;
use std::process::{Command, Stdio};
use crate::data::context::ScopeDefinition;
use crate::git::commit::FileChanges;
#[derive(Parser)]
pub struct StagedCommand {
#[arg(long)]
pub print_only: bool,
#[arg(long, value_name = "DIR")]
pub context_dir: Option<std::path::PathBuf>,
#[arg(long)]
pub no_ai: bool,
}
#[derive(Debug, Clone)]
pub struct StagedOutcome {
pub message: String,
pub applied: bool,
}
impl StagedCommand {
pub async fn execute(self, repo: Option<&std::path::Path>) -> Result<()> {
let outcome = run_staged(
self.print_only,
self.no_ai,
None,
None,
self.context_dir.as_deref(),
repo,
)
.await?;
if !outcome.applied {
println!("{}", outcome.message);
}
Ok(())
}
}
pub async fn run_staged(
print_only: bool,
no_ai: bool,
model: Option<String>,
beta_header: Option<(String, String)>,
context_dir: Option<&std::path::Path>,
repo_path: Option<&std::path::Path>,
) -> Result<StagedOutcome> {
let repo_root = match repo_path {
Some(p) => p.to_path_buf(),
None => std::env::current_dir().context("Failed to determine current directory")?,
};
let repo_root = repo_root.as_path();
if !has_staged_changes(repo_root)? {
anyhow::bail!("no staged changes — stage files with `git add` before running this command");
}
let resolved_context_dir =
crate::claude::context::resolve_context_dir_at(context_dir, repo_root);
let valid_scopes =
crate::claude::context::load_project_scopes(&resolved_context_dir, repo_root);
if no_ai {
return run_staged_no_ai(repo_root, &valid_scopes);
}
crate::utils::check_ai_command_prerequisites(model.as_deref(), repo_root)?;
let claude_client = crate::claude::create_default_claude_client(model, beta_header).await?;
run_staged_with_client(print_only, &valid_scopes, &claude_client, repo_root).await
}
fn run_staged_no_ai(
repo_root: &std::path::Path,
valid_scopes: &[ScopeDefinition],
) -> Result<StagedOutcome> {
let files = read_staged_files(repo_root)?;
let message = suggest_staged_skeleton(&files, valid_scopes);
Ok(StagedOutcome {
message,
applied: false,
})
}
pub(crate) async fn run_staged_with_client(
print_only: bool,
valid_scopes: &[ScopeDefinition],
claude_client: &crate::claude::client::ClaudeClient,
repo_root: &std::path::Path,
) -> Result<StagedOutcome> {
let diff = read_staged_diff(repo_root)?;
let system = crate::claude::prompts::generate_staged_commit_system_prompt(valid_scopes);
let user = crate::claude::prompts::generate_staged_commit_user_prompt(&diff);
let raw = claude_client.send_message(&system, &user).await?;
let message = raw.trim().to_string();
if message.is_empty() {
anyhow::bail!("AI returned an empty commit message");
}
if print_only {
return Ok(StagedOutcome {
message,
applied: false,
});
}
commit_with_message(&message, repo_root)?;
Ok(StagedOutcome {
message,
applied: true,
})
}
fn has_staged_changes(repo_root: &std::path::Path) -> Result<bool> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["diff", "--cached", "--quiet"])
.stdin(Stdio::null())
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.context("Failed to execute git diff --cached --quiet")?;
match output.status.code() {
Some(0) => Ok(false),
Some(1) => Ok(true),
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git diff --cached --quiet exited with code {code}: {stderr}")
}
None => anyhow::bail!("git diff --cached --quiet was terminated by a signal"),
}
}
fn read_staged_diff(repo_root: &std::path::Path) -> Result<String> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["diff", "--cached"])
.stdin(Stdio::null())
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.context("Failed to execute git diff --cached")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git diff --cached failed: {stderr}");
}
String::from_utf8(output.stdout).context("git diff --cached produced non-UTF-8 output")
}
fn parse_name_status(text: &str) -> FileChanges {
let mut file_list = Vec::new();
let mut files_added = 0;
let mut files_deleted = 0;
for line in text.lines().filter(|l| !l.is_empty()) {
let mut fields = line.split('\t');
let Some(status) = fields.next() else {
continue;
};
let Some(file) = fields.next_back() else {
continue;
};
let status_char = status.chars().next().unwrap_or('?');
match status_char {
'A' => files_added += 1,
'D' => files_deleted += 1,
_ => {}
}
file_list.push(crate::git::commit::FileChange {
status: status_char.to_string(),
file: file.to_string(),
});
}
FileChanges {
total_files: file_list.len(),
files_added,
files_deleted,
file_list,
}
}
fn read_staged_files(repo_root: &std::path::Path) -> Result<FileChanges> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["diff", "--cached", "--name-status"])
.stdin(Stdio::null())
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.context("Failed to execute git diff --cached --name-status")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git diff --cached --name-status failed: {stderr}");
}
let text = String::from_utf8(output.stdout)
.context("git diff --cached --name-status produced non-UTF-8 output")?;
Ok(parse_name_status(&text))
}
fn suggest_staged_skeleton(files: &FileChanges, valid_scopes: &[ScopeDefinition]) -> String {
let commit_type = crate::git::commit::detect_commit_type_from_message("", files);
let file_refs: Vec<&str> = files.file_list.iter().map(|f| f.file.as_str()).collect();
match crate::git::resolve_scope(&file_refs, valid_scopes) {
Some(scope) => format!("{commit_type}({scope}): "),
None => format!("{commit_type}: "),
}
}
fn commit_with_message(message: &str, repo_root: &std::path::Path) -> Result<()> {
let status = Command::new("git")
.current_dir(repo_root)
.args(["commit", "-m", message])
.stdin(Stdio::null())
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_EDITOR", "true")
.status()
.context("Failed to execute git commit -m")?;
if !status.success() {
anyhow::bail!("git commit failed (exit status: {status})");
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::claude::client::ClaudeClient;
use crate::claude::test_utils::ConfigurableMockAiClient;
use git2::{Repository, Signature};
fn init_empty_repo() -> tempfile::TempDir {
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
std::fs::create_dir_all(&tmp_root).unwrap();
let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
cfg.set_str("commit.gpgsign", "false").unwrap();
temp_dir
}
fn init_repo_with_staged_change() -> tempfile::TempDir {
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
std::fs::create_dir_all(&tmp_root).unwrap();
let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
cfg.set_str("commit.gpgsign", "false").unwrap();
}
let signature = Signature::now("Test", "test@example.com").unwrap();
std::fs::write(temp_dir.path().join("README"), "baseline\n").unwrap();
let mut idx = repo.index().unwrap();
idx.add_path(std::path::Path::new("README")).unwrap();
idx.write().unwrap();
let tree_id = idx.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(
Some("HEAD"),
&signature,
&signature,
"chore: baseline",
&tree,
&[],
)
.unwrap();
std::fs::write(temp_dir.path().join("new.rs"), "fn marker_xyz() {}\n").unwrap();
let mut idx = repo.index().unwrap();
idx.add_path(std::path::Path::new("new.rs")).unwrap();
idx.write().unwrap();
temp_dir
}
fn head_message(repo_path: &std::path::Path) -> String {
let repo = Repository::open(repo_path).unwrap();
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();
commit.message().unwrap().to_string()
}
fn head_oid(repo_path: &std::path::Path) -> String {
let repo = Repository::open(repo_path).unwrap();
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();
commit.id().to_string()
}
#[tokio::test]
async fn run_staged_errors_when_nothing_staged() {
let temp_dir = init_empty_repo();
let err = run_staged(true, false, None, None, None, Some(temp_dir.path()))
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.to_lowercase().contains("no staged changes"),
"expected 'no staged changes' error, got: {msg}"
);
}
#[tokio::test]
async fn run_staged_with_client_print_only_does_not_commit() {
let temp_dir = init_repo_with_staged_change();
let head_before = head_oid(temp_dir.path());
let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add bar".to_string())]);
let client = ClaudeClient::new(Box::new(mock));
let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
.await
.unwrap();
assert!(!outcome.applied, "print_only must not apply");
assert_eq!(outcome.message, "feat(foo): add bar");
let head_after = head_oid(temp_dir.path());
assert_eq!(head_before, head_after, "HEAD must be unchanged");
}
#[tokio::test]
async fn run_staged_with_client_commits_on_default() {
let temp_dir = init_repo_with_staged_change();
let head_before = head_oid(temp_dir.path());
let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add marker".to_string())]);
let client = ClaudeClient::new(Box::new(mock));
let outcome = run_staged_with_client(false, &[], &client, temp_dir.path())
.await
.unwrap();
assert!(outcome.applied, "default mode must commit");
let head_after = head_oid(temp_dir.path());
assert_ne!(head_before, head_after, "HEAD must advance");
let msg = head_message(temp_dir.path());
assert!(
msg.starts_with("feat(foo): add marker"),
"expected AI message at HEAD, got: {msg:?}"
);
}
#[tokio::test]
async fn run_staged_propagates_ai_failure() {
let temp_dir = init_repo_with_staged_change();
let head_before = head_oid(temp_dir.path());
let mock = ConfigurableMockAiClient::new(vec![]);
let client = ClaudeClient::new(Box::new(mock));
let err = run_staged_with_client(false, &[], &client, temp_dir.path())
.await
.unwrap_err();
let _ = err;
let head_after = head_oid(temp_dir.path());
assert_eq!(head_before, head_after, "HEAD must not advance on failure");
}
#[tokio::test]
async fn run_staged_with_client_trims_ai_response_whitespace() {
let temp_dir = init_repo_with_staged_change();
let mock = ConfigurableMockAiClient::new(vec![Ok(" feat(x): y \n\n".to_string())]);
let client = ClaudeClient::new(Box::new(mock));
let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
.await
.unwrap();
assert_eq!(outcome.message, "feat(x): y");
}
#[tokio::test]
async fn run_staged_with_client_empty_ai_response_errors() {
let temp_dir = init_repo_with_staged_change();
let mock = ConfigurableMockAiClient::new(vec![Ok(" \n\n".to_string())]);
let client = ClaudeClient::new(Box::new(mock));
let err = run_staged_with_client(false, &[], &client, temp_dir.path())
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.to_lowercase().contains("empty"),
"expected 'empty' error, got: {msg}"
);
}
#[tokio::test]
async fn run_staged_invokes_git_commit_subprocess_so_hooks_fire() {
let temp_dir = init_repo_with_staged_change();
let head_before = head_oid(temp_dir.path());
let hook_path = temp_dir.path().join(".git/hooks/commit-msg");
std::fs::write(&hook_path, "#!/bin/sh\necho REJECTED-BY-HOOK >&2\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).unwrap();
}
let mock = ConfigurableMockAiClient::new(vec![Ok("feat(x): y".to_string())]);
let client = ClaudeClient::new(Box::new(mock));
let err = run_staged_with_client(false, &[], &client, temp_dir.path())
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.to_lowercase().contains("git commit failed"),
"expected commit-failure error message, got: {msg}"
);
let head_after = head_oid(temp_dir.path());
assert_eq!(
head_before, head_after,
"HEAD must not advance when commit-msg hook rejects"
);
}
#[tokio::test]
async fn run_staged_passes_valid_scopes_into_prompt() {
let temp_dir = init_repo_with_staged_change();
let mock = ConfigurableMockAiClient::new(vec![Ok("feat(cli): add".to_string())]);
let prompts = mock.prompt_handle();
let client = ClaudeClient::new(Box::new(mock));
let scopes = vec![ScopeDefinition {
name: "cli".to_string(),
description: "CLI module".to_string(),
examples: Vec::new(),
file_patterns: Vec::new(),
}];
let _ = run_staged_with_client(true, &scopes, &client, temp_dir.path())
.await
.unwrap();
let recorded = prompts.prompts();
assert_eq!(recorded.len(), 1, "exactly one AI call");
let (system, _user) = &recorded[0];
assert!(
system.contains("VALID SCOPES FOR THIS PROJECT"),
"scopes section missing from system prompt"
);
assert!(system.contains("`cli`: CLI module"));
}
#[test]
fn staged_outcome_clone_and_debug() {
let outcome = StagedOutcome {
message: "feat: x".to_string(),
applied: true,
};
let cloned = outcome.clone();
assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
}
#[tokio::test]
async fn staged_command_execute_bails_when_nothing_staged() {
let temp_dir = init_empty_repo();
let cmd = StagedCommand {
print_only: true,
context_dir: None,
no_ai: false,
};
let err = cmd.execute(Some(temp_dir.path())).await.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.to_lowercase().contains("no staged changes"),
"expected 'no staged changes' error from execute(), got: {msg}"
);
}
#[tokio::test]
async fn run_staged_with_client_reads_diff_from_injected_repo() {
let temp_dir = init_repo_with_staged_change();
let mock = ConfigurableMockAiClient::new(vec![Ok("feat: x".to_string())]);
let prompts = mock.prompt_handle();
let client = ClaudeClient::new(Box::new(mock));
let _ = run_staged_with_client(true, &[], &client, temp_dir.path())
.await
.unwrap();
let recorded = prompts.prompts();
assert_eq!(recorded.len(), 1, "exactly one AI call");
let (_system, user) = &recorded[0];
assert!(
user.contains("marker_xyz"),
"staged diff from the injected repo must reach the prompt: {user}"
);
}
fn init_repo_with_staged_cargo_toml() -> tempfile::TempDir {
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
std::fs::create_dir_all(&tmp_root).unwrap();
let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
cfg.set_str("commit.gpgsign", "false").unwrap();
}
let signature = Signature::now("Test", "test@example.com").unwrap();
std::fs::write(temp_dir.path().join("README"), "baseline\n").unwrap();
let mut idx = repo.index().unwrap();
idx.add_path(std::path::Path::new("README")).unwrap();
idx.write().unwrap();
let tree_id = idx.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(
Some("HEAD"),
&signature,
&signature,
"chore: baseline",
&tree,
&[],
)
.unwrap();
std::fs::write(
temp_dir.path().join("Cargo.toml"),
"[package]\nname = \"x\"\n",
)
.unwrap();
let mut idx = repo.index().unwrap();
idx.add_path(std::path::Path::new("Cargo.toml")).unwrap();
idx.write().unwrap();
temp_dir
}
fn write_cargo_scope(context_dir: &std::path::Path) {
std::fs::create_dir_all(context_dir).unwrap();
std::fs::write(
context_dir.join("scopes.yaml"),
"scopes:\n - name: cargo\n description: Cargo files\n examples: []\n file_patterns:\n - Cargo.toml\n - Cargo.lock\n",
)
.unwrap();
}
#[test]
fn parse_name_status_added_file() {
let files = parse_name_status("A\tCargo.toml\n");
assert_eq!(files.total_files, 1);
assert_eq!(files.files_added, 1);
assert_eq!(files.files_deleted, 0);
assert_eq!(files.file_list[0].status, "A");
assert_eq!(files.file_list[0].file, "Cargo.toml");
}
#[test]
fn parse_name_status_modified_file() {
let files = parse_name_status("M\tsrc/main.rs\n");
assert_eq!(files.files_added, 0);
assert_eq!(files.files_deleted, 0);
assert_eq!(files.file_list[0].status, "M");
}
#[test]
fn parse_name_status_deleted_file() {
let files = parse_name_status("D\told.rs\n");
assert_eq!(files.files_deleted, 1);
assert_eq!(files.file_list[0].status, "D");
}
#[test]
fn parse_name_status_rename_uses_new_path_as_file() {
let files = parse_name_status("R100\told.rs\tnew.rs\n");
assert_eq!(files.file_list.len(), 1);
assert_eq!(files.file_list[0].status, "R");
assert_eq!(files.file_list[0].file, "new.rs");
}
#[test]
fn parse_name_status_blank_lines_ignored() {
let files = parse_name_status("A\ta.rs\n\nM\tb.rs\n");
assert_eq!(files.total_files, 2);
}
#[test]
fn parse_name_status_line_without_tab_is_skipped() {
let files = parse_name_status("A\ta.rs\nA\nM\tb.rs\n");
assert_eq!(files.total_files, 2);
assert_eq!(files.file_list[0].file, "a.rs");
assert_eq!(files.file_list[1].file, "b.rs");
}
#[test]
fn read_staged_files_errors_when_git_command_fails() {
let temp_dir = tempfile::tempdir().unwrap();
let err = read_staged_files(temp_dir.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.to_lowercase()
.contains("git diff --cached --name-status failed"),
"expected a git-failure error, got: {msg}"
);
}
#[tokio::test]
async fn run_staged_no_ai_prints_deterministic_skeleton_and_does_not_commit() {
let temp_dir = init_repo_with_staged_cargo_toml();
let context_dir = temp_dir.path().join(".omni-dev");
write_cargo_scope(&context_dir);
let head_before = head_oid(temp_dir.path());
let outcome = run_staged(
false,
true,
None,
None,
Some(&context_dir),
Some(temp_dir.path()),
)
.await
.unwrap();
assert!(!outcome.applied, "--no-ai must never commit");
assert_eq!(outcome.message, "feat(cargo): ");
let head_after = head_oid(temp_dir.path());
assert_eq!(head_before, head_after, "HEAD must be unchanged");
}
#[test]
fn run_staged_no_ai_no_matching_scope_omits_parens() {
let files = crate::git::commit::FileChanges {
total_files: 1,
files_added: 1,
files_deleted: 0,
file_list: vec![crate::git::commit::FileChange {
status: "A".to_string(),
file: "new.rs".to_string(),
}],
};
assert_eq!(suggest_staged_skeleton(&files, &[]), "feat: ");
}
#[tokio::test]
async fn run_staged_no_ai_errors_when_nothing_staged() {
let temp_dir = init_empty_repo();
let err = run_staged(false, true, None, None, None, Some(temp_dir.path()))
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.to_lowercase().contains("no staged changes"));
}
#[tokio::test]
async fn staged_command_execute_no_ai_dispatches_and_never_commits() {
let temp_dir = init_repo_with_staged_cargo_toml();
let head_before = head_oid(temp_dir.path());
let cmd = StagedCommand {
print_only: false,
context_dir: Some(temp_dir.path().join(".omni-dev")),
no_ai: true,
};
let result = cmd.execute(Some(temp_dir.path())).await;
assert!(result.is_ok(), "expected clean exit, got: {result:?}");
let head_after = head_oid(temp_dir.path());
assert_eq!(
head_before, head_after,
"HEAD must be unchanged (no_ai never commits, even with print_only: false)"
);
}
}