Skip to content

Commit d87ae7a

Browse files
committed
feat(cli): polite deep merge for settings.json with json5 and safe atomic write
1 parent 4a32344 commit d87ae7a

File tree

4 files changed

+297
-25
lines changed

4 files changed

+297
-25
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3535

3636
### Added
3737

38+
- feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks
3839
- feat(presets): Pluggable preset system with preset catalog and template resolver
3940
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types
4041
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies = [
1414
"pyyaml>=6.0",
1515
"packaging>=23.0",
1616
"pathspec>=0.12.0",
17+
"json5>=0.13.0",
1718
]
1819

1920
[project.scripts]

src/specify_cli/__init__.py

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# "platformdirs",
88
# "readchar",
99
# "httpx",
10+
# "json5",
1011
# ]
1112
# ///
1213
"""
@@ -32,6 +33,8 @@
3233
import shutil
3334
import shlex
3435
import json
36+
import json5
37+
import stat
3538
import yaml
3639
from pathlib import Path
3740
from typing import Any, Optional, Tuple
@@ -654,66 +657,143 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option
654657
os.chdir(original_cwd)
655658

656659
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+
"""
658665
def log(message, color="green"):
659666
if verbose and not tracker:
660667
console.print(f"[{color}]{message}[/] {rel_path}")
661668

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+
662701
try:
663702
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)
665705

666706
if dest_file.exists():
667707
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")
672714
else:
673715
shutil.copy2(sub_item, dest_file)
674716
log("Copied (no existing settings.json):", "blue")
675717

