Repository navigation
chore(ci): add opt-in local checks #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove or supply the PowerShell installer. The documented
|
||
|
|
||
| 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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) | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove inherited Git repository variables from the fixture environment. If a Git hook launches this test suite, Proposed change
Committable suggestion
|
||||||||||||||
| 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() | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inspect submodule changes when checking checkout state.
If a contributor sets
submodule.vendor/gpui-component.ignore=dirtyorall,git statuscan report a clean checkout while that dependency’s source differs from the pushed commit. Once the dependency prerequisites are resolved,cargo checkcan then validate local source that the push does not contain. Pass--ignore-submodules=noneto this status command so the clean-checkout check does not depend on local Git configuration. (git-scm.com)