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) -> Result<()> {
50 let beta = self
51 .beta_header
52 .as_deref()
53 .map(parse_beta_header)
54 .transpose()?;
55 let _ = run_staged(
56 self.print_only,
57 self.model,
58 beta,
59 self.context_dir.as_deref(),
60 None,
61 )
62 .await?;
63 Ok(())
64 }
65}
66
67pub async fn run_staged(
73 print_only: bool,
74 model: Option<String>,
75 beta_header: Option<(String, String)>,
76 context_dir: Option<&std::path::Path>,
77 repo_path: Option<&std::path::Path>,
78) -> Result<StagedOutcome> {
79 let _cwd_guard = match repo_path {
80 Some(p) => Some(super::CwdGuard::enter(p).await?),
81 None => None,
82 };
83
84 if !has_staged_changes()? {
85 anyhow::bail!("no staged changes — stage files with `git add` before running this command");
86 }
87
88 crate::utils::check_ai_command_prerequisites(model.as_deref())?;
89 let claude_client = crate::claude::create_default_claude_client(model, beta_header).await?;
90
91 let resolved_context_dir = crate::claude::context::resolve_context_dir(context_dir);
92 let valid_scopes = crate::claude::context::load_project_scopes(
93 &resolved_context_dir,
94 &std::path::PathBuf::from("."),
95 );
96
97 run_staged_with_client(print_only, &valid_scopes, &claude_client).await
98}
99
100pub(crate) async fn run_staged_with_client(
108 print_only: bool,
109 valid_scopes: &[ScopeDefinition],
110 claude_client: &crate::claude::client::ClaudeClient,
111) -> Result<StagedOutcome> {
112 let diff = read_staged_diff()?;
113 let system = crate::claude::prompts::generate_staged_commit_system_prompt(valid_scopes);
114 let user = crate::claude::prompts::generate_staged_commit_user_prompt(&diff);
115
116 let raw = claude_client.send_message(&system, &user).await?;
117 let message = raw.trim().to_string();
118
119 if message.is_empty() {
120 anyhow::bail!("AI returned an empty commit message");
121 }
122
123 if print_only {
124 println!("{message}");
125 return Ok(StagedOutcome {
126 message,
127 applied: false,
128 });
129 }
130
131 commit_with_message(&message)?;
132 Ok(StagedOutcome {
133 message,
134 applied: true,
135 })
136}
137
138fn has_staged_changes() -> Result<bool> {
145 let output = Command::new("git")
146 .args(["diff", "--cached", "--quiet"])
147 .stdin(Stdio::null())
148 .env("GIT_TERMINAL_PROMPT", "0")
149 .output()
150 .context("Failed to execute git diff --cached --quiet")?;
151 match output.status.code() {
152 Some(0) => Ok(false),
153 Some(1) => Ok(true),
154 Some(code) => {
155 let stderr = String::from_utf8_lossy(&output.stderr);
156 anyhow::bail!("git diff --cached --quiet exited with code {code}: {stderr}")
157 }
158 None => anyhow::bail!("git diff --cached --quiet was terminated by a signal"),
159 }
160}
161
162fn read_staged_diff() -> Result<String> {
164 let output = Command::new("git")
165 .args(["diff", "--cached"])
166 .stdin(Stdio::null())
167 .env("GIT_TERMINAL_PROMPT", "0")
168 .output()
169 .context("Failed to execute git diff --cached")?;
170 if !output.status.success() {
171 let stderr = String::from_utf8_lossy(&output.stderr);
172 anyhow::bail!("git diff --cached failed: {stderr}");
173 }
174 String::from_utf8(output.stdout).context("git diff --cached produced non-UTF-8 output")
175}
176
177fn commit_with_message(message: &str) -> Result<()> {
189 let status = Command::new("git")
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()))
283 .await
284 .unwrap_err();
285 let msg = format!("{err:#}");
286 assert!(
287 msg.to_lowercase().contains("no staged changes"),
288 "expected 'no staged changes' error, got: {msg}"
289 );
290 }
291
292 #[tokio::test]
293 async fn run_staged_with_client_print_only_does_not_commit() {
294 let temp_dir = init_repo_with_staged_change();
295 let _guard = super::super::CwdGuard::enter(temp_dir.path())
296 .await
297 .unwrap();
298 let head_before = head_oid(temp_dir.path());
299
300 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add bar".to_string())]);
301 let client = ClaudeClient::new(Box::new(mock));
302
303 let outcome = run_staged_with_client(true, &[], &client).await.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 _guard = super::super::CwdGuard::enter(temp_dir.path())
315 .await
316 .unwrap();
317 let head_before = head_oid(temp_dir.path());
318
319 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add marker".to_string())]);
320 let client = ClaudeClient::new(Box::new(mock));
321
322 let outcome = run_staged_with_client(false, &[], &client).await.unwrap();
323 assert!(outcome.applied, "default mode must commit");
324
325 let head_after = head_oid(temp_dir.path());
326 assert_ne!(head_before, head_after, "HEAD must advance");
327
328 let msg = head_message(temp_dir.path());
329 assert!(
330 msg.starts_with("feat(foo): add marker"),
331 "expected AI message at HEAD, got: {msg:?}"
332 );
333 }
334
335 #[tokio::test]
336 async fn run_staged_propagates_ai_failure() {
337 let temp_dir = init_repo_with_staged_change();
338 let _guard = super::super::CwdGuard::enter(temp_dir.path())
339 .await
340 .unwrap();
341 let head_before = head_oid(temp_dir.path());
342
343 let mock = ConfigurableMockAiClient::new(vec![]);
345 let client = ClaudeClient::new(Box::new(mock));
346
347 let err = run_staged_with_client(false, &[], &client)
348 .await
349 .unwrap_err();
350 let _ = err;
351
352 let head_after = head_oid(temp_dir.path());
353 assert_eq!(head_before, head_after, "HEAD must not advance on failure");
354 }
355
356 #[tokio::test]
357 async fn run_staged_with_client_trims_ai_response_whitespace() {
358 let temp_dir = init_repo_with_staged_change();
359 let _guard = super::super::CwdGuard::enter(temp_dir.path())
360 .await
361 .unwrap();
362
363 let mock = ConfigurableMockAiClient::new(vec![Ok(" feat(x): y \n\n".to_string())]);
364 let client = ClaudeClient::new(Box::new(mock));
365
366 let outcome = run_staged_with_client(true, &[], &client).await.unwrap();
367 assert_eq!(outcome.message, "feat(x): y");
368 }
369
370 #[tokio::test]
371 async fn run_staged_with_client_empty_ai_response_errors() {
372 let temp_dir = init_repo_with_staged_change();
373 let _guard = super::super::CwdGuard::enter(temp_dir.path())
374 .await
375 .unwrap();
376
377 let mock = ConfigurableMockAiClient::new(vec![Ok(" \n\n".to_string())]);
378 let client = ClaudeClient::new(Box::new(mock));
379
380 let err = run_staged_with_client(false, &[], &client)
381 .await
382 .unwrap_err();
383 let msg = format!("{err:#}");
384 assert!(
385 msg.to_lowercase().contains("empty"),
386 "expected 'empty' error, got: {msg}"
387 );
388 }
389
390 #[tokio::test]
391 async fn run_staged_invokes_git_commit_subprocess_so_hooks_fire() {
392 let temp_dir = init_repo_with_staged_change();
393 let _guard = super::super::CwdGuard::enter(temp_dir.path())
394 .await
395 .unwrap();
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)
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 let _guard = super::super::CwdGuard::enter(temp_dir.path())
434 .await
435 .unwrap();
436
437 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(cli): add".to_string())]);
438 let prompts = mock.prompt_handle();
439 let client = ClaudeClient::new(Box::new(mock));
440
441 let scopes = vec![ScopeDefinition {
442 name: "cli".to_string(),
443 description: "CLI module".to_string(),
444 examples: Vec::new(),
445 file_patterns: Vec::new(),
446 }];
447
448 let _ = run_staged_with_client(true, &scopes, &client)
449 .await
450 .unwrap();
451 let recorded = prompts.prompts();
452 assert_eq!(recorded.len(), 1, "exactly one AI call");
453 let (system, _user) = &recorded[0];
454 assert!(
455 system.contains("VALID SCOPES FOR THIS PROJECT"),
456 "scopes section missing from system prompt"
457 );
458 assert!(system.contains("`cli`: CLI module"));
459 }
460
461 #[test]
462 fn staged_outcome_clone_and_debug() {
463 let outcome = StagedOutcome {
464 message: "feat: x".to_string(),
465 applied: true,
466 };
467 let cloned = outcome.clone();
468 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
469 }
470
471 #[tokio::test]
476 async fn staged_command_execute_bails_when_nothing_staged() {
477 let temp_dir = init_empty_repo();
478 let _guard = super::super::CwdGuard::enter(temp_dir.path())
479 .await
480 .unwrap();
481 let cmd = StagedCommand {
482 print_only: true,
483 model: None,
484 beta_header: None,
485 context_dir: None,
486 };
487 let err = cmd.execute().await.unwrap_err();
488 let msg = format!("{err:#}");
489 assert!(
490 msg.to_lowercase().contains("no staged changes"),
491 "expected 'no staged changes' error from execute(), got: {msg}"
492 );
493 }
494
495 #[tokio::test]
498 async fn staged_command_execute_rejects_malformed_beta_header() {
499 let temp_dir = init_empty_repo();
500 let _guard = super::super::CwdGuard::enter(temp_dir.path())
501 .await
502 .unwrap();
503 let cmd = StagedCommand {
504 print_only: true,
505 model: None,
506 beta_header: Some("no-colon-here".to_string()),
507 context_dir: None,
508 };
509 let err = cmd.execute().await.unwrap_err();
510 let msg = format!("{err:#}");
511 assert!(
512 msg.contains("Invalid --beta-header"),
513 "expected beta-header parse error, got: {msg}"
514 );
515 }
516}