diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c849a23 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Keep hook entrypoints portable with Git autocrlf enabled. +.githooks/pre-push text eol=lf +scripts/hooks/*.sh text eol=lf +scripts/install-hooks.sh text eol=lf diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..92db22b --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +# Validate the pushed commit, never a different or partially edited checkout. +head=$(git rev-parse HEAD) +required=0 +updates=0 +paths=$(mktemp) +trap 'rm -f "$paths"' EXIT +while read -r local_ref local_oid remote_ref remote_oid extra || [[ -n ${local_ref:-}${local_oid:-}${remote_ref:-}${remote_oid:-}${extra:-} ]]; do + updates=1 + if [[ -n ${extra:-} || ! $local_oid =~ ^[0-9a-f]+$ || ! $remote_oid =~ ^[0-9a-f]+$ || ${#local_oid} != ${#head} || ${#remote_oid} != ${#head} ]]; then + echo 'Invalid pre-push input; no checks skipped.' >&2; exit 1 + fi + [[ $local_oid =~ ^0+$ ]] && continue + commit=$(git rev-parse --verify "$local_oid^{commit}") + if [[ $commit != "$head" ]]; then + echo 'Push only checked-out HEAD. Validate other refs in their own clean checkout.' >&2; exit 1 + fi + if [[ $remote_oid =~ ^0+$ ]] || ! base=$(git rev-parse --verify "$remote_oid^{commit}" 2>/dev/null); then + required=1 + continue + fi + git diff --no-ext-diff --name-only --no-renames -z "$base" "$commit" > "$paths" + # Empty/unknown ranges broaden to full automation checks. + [[ -s $paths ]] || required=1 + while IFS= read -r -d '' path; do + required=1 + done < "$paths" +done +[[ $updates == 1 ]] || required=1 +[[ $required == 1 ]] || exit 0 +checkout_status=$(git status --porcelain --untracked-files=normal) +if [[ -n $checkout_status ]]; then + echo 'Automation checks require a clean checkout of pushed HEAD. No stashing or rewriting is performed.' >&2; exit 1 +fi +exec bash scripts/hooks/check.sh diff --git a/docs/local-checks.md b/docs/local-checks.md new file mode 100644 index 0000000..28c90ab --- /dev/null +++ b/docs/local-checks.md @@ -0,0 +1,17 @@ +# Opt-in local checks + +Owner must first choose a replacement for the GPUI dependency that points at the deleted BumpyClock/zed repository. Never fetch or recreate that repository. Then explicitly prepare the approved pinned gpui-component submodule and installed Rust toolchain/dependencies. + +## Installation and push contract + +Enable explicitly with `bash scripts/install-hooks.sh` using Bash/Git Bash. Windows-target repositories also provide `pwsh -NoProfile -File scripts/install-hooks.ps1`. Installer is idempotent and refuses to hide existing active hooks or replace custom core.hooksPath. Relative repository-local .githooks resolves within each linked worktree. It is never installed automatically. + +Every nondeleted ref must resolve to checked-out HEAD. Checks require a clean checkout including nonignored untracked files. No stash, rewriting, fetch or actual push occurs. New refs, unavailable bases, empty input/ranges, deletions, renames and shared changes broaden to full project checks. Multiple refs are consumed before one validation. Deleted refs alone skip. Other commits need their own clean checkout. + +## Commands and acceptance + +Prepared hook refuses the deleted dependency before Cargo can fetch it. After dependency ownership is resolved, it runs full `cargo fmt --all -- --check` and `cargo check --workspace --locked --offline`. Whole workspace is conservative until a passing baseline exists. No credentials or live provider accounts are touched. + +Application ref is main, not data-only tsq-sync. Deleted pinned GPUI origin and absent vendor submodule prevent real workspace acceptance. No dependency substitution is guessed. + +`python3 scripts/hooks/test-hooks.py` exercises executable hooks and installers in disposable Git repositories with stub project commands, including linked worktrees. No actual push. Harness evidence is separate from actual project/native acceptance. No under-nine-second promise, CI activation or affected-only optimization is claimed. No user hooks were installed during preparation. diff --git a/scripts/hooks/check.sh b/scripts/hooks/check.sh new file mode 100755 index 0000000..bf2bada --- /dev/null +++ b/scripts/hooks/check.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +command -v cargo >/dev/null || { echo 'Prepare the project Rust toolchain and locked dependencies first.' >&2; exit 1; } +export RUSTUP_AUTO_INSTALL=0 CARGO_NET_OFFLINE=true +[[ -f Cargo.lock ]] || { echo 'Missing Cargo.lock; restore the tracked lockfile.' >&2; exit 1; } +if grep -q 'https://github.com/BumpyClock/zed' Cargo.toml; then + echo 'Pinned GPUI points at a deleted repository. Owner must choose a replacement; hook never fetches it.' >&2; exit 1 +fi +[[ -f vendor/gpui-component/crates/ui/Cargo.toml ]] || { echo 'Prepare the pinned gpui-component submodule explicitly before pushing.' >&2; exit 1; } +cargo fmt --all -- --check +cargo check --workspace --locked --offline diff --git a/scripts/hooks/test-hooks.py b/scripts/hooks/test-hooks.py new file mode 100644 index 0000000..54c4a0b --- /dev/null +++ b/scripts/hooks/test-hooks.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Exercise executable hooks/installers in disposable Git repositories. No push.""" +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).resolve().parents[2] + + +class HookTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="hooks space ") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.env = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) + self.run_command("git", "init", "-q") + shutil.copytree(SOURCE / ".githooks", self.root / ".githooks") + (self.root / "scripts/hooks").mkdir(parents=True) + shutil.copy2(SOURCE / "scripts/install-hooks.sh", self.root / "scripts/install-hooks.sh") + # Stub only the project payload. Range/clean-tree/installer code is real. + (self.root / "scripts/hooks/check.sh").write_text( + '#!/usr/bin/env bash\nprintf "checked\\n" >> "$HOOK_RECEIPT"\nexit "${HOOK_EXIT:-0}"\n') + self.env["HOOK_RECEIPT"] = str(self.root / ".git/receipt") + self.commit() + self.base = self.git("rev-parse", "HEAD").stdout.strip() + + def run_command(self, *args, input=None, expected=0): + result = subprocess.run(args, cwd=self.root, env=self.env, input=input, + text=True, capture_output=True) + if expected is not None: + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + return result + + def git(self, *args, **kwargs): + return self.run_command("git", *args, **kwargs) + + def commit(self): + self.git("add", ".") + self.git("-c", "user.name=Hook Fixture", "-c", "user.email=fixture@invalid", + "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", + "commit", "-qm", "fixture", "--allow-empty") + + def update(self, path="source with spaces.go"): + (self.root / path).write_text("changed\n") + self.commit() + return self.git("rev-parse", "HEAD").stdout.strip() + + def row(self, local=None, remote=None): + head = self.git("rev-parse", "HEAD").stdout.strip() + return f"refs/heads/test {local or head} refs/heads/test {remote or self.base}\n" + + def hook(self, data=None, expected=0): + return self.run_command(str(self.root / ".githooks/pre-push"), "origin", "unused", + input=self.row() if data is None else data, expected=expected) + + def checked(self): + return (self.root / ".git/receipt").exists() + + def test_changed_path_with_spaces_and_failure_propagation(self): + self.update() + self.env["HOOK_EXIT"] = "23" + self.hook(expected=23) + self.assertTrue(self.checked()) + + def test_new_branch_unknown_base_and_empty_input_broaden(self): + for data in (self.row(remote="0" * 40), self.row(remote="1" * 40), "", self.row()): + self.hook(data) + self.assertTrue(self.checked()) + (self.root / ".git/receipt").unlink() + + def test_deleting_ref_needs_no_project_tools(self): + self.hook(self.row(local="0" * 40)) + self.assertFalse(self.checked()) + + def test_multiple_refs_cannot_hide_later_failure(self): + self.update() + self.env["HOOK_EXIT"] = "17" + self.hook(self.row(local="0" * 40) + self.row(), expected=17) + self.assertTrue(self.checked()) + + def test_last_record_without_newline_is_not_skipped(self): + self.update() + self.env["HOOK_EXIT"] = "19" + self.hook(self.row(local="0" * 40) + self.row().rstrip("\n"), expected=19) + self.assertTrue(self.checked()) + + def test_git_status_failure_is_not_green(self): + self.update() + real_git = shutil.which("git") + tools = self.root / ".git/tools" + tools.mkdir() + stub = tools / "git" + stub.write_text(f'#!/bin/sh\nif [ "$1" = status ]; then exit 42; fi\nexec "{real_git}" "$@"\n') + stub.chmod(0o755) + self.env["PATH"] = str(tools) + os.pathsep + self.env["PATH"] + self.hook(expected=42) + self.assertFalse(self.checked()) + + def test_non_head_and_malformed_input_fail(self): + self.update() + self.hook(self.row(local=self.base), expected=1) + self.hook("malformed\n", expected=1) + self.assertFalse(self.checked()) + + def test_dirty_checkout_refused_without_rewriting(self): + self.update() + target = self.root / "untracked.txt" + target.write_text("preserve me") + self.hook(expected=1) + self.assertEqual(target.read_text(), "preserve me") + self.assertFalse(self.checked()) + + def test_rename_and_deletion_not_skipped(self): + self.update() + self.git("mv", "source with spaces.go", "renamed.go") + self.commit() + self.hook() + self.assertTrue(self.checked()) + (self.root / ".git/receipt").unlink() + self.git("rm", "renamed.go") + self.commit() + self.hook() + self.assertTrue(self.checked()) + + def test_installer_idempotent_and_preserves_active_hook(self): + active = self.root / ".git/hooks/pre-commit" + active.write_text("#!/bin/sh\nexit 7\n") + active.chmod(0o755) + self.run_command("bash", "scripts/install-hooks.sh", expected=1) + self.assertEqual(active.read_text(), "#!/bin/sh\nexit 7\n") + active.unlink() + self.run_command("bash", "scripts/install-hooks.sh") + self.run_command("bash", "scripts/install-hooks.sh") + self.assertEqual(self.git("config", "--get", "core.hooksPath").stdout.strip(), ".githooks") + + def test_installer_preserves_custom_configuration(self): + self.git("config", "core.hooksPath", "custom hooks") + self.run_command("bash", "scripts/install-hooks.sh", expected=1) + self.assertEqual(self.git("config", "--get", "core.hooksPath").stdout.strip(), "custom hooks") + + def test_linked_worktree_installer_resolves_its_own_checkout(self): + with tempfile.TemporaryDirectory(prefix="linked hooks ") as directory: + linked = Path(directory) / "checkout" + self.git("worktree", "add", "--detach", str(linked), "HEAD") + result = subprocess.run(["bash", "scripts/install-hooks.sh"], cwd=linked, + env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(linked / ".githooks/pre-push")], cwd=linked, + env=self.env, input="", capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(self.checked()) + + def test_content_only_skip_and_instruction_fallback(self): + if not (SOURCE / "package.json").exists() or 'personal-assistant' not in (SOURCE / "package.json").read_text(): + self.skipTest("PersonalAssistant content policy only") + self.update("notes.md") + self.hook() + self.assertFalse(self.checked()) + self.update("AGENTS.md") + self.hook() + self.assertTrue(self.checked()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..0d2afc3 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +current=$(git config --get core.hooksPath || true) +if [[ -n $current && $current != .githooks ]]; then + echo 'Refusing to replace existing core.hooksPath. Reconcile existing hooks explicitly.' >&2; exit 1 +fi +if [[ -z $current ]]; then + hooks=$(git rev-parse --git-path hooks) + for hook in "$hooks"/*; do + [[ $hook == *.sample || ! -x $hook || -d $hook ]] && continue + echo 'Refusing to hide existing active hooks. Reconcile them explicitly.' >&2; exit 1 + done +fi +[[ -x .githooks/pre-push ]] || { echo 'Missing executable .githooks/pre-push.' >&2; exit 1; } +# Relative hooksPath resolves within each worktree, not the installer checkout. +git config --local core.hooksPath .githooks +echo 'Opt-in hooks enabled for this repository. No tools installed.'