Skip to main content

omni_dev/cli/git/
staged.rs

1//! `omni-dev git commit message staged` — generate a Conventional Commits
2//! message from staged changes via the configured AI backend and (by default)
3//! commit them.
4//!
5//! Default behaviour mirrors `git commit -m <message>` so user-installed
6//! `pre-commit` / `commit-msg` hooks fire normally. Pass `--print-only` to
7//! print the generated message to stdout without committing, or `--no-ai`
8//! for a deterministic (no AI, no network) `type(scope): ` skeleton instead
9//! of a full AI-drafted message — always print-only, regardless of
10//! `--print-only`.
11
12use anyhow::{Context, Result};
13use clap::Parser;
14use std::process::{Command, Stdio};
15
16use crate::data::context::ScopeDefinition;
17use crate::git::commit::FileChanges;
18
19/// `omni-dev git commit message staged` CLI command.
20///
21/// Model/beta-header selection uses the global `--model`/`--beta-header`
22/// flags (propagated as `OMNI_DEV_MODEL`/`OMNI_DEV_BETA_HEADER`) and the
23/// per-backend env chain; the only subcommand-local flags are `--print-only`,
24/// `--context-dir`, and `--no-ai`.
25#[derive(Parser)]
26pub struct StagedCommand {
27    /// Print the generated message to stdout instead of committing.
28    #[arg(long)]
29    pub print_only: bool,
30
31    /// Override the context directory used to load project scopes.
32    #[arg(long, value_name = "DIR")]
33    pub context_dir: Option<std::path::PathBuf>,
34
35    /// Skip the AI backend entirely and print a deterministic `type(scope): `
36    /// skeleton derived from the staged diff's changed files — no AI, no
37    /// network, no credentials required. The scope is resolved the same way
38    /// `lint --suggest`/`--fix` do (`resolve_scope` against
39    /// `.omni-dev/scopes.yaml` + ecosystem defaults); the type is a
40    /// best-effort heuristic, not validated against an enumerable list.
41    /// Always prints and never commits, regardless of `--print-only` — a
42    /// bare skeleton has no description, so it's never a complete message
43    /// to commit.
44    #[arg(long)]
45    pub no_ai: bool,
46}
47
48/// Outcome of a staged-commit run.
49#[derive(Debug, Clone)]
50pub struct StagedOutcome {
51    /// The generated commit message (trimmed of surrounding whitespace).
52    pub message: String,
53    /// `true` when the commit was applied to the repository; `false` for
54    /// `--print-only` or any path that did not run `git commit`.
55    pub applied: bool,
56}
57
58impl StagedCommand {
59    /// Executes the staged command.
60    ///
61    /// `repo` is the repository location resolved at the CLI boundary
62    /// (`None` = current working directory).
63    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
82/// Public entry point for the staged-commit command.
83///
84/// Mirrors [`crate::cli::git::run_twiddle`]'s shape so the MCP server can wrap
85/// it the same way: resolve the repo root (the injected path, or the CWD as the
86/// default), run AI preflight, build the client, and delegate to the
87/// test-injectable inner [`run_staged_with_client`].
88///
89/// `no_ai` skips AI entirely (no credential preflight, no client, no network
90/// call) and returns a deterministic `type(scope): ` skeleton instead — see
91/// [`run_staged_no_ai`].
92pub 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    // Resolve the repo root once (the CWD is the default when no path is
101    // injected); every git subprocess and config/scopes read below anchors to
102    // it, so nothing deeper reads the ambient CWD.
103    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
128/// Deterministic (no-AI) core of [`run_staged`]'s `no_ai` path.
129///
130/// Reads the staged file list via `git diff --cached --name-status`, resolves
131/// a `type(scope): ` skeleton via [`suggest_staged_skeleton`], and always
132/// returns `applied: false` — a bare skeleton has no description, so it is
133/// never committed, regardless of `--print-only`. Never writes to stdout —
134/// the CLI-only caller (`StagedCommand::execute`) does that; the MCP handler
135/// reads `message` from the returned `StagedOutcome` instead (#1567).
136fn 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
148/// Test-injectable core of [`run_staged`].
149///
150/// Assumes the caller has already:
151/// - Verified the working directory contains staged changes.
152/// - Verified AI credentials.
153/// - Constructed a fully initialised `ClaudeClient`.
154/// - Loaded `valid_scopes` (may be empty).
155pub(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
186/// Returns `true` if `git diff --cached --quiet` reports staged changes.
187///
188/// Exit codes per `git diff --quiet`:
189/// - `0` ⇒ no diff (nothing staged)
190/// - `1` ⇒ diff present (staged changes exist)
191/// - other ⇒ a real error (not in a repo, permission denied, etc.)
192fn 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
211/// Reads the staged diff via `git diff --cached`.
212fn 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
227/// Parses `git diff --cached --name-status` output into [`FileChanges`].
228///
229/// Each line is `status\tpath` (`A`/`M`/`D`/...), or `status\told\tnew` for a
230/// rename/copy (`R100`/`C100`/...) — the *last* tab-separated field is always
231/// the file's current path. Pure and unit-testable without a git subprocess;
232/// [`read_staged_files`] is the thin subprocess wrapper around it.
233fn 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
266/// Reads the staged file list via `git diff --cached --name-status`.
267fn 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
284/// Builds a deterministic `type(scope): ` (or `type: ` when no scope
285/// resolves) skeleton from `files` — a prefix only, never a synthesized
286/// description. The type comes from
287/// [`crate::git::commit::detect_commit_type_from_message`] with an empty
288/// message (there's no message yet to seed from, so it falls straight
289/// through to the file-pattern heuristics); the scope from
290/// [`crate::git::resolve_scope`], the same deterministic resolution
291/// `lint --suggest`/`--fix` use.
292fn 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
301/// Commits staged changes via `git commit -m <msg>` as a subprocess.
302///
303/// Uses `.status()` so stdout/stderr are inherited from the parent — this is
304/// deliberate: it lets the user see hook output live and confirms hooks
305/// (`pre-commit`, `commit-msg`) fire normally, which `libgit2`'s
306/// `repo.commit()` would bypass.
307///
308/// Stdin is explicitly `Stdio::null()` so neither `git commit` nor any hook
309/// can block reading from an inherited stdin fd. On CI runners (Linux), an
310/// inherited stdin from `cargo test` can produce indefinite waits that don't
311/// reproduce on developer terminals.
312fn 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    /// Creates an empty repo with no commits and no staged content.
336    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    /// Creates a repo with a baseline commit, then stages a new file so
349    /// `git diff --cached` is non-empty.
350    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        // Baseline commit so HEAD exists.
362        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        // Stage a new file so the diff is non-empty.
380        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        // `has_staged_changes` is anchored to the injected repo (`.current_dir`),
406        // so this empty repo bails regardless of whether the process CWD has
407        // staged changes.
408        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        // Empty response queue → mock returns Err on first call.
465        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        // Install a commit-msg hook that always fails. If we go through real
513        // `git commit`, the hook fires and the commit is rejected. If we
514        // were using libgit2's repo.commit(), hooks would be bypassed.
515        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    // Drives `StagedCommand::execute()` through its no-staged-changes bail.
583    // The command's `execute` delegates to `run_staged`, which short-circuits
584    // before any AI credential check, so this exercises the dispatch wiring
585    // without needing real AI credentials.
586    #[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    /// "No silent mix" guard: `read_staged_diff` reads the staged diff from the
603    /// INJECTED repo, not the process CWD. We stage a uniquely-marked file in
604    /// the temp repo, run with that repo injected (the process CWD is the
605    /// omni-dev checkout), and assert the marker reached the AI prompt.
606    #[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    // ── --no-ai (#1564) ──────────────────────────────────────────────
628
629    /// Creates a repo with a baseline commit, then stages a new `Cargo.toml`
630    /// so a `cargo`-scoped skeleton can be resolved.
631    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    /// Writes a `cargo` scope (matching `Cargo.toml`/`Cargo.lock`) to
672    /// `context_dir/scopes.yaml`.
673    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    /// A line with a status but no tab-separated filename (malformed
722    /// `git diff --name-status` output) has no file to record — skipped
723    /// rather than panicking or fabricating a path.
724    #[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    /// `read_staged_files` surfaces a non-zero `git diff --cached
733    /// --name-status` exit as an error rather than silently returning an
734    /// empty file list. Unlike every other fixture in this module, this one
735    /// deliberately does NOT use `tempdir_in(CARGO_MANIFEST_DIR/tmp)` — that
736    /// convention nests the fixture inside omni-dev's own working tree, so
737    /// `git`'s upward repository discovery would find omni-dev's real
738    /// `.git` and the command would succeed trivially. This needs a
739    /// directory outside any git repository, so it uses the system temp
740    /// dir instead.
741    #[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        // Exercises `suggest_staged_skeleton` directly with an empty
782        // `valid_scopes` slice, rather than through `run_staged`'s full
783        // `load_project_scopes` config-loading chain — that chain falls back
784        // to the *user's real* XDG/`$HOME/.omni-dev/scopes.yaml` when no
785        // project-level file exists (by design, for global scope config),
786        // which would make this "nothing resolves" case depend on whatever
787        // happens to be configured on the machine running the test.
788        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}