use std::env;
use std::fs;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::rules::{ActionKind, CommandInvocation, RuleConfig};
#[derive(Debug, Clone)]
pub struct ContextEvaluation {
pub action_override: Option<ActionKind>,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextConfig {
#[serde(default = "default_regenerable_paths")]
pub regenerable_paths: Vec<String>,
#[serde(default = "default_protected_paths")]
pub protected_paths: Vec<String>,
#[serde(default)]
pub git: GitContextConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitContextConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
}
impl Default for GitContextConfig {
fn default() -> Self {
Self {
enabled: false,
timeout_ms: default_timeout_ms(),
}
}
}
fn default_timeout_ms() -> u64 {
100
}
pub fn default_regenerable_paths() -> Vec<String> {
vec![
"target/".to_string(),
"node_modules/".to_string(),
".next/".to_string(),
"dist/".to_string(),
"build/".to_string(),
"__pycache__/".to_string(),
".cache/".to_string(),
]
}
pub fn default_protected_paths() -> Vec<String> {
vec![
"src/".to_string(),
"lib/".to_string(),
".git/".to_string(),
".env".to_string(),
".ssh/".to_string(),
]
}
pub const NEVER_REGENERABLE: &[&str] = &["src", "lib", "app", ".git", ".env", ".ssh"];
pub(crate) fn normalize_path_with_base(path: &str, base: &Path) -> PathBuf {
let path = if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = env::var_os("HOME") {
PathBuf::from(home).join(rest)
} else {
PathBuf::from(path)
}
} else {
PathBuf::from(path)
};
let path = if path.is_relative() {
base.join(&path)
} else {
path
};
let mut components: Vec<Component> = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
if let Some(last) = components.last()
&& !matches!(last, Component::RootDir)
{
components.pop();
}
}
Component::CurDir => {}
other => components.push(other),
}
}
components.iter().collect()
}
pub fn normalize_path(path: &str) -> PathBuf {
let base = env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
normalize_path_with_base(path, &base)
}
pub(crate) fn resolve_path_with_base(raw: &str, base: &Path) -> (PathBuf, bool) {
let lexical = normalize_path_with_base(raw, base);
match fs::canonicalize(&lexical) {
Ok(canonical) => (canonical, true),
Err(_) => (lexical, false),
}
}
pub fn resolve_path(raw: &str) -> (PathBuf, bool) {
let base = env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
resolve_path_with_base(raw, &base)
}
pub fn path_matches_pattern(normalized: &Path, pattern: &str) -> bool {
let pattern_path = Path::new(pattern);
let pattern_components: Vec<Component> = pattern_path.components().collect();
let path_components: Vec<Component> = normalized.components().collect();
if pattern_components.is_empty() {
return false;
}
path_components
.windows(pattern_components.len())
.any(|window| window == pattern_components.as_slice())
}
fn matches_any_pattern(path: &Path, patterns: &[String]) -> Option<String> {
for pattern in patterns {
if path_matches_pattern(path, pattern) {
return Some(pattern.clone());
}
}
None
}
pub fn is_never_regenerable(pattern: &str) -> bool {
let clean = pattern.trim_end_matches('/');
NEVER_REGENERABLE.contains(&clean)
}
pub fn validate_regenerable_paths(paths: &[String]) -> Vec<String> {
let mut warnings = Vec::new();
for path in paths {
if is_never_regenerable(path) {
warnings.push(format!(
"regenerable_paths pattern \"{}\" conflicts with protected system path; pattern ignored for security",
path
));
}
}
warnings
}
fn effective_regenerable_paths(paths: &[String]) -> Vec<String> {
paths
.iter()
.filter(|p| !is_never_regenerable(p))
.cloned()
.collect()
}
pub(crate) fn evaluate_context_with_base(
invocation: &CommandInvocation,
_rule: &RuleConfig,
config: &ContextConfig,
base: &Path,
) -> ContextEvaluation {
let targets = invocation.target_args();
if targets.is_empty() {
return ContextEvaluation {
action_override: None,
reason: "no target paths to evaluate".to_string(),
};
}
let effective_regenerable = effective_regenerable_paths(&config.regenerable_paths);
let mut result = ContextEvaluation {
action_override: None,
reason: "no context pattern matched".to_string(),
};
for target in &targets {
let (resolved, canonicalized) = resolve_path_with_base(target, base);
if let Some(pattern) = matches_any_pattern(&resolved, &config.protected_paths) {
return ContextEvaluation {
action_override: Some(ActionKind::Block),
reason: format!("protected path (matched: {})", pattern),
};
}
if result.action_override.is_none()
&& let Some(pattern) = matches_any_pattern(&resolved, &effective_regenerable)
{
if canonicalized {
result = ContextEvaluation {
action_override: Some(ActionKind::LogOnly),
reason: format!("regenerable path (matched: {})", pattern),
};
} else {
result = ContextEvaluation {
action_override: None,
reason: format!(
"regenerable pattern matched ({}) but path could not be resolved; keeping original action",
pattern
),
};
}
}
}
result
}
pub fn evaluate_context(
invocation: &CommandInvocation,
rule: &RuleConfig,
config: &ContextConfig,
) -> ContextEvaluation {
let base = env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
evaluate_context_with_base(invocation, rule, config, &base)
}
const GIT_SPOOFABLE_ENV_VARS: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
];
fn git_status_porcelain(detector_env_keys: &[String], timeout_ms: u64) -> Result<String, String> {
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
let mut cmd = Command::new("git");
cmd.args(["status", "--porcelain"])
.stdout(Stdio::piped())
.stderr(Stdio::null());
for key in detector_env_keys {
cmd.env_remove(key);
}
for key in GIT_SPOOFABLE_ENV_VARS {
cmd.env_remove(key);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to spawn git: {e}"))?;
let (tx, rx) = mpsc::channel();
let child_stdout = child.stdout.take();
std::thread::spawn(move || {
use std::io::Read;
let mut output = String::new();
if let Some(mut stdout) = child_stdout {
let _ = stdout.read_to_string(&mut output);
}
let _ = tx.send(output);
});
match rx.recv_timeout(Duration::from_millis(timeout_ms)) {
Ok(output) => {
let _ = child.wait(); Ok(output)
}
Err(_) => {
let _ = child.kill();
let _ = child.wait(); Err(format!("git status timed out after {}ms", timeout_ms))
}
}
}
fn is_inside_git_repo(detector_env_keys: &[String]) -> bool {
use std::process::{Command, Stdio};
let mut cmd = Command::new("git");
cmd.args(["rev-parse", "--is-inside-work-tree"])
.stdout(Stdio::null())
.stderr(Stdio::null());
for key in detector_env_keys {
cmd.env_remove(key);
}
for key in GIT_SPOOFABLE_ENV_VARS {
cmd.env_remove(key);
}
cmd.status().map(|s| s.success()).unwrap_or(false)
}
pub fn evaluate_git_context(
invocation: &CommandInvocation,
config: &GitContextConfig,
detector_env_keys: &[String],
) -> Option<ContextEvaluation> {
if !config.enabled {
return None;
}
if invocation.program != "git" {
return None;
}
if !is_inside_git_repo(detector_env_keys) {
return Some(ContextEvaluation {
action_override: None,
reason: "not inside a git repository; skipping git-aware evaluation".to_string(),
});
}
let args: Vec<&str> = invocation.args.iter().map(String::as_str).collect();
if args.contains(&"reset") && args.contains(&"--hard") {
return match git_status_porcelain(detector_env_keys, config.timeout_ms) {
Ok(output) if output.trim().is_empty() => Some(ContextEvaluation {
action_override: Some(ActionKind::LogOnly),
reason: "no uncommitted changes detected".to_string(),
}),
Ok(_) => Some(ContextEvaluation {
action_override: None,
reason: "uncommitted changes present; keeping original action".to_string(),
}),
Err(reason) => Some(ContextEvaluation {
action_override: None,
reason: format!("git status failed ({}); keeping original action", reason),
}),
};
}
let expanded_args = crate::rules::expand_short_flags(&invocation.args);
let has_force = expanded_args.iter().any(|a| a == "-f" || a == "--force");
if args.contains(&"clean") && has_force {
return match git_status_porcelain(detector_env_keys, config.timeout_ms) {
Ok(output) => {
let has_untracked = output.lines().any(|line| line.starts_with("??"));
if has_untracked {
Some(ContextEvaluation {
action_override: None,
reason: "untracked files present; keeping original action".to_string(),
})
} else {
Some(ContextEvaluation {
action_override: Some(ActionKind::LogOnly),
reason: "no untracked files detected".to_string(),
})
}
}
Err(reason) => Some(ContextEvaluation {
action_override: None,
reason: format!("git status failed ({}); keeping original action", reason),
}),
};
}
None }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_resolves_dot_dot() {
let result = normalize_path("target/../src/main.rs");
assert!(
result.ends_with("src/main.rs"),
"expected ends_with src/main.rs, got: {}",
result.display()
);
let s = result.to_string_lossy();
assert!(
!s.contains("/target/"),
"should not contain /target/ after normalization: {}",
s
);
}
#[test]
fn normalize_resolves_dot() {
let result = normalize_path("./target/");
assert!(result.ends_with("target"));
}
#[test]
fn normalize_expands_tilde() {
let result = normalize_path("~/Documents");
if let Some(home) = env::var_os("HOME") {
assert!(result.starts_with(PathBuf::from(home)));
}
}
#[test]
fn normalize_makes_absolute() {
let result = normalize_path("target");
assert!(result.is_absolute());
}
#[test]
fn pattern_matches_exact_component() {
let cwd = env::current_dir().unwrap();
assert!(path_matches_pattern(&cwd.join("target"), "target"));
assert!(path_matches_pattern(&cwd.join("target/debug"), "target"));
}
#[test]
fn pattern_does_not_match_partial_name() {
let cwd = env::current_dir().unwrap();
assert!(!path_matches_pattern(&cwd.join("target_dir"), "target"));
assert!(!path_matches_pattern(&cwd.join("my-target"), "target"));
assert!(!path_matches_pattern(&cwd.join("src_backup"), "src"));
}
#[test]
fn pattern_matches_intermediate_component() {
let cwd = env::current_dir().unwrap();
assert!(path_matches_pattern(&cwd.join("lib/src/foo"), "src"));
}
#[test]
fn trailing_slash_does_not_affect_match() {
let cwd = env::current_dir().unwrap();
let path = cwd.join("target");
assert!(path_matches_pattern(&path, "target"));
assert!(path_matches_pattern(&path, "target/"));
let path_slash = cwd.join("target/");
assert!(path_matches_pattern(&path_slash, "target"));
}
#[test]
fn never_regenerable_catches_src() {
assert!(is_never_regenerable("src"));
assert!(is_never_regenerable("src/"));
assert!(is_never_regenerable(".git"));
assert!(is_never_regenerable(".git/"));
assert!(is_never_regenerable(".env"));
}
#[test]
fn never_regenerable_allows_target() {
assert!(!is_never_regenerable("target"));
assert!(!is_never_regenerable("target/"));
assert!(!is_never_regenerable("node_modules"));
assert!(!is_never_regenerable("dist"));
}
#[test]
fn validate_regenerable_warns_on_conflict() {
let paths = vec!["target/".to_string(), "src/".to_string()];
let warnings = validate_regenerable_paths(&paths);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("src/"));
}
fn test_config() -> ContextConfig {
ContextConfig {
regenerable_paths: vec!["target/".to_string(), "node_modules/".to_string()],
protected_paths: vec!["src/".to_string(), ".git/".to_string()],
git: GitContextConfig::default(),
}
}
fn test_rule() -> RuleConfig {
RuleConfig::new(
"rm-recursive-to-trash",
"rm",
ActionKind::Trash,
Vec::new(),
vec!["-rf".to_string()],
Some("test".to_string()),
)
}
fn test_base() -> PathBuf {
let base = PathBuf::from(format!("/tmp/omamori-ctx-test-{}", std::process::id()));
std::fs::create_dir_all(base.join("target")).unwrap();
std::fs::create_dir_all(base.join("node_modules")).unwrap();
base
}
#[test]
fn context_protected_path_escalates_to_block() {
let config = test_config();
let inv = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "src/".to_string()],
);
let result = evaluate_context(&inv, &test_rule(), &config);
assert_eq!(result.action_override, Some(ActionKind::Block));
assert!(result.reason.contains("protected path"));
}
#[test]
fn context_no_targets_returns_none() {
let config = test_config();
let inv = CommandInvocation::new("rm".to_string(), vec!["-rf".to_string()]);
let result = evaluate_context(&inv, &test_rule(), &config);
assert!(result.action_override.is_none());
}
#[test]
fn context_unmatched_path_returns_none() {
let config = test_config();
let inv = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "data/".to_string()],
);
let result = evaluate_context(&inv, &test_rule(), &config);
assert!(result.action_override.is_none());
}
#[test]
fn context_never_regenerable_overrides_config() {
let config = ContextConfig {
regenerable_paths: vec!["src/".to_string()],
protected_paths: vec![],
git: GitContextConfig::default(),
};
let inv = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "src/".to_string()],
);
let result = evaluate_context(&inv, &test_rule(), &config);
assert!(
result.action_override.is_none(),
"src/ should not be downgraded even if in regenerable_paths"
);
}
#[test]
fn context_both_match_escalation_wins() {
let config = ContextConfig {
regenerable_paths: vec!["shared/".to_string()],
protected_paths: vec!["shared/".to_string()],
git: GitContextConfig::default(),
};
let inv = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "shared/".to_string()],
);
let result = evaluate_context(&inv, &test_rule(), &config);
assert_eq!(result.action_override, Some(ActionKind::Block));
}
#[test]
fn traversal_attack_is_caught() {
let config = test_config();
let inv = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "target/../src/".to_string()],
);
let result = evaluate_context(&inv, &test_rule(), &config);
assert_eq!(
result.action_override,
Some(ActionKind::Block),
"target/../src/ should be caught as protected path after normalization"
);
}
#[test]
fn component_boundary_prevents_false_match() {
let config = test_config();
let inv = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "target_dir/".to_string()],
);
let result = evaluate_context(&inv, &test_rule(), &config);
assert!(
result.action_override.is_none(),
"target_dir should not match target pattern"
);
}
#[test]
fn multi_target_protected_wins_over_regenerable() {
let base = test_base();
let config = test_config();
let inv = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "target/".to_string(), "src/".to_string()],
);
let result = evaluate_context_with_base(&inv, &test_rule(), &config, &base);
assert_eq!(
result.action_override,
Some(ActionKind::Block),
"protected src/ must win even when regenerable target/ appears first"
);
}
#[test]
fn multi_target_all_regenerable_downgrades() {
let base = test_base();
let config = test_config();
let inv = CommandInvocation::new(
"rm".to_string(),
vec![
"-rf".to_string(),
"target/".to_string(),
"node_modules/".to_string(),
],
);
let result = evaluate_context_with_base(&inv, &test_rule(), &config, &base);
assert_eq!(result.action_override, Some(ActionKind::LogOnly));
}
fn create_git_repo() -> PathBuf {
let dir = std::env::temp_dir().join(format!("omamori-git-ctx-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.status()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.status()
.unwrap();
dir
}
fn git_config() -> GitContextConfig {
GitContextConfig {
enabled: true,
timeout_ms: 5000,
}
}
#[test]
#[serial_test::serial]
fn git_context_clean_repo_downgrades_to_log_only() {
let dir = create_git_repo();
let saved = env::current_dir().unwrap();
env::set_current_dir(&dir).unwrap();
std::fs::write(dir.join("dummy.txt"), "init").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.status()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
let inv = CommandInvocation::new(
"git".to_string(),
vec!["reset".to_string(), "--hard".to_string()],
);
let result = evaluate_git_context(&inv, &git_config(), &[]);
assert!(result.is_some());
let eval = result.unwrap();
assert_eq!(eval.action_override, Some(ActionKind::LogOnly));
env::set_current_dir(&saved).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[serial_test::serial]
fn git_context_dirty_repo_keeps_original() {
let dir = create_git_repo();
let saved = env::current_dir().unwrap();
env::set_current_dir(&dir).unwrap();
std::fs::write(dir.join("dummy.txt"), "init").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.status()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
std::fs::write(dir.join("dirty.txt"), "uncommitted").unwrap();
let inv = CommandInvocation::new(
"git".to_string(),
vec!["reset".to_string(), "--hard".to_string()],
);
let result = evaluate_git_context(&inv, &git_config(), &[]);
assert!(result.is_some());
let eval = result.unwrap();
assert!(eval.action_override.is_none());
assert!(eval.reason.contains("uncommitted"));
env::set_current_dir(&saved).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[serial_test::serial]
fn git_context_timeout_keeps_original() {
let dir = create_git_repo();
let saved = env::current_dir().unwrap();
env::set_current_dir(&dir).unwrap();
let config = GitContextConfig {
enabled: true,
timeout_ms: 0, };
let inv = CommandInvocation::new(
"git".to_string(),
vec!["reset".to_string(), "--hard".to_string()],
);
let result = evaluate_git_context(&inv, &config, &[]);
assert!(result.is_some());
env::set_current_dir(&saved).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[serial_test::serial]
fn git_context_sanitizes_git_dir_env() {
let dir = create_git_repo();
let saved = env::current_dir().unwrap();
env::set_current_dir(&dir).unwrap();
std::fs::write(dir.join("dummy.txt"), "init").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.status()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
unsafe { env::set_var("GIT_DIR", "/nonexistent/.git") };
let inv = CommandInvocation::new(
"git".to_string(),
vec!["reset".to_string(), "--hard".to_string()],
);
let result = evaluate_git_context(&inv, &git_config(), &[]);
unsafe { env::remove_var("GIT_DIR") };
assert!(result.is_some());
let eval = result.unwrap();
assert_eq!(eval.action_override, Some(ActionKind::LogOnly));
env::set_current_dir(&saved).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[serial_test::serial]
fn git_context_sanitizes_git_work_tree_env() {
let dir = create_git_repo();
let saved = env::current_dir().unwrap();
env::set_current_dir(&dir).unwrap();
std::fs::write(dir.join("dummy.txt"), "init").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.status()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
unsafe { env::set_var("GIT_WORK_TREE", "/nonexistent/fake") };
let inv = CommandInvocation::new(
"git".to_string(),
vec!["reset".to_string(), "--hard".to_string()],
);
let result = evaluate_git_context(&inv, &git_config(), &[]);
unsafe { env::remove_var("GIT_WORK_TREE") };
assert!(result.is_some());
let eval = result.unwrap();
assert_eq!(eval.action_override, Some(ActionKind::LogOnly));
env::set_current_dir(&saved).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn git_context_non_git_command_returns_none() {
let inv = CommandInvocation::new(
"rm".to_string(),
vec!["-rf".to_string(), "target/".to_string()],
);
let result = evaluate_git_context(&inv, &git_config(), &[]);
assert!(result.is_none());
}
#[test]
fn git_context_disabled_returns_none() {
let config = GitContextConfig {
enabled: false,
timeout_ms: 100,
};
let inv = CommandInvocation::new(
"git".to_string(),
vec!["reset".to_string(), "--hard".to_string()],
);
let result = evaluate_git_context(&inv, &config, &[]);
assert!(result.is_none());
}
#[test]
#[serial_test::serial]
fn git_context_non_git_directory_returns_some_none_override() {
let dir = std::env::temp_dir().join(format!("omamori-nongit-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let saved = env::current_dir().unwrap();
env::set_current_dir(&dir).unwrap();
let inv = CommandInvocation::new(
"git".to_string(),
vec!["reset".to_string(), "--hard".to_string()],
);
let result = evaluate_git_context(&inv, &git_config(), &[]);
assert!(result.is_some());
let eval = result.unwrap();
assert!(eval.action_override.is_none());
assert!(eval.reason.contains("not inside a git repository"));
env::set_current_dir(&saved).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[serial_test::serial]
fn git_context_clean_no_untracked_downgrades_to_log_only() {
let dir = create_git_repo();
let saved = env::current_dir().unwrap();
env::set_current_dir(&dir).unwrap();
std::fs::write(dir.join("committed.txt"), "init").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.status()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
let inv = super::CommandInvocation::new(
"git".to_string(),
vec!["clean".to_string(), "-fdx".to_string()],
);
let result = evaluate_git_context(&inv, &git_config(), &[]);
assert!(result.is_some());
let eval = result.unwrap();
assert_eq!(eval.action_override, Some(ActionKind::LogOnly));
assert!(eval.reason.contains("no untracked"));
env::set_current_dir(&saved).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[serial_test::serial]
fn git_context_clean_with_untracked_keeps_original() {
let dir = create_git_repo();
let saved = env::current_dir().unwrap();
env::set_current_dir(&dir).unwrap();
std::fs::write(dir.join("committed.txt"), "init").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.status()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
std::fs::write(dir.join("untracked.txt"), "not tracked").unwrap();
let inv = super::CommandInvocation::new(
"git".to_string(),
vec!["clean".to_string(), "-fd".to_string()],
);
let result = evaluate_git_context(&inv, &git_config(), &[]);
assert!(result.is_some());
let eval = result.unwrap();
assert!(eval.action_override.is_none());
assert!(eval.reason.contains("untracked files present"));
env::set_current_dir(&saved).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[serial_test::serial]
fn git_context_clean_split_flags_triggers_evaluation() {
let dir = create_git_repo();
let saved = env::current_dir().unwrap();
env::set_current_dir(&dir).unwrap();
std::fs::write(dir.join("committed.txt"), "init").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.status()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
let inv = super::CommandInvocation::new(
"git".to_string(),
vec!["clean".to_string(), "-f".to_string(), "-d".to_string()],
);
let result = evaluate_git_context(&inv, &git_config(), &[]);
assert!(
result.is_some(),
"split -f -d must trigger context evaluation"
);
let eval = result.unwrap();
assert_eq!(eval.action_override, Some(ActionKind::LogOnly));
let inv2 = super::CommandInvocation::new(
"git".to_string(),
vec!["clean".to_string(), "--force".to_string(), "-d".to_string()],
);
let result2 = evaluate_git_context(&inv2, &git_config(), &[]);
assert!(result2.is_some(), "--force must trigger context evaluation");
env::set_current_dir(&saved).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn never_regenerable_covers_all_default_protected_paths() {
let protected = default_protected_paths();
let never: std::collections::HashSet<&str> = NEVER_REGENERABLE.iter().copied().collect();
let missing: Vec<&str> = protected
.iter()
.map(|p| p.trim_end_matches('/'))
.filter(|p| !never.contains(p))
.collect();
assert!(
missing.is_empty(),
"default_protected_paths() contains entries not in NEVER_REGENERABLE: {:?}\n\
Either add them to NEVER_REGENERABLE or remove from default_protected_paths()",
missing,
);
}
}