use std::fs;
use std::io;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use runemark::Verdict;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gate {
WorkingTreeClean,
VersionUnreleased,
NotBehindUpstream,
}
impl Gate {
pub fn name(self) -> &'static str {
match self {
Self::WorkingTreeClean => "Working tree clean",
Self::VersionUnreleased => "Version not yet tagged",
Self::NotBehindUpstream => "Not behind upstream",
}
}
pub fn check(self, root: &Path, version: Option<&str>) -> GateResult {
match self {
Self::WorkingTreeClean => match git(root, &["status", "--porcelain"]) {
Some(output) if output.trim().is_empty() => GateResult::pass(),
Some(output) => {
let changed = output.lines().count();
GateResult::fail(format!("{changed} uncommitted change(s)"))
}
None => GateResult::skip("not a git repository"),
},
Self::VersionUnreleased => {
let Some(version) = version else {
return GateResult::skip("package.json has no version");
};
match git(root, &["tag", "--list", &format!("v{version}")]) {
Some(output) if output.trim().is_empty() => GateResult::pass(),
Some(_) => GateResult::fail(format!("v{version} is already tagged")),
None => GateResult::skip("not a git repository"),
}
}
Self::NotBehindUpstream => {
match git(root, &["rev-list", "--count", "HEAD..@{upstream}"]) {
Some(output) if output.trim() == "0" => GateResult::pass(),
Some(output) => {
GateResult::fail(format!("{} commit(s) behind — pull first", output.trim()))
}
None => GateResult::skip("no upstream branch"),
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct GateResult {
pub verdict: Verdict,
pub detail: Option<String>,
}
impl GateResult {
fn pass() -> Self {
Self {
verdict: Verdict::Passed,
detail: None,
}
}
fn fail(detail: impl Into<String>) -> Self {
Self {
verdict: Verdict::Failed,
detail: Some(detail.into()),
}
}
fn skip(reason: impl Into<String>) -> Self {
Self {
verdict: Verdict::Skipped,
detail: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Workflow {
Commit,
Push,
Release,
Deploy,
}
impl Workflow {
pub fn parse(name: &str) -> Option<Self> {
match name {
"commit" => Some(Self::Commit),
"push" => Some(Self::Push),
"release" => Some(Self::Release),
"deploy" => Some(Self::Deploy),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Commit => "before commit",
Self::Push => "before push",
Self::Release => "before release",
Self::Deploy => "before deploy",
}
}
pub fn gates(self) -> &'static [Gate] {
match self {
Self::Commit => &[],
Self::Push => &[Gate::WorkingTreeClean, Gate::NotBehindUpstream],
Self::Deploy => &[Gate::WorkingTreeClean],
Self::Release => &[Gate::WorkingTreeClean, Gate::VersionUnreleased],
}
}
pub fn builds(self) -> bool {
!matches!(self, Self::Commit)
}
pub fn includes(self, check: &str) -> bool {
match self {
Self::Push | Self::Release | Self::Deploy => true,
Self::Commit => !matches!(check, "Tests" | "Dead code"),
}
}
}
const HOOK_COMMAND: &str = "opi --check push";
#[derive(Debug, PartialEq, Eq)]
pub enum HookInstall {
Created(PathBuf),
Appended(PathBuf),
Present(PathBuf),
}
#[derive(Debug)]
pub enum HookError {
NotARepository,
Io(io::Error),
}
impl From<io::Error> for HookError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
pub fn install_hook(root: &Path) -> Result<HookInstall, HookError> {
let toplevel = git(root, &["rev-parse", "--show-toplevel"])
.map(|output| PathBuf::from(output.trim()))
.ok_or(HookError::NotARepository)?;
let husky = toplevel.join(".husky");
let dir = if husky.is_dir() {
husky
} else {
git(
root,
&["rev-parse", "--path-format=absolute", "--git-path", "hooks"],
)
.map(|output| PathBuf::from(output.trim()))
.ok_or(HookError::NotARepository)?
};
let hook = dir.join("pre-push");
let project = root.canonicalize()?;
let line = match project.strip_prefix(toplevel.canonicalize()?) {
Ok(relative) if !relative.as_os_str().is_empty() => {
format!("(cd '{}' && {HOOK_COMMAND})", relative.display())
}
_ => HOOK_COMMAND.to_owned(),
};
if hook.exists() {
let mut contents = fs::read_to_string(&hook)?;
if contents.contains(HOOK_COMMAND) {
return Ok(HookInstall::Present(hook));
}
if !contents.is_empty() && !contents.ends_with('\n') {
contents.push('\n');
}
contents.push_str(&line);
contents.push('\n');
fs::write(&hook, contents)?;
return Ok(HookInstall::Appended(hook));
}
fs::create_dir_all(&dir)?;
fs::write(&hook, format!("#!/bin/sh\n{line}\n"))?;
fs::set_permissions(&hook, fs::Permissions::from_mode(0o755))?;
Ok(HookInstall::Created(hook))
}
fn git(root: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workflows_are_named() {
assert_eq!(Workflow::parse("commit"), Some(Workflow::Commit));
assert_eq!(Workflow::parse("push"), Some(Workflow::Push));
assert_eq!(Workflow::parse("release"), Some(Workflow::Release));
assert_eq!(Workflow::parse("deploy"), Some(Workflow::Deploy));
}
#[test]
fn commit_leaves_out_the_slow_checks() {
assert!(!Workflow::Commit.includes("Tests"));
assert!(!Workflow::Commit.includes("Dead code"));
assert!(Workflow::Commit.includes("Lint & format"));
assert!(Workflow::Commit.includes("TypeScript"));
assert!(Workflow::Commit.includes("Secrets"));
}
#[test]
fn push_and_release_run_everything_and_build() {
for workflow in [Workflow::Push, Workflow::Release] {
for check in ["Tests", "Dead code", "TypeScript", "Secrets"] {
assert!(workflow.includes(check));
}
assert!(workflow.builds());
}
assert!(!Workflow::Commit.builds());
}
#[test]
fn commit_asks_nothing_about_the_repository() {
assert!(Workflow::Commit.gates().is_empty());
assert_eq!(Workflow::Push.gates().len(), 2);
assert_eq!(Workflow::Release.gates().len(), 2);
}
#[test]
fn a_branch_without_upstream_skips_the_behind_question() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(git(dir.path(), &["init"]).is_some());
let result = Gate::NotBehindUpstream.check(dir.path(), None);
assert_eq!(result.verdict, Verdict::Skipped);
}
#[test]
fn a_gate_outside_a_repository_is_skipped_not_failed() {
let dir = tempfile::tempdir().expect("temp dir");
let result = Gate::WorkingTreeClean.check(dir.path(), None);
assert_eq!(result.verdict, Verdict::Skipped);
}
#[test]
fn a_missing_version_skips_the_tag_question() {
let dir = tempfile::tempdir().expect("temp dir");
let result = Gate::VersionUnreleased.check(dir.path(), None);
assert_eq!(result.verdict, Verdict::Skipped);
assert_eq!(
result.detail.as_deref(),
Some("package.json has no version")
);
}
#[test]
fn a_dirty_tree_fails_and_says_how_dirty() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(
git(dir.path(), &["init"]).is_some(),
"git must be available"
);
std::fs::write(dir.path().join("new.txt"), "x").expect("write");
let result = Gate::WorkingTreeClean.check(dir.path(), None);
assert_eq!(result.verdict, Verdict::Failed);
assert!(result.detail.expect("detail").contains("1 uncommitted"));
}
#[test]
fn a_clean_tree_passes() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(git(dir.path(), &["init"]).is_some());
assert_eq!(
Gate::WorkingTreeClean.check(dir.path(), None).verdict,
Verdict::Passed
);
}
#[test]
fn the_hook_is_created_once_and_then_left_alone() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(git(dir.path(), &["init"]).is_some());
let HookInstall::Created(hook) = install_hook(dir.path()).expect("installed") else {
panic!("a fresh repository has no hook");
};
assert!(hook.ends_with(".git/hooks/pre-push"));
let contents = fs::read_to_string(&hook).expect("read");
assert_eq!(contents, "#!/bin/sh\nopi --check push\n");
let mode = fs::metadata(&hook).expect("stat").permissions().mode();
assert_eq!(mode & 0o111, 0o111, "git skips a hook it cannot execute");
assert_eq!(
install_hook(dir.path()).expect("second run"),
HookInstall::Present(hook)
);
}
#[test]
fn an_existing_hook_keeps_its_lines() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(git(dir.path(), &["init"]).is_some());
let husky = dir.path().join(".husky");
fs::create_dir_all(&husky).expect("mkdir");
fs::write(husky.join("pre-push"), "pnpm verify").expect("write");
let result = install_hook(dir.path()).expect("installed");
assert!(
matches!(result, HookInstall::Appended(ref hook) if hook.ends_with(".husky/pre-push"))
);
assert_eq!(
fs::read_to_string(husky.join("pre-push")).expect("read"),
"pnpm verify\nopi --check push\n"
);
}
#[test]
fn a_project_below_the_repository_root_is_stepped_into() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(git(dir.path(), &["init"]).is_some());
let app = dir.path().join("app");
fs::create_dir_all(&app).expect("mkdir");
let HookInstall::Created(hook) = install_hook(&app).expect("installed") else {
panic!("a fresh repository has no hook");
};
assert!(
fs::read_to_string(hook)
.expect("read")
.contains("(cd 'app' && opi --check push)")
);
}
#[test]
fn outside_a_repository_there_is_no_hook_to_install() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(matches!(
install_hook(dir.path()),
Err(HookError::NotARepository)
));
}
}