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