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