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/// Backend, model, and beta-header selection use the flattened
22/// [`AiBackendArgs`] flags (`--ai-backend`, `--model`, `--beta-header`, …;
23/// propagated as `OMNI_DEV_AI_BACKEND`/`OMNI_DEV_MODEL`/`OMNI_DEV_BETA_HEADER`)
24/// and the per-backend env chain.
25///
26/// [`AiBackendArgs`]: crate::cli::ai_backend_args::AiBackendArgs
27#[derive(Parser)]
28pub struct StagedCommand {
29    /// Print the generated message to stdout instead of committing.
30    #[arg(long)]
31    pub print_only: bool,
32
33    /// Override the context directory used to load project scopes.
34    #[arg(long, value_name = "DIR")]
35    pub context_dir: Option<std::path::PathBuf>,
36
37    /// Skip the AI backend entirely and print a deterministic `type(scope): `
38    /// skeleton derived from the staged diff's changed files — no AI, no
39    /// network, no credentials required. The scope is resolved the same way
40    /// `lint --suggest`/`--fix` do (`resolve_scope` against
41    /// `.omni-dev/scopes.yaml` + ecosystem defaults); the type is a
42    /// best-effort heuristic, not validated against an enumerable list.
43    /// Always prints and never commits, regardless of `--print-only` — a
44    /// bare skeleton has no description, so it's never a complete message
45    /// to commit.
46    #[arg(long)]
47    pub no_ai: bool,
48
49    /// AI backend selection (`--ai-backend`, `--model`, …).
50    #[command(flatten)]
51    pub ai: crate::cli::ai_backend_args::AiBackendArgs,
52}
53
54/// Outcome of a staged-commit run.
55#[derive(Debug, Clone)]
56pub struct StagedOutcome {
57    /// The generated commit message (trimmed of surrounding whitespace).
58    pub message: String,
59    /// `true` when the commit was applied to the repository; `false` for
60    /// `--print-only` or any path that did not run `git commit`.
61    pub applied: bool,
62}
63
64impl StagedCommand {
65    /// Executes the staged command.
66    ///
67    /// `repo` is the repository location resolved at the CLI boundary
68    /// (`None` = current working directory).
69    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
90/// Public entry point for the staged-commit command.
91///
92/// Mirrors [`crate::cli::git::run_twiddle`]'s shape so the MCP server can wrap
93/// it the same way: resolve the repo root (the injected path, or the CWD as the
94/// default), run AI preflight, build the client, and delegate to the
95/// test-injectable inner [`run_staged_with_client`].
96///
97/// `no_ai` skips AI entirely (no credential preflight, no client, no network
98/// call) and returns a deterministic `type(scope): ` skeleton instead — see
99/// [`run_staged_no_ai`].
100pub 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    // Resolve the repo root once (the CWD is the default when no path is
109    // injected); every git subprocess and config/scopes read below anchors to
110    // it, so nothing deeper reads the ambient CWD.
111    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
136/// Deterministic (no-AI) core of [`run_staged`]'s `no_ai` path.
137///
138/// Reads the staged file list via `git diff --cached --name-status`, resolves
139/// a `type(scope): ` skeleton via [`suggest_staged_skeleton`], and always
140/// returns `applied: false` — a bare skeleton has no description, so it is
141/// never committed, regardless of `--print-only`. Never writes to stdout —
142/// the CLI-only caller (`StagedCommand::execute`) does that; the MCP handler
143/// reads `message` from the returned `StagedOutcome` instead (#1567).
144fn 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
156/// Test-injectable core of [`run_staged`].
157///
158/// Assumes the caller has already:
159/// - Verified the working directory contains staged changes.
160/// - Verified AI credentials.
161/// - Constructed a fully initialised `ClaudeClient`.
162/// - Loaded `valid_scopes` (may be empty).
163pub(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
194/// Returns `true` if `git diff --cached --quiet` reports staged changes.
195///
196/// Exit codes per `git diff --quiet`:
197/// - `0` ⇒ no diff (nothing staged)
198/// - `1` ⇒ diff present (staged changes exist)
199/// - other ⇒ a real error (not in a repo, permission denied, etc.)
200fn 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
219/// Reads the staged diff via `git diff --cached`.
220fn 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
235/// Parses `git diff --cached --name-status` output into [`FileChanges`].
236///
237/// Each line is `status\tpath` (`A`/`M`/`D`/...), or `status\told\tnew` for a
238/// rename/copy (`R100`/`C100`/...) — the *last* tab-separated field is always
239/// the file's current path. Pure and unit-testable without a git subprocess;
240/// [`read_staged_files`] is the thin subprocess wrapper around it.
241fn 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
274/// Reads the staged file list via `git diff --cached --name-status`.
275fn 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
292/// Builds a deterministic `type(scope): ` (or `type: ` when no scope
293/// resolves) skeleton from `files` — a prefix only, never a synthesized
294/// description. The type comes from
295/// [`crate::git::commit::detect_commit_type_from_message`] with an empty
296/// message (there's no message yet to seed from, so it falls straight
297/// through to the file-pattern heuristics); the scope from
298/// [`crate::git::resolve_scope`], the same deterministic resolution
299/// `lint --suggest`/`--fix` use.
300fn 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
309/// Commits staged changes via `git commit -m <msg>` as a subprocess.
310///
311/// Uses `.status()` so stdout/stderr are inherited from the parent — this is
312/// deliberate: it lets the user see hook output live and confirms hooks
313/// (`pre-commit`, `commit-msg`) fire normally, which `libgit2`'s
314/// `repo.commit()` would bypass.
315///
316/// Stdin is explicitly `Stdio::null()` so neither `git commit` nor any hook
317/// can block reading from an inherited stdin fd. On CI runners (Linux), an
318/// inherited stdin from `cargo test` can produce indefinite waits that don't
319/// reproduce on developer terminals.
320fn 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    /// Creates an empty repo with no commits and no staged content.
344    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    /// Creates a repo with a baseline commit, then stages a new file so
357    /// `git diff --cached` is non-empty.
358    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        // Baseline commit so HEAD exists.
370        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        // Stage a new file so the diff is non-empty.
388        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        // `has_staged_changes` is anchored to the injected repo (`.current_dir`),
414        // so this empty repo bails regardless of whether the process CWD has
415        // staged changes.
416        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        // Empty response queue → mock returns Err on first call.
473        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        // Install a commit-msg hook that always fails. If we go through real
521        // `git commit`, the hook fires and the commit is rejected. If we
522        // were using libgit2's repo.commit(), hooks would be bypassed.
523        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    // Drives `StagedCommand::execute()` through its no-staged-changes bail.
591    // The command's `execute` delegates to `run_staged`, which short-circuits
592    // before any AI credential check, so this exercises the dispatch wiring
593    // without needing real AI credentials.
594    #[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    /// "No silent mix" guard: `read_staged_diff` reads the staged diff from the
612    /// INJECTED repo, not the process CWD. We stage a uniquely-marked file in
613    /// the temp repo, run with that repo injected (the process CWD is the
614    /// omni-dev checkout), and assert the marker reached the AI prompt.
615    #[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    // ── --no-ai (#1564) ──────────────────────────────────────────────
637
638    /// Creates a repo with a baseline commit, then stages a new `Cargo.toml`
639    /// so a `cargo`-scoped skeleton can be resolved.
640    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    /// Writes a `cargo` scope (matching `Cargo.toml`/`Cargo.lock`) to
681    /// `context_dir/scopes.yaml`.
682    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    /// A line with a status but no tab-separated filename (malformed
731    /// `git diff --name-status` output) has no file to record — skipped
732    /// rather than panicking or fabricating a path.
733    #[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    /// `read_staged_files` surfaces a non-zero `git diff --cached
742    /// --name-status` exit as an error rather than silently returning an
743    /// empty file list. Unlike every other fixture in this module, this one
744    /// deliberately does NOT use `tempdir_in(CARGO_MANIFEST_DIR/tmp)` — that
745    /// convention nests the fixture inside omni-dev's own working tree, so
746    /// `git`'s upward repository discovery would find omni-dev's real
747    /// `.git` and the command would succeed trivially. This needs a
748    /// directory outside any git repository, so it uses the system temp
749    /// dir instead.
750    #[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        // Exercises `suggest_staged_skeleton` directly with an empty
791        // `valid_scopes` slice, rather than through `run_staged`'s full
792        // `load_project_scopes` config-loading chain — that chain falls back
793        // to the *user's real* XDG/`$HOME/.omni-dev/scopes.yaml` when no
794        // project-level file exists (by design, for global scope config),
795        // which would make this "nothing resolves" case depend on whatever
796        // happens to be configured on the machine running the test.
797        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}