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 crate::data::context::ScopeDefinition;
14
15/// `omni-dev git commit message staged` CLI command.
16///
17/// Model/beta-header selection uses the global `--model`/`--beta-header`
18/// flags (propagated as `OMNI_DEV_MODEL`/`OMNI_DEV_BETA_HEADER`) and the
19/// per-backend env chain; there are no subcommand-local flags.
20#[derive(Parser)]
21pub struct StagedCommand {
22    /// Print the generated message to stdout instead of committing.
23    #[arg(long)]
24    pub print_only: bool,
25
26    /// Override the context directory used to load project scopes.
27    #[arg(long, value_name = "DIR")]
28    pub context_dir: Option<std::path::PathBuf>,
29}
30
31/// Outcome of a staged-commit run.
32#[derive(Debug, Clone)]
33pub struct StagedOutcome {
34    /// The generated commit message (trimmed of surrounding whitespace).
35    pub message: String,
36    /// `true` when the commit was applied to the repository; `false` for
37    /// `--print-only` or any path that did not run `git commit`.
38    pub applied: bool,
39}
40
41impl StagedCommand {
42    /// Executes the staged command.
43    ///
44    /// `repo` is the repository location resolved at the CLI boundary
45    /// (`None` = current working directory).
46    pub async fn execute(self, repo: Option<&std::path::Path>) -> Result<()> {
47        let _ = run_staged(
48            self.print_only,
49            None,
50            None,
51            self.context_dir.as_deref(),
52            repo,
53        )
54        .await?;
55        Ok(())
56    }
57}
58
59/// Public entry point for the staged-commit command.
60///
61/// Mirrors [`crate::cli::git::run_twiddle`]'s shape so the MCP server can wrap
62/// it the same way: resolve the repo root (the injected path, or the CWD as the
63/// default), run AI preflight, build the client, and delegate to the
64/// test-injectable inner [`run_staged_with_client`].
65pub async fn run_staged(
66    print_only: bool,
67    model: Option<String>,
68    beta_header: Option<(String, String)>,
69    context_dir: Option<&std::path::Path>,
70    repo_path: Option<&std::path::Path>,
71) -> Result<StagedOutcome> {
72    // Resolve the repo root once (the CWD is the default when no path is
73    // injected); every git subprocess and config/scopes read below anchors to
74    // it, so nothing deeper reads the ambient CWD.
75    let repo_root = match repo_path {
76        Some(p) => p.to_path_buf(),
77        None => std::env::current_dir().context("Failed to determine current directory")?,
78    };
79    let repo_root = repo_root.as_path();
80
81    if !has_staged_changes(repo_root)? {
82        anyhow::bail!("no staged changes — stage files with `git add` before running this command");
83    }
84
85    crate::utils::check_ai_command_prerequisites(model.as_deref(), repo_root)?;
86    let claude_client = crate::claude::create_default_claude_client(model, beta_header).await?;
87
88    let resolved_context_dir =
89        crate::claude::context::resolve_context_dir_at(context_dir, repo_root);
90    let valid_scopes =
91        crate::claude::context::load_project_scopes(&resolved_context_dir, repo_root);
92
93    run_staged_with_client(print_only, &valid_scopes, &claude_client, repo_root).await
94}
95
96/// Test-injectable core of [`run_staged`].
97///
98/// Assumes the caller has already:
99/// - Verified the working directory contains staged changes.
100/// - Verified AI credentials.
101/// - Constructed a fully initialised `ClaudeClient`.
102/// - Loaded `valid_scopes` (may be empty).
103pub(crate) async fn run_staged_with_client(
104    print_only: bool,
105    valid_scopes: &[ScopeDefinition],
106    claude_client: &crate::claude::client::ClaudeClient,
107    repo_root: &std::path::Path,
108) -> Result<StagedOutcome> {
109    let diff = read_staged_diff(repo_root)?;
110    let system = crate::claude::prompts::generate_staged_commit_system_prompt(valid_scopes);
111    let user = crate::claude::prompts::generate_staged_commit_user_prompt(&diff);
112
113    let raw = claude_client.send_message(&system, &user).await?;
114    let message = raw.trim().to_string();
115
116    if message.is_empty() {
117        anyhow::bail!("AI returned an empty commit message");
118    }
119
120    if print_only {
121        println!("{message}");
122        return Ok(StagedOutcome {
123            message,
124            applied: false,
125        });
126    }
127
128    commit_with_message(&message, repo_root)?;
129    Ok(StagedOutcome {
130        message,
131        applied: true,
132    })
133}
134
135/// Returns `true` if `git diff --cached --quiet` reports staged changes.
136///
137/// Exit codes per `git diff --quiet`:
138/// - `0` ⇒ no diff (nothing staged)
139/// - `1` ⇒ diff present (staged changes exist)
140/// - other ⇒ a real error (not in a repo, permission denied, etc.)
141fn has_staged_changes(repo_root: &std::path::Path) -> Result<bool> {
142    let output = Command::new("git")
143        .current_dir(repo_root)
144        .args(["diff", "--cached", "--quiet"])
145        .stdin(Stdio::null())
146        .env("GIT_TERMINAL_PROMPT", "0")
147        .output()
148        .context("Failed to execute git diff --cached --quiet")?;
149    match output.status.code() {
150        Some(0) => Ok(false),
151        Some(1) => Ok(true),
152        Some(code) => {
153            let stderr = String::from_utf8_lossy(&output.stderr);
154            anyhow::bail!("git diff --cached --quiet exited with code {code}: {stderr}")
155        }
156        None => anyhow::bail!("git diff --cached --quiet was terminated by a signal"),
157    }
158}
159
160/// Reads the staged diff via `git diff --cached`.
161fn read_staged_diff(repo_root: &std::path::Path) -> Result<String> {
162    let output = Command::new("git")
163        .current_dir(repo_root)
164        .args(["diff", "--cached"])
165        .stdin(Stdio::null())
166        .env("GIT_TERMINAL_PROMPT", "0")
167        .output()
168        .context("Failed to execute git diff --cached")?;
169    if !output.status.success() {
170        let stderr = String::from_utf8_lossy(&output.stderr);
171        anyhow::bail!("git diff --cached failed: {stderr}");
172    }
173    String::from_utf8(output.stdout).context("git diff --cached produced non-UTF-8 output")
174}
175
176/// Commits staged changes via `git commit -m <msg>` as a subprocess.
177///
178/// Uses `.status()` so stdout/stderr are inherited from the parent — this is
179/// deliberate: it lets the user see hook output live and confirms hooks
180/// (`pre-commit`, `commit-msg`) fire normally, which `libgit2`'s
181/// `repo.commit()` would bypass.
182///
183/// Stdin is explicitly `Stdio::null()` so neither `git commit` nor any hook
184/// can block reading from an inherited stdin fd. On CI runners (Linux), an
185/// inherited stdin from `cargo test` can produce indefinite waits that don't
186/// reproduce on developer terminals.
187fn commit_with_message(message: &str, repo_root: &std::path::Path) -> Result<()> {
188    let status = Command::new("git")
189        .current_dir(repo_root)
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        // `has_staged_changes` is anchored to the injected repo (`.current_dir`),
281        // so this empty repo bails regardless of whether the process CWD has
282        // staged changes.
283        let err = run_staged(true, None, None, None, Some(temp_dir.path()))
284            .await
285            .unwrap_err();
286        let msg = format!("{err:#}");
287        assert!(
288            msg.to_lowercase().contains("no staged changes"),
289            "expected 'no staged changes' error, got: {msg}"
290        );
291    }
292
293    #[tokio::test]
294    async fn run_staged_with_client_print_only_does_not_commit() {
295        let temp_dir = init_repo_with_staged_change();
296        let head_before = head_oid(temp_dir.path());
297
298        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add bar".to_string())]);
299        let client = ClaudeClient::new(Box::new(mock));
300
301        let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
302            .await
303            .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 head_before = head_oid(temp_dir.path());
315
316        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(foo): add marker".to_string())]);
317        let client = ClaudeClient::new(Box::new(mock));
318
319        let outcome = run_staged_with_client(false, &[], &client, temp_dir.path())
320            .await
321            .unwrap();
322        assert!(outcome.applied, "default mode must commit");
323
324        let head_after = head_oid(temp_dir.path());
325        assert_ne!(head_before, head_after, "HEAD must advance");
326
327        let msg = head_message(temp_dir.path());
328        assert!(
329            msg.starts_with("feat(foo): add marker"),
330            "expected AI message at HEAD, got: {msg:?}"
331        );
332    }
333
334    #[tokio::test]
335    async fn run_staged_propagates_ai_failure() {
336        let temp_dir = init_repo_with_staged_change();
337        let head_before = head_oid(temp_dir.path());
338
339        // Empty response queue → mock returns Err on first call.
340        let mock = ConfigurableMockAiClient::new(vec![]);
341        let client = ClaudeClient::new(Box::new(mock));
342
343        let err = run_staged_with_client(false, &[], &client, temp_dir.path())
344            .await
345            .unwrap_err();
346        let _ = err;
347
348        let head_after = head_oid(temp_dir.path());
349        assert_eq!(head_before, head_after, "HEAD must not advance on failure");
350    }
351
352    #[tokio::test]
353    async fn run_staged_with_client_trims_ai_response_whitespace() {
354        let temp_dir = init_repo_with_staged_change();
355
356        let mock = ConfigurableMockAiClient::new(vec![Ok("  feat(x): y  \n\n".to_string())]);
357        let client = ClaudeClient::new(Box::new(mock));
358
359        let outcome = run_staged_with_client(true, &[], &client, temp_dir.path())
360            .await
361            .unwrap();
362        assert_eq!(outcome.message, "feat(x): y");
363    }
364
365    #[tokio::test]
366    async fn run_staged_with_client_empty_ai_response_errors() {
367        let temp_dir = init_repo_with_staged_change();
368
369        let mock = ConfigurableMockAiClient::new(vec![Ok("   \n\n".to_string())]);
370        let client = ClaudeClient::new(Box::new(mock));
371
372        let err = run_staged_with_client(false, &[], &client, temp_dir.path())
373            .await
374            .unwrap_err();
375        let msg = format!("{err:#}");
376        assert!(
377            msg.to_lowercase().contains("empty"),
378            "expected 'empty' error, got: {msg}"
379        );
380    }
381
382    #[tokio::test]
383    async fn run_staged_invokes_git_commit_subprocess_so_hooks_fire() {
384        let temp_dir = init_repo_with_staged_change();
385        let head_before = head_oid(temp_dir.path());
386
387        // Install a commit-msg hook that always fails. If we go through real
388        // `git commit`, the hook fires and the commit is rejected. If we
389        // were using libgit2's repo.commit(), hooks would be bypassed.
390        let hook_path = temp_dir.path().join(".git/hooks/commit-msg");
391        std::fs::write(&hook_path, "#!/bin/sh\necho REJECTED-BY-HOOK >&2\nexit 1\n").unwrap();
392        #[cfg(unix)]
393        {
394            use std::os::unix::fs::PermissionsExt;
395            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
396            perms.set_mode(0o755);
397            std::fs::set_permissions(&hook_path, perms).unwrap();
398        }
399
400        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(x): y".to_string())]);
401        let client = ClaudeClient::new(Box::new(mock));
402
403        let err = run_staged_with_client(false, &[], &client, temp_dir.path())
404            .await
405            .unwrap_err();
406        let msg = format!("{err:#}");
407        assert!(
408            msg.to_lowercase().contains("git commit failed"),
409            "expected commit-failure error message, got: {msg}"
410        );
411
412        let head_after = head_oid(temp_dir.path());
413        assert_eq!(
414            head_before, head_after,
415            "HEAD must not advance when commit-msg hook rejects"
416        );
417    }
418
419    #[tokio::test]
420    async fn run_staged_passes_valid_scopes_into_prompt() {
421        let temp_dir = init_repo_with_staged_change();
422
423        let mock = ConfigurableMockAiClient::new(vec![Ok("feat(cli): add".to_string())]);
424        let prompts = mock.prompt_handle();
425        let client = ClaudeClient::new(Box::new(mock));
426
427        let scopes = vec![ScopeDefinition {
428            name: "cli".to_string(),
429            description: "CLI module".to_string(),
430            examples: Vec::new(),
431            file_patterns: Vec::new(),
432        }];
433
434        let _ = run_staged_with_client(true, &scopes, &client, temp_dir.path())
435            .await
436            .unwrap();
437        let recorded = prompts.prompts();
438        assert_eq!(recorded.len(), 1, "exactly one AI call");
439        let (system, _user) = &recorded[0];
440        assert!(
441            system.contains("VALID SCOPES FOR THIS PROJECT"),
442            "scopes section missing from system prompt"
443        );
444        assert!(system.contains("`cli`: CLI module"));
445    }
446
447    #[test]
448    fn staged_outcome_clone_and_debug() {
449        let outcome = StagedOutcome {
450            message: "feat: x".to_string(),
451            applied: true,
452        };
453        let cloned = outcome.clone();
454        assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
455    }
456
457    // Drives `StagedCommand::execute()` through its no-staged-changes bail.
458    // The command's `execute` delegates to `run_staged`, which short-circuits
459    // before any AI credential check, so this exercises the dispatch wiring
460    // without needing real AI credentials.
461    #[tokio::test]
462    async fn staged_command_execute_bails_when_nothing_staged() {
463        let temp_dir = init_empty_repo();
464        let cmd = StagedCommand {
465            print_only: true,
466            context_dir: None,
467        };
468        let err = cmd.execute(Some(temp_dir.path())).await.unwrap_err();
469        let msg = format!("{err:#}");
470        assert!(
471            msg.to_lowercase().contains("no staged changes"),
472            "expected 'no staged changes' error from execute(), got: {msg}"
473        );
474    }
475
476    /// "No silent mix" guard: `read_staged_diff` reads the staged diff from the
477    /// INJECTED repo, not the process CWD. We stage a uniquely-marked file in
478    /// the temp repo, run with that repo injected (the process CWD is the
479    /// omni-dev checkout), and assert the marker reached the AI prompt.
480    #[tokio::test]
481    async fn run_staged_with_client_reads_diff_from_injected_repo() {
482        let temp_dir = init_repo_with_staged_change();
483
484        let mock = ConfigurableMockAiClient::new(vec![Ok("feat: x".to_string())]);
485        let prompts = mock.prompt_handle();
486        let client = ClaudeClient::new(Box::new(mock));
487
488        let _ = run_staged_with_client(true, &[], &client, temp_dir.path())
489            .await
490            .unwrap();
491
492        let recorded = prompts.prompts();
493        assert_eq!(recorded.len(), 1, "exactly one AI call");
494        let (_system, user) = &recorded[0];
495        assert!(
496            user.contains("marker_xyz"),
497            "staged diff from the injected repo must reach the prompt: {user}"
498        );
499    }
500}