mirror of
https://github.com/pre-commit/pre-commit-hooks.git
synced 2026-03-29 18:16:52 +00:00
182 lines
6.6 KiB
Python
182 lines
6.6 KiB
Python
#!/usr/bin/env python
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
import re
|
||
import tempfile
|
||
from collections.abc import Sequence
|
||
from typing import Iterable
|
||
|
||
# --- Defaults / Constants ---
|
||
DEFAULT_ENV_FILE = ".env" # Canonical env file name
|
||
DEFAULT_GITIGNORE_FILE = ".gitignore"
|
||
DEFAULT_EXAMPLE_ENV_FILE = ".env.example"
|
||
GITIGNORE_BANNER = "# Added by pre-commit hook to prevent committing secrets"
|
||
|
||
_KEY_REGEX = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
||
|
||
|
||
def _atomic_write(path: str, data: str) -> None:
|
||
"""Write text to path atomically (best-effort)."""
|
||
# Using same directory for atomic os.replace semantics on POSIX.
|
||
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".")
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8", newline="") as tmp_f:
|
||
tmp_f.write(data)
|
||
os.replace(tmp_path, path)
|
||
finally: # Clean up if replace failed
|
||
if os.path.exists(tmp_path): # pragma: no cover (rare failure case)
|
||
try:
|
||
os.remove(tmp_path)
|
||
except OSError: # pragma: no cover
|
||
pass
|
||
|
||
|
||
def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) -> bool:
|
||
"""Normalize `.gitignore` so it contains exactly one banner + env line at end.
|
||
|
||
Returns True if the file was created or its contents changed, False otherwise.
|
||
Strategy: read existing lines, strip trailing blanks, remove any prior occurrences of
|
||
the banner or env_file (even if duplicated), then append a single blank line,
|
||
banner, and env_file. Produces an idempotent final layout.
|
||
"""
|
||
try:
|
||
if os.path.exists(gitignore_file):
|
||
with open(gitignore_file, "r", encoding="utf-8") as f:
|
||
lines = f.read().splitlines()
|
||
else:
|
||
lines = []
|
||
except OSError as exc:
|
||
print(f"ERROR: unable to read '{gitignore_file}': {exc}")
|
||
return False
|
||
|
||
original = list(lines)
|
||
|
||
# Trim trailing blank lines
|
||
while lines and not lines[-1].strip():
|
||
lines.pop()
|
||
|
||
# Remove existing occurrences (exact match after strip)
|
||
filtered: list[str] = [ln for ln in lines if ln.strip() not in {env_file, banner}]
|
||
|
||
if filtered and filtered[-1].strip():
|
||
filtered.append("") # ensure single blank before banner
|
||
elif not filtered: # empty file -> still separate section visually
|
||
filtered.append("")
|
||
|
||
filtered.append(banner)
|
||
filtered.append(env_file)
|
||
|
||
new_content = "\n".join(filtered) + "\n"
|
||
if original == filtered:
|
||
return False
|
||
try:
|
||
_atomic_write(gitignore_file, new_content)
|
||
return True
|
||
except OSError as exc: # pragma: no cover
|
||
print(f"ERROR: unable to write '{gitignore_file}': {exc}")
|
||
return False
|
||
|
||
|
||
def create_example_env(src_env: str, example_file: str) -> bool:
|
||
"""Write example file containing only variable keys from real env file.
|
||
|
||
Returns True if file written (or updated), False on read/write error.
|
||
Lines accepted: optional 'export ' prefix then KEY=...; ignores comments & duplicates.
|
||
"""
|
||
try:
|
||
with open(src_env, "r", encoding="utf-8") as f_env:
|
||
lines = f_env.readlines()
|
||
except OSError as exc:
|
||
print(f"ERROR: unable to read '{src_env}': {exc}")
|
||
return False
|
||
|
||
seen: set[str] = set()
|
||
keys: list[str] = []
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if not stripped or stripped.startswith('#'):
|
||
continue
|
||
m = _KEY_REGEX.match(stripped)
|
||
if not m:
|
||
continue
|
||
key = m.group(1)
|
||
if key not in seen:
|
||
seen.add(key)
|
||
keys.append(key)
|
||
|
||
header = [
|
||
'# Generated by catch-dotenv hook.',
|
||
'# Variable names only – fill in sample values as needed.',
|
||
'',
|
||
]
|
||
body = [f"{k}=" for k in keys]
|
||
try:
|
||
_atomic_write(example_file, "\n".join(header + body) + "\n")
|
||
return True
|
||
except OSError as exc: # pragma: no cover
|
||
print(f"ERROR: unable to write '{example_file}': {exc}")
|
||
return False
|
||
|
||
|
||
def _has_env(filenames: Iterable[str], env_file: str) -> bool:
|
||
"""Return True if any staged path refers to a target env file by basename."""
|
||
return any(os.path.basename(name) == env_file for name in filenames)
|
||
|
||
|
||
def _find_repo_root(start: str = '.') -> str:
|
||
"""Ascend from start until a directory containing '.git' is found.
|
||
|
||
Falls back to absolute path of start if no parent contains '.git'. This mirrors
|
||
typical pre-commit execution (already at repo root) but makes behavior stable
|
||
when hook is invoked from a subdirectory (e.g. for direct ad‑hoc testing).
|
||
"""
|
||
cur = os.path.abspath(start)
|
||
prev = None
|
||
while cur != prev:
|
||
if os.path.isdir(os.path.join(cur, '.git')):
|
||
return cur
|
||
prev, cur = cur, os.path.abspath(os.path.join(cur, os.pardir))
|
||
return os.path.abspath(start)
|
||
|
||
|
||
def _print_failure(env_file: str, gitignore_file: str, example_created: bool, gitignore_modified: bool) -> None:
|
||
parts: list[str] = [f"Blocked committing '{env_file}'."]
|
||
if gitignore_modified:
|
||
parts.append(f"Added to '{gitignore_file}'.")
|
||
if example_created:
|
||
parts.append("Example file generated.")
|
||
parts.append(f"Remove '{env_file}' from the commit and commit again.")
|
||
print(" ".join(parts))
|
||
|
||
|
||
def main(argv: Sequence[str] | None = None) -> int:
|
||
"""Main function for the pre-commit hook."""
|
||
parser = argparse.ArgumentParser(description="Block committing environment files (.env).")
|
||
parser.add_argument('filenames', nargs='*', help='Staged filenames (supplied by pre-commit).')
|
||
parser.add_argument('--create-example', action='store_true', help='Generate example env file (.env.example).')
|
||
args = parser.parse_args(argv)
|
||
env_file = DEFAULT_ENV_FILE
|
||
# Resolve repository root (directory containing .git) so writes happen there
|
||
repo_root = _find_repo_root('.')
|
||
gitignore_file = os.path.join(repo_root, DEFAULT_GITIGNORE_FILE)
|
||
example_file = os.path.join(repo_root, DEFAULT_EXAMPLE_ENV_FILE)
|
||
env_abspath = os.path.join(repo_root, env_file)
|
||
|
||
if not _has_env(args.filenames, env_file):
|
||
return 0
|
||
|
||
gitignore_modified = ensure_env_in_gitignore(env_file, gitignore_file, GITIGNORE_BANNER)
|
||
example_created = False
|
||
if args.create_example:
|
||
# Source env is always looked up relative to repo root
|
||
if os.path.exists(env_abspath):
|
||
example_created = create_example_env(env_abspath, example_file)
|
||
|
||
_print_failure(env_file, gitignore_file, example_created, gitignore_modified)
|
||
return 1 # Block commit
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|