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
23 changes: 23 additions & 0 deletions minigit/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@
import argparse
import sys
from collections.abc import Sequence
from pathlib import Path

from minigit import __version__, commits
from minigit.errors import MiniGitError
Expand All@@ -18,6 +19,25 @@
from minigit.remote import register_subcommands as register_remote_commands


def _init_repository(args) -> int:
"""
Initialize a new minigit repository in the current directory.
If a repository already exists, print a message and return 0.
"""
metadata_dir = Path(".minigit")

if metadata_dir.exists():
print("already a minigit repository")
return 0

(metadata_dir / "objects").mkdir(parents=True)
(metadata_dir / "refs" / "heads").mkdir(parents=True)
(metadata_dir / "index").write_bytes(b"")
(metadata_dir / "config").write_bytes(b"")
(metadata_dir / "HEAD").write_text("ref: refs/heads/main\n")
return 0


def _register_commands(subparsers) -> None:
"""Attach each module's subcommands to the parser.

Expand All@@ -29,6 +49,9 @@ def register_subcommands(subparsers):
parser.add_argument("path")
parser.set_defaults(handler=cmd_add)
"""
init_parser = subparsers.add_parser("init", help="create a minigit repository")
init_parser.set_defaults(handler=_init_repository)

register_object_commands(subparsers)
register_index_commands(subparsers)
commits.register_subcommands(subparsers)
Expand Down
52 changes: 39 additions & 13 deletions minigit/objects.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,53 +9,79 @@
Build the `ObjectStore` class here, per the interface contract.
"""

import hashlib
import zlib
from pathlib import Path

from minigit.errors import ObjectNotFoundError
from minigit.errors import ObjectCorruptError, ObjectNotFoundError


class ObjectStore:
root: Path
objects_dir: Path
_fake_store: dict[str, tuple[str, bytes]]

def __init__(self, repo_path=".") -> None:
"""
Initialize the object store.
"""
self.root = Path(repo_path)
self.objects_dir = self.root / ".minigit" / "objects"
self._fake_store = {}

def hash_object(self, data: bytes, obj_type: str) -> str:
"""
Return the SHA-1 hash of the object, given its data and type.
"""
# placeholder - real SHA-1 of "<type> <len>\0<content>" lands Week 2
return f"{zlib.crc32(obj_type.encode() + data):040x}"

return hashlib.sha1(f"{obj_type} {len(data)}".encode() + b"\0" + data).hexdigest()

def write_object(self, data: bytes, obj_type: str) -> str:
"""
Writes the object's hash into self._fake_store and returns the hash.
Write the object to the object store and return its hash.
Allow duplicates to be written.
"""
obj_hash = self.hash_object(data, obj_type)
self._fake_store[obj_hash] = (obj_type, data)
object_path = self._object_path(obj_hash)

if object_path.exists():
return obj_hash

header = f"{obj_type} {len(data)}".encode()
object_path.parent.mkdir(parents=True, exist_ok=True)
object_path.write_bytes(zlib.compress(header + b"\0" + data))
return obj_hash

def read_object(self, hash: str) -> tuple[str, bytes]:
"""
Reads the object from self._fake_store and returns a tuple of (type, data).
Reads the object from the object store and returns a tuple of (type, data).
Raise ObjectNotFoundError(hash) if the object is not found.
Raise ObjectCorruptError(hash) if the object is corrupt.
"""
if hash not in self._fake_store:
object_path = self._object_path(hash)

if not object_path.exists():
raise ObjectNotFoundError(hash)

return self._fake_store[hash]
try:
raw_object = zlib.decompress(object_path.read_bytes())
header, content = raw_object.split(b"\0", 1)
obj_type, obj_length = header.decode().split(" ", 1)

if int(obj_length) != len(content):
raise ObjectCorruptError(hash)

except (ValueError, UnicodeDecodeError, zlib.error) as error:
raise ObjectCorruptError(hash) from error

_cli_store = ObjectStore()
if self.hash_object(content, obj_type) != hash:
raise ObjectCorruptError(hash)

return obj_type, content

def _object_path(self, hash: str) -> Path:
"""
Return the path to the object with the given hash.
"""
return self.objects_dir / hash[:2] / hash[2:]


def run_hash_object(args) -> int:
Expand All@@ -64,7 +90,7 @@ def run_hash_object(args) -> int:
"""
with open(args.path, "rb") as f:
data = f.read()
obj_hash = _cli_store.write_object(data, "blob")
obj_hash = ObjectStore(".").write_object(data, "blob")
print(obj_hash)
return 0

