1use anyhow::{Context, Result};
10use clap::Parser;
11use std::process::{Command, Stdio};
12
13use crate::data::context::ScopeDefinition;
14
15#[derive(Parser)]
21pub struct StagedCommand {
22 #[arg(long)]
24 pub print_only: bool,
25
26 #[arg(long, value_name = "DIR")]
28 pub context_dir: Option<std::path::PathBuf>,
29}
30
31#[derive(Debug, Clone)]
33pub struct StagedOutcome {
34 pub message: String,
36 pub applied: bool,
39}
40
41impl StagedCommand {
42 pub async fn execute(self, repo: Option<&std::path::Path>) -> Result<()> {
47 let _ = run_staged(
48 self.print_only,
49 None,
50 None,
51 self.context_dir.as_deref(),
52 repo,
53 )
54 .await?;
55 Ok(())
56 }
57}
58
59pub async fn run_staged(
66 print_only: bool,
67 model: Option<String>,
68 beta_header: Option<(String, String)>,
69 context_dir: Option<&std::path::Path>,
70 repo_path: Option<&std::path::Path>,
71) -> Result<StagedOutcome> {
72 let repo_root = match repo_path {
76 Some(p) => p.to_path_buf(),
77 None => std::env::current_dir().context("Failed to determine current directory")?,
78 };
79 let repo_root = repo_root.as_path();
80
81 if !has_staged_changes(repo_root)? {
82 anyhow::bail!("no staged changes — stage files with `git add` before running this command");
83 }
84
85 crate::utils::check_ai_command_prerequisites(model.as_deref(), repo_root)?;
86 let claude_client = crate::claude::create_default_claude_client(model, beta_header).await?;
87
88 let resolved_context_dir =
89 crate::claude::context::resolve_context_dir_at(context_dir, repo_root);
90 let valid_scopes =
91 crate::claude::context::load_project_scopes(&resolved_context_dir, repo_root);
92
93 run_staged_with_client(print_only, &valid_scopes, &claude_client, repo_root).await
94}
95
96pub(crate) async fn run_staged_with_client(
104 print_only: bool,
105 valid_scopes: &[ScopeDefinition],
106 claude_client: &crate::claude::client::ClaudeClient,
107 repo_root: &std::path::Path,
108) -> Result<StagedOutcome> {
109 let diff = read_staged_diff(repo_root)?;
110 let system = crate::claude::prompts::generate_staged_commit_system_prompt(valid_scopes);
111 let user = crate::claude::prompts::generate_staged_commit_user_prompt(&diff);
112
113 let raw = claude_client.send_message(&system, &user).await?;
114 let message = raw.trim().to_string();
115
116 if message.is_empty() {
117 anyhow::bail!("AI returned an empty commit message");
118 }
119
120 if print_only {
121 println!("{message}");
122 return Ok(StagedOutcome {
123 message,
124 applied: false,
125 });
126 }
127
128 commit_with_message(&message, repo_root)?;
129 Ok(StagedOutcome {
130 message,
131 applied: true,
132 })
133}
134
135fn has_staged_changes(repo_root: &std::path::Path) -> Result<bool> {
142 let output = Command::new("git")
143 .current_dir(repo_root)
144 .args(["diff", "--cached", "--quiet"])
145 .stdin(Stdio::null())
146 .env("GIT_TERMINAL_PROMPT", "0")
147 .output()
148 .context("Failed to execute git diff --cached --quiet")?;
149 match output.status.code() {
150 Some(0) => Ok(false),
151 Some(1) => Ok(true),
152 Some(code) => {
153 let stderr = String::from_utf8_lossy(&output.stderr);
154 anyhow::bail!("git diff --cached --quiet exited with code {code}: {stderr}")
155 }
156 None => anyhow::bail!("git diff --cached --quiet was terminated by a signal"),
157 }
158}
159
160fn read_staged_diff(repo_root: &std::path::Path) -> Result<String> {
162 let output = Command::new("git")
163 .current_dir(repo_root)
164 .args(["diff", "--cached"])
165 .stdin(Stdio::null())
166 .env("GIT_TERMINAL_PROMPT", "0")
167 .output()
168 .context("Failed to execute git diff --cached")?;
169 if !output.status.success() {
170 let stderr = String::from_utf8_lossy(&output.stderr);
171 anyhow::bail!("git diff --cached failed: {stderr}");
172 }
173 String::from_utf8(output.stdout).context("git diff --cached produced non-UTF-8 output")
174}
175
176fn commit_with_message(message: &str, repo_root: &std::path::Path) -> Result<()> {
188 let status = Command::new("git")
189 .current_dir(repo_root)
190 .args(["commit", "-m", message])
191 .stdin(Stdio::null())
192 .env("GIT_TERMINAL_PROMPT", "0")
193 .env("GIT_EDITOR", "true")
194 .status()
195 .context("Failed to execute git commit -m")?;
196 if !status.success() {
197 anyhow::bail!("git commit failed (exit status: {status})");
198 }
199 Ok(())
200}
201
202#[cfg(test)]
203#[allow(clippy::unwrap_used, clippy::expect_used)]
204mod tests {
205 use super::*;
206 use crate::claude::client::ClaudeClient;
207 use crate::claude::test_utils::ConfigurableMockAiClient;
208 use git2::{Repository, Signature};
209
210 fn init_empty_repo() -> tempfile::TempDir {
212 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
213 std::fs::create_dir_all(&tmp_root).unwrap();
214 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
215 let repo = Repository::init(temp_dir.path()).unwrap();
216 let mut cfg = repo.config().unwrap();
217 cfg.set_str("user.name", "Test").unwrap();
218 cfg.set_str("user.email", "test@example.com").unwrap();
219 cfg.set_str("commit.gpgsign", "false").unwrap();
220 temp_dir
221 }
222
223 fn init_repo_with_staged_change() -> tempfile::TempDir {
226 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
227 std::fs::create_dir_all(&tmp_root).unwrap();
228 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
229 let repo = Repository::init(temp_dir.path()).unwrap();
230 {
231 let mut cfg = repo.config().unwrap();
232 cfg.set_str("user.name", "Test").unwrap();
233 cfg.set_str("user.email", "test@example.com").unwrap();
234 cfg.set_str("commit.gpgsign", "false").unwrap();
235 }
236 let signature = Signature::now("Test", "test@example.com").unwrap();
238 std::fs::write(temp_dir.path().join("README"), "baseline\n").unwrap();
239 let mut idx = repo.index().unwrap();
240 idx.add_path(std::path::Path::new("README")).unwrap();
241 idx.write().unwrap();
242 let tree_id = idx.write_tree().unwrap();
243 let tree = repo.find_tree(tree_id).unwrap();
244 repo.commit(
245 Some("HEAD"),
246 &signature,
247 &signature,
248 "chore: baseline",
249 &tree,
250 &[],
251 )
252 .unwrap();
253
254 std::fs::write(temp_dir.path().join("new.rs"), "fn marker_xyz() {}\n").unwrap();
256 let mut idx = repo.index().unwrap();
257 idx.add_path(std::path::Path::new("new.rs")).unwrap();
258 idx.write().unwrap();
259
260 temp_dir
261 }
262
263 fn head_message(repo_path: &std::path::Path) -> String {
264 let repo = Repository::open(repo_path).unwrap();
265 let head = repo.head().unwrap();
266 let commit = head.peel_to_commit().unwrap();
267 commit.message().unwrap().to_string()
268 }
269
270 fn head_oid(repo_path: &std::path::Path) -> String {
271 let repo = Repository::open(repo_path).unwrap();
272 let head = repo.head().unwrap();
273 let commit = head.peel_to_commit().unwrap();
274 commit.id().to_string()
275 }
276
277 #[tokio::test]
278 async fn run_staged_errors_when_nothing_staged() {
279 let temp_dir = init_empty_repo();
280 let err = run_staged(true, None, None, None, Some(temp_dir.path()))
284 .await
285 .unwrap_err();
286 let msg = format!("{err:#}");
287 assert!(
288 msg.to_lowercase().contains("no staged changes"),
289 "expected 'no staged changes' error, got: {msg}"
290 );
291 }
292
293 #[tokio::test]
294 async fn run_staged_with_client_print_only_does_not_commit() {
295 let temp_dir = init_repo_with_staged_change();
296 let head_before = head_oid(temp_dir.path());
297
298 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add bar".to_string())]);
299 let client = ClaudeClient::new(Box::new(mock));
300
301 let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
302 .await
303 .unwrap();
304 assert!(!outcome.applied, "print_only must not apply");
305 assert_eq!(outcome.message, "feat(foo): add bar");
306
307 let head_after = head_oid(temp_dir.path());
308 assert_eq!(head_before, head_after, "HEAD must be unchanged");
309 }
310
311 #[tokio::test]
312 async fn run_staged_with_client_commits_on_default() {
313 let temp_dir = init_repo_with_staged_change();
314 let head_before = head_oid(temp_dir.path());
315
316 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add marker".to_string())]);
317 let client = ClaudeClient::new(Box::new(mock));
318
319 let outcome = run_staged_with_client(false, &[], &client, temp_dir.path())
320 .await
321 .unwrap();
322 assert!(outcome.applied, "default mode must commit");
323
324 let head_after = head_oid(temp_dir.path());
325 assert_ne!(head_before, head_after, "HEAD must advance");
326
327 let msg = head_message(temp_dir.path());
328 assert!(
329 msg.starts_with("feat(foo): add marker"),
330 "expected AI message at HEAD, got: {msg:?}"
331 );
332 }
333
334 #[tokio::test]
335 async fn run_staged_propagates_ai_failure() {
336 let temp_dir = init_repo_with_staged_change();
337 let head_before = head_oid(temp_dir.path());
338
339 let mock = ConfigurableMockAiClient::new(vec![]);
341 let client = ClaudeClient::new(Box::new(mock));
342
343 let err = run_staged_with_client(false, &[], &client, temp_dir.path())
344 .await
345 .unwrap_err();
346 let _ = err;
347
348 let head_after = head_oid(temp_dir.path());
349 assert_eq!(head_before, head_after, "HEAD must not advance on failure");
350 }
351
352 #[tokio::test]
353 async fn run_staged_with_client_trims_ai_response_whitespace() {
354 let temp_dir = init_repo_with_staged_change();
355
356 let mock = ConfigurableMockAiClient::new(vec![Ok(" feat(x): y \n\n".to_string())]);
357 let client = ClaudeClient::new(Box::new(mock));
358
359 let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
360 .await
361 .unwrap();
362 assert_eq!(outcome.message, "feat(x): y");
363 }
364
365 #[tokio::test]
366 async fn run_staged_with_client_empty_ai_response_errors() {
367 let temp_dir = init_repo_with_staged_change();
368
369 let mock = ConfigurableMockAiClient::new(vec![Ok(" \n\n".to_string())]);
370 let client = ClaudeClient::new(Box::new(mock));
371
372 let err = run_staged_with_client(false, &[], &client, temp_dir.path())
373 .await
374 .unwrap_err();
375 let msg = format!("{err:#}");
376 assert!(
377 msg.to_lowercase().contains("empty"),
378 "expected 'empty' error, got: {msg}"
379 );
380 }
381
382 #[tokio::test]
383 async fn run_staged_invokes_git_commit_subprocess_so_hooks_fire() {
384 let temp_dir = init_repo_with_staged_change();
385 let head_before = head_oid(temp_dir.path());
386
387 let hook_path = temp_dir.path().join(".git/hooks/commit-msg");
391 std::fs::write(&hook_path, "#!/bin/sh\necho REJECTED-BY-HOOK >&2\nexit 1\n").unwrap();
392 #[cfg(unix)]
393 {
394 use std::os::unix::fs::PermissionsExt;
395 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
396 perms.set_mode(0o755);
397 std::fs::set_permissions(&hook_path, perms).unwrap();
398 }
399
400 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(x): y".to_string())]);
401 let client = ClaudeClient::new(Box::new(mock));
402
403 let err = run_staged_with_client(false, &[], &client, temp_dir.path())
404 .await
405 .unwrap_err();
406 let msg = format!("{err:#}");
407 assert!(
408 msg.to_lowercase().contains("git commit failed"),
409 "expected commit-failure error message, got: {msg}"
410 );
411
412 let head_after = head_oid(temp_dir.path());
413 assert_eq!(
414 head_before, head_after,
415 "HEAD must not advance when commit-msg hook rejects"
416 );
417 }
418
419 #[tokio::test]
420 async fn run_staged_passes_valid_scopes_into_prompt() {
421 let temp_dir = init_repo_with_staged_change();
422
423 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(cli): add".to_string())]);
424 let prompts = mock.prompt_handle();
425 let client = ClaudeClient::new(Box::new(mock));
426
427 let scopes = vec![ScopeDefinition {
428 name: "cli".to_string(),
429 description: "CLI module".to_string(),
430 examples: Vec::new(),
431 file_patterns: Vec::new(),
432 }];
433
434 let _ = run_staged_with_client(true, &scopes, &client, temp_dir.path())
435 .await
436 .unwrap();
437 let recorded = prompts.prompts();
438 assert_eq!(recorded.len(), 1, "exactly one AI call");
439 let (system, _user) = &recorded[0];
440 assert!(
441 system.contains("VALID SCOPES FOR THIS PROJECT"),
442 "scopes section missing from system prompt"
443 );
444 assert!(system.contains("`cli`: CLI module"));
445 }
446
447 #[test]
448 fn staged_outcome_clone_and_debug() {
449 let outcome = StagedOutcome {
450 message: "feat: x".to_string(),
451 applied: true,
452 };
453 let cloned = outcome.clone();
454 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
455 }
456
457 #[tokio::test]
462 async fn staged_command_execute_bails_when_nothing_staged() {
463 let temp_dir = init_empty_repo();
464 let cmd = StagedCommand {
465 print_only: true,
466 context_dir: None,
467 };
468 let err = cmd.execute(Some(temp_dir.path())).await.unwrap_err();
469 let msg = format!("{err:#}");
470 assert!(
471 msg.to_lowercase().contains("no staged changes"),
472 "expected 'no staged changes' error from execute(), got: {msg}"
473 );
474 }
475
476 #[tokio::test]
481 async fn run_staged_with_client_reads_diff_from_injected_repo() {
482 let temp_dir = init_repo_with_staged_change();
483
484 let mock = ConfigurableMockAiClient::new(vec![Ok("feat: x".to_string())]);
485 let prompts = mock.prompt_handle();
486 let client = ClaudeClient::new(Box::new(mock));
487
488 let _ = run_staged_with_client(true, &[], &client, temp_dir.path())
489 .await
490 .unwrap();
491
492 let recorded = prompts.prompts();
493 assert_eq!(recorded.len(), 1, "exactly one AI call");
494 let (_system, user) = &recorded[0];
495 assert!(
496 user.contains("marker_xyz"),
497 "staged diff from the injected repo must reach the prompt: {user}"
498 );
499 }
500}