Skip to main content

cc_toolgate/commands/tools/
git.rs

1//! Subcommand-aware git evaluation.
2//!
3//! Handles global flags (`-C`, `--no-pager`, etc.) to correctly extract the
4//! subcommand, distinguishes read-only from mutating operations, supports
5//! env-gated auto-allow for configured subcommands, and detects force-push flags.
6
7use super::super::CommandSpec;
8use crate::config::GitConfig;
9use crate::eval::{CommandContext, Decision, RuleMatch};
10use std::collections::HashMap;
11
12/// Subcommand-aware git evaluator.
13///
14/// Evaluation order:
15/// 1. Force-push flags → always ASK
16/// 2. Read-only subcommands → ALLOW (with redirection escalation)
17/// 3. Env-gated subcommands → ALLOW if all `config_env` entries match, else ASK
18/// 4. `--version` → ALLOW
19/// 5. Everything else → ASK
20pub struct GitSpec {
21    /// Git subcommands that are always allowed (e.g. `status`, `log`, `diff`).
22    read_only: Vec<String>,
23    /// Subcommands allowed only when all `config_env` entries match.
24    allowed_with_config: Vec<String>,
25    /// Required env var name→value pairs that gate `allowed_with_config` subcommands.
26    config_env: HashMap<String, String>,
27    /// Flags indicating force-push (always ASK regardless of env-gating).
28    force_push_flags: Vec<String>,
29}
30
31impl GitSpec {
32    /// Build a git spec from configuration.
33    pub fn from_config(config: &GitConfig) -> Self {
34        Self {
35            read_only: config.read_only.clone(),
36            allowed_with_config: config.allowed_with_config.clone(),
37            config_env: config.config_env.clone(),
38            force_push_flags: config.force_push_flags.clone(),
39        }
40    }
41
42    /// Global git flags that consume the next word as their argument.
43    /// These appear before the subcommand: `git -C /path status`.
44    const GLOBAL_ARG_FLAGS: &[&str] = &["-C", "-c", "--git-dir", "--work-tree", "--namespace"];
45
46    /// Global git flags that are standalone (no argument consumed).
47    const GLOBAL_SOLO_FLAGS: &[&str] = &[
48        "--bare",
49        "--no-pager",
50        "--no-replace-objects",
51        "--literal-pathspecs",
52        "--glob-pathspecs",
53        "--noglob-pathspecs",
54        "--icase-pathspecs",
55        "--no-optional-locks",
56    ];
57
58    /// Extract the git subcommand word (e.g. "push" from "git push origin main").
59    /// Skips global flags like `-C <path>` that appear before the subcommand.
60    fn subcommand(ctx: &CommandContext) -> Option<String> {
61        let mut iter = ctx.words.iter();
62        // Advance past env vars to find "git"
63        for word in iter.by_ref() {
64            if word == "git" {
65                break;
66            }
67        }
68        // Skip global flags to find the subcommand
69        loop {
70            let word = iter.next()?;
71            if Self::GLOBAL_ARG_FLAGS.contains(&word.as_str()) {
72                // Consume the flag's argument
73                iter.next();
74                continue;
75            }
76            if Self::GLOBAL_SOLO_FLAGS.contains(&word.as_str()) {
77                continue;
78            }
79            // Not a global flag — this is the subcommand
80            return Some(word.clone());
81        }
82    }
83
84    /// Format config_env keys for reason strings (e.g. "GIT_CONFIG_GLOBAL").
85    fn env_keys_display(&self) -> String {
86        let mut keys: Vec<&str> = self.config_env.keys().map(|k| k.as_str()).collect();
87        keys.sort();
88        keys.join(", ")
89    }
90}
91
92impl CommandSpec for GitSpec {
93    fn evaluate(&self, ctx: &CommandContext) -> RuleMatch {
94        let sub = Self::subcommand(ctx);
95        let sub_str = sub.as_deref().unwrap_or("?");
96
97        // Force-push → ask regardless of config
98        if sub_str == "push" {
99            let flag_strs: Vec<&str> = self.force_push_flags.iter().map(|s| s.as_str()).collect();
100            if ctx.has_any_flag(&flag_strs) {
101                return RuleMatch {
102                    decision: Decision::Ask,
103                    reason: "git force-push requires confirmation".into(),
104                };
105            }
106        }
107
108        // Read-only git subcommands — always allowed
109        if self.read_only.iter().any(|s| s == sub_str) {
110            if let Some(ref r) = ctx.redirection {
111                return RuleMatch {
112                    decision: Decision::Ask,
113                    reason: format!("git {sub_str} with {}", r.description),
114                };
115            }
116            return RuleMatch {
117                decision: Decision::Allow,
118                reason: format!("read-only git {sub_str}"),
119            };
120        }
121
122        // Env-gated subcommands: allowed only when all config_env entries match
123        if self.allowed_with_config.iter().any(|s| s == sub_str) {
124            if !self.config_env.is_empty() && ctx.env_satisfies(&self.config_env) {
125                if let Some(ref r) = ctx.redirection {
126                    return RuleMatch {
127                        decision: Decision::Ask,
128                        reason: format!("git {sub_str} with {}", r.description),
129                    };
130                }
131                return RuleMatch {
132                    decision: Decision::Allow,
133                    reason: format!("git {sub_str} with {}", self.env_keys_display()),
134                };
135            }
136            return RuleMatch {
137                decision: Decision::Ask,
138                reason: format!("git {sub_str} requires confirmation"),
139            };
140        }
141
142        // --version check
143        if ctx.has_flag("--version") && ctx.words.len() <= 3 {
144            return RuleMatch {
145                decision: Decision::Allow,
146                reason: "git --version".into(),
147            };
148        }
149
150        RuleMatch {
151            decision: Decision::Ask,
152            reason: format!("git {sub_str} requires confirmation"),
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::config::{Config, GitConfig};
161
162    /// Clear `GIT_CONFIG_GLOBAL` from the process environment so the
163    /// env-gate fallback in `env_satisfies` doesn't interfere with tests
164    /// that assert "no config → Ask".  Requires nextest (process-per-test).
165    fn clear_git_env() {
166        assert!(
167            std::env::var("NEXTEST").is_ok(),
168            "this test mutates process env and requires nextest (cargo nextest run)"
169        );
170        unsafe { std::env::remove_var("GIT_CONFIG_GLOBAL") };
171    }
172
173    fn default_spec() -> GitSpec {
174        GitSpec::from_config(&Config::default_config().git)
175    }
176
177    fn eval(cmd: &str) -> Decision {
178        let s = default_spec();
179        let ctx = CommandContext::from_command(cmd);
180        s.evaluate(&ctx).decision
181    }
182
183    /// Build a spec with env-gated config enabled (like a user's custom config).
184    fn spec_with_env_gate() -> GitSpec {
185        GitSpec::from_config(&GitConfig {
186            read_only: vec![
187                "status".into(),
188                "log".into(),
189                "diff".into(),
190                "branch".into(),
191            ],
192            allowed_with_config: vec!["push".into(), "pull".into(), "add".into()],
193            config_env: HashMap::from([("GIT_CONFIG_GLOBAL".into(), "~/.gitconfig.ai".into())]),
194            force_push_flags: vec!["--force".into(), "-f".into(), "--force-with-lease".into()],
195        })
196    }
197
198    fn eval_with_env_gate(cmd: &str) -> Decision {
199        let s = spec_with_env_gate();
200        let ctx = CommandContext::from_command(cmd);
201        s.evaluate(&ctx).decision
202    }
203
204    // ── Default config: no env-gated commands ──
205
206    #[test]
207    fn default_push_asks() {
208        assert_eq!(eval("git push origin main"), Decision::Ask);
209    }
210
211    #[test]
212    fn default_push_with_env_still_asks() {
213        // Default config has empty config_env, so env var presence doesn't help
214        assert_eq!(
215            eval("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git push origin main"),
216            Decision::Ask
217        );
218    }
219
220    #[test]
221    fn allow_log() {
222        assert_eq!(eval("git log --oneline -10"), Decision::Allow);
223    }
224
225    #[test]
226    fn allow_diff() {
227        assert_eq!(eval("git diff HEAD~1"), Decision::Allow);
228    }
229
230    #[test]
231    fn allow_branch() {
232        assert_eq!(eval("git branch -a"), Decision::Allow);
233    }
234
235    #[test]
236    fn allow_status() {
237        assert_eq!(eval("git status"), Decision::Allow);
238    }
239
240    #[test]
241    fn redir_log() {
242        assert_eq!(eval("git log > /tmp/log.txt"), Decision::Ask);
243    }
244
245    // ── Custom config with env-gated commands ──
246
247    #[test]
248    fn env_gate_push_with_matching_value() {
249        assert_eq!(
250            eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git push origin main"),
251            Decision::Allow
252        );
253    }
254
255    #[test]
256    fn env_gate_push_with_wrong_value() {
257        assert_eq!(
258            eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig git push origin main"),
259            Decision::Ask
260        );
261    }
262
263    #[test]
264    fn env_gate_push_no_config() {
265        clear_git_env();
266        assert_eq!(eval_with_env_gate("git push origin main"), Decision::Ask);
267    }
268
269    #[test]
270    fn env_gate_force_push() {
271        assert_eq!(
272            eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git push --force origin main"),
273            Decision::Ask
274        );
275    }
276
277    #[test]
278    fn env_gate_commit_still_asks() {
279        // commit is not in allowed_with_config
280        assert_eq!(
281            eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git commit -m 'test'"),
282            Decision::Ask
283        );
284    }
285
286    // ── Global flag skipping (-C, -c, etc.) ──
287
288    #[test]
289    fn allow_git_c_dir_status() {
290        assert_eq!(eval("git -C /some/path status"), Decision::Allow);
291    }
292
293    #[test]
294    fn allow_git_c_dir_log() {
295        assert_eq!(eval("git -C /some/repo log --oneline"), Decision::Allow);
296    }
297
298    #[test]
299    fn allow_git_c_dir_diff() {
300        assert_eq!(eval("git -C ../other diff"), Decision::Allow);
301    }
302
303    #[test]
304    fn ask_git_c_dir_push() {
305        assert_eq!(eval("git -C /some/repo push origin main"), Decision::Ask);
306    }
307
308    #[test]
309    fn allow_git_no_pager_log() {
310        assert_eq!(eval("git --no-pager log"), Decision::Allow);
311    }
312
313    #[test]
314    fn allow_git_c_config_status() {
315        // -c key=value is also a global flag
316        assert_eq!(eval("git -c core.pager=cat status"), Decision::Allow);
317    }
318}