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    pub async fn execute(self) -> Result<()> {
50        let beta = self
51            .beta_header
52            .as_deref()
53            .map(parse_beta_header)
54            .transpose()?;
55        let _ = run_staged(
56            self.print_only,
57            self.model,
58            beta,
59            self.context_dir.as_deref(),
60            None,
61        )
62        .await?;
63        Ok(())
64    }
65}
66
67/// Public entry point for the staged-commit command.
68///
69/// Mirrors [`crate::cli::git::run_twiddle`]'s shape so the MCP server can wrap
70/// it the same way: pin the CWD, run AI preflight, build the client, delegate
71/// to the test-injectable inner [`run_staged_with_client`].
72pub async fn run_staged(
73    print_only: bool,
74    model: Option<String>,
75    beta_header: Option<(String, String)>,
76    context_dir: Option<&std::path::Path>,
77    repo_path: Option<&std::path::Path>,
78) -> Result<StagedOutcome> {
79    let _cwd_guard = match repo_path {
80        Some(p) => Some(super::CwdGuard::enter(p).await?),
81        None => None,
82    };
83
84    if !has_staged_changes()? {
85        anyhow::bail!("no staged changes — stage files with `git add` before running this command");
86    }
87
88    crate::utils::check_ai_command_prerequisites(model.as_deref())?;
89    let claude_client = crate::claude::create_default_claude_client(model, beta_header).await?;
90
91    let resolved_context_dir = crate::claude::context::resolve_context_dir(context_dir);
92    let valid_scopes = crate::claude::context::load_project_scopes(
93        &resolved_context_dir,
94        &std::path::PathBuf::from("."),
95    );
96
97    run_staged_with_client(print_only, &valid_scopes, &claude_client).await
98}
99
100/// Test-injectable core of [`run_staged`].
101///
102/// Assumes the caller has already:
103/// - Verified the working directory contains staged changes.
104/// - Verified AI credentials.
105/// - Constructed a fully initialised `ClaudeClient`.
106/// - Loaded `valid_scopes` (may be empty).
107pub(crate) async fn run_staged_with_client(
108    print_only: bool,
109    valid_scopes: &[ScopeDefinition],
110    claude_client: &crate::claude::client::ClaudeClient,
111) -> Result<StagedOutcome> {
112    let diff = read_staged_diff()?;
113    let system = crate::claude::prompts::generate_staged_commit_system_prompt(valid_scopes);
114    let user = crate::claude::prompts::generate_staged_commit_user_prompt(&diff);
115
116    let raw = claude_client.send_message(&system, &user).await?;
117    let message = raw.trim().to_string();
118
119    if message.is_empty() {
120        anyhow::bail!("AI returned an empty commit message");
121    }
122
123    if print_only {
124        println!("{message}");
125        return Ok(StagedOutcome {
126            message,
127            applied: false,
128        });
129    }
130
131    commit_with_message(&message)?;
132    Ok(StagedOutcome {
133        message,
134        applied: true,
135    })
136}
137
138/// Returns `true` if `git diff --cached --quiet` reports staged changes.
139///
140/// Exit codes per `git diff --quiet`:
141/// - `0` ⇒ no diff (nothing staged)
142/// - `1` ⇒ diff present (staged changes exist)
143/// - other ⇒ a real error (not in a repo, permission denied, etc.)
144fn has_staged_changes() -> Result<bool> {
145    let output = Command::new("git")
146        .args(["diff", "--cached", "--quiet"])
147        .stdin(Stdio::null())
148        .env("GIT_TERMINAL_PROMPT", "0")
149        .output()
150        .context("Failed to execute git diff --cached --quiet")?;
151    match output.status.code() {
152        Some(0) => Ok(false),
153        Some(1) => Ok(true),
154        Some(code) => {
155            let stderr = String::from_utf8_lossy(&output.stderr);
156            anyhow::bail!("git diff --cached --quiet exited with code {code}: {stderr}")
157        }
158        None => anyhow::bail!("git diff --cached --quiet was terminated by a signal"),
159    }
160}
161
162/// Reads the staged diff via `git diff --cached`.
163fn read_staged_diff() -> Result<String> {
164    let output = Command::new("git")
165        .args(["diff", "--cached"])
166        .stdin(Stdio::null())
167        .env("GIT_TERMINAL_PROMPT", "0")
168        .output()
169        .context("Failed to execute git diff --cached")?;
170    if !output.status.success() {
171        let stderr = String::from_utf8_lossy(&output.stderr);
172        anyhow::bail!("git diff --cached failed: {stderr}");
173    }
174    String::from_utf8(output.stdout).context("git diff --cached produced non-UTF-8 output")
175}
176
177/// Commits staged changes via `git commit -m <msg>` as a subprocess.
178///
179/// Uses `.status()` so stdout/stderr are inherited from the parent — this is
180/// deliberate: it lets the user see hook output live and confirms hooks
181/// (`pre-commit`, `commit-msg`) fire normally, which `libgit2`'s
182/// `repo.commit()` would bypass.
183///
184/// Stdin is explicitly `Stdio::null()` so neither `git commit` nor any hook
185/// can block reading from an inherited stdin fd. On CI runners (Linux), an
186/// inherited stdin from `cargo test` can produce indefinite waits that don't
187/// reproduce on developer terminals.
188fn commit_with_message(message: &str) -> Result<()> {
189    let status = Command::new("git")
190        .args(["commit", "-m", message])
191        .stdin(Stdio::null())
192        .env("GIT_TERMINAL_PROMPT", "0")
193        .env("GIT_EDITOR", "true")
194        .status()
195        .context("Failed to execute git commit -m")?;
196    if !status.success() {
197        anyhow::bail!("git commit failed (exit status: {status})");
198    }
199    Ok(())
200}
201
202#[cfg(test)]
203#[allow(clippy::unwrap_used, clippy::expect_used)]
204mod tests {
205    use super::*;
206    use crate::claude::client::ClaudeClient;
207    use crate::claude::test_utils::ConfigurableMockAiClient;
208    use git2::{Repository, Signature};
209
210    /// Creates an empty repo with no commits and no staged content.
211    fn init_empty_repo() -> tempfile::TempDir {
212        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
213        std::fs::create_dir_all(&tmp_root).unwrap();
214        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
215        let repo = Repository::init(temp_dir.path()).unwrap();
216        let mut cfg = repo.config().unwrap();
217        cfg.set_str("user.name", "Test").unwrap();
218        cfg.set_str("user.email", "test@example.com").unwrap();
219        cfg.set_str("commit.gpgsign", "false").unwrap();
220        temp_dir
221    }
222
223    /// Creates a repo with a baseline commit, then stages a new file so
224    /// `git diff --cached` is non-empty.
225    fn init_repo_with_staged_change() -> tempfile::TempDir {
226        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
227        std::fs::create_dir_all(&tmp_root).unwrap();
228        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
229        let repo = Repository::init(temp_dir.path()).unwrap();
230        {
231            let mut cfg = repo.config().unwrap();
232            cfg.set_str("user.name", "Test").unwrap();
233            cfg.set_str("user.email", "test@example.com").unwrap();
234            cfg.set_str("commit.gpgsign", "false").unwrap();
235        }
236        // Baseline commit so HEAD exists.
237        let signature = Signature::now("Test", "test@example.com").unwrap();
238        std::fs::write(temp_dir.path().join("README"), "baseline\n").unwrap();
239        let mut idx = repo.index().unwrap();
240        idx.add_path(std::path::Path::new("README")).unwrap();
241        idx.write().unwrap();
242        let tree_id = idx.write_tree().unwrap();
243        let tree = repo.find_tree(tree_id).unwrap();
244        repo.commit(
245            Some("HEAD"),
246            &signature,
247            &signature,
248            "chore: baseline",
249            &tree,
250            &[],
251        )
252        .unwrap();
253
254        // Stage a new file so the diff is non-empty.
255        std::fs::write(temp_dir.path().join("new.rs"), "fn marker_xyz() {}\n").unwrap();
256        let mut idx = repo.index().unwrap();
257        idx.add_path(std::path::Path::new("new.rs")).unwrap();
258        idx.write().unwrap();
259
260        temp_dir
261    }
262
263    fn head_message(repo_path: &std::path::Path) -> String {
264        let repo = Repository::open(repo_path).unwrap();
265        let head = repo.head().unwrap();
266        let commit = head.peel_to_commit().unwrap();
267        commit.message().unwrap().to_string()
268    }
269
270    fn head_oid(repo_path: &std::path::Path) -> String {
271        let repo = Repository::open(repo_path).unwrap();
272        let head = repo.head().unwrap();
273        let commit = head.peel_to_commit().unwrap();
274        commit.id().to_string()
275    }
276
277    #[tokio::test]
278    async fn run_staged_errors_when_nothing_staged() {
279        let temp_dir = init_empty_repo();
280        // run_staged() acquires CwdGuard internally — do NOT acquire it
281        // here as well or we deadlock on the shared CWD mutex.
282        let err = run_staged(true, None, None, None, Some(temp_dir.path()))
283            .await
284            .unwrap_err();
285        let msg = format!("{err:#}");
286        assert!(
287            msg.to_lowercase().contains("no staged changes"),
288            "expected 'no staged changes' error, got: {msg}"
289        );
290    }
291
292    #[tokio::test]
293    async fn run_staged_with_client_print_only_does_not_commit() {
294        let temp_dir = init_repo_with_staged_change();
295        let _guard = super::super::CwdGuard::enter(temp_dir.path())
296            .await
297            .unwrap();
298        let head_before = head_oid(temp_dir.path());
299
300        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add bar".to_string())]);
301        let client = ClaudeClient::new(Box::new(mock));
302
303        let outcome = run_staged_with_client(true, &[], &client).await.unwrap();
304        assert!(!outcome.applied, "print_only must not apply");
305        assert_eq!(outcome.message, "feat(foo): add bar");
306
307        let head_after = head_oid(temp_dir.path());
308        assert_eq!(head_before, head_after, "HEAD must be unchanged");
309    }
310
311    #[tokio::test]
312    async fn run_staged_with_client_commits_on_default() {
313        let temp_dir = init_repo_with_staged_change();
314        let _guard = super::super::CwdGuard::enter(temp_dir.path())
315            .await
316            .unwrap();
317        let head_before = head_oid(temp_dir.path());
318
319        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add marker".to_string())]);
320        let client = ClaudeClient::new(Box::new(mock));
321
322        let outcome = run_staged_with_client(false, &[], &client).await.unwrap();
323        assert!(outcome.applied, "default mode must commit");
324
325        let head_after = head_oid(temp_dir.path());
326        assert_ne!(head_before, head_after, "HEAD must advance");
327
328        let msg = head_message(temp_dir.path());
329        assert!(
330            msg.starts_with("feat(foo): add marker"),
331            "expected AI message at HEAD, got: {msg:?}"
332        );
333    }
334
335    #[tokio::test]
336    async fn run_staged_propagates_ai_failure() {
337        let temp_dir = init_repo_with_staged_change();
338        let _guard = super::super::CwdGuard::enter(temp_dir.path())
339            .await
340            .unwrap();
341        let head_before = head_oid(temp_dir.path());
342
343        // Empty response queue → mock returns Err on first call.
344        let mock = ConfigurableMockAiClient::new(vec![]);
345        let client = ClaudeClient::new(Box::new(mock));
346
347        let err = run_staged_with_client(false, &[], &client)
348            .await
349            .unwrap_err();
350        let _ = err;
351
352        let head_after = head_oid(temp_dir.path());
353        assert_eq!(head_before, head_after, "HEAD must not advance on failure");
354    }
355
356    #[tokio::test]
357    async fn run_staged_with_client_trims_ai_response_whitespace() {
358        let temp_dir = init_repo_with_staged_change();
359        let _guard = super::super::CwdGuard::enter(temp_dir.path())
360            .await
361            .unwrap();
362
363        let mock = ConfigurableMockAiClient::new(vec![Ok("  feat(x): y  \n\n".to_string())]);
364        let client = ClaudeClient::new(Box::new(mock));
365
366        let outcome = run_staged_with_client(true, &[], &client).await.unwrap();
367        assert_eq!(outcome.message, "feat(x): y");
368    }
369
370    #[tokio::test]
371    async fn run_staged_with_client_empty_ai_response_errors() {
372        let temp_dir = init_repo_with_staged_change();
373        let _guard = super::super::CwdGuard::enter(temp_dir.path())
374            .await
375            .unwrap();
376
377        let mock = ConfigurableMockAiClient::new(vec![Ok("   \n\n".to_string())]);
378        let client = ClaudeClient::new(Box::new(mock));
379
380        let err = run_staged_with_client(false, &[], &client)
381            .await
382            .unwrap_err();
383        let msg = format!("{err:#}");
384        assert!(
385            msg.to_lowercase().contains("empty"),
386            "expected 'empty' error, got: {msg}"
387        );
388    }
389
390    #[tokio::test]
391    async fn run_staged_invokes_git_commit_subprocess_so_hooks_fire() {
392        let temp_dir = init_repo_with_staged_change();
393        let _guard = super::super::CwdGuard::enter(temp_dir.path())
394            .await
395            .unwrap();
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)
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        let _guard = super::super::CwdGuard::enter(temp_dir.path())
434            .await
435            .unwrap();
436
437        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(cli): add".to_string())]);
438        let prompts = mock.prompt_handle();
439        let client = ClaudeClient::new(Box::new(mock));
440
441        let scopes = vec![ScopeDefinition {
442            name: "cli".to_string(),
443            description: "CLI module".to_string(),
444            examples: Vec::new(),
445            file_patterns: Vec::new(),
446        }];
447
448        let _ = run_staged_with_client(true, &scopes, &client)
449            .await
450            .unwrap();
451        let recorded = prompts.prompts();
452        assert_eq!(recorded.len(), 1, "exactly one AI call");
453        let (system, _user) = &recorded[0];
454        assert!(
455            system.contains("VALID SCOPES FOR THIS PROJECT"),
456            "scopes section missing from system prompt"
457        );
458        assert!(system.contains("`cli`: CLI module"));
459    }
460
461    #[test]
462    fn staged_outcome_clone_and_debug() {
463        let outcome = StagedOutcome {
464            message: "feat: x".to_string(),
465            applied: true,
466        };
467        let cloned = outcome.clone();
468        assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
469    }
470
471    // Drives `StagedCommand::execute()` through its no-staged-changes bail.
472    // The command's `execute` delegates to `run_staged`, which short-circuits
473    // before any AI credential check, so this exercises the dispatch wiring
474    // without needing real AI credentials.
475    #[tokio::test]
476    async fn staged_command_execute_bails_when_nothing_staged() {
477        let temp_dir = init_empty_repo();
478        let _guard = super::super::CwdGuard::enter(temp_dir.path())
479            .await
480            .unwrap();
481        let cmd = StagedCommand {
482            print_only: true,
483            model: None,
484            beta_header: None,
485            context_dir: None,
486        };
487        let err = cmd.execute().await.unwrap_err();
488        let msg = format!("{err:#}");
489        assert!(
490            msg.to_lowercase().contains("no staged changes"),
491            "expected 'no staged changes' error from execute(), got: {msg}"
492        );
493    }
494
495    // `execute()` parses `--beta-header` before any other work; an invalid
496    // value should error out with a clear "Invalid --beta-header" message.
497    #[tokio::test]
498    async fn staged_command_execute_rejects_malformed_beta_header() {
499        let temp_dir = init_empty_repo();
500        let _guard = super::super::CwdGuard::enter(temp_dir.path())
501            .await
502            .unwrap();
503        let cmd = StagedCommand {
504            print_only: true,
505            model: None,
506            beta_header: Some("no-colon-here".to_string()),
507            context_dir: None,
508        };
509        let err = cmd.execute().await.unwrap_err();
510        let msg = format!("{err:#}");
511        assert!(
512            msg.contains("Invalid --beta-header"),
513            "expected beta-header parse error, got: {msg}"
514        );
515    }
516}