From 47c95130f793038e6cfaf3a33ecf03273ada83f7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 20:31:09 -0500 Subject: [PATCH] fix(project-tree): absorb deleted-worktree sessions into the parent home checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A linked worktree at - that has been deleted leaves its sessions with a dangling cwd: the git probe fails and no git_repo_root was persisted, so the path-only heuristic promoted each one to its own standalone project. Every abandoned worktree added another phantom entry to the sidebar, and they accumulate indefinitely. Recover the parent by trimming one - at a time off the basename and returning the first sibling that resolves. A deleted worktree has no checkout to return to, so its sessions land in the parent's trunk lane rather than a lane keyed by the dead path. Live worktrees are unaffected — they resolve through the git probe and keep their own lane. --- tui_gateway/project_tree.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index 3f243153964..059793a8949 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)