From 96d0f9a3532a8331353b7ac0ed0c802226bdab35 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 8 Aug 2026 09:27:19 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20https=E2=86=92ssh=20fallback=20for=20bl?= =?UTF-8?q?ocked=20github.com:443=20in=20evolution=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 2 +- README.cn.md | 2 +- README.md | 2 +- emrg/server/git_utils.py | 60 +++++++++ emrg/server/scheduler.py | 117 ++++++++++++++++-- tests/test_git_utils.py | 73 +++++++++++ tests/test_scheduler.py | 259 ++++++++++++++++++++++++++++++++++++--- 7 files changed, 483 insertions(+), 32 deletions(-) diff --git a/Agent.md b/Agent.md index 0392feb..c75557c 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,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` (575) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (589) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands) — 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 路径不受影响) diff --git a/README.cn.md b/README.cn.md index ed5fb05..8d60392 100644 --- a/README.cn.md +++ b/README.cn.md @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 548 项) +uv run pytest tests/ -v # 跑测试(当前 589 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index 146809f..c8cae05 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 575 items) +uv run pytest tests/ -v # run tests (currently 589 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/server/git_utils.py b/emrg/server/git_utils.py index 6ed2f76..3ffd93a 100644 --- a/emrg/server/git_utils.py +++ b/emrg/server/git_utils.py @@ -59,6 +59,66 @@ def parse_gh_auth_user(output: str) -> str | None: return match.group(1) if match else None +# ── HTTPS→SSH fallback for blocked github.com:443 (2026-08-08) ─── +# +# Some networks block github.com:443 (HTTPS git transport) while SSH +# (port 22) and the api.github.com REST endpoint stay reachable. Observed +# on the packaged host: `git pull` hangs ~75 s then fails with "Failed to +# connect to github.com port 443", while `ssh -T git@github.com` succeeds. +# A fresh `git clone` or any pull/push against an https origin then fails +# and the evolution workspace never syncs. These helpers convert a +# github.com https URL to its SSH form and recognise connection-type git +# errors, so the scheduler can retry via SSH. Deliberately narrow: auth +# failures / 404s / repo-specific errors never trigger a switch. + +_HTTPS_GITHUB_RE = re.compile(r"^https://github\.com/([^/]+)/([^/]+?)(?:\.git)?$") + +_CONNECTION_ERROR_MARKERS = ( + "failed to connect", + "couldn't connect", + "could not connect", + "connection refused", + "connection timed out", + "operation timed out", + "could not resolve host", + "network is unreachable", + "unable to access", + "tls handshake timeout", +) + + +def https_to_ssh_url(url: str) -> str | None: + """Convert a github.com https URL to its SSH form, or None. + + ``https://github.com/owner/repo.git`` → ``git@github.com:owner/repo.git`` + Returns None for non-github / non-https URLs (SSH URLs, enterprise + hosts, local paths) — callers must not switch those. + """ + match = _HTTPS_GITHUB_RE.match((url or "").strip()) + if not match: + return None + return f"git@github.com:{match.group(1)}/{match.group(2)}.git" + + +def is_git_connection_error(stderr: str) -> bool: + """True when git stderr indicates a network/connection failure. + + Does NOT match auth errors ("Authentication failed", "Permission + denied (publickey)"), missing repos ("Repository not found") or other + non-connection failures — switching the remote would not fix those. + """ + text = (stderr or "").lower() + return any(marker in text for marker in _CONNECTION_ERROR_MARKERS) + + +def git_origin_url(cwd: str) -> str: + """Return the raw origin URL for a repo, '' when absent/unreadable.""" + result = git_cmd("remote", "get-url", "origin", cwd=cwd, timeout=5) + if result.returncode == 0: + return result.stdout.strip() + return "" + + def _detect_git_remote(cwd: str) -> str: """Detect the origin remote (owner/repo) from a git repository. diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 613af47..c9c48a7 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -31,6 +31,9 @@ from emrg.server.git_utils import ( INSTALL_INFO, _detect_git_remote, + git_origin_url, + https_to_ssh_url, + is_git_connection_error, no_prompt_env, resolve_git_gh, ) @@ -162,6 +165,9 @@ def __init__( self._repo_url = self.EMRG_REPO_URL self._session_id = f"emrg-evolution-{name}" self._source_dir = path or name + # One-shot https-origin probe per handler lifetime (see + # _ensure_origin_reachable) — avoids re-probing every cycle. + self._origin_probed = False def _get_git_head(self) -> str | None: """Return current git HEAD hash, or None if not a git repo.""" @@ -342,6 +348,7 @@ def _ensure_evolution_workspace(self) -> bool: if self._project_name != "emrg" or self._repo != self.REPO: return True # paper/open-source/promote tasks: not our concern if self._is_usable_git_repo(self._source_dir): + self._ensure_origin_reachable() return True # dev machine — use the existing repo as-is repo_url = self._repo_url_from_install_info() or self._repo_url evolve_dir = EVOLUTION_CWD / self.REPO @@ -349,6 +356,7 @@ def _ensure_evolution_workspace(self) -> bool: if self._is_usable_git_repo(str(evolve_dir)): self._source_dir = str(evolve_dir) self.project_path = str(evolve_dir) + self._ensure_origin_reachable() return True logger.warning( "EvolutionHandler[%s]: %s exists but is not a git repo — " @@ -361,15 +369,7 @@ def _ensure_evolution_workspace(self) -> bool: "EvolutionHandler[%s]: cloning %s → %s (workspace self-heal)", self.name, repo_url, evolve_dir, ) - subprocess.run( - ["git", "clone", repo_url, str(evolve_dir)], - capture_output=True, - text=True, - encoding="utf-8", - timeout=120, - check=True, - env=no_prompt_env(), - ) + self._clone_workspace(repo_url, evolve_dir) self._align_to_installed_version(evolve_dir) self._ensure_git_identity(evolve_dir) self._source_dir = str(evolve_dir) @@ -384,6 +384,88 @@ def _ensure_evolution_workspace(self) -> bool: ) return False + def _clone_workspace(self, repo_url: str, target: Path) -> None: + """Clone the evolution repo, retrying via SSH when https is blocked. + + Uses a short ``http.connectTimeout`` so a blocked github.com:443 + fails fast (seconds) instead of hanging; on a connection-type + failure the clone is retried with the SSH URL + (``git@github.com:owner/repo.git``), which works on networks that + block https git transport (observed on the packaged host). Other + failures (auth / 404 / repo-specific) propagate unchanged. + """ + cmd = ["git", "-c", "http.connectTimeout=10", "clone", repo_url, str(target)] + reason = "" + try: + subprocess.run( + cmd, capture_output=True, text=True, encoding="utf-8", + timeout=120, check=True, env=no_prompt_env(), + ) + return + except subprocess.CalledProcessError as e: + ssh_url = https_to_ssh_url(repo_url) + if not ssh_url or not is_git_connection_error(e.stderr or ""): + raise + # NB: `e` is deleted when the except block exits — capture first. + reason = (e.stderr.strip() or str(e))[:80] + logger.warning( + "EvolutionHandler[%s]: https clone failed (%s) — retrying via SSH", + self.name, reason, + ) + subprocess.run( + ["git", "clone", ssh_url, str(target)], + capture_output=True, text=True, encoding="utf-8", + timeout=120, check=True, env=no_prompt_env(), + ) + + def _ensure_origin_reachable(self) -> None: + """Probe the github.com https origin; switch to SSH when blocked. + + Some networks block github.com:443 while SSH port 22 stays open. + With an https origin every evolution pull/push hangs ~75 s and the + saturation-halt auto-resume (``git ls-remote``) never fires, + silently starving the cycle. One cheap probe per handler lifetime + (bounded by ``http.connectTimeout``) detects the blocked case; on + success nothing changes; on a connection-type failure the origin is + switched to the equivalent SSH URL so pull/push/ls-remote keep + working. Auth/404 errors never trigger a switch. + """ + if self._origin_probed: + return + self._origin_probed = True + origin = git_origin_url(self._source_dir) + ssh_url = https_to_ssh_url(origin) + if not ssh_url: + return # not a github.com https origin — nothing to switch + result = subprocess.run( + ["git", "-c", "http.connectTimeout=4", "ls-remote", origin, "HEAD"], + cwd=self._source_dir, + capture_output=True, + text=True, + encoding="utf-8", + timeout=15, + env=no_prompt_env(), + ) + if result.returncode == 0: + return # reachable — keep https + if not is_git_connection_error(result.stderr): + return # auth/404 etc — switching would not help + switch = subprocess.run( + ["git", "remote", "set-url", "origin", ssh_url], + cwd=self._source_dir, + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + env=no_prompt_env(), + ) + if switch.returncode == 0: + logger.warning( + "EvolutionHandler[%s]: https origin unreachable (%s) — " + "switched origin to %s", + self.name, (result.stderr.strip() or "")[:80], ssh_url, + ) + def _load_saturation_state(self) -> int: """Restore _empty_cycles counter from disk (survives daemon restarts).""" try: @@ -524,13 +606,26 @@ def _remote_advanced(self) -> bool: if not local: return False result = subprocess.run( - ["git", "ls-remote", "origin", "master"], + ["git", "-c", "http.connectTimeout=4", "ls-remote", "origin", "master"], cwd=self._source_dir, capture_output=True, text=True, - timeout=10, + timeout=15, env=no_prompt_env(), ) + if result.returncode != 0: + # https github.com may be blocked while SSH port 22 works — + # retry with the SSH form of the origin before giving up. + ssh_url = https_to_ssh_url(git_origin_url(self._source_dir)) + if ssh_url and is_git_connection_error(result.stderr): + result = subprocess.run( + ["git", "ls-remote", ssh_url, "master"], + cwd=self._source_dir, + capture_output=True, + text=True, + timeout=15, + env=no_prompt_env(), + ) if result.returncode != 0: return False remote = result.stdout.strip().split() diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 57a9827..c72682b 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -164,3 +164,76 @@ def test_parse_gh_auth_user_empty(): assert parse_gh_auth_user("") is None assert parse_gh_auth_user(None) is None # type: ignore[arg-type] + + +# ── HTTPS→SSH fallback helpers (2026-08-08) ─────────────────────── + +def test_https_to_ssh_url_dot_git(): + from emrg.server.git_utils import https_to_ssh_url + + assert https_to_ssh_url("https://github.com/argszero/emrg.git") == "git@github.com:argszero/emrg.git" + + +def test_https_to_ssh_url_no_dot_git(): + from emrg.server.git_utils import https_to_ssh_url + + assert https_to_ssh_url("https://github.com/argszero/emrg") == "git@github.com:argszero/emrg.git" + + +def test_https_to_ssh_url_rejects_ssh_url(): + from emrg.server.git_utils import https_to_ssh_url + + assert https_to_ssh_url("git@github.com:argszero/emrg.git") is None + + +def test_https_to_ssh_url_rejects_other_hosts_and_garbage(): + from emrg.server.git_utils import https_to_ssh_url + + assert https_to_ssh_url("https://gitlab.com/argszero/emrg.git") is None + assert https_to_ssh_url("https://github.example.com/a/b.git") is None + assert https_to_ssh_url("") is None + assert https_to_ssh_url(None) is None # type: ignore[arg-type] + assert https_to_ssh_url("file:///tmp/repo") is None + + +def test_is_git_connection_error_matches_connection_failures(): + from emrg.server.git_utils import is_git_connection_error + + assert is_git_connection_error( + "fatal: unable to access 'https://github.com/a/b.git/': " + "Failed to connect to github.com port 443 after 10013 ms" + ) + assert is_git_connection_error("ssh: connect to host github.com port 22: Connection refused") + + +def test_is_git_connection_error_rejects_auth_and_404(): + from emrg.server.git_utils import is_git_connection_error + + assert not is_git_connection_error("remote: Repository not found.") + assert not is_git_connection_error("Permission denied (publickey).") + assert not is_git_connection_error("Authentication failed for 'https://github.com/a/b.git'") + assert not is_git_connection_error("") + assert not is_git_connection_error(None) # type: ignore[arg-type] + + +def test_git_origin_url_real_repo(): + """Reads the raw origin URL from a real repo.""" + import subprocess as real_subprocess + + from emrg.server.git_utils import git_origin_url + + repo = real_subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True) + if repo.returncode != 0: + return # not in a git repo (packaged source) — skip + url = git_origin_url(repo.stdout.strip()) + assert isinstance(url, str) + assert url # the evolution workspace has an origin + + +def test_git_origin_url_missing_remote(tmp_path): + import subprocess as real_subprocess + + from emrg.server.git_utils import git_origin_url + + real_subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + assert git_origin_url(str(tmp_path)) == "" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index f844b6c..c6fe813 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -513,51 +513,83 @@ def _make_handler(tmp_path, name="emrg-task", project="emrg", path=None): class FakeGitRun: """Controllable subprocess.run fake for git commands.""" - def __init__(self, git_repo=True, tags="v0.2.7", clone_fails=False, remote_head="abc123"): + def __init__(self, git_repo=True, tags="v0.2.7", clone_fails=False, remote_head="abc123", + origin_url="", ls_remote_stderr="", clone_stderr="", clone_fail_once=False): self.calls = [] self.git_repo = git_repo self.tags = tags self.clone_fails = clone_fails self.remote_head = remote_head + self.origin_url = origin_url + self.ls_remote_stderr = ls_remote_stderr + self.clone_stderr = clone_stderr + self.clone_fail_once = clone_fail_once + self._clone_calls = 0 + + @staticmethod + def _norm(cmd): + """Strip `git -c key=value` config pairs (http.connectTimeout=…).""" + out, i = [], 1 + args = list(cmd) + while i < len(args): + if args[i] == "-c" and i + 1 < len(args): + i += 2 + continue + out.append(args[i]) + i += 1 + return out def __call__(self, cmd, *args, **kwargs): self.calls.append((list(cmd), kwargs.get("cwd"))) cwd = kwargs.get("cwd") or "" if cmd[0] == "git": - sub = cmd[1] - if sub == "rev-parse": - if "--is-inside-work-tree" in cmd: + sub = self._norm(cmd) + if sub and sub[0] == "rev-parse": + if "--is-inside-work-tree" in sub: return _R(0, "true\n" if self.git_repo else "false\n") - if "HEAD" in cmd: + if "HEAD" in sub: return _R(0, "abc123\n") - if sub == "ls-remote": - # `git ls-remote origin master` → "\trefs/heads/master" + if sub and sub[0] == "remote": + if sub[1] == "get-url": + return _R(0, self.origin_url + "\n") + if sub[1] == "set-url": + return _R(0, "") + if sub and sub[0] == "ls-remote": + # `git ls-remote origin master` → "\trefs/heads/master". + # When ls_remote_stderr is set, only the https-origin form + # fails — the SSH retry (git@github.com:…) succeeds. + # NB: list `in` is element-equality — use substring scan. + ssh_retry = any("git@github.com" in str(c) for c in cmd) + if self.ls_remote_stderr and not ssh_retry: + return _R(128, "", self.ls_remote_stderr) return _R(0, f"{self.remote_head}\trefs/heads/master\n") - if sub == "clone": - if self.clone_fails: - raise _CalledProcessErrorStub("clone failed") + if sub and sub[0] == "clone": + self._clone_calls += 1 + if self.clone_fails and (not self.clone_fail_once or self._clone_calls == 1): + raise _CalledProcessErrorStub(self.clone_stderr or "clone failed", + stderr=self.clone_stderr) target = Path(cmd[-1]) target.mkdir(parents=True, exist_ok=True) return _R(0, "") - if sub == "tag": + if sub and sub[0] == "tag": return _R(0, self.tags + "\n") - if sub == "checkout": + if sub and sub[0] == "checkout": return _R(0, "") - if sub == "config": + if sub and sub[0] == "config": return _R(0, "") # getter → empty → setter will run return _R(0, "") class _R: - def __init__(self, returncode, stdout): + def __init__(self, returncode, stdout, stderr=""): self.returncode = returncode self.stdout = stdout - self.stderr = "" + self.stderr = stderr class _CalledProcessErrorStub(subprocess.CalledProcessError): - def __init__(self, msg): - super().__init__(returncode=1, cmd=["git", "clone"], output=msg) + def __init__(self, msg, stderr=""): + super().__init__(returncode=1, cmd=["git", "clone"], output=msg, stderr=stderr) def test_ensure_self_evolution_task_adds_when_missing(tmp_path): @@ -781,7 +813,7 @@ def test_ensure_evolution_workspace_clones_and_aligns(tmp_path): assert ok is True assert handler._source_dir == str(evolve_dir) # clone called with repo URL + target - clone_calls = [c for c in fake.calls if c[0][1] == "clone"] + clone_calls = [c for c in fake.calls if "clone" in c[0]] assert len(clone_calls) == 1 # tag alignment: checkout -B master v0.2.7 checkout_calls = [c for c in fake.calls if c[0][1] == "checkout"] @@ -817,6 +849,197 @@ def test_ensure_evolution_workspace_clone_failure_skips(tmp_path): assert handler._source_dir != str(mod.EVOLUTION_CWD / "emrg") +# ── HTTPS→SSH fallback for blocked github.com:443 (2026-08-08) ───── +# Some networks block github.com:443 while SSH port 22 stays open — the +# self-heal clone and the saturation auto-resume (ls-remote) must not +# hard-depend on https reaching github.com. + +def test_ensure_origin_reachable_switches_to_ssh_when_https_blocked(tmp_path): + """https origin unreachable (connection error) → origin switched to SSH.""" + from emrg.server import scheduler as mod + + handler = _make_handler(tmp_path, path=str(tmp_path)) + handler._origin_probed = False + fake = FakeGitRun( + origin_url="https://github.com/argszero/emrg.git", + ls_remote_stderr=( + "fatal: unable to access 'https://github.com/argszero/emrg.git/': " + "Failed to connect to github.com port 443 after 4004 ms: " + "Couldn't connect to server" + ), + ) + orig_run = mod.subprocess.run + orig_origin = mod.git_origin_url + mod.subprocess.run = fake + mod.git_origin_url = lambda cwd: "https://github.com/argszero/emrg.git" + try: + handler._ensure_origin_reachable() + finally: + mod.subprocess.run = orig_run + mod.git_origin_url = orig_origin + + set_url_calls = [ + c for c in fake.calls + if c[0][0] == "git" and c[0][1] == "remote" and c[0][2] == "set-url" + ] + assert len(set_url_calls) == 1, f"expected one set-url, got {fake.calls}" + assert set_url_calls[0][0][4] == "git@github.com:argszero/emrg.git" + + +def test_ensure_origin_reachable_probes_only_once(tmp_path): + """One-shot probe: a second call never re-runs git.""" + from emrg.server import scheduler as mod + + handler = _make_handler(tmp_path, path=str(tmp_path)) + handler._origin_probed = False + fake = FakeGitRun( + origin_url="https://github.com/argszero/emrg.git", + ls_remote_stderr="fatal: unable to access: Failed to connect", + ) + orig_run = mod.subprocess.run + orig_origin = mod.git_origin_url + mod.subprocess.run = fake + mod.git_origin_url = lambda cwd: "https://github.com/argszero/emrg.git" + try: + handler._ensure_origin_reachable() + handler._ensure_origin_reachable() + finally: + mod.subprocess.run = orig_run + mod.git_origin_url = orig_origin + + set_url_calls = [ + c for c in fake.calls + if c[0][0] == "git" and c[0][1] == "remote" and c[0][2] == "set-url" + ] + assert len(set_url_calls) == 1 + + +def test_ensure_origin_reachable_keeps_https_when_reachable(tmp_path): + """ls-remote succeeds → origin untouched.""" + from emrg.server import scheduler as mod + + handler = _make_handler(tmp_path, path=str(tmp_path)) + handler._origin_probed = False + fake = FakeGitRun(origin_url="https://github.com/argszero/emrg.git") + orig_run = mod.subprocess.run + orig_origin = mod.git_origin_url + mod.subprocess.run = fake + mod.git_origin_url = lambda cwd: "https://github.com/argszero/emrg.git" + try: + handler._ensure_origin_reachable() + finally: + mod.subprocess.run = orig_run + mod.git_origin_url = orig_origin + + set_url_calls = [ + c for c in fake.calls + if c[0][0] == "git" and c[0][1] == "remote" and c[0][2] == "set-url" + ] + assert set_url_calls == [] + + +def test_ensure_origin_reachable_ignores_non_connection_errors(tmp_path): + """Auth/404 failures never switch the origin.""" + from emrg.server import scheduler as mod + + handler = _make_handler(tmp_path, path=str(tmp_path)) + handler._origin_probed = False + fake = FakeGitRun( + origin_url="https://github.com/argszero/emrg.git", + ls_remote_stderr="remote: Repository not found.", + ) + orig_run = mod.subprocess.run + orig_origin = mod.git_origin_url + mod.subprocess.run = fake + mod.git_origin_url = lambda cwd: "https://github.com/argszero/emrg.git" + try: + handler._ensure_origin_reachable() + finally: + mod.subprocess.run = orig_run + mod.git_origin_url = orig_origin + + set_url_calls = [ + c for c in fake.calls + if c[0][0] == "git" and c[0][1] == "remote" and c[0][2] == "set-url" + ] + assert set_url_calls == [] + + +def test_ensure_evolution_workspace_clone_falls_back_to_ssh(tmp_path): + """https clone connection failure → retried via SSH, workspace usable.""" + import pathlib as _pathlib + + from emrg.server import scheduler as mod + + evolve_dir = tmp_path / "evolution" / "emrg" + mod.EVOLUTION_CWD = tmp_path / "evolution" + handler = _make_handler(tmp_path, path=str(tmp_path / "nonexistent")) + handler._repo_url = "https://github.com/argszero/emrg.git" + + install_dir = tmp_path / ".emrg" / "install" + install_dir.mkdir(parents=True) + (install_dir / "version.txt").write_text("0.2.7", encoding="utf-8") + + fake = FakeGitRun( + git_repo=False, tags="v0.2.7", + clone_fails=True, clone_fail_once=True, + clone_stderr=( + "fatal: unable to access 'https://github.com/argszero/emrg.git/': " + "Failed to connect to github.com port 443 after 10013 ms: " + "Couldn't connect to server" + ), + ) + orig_run = mod.subprocess.run + orig_evolve = mod.EVOLUTION_CWD + orig_config = mod.config_dir + orig_home = _pathlib.Path.home + mod.subprocess.run = fake + mod.config_dir = lambda: tmp_path + _pathlib.Path.home = classmethod(lambda cls: tmp_path) + try: + ok = handler._ensure_evolution_workspace() + finally: + mod.subprocess.run = orig_run + mod.config_dir = orig_config + mod.EVOLUTION_CWD = orig_evolve + _pathlib.Path.home = orig_home + + assert ok is True + assert handler._source_dir == str(evolve_dir) + clone_calls = [c for c in fake.calls if "clone" in c[0]] + assert len(clone_calls) == 2, f"expected https + ssh clone, got {fake.calls}" + assert clone_calls[0][0][-2] == "https://github.com/argszero/emrg.git" + assert clone_calls[1][0][-2] == "git@github.com:argszero/emrg.git" + + +def test_remote_advanced_ssh_fallback_when_https_blocked(tmp_path): + """ls-remote over a blocked https origin → retried via the SSH URL.""" + from emrg.server import scheduler as mod + + handler = _make_handler(tmp_path, project="", path=str(tmp_path)) + fake = FakeGitRun( + origin_url="https://github.com/argszero/emrg.git", + ls_remote_stderr=( + "fatal: unable to access 'https://github.com/argszero/emrg.git/': " + "Failed to connect to github.com port 443" + ), + remote_head="9f8e7d6", # != local abc123 → advanced + ) + orig_run = mod.subprocess.run + orig_origin = mod.git_origin_url + mod.subprocess.run = fake + mod.git_origin_url = lambda cwd: "https://github.com/argszero/emrg.git" + try: + assert handler._remote_advanced() is True + finally: + mod.subprocess.run = orig_run + mod.git_origin_url = orig_origin + + ls_calls = [c for c in fake.calls if "ls-remote" in c[0]] + assert len(ls_calls) == 2, f"expected https + ssh ls-remote, got {fake.calls}" + assert "git@github.com:argszero/emrg.git" in ls_calls[1][0] + + # ── EvolutionHandler cycle truncation detection ────────────────── # mem-repo lesson (tool-call truncation must be flagged, not silently # treated as a successful/empty cycle — #523 applied it to the chat UI;