66import re
77import sys
88import tempfile
9+ from collections .abc import Iterable
910from collections .abc import Sequence
10- from typing import Iterable
1111
1212# Defaults / constants
13- DEFAULT_ENV_FILE = " .env"
14- DEFAULT_GITIGNORE_FILE = " .gitignore"
15- DEFAULT_EXAMPLE_ENV_FILE = " .env.example"
16- GITIGNORE_BANNER = " # Added by pre-commit hook to prevent committing secrets"
13+ DEFAULT_ENV_FILE = ' .env'
14+ DEFAULT_GITIGNORE_FILE = ' .gitignore'
15+ DEFAULT_EXAMPLE_ENV_FILE = ' .env.example'
16+ GITIGNORE_BANNER = ' # Added by pre-commit hook to prevent committing secrets'
1717
18- _KEY_REGEX = re .compile (r" ^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=" )
18+ _KEY_REGEX = re .compile (r' ^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=' )
1919
2020
2121def _atomic_write (path : str , data : str ) -> None :
@@ -28,55 +28,68 @@ def _atomic_write(path: str, data: str) -> None:
2828 parallel (tests exercise concurrent normalization). Keeping this helper
2929 local avoids adding any dependency.
3030 """
31- fd , tmp_path = tempfile .mkstemp (dir = os .path .dirname (path ) or "." )
31+ fd , tmp_path = tempfile .mkstemp (dir = os .path .dirname (path ) or '.' )
3232 try :
33- with os .fdopen (fd , "w" , encoding = " utf-8" , newline = "" ) as tmp_f :
33+ with os .fdopen (fd , 'w' , encoding = ' utf-8' , newline = '' ) as tmp_f :
3434 tmp_f .write (data )
3535 os .replace (tmp_path , path )
3636 finally : # Clean up if replace failed
3737 if os .path .exists (tmp_path ): # (rare failure case)
3838 try :
3939 os .remove (tmp_path )
40- except OSError :
40+ except OSError :
4141 pass
4242
4343
4444def _read_gitignore (gitignore_file : str ) -> tuple [str , list [str ]]:
4545 """Read and parse .gitignore file content."""
4646 try :
4747 if os .path .exists (gitignore_file ):
48- with open (gitignore_file , "r" , encoding = " utf-8" ) as f :
48+ with open (gitignore_file , encoding = ' utf-8' ) as f :
4949 original_text = f .read ()
5050 lines = original_text .splitlines ()
5151 else :
52- original_text = ""
52+ original_text = ''
5353 lines = []
5454 except OSError as exc :
55- print (f"ERROR: unable to read { gitignore_file } : { exc } " , file = sys .stderr )
55+ print (
56+ f"ERROR: unable to read { gitignore_file } : { exc } " ,
57+ file = sys .stderr ,
58+ )
5659 raise
57- return original_text if lines else "" , lines
60+ return original_text if lines else '' , lines
5861
5962
60- def _normalize_gitignore_lines (lines : list [str ], env_file : str , banner : str ) -> list [str ]:
61- """Normalize .gitignore lines by removing duplicates and adding canonical tail."""
63+ def _normalize_gitignore_lines (
64+ lines : list [str ],
65+ env_file : str ,
66+ banner : str ,
67+ ) -> list [str ]:
68+ """Normalize .gitignore lines by removing duplicates and canonical tail."""
6269 # Trim trailing blank lines
6370 while lines and not lines [- 1 ].strip ():
6471 lines .pop ()
6572
6673 # Remove existing occurrences
67- filtered : list [str ] = [ln for ln in lines if ln .strip () not in {env_file , banner }]
74+ filtered : list [str ] = [
75+ ln for ln in lines if ln .strip () not in {env_file , banner }
76+ ]
6877
6978 if filtered and filtered [- 1 ].strip ():
70- filtered .append ("" ) # ensure single blank before banner
79+ filtered .append ('' ) # ensure single blank before banner
7180 elif not filtered : # empty file -> still separate section visually
72- filtered .append ("" )
81+ filtered .append ('' )
7382
7483 filtered .append (banner )
7584 filtered .append (env_file )
7685 return filtered
7786
7887
79- def ensure_env_in_gitignore (env_file : str , gitignore_file : str , banner : str ) -> bool :
88+ def ensure_env_in_gitignore (
89+ env_file : str ,
90+ gitignore_file : str ,
91+ banner : str ,
92+ ) -> bool :
8093 """Ensure canonical banner + env tail in .gitignore.
8194
8295 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) ->
89102 return False
90103
91104 filtered = _normalize_gitignore_lines (lines , env_file , banner )
92- new_content = " \n " .join (filtered ) + " \n "
105+ new_content = ' \n ' .join (filtered ) + ' \n '
93106
94107 # Normalize original content to a single trailing newline for comparison
95108 normalized_original = original_content_str
96- if normalized_original and not normalized_original .endswith (" \n " ):
97- normalized_original += " \n "
109+ if normalized_original and not normalized_original .endswith (' \n ' ):
110+ normalized_original += ' \n '
98111 if new_content == normalized_original :
99112 return False
100113
101114 try :
102115 _atomic_write (gitignore_file , new_content )
103116 return True
104- except OSError as exc :
105- print (f"ERROR: unable to write { gitignore_file } : { exc } " , file = sys .stderr )
117+ except OSError as exc :
118+ print (
119+ f"ERROR: unable to write { gitignore_file } : { exc } " ,
120+ file = sys .stderr ,
121+ )
106122 return False
107123
108124
109125def create_example_env (src_env : str , example_file : str ) -> bool :
110126 """Generate .env.example with unique KEY= lines (no values)."""
111127 try :
112- with open (src_env , "r" , encoding = " utf-8" ) as f_env :
128+ with open (src_env , encoding = ' utf-8' ) as f_env :
113129 lines = f_env .readlines ()
114130 except OSError as exc :
115131 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:
136152 ]
137153 body = [f"{ k } =" for k in keys ]
138154 try :
139- _atomic_write (example_file , " \n " .join (header + body ) + " \n " )
155+ _atomic_write (example_file , ' \n ' .join (header + body ) + ' \n ' )
140156 return True
141157 except OSError as exc : # pragma: no cover
142- print (f"ERROR: unable to write '{ example_file } ': { exc } " , file = sys .stderr )
158+ print (
159+ f"ERROR: unable to write '{ example_file } ': { exc } " ,
160+ file = sys .stderr ,
161+ )
143162 return False
144163
145164
146165def _has_env (filenames : Iterable [str ], env_file : str ) -> bool :
147- """Return True if any staged path refers to a target env file by basename."""
166+ """Return True if any staged path refers to target env file by basename."""
148167 return any (os .path .basename (name ) == env_file for name in filenames )
149168
150169
151- def _print_failure (env_file : str , gitignore_file : str , example_created : bool , gitignore_modified : bool ) -> None :
170+ def _print_failure (
171+ env_file : str ,
172+ gitignore_file : str ,
173+ example_created : bool ,
174+ gitignore_modified : bool ,
175+ ) -> None :
152176 # Match typical hook output style: one short line per action.
153177 print (f"Blocked committing { env_file } ." )
154178 if gitignore_modified :
155179 print (f"Updated { gitignore_file } ." )
156180 if example_created :
157- print (" Generated .env.example." )
181+ print (' Generated .env.example.' )
158182 print (f"Remove { env_file } from the commit and retry." )
159183
160184
161185def main (argv : Sequence [str ] | None = None ) -> int :
162186 """Hook entry-point."""
163- parser = argparse .ArgumentParser (description = "Blocks committing .env files." )
164- parser .add_argument ('filenames' , nargs = '*' , help = 'Staged filenames (supplied by pre-commit).' )
165- parser .add_argument ('--create-example' , action = 'store_true' , help = 'Generate example env file (.env.example).' )
187+ parser = argparse .ArgumentParser (
188+ description = 'Blocks committing .env files.' ,
189+ )
190+ parser .add_argument (
191+ 'filenames' ,
192+ nargs = '*' ,
193+ help = 'Staged filenames (supplied by pre-commit).' ,
194+ )
195+ parser .add_argument (
196+ '--create-example' ,
197+ action = 'store_true' ,
198+ help = 'Generate example env file (.env.example).' ,
199+ )
166200 args = parser .parse_args (argv )
167201 env_file = DEFAULT_ENV_FILE
168202 # Use current working directory as repository root (pre-commit executes
@@ -175,14 +209,26 @@ def main(argv: Sequence[str] | None = None) -> int:
175209 if not _has_env (args .filenames , env_file ):
176210 return 0
177211
178- gitignore_modified = ensure_env_in_gitignore (env_file , gitignore_file , GITIGNORE_BANNER )
212+ gitignore_modified = ensure_env_in_gitignore (
213+ env_file ,
214+ gitignore_file ,
215+ GITIGNORE_BANNER ,
216+ )
179217 example_created = False
180218 if args .create_example :
181219 # Source env is always looked up relative to repo root
182220 if os .path .exists (env_abspath ):
183- example_created = create_example_env (env_abspath , example_file )
184-
185- _print_failure (env_file , gitignore_file , example_created , gitignore_modified )
221+ example_created = create_example_env (
222+ env_abspath ,
223+ example_file ,
224+ )
225+
226+ _print_failure (
227+ env_file ,
228+ gitignore_file ,
229+ example_created ,
230+ gitignore_modified ,
231+ )
186232 return 1 # Block commit
187233
188234
0 commit comments