3434import shlex
3535import json
3636import json5
37+ import stat
3738import yaml
3839from pathlib import Path
3940from typing import Any , Optional , Tuple
@@ -656,11 +657,47 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option
656657 os .chdir (original_cwd )
657658
658659def handle_vscode_settings (sub_item , dest_file , rel_path , verbose = False , tracker = None ) -> None :
659- """Handle merging or copying of .vscode/settings.json files."""
660+ """Handle merging or copying of .vscode/settings.json files.
661+
662+ Note: when merge produces changes, rewritten output is normalized JSON and
663+ existing JSONC comments/trailing commas are not preserved.
664+ """
660665 def log (message , color = "green" ):
661666 if verbose and not tracker :
662667 console .print (f"[{ color } ]{ message } [/] { rel_path } " )
663668
669+ def atomic_write_json (target_file : Path , payload : dict [str , Any ]) -> None :
670+ """Atomically write JSON while preserving existing mode bits when possible."""
671+ fd , temp_path = tempfile .mkstemp (
672+ dir = target_file .parent ,
673+ prefix = f"{ target_file .name } ." ,
674+ suffix = ".tmp" ,
675+ )
676+ try :
677+ with os .fdopen (fd , 'w' , encoding = 'utf-8' ) as f :
678+ json .dump (payload , f , indent = 4 )
679+ f .write ('\n ' )
680+
681+ if target_file .exists ():
682+ try :
683+ existing_stat = target_file .stat ()
684+ os .chmod (temp_path , stat .S_IMODE (existing_stat .st_mode ))
685+ if hasattr (os , "chown" ):
686+ try :
687+ os .chown (temp_path , existing_stat .st_uid , existing_stat .st_gid )
688+ except PermissionError :
689+ # Best-effort owner/group preservation without requiring elevated privileges.
690+ pass
691+ except OSError :
692+ # Best-effort metadata preservation; data safety is prioritized.
693+ pass
694+
695+ os .replace (temp_path , target_file )
696+ except Exception :
697+ if os .path .exists (temp_path ):
698+ os .unlink (temp_path )
699+ raise
700+
664701 try :
665702 with open (sub_item , 'r' , encoding = 'utf-8' ) as f :
666703 # json5 natively supports comments and trailing commas (JSONC)
@@ -669,18 +706,9 @@ def log(message, color="green"):
669706 if dest_file .exists ():
670707 merged = merge_json_files (dest_file , new_settings , verbose = verbose and not tracker )
671708 if merged is not None :
672- # Use atomic write: write to temp file then replace
673- fd , temp_path = tempfile .mkstemp (dir = dest_file .parent , prefix = "settings." , suffix = ".json" )
674- try :
675- with os .fdopen (fd , 'w' , encoding = 'utf-8' ) as f :
676- json .dump (merged , f , indent = 4 )
677- f .write ('\n ' )
678- os .replace (temp_path , dest_file )
679- except Exception :
680- if os .path .exists (temp_path ):
681- os .unlink (temp_path )
682- raise
709+ atomic_write_json (dest_file , merged )
683710 log ("Merged:" , "green" )
711+ log ("Note: comments/trailing commas are normalized when rewritten" , "yellow" )
684712 else :
685713 log ("Skipped merge (preserved existing settings)" , "yellow" )
686714 else :
@@ -762,7 +790,7 @@ def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str,
762790
763791 merged = deep_merge_polite (existing_content , new_content )
764792
765- # Detect if anything actually changed. If not, return None so the caller
793+ # Detect if anything actually changed. If not, return None so the caller
766794 # can skip rewriting the file (preserving user's comments/formatting).
767795 if merged == existing_content :
768796 return None
0 commit comments