Skip to content

Commit c12b8c1

Browse files
authored
feat(cli): polite deep merge for settings.json and support JSONC (#1874)
* feat(cli): polite deep merge for settings.json with json5 and safe atomic write * fix(cli): prevent temp fd leak and align merge-policy docs
1 parent d2ecf65 commit c12b8c1

File tree

4 files changed

+302
-26
lines changed

4 files changed

+302
-26
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: 110 additions & 26 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,147 @@ 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+
temp_path: Optional[Path] = None
672+
try:
673+
with tempfile.NamedTemporaryFile(
674+
mode='w',
675+
encoding='utf-8',
676+
dir=target_file.parent,
677+
prefix=f"{target_file.name}.",
678+
suffix=".tmp",
679+
delete=False,
680+
) as f:
681+
temp_path = Path(f.name)
682+
json.dump(payload, f, indent=4)
683+
f.write('\n')
684+
685+
if target_file.exists():
686+
try:
687+
existing_stat = target_file.stat()
688+
os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode))
689+
if hasattr(os, "chown"):
690+
try:
691+
os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid)
692+
except PermissionError:
693+
# Best-effort owner/group preservation without requiring elevated privileges.
694+
pass
695+
except OSError:
696+
# Best-effort metadata preservation; data safety is prioritized.
697+
pass
698+
699+
os.replace(temp_path, target_file)
700+
except Exception:
701+
if temp_path and temp_path.exists():
702+
temp_path.unlink()
703+
raise
704+
662705
try:
663706
with open(sub_item, 'r', encoding='utf-8') as f:
664-
new_settings = json.load(f)
707+
# json5 natively supports comments and trailing commas (JSONC)
708+
new_settings = json5.load(f)
665709

666710
if dest_file.exists():
667711
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")
712+
if merged is not None:
713+
atomic_write_json(dest_file, merged)
714+
log("Merged:", "green")
715+
log("Note: comments/trailing commas are normalized when rewritten", "yellow")
716+
else:
717+
log("Skipped merge (preserved existing settings)", "yellow")
672718
else:
673719
shutil.copy2(sub_item, dest_file)
674720
log("Copied (no existing settings.json):", "blue")
675721

676722
except Exception as e:
677-
log(f"Warning: Could not merge, copying instead: {e}", "yellow")
678-
shutil.copy2(sub_item, dest_file)
723+
log(f"Warning: Could not merge settings: {e}", "yellow")
724+
if not dest_file.exists():
725+
shutil.copy2(sub_item, dest_file)
679726

680-
def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict:
727+
728+
def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]:
681729
"""Merge new JSON content into existing JSON file.
682730
683-
Performs a deep merge where:
731+
Performs a polite deep merge where:
684732
- New keys are added
685-
- Existing keys are preserved unless overwritten by new content
686-
- Nested dictionaries are merged recursively
687-
- Lists and other values are replaced (not merged)
733+
- Existing keys are preserved (not overwritten) unless both values are dictionaries
734+
- Nested dictionaries are merged recursively only when both sides are dictionaries
735+
- Lists and other values are preserved from base if they exist
688736
689737
Args:
690738
existing_path: Path to existing JSON file
691739
new_content: New JSON content to merge in
692740
verbose: Whether to print merge details
693741
694742
Returns:
695-
Merged JSON content as dict
743+
Merged JSON content as dict, or None if the existing file should be left untouched.
696744
"""
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
745+
# Load existing content first to have a safe fallback
746+
existing_content = None
747+
exists = existing_path.exists()
748+
749+
if exists:
750+
try:
751+
with open(existing_path, 'r', encoding='utf-8') as f:
752+
# Handle comments (JSONC) natively with json5
753+
# Note: json5 handles BOM automatically
754+
existing_content = json5.load(f)
755+
except FileNotFoundError:
756+
# Handle race condition where file is deleted after exists() check
757+
exists = False
758+
except Exception as e:
759+
if verbose:
760+
console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]")
761+
# Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError)
762+
return None
763+
764+
# Validate template content
765+
if not isinstance(new_content, dict):
766+
if verbose:
767+
console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]")
768+
return None
769+
770+
if not exists:
702771
return new_content
703772

704-
def deep_merge(base: dict, update: dict) -> dict:
705-
"""Recursively merge update dict into base dict."""
773+
# If existing content parsed but is not a dict, skip merge to avoid data loss
774+
if not isinstance(existing_content, dict):
775+
if verbose:
776+
console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]")
777+
return None
778+
779+
def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
780+
"""Recursively merge update dict into base dict, preserving base values."""
706781
result = base.copy()
707782
for key, value in update.items():
708-
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
783+
if key not in result:
784+
# Add new key
785+
result[key] = value
786+
elif isinstance(result[key], dict) and isinstance(value, dict):
709787
# Recursively merge nested dictionaries
710-
result[key] = deep_merge(result[key], value)
788+
result[key] = deep_merge_polite(result[key], value)
711789
else:
712-
# Add new key or replace existing value
713-
result[key] = value
790+
# Key already exists and values are not both dicts; preserve existing value.
791+
# This ensures user settings aren't overwritten by template defaults.
792+
pass
714793
return result
715794

716-
merged = deep_merge(existing_content, new_content)
795+
merged = deep_merge_polite(existing_content, new_content)
796+
797+
# Detect if anything actually changed. If not, return None so the caller
798+
# can skip rewriting the file (preserving user's comments/formatting).
799+
if merged == existing_content:
800+
return None
717801

718802
if verbose:
719803
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)