pre-commit-hooks/pre_commit_hooks/catch_dotenv.py

182 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 adhoc 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())