1mod 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
29pub(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
46pub(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#[derive(Parser)]
61pub struct GitCommand {
62 #[command(subcommand)]
64 pub command: GitSubcommands,
65
66 #[command(flatten)]
68 pub repo: crate::cli::repo_arg::RepoArg,
69}
70
71#[derive(Subcommand)]
73pub enum GitSubcommands {
74 Commit(CommitCommand),
76 Branch(BranchCommand),
78 Worktree(WorktreeCommand),
80}
81
82#[derive(Parser)]
84pub struct CommitCommand {
85 #[command(subcommand)]
87 pub command: CommitSubcommands,
88}
89
90#[derive(Subcommand)]
92pub enum CommitSubcommands {
93 Message(MessageCommand),
95}
96
97#[derive(Parser)]
99pub struct MessageCommand {
100 #[command(subcommand)]
102 pub command: MessageSubcommands,
103}
104
105#[derive(Subcommand)]
107pub enum MessageSubcommands {
108 View(ViewCommand),
110 Amend(AmendCommand),
112 Twiddle(TwiddleCommand),
114 Check(CheckCommand),
116 Lint(LintCommand),
118 Staged(StagedCommand),
120}
121
122#[derive(Parser)]
124pub struct BranchCommand {
125 #[command(subcommand)]
127 pub command: BranchSubcommands,
128}
129
130#[derive(Subcommand)]
132pub enum BranchSubcommands {
133 Info(InfoCommand),
135 Create(CreateCommand),
137}
138
139#[derive(Parser)]
141pub struct CreateCommand {
142 #[command(subcommand)]
144 pub command: CreateSubcommands,
145}
146
147#[derive(Subcommand)]
149pub enum CreateSubcommands {
150 Pr(CreatePrCommand),
152}
153
154impl GitCommand {
155 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 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 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 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 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 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 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 #[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}