|
7 | 7 | # "platformdirs", |
8 | 8 | # "readchar", |
9 | 9 | # "httpx", |
| 10 | +# "json5", |
10 | 11 | # ] |
11 | 12 | # /// |
12 | 13 | """ |
|
32 | 33 | import shutil |
33 | 34 | import shlex |
34 | 35 | import json |
| 36 | +import json5 |
| 37 | +import stat |
35 | 38 | import yaml |
36 | 39 | from pathlib import Path |
37 | 40 | from typing import Any, Optional, Tuple |
@@ -654,66 +657,143 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option |
654 | 657 | os.chdir(original_cwd) |
655 | 658 |
|
656 | 659 | def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: |
657 | | - """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 | + """ |
658 | 665 | def log(message, color="green"): |
659 | 666 | if verbose and not tracker: |
660 | 667 | console.print(f"[{color}]{message}[/] {rel_path}") |
661 | 668 |
|
| 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 | + |
662 | 701 | try: |
663 | 702 | with open(sub_item, 'r', encoding='utf-8') as f: |
664 | | - new_settings = json.load(f) |
| 703 | + # json5 natively supports comments and trailing commas (JSONC) |
| 704 | + new_settings = json5.load(f) |
665 | 705 |
|
666 | 706 | if dest_file.exists(): |
667 | 707 | merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker) |
668 | | - with open(dest_file, 'w', encoding='utf-8') as f: |
669 | | - json.dump(merged, f, indent=4) |
670 | | - f.write('\n') |
671 | | - log("Merged:", "green") |
| 708 | + if merged is not None: |
| 709 | + atomic_write_json(dest_file, merged) |
| 710 | + log("Merged:", "green") |
| 711 | + log("Note: comments/trailing commas are normalized when rewritten", "yellow") |
| 712 | + else: |
| 713 | + log("Skipped merge (preserved existing settings)", "yellow") |
672 | 714 | else: |
673 | 715 | shutil.copy2(sub_item, dest_file) |
674 | 716 | log("Copied (no existing settings.json):", "blue") |
675 | 717 |
|
676 | 718 | except Exception as e: |
677 | | - log(f"Warning: Could not merge, copying instead: {e}", "yellow") |
678 | | - shutil.copy2(sub_item, dest_file) |
| 719 | + log(f"Warning: Could not merge settings: {e}", "yellow") |
| 720 | + if not dest_file.exists(): |
| 721 | + shutil.copy2(sub_item, dest_file) |
| 722 | + |
679 | 723 |
|
680 | | -def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict: |
| 724 | +def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]: |
681 | 725 | """Merge new JSON content into existing JSON file. |
682 | 726 |
|
683 | | - Performs a deep merge where: |
| 727 | + Performs a polite deep merge where: |
684 | 728 | - New keys are added |
685 | | - - Existing keys are preserved unless overwritten by new content |
| 729 | + - Existing keys are PRESERVED (not overwritten) unless they are dictionaries |
686 | 730 | - Nested dictionaries are merged recursively |
687 | | - - Lists and other values are replaced (not merged) |
| 731 | + - Lists and other values are preserved from base if they exist |
688 | 732 |
|
689 | 733 | Args: |
690 | 734 | existing_path: Path to existing JSON file |
691 | 735 | new_content: New JSON content to merge in |
692 | 736 | verbose: Whether to print merge details |
693 | 737 |
|
694 | 738 | Returns: |
695 | | - Merged JSON content as dict |
| 739 | + Merged JSON content as dict, or None if the existing file should be left untouched. |
696 | 740 | """ |
697 | | - try: |
698 | | - with open(existing_path, 'r', encoding='utf-8') as f: |
699 | | - existing_content = json.load(f) |
700 | | - except (FileNotFoundError, json.JSONDecodeError): |
701 | | - # If file doesn't exist or is invalid, just use new content |
| 741 | + # Load existing content first to have a safe fallback |
| 742 | + existing_content = None |
| 743 | + exists = existing_path.exists() |
| 744 | + |
| 745 | + if exists: |
| 746 | + try: |
| 747 | + with open(existing_path, 'r', encoding='utf-8') as f: |
| 748 | + # Handle comments (JSONC) natively with json5 |
| 749 | + # Note: json5 handles BOM automatically |
| 750 | + existing_content = json5.load(f) |
| 751 | + except FileNotFoundError: |
| 752 | + # Handle race condition where file is deleted after exists() check |
| 753 | + exists = False |
| 754 | + except Exception as e: |
| 755 | + if verbose: |
| 756 | + console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]") |
| 757 | + # Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError) |
| 758 | + return None |
| 759 | + |
| 760 | + # Validate template content |
| 761 | + if not isinstance(new_content, dict): |
| 762 | + if verbose: |
| 763 | + console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]") |
| 764 | + return None |
| 765 | + |
| 766 | + if not exists: |
702 | 767 | return new_content |
703 | 768 |
|
704 | | - def deep_merge(base: dict, update: dict) -> dict: |
705 | | - """Recursively merge update dict into base dict.""" |
| 769 | + # If existing content parsed but is not a dict, skip merge to avoid data loss |
| 770 | + if not isinstance(existing_content, dict): |
| 771 | + if verbose: |
| 772 | + console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]") |
| 773 | + return None |
| 774 | + |
| 775 | + def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: |
| 776 | + """Recursively merge update dict into base dict, preserving base values.""" |
706 | 777 | result = base.copy() |
707 | 778 | for key, value in update.items(): |
708 | | - if key in result and isinstance(result[key], dict) and isinstance(value, dict): |
| 779 | + if key not in result: |
| 780 | + # Add new key |
| 781 | + result[key] = value |
| 782 | + elif isinstance(result[key], dict) and isinstance(value, dict): |
709 | 783 | # Recursively merge nested dictionaries |
710 | | - result[key] = deep_merge(result[key], value) |
| 784 | + result[key] = deep_merge_polite(result[key], value) |
711 | 785 | else: |
712 | | - # Add new key or replace existing value |
713 | | - result[key] = value |
| 786 | + # Key already exists and is not a dict, PRESERVE existing value |
| 787 | + # This ensures user settings aren't overwritten by template defaults |
| 788 | + pass |
714 | 789 | return result |
715 | 790 |
|
716 | | - merged = deep_merge(existing_content, new_content) |
| 791 | + merged = deep_merge_polite(existing_content, new_content) |
| 792 | + |
| 793 | + # Detect if anything actually changed. If not, return None so the caller |
| 794 | + # can skip rewriting the file (preserving user's comments/formatting). |
| 795 | + if merged == existing_content: |
| 796 | + return None |
717 | 797 |
|
718 | 798 | if verbose: |
719 | 799 | console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}") |
|
0 commit comments