Files
hermes-agent/tui_gateway/git_probe.py
T
Teknium 93fead86dd refactor(tui): compact peripheral modules (compute_host, entry, hosted_room_*, ws, project_tree, ...); add _env knobs and method_ctx.bind_module
Docstring/comment compaction plus small structural dedupe across the
tui_gateway peripheral modules. Originally landed as an outage-recovery
snapshot; reviewed and verified afterwards (import smokes, cluster tests).
2026-09-02 14:09:00 -07:00

176 lines
6.7 KiB
Python

"""Git working-tree probing for the gateway: run git, resolve repo roots, fold
linked worktrees under their common root.
Probing runs where the gateway runs, so it covers local and remote backends
(the desktop's electron probe only sees the local fs). Roots go through a
thread-safe single-flight cache: gateway handlers run on worker threads, so
concurrent identical probes share one ``git`` spawn instead of racing a dict.
Positive results are cached for the process lifetime; negatives (not a repo, or
a deleted dir) only for ``_NEG_TTL``. Caching negatives matters: ``build_tree``
resolves a cwd once *per session*, so hundreds of sessions in non-git/deleted
dirs would otherwise re-spawn ``git`` on every sidebar open (multi-second
"Projects" load). The TTL keeps a not-yet-repo cwd re-probable — we ``git init``
a new project's folder on its first worktree, and a frozen "" would mislabel its
main lane by dir basename. ``invalidate()`` drops everything after a known
mutation.
"""
from __future__ import annotations
import os
import threading
import time
from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor
from hermes_cli._subprocess_compat import bounded_git_probe
_GIT_TIMEOUT = 1.5
_WARM_WORKERS = 8
# "Not a git repo" cache TTL: short enough that a freshly `git init`-ed folder
# shows correctly within seconds, long enough to collapse a tree build's
# hundreds of redundant probes.
_NEG_TTL = 30.0
def run_git(cwd: str, *args: str) -> str:
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
Uses :func:`bounded_git_probe` so post-kill cleanup is bounded on Windows —
a plain ``subprocess.run(timeout=...)`` deadlocked Desktop session readiness
when a killed git left a suspended descendant holding the pipe handles.
"""
if not cwd or not os.path.isdir(cwd):
# `git -C` on a missing dir can only fail, at the price of a fork; deleted
# worktrees dominate a long session history's cwds, so the stat pays off.
return ""
return bounded_git_probe(["git", "-C", cwd, *args], timeout=_GIT_TIMEOUT)
def branch(cwd: str) -> str:
return run_git(cwd, "branch", "--show-current") or run_git(cwd, "rev-parse", "--short", "HEAD")
class _RootCache:
"""Thread-safe, single-flight cache of git-root probes: positives live for
the process, negatives for ``_NEG_TTL``; followers wait on the leader."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._roots: dict[str, str] = {}
self._neg: dict[str, float] = {} # key -> monotonic expiry
self._inflight: dict[str, threading.Event] = {}
def invalidate(self) -> None:
with self._lock:
self._roots.clear()
self._neg.clear()
self._inflight.clear()
def resolve(self, key: str, probe) -> str:
while True:
with self._lock:
hit = self._roots.get(key)
if hit:
return hit
expiry = self._neg.get(key)
if expiry is not None:
if expiry > time.monotonic():
return "" # recent "not a repo": trust it briefly
del self._neg[key] # TTL elapsed: re-probe (may be a repo now)
gate = self._inflight.get(key)
leader = gate is None
if leader:
gate = self._inflight[key] = threading.Event()
if not leader:
# Another thread is probing this key — wait, then re-read.
gate.wait(timeout=_GIT_TIMEOUT + 0.5)
continue
value = ""
try:
value = probe()
finally:
with self._lock:
if value:
self._roots[key] = value
else:
self._neg[key] = time.monotonic() + _NEG_TTL
self._inflight.pop(key, None)
gate.set()
return value
_cache = _RootCache()
def invalidate() -> None:
"""Drop cached roots after a known mutation (e.g. a worktree was added)."""
_cache.invalidate()
def repo_root(cwd: str) -> str:
"""Top-level git repo root for ``cwd`` (``""`` when not a repo)."""
if not cwd:
return ""
return _cache.resolve(cwd, lambda: run_git(cwd, "rev-parse", "--show-toplevel"))
def common_repo_root(cwd: str) -> str:
"""The MAIN (common) repo root for ``cwd``, folding linked worktrees.
``--show-toplevel`` returns a linked worktree's OWN root, splitting every
worktree into its own "repo"; the parent of the shared ``--git-common-dir``
is the one true root (fallback: the toplevel root).
The result is normalized to git's forward-slash spelling so it compares
equal to :func:`repo_root` (raw ``--show-toplevel``). ``os.path.realpath``
uses native ``\\`` on Windows, so without this the main checkout compared
unequal to its own common root, was misread as a linked worktree, and the
desktop sidebar rendered it twice.
"""
# Not a repo: nothing to fold. Checking the (warmed, negative-cached)
# toplevel first spares every non-repo cwd a second `git` spawn that the
# parallel warm can't absorb (`resolve()` only reaches here for repos).
if not cwd or not repo_root(cwd):
return ""
def _probe() -> str:
gitdir = run_git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir")
if gitdir:
gitdir = os.path.realpath(gitdir)
if os.path.basename(gitdir) == ".git":
return os.path.dirname(gitdir).replace(os.sep, "/")
return repo_root(cwd)
return _cache.resolve(f"common:{cwd}", _probe)
def resolve(cwd: str) -> dict | None:
"""Inject-able resolver for ``project_tree.build_tree``.
Returns ``{"repo_root": <common root>, "worktree_root": <this checkout>}``
or ``None`` when ``cwd`` is not in a git repo. ``build_tree`` treats
``worktree_root == repo_root`` as the main checkout.
"""
worktree_root = repo_root(cwd)
if not worktree_root:
return None
return {"repo_root": common_repo_root(cwd) or worktree_root, "worktree_root": worktree_root}
def warm_roots(cwds: Iterable[str], max_workers: int = _WARM_WORKERS) -> None:
"""Pre-resolve many cwds' roots in parallel (bounded) so a cold first paint
doesn't serialize one git spawn per session cwd; results land in the cache."""
pending = sorted({(cwd or "").strip() for cwd in cwds} - {""})
if not pending:
return
if len(pending) == 1:
resolve(pending[0])
return
with ThreadPoolExecutor(max_workers=min(max_workers, len(pending))) as pool:
list(pool.map(resolve, pending))