-
Notifications
You must be signed in to change notification settings - Fork 6.6k
feat(cli): polite deep merge for settings.json and support JSONC #1874
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mnriem
merged 2 commits into
github:main
from
RbBtSn0w:fix/settings-json-polite-merge-v2
Mar 17, 2026
+302
−26
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| import stat | ||
|
|
||
| from specify_cli import merge_json_files | ||
| from specify_cli import handle_vscode_settings | ||
|
|
||
| # --- Dimension 2: Polite Deep Merge Strategy --- | ||
|
|
||
| def test_merge_json_files_type_mismatch_preservation(tmp_path): | ||
| """If user has a string but template wants a dict, PRESERVE user's string.""" | ||
| existing_file = tmp_path / "settings.json" | ||
| # User might have overridden a setting with a simple string or different type | ||
| existing_file.write_text('{"chat.editor.fontFamily": "CustomFont"}') | ||
|
|
||
| # Template might expect a dict for the same key (hypothetically) | ||
| new_settings = { | ||
| "chat.editor.fontFamily": {"font": "TemplateFont"} | ||
| } | ||
|
|
||
| merged = merge_json_files(existing_file, new_settings) | ||
| # Result is None because user settings were preserved and nothing else changed | ||
| assert merged is None | ||
|
|
||
| def test_merge_json_files_deep_nesting(tmp_path): | ||
| """Verify deep recursive merging of new keys.""" | ||
| existing_file = tmp_path / "settings.json" | ||
| existing_file.write_text(""" | ||
| { | ||
| "a": { | ||
| "b": { | ||
| "c": 1 | ||
| } | ||
| } | ||
| } | ||
| """) | ||
|
|
||
| new_settings = { | ||
| "a": { | ||
| "b": { | ||
| "d": 2 # New nested key | ||
| }, | ||
| "e": 3 # New mid-level key | ||
| } | ||
| } | ||
|
|
||
| merged = merge_json_files(existing_file, new_settings) | ||
| assert merged["a"]["b"]["c"] == 1 | ||
| assert merged["a"]["b"]["d"] == 2 | ||
| assert merged["a"]["e"] == 3 | ||
|
|
||
| def test_merge_json_files_empty_existing(tmp_path): | ||
| """Merging into an empty/new file.""" | ||
| existing_file = tmp_path / "empty.json" | ||
| existing_file.write_text("{}") | ||
|
|
||
| new_settings = {"a": 1} | ||
| merged = merge_json_files(existing_file, new_settings) | ||
| assert merged == {"a": 1} | ||
|
|
||
| # --- Dimension 3: Real-world Simulation --- | ||
|
|
||
| def test_merge_vscode_realistic_scenario(tmp_path): | ||
| """A realistic VSCode settings.json with many existing preferences, comments, and trailing commas.""" | ||
| existing_file = tmp_path / "vscode_settings.json" | ||
| existing_file.write_text(""" | ||
| { | ||
| "editor.fontSize": 12, | ||
| "editor.formatOnSave": true, /* block comment */ | ||
| "files.exclude": { | ||
| "**/.git": true, | ||
| "**/node_modules": true, | ||
| }, | ||
| "chat.promptFilesRecommendations": { | ||
| "existing.tool": true, | ||
| } // User comment | ||
| } | ||
| """) | ||
|
|
||
| template_settings = { | ||
| "chat.promptFilesRecommendations": { | ||
| "speckit.specify": True, | ||
| "speckit.plan": True | ||
| }, | ||
| "chat.tools.terminal.autoApprove": { | ||
| ".specify/scripts/bash/": True | ||
| } | ||
| } | ||
|
|
||
| merged = merge_json_files(existing_file, template_settings) | ||
|
|
||
| # Check preservation | ||
| assert merged["editor.fontSize"] == 12 | ||
| assert merged["files.exclude"]["**/.git"] is True | ||
| assert merged["chat.promptFilesRecommendations"]["existing.tool"] is True | ||
|
|
||
| # Check additions | ||
| assert merged["chat.promptFilesRecommendations"]["speckit.specify"] is True | ||
| assert merged["chat.tools.terminal.autoApprove"][".specify/scripts/bash/"] is True | ||
|
|
||
| # --- Dimension 4: Error Handling & Robustness --- | ||
|
|
||
| def test_merge_json_files_with_bom(tmp_path): | ||
| """Test files with UTF-8 BOM (sometimes created on Windows).""" | ||
| existing_file = tmp_path / "bom.json" | ||
| content = '{"a": 1}' | ||
| # Prepend UTF-8 BOM | ||
| existing_file.write_bytes(b'\xef\xbb\xbf' + content.encode('utf-8')) | ||
|
|
||
| new_settings = {"b": 2} | ||
| merged = merge_json_files(existing_file, new_settings) | ||
| assert merged == {"a": 1, "b": 2} | ||
|
|
||
| def test_merge_json_files_not_a_dictionary_template(tmp_path): | ||
| """If for some reason new_content is not a dict, PRESERVE existing settings by returning None.""" | ||
| existing_file = tmp_path / "ok.json" | ||
| existing_file.write_text('{"a": 1}') | ||
|
|
||
| # Secure fallback: return None to skip writing and avoid clobbering | ||
| assert merge_json_files(existing_file, ["not", "a", "dict"]) is None | ||
|
|
||
| def test_merge_json_files_unparseable_existing(tmp_path): | ||
| """If the existing file is unparseable JSON, return None to avoid overwriting it.""" | ||
| bad_file = tmp_path / "bad.json" | ||
| bad_file.write_text('{"a": 1, missing_value}') # Invalid JSON | ||
|
|
||
| assert merge_json_files(bad_file, {"b": 2}) is None | ||
|
|
||
|
|
||
| def test_merge_json_files_list_preservation(tmp_path): | ||
| """Verify that existing list values are preserved and NOT merged or overwritten.""" | ||
| existing_file = tmp_path / "list.json" | ||
| existing_file.write_text('{"my.list": ["user_item"]}') | ||
|
|
||
| template_settings = { | ||
| "my.list": ["template_item"] | ||
| } | ||
|
|
||
| merged = merge_json_files(existing_file, template_settings) | ||
| # The polite merge policy says: keep existing values if they exist and aren't both dicts. | ||
| # Since nothing changed, it returns None. | ||
| assert merged is None | ||
|
|
||
| def test_merge_json_files_no_changes(tmp_path): | ||
| """If the merge doesn't introduce any new keys or changes, return None to skip rewrite.""" | ||
| existing_file = tmp_path / "no_change.json" | ||
| existing_file.write_text('{"a": 1, "b": {"c": 2}}') | ||
|
|
||
| template_settings = { | ||
| "a": 1, # Already exists | ||
| "b": {"c": 2} # Already exists nested | ||
| } | ||
|
|
||
| # Should return None because result == existing | ||
| assert merge_json_files(existing_file, template_settings) is None | ||
|
|
||
| def test_merge_json_files_type_mismatch_no_op(tmp_path): | ||
| """If a key exists with different type and we preserve it, it might still result in no change.""" | ||
| existing_file = tmp_path / "mismatch_no_op.json" | ||
| existing_file.write_text('{"a": "user_string"}') | ||
|
|
||
| template_settings = { | ||
| "a": {"key": "template_dict"} # Mismatch, will be ignored | ||
| } | ||
|
|
||
| # Should return None because we preserved the user's string and nothing else changed | ||
| assert merge_json_files(existing_file, template_settings) is None | ||
|
|
||
|
|
||
| def test_handle_vscode_settings_preserves_mode_on_atomic_write(tmp_path): | ||
| """Atomic rewrite should preserve existing file mode bits.""" | ||
| vscode_dir = tmp_path / ".vscode" | ||
| vscode_dir.mkdir() | ||
| dest_file = vscode_dir / "settings.json" | ||
| template_file = tmp_path / "template_settings.json" | ||
|
|
||
| dest_file.write_text('{"a": 1}\n', encoding="utf-8") | ||
| dest_file.chmod(0o640) | ||
| before_mode = stat.S_IMODE(dest_file.stat().st_mode) | ||
|
|
||
| template_file.write_text('{"b": 2}\n', encoding="utf-8") | ||
|
|
||
| handle_vscode_settings( | ||
| template_file, | ||
| dest_file, | ||
| "settings.json", | ||
| verbose=False, | ||
| tracker=None, | ||
| ) | ||
|
|
||
| after_mode = stat.S_IMODE(dest_file.stat().st_mode) | ||
| assert after_mode == before_mode |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.