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