1use anyhow::{Context, Result};
13use clap::Parser;
14use std::process::{Command, Stdio};
15
16use crate::data::context::ScopeDefinition;
17use crate::git::commit::FileChanges;
18
19#[derive(Parser)]
28pub struct StagedCommand {
29 #[arg(long)]
31 pub print_only: bool,
32
33 #[arg(long, value_name = "DIR")]
35 pub context_dir: Option<std::path::PathBuf>,
36
37 #[arg(long)]
47 pub no_ai: bool,
48
49 #[command(flatten)]
51 pub ai: crate::cli::ai_backend_args::AiBackendArgs,
52}
53
54#[derive(Debug, Clone)]
56pub struct StagedOutcome {
57 pub message: String,
59 pub applied: bool,
62}
63
64impl StagedCommand {
65 pub async fn execute(self, repo: Option<&std::path::Path>) -> Result<()> {
70 self.ai.apply();
71
72 let outcome = run_staged(
73 self.print_only,
74 self.no_ai,
75 None,
76 None,
77 self.context_dir.as_deref(),
78 repo,
79 )
80 .await?;
81
82 if !outcome.applied {
83 println!("{}", outcome.message);
84 }
85
86 Ok(())
87 }
88}
89
90pub async fn run_staged(
101 print_only: bool,
102 no_ai: bool,
103 model: Option<String>,
104 beta_header: Option<(String, String)>,
105 context_dir: Option<&std::path::Path>,
106 repo_path: Option<&std::path::Path>,
107) -> Result<StagedOutcome> {
108 let repo_root = match repo_path {
112 Some(p) => p.to_path_buf(),
113 None => std::env::current_dir().context("Failed to determine current directory")?,
114 };
115 let repo_root = repo_root.as_path();
116
117 if !has_staged_changes(repo_root)? {
118 anyhow::bail!("no staged changes — stage files with `git add` before running this command");
119 }
120
121 let resolved_context_dir =
122 crate::claude::context::resolve_context_dir_at(context_dir, repo_root);
123 let valid_scopes =
124 crate::claude::context::load_project_scopes(&resolved_context_dir, repo_root);
125
126 if no_ai {
127 return run_staged_no_ai(repo_root, &valid_scopes);
128 }
129
130 crate::utils::check_ai_command_prerequisites(model.as_deref(), repo_root)?;
131 let claude_client = crate::claude::create_default_claude_client(model, beta_header).await?;
132
133 run_staged_with_client(print_only, &valid_scopes, &claude_client, repo_root).await
134}
135
136fn run_staged_no_ai(
145 repo_root: &std::path::Path,
146 valid_scopes: &[ScopeDefinition],
147) -> Result<StagedOutcome> {
148 let files = read_staged_files(repo_root)?;
149 let message = suggest_staged_skeleton(&files, valid_scopes);
150 Ok(StagedOutcome {
151 message,
152 applied: false,
153 })
154}
155
156pub(crate) async fn run_staged_with_client(
164 print_only: bool,
165 valid_scopes: &[ScopeDefinition],
166 claude_client: &crate::claude::client::ClaudeClient,
167 repo_root: &std::path::Path,
168) -> Result<StagedOutcome> {
169 let diff = read_staged_diff(repo_root)?;
170 let system = crate::claude::prompts::generate_staged_commit_system_prompt(valid_scopes);
171 let user = crate::claude::prompts::generate_staged_commit_user_prompt(&diff);
172
173 let raw = claude_client.send_message(&system, &user).await?;
174 let message = raw.trim().to_string();
175
176 if message.is_empty() {
177 anyhow::bail!("AI returned an empty commit message");
178 }
179
180 if print_only {
181 return Ok(StagedOutcome {
182 message,
183 applied: false,
184 });
185 }
186
187 commit_with_message(&message, repo_root)?;
188 Ok(StagedOutcome {
189 message,
190 applied: true,
191 })
192}
193
194fn has_staged_changes(repo_root: &std::path::Path) -> Result<bool> {
201 let output = Command::new("git")
202 .current_dir(repo_root)
203 .args(["diff", "--cached", "--quiet"])
204 .stdin(Stdio::null())
205 .env("GIT_TERMINAL_PROMPT", "0")
206 .output()
207 .context("Failed to execute git diff --cached --quiet")?;
208 match output.status.code() {
209 Some(0) => Ok(false),
210 Some(1) => Ok(true),
211 Some(code) => {
212 let stderr = String::from_utf8_lossy(&output.stderr);
213 anyhow::bail!("git diff --cached --quiet exited with code {code}: {stderr}")
214 }
215 None => anyhow::bail!("git diff --cached --quiet was terminated by a signal"),
216 }
217}
218
219fn read_staged_diff(repo_root: &std::path::Path) -> Result<String> {
221 let output = Command::new("git")
222 .current_dir(repo_root)
223 .args(["diff", "--cached"])
224 .stdin(Stdio::null())
225 .env("GIT_TERMINAL_PROMPT", "0")
226 .output()
227 .context("Failed to execute git diff --cached")?;
228 if !output.status.success() {
229 let stderr = String::from_utf8_lossy(&output.stderr);
230 anyhow::bail!("git diff --cached failed: {stderr}");
231 }
232 String::from_utf8(output.stdout).context("git diff --cached produced non-UTF-8 output")
233}
234
235fn parse_name_status(text: &str) -> FileChanges {
242 let mut file_list = Vec::new();
243 let mut files_added = 0;
244 let mut files_deleted = 0;
245
246 for line in text.lines().filter(|l| !l.is_empty()) {
247 let mut fields = line.split('\t');
248 let Some(status) = fields.next() else {
249 continue;
250 };
251 let Some(file) = fields.next_back() else {
252 continue;
253 };
254 let status_char = status.chars().next().unwrap_or('?');
255 match status_char {
256 'A' => files_added += 1,
257 'D' => files_deleted += 1,
258 _ => {}
259 }
260 file_list.push(crate::git::commit::FileChange {
261 status: status_char.to_string(),
262 file: file.to_string(),
263 });
264 }
265
266 FileChanges {
267 total_files: file_list.len(),
268 files_added,
269 files_deleted,
270 file_list,
271 }
272}
273
274fn read_staged_files(repo_root: &std::path::Path) -> Result<FileChanges> {
276 let output = Command::new("git")
277 .current_dir(repo_root)
278 .args(["diff", "--cached", "--name-status"])
279 .stdin(Stdio::null())
280 .env("GIT_TERMINAL_PROMPT", "0")
281 .output()
282 .context("Failed to execute git diff --cached --name-status")?;
283 if !output.status.success() {
284 let stderr = String::from_utf8_lossy(&output.stderr);
285 anyhow::bail!("git diff --cached --name-status failed: {stderr}");
286 }
287 let text = String::from_utf8(output.stdout)
288 .context("git diff --cached --name-status produced non-UTF-8 output")?;
289 Ok(parse_name_status(&text))
290}
291
292fn suggest_staged_skeleton(files: &FileChanges, valid_scopes: &[ScopeDefinition]) -> String {
301 let commit_type = crate::git::commit::detect_commit_type_from_message("", files);
302 let file_refs: Vec<&str> = files.file_list.iter().map(|f| f.file.as_str()).collect();
303 match crate::git::resolve_scope(&file_refs, valid_scopes) {
304 Some(scope) => format!("{commit_type}({scope}): "),
305 None => format!("{commit_type}: "),
306 }
307}
308
309fn commit_with_message(message: &str, repo_root: &std::path::Path) -> Result<()> {
321 let status = Command::new("git")
322 .current_dir(repo_root)
323 .args(["commit", "-m", message])
324 .stdin(Stdio::null())
325 .env("GIT_TERMINAL_PROMPT", "0")
326 .env("GIT_EDITOR", "true")
327 .status()
328 .context("Failed to execute git commit -m")?;
329 if !status.success() {
330 anyhow::bail!("git commit failed (exit status: {status})");
331 }
332 Ok(())
333}
334
335#[cfg(test)]
336#[allow(clippy::unwrap_used, clippy::expect_used)]
337mod tests {
338 use super::*;
339 use crate::claude::client::ClaudeClient;
340 use crate::claude::test_utils::ConfigurableMockAiClient;
341 use git2::{Repository, Signature};
342
343 fn init_empty_repo() -> tempfile::TempDir {
345 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
346 std::fs::create_dir_all(&tmp_root).unwrap();
347 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
348 let repo = Repository::init(temp_dir.path()).unwrap();
349 let mut cfg = repo.config().unwrap();
350 cfg.set_str("user.name", "Test").unwrap();
351 cfg.set_str("user.email", "test@example.com").unwrap();
352 cfg.set_str("commit.gpgsign", "false").unwrap();
353 temp_dir
354 }
355
356 fn init_repo_with_staged_change() -> tempfile::TempDir {
359 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
360 std::fs::create_dir_all(&tmp_root).unwrap();
361 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
362 let repo = Repository::init(temp_dir.path()).unwrap();
363 {
364 let mut cfg = repo.config().unwrap();
365 cfg.set_str("user.name", "Test").unwrap();
366 cfg.set_str("user.email", "test@example.com").unwrap();
367 cfg.set_str("commit.gpgsign", "false").unwrap();
368 }
369 let signature = Signature::now("Test", "test@example.com").unwrap();
371 std::fs::write(temp_dir.path().join("README"), "baseline\n").unwrap();
372 let mut idx = repo.index().unwrap();
373 idx.add_path(std::path::Path::new("README")).unwrap();
374 idx.write().unwrap();
375 let tree_id = idx.write_tree().unwrap();
376 let tree = repo.find_tree(tree_id).unwrap();
377 repo.commit(
378 Some("HEAD"),
379 &signature,
380 &signature,
381 "chore: baseline",
382 &tree,
383 &[],
384 )
385 .unwrap();
386
387 std::fs::write(temp_dir.path().join("new.rs"), "fn marker_xyz() {}\n").unwrap();
389 let mut idx = repo.index().unwrap();
390 idx.add_path(std::path::Path::new("new.rs")).unwrap();
391 idx.write().unwrap();
392
393 temp_dir
394 }
395
396 fn head_message(repo_path: &std::path::Path) -> String {
397 let repo = Repository::open(repo_path).unwrap();
398 let head = repo.head().unwrap();
399 let commit = head.peel_to_commit().unwrap();
400 commit.message().unwrap().to_string()
401 }
402
403 fn head_oid(repo_path: &std::path::Path) -> String {
404 let repo = Repository::open(repo_path).unwrap();
405 let head = repo.head().unwrap();
406 let commit = head.peel_to_commit().unwrap();
407 commit.id().to_string()
408 }
409
410 #[tokio::test]
411 async fn run_staged_errors_when_nothing_staged() {
412 let temp_dir = init_empty_repo();
413 let err = run_staged(true, false, None, None, None, Some(temp_dir.path()))
417 .await
418 .unwrap_err();
419 let msg = format!("{err:#}");
420 assert!(
421 msg.to_lowercase().contains("no staged changes"),
422 "expected 'no staged changes' error, got: {msg}"
423 );
424 }
425
426 #[tokio::test]
427 async fn run_staged_with_client_print_only_does_not_commit() {
428 let temp_dir = init_repo_with_staged_change();
429 let head_before = head_oid(temp_dir.path());
430
431 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add bar".to_string())]);
432 let client = ClaudeClient::new(Box::new(mock));
433
434 let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
435 .await
436 .unwrap();
437 assert!(!outcome.applied, "print_only must not apply");
438 assert_eq!(outcome.message, "feat(foo): add bar");
439
440 let head_after = head_oid(temp_dir.path());
441 assert_eq!(head_before, head_after, "HEAD must be unchanged");
442 }
443
444 #[tokio::test]
445 async fn run_staged_with_client_commits_on_default() {
446 let temp_dir = init_repo_with_staged_change();
447 let head_before = head_oid(temp_dir.path());
448
449 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add marker".to_string())]);
450 let client = ClaudeClient::new(Box::new(mock));
451
452 let outcome = run_staged_with_client(false, &[], &client, temp_dir.path())
453 .await
454 .unwrap();
455 assert!(outcome.applied, "default mode must commit");
456
457 let head_after = head_oid(temp_dir.path());
458 assert_ne!(head_before, head_after, "HEAD must advance");
459
460 let msg = head_message(temp_dir.path());
461 assert!(
462 msg.starts_with("feat(foo): add marker"),
463 "expected AI message at HEAD, got: {msg:?}"
464 );
465 }
466
467 #[tokio::test]
468 async fn run_staged_propagates_ai_failure() {
469 let temp_dir = init_repo_with_staged_change();
470 let head_before = head_oid(temp_dir.path());
471
472 let mock = ConfigurableMockAiClient::new(vec![]);
474 let client = ClaudeClient::new(Box::new(mock));
475
476 let err = run_staged_with_client(false, &[], &client, temp_dir.path())
477 .await
478 .unwrap_err();
479 let _ = err;
480
481 let head_after = head_oid(temp_dir.path());
482 assert_eq!(head_before, head_after, "HEAD must not advance on failure");
483 }
484
485 #[tokio::test]
486 async fn run_staged_with_client_trims_ai_response_whitespace() {
487 let temp_dir = init_repo_with_staged_change();
488
489 let mock = ConfigurableMockAiClient::new(vec![Ok(" feat(x): y \n\n".to_string())]);
490 let client = ClaudeClient::new(Box::new(mock));
491
492 let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
493 .await
494 .unwrap();
495 assert_eq!(outcome.message, "feat(x): y");
496 }
497
498 #[tokio::test]
499 async fn run_staged_with_client_empty_ai_response_errors() {
500 let temp_dir = init_repo_with_staged_change();
501
502 let mock = ConfigurableMockAiClient::new(vec![Ok(" \n\n".to_string())]);
503 let client = ClaudeClient::new(Box::new(mock));
504
505 let err = run_staged_with_client(false, &[], &client, temp_dir.path())
506 .await
507 .unwrap_err();
508 let msg = format!("{err:#}");
509 assert!(
510 msg.to_lowercase().contains("empty"),
511 "expected 'empty' error, got: {msg}"
512 );
513 }
514
515 #[tokio::test]
516 async fn run_staged_invokes_git_commit_subprocess_so_hooks_fire() {
517 let temp_dir = init_repo_with_staged_change();
518 let head_before = head_oid(temp_dir.path());
519
520 let hook_path = temp_dir.path().join(".git/hooks/commit-msg");
524 std::fs::write(&hook_path, "#!/bin/sh\necho REJECTED-BY-HOOK >&2\nexit 1\n").unwrap();
525 #[cfg(unix)]
526 {
527 use std::os::unix::fs::PermissionsExt;
528 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
529 perms.set_mode(0o755);
530 std::fs::set_permissions(&hook_path, perms).unwrap();
531 }
532
533 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(x): y".to_string())]);
534 let client = ClaudeClient::new(Box::new(mock));
535
536 let err = run_staged_with_client(false, &[], &client, temp_dir.path())
537 .await
538 .unwrap_err();
539 let msg = format!("{err:#}");
540 assert!(
541 msg.to_lowercase().contains("git commit failed"),
542 "expected commit-failure error message, got: {msg}"
543 );
544
545 let head_after = head_oid(temp_dir.path());
546 assert_eq!(
547 head_before, head_after,
548 "HEAD must not advance when commit-msg hook rejects"
549 );
550 }
551
552 #[tokio::test]
553 async fn run_staged_passes_valid_scopes_into_prompt() {
554 let temp_dir = init_repo_with_staged_change();
555
556 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(cli): add".to_string())]);
557 let prompts = mock.prompt_handle();
558 let client = ClaudeClient::new(Box::new(mock));
559
560 let scopes = vec![ScopeDefinition {
561 name: "cli".to_string(),
562 description: "CLI module".to_string(),
563 examples: Vec::new(),
564 file_patterns: Vec::new(),
565 }];
566
567 let _ = run_staged_with_client(true, &scopes, &client, temp_dir.path())
568 .await
569 .unwrap();
570 let recorded = prompts.prompts();
571 assert_eq!(recorded.len(), 1, "exactly one AI call");
572 let (system, _user) = &recorded[0];
573 assert!(
574 system.contains("VALID SCOPES FOR THIS PROJECT"),
575 "scopes section missing from system prompt"
576 );
577 assert!(system.contains("`cli`: CLI module"));
578 }
579
580 #[test]
581 fn staged_outcome_clone_and_debug() {
582 let outcome = StagedOutcome {
583 message: "feat: x".to_string(),
584 applied: true,
585 };
586 let cloned = outcome.clone();
587 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
588 }
589
590 #[tokio::test]
595 async fn staged_command_execute_bails_when_nothing_staged() {
596 let temp_dir = init_empty_repo();
597 let cmd = StagedCommand {
598 print_only: true,
599 context_dir: None,
600 no_ai: false,
601 ai: crate::cli::ai_backend_args::AiBackendArgs::default(),
602 };
603 let err = cmd.execute(Some(temp_dir.path())).await.unwrap_err();
604 let msg = format!("{err:#}");
605 assert!(
606 msg.to_lowercase().contains("no staged changes"),
607 "expected 'no staged changes' error from execute(), got: {msg}"
608 );
609 }
610
611 #[tokio::test]
616 async fn run_staged_with_client_reads_diff_from_injected_repo() {
617 let temp_dir = init_repo_with_staged_change();
618
619 let mock = ConfigurableMockAiClient::new(vec![Ok("feat: x".to_string())]);
620 let prompts = mock.prompt_handle();
621 let client = ClaudeClient::new(Box::new(mock));
622
623 let _ = run_staged_with_client(true, &[], &client, temp_dir.path())
624 .await
625 .unwrap();
626
627 let recorded = prompts.prompts();
628 assert_eq!(recorded.len(), 1, "exactly one AI call");
629 let (_system, user) = &recorded[0];
630 assert!(
631 user.contains("marker_xyz"),
632 "staged diff from the injected repo must reach the prompt: {user}"
633 );
634 }
635
636 fn init_repo_with_staged_cargo_toml() -> tempfile::TempDir {
641 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
642 std::fs::create_dir_all(&tmp_root).unwrap();
643 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
644 let repo = Repository::init(temp_dir.path()).unwrap();
645 {
646 let mut cfg = repo.config().unwrap();
647 cfg.set_str("user.name", "Test").unwrap();
648 cfg.set_str("user.email", "test@example.com").unwrap();
649 cfg.set_str("commit.gpgsign", "false").unwrap();
650 }
651 let signature = Signature::now("Test", "test@example.com").unwrap();
652 std::fs::write(temp_dir.path().join("README"), "baseline\n").unwrap();
653 let mut idx = repo.index().unwrap();
654 idx.add_path(std::path::Path::new("README")).unwrap();
655 idx.write().unwrap();
656 let tree_id = idx.write_tree().unwrap();
657 let tree = repo.find_tree(tree_id).unwrap();
658 repo.commit(
659 Some("HEAD"),
660 &signature,
661 &signature,
662 "chore: baseline",
663 &tree,
664 &[],
665 )
666 .unwrap();
667
668 std::fs::write(
669 temp_dir.path().join("Cargo.toml"),
670 "[package]\nname = \"x\"\n",
671 )
672 .unwrap();
673 let mut idx = repo.index().unwrap();
674 idx.add_path(std::path::Path::new("Cargo.toml")).unwrap();
675 idx.write().unwrap();
676
677 temp_dir
678 }
679
680 fn write_cargo_scope(context_dir: &std::path::Path) {
683 std::fs::create_dir_all(context_dir).unwrap();
684 std::fs::write(
685 context_dir.join("scopes.yaml"),
686 "scopes:\n - name: cargo\n description: Cargo files\n examples: []\n file_patterns:\n - Cargo.toml\n - Cargo.lock\n",
687 )
688 .unwrap();
689 }
690
691 #[test]
692 fn parse_name_status_added_file() {
693 let files = parse_name_status("A\tCargo.toml\n");
694 assert_eq!(files.total_files, 1);
695 assert_eq!(files.files_added, 1);
696 assert_eq!(files.files_deleted, 0);
697 assert_eq!(files.file_list[0].status, "A");
698 assert_eq!(files.file_list[0].file, "Cargo.toml");
699 }
700
701 #[test]
702 fn parse_name_status_modified_file() {
703 let files = parse_name_status("M\tsrc/main.rs\n");
704 assert_eq!(files.files_added, 0);
705 assert_eq!(files.files_deleted, 0);
706 assert_eq!(files.file_list[0].status, "M");
707 }
708
709 #[test]
710 fn parse_name_status_deleted_file() {
711 let files = parse_name_status("D\told.rs\n");
712 assert_eq!(files.files_deleted, 1);
713 assert_eq!(files.file_list[0].status, "D");
714 }
715
716 #[test]
717 fn parse_name_status_rename_uses_new_path_as_file() {
718 let files = parse_name_status("R100\told.rs\tnew.rs\n");
719 assert_eq!(files.file_list.len(), 1);
720 assert_eq!(files.file_list[0].status, "R");
721 assert_eq!(files.file_list[0].file, "new.rs");
722 }
723
724 #[test]
725 fn parse_name_status_blank_lines_ignored() {
726 let files = parse_name_status("A\ta.rs\n\nM\tb.rs\n");
727 assert_eq!(files.total_files, 2);
728 }
729
730 #[test]
734 fn parse_name_status_line_without_tab_is_skipped() {
735 let files = parse_name_status("A\ta.rs\nA\nM\tb.rs\n");
736 assert_eq!(files.total_files, 2);
737 assert_eq!(files.file_list[0].file, "a.rs");
738 assert_eq!(files.file_list[1].file, "b.rs");
739 }
740
741 #[test]
751 fn read_staged_files_errors_when_git_command_fails() {
752 let temp_dir = tempfile::tempdir().unwrap();
753
754 let err = read_staged_files(temp_dir.path()).unwrap_err();
755 let msg = format!("{err:#}");
756 assert!(
757 msg.to_lowercase()
758 .contains("git diff --cached --name-status failed"),
759 "expected a git-failure error, got: {msg}"
760 );
761 }
762
763 #[tokio::test]
764 async fn run_staged_no_ai_prints_deterministic_skeleton_and_does_not_commit() {
765 let temp_dir = init_repo_with_staged_cargo_toml();
766 let context_dir = temp_dir.path().join(".omni-dev");
767 write_cargo_scope(&context_dir);
768 let head_before = head_oid(temp_dir.path());
769
770 let outcome = run_staged(
771 false,
772 true,
773 None,
774 None,
775 Some(&context_dir),
776 Some(temp_dir.path()),
777 )
778 .await
779 .unwrap();
780
781 assert!(!outcome.applied, "--no-ai must never commit");
782 assert_eq!(outcome.message, "feat(cargo): ");
783
784 let head_after = head_oid(temp_dir.path());
785 assert_eq!(head_before, head_after, "HEAD must be unchanged");
786 }
787
788 #[test]
789 fn run_staged_no_ai_no_matching_scope_omits_parens() {
790 let files = crate::git::commit::FileChanges {
798 total_files: 1,
799 files_added: 1,
800 files_deleted: 0,
801 file_list: vec![crate::git::commit::FileChange {
802 status: "A".to_string(),
803 file: "new.rs".to_string(),
804 }],
805 };
806 assert_eq!(suggest_staged_skeleton(&files, &[]), "feat: ");
807 }
808
809 #[tokio::test]
810 async fn run_staged_no_ai_errors_when_nothing_staged() {
811 let temp_dir = init_empty_repo();
812 let err = run_staged(false, true, None, None, None, Some(temp_dir.path()))
813 .await
814 .unwrap_err();
815 let msg = format!("{err:#}");
816 assert!(msg.to_lowercase().contains("no staged changes"));
817 }
818
819 #[tokio::test]
820 async fn staged_command_execute_no_ai_dispatches_and_never_commits() {
821 let temp_dir = init_repo_with_staged_cargo_toml();
822 let head_before = head_oid(temp_dir.path());
823
824 let cmd = StagedCommand {
825 print_only: false,
826 context_dir: Some(temp_dir.path().join(".omni-dev")),
827 no_ai: true,
828 ai: crate::cli::ai_backend_args::AiBackendArgs::default(),
829 };
830 let result = cmd.execute(Some(temp_dir.path())).await;
831 assert!(result.is_ok(), "expected clean exit, got: {result:?}");
832
833 let head_after = head_oid(temp_dir.path());
834 assert_eq!(
835 head_before, head_after,
836 "HEAD must be unchanged (no_ai never commits, even with print_only: false)"
837 );
838 }
839}