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
+116
View File
@@ -926,6 +926,80 @@ class RestoreDialog(QDialog):
return item.data(Qt.ItemDataRole.UserRole)
# --------------------------------------------------------------------------- #
# Stale-file conflict dialog
# Shown when the config file was modified on disk between load and save.
# --------------------------------------------------------------------------- #
class StaleDialog(QDialog):
MERGE = 0
OVERWRITE = 1
def __init__(
self,
parent,
profile_path: str,
changed_keys: list[str],
server_diff: str,
):
super().__init__(parent)
self.setWindowTitle("Config changed on disk")
self.resize(680, 420)
self._choice = self.MERGE
v = QVBoxLayout(self)
v.setSpacing(8)
intro = QLabel(f"<b>{profile_path}</b> was modified on disk since you loaded it.")
intro.setWordWrap(True)
v.addWidget(intro)
if changed_keys:
kl = QLabel("Changed keys: " + ", ".join(f"<code>{k}</code>" for k in changed_keys))
kl.setObjectName("muted")
kl.setWordWrap(True)
v.addWidget(kl)
if server_diff:
v.addWidget(QLabel("Server section changes (on disk vs loaded):"))
dv = QPlainTextEdit()
dv.setObjectName("diag")
dv.setReadOnly(True)
dv.setPlainText(server_diff)
v.addWidget(dv, 1)
else:
note = QLabel("(Server sections unchanged — the external edit is in other keys.)")
note.setObjectName("muted")
v.addWidget(note)
desc = QLabel(
"<b>Merge &amp; save</b> applies your server edits on top of the current file "
"on disk — both sets of changes are kept. "
"<b>Overwrite</b> saves your in-memory state, discarding external changes."
)
desc.setObjectName("muted")
desc.setWordWrap(True)
v.addWidget(desc)
btns = QDialogButtonBox()
merge_btn = btns.addButton("Merge && save", QDialogButtonBox.ButtonRole.AcceptRole)
merge_btn.setObjectName("primary")
overwrite_btn = btns.addButton(
"Overwrite anyway", QDialogButtonBox.ButtonRole.DestructiveRole
)
btns.addButton(QDialogButtonBox.StandardButton.Cancel)
merge_btn.clicked.connect(lambda: self._choose(self.MERGE))
overwrite_btn.clicked.connect(lambda: self._choose(self.OVERWRITE))
btns.rejected.connect(self.reject)
v.addWidget(btns)
def _choose(self, choice: int) -> None:
self._choice = choice
self.accept()
def choice(self) -> int:
return self._choice
# --------------------------------------------------------------------------- #
# Paste-JSON dialog
# --------------------------------------------------------------------------- #
@@ -1016,6 +1090,7 @@ class MainWindow(QMainWindow):
self.full_config: dict = {}
self.servers: list[core.ServerEntry] = []
self.current_profile: core.Profile | None = None
self._loaded_mtime: float | None = None
self.dirty = False
self._suppress_table = False
self._suppress_sel = False
@@ -1291,6 +1366,7 @@ class MainWindow(QMainWindow):
return
self.full_config = cfg
repaired = True
self._loaded_mtime = core.config_mtime(profile.path)
self.current_profile = profile
self.servers = core.extract_servers(self.full_config)
self.dirty = False
@@ -1627,12 +1703,52 @@ class MainWindow(QMainWindow):
if not self._validate():
QMessageBox.warning(self, "Can't save yet", "Fix the highlighted problem first.")
return
# Stale-file check: if the file changed on disk since we loaded it, prompt.
disk_mtime = core.config_mtime(self.current_profile.path)
if (
disk_mtime is not None
and self._loaded_mtime is not None
and disk_mtime != self._loaded_mtime
):
changed_keys, server_diff = core.external_change_summary(
self.full_config, self.current_profile.path
)
dlg = StaleDialog(self, str(self.current_profile.path), changed_keys, server_diff)
if not dlg.exec():
return # user cancelled
if dlg.choice() == StaleDialog.MERGE:
try:
fresh = core.load_config(self.current_profile.path)
except Exception as e:
QMessageBox.critical(self, "Reload failed", str(e))
return
core.apply_servers(fresh, self.servers)
try:
backup = core.write_config(self.current_profile.path, fresh)
except Exception as e:
QMessageBox.critical(self, "Save failed", str(e))
return
self.full_config = fresh
self._loaded_mtime = core.config_mtime(self.current_profile.path)
self.current_profile.config_exists = True
self.dirty = False
self.save_btn.setEnabled(False)
bnote = f" · backup: {backup.name}" if backup else " · (new file)"
self.status.setText(
f"Merged & saved {self.current_profile.path}{bnote}"
f" · Restart {self.current_profile.label} to apply."
)
return
# else OVERWRITE: fall through to normal write
core.apply_servers(self.full_config, self.servers)
try:
backup = core.write_config(self.current_profile.path, self.full_config)
except Exception as e:
QMessageBox.critical(self, "Save failed", str(e))
return
self._loaded_mtime = core.config_mtime(self.current_profile.path)
self.current_profile.config_exists = True
self.dirty = False
self.save_btn.setEnabled(False)