refactor: ZIP swap journal and recovery live in _early_recovery_zip
Pure move, behaviour-identical. The ZIP swap journal, owner lock and journal-driven recovery (~250 lines) are unrelated to the git-marker restore, yet had grown _early_recovery.py from 684 to 1455 lines. They now live in the topical sibling hermes_cli/_early_recovery_zip.py. restore_interrupted_pull imports it only when the journal exists, so the git-marker repair and its published closure (RECOVERY_CLOSURE) still need only _early_recovery.py; ZIP_SWAP_JOURNAL stays there for that fast-path stat. write_durable_text stays too (the marker and fleet writers use it). Importers and the one monkeypatch seam (_lock_fd, read by the lock) now point at the module that reads them. (review K132361 shape gate)
This commit is contained in:
@@ -6,7 +6,6 @@ import contextlib
|
||||
import errno
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -663,95 +662,10 @@ def _release_dead_index_lock(git_dir: Path, root: Path | None = None) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# The ZIP update's equivalent of the interrupted-pull marker: a journal in the install root naming
|
||||
# every entry the swap stages/renames and whether it existed before, plus an OS lock its owner holds
|
||||
# for the whole stage+swap (the kernel drops it with the owner: liveness without pids or ages).
|
||||
# The ZIP update's equivalent of the interrupted-pull marker (``_early_recovery_zip``): a journal in the
|
||||
# install root naming every entry the swap stages/renames. Its name stays here: the launch fast path
|
||||
# stats it without importing the ZIP code.
|
||||
ZIP_SWAP_JOURNAL = ".hermes-update-zip-swap"
|
||||
_ZIP_STAGING_SUFFIX, _ZIP_OLD_SUFFIX = ".hermes-update-staging", ".hermes-update-old"
|
||||
|
||||
|
||||
class ZipSwapLock:
|
||||
"""``zip_swap_owner_lock``'s verdict: truthy only while this process holds the kernel lock.
|
||||
|
||||
``reason`` names a refusal: ``busy`` (a live owner holds it) or why no lock could be taken at all.
|
||||
"""
|
||||
|
||||
__slots__ = ("owned", "reason")
|
||||
|
||||
def __init__(self, owned: bool, reason: str = "") -> None:
|
||||
self.owned, self.reason = owned, reason
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.owned
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def zip_swap_owner_lock(root: Path, *, wait: float = 0.0):
|
||||
"""Yields a truthy ``ZipSwapLock`` while this process owns the ZIP swap lock, a falsy one otherwise.
|
||||
|
||||
The lock file is a stable sidecar: never unlinked, so every process locks the same inode (an
|
||||
unlink lets a waiter keep the old inode while a newcomer locks a fresh one). No lock, no admission:
|
||||
a root where the file cannot be opened or locked refuses with the reason (fail closed)."""
|
||||
lock_path = Path(root) / (ZIP_SWAP_JOURNAL + ".lock")
|
||||
try:
|
||||
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
||||
except OSError as exc:
|
||||
yield ZipSwapLock(False, f"cannot open the ZIP swap lock {lock_path}: {exc.strerror or exc}")
|
||||
return
|
||||
try:
|
||||
deadline = time.monotonic() + wait
|
||||
while True:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except OSError as exc:
|
||||
if exc.errno not in _CLAIM_HELD_ERRNOS:
|
||||
yield ZipSwapLock(False, f"cannot lock the ZIP swap lock {lock_path}: {exc.strerror or exc}")
|
||||
return
|
||||
if time.monotonic() >= deadline:
|
||||
yield ZipSwapLock(False, "busy")
|
||||
return
|
||||
time.sleep(0.05)
|
||||
try:
|
||||
yield ZipSwapLock(True)
|
||||
finally:
|
||||
_lock_fd(fd, False)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def write_zip_swap_journal(root: Path, phase: str, entries: list, gen: str) -> None:
|
||||
"""Publish the swap journal: ``entries`` are ``[name, existed, staged_id, live_id]`` (ids from
|
||||
``zip_entry_identity``, "" while unknown); ``gen`` is the run's random tag (its backup temps)."""
|
||||
import json
|
||||
|
||||
write_durable_text(Path(root) / ZIP_SWAP_JOURNAL,
|
||||
json.dumps({"pid": os.getpid(), "gen": gen, "phase": phase, "entries": entries}))
|
||||
|
||||
|
||||
def zip_entry_identity(path) -> str:
|
||||
"""The entry's own identity (lstat, never its target); "" when absent or unidentifiable.
|
||||
|
||||
The ZIP journal's provenance: recovery deletes only an entry whose identity it recorded when the
|
||||
swap made it (a staging copy, or the live entry its backup links/renames), never a lookalike. A
|
||||
non-directory adds size and mtime: a file deleted and recreated often gets the same inode back.
|
||||
A directory is ``dev:ino:type`` only: a staged tree's own mtime moves while it is being filled."""
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except OSError:
|
||||
return ""
|
||||
if not st.st_ino:
|
||||
return ""
|
||||
base = f"{st.st_dev}:{st.st_ino}:{stat.S_IFMT(st.st_mode):o}"
|
||||
return base if stat.S_ISDIR(st.st_mode) else f"{base}:{st.st_size}:{st.st_mtime_ns}"
|
||||
|
||||
|
||||
def write_durable_text(path: Path, text: str) -> None:
|
||||
@@ -781,173 +695,6 @@ def write_durable_text(path: Path, text: str) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def _drop_path(path: Path) -> None:
|
||||
if path.is_dir() and not path.is_symlink():
|
||||
import shutil
|
||||
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except OSError:
|
||||
# A staged copy keeps its source's modes: a read-only directory refuses to give up its
|
||||
# entries. These trees are the update's own copies, so make their directories writable and
|
||||
# retry. Files keep their modes: a grafted artifact is a hardlink to the LIVE file, and POSIX
|
||||
# deletion needs only the parent's write bit. Windows refuses to delete a read-only file, so
|
||||
# there only a file no other link shares has its read-only bit cleared.
|
||||
rwx = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR
|
||||
os.chmod(path, os.stat(path).st_mode | rwx)
|
||||
for dirpath, dirnames, files in os.walk(path):
|
||||
for name in dirnames:
|
||||
child = os.path.join(dirpath, name)
|
||||
if not os.path.islink(child):
|
||||
os.chmod(child, os.stat(child).st_mode | rwx)
|
||||
for name in files if sys.platform == "win32" else ():
|
||||
child = os.path.join(dirpath, name)
|
||||
st = os.lstat(child)
|
||||
if st.st_nlink == 1 and not stat.S_ISLNK(st.st_mode):
|
||||
os.chmod(child, st.st_mode | stat.S_IWUSR)
|
||||
shutil.rmtree(path)
|
||||
elif path.exists() or path.is_symlink():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def _parse_zip_swap_journal(raw: str) -> tuple[str, str, list[tuple[str, bool, str, str]]] | None:
|
||||
"""``(phase, gen, [(entry name, existed, staged_id, live_id)])`` from a journal this code wrote, else None."""
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get("phase") not in ("staging", "swapping", "committed"):
|
||||
return None
|
||||
entries, gen = data.get("entries"), data.get("gen")
|
||||
if not isinstance(entries, list) or not isinstance(gen, str) or not re.fullmatch(r"[0-9a-f]{12}", gen):
|
||||
return None
|
||||
if not all(isinstance(e, list) and len(e) == 4 and all(isinstance(v, str) for v in (e[0], e[2], e[3]))
|
||||
and isinstance(e[1], bool) and e[0] not in ("", ".", "..") and "/" not in e[0] and "\\" not in e[0]
|
||||
for e in entries):
|
||||
return None
|
||||
return data["phase"], gen, [(name, existed, staged, live) for name, existed, staged, live in entries]
|
||||
|
||||
|
||||
def _discard_unless_owned(path: Path, identity: str, kept: list[Path]) -> None:
|
||||
"""Delete ``path`` only when it is provably the entry the swap recorded; anything else (a later
|
||||
user file at the suffix, a foreign journal's victim) is renamed aside, never deleted (review Z1)."""
|
||||
if not os.path.lexists(path):
|
||||
return
|
||||
if identity and zip_entry_identity(path) == identity:
|
||||
_drop_path(path)
|
||||
else:
|
||||
kept.append(_keep_aside(path))
|
||||
|
||||
|
||||
def _settle_zip_entry(root: Path, phase: str, gen: str, entry: tuple[str, bool, str, str], kept: list[Path]) -> bool:
|
||||
"""Finish or roll back one journaled entry; True when the live tree changed.
|
||||
|
||||
Presence is the ENTRY's (lexists/lstat), never its target's: a dangling symlink backup is the only
|
||||
copy of a tracked symlink, not "no backup" (review Z3). Restoring a backup never deletes bytes, so
|
||||
it needs no provenance; every deletion does (``_discard_unless_owned``)."""
|
||||
name, existed, staged_id, live_id = entry
|
||||
here = os.path.lexists
|
||||
dst = root / name
|
||||
staging, old = Path(f"{dst}{_ZIP_STAGING_SUFFIX}"), Path(f"{dst}{_ZIP_OLD_SUFFIX}")
|
||||
changed = False
|
||||
if phase == "swapping":
|
||||
# Staging finished before this phase, so a backup now is the one the swap made from the old
|
||||
# entry: it existed, whatever an older journal recorded.
|
||||
existed = existed or here(old)
|
||||
if existed and here(old) and not (live_id and zip_entry_identity(dst) == live_id):
|
||||
if (old.is_file() and not old.is_symlink() and staged_id and zip_entry_identity(dst) == staged_id
|
||||
and not dst.is_dir()):
|
||||
os.replace(old, dst) # a file entry never goes missing, not even here
|
||||
else:
|
||||
_discard_unless_owned(dst, staged_id, kept)
|
||||
os.rename(old, dst) # moves a symlink itself, never its target
|
||||
changed = True
|
||||
elif not existed and here(dst) and not here(staging):
|
||||
_discard_unless_owned(dst, staged_id, kept)
|
||||
changed = True
|
||||
elif existed and not here(dst) and here(old):
|
||||
os.rename(old, dst) # the backup is the only copy left
|
||||
changed = True
|
||||
for leftover, identity in ((staging, staged_id), (old, live_id)):
|
||||
_discard_unless_owned(leftover, identity, kept)
|
||||
# ``<old>.<gen>.tmp``: a backup copy of the live file killed before its rename (no-hardlink file
|
||||
# systems). The tag names it but proves nothing about the bytes (F78): only a prefix of the live
|
||||
# file is that copy; anything else at the name is kept aside.
|
||||
tmp = Path(f"{old}.{gen}.tmp")
|
||||
if here(tmp):
|
||||
regular = [p.is_file() and not p.is_symlink() for p in (tmp, dst)]
|
||||
if all(regular) and dst.read_bytes().startswith(tmp.read_bytes()):
|
||||
_drop_path(tmp)
|
||||
else:
|
||||
kept.append(_keep_aside(tmp))
|
||||
return changed
|
||||
|
||||
|
||||
def restore_interrupted_zip_swap(project_root: Path | None = None) -> bool:
|
||||
"""Finish or roll back a ZIP swap whose owner died; True when the tree changed (relaunch).
|
||||
|
||||
``committed``: every rename landed, only backups remain -> drop them (finish). ``swapping``: put
|
||||
each moved-aside entry back and remove entries the swap added (roll back to the old tree, which
|
||||
the venv was built for; ``hermes update`` redoes it). ``staging``: nothing live moved -> drop the
|
||||
staging copies. Either way no ``*.hermes-update-staging``/``-old`` sibling is left to wedge the
|
||||
next run's free-space or dirty-tree checks. A live owner (lock held) is never second-guessed.
|
||||
Provenance (review Z1): a sibling or entry is deleted only when its identity is the one the journal
|
||||
recorded; anything else is kept aside under a ``.hermes-update-kept`` name and reported.
|
||||
"""
|
||||
root = _project_root() if project_root is None else Path(project_root)
|
||||
journal = root / ZIP_SWAP_JOURNAL
|
||||
if not journal.is_file() or _pytest_owns_live_checkout(root):
|
||||
return False
|
||||
with zip_swap_owner_lock(root) as owned:
|
||||
if not owned and owned.reason != "busy":
|
||||
print(f"⚠ An interrupted ZIP update cannot be settled: {owned.reason}.", file=sys.stderr)
|
||||
if not owned or not journal.is_file():
|
||||
return False
|
||||
try:
|
||||
raw = journal.read_text(encoding="utf-8-sig")
|
||||
except OSError as exc: # transient (AV, sharing violation): the journal is still the record
|
||||
print(f"⚠ Could not read the interrupted ZIP update's journal ({exc}); the next launch retries.",
|
||||
file=sys.stderr)
|
||||
return False
|
||||
parsed = _parse_zip_swap_journal(raw)
|
||||
if parsed is None:
|
||||
# Never guess which live entry to move, and never retire the only record of a mixed tree.
|
||||
print(f"⚠ The interrupted ZIP update's journal {journal} is unreadable or from another version; "
|
||||
"it and every `*.hermes-update-old` backup were kept. Put back what each backup replaced "
|
||||
"(or reinstall), then delete the journal.", file=sys.stderr)
|
||||
return False
|
||||
phase, gen, entries = parsed
|
||||
changed = False
|
||||
failed = False
|
||||
kept: list[Path] = []
|
||||
for entry in reversed(entries):
|
||||
try:
|
||||
changed = _settle_zip_entry(root, phase, gen, entry, kept) or changed
|
||||
except OSError as exc:
|
||||
failed = True
|
||||
print(f"⚠ Could not settle {entry[0]} after an interrupted ZIP update: {exc}", file=sys.stderr)
|
||||
if kept:
|
||||
print("⚠ An interrupted ZIP update's recovery found entries it could not prove were its own and "
|
||||
f"kept them aside instead of deleting them: {', '.join(map(str, kept))}. Delete each once "
|
||||
"you know it is not yours.", file=sys.stderr)
|
||||
# Retire the journal only on a verified terminal state, not an exception-free loop: no sibling
|
||||
# (staging copy, backup, backup temp) left that only this journal could still explain.
|
||||
siblings = (_ZIP_STAGING_SUFFIX, _ZIP_OLD_SUFFIX, f"{_ZIP_OLD_SUFFIX}.{gen}.tmp")
|
||||
unsettled = [e[0] for e in entries if any(os.path.lexists(f"{root / e[0]}{x}") for x in siblings)]
|
||||
if failed or unsettled:
|
||||
if unsettled and not failed:
|
||||
print(f"⚠ The interrupted ZIP update left {', '.join(unsettled)} unsettled; its journal "
|
||||
f"{journal} stays and the next launch retries.", file=sys.stderr)
|
||||
return changed # the journal stays: the next launch retries
|
||||
journal.unlink(missing_ok=True)
|
||||
if changed:
|
||||
print("⚠ A previous ZIP `hermes update` was interrupted mid-swap; the old install was put back. "
|
||||
"`hermes update` updates it again.", file=sys.stderr)
|
||||
return changed
|
||||
|
||||
|
||||
def restore_interrupted_pull(project_root: Path | None = None, *, after_failure: bool = False) -> bool:
|
||||
"""Put back the files a killed ``hermes update`` had half-moved to the new commit.
|
||||
|
||||
@@ -987,6 +734,8 @@ def restore_interrupted_pull(project_root: Path | None = None, *, after_failure:
|
||||
if busy:
|
||||
print(busy_note.format(busy), file=sys.stderr)
|
||||
return False
|
||||
from hermes_cli._early_recovery_zip import restore_interrupted_zip_swap
|
||||
|
||||
if restore_interrupted_zip_swap(root):
|
||||
return True
|
||||
if not marker.is_file():
|
||||
|
||||
276
hermes_cli/_early_recovery_zip.py
Normal file
276
hermes_cli/_early_recovery_zip.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Settles a ZIP ``hermes update`` swap whose owner died: its journal, owner lock and recovery.
|
||||
|
||||
Split out of ``_early_recovery`` (stdlib-only, like it). ``_early_recovery.restore_interrupted_pull``
|
||||
imports this only when the journal exists, so the git-marker repair and its published closure
|
||||
(``RECOVERY_CLOSURE``) never need it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli._early_recovery import (
|
||||
_CLAIM_HELD_ERRNOS, ZIP_SWAP_JOURNAL, _keep_aside, _lock_fd, _project_root, _pytest_owns_live_checkout,
|
||||
write_durable_text)
|
||||
|
||||
# A journal in the install root naming every entry the swap stages/renames and whether it existed
|
||||
# before, plus an OS lock its owner holds for the whole stage+swap (the kernel drops it with the
|
||||
# owner: liveness without pids or ages).
|
||||
_ZIP_STAGING_SUFFIX, _ZIP_OLD_SUFFIX = ".hermes-update-staging", ".hermes-update-old"
|
||||
|
||||
|
||||
class ZipSwapLock:
|
||||
"""``zip_swap_owner_lock``'s verdict: truthy only while this process holds the kernel lock.
|
||||
|
||||
``reason`` names a refusal: ``busy`` (a live owner holds it) or why no lock could be taken at all.
|
||||
"""
|
||||
|
||||
__slots__ = ("owned", "reason")
|
||||
|
||||
def __init__(self, owned: bool, reason: str = "") -> None:
|
||||
self.owned, self.reason = owned, reason
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.owned
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def zip_swap_owner_lock(root: Path, *, wait: float = 0.0):
|
||||
"""Yields a truthy ``ZipSwapLock`` while this process owns the ZIP swap lock, a falsy one otherwise.
|
||||
|
||||
The lock file is a stable sidecar: never unlinked, so every process locks the same inode (an
|
||||
unlink lets a waiter keep the old inode while a newcomer locks a fresh one). No lock, no admission:
|
||||
a root where the file cannot be opened or locked refuses with the reason (fail closed)."""
|
||||
lock_path = Path(root) / (ZIP_SWAP_JOURNAL + ".lock")
|
||||
try:
|
||||
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
||||
except OSError as exc:
|
||||
yield ZipSwapLock(False, f"cannot open the ZIP swap lock {lock_path}: {exc.strerror or exc}")
|
||||
return
|
||||
try:
|
||||
deadline = time.monotonic() + wait
|
||||
while True:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except OSError as exc:
|
||||
if exc.errno not in _CLAIM_HELD_ERRNOS:
|
||||
yield ZipSwapLock(False, f"cannot lock the ZIP swap lock {lock_path}: {exc.strerror or exc}")
|
||||
return
|
||||
if time.monotonic() >= deadline:
|
||||
yield ZipSwapLock(False, "busy")
|
||||
return
|
||||
time.sleep(0.05)
|
||||
try:
|
||||
yield ZipSwapLock(True)
|
||||
finally:
|
||||
_lock_fd(fd, False)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def write_zip_swap_journal(root: Path, phase: str, entries: list, gen: str) -> None:
|
||||
"""Publish the swap journal: ``entries`` are ``[name, existed, staged_id, live_id]`` (ids from
|
||||
``zip_entry_identity``, "" while unknown); ``gen`` is the run's random tag (its backup temps)."""
|
||||
import json
|
||||
|
||||
write_durable_text(Path(root) / ZIP_SWAP_JOURNAL,
|
||||
json.dumps({"pid": os.getpid(), "gen": gen, "phase": phase, "entries": entries}))
|
||||
|
||||
|
||||
def zip_entry_identity(path) -> str:
|
||||
"""The entry's own identity (lstat, never its target); "" when absent or unidentifiable.
|
||||
|
||||
The ZIP journal's provenance: recovery deletes only an entry whose identity it recorded when the
|
||||
swap made it (a staging copy, or the live entry its backup links/renames), never a lookalike. A
|
||||
non-directory adds size and mtime: a file deleted and recreated often gets the same inode back.
|
||||
A directory is ``dev:ino:type`` only: a staged tree's own mtime moves while it is being filled."""
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except OSError:
|
||||
return ""
|
||||
if not st.st_ino:
|
||||
return ""
|
||||
base = f"{st.st_dev}:{st.st_ino}:{stat.S_IFMT(st.st_mode):o}"
|
||||
return base if stat.S_ISDIR(st.st_mode) else f"{base}:{st.st_size}:{st.st_mtime_ns}"
|
||||
|
||||
|
||||
def _drop_path(path: Path) -> None:
|
||||
if path.is_dir() and not path.is_symlink():
|
||||
import shutil
|
||||
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except OSError:
|
||||
# A staged copy keeps its source's modes: a read-only directory refuses to give up its
|
||||
# entries. These trees are the update's own copies, so make their directories writable and
|
||||
# retry. Files keep their modes: a grafted artifact is a hardlink to the LIVE file, and POSIX
|
||||
# deletion needs only the parent's write bit. Windows refuses to delete a read-only file, so
|
||||
# there only a file no other link shares has its read-only bit cleared.
|
||||
rwx = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR
|
||||
os.chmod(path, os.stat(path).st_mode | rwx)
|
||||
for dirpath, dirnames, files in os.walk(path):
|
||||
for name in dirnames:
|
||||
child = os.path.join(dirpath, name)
|
||||
if not os.path.islink(child):
|
||||
os.chmod(child, os.stat(child).st_mode | rwx)
|
||||
for name in files if sys.platform == "win32" else ():
|
||||
child = os.path.join(dirpath, name)
|
||||
st = os.lstat(child)
|
||||
if st.st_nlink == 1 and not stat.S_ISLNK(st.st_mode):
|
||||
os.chmod(child, st.st_mode | stat.S_IWUSR)
|
||||
shutil.rmtree(path)
|
||||
elif path.exists() or path.is_symlink():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def _parse_zip_swap_journal(raw: str) -> tuple[str, str, list[tuple[str, bool, str, str]]] | None:
|
||||
"""``(phase, gen, [(entry name, existed, staged_id, live_id)])`` from a journal this code wrote, else None."""
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get("phase") not in ("staging", "swapping", "committed"):
|
||||
return None
|
||||
entries, gen = data.get("entries"), data.get("gen")
|
||||
if not isinstance(entries, list) or not isinstance(gen, str) or not re.fullmatch(r"[0-9a-f]{12}", gen):
|
||||
return None
|
||||
if not all(isinstance(e, list) and len(e) == 4 and all(isinstance(v, str) for v in (e[0], e[2], e[3]))
|
||||
and isinstance(e[1], bool) and e[0] not in ("", ".", "..") and "/" not in e[0] and "\\" not in e[0]
|
||||
for e in entries):
|
||||
return None
|
||||
return data["phase"], gen, [(name, existed, staged, live) for name, existed, staged, live in entries]
|
||||
|
||||
|
||||
def _discard_unless_owned(path: Path, identity: str, kept: list[Path]) -> None:
|
||||
"""Delete ``path`` only when it is provably the entry the swap recorded; anything else (a later
|
||||
user file at the suffix, a foreign journal's victim) is renamed aside, never deleted (review Z1)."""
|
||||
if not os.path.lexists(path):
|
||||
return
|
||||
if identity and zip_entry_identity(path) == identity:
|
||||
_drop_path(path)
|
||||
else:
|
||||
kept.append(_keep_aside(path))
|
||||
|
||||
|
||||
def _settle_zip_entry(root: Path, phase: str, gen: str, entry: tuple[str, bool, str, str], kept: list[Path]) -> bool:
|
||||
"""Finish or roll back one journaled entry; True when the live tree changed.
|
||||
|
||||
Presence is the ENTRY's (lexists/lstat), never its target's: a dangling symlink backup is the only
|
||||
copy of a tracked symlink, not "no backup" (review Z3). Restoring a backup never deletes bytes, so
|
||||
it needs no provenance; every deletion does (``_discard_unless_owned``)."""
|
||||
name, existed, staged_id, live_id = entry
|
||||
here = os.path.lexists
|
||||
dst = root / name
|
||||
staging, old = Path(f"{dst}{_ZIP_STAGING_SUFFIX}"), Path(f"{dst}{_ZIP_OLD_SUFFIX}")
|
||||
changed = False
|
||||
if phase == "swapping":
|
||||
# Staging finished before this phase, so a backup now is the one the swap made from the old
|
||||
# entry: it existed, whatever an older journal recorded.
|
||||
existed = existed or here(old)
|
||||
if existed and here(old) and not (live_id and zip_entry_identity(dst) == live_id):
|
||||
if (old.is_file() and not old.is_symlink() and staged_id and zip_entry_identity(dst) == staged_id
|
||||
and not dst.is_dir()):
|
||||
os.replace(old, dst) # a file entry never goes missing, not even here
|
||||
else:
|
||||
_discard_unless_owned(dst, staged_id, kept)
|
||||
os.rename(old, dst) # moves a symlink itself, never its target
|
||||
changed = True
|
||||
elif not existed and here(dst) and not here(staging):
|
||||
_discard_unless_owned(dst, staged_id, kept)
|
||||
changed = True
|
||||
elif existed and not here(dst) and here(old):
|
||||
os.rename(old, dst) # the backup is the only copy left
|
||||
changed = True
|
||||
for leftover, identity in ((staging, staged_id), (old, live_id)):
|
||||
_discard_unless_owned(leftover, identity, kept)
|
||||
# ``<old>.<gen>.tmp``: a backup copy of the live file killed before its rename (no-hardlink file
|
||||
# systems). The tag names it but proves nothing about the bytes (F78): only a prefix of the live
|
||||
# file is that copy; anything else at the name is kept aside.
|
||||
tmp = Path(f"{old}.{gen}.tmp")
|
||||
if here(tmp):
|
||||
regular = [p.is_file() and not p.is_symlink() for p in (tmp, dst)]
|
||||
if all(regular) and dst.read_bytes().startswith(tmp.read_bytes()):
|
||||
_drop_path(tmp)
|
||||
else:
|
||||
kept.append(_keep_aside(tmp))
|
||||
return changed
|
||||
|
||||
|
||||
def restore_interrupted_zip_swap(project_root: Path | None = None) -> bool:
|
||||
"""Finish or roll back a ZIP swap whose owner died; True when the tree changed (relaunch).
|
||||
|
||||
``committed``: every rename landed, only backups remain -> drop them (finish). ``swapping``: put
|
||||
each moved-aside entry back and remove entries the swap added (roll back to the old tree, which
|
||||
the venv was built for; ``hermes update`` redoes it). ``staging``: nothing live moved -> drop the
|
||||
staging copies. Either way no ``*.hermes-update-staging``/``-old`` sibling is left to wedge the
|
||||
next run's free-space or dirty-tree checks. A live owner (lock held) is never second-guessed.
|
||||
Provenance (review Z1): a sibling or entry is deleted only when its identity is the one the journal
|
||||
recorded; anything else is kept aside under a ``.hermes-update-kept`` name and reported.
|
||||
"""
|
||||
root = _project_root() if project_root is None else Path(project_root)
|
||||
journal = root / ZIP_SWAP_JOURNAL
|
||||
if not journal.is_file() or _pytest_owns_live_checkout(root):
|
||||
return False
|
||||
with zip_swap_owner_lock(root) as owned:
|
||||
if not owned and owned.reason != "busy":
|
||||
print(f"⚠ An interrupted ZIP update cannot be settled: {owned.reason}.", file=sys.stderr)
|
||||
if not owned or not journal.is_file():
|
||||
return False
|
||||
try:
|
||||
raw = journal.read_text(encoding="utf-8-sig")
|
||||
except OSError as exc: # transient (AV, sharing violation): the journal is still the record
|
||||
print(f"⚠ Could not read the interrupted ZIP update's journal ({exc}); the next launch retries.",
|
||||
file=sys.stderr)
|
||||
return False
|
||||
parsed = _parse_zip_swap_journal(raw)
|
||||
if parsed is None:
|
||||
# Never guess which live entry to move, and never retire the only record of a mixed tree.
|
||||
print(f"⚠ The interrupted ZIP update's journal {journal} is unreadable or from another version; "
|
||||
"it and every `*.hermes-update-old` backup were kept. Put back what each backup replaced "
|
||||
"(or reinstall), then delete the journal.", file=sys.stderr)
|
||||
return False
|
||||
phase, gen, entries = parsed
|
||||
changed = False
|
||||
failed = False
|
||||
kept: list[Path] = []
|
||||
for entry in reversed(entries):
|
||||
try:
|
||||
changed = _settle_zip_entry(root, phase, gen, entry, kept) or changed
|
||||
except OSError as exc:
|
||||
failed = True
|
||||
print(f"⚠ Could not settle {entry[0]} after an interrupted ZIP update: {exc}", file=sys.stderr)
|
||||
if kept:
|
||||
print("⚠ An interrupted ZIP update's recovery found entries it could not prove were its own and "
|
||||
f"kept them aside instead of deleting them: {', '.join(map(str, kept))}. Delete each once "
|
||||
"you know it is not yours.", file=sys.stderr)
|
||||
# Retire the journal only on a verified terminal state, not an exception-free loop: no sibling
|
||||
# (staging copy, backup, backup temp) left that only this journal could still explain.
|
||||
siblings = (_ZIP_STAGING_SUFFIX, _ZIP_OLD_SUFFIX, f"{_ZIP_OLD_SUFFIX}.{gen}.tmp")
|
||||
unsettled = [e[0] for e in entries if any(os.path.lexists(f"{root / e[0]}{x}") for x in siblings)]
|
||||
if failed or unsettled:
|
||||
if unsettled and not failed:
|
||||
print(f"⚠ The interrupted ZIP update left {', '.join(unsettled)} unsettled; its journal "
|
||||
f"{journal} stays and the next launch retries.", file=sys.stderr)
|
||||
return changed # the journal stays: the next launch retries
|
||||
journal.unlink(missing_ok=True)
|
||||
if changed:
|
||||
print("⚠ A previous ZIP `hermes update` was interrupted mid-swap; the old install was put back. "
|
||||
"`hermes update` updates it again.", file=sys.stderr)
|
||||
return changed
|
||||
@@ -14,9 +14,9 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Collection, Optional
|
||||
|
||||
from hermes_cli._early_recovery import (
|
||||
ZIP_SWAP_JOURNAL, _keep_aside, restore_interrupted_zip_swap, write_zip_swap_journal, zip_entry_identity,
|
||||
zip_swap_owner_lock)
|
||||
from hermes_cli._early_recovery import _keep_aside
|
||||
from hermes_cli._early_recovery_zip import (
|
||||
ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap, write_zip_swap_journal, zip_entry_identity, zip_swap_owner_lock)
|
||||
|
||||
# Log-record parity with the origin module.
|
||||
logger = logging.getLogger("hermes_cli.update_cmd")
|
||||
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from hermes_cli import _early_recovery as er
|
||||
from hermes_cli import _early_recovery_zip as erz
|
||||
from pm import recovery
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -205,7 +206,7 @@ def test_zip_journal_writer_never_writes_through_a_preexisting_temp(tmp_path, li
|
||||
tmp.symlink_to(secret)
|
||||
except OSError:
|
||||
pytest.skip("symlinks need privileges here")
|
||||
er.write_zip_swap_journal(root, "staging", [["a.py", True, "", ""]], "0123456789ab")
|
||||
erz.write_zip_swap_journal(root, "staging", [["a.py", True, "", ""]], "0123456789ab")
|
||||
assert secret.read_text(encoding="utf-8-sig") == "API_KEY=keep\n"
|
||||
journal = root / er.ZIP_SWAP_JOURNAL
|
||||
assert not journal.is_symlink() and '"phase": "staging"' in journal.read_text(encoding="utf-8-sig")
|
||||
@@ -218,8 +219,8 @@ def test_zip_journal_publication_never_deletes_a_file_at_its_old_fixed_temp_name
|
||||
root.mkdir()
|
||||
user = root / (er.ZIP_SWAP_JOURNAL + ".tmp")
|
||||
user.write_text("USER NOTES\n", encoding="utf-8")
|
||||
er.write_zip_swap_journal(root, "staging", [["a.py", True, "", ""]], "0123456789ab")
|
||||
er.write_zip_swap_journal(root, "swapping", [["a.py", True, "", ""]], "0123456789ab")
|
||||
erz.write_zip_swap_journal(root, "staging", [["a.py", True, "", ""]], "0123456789ab")
|
||||
erz.write_zip_swap_journal(root, "swapping", [["a.py", True, "", ""]], "0123456789ab")
|
||||
assert user.read_text(encoding="utf-8-sig") == "USER NOTES\n"
|
||||
assert '"phase": "swapping"' in (root / er.ZIP_SWAP_JOURNAL).read_text(encoding="utf-8-sig")
|
||||
assert sorted(p.name for p in root.iterdir()) == sorted([er.ZIP_SWAP_JOURNAL, user.name])
|
||||
@@ -247,10 +248,10 @@ def test_an_interrupted_swap_restores_its_backup_by_entry_not_by_target(tmp_path
|
||||
_symlink_or_skip(old, "gone.txt" if kind == "dangling-symlink" else "target.txt")
|
||||
(root / "alpha").write_text("NEW", encoding="utf-8")
|
||||
# As the swap journals it: the staged copy (now live) and the moved-aside original by identity.
|
||||
er.write_zip_swap_journal(root, "swapping", [["alpha", True, er.zip_entry_identity(root / "alpha"),
|
||||
er.zip_entry_identity(old)]], "0123456789ab")
|
||||
erz.write_zip_swap_journal(root, "swapping", [["alpha", True, erz.zip_entry_identity(root / "alpha"),
|
||||
erz.zip_entry_identity(old)]], "0123456789ab")
|
||||
|
||||
assert er.restore_interrupted_zip_swap(root) is True
|
||||
assert erz.restore_interrupted_zip_swap(root) is True
|
||||
alpha = root / "alpha"
|
||||
if kind == "regular":
|
||||
assert not alpha.is_symlink() and alpha.read_text(encoding="utf-8") == "OLD"
|
||||
@@ -282,7 +283,7 @@ def test_an_unparsed_zip_journal_keeps_itself_and_every_backup(tmp_path, monkeyp
|
||||
return real(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", flaky)
|
||||
assert er.restore_interrupted_zip_swap(root) is False
|
||||
assert erz.restore_interrupted_zip_swap(root) is False
|
||||
assert journal.is_file()
|
||||
assert {p.name: p.read_text(encoding="utf-8-sig") for p in root.iterdir() if p != journal and p.suffix != ".lock"} == {
|
||||
"a.py": "NEW_A", "a.py.hermes-update-old": "OLD_A", "b.py": "OLD_B", "b.py.hermes-update-staging": "NEW_B"}
|
||||
@@ -299,7 +300,7 @@ def test_dropping_a_staged_tree_never_changes_a_hardlinked_live_files_mode(tmp_p
|
||||
os.link(live / "Hermes.exe", staging / "Hermes.exe")
|
||||
(live / "Hermes.exe").chmod(0o555)
|
||||
staging.chmod(0o555) # a read-only dir refuses rmtree: the retry path runs
|
||||
er._drop_path(staging.parent)
|
||||
erz._drop_path(staging.parent)
|
||||
assert not staging.parent.exists()
|
||||
assert (live / "Hermes.exe").stat().st_mode & 0o777 == 0o555
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ def test_zip_gate_refuses_on_a_user_file_at_the_old_fixed_journal_temp_name(tmp_
|
||||
|
||||
def test_zip_gate_admits_the_retry_after_an_interrupted_swap(tmp_path, monkeypatch):
|
||||
"""The siblings an interrupted swap's journal owns never refuse the retry: they are settled first."""
|
||||
from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, write_zip_swap_journal, zip_entry_identity
|
||||
from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, write_zip_swap_journal, zip_entry_identity
|
||||
|
||||
_git_install(tmp_path)
|
||||
leftover = tmp_path / "alpha.hermes-update-staging"
|
||||
|
||||
@@ -429,13 +429,13 @@ def test_root_files_never_go_missing_mid_swap(tmp_path, monkeypatch, hardlinks):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ZIP swap owner lock and staging journal (_early_recovery / _journaled_stage_and_swap)
|
||||
# ZIP swap owner lock and staging journal (_early_recovery_zip / _journaled_stage_and_swap)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LOCK_ROLE = r'''
|
||||
import json, os, sys, time
|
||||
from pathlib import Path
|
||||
from hermes_cli import _early_recovery as er
|
||||
from hermes_cli import _early_recovery_zip as erz
|
||||
role, work = sys.argv[1], Path(sys.argv[2])
|
||||
live, lock_path = work / "live", work / "live" / ".hermes-update-zip-swap.lock"
|
||||
def wait_for(name):
|
||||
@@ -448,25 +448,25 @@ def mark(name, owned):
|
||||
(work / name).write_text(json.dumps({"owned": bool(owned), "inode": os.stat(lock_path).st_ino
|
||||
if lock_path.exists() else None}))
|
||||
if role == "a": # owner whose release is caught right after its unlock: B already holds the inode
|
||||
real = er._lock_fd
|
||||
real = erz._lock_fd
|
||||
def lock_fd(fd, lock):
|
||||
done = real(fd, lock)
|
||||
if not lock:
|
||||
wait_for("b")
|
||||
return done
|
||||
er._lock_fd = lock_fd
|
||||
with er.zip_swap_owner_lock(live) as owned:
|
||||
erz._lock_fd = lock_fd
|
||||
with erz.zip_swap_owner_lock(live) as owned:
|
||||
mark("a", owned)
|
||||
wait_for("b-waiting"); time.sleep(0.3)
|
||||
(work / "a-done").touch()
|
||||
elif role == "b": # waiter that wins the lock as A lets go
|
||||
wait_for("a"); (work / "b-waiting").touch()
|
||||
with er.zip_swap_owner_lock(live, wait=20) as owned:
|
||||
with erz.zip_swap_owner_lock(live, wait=20) as owned:
|
||||
mark("b", owned)
|
||||
wait_for("c")
|
||||
else: # newcomer after A's release completed
|
||||
wait_for("a-done")
|
||||
with er.zip_swap_owner_lock(live) as owned:
|
||||
with erz.zip_swap_owner_lock(live) as owned:
|
||||
mark("c", owned)
|
||||
'''
|
||||
|
||||
@@ -493,7 +493,7 @@ def test_zip_swap_lock_is_one_inode_across_a_release(tmp_path):
|
||||
reason="POSIX permission bits; root ignores them")
|
||||
def test_zip_swap_lock_refuses_admission_without_a_lock(tmp_path):
|
||||
"""A root where the lock file cannot be created grants nothing: no lock, no swap, and a named reason."""
|
||||
from hermes_cli._early_recovery import zip_swap_owner_lock
|
||||
from hermes_cli._early_recovery_zip import zip_swap_owner_lock
|
||||
|
||||
live = tmp_path / "live"
|
||||
live.mkdir()
|
||||
@@ -538,7 +538,7 @@ _POSIX_MODES = pytest.mark.skipif(os.name == "nt" or (hasattr(os, "geteuid") and
|
||||
def test_unreadable_source_file_leaves_no_partial_stage(tmp_path):
|
||||
"""The copy of ``second`` dies on an unreadable file after copying the rest: that partial staging
|
||||
tree is the failing entry's and must be dropped like the finished ones (nothing live changes)."""
|
||||
from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
|
||||
live, extracted, locked = _unreadable_release(tmp_path, read_only_dir=False)
|
||||
try:
|
||||
@@ -557,7 +557,7 @@ def test_unreadable_source_file_leaves_no_partial_stage(tmp_path):
|
||||
def test_a_stage_cleanup_that_cannot_finish_keeps_the_journal_for_recovery(tmp_path):
|
||||
"""When the updater cannot remove its partial stage, the journal is that stage's only record: it
|
||||
stays, and the next launch's recovery removes the stage, then the journal."""
|
||||
from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
|
||||
live, extracted, locked = _unreadable_release(tmp_path, read_only_dir=True)
|
||||
try:
|
||||
@@ -575,7 +575,7 @@ def test_a_stage_cleanup_that_cannot_finish_keeps_the_journal_for_recovery(tmp_p
|
||||
|
||||
def _swap_or_recover(extracted: Path, entries: list[str], live: Path) -> None:
|
||||
"""``_download_and_swap_zip``'s wiring: a failed swap is settled from its journal right after."""
|
||||
from hermes_cli._early_recovery import restore_interrupted_zip_swap
|
||||
from hermes_cli._early_recovery_zip import restore_interrupted_zip_swap
|
||||
|
||||
try:
|
||||
update_cmd_zip._journaled_stage_and_swap(str(extracted), entries, live, None)
|
||||
@@ -666,7 +666,7 @@ def test_a_backup_copy_killed_before_its_rename_is_cleared_by_the_recovery(tmp_p
|
||||
"""On a file system without hardlinks ``_file_backup`` copies to ``<entry>.hermes-update-old.tmp``
|
||||
first. A kill inside that copy left the temp behind: recovery dropped only staging and the backup,
|
||||
so every later ZIP update refused on "uncommitted changes" (review C4)."""
|
||||
from hermes_cli._early_recovery import (
|
||||
from hermes_cli._early_recovery_zip import (
|
||||
ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap, write_zip_swap_journal, zip_entry_identity)
|
||||
|
||||
live = tmp_path / "live"
|
||||
@@ -689,7 +689,7 @@ def test_a_file_at_the_backup_temp_name_that_is_not_the_killed_copy_is_kept(tmp_
|
||||
backup copy existed leaves the name free, and a file there afterwards is not Hermes'. Recovery
|
||||
deletes only a prefix of the live file (what a killed copy of it is) and keeps anything else
|
||||
aside, still retiring the journal (review F78)."""
|
||||
from hermes_cli._early_recovery import (
|
||||
from hermes_cli._early_recovery_zip import (
|
||||
ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap, write_zip_swap_journal, zip_entry_identity)
|
||||
|
||||
live = tmp_path / "live"
|
||||
@@ -742,7 +742,7 @@ def test_recovery_never_installs_or_deletes_a_backup_suffix_created_after_the_ki
|
||||
path-only journal took it for the swap's backup: it overwrote the live cli.py with it, and the old
|
||||
bytes were gone (review Z1). Recovery now proves a backup is the entry it recorded before using or
|
||||
deleting it: the live file stays, the user's file is kept aside, byte for byte."""
|
||||
from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
|
||||
root = _swap_killed(tmp_path, monkeypatch, live={"cli.py": "LIVE"}, new={"cli.py": "NEW"}, install_first=False)
|
||||
(root / "cli.py.hermes-update-old").write_text("USER LATER", encoding="utf-8")
|
||||
@@ -759,7 +759,7 @@ def test_recovery_never_deletes_a_user_file_that_replaced_an_installed_entry(tmp
|
||||
"""A new entry (notes.md) was renamed into place, then the swap was killed; the user then replaced
|
||||
notes.md with a file of their own. Recovery deleted whatever sat at the journaled name (review Z1);
|
||||
only the entry the swap installed (its recorded staging identity) may go."""
|
||||
from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
|
||||
root = _swap_killed(tmp_path, monkeypatch, live={}, new={"notes.md": "NEW"}, install_first=True)
|
||||
(root / "notes.md").unlink()
|
||||
@@ -774,7 +774,7 @@ def test_recovery_never_deletes_a_user_file_that_replaced_an_installed_entry(tmp
|
||||
def test_recovery_still_rolls_back_an_authentic_killed_swap(tmp_path, monkeypatch):
|
||||
"""The control: a swap killed after installing its first entry rolls back to the old tree, and every
|
||||
staging copy and backup the swap itself made is deleted (provenance proven), with nothing kept aside."""
|
||||
from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
|
||||
root = _swap_killed(tmp_path, monkeypatch, live={"a.py": "OLD_A", "b.py": "OLD_B"},
|
||||
new={"a.py": "NEW_A", "b.py": "NEW_B", "c.py": "NEW_C"}, install_first=True)
|
||||
@@ -830,7 +830,7 @@ def test_a_committed_swap_keeps_its_journal_until_the_backup_is_gone(tmp_path, m
|
||||
import json
|
||||
|
||||
from hermes_cli import update_cmd_commit
|
||||
from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
|
||||
monkeypatch.setattr(update_cmd_commit, "arm_commit_obligations", lambda *a, **k: None)
|
||||
live, extracted = tmp_path / "live", tmp_path / "extracted"
|
||||
@@ -861,7 +861,7 @@ def test_a_suffix_path_that_appears_after_the_preflight_is_never_deleted(tmp_pat
|
||||
stage dropped the first unconditionally and the hardlink backup the second; both survive now, byte
|
||||
for byte, and the swap is refused with the live tree left old."""
|
||||
from hermes_cli import update_cmd_commit
|
||||
from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap
|
||||
|
||||
live, extracted = tmp_path / "live", tmp_path / "extracted"
|
||||
live.mkdir()
|
||||
|
||||
Reference in New Issue
Block a user