From bcafb2b0ade489346f662bf117d0b42647a56400 Mon Sep 17 00:00:00 2001 From: Justyna Wojtczak Date: Mon, 4 May 2026 15:38:51 +0200 Subject: [PATCH] fix(cli): show paths when ambiguous project names share a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change, `armillary exclude foo` with two cached projects named "foo" (e.g. one under ~/projects_prod/foo, one under ~/RubymineProjects/foo from a different IDE umbrella) errored with: 'foo' is ambiguous: foo, foo. Be more specific. That message is unactionable — both candidates have the same name, so "be more specific" tells the user to do something they can't express through the existing CLI. The same dead-end hits every lifecycle command that uses `_resolve_project_or_report` (exclude, include, archive, activate, purpose, talked, revenue, plus the context service's manual fallback). Fix: when the visible matches contain duplicate names, `summarize_project_matches` now annotates each entry with its home-shortened path: 'foo' is ambiguous: foo (~/projects_prod/foo), foo (~/RubymineProjects/foo). Be more specific. The user can now disambiguate via the Streamlit panel (which operates on paths), or by reading the path and calling `armillary.exclude_service.exclude_project` directly. A first-class `armillary exclude --path

` flag is a natural follow-up but is out of scope for this minimal-message fix. Behavior preserved when names are unique — only the duplicate case adds the path annotation, so existing terse messages don't change. Tests: - `summarize_project_matches` directly exercised across 5 cases: unique names, duplicate names, partial duplicate, truncation, empty. - Existing CLI tests stay green (no message-format regressions). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/armillary/utils.py | 27 ++++++++++++++++--- tests/test_utils.py | 60 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/armillary/utils.py b/src/armillary/utils.py index c9f11b0..dbabba6 100644 --- a/src/armillary/utils.py +++ b/src/armillary/utils.py @@ -112,10 +112,31 @@ def find_projects_by_name(projects: list[Project], project_name: str) -> list[Pr def summarize_project_matches(projects: list[Project], *, limit: int = 5) -> str: - """Return a short human-readable summary of matching project names.""" - names = ", ".join(project.name for project in projects[:limit]) + """Return a short human-readable summary of matching project names. + + When the visible matches contain duplicate names (common with + duplicate umbrellas like ``RubymineProjects/`` + ``projects_prod/`` + holding the same repo), each entry is annotated with its + home-shortened path so the user can disambiguate. Without that + annotation, the message ``'foo' is ambiguous: foo, foo`` gives the + user no way to "be more specific". + """ + if not projects: + return "" + + visible = projects[:limit] + names = [project.name for project in visible] + has_duplicates = len(set(names)) < len(names) + + if has_duplicates: + rendered = ", ".join( + f"{project.name} ({shorten_home(project.path)})" for project in visible + ) + else: + rendered = ", ".join(names) + suffix = "" if len(projects) <= limit else f" (+{len(projects) - limit} more)" - return f"{names}{suffix}" + return f"{rendered}{suffix}" def _load_json( diff --git a/tests/test_utils.py b/tests/test_utils.py index 7526c8b..63ca591 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,9 +2,11 @@ from __future__ import annotations +from datetime import datetime from pathlib import Path -from armillary.utils import load_json_str_list +from armillary.models import Project, ProjectType +from armillary.utils import load_json_str_list, summarize_project_matches def test_load_json_str_list_filters_non_strings(tmp_path: Path) -> None: @@ -13,3 +15,59 @@ def test_load_json_str_list_filters_non_strings(tmp_path: Path) -> None: path.write_text(payload, encoding="utf-8") assert load_json_str_list(path) == ["ok", "still-ok"] + + +def _project(name: str, path: str) -> Project: + p = Path(path) + return Project( + path=p, + name=name, + type=ProjectType.GIT, + umbrella=p.parent, + last_modified=datetime(2026, 5, 4, 12, 0, 0), + metadata=None, + ) + + +def test_summarize_project_matches_unique_names_just_names() -> None: + matches = [ + _project("alpha", "/repos/alpha"), + _project("beta", "/repos/beta"), + ] + assert summarize_project_matches(matches) == "alpha, beta" + + +def test_summarize_project_matches_duplicate_names_show_paths() -> None: + home = str(Path.home()) + matches = [ + _project("foo", f"{home}/projects_prod/foo"), + _project("foo", f"{home}/RubymineProjects/foo"), + ] + out = summarize_project_matches(matches) + # Both names annotated with their home-shortened paths so the + # CLI's "Be more specific" prompt is actually actionable. + assert "foo (~/projects_prod/foo)" in out + assert "foo (~/RubymineProjects/foo)" in out + + +def test_summarize_project_matches_partial_duplicate_annotates_all() -> None: + """When ANY duplicate is in the visible window, every entry gets a + path so the rendered list is consistent and unambiguous.""" + matches = [ + _project("foo", "/a/foo"), + _project("foo", "/b/foo"), + _project("bar", "/c/bar"), + ] + out = summarize_project_matches(matches) + assert "foo (/a/foo)" in out + assert "foo (/b/foo)" in out + assert "bar (/c/bar)" in out + + +def test_summarize_project_matches_truncates_to_limit() -> None: + matches = [_project(f"p{i}", f"/r/p{i}") for i in range(7)] + assert summarize_project_matches(matches, limit=3) == "p0, p1, p2 (+4 more)" + + +def test_summarize_project_matches_empty_returns_empty_string() -> None: + assert summarize_project_matches([]) == ""