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