Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/hook/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ use crate::hook::template::HOOK_TEMPLATE;
/// Sentinel marker identifying a cora-managed hook.
const CORA_HOOK_SENTINEL: &str = "# cora-managed-hook";

/// First line of the v1 hook, which had no sentinel. Hooks carrying it are cora's
/// own and are upgraded in place instead of being wrapped a second time.
const LEGACY_HOOK_MARK: &str = "cora pre-commit hook — installed by `cora hook install`";

/// Is `content` a hook written by `cora hook install` (any version)?
fn is_cora_managed(content: &str) -> bool {
content.contains(CORA_HOOK_SENTINEL) || content.contains(LEGACY_HOOK_MARK)
}

/// Install the pre-commit hook to `.git/hooks/pre-commit`.
pub fn install_hook() -> Result<String> {
let hooks_dir = find_git_hooks_dir()?;
Expand All @@ -14,7 +23,7 @@ pub fn install_hook() -> Result<String> {
// Check if a hook already exists and handle accordingly
if hook_path.is_file() {
let existing = std::fs::read_to_string(&hook_path)?;
if existing.contains(CORA_HOOK_SENTINEL) {
if is_cora_managed(&existing) {
// Already a cora-managed hook — just overwrite
debug!("existing hook is cora-managed, overwriting");
} else {
Expand Down Expand Up @@ -139,6 +148,7 @@ pub fn is_hook_installed() -> Result<bool> {

#[cfg(test)]
mod tests {
use super::is_cora_managed;
use std::fs;
use std::process::Command;

Expand Down Expand Up @@ -187,4 +197,25 @@ mod tests {
"install.rs should consistently use '{expected_backup}' for backup"
);
}

use crate::hook::template::HOOK_TEMPLATE;

#[test]
fn current_and_legacy_hooks_are_recognised_as_cora_managed() {
assert!(is_cora_managed(HOOK_TEMPLATE));
let v1 = "#!/usr/bin/env bash\n# cora pre-commit hook — installed by `cora hook install`\n# Run `cora hook uninstall` to remove.\n";
assert!(is_cora_managed(v1), "v1 hooks had no sentinel");
let wrapped = format!(
"#!/bin/sh\necho mine\n\n# cora-managed-hook — the section below was added by `cora hook install`\n{HOOK_TEMPLATE}"
);
assert!(is_cora_managed(&wrapped));
}

#[test]
fn a_users_own_hook_is_not_cora_managed() {
assert!(!is_cora_managed("#!/bin/sh\nnpm test\n"));
assert!(!is_cora_managed(
"#!/bin/sh\n# runs cora review by hand\ncora review --staged\n"
));
}
}
100 changes: 76 additions & 24 deletions src/hook/template.rs
Original file line number Diff line number Diff line change
@@ -1,38 +1,90 @@
/// Pre-commit hook template content.
/// Pre-commit hook template content (v2, #674).
///
/// The hook runs `cora index --quiet` (incremental, ~0.014s) then
/// `cora review --staged --format compact` and checks the exit
/// code: 0 = ok (allow commit), 1 = error (allow commit), 2 = blocked (deny commit).
/// 1. A staged diff that an agent (`cora.review_finalize`) or `cora review
/// --staged` already judged passes at once: `cora hook verify` exits 0.
/// 2. Otherwise the hook runs `cora review --staged --ci --severity major`
/// (an LLM when a key is configured, the deterministic checks otherwise).
///
/// `cora review` exit codes: 0 = pass, 2 = blocking findings (deny the commit),
/// anything else = tooling error (the commit proceeds, but it is NOT a pass).
/// `cora hook verify` exit codes: 0 validated, 10 validated but blocking,
/// 11 not validated; an older cora without the subcommand exits 2 (a usage
/// error), which falls through to the full review.
pub const HOOK_TEMPLATE: &str = r#"#!/usr/bin/env bash
# cora pre-commit hook — installed by `cora hook install`
# cora-managed-hook
# cora pre-commit hook (v2) — installed by `cora hook install`.
# Run `cora hook uninstall` to remove.

set -euo pipefail
set -uo pipefail

# Locate cora binary (prefer the one used to install the hook)
CORA_BIN="${CORA_BIN:-cora}"

echo "🔍 Running cora code review..."
# 1. The staged diff was already reviewed (by an agent or by `cora review --staged`).
"$CORA_BIN" hook verify --quiet
case $? in
0)
echo "✅ cora: staged diff already validated."
exit 0
;;
10)
echo "❌ cora: the staged diff was reviewed and the verdict blocks the commit."
echo " Fix the findings, git add, and review again (see: cora hook verify)."
exit 2
;;
esac
# 11 = not validated; any other code (an older cora) falls through to a full review.

# 2. Fallback: review the staged diff now.
echo "🔍 Running cora review..."

# Update the symbol index (incremental — fast, non-blocking)
"$CORA_BIN" index 2>/dev/null || true

# Run cora review on staged changes (uses index for brain enrichment)
if "$CORA_BIN" review --staged --format compact 2>/dev/null; then
echo "✅ cora review passed."
exit 0
else
EXIT_CODE=$?
if [ $EXIT_CODE -eq 2 ]; then
"$CORA_BIN" index >/dev/null 2>&1 || true

"$CORA_BIN" review --staged --ci --severity major --format compact
code=$?
case $code in
0)
echo "✅ cora review passed."
exit 0
;;
2)
echo "❌ cora review found blocking issues. Commit denied."
echo " Fix the issues above, or run: git commit --no-verify"
echo " Fix the issues above; do not use --no-verify to get past a finding."
exit 2
elif [ $EXIT_CODE -eq 1 ]; then
echo "⚠️ cora review encountered an error (commit will proceed)."
;;
*)
echo "⚠️ cora review did not complete (exit $code): that is not a pass."
echo " Re-run: cora review --base <default-branch> --ci --severity major"
echo " and get a real verdict before pushing."
exit 0
else
echo "⚠️ cora exited with code $EXIT_CODE (commit will proceed)."
exit 0
fi
fi
;;
esac
"#;

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn template_is_a_managed_v2_hook_with_both_tiers() {
assert!(HOOK_TEMPLATE.starts_with("#!/usr/bin/env bash\n# cora-managed-hook\n"));
assert!(HOOK_TEMPLATE.contains("hook verify --quiet"));
assert!(HOOK_TEMPLATE.contains("review --staged --ci --severity major"));
}

#[test]
fn template_denies_only_on_exit_2_or_10() {
// The tooling-error branch must not deny (and must say it is no pass).
assert!(HOOK_TEMPLATE.contains("that is not a pass"));
assert_eq!(HOOK_TEMPLATE.matches("exit 2").count(), 2);
// An old cora (usage error 2 from `hook verify`) falls through.
assert!(HOOK_TEMPLATE.contains("falls through to a full review"));
}

#[test]
fn template_never_tells_users_to_bypass_a_finding() {
assert!(HOOK_TEMPLATE.contains("do not use --no-verify to get past a finding"));
assert!(!HOOK_TEMPLATE.contains("or run: git commit --no-verify"));
}
}
Loading
Loading