mirror of
https://github.com/pre-commit/pre-commit-hooks.git
synced 2026-03-30 02:16:52 +00:00
236 lines
7.1 KiB
Python
236 lines
7.1 KiB
Python
#!/usr/bin/env python
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
import re
|
||
import sys
|
||
import tempfile
|
||
from collections.abc import Iterable
|
||
from collections.abc import Sequence
|
||
|
||
# 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:
|
||
"""Atomically (best-effort) write text.
|
||
|
||
Writes to a same-directory temporary file then replaces the target with
|
||
os.replace(). This is a slight divergence from most existing hooks which
|
||
write directly, but here we intentionally reduce the (small) risk of
|
||
partially-written files because the hook may be invoked rapidly / in
|
||
parallel (tests exercise concurrent normalization). Keeping this helper
|
||
local avoids adding any dependency.
|
||
"""
|
||
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): # (rare failure case)
|
||
try:
|
||
os.remove(tmp_path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _read_gitignore(gitignore_file: str) -> tuple[str, list[str]]:
|
||
"""Read and parse .gitignore file content."""
|
||
try:
|
||
if os.path.exists(gitignore_file):
|
||
with open(gitignore_file, 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,
|
||
)
|
||
raise
|
||
return original_text, lines
|
||
|
||
|
||
def _normalize_gitignore_lines(
|
||
lines: list[str],
|
||
env_file: str,
|
||
banner: str,
|
||
) -> list[str]:
|
||
"""Normalize .gitignore lines by removing duplicates and canonical tail."""
|
||
# Trim trailing blank lines
|
||
while lines and not lines[-1].strip():
|
||
lines.pop()
|
||
|
||
# Remove existing occurrences
|
||
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)
|
||
return filtered
|
||
|
||
|
||
def ensure_env_in_gitignore(
|
||
env_file: str,
|
||
gitignore_file: str,
|
||
banner: str,
|
||
) -> bool:
|
||
"""Ensure canonical banner + env tail in .gitignore.
|
||
|
||
Returns True only when the file content was changed. Returns False both
|
||
when unchanged and on IO errors (we intentionally conflate for the simple
|
||
hook contract; errors are still surfaced via stderr output).
|
||
"""
|
||
try:
|
||
original_content_str, lines = _read_gitignore(gitignore_file)
|
||
except OSError:
|
||
return False
|
||
|
||
filtered = _normalize_gitignore_lines(lines, env_file, banner)
|
||
new_content = '\n'.join(filtered) + '\n'
|
||
|
||
# Normalize original content to a single trailing newline for comparison
|
||
normalized_original = original_content_str
|
||
if normalized_original and not normalized_original.endswith('\n'):
|
||
normalized_original += '\n'
|
||
if new_content == normalized_original:
|
||
return False
|
||
|
||
try:
|
||
_atomic_write(gitignore_file, new_content)
|
||
return True
|
||
except OSError as exc:
|
||
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, 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}",
|
||
file=sys.stderr,
|
||
)
|
||
return False
|
||
|
||
|
||
def _has_env(filenames: Iterable[str], env_file: str) -> bool:
|
||
"""Return True if any staged path refers to 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:
|
||
# Match typical hook output style: one short line per action.
|
||
print(f"Blocked committing {env_file}.")
|
||
if gitignore_modified:
|
||
print(f"Updated {gitignore_file}.")
|
||
if example_created:
|
||
print('Generated .env.example.')
|
||
print(f"Remove {env_file} from the commit and retry.")
|
||
|
||
|
||
def main(argv: Sequence[str] | None = None) -> int:
|
||
"""Hook entry-point."""
|
||
parser = argparse.ArgumentParser(
|
||
description='Blocks committing .env files.',
|
||
)
|
||
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 (pre-commit executes
|
||
# hooks from the repo root).
|
||
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())
|