use std::path::{Path, PathBuf};
use std::process::Command;
use serde::Serialize;
use crate::severities::{self, SeverityOverride};
use crate::shim::{self, BakeState, ShimState, DISPATCHERS};
use crate::skips::{self, SkipEntry};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DeclaredCheck {
pub name: String,
pub stage: String,
#[serde(flatten)]
pub state: DeclaredState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum DeclaredState {
Usable {
severity: String,
exts: Vec<String>,
command: String,
},
Unusable { why: String },
}
impl DeclaredCheck {
pub fn is_unusable(&self) -> bool {
matches!(self.state, DeclaredState::Unusable { .. })
}
}
fn declared_checks(repo: &Path) -> Vec<DeclaredCheck> {
amont_runtime::manifest::read_lines(repo)
.into_iter()
.map(|line| {
let (name, stage, parsed) = line.into_parts();
DeclaredCheck {
name,
stage: stage.as_str().to_string(),
state: match parsed {
Ok(declared) => DeclaredState::Usable {
severity: declared.severity.as_str().to_string(),
command: declared.command(),
exts: declared.exts,
},
Err(why) => DeclaredState::Unusable { why },
},
}
})
.collect()
}
pub const EXCLUDED: [&str; 6] = ["node_modules", "target", "dist", "build", ".venv", "vendor"];
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Repo {
pub path: PathBuf,
pub managed: bool,
pub shims: Vec<ShimState>,
pub baked: BakeState,
pub stale_ours: Vec<String>,
pub foreign_subs: Vec<String>,
pub hook_pkgjson: bool,
pub languages: Vec<String>,
pub applicable: Vec<String>,
pub skips: Vec<SkipEntry>,
pub severities: Vec<SeverityOverride>,
pub declared: Vec<DeclaredCheck>,
pub trusted: Option<bool>,
pub agents_md: AgentsMdState,
pub hooks_dir: HooksDir,
pub shares_hooks_with: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentsMdState {
UpToDate,
Missing,
Drifted,
Malformed,
}
fn agents_md_state(repo: &Path) -> AgentsMdState {
use amont_runtime::agents_md::CheckResult;
match amont_runtime::agents_md::check(&repo.join("AGENTS.md")) {
Ok(CheckResult::MatchesGenerated) => AgentsMdState::UpToDate,
Ok(CheckResult::NotPresent) => AgentsMdState::Missing,
Ok(CheckResult::Drifted) => AgentsMdState::Drifted,
Err(_) => AgentsMdState::Malformed,
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct FleetScan {
pub root: PathBuf,
pub depth: usize,
pub git_dirs_found: usize,
pub hook_dirs_seen: usize,
pub managed_seen: usize,
pub unmanaged_seen: usize,
pub unreadable: Vec<PathBuf>,
pub hooks_outside_seen: usize,
pub excluded_dirs: usize,
pub dirs_visited: usize,
pub repos: Vec<Repo>,
}
impl FleetScan {
pub fn looks_like_a_failed_scan(&self) -> bool {
self.git_dirs_found == 0
}
}
fn is_ours(path: &Path) -> bool {
matches!(
amont_runtime::hookfile::classify(path),
amont_runtime::hookfile::HookFile::Ours
)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "where", rename_all = "snake_case")]
pub enum HooksDir {
In { path: PathBuf },
Outside { path: PathBuf },
Unknown { why: String },
}
impl HooksDir {
pub fn inside(&self) -> Option<&Path> {
match self {
HooksDir::In { path } => Some(path),
_ => None,
}
}
pub fn describe(&self) -> String {
match self {
HooksDir::In { path } => path.display().to_string(),
HooksDir::Outside { path } => {
format!("{} — OUTSIDE the repository", path.display())
}
HooksDir::Unknown { why } => format!("unresolvable ({why})"),
}
}
}
pub fn hooks_dir_for(repo: &Path) -> HooksDir {
let out = match Command::new("git")
.arg("-C")
.arg(repo)
.args([
"rev-parse",
"--path-format=absolute",
"--git-path",
"hooks",
"--git-common-dir",
"--show-toplevel",
])
.output()
{
Ok(o) => o,
Err(e) => {
return HooksDir::Unknown {
why: format!("could not run git: {e}"),
}
}
};
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.to_lowercase().contains("not a git repository") && repo.join(".git").is_dir() {
return HooksDir::In {
path: repo.join(".git").join("hooks"),
};
}
return HooksDir::Unknown {
why: first_line(&stderr),
};
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut lines = stdout.lines();
let (Some(hooks), Some(common), Some(top)) = (lines.next(), lines.next(), lines.next()) else {
return HooksDir::Unknown {
why: "git rev-parse answered with fewer paths than it was asked for".to_string(),
};
};
let hooks = PathBuf::from(hooks);
if let Ok(rel) = amont_runtime::hookfile::resolve_lexical(&hooks)
.strip_prefix(amont_runtime::hookfile::resolve_lexical(Path::new(top)))
{
return HooksDir::In {
path: repo.join(rel),
};
}
if amont_runtime::hookfile::is_within(&hooks, Path::new(common)) {
HooksDir::In { path: hooks }
} else {
HooksDir::Outside { path: hooks }
}
}
fn common_dir_for(repo: &Path) -> Option<PathBuf> {
amont_runtime::git::stdout_in(
repo,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)
.map(PathBuf::from)
}
fn first_line(s: &str) -> String {
s.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("no output")
.trim()
.to_string()
}
pub fn is_managed(hooks: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(hooks) else {
return false;
};
entries.flatten().any(|e| is_ours(&e.path()))
}
pub enum Progress<'a> {
Visited(usize),
Found(&'a Repo),
}
pub fn scan(root: &Path, depth: usize, installed_binary: &str) -> FleetScan {
scan_with(root, depth, installed_binary, &mut |_| {})
}
pub fn scan_with(
root: &Path,
depth: usize,
installed_binary: &str,
on: &mut dyn FnMut(Progress),
) -> FleetScan {
let mut w = Walk {
root: root.to_path_buf(),
installed_binary: installed_binary.to_string(),
seen_common: std::collections::HashMap::new(),
scan: FleetScan {
root: root.to_path_buf(),
depth,
git_dirs_found: 0,
hook_dirs_seen: 0,
managed_seen: 0,
unmanaged_seen: 0,
unreadable: Vec::new(),
hooks_outside_seen: 0,
excluded_dirs: 0,
dirs_visited: 0,
repos: Vec::new(),
},
};
walk(&mut w, root, depth, on);
w.scan.repos.sort_by(|a, b| a.path.cmp(&b.path));
w.scan
}
struct Walk {
root: PathBuf,
installed_binary: String,
seen_common: std::collections::HashMap<PathBuf, PathBuf>,
scan: FleetScan,
}
fn walk(w: &mut Walk, dir: &Path, budget: usize, on: &mut dyn FnMut(Progress)) {
w.scan.dirs_visited += 1;
on(Progress::Visited(w.scan.dirs_visited));
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => {
w.scan.unreadable.push(dir.to_path_buf());
return;
}
};
let mut repos = Vec::new();
let mut subdirs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if name == ".git" {
repos.push(path.parent().unwrap_or(&path).to_path_buf());
continue;
}
if !path.is_dir() {
continue;
}
if EXCLUDED.contains(&name.as_str()) {
w.scan.excluded_dirs += 1;
continue;
}
subdirs.push(path);
}
repos.sort();
subdirs.sort();
for repo in repos {
let found = found_repo(w, &repo);
on(Progress::Found(&found));
w.scan.repos.push(found);
}
if budget == 0 {
return;
}
for subdir in subdirs {
walk(w, &subdir, budget - 1, on);
}
}
fn found_repo(w: &mut Walk, repo: &Path) -> Repo {
w.scan.git_dirs_found += 1;
let hooks_dir = hooks_dir_for(repo);
let shares_hooks_with = common_dir_for(repo).and_then(|common| {
use std::collections::hash_map::Entry;
let rel = repo.strip_prefix(&w.root).unwrap_or(repo).to_path_buf();
match w.seen_common.entry(common) {
Entry::Occupied(e) => Some(e.get().clone()),
Entry::Vacant(e) => {
e.insert(rel);
None
}
}
});
match &hooks_dir {
HooksDir::In { path } => {
if path.is_dir() {
w.scan.hook_dirs_seen += 1;
}
}
_ => w.scan.hooks_outside_seen += 1,
}
let managed = hooks_dir.inside().is_some_and(is_managed);
if managed {
w.scan.managed_seen += 1;
} else {
w.scan.unmanaged_seen += 1;
}
inspect(
&w.root,
repo,
hooks_dir,
managed,
shares_hooks_with,
&w.installed_binary,
)
}
fn inspect(
root: &Path,
repo: &Path,
hooks_dir: HooksDir,
managed: bool,
shares_hooks_with: Option<PathBuf>,
installed_binary: &str,
) -> Repo {
let (mut stale_ours, mut foreign_subs) = (Vec::new(), Vec::new());
let mut hook_pkgjson = false;
let shims: Vec<ShimState> = match hooks_dir.inside() {
None => vec![
ShimState::Unreadable {
why: format!("hooks directory {}", hooks_dir.describe()),
};
DISPATCHERS.len()
],
Some(hooks) => {
if let Ok(entries) = std::fs::read_dir(hooks) {
for entry in entries.flatten() {
let p = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(".sample") || DISPATCHERS.contains(&name.as_str()) {
continue;
}
if name == "package.json" {
hook_pkgjson = std::fs::read_to_string(&p)
.map(|c| c.contains("Forces Node"))
.unwrap_or(false);
continue;
}
if is_ours(&p) {
stale_ours.push(name);
} else if name.starts_with("pre-commit-") || name.starts_with("pre-push-") {
foreign_subs.push(name);
}
}
}
DISPATCHERS
.iter()
.map(|n| shim::classify(&hooks.join(n)))
.collect()
}
};
stale_ours.sort();
foreign_subs.sort();
Repo {
path: repo.strip_prefix(root).unwrap_or(repo).to_path_buf(),
managed,
baked: shim::bake_state(&shims, installed_binary),
shims,
stale_ours,
foreign_subs,
hook_pkgjson,
languages: languages(repo),
applicable: applicable_checks(repo),
skips: skips::read(repo),
severities: severities::read(repo),
declared: declared_checks(repo),
trusted: match amont_runtime::trust::state(repo) {
amont_runtime::trust::State::NoManifest => None,
amont_runtime::trust::State::Trusted => Some(true),
_ => Some(false),
},
agents_md: agents_md_state(repo),
hooks_dir,
shares_hooks_with,
}
}
fn languages(repo: &Path) -> Vec<String> {
let mut out = Vec::new();
let has = |f: &str| repo.join(f).is_file();
if has("Cargo.toml") {
out.push("rust".into());
}
if has("package.json") {
out.push("js".into());
}
if has("pyproject.toml") || has("requirements.txt") || has("setup.py") {
out.push("python".into());
}
if has("kustomization.yaml") || has("kustomization.yml") || repo.join("k8s").is_dir() {
out.push("k8s".into());
}
out
}
fn applicable_checks(repo: &Path) -> Vec<String> {
let Ok(out) = Command::new("git")
.args(["ls-files"])
.current_dir(repo)
.output()
else {
return Vec::new();
};
let paths: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::to_owned)
.collect();
applicable_from_paths(&paths)
}
pub fn applicable_from_paths(paths: &[String]) -> Vec<String> {
amont_runtime::registry::CHECKS
.iter()
.filter(|c| c.scope.matches(paths))
.map(|c| c.name.to_string())
.collect()
}