fix(projects): fold deleted-worktree subdirs into their parent repo

`_probe_sibling_worktree` recovers a deleted `<repo>-<suffix>` 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
(`<repo>-<suffix>/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.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 00:56:19 -05:00
parent 92549c9a6e
commit c7fcb73e3d
2 changed files with 60 additions and 8 deletions

View file

@ -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 `<repo>-<suffix>/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(
{

View file

@ -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 ``<repo>-<suffix>`` worktree, else ``""``.
A deleted worktree dir can't be probed, so walk back up its name — trimming
one ``-<segment>`` 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 ``<repo>-<suffix>/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 ""