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