@@ -163,6 +163,49 @@ def placeholder_for(rule_id: str) -> str:
163163 return f"<REDACTED_{ re .sub (r'[^A-Z0-9]+' , '_' , rule_id .upper ())} >"
164164
165165
166+ def _git_dir () -> Path :
167+ output = subprocess .run (
168+ ["git" , "rev-parse" , "--git-dir" ],
169+ cwd = REPO_ROOT ,
170+ capture_output = True ,
171+ text = True ,
172+ check = True ,
173+ ).stdout .strip ()
174+ return (REPO_ROOT / output ).resolve ()
175+
176+
177+ def record_touched_files (relative_files ) -> None :
178+ """Records exactly which files this run modified (an empty list if none).
179+
180+ Lets the pre-commit hook stage only those files - plus whatever was
181+ already staged - instead of the entire generated-code directories,
182+ which would otherwise sweep in unrelated or intentionally-unstaged
183+ in-progress changes sitting in those same directories. Called at every
184+ exit point so the hook never reads a stale list left over from a prior
185+ run. Best-effort: if this can't be written, the hook simply finds no
186+ list and stages nothing beyond what was already staged.
187+ """
188+ try :
189+ list_path = _git_dir () / "leak-guard-touched-files.txt"
190+ list_path .write_text (
191+ "" .join (f"{ f } \n " for f in relative_files ), encoding = "utf-8"
192+ )
193+ except (OSError , subprocess .CalledProcessError ):
194+ pass
195+
196+
197+ def _rollback (original_contents : dict ) -> None :
198+ """Restores every file this run has written so far back to what it read
199+ at the start. Called from every failure path after files start getting
200+ written, so a build break, a final-scan tool failure, or leftover
201+ findings after redaction never leaves a partially-redacted, unverified
202+ file sitting in the working tree.
203+ """
204+ for file_path , content in original_contents .items ():
205+ file_path .write_text (content , encoding = "utf-8" )
206+ record_touched_files ([])
207+
208+
166209def _line_start_offsets (text : str ) -> list :
167210 """Absolute char offset where each 1-indexed line starts in `text`.
168211
@@ -213,6 +256,7 @@ def main() -> int:
213256 "generated code. CI will still scan for this." ,
214257 file = sys .stderr ,
215258 )
259+ record_touched_files ([])
216260 return 0
217261
218262 if not self_test_allowlist_support ():
@@ -225,11 +269,13 @@ def main() -> int:
225269 "zricethezav/gitleaks:latest) and try again." ,
226270 file = sys .stderr ,
227271 )
272+ record_touched_files ([])
228273 return 1
229274
230275 findings = scan_generated_dirs ()
231276 if not findings :
232277 print ("No gitleaks findings in generated code." )
278+ record_touched_files ([])
233279 return 0
234280
235281 allowed_prefixes = tuple (GENERATED_DIRS )
@@ -243,6 +289,7 @@ def main() -> int:
243289 + "\n " .join (f" - { f ['File' ]} " for f in out_of_scope ),
244290 file = sys .stderr ,
245291 )
292+ record_touched_files ([])
246293 return 1
247294
248295 by_file = {}
@@ -284,11 +331,21 @@ def main() -> int:
284331 continue
285332 spans .append ((span [0 ], span [1 ], finding ["RuleID" ]))
286333
287- # Deduplicate by (start, end): if two rules flag the exact same
288- # span, it must only be redacted once. Applied back-to-front
289- # (highest offset first) so replacing a later span never shifts
290- # the offsets of an earlier one still waiting to be processed.
291- unique_spans_by_file [file_path ] = sorted (set (spans ), reverse = True )
334+ # Deduplicate by (start, end) only - not the full (start, end,
335+ # rule_id) triple, which wouldn't collapse two different rules
336+ # flagging the exact same span; a stale second replacement at an
337+ # already-redacted offset would then corrupt the file or eat
338+ # adjacent text. Keeps whichever rule_id was seen first for that
339+ # span. Applied back-to-front (highest offset first) so replacing
340+ # a later span never shifts the offsets of an earlier one still
341+ # waiting to be processed.
342+ spans_by_range = {}
343+ for start , end , rule_id in spans :
344+ spans_by_range .setdefault ((start , end ), rule_id )
345+ unique_spans_by_file [file_path ] = sorted (
346+ ((start , end , rule_id ) for (start , end ), rule_id in spans_by_range .items ()),
347+ reverse = True ,
348+ )
292349
293350 if unresolved :
294351 print (
@@ -300,9 +357,11 @@ def main() -> int:
300357 ),
301358 file = sys .stderr ,
302359 )
360+ record_touched_files ([])
303361 return 1
304362
305363 original_contents = dict (file_contents )
364+ touched_files = set ()
306365 redacted_count = 0
307366 for file_path , content in file_contents .items ():
308367 by_rule = {}
@@ -317,6 +376,8 @@ def main() -> int:
317376 print (f"[{ rule_id } ] redacted in { file_path .relative_to (REPO_ROOT )} -> { placeholder } " )
318377
319378 file_path .write_text (content , encoding = "utf-8" )
379+ if content != original_contents [file_path ]:
380+ touched_files .add (str (file_path .relative_to (REPO_ROOT )))
320381
321382 build_ok = True
322383 for file_path in original_contents :
@@ -330,26 +391,43 @@ def main() -> int:
330391 )
331392
332393 if not build_ok :
333- for file_path , content in original_contents .items ():
334- file_path .write_text (content , encoding = "utf-8" )
394+ _rollback (original_contents )
335395 print (
336396 "Rolled back all changes from this run - redaction needs manual review." ,
337397 file = sys .stderr ,
338398 )
339399 return 1
340400
341- remaining = scan_generated_dirs ()
401+ # The final re-scan can itself fail for reasons other than "still
402+ # leaks" (e.g. the gitleaks binary crashing mid-run) -
403+ # run_gitleaks_detect (via scan_generated_dirs) raises in that case
404+ # rather than returning a findings list. Either way, an unverified
405+ # redaction must never be left on disk: roll back exactly as the
406+ # build-failure path above does.
407+ try :
408+ remaining = scan_generated_dirs ()
409+ except RuntimeError as err :
410+ _rollback (original_contents )
411+ print (
412+ f"Final gitleaks re-scan failed to run ({ err } ) - rolled back all "
413+ "changes from this run. Redaction needs manual review." ,
414+ file = sys .stderr ,
415+ )
416+ return 1
342417 if remaining :
418+ _rollback (original_contents )
343419 print (
344420 f"Redacted { redacted_count } secret(s), but { len (remaining )} finding(s) "
345- "remain after re-scanning. Manual review needed:\n "
421+ "remain after re-scanning. Rolled back all changes from this run. "
422+ "Manual review needed:\n "
346423 + "\n " .join (
347424 f" - [{ f ['RuleID' ]} ] { f ['File' ]} :{ f ['StartLine' ]} " for f in remaining
348425 ),
349426 file = sys .stderr ,
350427 )
351428 return 1
352429
430+ record_touched_files (sorted (touched_files ))
353431 print (
354432 f"\n Done. Redacted { redacted_count } secret(s) across { len (by_file )} file(s). "
355433 "Build and gitleaks re-scan both clean."
0 commit comments