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 std::path::Path;
21
22use anyhow::Result;
23use clap::{Parser, Subcommand};
24
25/// Reads one line of interactive input from `reader`.
26///
27/// Returns `Some(line)` on success, or `None` when the reader reaches EOF
28/// (i.e., `read_line` returns 0 bytes). Callers handle the `None` case
29/// with context-specific warnings and control flow.
30pub(super) fn read_interactive_line(
31    reader: &mut (dyn std::io::BufRead + Send),
32) -> std::io::Result<Option<String>> {
33    let mut input = String::new();
34    let bytes = reader.read_line(&mut input)?;
35    if bytes == 0 {
36        Ok(None)
37    } else {
38        Ok(Some(input))
39    }
40}
41
42/// Parses a `--beta-header key:value` string into a `(key, value)` tuple.
43pub(crate) fn parse_beta_header(s: &str) -> Result<(String, String)> {
44    let (k, v) = s
45        .split_once(':')
46        .ok_or_else(|| anyhow::anyhow!("Invalid --beta-header format '{s}'. Expected key:value"))?;
47    Ok((k.to_string(), v.to_string()))
48}
49
50/// Git operations.
51#[derive(Parser)]
52pub struct GitCommand {
53    /// Git subcommand to execute.
54    #[command(subcommand)]
55    pub command: GitSubcommands,
56}
57
58/// Git subcommands.
59#[derive(Subcommand)]
60pub enum GitSubcommands {
61    /// Commit-related operations.
62    Commit(CommitCommand),
63    /// Branch-related operations.
64    Branch(BranchCommand),
65}
66
67/// Commit operations.
68#[derive(Parser)]
69pub struct CommitCommand {
70    /// Commit subcommand to execute.
71    #[command(subcommand)]
72    pub command: CommitSubcommands,
73}
74
75/// Commit subcommands.
76#[derive(Subcommand)]
77pub enum CommitSubcommands {
78    /// Commit message operations.
79    Message(MessageCommand),
80}
81
82/// Message operations.
83#[derive(Parser)]
84pub struct MessageCommand {
85    /// Message subcommand to execute.
86    #[command(subcommand)]
87    pub command: MessageSubcommands,
88}
89
90/// Message subcommands.
91#[derive(Subcommand)]
92pub enum MessageSubcommands {
93    /// Analyzes commits and outputs repository information in YAML format.
94    View(ViewCommand),
95    /// Amends commit messages based on a YAML configuration file.
96    Amend(AmendCommand),
97    /// AI-powered commit message improvement using Claude.
98    Twiddle(TwiddleCommand),
99    /// Checks commit messages against guidelines without modifying them.
100    Check(CheckCommand),
101    /// Generates a commit message from staged changes and commits them.
102    Staged(StagedCommand),
103}
104
105/// Branch operations.
106#[derive(Parser)]
107pub struct BranchCommand {
108    /// Branch subcommand to execute.
109    #[command(subcommand)]
110    pub command: BranchSubcommands,
111}
112
113/// Branch subcommands.
114#[derive(Subcommand)]
115pub enum BranchSubcommands {
116    /// Analyzes branch commits and outputs repository information in YAML format.
117    Info(InfoCommand),
118    /// Create operations.
119    Create(CreateCommand),
120}
121
122/// Create operations.
123#[derive(Parser)]
124pub struct CreateCommand {
125    /// Create subcommand to execute.
126    #[command(subcommand)]
127    pub command: CreateSubcommands,
128}
129
130/// Create subcommands.
131#[derive(Subcommand)]
132pub enum CreateSubcommands {
133    /// Creates a pull request with AI-generated description.
134    Pr(CreatePrCommand),
135}
136
137impl GitCommand {
138    /// Executes the git command.
139    ///
140    /// `repo` is the repository location resolved once at the CLI boundary
141    /// (`None` = current working directory); it is threaded explicitly down to
142    /// each leaf command rather than read from the ambient CWD.
143    pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
144        match self.command {
145            GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(repo).await,
146            GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(repo).await,
147        }
148    }
149}
150
151impl CommitCommand {
152    /// Executes the commit command.
153    pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
154        match self.command {
155            CommitSubcommands::Message(message_cmd) => message_cmd.execute(repo).await,
156        }
157    }
158}
159
160impl MessageCommand {
161    /// Executes the message command.
162    pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
163        match self.command {
164            MessageSubcommands::View(view_cmd) => view_cmd.execute(repo),
165            MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(repo),
166            MessageSubcommands::Twiddle(twiddle_cmd) => twiddle_cmd.execute(repo).await,
167            MessageSubcommands::Check(check_cmd) => check_cmd.execute(repo).await,
168            MessageSubcommands::Staged(staged_cmd) => staged_cmd.execute(repo).await,
169        }
170    }
171}
172
173impl BranchCommand {
174    /// Executes the branch command.
175    pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
176        match self.command {
177            BranchSubcommands::Info(info_cmd) => info_cmd.execute(repo),
178            BranchSubcommands::Create(create_cmd) => create_cmd.execute(repo).await,
179        }
180    }
181}
182
183impl CreateCommand {
184    /// Executes the create command.
185    pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
186        match self.command {
187            CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute(repo).await,
188        }
189    }
190}
191
192#[cfg(test)]
193#[allow(clippy::unwrap_used, clippy::expect_used)]
194mod tests {
195    use super::*;
196    use crate::cli::Cli;
197    // Parser trait must be in scope for try_parse_from
198    use clap::Parser as _ClapParser;
199
200    #[test]
201    fn parse_beta_header_valid() {
202        let (key, value) = parse_beta_header("anthropic-beta:output-128k-2025-02-19").unwrap();
203        assert_eq!(key, "anthropic-beta");
204        assert_eq!(value, "output-128k-2025-02-19");
205    }
206
207    #[test]
208    fn parse_beta_header_multiple_colons() {
209        // Only splits on the first colon
210        let (key, value) = parse_beta_header("key:value:with:colons").unwrap();
211        assert_eq!(key, "key");
212        assert_eq!(value, "value:with:colons");
213    }
214
215    #[test]
216    fn parse_beta_header_missing_colon() {
217        let result = parse_beta_header("no-colon-here");
218        assert!(result.is_err());
219        let err_msg = result.unwrap_err().to_string();
220        assert!(err_msg.contains("no-colon-here"));
221    }
222
223    #[test]
224    fn parse_beta_header_empty_value() {
225        let (key, value) = parse_beta_header("key:").unwrap();
226        assert_eq!(key, "key");
227        assert_eq!(value, "");
228    }
229
230    #[test]
231    fn parse_beta_header_empty_key() {
232        let (key, value) = parse_beta_header(":value").unwrap();
233        assert_eq!(key, "");
234        assert_eq!(value, "value");
235    }
236
237    #[test]
238    fn cli_parses_git_commit_message_view() {
239        let cli = Cli::try_parse_from([
240            "omni-dev",
241            "git",
242            "commit",
243            "message",
244            "view",
245            "HEAD~3..HEAD",
246        ]);
247        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
248    }
249
250    #[test]
251    fn cli_parses_git_commit_message_amend() {
252        let cli = Cli::try_parse_from([
253            "omni-dev",
254            "git",
255            "commit",
256            "message",
257            "amend",
258            "amendments.yaml",
259        ]);
260        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
261    }
262
263    #[test]
264    fn cli_parses_git_branch_info() {
265        let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info"]);
266        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
267    }
268
269    #[test]
270    fn cli_parses_git_branch_info_with_base() {
271        let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info", "develop"]);
272        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
273    }
274
275    #[test]
276    fn cli_parses_config_models_show() {
277        let cli = Cli::try_parse_from(["omni-dev", "config", "models", "show"]);
278        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
279    }
280
281    #[test]
282    fn cli_parses_help_all() {
283        let cli = Cli::try_parse_from(["omni-dev", "help-all"]);
284        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
285    }
286
287    #[test]
288    fn cli_rejects_unknown_command() {
289        let cli = Cli::try_parse_from(["omni-dev", "nonexistent"]);
290        assert!(cli.is_err());
291    }
292
293    #[test]
294    fn cli_parses_twiddle_with_options() {
295        let cli = Cli::try_parse_from([
296            "omni-dev",
297            "git",
298            "commit",
299            "message",
300            "twiddle",
301            "--auto-apply",
302            "--no-context",
303            "--concurrency",
304            "8",
305        ]);
306        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
307    }
308
309    #[test]
310    fn cli_parses_check_with_options() {
311        let cli = Cli::try_parse_from([
312            "omni-dev", "git", "commit", "message", "check", "--strict", "--quiet", "--format",
313            "json",
314        ]);
315        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
316    }
317
318    #[test]
319    fn cli_parses_git_commit_message_staged() {
320        let cli = Cli::try_parse_from(["omni-dev", "git", "commit", "message", "staged"]);
321        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
322    }
323
324    #[test]
325    fn cli_parses_git_commit_message_staged_print_only() {
326        let cli = Cli::try_parse_from([
327            "omni-dev",
328            "git",
329            "commit",
330            "message",
331            "staged",
332            "--print-only",
333        ]);
334        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
335    }
336
337    #[test]
338    fn cli_parses_git_commit_message_staged_with_model_and_beta() {
339        let cli = Cli::try_parse_from([
340            "omni-dev",
341            "git",
342            "commit",
343            "message",
344            "staged",
345            "--model",
346            "claude-sonnet-4-6",
347            "--beta-header",
348            "anthropic-beta:output-128k-2025-02-19",
349        ]);
350        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
351    }
352
353    #[test]
354    fn cli_parses_commands_generate_all() {
355        let cli = Cli::try_parse_from(["omni-dev", "commands", "generate", "all"]);
356        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
357    }
358
359    #[test]
360    fn cli_parses_ai_chat() {
361        let cli = Cli::try_parse_from(["omni-dev", "ai", "chat"]);
362        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
363    }
364
365    #[test]
366    fn cli_parses_ai_chat_with_model() {
367        let cli = Cli::try_parse_from(["omni-dev", "ai", "chat", "--model", "claude-sonnet-4"]);
368        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
369    }
370
371    #[test]
372    fn cli_parses_ai_claude_cli_model_resolve() {
373        let cli = Cli::try_parse_from(["omni-dev", "ai", "claude", "cli", "model", "resolve"]);
374        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
375    }
376
377    #[test]
378    fn read_interactive_line_returns_input() {
379        let mut reader = std::io::Cursor::new(b"hello\n" as &[u8]);
380        let result = read_interactive_line(&mut reader).unwrap();
381        assert_eq!(result, Some("hello\n".to_string()));
382    }
383
384    #[test]
385    fn read_interactive_line_eof_returns_none() {
386        let mut reader = std::io::Cursor::new(b"" as &[u8]);
387        let result = read_interactive_line(&mut reader).unwrap();
388        assert_eq!(result, None);
389    }
390
391    #[test]
392    fn read_interactive_line_empty_line() {
393        let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
394        let result = read_interactive_line(&mut reader).unwrap();
395        assert_eq!(result, Some("\n".to_string()));
396    }
397
398    /// All `git` message and branch commands now honor `--repo` by threading
399    /// an explicit repo root through their reads (RULE 6 fully satisfied):
400    /// `git branch info`, `git branch create pr`, `git commit message view`,
401    /// `git commit message staged`, `git commit message check`, `git commit
402    /// message amend`, and `git commit message twiddle` are all converted, so
403    /// there are no remaining reject-guards. The empty array keeps this guard
404    /// in place: should a future unconverted command be added, list it here so
405    /// it is asserted to reject `--repo` rather than silently ignoring it.
406    #[tokio::test]
407    async fn repo_flag_rejected_for_unconverted_commands() {
408        let unconverted: [&[&str]; 0] = [];
409        for args in unconverted {
410            let cli = Cli::try_parse_from(args.iter().copied()).unwrap();
411            let err = cli
412                .execute()
413                .await
414                .expect_err("unconverted command must reject --repo");
415            let msg = format!("{err:#}");
416            assert!(
417                msg.contains("not yet supported"),
418                "args {args:?} -> unexpected error: {msg}"
419            );
420        }
421    }
422}