diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 56a7bf6e0..37cba2868 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -485,6 +485,73 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option finally: os.chdir(original_cwd) +def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: + """Handle merging or copying of .vscode/settings.json files.""" + def log(message, color="green"): + if verbose and not tracker: + console.print(f"[{color}]{message}[/] {rel_path}") + + try: + with open(sub_item, 'r', encoding='utf-8') as f: + new_settings = json.load(f) + + if dest_file.exists(): + merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker) + with open(dest_file, 'w', encoding='utf-8') as f: + json.dump(merged, f, indent=4) + f.write('\n') + log("Merged:", "green") + else: + shutil.copy2(sub_item, dest_file) + log("Copied (no existing settings.json):", "blue") + + except Exception as e: + log(f"Warning: Could not merge, copying instead: {e}", "yellow") + shutil.copy2(sub_item, dest_file) + +def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict: + """Merge new JSON content into existing JSON file. + + Performs a deep merge where: + - New keys are added + - Existing keys are preserved unless overwritten by new content + - Nested dictionaries are merged recursively + - Lists and other values are replaced (not merged) + + Args: + existing_path: Path to existing JSON file + new_content: New JSON content to merge in + verbose: Whether to print merge details + + Returns: + Merged JSON content as dict + """ + try: + with open(existing_path, 'r', encoding='utf-8') as f: + existing_content = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + # If file doesn't exist or is invalid, just use new content + return new_content + + def deep_merge(base: dict, update: dict) -> dict: + """Recursively merge update dict into base dict.""" + result = base.copy() + for key, value in update.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + # Recursively merge nested dictionaries + result[key] = deep_merge(result[key], value) + else: + # Add new key or replace existing value + result[key] = value + return result + + merged = deep_merge(existing_content, new_content) + + if verbose: + console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}") + + return merged + def download_template_from_github(ai_assistant: str, download_dir: Path, *, script_type: str = "sh", verbose: bool = True, show_progress: bool = True, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Tuple[Path, dict]: repo_owner = "github" repo_name = "spec-kit" @@ -676,7 +743,11 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ rel_path = sub_item.relative_to(item) dest_file = dest_path / rel_path dest_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(sub_item, dest_file) + # Special handling for .vscode/settings.json - merge instead of overwrite + if dest_file.name == "settings.json" and dest_file.parent.name == ".vscode": + handle_vscode_settings(sub_item, dest_file, rel_path, verbose, tracker) + else: + shutil.copy2(sub_item, dest_file) else: shutil.copytree(item, dest_path) else: