fix(update): ZIP swap preserves the built desktop app (apps/desktop/release)

The #70337/#87331 win-unpacked wipe half, from PR #70477 by @JonthanaHanh
(reimplemented against the two-phase staged swap that postdates that
branch — the live release/ dir is grafted into the staged apps copy
BEFORE the atomic commit, so preservation rides the same rollback
machinery instead of a post-hoc copy).

Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
This commit is contained in:
JonthanaHanh
2026-08-21 19:38:55 -07:00
committed by Teknium
parent eac3f645ef
commit 01c14ad7f3
2 changed files with 72 additions and 0 deletions
+16
View File
@@ -1522,6 +1522,22 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> boo
src = os.path.join(extracted, item)
dst = os.path.join(str(_m().PROJECT_ROOT), item)
staged.append((_stage_replacement(src, dst), dst))
# #70337/#87331: the GitHub source ZIP contains only source —
# apps/desktop/release/ (the BUILT desktop app, win-unpacked/
# Hermes.exe) exists only in the LIVE tree. Swapping `apps`
# without it deletes the desktop build and breaks the
# shortcut. Graft the live release dir into the staged copy
# BEFORE the swap so the commit preserves it atomically.
if item == "apps":
live_release = os.path.join(dst, "desktop", "release")
staged_release = os.path.join(
staged[-1][0], "desktop", "release"
)
if os.path.isdir(live_release) and not os.path.exists(
staged_release
):
os.makedirs(os.path.dirname(staged_release), exist_ok=True)
shutil.copytree(live_release, staged_release)
except Exception:
# Nothing is live yet; drop the partial staging copies so a retry
# starts from the same free space this attempt did.
@@ -0,0 +1,56 @@
"""#70337/#87331: the ZIP swap must preserve apps/desktop/release/.
The GitHub source ZIP carries only source; the BUILT desktop app
(release/win-unpacked/Hermes.exe) exists only in the live tree. Swapping
`apps` without grafting the live release dir deletes the desktop build.
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
def test_staged_apps_swap_preserves_live_release_dir(tmp_path, monkeypatch):
from hermes_cli import main as hermes_main
from hermes_cli.update_cmd import (
_commit_staged_replacements,
_stage_replacement,
)
# live tree: apps/desktop/release/win-unpacked/Hermes.exe + old source
root = tmp_path / "install"
live_apps = root / "apps" / "desktop"
(live_apps / "release" / "win-unpacked").mkdir(parents=True)
(live_apps / "release" / "win-unpacked" / "Hermes.exe").write_bytes(b"MZbuilt")
(live_apps / "electron").mkdir()
(live_apps / "electron" / "main.ts").write_text("old source")
# extracted ZIP: new source, NO release dir (GitHub source archive shape)
extracted = tmp_path / "extracted"
zip_apps = extracted / "apps" / "desktop"
(zip_apps / "electron").mkdir(parents=True)
(zip_apps / "electron" / "main.ts").write_text("new source")
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", root)
# Reproduce the _update_via_zip staging loop for the `apps` entry,
# including the release-dir graft.
src = str(extracted / "apps")
dst = str(root / "apps")
staged_path = _stage_replacement(src, dst)
live_release = os.path.join(dst, "desktop", "release")
staged_release = os.path.join(staged_path, "desktop", "release")
if os.path.isdir(live_release) and not os.path.exists(staged_release):
os.makedirs(os.path.dirname(staged_release), exist_ok=True)
shutil.copytree(live_release, staged_release)
_commit_staged_replacements([(staged_path, dst)])
# New source landed AND the built desktop app survived.
assert (root / "apps" / "desktop" / "electron" / "main.ts").read_text() == (
"new source"
)
exe = root / "apps" / "desktop" / "release" / "win-unpacked" / "Hermes.exe"
assert exe.exists() and exe.read_bytes() == b"MZbuilt"