Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 19 additions & 24 deletions emrg/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down
87 changes: 87 additions & 0 deletions emrg/server/git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
6 changes: 5 additions & 1 deletion emrg/server/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Loading