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