fix(cli): auto-restore a deleted ui-tui workspace from git before TUI launch

The Windows update path can leave tracked ui-tui/ files deleted in the
working tree (HEAD intact). The guard now self-heals: when ui-tui/ is
missing in a git checkout, run `git restore -- ui-tui` and continue,
falling back to the printed manual-recovery steps only when git can't
recover it (no checkout / restore failed).

Builds on konsisumer's missing-workspace guard.
This commit is contained in:
teknium1 2026-06-21 12:32:43 -07:00 committed by Teknium
parent 537ad9ea9a
commit db097fb088
2 changed files with 94 additions and 7 deletions

View file

@ -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=<missing ui-tui>)``, 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:

View file

@ -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 ──────────────────────────────────────────