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 sys
import tempfile
from collections.abc import Iterable
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"
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*=")
_KEY_REGEX = re.compile(r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=')
def _atomic_write(path: str, data: str) -> None:
@ -28,16 +28,16 @@ def _atomic_write(path: str, data: str) -> None:
parallel (tests exercise concurrent normalization). Keeping this helper
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:
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)
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:
except OSError:
pass
@ -45,38 +45,51 @@ 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, "r", encoding="utf-8") as f:
with open(gitignore_file, encoding='utf-8') as f:
original_text = f.read()
lines = original_text.splitlines()
else:
original_text = ""
original_text = ''
lines = []
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
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]:
"""Normalize .gitignore lines by removing duplicates and adding canonical tail."""
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}]
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
filtered.append('') # ensure single blank before banner
elif not filtered: # empty file -> still separate section visually
filtered.append("")
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:
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
@ -89,27 +102,30 @@ def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) ->
return False
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
normalized_original = original_content_str
if normalized_original and not normalized_original.endswith("\n"):
normalized_original += "\n"
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)
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, "r", encoding="utf-8") as f_env:
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)
@ -136,33 +152,51 @@ def create_example_env(src_env: str, example_file: str) -> bool:
]
body = [f"{k}=" for k in keys]
try:
_atomic_write(example_file, "\n".join(header + body) + "\n")
_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)
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 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)
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.
print(f"Blocked committing {env_file}.")
if gitignore_modified:
print(f"Updated {gitignore_file}.")
if example_created:
print("Generated .env.example.")
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).')
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
@ -175,14 +209,26 @@ def main(argv: Sequence[str] | None = None) -> int:
if not _has_env(args.filenames, env_file):
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
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)
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

View file

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

View file

