From 0191a93eb954df0aa60d864ddebdb3c2500e382f Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 4 Aug 2026 03:29:13 +0000 Subject: [PATCH] fix: disambiguate project profiles that share a directory basename (#74) discover_project_configs labelled every project by basename alone, so ~/work/app/.mcp.json and ~/personal/app/.mcp.json both read as "Project: app". Paths dedupe correctly, so both profiles existed -- they were just indistinguishable in the picker, and picking the wrong one meant editing, backing up, and writing the wrong repo's config. api/web/app/ server/client as repo names make this common. - disambiguate_project_labels(dirs): pure, testable. Labels stay "Project: " until a basename collides, then only the colliding ones widen toward the root one component at a time ("Project: work/app" vs "Project: personal/app"), widening further if the parent also collides. The common no-collision case is unchanged. - Full path goes in the combo item's ToolTipRole, so a profile is always verifiable by hover regardless of label. - Tightened the "is this a project config?" check: the docstring claimed a top-level object but the code only checked is_file(), so a .mcp.json that was a JSON array or garbage still became a profile and only failed on open. It now must parse (strict) to a dict, else it's skipped. Tests: +6 (no-collision basename, colliding widen, deeper widen, and discover_project_configs disambiguation + skipping array/garbage/missing). 463 passed, ruff clean. Closes #74 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE --- bcc.py | 5 +++ bcc_core.py | 79 ++++++++++++++++++++++++++++++++++++++++------ tests/test_core.py | 68 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 10 deletions(-) diff --git a/bcc.py b/bcc.py index cd71313..dc224f7 100644 --- a/bcc.py +++ b/bcc.py @@ -2043,6 +2043,11 @@ class MainWindow(QMainWindow): for p in self.profiles: tag = "" if p.config_exists else " (no config yet)" self.profile_combo.addItem(f"{p.label}{tag}") + # Full path in the tooltip so a profile is always verifiable even + # when two labels look alike (e.g. two repos both named "app"). + self.profile_combo.setItemData( + self.profile_combo.count() - 1, str(p.path), Qt.ItemDataRole.ToolTipRole + ) self.profile_combo.blockSignals(False) if self.profiles: self.profile_combo.setCurrentIndex(0) diff --git a/bcc_core.py b/bcc_core.py index c562034..d709b28 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -648,16 +648,67 @@ def msix_warning_text( ) +def _dir_name_parts(directory: Path) -> list[str]: + """The path components of `directory` with the filesystem anchor dropped. + + `/home/me/work/app` -> ['home', 'me', 'work', 'app']; `C:\\src\\app` -> + ['src', 'app']. Falls back to the raw name for an anchor-only path so a + label is never empty. + """ + parts = [p for p in directory.parts if p != directory.anchor] + return parts or [directory.name or str(directory)] + + +def disambiguate_project_labels(dirs: list[Path]) -> dict[Path, str]: + """Map each project directory to a unique `Project: ` label. + + The suffix is the directory's basename, widened toward the root one + component at a time ONLY for directories whose current suffix collides with + another in the set -- so the common case stays `Project: app`, and two repos + both named `app` become `Project: work/app` and `Project: personal/app` + (widening further if even that collides). Deterministic; expects `dirs` to + already be de-duplicated by path (identical paths can't be told apart). + """ + depth = {d: 1 for d in dirs} + max_depth = {d: len(_dir_name_parts(d)) for d in dirs} + + def suffix(d: Path) -> str: + return "/".join(_dir_name_parts(d)[-depth[d] :]) + + # Widen colliding groups until every label is unique or no group can widen. + while True: + groups: dict[str, list[Path]] = {} + for d in dirs: + groups.setdefault(suffix(d), []).append(d) + widened = False + for members in groups.values(): + if len(members) > 1: + for d in members: + if depth[d] < max_depth[d]: + depth[d] += 1 + widened = True + if not widened: + break + + return {d: f"Project: {suffix(d)}" for d in dirs} + + def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]: """ Find project-scope `.mcp.json` configs known to Claude Code. `~/.claude.json` keeps a `projects` map keyed by absolute project directory path (that's what the CLI writes as it's used in each repo). - Any project whose directory has a `.mcp.json` file next to it -- a - standalone file with a top-level `mcpServers` object, same shape BCC - already edits -- is surfaced here as its own profile so it can be opened - via 'Add config...' without hunting for the path by hand. + Any project whose directory has a `.mcp.json` file that parses to a JSON + object -- the same shape BCC already edits -- is surfaced here as its own + profile so it can be opened via 'Add config...' without hunting for the + path by hand. A `.mcp.json` that is missing, unreadable, or not a JSON + object (an array, a string, garbage) is skipped rather than surfaced as a + profile that only fails when opened. + + Labels are disambiguated so two repos with the same basename don't both + read as `Project: app` and let the user edit the wrong one (see + `disambiguate_project_labels`). Fails quiet: a missing/unreadable/malformed `claude_json_path`, or a `projects` value that isn't a dict, just yields an empty list rather than @@ -670,14 +721,22 @@ def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profil projects = cfg.get("projects") if not isinstance(projects, dict): return [] - out: list[Profile] = [] + + valid_dirs: list[Path] = [] for key in sorted(k for k in projects if isinstance(k, str)): mcp_path = Path(key) / ".mcp.json" - if mcp_path.is_file(): - out.append( - Profile(label=f"Project: {Path(key).name}", path=mcp_path, config_exists=True) - ) - return out + if not mcp_path.is_file(): + continue + try: + data = load_config(mcp_path) # strict; raises on bad JSON / non-object + except Exception: + continue + if not isinstance(data, dict): + continue + valid_dirs.append(Path(key)) + + labels = disambiguate_project_labels(valid_dirs) + return [Profile(label=labels[d], path=d / ".mcp.json", config_exists=True) for d in valid_dirs] def discover_profiles() -> list[Profile]: diff --git a/tests/test_core.py b/tests/test_core.py index 1b103dc..fd36e80 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2991,3 +2991,71 @@ def test_server_sections_and_change_summary_follow_a_custom_key(tmp_path): changed_keys, diff = c.external_change_summary(loaded, p, _VSCODE) assert "servers" in changed_keys assert diff # a server-section change under the custom key is diffed + + +# --------------------------------------------------------------------------- # +# Project profile label disambiguation (issue #74) +# --------------------------------------------------------------------------- # +def test_disambiguate_labels_no_collision_uses_basename(): + dirs = [Path("/home/me/work/api"), Path("/home/me/work/web")] + labels = c.disambiguate_project_labels(dirs) + assert labels[Path("/home/me/work/api")] == "Project: api" + assert labels[Path("/home/me/work/web")] == "Project: web" + + +def test_disambiguate_labels_widens_only_colliding_basenames(): + dirs = [ + Path("/home/me/work/app"), + Path("/home/me/personal/app"), + Path("/home/me/notes"), + ] + labels = c.disambiguate_project_labels(dirs) + # the two "app"s widen by one parent; the unique "notes" stays plain + assert labels[Path("/home/me/work/app")] == "Project: work/app" + assert labels[Path("/home/me/personal/app")] == "Project: personal/app" + assert labels[Path("/home/me/notes")] == "Project: notes" + + +def test_disambiguate_labels_widens_further_when_parent_also_collides(): + dirs = [Path("/a/x/app"), Path("/b/x/app")] + labels = c.disambiguate_project_labels(dirs) + assert labels[Path("/a/x/app")] == "Project: a/x/app" + assert labels[Path("/b/x/app")] == "Project: b/x/app" + + +def _write_project(tmp_path, name, mcp_content): + d = tmp_path / name + d.mkdir(parents=True) + if mcp_content is not None: + (d / ".mcp.json").write_text(mcp_content, encoding="utf-8") + return d + + +def _claude_json_with_projects(tmp_path, dirs): + cj = tmp_path / ".claude.json" + cj.write_text(json.dumps({"projects": {str(d): {} for d in dirs}}), encoding="utf-8") + return cj + + +def test_discover_project_configs_disambiguates_same_basename(tmp_path): + d1 = _write_project(tmp_path / "work", "app", '{"mcpServers": {}}') + d2 = _write_project(tmp_path / "personal", "app", '{"mcpServers": {}}') + cj = _claude_json_with_projects(tmp_path, [d1, d2]) + profiles = c.discover_project_configs(cj) + labels = sorted(p.label for p in profiles) + assert labels == ["Project: personal/app", "Project: work/app"] + + +def test_discover_project_configs_skips_non_object_and_garbage(tmp_path): + good = _write_project(tmp_path, "good", '{"mcpServers": {}}') + array = _write_project(tmp_path, "arr", "[1, 2, 3]") + garbage = _write_project(tmp_path, "junk", "not json at all") + missing = tmp_path / "nofile" + missing.mkdir() + cj = _claude_json_with_projects(tmp_path, [good, array, garbage, missing]) + profiles = c.discover_project_configs(cj) + paths = {str(p.path) for p in profiles} + assert str(good / ".mcp.json") in paths + assert str(array / ".mcp.json") not in paths + assert str(garbage / ".mcp.json") not in paths + assert str(missing / ".mcp.json") not in paths -- 2.54.0