use super::super::CommandSpec;
use crate::config::GitConfig;
use crate::eval::{CommandContext, Decision, RuleMatch};
use std::collections::HashMap;
pub struct GitSpec {
read_only: Vec<String>,
allowed_with_config: Vec<String>,
config_env: HashMap<String, String>,
force_push_flags: Vec<String>,
}
impl GitSpec {
pub fn from_config(config: &GitConfig) -> Self {
Self {
read_only: config.read_only.clone(),
allowed_with_config: config.allowed_with_config.clone(),
config_env: config.config_env.clone(),
force_push_flags: config.force_push_flags.clone(),
}
}
const GLOBAL_ARG_FLAGS: &[&str] = &["-C", "-c", "--git-dir", "--work-tree", "--namespace"];
const GLOBAL_SOLO_FLAGS: &[&str] = &[
"--bare",
"--no-pager",
"--no-replace-objects",
"--literal-pathspecs",
"--glob-pathspecs",
"--noglob-pathspecs",
"--icase-pathspecs",
"--no-optional-locks",
];
fn subcommand(ctx: &CommandContext) -> Option<String> {
let mut iter = ctx.words.iter();
for word in iter.by_ref() {
if word == "git" {
break;
}
}
loop {
let word = iter.next()?;
if Self::GLOBAL_ARG_FLAGS.contains(&word.as_str()) {
iter.next();
continue;
}
if Self::GLOBAL_SOLO_FLAGS.contains(&word.as_str()) {
continue;
}
return Some(word.clone());
}
}
fn env_keys_display(&self) -> String {
let mut keys: Vec<&str> = self.config_env.keys().map(|k| k.as_str()).collect();
keys.sort();
keys.join(", ")
}
}
impl CommandSpec for GitSpec {
fn evaluate(&self, ctx: &CommandContext) -> RuleMatch {
let sub = Self::subcommand(ctx);
let sub_str = sub.as_deref().unwrap_or("?");
if sub_str == "push" {
let flag_strs: Vec<&str> = self.force_push_flags.iter().map(|s| s.as_str()).collect();
if ctx.has_any_flag(&flag_strs) {
return RuleMatch {
decision: Decision::Ask,
reason: "git force-push requires confirmation".into(),
};
}
}
if self.read_only.iter().any(|s| s == sub_str) {
if let Some(ref r) = ctx.redirection {
return RuleMatch {
decision: Decision::Ask,
reason: format!("git {sub_str} with {}", r.description),
};
}
return RuleMatch {
decision: Decision::Allow,
reason: format!("read-only git {sub_str}"),
};
}
if self.allowed_with_config.iter().any(|s| s == sub_str) {
if !self.config_env.is_empty() && ctx.env_satisfies(&self.config_env) {
if let Some(ref r) = ctx.redirection {
return RuleMatch {
decision: Decision::Ask,
reason: format!("git {sub_str} with {}", r.description),
};
}
return RuleMatch {
decision: Decision::Allow,
reason: format!("git {sub_str} with {}", self.env_keys_display()),
};
}
return RuleMatch {
decision: Decision::Ask,
reason: format!("git {sub_str} requires confirmation"),
};
}
if ctx.has_flag("--version") && ctx.words.len() <= 3 {
return RuleMatch {
decision: Decision::Allow,
reason: "git --version".into(),
};
}
RuleMatch {
decision: Decision::Ask,
reason: format!("git {sub_str} requires confirmation"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, GitConfig};
fn clear_git_env() {
assert!(
std::env::var("NEXTEST").is_ok(),
"this test mutates process env and requires nextest (cargo nextest run)"
);
unsafe { std::env::remove_var("GIT_CONFIG_GLOBAL") };
}
fn default_spec() -> GitSpec {
GitSpec::from_config(&Config::default_config().git)
}
fn eval(cmd: &str) -> Decision {
let s = default_spec();
let ctx = CommandContext::from_command(cmd);
s.evaluate(&ctx).decision
}
fn spec_with_env_gate() -> GitSpec {
GitSpec::from_config(&GitConfig {
read_only: vec![
"status".into(),
"log".into(),
"diff".into(),
"branch".into(),
],
allowed_with_config: vec!["push".into(), "pull".into(), "add".into()],
config_env: HashMap::from([("GIT_CONFIG_GLOBAL".into(), "~/.gitconfig.ai".into())]),
force_push_flags: vec!["--force".into(), "-f".into(), "--force-with-lease".into()],
})
}
fn eval_with_env_gate(cmd: &str) -> Decision {
let s = spec_with_env_gate();
let ctx = CommandContext::from_command(cmd);
s.evaluate(&ctx).decision
}
#[test]
fn default_push_asks() {
assert_eq!(eval("git push origin main"), Decision::Ask);
}
#[test]
fn default_push_with_env_still_asks() {
assert_eq!(
eval("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git push origin main"),
Decision::Ask
);
}
#[test]
fn allow_log() {
assert_eq!(eval("git log --oneline -10"), Decision::Allow);
}
#[test]
fn allow_diff() {
assert_eq!(eval("git diff HEAD~1"), Decision::Allow);
}
#[test]
fn allow_branch() {
assert_eq!(eval("git branch -a"), Decision::Allow);
}
#[test]
fn allow_status() {
assert_eq!(eval("git status"), Decision::Allow);
}
#[test]
fn redir_log() {
assert_eq!(eval("git log > /tmp/log.txt"), Decision::Ask);
}
#[test]
fn env_gate_push_with_matching_value() {
assert_eq!(
eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git push origin main"),
Decision::Allow
);
}
#[test]
fn env_gate_push_with_wrong_value() {
assert_eq!(
eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig git push origin main"),
Decision::Ask
);
}
#[test]
fn env_gate_push_no_config() {
clear_git_env();
assert_eq!(eval_with_env_gate("git push origin main"), Decision::Ask);
}
#[test]
fn env_gate_force_push() {
assert_eq!(
eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git push --force origin main"),
Decision::Ask
);
}
#[test]
fn env_gate_commit_still_asks() {
assert_eq!(
eval_with_env_gate("GIT_CONFIG_GLOBAL=~/.gitconfig.ai git commit -m 'test'"),
Decision::Ask
);
}
#[test]
fn allow_git_c_dir_status() {
assert_eq!(eval("git -C /some/path status"), Decision::Allow);
}
#[test]
fn allow_git_c_dir_log() {
assert_eq!(eval("git -C /some/repo log --oneline"), Decision::Allow);
}
#[test]
fn allow_git_c_dir_diff() {
assert_eq!(eval("git -C ../other diff"), Decision::Allow);
}
#[test]
fn ask_git_c_dir_push() {
assert_eq!(eval("git -C /some/repo push origin main"), Decision::Ask);
}
#[test]
fn allow_git_no_pager_log() {
assert_eq!(eval("git --no-pager log"), Decision::Allow);
}
#[test]
fn allow_git_c_config_status() {
assert_eq!(eval("git -c core.pager=cat status"), Decision::Allow);
}
}