fix(projects): don't promote a deleted workspace to a project

The name-based sibling probe can't reach every dead worktree: a dir renamed away
from its repo's prefix (`hermes-salvage-drafts` next to `hermes-agent`) shares
nothing to trim back to, and a scratch dir under /tmp was never a worktree at
all. Those fall through to the path-only heuristic, which reports the cwd as its
own repo root, and Tier 2 promotes it to a top-level project — one that can't be
opened and can only be dismissed by hand.

Gate auto-project promotion on the directory still existing, threaded in as an
injected predicate to keep the builder pure. The guard keys on the directory,
not on git-ness, so a plain non-git folder that's still on disk keeps its
project; callers that can't stat (remote backends) omit it and keep every
candidate. A stale persisted `git_repo_root` gets the same treatment, so a
deleted repo can't resurrect from the recorded value alone.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 00:57:17 -05:00
parent c7fcb73e3d
commit b572ec3dda
3 changed files with 95 additions and 4 deletions

View file

@ -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.

View file

@ -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 (`<repo>/.worktrees/t_<hex>`, 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()

View file

@ -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