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)]
26pub struct StagedCommand {
27 #[arg(long)]
29 pub print_only: bool,
30
31 #[arg(long, value_name = "DIR")]
33 pub context_dir: Option<std::path::PathBuf>,
34
35 #[arg(long)]
45 pub no_ai: bool,
46}
47
48#[derive(Debug, Clone)]
50pub struct StagedOutcome {
51 pub message: String,
53 pub applied: bool,
56}
57
58impl StagedCommand {
59 pub async fn execute(self, repo: Option<&std::path::Path>) -> Result<()> {
64 let outcome = run_staged(
65 self.print_only,
66 self.no_ai,
67 None,
68 None,
69 self.context_dir.as_deref(),
70 repo,
71 )
72 .await?;
73
74 if !outcome.applied {
75 println!("{}", outcome.message);
76 }
77
78 Ok(())
79 }
80}
81
82pub async fn run_staged(
93 print_only: bool,
94 no_ai: bool,
95 model: Option<String>,
96 beta_header: Option<(String, String)>,
97 context_dir: Option<&std::path::Path>,
98 repo_path: Option<&std::path::Path>,
99) -> Result<StagedOutcome> {
100 let repo_root = match repo_path {
104 Some(p) => p.to_path_buf(),
105 None => std::env::current_dir().context("Failed to determine current directory")?,
106 };
107 let repo_root = repo_root.as_path();
108
109 if !has_staged_changes(repo_root)? {
110 anyhow::bail!("no staged changes — stage files with `git add` before running this command");
111 }
112
113 let resolved_context_dir =
114 crate::claude::context::resolve_context_dir_at(context_dir, repo_root);
115 let valid_scopes =
116 crate::claude::context::load_project_scopes(&resolved_context_dir, repo_root);
117
118 if no_ai {
119 return run_staged_no_ai(repo_root, &valid_scopes);
120 }
121
122 crate::utils::check_ai_command_prerequisites(model.as_deref(), repo_root)?;
123 let claude_client = crate::claude::create_default_claude_client(model, beta_header).await?;
124
125 run_staged_with_client(print_only, &valid_scopes, &claude_client, repo_root).await
126}
127
128fn run_staged_no_ai(
137 repo_root: &std::path::Path,
138 valid_scopes: &[ScopeDefinition],
139) -> Result<StagedOutcome> {
140 let files = read_staged_files(repo_root)?;
141 let message = suggest_staged_skeleton(&files, valid_scopes);
142 Ok(StagedOutcome {
143 message,
144 applied: false,
145 })
146}
147
148pub(crate) async fn run_staged_with_client(
156 print_only: bool,
157 valid_scopes: &[ScopeDefinition],
158 claude_client: &crate::claude::client::ClaudeClient,
159 repo_root: &std::path::Path,
160) -> Result<StagedOutcome> {
161 let diff = read_staged_diff(repo_root)?;
162 let system = crate::claude::prompts::generate_staged_commit_system_prompt(valid_scopes);
163 let user = crate::claude::prompts::generate_staged_commit_user_prompt(&diff);
164
165 let raw = claude_client.send_message(&system, &user).await?;
166 let message = raw.trim().to_string();
167
168 if message.is_empty() {
169 anyhow::bail!("AI returned an empty commit message");
170 }
171
172 if print_only {
173 return Ok(StagedOutcome {
174 message,
175 applied: false,
176 });
177 }
178
179 commit_with_message(&message, repo_root)?;
180 Ok(StagedOutcome {
181 message,
182 applied: true,
183 })
184}
185
186fn has_staged_changes(repo_root: &std::path::Path) -> Result<bool> {
193 let output = Command::new("git")
194 .current_dir(repo_root)
195 .args(["diff", "--cached", "--quiet"])
196 .stdin(Stdio::null())
197 .env("GIT_TERMINAL_PROMPT", "0")
198 .output()
199 .context("Failed to execute git diff --cached --quiet")?;
200 match output.status.code() {
201 Some(0) => Ok(false),
202 Some(1) => Ok(true),
203 Some(code) => {
204 let stderr = String::from_utf8_lossy(&output.stderr);
205 anyhow::bail!("git diff --cached --quiet exited with code {code}: {stderr}")
206 }
207 None => anyhow::bail!("git diff --cached --quiet was terminated by a signal"),
208 }
209}
210
211fn read_staged_diff(repo_root: &std::path::Path) -> Result<String> {
213 let output = Command::new("git")
214 .current_dir(repo_root)
215 .args(["diff", "--cached"])
216 .stdin(Stdio::null())
217 .env("GIT_TERMINAL_PROMPT", "0")
218 .output()
219 .context("Failed to execute git diff --cached")?;
220 if !output.status.success() {
221 let stderr = String::from_utf8_lossy(&output.stderr);
222 anyhow::bail!("git diff --cached failed: {stderr}");
223 }
224 String::from_utf8(output.stdout).context("git diff --cached produced non-UTF-8 output")
225}
226
227fn parse_name_status(text: &str) -> FileChanges {
234 let mut file_list = Vec::new();
235 let mut files_added = 0;
236 let mut files_deleted = 0;
237
238 for line in text.lines().filter(|l| !l.is_empty()) {
239 let mut fields = line.split('\t');
240 let Some(status) = fields.next() else {
241 continue;
242 };
243 let Some(file) = fields.next_back() else {
244 continue;
245 };
246 let status_char = status.chars().next().unwrap_or('?');
247 match status_char {
248 'A' => files_added += 1,
249 'D' => files_deleted += 1,
250 _ => {}
251 }
252 file_list.push(crate::git::commit::FileChange {
253 status: status_char.to_string(),
254 file: file.to_string(),
255 });
256 }
257
258 FileChanges {
259 total_files: file_list.len(),
260 files_added,
261 files_deleted,
262 file_list,
263 }
264}
265
266fn read_staged_files(repo_root: &std::path::Path) -> Result<FileChanges> {
268 let output = Command::new("git")
269 .current_dir(repo_root)
270 .args(["diff", "--cached", "--name-status"])
271 .stdin(Stdio::null())
272 .env("GIT_TERMINAL_PROMPT", "0")
273 .output()
274 .context("Failed to execute git diff --cached --name-status")?;
275 if !output.status.success() {
276 let stderr = String::from_utf8_lossy(&output.stderr);
277 anyhow::bail!("git diff --cached --name-status failed: {stderr}");
278 }
279 let text = String::from_utf8(output.stdout)
280 .context("git diff --cached --name-status produced non-UTF-8 output")?;
281 Ok(parse_name_status(&text))
282}
283
284fn suggest_staged_skeleton(files: &FileChanges, valid_scopes: &[ScopeDefinition]) -> String {
293 let commit_type = crate::git::commit::detect_commit_type_from_message("", files);
294 let file_refs: Vec<&str> = files.file_list.iter().map(|f| f.file.as_str()).collect();
295 match crate::git::resolve_scope(&file_refs, valid_scopes) {
296 Some(scope) => format!("{commit_type}({scope}): "),
297 None => format!("{commit_type}: "),
298 }
299}
300
301fn commit_with_message(message: &str, repo_root: &std::path::Path) -> Result<()> {
313 let status = Command::new("git")
314 .current_dir(repo_root)
315 .args(["commit", "-m", message])
316 .stdin(Stdio::null())
317 .env("GIT_TERMINAL_PROMPT", "0")
318 .env("GIT_EDITOR", "true")
319 .status()
320 .context("Failed to execute git commit -m")?;
321 if !status.success() {
322 anyhow::bail!("git commit failed (exit status: {status})");
323 }
324 Ok(())
325}
326
327#[cfg(test)]
328#[allow(clippy::unwrap_used, clippy::expect_used)]
329mod tests {
330 use super::*;
331 use crate::claude::client::ClaudeClient;
332 use crate::claude::test_utils::ConfigurableMockAiClient;
333 use git2::{Repository, Signature};
334
335 fn init_empty_repo() -> tempfile::TempDir {
337 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
338 std::fs::create_dir_all(&tmp_root).unwrap();
339 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
340 let repo = Repository::init(temp_dir.path()).unwrap();
341 let mut cfg = repo.config().unwrap();
342 cfg.set_str("user.name", "Test").unwrap();
343 cfg.set_str("user.email", "test@example.com").unwrap();
344 cfg.set_str("commit.gpgsign", "false").unwrap();
345 temp_dir
346 }
347
348 fn init_repo_with_staged_change() -> tempfile::TempDir {
351 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
352 std::fs::create_dir_all(&tmp_root).unwrap();
353 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
354 let repo = Repository::init(temp_dir.path()).unwrap();
355 {
356 let mut cfg = repo.config().unwrap();
357 cfg.set_str("user.name", "Test").unwrap();
358 cfg.set_str("user.email", "test@example.com").unwrap();
359 cfg.set_str("commit.gpgsign", "false").unwrap();
360 }
361 let signature = Signature::now("Test", "test@example.com").unwrap();
363 std::fs::write(temp_dir.path().join("README"), "baseline\n").unwrap();
364 let mut idx = repo.index().unwrap();
365 idx.add_path(std::path::Path::new("README")).unwrap();
366 idx.write().unwrap();
367 let tree_id = idx.write_tree().unwrap();
368 let tree = repo.find_tree(tree_id).unwrap();
369 repo.commit(
370 Some("HEAD"),
371 &signature,
372 &signature,
373 "chore: baseline",
374 &tree,
375 &[],
376 )
377 .unwrap();
378
379 std::fs::write(temp_dir.path().join("new.rs"), "fn marker_xyz() {}\n").unwrap();
381 let mut idx = repo.index().unwrap();
382 idx.add_path(std::path::Path::new("new.rs")).unwrap();
383 idx.write().unwrap();
384
385 temp_dir
386 }
387
388 fn head_message(repo_path: &std::path::Path) -> String {
389 let repo = Repository::open(repo_path).unwrap();
390 let head = repo.head().unwrap();
391 let commit = head.peel_to_commit().unwrap();
392 commit.message().unwrap().to_string()
393 }
394
395 fn head_oid(repo_path: &std::path::Path) -> String {
396 let repo = Repository::open(repo_path).unwrap();
397 let head = repo.head().unwrap();
398 let commit = head.peel_to_commit().unwrap();
399 commit.id().to_string()
400 }
401
402 #[tokio::test]
403 async fn run_staged_errors_when_nothing_staged() {
404 let temp_dir = init_empty_repo();
405 let err = run_staged(true, false, None, None, None, Some(temp_dir.path()))
409 .await
410 .unwrap_err();
411 let msg = format!("{err:#}");
412 assert!(
413 msg.to_lowercase().contains("no staged changes"),
414 "expected 'no staged changes' error, got: {msg}"
415 );
416 }
417
418 #[tokio::test]
419 async fn run_staged_with_client_print_only_does_not_commit() {
420 let temp_dir = init_repo_with_staged_change();
421 let head_before = head_oid(temp_dir.path());
422
423 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add bar".to_string())]);
424 let client = ClaudeClient::new(Box::new(mock));
425
426 let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
427 .await
428 .unwrap();
429 assert!(!outcome.applied, "print_only must not apply");
430 assert_eq!(outcome.message, "feat(foo): add bar");
431
432 let head_after = head_oid(temp_dir.path());
433 assert_eq!(head_before, head_after, "HEAD must be unchanged");
434 }
435
436 #[tokio::test]
437 async fn run_staged_with_client_commits_on_default() {
438 let temp_dir = init_repo_with_staged_change();
439 let head_before = head_oid(temp_dir.path());
440
441 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add marker".to_string())]);
442 let client = ClaudeClient::new(Box::new(mock));
443
444 let outcome = run_staged_with_client(false, &[], &client, temp_dir.path())
445 .await
446 .unwrap();
447 assert!(outcome.applied, "default mode must commit");
448
449 let head_after = head_oid(temp_dir.path());
450 assert_ne!(head_before, head_after, "HEAD must advance");
451
452 let msg = head_message(temp_dir.path());
453 assert!(
454 msg.starts_with("feat(foo): add marker"),
455 "expected AI message at HEAD, got: {msg:?}"
456 );
457 }
458
459 #[tokio::test]
460 async fn run_staged_propagates_ai_failure() {
461 let temp_dir = init_repo_with_staged_change();
462 let head_before = head_oid(temp_dir.path());
463
464 let mock = ConfigurableMockAiClient::new(vec![]);
466 let client = ClaudeClient::new(Box::new(mock));
467
468 let err = run_staged_with_client(false, &[], &client, temp_dir.path())
469 .await
470 .unwrap_err();
471 let _ = err;
472
473 let head_after = head_oid(temp_dir.path());
474 assert_eq!(head_before, head_after, "HEAD must not advance on failure");
475 }
476
477 #[tokio::test]
478 async fn run_staged_with_client_trims_ai_response_whitespace() {
479 let temp_dir = init_repo_with_staged_change();
480
481 let mock = ConfigurableMockAiClient::new(vec![Ok(" feat(x): y \n\n".to_string())]);
482 let client = ClaudeClient::new(Box::new(mock));
483
484 let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
485 .await
486 .unwrap();
487 assert_eq!(outcome.message, "feat(x): y");
488 }
489
490 #[tokio::test]
491 async fn run_staged_with_client_empty_ai_response_errors() {
492 let temp_dir = init_repo_with_staged_change();
493
494 let mock = ConfigurableMockAiClient::new(vec![Ok(" \n\n".to_string())]);
495 let client = ClaudeClient::new(Box::new(mock));
496
497 let err = run_staged_with_client(false, &[], &client, temp_dir.path())
498 .await
499 .unwrap_err();
500 let msg = format!("{err:#}");
501 assert!(
502 msg.to_lowercase().contains("empty"),
503 "expected 'empty' error, got: {msg}"
504 );
505 }
506
507 #[tokio::test]
508 async fn run_staged_invokes_git_commit_subprocess_so_hooks_fire() {
509 let temp_dir = init_repo_with_staged_change();
510 let head_before = head_oid(temp_dir.path());
511
512 let hook_path = temp_dir.path().join(".git/hooks/commit-msg");
516 std::fs::write(&hook_path, "#!/bin/sh\necho REJECTED-BY-HOOK >&2\nexit 1\n").unwrap();
517 #[cfg(unix)]
518 {
519 use std::os::unix::fs::PermissionsExt;
520 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
521 perms.set_mode(0o755);
522 std::fs::set_permissions(&hook_path, perms).unwrap();
523 }
524
525 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(x): y".to_string())]);
526 let client = ClaudeClient::new(Box::new(mock));
527
528 let err = run_staged_with_client(false, &[], &client, temp_dir.path())
529 .await
530 .unwrap_err();
531 let msg = format!("{err:#}");
532 assert!(
533 msg.to_lowercase().contains("git commit failed"),
534 "expected commit-failure error message, got: {msg}"
535 );
536
537 let head_after = head_oid(temp_dir.path());
538 assert_eq!(
539 head_before, head_after,
540 "HEAD must not advance when commit-msg hook rejects"
541 );
542 }
543
544 #[tokio::test]
545 async fn run_staged_passes_valid_scopes_into_prompt() {
546 let temp_dir = init_repo_with_staged_change();
547
548 let mock = ConfigurableMockAiClient::new(vec![Ok("feat(cli): add".to_string())]);
549 let prompts = mock.prompt_handle();
550 let client = ClaudeClient::new(Box::new(mock));
551
552 let scopes = vec![ScopeDefinition {
553 name: "cli".to_string(),
554 description: "CLI module".to_string(),
555 examples: Vec::new(),
556 file_patterns: Vec::new(),
557 }];
558
559 let _ = run_staged_with_client(true, &scopes, &client, temp_dir.path())
560 .await
561 .unwrap();
562 let recorded = prompts.prompts();
563 assert_eq!(recorded.len(), 1, "exactly one AI call");
564 let (system, _user) = &recorded[0];
565 assert!(
566 system.contains("VALID SCOPES FOR THIS PROJECT"),
567 "scopes section missing from system prompt"
568 );
569 assert!(system.contains("`cli`: CLI module"));
570 }
571
572 #[test]
573 fn staged_outcome_clone_and_debug() {
574 let outcome = StagedOutcome {
575 message: "feat: x".to_string(),
576 applied: true,
577 };
578 let cloned = outcome.clone();
579 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
580 }
581
582 #[tokio::test]
587 async fn staged_command_execute_bails_when_nothing_staged() {
588 let temp_dir = init_empty_repo();
589 let cmd = StagedCommand {
590 print_only: true,
591 context_dir: None,
592 no_ai: false,
593 };
594 let err = cmd.execute(Some(temp_dir.path())).await.unwrap_err();
595 let msg = format!("{err:#}");
596 assert!(
597 msg.to_lowercase().contains("no staged changes"),
598 "expected 'no staged changes' error from execute(), got: {msg}"
599 );
600 }
601
602 #[tokio::test]
607 async fn run_staged_with_client_reads_diff_from_injected_repo() {
608 let temp_dir = init_repo_with_staged_change();
609
610 let mock = ConfigurableMockAiClient::new(vec![Ok("feat: x".to_string())]);
611 let prompts = mock.prompt_handle();
612 let client = ClaudeClient::new(Box::new(mock));
613
614 let _ = run_staged_with_client(true, &[], &client, temp_dir.path())
615 .await
616 .unwrap();
617
618 let recorded = prompts.prompts();
619 assert_eq!(recorded.len(), 1, "exactly one AI call");
620 let (_system, user) = &recorded[0];
621 assert!(
622 user.contains("marker_xyz"),
623 "staged diff from the injected repo must reach the prompt: {user}"
624 );
625 }
626
627 fn init_repo_with_staged_cargo_toml() -> tempfile::TempDir {
632 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
633 std::fs::create_dir_all(&tmp_root).unwrap();
634 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
635 let repo = Repository::init(temp_dir.path()).unwrap();
636 {
637 let mut cfg = repo.config().unwrap();
638 cfg.set_str("user.name", "Test").unwrap();
639 cfg.set_str("user.email", "test@example.com").unwrap();
640 cfg.set_str("commit.gpgsign", "false").unwrap();
641 }
642 let signature = Signature::now("Test", "test@example.com").unwrap();
643 std::fs::write(temp_dir.path().join("README"), "baseline\n").unwrap();
644 let mut idx = repo.index().unwrap();
645 idx.add_path(std::path::Path::new("README")).unwrap();
646 idx.write().unwrap();
647 let tree_id = idx.write_tree().unwrap();
648 let tree = repo.find_tree(tree_id).unwrap();
649 repo.commit(
650 Some("HEAD"),
651 &signature,
652 &signature,
653 "chore: baseline",
654 &tree,
655 &[],
656 )
657 .unwrap();
658
659 std::fs::write(
660 temp_dir.path().join("Cargo.toml"),
661 "[package]\nname = \"x\"\n",
662 )
663 .unwrap();
664 let mut idx = repo.index().unwrap();
665 idx.add_path(std::path::Path::new("Cargo.toml")).unwrap();
666 idx.write().unwrap();
667
668 temp_dir
669 }
670
671 fn write_cargo_scope(context_dir: &std::path::Path) {
674 std::fs::create_dir_all(context_dir).unwrap();
675 std::fs::write(
676 context_dir.join("scopes.yaml"),
677 "scopes:\n - name: cargo\n description: Cargo files\n examples: []\n file_patterns:\n - Cargo.toml\n - Cargo.lock\n",
678 )
679 .unwrap();
680 }
681
682 #[test]
683 fn parse_name_status_added_file() {
684 let files = parse_name_status("A\tCargo.toml\n");
685 assert_eq!(files.total_files, 1);
686 assert_eq!(files.files_added, 1);
687 assert_eq!(files.files_deleted, 0);
688 assert_eq!(files.file_list[0].status, "A");
689 assert_eq!(files.file_list[0].file, "Cargo.toml");
690 }
691
692 #[test]
693 fn parse_name_status_modified_file() {
694 let files = parse_name_status("M\tsrc/main.rs\n");
695 assert_eq!(files.files_added, 0);
696 assert_eq!(files.files_deleted, 0);
697 assert_eq!(files.file_list[0].status, "M");
698 }
699
700 #[test]
701 fn parse_name_status_deleted_file() {
702 let files = parse_name_status("D\told.rs\n");
703 assert_eq!(files.files_deleted, 1);
704 assert_eq!(files.file_list[0].status, "D");
705 }
706
707 #[test]
708 fn parse_name_status_rename_uses_new_path_as_file() {
709 let files = parse_name_status("R100\told.rs\tnew.rs\n");
710 assert_eq!(files.file_list.len(), 1);
711 assert_eq!(files.file_list[0].status, "R");
712 assert_eq!(files.file_list[0].file, "new.rs");
713 }
714
715 #[test]
716 fn parse_name_status_blank_lines_ignored() {
717 let files = parse_name_status("A\ta.rs\n\nM\tb.rs\n");
718 assert_eq!(files.total_files, 2);
719 }
720
721 #[test]
725 fn parse_name_status_line_without_tab_is_skipped() {
726 let files = parse_name_status("A\ta.rs\nA\nM\tb.rs\n");
727 assert_eq!(files.total_files, 2);
728 assert_eq!(files.file_list[0].file, "a.rs");
729 assert_eq!(files.file_list[1].file, "b.rs");
730 }
731
732 #[test]
742 fn read_staged_files_errors_when_git_command_fails() {
743 let temp_dir = tempfile::tempdir().unwrap();
744
745 let err = read_staged_files(temp_dir.path()).unwrap_err();
746 let msg = format!("{err:#}");
747 assert!(
748 msg.to_lowercase()
749 .contains("git diff --cached --name-status failed"),
750 "expected a git-failure error, got: {msg}"
751 );
752 }
753
754 #[tokio::test]
755 async fn run_staged_no_ai_prints_deterministic_skeleton_and_does_not_commit() {
756 let temp_dir = init_repo_with_staged_cargo_toml();
757 let context_dir = temp_dir.path().join(".omni-dev");
758 write_cargo_scope(&context_dir);
759 let head_before = head_oid(temp_dir.path());
760
761 let outcome = run_staged(
762 false,
763 true,
764 None,
765 None,
766 Some(&context_dir),
767 Some(temp_dir.path()),
768 )
769 .await
770 .unwrap();
771
772 assert!(!outcome.applied, "--no-ai must never commit");
773 assert_eq!(outcome.message, "feat(cargo): ");
774
775 let head_after = head_oid(temp_dir.path());
776 assert_eq!(head_before, head_after, "HEAD must be unchanged");
777 }
778
779 #[test]
780 fn run_staged_no_ai_no_matching_scope_omits_parens() {
781 let files = crate::git::commit::FileChanges {
789 total_files: 1,
790 files_added: 1,
791 files_deleted: 0,
792 file_list: vec![crate::git::commit::FileChange {
793 status: "A".to_string(),
794 file: "new.rs".to_string(),
795 }],
796 };
797 assert_eq!(suggest_staged_skeleton(&files, &[]), "feat: ");
798 }
799
800 #[tokio::test]
801 async fn run_staged_no_ai_errors_when_nothing_staged() {
802 let temp_dir = init_empty_repo();
803 let err = run_staged(false, true, None, None, None, Some(temp_dir.path()))
804 .await
805 .unwrap_err();
806 let msg = format!("{err:#}");
807 assert!(msg.to_lowercase().contains("no staged changes"));
808 }
809
810 #[tokio::test]
811 async fn staged_command_execute_no_ai_dispatches_and_never_commits() {
812 let temp_dir = init_repo_with_staged_cargo_toml();
813 let head_before = head_oid(temp_dir.path());
814
815 let cmd = StagedCommand {
816 print_only: false,
817 context_dir: Some(temp_dir.path().join(".omni-dev")),
818 no_ai: true,
819 };
820 let result = cmd.execute(Some(temp_dir.path())).await;
821 assert!(result.is_ok(), "expected clean exit, got: {result:?}");
822
823 let head_after = head_oid(temp_dir.path());
824 assert_eq!(
825 head_before, head_after,
826 "HEAD must be unchanged (no_ai never commits, even with print_only: false)"
827 );
828 }
829}