diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py index 0958a7696886..5608f2c26259 100644 --- a/tests/tui_gateway/test_project_tree.py +++ b/tests/tui_gateway/test_project_tree.py @@ -180,6 +180,75 @@ def test_persisted_repo_root_used_when_no_live_probe(): assert _lane_ids(project) == ["/repo::branch::main"] +def test_non_git_cwd_preserves_legacy_workspace_grouping(): + # Before first-class Projects, every non-empty session cwd appeared as a + # workspace even when it was not a git repo. Historical sessions must keep + # that grouping instead of falling through to the flat Sessions list. + legacy = _session("/work/notes", title="Research notes") + + tree = pt.build_tree([], [legacy], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["/work/notes"] + project = tree["projects"][0] + assert project["isAuto"] is True + assert project["label"] == "notes" + assert project["sessionCount"] == 1 + assert _lane_ids(project) == ["/work/notes"] + assert tree["scoped_session_ids"] == [legacy["id"]] + + +def test_non_git_windows_cwd_preserves_legacy_workspace_grouping(): + cwd = r"C:\Users\alice\workspace\notes" + legacy = _session(cwd) + + tree = pt.build_tree([], [legacy], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == [cwd] + assert tree["projects"][0]["label"] == "notes" + assert tree["scoped_session_ids"] == [legacy["id"]] + + +def test_equivalent_windows_cwds_collapse_into_one_auto_project(): + sessions = [ + _session("C:/work/notes"), + _session(r"c:\WORK\notes"), + _session("C:/work/notes/"), + ] + + tree = pt.build_tree([], sessions, [], resolve=lambda _cwd: None, hydrate=True) + + assert len(tree["projects"]) == 1 + project = tree["projects"][0] + assert project["id"] == "C:/work/notes" + assert project["sessionCount"] == 3 + assert len(project["repos"]) == 1 + assert len(project["repos"][0]["groups"]) == 1 + assert len(project["repos"][0]["groups"][0]["sessions"]) == 3 + + +def test_windows_path_identity_preserves_explicit_project_priority(): + explicit = _project("p_notes", "Notes", ["C:/Work/Notes"]) + session = _session("c:\\work\\notes\\") + + tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["p_notes"] + assert tree["projects"][0]["sessionCount"] == 1 + assert tree["scoped_session_ids"] == [session["id"]] + + +def test_posix_path_identity_remains_case_sensitive(): + explicit = _project("p_notes", "Notes", ["/Work/Notes"]) + session = _session("/work/notes") + + tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True) + + assert [(p["id"], p["sessionCount"]) for p in tree["projects"]] == [ + ("p_notes", 0), + ("/work/notes", 1), + ] + + def test_explicit_project_claims_sessions_and_beats_auto(): project = _project("p_app", "App", ["/www/app"]) resolve = _resolver( @@ -337,6 +406,41 @@ def test_junk_root_is_dropped_from_the_discovered_tier(): assert tree["projects"] == [] +def test_non_git_cwd_can_group_inside_a_junk_repo_subtree(): + # Repo discovery rejects the full state subtree, but a selected non-git + # descendant may be an intentional workspace carried over from the old UI. + workspace = _session("/home/test/.hermes/workspaces/notes") + + tree = pt.build_tree( + [], + [workspace], + [], + resolve=lambda _cwd: None, + hydrate=True, + is_junk_root=lambda path: path.startswith("/home/test/.hermes"), + is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"}, + ) + + assert [p["id"] for p in tree["projects"]] == ["/home/test/.hermes/workspaces/notes"] + assert tree["scoped_session_ids"] == [workspace["id"]] + + +def test_broad_default_non_git_cwd_stays_unscoped(): + detached = _session("/home/test/.hermes") + + tree = pt.build_tree( + [], + [detached], + [], + resolve=lambda _cwd: None, + hydrate=True, + is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"}, + ) + + assert tree["projects"] == [] + assert detached["id"] not in tree["scoped_session_ids"] + + def test_colliding_repo_basenames_disambiguate_labels(): resolve = _resolver( { diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index ded460f28115..bb18473dc651 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -60,6 +60,39 @@ def _segments(path: str) -> list[str]: return [s for s in re.split(r"[/\\]", (path or "").rstrip("/\\")) if s] +def _is_windows_path(path: str) -> bool: + value = (path or "").strip() + return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\\\", "//")) + + +def _comparison_segments(path: str) -> list[str]: + """Path segments suitable for identity comparisons on any host. + + Windows paths remain case-insensitive even when tests or remote backends run + on POSIX. Display paths and emitted IDs keep their original spelling. + """ + segs = _segments(path) + return [segment.casefold() for segment in segs] if _is_windows_path(path) else segs + + +def _path_key(path: str) -> str: + """Canonical comparison key (separator/trailing-slash agnostic).""" + return "/".join(_comparison_segments(path)) + + +def _lane_key(path_or_lane: str) -> str: + """Canonicalize only the path portion of a lane id. + + Branch labels remain byte-preserved; repo/worktree paths follow platform path + identity so equivalent Windows spellings do not create duplicate lanes. + """ + for marker in ("::branch::", "::kanban"): + if marker in path_or_lane: + root, suffix = path_or_lane.split(marker, 1) + return f"{_path_key(root)}{marker}{suffix}" + return _path_key(path_or_lane) + + def base_name(path: str) -> str: segs = _segments(path) return segs[-1] if segs else "" @@ -73,8 +106,8 @@ def kanban_worktree_dir(path: str) -> Optional[str]: def _is_path_under(folder: str, target: str) -> bool: """True when ``target`` equals ``folder`` or is nested under it (segment-wise).""" - f = _segments(folder) - t = _segments(target) + f = _comparison_segments(folder) + t = _comparison_segments(target) if not f or len(f) > len(t): return False return all(f[i] == t[i] for i in range(len(f))) @@ -249,7 +282,8 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool if not placement: continue - entry = lanes.get(placement["lane_key"]) + lane_identity = _lane_key(placement["lane_key"]) + entry = lanes.get(lane_identity) if entry is None: entry = { "group": { @@ -264,7 +298,7 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool "repo_label": placement["repo_label"], "repo_path": placement["repo_path"], } - lanes[placement["lane_key"]] = entry + lanes[lane_identity] = entry entry["group"]["sessions"].append(session) repos: dict[str, dict] = {} @@ -275,7 +309,8 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool if not hydrate: group["sessions"] = [] - repo = repos.get(entry["repo_key"]) + repo_identity = _path_key(entry["repo_key"]) + repo = repos.get(repo_identity) if repo is None: repo = { "id": entry["repo_key"], @@ -284,7 +319,7 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool "groups": [], "sessionCount": 0, } - repos[entry["repo_key"]] = repo + repos[repo_identity] = repo repo["groups"].append(group) repo["sessionCount"] += count @@ -311,7 +346,12 @@ def _seed_folder_repos( empty) project body. Folders already covered by a session-derived repo (same git root) are left untouched. """ - seen = {r["id"] for r in repos} | {r["path"] for r in repos if r.get("path")} + seen = { + _path_key(value) + for repo in repos + for value in (repo.get("id"), repo.get("path")) + if value + } seeded = list(repos) for folder in folders or []: @@ -320,10 +360,11 @@ def _seed_folder_repos( continue info = resolve(raw) if resolve else None root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw) - if not root or root in seen: + root_key = _path_key(root) + if not root_key or root_key in seen: continue seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0}) - seen.add(root) + seen.add(root_key) if len(seeded) != len(repos): _disambiguate_labels(seeded) @@ -347,7 +388,7 @@ class _FolderIndex: self._by_path: dict[str, tuple[dict, int]] = {} for project in projects: for folder in project.get("folders") or []: - segs = _segments(folder.get("path") or "") + segs = _comparison_segments(folder.get("path") or "") if not segs: continue key = "/".join(segs) @@ -359,7 +400,7 @@ class _FolderIndex: def match(self, target: str) -> tuple[Optional[dict], int]: """Owning project for ``target`` by longest ancestor folder, + its depth.""" - segs = _segments(target or "") + segs = _comparison_segments(target or "") # Longest prefix first → deepest (most specific) folder wins. for end in range(len(segs), 0, -1): hit = self._by_path.get("/".join(segs[:end])) @@ -430,6 +471,7 @@ def build_tree( preview_limit: int = 3, hydrate: bool = False, is_junk_root: Optional[Callable[[str], bool]] = None, + is_junk_cwd: Optional[Callable[[str], bool]] = None, ) -> dict: """Build the authoritative project tree. @@ -437,9 +479,11 @@ def build_tree( ``sessions`` are projected session-row dicts (must carry ``id``, ``cwd``, ``git_branch``, ``git_repo_root``, ``started_at``, ``last_active``). ``discovered_repos`` are ``{"root", "label", "sessions", "last_active"}``. - ``is_junk_root`` flags roots that must never become an AUTO project (the - bare home dir, the HERMES_HOME subtree) — their sessions fall through to the - flat Recents list. User-created projects are honored regardless. + ``is_junk_root`` flags git roots that must never become an AUTO project (the + bare home dir, the HERMES_HOME subtree). ``is_junk_cwd`` is the narrower + policy for non-git session folders: selected descendants may be intentional + workspaces even when their parent tree contains Hermes state. User-created + projects are honored regardless. Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When ``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but @@ -448,6 +492,7 @@ def build_tree( """ active_projects = [p for p in projects if not p.get("archived")] _junk = is_junk_root or (lambda _root: False) + _junk_cwd = is_junk_cwd or (lambda _cwd: False) folder_index = _FolderIndex(active_projects) by_project: dict[str, list[dict]] = {} @@ -493,34 +538,67 @@ def build_tree( ) ) - # Tier 2: auto projects from leftover sessions, one per common git repo root. - by_repo: dict[str, list[dict]] = {} + # Tier 2: auto projects from leftover sessions. Prefer the common git repo + # root, then fall back to the session cwd for historical/non-git workspaces. + # The pre-Projects desktop grouped every non-empty cwd; keeping that fallback + # prevents upgrades from flattening those sessions into Recents. + by_auto_root: dict[str, dict] = {} + + def _add_auto(root: str, session: dict) -> None: + key = _path_key(root) + if not key: + return + bucket = by_auto_root.setdefault(key, {"root": root, "sessions": []}) + bucket["sessions"].append(session) + for session in unowned: root = _session_repo_root(session, resolve) if root: - by_repo.setdefault(root, []).append(session) + # A real git root uses the stricter repo policy. Do not reinterpret a + # filtered internal repo as a cwd-only project. + if not _junk(root): + _add_auto(root, session) + continue + + cwd = (session.get("cwd") or "").strip() + if not cwd or _junk_cwd(cwd): + continue + placement = _place( + cwd, + (session.get("git_branch") or "").strip(), + resolve, + (session.get("git_repo_root") or "").strip(), + ) + if placement: + _add_auto(placement["repo_key"], session) seen: set[str] = set() - for repo_root, repo_sessions in by_repo.items(): - # The home dir / HERMES_HOME subtree is config + state, never a project; - # its sessions stay loose in Recents (not scoped to a phantom project). - if _junk(repo_root): - continue - repos = _build_repos(repo_sessions, resolve, hydrate) - repo_node = next((r for r in repos if r["id"] == repo_root or r["path"] == repo_root), None) + for bucket in by_auto_root.values(): + auto_root = bucket["root"] + auto_sessions = bucket["sessions"] + auto_key = _path_key(auto_root) + repos = _build_repos(auto_sessions, resolve, hydrate) + repo_node = next( + ( + repo + for repo in repos + if _path_key(repo.get("id") or repo.get("path") or "") == auto_key + ), + None, + ) if repo_node is None: continue - seen.add(repo_root) - scoped_ids.extend(s["id"] for s in repo_sessions if s.get("id")) + seen.add(auto_key) + scoped_ids.extend(s["id"] for s in auto_sessions if s.get("id")) result.append( _project_node( - pid=repo_root, - label=base_name(repo_root) or repo_root, - path=repo_root, + pid=auto_root, + label=base_name(auto_root) or auto_root, + path=auto_root, repos=repos, session_count=repo_node["sessionCount"], - last_active=_last_active(repo_sessions), - preview_sessions=_previews(repo_sessions), + last_active=_last_active(auto_sessions), + preview_sessions=_previews(auto_sessions), is_auto=True, ) ) @@ -533,9 +611,10 @@ def build_tree( continue info = resolve(raw_root) if resolve else None root = (info or {}).get("repo_root") or raw_root - if root in seen or _junk(root) or _project_for_path(folder_index, root): + root_key = _path_key(root) + if root_key in seen or _junk(root) or _project_for_path(folder_index, root): continue - seen.add(root) + seen.add(root_key) label = repo.get("label") or base_name(root) or root result.append( _project_node( diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 58e8e1ce16d9..192cb6c0f9fa 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10920,6 +10920,25 @@ def _is_repo_junk(root: str) -> bool: return real == home or real == hermes_home or real.startswith(hermes_home + os.sep) +def _is_session_cwd_junk(cwd: str) -> bool: + """A non-git cwd that should stay in flat Recents rather than auto-group. + + Unlike discovered git roots, an explicitly selected descendant of + HERMES_HOME may be an intentional prose/data workspace. The pre-Projects + desktop surfaced every such cwd, so exclude only the two broad defaults + that would create catch-all projects. + """ + if not cwd: + return True + + from hermes_constants import get_hermes_home + + real = os.path.normcase(os.path.realpath(cwd)) + home = os.path.normcase(os.path.realpath(os.path.expanduser("~"))) + hermes_home = os.path.normcase(os.path.realpath(str(get_hermes_home()))) + return real == home or real == hermes_home + + def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dict]: """Merge filesystem-scanned repos (cached) with session-derived repo roots. @@ -11121,6 +11140,7 @@ def _build_project_tree( preview_limit=preview_limit, hydrate=hydrate, is_junk_root=_is_repo_junk, + is_junk_cwd=_is_session_cwd_junk, ) return tree, active_id