44import argparse
55import os
66import re
7+ import sys
78import tempfile
89from collections .abc import Sequence
910from typing import Iterable
1011
11- # --- Defaults / Constants ---
12- DEFAULT_ENV_FILE = ".env" # Canonical env file name
12+ # Defaults / constants
13+ DEFAULT_ENV_FILE = ".env"
1314DEFAULT_GITIGNORE_FILE = ".gitignore"
1415DEFAULT_EXAMPLE_ENV_FILE = ".env.example"
1516GITIGNORE_BANNER = "# Added by pre-commit hook to prevent committing secrets"
1819
1920
2021def _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
3636def 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
8277def 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
144121def _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
154131def 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 )
0 commit comments