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
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (744) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (746) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (229: 44 daemon_client + 19 conn-manager + 22 app-commands + 107 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
37 changes: 36 additions & 1 deletion emrg/server/git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
import logging
import os
import re
import shutil
Expand All @@ -15,6 +16,13 @@
INSTALL_BIN = Path.home() / ".emrg" / "install" / "bin"
INSTALL_INFO = config_dir() / "install-info.json"

logger = logging.getLogger(__name__)

# One-shot guard: when no git executable can be resolved at all, log the
# root cause once per process (2026-08-12 incident follow-up — the daemon
# restarted without PATH git and cycles were silently skipped for 18 min).
_GIT_MISSING_WARNED = False


# ── Non-interactive subprocess environment (rant 2026-08-07T10:17:27) ──
#
Expand Down Expand Up @@ -231,11 +239,38 @@ def resolve_git_gh() -> tuple[str, str]:
else:
gh = _tool_in_install("gh") or (shutil.which("gh") or "")

if git or gh:
if git:
_cache_tool_paths(git, gh)
else:
# git is the failure mode that silently disables evolution (2026-08-12
# incident) — warn regardless of whether gh resolved. Also skip the
# cache write so a previously valid cached git_path is not clobbered
# with '' (review #714 note).
_warn_git_missing_once()
return git, gh


def _warn_git_missing_once() -> None:
"""Log one actionable WARNING when no git executable can be resolved.

2026-08-12 incident follow-up: a daemon restart in an environment with
neither bundled nor PATH git made every evolution git call raise
FileNotFoundError; _is_usable_git_repo() swallowed the OSError and the
cycle log only said "workspace not ready — skipping cycle" for 18
minutes. Log the root cause once per process so the daemon log is
diagnosable.
"""
global _GIT_MISSING_WARNED
if _GIT_MISSING_WARNED:
return
_GIT_MISSING_WARNED = True
logger.warning(
"git executable not found (install-info / ~/.emrg/install / PATH all "
"empty) — evolution cycles will be skipped as 'not a git repo'; "
"install git or add it to PATH, then restart the daemon"
)


def git_cmd(*args: str, cwd: str | None = None, timeout: int = 10) -> subprocess.CompletedProcess:
"""Run a git command using the resolved git binary.

Expand Down
61 changes: 61 additions & 0 deletions tests/test_git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,64 @@ def test_git_origin_url_missing_remote(tmp_path):

real_subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
assert git_origin_url(str(tmp_path)) == ""


def test_resolve_git_gh_warns_once_when_git_missing(monkeypatch, caplog):
"""Total git absence logs one actionable WARNING (2026-08-12 incident).

A daemon restart with neither bundled nor PATH git used to fail silently
(FileNotFoundError swallowed by _is_usable_git_repo → cycles skipped as
"not a git repo"). The one-shot warning makes the root cause visible.
"""
import logging

from emrg.server import git_utils as gu

# Force all three resolution sources empty (no install-info / bundled / PATH).
monkeypatch.setattr(gu, "_cached_tool_path", lambda tool: None)
monkeypatch.setattr(gu, "_tool_in_install", lambda tool: None)
monkeypatch.setattr(gu.shutil, "which", lambda tool: None)
monkeypatch.setattr(gu, "_GIT_MISSING_WARNED", False)

with caplog.at_level(logging.WARNING, logger="emrg.server.git_utils"):
git, gh = gu.resolve_git_gh()
assert git == "" and gh == ""
gu.resolve_git_gh() # second call must NOT re-warn

warns = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert len(warns) == 1
assert "git executable not found" in warns[0].getMessage()


def test_resolve_git_gh_warns_when_git_missing_but_gh_present(monkeypatch, caplog):
"""Git-missing must warn even when gh resolves (review #714 finding).

The failure mode is git-specific: a dev box with gh on PATH but no git
would previously skip the warning entirely (the old `else` of `if git or
gh`) yet still silently disable evolution. The warning is gated on git
alone; the cache write is skipped so a previously valid cached git_path
is not clobbered with ''.
"""
import logging

from emrg.server import git_utils as gu

def fake_cache(git: str, gh: str) -> None:
assert git == "", f"cache must not be written with empty git, got {git=} {gh=}"
raise AssertionError("_cache_tool_paths should not be called when git is missing")

monkeypatch.setattr(gu, "_cached_tool_path", lambda tool: None)
monkeypatch.setattr(gu, "_tool_in_install", lambda tool: None)
# gh resolves via PATH, git does not.
monkeypatch.setattr(gu.shutil, "which", lambda tool: "/usr/bin/gh" if tool == "gh" else None)
monkeypatch.setattr(gu, "_cache_tool_paths", fake_cache)
monkeypatch.setattr(gu, "_GIT_MISSING_WARNED", False)

with caplog.at_level(logging.WARNING, logger="emrg.server.git_utils"):
git, gh = gu.resolve_git_gh()
assert git == "" and gh == "/usr/bin/gh"
gu.resolve_git_gh() # second call must NOT re-warn

warns = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert len(warns) == 1
assert "git executable not found" in warns[0].getMessage()
Loading