From cb508833de8e4c44a5abed5708d8167bd95734ac Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 4 Aug 2026 22:04:39 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20Phase=204=20=E6=A0=B8=E5=BF=83=E6=94=B9?= =?UTF-8?q?=E9=80=A0=20resolve=5Fgit=5Fgh=20+=20R86=20=E6=89=93=E5=8C=85?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=20+=20=E6=A8=A1=E6=9D=BF=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=EF=BC=88rant=20#12=20=C2=A76/=C2=A79/=C2=A713=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emrg/__main__.py | 43 +++++++++----------- emrg/server/git_utils.py | 87 ++++++++++++++++++++++++++++++++++++++++ emrg/server/scheduler.py | 6 ++- 3 files changed, 111 insertions(+), 25 deletions(-) diff --git a/emrg/__main__.py b/emrg/__main__.py index 73094ea..b8a589a 100644 --- a/emrg/__main__.py +++ b/emrg/__main__.py @@ -303,12 +303,18 @@ def _run_client(init_auto_evolve: bool = False) -> None: # ── Update ──────────────────────────────────────────────────── def _run_update() -> None: - """git pull the latest source and reinstall via uv tool install.""" + """git pull the latest source and reinstall via uv tool install. + + Packaged mode (rant #12 §9 R86): when no source dir is detectable the + binary install cannot self-update — print a pointer to GitHub Releases + and exit (v1.1 adds binary self-update). + """ source_dir = _find_source_dir() if source_dir is None: - print("Error: cannot find emrg source directory.", file=sys.stderr) print( - "Reinstall with: git clone https://github.com/argszero/emrg.git", + "EMRG is installed in packaged mode — self-update is not supported yet.\n" + "Please download the new version from GitHub Releases:\n" + " https://github.com/argszero/emrg/releases", file=sys.stderr, ) sys.exit(1) @@ -359,30 +365,19 @@ def _run_update() -> None: def _find_source_dir() -> Path | None: """Find the emrg source directory (the git repo root). - Tries in order: - 1. Editable install: emrg.__file__ → parent → parent is the git repo - 2. Current directory: if user is inside the source tree + R86 (rant #12 §9): only the emrg package's parent directory is a valid + source dir. NEVER fall back to the current working directory or walk up + from cwd — in packaged mode a user running ``emrg update`` from any git + repo would otherwise have an unrelated repo pulled/upgraded (dangerous). """ import emrg - candidates: list[Path] = [] - - # Editable install path - pkg_dir = Path(emrg.__file__).resolve().parent # emrg/emrg/ - candidates.append(pkg_dir.parent) # emrg/ - - # Current working directory (for wheel installs) - candidates.append(Path.cwd()) - - # Walk up from cwd (in case user is in a subdirectory) - for p in Path.cwd().parents: - candidates.append(p) - - for source_dir in candidates: - git_dir = source_dir / ".git" - if git_dir.exists(): - return source_dir - + # Editable install path: emrg.__file__ → parent → parent is the git repo + pkg_dir = Path(emrg.__file__).resolve().parent # .../site-packages/emrg/ + source_dir = pkg_dir.parent # repo root when installed with -e . + git_dir = source_dir / ".git" + if git_dir.exists(): + return source_dir return None diff --git a/emrg/server/git_utils.py b/emrg/server/git_utils.py index be73fa8..3b2b990 100644 --- a/emrg/server/git_utils.py +++ b/emrg/server/git_utils.py @@ -2,7 +2,16 @@ from __future__ import annotations +import json +import os +import shutil import subprocess +from pathlib import Path + +from emrg.config import config_dir + +INSTALL_BIN = Path.home() / ".emrg" / "install" / "bin" +INSTALL_INFO = config_dir() / "install-info.json" def _detect_git_remote(cwd: str) -> str: @@ -33,3 +42,81 @@ def _detect_git_remote(cwd: str) -> str: except (subprocess.TimeoutExpired, OSError, FileNotFoundError): pass return "" + + +def _cached_tool_path(tool: str) -> str | None: + """Return a cached tool path from install-info.json, if present.""" + try: + data = json.loads(INSTALL_INFO.read_text(encoding="utf-8")) + value = data.get(f"{tool}_path") + return str(value) if value else None + except (OSError, json.JSONDecodeError, AttributeError): + return None + + +def _cache_tool_paths(git: str, gh: str) -> None: + """Persist resolved tool paths so later lookups are O(1).""" + try: + data = {} + if INSTALL_INFO.exists(): + data = json.loads(INSTALL_INFO.read_text(encoding="utf-8")) + data.update({"git_path": git, "gh_path": gh}) + INSTALL_INFO.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + except OSError: + pass + + +def _tool_in_install(tool: str) -> str | None: + """Return the bundled tool path under ~/.emrg/install/bin, if present. + + Windows: git lives in install/git/cmd/git.exe; gh is a single binary in + install/bin. POSIX: both are single binaries in install/bin. + """ + if os.name == "nt": + git_in_install = INSTALL_BIN.parent / "git" / "cmd" / "git.exe" + if tool == "git" and git_in_install.exists(): + return str(git_in_install) + exe = INSTALL_BIN / (tool + (".exe" if os.name == "nt" else "")) + return str(exe) if exe.exists() else None + + +def resolve_git_gh() -> tuple[str, str]: + """Resolve git and gh executable paths for the evolution environment. + + Priority (rant #12 §6): + 1. cached install-info.json paths + 2. bundled binaries under ~/.emrg/install/bin (or install/git/cmd on Windows) + 3. shutil.which() fallback (dev / source mode) + + Returns (git_path, gh_path). Missing executables yield '' (callers decide + how to degrade). + """ + git = _cached_tool_path("git") + gh = _cached_tool_path("gh") + if git and Path(git).exists(): + pass + else: + git = _tool_in_install("git") or (shutil.which("git") or "") + if gh and Path(gh).exists(): + pass + else: + gh = _tool_in_install("gh") or (shutil.which("gh") or "") + + if git or gh: + _cache_tool_paths(git, gh) + return git, gh + + +def git_cmd(*args: str, cwd: str | None = None, timeout: int = 10) -> subprocess.CompletedProcess: + """Run a git command using the resolved git binary. + + Falls back to bare ``git`` when no bundled binary is found (dev mode). + """ + git, _ = resolve_git_gh() + exe = git or "git" + return subprocess.run( + [exe, *args], cwd=cwd, capture_output=True, text=True, + encoding="utf-8", timeout=timeout, + ) diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 11328a0..d633297 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -27,7 +27,7 @@ from websockets.exceptions import ConnectionClosed from emrg.protocol import EvolutionLog, InstanceIdentity from emrg.server.atomic import atomic_write_yaml -from emrg.server.git_utils import _detect_git_remote +from emrg.server.git_utils import _detect_git_remote, resolve_git_gh logger = logging.getLogger("emrg.server.scheduler") @@ -432,6 +432,8 @@ def _build_evolution_prompt(self) -> str: uptime_seconds = 0 uptime = f"{uptime_seconds // 3600}h {(uptime_seconds % 3600) // 60}m" + git_path, gh_path = resolve_git_gh() + context = { "instance_id": self.identity.instance_id, "host_name": self.identity.host_name, @@ -447,6 +449,8 @@ def _build_evolution_prompt(self) -> str: "timestamp": datetime.now().strftime("%Y%m%d-%H%M%S"), "task": self._config, "project": _load_project_config(self._project_name, str(self._source_dir)), + "git_path": git_path, + "gh_path": gh_path, } env = jinja2.Environment(undefined=jinja2.Undefined)