Refactor catch_dotenv hook and tests for improved readability and consistency

This commit is contained in:
Chris Rowe 2025-08-28 20:53:45 -06:00
parent 989ac68f29
commit c7f0dae9a4
No known key found for this signature in database
3 changed files with 259 additions and 98 deletions

View file

@ -6,16 +6,16 @@ import os
import re import re
import sys import sys
import tempfile import tempfile
from collections.abc import Iterable
from collections.abc import Sequence from collections.abc import Sequence
from typing import Iterable
# Defaults / constants # Defaults / constants
DEFAULT_ENV_FILE = ".env" DEFAULT_ENV_FILE = '.env'
DEFAULT_GITIGNORE_FILE = ".gitignore" DEFAULT_GITIGNORE_FILE = '.gitignore'
DEFAULT_EXAMPLE_ENV_FILE = ".env.example" DEFAULT_EXAMPLE_ENV_FILE = '.env.example'
GITIGNORE_BANNER = "# Added by pre-commit hook to prevent committing secrets" 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*=") _KEY_REGEX = re.compile(r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=')
def _atomic_write(path: str, data: str) -> None: def _atomic_write(path: str, data: str) -> None:
@ -28,9 +28,9 @@ def _atomic_write(path: str, data: str) -> None:
parallel (tests exercise concurrent normalization). Keeping this helper parallel (tests exercise concurrent normalization). Keeping this helper
local avoids adding any dependency. local avoids adding any dependency.
""" """
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".") fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or '.')
try: try:
with os.fdopen(fd, "w", encoding="utf-8", newline="") as tmp_f: with os.fdopen(fd, 'w', encoding='utf-8', newline='') as tmp_f:
tmp_f.write(data) tmp_f.write(data)
os.replace(tmp_path, path) os.replace(tmp_path, path)
finally: # Clean up if replace failed finally: # Clean up if replace failed
@ -45,38 +45,51 @@ def _read_gitignore(gitignore_file: str) -> tuple[str, list[str]]:
"""Read and parse .gitignore file content.""" """Read and parse .gitignore file content."""
try: try:
if os.path.exists(gitignore_file): if os.path.exists(gitignore_file):
with open(gitignore_file, "r", encoding="utf-8") as f: with open(gitignore_file, encoding='utf-8') as f:
original_text = f.read() original_text = f.read()
lines = original_text.splitlines() lines = original_text.splitlines()
else: else:
original_text = "" original_text = ''
lines = [] lines = []
except OSError as exc: except OSError as exc:
print(f"ERROR: unable to read {gitignore_file}: {exc}", file=sys.stderr) print(
f"ERROR: unable to read {gitignore_file}: {exc}",
file=sys.stderr,
)
raise raise
return original_text if lines else "", lines return original_text if lines else '', lines
def _normalize_gitignore_lines(lines: list[str], env_file: str, banner: str) -> list[str]: def _normalize_gitignore_lines(
"""Normalize .gitignore lines by removing duplicates and adding canonical tail.""" lines: list[str],
env_file: str,
banner: str,
) -> list[str]:
"""Normalize .gitignore lines by removing duplicates and canonical tail."""
# Trim trailing blank lines # Trim trailing blank lines
while lines and not lines[-1].strip(): while lines and not lines[-1].strip():
lines.pop() lines.pop()
# Remove existing occurrences # Remove existing occurrences
filtered: list[str] = [ln for ln in lines if ln.strip() not in {env_file, banner}] filtered: list[str] = [
ln for ln in lines if ln.strip() not in {env_file, banner}
]
if filtered and filtered[-1].strip(): if filtered and filtered[-1].strip():
filtered.append("") # ensure single blank before banner filtered.append('') # ensure single blank before banner
elif not filtered: # empty file -> still separate section visually elif not filtered: # empty file -> still separate section visually
filtered.append("") filtered.append('')
filtered.append(banner) filtered.append(banner)
filtered.append(env_file) filtered.append(env_file)
return filtered return filtered
def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) -> bool: def ensure_env_in_gitignore(
env_file: str,
gitignore_file: str,
banner: str,
) -> bool:
"""Ensure canonical banner + env tail in .gitignore. """Ensure canonical banner + env tail in .gitignore.
Returns True only when the file content was changed. Returns False both Returns True only when the file content was changed. Returns False both
@ -89,12 +102,12 @@ def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) ->
return False return False
filtered = _normalize_gitignore_lines(lines, env_file, banner) filtered = _normalize_gitignore_lines(lines, env_file, banner)
new_content = "\n".join(filtered) + "\n" new_content = '\n'.join(filtered) + '\n'
# Normalize original content to a single trailing newline for comparison # Normalize original content to a single trailing newline for comparison
normalized_original = original_content_str normalized_original = original_content_str
if normalized_original and not normalized_original.endswith("\n"): if normalized_original and not normalized_original.endswith('\n'):
normalized_original += "\n" normalized_original += '\n'
if new_content == normalized_original: if new_content == normalized_original:
return False return False
@ -102,14 +115,17 @@ def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) ->
_atomic_write(gitignore_file, new_content) _atomic_write(gitignore_file, new_content)
return True return True
except OSError as exc: except OSError as exc:
print(f"ERROR: unable to write {gitignore_file}: {exc}", file=sys.stderr) print(
f"ERROR: unable to write {gitignore_file}: {exc}",
file=sys.stderr,
)
return False return False
def create_example_env(src_env: str, example_file: str) -> bool: def create_example_env(src_env: str, example_file: str) -> bool:
"""Generate .env.example with unique KEY= lines (no values).""" """Generate .env.example with unique KEY= lines (no values)."""
try: try:
with open(src_env, "r", encoding="utf-8") as f_env: with open(src_env, encoding='utf-8') as f_env:
lines = f_env.readlines() lines = f_env.readlines()
except OSError as exc: except OSError as exc:
print(f"ERROR: unable to read {src_env}: {exc}", file=sys.stderr) print(f"ERROR: unable to read {src_env}: {exc}", file=sys.stderr)
@ -136,33 +152,51 @@ def create_example_env(src_env: str, example_file: str) -> bool:
] ]
body = [f"{k}=" for k in keys] body = [f"{k}=" for k in keys]
try: try:
_atomic_write(example_file, "\n".join(header + body) + "\n") _atomic_write(example_file, '\n'.join(header + body) + '\n')
return True return True
except OSError as exc: # pragma: no cover except OSError as exc: # pragma: no cover
print(f"ERROR: unable to write '{example_file}': {exc}", file=sys.stderr) print(
f"ERROR: unable to write '{example_file}': {exc}",
file=sys.stderr,
)
return False return False
def _has_env(filenames: Iterable[str], env_file: str) -> bool: 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 True if any staged path refers to target env file by basename."""
return any(os.path.basename(name) == env_file for name in filenames) 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: 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. # Match typical hook output style: one short line per action.
print(f"Blocked committing {env_file}.") print(f"Blocked committing {env_file}.")
if gitignore_modified: if gitignore_modified:
print(f"Updated {gitignore_file}.") print(f"Updated {gitignore_file}.")
if example_created: if example_created:
print("Generated .env.example.") print('Generated .env.example.')
print(f"Remove {env_file} from the commit and retry.") print(f"Remove {env_file} from the commit and retry.")
def main(argv: Sequence[str] | None = None) -> int: def main(argv: Sequence[str] | None = None) -> int:
"""Hook entry-point.""" """Hook entry-point."""
parser = argparse.ArgumentParser(description="Blocks committing .env files.") parser = argparse.ArgumentParser(
parser.add_argument('filenames', nargs='*', help='Staged filenames (supplied by pre-commit).') description='Blocks committing .env files.',
parser.add_argument('--create-example', action='store_true', help='Generate example env file (.env.example).') )
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) args = parser.parse_args(argv)
env_file = DEFAULT_ENV_FILE env_file = DEFAULT_ENV_FILE
# Use current working directory as repository root (pre-commit executes # Use current working directory as repository root (pre-commit executes
@ -175,14 +209,26 @@ def main(argv: Sequence[str] | None = None) -> int:
if not _has_env(args.filenames, env_file): if not _has_env(args.filenames, env_file):
return 0 return 0
gitignore_modified = ensure_env_in_gitignore(env_file, gitignore_file, GITIGNORE_BANNER) gitignore_modified = ensure_env_in_gitignore(
env_file,
gitignore_file,
GITIGNORE_BANNER,
)
example_created = False example_created = False
if args.create_example: if args.create_example:
# Source env is always looked up relative to repo root # Source env is always looked up relative to repo root
if os.path.exists(env_abspath): if os.path.exists(env_abspath):
example_created = create_example_env(env_abspath, example_file) example_created = create_example_env(
env_abspath,
example_file,
)
_print_failure(env_file, gitignore_file, example_created, gitignore_modified) _print_failure(
env_file,
gitignore_file,
example_created,
gitignore_modified,
)
return 1 # Block commit return 1 # Block commit

View file

@ -29,6 +29,7 @@ exclude =
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =
catch-dotenv = pre_commit_hooks.catch_dotenv:main
check-added-large-files = pre_commit_hooks.check_added_large_files:main check-added-large-files = pre_commit_hooks.check_added_large_files:main
check-ast = pre_commit_hooks.check_ast:main check-ast = pre_commit_hooks.check_ast:main
check-builtin-literals = pre_commit_hooks.check_builtin_literals:main check-builtin-literals = pre_commit_hooks.check_builtin_literals:main

View file

@ -1,18 +1,24 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
import shutil
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
import shutil
import re
import pytest import pytest
from pre_commit_hooks.catch_dotenv import main, ensure_env_in_gitignore, GITIGNORE_BANNER, DEFAULT_ENV_FILE, DEFAULT_EXAMPLE_ENV_FILE, DEFAULT_GITIGNORE_FILE from pre_commit_hooks.catch_dotenv import DEFAULT_ENV_FILE
from pre_commit_hooks.catch_dotenv import DEFAULT_EXAMPLE_ENV_FILE
from pre_commit_hooks.catch_dotenv import DEFAULT_GITIGNORE_FILE
from pre_commit_hooks.catch_dotenv import ensure_env_in_gitignore
from pre_commit_hooks.catch_dotenv import GITIGNORE_BANNER
from pre_commit_hooks.catch_dotenv import main
# Tests cover hook behavior: detection gating, .gitignore normalization, example # Tests cover hook behavior: detection gating, .gitignore normalization,
# file generation parsing edge cases, idempotency, and preservation of existing # example file generation parsing edge cases, idempotency, and preservation of
# content. Each test isolates a single behavioral contract. # existing content. Each test isolates a single behavioral contract.
@pytest.fixture() @pytest.fixture()
@ -25,13 +31,18 @@ def env_file(tmp_path: Path) -> Path:
# __file__ => <repo_root>/tests/catch_dotenv_test.py # __file__ => <repo_root>/tests/catch_dotenv_test.py
# parents[0] = <repo_root>/tests, parents[1] = <repo_root> # parents[0] = <repo_root>/tests, parents[1] = <repo_root>
# Source file stored as test.env in repo (cannot commit a real .env in CI) # Source file stored as test.env in repo (cannot commit a real .env in CI)
resource_env = Path(__file__).resolve().parents[1] / 'testing' / 'resources' / 'test.env' resource_env = (
Path(__file__).resolve().parents[1] /
'testing' / 'resources' / 'test.env'
)
dest = tmp_path / DEFAULT_ENV_FILE dest = tmp_path / DEFAULT_ENV_FILE
shutil.copyfile(resource_env, dest) shutil.copyfile(resource_env, dest)
return dest return dest
def run_hook(tmp_path: Path, staged: list[str], create_example: bool = False) -> int: def run_hook(
tmp_path: Path, staged: list[str], create_example: bool = False,
) -> int:
cwd = os.getcwd() cwd = os.getcwd()
os.chdir(tmp_path) os.chdir(tmp_path)
try: try:
@ -43,13 +54,15 @@ def run_hook(tmp_path: Path, staged: list[str], create_example: bool = False) ->
os.chdir(cwd) os.chdir(cwd)
def test_no_env_file(tmp_path: Path, env_file: Path): def test_no_env_file(tmp_path: Path, env_file: Path) -> None:
"""Hook should no-op (return 0) if .env not staged even if it exists.""" """Hook should no-op (return 0) if .env not staged even if it exists."""
(tmp_path / 'foo.txt').write_text('x') (tmp_path / 'foo.txt').write_text('x')
assert run_hook(tmp_path, ['foo.txt']) == 0 assert run_hook(tmp_path, ['foo.txt']) == 0
def test_blocks_env_and_updates_gitignore(tmp_path: Path, env_file: Path): def test_blocks_env_and_updates_gitignore(
tmp_path: Path, env_file: Path,
) -> None:
"""Staging .env triggers block (exit 1) and appends banner + env entry.""" """Staging .env triggers block (exit 1) and appends banner + env entry."""
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE]) ret = run_hook(tmp_path, [DEFAULT_ENV_FILE])
assert ret == 1 assert ret == 1
@ -58,12 +71,12 @@ def test_blocks_env_and_updates_gitignore(tmp_path: Path, env_file: Path):
assert gi[-1] == DEFAULT_ENV_FILE assert gi[-1] == DEFAULT_ENV_FILE
def test_env_present_but_not_staged(tmp_path: Path, env_file: Path): def test_env_present_but_not_staged(tmp_path: Path, env_file: Path) -> None:
"""Existing .env on disk but not staged should not block commit.""" """Existing .env on disk but not staged should not block commit."""
assert run_hook(tmp_path, ['unrelated.txt']) == 0 assert run_hook(tmp_path, ['unrelated.txt']) == 0
def test_idempotent_gitignore(tmp_path: Path, env_file: Path): def test_idempotent_gitignore(tmp_path: Path, env_file: Path) -> None:
"""Re-running after initial normalization leaves .gitignore unchanged.""" """Re-running after initial normalization leaves .gitignore unchanged."""
g = tmp_path / DEFAULT_GITIGNORE_FILE g = tmp_path / DEFAULT_GITIGNORE_FILE
g.write_text(f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n") g.write_text(f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n")
@ -75,10 +88,14 @@ def test_idempotent_gitignore(tmp_path: Path, env_file: Path):
assert g.read_text() == content1 # unchanged assert g.read_text() == content1 # unchanged
def test_gitignore_with_existing_content_preserved(tmp_path: Path, env_file: Path): def test_gitignore_with_existing_content_preserved(
tmp_path: Path, env_file: Path,
) -> None:
"""Existing entries stay intact; banner/env appended at end cleanly.""" """Existing entries stay intact; banner/env appended at end cleanly."""
g = tmp_path / DEFAULT_GITIGNORE_FILE g = tmp_path / DEFAULT_GITIGNORE_FILE
g.write_text('node_modules/\n# comment line\n') # no trailing newline section markers g.write_text(
'node_modules/\n# comment line\n',
) # no trailing newline section markers
run_hook(tmp_path, [DEFAULT_ENV_FILE]) run_hook(tmp_path, [DEFAULT_ENV_FILE])
lines = g.read_text().splitlines() lines = g.read_text().splitlines()
# original content should still be at top # original content should still be at top
@ -88,10 +105,15 @@ def test_gitignore_with_existing_content_preserved(tmp_path: Path, env_file: Pat
assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE]
def test_gitignore_duplicates_are_collapsed(tmp_path: Path, env_file: Path): def test_gitignore_duplicates_are_collapsed(
tmp_path: Path, env_file: Path,
) -> None:
"""Multiple prior duplicate banner/env lines collapse to single pair.""" """Multiple prior duplicate banner/env lines collapse to single pair."""
g = tmp_path / DEFAULT_GITIGNORE_FILE g = tmp_path / DEFAULT_GITIGNORE_FILE
g.write_text(f"other\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n\n") g.write_text(
f"other\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n"
f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n\n",
)
run_hook(tmp_path, [DEFAULT_ENV_FILE]) run_hook(tmp_path, [DEFAULT_ENV_FILE])
lines = g.read_text().splitlines() lines = g.read_text().splitlines()
assert lines.count(GITIGNORE_BANNER) == 1 assert lines.count(GITIGNORE_BANNER) == 1
@ -99,7 +121,7 @@ def test_gitignore_duplicates_are_collapsed(tmp_path: Path, env_file: Path):
assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE]
def test_create_example(tmp_path: Path, env_file: Path): def test_create_example(tmp_path: Path, env_file: Path) -> None:
"""Example file includes discovered keys; values stripped to KEY=.""" """Example file includes discovered keys; values stripped to KEY=."""
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True)
assert ret == 1 assert ret == 1
@ -108,12 +130,20 @@ def test_create_example(tmp_path: Path, env_file: Path):
# All key lines should be KEY= # All key lines should be KEY=
assert all(re.match(r'^[A-Za-z_][A-Za-z0-9_]*=$', ln) for ln in key_lines) assert all(re.match(r'^[A-Za-z_][A-Za-z0-9_]*=$', ln) for ln in key_lines)
# Spot check a few known keys from resource file # Spot check a few known keys from resource file
for k in ['BACKEND_CONTAINER_PORT=', 'ACCESS_TOKEN_SECRET=', 'SUPABASE_SERVICE_KEY=']: for k in [
'OPENAI_API_KEY=',
'ACCESS_TOKEN_SECRET=',
'SUPABASE_SERVICE_KEY=',
]:
assert k in key_lines assert k in key_lines
def test_create_example_duplicate_key_variant_ignored(tmp_path: Path, env_file: Path): def test_create_example_duplicate_key_variant_ignored(
"""Appending whitespace duplicate of existing key should not duplicate in example.""" tmp_path: Path, env_file: Path,
) -> None:
"""Appending whitespace duplicate of existing key should not duplicate
in example.
"""
with open(env_file, 'a', encoding='utf-8') as f: with open(env_file, 'a', encoding='utf-8') as f:
f.write('BACKEND_CONTAINER_PORT =999 # duplicate variant\n') f.write('BACKEND_CONTAINER_PORT =999 # duplicate variant\n')
run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True)
@ -122,7 +152,9 @@ def test_create_example_duplicate_key_variant_ignored(tmp_path: Path, env_file:
assert key_lines.count('BACKEND_CONTAINER_PORT=') == 1 assert key_lines.count('BACKEND_CONTAINER_PORT=') == 1
def test_gitignore_without_trailing_newline(tmp_path: Path, env_file: Path): def test_gitignore_without_trailing_newline(
tmp_path: Path, env_file: Path,
) -> None:
"""Normalization works when original .gitignore lacks trailing newline.""" """Normalization works when original .gitignore lacks trailing newline."""
g = tmp_path / DEFAULT_GITIGNORE_FILE g = tmp_path / DEFAULT_GITIGNORE_FILE
g.write_text('existing_line') # no newline at EOF g.write_text('existing_line') # no newline at EOF
@ -132,11 +164,20 @@ def test_gitignore_without_trailing_newline(tmp_path: Path, env_file: Path):
assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE]
def test_ensure_env_in_gitignore_normalizes(tmp_path: Path, env_file: Path): def test_ensure_env_in_gitignore_normalizes(
"""Direct API call collapses duplicates and produces canonical tail layout.""" tmp_path: Path, env_file: Path,
) -> None:
"""Direct API call collapses duplicates and produces canonical tail
layout.
"""
g = tmp_path / DEFAULT_GITIGNORE_FILE g = tmp_path / DEFAULT_GITIGNORE_FILE
g.write_text(f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n") g.write_text(
modified = ensure_env_in_gitignore(DEFAULT_ENV_FILE, str(g), GITIGNORE_BANNER) f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n"
f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n",
)
modified = ensure_env_in_gitignore(
DEFAULT_ENV_FILE, str(g), GITIGNORE_BANNER,
)
assert modified is True assert modified is True
lines = g.read_text().splitlines() lines = g.read_text().splitlines()
# final two lines should be banner + env # final two lines should be banner + env
@ -146,37 +187,55 @@ def test_ensure_env_in_gitignore_normalizes(tmp_path: Path, env_file: Path):
assert lines.count(DEFAULT_ENV_FILE) == 1 assert lines.count(DEFAULT_ENV_FILE) == 1
def test_source_env_file_not_modified(tmp_path: Path, env_file: Path): def test_source_env_file_not_modified(
tmp_path: Path, env_file: Path,
) -> None:
"""Hook must not alter original .env (comments and formatting stay).""" """Hook must not alter original .env (comments and formatting stay)."""
original = env_file.read_text() original = env_file.read_text()
run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True)
assert env_file.read_text() == original assert env_file.read_text() == original
def test_large_resource_env_parsing(tmp_path: Path, env_file: Path): def test_large_resource_env_parsing(
"""Generate example from resource env; assert broad key coverage & format.""" tmp_path: Path, env_file: Path,
) -> None:
"""Generate example from resource env; assert broad key coverage &
format.
"""
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True)
assert ret == 1 assert ret == 1
example_lines = (tmp_path / DEFAULT_EXAMPLE_ENV_FILE).read_text().splitlines() example_lines = (
(tmp_path / DEFAULT_EXAMPLE_ENV_FILE).read_text().splitlines()
)
key_lines = [ln for ln in example_lines if ln and not ln.startswith('#')] key_lines = [ln for ln in example_lines if ln and not ln.startswith('#')]
assert len(key_lines) > 20 assert len(key_lines) > 20
assert all(re.match(r'^[A-Za-z_][A-Za-z0-9_]*=$', ln) for ln in key_lines) assert all(re.match(r'^[A-Za-z_][A-Za-z0-9_]*=$', ln) for ln in key_lines)
for k in ['BACKEND_CONTAINER_PORT=', 'SUPABASE_SERVICE_KEY=', 'ACCESS_TOKEN_SECRET=']: for k in [
'BACKEND_CONTAINER_PORT=',
'SUPABASE_SERVICE_KEY=',
'ACCESS_TOKEN_SECRET=',
]:
assert k in key_lines assert k in key_lines
def test_failure_message_content(tmp_path: Path, env_file: Path, capsys): def test_failure_message_content(
tmp_path: Path,
env_file: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Hook stdout message should contain key phrases when blocking commit.""" """Hook stdout message should contain key phrases when blocking commit."""
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True)
assert ret == 1 assert ret == 1
out = capsys.readouterr().out.strip() out = capsys.readouterr().out.strip()
assert "Blocked committing" in out assert 'Blocked committing' in out
assert DEFAULT_GITIGNORE_FILE in out # updated path appears assert DEFAULT_GITIGNORE_FILE in out # updated path appears
assert "Generated .env.example." in out assert 'Generated .env.example.' in out
assert "Remove .env" in out assert 'Remove .env' in out
def test_create_example_when_env_missing(tmp_path: Path, env_file: Path): def test_create_example_when_env_missing(
tmp_path: Path, env_file: Path,
) -> None:
"""--create-example with no .env staged or present should no-op (exit 0). """--create-example with no .env staged or present should no-op (exit 0).
Uses env_file fixture (requirement: all tests use fixture) then removes the Uses env_file fixture (requirement: all tests use fixture) then removes the
@ -188,18 +247,28 @@ def test_create_example_when_env_missing(tmp_path: Path, env_file: Path):
assert not (tmp_path / DEFAULT_EXAMPLE_ENV_FILE).exists() assert not (tmp_path / DEFAULT_EXAMPLE_ENV_FILE).exists()
def test_gitignore_is_directory_error(tmp_path: Path, env_file: Path, capsys): def test_gitignore_is_directory_error(
"""If .gitignore path is a directory, hook should print error and still block.""" tmp_path: Path,
env_file: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""If .gitignore path is a directory, hook should print error and still
block.
"""
gitignore_dir = tmp_path / DEFAULT_GITIGNORE_FILE gitignore_dir = tmp_path / DEFAULT_GITIGNORE_FILE
gitignore_dir.mkdir() gitignore_dir.mkdir()
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE]) ret = run_hook(tmp_path, [DEFAULT_ENV_FILE])
assert ret == 1 # still blocks commit assert ret == 1 # still blocks commit
captured = capsys.readouterr() captured = capsys.readouterr()
assert "ERROR:" in captured.err # error now printed to stderr assert 'ERROR:' in captured.err # error now printed to stderr
def test_env_example_overwrites_existing(tmp_path: Path, env_file: Path): def test_env_example_overwrites_existing(
"""Pre-existing example file with junk should be overwritten with header & keys.""" tmp_path: Path, env_file: Path,
) -> None:
"""Pre-existing example file with junk should be overwritten with header
& keys.
"""
example = tmp_path / DEFAULT_EXAMPLE_ENV_FILE example = tmp_path / DEFAULT_EXAMPLE_ENV_FILE
example.write_text('junk=1\nSHOULD_NOT_REMAIN=2\n') example.write_text('junk=1\nSHOULD_NOT_REMAIN=2\n')
run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True)
@ -210,12 +279,17 @@ def test_env_example_overwrites_existing(tmp_path: Path, env_file: Path):
assert 'SHOULD_NOT_REMAIN=2' not in content assert 'SHOULD_NOT_REMAIN=2' not in content
def test_large_gitignore_normalization_performance(tmp_path: Path, env_file: Path): def test_large_gitignore_normalization_performance(
tmp_path: Path, env_file: Path,
) -> None:
"""Very large .gitignore remains normalized quickly (functional smoke).""" """Very large .gitignore remains normalized quickly (functional smoke)."""
g = tmp_path / DEFAULT_GITIGNORE_FILE g = tmp_path / DEFAULT_GITIGNORE_FILE
# Generate many lines with scattered duplicates of banner/env # Generate many lines with scattered duplicates of banner/env
lines = [f"file_{i}" for i in range(3000)] + [GITIGNORE_BANNER, DEFAULT_ENV_FILE] * 3 lines = (
g.write_text("\n".join(lines) + "\n") [f"file_{i}" for i in range(3000)] +
[GITIGNORE_BANNER, DEFAULT_ENV_FILE] * 3
)
g.write_text('\n'.join(lines) + '\n')
start = time.time() start = time.time()
run_hook(tmp_path, [DEFAULT_ENV_FILE]) run_hook(tmp_path, [DEFAULT_ENV_FILE])
elapsed = time.time() - start elapsed = time.time() - start
@ -223,12 +297,17 @@ def test_large_gitignore_normalization_performance(tmp_path: Path, env_file: Pat
assert result_lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] assert result_lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE]
assert result_lines.count(GITIGNORE_BANNER) == 1 assert result_lines.count(GITIGNORE_BANNER) == 1
assert result_lines.count(DEFAULT_ENV_FILE) == 1 assert result_lines.count(DEFAULT_ENV_FILE) == 1
# Soft performance expectation: should finish fast (< 0.5s on typical dev machine) # Soft performance expectation: should finish fast
# (< 0.5s on typical dev machine)
assert elapsed < 0.5 assert elapsed < 0.5
def test_concurrent_gitignore_writes(tmp_path: Path, env_file: Path): def test_concurrent_gitignore_writes(
"""Concurrent ensure_env_in_gitignore calls result in canonical final state.""" tmp_path: Path, env_file: Path,
) -> None:
"""Concurrent ensure_env_in_gitignore calls result in canonical final
state.
"""
g = tmp_path / DEFAULT_GITIGNORE_FILE g = tmp_path / DEFAULT_GITIGNORE_FILE
# Seed with messy duplicates # Seed with messy duplicates
g.write_text(f"other\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n") g.write_text(f"other\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n")
@ -247,8 +326,12 @@ def test_concurrent_gitignore_writes(tmp_path: Path, env_file: Path):
assert lines.count(DEFAULT_ENV_FILE) == 1 assert lines.count(DEFAULT_ENV_FILE) == 1
def test_mixed_staged_files(tmp_path: Path, env_file: Path): def test_mixed_staged_files(
"""Staging .env with other files still blocks and only normalizes gitignore once.""" tmp_path: Path, env_file: Path,
) -> None:
"""Staging .env with other files still blocks and only normalizes
gitignore once.
"""
other = tmp_path / 'README.md' other = tmp_path / 'README.md'
other.write_text('hi') other.write_text('hi')
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE, 'README.md']) ret = run_hook(tmp_path, [DEFAULT_ENV_FILE, 'README.md'])
@ -257,18 +340,29 @@ def test_mixed_staged_files(tmp_path: Path, env_file: Path):
assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE]
def test_already_ignored_env_with_variations(tmp_path: Path, env_file: Path): def test_already_ignored_env_with_variations(
"""Pre-existing ignore lines with spacing normalize to single canonical pair.""" tmp_path: Path, env_file: Path,
) -> None:
"""Pre-existing ignore lines with spacing normalize to single
canonical pair.
"""
g = tmp_path / DEFAULT_GITIGNORE_FILE g = tmp_path / DEFAULT_GITIGNORE_FILE
g.write_text(f" {DEFAULT_ENV_FILE} \n{GITIGNORE_BANNER}\n {DEFAULT_ENV_FILE}\n") g.write_text(
f" {DEFAULT_ENV_FILE} \n{GITIGNORE_BANNER}\n"
f" {DEFAULT_ENV_FILE}\n",
)
run_hook(tmp_path, [DEFAULT_ENV_FILE]) run_hook(tmp_path, [DEFAULT_ENV_FILE])
lines = g.read_text().splitlines() lines = g.read_text().splitlines()
assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE]
assert lines.count(DEFAULT_ENV_FILE) == 1 assert lines.count(DEFAULT_ENV_FILE) == 1
def test_subdirectory_invocation(tmp_path: Path, env_file: Path): def test_subdirectory_invocation(
"""Running from a subdirectory now writes .gitignore relative to CWD (simplified behavior).""" tmp_path: Path, env_file: Path,
) -> None:
"""Running from a subdirectory now writes .gitignore relative to CWD
(simplified behavior).
"""
sub = tmp_path / 'subdir' sub = tmp_path / 'subdir'
sub.mkdir() sub.mkdir()
# simulate repository root marker # simulate repository root marker
@ -277,7 +371,9 @@ def test_subdirectory_invocation(tmp_path: Path, env_file: Path):
cwd = os.getcwd() cwd = os.getcwd()
os.chdir(sub) os.chdir(sub)
try: try:
ret = main(['../' + DEFAULT_ENV_FILE]) # staged path relative to subdir ret = main(
['../' + DEFAULT_ENV_FILE],
) # staged path relative to subdir
gi = (sub / DEFAULT_GITIGNORE_FILE).read_text().splitlines() gi = (sub / DEFAULT_GITIGNORE_FILE).read_text().splitlines()
finally: finally:
os.chdir(cwd) os.chdir(cwd)
@ -285,31 +381,49 @@ def test_subdirectory_invocation(tmp_path: Path, env_file: Path):
assert gi[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] assert gi[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE]
def test_atomic_write_failure_gitignore(monkeypatch, tmp_path: Path, env_file: Path, capsys): def test_atomic_write_failure_gitignore(
"""Simulate os.replace failure during gitignore write to exercise error path.""" monkeypatch: pytest.MonkeyPatch,
def boom(*a, **k): tmp_path: Path,
env_file: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Simulate os.replace failure during gitignore write to exercise error
path.
"""
def boom(*_a: object, **_k: object) -> None:
raise OSError('replace-fail') raise OSError('replace-fail')
monkeypatch.setattr('pre_commit_hooks.catch_dotenv.os.replace', boom) monkeypatch.setattr('pre_commit_hooks.catch_dotenv.os.replace', boom)
modified = ensure_env_in_gitignore(DEFAULT_ENV_FILE, str(tmp_path / DEFAULT_GITIGNORE_FILE), GITIGNORE_BANNER) modified = ensure_env_in_gitignore(
DEFAULT_ENV_FILE,
str(tmp_path / DEFAULT_GITIGNORE_FILE),
GITIGNORE_BANNER,
)
assert modified is False assert modified is False
captured = capsys.readouterr() captured = capsys.readouterr()
assert 'ERROR: unable to write' in captured.err assert 'ERROR: unable to write' in captured.err
def test_atomic_write_failure_example(monkeypatch, tmp_path: Path, env_file: Path, capsys): def test_atomic_write_failure_example(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
env_file: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Simulate os.replace failure when writing example env file.""" """Simulate os.replace failure when writing example env file."""
def boom(*a, **k): def boom(*_a: object, **_k: object) -> None:
raise OSError('replace-fail') raise OSError('replace-fail')
monkeypatch.setattr('pre_commit_hooks.catch_dotenv.os.replace', boom) monkeypatch.setattr('pre_commit_hooks.catch_dotenv.os.replace', boom)
ok = False ok = False
# create_example_env requires source .env to exist; env_file fixture provides it in tmp_path root # create_example_env requires source .env to exist; env_file fixture
# provides it in tmp_path root
cwd = os.getcwd() cwd = os.getcwd()
os.chdir(tmp_path) os.chdir(tmp_path)
try: try:
ok = main([DEFAULT_ENV_FILE, '--create-example']) == 1 ok = main([DEFAULT_ENV_FILE, '--create-example']) == 1
finally: finally:
os.chdir(cwd) os.chdir(cwd)
# hook still blocks; but example creation failed -> message should not claim Example file generated # hook still blocks; but example creation failed -> message should
# not claim Example file generated
assert ok is True assert ok is True
captured = capsys.readouterr() captured = capsys.readouterr()
out = captured.out out = captured.out