use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use anyhow::{Result, bail};
use crate::analysis::result::FailureReason;
use crate::cli::check::CheckArgs;
use crate::diff;
use crate::diff::hunks::{Hunk, group_by_file};
use crate::files;
pub const READ_MAX_BYTES: u64 = 8 * 1024 * 1024;
const _: () = assert!(READ_MAX_BYTES >= crate::analysis::payload::PAYLOAD_MAX_BYTES);
pub struct Work {
pub by_file: Vec<Vec<Hunk>>,
pub read_failures: BTreeMap<PathBuf, FailureReason>,
pub lint_only: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PreCommitPush {
Range { from: String, to: String },
AllFiles,
}
impl PreCommitPush {
fn from_env() -> Result<Self> {
Self::from_lookup(|name| std::env::var_os(name))
}
pub(crate) fn from_lookup(mut lookup: impl FnMut(&str) -> Option<OsString>) -> Result<Self> {
let mut value = |name: &str| {
lookup(name)
.map(|raw| {
raw.into_string().map_err(|_| {
anyhow::anyhow!("pre-commit environment variable `{name}` is not UTF-8")
})
})
.transpose()
};
if value("PRE_COMMIT")?.as_deref() != Some("1") {
bail!("--pre-commit-push must be invoked by pre-commit's pre-push hook");
}
let from = value("PRE_COMMIT_FROM_REF")?.or(value("PRE_COMMIT_ORIGIN")?);
let to = value("PRE_COMMIT_TO_REF")?.or(value("PRE_COMMIT_SOURCE")?);
match (from, to) {
(Some(from), Some(to)) if !from.is_empty() && !to.is_empty() => {
Ok(Self::Range { from, to })
}
(None, None) => {
let remote = value("PRE_COMMIT_REMOTE_NAME")?;
if remote.as_deref().is_none_or(str::is_empty) {
bail!(
"--pre-commit-push has no ref range or `PRE_COMMIT_REMOTE_NAME`; the hook context is incomplete"
);
}
Ok(Self::AllFiles)
}
_ => bail!(
"--pre-commit-push requires both `PRE_COMMIT_FROM_REF` and `PRE_COMMIT_TO_REF`, or neither for an all-files new-branch push"
),
}
}
}
pub async fn resolve(args: &CheckArgs, root: &Path) -> Result<Work> {
if args.pre_commit_push {
return resolve_pre_commit(root, &PreCommitPush::from_env()?).await;
}
let hunks = if args.staged {
diff::staged_hunks(root, files::is_scan_target).await?
} else if let Some(git_ref) = args.diff.as_deref() {
diff::hunks_between(root, git_ref, args.tip.as_deref(), files::is_scan_target).await?
} else {
return resolve_paths(&args.paths, root);
};
Ok(Work {
by_file: group_by_file(hunks),
read_failures: BTreeMap::new(),
lint_only: Vec::new(),
})
}
pub(crate) async fn resolve_pre_commit(root: &Path, pre_commit: &PreCommitPush) -> Result<Work> {
let hunks = match pre_commit {
PreCommitPush::Range { from, to } => {
diff::hunks_between(root, from, Some(to), files::is_scan_target).await?
}
PreCommitPush::AllFiles => return resolve_paths(&[], root),
};
Ok(Work {
by_file: group_by_file(hunks),
read_failures: BTreeMap::new(),
lint_only: Vec::new(),
})
}
fn resolve_paths(paths: &[PathBuf], root: &Path) -> Result<Work> {
let files::Expansion { targets, rejected } =
files::expand_named(paths, root, files::is_scan_target);
let mut by_file: Vec<Vec<Hunk>> = Vec::new();
let mut read_failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
let mut lint_only: Vec<PathBuf> = Vec::new();
for (path, why) in rejected {
let reason = match why {
files::Rejected::Missing => {
FailureReason::Unreadable("no such file or directory".to_owned())
}
files::Rejected::Unanalyzable => {
FailureReason::unsupported(&path, files::redirect_hint(&path))
}
};
read_failures.insert(path, reason);
}
for path in targets {
let bytes = match std::fs::metadata(&path) {
Ok(meta) => meta.len(),
Err(err) => {
read_failures.insert(path, FailureReason::Unreadable(err.to_string()));
continue;
}
};
if bytes > READ_MAX_BYTES {
read_failures.insert(
path.clone(),
FailureReason::FileTooLarge {
bytes,
limit: READ_MAX_BYTES,
},
);
lint_only.push(path);
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(err) => {
read_failures.insert(path.clone(), FailureReason::Unreadable(err.to_string()));
continue;
}
};
by_file.push(vec![Hunk::whole_file(path, &content)]);
}
Ok(Work {
by_file,
read_failures,
lint_only,
})
}