From f90f27baf815ca855bf38e108aaaafee62bc3a7a Mon Sep 17 00:00:00 2001 From: leeminseo Date: Thu, 23 Jul 2026 20:03:01 +0900 Subject: [PATCH] =?UTF-8?q?mygit:=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=ED=95=B4=EC=8B=9C=20=EA=B8=B0=EB=B0=98=20=EC=9E=90=EC=B2=B4=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EA=B4=80=EB=A6=AC=20CLI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add/commit/branch/log/status/checkout을 지원하며, 실제 .git과 분리된 .mygit/ 디렉토리에 JSON 기반 자체 오브젝트 포맷으로 이력을 저장한다. 커밋/트리/블롭 해시는 sha1 다이제스트를 이모지 시퀀스로 인코딩해 표현한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018KhPGrxvueNueGTGVtxLC1 --- .gitignore | 5 + pyproject.toml | 18 +++ src/mygit/__init__.py | 1 + src/mygit/cli.py | 288 ++++++++++++++++++++++++++++++++++++++++ src/mygit/config.py | 37 ++++++ src/mygit/index.py | 56 ++++++++ src/mygit/objects.py | 140 +++++++++++++++++++ src/mygit/repository.py | 96 ++++++++++++++ src/mygit/treebuild.py | 44 ++++++ src/mygit/worktree.py | 15 +++ tests/conftest.py | 20 +++ tests/test_branch.py | 20 +++ tests/test_checkout.py | 73 ++++++++++ tests/test_cli.py | 21 +++ tests/test_commit.py | 51 +++++++ tests/test_index.py | 22 +++ tests/test_objects.py | 25 ++++ tests/test_status.py | 25 ++++ 18 files changed, 957 insertions(+) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/mygit/__init__.py create mode 100644 src/mygit/cli.py create mode 100644 src/mygit/config.py create mode 100644 src/mygit/index.py create mode 100644 src/mygit/objects.py create mode 100644 src/mygit/repository.py create mode 100644 src/mygit/treebuild.py create mode 100644 src/mygit/worktree.py create mode 100644 tests/conftest.py create mode 100644 tests/test_branch.py create mode 100644 tests/test_checkout.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_commit.py create mode 100644 tests/test_index.py create mode 100644 tests/test_objects.py create mode 100644 tests/test_status.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8943d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.egg-info/ +.pytest_cache/ +.omc/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e4b71c9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "mygit" +version = "0.1.0" +description = "Emoji 해시를 사용하는 자체 구현 버전 관리 CLI" +requires-python = ">=3.9" + +[project.scripts] +mygit = "mygit.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[project.optional-dependencies] +test = ["pytest"] diff --git a/src/mygit/__init__.py b/src/mygit/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/mygit/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/mygit/cli.py b/src/mygit/cli.py new file mode 100644 index 0000000..4ef34f2 --- /dev/null +++ b/src/mygit/cli.py @@ -0,0 +1,288 @@ +"""mygit CLI: add / commit / branch / log / status.""" +from __future__ import annotations + +import argparse +import sys +import time +from datetime import datetime +from pathlib import Path + +from .config import get_author +from .index import Index, IndexEntry +from .objects import Commit, ObjectFormatError, hash_object, read_object, short_hash, write_object +from .repository import Repository, RepositoryNotFound +from .treebuild import build_tree, flatten_tree +from .worktree import iter_working_files + +COMMIT_EMOJI = "\U0001F389" # 🎉 +BRANCH_EMOJI = "\U0001F33F" # 🌿 +CHECKOUT_EMOJI = "\U0001F500" # 🔀 + + +def _commit_flat_tree(repo, commit_hash: str | None) -> dict[str, str]: + if not commit_hash: + return {} + try: + _, data = read_object(repo.objects_dir, commit_hash) + commit = Commit.deserialize(data) + except (FileNotFoundError, ObjectFormatError): + return {} + return flatten_tree(repo, commit.tree) + + +def cmd_add(args: argparse.Namespace) -> int: + repo = Repository.find_or_create() + index = Index.load(repo.index_path) + + targets: list[str] = [] + for raw_path in args.paths: + path = (Path.cwd() / raw_path).resolve() + if not path.exists(): + print(f"경로를 찾을 수 없습니다: {raw_path}", file=sys.stderr) + return 1 + if path.is_dir(): + for rel in iter_working_files(path): + targets.append((path.relative_to(repo.root) / rel).as_posix()) + else: + targets.append(path.relative_to(repo.root).as_posix()) + + for rel_path in targets: + emoji_hash = index.add_file(repo, rel_path) + print(f"add: {rel_path} -> {short_hash(emoji_hash)}") + + index.save(repo.index_path) + return 0 + + +def cmd_commit(args: argparse.Namespace) -> int: + repo = Repository.find_or_create() + index = Index.load(repo.index_path) + + if not index.entries: + print("커밋할 스테이징된 변경사항이 없습니다. 먼저 'mygit add'를 실행하세요.", file=sys.stderr) + return 1 + + tree_hash = build_tree(repo, index.as_dict()) + parent = repo.head_commit_hash() + parents = [parent] if parent else [] + author = get_author(repo.root) + + commit = Commit( + tree=tree_hash, + parents=parents, + author=author, + message=args.message, + timestamp=time.time(), + ) + data = commit.serialize() + commit_hash = write_object(repo.objects_dir, "commit", data) + + branch = repo.current_branch() + repo.update_branch(branch, commit_hash) + + print(f"{COMMIT_EMOJI} [{branch} {short_hash(commit_hash)}] {args.message}") + print(f" commit hash: {commit_hash}") + return 0 + + +def cmd_branch(args: argparse.Namespace) -> int: + repo = Repository.find_or_create() + current = repo.current_branch() + + if not args.name: + for branch in repo.list_branches(): + marker = "*" if branch == current else " " + tip = repo.resolve_branch(branch) + tip_display = short_hash(tip) if tip else "(커밋 없음)" + print(f"{marker} {branch} {tip_display}") + return 0 + + head_hash = repo.head_commit_hash() + if head_hash is None: + print("아직 커밋이 없어 브랜치를 만들 수 없습니다. 먼저 'mygit commit'을 실행하세요.", file=sys.stderr) + return 1 + + try: + repo.create_branch(args.name, head_hash) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 + + print(f"{BRANCH_EMOJI} 새 브랜치 생성: {args.name} -> {short_hash(head_hash)}") + return 0 + + +def cmd_checkout(args: argparse.Namespace) -> int: + repo = Repository.find_or_create() + branch = args.branch + + if args.create: + head_hash = repo.head_commit_hash() + try: + repo.create_branch(branch, head_hash) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 + + if branch not in repo.list_branches(): + print(f"브랜치를 찾을 수 없습니다: {branch}", file=sys.stderr) + return 1 + + current_branch = repo.current_branch() + if branch == current_branch: + print(f"이미 '{branch}' 브랜치에 있습니다.") + return 0 + + old_flat = _commit_flat_tree(repo, repo.head_commit_hash()) + + dirty = [] + for path, blob_hash in old_flat.items(): + full = repo.root / path + if not full.exists(): + dirty.append(path) + continue + working_hash, _ = hash_object("blob", full.read_bytes()) + if working_hash != blob_hash: + dirty.append(path) + if dirty: + print("체크아웃 전에 커밋되지 않은 변경사항을 커밋하거나 되돌리세요:", file=sys.stderr) + for path in dirty: + print(f" {path}", file=sys.stderr) + return 1 + + new_flat = _commit_flat_tree(repo, repo.resolve_branch(branch)) + + for path, blob_hash in new_flat.items(): + _, data = read_object(repo.objects_dir, blob_hash) + full = repo.root / path + full.parent.mkdir(parents=True, exist_ok=True) + full.write_bytes(data) + + for path in old_flat: + if path not in new_flat: + full = repo.root / path + if full.exists(): + full.unlink() + + repo.head_path.write_text(f"ref: refs/heads/{branch}\n", encoding="utf-8") + + new_index = Index(entries={path: IndexEntry(path=path, hash=h) for path, h in new_flat.items()}) + new_index.save(repo.index_path) + + print(f"{CHECKOUT_EMOJI} '{branch}' 브랜치로 전환했습니다.") + return 0 + + +def cmd_log(args: argparse.Namespace) -> int: + repo = Repository.find_or_create() + commit_hash = repo.head_commit_hash() + + if commit_hash is None: + print("(커밋 이력이 없습니다)") + return 0 + + while commit_hash: + try: + _, data = read_object(repo.objects_dir, commit_hash) + commit = Commit.deserialize(data) + except (FileNotFoundError, ObjectFormatError): + print("(이 지점 이전의 커밋은 호환되지 않는 형식이라 표시할 수 없습니다)") + break + + when = datetime.fromtimestamp(commit.timestamp).strftime("%Y-%m-%d %H:%M:%S") + print(f"{short_hash(commit_hash)} {commit.author} {when}") + print(f" {commit.message}") + commit_hash = commit.parents[0] if commit.parents else None + + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + repo = Repository.find_or_create() + index = Index.load(repo.index_path) + + committed = _commit_flat_tree(repo, repo.head_commit_hash()) + + staged_index = index.as_dict() + working: dict[str, str] = {} + for rel_path in iter_working_files(repo.root): + content = (repo.root / rel_path).read_bytes() + working_hash, _ = hash_object("blob", content) + working[rel_path] = working_hash + + staged_new_or_modified = sorted( + p for p, h in staged_index.items() if committed.get(p) != h + ) + staged_deleted = sorted(p for p in committed if p not in staged_index) + unstaged_modified = sorted( + p for p, h in working.items() if p in staged_index and staged_index[p] != h + ) + untracked = sorted(p for p in working if p not in staged_index) + + branch = repo.current_branch() + print(f"브랜치: {branch}") + + if staged_new_or_modified or staged_deleted: + print("커밋 대기 중인 변경사항:") + for p in staged_new_or_modified: + print(f" staged: {p}") + for p in staged_deleted: + print(f" deleted: {p}") + else: + print("커밋 대기 중인 변경사항 없음") + + if unstaged_modified: + print("스테이징되지 않은 변경사항:") + for p in unstaged_modified: + print(f" modified: {p}") + + if untracked: + print("추적되지 않는 파일:") + for p in untracked: + print(f" {p}") + + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="mygit", description="이모지 해시를 쓰는 자체 버전 관리 CLI") + subparsers = parser.add_subparsers(dest="command", required=True) + + add_parser = subparsers.add_parser("add", help="파일을 스테이징 영역에 추가") + add_parser.add_argument("paths", nargs="+", help="추가할 파일 또는 디렉토리 경로") + add_parser.set_defaults(func=cmd_add) + + commit_parser = subparsers.add_parser("commit", help="스테이징된 변경사항을 커밋") + commit_parser.add_argument("-m", "--message", required=True, help="커밋 메시지") + commit_parser.set_defaults(func=cmd_commit) + + branch_parser = subparsers.add_parser("branch", help="브랜치 목록/생성") + branch_parser.add_argument("name", nargs="?", help="생성할 브랜치 이름 (생략 시 목록 출력)") + branch_parser.set_defaults(func=cmd_branch) + + checkout_parser = subparsers.add_parser("checkout", help="브랜치 전환") + checkout_parser.add_argument("branch", help="전환할 브랜치 이름") + checkout_parser.add_argument("-b", dest="create", action="store_true", help="새 브랜치를 만들고 전환") + checkout_parser.set_defaults(func=cmd_checkout) + + log_parser = subparsers.add_parser("log", help="커밋 이력 출력") + log_parser.set_defaults(func=cmd_log) + + status_parser = subparsers.add_parser("status", help="작업 디렉토리 상태 출력") + status_parser.set_defaults(func=cmd_status) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except RepositoryNotFound as exc: + print(str(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/mygit/config.py b/src/mygit/config.py new file mode 100644 index 0000000..018bc01 --- /dev/null +++ b/src/mygit/config.py @@ -0,0 +1,37 @@ +"""커밋 작성자 정보 조회: 프로젝트 .git/config -> ~/.gitconfig -> 환경변수 -> 기본값.""" +from __future__ import annotations + +import configparser +import os +from pathlib import Path + +DEFAULT_AUTHOR = "unknown " + + +def _read_user_section(config_path: Path) -> tuple[str | None, str | None]: + if not config_path.exists(): + return None, None + parser = configparser.ConfigParser() + try: + parser.read(config_path) + except configparser.Error: + return None, None + if not parser.has_section("user"): + return None, None + name = parser.get("user", "name", fallback=None) + email = parser.get("user", "email", fallback=None) + return name, email + + +def get_author(project_root: Path) -> str: + for config_path in (project_root / ".git" / "config", Path.home() / ".gitconfig"): + name, email = _read_user_section(config_path) + if name and email: + return f"{name} <{email}>" + + name = os.environ.get("MYGIT_AUTHOR_NAME") + email = os.environ.get("MYGIT_AUTHOR_EMAIL") + if name and email: + return f"{name} <{email}>" + + return DEFAULT_AUTHOR diff --git a/src/mygit/index.py b/src/mygit/index.py new file mode 100644 index 0000000..cbeaf14 --- /dev/null +++ b/src/mygit/index.py @@ -0,0 +1,56 @@ +"""스테이징 영역(.mygit/index)을 JSON으로 관리.""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + +from .objects import write_object + + +@dataclass +class IndexEntry: + path: str + hash: str + mode: str = "100644" + + +@dataclass +class Index: + entries: dict[str, IndexEntry] = field(default_factory=dict) + + @classmethod + def load(cls, index_path: Path) -> "Index": + if not index_path.exists(): + return cls() + try: + payload = json.loads(index_path.read_text(encoding="utf-8")) + entries = { + e["path"]: IndexEntry(path=e["path"], hash=e["hash"], mode=e.get("mode", "100644")) + for e in payload.get("entries", []) + } + return cls(entries=entries) + except (json.JSONDecodeError, KeyError): + # 실제 git이 만든 바이너리 index 등 호환되지 않는 파일 -> 빈 인덱스로 취급 + return cls() + + def save(self, index_path: Path) -> None: + payload = { + "entries": [ + {"path": e.path, "hash": e.hash, "mode": e.mode} + for e in sorted(self.entries.values(), key=lambda e: e.path) + ] + } + index_path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8") + + def add_file(self, repo, relative_path: str) -> str: + content = (repo.root / relative_path).read_bytes() + emoji_hash = write_object(repo.objects_dir, "blob", content) + self.entries[relative_path] = IndexEntry(path=relative_path, hash=emoji_hash) + return emoji_hash + + def remove(self, relative_path: str) -> None: + self.entries.pop(relative_path, None) + + def as_dict(self) -> dict[str, str]: + return {path: entry.hash for path, entry in self.entries.items()} diff --git a/src/mygit/objects.py b/src/mygit/objects.py new file mode 100644 index 0000000..80974c0 --- /dev/null +++ b/src/mygit/objects.py @@ -0,0 +1,140 @@ +"""블롭/트리/커밋 객체의 직렬화와 이모지 해시 인코딩.""" +from __future__ import annotations + +import base64 +import hashlib +import json +from dataclasses import dataclass +from typing import Any + +# U+1F400 ~ U+1F4FF: 동물, 자연, 사물 계열의 단일 코드포인트 이모지 256개. +# ZWJ 시퀀스나 스킨톤 수정자가 없어 바이트 <-> 이모지 1:1 매핑에 안전하다. +EMOJI_ALPHABET: tuple[str, ...] = tuple(chr(0x1F400 + i) for i in range(256)) +_EMOJI_TO_BYTE = {emoji: i for i, emoji in enumerate(EMOJI_ALPHABET)} + +SHORT_HASH_LENGTH = 8 + + +def encode_emoji_hash(digest: bytes) -> str: + """sha1 다이제스트(바이트열)를 이모지 시퀀스 문자열로 변환한다.""" + return "".join(EMOJI_ALPHABET[b] for b in digest) + + +def decode_emoji_hash(emoji_hash: str) -> bytes: + """이모지 해시 문자열을 원래 바이트열로 되돌린다 (검증/디버깅용).""" + return bytes(_EMOJI_TO_BYTE[ch] for ch in emoji_hash) + + +def short_hash(emoji_hash: str, length: int = SHORT_HASH_LENGTH) -> str: + return emoji_hash[:length] + + +def hash_object(obj_type: str, data: bytes) -> tuple[str, bytes]: + """git과 동일한 방식(`type len\\0content`)으로 sha1을 구하고 이모지 해시로 변환한다.""" + header = f"{obj_type} {len(data)}\0".encode() + digest = hashlib.sha1(header + data).digest() + return encode_emoji_hash(digest), data + + +@dataclass +class Blob: + content: bytes + + def serialize(self) -> bytes: + return self.content + + @classmethod + def deserialize(cls, data: bytes) -> "Blob": + return cls(content=data) + + +@dataclass +class TreeEntry: + name: str + hash: str + type: str # "blob" | "tree" + + +@dataclass +class Tree: + entries: list[TreeEntry] + + def serialize(self) -> bytes: + payload = { + "entries": [ + {"name": e.name, "hash": e.hash, "type": e.type} + for e in sorted(self.entries, key=lambda e: e.name) + ] + } + return json.dumps(payload, sort_keys=True).encode() + + @classmethod + def deserialize(cls, data: bytes) -> "Tree": + payload = json.loads(data.decode()) + entries = [ + TreeEntry(name=e["name"], hash=e["hash"], type=e["type"]) + for e in payload["entries"] + ] + return cls(entries=entries) + + +@dataclass +class Commit: + tree: str + parents: list[str] + author: str + message: str + timestamp: float + + def serialize(self) -> bytes: + payload: dict[str, Any] = { + "tree": self.tree, + "parents": self.parents, + "author": self.author, + "message": self.message, + "timestamp": self.timestamp, + } + return json.dumps(payload, sort_keys=True).encode() + + @classmethod + def deserialize(cls, data: bytes) -> "Commit": + payload = json.loads(data.decode()) + return cls( + tree=payload["tree"], + parents=payload["parents"], + author=payload["author"], + message=payload["message"], + timestamp=payload["timestamp"], + ) + + +class ObjectFormatError(Exception): + """저장된 객체를 우리 포맷으로 파싱할 수 없을 때 발생.""" + + +def object_path(objects_dir, emoji_hash: str): + return objects_dir / emoji_hash[0] / emoji_hash[1:] + + +def write_object(objects_dir, obj_type: str, data: bytes) -> str: + emoji_hash, _ = hash_object(obj_type, data) + path = object_path(objects_dir, emoji_hash) + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + payload = json.dumps( + {"type": obj_type, "data": base64.b64encode(data).decode("ascii")}, + sort_keys=True, + ) + path.write_text(payload, encoding="utf-8") + return emoji_hash + + +def read_object(objects_dir, emoji_hash: str) -> tuple[str, bytes]: + path = object_path(objects_dir, emoji_hash) + if not path.exists(): + raise FileNotFoundError(f"object not found: {emoji_hash}") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + return payload["type"], base64.b64decode(payload["data"]) + except (json.JSONDecodeError, KeyError, UnicodeDecodeError, ValueError) as exc: + raise ObjectFormatError(f"호환되지 않는 객체 포맷: {emoji_hash}") from exc diff --git a/src/mygit/repository.py b/src/mygit/repository.py new file mode 100644 index 0000000..73c926b --- /dev/null +++ b/src/mygit/repository.py @@ -0,0 +1,96 @@ +"""`.mygit/` 저장소 위치 탐색/생성과 HEAD·ref 관리.""" +from __future__ import annotations + +from pathlib import Path + +from .objects import ObjectFormatError, read_object + +MYGIT_DIR_NAME = ".mygit" +DEFAULT_BRANCH = "main" + + +class RepositoryNotFound(Exception): + pass + + +class Repository: + def __init__(self, mygit_dir: Path): + self.mygit_dir = mygit_dir + self.objects_dir = mygit_dir / "objects" + self.refs_dir = mygit_dir / "refs" / "heads" + self.head_path = mygit_dir / "HEAD" + self.index_path = mygit_dir / "index" + + @property + def root(self) -> Path: + return self.mygit_dir.parent + + @classmethod + def find(cls, start: Path | None = None) -> "Repository": + current = (start or Path.cwd()).resolve() + for directory in [current, *current.parents]: + candidate = directory / MYGIT_DIR_NAME + if candidate.is_dir(): + return cls(candidate) + raise RepositoryNotFound( + "'.mygit' 저장소를 찾을 수 없습니다. 'mygit add' 또는 'mygit commit'을 먼저 실행하세요." + ) + + @classmethod + def find_or_create(cls, start: Path | None = None) -> "Repository": + try: + return cls.find(start) + except RepositoryNotFound: + root = (start or Path.cwd()).resolve() + mygit_dir = root / MYGIT_DIR_NAME + repo = cls(mygit_dir) + repo._bootstrap() + return repo + + def _bootstrap(self) -> None: + self.objects_dir.mkdir(parents=True, exist_ok=True) + self.refs_dir.mkdir(parents=True, exist_ok=True) + self.head_path.write_text(f"ref: refs/heads/{DEFAULT_BRANCH}\n", encoding="utf-8") + self.index_path.write_text('{"entries": []}', encoding="utf-8") + + def current_branch(self) -> str: + head_content = self.head_path.read_text(encoding="utf-8").strip() + if head_content.startswith("ref: refs/heads/"): + return head_content[len("ref: refs/heads/"):] + raise ValueError(f"지원하지 않는 HEAD 형식입니다: {head_content}") + + def branch_ref_path(self, branch: str) -> Path: + return self.refs_dir / branch + + def resolve_branch(self, branch: str) -> str | None: + ref_path = self.branch_ref_path(branch) + if not ref_path.exists(): + return None + content = ref_path.read_text(encoding="utf-8").strip() + return content or None + + def update_branch(self, branch: str, emoji_hash: str) -> None: + self.branch_ref_path(branch).write_text(emoji_hash, encoding="utf-8") + + def list_branches(self) -> list[str]: + if not self.refs_dir.exists(): + return [] + return sorted(p.name for p in self.refs_dir.iterdir() if p.is_file()) + + def create_branch(self, branch: str, emoji_hash: str | None) -> None: + ref_path = self.branch_ref_path(branch) + if ref_path.exists(): + raise ValueError(f"브랜치가 이미 존재합니다: {branch}") + ref_path.write_text(emoji_hash or "", encoding="utf-8") + + def head_commit_hash(self) -> str | None: + """현재 브랜치가 가리키는 커밋의 이모지 해시. 파싱 불가/없음이면 None.""" + branch = self.current_branch() + emoji_hash = self.resolve_branch(branch) + if not emoji_hash: + return None + try: + read_object(self.objects_dir, emoji_hash) + except (FileNotFoundError, ObjectFormatError): + return None + return emoji_hash diff --git a/src/mygit/treebuild.py b/src/mygit/treebuild.py new file mode 100644 index 0000000..ca57cba --- /dev/null +++ b/src/mygit/treebuild.py @@ -0,0 +1,44 @@ +"""인덱스(플랫 경로 목록) <-> 중첩 tree 객체 변환.""" +from __future__ import annotations + +from .objects import ObjectFormatError, Tree, TreeEntry, read_object, write_object + + +def build_tree(repo, entries: dict[str, str]) -> str: + root: dict = {} + for path, blob_hash in entries.items(): + parts = path.split("/") + node = root + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = blob_hash + + def write_node(node: dict) -> str: + tree_entries = [] + for name, value in node.items(): + if isinstance(value, dict): + sub_hash = write_node(value) + tree_entries.append(TreeEntry(name=name, hash=sub_hash, type="tree")) + else: + tree_entries.append(TreeEntry(name=name, hash=value, type="blob")) + data = Tree(entries=tree_entries).serialize() + return write_object(repo.objects_dir, "tree", data) + + return write_node(root) + + +def flatten_tree(repo, tree_hash: str, prefix: str = "") -> dict[str, str]: + try: + _, data = read_object(repo.objects_dir, tree_hash) + tree = Tree.deserialize(data) + except (FileNotFoundError, ObjectFormatError): + return {} + + result: dict[str, str] = {} + for entry in tree.entries: + path = f"{prefix}{entry.name}" + if entry.type == "tree": + result.update(flatten_tree(repo, entry.hash, prefix=f"{path}/")) + else: + result[path] = entry.hash + return result diff --git a/src/mygit/worktree.py b/src/mygit/worktree.py new file mode 100644 index 0000000..f48ba98 --- /dev/null +++ b/src/mygit/worktree.py @@ -0,0 +1,15 @@ +"""워킹 디렉토리 파일 목록 탐색 (자체 저장소/캐시 디렉토리는 제외).""" +from __future__ import annotations + +import os +from pathlib import Path + +IGNORED_DIRS = {".mygit", ".git", ".omc", "__pycache__", ".venv", "venv", "node_modules", ".pytest_cache"} + + +def iter_working_files(root: Path): + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRS] + for filename in filenames: + full_path = Path(dirpath) / filename + yield full_path.relative_to(root).as_posix() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c52ebf3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.fixture +def project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + yield tmp_path + + +def run_mygit(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-m", "mygit.cli", *args], + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + ) diff --git a/tests/test_branch.py b/tests/test_branch.py new file mode 100644 index 0000000..ed77d55 --- /dev/null +++ b/tests/test_branch.py @@ -0,0 +1,20 @@ +from conftest import run_mygit + + +def test_branch_requires_commit_first(project): + result = run_mygit("branch", "feature", cwd=project) + assert result.returncode == 1 + assert "먼저" in result.stderr + + +def test_branch_create_and_list(project): + (project / "a.txt").write_text("hello") + run_mygit("add", "a.txt", cwd=project) + run_mygit("commit", "-m", "first", cwd=project) + + create = run_mygit("branch", "feature", cwd=project) + assert create.returncode == 0 + + listing = run_mygit("branch", cwd=project) + assert "feature" in listing.stdout + assert "* main" in listing.stdout diff --git a/tests/test_checkout.py b/tests/test_checkout.py new file mode 100644 index 0000000..111f8bb --- /dev/null +++ b/tests/test_checkout.py @@ -0,0 +1,73 @@ +from conftest import run_mygit + + +def test_checkout_switches_branch_and_files(project): + (project / "a.txt").write_text("main content") + run_mygit("add", "a.txt", cwd=project) + run_mygit("commit", "-m", "main commit", cwd=project) + + run_mygit("branch", "feature", cwd=project) + result = run_mygit("checkout", "feature", cwd=project) + assert result.returncode == 0 + assert "feature" in result.stdout + + (project / "a.txt").write_text("feature content") + run_mygit("add", "a.txt", cwd=project) + run_mygit("commit", "-m", "feature commit", cwd=project) + + back = run_mygit("checkout", "main", cwd=project) + assert back.returncode == 0 + assert (project / "a.txt").read_text() == "main content" + + forward = run_mygit("checkout", "feature", cwd=project) + assert forward.returncode == 0 + assert (project / "a.txt").read_text() == "feature content" + + +def test_checkout_creates_new_branch_with_b_flag(project): + (project / "a.txt").write_text("hello") + run_mygit("add", "a.txt", cwd=project) + run_mygit("commit", "-m", "first", cwd=project) + + result = run_mygit("checkout", "-b", "new-feature", cwd=project) + assert result.returncode == 0 + + branches = run_mygit("branch", cwd=project) + assert "new-feature" in branches.stdout + assert "* new-feature" in branches.stdout + + +def test_checkout_blocks_when_dirty(project): + (project / "a.txt").write_text("hello") + run_mygit("add", "a.txt", cwd=project) + run_mygit("commit", "-m", "first", cwd=project) + run_mygit("branch", "feature", cwd=project) + + (project / "a.txt").write_text("uncommitted change") + result = run_mygit("checkout", "feature", cwd=project) + assert result.returncode == 1 + assert "커밋되지 않은 변경사항" in result.stderr + + +def test_checkout_unknown_branch_fails(project): + (project / "a.txt").write_text("hello") + run_mygit("add", "a.txt", cwd=project) + run_mygit("commit", "-m", "first", cwd=project) + + result = run_mygit("checkout", "does-not-exist", cwd=project) + assert result.returncode == 1 + + +def test_checkout_removes_files_not_in_target_branch(project): + (project / "a.txt").write_text("shared") + run_mygit("add", "a.txt", cwd=project) + run_mygit("commit", "-m", "first", cwd=project) + run_mygit("branch", "feature", cwd=project) + run_mygit("checkout", "feature", cwd=project) + + (project / "only-on-feature.txt").write_text("feature only") + run_mygit("add", "only-on-feature.txt", cwd=project) + run_mygit("commit", "-m", "feature adds file", cwd=project) + + run_mygit("checkout", "main", cwd=project) + assert not (project / "only-on-feature.txt").exists() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..07027eb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,21 @@ +from conftest import run_mygit + + +def test_full_workflow(project): + (project / "a.txt").write_text("hello") + + add_result = run_mygit("add", "a.txt", cwd=project) + assert add_result.returncode == 0 + assert (project / ".mygit").is_dir() + + commit_result = run_mygit("commit", "-m", "첫 커밋", cwd=project) + assert commit_result.returncode == 0 + assert "\U0001F389" in commit_result.stdout + + log_result = run_mygit("log", cwd=project) + assert "첫 커밋" in log_result.stdout + + +def test_commit_without_staged_changes_fails(project): + result = run_mygit("commit", "-m", "no changes", cwd=project) + assert result.returncode == 1 diff --git a/tests/test_commit.py b/tests/test_commit.py new file mode 100644 index 0000000..d8ccce7 --- /dev/null +++ b/tests/test_commit.py @@ -0,0 +1,51 @@ +from mygit.objects import Commit, read_object +from mygit.index import Index +from mygit.repository import Repository +from mygit.treebuild import build_tree, flatten_tree + + +def _commit(project, filename, content, message): + (project / filename).write_text(content) + repo = Repository.find_or_create() + index = Index.load(repo.index_path) + index.add_file(repo, filename) + index.save(repo.index_path) + + tree_hash = build_tree(repo, index.as_dict()) + parent = repo.head_commit_hash() + from mygit.objects import write_object + + commit = Commit( + tree=tree_hash, + parents=[parent] if parent else [], + author="tester ", + message=message, + timestamp=0.0, + ) + commit_hash = write_object(repo.objects_dir, "commit", commit.serialize()) + repo.update_branch(repo.current_branch(), commit_hash) + return repo, commit_hash + + +def test_commit_creates_readable_object(project): + repo, commit_hash = _commit(project, "a.txt", "hello", "first commit") + _, data = read_object(repo.objects_dir, commit_hash) + commit = Commit.deserialize(data) + assert commit.message == "first commit" + assert commit.parents == [] + + +def test_second_commit_has_parent(project): + repo, first_hash = _commit(project, "a.txt", "hello", "first") + _, second_hash = _commit(project, "b.txt", "world", "second") + _, data = read_object(repo.objects_dir, second_hash) + commit = Commit.deserialize(data) + assert commit.parents == [first_hash] + + +def test_tree_roundtrip(project): + repo, commit_hash = _commit(project, "a.txt", "hello", "first") + _, data = read_object(repo.objects_dir, commit_hash) + commit = Commit.deserialize(data) + flat = flatten_tree(repo, commit.tree) + assert "a.txt" in flat diff --git a/tests/test_index.py b/tests/test_index.py new file mode 100644 index 0000000..b8e7582 --- /dev/null +++ b/tests/test_index.py @@ -0,0 +1,22 @@ +from mygit.index import Index +from mygit.repository import Repository + + +def test_add_file_updates_index(project): + (project / "a.txt").write_text("hello") + repo = Repository.find_or_create() + index = Index.load(repo.index_path) + emoji_hash = index.add_file(repo, "a.txt") + index.save(repo.index_path) + + reloaded = Index.load(repo.index_path) + assert reloaded.entries["a.txt"].hash == emoji_hash + + +def test_remove_from_index(project): + (project / "a.txt").write_text("hello") + repo = Repository.find_or_create() + index = Index.load(repo.index_path) + index.add_file(repo, "a.txt") + index.remove("a.txt") + assert "a.txt" not in index.entries diff --git a/tests/test_objects.py b/tests/test_objects.py new file mode 100644 index 0000000..c0ca918 --- /dev/null +++ b/tests/test_objects.py @@ -0,0 +1,25 @@ +from mygit.objects import decode_emoji_hash, encode_emoji_hash, hash_object + + +def test_emoji_hash_roundtrip(): + digest = bytes(range(20)) + emoji_hash = encode_emoji_hash(digest) + assert len(emoji_hash) == 20 + assert decode_emoji_hash(emoji_hash) == digest + + +def test_hash_object_deterministic(): + hash_a, _ = hash_object("blob", b"hello world") + hash_b, _ = hash_object("blob", b"hello world") + assert hash_a == hash_b + + +def test_hash_object_differs_by_content(): + hash_a, _ = hash_object("blob", b"hello") + hash_b, _ = hash_object("blob", b"world") + assert hash_a != hash_b + + +def test_hash_is_pure_emoji(): + emoji_hash, _ = hash_object("blob", b"hello world") + assert all(0x1F400 <= ord(ch) <= 0x1F4FF for ch in emoji_hash) diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..ad5c5a4 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,25 @@ +from conftest import run_mygit + + +def test_status_shows_untracked(project): + (project / "a.txt").write_text("hello") + result = run_mygit("status", cwd=project) + assert result.returncode == 0 + assert "a.txt" in result.stdout + assert "추적되지 않는 파일" in result.stdout + + +def test_status_shows_staged(project): + (project / "a.txt").write_text("hello") + run_mygit("add", "a.txt", cwd=project) + result = run_mygit("status", cwd=project) + assert "staged: a.txt" in result.stdout + + +def test_status_clean_after_commit(project): + (project / "a.txt").write_text("hello") + run_mygit("add", "a.txt", cwd=project) + run_mygit("commit", "-m", "first", cwd=project) + result = run_mygit("status", cwd=project) + assert "커밋 대기 중인 변경사항 없음" in result.stdout + assert "추적되지 않는 파일" not in result.stdout