use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
const ALLOWED_GIT_SPAWNS: &[(&str, &str, &str)] = &[];
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
struct SpawnSite {
file: String,
function: String,
line: usize,
source: String,
}
#[test]
fn runtime_git_process_spawns_match_reviewed_allowlist() {
let workspace = workspace_root();
let mut sites = Vec::new();
for dir in default_cli_runtime_source_dirs(&workspace) {
walk_rust_files(&dir, &mut |path| scan_file(&workspace, path, &mut sites));
}
let allowed: BTreeMap<(&str, &str), &str> = ALLOWED_GIT_SPAWNS
.iter()
.map(|(file, function, reason)| ((*file, *function), *reason))
.collect();
let mut unexpected = Vec::new();
let mut seen = BTreeSet::new();
for site in &sites {
let key = (site.file.as_str(), site.function.as_str());
if let Some(reason) = allowed.get(&key) {
assert!(
!reason.trim().is_empty(),
"allowlist reason must be nonempty"
);
seen.insert(key);
} else {
unexpected.push(site.clone());
}
}
assert!(
unexpected.is_empty(),
"runtime `git` process spawn(s) are not allowed:\n{}\nReplace the call with native/Sley behavior or move fixture setup into tests.",
unexpected
.iter()
.map(|site| format!(
" {}:{} in {}: {}",
site.file, site.line, site.function, site.source
))
.collect::<Vec<_>>()
.join("\n")
);
let missing: Vec<_> = allowed
.keys()
.copied()
.filter(|key| !seen.contains(key))
.collect();
assert!(
missing.is_empty(),
"git-process allowlist entry no longer matches a production spawn; remove or update it: {missing:?}"
);
}
#[test]
fn git_engine_dependency_is_sley_not_gix() {
let workspace = workspace_root();
let root_manifest =
fs::read_to_string(workspace.join("Cargo.toml")).expect("read workspace Cargo.toml");
assert!(
root_manifest
.lines()
.any(|line| line.trim_start().starts_with("sley = ")),
"workspace dependencies must name Sley as the Git-format engine"
);
let mut manifests = Vec::new();
collect_manifest_files(&workspace, &mut manifests);
let mut direct_gix_mentions = Vec::new();
for manifest in manifests {
let rel = manifest
.strip_prefix(&workspace)
.unwrap_or(&manifest)
.display()
.to_string();
let body = fs::read_to_string(&manifest)
.unwrap_or_else(|err| panic!("read {}: {err}", manifest.display()));
for (idx, line) in body.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("gix")
|| trimmed.starts_with("gitoxide")
|| trimmed.contains("package = \"gix")
{
direct_gix_mentions.push(format!("{rel}:{}: {trimmed}", idx + 1));
}
}
}
assert!(
direct_gix_mentions.is_empty(),
"Heddle should depend on Sley, not direct gix/gitoxide crates:\n{}",
direct_gix_mentions.join("\n")
);
}
fn scan_file(workspace: &Path, path: &Path, sites: &mut Vec<SpawnSite>) {
let rel = path
.strip_prefix(workspace)
.unwrap_or(path)
.to_string_lossy();
if rel.ends_with("_tests.rs") {
return;
}
let source =
fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
let mut function = String::from("<module>");
let mut git_command_aliases = BTreeSet::new();
let mut pending_command_new: Option<(usize, String, String, usize)> = None;
let mut pending_cfg_test = false;
let mut test_module_depth: Option<usize> = None;
for (idx, line) in source.lines().enumerate() {
let trimmed = line.trim_start();
if let Some(depth) = test_module_depth {
let depth = brace_depth_after_line(depth, line);
test_module_depth = (depth > 0).then_some(depth);
continue;
}
if trimmed == "#[cfg(test)]" {
pending_cfg_test = true;
continue;
}
if pending_cfg_test {
if trimmed.starts_with("mod tests") && trimmed.contains('{') {
let depth = brace_depth_after_line(0, line);
test_module_depth = (depth > 0).then_some(depth);
continue;
}
if !trimmed.starts_with('#') && !trimmed.is_empty() {
pending_cfg_test = false;
}
}
if let Some(name) = parse_function_name(trimmed) {
function = name.to_string();
git_command_aliases.clear();
pending_command_new = None;
}
if let Some((line, source, function, remaining)) = pending_command_new.take() {
if line_mentions_git_command_arg(trimmed, &git_command_aliases) {
sites.push(SpawnSite {
file: rel.to_string(),
function,
line,
source: format!("{source} {}", trimmed.trim()),
});
continue;
}
if remaining > 0 && !trimmed.contains(')') {
pending_command_new = Some((line, source, function, remaining - 1));
}
}
if let Some(alias) = parse_git_command_alias(trimmed) {
git_command_aliases.insert(alias.to_ascii_lowercase());
}
if is_git_spawn_with_aliases(trimmed, &git_command_aliases) {
sites.push(SpawnSite {
file: rel.to_string(),
function: function.clone(),
line: idx + 1,
source: trimmed.to_string(),
});
} else if starts_multiline_command_new(trimmed) {
pending_command_new = Some((idx + 1, trimmed.to_string(), function.clone(), 4));
}
}
}
fn collect_manifest_files(dir: &Path, manifests: &mut Vec<PathBuf>) {
let entries =
fs::read_dir(dir).unwrap_or_else(|err| panic!("read_dir {}: {err}", dir.display()));
for entry in entries {
let entry = entry.expect("read dir entry");
let path = entry.path();
let file_type = entry.file_type().expect("file type");
if file_type.is_dir() {
let name = entry.file_name();
if matches!(name.to_str(), Some(".git" | "target")) {
continue;
}
collect_manifest_files(&path, manifests);
} else if entry.file_name() == "Cargo.toml" {
manifests.push(path);
}
}
}
fn is_git_spawn(line: &str) -> bool {
let compact = line.split_whitespace().collect::<String>();
let lower = compact.to_ascii_lowercase();
lower.contains("command::new(\"git\")")
|| lower.contains("processcommand::new(\"git\")")
|| lower.contains("command::new(r#\"git\"#)")
|| lower.contains("command::new(git")
|| lower.contains("command::new(&git")
|| shell_wrapper_mentions_git(&lower)
}
fn is_git_spawn_with_aliases(line: &str, aliases: &BTreeSet<String>) -> bool {
if is_git_spawn(line) {
return true;
}
let compact = line
.split_whitespace()
.collect::<String>()
.to_ascii_lowercase();
aliases.iter().any(|alias| {
compact.contains(&format!("command::new({alias})"))
|| compact.contains(&format!("command::new(&{alias})"))
|| compact.contains(&format!("processcommand::new({alias})"))
|| compact.contains(&format!("processcommand::new(&{alias})"))
})
}
fn starts_multiline_command_new(line: &str) -> bool {
let compact = line
.split_whitespace()
.collect::<String>()
.to_ascii_lowercase();
(compact.contains("command::new(") || compact.contains("processcommand::new("))
&& !compact.contains(')')
}
fn line_mentions_git_command_arg(line: &str, aliases: &BTreeSet<String>) -> bool {
let compact = line
.split_whitespace()
.collect::<String>()
.to_ascii_lowercase();
compact.contains("\"git\"")
|| compact.contains("r#\"git\"#")
|| aliases.iter().any(|alias| {
compact == *alias
|| compact == format!("{alias},")
|| compact == format!("&{alias}")
|| compact == format!("&{alias},")
})
}
fn parse_git_command_alias(line: &str) -> Option<&str> {
let line = line.trim_start();
let rest = line.strip_prefix("let ")?;
let (name, value) = rest.split_once('=')?;
let name = name.trim().trim_start_matches("mut ").trim();
if !is_rust_identifier(name) {
return None;
}
let value = value.trim().trim_end_matches(';').trim();
matches!(value, "\"git\"" | "r#\"git\"#").then_some(name)
}
fn is_rust_identifier(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
(first == '_' || first.is_ascii_alphabetic())
&& chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn shell_wrapper_mentions_git(line: &str) -> bool {
let shell_spawn = line.contains("command::new(\"sh\")")
|| line.contains("command::new(\"bash\")")
|| line.contains("command::new(\"cmd\")")
|| line.contains("command::new(\"powershell\")")
|| line.contains("command::new(\"pwsh\")");
shell_spawn && line.contains("git")
}
fn parse_function_name(line: &str) -> Option<&str> {
let fn_pos = line.find("fn ")?;
let after = &line[fn_pos + 3..];
let name_end = after
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(after.len());
let name = &after[..name_end];
(!name.is_empty()).then_some(name)
}
fn brace_depth_after_line(mut depth: usize, line: &str) -> usize {
for ch in line.chars() {
match ch {
'{' => depth += 1,
'}' => depth = depth.saturating_sub(1),
_ => {}
}
}
depth
}
fn walk_rust_files(dir: &Path, visit: &mut impl FnMut(&Path)) {
let entries =
fs::read_dir(dir).unwrap_or_else(|err| panic!("read_dir {}: {err}", dir.display()));
for entry in entries {
let entry = entry.expect("read dir entry");
let path = entry.path();
let file_type = entry.file_type().expect("file type");
if file_type.is_dir() {
walk_rust_files(&path, visit);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
visit(&path);
}
}
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("workspace root")
.to_path_buf()
}
fn default_cli_runtime_source_dirs(workspace: &Path) -> Vec<PathBuf> {
[
"crates/cli/src",
"crates/cli-shared/src",
"crates/client/src",
"crates/crypto/src",
"crates/daemon/src",
"crates/grpc/src",
"crates/ingest/src",
"crates/merge/src",
"crates/mount/src",
"crates/objects/src",
"crates/oplog/src",
"crates/wire/src",
"crates/refs/src",
"crates/repo/src",
"crates/review/src",
"crates/runtime-bridge/src",
"crates/semantic/src",
"crates/state_review/src",
"crates/weft-client-shim/src",
]
.into_iter()
.map(|path| workspace.join(path))
.filter(|path| path.exists())
.collect()
}
#[test]
fn git_spawn_detector_catches_aliases_and_shell_wrappers() {
for line in [
"Command::new(\"git\")",
"std::process::Command::new(\"git\")",
"tokio::process::Command::new(\"git\")",
"ProcessCommand::new(\"git\")",
"Command::new(GIT_BINARY)",
"Command::new(&git_path)",
"Command::new(\"sh\").arg(\"-c\").arg(\"git status\")",
"Command::new(\"bash\").args([\"-c\", \"git fetch\"])",
] {
assert!(is_git_spawn(line), "should flag {line:?}");
}
for line in [
"Command::new(\"heddle\")",
"Command::new(\"xdg-open\")",
"Command::new(\"cmd\").args([\"/C\", \"start\", url])",
"let git = gix::open(path)?;",
] {
assert!(!is_git_spawn(line), "should not flag {line:?}");
}
}
#[test]
fn git_spawn_detector_catches_multiline_and_local_aliases() {
let mut aliases = BTreeSet::new();
aliases.insert("git_cmd".to_string());
assert!(is_git_spawn_with_aliases(
"Command::new(git_cmd).arg(\"status\")",
&aliases
));
assert!(is_git_spawn_with_aliases(
"std::process::Command::new(&git_cmd)",
&aliases
));
assert!(starts_multiline_command_new("Command::new("));
assert!(line_mentions_git_command_arg(
" \"git\"",
&BTreeSet::new()
));
assert_eq!(
parse_git_command_alias("let git_cmd = \"git\";"),
Some("git_cmd")
);
assert_eq!(
parse_git_command_alias("let mut git_cmd = r#\"git\"#;"),
Some("git_cmd")
);
assert_eq!(parse_git_command_alias("let git = gix::open(path)?;"), None);
}