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