Skip to content

Commit 33746f5

Browse files
committed
Refactor catch_dotenv hook and tests for improved error handling and clarity
1 parent 25a3d2e commit 33746f5

File tree

2 files changed

+31
-54
lines changed

2 files changed

+31
-54
lines changed

pre_commit_hooks/catch_dotenv.py

Lines changed: 21 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
import argparse
55
import os
66
import re
7+
import sys
78
import tempfile
89
from collections.abc import Sequence
910
from typing import Iterable
1011

11-
# --- Defaults / Constants ---
12-
DEFAULT_ENV_FILE = ".env" # Canonical env file name
12+
# Defaults / constants
13+
DEFAULT_ENV_FILE = ".env"
1314
DEFAULT_GITIGNORE_FILE = ".gitignore"
1415
DEFAULT_EXAMPLE_ENV_FILE = ".env.example"
1516
GITIGNORE_BANNER = "# Added by pre-commit hook to prevent committing secrets"
@@ -18,8 +19,7 @@
1819

1920

2021
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.
22+
"""Atomic-ish text write: write to same-dir temp then os.replace."""
2323
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".")
2424
try:
2525
with os.fdopen(fd, "w", encoding="utf-8", newline="") as tmp_f:
@@ -34,24 +34,19 @@ def _atomic_write(path: str, data: str) -> None:
3434

3535

3636
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-
"""
37+
"""Normalize .gitignore tail (banner + env) collapsing duplicates. Returns True if modified."""
4438
try:
4539
if os.path.exists(gitignore_file):
4640
with open(gitignore_file, "r", encoding="utf-8") as f:
47-
lines = f.read().splitlines()
41+
original_text = f.read()
42+
lines = original_text.splitlines()
4843
else:
44+
original_text = ""
4945
lines = []
5046
except OSError as exc:
51-
print(f"ERROR: unable to read '{gitignore_file}': {exc}")
47+
print(f"ERROR: unable to read {gitignore_file}: {exc}", file=sys.stderr)
5248
return False
53-
54-
original = list(lines)
49+
original_content_str = original_text if lines else "" # post-read snapshot
5550

5651
# Trim trailing blank lines
5752
while lines and not lines[-1].strip():
@@ -69,27 +64,23 @@ def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) ->
6964
filtered.append(env_file)
7065

7166
new_content = "\n".join(filtered) + "\n"
72-
if original == filtered:
67+
if new_content == (original_content_str if original_content_str.endswith("\n") else original_content_str + ("" if not original_content_str else "\n")):
7368
return False
7469
try:
7570
_atomic_write(gitignore_file, new_content)
7671
return True
7772
except OSError as exc: # pragma: no cover
78-
print(f"ERROR: unable to write '{gitignore_file}': {exc}")
73+
print(f"ERROR: unable to write {gitignore_file}: {exc}", file=sys.stderr)
7974
return False
8075

8176

8277
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-
"""
78+
"""Generate .env.example with unique KEY= lines (no values)."""
8879
try:
8980
with open(src_env, "r", encoding="utf-8") as f_env:
9081
lines = f_env.readlines()
9182
except OSError as exc:
92-
print(f"ERROR: unable to read '{src_env}': {exc}")
83+
print(f"ERROR: unable to read {src_env}: {exc}", file=sys.stderr)
9384
return False
9485

9586
seen: set[str] = set()
@@ -125,41 +116,27 @@ def _has_env(filenames: Iterable[str], env_file: str) -> bool:
125116
return any(os.path.basename(name) == env_file for name in filenames)
126117

127118

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)
142119

143120

144121
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}'."]
122+
parts: list[str] = [f"Blocked committing {env_file}."]
146123
if gitignore_modified:
147-
parts.append(f"Added to '{gitignore_file}'.")
124+
parts.append(f"Updated {gitignore_file}.")
148125
if example_created:
149-
parts.append("Example file generated.")
150-
parts.append(f"Remove '{env_file}' from the commit and commit again.")
126+
parts.append("Generated .env.example.")
127+
parts.append(f"Remove {env_file} from the commit and retry.")
151128
print(" ".join(parts))
152129

153130

