from __future__ import annotations from pathlib import Path import sys import tempfile import unittest import unittest.mock RUNTIME = Path(__file__).resolve().parents[1] / "runtime" sys.path.insert(0, str(RUNTIME)) import fs_transaction # noqa: E402 class ReplaceManySymlinkTest(unittest.TestCase): """vault cutover 이후 legacy 경로는 정본을 가리키는 심링크다. ``os.replace`` 는 심링크를 따라가지 않고 그 자리를 실파일로 갈아치운다. 이 원시가 심링크를 고려하지 않던 동안 writer 8곳 중 7곳이 legacy 경로를 그대로 넘기고 있었고, 쓰면 호환 심링크가 끊기면서 정본은 낡은 채로 남았다 — 그리고 조용했다. """ def _cutover_layout(self, root: Path) -> tuple[Path, Path]: canonical = root / "vault" / "doc.md" canonical.parent.mkdir(parents=True) canonical.write_text("원본\n", encoding="utf-8") legacy_dir = root / "raw" legacy_dir.mkdir() legacy = legacy_dir / "doc.md" legacy.symlink_to("../vault/doc.md") return canonical, legacy def test_bytes_write_through_symlink_updates_canonical_and_keeps_link(self) -> None: with tempfile.TemporaryDirectory() as tmp: canonical, legacy = self._cutover_layout(Path(tmp)) fs_transaction.replace_many({legacy: "수정됨\n".encode("utf-8")}) self.assertTrue(legacy.is_symlink(), "호환 심링크가 실파일로 대체되면 안 된다") self.assertEqual(canonical.read_text(encoding="utf-8"), "수정됨\n") def test_follow_symlinks_false_replaces_the_link_itself(self) -> None: """cutover/rollback 은 엔트리 종류 자체를 바꾸는 것이 목적이므로 예외가 필요하다.""" with tempfile.TemporaryDirectory() as tmp: canonical, legacy = self._cutover_layout(Path(tmp)) fs_transaction.replace_many( {legacy: "롤백\n".encode("utf-8")}, follow_symlinks=False ) self.assertFalse(legacy.is_symlink()) self.assertEqual(legacy.read_text(encoding="utf-8"), "롤백\n") self.assertEqual(canonical.read_text(encoding="utf-8"), "원본\n") def test_symlink_value_is_always_installed_lexically(self) -> None: with tempfile.TemporaryDirectory() as tmp: canonical, legacy = self._cutover_layout(Path(tmp)) other = canonical.parent / "other.md" other.write_text("다른 정본\n", encoding="utf-8") fs_transaction.replace_many({legacy: fs_transaction.SymlinkValue("../vault/other.md")}) self.assertTrue(legacy.is_symlink()) self.assertEqual(legacy.read_text(encoding="utf-8"), "다른 정본\n") self.assertEqual(canonical.read_text(encoding="utf-8"), "원본\n") def test_plain_file_write_is_unaffected(self) -> None: with tempfile.TemporaryDirectory() as tmp: target = Path(tmp) / "plain.md" target.write_text("이전\n", encoding="utf-8") fs_transaction.replace_many({target: "이후\n".encode("utf-8")}) self.assertFalse(target.is_symlink()) self.assertEqual(target.read_text(encoding="utf-8"), "이후\n") def test_failed_commit_restores_symlink_entry_kind(self) -> None: """commit 도중 실패해도 심링크는 심링크로 복원돼야 한다. 롤백이 원래 엔트리 종류를 잃으면, 실패한 트랜잭션이 호환 계층을 조용히 실파일로 바꿔 놓는다 — 성공 경로를 고쳐도 실패 경로로 같은 손상이 들어올 수 있다. """ with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) canonical, legacy = self._cutover_layout(root) second = root / "vault" / "second.md" second.write_text("두번째\n", encoding="utf-8") real_replace = fs_transaction.os.replace calls: list[int] = [] def flaky(src, dst): # noqa: ANN001 - 테스트 더블 calls.append(1) if len(calls) == 2: raise OSError("주입된 commit 실패") return real_replace(src, dst) with unittest.mock.patch.object(fs_transaction.os, "replace", flaky): with self.assertRaises(fs_transaction.TransactionError): fs_transaction.replace_many({ legacy: "수정됨\n".encode("utf-8"), second: "수정됨2\n".encode("utf-8"), }) self.assertTrue(legacy.is_symlink(), "실패 롤백 후에도 심링크가 살아있어야 한다") self.assertEqual(canonical.read_text(encoding="utf-8"), "원본\n") self.assertEqual(second.read_text(encoding="utf-8"), "두번째\n") class StageRepositoryTest(unittest.TestCase): """검증 스테이징은 심링크를 심링크로 복제해야 한다. 기본 ``copytree(symlinks=False)`` 는 각 링크를 따라가 실파일로 복제하므로, 스테이지가 split-brain(정본·링크가 독립된 실파일 2개)이 돼 candidate 가 legacy 경로로 정본을 우회 편집해도 투영/레이아웃 검증이 그 사실을 못 잡는다. 예전엔 이 함수가 세 벌 복사돼 있었고 한 벌만 고치면 나머지가 조용히 어긋났다 — 이제 단일 구현이다. """ def test_stage_preserves_symlinks_and_keeps_canonical_link(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) / "repo" (root / "vault").mkdir(parents=True) (root / "vault" / "doc.md").write_text("원본\n", encoding="utf-8") (root / "raw").mkdir() (root / "raw" / "doc.md").symlink_to("../vault/doc.md") dest = Path(tmp) / "stage" dest.mkdir() fs_transaction.stage_repository(root, dest) staged_legacy = dest / "raw" / "doc.md" self.assertTrue(staged_legacy.is_symlink(), "스테이지의 legacy 경로는 심링크여야 한다") # 정본을 바꾸면 심링크가 그대로 반영해야 한다(독립 실파일이 아니라). (dest / "vault" / "doc.md").write_text("수정됨\n", encoding="utf-8") self.assertEqual(staged_legacy.read_text(encoding="utf-8"), "수정됨\n") def test_stage_skips_git_directory(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) / "repo" (root / ".git").mkdir(parents=True) (root / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") (root / "keep.md").write_text("문서\n", encoding="utf-8") dest = Path(tmp) / "stage" dest.mkdir() fs_transaction.stage_repository(root, dest) self.assertFalse((dest / ".git").exists()) self.assertTrue((dest / "keep.md").is_file()) if __name__ == "__main__": unittest.main()