omni_dev/cli/repo_arg.rs
1//! The `-C/--repo` flag, scoped to the commands that act on a local repository.
2//!
3//! Flattened as a subtree-`global = true` arg onto `git`, `coverage`,
4//! `config scopes`, and `ai claude skills` (every leaf below them reads it),
5//! and onto the `worktrees rebase` / `worktrees push` leaves (the only
6//! `worktrees` subcommands that touch the local repository) — per the
7//! placement rule in [`crate::cli::ai_backend_args`]. Everywhere else clap
8//! rejects it rather
9//! than silently ignoring it (#1778).
10
11use std::path::{Path, PathBuf};
12
13use clap::Args;
14
15/// `-C/--repo <PATH>`.
16#[derive(Args, Debug, Clone, Default)]
17pub struct RepoArg {
18 /// Run as if omni-dev was started in `<PATH>` instead of the current
19 /// working directory.
20 ///
21 /// Resolved exactly once by the owning command and threaded explicitly to
22 /// its leaves as a parameter; deliberately **not** propagated to an
23 /// environment variable (so, unlike the other scoped flags, `RepoArg` has
24 /// no `apply()`), so the repo location never becomes an ambient global.
25 /// Mirrors `git -C`.
26 #[arg(long = "repo", short = 'C', global = true, value_name = "PATH")]
27 pub repo: Option<PathBuf>,
28}
29
30impl RepoArg {
31 /// The repository location, `None` meaning the current working directory.
32 pub fn path(&self) -> Option<&Path> {
33 self.repo.as_deref()
34 }
35}
36
37#[cfg(test)]
38#[allow(clippy::unwrap_used, clippy::expect_used)]
39mod tests {
40 use crate::cli::{Cli, Commands};
41 use clap::Parser;
42
43 fn git_repo(argv: &[&str]) -> Option<std::path::PathBuf> {
44 let cli = Cli::try_parse_from(argv).unwrap();
45 let Commands::Git(git) = cli.command else {
46 panic!("expected a git command");
47 };
48 git.repo.repo
49 }
50
51 #[test]
52 fn parses_long_and_short_after_the_owning_subcommand() {
53 let view = ["commit", "message", "view", "HEAD"];
54 for flag in ["--repo", "-C"] {
55 // Directly after the owner, and — being subtree-global — after the leaf.
56 let early: Vec<&str> = ["omni-dev", "git", flag, "/tmp/r"]
57 .into_iter()
58 .chain(view)
59 .collect();
60 let late: Vec<&str> = ["omni-dev", "git"]
61 .into_iter()
62 .chain(view)
63 .chain([flag, "/tmp/r"])
64 .collect();
65 for argv in [early, late] {
66 assert_eq!(
67 git_repo(&argv).as_deref(),
68 Some(std::path::Path::new("/tmp/r")),
69 "{argv:?}"
70 );
71 }
72 }
73 assert!(git_repo(&["omni-dev", "git", "commit", "message", "view"]).is_none());
74 }
75
76 /// RULE 3: the repo location is a parameter, never a relocated global.
77 /// Unlike the other scoped-flag groups, `RepoArg` deliberately has no
78 /// `apply()` exporting it to an env var; this guard stops one being added
79 /// "to complete the pattern" by analogy with `AiBackendArgs`/`InstanceArg`.
80 #[test]
81 fn repo_arg_is_never_exported_to_the_environment() {
82 let source = include_str!("repo_arg.rs");
83 let production = source
84 .split("#[cfg(test)]")
85 .next()
86 .expect("split always yields a first piece");
87 for forbidden in ["set_var", "fn apply"] {
88 assert!(
89 !production.contains(forbidden),
90 "repo_arg.rs must not `{forbidden}`: -C/--repo is threaded as a \
91 parameter, never exported to an env var"
92 );
93 }
94 }
95
96 /// The pre-#1778 placement — before the subcommand — no longer parses.
97 #[test]
98 fn rejected_before_the_subcommand_and_on_non_readers() {
99 for argv in [
100 &[
101 "omni-dev", "-C", "/tmp/r", "git", "commit", "message", "view",
102 ][..],
103 &["omni-dev", "--repo", "/tmp/r", "help-all"],
104 &["omni-dev", "config", "models", "show", "-C", "/tmp/r"],
105 &["omni-dev", "log", "-C", "/tmp/r"],
106 ] {
107 assert!(Cli::try_parse_from(argv).is_err(), "{argv:?}");
108 }
109 }
110}