cc_toolgate/commands/tools/
git.rs1use super::super::CommandSpec;
8use crate::config::GitConfig;
9use crate::eval::{CommandContext, Decision, RuleMatch};
10use std::collections::HashMap;
11
12pub struct GitSpec {
21 read_only: Vec<String>,
23 allowed_with_config: Vec<String>,
25 config_env: HashMap<String, String>,
27 force_push_flags: Vec<String>,
29}
30
31impl GitSpec {
32 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 const GLOBAL_ARG_FLAGS: &[&str] = &["-C", "-c", "--git-dir", "--work-tree", "--namespace"];
45
46 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 fn subcommand(ctx: &CommandContext) -> Option<String> {
61 let mut iter = ctx.words.iter();
62 for word in iter.by_ref() {
64 if word == "git" {
65 break;
66 }
67 }
68 loop {
70 let word = iter.next()?;
71 if Self::GLOBAL_ARG_FLAGS.contains(&word.as_str()) {
72 iter.next();
74 continue;
75 }
76 if Self::GLOBAL_SOLO_FLAGS.contains(&word.as_str()) {
77 continue;
78 }
79 return Some(word.clone());
81 }
82 }
83
84 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 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 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 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 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 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 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 #[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 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 #[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 assert_eq!(
281 eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git commit -m 'test'"),
282 Decision::Ask
283 );
284 }
285
286 #[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 assert_eq!(eval("git -c core.pager=cat status"), Decision::Allow);
317 }
318}