154131
def main(argv: Sequence[str] | None = None) -> int:
155-
"""Main function for the pre-commit hook."""
132+
"""Hook entry-point."""
156133
parser = argparse.ArgumentParser(description="Block committing environment files (.env).")
157134
parser.add_argument('filenames', nargs='*', help='Staged filenames (supplied by pre-commit).')
158135
parser.add_argument('--create-example', action='store_true', help='Generate example env file (.env.example).')
159136
args = parser.parse_args(argv)
160137
env_file = DEFAULT_ENV_FILE
161-
# Resolve repository root (directory containing .git) so writes happen there
162-
repo_root = _find_repo_root('.')
138+
# Use current working directory as repository root (simplified; no ascent)
139+
repo_root = os.getcwd()
163140
gitignore_file = os.path.join(repo_root, DEFAULT_GITIGNORE_FILE)
164141
example_file = os.path.join(repo_root, DEFAULT_EXAMPLE_ENV_FILE)
165142
env_abspath = os.path.join(repo_root, env_file)

tests/catch_dotenv_test.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,9 @@ def test_failure_message_content(tmp_path: Path, env_file: Path, capsys):
171171
assert ret == 1
172172
out = capsys.readouterr().out.strip()
173173
assert "Blocked committing" in out
174-
assert DEFAULT_GITIGNORE_FILE in out
175-
assert "Example file generated" in out
176-
assert "Remove '.env'" in out
174+
assert DEFAULT_GITIGNORE_FILE in out # updated path appears
175+
assert "Generated .env.example." in out
176+
assert "Remove .env" in out
177177

178178

179179
def test_create_example_when_env_missing(tmp_path: Path, env_file: Path):
@@ -194,8 +194,8 @@ def test_gitignore_is_directory_error(tmp_path: Path, env_file: Path, capsys):
194194
gitignore_dir.mkdir()
195195
ret = run_hook(tmp_path, [DEFAULT_ENV_FILE])
196196
assert ret == 1 # still blocks commit
197-
out = capsys.readouterr().out
198-
assert "ERROR:" in out # read failure logged
197+
captured = capsys.readouterr()
198+
assert "ERROR:" in captured.err # error now printed to stderr
199199

200200

201201
def test_env_example_overwrites_existing(tmp_path: Path, env_file: Path):
@@ -268,7 +268,7 @@ def test_already_ignored_env_with_variations(tmp_path: Path, env_file: Path):
268268

269269

270270
def test_subdirectory_invocation(tmp_path: Path, env_file: Path):
271-
"""Running from a subdirectory still writes gitignore/example at repo root."""
271+
"""Running from a subdirectory now writes .gitignore relative to CWD (simplified behavior)."""
272272
sub = tmp_path / 'subdir'
273273
sub.mkdir()
274274
# simulate repository root marker
@@ -277,8 +277,8 @@ def test_subdirectory_invocation(tmp_path: Path, env_file: Path):
277277
cwd = os.getcwd()
278278
os.chdir(sub)
279279
try:
280-
ret = main(['../' + DEFAULT_ENV_FILE])
281-
gi = (tmp_path / DEFAULT_GITIGNORE_FILE).read_text().splitlines()
280+
ret = main(['../' + DEFAULT_ENV_FILE]) # staged path relative to subdir
281+
gi = (sub / DEFAULT_GITIGNORE_FILE).read_text().splitlines()
282282
finally:
283283
os.chdir(cwd)
284284
assert ret == 1
@@ -292,8 +292,8 @@ def boom(*a, **k):
292292
monkeypatch.setattr('pre_commit_hooks.catch_dotenv.os.replace', boom)
293293
modified = ensure_env_in_gitignore(DEFAULT_ENV_FILE, str(tmp_path / DEFAULT_GITIGNORE_FILE), GITIGNORE_BANNER)
294294
assert modified is False
295-
out = capsys.readouterr().out
296-
assert 'ERROR: unable to write' in out
295+
captured = capsys.readouterr()
296+
assert 'ERROR: unable to write' in captured.err
297297

298298

299299
def test_atomic_write_failure_example(monkeypatch, tmp_path: Path, env_file: Path, capsys):

0 commit comments

Comments
 (0)