mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(desktop): preserve legacy non-git workspace groups
This commit is contained in:
parent
095b9eed38
commit
fff1769bd1
3 changed files with 236 additions and 33 deletions
|
|
@ -180,6 +180,75 @@ def test_persisted_repo_root_used_when_no_live_probe():
|
|||
assert _lane_ids(project) == ["/repo::branch::main"]
|
||||
|
||||
|
||||
def test_non_git_cwd_preserves_legacy_workspace_grouping():
|
||||
# Before first-class Projects, every non-empty session cwd appeared as a
|
||||
# workspace even when it was not a git repo. Historical sessions must keep
|
||||
# that grouping instead of falling through to the flat Sessions list.
|
||||
legacy = _session("/work/notes", title="Research notes")
|
||||
|
||||
tree = pt.build_tree([], [legacy], [], resolve=lambda _cwd: None, hydrate=True)
|
||||
|
||||
assert [p["id"] for p in tree["projects"]] == ["/work/notes"]
|
||||
project = tree["projects"][0]
|
||||
assert project["isAuto"] is True
|
||||
assert project["label"] == "notes"
|
||||
assert project["sessionCount"] == 1
|
||||
assert _lane_ids(project) == ["/work/notes"]
|
||||
assert tree["scoped_session_ids"] == [legacy["id"]]
|
||||
|
||||
|
||||
def test_non_git_windows_cwd_preserves_legacy_workspace_grouping():
|
||||
cwd = r"C:\Users\alice\workspace\notes"
|
||||
legacy = _session(cwd)
|
||||
|
||||
tree = pt.build_tree([], [legacy], [], resolve=lambda _cwd: None, hydrate=True)
|
||||
|
||||
assert [p["id"] for p in tree["projects"]] == [cwd]
|
||||
assert tree["projects"][0]["label"] == "notes"
|
||||
assert tree["scoped_session_ids"] == [legacy["id"]]
|
||||
|
||||
|
||||
def test_equivalent_windows_cwds_collapse_into_one_auto_project():
|
||||
sessions = [
|
||||
_session("C:/work/notes"),
|
||||
_session(r"c:\WORK\notes"),
|
||||
_session("C:/work/notes/"),
|
||||
]
|
||||
|
||||
tree = pt.build_tree([], sessions, [], resolve=lambda _cwd: None, hydrate=True)
|
||||
|
||||
assert len(tree["projects"]) == 1
|
||||
project = tree["projects"][0]
|
||||
assert project["id"] == "C:/work/notes"
|
||||
assert project["sessionCount"] == 3
|
||||
assert len(project["repos"]) == 1
|
||||
assert len(project["repos"][0]["groups"]) == 1
|
||||
assert len(project["repos"][0]["groups"][0]["sessions"]) == 3
|
||||
|
||||
|
||||
def test_windows_path_identity_preserves_explicit_project_priority():
|
||||
explicit = _project("p_notes", "Notes", ["C:/Work/Notes"])
|
||||
session = _session("c:\\work\\notes\\")
|
||||
|
||||
tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True)
|
||||
|
||||
assert [p["id"] for p in tree["projects"]] == ["p_notes"]
|
||||
assert tree["projects"][0]["sessionCount"] == 1
|
||||
assert tree["scoped_session_ids"] == [session["id"]]
|
||||
|
||||
|
||||
def test_posix_path_identity_remains_case_sensitive():
|
||||
explicit = _project("p_notes", "Notes", ["/Work/Notes"])
|
||||
session = _session("/work/notes")
|
||||
|
||||
tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True)
|
||||
|
||||
assert [(p["id"], p["sessionCount"]) for p in tree["projects"]] == [
|
||||
("p_notes", 0),
|
||||
("/work/notes", 1),
|
||||
]
|
||||
|
||||
|
||||
def test_explicit_project_claims_sessions_and_beats_auto():
|
||||
project = _project("p_app", "App", ["/www/app"])
|
||||
resolve = _resolver(
|
||||
|
|
@ -337,6 +406,41 @@ def test_junk_root_is_dropped_from_the_discovered_tier():
|
|||
assert tree["projects"] == []
|
||||
|
||||
|
||||
def test_non_git_cwd_can_group_inside_a_junk_repo_subtree():
|
||||
# Repo discovery rejects the full state subtree, but a selected non-git
|
||||
# descendant may be an intentional workspace carried over from the old UI.
|
||||
workspace = _session("/home/test/.hermes/workspaces/notes")
|
||||
|
||||
tree = pt.build_tree(
|
||||
[],
|
||||
[workspace],
|
||||
[],
|
||||
resolve=lambda _cwd: None,
|
||||
hydrate=True,
|
||||
is_junk_root=lambda path: path.startswith("/home/test/.hermes"),
|
||||
is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"},
|
||||
)
|
||||
|
||||
assert [p["id"] for p in tree["projects"]] == ["/home/test/.hermes/workspaces/notes"]
|
||||
assert tree["scoped_session_ids"] == [workspace["id"]]
|
||||
|
||||
|
||||
def test_broad_default_non_git_cwd_stays_unscoped():
|
||||
detached = _session("/home/test/.hermes")
|
||||
|
||||
tree = pt.build_tree(
|
||||
[],
|
||||
[detached],
|
||||
[],
|
||||
resolve=lambda _cwd: None,
|
||||
hydrate=True,
|
||||
is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"},
|
||||
)
|
||||
|
||||
assert tree["projects"] == []
|
||||
assert detached["id"] not in tree["scoped_session_ids"]
|
||||
|
||||
|
||||
def test_colliding_repo_basenames_disambiguate_labels():
|
||||
resolve = _resolver(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -60,6 +60,39 @@ def _segments(path: str) -> list[str]:
|
|||
return [s for s in re.split(r"[/\\]", (path or "").rstrip("/\\")) if s]
|
||||
|
||||
|
||||
def _is_windows_path(path: str) -> bool:
|
||||
value = (path or "").strip()
|
||||
return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\\\", "//"))
|
||||
|
||||
|
||||
def _comparison_segments(path: str) -> list[str]:
|
||||
"""Path segments suitable for identity comparisons on any host.
|
||||
|
||||
Windows paths remain case-insensitive even when tests or remote backends run
|
||||
on POSIX. Display paths and emitted IDs keep their original spelling.
|
||||
"""
|
||||
segs = _segments(path)
|
||||
return [segment.casefold() for segment in segs] if _is_windows_path(path) else segs
|
||||
|
||||
|
||||
def _path_key(path: str) -> str:
|
||||
"""Canonical comparison key (separator/trailing-slash agnostic)."""
|
||||
return "/".join(_comparison_segments(path))
|
||||
|
||||
|
||||
def _lane_key(path_or_lane: str) -> str:
|
||||
"""Canonicalize only the path portion of a lane id.
|
||||
|
||||
Branch labels remain byte-preserved; repo/worktree paths follow platform path
|
||||
identity so equivalent Windows spellings do not create duplicate lanes.
|
||||
"""
|
||||
for marker in ("::branch::", "::kanban"):
|
||||
if marker in path_or_lane:
|
||||
root, suffix = path_or_lane.split(marker, 1)
|
||||
return f"{_path_key(root)}{marker}{suffix}"
|
||||
return _path_key(path_or_lane)
|
||||
|
||||
|
||||
def base_name(path: str) -> str:
|
||||
segs = _segments(path)
|
||||
return segs[-1] if segs else ""
|
||||
|
|
@ -73,8 +106,8 @@ def kanban_worktree_dir(path: str) -> Optional[str]:
|
|||
|
||||
def _is_path_under(folder: str, target: str) -> bool:
|
||||
"""True when ``target`` equals ``folder`` or is nested under it (segment-wise)."""
|
||||
f = _segments(folder)
|
||||
t = _segments(target)
|
||||
f = _comparison_segments(folder)
|
||||
t = _comparison_segments(target)
|
||||
if not f or len(f) > len(t):
|
||||
return False
|
||||
return all(f[i] == t[i] for i in range(len(f)))
|
||||
|
|
@ -249,7 +282,8 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool
|
|||
if not placement:
|
||||
continue
|
||||
|
||||
entry = lanes.get(placement["lane_key"])
|
||||
lane_identity = _lane_key(placement["lane_key"])
|
||||
entry = lanes.get(lane_identity)
|
||||
if entry is None:
|
||||
entry = {
|
||||
"group": {
|
||||
|
|
@ -264,7 +298,7 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool
|
|||
"repo_label": placement["repo_label"],
|
||||
"repo_path": placement["repo_path"],
|
||||
}
|
||||
lanes[placement["lane_key"]] = entry
|
||||
lanes[lane_identity] = entry
|
||||
entry["group"]["sessions"].append(session)
|
||||
|
||||
repos: dict[str, dict] = {}
|
||||
|
|
@ -275,7 +309,8 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool
|
|||
if not hydrate:
|
||||
group["sessions"] = []
|
||||
|
||||
repo = repos.get(entry["repo_key"])
|
||||
repo_identity = _path_key(entry["repo_key"])
|
||||
repo = repos.get(repo_identity)
|
||||
if repo is None:
|
||||
repo = {
|
||||
"id": entry["repo_key"],
|
||||
|
|
@ -284,7 +319,7 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool
|
|||
"groups": [],
|
||||
"sessionCount": 0,
|
||||
}
|
||||
repos[entry["repo_key"]] = repo
|
||||
repos[repo_identity] = repo
|
||||
repo["groups"].append(group)
|
||||
repo["sessionCount"] += count
|
||||
|
||||
|
|
@ -311,7 +346,12 @@ def _seed_folder_repos(
|
|||
empty) project body. Folders already covered by a session-derived repo (same
|
||||
git root) are left untouched.
|
||||
"""
|
||||
seen = {r["id"] for r in repos} | {r["path"] for r in repos if r.get("path")}
|
||||
seen = {
|
||||
_path_key(value)
|
||||
for repo in repos
|
||||
for value in (repo.get("id"), repo.get("path"))
|
||||
if value
|
||||
}
|
||||
seeded = list(repos)
|
||||
|
||||
for folder in folders or []:
|
||||
|
|
@ -320,10 +360,11 @@ def _seed_folder_repos(
|
|||
continue
|
||||
info = resolve(raw) if resolve else None
|
||||
root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw)
|
||||
if not root or root in seen:
|
||||
root_key = _path_key(root)
|
||||
if not root_key or root_key in seen:
|
||||
continue
|
||||
seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0})
|
||||
seen.add(root)
|
||||
seen.add(root_key)
|
||||
|
||||
if len(seeded) != len(repos):
|
||||
_disambiguate_labels(seeded)
|
||||
|
|
@ -347,7 +388,7 @@ class _FolderIndex:
|
|||
self._by_path: dict[str, tuple[dict, int]] = {}
|
||||
for project in projects:
|
||||
for folder in project.get("folders") or []:
|
||||
segs = _segments(folder.get("path") or "")
|
||||
segs = _comparison_segments(folder.get("path") or "")
|
||||
if not segs:
|
||||
continue
|
||||
key = "/".join(segs)
|
||||
|
|
@ -359,7 +400,7 @@ class _FolderIndex:
|
|||
|
||||
def match(self, target: str) -> tuple[Optional[dict], int]:
|
||||
"""Owning project for ``target`` by longest ancestor folder, + its depth."""
|
||||
segs = _segments(target or "")
|
||||
segs = _comparison_segments(target or "")
|
||||
# Longest prefix first → deepest (most specific) folder wins.
|
||||
for end in range(len(segs), 0, -1):
|
||||
hit = self._by_path.get("/".join(segs[:end]))
|
||||
|
|
@ -430,6 +471,7 @@ def build_tree(
|
|||
preview_limit: int = 3,
|
||||
hydrate: bool = False,
|
||||
is_junk_root: Optional[Callable[[str], bool]] = None,
|
||||
is_junk_cwd: Optional[Callable[[str], bool]] = None,
|
||||
) -> dict:
|
||||
"""Build the authoritative project tree.
|
||||
|
||||
|
|
@ -437,9 +479,11 @@ def build_tree(
|
|||
``sessions`` are projected session-row dicts (must carry ``id``, ``cwd``,
|
||||
``git_branch``, ``git_repo_root``, ``started_at``, ``last_active``).
|
||||
``discovered_repos`` are ``{"root", "label", "sessions", "last_active"}``.
|
||||
``is_junk_root`` flags roots that must never become an AUTO project (the
|
||||
bare home dir, the HERMES_HOME subtree) — their sessions fall through to the
|
||||
flat Recents list. User-created projects are honored regardless.
|
||||
``is_junk_root`` flags git roots that must never become an AUTO project (the
|
||||
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.
|
||||
|
||||
Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When
|
||||
``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but
|
||||
|
|
@ -448,6 +492,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)
|
||||
folder_index = _FolderIndex(active_projects)
|
||||
|
||||
by_project: dict[str, list[dict]] = {}
|
||||
|
|
@ -493,34 +538,67 @@ def build_tree(
|
|||
)
|
||||
)
|
||||
|
||||
# Tier 2: auto projects from leftover sessions, one per common git repo root.
|
||||
by_repo: dict[str, list[dict]] = {}
|
||||
# Tier 2: auto projects from leftover sessions. Prefer the common git repo
|
||||
# root, then fall back to the session cwd for historical/non-git workspaces.
|
||||
# The pre-Projects desktop grouped every non-empty cwd; keeping that fallback
|
||||
# prevents upgrades from flattening those sessions into Recents.
|
||||
by_auto_root: dict[str, dict] = {}
|
||||
|
||||
def _add_auto(root: str, session: dict) -> None:
|
||||
key = _path_key(root)
|
||||
if not key:
|
||||
return
|
||||
bucket = by_auto_root.setdefault(key, {"root": root, "sessions": []})
|
||||
bucket["sessions"].append(session)
|
||||
|
||||
for session in unowned:
|
||||
root = _session_repo_root(session, resolve)
|
||||
if root:
|
||||
by_repo.setdefault(root, []).append(session)
|
||||
# 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):
|
||||
_add_auto(root, session)
|
||||
continue
|
||||
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if not cwd or _junk_cwd(cwd):
|
||||
continue
|
||||
placement = _place(
|
||||
cwd,
|
||||
(session.get("git_branch") or "").strip(),
|
||||
resolve,
|
||||
(session.get("git_repo_root") or "").strip(),
|
||||
)
|
||||
if placement:
|
||||
_add_auto(placement["repo_key"], session)
|
||||
|
||||
seen: set[str] = set()
|
||||
for repo_root, repo_sessions in by_repo.items():
|
||||
# The home dir / HERMES_HOME subtree is config + state, never a project;
|
||||
# its sessions stay loose in Recents (not scoped to a phantom project).
|
||||
if _junk(repo_root):
|
||||
continue
|
||||
repos = _build_repos(repo_sessions, resolve, hydrate)
|
||||
repo_node = next((r for r in repos if r["id"] == repo_root or r["path"] == repo_root), None)
|
||||
for bucket in by_auto_root.values():
|
||||
auto_root = bucket["root"]
|
||||
auto_sessions = bucket["sessions"]
|
||||
auto_key = _path_key(auto_root)
|
||||
repos = _build_repos(auto_sessions, resolve, hydrate)
|
||||
repo_node = next(
|
||||
(
|
||||
repo
|
||||
for repo in repos
|
||||
if _path_key(repo.get("id") or repo.get("path") or "") == auto_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if repo_node is None:
|
||||
continue
|
||||
seen.add(repo_root)
|
||||
scoped_ids.extend(s["id"] for s in repo_sessions if s.get("id"))
|
||||
seen.add(auto_key)
|
||||
scoped_ids.extend(s["id"] for s in auto_sessions if s.get("id"))
|
||||
result.append(
|
||||
_project_node(
|
||||
pid=repo_root,
|
||||
label=base_name(repo_root) or repo_root,
|
||||
path=repo_root,
|
||||
pid=auto_root,
|
||||
label=base_name(auto_root) or auto_root,
|
||||
path=auto_root,
|
||||
repos=repos,
|
||||
session_count=repo_node["sessionCount"],
|
||||
last_active=_last_active(repo_sessions),
|
||||
preview_sessions=_previews(repo_sessions),
|
||||
last_active=_last_active(auto_sessions),
|
||||
preview_sessions=_previews(auto_sessions),
|
||||
is_auto=True,
|
||||
)
|
||||
)
|
||||
|
|
@ -533,9 +611,10 @@ def build_tree(
|
|||
continue
|
||||
info = resolve(raw_root) if resolve else None
|
||||
root = (info or {}).get("repo_root") or raw_root
|
||||
if root in seen or _junk(root) or _project_for_path(folder_index, root):
|
||||
root_key = _path_key(root)
|
||||
if root_key in seen or _junk(root) or _project_for_path(folder_index, root):
|
||||
continue
|
||||
seen.add(root)
|
||||
seen.add(root_key)
|
||||
label = repo.get("label") or base_name(root) or root
|
||||
result.append(
|
||||
_project_node(
|
||||
|
|
|
|||
|
|
@ -10920,6 +10920,25 @@ def _is_repo_junk(root: str) -> bool:
|
|||
return real == home or real == hermes_home or real.startswith(hermes_home + os.sep)
|
||||
|
||||
|
||||
def _is_session_cwd_junk(cwd: str) -> bool:
|
||||
"""A non-git cwd that should stay in flat Recents rather than auto-group.
|
||||
|
||||
Unlike discovered git roots, an explicitly selected descendant of
|
||||
HERMES_HOME may be an intentional prose/data workspace. The pre-Projects
|
||||
desktop surfaced every such cwd, so exclude only the two broad defaults
|
||||
that would create catch-all projects.
|
||||
"""
|
||||
if not cwd:
|
||||
return True
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
real = os.path.normcase(os.path.realpath(cwd))
|
||||
home = os.path.normcase(os.path.realpath(os.path.expanduser("~")))
|
||||
hermes_home = os.path.normcase(os.path.realpath(str(get_hermes_home())))
|
||||
return real == home or real == hermes_home
|
||||
|
||||
|
||||
def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dict]:
|
||||
"""Merge filesystem-scanned repos (cached) with session-derived repo roots.
|
||||
|
||||
|
|
@ -11121,6 +11140,7 @@ def _build_project_tree(
|
|||
preview_limit=preview_limit,
|
||||
hydrate=hydrate,
|
||||
is_junk_root=_is_repo_junk,
|
||||
is_junk_cwd=_is_session_cwd_junk,
|
||||
)
|
||||
return tree, active_id
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue