Skip to main content

omni_dev/cli/
git.rs

1//! Git-related CLI commands.
2
3mod amend;
4mod check;
5mod create_pr;
6pub(crate) mod formatting;
7mod info;
8mod staged;
9mod twiddle;
10mod view;
11
12pub use amend::AmendCommand;
13pub use check::{run_check, CheckCommand, CheckOutcome};
14pub use create_pr::{run_create_pr, CreatePrCommand, CreatePrOutcome, PrContent};
15pub use info::{run_info, InfoCommand};
16pub use staged::{run_staged, StagedCommand, StagedOutcome};
17pub use twiddle::{run_twiddle, TwiddleCommand, TwiddleOutcome};
18pub use view::{run_view, ViewCommand};
19
20use anyhow::Result;
21use clap::{Parser, Subcommand};
22
23/// Global async mutex serialising every caller that mutates the process-wide
24/// current working directory via `std::env::set_current_dir`.
25///
26/// Used by:
27/// - The production [`CwdGuard`] wrapper that MCP tool handlers acquire via
28///   `.lock().await`.
29/// - Async unit tests that call `CwdGuard::enter` directly (e.g., `check`,
30///   `twiddle`, `create_pr`).
31/// - Sync unit tests that change CWD directly; they acquire the same mutex
32///   via [`tokio::sync::Mutex::blocking_lock`] so both styles of test
33///   serialise through one instance and cannot race on the shared CWD.
34///
35/// We use `tokio::sync::Mutex` rather than `std::sync::Mutex` so the guard is
36/// `Send` and can be held across `.await` points (required by the MCP
37/// async tool handlers).
38pub(crate) static CWD_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
39
40/// RAII guard that temporarily changes the process current working directory
41/// and restores it on drop.
42///
43/// Shared by MCP tool handlers that accept a `repo_path` parameter: many
44/// commands (check/twiddle/create_pr) read configuration and invoke external
45/// tools relative to the current working directory, so the simplest way to
46/// "run this command at a different path" is to pin the CWD for the duration
47/// of the call. A global async mutex serialises concurrent callers.
48pub(crate) struct CwdGuard {
49    original: std::path::PathBuf,
50    _lock: tokio::sync::MutexGuard<'static, ()>,
51}
52
53impl CwdGuard {
54    /// Enters `path`, holding the CWD mutex for the lifetime of the guard.
55    pub(crate) async fn enter<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
56        let lock = CWD_MUTEX.lock().await;
57        let original =
58            std::env::current_dir().map_err(|e| anyhow::anyhow!("current_dir failed: {e}"))?;
59        std::env::set_current_dir(path.as_ref())
60            .map_err(|e| anyhow::anyhow!("set_current_dir failed: {e}"))?;
61        Ok(Self {
62            original,
63            _lock: lock,
64        })
65    }
66}
67
68impl Drop for CwdGuard {
69    fn drop(&mut self) {
70        let _ = std::env::set_current_dir(&self.original);
71    }
72}
73
74/// Reads one line of interactive input from `reader`.
75///
76/// Returns `Some(line)` on success, or `None` when the reader reaches EOF
77/// (i.e., `read_line` returns 0 bytes). Callers handle the `None` case
78/// with context-specific warnings and control flow.
79pub(super) fn read_interactive_line(
80    reader: &mut (dyn std::io::BufRead + Send),
81) -> std::io::Result<Option<String>> {
82    let mut input = String::new();
83    let bytes = reader.read_line(&mut input)?;
84    if bytes == 0 {
85        Ok(None)
86    } else {
87        Ok(Some(input))
88    }
89}
90
91/// Parses a `--beta-header key:value` string into a `(key, value)` tuple.
92pub(crate) fn parse_beta_header(s: &str) -> Result<(String, String)> {
93    let (k, v) = s
94        .split_once(':')
95        .ok_or_else(|| anyhow::anyhow!("Invalid --beta-header format '{s}'. Expected key:value"))?;
96    Ok((k.to_string(), v.to_string()))
97}
98
99/// Git operations.
100#[derive(Parser)]
101pub struct GitCommand {
102    /// Git subcommand to execute.
103    #[command(subcommand)]
104    pub command: GitSubcommands,
105}
106
107/// Git subcommands.
108#[derive(Subcommand)]
109pub enum GitSubcommands {
110    /// Commit-related operations.
111    Commit(CommitCommand),
112    /// Branch-related operations.
113    Branch(BranchCommand),
114}
115
116/// Commit operations.
117#[derive(Parser)]
118pub struct CommitCommand {
119    /// Commit subcommand to execute.
120    #[command(subcommand)]
121    pub command: CommitSubcommands,
122}
123
124/// Commit subcommands.
125#[derive(Subcommand)]
126pub enum CommitSubcommands {
127    /// Commit message operations.
128    Message(MessageCommand),
129}
130
131/// Message operations.
132#[derive(Parser)]
133pub struct MessageCommand {
134    /// Message subcommand to execute.
135    #[command(subcommand)]
136    pub command: MessageSubcommands,
137}
138
139/// Message subcommands.
140#[derive(Subcommand)]
141pub enum MessageSubcommands {
142    /// Analyzes commits and outputs repository information in YAML format.
143    View(ViewCommand),
144    /// Amends commit messages based on a YAML configuration file.
145    Amend(AmendCommand),
146    /// AI-powered commit message improvement using Claude.
147    Twiddle(TwiddleCommand),
148    /// Checks commit messages against guidelines without modifying them.
149    Check(CheckCommand),
150    /// Generates a commit message from staged changes and commits them.
151    Staged(StagedCommand),
152}
153
154/// Branch operations.
155#[derive(Parser)]
156pub struct BranchCommand {
157    /// Branch subcommand to execute.
158    #[command(subcommand)]
159    pub command: BranchSubcommands,
160}
161
162/// Branch subcommands.
163#[derive(Subcommand)]
164pub enum BranchSubcommands {
165    /// Analyzes branch commits and outputs repository information in YAML format.
166    Info(InfoCommand),
167    /// Create operations.
168    Create(CreateCommand),
169}
170
171/// Create operations.
172#[derive(Parser)]
173pub struct CreateCommand {
174    /// Create subcommand to execute.
175    #[command(subcommand)]
176    pub command: CreateSubcommands,
177}
178
179/// Create subcommands.
180#[derive(Subcommand)]
181pub enum CreateSubcommands {
182    /// Creates a pull request with AI-generated description.
183    Pr(CreatePrCommand),
184}
185
186impl GitCommand {
187    /// Executes the git command.
188    pub async fn execute(self) -> Result<()> {
189        match self.command {
190            GitSubcommands::Commit(commit_cmd) => commit_cmd.execute().await,
191            GitSubcommands::Branch(branch_cmd) => branch_cmd.execute().await,
192        }
193    }
194}
195
196impl CommitCommand {
197    /// Executes the commit command.
198    pub async fn execute(self) -> Result<()> {
199        match self.command {
200            CommitSubcommands::Message(message_cmd) => message_cmd.execute().await,
201        }
202    }
203}
204
205impl MessageCommand {
206    /// Executes the message command.
207    pub async fn execute(self) -> Result<()> {
208        match self.command {
209            MessageSubcommands::View(view_cmd) => view_cmd.execute(),
210            MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
211            MessageSubcommands::Twiddle(twiddle_cmd) => twiddle_cmd.execute().await,
212            MessageSubcommands::Check(check_cmd) => check_cmd.execute().await,
213            MessageSubcommands::Staged(staged_cmd) => staged_cmd.execute().await,
214        }
215    }
216}
217
218impl BranchCommand {
219    /// Executes the branch command.
220    pub async fn execute(self) -> Result<()> {
221        match self.command {
222            BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
223            BranchSubcommands::Create(create_cmd) => create_cmd.execute().await,
224        }
225    }
226}
227
228impl CreateCommand {
229    /// Executes the create command.
230    pub async fn execute(self) -> Result<()> {
231        match self.command {
232            CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute().await,
233        }
234    }
235}
236
237#[cfg(test)]
238#[allow(clippy::unwrap_used, clippy::expect_used)]
239mod tests {
240    use super::*;
241    use crate::cli::Cli;
242    // Parser trait must be in scope for try_parse_from
243    use clap::Parser as _ClapParser;
244
245    #[test]
246    fn parse_beta_header_valid() {
247        let (key, value) = parse_beta_header("anthropic-beta:output-128k-2025-02-19").unwrap();
248        assert_eq!(key, "anthropic-beta");
249        assert_eq!(value, "output-128k-2025-02-19");
250    }
251
252    #[test]
253    fn parse_beta_header_multiple_colons() {
254        // Only splits on the first colon
255        let (key, value) = parse_beta_header("key:value:with:colons").unwrap();
256        assert_eq!(key, "key");
257        assert_eq!(value, "value:with:colons");
258    }
259
260    #[test]
261    fn parse_beta_header_missing_colon() {
262        let result = parse_beta_header("no-colon-here");
263        assert!(result.is_err());
264        let err_msg = result.unwrap_err().to_string();
265        assert!(err_msg.contains("no-colon-here"));
266    }
267
268    #[test]
269    fn parse_beta_header_empty_value() {
270        let (key, value) = parse_beta_header("key:").unwrap();
271        assert_eq!(key, "key");
272        assert_eq!(value, "");
273    }
274
275    #[test]
276    fn parse_beta_header_empty_key() {
277        let (key, value) = parse_beta_header(":value").unwrap();
278        assert_eq!(key, "");
279        assert_eq!(value, "value");
280    }
281
282    #[test]
283    fn cli_parses_git_commit_message_view() {
284        let cli = Cli::try_parse_from([
285            "omni-dev",
286            "git",
287            "commit",
288            "message",
289            "view",
290            "HEAD~3..HEAD",
291        ]);
292        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
293    }
294
295    #[test]
296    fn cli_parses_git_commit_message_amend() {
297        let cli = Cli::try_parse_from([
298            "omni-dev",
299            "git",
300            "commit",
301            "message",
302            "amend",
303            "amendments.yaml",
304        ]);
305        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
306    }
307
308    #[test]
309    fn cli_parses_git_branch_info() {
310        let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info"]);
311        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
312    }
313
314    #[test]
315    fn cli_parses_git_branch_info_with_base() {
316        let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info", "develop"]);
317        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
318    }
319
320    #[test]
321    fn cli_parses_config_models_show() {
322        let cli = Cli::try_parse_from(["omni-dev", "config", "models", "show"]);
323        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
324    }
325
326    #[test]
327    fn cli_parses_help_all() {
328        let cli = Cli::try_parse_from(["omni-dev", "help-all"]);
329        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
330    }
331
332    #[test]
333    fn cli_rejects_unknown_command() {
334        let cli = Cli::try_parse_from(["omni-dev", "nonexistent"]);
335        assert!(cli.is_err());
336    }
337
338    #[test]
339    fn cli_parses_twiddle_with_options() {
340        let cli = Cli::try_parse_from([
341            "omni-dev",
342            "git",
343            "commit",
344            "message",
345            "twiddle",
346            "--auto-apply",
347            "--no-context",
348            "--concurrency",
349            "8",
350        ]);
351        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
352    }
353
354    #[test]
355    fn cli_parses_check_with_options() {
356        let cli = Cli::try_parse_from([
357            "omni-dev", "git", "commit", "message", "check", "--strict", "--quiet", "--format",
358            "json",
359        ]);
360        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
361    }
362
363    #[test]
364    fn cli_parses_git_commit_message_staged() {
365        let cli = Cli::try_parse_from(["omni-dev", "git", "commit", "message", "staged"]);
366        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
367    }
368
369    #[test]
370    fn cli_parses_git_commit_message_staged_print_only() {
371        let cli = Cli::try_parse_from([
372            "omni-dev",
373            "git",
374            "commit",
375            "message",
376            "staged",
377            "--print-only",
378        ]);
379        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
380    }
381
382    #[test]
383    fn cli_parses_git_commit_message_staged_with_model_and_beta() {
384        let cli = Cli::try_parse_from([
385            "omni-dev",
386            "git",
387            "commit",
388            "message",
389            "staged",
390            "--model",
391            "claude-sonnet-4-6",
392            "--beta-header",
393            "anthropic-beta:output-128k-2025-02-19",
394        ]);
395        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
396    }
397
398    #[test]
399    fn cli_parses_commands_generate_all() {
400        let cli = Cli::try_parse_from(["omni-dev", "commands", "generate", "all"]);
401        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
402    }
403
404    #[test]
405    fn cli_parses_ai_chat() {
406        let cli = Cli::try_parse_from(["omni-dev", "ai", "chat"]);
407        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
408    }
409
410    #[test]
411    fn cli_parses_ai_chat_with_model() {
412        let cli = Cli::try_parse_from(["omni-dev", "ai", "chat", "--model", "claude-sonnet-4"]);
413        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
414    }
415
416    #[test]
417    fn cli_parses_ai_claude_cli_model_resolve() {
418        let cli = Cli::try_parse_from(["omni-dev", "ai", "claude", "cli", "model", "resolve"]);
419        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
420    }
421
422    #[test]
423    fn read_interactive_line_returns_input() {
424        let mut reader = std::io::Cursor::new(b"hello\n" as &[u8]);
425        let result = read_interactive_line(&mut reader).unwrap();
426        assert_eq!(result, Some("hello\n".to_string()));
427    }
428
429    #[test]
430    fn read_interactive_line_eof_returns_none() {
431        let mut reader = std::io::Cursor::new(b"" as &[u8]);
432        let result = read_interactive_line(&mut reader).unwrap();
433        assert_eq!(result, None);
434    }
435
436    #[test]
437    fn read_interactive_line_empty_line() {
438        let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
439        let result = read_interactive_line(&mut reader).unwrap();
440        assert_eq!(result, Some("\n".to_string()));
441    }
442
443    #[tokio::test]
444    async fn cwd_guard_invalid_path_returns_error() {
445        // Error path doesn't mutate the shared CWD, so it is safe to run in
446        // parallel with the rest of the test suite. The happy path is covered
447        // indirectly by `run_{check,twiddle,create_pr}` error-path tests
448        // that exercise `CwdGuard::enter(valid_path)` followed by restoration.
449        let result = CwdGuard::enter("/no/such/path/exists").await;
450        assert!(result.is_err(), "expected error for nonexistent path");
451    }
452}