pre-commit-hooks/pre_commit_hooks/catch_dotenv.py

159 lines
5.7 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 sys
import tempfile
from collections.abc import Sequence
from typing import Iterable
# Defaults / constants
DEFAULT_ENV_FILE = ".env"
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:
"""Atomic-ish text write: write to same-dir temp then os.replace."""
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 tail (banner + env) collapsing duplicates. Returns True if modified."""
try:
if os.path.exists(gitignore_file):
with open(gitignore_file, "r", encoding="utf-8") as f:
original_text = f.read()
lines = original_text.splitlines()
else:
original_text = ""
lines = []
except OSError as exc:
print(f"ERROR: unable to read {gitignore_file}: {exc}", file=sys.stderr)
return False
original_content_str = original_text if lines else "" # post-read snapshot
# 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 new_content == (original_content_str if original_content_str.endswith("\n") else original_content_str + ("" if not original_content_str else "\n")):
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}", file=sys.stderr)
return False
def create_example_env(src_env: str, example_file: str) -> bool:
"""Generate .env.example with unique KEY= lines (no values)."""
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}", file=sys.stderr)
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 _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"Updated {gitignore_file}.")
if example_created:
parts.append("Generated .env.example.")
parts.append(f"Remove {env_file} from the commit and retry.")
print(" ".join(parts))
def main(argv: Sequence[str] | None = None) -> int:
"""Hook entry-point."""
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
# Use current working directory as repository root (simplified; no ascent)
repo_root = os.getcwd()
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())