Skip to content
Merged
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
10 changes: 6 additions & 4 deletions minigit/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,9 +11,10 @@
import sys
from collections.abc import Sequence

from minigit import __version__
from minigit import __version__, commits
from minigit.errors import MiniGitError
from minigit.objects import register_subcommands
from minigit.index import register_index_commands
from minigit.objects import register_subcommands as register_object_commands


def _register_commands(subparsers) -> None:
Expand All@@ -27,8 +28,9 @@ def register_subcommands(subparsers):
parser.add_argument("path")
parser.set_defaults(handler=cmd_add)
"""

register_subcommands(subparsers)
register_object_commands(subparsers)
register_index_commands(subparsers)
commits.register_subcommands(subparsers)


def build_parser() -> argparse.ArgumentParser:
Expand Down
107 changes: 107 additions & 0 deletions minigit/commits.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,3 +8,110 @@

Build the `CommitManager` class here, per the interface contract.
"""

import os
import time

from minigit.errors import RefNotFoundError
from minigit.index import WorkingTree
from minigit.objects import ObjectStore


def _cmd_commit(args) -> int:
"""Handle the 'minigit commit -m <message>' command"""
manager = CommitManager()
stub_tree = "0" * 40
manager.create_commit(
stub_tree,
[],
"Daniel <daniel@example.com>",
args.message,
)
return 0


def _cmd_branch(args) -> int:
"""Handle 'minigit branch <name>' command"""
manager = CommitManager()
if args.name:
manager.create_branch(args.name, "0" * 40)
else:
for branch in manager.list_branches():
if branch == manager._head:
marker = "* "
else:
marker = " "
print(f"{marker}{branch}")
return 0


def _cmd_checkout(args) -> int:
"""
Handle 'minigit checkout <name> command'
"""
manager = CommitManager()
manager.switch_branch(args.name)
print(f"Switched to branch '{args.name}'")
return 0


def register_subcommands(subparsers) -> None:
"""
Register the three commands: commit, branch, checkout
"""
commit_parser = subparsers.add_parser("commit", help="record changes to the repository")
commit_parser.add_argument("-m", dest="message", required=True, help="commit message")
commit_parser.set_defaults(handler=_cmd_commit)

branch_parser = subparsers.add_parser("branch", help="create or list branches")
branch_parser.add_argument("name", nargs="?", help="branch name to create")
branch_parser.set_defaults(handler=_cmd_branch)

checkout_parser = subparsers.add_parser("checkout", help="switch branches")
checkout_parser.add_argument("name", help="branch name to switch to")
checkout_parser.set_defaults(handler=_cmd_checkout)


class CommitManager:
def __init__(self, repo_path=".", store=None, tree=None):
self.root = os.path.abspath(repo_path)
self._refs = {}
self._head = "main"
self.store = store if store is not None else ObjectStore(repo_path)
self.tree = tree if tree is not None else WorkingTree(repo_path)

def _format_commit(self, tree_hash, parents, author, message) -> str:
"""Format commit object as a string"""
lines = []
timestamp = int(time.time())
lines.append(f"tree {tree_hash}")
for parent in parents:
lines.append(f"parent {parent}")
lines.append(f"author {author} {timestamp}")
lines.append(f"committer {author} {timestamp}")
lines.append("")
lines.append(message)
return "\n".join(lines)

def create_commit(self, tree_hash, parents: list[str], author, message) -> str:
"""
Create a new commit object and write it to the object store
"""
body = self._format_commit(tree_hash, parents, author, message)
return self.store.write_object(body.encode(), "commit")

def create_branch(self, name, commit_hash) -> None:
"""Create a new branch that points at commit_hash"""
self._refs[name] = commit_hash

def switch_branch(self, name) -> None:
"""Switch to a branch"""
if name not in self._refs:
raise RefNotFoundError(name)
self._head = name

def list_branches(self) -> list[str]:
return sorted(self._refs)

def merge(self, branch_name) -> str | None:
return None
78 changes: 78 additions & 0 deletions minigit/index.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,3 +8,81 @@

