Repository navigation
build: add a pre-commit hook and a direnv shell #1021
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
Changes from all commits
43dfa2a
c55a7a5
4837927
343cdd3
2cba301
ad19868
750a787
9e6ebf0
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,2 @@ | ||
| watch_file Scripts/dev-tools.env | ||
| use flake |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,35 @@ | ||||||
| #!/bin/sh | ||||||
| set -u | ||||||
|
|
||||||
| root="$(git rev-parse --show-toplevel)" | ||||||
| tools="$root/.cache/flake-tools/bin" | ||||||
| status=0 | ||||||
| swift="$(git diff --cached --name-only --diff-filter=ACMR -- '*.swift')" | ||||||
|
|
||||||
| if [ -n "$swift" ]; then | ||||||
| if [ -x "$tools/swiftformat" ] && [ -x "$tools/swiftlint" ]; then | ||||||
| snapshot="$(mktemp -d)" | ||||||
| trap 'rm -rf "$snapshot"' EXIT | ||||||
| printf '%s\n' "$swift" .swiftformat .swiftlint.yml .swift-version | git checkout-index --prefix="$snapshot/" --stdin | ||||||
| sources="$(printf '%s\n' "$swift" | grep '^Sources/' || true)" | ||||||
| ( | ||||||
| cd "$snapshot" || exit 1 | ||||||
| printf '%s\n' "$swift" | tr '\n' '\0' | xargs -0 "$tools/swiftformat" --lint --quiet || exit 1 | ||||||
|
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. Show SwiftFormat violations when the hook rejects a commit. SwiftFormat 0.64.4 suppresses individual lint diagnostics with Proposed change
Committable suggestion
|
||||||
| if [ -n "$sources" ]; then | ||||||
| printf '%s\n' "$sources" | tr '\n' '\0' | xargs -0 "$tools/swiftlint" lint --quiet --force-exclude || exit 1 | ||||||
| fi | ||||||
| ) || status=1 | ||||||
| else | ||||||
| echo "pre-commit: skipping SwiftFormat and SwiftLint, run make setup-tools to enable them" >&2 | ||||||
| fi | ||||||
| fi | ||||||
|
|
||||||
| if ! git diff --cached --quiet -- Scripts/dev-tools.env; then | ||||||
| pins="$(mktemp)" | ||||||
| git show :Scripts/dev-tools.env > "$pins" | ||||||
| python3 "$root/Scripts/check-pinned-downloads.py" "$pins" || | ||||||
| echo "pre-commit: warning, ci and make setup will fail until the pinned download above is public" >&2 | ||||||
| rm -f "$pins" | ||||||
| fi | ||||||
|
|
||||||
| exit "$status" | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| # Build artifacts | ||
| .build/ | ||
| .direnv/ | ||
| __pycache__/ | ||
|
|
||
| # SwiftPM | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| """Check that every *_URL pinned in Scripts/dev-tools.env is downloadable. | ||
|
|
||
| The flake fetches these URLs during make setup and in every CI job. A pin | ||
| that points at a release asset before the release is published breaks both. | ||
| """ | ||
|
|
||
| import re | ||
| import sys | ||
| import urllib.error | ||
| import urllib.request | ||
| from pathlib import Path | ||
|
|
||
| ROOT = Path(__file__).resolve().parents[1] | ||
|
|
||
|
|
||
| def pinned_urls(path): | ||
| urls = {} | ||
| for line in path.read_text(encoding="utf-8").splitlines(): | ||
| key, separator, value = line.partition("=") | ||
| if separator and re.fullmatch(r"[A-Z0-9_]+_URL", key): | ||
| urls[key] = value | ||
| return urls | ||
|
|
||
|
|
||
| def unreachable(url, timeout=30): | ||
| try: | ||
| request = urllib.request.Request(url, method="HEAD") | ||
| with urllib.request.urlopen(request, timeout=timeout): | ||
| return None | ||
| except urllib.error.HTTPError as error: | ||
| return f"HTTP {error.code}" | ||
| except (urllib.error.URLError, OSError, ValueError) as error: | ||
| return str(getattr(error, "reason", error)) | ||
|
|
||
|
|
||
| def main(argv): | ||
| path = Path(argv[1]) if len(argv) > 1 else ROOT / "Scripts/dev-tools.env" | ||
| failures = 0 | ||
| for key, url in pinned_urls(path).items(): | ||
| reason = unreachable(url) | ||
| if reason: | ||
| failures += 1 | ||
| print(f"{path.name}: {key} is not downloadable ({reason}): {url}", file=sys.stderr) | ||
| if failures: | ||
| print("publish the asset or keep the previous pin until it is public", file=sys.stderr) | ||
| return 1 if failures else 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main(sys.argv)) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | ||
| from pathlib import Path | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| import threading | ||
| import unittest | ||
|
|
||
|
|
||
| ROOT = Path(__file__).resolve().parents[2] | ||
| SCRIPT = ROOT / "Scripts/check-pinned-downloads.py" | ||
|
|
||
|
|
||
| class Handler(BaseHTTPRequestHandler): | ||
| def do_HEAD(self): | ||
| self.send_response(200 if self.path == "/published.zip" else 404) | ||
| self.end_headers() | ||
|
|
||
| def log_message(self, format, *args): | ||
| pass | ||
|
|
||
|
|
||
| class CheckPinnedDownloadsTests(unittest.TestCase): | ||
| def setUp(self): | ||
| self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) | ||
| threading.Thread(target=self.server.serve_forever, daemon=True).start() | ||
| self.addCleanup(self.server.server_close) | ||
| self.addCleanup(self.server.shutdown) | ||
| self.base = f"http://127.0.0.1:{self.server.server_port}" | ||
|
|
||
| def run_check(self, pins): | ||
| with tempfile.TemporaryDirectory() as raw: | ||
| path = Path(raw) / "dev-tools.env" | ||
| path.write_text(pins) | ||
| return subprocess.run( | ||
| [sys.executable, SCRIPT, path], capture_output=True, text=True, check=False | ||
| ) | ||
|
|
||
| def test_published_pins_pass(self): | ||
| result = self.run_check( | ||
| "SWIFTLINT_VERSION=0.65.1\n" | ||
| f"OMNIWM_GHOSTTY_DOWNLOAD_URL={self.base}/published.zip\n" | ||
| f"# OMNIWM_GHOSTTY_DOWNLOAD_URL={self.base}/v0.0.0/retired.zip\n" | ||
| ) | ||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| self.assertEqual(result.stderr, "") | ||
|
|
||
| def test_unpublished_pin_fails_and_names_the_key(self): | ||
| result = self.run_check( | ||
| f"OMNIWM_GHOSTTY_DOWNLOAD_URL={self.base}/published.zip\n" | ||
| "OMNIWM_MALFORMED_DOWNLOAD_URL=github.com/missing-scheme.zip\n" | ||
| f"OMNIWM_OTHER_DOWNLOAD_URL={self.base}/v9.9.9/missing.zip\n" | ||
| ) | ||
| self.assertEqual(result.returncode, 1) | ||
| self.assertNotIn("Traceback", result.stderr) | ||
| self.assertIn("OMNIWM_MALFORMED_DOWNLOAD_URL is not downloadable (unknown url type", result.stderr) | ||
| self.assertIn("OMNIWM_OTHER_DOWNLOAD_URL is not downloadable (HTTP 404)", result.stderr) | ||
| self.assertNotIn("OMNIWM_GHOSTTY_DOWNLOAD_URL", result.stderr) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | ||
| import os | ||
| from pathlib import Path | ||
| import shutil | ||
| import subprocess | ||
| import tempfile | ||
| import threading | ||
| import unittest | ||
|
|
||
|
|
||
| ROOT = Path(__file__).resolve().parents[2] | ||
| TOOLS = ROOT / ".cache/flake-tools" | ||
| GOOD_SWIFT = (ROOT / "Sources/OmniWMIPC/IPCSocketPath.swift").read_text() | ||
|
|
||
|
|
||
| class MissingAsset(BaseHTTPRequestHandler): | ||
| def do_HEAD(self): | ||
| self.send_response(404) | ||
| self.end_headers() | ||
|
|
||
| def log_message(self, format, *args): | ||
| pass | ||
|
|
||
|
|
||
| class PreCommitHookTests(unittest.TestCase): | ||
| def make_repo(self): | ||
| raw = tempfile.TemporaryDirectory() | ||
| self.addCleanup(raw.cleanup) | ||
| repo = Path(raw.name) | ||
| (repo / "Scripts").mkdir() | ||
| shutil.copy2(ROOT / "Scripts/check-pinned-downloads.py", repo / "Scripts") | ||
| shutil.copytree(ROOT / ".githooks", repo / ".githooks") | ||
| for name in (".swiftformat", ".swiftlint.yml", ".swift-version"): | ||
| shutil.copy2(ROOT / name, repo / name) | ||
| if TOOLS.exists(): | ||
| (repo / ".cache").mkdir() | ||
| (repo / ".cache/flake-tools").symlink_to(TOOLS.resolve()) | ||
| env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"} | ||
|
|
||
| def git(*args): | ||
| return subprocess.run(["git", *args], cwd=repo, env=env, capture_output=True, text=True, check=False) | ||
|
|
||
| git("init", "-q") | ||
| git("config", "core.hooksPath", ".githooks") | ||
| git("add", ".swiftformat", ".swiftlint.yml", ".swift-version") | ||
| return repo, git | ||
|
|
||
| def commit(self, git): | ||
| return git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "change") | ||
|
|
||
| def test_unpublished_pin_warns_without_blocking_the_commit(self): | ||
| server = ThreadingHTTPServer(("127.0.0.1", 0), MissingAsset) | ||
| threading.Thread(target=server.serve_forever, daemon=True).start() | ||
| self.addCleanup(server.server_close) | ||
| self.addCleanup(server.shutdown) | ||
| repo, git = self.make_repo() | ||
| url = f"http://127.0.0.1:{server.server_port}/v9.9.9/GhosttyKit.zip" | ||
| (repo / "Scripts/dev-tools.env").write_text(f"OMNIWM_GHOSTTY_DOWNLOAD_URL={url}\n") | ||
| git("add", "Scripts/dev-tools.env") | ||
|
|
||
| result = self.commit(git) | ||
|
|
||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
| self.assertIn("OMNIWM_GHOSTTY_DOWNLOAD_URL is not downloadable (HTTP 404)", result.stderr) | ||
| self.assertIn("pre-commit: warning", result.stderr) | ||
| self.assertEqual(git("rev-list", "--count", "HEAD").stdout.strip(), "1") | ||
|
|
||
| @unittest.skipUnless((TOOLS / "bin/swiftformat").exists(), "needs make setup-tools") | ||
| def test_swift_checks_read_the_staged_content(self): | ||
| repo, git = self.make_repo() | ||
| (repo / "Sources").mkdir() | ||
| source = repo / "Sources/Socket.swift" | ||
| source.write_text(GOOD_SWIFT.replace(" public static", " public static", 1)) | ||
| git("add", "Sources/Socket.swift") | ||
| source.write_text(GOOD_SWIFT) | ||
|
|
||
| result = self.commit(git) | ||
|
|
||
| self.assertNotEqual(result.returncode, 0) | ||
| self.assertNotEqual(git("rev-parse", "--verify", "-q", "HEAD").returncode, 0) | ||
|
|
||
| @unittest.skipUnless((TOOLS / "bin/swiftlint").exists(), "needs make setup-tools") | ||
| def test_swiftlint_only_checks_the_sources_make_lint_covers(self): | ||
| repo, git = self.make_repo() | ||
| shutil.copy2(ROOT / "Package.swift", repo / "Package.swift") | ||
| git("add", "Package.swift") | ||
|
|
||
| result = self.commit(git) | ||
|
|
||
| self.assertEqual(result.returncode, 0, result.stderr) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
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.
Preserve staged Swift paths as NUL-delimited records.
With default
core.quotePath, a staged path such asSources/café.swiftbecomes"Sources/caf\303\251.swift"in$swift. The snapshot restores the decoded path, but thexargs -0pipeline passes the quoted text to SwiftFormat. SwiftFormat finds no eligible file and returns success. Thegrep '^Sources/'filter also excludes the path, so SwiftLint receives no file. The commit can bypass both linters.Use
git diff --cached --name-only -zand preserve the NUL-delimited records throughgit checkout-index --stdin -z, SwiftFormat'sxargs -0, and a NUL-awareSources/filter for SwiftLint. Keep this as a pipeline so the correction does not require temporary path lists.