feat: detect stale config and prompt merge-or-overwrite on save
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 8s

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:
AJ
2026-07-02 01:57:32 -04:00
parent be45be9eab
commit 9fa502c500
3 changed files with 247 additions and 6 deletions
+48 -6
View File
@@ -290,6 +290,14 @@ def _redact_servers_block(block: dict | None) -> dict:
return {name: _redact_server_data(data) for name, data in block.items()}
def _server_sections(cfg: dict) -> dict:
"""Return the masked server sections of a config dict, safe for diff display."""
out: dict = {"mcpServers": _redact_servers_block(cfg.get("mcpServers"))}
if DISABLED_KEY in cfg:
out[DISABLED_KEY] = _redact_servers_block(cfg.get(DISABLED_KEY))
return out
def backup_diff(
config_path: Path | str,
backup_path: Path | str,
@@ -318,12 +326,6 @@ def backup_diff(
after_cfg = dict(current_cfg)
apply_servers(after_cfg, backup_servers)
def _server_sections(cfg: dict) -> dict:
out: dict = {"mcpServers": _redact_servers_block(cfg.get("mcpServers"))}
if DISABLED_KEY in cfg:
out[DISABLED_KEY] = _redact_servers_block(cfg.get(DISABLED_KEY))
return out
before_lines = (
json.dumps(_server_sections(current_cfg), indent=2, ensure_ascii=False) + "\n"
).splitlines(keepends=True)
@@ -370,6 +372,46 @@ def restore_backup(
return write_config(p, cfg)
def config_mtime(path: Path | str) -> float | None:
"""Return the file's mtime, or None if the file does not exist."""
try:
return Path(path).stat().st_mtime
except OSError:
return None
def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[str], str]:
"""
Compare original_cfg (what BCC loaded) with the current on-disk state.
Returns:
changed_keys — sorted list of top-level keys whose values differ.
server_diff — masked unified diff of server sections (empty if unchanged
or the file cannot be read).
"""
try:
disk_cfg = load_config(Path(path))
except Exception:
return [], ""
all_keys = set(original_cfg) | set(disk_cfg)
changed_keys = sorted(k for k in all_keys if original_cfg.get(k) != disk_cfg.get(k))
server_diff = ""
if any(k in {"mcpServers", DISABLED_KEY} for k in changed_keys):
before_lines = (
json.dumps(_server_sections(original_cfg), indent=2, ensure_ascii=False) + "\n"
).splitlines(keepends=True)
after_lines = (
json.dumps(_server_sections(disk_cfg), indent=2, ensure_ascii=False) + "\n"
).splitlines(keepends=True)
server_diff = "".join(
difflib.unified_diff(before_lines, after_lines, fromfile="loaded", tofile="on disk now")
)
return changed_keys, server_diff
# --------------------------------------------------------------------------- #
# Paste / import parsing
# --------------------------------------------------------------------------- #