Skip to content

Commit da7843a

Browse files
committed
fix(cli): preserve settings file metadata on atomic merge write
1 parent 9f22ab9 commit da7843a

File tree

2 files changed

+69
-13
lines changed

2 files changed

+69
-13
lines changed

src/specify_cli/__init__.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import shlex
3535
import json
3636
import json5
37+
import stat
3738
import yaml
3839
from pathlib import Path
3940
from typing import Any, Optional, Tuple
@@ -656,11 +657,47 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option
656657
os.chdir(original_cwd)
657658

658659
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
659-
"""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+
"""
660665
def log(message, color="green"):
661666
if verbose and not tracker:
662667
console.print(f"[{color}]{message}[/] {rel_path}")
663668

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+
664701
try:
665702
with open(sub_item, 'r', encoding='utf-8') as f:
666703
# json5 natively supports comments and trailing commas (JSONC)
@@ -669,18 +706,9 @@ def log(message, color="green"):
669706
if dest_file.exists():
670707
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
671708
if merged is not None:
672-
# Use atomic write: write to temp file then replace
673-
fd, temp_path = tempfile.mkstemp(dir=dest_file.parent, prefix="settings.", suffix=".json")
674-
try:
675-
with os.fdopen(fd, 'w', encoding='utf-8') as f:
676-
json.dump(merged, f, indent=4)
677-
f.write('\n')
678-
os.replace(temp_path, dest_file)
679-
except Exception:
680-
if os.path.exists(temp_path):
681-
os.unlink(temp_path)
682-
raise
709+
atomic_write_json(dest_file, merged)
683710
log("Merged:", "green")
711+
log("Note: comments/trailing commas are normalized when rewritten", "yellow")
684712
else:
685713
log("Skipped merge (preserved existing settings)", "yellow")
686714
else:
@@ -762,7 +790,7 @@ def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str,
762790

763791
merged = deep_merge_polite(existing_content, new_content)
764792

765-
# Detect if anything actually changed. If not, return None so the caller
793+
# Detect if anything actually changed. If not, return None so the caller
766794
# can skip rewriting the file (preserving user's comments/formatting).
767795
if merged == existing_content:
768796
return None

tests/test_merge.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import stat
2+
13
from specify_cli import merge_json_files
4+
from specify_cli import handle_vscode_settings
25

36
# --- Dimension 2: Polite Deep Merge Strategy ---
47

@@ -160,3 +163,28 @@ def test_merge_json_files_type_mismatch_no_op(tmp_path):
160163

161164
# Should return None because we preserved the user's string and nothing else changed
162165
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)