#!/usr/bin/env python3 """Recoverable multi-file replacement using same-filesystem ``os.replace``.""" from __future__ import annotations import os import shutil from dataclasses import dataclass from pathlib import Path import tempfile from typing import Mapping class TransactionError(OSError): """A commit failed; rollback details are included in the message.""" @dataclass(frozen=True) class SymlinkValue: """A lexical symlink target to install with the surrounding transaction.""" target: str ReplacementValue = bytes | SymlinkValue OriginalValue = tuple[str, bytes | str, int | None] def _lexical_absolute(path: Path) -> Path: """Make a path absolute without following an existing symlink.""" return Path(os.path.abspath(path)) def _temporary_bytes(target: Path, data: bytes, mode: int | None = None) -> Path: target.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent) temporary = Path(temporary_name) try: with os.fdopen(descriptor, "wb") as stream: stream.write(data) stream.flush() os.fsync(stream.fileno()) if mode is not None: os.chmod(temporary, mode) return temporary except Exception: temporary.unlink(missing_ok=True) raise def _temporary_symlink(target: Path, link_target: str) -> Path: target.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent) os.close(descriptor) temporary = Path(temporary_name) temporary.unlink() try: os.symlink(link_target, temporary) return temporary except Exception: temporary.unlink(missing_ok=True) raise def _temporary_value(target: Path, value: ReplacementValue, mode: int | None = None) -> Path: if isinstance(value, SymlinkValue): return _temporary_symlink(target, value.target) return _temporary_bytes(target, value, mode) def _capture_original(target: Path) -> OriginalValue | None: if target.is_symlink(): return ("symlink", os.readlink(target), None) if target.exists(): return ("bytes", target.read_bytes(), target.stat().st_mode & 0o777) return None def _write_target(path: Path, value: ReplacementValue, follow_symlinks: bool) -> Path: """Pick the entry this value should land on. vault cutover 이후 ``raw/**``·``wiki/**`` 의 문서는 ``vault/**`` 정본을 가리키는 심링크다. ``os.replace`` 는 심링크를 *따라가지 않고* 그 자리를 실파일로 갈아치우므로, 호출자가 심링크 경로를 그대로 넘기면 (1) 호환 계층이 끊기고 (2) 정본은 낡은 채로 남는 split-brain 이 된다 — 그리고 그 사실이 조용하다. 그래서 bytes 쓰기는 기본적으로 심링크를 따라 정본에 쓴다. ``SymlinkValue`` 는 링크 *자체* 를 설치하는 것이므로 언제나 lexical 경로에 쓴다. ``follow_symlinks=False`` 는 cutover/rollback 처럼 심링크를 실파일로 되돌리는 것이 목적인 호출자(``vault_migrate``)를 위한 예외다. """ if isinstance(value, SymlinkValue) or not follow_symlinks: return _lexical_absolute(path) if path.is_symlink(): return _lexical_absolute(path.resolve()) return _lexical_absolute(path) _STAGE_IGNORE = shutil.ignore_patterns(".git", "__pycache__", "*.pyc", ".DS_Store") def stage_repository(root: Path, destination: Path) -> None: """Copy the working tree into ``destination`` *preserving symlinks*. canonical 모드에서 ``raw/``·``wiki/`` 는 ``vault/`` 정본을 가리키는 상대 심링크 디렉토리다. 기본 ``copytree``(``symlinks=False``)는 각 링크를 따라가 실파일로 복제하므로 스테이지가 split-brain(정본·링크가 독립된 실파일 2개)이 되고, candidate 가 legacy 경로로 정본을 우회 편집해도 투영/레이아웃 검증이 그 사실을 못 잡는다. ``symlinks=True`` 로 링크를 링크 그대로 복제해 실제 저장소 구조를 재현한다. (검증 스테이징의 단일 구현 — 예전엔 document_commit / branch_contract_check / migrate_graph_contracts 에 같은 함수가 세 벌 복사돼 있었고, 그 중 하나만 고치면 나머지가 조용히 어긋났다.) """ for child in root.iterdir(): if child.name == ".git": continue target = destination / child.name if child.is_dir(): shutil.copytree(child, target, ignore=_STAGE_IGNORE, symlinks=True) elif child.is_file(): shutil.copy2(child, target, follow_symlinks=False) def replace_many( changes: Mapping[Path, ReplacementValue], *, must_not_exist: set[Path] | None = None, follow_symlinks: bool = True, ) -> None: """Replace bytes/symlinks atomically and restore the original entry kind on failure.""" forbidden = {_lexical_absolute(path) for path in (must_not_exist or set())} normalized = { _write_target(path, data, follow_symlinks): data for path, data in changes.items() } for target in forbidden: if target.exists() or target.is_symlink(): raise FileExistsError(f"target already exists: {target}") originals: dict[Path, OriginalValue | None] = {} pending: dict[Path, Path] = {} committed: list[Path] = [] try: for target, value in normalized.items(): originals[target] = _capture_original(target) original_mode = originals[target][2] if originals[target] is not None else None pending[target] = _temporary_value(target, value, original_mode) except Exception: for temporary in pending.values(): temporary.unlink(missing_ok=True) raise try: for target in sorted(pending, key=lambda item: item.as_posix()): os.replace(pending[target], target) committed.append(target) except Exception as exc: rollback_errors: list[str] = [] for target in reversed(committed): original = originals[target] try: if original is None: target.unlink(missing_ok=True) else: kind, payload, mode = original restore = ( _temporary_symlink(target, str(payload)) if kind == "symlink" else _temporary_bytes(target, bytes(payload), mode) ) os.replace(restore, target) except Exception as rollback_exc: # pragma: no cover - catastrophic filesystem failure rollback_errors.append(f"{target}: {rollback_exc}") detail = f"; rollback failures: {rollback_errors}" if rollback_errors else "; rollback completed" raise TransactionError(f"multi-file commit failed: {exc}{detail}") from exc finally: for temporary in pending.values(): temporary.unlink(missing_ok=True)