diff --git a/git_scratch/commands/porcelain_commit.py b/git_scratch/commands/porcelain_commit.py new file mode 100644 index 0000000..a8930ff --- /dev/null +++ b/git_scratch/commands/porcelain_commit.py @@ -0,0 +1,57 @@ +import typer + +from git_scratch.utils.tree import create_root_tree_object +from git_scratch.utils.commit import build_commit_object +from git_scratch.utils.refs import get_head_commit_oid, update_head_to_commit, get_head_display, InvalidHeadError + +def commit( + message: str = typer.Option(..., "-m", help="The commit message."), + verbose: bool = typer.Option(True, "--verbose/--quiet", help="Show commit summary."), +): + """ + Records changes to the repository. + """ + try: + tree_oid = create_root_tree_object() + parent_oid = get_head_commit_oid() + + is_root_commit = parent_oid is None + + new_commit_oid = build_commit_object( + tree_oid=tree_oid, + message=message, + parent_oid=parent_oid + ) + + update_head_to_commit(new_commit_oid) + + if verbose: + head_desc = get_head_display(new_commit_oid) + + commit_prefix = f"[{head_desc}" + if is_root_commit: + commit_prefix += " (root-commit)" + commit_prefix += f" {new_commit_oid[:7]}]" + + typer.echo(f"{commit_prefix} {message}") + else: + typer.echo(new_commit_oid) + + + except InvalidHeadError as e: + typer.secho(f"HEAD Error: {e}", fg=typer.colors.RED) + raise typer.Exit(code=1) + + except ValueError as e: + typer.secho(f"Error: {e}", fg=typer.colors.RED) + if "identity" in str(e).lower(): + typer.secho('\nTo configure your user identity:\n' + ' git config --global user.email "you@example.com"\n' + ' git config --global user.name "Your Name"\n' + "Omit --global to set identity only for this repository.", + fg=typer.colors.RED) + raise typer.Exit(code=1) + + except Exception as e: + typer.secho(f"An unexpected error occurred: {e}", fg=typer.colors.RED) + raise typer.Exit(code=1) diff --git a/git_scratch/commands/write_tree.py b/git_scratch/commands/write_tree.py index d493c73..e9d0cee 100644 --- a/git_scratch/commands/write_tree.py +++ b/git_scratch/commands/write_tree.py @@ -1,19 +1,19 @@ import typer -from git_scratch.utils.index_utils import load_index -from git_scratch.utils.tree import build_tree -from git_scratch.utils.object import write_object +from git_scratch.utils.tree import create_root_tree_object def write_tree(): """ Writes a recursive Git tree from .git/index.json and displays its OID. """ - index = load_index() - if not index: - typer.secho("Erreur : .git/index.json not found or empty.", fg=typer.colors.RED) + try: + + oid = create_root_tree_object() + typer.echo(oid) + except ValueError as e: + typer.secho(f"Error: {e}", fg=typer.colors.RED) + raise typer.Exit(code=1) + except Exception as e: + typer.secho(f"An unexpected error occurred while writing the tree: {e}", fg=typer.colors.RED) raise typer.Exit(code=1) - - tree_data = build_tree(index) - oid = write_object(tree_data, "tree") - typer.echo(oid) diff --git a/git_scratch/main.py b/git_scratch/main.py index a060ec0..b7657ad 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -9,6 +9,9 @@ from git_scratch.commands.rev_parse import rev_parse from git_scratch.commands.ls_tree import ls_tree from git_scratch.commands.commit_tree import commit_tree +from git_scratch.commands.porcelain_commit import commit + + from git_scratch.commands.status import status from git_scratch.commands.log import log from git_scratch.commands.init import init @@ -26,6 +29,9 @@ app.command("show-ref")(show_ref) app.command("ls-tree")(ls_tree) app.command("commit-tree")(commit_tree) +app.command("commit")(commit) + + app.command("status")(status) app.command("log")(log) diff --git a/git_scratch/readme.txt b/git_scratch/readme.txt new file mode 100644 index 0000000..c32187e --- /dev/null +++ b/git_scratch/readme.txt @@ -0,0 +1 @@ +Salut l'homme au coeur blanc \ No newline at end of file diff --git a/git_scratch/utils/commit.py b/git_scratch/utils/commit.py index 4d1d35c..59c9783 100644 --- a/git_scratch/utils/commit.py +++ b/git_scratch/utils/commit.py @@ -10,25 +10,18 @@ def build_commit_object( ) -> str: """ Construct and store a commit object. - - Args: - tree_oid (str): OID of the associated tree. - message (str): Commit message. - parent_oid (Optional[str]): Parent commit OID, if any. - - Returns: - str: The OID of the created commit object. """ author_name, author_email = get_author_identity() - timestamp, timezone = get_timestamp_info() + author_timestamp, author_timezone = get_timestamp_info(is_committer=False) + committer_timestamp, committer_timezone = get_timestamp_info(is_committer=True) lines = [f"tree {tree_oid}"] if parent_oid: lines.append(f"parent {parent_oid}") - lines.append(f"author {author_name} <{author_email}> {timestamp} {timezone}") - lines.append(f"committer {author_name} <{author_email}> {timestamp} {timezone}") + lines.append(f"author {author_name} <{author_email}> {author_timestamp} {author_timezone}") + lines.append(f"committer {author_name} <{author_email}> {committer_timestamp} {committer_timezone}") lines.append("") lines.append(message) diff --git a/git_scratch/utils/identity.py b/git_scratch/utils/identity.py index 0a83e55..5ffc04b 100644 --- a/git_scratch/utils/identity.py +++ b/git_scratch/utils/identity.py @@ -1,9 +1,8 @@ # git_scratch/pit_identity.py import os import configparser -from datetime import datetime +from datetime import datetime, timezone from typing import Tuple -import typer from pathlib import Path def get_author_identity() -> Tuple[str, str]: @@ -44,14 +43,34 @@ def get_author_identity() -> Tuple[str, str]: return name, email -def get_timestamp_info() -> Tuple[int, str]: +def get_timestamp_info(is_committer: bool = False) -> tuple[int, str]: """ - Retrieve the current timestamp and timezone offset in Git's format. - - Returns: - Tuple[int, str]: Timestamp (seconds since epoch) and timezone offset (e.g., "+0100"). + Returns the timestamp and timezone for the commit. """ + env_date_var = "GIT_COMMITTER_DATE" if is_committer else "GIT_AUTHOR_DATE" + date_str = os.environ.get(env_date_var) + + if date_str: + parts = date_str.split(' ') + if len(parts) == 2: + try: + timestamp = int(parts[0]) + tz_offset = parts[1] + if len(tz_offset) == 5 and (tz_offset[0] == '+' or tz_offset[0] == '-'): + return timestamp, tz_offset + print(f"[WARN] Format de fuseau horaire non standard pour '{date_str}'. Tentative d'un autre format.") + except ValueError: + print(f"[WARN] Impossible de parser '{parts[0]}' comme un timestamp. Tentative d'un autre format.") + + try: + dt = datetime.strptime(date_str, "%a %b %d %H:%M:%S %Y %z") + timestamp = int(dt.timestamp()) + tz_offset = date_str.strip().split()[-1] + return timestamp, tz_offset + except ValueError: + raise ValueError(f"Format de date/heure invalide dans GIT_AUTHOR_DATE/GIT_COMMITTER_DATE: '{date_str}'. Formats supportés: 'timestamp timezone_offset' ou 'Jour Mois Jour HH:MM:SS Année DécalageTimeZone'.") + now = datetime.now().astimezone() timestamp = int(now.timestamp()) - timezone = now.strftime('%z') - return timestamp, timezone + tz_offset = now.strftime("%z") # e.g. +0200 + return timestamp, tz_offset \ No newline at end of file diff --git a/git_scratch/utils/porcelain_commit.py b/git_scratch/utils/porcelain_commit.py deleted file mode 100644 index 167da8f..0000000 --- a/git_scratch/utils/porcelain_commit.py +++ /dev/null @@ -1,65 +0,0 @@ -import os -from pathlib import Path -from typing import Optional - -def get_head_commit_oid() -> Optional[str]: - """ - Lit la référence HEAD et retourne l'OID du commit vers lequel elle pointe. - Retourne None si HEAD n'existe pas (dépôt vide) ou si elle ne pointe nulle part. - """ - head_path = Path(".git") / "HEAD" - if not head_path.exists(): - return None # HEAD n'existe pas, probable dépôt vide - - with open(head_path, "r") as f: - head_content = f.read().strip() - - if head_content.startswith("ref: "): - # HEAD pointe vers une branche, ex: "ref: refs/heads/main" - ref_name = head_content[len("ref: "):] - ref_path = Path(".git") / ref_name - if ref_path.exists(): - with open(ref_path, "r") as f_ref: - return f_ref.read().strip() # Lit l'OID de la branche - else: - # La référence de branche n'existe pas (ex: branche toute neuve sans commit) - return None - else: - # HEAD est en mode "detached HEAD", pointe directement vers un OID de commit - # (Cela ne devrait pas arriver avec un 'commit' normal, mais on le gère) - # Vérifie que c'est un OID valide (40 caractères hexadécimaux) - if len(head_content) == 40 and all(c in "0123456789abcdef" for c in head_content): - return head_content - return None # Référence HEAD détachée invalide - -def update_head_to_commit(new_commit_oid: str): - """ - Met à jour la référence HEAD (et la branche actuelle qu'elle pointe) - vers le nouvel OID du commit. - """ - head_path = Path(".git") / "HEAD" - - if not head_path.exists() or not head_path.read_text().strip().startswith("ref: "): - # Si HEAD n'existe pas ou n'est pas une référence de branche, - # on assume qu'on doit créer/mettre à jour la branche 'main' (par défaut). - # Un 'git init' plus complet devrait déjà configurer 'HEAD' pour pointer à 'main'. - branch_ref_path = Path(".git") / "refs" / "heads" / "main" - branch_ref_path.parent.mkdir(parents=True, exist_ok=True) # S'assure que le répertoire existe - with open(branch_ref_path, "w") as f: - f.write(new_commit_oid + "\n") - # Et s'assure que HEAD pointe bien vers cette nouvelle branche - with open(head_path, "w") as f: - f.write("ref: refs/heads/main\n") - return - - # HEAD est une référence de branche (ex: ref: refs/heads/ma_branche) - with open(head_path, "r") as f: - head_content = f.read().strip() - - # Extrait le chemin de la référence (ex: refs/heads/ma_branche) - ref_name = head_content[len("ref: "):] - ref_path = Path(".git") / ref_name - - ref_path.parent.mkdir(parents=True, exist_ok=True) # S'assure que le répertoire de la référence existe - with open(ref_path, "w") as f: - f.write(new_commit_oid + "\n") \ No newline at end of file diff --git a/git_scratch/utils/refs.py b/git_scratch/utils/refs.py new file mode 100644 index 0000000..ea03f65 --- /dev/null +++ b/git_scratch/utils/refs.py @@ -0,0 +1,78 @@ +from pathlib import Path +from typing import Optional + + +class GitError(Exception): + """Base exception for Git errors.""" + + +class InvalidHeadError(GitError): + """Raised when HEAD points to an invalid ref or commit, or refers to a missing or invalid object.""" + + +def _is_valid_oid(oid: str) -> bool: + return len(oid) == 40 and all(c in "0123456789abcdef" for c in oid.lower()) + + +def get_head_commit_oid() -> Optional[str]: + head_path = Path(".git") / "HEAD" + if not head_path.exists(): + return None + + head_content = head_path.read_text().strip() + + if head_content.startswith("ref: "): + ref_name = head_content[len("ref: "):] + ref_path = Path(".git") / ref_name + + if not ref_path.exists(): + return None + + ref_oid = ref_path.read_text().strip() + + if not _is_valid_oid(ref_oid): + raise InvalidHeadError(f"Ref '{ref_name}' contains an invalid or empty OID: '{ref_oid}'") + + return ref_oid + else: + if _is_valid_oid(head_content): + return head_content + raise InvalidHeadError("HEAD contains an invalid commit OID format.") + + +def update_head_to_commit(new_commit_oid: str): + head_path = Path(".git") / "HEAD" + + if not head_path.exists() or not head_path.read_text().strip().startswith("ref: "): + default_branch = "main" + branch_ref_path = Path(".git") / "refs" / "heads" / default_branch + + branch_ref_path.parent.mkdir(parents=True, exist_ok=True) + + branch_ref_path.write_text(new_commit_oid + "\n") + + head_path.write_text(f"ref: refs/heads/{default_branch}\n") + return + + head_content = head_path.read_text().strip() + ref_name = head_content[len("ref: "):] + ref_path = Path(".git") / ref_name + + ref_path.parent.mkdir(parents=True, exist_ok=True) + + ref_path.write_text(new_commit_oid + "\n") + +def get_head_display(oid: str) -> str: + """ +Returns a description of the HEAD for display purposes + """ + head_path = Path(".git") / "HEAD" + content = head_path.read_text().strip() + + if content.startswith("ref: "): + ref = content[len("ref: "):] + if ref.startswith("refs/heads/"): + return ref[len("refs/heads/"):] + return ref + else: + return f"HEAD detached at {oid[:7]}" diff --git a/git_scratch/utils/tree.py b/git_scratch/utils/tree.py index 7b420ed..691f6a0 100644 --- a/git_scratch/utils/tree.py +++ b/git_scratch/utils/tree.py @@ -2,6 +2,7 @@ import os from typing import List, Dict, Tuple from git_scratch.utils.object import write_object +from git_scratch.utils.index_utils import load_index def build_tree(entries: List[Dict], base_path: str = "") -> bytes: @@ -33,4 +34,22 @@ def build_tree(entries: List[Dict], base_path: str = "") -> bytes: mode, oid = tree_entries[name] result += f"{mode} {name}".encode() + b"\x00" + oid - return result \ No newline at end of file + return result + +def create_root_tree_object() -> str: + """ + Loads the index, recursively builds the root Git tree object, + stores it, and returns its OID. + This function orchestrates the creation of the complete tree. + """ + index_entries = load_index() + if not index_entries: + raise ValueError("Index is empty or not found. Nothing to commit.") + + # Utilise la fonction build_tree existante pour obtenir le contenu binaire du tree racine pour l'arbre racine, base_path est vide + tree_content_bytes = build_tree(index_entries, base_path="") + + # Stocke cet objet tree racine dans le dépôt Git + tree_oid = write_object(tree_content_bytes, "tree") + + return tree_oid \ No newline at end of file diff --git a/tests/unit/test_commit.py b/tests/unit/test_commit.py new file mode 100644 index 0000000..99058d6 --- /dev/null +++ b/tests/unit/test_commit.py @@ -0,0 +1,68 @@ +import os +import subprocess +from pathlib import Path +import pytest +from typer.testing import CliRunner +from git_scratch.main import app + +runner = CliRunner() + +@pytest.fixture +def git_and_pit_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0, f"Pit init failed: {result.stderr}" + + subprocess.run(["git", "init"], check=True) + + git_dir = tmp_path / ".git" + os.chmod(tmp_path, 0o755) + for root, dirs, files in os.walk(git_dir): + for d in dirs: + os.chmod(Path(root) / d, 0o755) + for f in files: + os.chmod(Path(root) / f, 0o644) + + subprocess.run(["git", "config", "user.name", "Test"], check=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], check=True) + + (tmp_path / "test.txt").write_text("Hello test\n") + + result = runner.invoke(app, ["add", "test.txt"]) + assert result.exit_code == 0, f"Pit add failed: {result.stderr}" + + subprocess.run(["git", "add", "test.txt"], check=True) + + + subprocess.run([ + "git", "-c", "user.name=Test", "-c", "user.email=test@test.com", + "commit", "-m", "hello" + ], check=True) + + monkeypatch.setenv("GIT_AUTHOR_NAME", "Test") + monkeypatch.setenv("GIT_AUTHOR_EMAIL", "test@test.com") + monkeypatch.setenv("GIT_COMMITTER_NAME", "Test") + monkeypatch.setenv("GIT_COMMITTER_EMAIL", "test@test.com") + monkeypatch.setenv("TZ", "UTC") + + result = runner.invoke(app, ["commit", "-m", "hello"]) + assert result.exit_code == 0, f"Pit commit failed: {result.stderr}" + + yield tmp_path + + finally: + os.chdir(old_cwd) + + +def test_commit_sha_matches_git(git_and_pit_repo: Path): + git_sha = subprocess.run( + ["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + pit_sha = subprocess.run( + ["pit", "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + + assert git_sha == pit_sha, f"SHA mismatch: Git={git_sha}, Pit={pit_sha}" diff --git a/tests/unit/test_commit_tree.py b/tests/unit/test_commit_tree.py index 7c672de..efffa51 100644 --- a/tests/unit/test_commit_tree.py +++ b/tests/unit/test_commit_tree.py @@ -1,77 +1,68 @@ import os -import zlib +import subprocess from pathlib import Path -import json +import pytest from typer.testing import CliRunner -from git_scratch.main import app +from git_scratch.main import app runner = CliRunner() -def test_commit_tree_creates_valid_commit_object(tmp_path, monkeypatch): - original_cwd = os.getcwd() +@pytest.fixture +def git_and_pit_repo_for_commit_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + old_cwd = os.getcwd() + os.chdir(tmp_path) try: - os.chdir(tmp_path) - - monkeypatch.setenv("GIT_AUTHOR_NAME", "Alice Dev") - monkeypatch.setenv("GIT_AUTHOR_EMAIL", "alice@example.com") - - - result_init = runner.invoke(app, ["init"]) - assert result_init.exit_code == 0 - - # -- Créer un fichier et le hasher avec pit - file = tmp_path / "hello.txt" - file.write_text("Salut du test\n") - result_hash = runner.invoke(app, ["hash-object", str(file), "--write"]) - assert result_hash.exit_code == 0 - blob_oid = result_hash.stdout.strip() - - # -- Créer un index.json minimal - index = [{ - "path": "hello.txt", - "oid": blob_oid, - "mode": "100644" - }] - index_path = tmp_path / ".git" / "index.json" - # Utiliser json.dumps pour une conversion robuste en JSON - index_path.write_text(json.dumps(index)) - - - result_tree = runner.invoke(app, ["write-tree"]) - assert result_tree.exit_code == 0 - tree_oid = result_tree.stdout.strip() - if "Tree OID: " in tree_oid: - tree_oid = tree_oid.split("Tree OID: ")[1] - - - message = "Premier commit pit" - result_commit = runner.invoke(app, ["commit-tree", tree_oid, "-m", message]) - assert result_commit.exit_code == 0 - commit_oid = result_commit.stdout.strip() - - - obj_dir = tmp_path / ".git" / "objects" / commit_oid[:2] - obj_file = obj_dir / commit_oid[2:] - assert obj_file.exists(), f"Commit object {commit_oid} not written" - - # -- Décompresser et VÉRIFIER LE CONTENU DU COMMIT (en ignorant l'en-tête) - with open(obj_file, "rb") as f: - decompressed_full_content = zlib.decompress(f.read()).decode() - - # Trouver la fin de l'en-tête (le premier caractère nul '\x00') - null_byte_index = decompressed_full_content.find('\x00') - assert null_byte_index != -1, "Could not find null byte separator in decompressed content" - - # Le contenu "pur" du commit commence après le caractère nul - commit_body = decompressed_full_content[null_byte_index + 1:] - - # --- Assertions sur le corps du commit --- - # Remplacez content par commit_body dans vos assertions - assert commit_body.startswith("tree " + tree_oid), \ - f"Expected commit body to start with 'tree {tree_oid}', but got: '{commit_body[:50]}...'" - assert f"author Alice Dev " in commit_body - assert f"committer Alice Dev " in commit_body - assert message in commit_body - + subprocess.run(["git", "init"], check=True) + subprocess.run(["git", "config", "user.name", "Test"], check=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], check=True) + + git_dir = tmp_path / ".git" + os.chmod(tmp_path, 0o755) + for root, dirs, files in os.walk(git_dir): + for d in dirs: + os.chmod(Path(root) / d, 0o755) + for f in files: + os.chmod(Path(root) / f, 0o644) + + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0, f"Pit init failed: {result.stderr}" + + (tmp_path / "test.txt").write_text("Hello commit-tree\n") + result = runner.invoke(app, ["add", "test.txt"]) + assert result.exit_code == 0, f"Pit add failed: {result.stderr}" + subprocess.run(["git", "add", "test.txt"], check=True) + + subprocess.run([ + "git", "-c", "user.name=Test", "-c", "user.email=test@test.com", + "commit", "-m", "initial commit" + ], check=True) + + monkeypatch.setenv("GIT_AUTHOR_NAME", "Test") + monkeypatch.setenv("GIT_AUTHOR_EMAIL", "test@test.com") + monkeypatch.setenv("GIT_COMMITTER_NAME", "Test") + monkeypatch.setenv("GIT_COMMITTER_EMAIL", "test@test.com") + monkeypatch.setenv("TZ", "UTC") + + yield tmp_path finally: - os.chdir(original_cwd) \ No newline at end of file + os.chdir(old_cwd) + +def test_pit_commit_tree(git_and_pit_repo_for_commit_tree: Path): + result = runner.invoke(app, ["write-tree"]) + assert result.exit_code == 0, f"Pit write-tree failed: {result.stderr}" + tree_oid = result.stdout.strip() + assert len(tree_oid) == 40, "Tree OID should be 40 characters SHA-1" + + parent_sha = subprocess.run( + ["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + + result = runner.invoke(app, ["commit-tree", tree_oid, "-p", parent_sha, "-m", "commit-tree test"]) + assert result.exit_code == 0, f"Pit commit-tree failed: {result.stderr}" + commit_oid = result.stdout.strip() + assert len(commit_oid) == 40, "Commit OID should be 40 characters SHA-1" + + git_commit_oid = subprocess.run( + ["git", "rev-parse", commit_oid], capture_output=True, text=True, check=True + ).stdout.strip() + assert commit_oid == git_commit_oid, "Commit OID not found by git rev-parse"