From c7fcb73e3d9eee0929a8c3397165e6f1e76f045d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 00:56:19 -0500 Subject: [PATCH] fix(projects): fold deleted-worktree subdirs into their parent repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_probe_sibling_worktree` recovers a deleted `-` worktree by trimming its name back to a sibling that still resolves, but it only trimmed the LEAF segment. A session's cwd is usually a subdir of the worktree (`-/apps/desktop`), whose basename shares nothing with the repo, so the probe no-oped and the dead path fell through to the path-only heuristic — which minted it as its own main repo root, and then as a top-level project. Walk the ancestors, deepest first, with the probe budget shared across the whole walk so a deeply nested cwd can't fan out into a probe storm. --- tests/tui_gateway/test_project_tree.py | 32 +++++++++++++++++++++++ tui_gateway/project_tree.py | 36 ++++++++++++++++++++------ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py index 96cb49b05b4e..8195ea5e4fd5 100644 --- a/tests/tui_gateway/test_project_tree.py +++ b/tests/tui_gateway/test_project_tree.py @@ -486,6 +486,38 @@ def test_deleted_sibling_worktree_folds_into_parent_home_checkout(): assert len(main["sessions"]) == 2 +def test_deleted_sibling_worktree_subdir_folds_into_parent_home_checkout(): + # Same as above, but the session's cwd is a SUBDIR of the deleted worktree + # (an agent that cd-ed into `-/apps/desktop`). The leaf name + # ("desktop") shares nothing with the repo, so the sibling probe has to walk + # the ancestors — otherwise the dead path is minted as its own project. + resolve = _resolver({"/www/hermes-agent": ("/www/hermes-agent", "/www/hermes-agent")}) + sessions = [ + _session("/www/hermes-agent", branch="main"), + _session("/www/hermes-agent-guiperf/apps/desktop"), + ] + + tree = pt.build_tree([], sessions, [], resolve, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["/www/hermes-agent"] + project = tree["projects"][0] + assert _lane_ids(project) == ["/www/hermes-agent::branch::main"] + assert len(project["repos"][0]["groups"][0]["sessions"]) == 2 + + +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. + probed = [] + + def resolve(cwd): + probed.append(cwd) + return None + + assert pt._probe_sibling_worktree("/a-b-c/d-e-f/g-h-i/j-k-l", resolve) == "" + assert len(probed) <= pt._MAX_SIBLING_PROBES + + def test_colliding_repo_basenames_disambiguate_labels(): resolve = _resolver( { diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index 059793a8949d..c6ba0c0969b4 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -151,20 +151,40 @@ def _placement( } +def _parent_dir(path: str) -> str: + """The containing directory of ``path`` (``""`` once the root is passed).""" + stripped = re.sub(r"[/\\]+$", "", path or "") + return re.sub(r"[/\\]+$", "", re.sub(r"[^/\\]+$", "", stripped)) + + 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() + The session's cwd is frequently a SUBDIR of the deleted worktree (an agent + that ``cd``-ed into ``-/apps/desktop``), whose basename shares + nothing with the repo. So the trim is applied to each ANCESTOR, deepest + first, not just to the leaf — otherwise the probe silently no-ops and the + dead path gets minted as its own top-level project. Probes are bounded in + total (each costs a git invocation) and served from the shared probe cache. + """ + probes = 0 + path = re.sub(r"[/\\]+$", "", cwd or "") + + while path and probes < _MAX_SIBLING_PROBES: + parts = base_name(path).split("-") + + for i in range(len(parts) - 1, 0, -1): + if probes >= _MAX_SIBLING_PROBES: + break + probes += 1 + info = resolve(_with_base_name(path, "-".join(parts[:i]))) + if info and info.get("repo_root"): + return (info["repo_root"] or "").strip() + + path = _parent_dir(path) return ""