Expand All@@ -73,7 +99,7 @@ def run_cat_file(args) -> int:
"""
Print the contents of the object with the given hash.
"""
_, obj_data = _cli_store.read_object(args.hash)
_, obj_data = ObjectStore(".").read_object(args.hash)
print(obj_data.decode("utf-8", errors="replace"), end="")
return 0

Expand Down
55 changes: 54 additions & 1 deletion tests/test_objects.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
import pytest

from minigit.cli import main
from minigit.errors import ObjectNotFoundError
from minigit.errors import ObjectCorruptError, ObjectNotFoundError
from minigit.objects import ObjectStore


Expand DownExpand Up@@ -96,3 +96,56 @@ def test_cat_file_cli_print(tmp_path, capsys):
assert main(["cat-file", obj_hash]) == 0
captured = capsys.readouterr()
assert captured.out == "hello minigit"


def test_hash_correctness(tmp_path):
"""
Test that the hash of a known object is correct.
"""

store = ObjectStore(tmp_path)

assert store.hash_object(b"hi", "blob") == "32f95c0d1244a78b2be1bab8de17906fabb2c4a8"
assert store.hash_object(b"", "blob") == "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"


def test_object_compressed(tmp_path):
"""
Test that the compressed object does not equal the content.
"""

store = ObjectStore(tmp_path)
obj_hash = store.write_object(b"hi", "blob")
object_path = store._object_path(obj_hash)

compressed_data = object_path.read_bytes()
assert compressed_data != b"hi"


def test_file_overwrite(tmp_path):
"""
Test that overwriting an existing object raises ObjectCorruptError.
"""

store = ObjectStore(tmp_path)
obj_hash = store.write_object(b"hi", "blob")

store._object_path(obj_hash).write_bytes(b"junk")

with pytest.raises(ObjectCorruptError):
store.read_object(obj_hash)


def test_duplicate_write_leaves_one_object_file(tmp_path):
"""
Test that writing the same object twice leaves only one object file in the object store.
"""
store = ObjectStore(tmp_path)

obj_hash = store.write_object(b"hi", "blob")
assert store.write_object(b"hi", "blob") == obj_hash

assert list((tmp_path / ".minigit" / "objects").rglob("*")) == [
store._object_path(obj_hash).parent,
store._object_path(obj_hash),
]
22 changes: 22 additions & 0 deletions tests/test_smoke.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,28 @@ def test_cli_without_a_command_prints_help(capsys):
assert "usage: minigit" in capsys.readouterr().out


def test_init_creates_repository_layout(tmp_path, monkeypatch, capsys):
"""
Test that the init command creates the expected repository layout.
"""
monkeypatch.chdir(tmp_path)

assert main(["init"]) == 0

assert (tmp_path / ".minigit").is_dir()
assert (tmp_path / ".minigit" / "objects").is_dir()
assert (tmp_path / ".minigit" / "refs" / "heads").is_dir()
assert (tmp_path / ".minigit" / "index").read_bytes() == b""
assert (tmp_path / ".minigit" / "config").read_bytes() == b""
assert (tmp_path / ".minigit" / "HEAD").read_text() == "ref: refs/heads/main\n"

sentinel = tmp_path / ".minigit" / "config"
sentinel.write_bytes(b"keep me")
assert main(["init"]) == 0
assert sentinel.read_bytes() == b"keep me"
assert "already a minigit repository" in capsys.readouterr().out


def test_merge_conflict_error_carries_paths():
error = MergeConflictError(["src/a.py", "src/b.py"])

Expand Down
Loading