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