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