676718
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+
679723

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]]:
681725
"""Merge new JSON content into existing JSON file.
682726
683-
Performs a deep merge where:
727+
Performs a polite deep merge where:
684728
- 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
686730
- 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
688732
689733
Args:
690734
existing_path: Path to existing JSON file
691735
new_content: New JSON content to merge in
692736
verbose: Whether to print merge details
693737
694738
Returns:
695-
Merged JSON content as dict
739+
Merged JSON content as dict, or None if the existing file should be left untouched.
696740
"""
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:
702767
return new_content
703768

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."""
706777
result = base.copy()
707778
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):
709783
# Recursively merge nested dictionaries
710-
result[key] = deep_merge(result[key], value)
784+
result[key] = deep_merge_polite(result[key], value)
711785
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
714789
return result
715790

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
717797

718798
if verbose:
719799
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")

tests/test_merge.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import stat
2+
3+
from specify_cli import merge_json_files
4+
from specify_cli import handle_vscode_settings
5+
6+
# --- Dimension 2: Polite Deep Merge Strategy ---
7+
8+
def test_merge_json_files_type_mismatch_preservation(tmp_path):
9+
"""If user has a string but template wants a dict, PRESERVE user's string."""
10+
existing_file = tmp_path / "settings.json"
11+
# User might have overridden a setting with a simple string or different type
12+
existing_file.write_text('{"chat.editor.fontFamily": "CustomFont"}')
13+
14+
# Template might expect a dict for the same key (hypothetically)
15+
new_settings = {
16+
"chat.editor.fontFamily": {"font": "TemplateFont"}
17+
}
18+
19+
merged = merge_json_files(existing_file, new_settings)
20+
# Result is None because user settings were preserved and nothing else changed
21+
assert merged is None
22+
23+
def test_merge_json_files_deep_nesting(tmp_path):
24+
"""Verify deep recursive merging of new keys."""
25+
existing_file = tmp_path / "settings.json"
26+
existing_file.write_text("""
27+
{
28+
"a": {
29+
"b": {
30+
"c": 1
31+
}
32+
}
33+
}
34+
""")
35+
36+
new_settings = {
37+
"a": {
38+
"b": {
39+
"d": 2 # New nested key
40+
},
41+
"e": 3 # New mid-level key
42+
}
43+
}
44+
45+
merged = merge_json_files(existing_file, new_settings)
46+
assert merged["a"]["b"]["c"] == 1
47+
assert merged["a"]["b"]["d"] == 2
48+
assert merged["a"]["e"] == 3
49+
50+
def test_merge_json_files_empty_existing(tmp_path):
51+
"""Merging into an empty/new file."""
52+
existing_file = tmp_path / "empty.json"
53+
existing_file.write_text("{}")
54+
55+
new_settings = {"a": 1}
56+
merged = merge_json_files(existing_file, new_settings)
57+
assert merged == {"a": 1}
58+
59+
# --- Dimension 3: Real-world Simulation ---
60+
61+
def test_merge_vscode_realistic_scenario(tmp_path):
62+
"""A realistic VSCode settings.json with many existing preferences, comments, and trailing commas."""
63+
existing_file = tmp_path / "vscode_settings.json"
64+
existing_file.write_text("""
65+
{
66+
"editor.fontSize": 12,
67+
"editor.formatOnSave": true, /* block comment */
68+
"files.exclude": {
69+
"**/.git": true,
70+
"**/node_modules": true,
71+
},
72+
"chat.promptFilesRecommendations": {
73+
"existing.tool": true,
74+
} // User comment
75+
}
76+
""")
77+
78+
template_settings = {
79+
"chat.promptFilesRecommendations": {
80+
"speckit.specify": True,
81+
"speckit.plan": True
82+
},
83+
"chat.tools.terminal.autoApprove": {
84+
".specify/scripts/bash/": True
85+
}
86+
}
87+
88+
merged = merge_json_files(existing_file, template_settings)
89+
90+
# Check preservation
91+
assert merged["editor.fontSize"] == 12
92+
assert merged["files.exclude"]["**/.git"] is True
93+
assert merged["chat.promptFilesRecommendations"]["existing.tool"] is True
94+
95+
# Check additions
96+
assert merged["chat.promptFilesRecommendations"]["speckit.specify"] is True
97+
assert merged["chat.tools.terminal.autoApprove"][".specify/scripts/bash/"] is True
98+
99+
# --- Dimension 4: Error Handling & Robustness ---
100+
101+
def test_merge_json_files_with_bom(tmp_path):
102+
"""Test files with UTF-8 BOM (sometimes created on Windows)."""
103+
existing_file = tmp_path / "bom.json"
104+
content = '{"a": 1}'
105+
# Prepend UTF-8 BOM
106+
existing_file.write_bytes(b'\xef\xbb\xbf' + content.encode('utf-8'))
107+
108+
new_settings = {"b": 2}
109+
merged = merge_json_files(existing_file, new_settings)
110+
assert merged == {"a": 1, "b": 2}
111+
112+
def test_merge_json_files_not_a_dictionary_template(tmp_path):
113+
"""If for some reason new_content is not a dict, PRESERVE existing settings by returning None."""
114+
existing_file = tmp_path / "ok.json"
115+
existing_file.write_text('{"a": 1}')
116+
117+
# Secure fallback: return None to skip writing and avoid clobbering
118+
assert merge_json_files(existing_file, ["not", "a", "dict"]) is None
119+
120+
def test_merge_json_files_unparseable_existing(tmp_path):
121+
"""If the existing file is unparseable JSON, return None to avoid overwriting it."""
122+
bad_file = tmp_path / "bad.json"
123+
bad_file.write_text('{"a": 1, missing_value}') # Invalid JSON
124+
125+
assert merge_json_files(bad_file, {"b": 2}) is None
126+
127+
128+
def test_merge_json_files_list_preservation(tmp_path):
129+
"""Verify that existing list values are preserved and NOT merged or overwritten."""
130+
existing_file = tmp_path / "list.json"
131+
existing_file.write_text('{"my.list": ["user_item"]}')
132+
133+
template_settings = {
134+
"my.list": ["template_item"]
135+
}
136+
137+
merged = merge_json_files(existing_file, template_settings)
138+
# The polite merge policy says: keep existing values if they exist and aren't both dicts.
139+
# Since nothing changed, it returns None.
140+
assert merged is None
141+
142+
def test_merge_json_files_no_changes(tmp_path):
143+
"""If the merge doesn't introduce any new keys or changes, return None to skip rewrite."""
144+
existing_file = tmp_path / "no_change.json"
145+
existing_file.write_text('{"a": 1, "b": {"c": 2}}')
146+
147+
template_settings = {
148+
"a": 1, # Already exists
149+
"b": {"c": 2} # Already exists nested
150+
}
151+
152+
# Should return None because result == existing
153+
assert merge_json_files(existing_file, template_settings) is None
154+
155+
def test_merge_json_files_type_mismatch_no_op(tmp_path):
156+
"""If a key exists with different type and we preserve it, it might still result in no change."""
157+
existing_file = tmp_path / "mismatch_no_op.json"
158+
existing_file.write_text('{"a": "user_string"}')
159+
160+
template_settings = {
161+
"a": {"key": "template_dict"} # Mismatch, will be ignored
162+
}
163+
164+
# Should return None because we preserved the user's string and nothing else changed
165+
assert merge_json_files(existing_file, template_settings) is None
166+
167+
168+
def test_handle_vscode_settings_preserves_mode_on_atomic_write(tmp_path):
169+
"""Atomic rewrite should preserve existing file mode bits."""
170+
vscode_dir = tmp_path / ".vscode"
171+
vscode_dir.mkdir()
172+
dest_file = vscode_dir / "settings.json"
173+
template_file = tmp_path / "template_settings.json"
174+
175+
dest_file.write_text('{"a": 1}\n', encoding="utf-8")
176+
dest_file.chmod(0o640)
177+
before_mode = stat.S_IMODE(dest_file.stat().st_mode)
178+
179+
template_file.write_text('{"b": 2}\n', encoding="utf-8")
180+
181+
handle_vscode_settings(
182+
template_file,
183+
dest_file,
184+
"settings.json",
185+
verbose=False,
186+
tracker=None,
187+
)
188+
189+
after_mode = stat.S_IMODE(dest_file.stat().st_mode)
190+
assert after_mode == before_mode

0 commit comments

Comments
 (0)