Skip to content

Commit 25a3d2e

Browse files
committed
Add catch_dotenv hook and corresponding tests to manage .env files
1 parent e5e94e8 commit 25a3d2e

File tree

2 files changed

+498
-0
lines changed

2 files changed

+498
-0
lines changed

pre_commit_hooks/catch_dotenv.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
#!/usr/bin/env python
2+
from __future__ import annotations
3+
4+
import argparse
5+
import os
6+
import re
7+
import tempfile
8+
from collections.abc import Sequence
9+
from typing import Iterable
10+
11+
# --- Defaults / Constants ---
12+
DEFAULT_ENV_FILE = ".env" # Canonical env file name
13+
DEFAULT_GITIGNORE_FILE = ".gitignore"
14+
DEFAULT_EXAMPLE_ENV_FILE = ".env.example"
15+
GITIGNORE_BANNER = "# Added by pre-commit hook to prevent committing secrets"
16+
17+
_KEY_REGEX = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
18+
19+
20+
def _atomic_write(path: str, data: str) -> None:
21+
"""Write text to path atomically (best-effort)."""
22+
# Using same directory for atomic os.replace semantics on POSIX.
23+
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".")
24+
try:
25+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as tmp_f:
26+
tmp_f.write(data)
27+
os.replace(tmp_path, path)
28+
finally: # Clean up if replace failed
29+
if os.path.exists(tmp_path): # pragma: no cover (rare failure case)
30+
try:
31+
os.remove(tmp_path)
32+
except OSError: # pragma: no cover
33+
pass
34+
35+
36+
def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) -> bool:
37+
"""Normalize `.gitignore` so it contains exactly one banner + env line at end.
38+
39+
Returns True if the file was created or its contents changed, False otherwise.
40+
Strategy: read existing lines, strip trailing blanks, remove any prior occurrences of
41+
the banner or env_file (even if duplicated), then append a single blank line,
42+
banner, and env_file. Produces an idempotent final layout.
43+
"""
44+
try:
45+
if os.path.exists(gitignore_file):
46+
with open(gitignore_file, "r", encoding="utf-8") as f:
47+
lines = f.read().splitlines()
48+
else:
49+
lines = []
50+
except OSError as exc:
51+
print(f"ERROR: unable to read '{gitignore_file}': {exc}")
52+
return False
53+
54+
original = list(lines)
55+
56+
# Trim trailing blank lines
57+
while lines and not lines[-1].strip():
58+
lines.pop()
59+
60+
# Remove existing occurrences (exact match after strip)
61+
filtered: list[str] = [ln for ln in lines if ln.strip() not in {env_file, banner}]
62+
63+
if filtered and filtered[-1].strip():
64+
filtered.append("") # ensure single blank before banner
65+
elif not filtered: # empty file -> still separate section visually
66+
filtered.append("")
67+
68+
filtered.append(banner)
69+
filtered.append(env_file)
70+
71+
new_content = "\n".join(filtered) + "\n"
72+
if original == filtered:
73+
return False
74+
try:
75+
_atomic_write(gitignore_file, new_content)
76+
return True
77+
except OSError as exc: # pragma: no cover
78+
print(f"ERROR: unable to write '{gitignore_file}': {exc}")
79+
return False
80+
81+
82+
def create_example_env(src_env: str, example_file: str) -> bool:
83+
"""Write example file containing only variable keys from real env file.
84+
85+
Returns True if file written (or updated), False on read/write error.
86+
Lines accepted: optional 'export ' prefix then KEY=...; ignores comments & duplicates.
87+
"""
88+
try:
89+
with open(src_env, "r", encoding="utf-8") as f_env:
90+
lines = f_env.readlines()
91+
except OSError as exc:
92+
print(f"ERROR: unable to read '{src_env}': {exc}")
93+
return False
94+
95+
seen: set[str] = set()
96+
keys: list[str] = []
97+
for line in lines:
98+
stripped = line.strip()
99+
if not stripped or stripped.startswith('#'):
100+
continue
101+
m = _KEY_REGEX.match(stripped)
102+
if not m:
103+
continue
104+
key = m.group(1)
105+
if key not in seen:
106+
seen.add(key)
107+
keys.append(key)
108+
109+
header = [
110+
'# Generated by catch-dotenv hook.',
111+
'# Variable names only – fill in sample values as needed.',
112+
'',
113+
]
114+
body = [f"{k}=" for k in keys]
115+
try:
116+
_atomic_write(example_file, "\n".join(header + body) + "\n")
117+
return True
118+
except OSError as exc: # pragma: no cover
119+
print(f"ERROR: unable to write '{example_file}': {exc}")
120+
return False
121+
122+
123+
def _has_env(filenames: Iterable[str], env_file: str) -> bool:
124+
"""Return True if any staged path refers to a target env file by basename."""
125+
return any(os.path.basename(name) == env_file for name in filenames)
126+
127+
128+
def _find_repo_root(start: str = '.') -> str:
129+
"""Ascend from start until a directory containing '.git' is found.
130+
131+
Falls back to absolute path of start if no parent contains '.git'. This mirrors
132+
typical pre-commit execution (already at repo root) but makes behavior stable
133+
when hook is invoked from a subdirectory (e.g. for direct ad‑hoc testing).
134+
"""
135+
cur = os.path.abspath(start)
136+
prev = None
137+
while cur != prev:
138+
if os.path.isdir(os.path.join(cur, '.git')):
139+
return cur
140+
prev, cur = cur, os.path.abspath(os.path.join(cur, os.pardir))
141+
return os.path.abspath(start)
142+
143+
144+
def _print_failure(env_file: str, gitignore_file: str, example_created: bool, gitignore_modified: bool) -> None:
145+
parts: list[str] = [f"Blocked committing '{env_file}'."]
146+
if gitignore_modified:
147+
parts.append(f"Added to '{gitignore_file}'.")
148+
if example_created:
149+
parts.append("Example file generated.")
150+
parts.append(f"Remove '{env_file}' from the commit and commit again.")
151+
print(" ".join(parts))
152+
153+
154+
def main(argv: Sequence[str] | None = None) -> int:
155+
"""Main function for the pre-commit hook."""
156+
parser = argparse.ArgumentParser(description="Block committing environment files (.env).")
157+
parser.add_argument('filenames', nargs='*', help='Staged filenames (supplied by pre-commit).')
158+
parser.add_argument('--create-example', action='store_true', help='Generate example env file (.env.example).')
159+
args = parser.parse_args(argv)
160+
env_file = DEFAULT_ENV_FILE
161+
# Resolve repository root (directory containing .git) so writes happen there
162+
repo_root = _find_repo_root('.')
163+
gitignore_file = os.path.join(repo_root, DEFAULT_GITIGNORE_FILE)
164+
example_file = os.path.join(repo_root, DEFAULT_EXAMPLE_ENV_FILE)
165+
env_abspath = os.path.join(repo_root, env_file)
166+
167+
if not _has_env(args.filenames, env_file):
168+
return 0
169+
170+
gitignore_modified = ensure_env_in_gitignore(env_file, gitignore_file, GITIGNORE_BANNER)
171+
example_created = False
172+
if args.create_example:
173+
# Source env is always looked up relative to repo root
174+
if os.path.exists(env_abspath):
175+
example_created = create_example_env(env_abspath, example_file)
176+
177+
_print_failure(env_file, gitignore_file, example_created, gitignore_modified)
178+
return 1 # Block commit
179+
180+
181+
if __name__ == '__main__':
182+
raise SystemExit(main())

0 commit comments

Comments
 (0)