Build the `WorkingTree` class here, per the interface contract.
"""

from dataclasses import dataclass
from typing import NamedTuple


class IndexEntry(NamedTuple):
mode: str
hash: str
path: str


@dataclass
class DiffResult:
added: list
deleted: list
modified: list


class WorkingTree:
def __init__(self, repo_path=".", store=None):
self.root = repo_path
self.index_path = f"{repo_path}/.minigit/index"

if store is None:
from minigit.objects import ObjectStore

store = ObjectStore(repo_path)

self.store = store
self._entries: list[IndexEntry] = []

def read_index(self) -> list[IndexEntry]:
return sorted(self._entries, key=lambda e: e.path)

def write_index(self, entries) -> None:
self._entries = sorted(entries, key=lambda e: e.path)

def stage_file(self, path) -> None:
with open(f"{self.root}/{path}", "rb") as f:
data = f.read()

blob_hash = self.store.write_object(data, "blob")

self._entries = [e for e in self._entries if e.path != path]

self._entries.append(IndexEntry("100644", blob_hash, path))
self._entries.sort(key=lambda e: e.path)

def build_tree_from_index(self) -> str:
return self.store.write_object(b"", "tree")

def diff_working_tree_vs(self, tree_hash) -> DiffResult:
return DiffResult([], [], [])

def checkout(self, tree_hash) -> None:
pass


def cmd_add(args) -> int:
wt = WorkingTree()
wt.stage_file(args.path)
return 0


def cmd_status(args) -> int:
wt = WorkingTree()
for entry in wt.read_index():
print(entry.path)
return 0


def register_index_commands(subparsers) -> None:
add_parser = subparsers.add_parser("add", help="stage a file")
add_parser.add_argument("path")
add_parser.set_defaults(handler=cmd_add)

status_parser = subparsers.add_parser("status", help="show staged files")
status_parser.set_defaults(handler=cmd_status)
107 changes: 107 additions & 0 deletions tests/test_commits.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
# Run to test: scripts/test.sh tests/test_commits.py
# Testing for Module 3

import pytest

from minigit.commits import CommitManager
from minigit.errors import RefNotFoundError


class FakeObjectStore:
def __init__(self):
"""Initialize with an empty record of written objects."""
self.written = []

def write_object(self, data: bytes, obj_type: str) -> str:
"""Record the write and return a deterministic fake hash."""
self.written.append((obj_type, data))
return "a" * 40


class FakeWorkingTree:
"""Minimal stand-in for WorkingTree. No filesystem operations."""

def checkout(self, tree_hash: str) -> None:
"""Accept a checkout call without doing anything."""


def make_manager():
"""Return CommitManager for testing"""
return CommitManager(store=FakeObjectStore(), tree=FakeWorkingTree())


# testing commits


def test_contains_tree_line():
m = make_manager()
body = m._format_commit("abc" * 13 + "a", [], "Daniel <Daniel@example.com>", "init")
assert body.startswith("tree ")


def test_contains_author_line():
m = make_manager()
body = m._format_commit("a" * 40, [], "Daniel <Daniel@example.com>", "init")
assert any(line.startswith("author ") for line in body.splitlines())


def test_blank_line_before_message():
m = make_manager()
body = m._format_commit("a" * 40, [], "Daniel <Daniel@example.com>", "hello")
lines = body.splitlines()
assert lines[-2] == ""
assert lines[-1] == "hello"


def test_root_commit_no_parent_lines():
m = make_manager()
body = m._format_commit("a" * 40, [], "Daniel <Daniel@example.com>", "root")
assert "parent" not in body


def test_normal_commit_one_parent_line():
m = make_manager()
body = m._format_commit("a" * 40, ["b" * 40], "Daniel <Daniel@example.com>", "second")
parent_lines = [line for line in body.splitlines() if line.startswith("parent ")]
assert len(parent_lines) == 1
assert "b" * 40 in parent_lines[0]


def test_merge_commit_two_parent_lines_in_order():
m = make_manager()
p1 = "1" * 40
p2 = "2" * 40
body = m._format_commit("0" * 40, [p1, p2], "Daniel <Daniel@example.com>", "merge")
parent_lines = [line for line in body.splitlines() if line.startswith("parent ")]
assert len(parent_lines) == 2
assert p1 in parent_lines[0]
assert p2 in parent_lines[1]


def test_create_commit_returns_hash():
m = make_manager()
result = m.create_commit("0" * 40, [], "Daniel <Daniel@example.com>", "init")
assert len(result) == 40


# branches test:


def test_create_and_list_branches():
m = make_manager()
m.create_branch("feature1", "a" * 40)
m.create_branch("feature2", "b" * 40)
assert m.list_branches() == ["feature1", "feature2"]


def test_switch_branch_unknown_raises():
m = make_manager()
with pytest.raises(RefNotFoundError):
m.switch_branch("nope")


def test_switch_branch_updates_head():
m = make_manager()
m.create_branch("feature", "a" * 40)
m.switch_branch("feature")
assert m._head == "feature"
68 changes: 68 additions & 0 deletions tests/test_index.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
"""Tests for minigit/index.py - WorkingTree, IndexEntry, DiffResult."""

from minigit.index import WorkingTree


class FakeObjectStore:
"""Stand-in for the real ObjectStore - keeps everything in memory."""

def __init__(self):
self.objects = {}

def write_object(self, data, obj_type):
fake_hash = f"hash{len(data)}"
self.objects[fake_hash] = data
return fake_hash


def test_staging_adds_one_entry(tmp_path):
file = tmp_path / "hello.txt"
file.write_text("hi")

wt = WorkingTree(repo_path=str(tmp_path), store=FakeObjectStore())
wt.stage_file("hello.txt")

entries = wt.read_index()
assert len(entries) == 1
assert entries[0].path == "hello.txt"


def test_staging_twice_replaces_not_duplicates(tmp_path):
file = tmp_path / "hello.txt"
file.write_text("hi")

wt = WorkingTree(repo_path=str(tmp_path), store=FakeObjectStore())
wt.stage_file("hello.txt")
wt.stage_file("hello.txt")

entries = wt.read_index()
assert len(entries) == 1


def test_read_index_sorted(tmp_path):
(tmp_path / "z.txt").write_text("z")
(tmp_path / "a.txt").write_text("a")

wt = WorkingTree(repo_path=str(tmp_path), store=FakeObjectStore())
wt.stage_file("z.txt")
wt.stage_file("a.txt")

entries = wt.read_index()
assert entries[0].path == "a.txt"
assert entries[1].path == "z.txt"


def test_editing_after_staging_does_not_change_entry(tmp_path):
file = tmp_path / "hello.txt"
file.write_text("original content")

wt = WorkingTree(repo_path=str(tmp_path), store=FakeObjectStore())
wt.stage_file("hello.txt")

hash_before = wt.read_index()[0].hash

file.write_text("changed content!!!")

hash_after = wt.read_index()[0].hash

assert hash_before == hash_after
Loading