feat: detect stale config and prompt merge-or-overwrite on save
Records the file mtime at load time; before write_config() fires, re-checks it. If it changed (e.g. `claude mcp add`, a second BCC window, or Claude itself writing ~/.claude.json), StaleDialog prompts with the changed top-level key names and a masked server-section diff. "Merge & save" applies the user's in-memory server edits on top of the current on-disk file (preserving external non-server changes); "Overwrite anyway" proceeds as before. - bcc_core: config_mtime(), external_change_summary(), _server_sections() helper extracted from backup_diff for reuse - bcc.py: StaleDialog, MainWindow._loaded_mtime tracked through load/save - tests: 7 new tests (config_mtime, external_change_summary variants, AC merge test) Closes #4 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -500,3 +500,86 @@ def test_restore_backup_restores_disabled_servers(tmp_path):
|
||||
assert "parked" in restored.get(c.DISABLED_KEY, {})
|
||||
assert "active" in restored.get("mcpServers", {})
|
||||
assert "newcomer" not in restored.get("mcpServers", {})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 9 — Stale-file protection
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_config_mtime_returns_float_for_existing_file(tmp_path):
|
||||
f = tmp_path / "cfg.json"
|
||||
f.write_text("{}")
|
||||
assert isinstance(c.config_mtime(f), float)
|
||||
|
||||
|
||||
def test_config_mtime_returns_none_for_missing_file(tmp_path):
|
||||
assert c.config_mtime(tmp_path / "nonexistent.json") is None
|
||||
|
||||
|
||||
def test_external_change_summary_detects_non_server_key_change(tmp_path):
|
||||
cfgpath = tmp_path / "cfg.json"
|
||||
original = {"numStartups": 1, "mcpServers": {"s": {"command": "node"}}}
|
||||
cfgpath.write_text(json.dumps({"numStartups": 2, "mcpServers": {"s": {"command": "node"}}}))
|
||||
changed_keys, server_diff = c.external_change_summary(original, cfgpath)
|
||||
assert "numStartups" in changed_keys
|
||||
assert server_diff == "" # server sections unchanged
|
||||
|
||||
|
||||
def test_external_change_summary_detects_server_section_change(tmp_path):
|
||||
cfgpath = tmp_path / "cfg.json"
|
||||
original = {"mcpServers": {"s1": {"command": "node"}}}
|
||||
cfgpath.write_text(json.dumps({"mcpServers": {"s2": {"command": "python"}}}))
|
||||
changed_keys, server_diff = c.external_change_summary(original, cfgpath)
|
||||
assert "mcpServers" in changed_keys
|
||||
assert "s1" in server_diff or "s2" in server_diff
|
||||
|
||||
|
||||
def test_external_change_summary_server_diff_masks_secrets(tmp_path):
|
||||
cfgpath = tmp_path / "cfg.json"
|
||||
original = {"mcpServers": {"a": {"command": "node", "env": {"SECRET_KEY": "s3cr3t"}}}}
|
||||
# command changes (visible in diff) AND secret value changes (both sides masked →
|
||||
# MASK appears in context lines, raw secret never appears)
|
||||
cfgpath.write_text(
|
||||
json.dumps(
|
||||
{"mcpServers": {"a": {"command": "python", "env": {"SECRET_KEY": "n3w_s3cr3t"}}}}
|
||||
)
|
||||
)
|
||||
changed_keys, server_diff = c.external_change_summary(original, cfgpath)
|
||||
assert "mcpServers" in changed_keys
|
||||
assert "s3cr3t" not in server_diff
|
||||
assert "n3w_s3cr3t" not in server_diff
|
||||
assert c.MASK in server_diff # appears in context for the masked env value
|
||||
|
||||
|
||||
def test_external_change_summary_empty_when_unchanged(tmp_path):
|
||||
cfgpath = tmp_path / "cfg.json"
|
||||
cfg = {"mcpServers": {"s": {"command": "node"}}}
|
||||
cfgpath.write_text(json.dumps(cfg))
|
||||
changed_keys, server_diff = c.external_change_summary(cfg, cfgpath)
|
||||
assert changed_keys == []
|
||||
assert server_diff == ""
|
||||
|
||||
|
||||
def test_merge_and_reapply_preserves_both_edits(tmp_path):
|
||||
"""AC test: external non-server change + in-memory server edit both survive merge."""
|
||||
cfgpath = tmp_path / "cfg.json"
|
||||
original = {"numStartups": 1, "mcpServers": {"s1": {"command": "node"}}}
|
||||
cfgpath.write_text(json.dumps(original, indent=2))
|
||||
|
||||
user_servers = [c.ServerEntry("s2", {"command": "python"}, True)]
|
||||
|
||||
# External process bumps numStartups without touching servers
|
||||
disk_changed = dict(original)
|
||||
disk_changed["numStartups"] = 42
|
||||
cfgpath.write_text(json.dumps(disk_changed, indent=2))
|
||||
|
||||
# Merge: load fresh from disk, apply user's server edits, write back
|
||||
fresh = c.load_config(cfgpath)
|
||||
c.apply_servers(fresh, user_servers)
|
||||
c.write_config(cfgpath, fresh)
|
||||
|
||||
result = json.loads(cfgpath.read_text())
|
||||
assert result["numStartups"] == 42 # external change preserved
|
||||
assert "s2" in result["mcpServers"] # user's server edit preserved
|
||||
assert "s1" not in result["mcpServers"] # old server replaced
|
||||
|
||||
Reference in New Issue
Block a user