@@ -620,28 +620,75 @@ def _line_count(text: str) -> int:
620620 end_idx = existing .find (_MARKER_END )
621621
622622 if start_idx == - 1 or end_idx == - 1 :
623- # No markers -- file exists but was written before markers were introduced
624- # (or is purely hand-written). Treat it like first write: prepend auto block.
625- target .write_text (wrapped + "\n " + existing , encoding = "utf-8" )
623+ # No saar markers -- file exists but has never been written by saar.
624+ # This means it's either hand-crafted or written by another tool.
625+ # We must NOT silently overwrite hand-crafted files. (OPE-181)
626+ existing_lines = _line_count (existing )
627+
628+ if existing_lines >= 5 and not force :
629+ # Substantial hand-crafted file -- protect it.
630+ # In interactive mode: ask. In non-interactive: skip with clear message.
631+ import sys
632+ import os
633+ is_tty = sys .stdin .isatty () and sys .stdout .isatty ()
634+ ci = any (os .environ .get (v ) for v in [
635+ "CI" , "GITHUB_ACTIONS" , "GITLAB_CI" , "JENKINS_URL" ,
636+ "CIRCLECI" , "TRAVIS" , "BUILDKITE" ,
637+ ])
638+ interactive = is_tty and not ci
639+
640+ if interactive :
641+ try :
642+ import questionary
643+ answer = questionary .confirm (
644+ f"{ _display_path (target )} exists ({ existing_lines } lines, "
645+ "not generated by saar). Overwrite it?" ,
646+ default = False ,
647+ ).ask ()
648+ if not answer :
649+ console .print (
650+ f" [yellow]skipped[/yellow] { _display_path (target )} "
651+ "[dim]Use --force to overwrite.[/dim]"
652+ )
653+ return
654+ except Exception :
655+ # questionary not available -- fall through to skip
656+ console .print (
657+ f" [yellow]skipped[/yellow] { _display_path (target )} "
658+ f"[dim]({ existing_lines } lines, not generated by saar). "
659+ "Use --force to overwrite.[/dim]"
660+ )
661+ return
662+ else :
663+ # Non-interactive (CI / --no-interview): never overwrite silently.
664+ console .print (
665+ f" [yellow]skipped[/yellow] { _display_path (target )} "
666+ f"[dim]({ existing_lines } lines, not generated by saar). "
667+ "Use --force to overwrite.[/dim]"
668+ )
669+ return
670+
671+ # Either force=True, or the file is essentially empty (<5 lines).
672+ # Safe to write.
673+ target .write_text (wrapped , encoding = "utf-8" )
626674 console .print (
627- f" [green]updated [/green] { _display_path (target )} "
628- f" [dim]({ _line_count (wrapped + existing )} lines)[/dim]"
675+ f" [green]wrote [/green] { _display_path (target )} "
676+ f" [dim]({ _line_count (wrapped )} lines)[/dim]"
629677 )
630678 return
631679
632680 # Splice: keep everything before the start marker and after the end marker.
633- # Use find() for END to get the first legitimate closing marker (the one that
634- # belongs to this auto-block). Then strip any orphaned SAAR markers from the
635- # preserved manual content -- these can accumulate from old runs or copy-paste.
681+ # Splice: keep before START + new auto block + after END.
636682 # rfind() would be wrong here: it would swallow manual content sitting between
637683 # the legitimate END and an orphaned END. (OPE-169)
638684 before = existing [:start_idx ]
639685 after = existing [end_idx + len (_MARKER_END ):]
640686
641- # Strip any orphaned SAAR markers from the preserved manual section.
687+ # Strip orphaned SAAR markers and leading blank lines. (OPE-179)
642688 after = after .replace (_MARKER_START , "" ).replace (_MARKER_END , "" )
689+ after = after .lstrip ("\n " )
643690
644- final = before + wrapped + after
691+ final = before + wrapped + ( " \n " + after if after . strip () else "" )
645692 target .write_text (final , encoding = "utf-8" )
646693 console .print (
647694 f" [green]updated[/green] { _display_path (target )} "
0 commit comments