diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py index 8195ea5e4fd5..a7e73703f948 100644 --- a/tests/tui_gateway/test_project_tree.py +++ b/tests/tui_gateway/test_project_tree.py @@ -505,6 +505,54 @@ def test_deleted_sibling_worktree_subdir_folds_into_parent_home_checkout(): assert len(project["repos"][0]["groups"][0]["sessions"]) == 2 +def test_deleted_unrelated_workspace_does_not_become_a_project(): + # A deleted dir the sibling probe can't reach by name (`hermes-salvage-drafts` + # shares no prefix with `hermes-agent`; `/tmp/scratch` was never a worktree) + # must not be promoted to a phantom project — it can never be opened and can + # only be dismissed by hand. The session stays in flat Recents. + resolve = _resolver({"/www/hermes-agent": ("/www/hermes-agent", "/www/hermes-agent")}) + sessions = [ + _session("/www/hermes-agent", branch="main"), + _session("/www/hermes-salvage-drafts/apps/desktop"), + _session("/tmp/scratch"), + ] + on_disk = {"/www/hermes-agent"} + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True, exists=lambda p: p in on_disk) + + assert [p["id"] for p in tree["projects"]] == ["/www/hermes-agent"] + + +def test_existing_non_git_workspace_still_becomes_a_project(): + # The existence guard keys on the DIRECTORY, not on git-ness: a plain folder + # that's still on disk is a legitimate workspace and must keep its project. + sessions = [_session("/www/notes")] + + tree = pt.build_tree([], sessions, [], lambda _cwd: None, hydrate=True, exists=lambda _p: True) + + assert [p["id"] for p in tree["projects"]] == ["/www/notes"] + + +def test_stale_persisted_repo_root_does_not_become_a_project(): + # A session carrying a git_repo_root whose repo has since been deleted must + # not resurrect it as a project on the strength of the persisted value alone. + sessions = [_session("/tmp/gone/sub", repo_root="/tmp/gone")] + + tree = pt.build_tree([], sessions, [], lambda _cwd: None, hydrate=True, exists=lambda _p: False) + + assert tree["projects"] == [] + + +def test_exists_defaults_to_keeping_everything(): + # Omitting `exists` (remote backends, which can't stat) preserves the old + # behavior: guessing "gone" would wrongly hide a project on the other host. + sessions = [_session("/remote/workspace")] + + tree = pt.build_tree([], sessions, [], lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["/remote/workspace"] + + def test_sibling_probe_is_bounded(): # Each miss costs a git invocation, and this runs per session, so a deeply # nested unresolvable cwd must not fan out into an unbounded probe storm. diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index c6ba0c0969b4..545600eaf6cd 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -34,6 +34,13 @@ from typing import Any, Callable, Optional # cwd is not in a git repo (or cannot be probed, e.g. a remote backend). Resolve = Callable[[str], Optional[dict]] +# A "does this directory still exist?" predicate, injected for the same reason +# ``Resolve`` is: the builder stays pure and unit-testable. Defaults to assuming +# everything exists, which preserves the previous behavior for callers that +# can't stat (remote backends), where guessing "gone" would wrongly hide a +# project that lives on the other host. +Exists = Callable[[str], bool] + # Only KANBAN-TASK worktrees (`/.worktrees/t_`, the `t_…` id kanban_db # mints) collapse into one lane; user-named "New worktree" dirs under # `.worktrees/` stay as their own lanes. @@ -526,6 +533,7 @@ def build_tree( hydrate: bool = False, is_junk_root: Optional[Callable[[str], bool]] = None, is_junk_cwd: Optional[Callable[[str], bool]] = None, + exists: Optional[Exists] = None, ) -> dict: """Build the authoritative project tree. @@ -537,7 +545,10 @@ def build_tree( 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. + projects are honored regardless. ``exists`` reports whether a directory is + still on disk, so a session whose workspace was DELETED (a removed worktree, + a scratch dir under /tmp) doesn't get promoted to a phantom AUTO project; + omit it (remote backends) to keep every candidate. Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When ``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but @@ -547,6 +558,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) + _exists = exists or (lambda _path: True) folder_index = _FolderIndex(active_projects) by_project: dict[str, list[dict]] = {} @@ -609,8 +621,10 @@ def build_tree( root = _session_repo_root(session, resolve) if root: # 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): + # filtered internal repo as a cwd-only project. A root that no longer + # exists is a stale persisted value (the repo was deleted after the + # session ran) and must not resurrect as a project. + if not _junk(root) and _exists(root): _add_auto(root, session) continue @@ -623,7 +637,13 @@ def build_tree( resolve, (session.get("git_repo_root") or "").strip(), ) - if placement: + # A placement that only echoes back the unresolvable cwd is the + # path-only heuristic guessing — it never found a repo. When that dir is + # also gone from disk (a deleted worktree whose name shares no prefix + # with its parent, a removed /tmp scratch dir), promoting it mints a + # phantom project that can never be opened and can only be dismissed by + # hand. The session keeps its place in flat Recents. + if placement and _exists(placement["repo_key"]): _add_auto(placement["repo_key"], session) seen: set[str] = set() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 16daf7b9676d..09a1774167fd 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -13888,12 +13888,34 @@ def _project_tree_inputs( return sessions, projects, discovered, active_id +# Per-build memo for `_dir_exists_cached`. Cleared at the top of every +# `_build_project_tree`, so a dir created or deleted between sidebar refreshes +# is seen on the next one. +_DIR_EXISTS_CACHE: dict[str, bool] = {} + + +def _dir_exists_cached(path: str) -> bool: + """``os.path.isdir`` for the project tree, memoized per build. + + ``build_tree`` asks per SESSION, not per distinct path, so a power user with + hundreds of sessions across a handful of dirs would otherwise fire hundreds + of redundant stats on every sidebar open. The memo is per build, so a dir + created or deleted between refreshes is picked up on the next one. + """ + hit = _DIR_EXISTS_CACHE.get(path) + if hit is None: + hit = os.path.isdir(path) + _DIR_EXISTS_CACHE[path] = hit + return hit + + def _build_project_tree( db, *, preview_limit: int, hydrate: bool, session_limit: int, include_discovered: bool ) -> tuple[dict, str | None]: """Gather inputs and run the one authoritative builder. Returns (tree, active_id).""" from tui_gateway import project_tree + _DIR_EXISTS_CACHE.clear() sessions, projects, discovered, active_id = _project_tree_inputs( db, session_limit, include_discovered=include_discovered ) @@ -13906,6 +13928,7 @@ def _build_project_tree( hydrate=hydrate, is_junk_root=_is_repo_junk, is_junk_cwd=_is_session_cwd_junk, + exists=_dir_exists_cached, ) return tree, active_id