diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 918733325e2..82f4a95f7a1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1650,8 +1650,50 @@ def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None: return bundled if bundled.is_file() else None -def _exit_missing_tui_workspace(tui_dir: Path) -> "NoReturn": - """Abort TUI launch with a recovery hint when the workspace checkout is missing.""" +def _restore_tui_workspace(tui_dir: Path) -> bool: + """Try to restore a missing ``ui-tui/`` from git, returning True on success. + + On Windows an antivirus / NTFS filter driver can leave tracked ``ui-tui/`` + files deleted in the working tree after ``hermes update`` (HEAD stays + intact; the files just vanish — see issue #49145). Those files are tracked, + so ``git restore`` puts them back deterministically. Best-effort: returns + False (rather than raising) when git is unavailable, this isn't a checkout, + or the restore leaves the directory still missing — the caller then prints + the manual-recovery message. + """ + git = shutil.which("git") + if not git or not (tui_dir.parent / ".git").exists(): + return False + try: + subprocess.run( + [git, "restore", "--", tui_dir.name], + cwd=str(tui_dir.parent), + capture_output=True, + text=True, + check=False, + ) + except OSError: + return False + return tui_dir.is_dir() + + +def _ensure_tui_workspace(tui_dir: Path) -> None: + """Ensure ``ui-tui/`` exists before any npm/node subprocess uses it as cwd. + + Without this, a missing workspace falls through to ``subprocess.run(..., + cwd=)``, which crashes with ``NotADirectoryError`` + (``WinError 267`` on Windows) instead of a usable message (#49145). We + first try to self-heal via ``git restore``; only if that can't recover the + directory do we abort with concrete manual-recovery steps. + """ + if tui_dir.is_dir(): + return + + if _restore_tui_workspace(tui_dir): + if not os.environ.get("HERMES_QUIET"): + print(f"Restored missing TUI workspace: {tui_dir}") + return + print( "Error: the TUI workspace is missing from this Hermes checkout.\n" f"Expected directory: {tui_dir}\n" @@ -1699,8 +1741,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: ) sys.exit(1) - if not ext_dir and not tui_dir.is_dir(): - _exit_missing_tui_workspace(tui_dir) + if not ext_dir: + _ensure_tui_workspace(tui_dir) # 1. Prebuilt bundle (nix / packaged release): just run it. if not tui_dev: diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index 08a2200fa0a..109fe641120 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -327,16 +327,20 @@ def test_make_tui_argv_decodes_dev_prebuild_with_utf8_replace( _assert_utf8_replace_capture(calls[0][1]) -def test_make_tui_argv_exits_with_recovery_hint_when_workspace_missing( +def test_make_tui_argv_exits_with_recovery_hint_when_workspace_unrecoverable( tmp_path: Path, main_mod, monkeypatch, capsys ) -> None: + """Missing ui-tui + no git checkout → clean error, never touches node/npm.""" monkeypatch.delenv("HERMES_TUI_DIR", raising=False) monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None) - def fail_which(_name: str) -> str: + # No .git beside ui-tui → _restore_tui_workspace bails, fallback message fires. + def which(name: str) -> str | None: + if name == "git": + return "/usr/bin/git" raise AssertionError("node/npm lookup must not run when ui-tui is missing") - monkeypatch.setattr(main_mod.shutil, "which", fail_which) + monkeypatch.setattr(main_mod.shutil, "which", which) with pytest.raises(SystemExit) as exc: main_mod._make_tui_argv(tmp_path / "ui-tui", tui_dev=False) @@ -348,6 +352,47 @@ def test_make_tui_argv_exits_with_recovery_hint_when_workspace_missing( assert "hermes update --force" in err +def test_make_tui_argv_restores_missing_workspace_from_git( + tmp_path: Path, main_mod, monkeypatch, capsys +) -> None: + """Missing ui-tui in a git checkout self-heals via `git restore` and continues.""" + monkeypatch.delenv("HERMES_TUI_DIR", raising=False) + monkeypatch.delenv("HERMES_QUIET", raising=False) + monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None) + + tui_dir = tmp_path / "ui-tui" + (tmp_path / ".git").mkdir() # mark tmp_path as a checkout + + monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/usr/bin/{name}") + + restore_calls: list[tuple[list[str], object]] = [] + + def fake_run(cmd, *args, **kwargs): + # Simulate `git restore -- ui-tui` materialising the directory. + if cmd[:2] == ["/usr/bin/git", "restore"]: + restore_calls.append((cmd, kwargs.get("cwd"))) + tui_dir.mkdir(exist_ok=True) + (tui_dir / "dist").mkdir() + (tui_dir / "dist" / "entry.js").write_text("// bundle") + (tui_dir / "package.json").write_text("{}") + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(main_mod.subprocess, "run", fake_run) + # node_modules present + lockfile-in-sync so we skip the install/build path + # and land straight on the node dist/entry.js return. + monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False) + monkeypatch.setattr(main_mod, "_is_termux_startup_environment", lambda: False) + + argv, cwd = main_mod._make_tui_argv(tui_dir, tui_dev=False) + + assert restore_calls, "expected a `git restore` attempt" + assert restore_calls[0][0] == ["/usr/bin/git", "restore", "--", "ui-tui"] + assert restore_calls[0][1] == str(tmp_path) + assert argv[-1] == str(tui_dir / "dist" / "entry.js") + assert cwd == tui_dir + assert "Restored missing TUI workspace" in capsys.readouterr().out + + # ── _workspace_root helper ──────────────────────────────────────────