1919
2020
2121def _atomic_write (path : str , data : str ) -> None :
22- """Atomic-ish text write: write to same-dir temp then os.replace."""
22+ """Atomically (best-effort) write text.
23+
24+ Writes to a same-directory temporary file then replaces the target with
25+ os.replace(). This is a slight divergence from most existing hooks which
26+ write directly, but here we intentionally reduce the (small) risk of
27+ partially-written files because the hook may be invoked rapidly / in
28+ parallel (tests exercise concurrent normalization). Keeping this helper
29+ local avoids adding any dependency.
30+ """
2331 fd , tmp_path = tempfile .mkstemp (dir = os .path .dirname (path ) or "." )
2432 try :
2533 with os .fdopen (fd , "w" , encoding = "utf-8" , newline = "" ) as tmp_f :
2634 tmp_f .write (data )
2735 os .replace (tmp_path , path )
2836 finally : # Clean up if replace failed
29- if os .path .exists (tmp_path ): # pragma: no cover (rare failure case)
37+ if os .path .exists (tmp_path ): # (rare failure case)
3038 try :
3139 os .remove (tmp_path )
32- except OSError : # pragma: no cover
40+ except OSError :
3341 pass
3442
3543
36- def ensure_env_in_gitignore ( env_file : str , gitignore_file : str , banner : str ) -> bool :
37- """Normalize .gitignore tail (banner + env) collapsing duplicates. Returns True if modified ."""
44+ def _read_gitignore ( gitignore_file : str ) -> tuple [ str , list [ str ]] :
45+ """Read and parse .gitignore file content ."""
3846 try :
3947 if os .path .exists (gitignore_file ):
4048 with open (gitignore_file , "r" , encoding = "utf-8" ) as f :
@@ -45,14 +53,17 @@ def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) ->
4553 lines = []
4654 except OSError as exc :
4755 print (f"ERROR: unable to read { gitignore_file } : { exc } " , file = sys .stderr )
48- return False
49- original_content_str = original_text if lines else "" # post-read snapshot
56+ raise
57+ return original_text if lines else "" , lines
5058
59+
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."""
5162 # Trim trailing blank lines
5263 while lines and not lines [- 1 ].strip ():
5364 lines .pop ()
5465
55- # Remove existing occurrences (exact match after strip)
66+ # Remove existing occurrences
5667 filtered : list [str ] = [ln for ln in lines if ln .strip () not in {env_file , banner }]
5768
5869 if filtered and filtered [- 1 ].strip ():
@@ -62,14 +73,35 @@ def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) ->
6273
6374 filtered .append (banner )
6475 filtered .append (env_file )
76+ return filtered
77+
78+
79+ def ensure_env_in_gitignore (env_file : str , gitignore_file : str , banner : str ) -> bool :
80+ """Ensure canonical banner + env tail in .gitignore.
81+
82+ Returns True only when the file content was changed. Returns False both
83+ when unchanged and on IO errors (we intentionally conflate for the simple
84+ hook contract; errors are still surfaced via stderr output).
85+ """
86+ try :
87+ original_content_str , lines = _read_gitignore (gitignore_file )
88+ except OSError :
89+ return False
6590
91+ filtered = _normalize_gitignore_lines (lines , env_file , banner )
6692 new_content = "\n " .join (filtered ) + "\n "
67- if new_content == (original_content_str if original_content_str .endswith ("\n " ) else original_content_str + ("" if not original_content_str else "\n " )):
93+
94+ # Normalize original content to a single trailing newline for comparison
95+ normalized_original = original_content_str
96+ if normalized_original and not normalized_original .endswith ("\n " ):
97+ normalized_original += "\n "
98+ if new_content == normalized_original :
6899 return False
100+
69101 try :
70102 _atomic_write (gitignore_file , new_content )
71103 return True
72- except OSError as exc : # pragma: no cover
104+ except OSError as exc :
73105 print (f"ERROR: unable to write { gitignore_file } : { exc } " , file = sys .stderr )
74106 return False
75107
@@ -107,7 +139,7 @@ def create_example_env(src_env: str, example_file: str) -> bool:
107139 _atomic_write (example_file , "\n " .join (header + body ) + "\n " )
108140 return True
109141 except OSError as exc : # pragma: no cover
110- print (f"ERROR: unable to write '{ example_file } ': { exc } " )
142+ print (f"ERROR: unable to write '{ example_file } ': { exc } " , file = sys . stderr )
111143 return False
112144
113145
@@ -116,26 +148,25 @@ def _has_env(filenames: Iterable[str], env_file: str) -> bool:
116148 return any (os .path .basename (name ) == env_file for name in filenames )
117149
118150
119-
120-
121151def _print_failure (env_file : str , gitignore_file : str , example_created : bool , gitignore_modified : bool ) -> None :
122- parts : list [str ] = [f"Blocked committing { env_file } ." ]
152+ # Match typical hook output style: one short line per action.
153+ print (f"Blocked committing { env_file } ." )
123154 if gitignore_modified :
124- parts . append (f"Updated { gitignore_file } ." )
155+ print (f"Updated { gitignore_file } ." )
125156 if example_created :
126- parts .append ("Generated .env.example." )
127- parts .append (f"Remove { env_file } from the commit and retry." )
128- print (" " .join (parts ))
157+ print ("Generated .env.example." )
158+ print (f"Remove { env_file } from the commit and retry." )
129159
130160
131161def main (argv : Sequence [str ] | None = None ) -> int :
132162 """Hook entry-point."""
133- parser = argparse .ArgumentParser (description = "Block committing environment files ( .env) ." )
163+ parser = argparse .ArgumentParser (description = "Blocks committing .env files ." )
134164 parser .add_argument ('filenames' , nargs = '*' , help = 'Staged filenames (supplied by pre-commit).' )
135165 parser .add_argument ('--create-example' , action = 'store_true' , help = 'Generate example env file (.env.example).' )
136166 args = parser .parse_args (argv )
137167 env_file = DEFAULT_ENV_FILE
138- # Use current working directory as repository root (simplified; no ascent)
168+ # Use current working directory as repository root (pre-commit executes
169+ # hooks from the repo root).
139170 repo_root = os .getcwd ()
140171 gitignore_file = os .path .join (repo_root , DEFAULT_GITIGNORE_FILE )
141172 example_file = os .path .join (repo_root , DEFAULT_EXAMPLE_ENV_FILE )
0 commit comments