@ -1,18 +1,24 @@
from __future__ import annotations
import os
import re
import shutil
import threading
import time
from pathlib import Path
import shutil
import re
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
# file generation parsing edge cases, idempotency, and preservation of existing
# content. Each test isolates a single behavioral contract.
# Tests cover hook behavior: detection gating, .gitignore normalization,
# example file generation parsing edge cases, idempotency, and preservation of
# existing content. Each test isolates a single behavioral contract.
@pytest.fixture()
@ -25,13 +31,18 @@ def env_file(tmp_path: Path) -> Path:
# __file__ => <repo_root>/tests/catch_dotenv_test.py
# parents[0] = <repo_root>/tests, parents[1] = <repo_root>
# 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
shutil.copyfile(resource_env, 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()
os.chdir(tmp_path)
try:
@ -43,13 +54,15 @@ def run_hook(tmp_path: Path, staged: list[str], create_example: bool = False) ->
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."""
(tmp_path / 'foo.txt').write_text('x')
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."""
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE])
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
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."""
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."""
g = tmp_path / DEFAULT_GITIGNORE_FILE
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
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."""
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])
lines = g.read_text().splitlines()
# 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]
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."""
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])
lines = g.read_text().splitlines()
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]
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=."""
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True)
assert ret == 1
@ -108,12 +130,20 @@ def test_create_example(tmp_path: Path, env_file: Path):
# All key lines should be KEY=
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
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
def test_create_example_duplicate_key_variant_ignored(tmp_path: Path, env_file: Path):
"""Appending whitespace duplicate of existing key should not duplicate in example."""
def test_create_example_duplicate_key_variant_ignored(
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:
f.write('BACKEND_CONTAINER_PORT =999 # duplicate variant\n')
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
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."""
g = tmp_path / DEFAULT_GITIGNORE_FILE
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]
def test_ensure_env_in_gitignore_normalizes(tmp_path: Path, env_file: Path):
"""Direct API call collapses duplicates and produces canonical tail layout."""
def test_ensure_env_in_gitignore_normalizes(
tmp_path: Path, env_file: Path,
) -> None:
"""Direct API call collapses duplicates and produces canonical tail
layout.
"""
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")
modified = ensure_env_in_gitignore(DEFAULT_ENV_FILE, str(g), GITIGNORE_BANNER)
g.write_text(
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
lines = g.read_text().splitlines()
# 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
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)."""
original = env_file.read_text()
run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True)
assert env_file.read_text() == original
def test_large_resource_env_parsing(tmp_path: Path, env_file: Path):
"""Generate example from resource env; assert broad key coverage & format."""
def test_large_resource_env_parsing(
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)
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('#')]
assert len(key_lines) > 20
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
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."""
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True)
assert ret == 1
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 "Generated .env.example." in out
assert "Remove .env" in out
assert 'Generated .env.example.' 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).
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()
def test_gitignore_is_directory_error(tmp_path: Path, env_file: Path, capsys):
"""If .gitignore path is a directory, hook should print error and still block."""
def test_gitignore_is_directory_error(
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.mkdir()
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE])
assert ret == 1 # still blocks commit
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):
"""Pre-existing example file with junk should be overwritten with header & keys."""
def test_env_example_overwrites_existing(
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.write_text('junk=1\nSHOULD_NOT_REMAIN=2\n')
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
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)."""
g = tmp_path / DEFAULT_GITIGNORE_FILE
# Generate many lines with scattered duplicates of banner/env
lines = [f"file_{i}" for i in range(3000)] + [GITIGNORE_BANNER, DEFAULT_ENV_FILE] * 3
g.write_text("\n".join(lines) + "\n")
lines = (
[f"file_{i}" for i in range(3000)] +
[GITIGNORE_BANNER, DEFAULT_ENV_FILE] * 3
)
g.write_text('\n'.join(lines) + '\n')
start = time.time()
run_hook(tmp_path, [DEFAULT_ENV_FILE])
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.count(GITIGNORE_BANNER) == 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
def test_concurrent_gitignore_writes(tmp_path: Path, env_file: Path):
"""Concurrent ensure_env_in_gitignore calls result in canonical final state."""
def test_concurrent_gitignore_writes(
tmp_path: Path, env_file: Path,
) -> None:
"""Concurrent ensure_env_in_gitignore calls result in canonical final
state.
"""
g = tmp_path / DEFAULT_GITIGNORE_FILE
# Seed with messy duplicates
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
def test_mixed_staged_files(tmp_path: Path, env_file: Path):
"""Staging .env with other files still blocks and only normalizes gitignore once."""
def test_mixed_staged_files(
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.write_text('hi')
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]
def test_already_ignored_env_with_variations(tmp_path: Path, env_file: Path):
"""Pre-existing ignore lines with spacing normalize to single canonical pair."""
def test_already_ignored_env_with_variations(
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.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])
lines = g.read_text().splitlines()
assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE]
assert lines.count(DEFAULT_ENV_FILE) == 1
def test_subdirectory_invocation(tmp_path: Path, env_file: Path):
"""Running from a subdirectory now writes .gitignore relative to CWD (simplified behavior)."""
def test_subdirectory_invocation(
tmp_path: Path, env_file: Path,
) -> None:
"""Running from a subdirectory now writes .gitignore relative to CWD
(simplified behavior).
"""
sub = tmp_path / 'subdir'
sub.mkdir()
# simulate repository root marker
@ -277,7 +371,9 @@ def test_subdirectory_invocation(tmp_path: Path, env_file: Path):
cwd = os.getcwd()
os.chdir(sub)
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()
finally:
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]
def test_atomic_write_failure_gitignore(monkeypatch, tmp_path: Path, env_file: Path, capsys):
"""Simulate os.replace failure during gitignore write to exercise error path."""
def boom(*a, **k):
def test_atomic_write_failure_gitignore(
monkeypatch: pytest.MonkeyPatch,
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')
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
captured = capsys.readouterr()
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."""
def boom(*a, **k):
def boom(*_a: object, **_k: object) -> None:
raise OSError('replace-fail')
monkeypatch.setattr('pre_commit_hooks.catch_dotenv.os.replace', boom)
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()
os.chdir(tmp_path)
try:
ok = main([DEFAULT_ENV_FILE, '--create-example']) == 1
finally:
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
captured = capsys.readouterr()
out = captured.out