144 lines
6.9 KiB
Python
144 lines
6.9 KiB
Python
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()
|