diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py index cd0424c5d5a5..96cb49b05b4e 100644 --- a/tests/tui_gateway/test_project_tree.py +++ b/tests/tui_gateway/test_project_tree.py @@ -466,6 +466,26 @@ def test_broad_default_non_git_cwd_stays_unscoped(): assert detached["id"] not in tree["scoped_session_ids"] +def test_deleted_sibling_worktree_folds_into_parent_home_checkout(): + # A deleted - worktree leaves its session with an unresolvable + # cwd and no persisted root. It joins the parent's trunk lane — no dead-path + # lane, no phantom project. + resolve = _resolver({"/www/hermes-agent": ("/www/hermes-agent", "/www/hermes-agent")}) + sessions = [ + _session("/www/hermes-agent", branch="main"), + _session("/www/hermes-agent-session-links"), + ] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + project = tree["projects"][0] + + assert [p["id"] for p in tree["projects"]] == ["/www/hermes-agent"] + assert _lane_ids(project) == ["/www/hermes-agent::branch::main"] + main = project["repos"][0]["groups"][0] + assert main["isMain"] and main["path"] == "/www/hermes-agent" + assert len(main["sessions"]) == 2 + + def test_colliding_repo_basenames_disambiguate_labels(): resolve = _resolver( { diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index 3f243153964c..059793a8949d 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -41,6 +41,11 @@ _KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$") _TRUNK_BRANCHES = {"main", "master", "trunk", "develop"} DEFAULT_BRANCH_LABEL = "main" +# How many sibling candidates to try when recovering a deleted worktree's parent +# repo (see ``_probe_sibling_worktree``). Each miss costs a git probe, so keep it +# tight — real suffixes are one or two segments. +_MAX_SIBLING_PROBES = 4 + def _branch_lane_id(repo_root: str, branch: str = "") -> str: """The one definition of a main-checkout lane id (must match the desktop).""" @@ -146,6 +151,24 @@ def _placement( } +def _probe_sibling_worktree(cwd: str, resolve: Resolve) -> str: + """The parent repo root of a deleted ``-`` worktree, else ``""``. + + A deleted worktree dir can't be probed, so walk back up its name — trimming + one ``-`` at a time — and return the first sibling that resolves. + Probes are bounded and served from the shared git-probe cache. + """ + parts = base_name(cwd).split("-") + floor = max(0, len(parts) - 1 - _MAX_SIBLING_PROBES) + + for i in range(len(parts) - 1, floor, -1): + info = resolve(_with_base_name(cwd, "-".join(parts[:i]))) + if info and info.get("repo_root"): + return (info["repo_root"] or "").strip() + + return "" + + def _place_by_heuristic(path: str) -> Optional[dict]: """Path-only fallback when there is no git probe and no persisted root.""" base = base_name(path) @@ -195,6 +218,14 @@ def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: st b = (branch or "").strip() or DEFAULT_BRANCH_LABEL return _placement(persisted_root, _branch_lane_id(persisted_root, b), b, persisted_root, True, False) + # Unresolvable cwd: a deleted ``-`` worktree still belongs to + # its parent. It has no checkout to return to, so absorb it into the trunk + # lane rather than stranding a dead-path lane in the project forever. + sibling_root = _probe_sibling_worktree(cwd, resolve) if resolve else "" + if sibling_root: + b = (branch or "").strip() or DEFAULT_BRANCH_LABEL + return _placement(sibling_root, _branch_lane_id(sibling_root, b), b, sibling_root, True, False) + return _place_by_heuristic(cwd)