Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.egg-info/
.pytest_cache/
.omc/
18 changes: 18 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
1 change: 1 addition & 0 deletions src/mygit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.1.0"
288 changes: 288 additions & 0 deletions src/mygit/cli.py
Original file line number Diff line number Diff line change
@@ -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())
37 changes: 37 additions & 0 deletions src/mygit/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""커밋 작성자 정보 조회: 프로젝트 .git/config -> ~/.gitconfig -> 환경변수 -> 기본값."""
from __future__ import annotations

import configparser
import os
from pathlib import Path

DEFAULT_AUTHOR = "unknown <unknown@example.com>"


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
Loading