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.
8
9use anyhow::{Context, Result};
10use clap::Parser;
11use std::process::{Command, Stdio};
12
13use super::parse_beta_header;
14use crate::data::context::ScopeDefinition;
15
16/// `omni-dev git commit message staged` CLI command.
17#[derive(Parser)]
18pub struct StagedCommand {
19    /// Print the generated message to stdout instead of committing.
20    #[arg(long)]
21    pub print_only: bool,
22
23    /// Claude API model to use (if not specified, uses settings or default).
24    #[arg(long)]
25    pub model: Option<String>,
26
27    /// Beta header to send with API requests (format: key:value).
28    /// Only sent if the model supports it in the registry.
29    #[arg(long, value_name = "KEY:VALUE")]
30    pub beta_header: Option<String>,
31
32    /// Override the context directory used to load project scopes.
33    #[arg(long, value_name = "DIR")]
34    pub context_dir: Option<std::path::PathBuf>,
35}
36
37/// Outcome of a staged-commit run.
38#[derive(Debug, Clone)]
39pub struct StagedOutcome {
40    /// The generated commit message (trimmed of surrounding whitespace).
41    pub message: String,
42    /// `true` when the commit was applied to the repository; `false` for
43    /// `--print-only` or any path that did not run `git commit`.
44    pub applied: bool,
45}
46
47impl StagedCommand {
48    /// Executes the staged command.
49    ///
50    /// `repo` is the repository location resolved at the CLI boundary
51    /// (`None` = current working directory).
52    pub async fn execute(self, repo: Option<&std::path::Path>) -> Result<()> {
53        let beta = self
54            .beta_header
55            .as_deref()
56            .map(parse_beta_header)
57            .transpose()?;
58        let _ = run_staged(
59            self.print_only,
60            self.model,
61            beta,
62            self.context_dir.as_deref(),
63            repo,
64        )
65        .await?;
66        Ok(())
67    }
68}
69
70/// Public entry point for the staged-commit command.
71///
72/// Mirrors [`crate::cli::git::run_twiddle`]'s shape so the MCP server can wrap
73/// it the same way: resolve the repo root (the injected path, or the CWD as the
74/// default), run AI preflight, build the client, and delegate to the
75/// test-injectable inner [`run_staged_with_client`].
76pub async fn run_staged(
77    print_only: bool,
78    model: Option<String>,
79    beta_header: Option<(String, String)>,
80    context_dir: Option<&std::path::Path>,
81    repo_path: Option<&std::path::Path>,
82) -> Result<StagedOutcome> {
83    // Resolve the repo root once (the CWD is the default when no path is
84    // injected); every git subprocess and config/scopes read below anchors to
85    // it, so nothing deeper reads the ambient CWD.
86    let repo_root = match repo_path {
87        Some(p) => p.to_path_buf(),
88        None => std::env::current_dir().context("Failed to determine current directory")?,
89    };
90    let repo_root = repo_root.as_path();
91
92    if !has_staged_changes(repo_root)? {
93        anyhow::bail!("no staged changes — stage files with `git add` before running this command");
94    }
95
96    crate::utils::check_ai_command_prerequisites(model.as_deref(), repo_root)?;
97    let claude_client = crate::claude::create_default_claude_client(model, beta_header).await?;
98
99    let resolved_context_dir =
100        crate::claude::context::resolve_context_dir_at(context_dir, repo_root);
101    let valid_scopes =
102        crate::claude::context::load_project_scopes(&resolved_context_dir, repo_root);
103
104    run_staged_with_client(print_only, &valid_scopes, &claude_client, repo_root).await
105}
106
107/// Test-injectable core of [`run_staged`].
108///
109/// Assumes the caller has already:
110/// - Verified the working directory contains staged changes.
111/// - Verified AI credentials.
112/// - Constructed a fully initialised `ClaudeClient`.
113/// - Loaded `valid_scopes` (may be empty).
114pub(crate) async fn run_staged_with_client(
115    print_only: bool,
116    valid_scopes: &[ScopeDefinition],
117    claude_client: &crate::claude::client::ClaudeClient,
118    repo_root: &std::path::Path,
119) -> Result<StagedOutcome> {
120    let diff = read_staged_diff(repo_root)?;
121    let system = crate::claude::prompts::generate_staged_commit_system_prompt(valid_scopes);
122    let user = crate::claude::prompts::generate_staged_commit_user_prompt(&diff);
123
124    let raw = claude_client.send_message(&system, &user).await?;
125    let message = raw.trim().to_string();
126
127    if message.is_empty() {
128        anyhow::bail!("AI returned an empty commit message");
129    }
130
131    if print_only {
132        println!("{message}");
133        return Ok(StagedOutcome {
134            message,
135            applied: false,
136        });
137    }
138
139    commit_with_message(&message, repo_root)?;
140    Ok(StagedOutcome {
141        message,
142        applied: true,
143    })
144}
145
146/// Returns `true` if `git diff --cached --quiet` reports staged changes.
147///
148/// Exit codes per `git diff --quiet`:
149/// - `0` ⇒ no diff (nothing staged)
150/// - `1` ⇒ diff present (staged changes exist)
151/// - other ⇒ a real error (not in a repo, permission denied, etc.)
152fn has_staged_changes(repo_root: &std::path::Path) -> Result<bool> {
153    let output = Command::new("git")
154        .current_dir(repo_root)
155        .args(["diff", "--cached", "--quiet"])
156        .stdin(Stdio::null())
157        .env("GIT_TERMINAL_PROMPT", "0")
158        .output()
159        .context("Failed to execute git diff --cached --quiet")?;
160    match output.status.code() {
161        Some(0) => Ok(false),
162        Some(1) => Ok(true),
163        Some(code) => {
164            let stderr = String::from_utf8_lossy(&output.stderr);
165            anyhow::bail!("git diff --cached --quiet exited with code {code}: {stderr}")
166        }
167        None => anyhow::bail!("git diff --cached --quiet was terminated by a signal"),
168    }
169}
170
171/// Reads the staged diff via `git diff --cached`.
172fn read_staged_diff(repo_root: &std::path::Path) -> Result<String> {
173    let output = Command::new("git")
174        .current_dir(repo_root)
175        .args(["diff", "--cached"])
176        .stdin(Stdio::null())
177        .env("GIT_TERMINAL_PROMPT", "0")
178        .output()
179        .context("Failed to execute git diff --cached")?;
180    if !output.status.success() {
181        let stderr = String::from_utf8_lossy(&output.stderr);
182        anyhow::bail!("git diff --cached failed: {stderr}");
183    }
184    String::from_utf8(output.stdout).context("git diff --cached produced non-UTF-8 output")
185}
186
187/// Commits staged changes via `git commit -m <msg>` as a subprocess.
188///
189/// Uses `.status()` so stdout/stderr are inherited from the parent — this is
190/// deliberate: it lets the user see hook output live and confirms hooks
191/// (`pre-commit`, `commit-msg`) fire normally, which `libgit2`'s
192/// `repo.commit()` would bypass.
193///
194/// Stdin is explicitly `Stdio::null()` so neither `git commit` nor any hook
195/// can block reading from an inherited stdin fd. On CI runners (Linux), an
196/// inherited stdin from `cargo test` can produce indefinite waits that don't
197/// reproduce on developer terminals.
198fn commit_with_message(message: &str, repo_root: &std::path::Path) -> Result<()> {
199    let status = Command::new("git")
200        .current_dir(repo_root)
201        .args(["commit", "-m", message])
202        .stdin(Stdio::null())
203        .env("GIT_TERMINAL_PROMPT", "0")
204        .env("GIT_EDITOR", "true")
205        .status()
206        .context("Failed to execute git commit -m")?;
207    if !status.success() {
208        anyhow::bail!("git commit failed (exit status: {status})");
209    }
210    Ok(())
211}
212
213#[cfg(test)]
214#[allow(clippy::unwrap_used, clippy::expect_used)]
215mod tests {
216    use super::*;
217    use crate::claude::client::ClaudeClient;
218    use crate::claude::test_utils::ConfigurableMockAiClient;
219    use git2::{Repository, Signature};
220
221    /// Creates an empty repo with no commits and no staged content.
222    fn init_empty_repo() -> tempfile::TempDir {
223        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
224        std::fs::create_dir_all(&tmp_root).unwrap();
225        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
226        let repo = Repository::init(temp_dir.path()).unwrap();
227        let mut cfg = repo.config().unwrap();
228        cfg.set_str("user.name", "Test").unwrap();
229        cfg.set_str("user.email", "test@example.com").unwrap();
230        cfg.set_str("commit.gpgsign", "false").unwrap();
231        temp_dir
232    }
233
234    /// Creates a repo with a baseline commit, then stages a new file so
235    /// `git diff --cached` is non-empty.
236    fn init_repo_with_staged_change() -> tempfile::TempDir {
237        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
238        std::fs::create_dir_all(&tmp_root).unwrap();
239        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
240        let repo = Repository::init(temp_dir.path()).unwrap();
241        {
242            let mut cfg = repo.config().unwrap();
243            cfg.set_str("user.name", "Test").unwrap();
244            cfg.set_str("user.email", "test@example.com").unwrap();
245            cfg.set_str("commit.gpgsign", "false").unwrap();
246        }
247        // Baseline commit so HEAD exists.
248        let signature = Signature::now("Test", "test@example.com").unwrap();
249        std::fs::write(temp_dir.path().join("README"), "baseline\n").unwrap();
250        let mut idx = repo.index().unwrap();
251        idx.add_path(std::path::Path::new("README")).unwrap();
252        idx.write().unwrap();
253        let tree_id = idx.write_tree().unwrap();
254        let tree = repo.find_tree(tree_id).unwrap();
255        repo.commit(
256            Some("HEAD"),
257            &signature,
258            &signature,
259            "chore: baseline",
260            &tree,
261            &[],
262        )
263        .unwrap();
264
265        // Stage a new file so the diff is non-empty.
266        std::fs::write(temp_dir.path().join("new.rs"), "fn marker_xyz() {}\n").unwrap();
267        let mut idx = repo.index().unwrap();
268        idx.add_path(std::path::Path::new("new.rs")).unwrap();
269        idx.write().unwrap();
270
271        temp_dir
272    }
273
274    fn head_message(repo_path: &std::path::Path) -> String {
275        let repo = Repository::open(repo_path).unwrap();
276        let head = repo.head().unwrap();
277        let commit = head.peel_to_commit().unwrap();
278        commit.message().unwrap().to_string()
279    }
280
281    fn head_oid(repo_path: &std::path::Path) -> String {
282        let repo = Repository::open(repo_path).unwrap();
283        let head = repo.head().unwrap();
284        let commit = head.peel_to_commit().unwrap();
285        commit.id().to_string()
286    }
287
288    #[tokio::test]
289    async fn run_staged_errors_when_nothing_staged() {
290        let temp_dir = init_empty_repo();
291        // `has_staged_changes` is anchored to the injected repo (`.current_dir`),
292        // so this empty repo bails regardless of whether the process CWD has
293        // staged changes.
294        let err = run_staged(true, None, None, None, Some(temp_dir.path()))
295            .await
296            .unwrap_err();
297        let msg = format!("{err:#}");
298        assert!(
299            msg.to_lowercase().contains("no staged changes"),
300            "expected 'no staged changes' error, got: {msg}"
301        );
302    }
303
304    #[tokio::test]
305    async fn run_staged_with_client_print_only_does_not_commit() {
306        let temp_dir = init_repo_with_staged_change();
307        let head_before = head_oid(temp_dir.path());
308
309        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add bar".to_string())]);
310        let client = ClaudeClient::new(Box::new(mock));
311
312        let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
313            .await
314            .unwrap();
315        assert!(!outcome.applied, "print_only must not apply");
316        assert_eq!(outcome.message, "feat(foo): add bar");
317
318        let head_after = head_oid(temp_dir.path());
319        assert_eq!(head_before, head_after, "HEAD must be unchanged");
320    }
321
322    #[tokio::test]
323    async fn run_staged_with_client_commits_on_default() {
324        let temp_dir = init_repo_with_staged_change();
325        let head_before = head_oid(temp_dir.path());
326
327        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add marker".to_string())]);
328        let client = ClaudeClient::new(Box::new(mock));
329
330        let outcome = run_staged_with_client(false, &[], &client, temp_dir.path())
331            .await
332            .unwrap();
333        assert!(outcome.applied, "default mode must commit");
334
335        let head_after = head_oid(temp_dir.path());
336        assert_ne!(head_before, head_after, "HEAD must advance");
337
338        let msg = head_message(temp_dir.path());
339        assert!(
340            msg.starts_with("feat(foo): add marker"),
341            "expected AI message at HEAD, got: {msg:?}"
342        );
343    }
344
345    #[tokio::test]
346    async fn run_staged_propagates_ai_failure() {
347        let temp_dir = init_repo_with_staged_change();
348        let head_before = head_oid(temp_dir.path());
349
350        // Empty response queue → mock returns Err on first call.
351        let mock = ConfigurableMockAiClient::new(vec![]);
352        let client = ClaudeClient::new(Box::new(mock));
353
354        let err = run_staged_with_client(false, &[], &client, temp_dir.path())
355            .await
356            .unwrap_err();
357        let _ = err;
358
359        let head_after = head_oid(temp_dir.path());
360        assert_eq!(head_before, head_after, "HEAD must not advance on failure");
361    }
362
363    #[tokio::test]
364    async fn run_staged_with_client_trims_ai_response_whitespace() {
365        let temp_dir = init_repo_with_staged_change();
366
367        let mock = ConfigurableMockAiClient::new(vec![Ok("  feat(x): y  \n\n".to_string())]);
368        let client = ClaudeClient::new(Box::new(mock));
369
370        let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
371            .await
372            .unwrap();
373        assert_eq!(outcome.message, "feat(x): y");
374    }
375
376    #[tokio::test]
377    async fn run_staged_with_client_empty_ai_response_errors() {
378        let temp_dir = init_repo_with_staged_change();
379
380        let mock = ConfigurableMockAiClient::new(vec![Ok("   \n\n".to_string())]);
381        let client = ClaudeClient::new(Box::new(mock));
382
383        let err = run_staged_with_client(false, &[], &client, temp_dir.path())
384            .await
385            .unwrap_err();
386        let msg = format!("{err:#}");
387        assert!(
388            msg.to_lowercase().contains("empty"),
389            "expected 'empty' error, got: {msg}"
390        );
391    }
392
393    #[tokio::test]
394    async fn run_staged_invokes_git_commit_subprocess_so_hooks_fire() {
395        let temp_dir = init_repo_with_staged_change();
396        let head_before = head_oid(temp_dir.path());
397
398        // Install a commit-msg hook that always fails. If we go through real
399        // `git commit`, the hook fires and the commit is rejected. If we
400        // were using libgit2's repo.commit(), hooks would be bypassed.
401        let hook_path = temp_dir.path().join(".git/hooks/commit-msg");
402        std::fs::write(&hook_path, "#!/bin/sh\necho REJECTED-BY-HOOK >&2\nexit 1\n").unwrap();
403        #[cfg(unix)]
404        {
405            use std::os::unix::fs::PermissionsExt;
406            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
407            perms.set_mode(0o755);
408            std::fs::set_permissions(&hook_path, perms).unwrap();
409        }
410
411        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(x): y".to_string())]);
412        let client = ClaudeClient::new(Box::new(mock));
413
414        let err = run_staged_with_client(false, &[], &client, temp_dir.path())
415            .await
416            .unwrap_err();
417        let msg = format!("{err:#}");
418        assert!(
419            msg.to_lowercase().contains("git commit failed"),
420            "expected commit-failure error message, got: {msg}"
421        );
422
423        let head_after = head_oid(temp_dir.path());
424        assert_eq!(
425            head_before, head_after,
426            "HEAD must not advance when commit-msg hook rejects"
427        );
428    }
429
430    #[tokio::test]
431    async fn run_staged_passes_valid_scopes_into_prompt() {
432        let temp_dir = init_repo_with_staged_change();
433
434        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(cli): add".to_string())]);
435        let prompts = mock.prompt_handle();
436        let client = ClaudeClient::new(Box::new(mock));
437
438        let scopes = vec![ScopeDefinition {
439            name: "cli".to_string(),
440            description: "CLI module".to_string(),
441            examples: Vec::new(),
442            file_patterns: Vec::new(),
443        }];
444
445        let _ = run_staged_with_client(true, &scopes, &client, temp_dir.path())
446            .await
447            .unwrap();
448        let recorded = prompts.prompts();
449        assert_eq!(recorded.len(), 1, "exactly one AI call");
450        let (system, _user) = &recorded[0];
451        assert!(
452            system.contains("VALID SCOPES FOR THIS PROJECT"),
453            "scopes section missing from system prompt"
454        );
455        assert!(system.contains("`cli`: CLI module"));
456    }
457
458    #[test]
459    fn staged_outcome_clone_and_debug() {
460        let outcome = StagedOutcome {
461            message: "feat: x".to_string(),
462            applied: true,
463        };
464        let cloned = outcome.clone();
465        assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
466    }
467
468    // Drives `StagedCommand::execute()` through its no-staged-changes bail.
469    // The command's `execute` delegates to `run_staged`, which short-circuits
470    // before any AI credential check, so this exercises the dispatch wiring
471    // without needing real AI credentials.
472    #[tokio::test]
473    async fn staged_command_execute_bails_when_nothing_staged() {
474        let temp_dir = init_empty_repo();
475        let cmd = StagedCommand {
476            print_only: true,
477            model: None,
478            beta_header: None,
479            context_dir: None,
480        };
481        let err = cmd.execute(Some(temp_dir.path())).await.unwrap_err();
482        let msg = format!("{err:#}");
483        assert!(
484            msg.to_lowercase().contains("no staged changes"),
485            "expected 'no staged changes' error from execute(), got: {msg}"
486        );
487    }
488
489    // `execute()` parses `--beta-header` before any other work; an invalid
490    // value should error out with a clear "Invalid --beta-header" message.
491    #[tokio::test]
492    async fn staged_command_execute_rejects_malformed_beta_header() {
493        let temp_dir = init_empty_repo();
494        let cmd = StagedCommand {
495            print_only: true,
496            model: None,
497            beta_header: Some("no-colon-here".to_string()),
498            context_dir: None,
499        };
500        let err = cmd.execute(Some(temp_dir.path())).await.unwrap_err();
501        let msg = format!("{err:#}");
502        assert!(
503            msg.contains("Invalid --beta-header"),
504            "expected beta-header parse error, got: {msg}"
505        );
506    }
507
508    /// "No silent mix" guard: `read_staged_diff` reads the staged diff from the
509    /// INJECTED repo, not the process CWD. We stage a uniquely-marked file in
510    /// the temp repo, run with that repo injected (the process CWD is the
511    /// omni-dev checkout), and assert the marker reached the AI prompt.
512    #[tokio::test]
513    async fn run_staged_with_client_reads_diff_from_injected_repo() {
514        let temp_dir = init_repo_with_staged_change();
515
516        let mock = ConfigurableMockAiClient::new(vec![Ok("feat: x".to_string())]);
517        let prompts = mock.prompt_handle();
518        let client = ClaudeClient::new(Box::new(mock));
519
520        let _ = run_staged_with_client(true, &[], &client, temp_dir.path())
521            .await
522            .unwrap();
523
524        let recorded = prompts.prompts();
525        assert_eq!(recorded.len(), 1, "exactly one AI call");
526        let (_system, user) = &recorded[0];
527        assert!(
528            user.contains("marker_xyz"),
529            "staged diff from the injected repo must reach the prompt: {user}"
530        );
531    }
532}