diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..2770e29 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,46 @@ +name: Tests Python + +on: + push: + branches: ["**"] + +jobs: + tests: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + # 1) Récupération du code + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure Git identity + run: | + git config --global user.name "CI Bot" + git config --global user.email "ci@example.com" + + # 2) Installation de Python + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11.0" + + # 3) Mettre en cache le dossier pip (accélère les exécutions) + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml', 'setup.cfg', 'setup.py') }} + restore-keys: | + ${{ runner.os }}-pip- + + # 4) Installer les dépendances + - name: Install dependencies + run: pip install -e . + + # 5) Lancer les tests + - name: Run pytest + run: pytest \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7568683 --- /dev/null +++ b/.gitignore @@ -0,0 +1,196 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore +.DS_Store diff --git a/README.md b/README.md index 36e7bc8..ca8a057 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,24 @@ You will implement a subset of Git commands, both low-level (plumbing) and user- ## 🛠 Plumbing Commands -### `git hash-object ` +### `git hash-object [-w] ` + - **Creates a blob** object from file content and writes its SHA-1 to stdout. - ❌ Reject directories or missing files. ### `git cat-file -t|-p ` + - `-t`: Print object type. - `-p`: Pretty-print blob/tree/commit content. - ❌ Reject invalid OIDs or missing options. ### `git write-tree` + - Create a tree object from the staging area. - Writes SHA-1 of the tree to stdout. ### `git commit-tree -m "msg" [-p ]` + - Creates a commit object pointing to a tree (and parent commit if any) and writes its oid to stdout - Requires `-m` message. - ❌ No annotated tags. @@ -33,48 +37,60 @@ You will implement a subset of Git commands, both low-level (plumbing) and user- ## 🧑‍💻 Porcelain Commands ### `git init []` + - Initializes a Git repository in the given directory. - Create .git/objects, .git/refs/heads, HEAD, and minimal config ### `git add …` + - Adds files to the staging area (not directories). - ❌ No `-p`, no wildcards. ### `git rm …` + - Removes a file from working directory and index. ### `git commit -m "msg"` + - Runs `write-tree`, creates a commit with HEAD as parent. - ❌ No editor or message prompt. ### `git status` + - Shows staged and unstaged changes. ### `git checkout [-b] ` + - Switch to existing commit or branch. - `-b ` creates a new branch. - Change HEAD, update working dir, check for conflicts ### `git reset [--soft|--mixed|--hard] ` + - `--soft`: move HEAD - `--mixed`: + reset index - `--hard`: + reset working directory - ❌ No file-specific reset. ### `git log` + - Print commit history from HEAD (one-line summary ok). ### `git ls-files` + - List all files in the index. ### `git ls-tree ` + - List contents of a tree object. ### `git rev-parse ` + - Convert ref/branch/HEAD into SHA-1. - ❌ No complex selectors ### `git show-ref` + - List all refs and their hashes. --- @@ -82,6 +98,7 @@ You will implement a subset of Git commands, both low-level (plumbing) and user- ## 🧠 Advanced Feature: Merge Support ### `git merge ` + - Perform 3-way merge and create a merge commit with 2 parents. - On conflict: insert `<<<<<<<`, `=======`, `>>>>>>>` markers into file(s). - ❌ No rebase, squash, or fast-forward-only merges. diff --git a/git_scratch/__init__.py b/git_scratch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/git_scratch/commands/__init__.py b/git_scratch/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/git_scratch/commands/add.py b/git_scratch/commands/add.py new file mode 100644 index 0000000..490907b --- /dev/null +++ b/git_scratch/commands/add.py @@ -0,0 +1,70 @@ +import os +import typer +import hashlib +import zlib +from git_scratch.utils.index_utils import load_index, save_index, compute_mode +from git_scratch.utils.gitignore_utils import load_gitignore_spec, is_ignored + +app = typer.Typer() + +def add_file_to_index(file_path: str): + with open(file_path, "rb") as f: + content = f.read() + + header = f"blob {len(content)}\0".encode() + full_data = header + content + oid = hashlib.sha1(full_data).hexdigest() + + obj_dir = os.path.join(".git", "objects", oid[:2]) + obj_path = os.path.join(obj_dir, oid[2:]) + os.makedirs(obj_dir, exist_ok=True) + with open(obj_path, "wb") as f: + f.write(zlib.compress(full_data)) + + index = load_index() + rel_path = os.path.relpath(file_path) + mode = compute_mode(file_path) + + entry = { + "mode": mode, + "oid": oid, + "path": rel_path + } + + index = [e for e in index if e["path"] != rel_path] + index.append(entry) + + save_index(index) + typer.echo(f"{rel_path} added to index with OID {oid} and mode {mode}") + +@app.command() +def add(file_path: str = typer.Argument(..., help="Path to file or directory to add.")): + """ + Adds file(s) to the staging area (.git/index.json), ignoring .gitignore files. + """ + if not os.path.exists(file_path): + typer.secho(f"Error: {file_path} does not exist.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + spec = load_gitignore_spec() + + if os.path.isfile(file_path): + rel_path = os.path.relpath(file_path) + if not is_ignored(rel_path, spec): + add_file_to_index(file_path) + elif os.path.isdir(file_path): + for root, _, files in os.walk(file_path): + if ".git" in root: + continue + for name in files: + full_path = os.path.join(root, name) + rel_path = os.path.relpath(full_path) + if is_ignored(rel_path, spec): + continue + add_file_to_index(full_path) + else: + typer.secho("Unsupported file type.", fg=typer.colors.RED) + raise typer.Exit(code=1) + +if __name__ == "__main__": + app() diff --git a/git_scratch/commands/cat_file.py b/git_scratch/commands/cat_file.py new file mode 100644 index 0000000..ff6a810 --- /dev/null +++ b/git_scratch/commands/cat_file.py @@ -0,0 +1,53 @@ +import typer +from git_scratch.utils.read_object import read_object + +def error(msg: str): + typer.secho(f"Error: {msg}", fg=typer.colors.RED) + raise typer.Exit(code=1) + +def pretty_print_tree(content: bytes): + i = 0 + while i < len(content): + mode_end = content.find(b' ', i) + name_end = content.find(b'\x00', mode_end) + if mode_end == -1 or name_end == -1: + error("Invalid tree object format.") + + mode = content[i:mode_end].decode() + name = content[mode_end + 1:name_end].decode() + sha = content[name_end + 1:name_end + 21].hex() + type_ = "tree" if mode.startswith("40000") else "blob" + + typer.echo(f"{mode} {type_} {sha}\t{name}") + i = name_end + 21 + +def cat_file( + oid: str = typer.Argument(..., help="SHA-1 object ID to inspect."), + type_opt: bool = typer.Option(False, "-t", help="Show the type of the object."), + pretty: bool = typer.Option(False, "-p", help="Pretty-print the object’s content.") +): + """ + Show information about a Git object by its OID. + """ + if not (type_opt or pretty): + error("You must specify either -t or -p.") + + if len(oid) != 40 or not all(c in "0123456789abcdef" for c in oid.lower()): + error(f"Invalid OID format: {oid}") + + try: + obj_type, content = read_object(oid) + except FileNotFoundError: + error(f"Object {oid} not found.") + except Exception: + error("Failed to read Git object.") + + if type_opt: + typer.echo(obj_type) + elif pretty: + if obj_type in {"blob", "commit"}: + typer.echo(content.decode(errors="replace")) + elif obj_type == "tree": + pretty_print_tree(content) + else: + error(f"Pretty-print not supported for object type '{obj_type}'.") diff --git a/git_scratch/commands/commit_tree.py b/git_scratch/commands/commit_tree.py new file mode 100644 index 0000000..2e2dd52 --- /dev/null +++ b/git_scratch/commands/commit_tree.py @@ -0,0 +1,26 @@ +import typer +from git_scratch.utils.commit import build_commit_object + +def commit_tree( + tree_oid: str = typer.Argument(..., help="OID of the tree object."), + message: str = typer.Option(..., "-m", help="Commit message."), + parent: str = typer.Option(None, "-p", help="OID of the parent commit (optional).") +): + """ + Create a commit object pointing to a tree and optional parent commit. + """ + try: + oid = build_commit_object(tree_oid=tree_oid, message=message, parent_oid=parent) + typer.echo(oid) + except ValueError as e: + + typer.secho(f"Error: {e}\n\n" + "Please 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 the identity only in 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) \ No newline at end of file diff --git a/git_scratch/commands/hash_object.py b/git_scratch/commands/hash_object.py new file mode 100644 index 0000000..06129da --- /dev/null +++ b/git_scratch/commands/hash_object.py @@ -0,0 +1,25 @@ +import os +import typer +from git_scratch.utils.object import write_object +from git_scratch.utils.hash import compute_blob_hash + + +def hash_object( + file_path: str = typer.Argument(..., help="Path to the file to hash."), + write: bool = typer.Option(False, "--write", "-w", help="Store object in .git/objects.") +): + """ + Computes SHA-1 hash of a file's contents and optionally writes it as a Git blob. + """ + if not os.path.isfile(file_path): + typer.secho(f"Error: {file_path} is not a valid file.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + with open(file_path, 'rb') as f: + content = f.read() + + oid, _ = compute_blob_hash(content) + if write: + write_object(content, "blob") + + typer.echo(oid) diff --git a/git_scratch/commands/init.py b/git_scratch/commands/init.py new file mode 100644 index 0000000..63d3e71 --- /dev/null +++ b/git_scratch/commands/init.py @@ -0,0 +1,41 @@ +import os +import typer + + +def init( + folder: str = typer.Argument(None, help="Path to the folder where to initialize the repository") +): + """ + Initialize a new, empty pit repository. + """ + # os.getcwd() = current path + target_dir = os.path.abspath(folder) if folder else os.getcwd() + git_dir = os.path.join(target_dir, ".git") + + if os.path.exists(git_dir): + typer.secho("Reinitialized existing Pit repository in {}/.git/".format(target_dir), fg=typer.colors.YELLOW) + raise typer.Exit() + + # Create basic Git structure + os.makedirs(os.path.join(git_dir, "objects", "info"), exist_ok=True) + os.makedirs(os.path.join(git_dir, "objects", "pack"), exist_ok=True) + os.makedirs(os.path.join(git_dir, "refs", "heads"), exist_ok=True) + os.makedirs(os.path.join(git_dir, "refs", "tags"), exist_ok=True) + os.makedirs(os.path.join(git_dir, "hooks"), exist_ok=True) + os.makedirs(os.path.join(git_dir, "info"), exist_ok=True) + + # Create essential files + with open(os.path.join(git_dir, "HEAD"), "w") as f: + f.write("ref: refs/heads/master\n") + with open(os.path.join(git_dir, "config"), "w") as f: + f.write( + "[core]\n" + "\trepositoryformatversion = 0\n" + "\tfilemode = true\n" + "\tbare = false\n" + "\tlogallrefupdates = true\n" + ) + with open(os.path.join(git_dir, "description"), "w") as f: + f.write("Unnamed pit repository; edit this file 'description' to name the repository.\n") + + typer.secho("Initialized empty Pit repository in {}/.git/".format(target_dir), fg=typer.colors.GREEN) diff --git a/git_scratch/commands/log.py b/git_scratch/commands/log.py new file mode 100644 index 0000000..86131ff --- /dev/null +++ b/git_scratch/commands/log.py @@ -0,0 +1,102 @@ +import typer +import os +import time +from git_scratch.utils.read_object import read_object + +def parse_commit(content: bytes) -> dict: + """ + Parse un objet commit et retourne ses métadonnées. + """ + lines = content.decode().split("\n") + commit_data = { + "tree": None, + "parent": None, + "author": None, + "committer": None, + "message": "" + } + + i = 0 + while i < len(lines): + line = lines[i] + if line.startswith("tree "): + commit_data["tree"] = line.split(" ")[1] + elif line.startswith("parent "): + commit_data["parent"] = line.split(" ")[1] + elif line.startswith("author "): + commit_data["author"] = line[7:] + elif line.startswith("committer "): + commit_data["committer"] = line[10:] + elif line == "": + # Le message commence après la ligne vide + commit_data["message"] = "\n".join(lines[i+1:]).strip() + break + i += 1 + + return commit_data + + +def format_git_date(timestamp: str, tz_offset: str) -> str: + """ + Convertit un timestamp UNIX + offset en format git log. + """ + t = time.localtime(int(timestamp)) + # Format identique à `git log` + return time.strftime("%a %b %d %H:%M:%S %Y", t) + f" {tz_offset}" + + +def log(): + """ + Réimplémente `git log` en lisant les commits dans .git/objects. + """ + try: + head_path = os.path.join(".git", "HEAD") + with open(head_path) as f: + head_ref = f.read().strip() + except FileNotFoundError: + typer.secho(f"File not found: {head_path}", fg=typer.colors.RED) + return + + if head_ref.startswith("ref:"): + ref_path = os.path.join(".git", head_ref[5:]) + try: + with open(ref_path) as f: + oid = f.read().strip() + except FileNotFoundError: + typer.secho(f"File not found: {ref_path}", fg=typer.colors.RED) + return + else: + oid = head_ref + + if not oid: + typer.secho("No commits found or repository not initialized.", fg=typer.colors.RED) + return + + while oid: + try: + obj_type, content = read_object(oid) + except FileNotFoundError: + typer.secho(f"Object {oid} not found in .git/objects", fg=typer.colors.RED) + return + + if obj_type != "commit": + typer.secho(f"Expected commit, got {obj_type}", fg=typer.colors.RED) + return + + commit = parse_commit(content) + + # Récupère l'auteur + timestamp + author_parts = commit["author"].rsplit(" ", 2) + author_name = author_parts[0] + timestamp = author_parts[1] + tz_offset = author_parts[2] + + date_str = format_git_date(timestamp, tz_offset) + + # Format identique à git log + typer.echo(f"commit {oid}") + typer.echo(f"Author: {author_name}") + typer.echo(f"Date: {date_str}\n") + typer.echo(f" {commit['message']}\n") + + oid = commit["parent"] diff --git a/git_scratch/commands/ls_files.py b/git_scratch/commands/ls_files.py new file mode 100644 index 0000000..a06a6a8 --- /dev/null +++ b/git_scratch/commands/ls_files.py @@ -0,0 +1,26 @@ + +import os +import json +import typer + +INDEX_PATH = os.path.join(".git", "index.json") + + +def ls_files(): + """ + List all staged files from the index. + """ + if not os.path.exists(INDEX_PATH): + typer.secho("Error: index file not found.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + with open(INDEX_PATH, "r") as f: + try: + entries = json.load(f) + except json.JSONDecodeError: + typer.secho("Error: index file is not valid JSON.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + for entry in entries: + typer.echo(entry["path"]) + diff --git a/git_scratch/commands/ls_tree.py b/git_scratch/commands/ls_tree.py new file mode 100644 index 0000000..c60b032 --- /dev/null +++ b/git_scratch/commands/ls_tree.py @@ -0,0 +1,39 @@ + +import typer +from git_scratch.utils.read_object import read_object + +def ls_tree( + oid: str = typer.Argument(..., help="OID of the tree object") +): + """ + List the contents of a Git tree object. + """ + try: + obj_type, content = read_object(oid) + except FileNotFoundError: + typer.secho(f"Error: Object {oid} not found.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if obj_type != "tree": + typer.secho(f"Error: Object {oid} is not a tree.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + i = 0 + while i < len(content): + # Mode (e.g., 100644 or 40000) + end_mode = content.index(b' ', i) + mode = content[i:end_mode].decode() + i = end_mode + 1 + + # Filename + end_path = content.index(b'\x00', i) + name = content[i:end_path].decode() + i = end_path + 1 + + # Raw SHA (20 bytes) + oid_raw = content[i:i+20] + oid_hex = oid_raw.hex() + i += 20 + + obj_type = "blob" if mode.startswith("10") else "tree" + typer.echo(f"{mode} {obj_type} {oid_hex}\t{name}") diff --git a/git_scratch/commands/reset.py b/git_scratch/commands/reset.py new file mode 100644 index 0000000..e13e16a --- /dev/null +++ b/git_scratch/commands/reset.py @@ -0,0 +1,100 @@ +import os +from pathlib import Path +from typing import List + +import typer + +from git_scratch.utils.read_object import read_object +from git_scratch.utils.porcelain_commit import update_head_to_commit +from git_scratch.utils.index_utils import save_index +from git_scratch.utils.tree_walker import entries_from_tree + +_HEX = set("0123456789abcdef") + + +def _resolve_ref(ref: str) -> str: + """Return the full 40‑char SHA for *ref* (HEAD, branch, tag or raw SHA).""" + # Raw SHA + if len(ref) == 40 and all(c in _HEX for c in ref.lower()): + return ref.lower() + + # HEAD (symbolic ou détaché) + if ref.upper() == "HEAD": + head_path = Path(".git") / "HEAD" + if not head_path.is_file(): + typer.secho("Error: HEAD not found.", fg=typer.colors.RED) + raise typer.Exit(code=1) + head_content = head_path.read_text().strip() + if head_content.startswith("ref: "): + ref_path = Path(".git") / head_content[5:] + return ref_path.read_text().strip() + return head_content # SHA détaché + + # Recherche dans refs/heads et refs/tags + for cat in ("heads", "tags"): + p = Path(".git") / "refs" / cat / ref + if p.is_file(): + return p.read_text().strip() + + typer.secho(f"Error: unknown reference '{ref}'.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + +def _get_tree_oid(commit_oid: str) -> str: + obj_type, content = read_object(commit_oid) + if obj_type != "commit": + typer.secho("Error: target OID is not a commit.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + first_line = content.decode(errors="replace").splitlines()[0] + if not first_line.startswith("tree "): + typer.secho("Error: malformed commit object.", fg=typer.colors.RED) + raise typer.Exit(code=1) + return first_line.split()[1] + + +def _checkout_tree(tree_oid: str, dest_dir: str = ".") -> None: + """Overwrite *dest_dir* with the blobs of *tree_oid* (tracked files only).""" + for entry in entries_from_tree(tree_oid): + file_path = Path(dest_dir) / entry["path"] + file_path.parent.mkdir(parents=True, exist_ok=True) + _, blob_content = read_object(entry["oid"]) + with open(file_path, "wb") as f: + f.write(blob_content) + +def reset( + ref: str = typer.Argument(..., help="Commit (SHA / ref) to reset to."), + soft: bool = typer.Option(False, "--soft", help="Move HEAD only."), + hard: bool = typer.Option(False, "--hard", help="Reset index and working tree."), + mixed: bool = typer.Option(False, "--mixed", help="Reset index only."), +): + """Re‑implémentation minimaliste de `git reset`.""" + if soft and hard: + typer.secho("Error: choose only one of --soft / --hard.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if soft: + mode = "soft" + elif mixed: + mode = "mixed" + elif hard: + mode = "hard" + else: + mode = "mixed" + + # 1. Résolution de la référence + target_oid = _resolve_ref(ref) + + # 2. Mise à jour de HEAD + update_head_to_commit(target_oid) + + # 3. Index + tree_oid = _get_tree_oid(target_oid) + if mode in {"mixed", "hard"}: + save_index(entries_from_tree(tree_oid)) + + # 4. Working directory + if mode == "hard": + _checkout_tree(tree_oid) + + typer.echo(f"HEAD is now at {target_oid[:7]} ({mode})") \ No newline at end of file diff --git a/git_scratch/commands/rev_parse.py b/git_scratch/commands/rev_parse.py new file mode 100644 index 0000000..8d7fad3 --- /dev/null +++ b/git_scratch/commands/rev_parse.py @@ -0,0 +1,99 @@ +import os +import re +import pathlib +import typer + + +def rev_parse( + ref: str = typer.Argument(..., help="Reference to resolve (branch, tag, SHA, HEAD)"), +): + """Resolve *ref* to its full 40‑character SHA‑1 and print it. + + Handles: + 1. Full SHA (40 hex) + 2. Abbreviated SHA (4‑39 hex) + 3. refs/heads/ and refs/tags/ + 4. HEAD (symbolic or detached) + 5. packed‑refs lookup + """ + # check if current directory has a Git repository + git_dir = pathlib.Path(".git") + if not git_dir.is_dir(): + typer.secho("Error: .git directory not found.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + # regex for check if a string is a valid hexadecimal + HEX_RE = re.compile(r"^[0-9a-fA-F]+$") + + # check if an object exists + def object_exists(sha: str) -> bool: + return (git_dir / "objects" / sha[:2] / sha[2:]).is_file() + + # full SHA + if len(ref) == 40 and HEX_RE.fullmatch(ref): + if object_exists(ref): + typer.echo(ref.lower()) + return + typer.secho(f"Error: unknown revision '{ref}'", fg=typer.colors.RED) + raise typer.Exit(code=1) + + # abbreviated SHA + if 4 <= len(ref) < 40 and HEX_RE.fullmatch(ref): + matches = [] + obj_base = git_dir / "objects" + for sub in obj_base.iterdir(): + if sub.is_dir() and len(sub.name) == 2 and sub.name not in ("info", "pack"): + for obj in sub.iterdir(): + if obj.is_file(): + sha = sub.name + obj.name + if sha.startswith(ref.lower()): + matches.append(sha) + if len(matches) == 1: + typer.echo(matches[0]) + return + if len(matches) > 1: + typer.secho(f"Error: ambiguous revision '{ref}'", fg=typer.colors.RED) + else: + typer.secho(f"Error: unknown revision '{ref}'", fg=typer.colors.RED) + raise typer.Exit(code=1) + + # refs/heads or refs/tags (branches or tags) + for subdir in ("refs/heads", "refs/tags"): + path = git_dir / subdir / ref + if path.is_file(): + sha = path.read_text().strip() + if sha: + typer.echo(sha.lower()) + return + + # HEAD + if ref.upper() == "HEAD": + head_path = git_dir / "HEAD" + if head_path.is_file(): + content = head_path.read_text().strip() + if content.startswith("ref:"): + target = git_dir / content[5:].strip() + if target.is_file(): + typer.echo(target.read_text().strip().lower()) + return + elif len(content) == 40 and HEX_RE.fullmatch(content): + typer.echo(content.lower()) + return + typer.secho("Error: HEAD not found or invalid.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + # packed‑refs + packed = git_dir / "packed-refs" + if packed.is_file(): + with packed.open() as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or line.startswith("^"): + continue + sha, refname = line.split(" ", 1) + if refname in (f"refs/heads/{ref}", f"refs/tags/{ref}"): + typer.echo(sha.lower()) + return + + typer.secho(f"Error: unknown revision '{ref}'", fg=typer.colors.RED) + raise typer.Exit(code=1) \ No newline at end of file diff --git a/git_scratch/commands/rmfile.py b/git_scratch/commands/rmfile.py new file mode 100644 index 0000000..bc7ea99 --- /dev/null +++ b/git_scratch/commands/rmfile.py @@ -0,0 +1,33 @@ + +import os +import typer +from git_scratch.utils.index_utils import load_index, save_index + +def rmfile(file_path: str): + """ + Remove a file from the working directory and from the index. + """ + index = load_index() + filename = os.path.relpath(file_path) + + # --- Étape 1 : suppression du fichier dans le working directory --- + if not os.path.isfile(file_path): + typer.echo(f"File '{file_path}' does not exist in working directory.") + else: + try: + os.remove(file_path) + typer.echo(f"File '{file_path}' removed from working directory.") + except Exception as e: + typer.echo(f"Error while removing file: {e}") + raise typer.Exit(code=1) + + # --- Étape 2 : suppression dans l’index --- + new_index = [entry for entry in index if entry["path"] != filename] + removed = len(new_index) != len(index) + + if removed: + save_index(new_index) + typer.echo(f"File '{filename}' removed from staging area.") + else: + typer.echo(f"File '{filename}' was not in the index.") + diff --git a/git_scratch/commands/show_ref.py b/git_scratch/commands/show_ref.py new file mode 100644 index 0000000..277f5fa --- /dev/null +++ b/git_scratch/commands/show_ref.py @@ -0,0 +1,42 @@ +import os +import typer + +def resolve_ref(path): + with open(path, "r") as f: + content = f.read().strip() + if content.startswith("ref: "): + target = os.path.join(".git", content[5:]) + return resolve_ref(target) + return content + +def show_ref(): + """ + List references in a local repository + """ + refs = {} + + # Refs dans .git/refs + refs_dir = os.path.join(".git", "refs") + for root, _, files in os.walk(refs_dir): + for file in files: + ref_path = os.path.join(root, file) + rel_path = os.path.relpath(ref_path, ".git").replace(os.sep, "/") + oid = resolve_ref(ref_path) + refs[rel_path] = oid + + # Refs dans packed-refs + packed_refs_path = os.path.join(".git", "packed-refs") + if os.path.exists(packed_refs_path): + with open(packed_refs_path) as f: + for line in f: + if line.startswith("#") or line.strip() == "": + continue + if " " in line: + oid, refname = line.strip().split(" ", 1) + # N’ajouter que si pas déjà présent + if refname not in refs: + refs[refname] = oid + + # Affichage trié + for ref in sorted(refs): + typer.echo(f"{refs[ref]} {ref}") diff --git a/git_scratch/commands/status.py b/git_scratch/commands/status.py new file mode 100644 index 0000000..a2e7a50 --- /dev/null +++ b/git_scratch/commands/status.py @@ -0,0 +1,128 @@ +import hashlib +import os +import time +from pathlib import Path +from typing import List, Tuple + +import pathspec +import typer + +from git_scratch.utils.gitignore_utils import is_ignored, load_gitignore_spec +from git_scratch.utils.index_utils import load_index +from git_scratch.utils.read_object import read_object +from git_scratch.utils.tree_walker import entries_from_tree + + +def get_head_tree_oid() -> str: + """ + Lit la référence HEAD et renvoie l'OID du tree du dernier commit. + """ + head_content = Path('.git/HEAD').read_text().strip() + if head_content.startswith('ref: '): + ref_path = Path('.git') / head_content[5:] + commit_oid = ref_path.read_text().strip() + else: + commit_oid = head_content + + obj_type, commit_data = read_object(commit_oid) + if obj_type != 'commit': + raise ValueError(f"OID {commit_oid} is not a commit (got {obj_type})") + + for line in commit_data.split(b'\n'): + if line.startswith(b'tree '): + return line.split()[1].decode() + raise ValueError('Missing tree entry in commit') + + +def git_hash_object(file_path: Path) -> str: + """ + Calcule le SHA-1 Git d'un fichier blob. + """ + data = file_path.read_bytes() + header = f"blob {len(data)}\0".encode() + return hashlib.sha1(header + data).hexdigest() + + +def list_project_files() -> List[str]: + """ + Parcourt récursivement le projet et renvoie les chemins de tous fichiers hors .git. + """ + return [str(p) for p in Path('.') + .rglob('*') + if p.is_file() and '.git' not in p.parts] + + +def print_section(title: str, entries: List[Tuple[str, str]], color: str) -> None: + """ + Affiche une section de status avec un titre et des paires (action, chemin). + """ + typer.echo(typer.style(title, fg=color)) + for action, path in entries: + typer.echo(typer.style(f' {action:>9} {path}', fg=color)) + typer.echo() + + +def status() -> None: + """ + Implémentation de la commande `pit status`: + - Compare HEAD vs index (staged) + - Compare index vs working directory (unstaged) + - Liste les fichiers non suivis + """ + start = time.time() + + head_tree_oid = get_head_tree_oid() + commit_entries = {e['path']: e['oid'] for e in entries_from_tree(head_tree_oid)} + + index_list = load_index() + print("==== INDEX ENTRIES ====") + for item in index_list: + print(item["path"]) + print("=======================") + index_entries = {item['path']: item['oid'] for item in index_list} + + spec = load_gitignore_spec() + + staged: List[Tuple[str, str]] = [] + unstaged: List[Tuple[str, str]] = [] + untracked: List[Tuple[str, str]] = [] + + for fpath_str in list_project_files(): + if spec.match_file(fpath_str) or is_ignored(fpath_str, spec): + continue + + in_index = fpath_str in index_entries + in_commit = fpath_str in commit_entries + + if in_index: + oid_idx = index_entries[fpath_str] + if not in_commit: + staged.append(('new file', fpath_str)) + elif commit_entries[fpath_str] != oid_idx: + staged.append(('modified', fpath_str)) + else: + if not in_commit: + untracked.append(('?', fpath_str)) + + for path in commit_entries: + if path not in index_entries: + staged.append(('deleted', path)) + + for path, oid in index_entries.items(): + fpath = Path(path) + if not fpath.exists(): + unstaged.append(('deleted', path)) + else: + if git_hash_object(fpath) != oid: + unstaged.append(('modified', path)) + + if staged: + print_section('Changes to be committed:\n (use "pit reset ..." to unstage)\n', staged, typer.colors.GREEN) + if unstaged: + print_section('Changes not staged for commit:\n (use "pit add ..." to update what will be committed)\n (use "pit restore ..." to discard changes in working directory)\n', unstaged, typer.colors.RED) + if untracked: + print_section('Untracked files:\n (use "pit add ..." to include in what will be committed)\n', untracked, typer.colors.RED) + if not (staged or unstaged or untracked): + typer.echo("Rien à valider, l'arbre et l'index sont à jour.") + + typer.echo(f"Temps total: {time.time() - start:.3f}s") \ No newline at end of file diff --git a/git_scratch/commands/write_tree.py b/git_scratch/commands/write_tree.py new file mode 100644 index 0000000..d493c73 --- /dev/null +++ b/git_scratch/commands/write_tree.py @@ -0,0 +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 + +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) + 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 new file mode 100644 index 0000000..21905a9 --- /dev/null +++ b/git_scratch/main.py @@ -0,0 +1,33 @@ +import typer +from git_scratch.commands.ls_files import ls_files +from git_scratch.commands.hash_object import hash_object +from git_scratch.commands.cat_file import cat_file +from git_scratch.commands.show_ref import show_ref +from git_scratch.commands.add import add +from git_scratch.commands.write_tree import write_tree +from git_scratch.commands.rmfile import rmfile +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.status import status + +from git_scratch.commands.init import init + +app = typer.Typer(help="Git from scratch in Python.") + +app.command("hash-object")(hash_object) +app.command("cat-file")(cat_file) +app.command("add")(add) +app.command("write-tree")(write_tree) +app.command("rm")(rmfile) +app.command("init")(init) +app.command("ls-files")(ls_files) +app.command("rev-parse")(rev_parse) +app.command("show-ref")(show_ref) +app.command("ls-tree")(ls_tree) +app.command("commit-tree")(commit_tree) +app.command("status")(status) + + +if __name__ == "__main__": + app() diff --git a/git_scratch/utils/__init__.py b/git_scratch/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/git_scratch/utils/commit.py b/git_scratch/utils/commit.py new file mode 100644 index 0000000..4d1d35c --- /dev/null +++ b/git_scratch/utils/commit.py @@ -0,0 +1,38 @@ +from typing import Optional +from git_scratch.utils.identity import get_author_identity, get_timestamp_info +from git_scratch.utils.object import write_object + + +def build_commit_object( + tree_oid: str, + message: str, + parent_oid: Optional[str] = None, +) -> 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() + + 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("") + lines.append(message) + + commit_content = "\n".join(lines).encode() + oid = write_object(commit_content, "commit") + + return oid diff --git a/git_scratch/utils/find_git_dir.py b/git_scratch/utils/find_git_dir.py new file mode 100644 index 0000000..f71864e --- /dev/null +++ b/git_scratch/utils/find_git_dir.py @@ -0,0 +1,18 @@ +from pathlib import Path + +def find_git_dir(start_path: Path = Path.cwd()) -> Path: + """ + Remonte l'arborescence depuis `start_path` jusqu'à trouver un dossier `.git`. + Renvoie le chemin absolu vers `.git`, ou lève une FileNotFoundError si non trouvé. + """ + path = start_path.resolve() + while True: + git_path = path / ".git" + if git_path.is_dir(): + return git_path + + parent = path.parent + if parent == path: + # On est à la racine du système + raise FileNotFoundError("No .git directory found.") + path = parent \ No newline at end of file diff --git a/git_scratch/utils/gitignore_utils.py b/git_scratch/utils/gitignore_utils.py new file mode 100644 index 0000000..83bb63d --- /dev/null +++ b/git_scratch/utils/gitignore_utils.py @@ -0,0 +1,21 @@ +from pathlib import Path +import pathspec + +def load_gitignore_spec(): + """ + Charge les règles de .gitignore en utilisant pathspec. + Retourne un objet PathSpec utilisable pour ignorer les fichiers. + """ + gitignore_path = Path(".gitignore") + if gitignore_path.exists(): + with open(gitignore_path, "r") as f: + return pathspec.PathSpec.from_lines("gitwildmatch", f) + return pathspec.PathSpec.from_lines("gitwildmatch", []) + +def is_ignored(path: str, spec=None) -> bool: + """ + Vérifie si un chemin correspond à une règle de .gitignore. + """ + if spec is None: + spec = load_gitignore_spec() + return spec.match_file(path) \ No newline at end of file diff --git a/git_scratch/utils/hash.py b/git_scratch/utils/hash.py new file mode 100644 index 0000000..25c89a7 --- /dev/null +++ b/git_scratch/utils/hash.py @@ -0,0 +1,8 @@ + +import hashlib + +def compute_blob_hash(content: bytes) -> tuple[str, bytes]: + header = f"blob {len(content)}\0".encode() + full_data = header + content + oid = hashlib.sha1(full_data).hexdigest() + return oid, full_data diff --git a/git_scratch/utils/identity.py b/git_scratch/utils/identity.py new file mode 100644 index 0000000..0a83e55 --- /dev/null +++ b/git_scratch/utils/identity.py @@ -0,0 +1,57 @@ +# git_scratch/pit_identity.py +import os +import configparser +from datetime import datetime +from typing import Tuple +import typer +from pathlib import Path + +def get_author_identity() -> Tuple[str, str]: + """ + Retrieve the author's identity following Git's resolution order: + 1. Environment variables + 2. Repository config (.git/config) + 3. Global config (~/.gitconfig) + + Raises: + ValueError: If author name or email cannot be determined. + + Returns: + Tuple[str, str]: The author's name and email. + """ + name = os.getenv("GIT_AUTHOR_NAME") or os.getenv("GIT_COMMITTER_NAME") + email = os.getenv("GIT_AUTHOR_EMAIL") or os.getenv("GIT_COMMITTER_EMAIL") + + if not name or not email: + config = configparser.ConfigParser() + paths = [ + Path(".git") / "config", # Local project config + Path.home() / ".gitconfig" # Global config + ] + for path in paths: + if path.exists(): + try: + config.read(path) + if config.has_section("user"): + name = name or config.get("user", "name", fallback=None) + email = email or config.get("user", "email", fallback=None) + except configparser.Error as e: + print(f"[warn] Failed to parse config at {path}: {e}") + + if not name or not email: + raise ValueError("Author identity unknown. Please configure it using 'git config'.") + + return name, email + + +def get_timestamp_info() -> 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"). + """ + now = datetime.now().astimezone() + timestamp = int(now.timestamp()) + timezone = now.strftime('%z') + return timestamp, timezone diff --git a/git_scratch/utils/index_utils.py b/git_scratch/utils/index_utils.py new file mode 100644 index 0000000..260a6f8 --- /dev/null +++ b/git_scratch/utils/index_utils.py @@ -0,0 +1,44 @@ + +import os +import json +import stat +from pathlib import Path + +def get_index_path(): + """ + Returns the path to the .git/index.json file based on the current directory. + """ + return Path(os.getcwd()) / ".git" / "index.json" + +def load_index(): + """ + Load the index.json file from .git/, or return an empty list if not found. + """ + index_path = get_index_path() + if not index_path.exists(): + return [] + + with open(index_path, "r") as f: + return json.load(f) + +def save_index(index): + """ + Save the given index to .git/index.json. + """ + index_path = get_index_path() + index_path.parent.mkdir(parents=True, exist_ok=True) + with open(index_path, "w") as f: + json.dump(index, f, indent=2) + +def compute_mode(file_path): + """ + Compute the file mode as a string (e.g. '100644', '100755', '120000'). + """ + st = os.stat(file_path) + if stat.S_ISLNK(st.st_mode): + return "120000" + elif st.st_mode & stat.S_IXUSR: + return "100755" + else: + return "100644" + diff --git a/git_scratch/utils/object.py b/git_scratch/utils/object.py new file mode 100644 index 0000000..add46e4 --- /dev/null +++ b/git_scratch/utils/object.py @@ -0,0 +1,33 @@ +import os +import hashlib +import zlib + +def write_object(content: bytes, obj_type: str) -> str: + """ + Write a Git object to the .git/objects directory. + + Args: + content (bytes): Raw content of the object (e.g. file data, commit, tree). + obj_type (str): Type of the object: 'blob', 'tree', or 'commit'. + + Returns: + str: The SHA-1 object ID (OID) of the written object. + """ + if obj_type not in {"blob", "tree", "commit"}: + raise ValueError(f"Invalid object type: {obj_type}") + + header = f"{obj_type} {len(content)}\0".encode() + full_data = header + content + oid = hashlib.sha1(full_data).hexdigest() + + dir_path = os.path.join(".git", "objects", oid[:2]) + file_path = os.path.join(dir_path, oid[2:]) + + if os.path.exists(file_path): + return oid + + os.makedirs(dir_path, exist_ok=True) + with open(file_path, "wb") as f: + f.write(zlib.compress(full_data)) + + return oid \ No newline at end of file diff --git a/git_scratch/utils/porcelain_commit.py b/git_scratch/utils/porcelain_commit.py new file mode 100644 index 0000000..167da8f --- /dev/null +++ b/git_scratch/utils/porcelain_commit.py @@ -0,0 +1,65 @@ +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/read_object.py b/git_scratch/utils/read_object.py new file mode 100644 index 0000000..751f52c --- /dev/null +++ b/git_scratch/utils/read_object.py @@ -0,0 +1,23 @@ +import os +import zlib + +def read_object(oid: str) -> tuple[str, bytes]: + """ + Read and decompress a Git object by its OID. + Returns: + - type (e.g., 'tree', 'blob') + - content (raw bytes after header) + """ + path = os.path.join(".git", "objects", oid[:2], oid[2:]) + if not os.path.exists(path): + raise FileNotFoundError(f"Object {oid} not found.") + + with open(path, "rb") as f: + compressed = f.read() + + data = zlib.decompress(compressed) + header_end = data.index(b"\x00") + header = data[:header_end].decode() + obj_type, _ = header.split() + content = data[header_end + 1:] + return obj_type, content diff --git a/git_scratch/utils/tree.py b/git_scratch/utils/tree.py new file mode 100644 index 0000000..7b420ed --- /dev/null +++ b/git_scratch/utils/tree.py @@ -0,0 +1,36 @@ + +import os +from typing import List, Dict, Tuple +from git_scratch.utils.object import write_object + + +def build_tree(entries: List[Dict], base_path: str = "") -> bytes: + tree_entries: Dict[str, Tuple[str, bytes]] = {} + + for entry in entries: + rel_path = entry["path"] + if not rel_path.startswith(base_path): + continue + + sub_path = rel_path[len(base_path):].lstrip("/") + parts = sub_path.split("/", 1) + + if len(parts) == 1: + mode = entry["mode"] + oid = bytes.fromhex(entry["oid"]) + name = parts[0] + tree_entries[name] = (mode, oid) + else: + dir_name = parts[0] + sub_base = os.path.join(base_path, dir_name) + if dir_name not in tree_entries: + sub_tree = build_tree(entries, sub_base) + sub_oid = write_object(sub_tree, "tree") + tree_entries[dir_name] = ("40000", bytes.fromhex(sub_oid)) + + result = b"" + for name in sorted(tree_entries): + mode, oid = tree_entries[name] + result += f"{mode} {name}".encode() + b"\x00" + oid + + return result \ No newline at end of file diff --git a/git_scratch/utils/tree_walker.py b/git_scratch/utils/tree_walker.py new file mode 100644 index 0000000..3886bb2 --- /dev/null +++ b/git_scratch/utils/tree_walker.py @@ -0,0 +1,28 @@ +from git_scratch.utils.read_object import read_object +from typing import List +import os + +def entries_from_tree(tree_oid: str, base_path: str = "") -> List[dict]: + """Walk *tree_oid* recursively and return index‑style dict entries.""" + entries: List[dict] = [] + obj_type, content = read_object(tree_oid) + if obj_type != "tree": + raise ValueError("Expected tree object") + + i = 0 + while i < len(content): + mode_end = content.find(b" ", i) + name_end = content.find(b"\x00", mode_end) + mode = content[i:mode_end].decode() + name = content[mode_end + 1 : name_end].decode() + oid_bytes = content[name_end + 1 : name_end + 21] + oid = oid_bytes.hex() + i = name_end + 21 + + rel_path = os.path.join(base_path, name) + if mode == "40000": # subtree + entries.extend(entries_from_tree(oid, rel_path)) + else: + entries.append({"path": rel_path, "oid": oid, "mode": mode}) + + return entries \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..576d6c1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "git_scratch" +version = "0.1.0" +description = "Reimplémentation de Git en Python" +dependencies = [ + "typer[all]", + "pytest", + "configparser", + "pathspec" +] + +[project.optional-dependencies] +dev = ["pytest"] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project.scripts] +pit = "git_scratch.main:app" diff --git a/tests/unit/test_add.py b/tests/unit/test_add.py new file mode 100644 index 0000000..331d403 --- /dev/null +++ b/tests/unit/test_add.py @@ -0,0 +1,48 @@ +from typer.testing import CliRunner +import hashlib +import json +import os + +from git_scratch.main import app + +runner = CliRunner() + +def test_add_creates_object_and_index_entry(tmp_path): + + git_dir = tmp_path / ".git" + objects_dir = git_dir / "objects" + git_dir.mkdir() + objects_dir.mkdir() + + file = tmp_path / "test.txt" + content = b"Hello from test_add" + file.write_bytes(content) + + old_cwd = os.getcwd() + os.chdir(tmp_path) + + try: + result = runner.invoke(app, ["add", str(file.name)]) + assert result.exit_code == 0, f"Error: {result.stderr}" + + header = f"blob {len(content)}\0".encode() + full_data = header + content + + expected_oid = hashlib.sha1(full_data).hexdigest() + obj_path = git_dir / "objects" / expected_oid[:2] / expected_oid[2:] + assert obj_path.exists(), " Object was not created" + + index_path = git_dir / "index.json" + assert index_path.exists(), " index.json file is missing" + + with open(index_path) as f: + index = json.load(f) + + assert len(index) == 1, " Index does not contain exactly 1 entry" + entry = index[0] + assert entry["mode"] == "100644", " Incorrect mode" + assert entry["oid"] == expected_oid, " Incorrect OID" + assert entry["path"] == "test.txt", " Incorrect path" + + finally: + os.chdir(old_cwd) diff --git a/tests/unit/test_cat_file.py b/tests/unit/test_cat_file.py new file mode 100644 index 0000000..90b20fa --- /dev/null +++ b/tests/unit/test_cat_file.py @@ -0,0 +1,22 @@ +from typer.testing import CliRunner +import subprocess + +from git_scratch.main import app + +runner = CliRunner() + +def test_cat_file_all_modes(tmp_path): + file = tmp_path / "hello.txt" + file.write_text("Hello from test") + + result_pit_oid = runner.invoke(app, ["hash-object", "-w", str(file)]) + oid = result_pit_oid.stdout.strip() + + for flag in ["-p", "-t"]: + result_git = subprocess.run( + ["git", "cat-file", flag, oid], + capture_output=True, text=True + ) + result_pit = runner.invoke(app, ["cat-file", flag, oid]) + + assert result_git.stdout.strip() == result_pit.stdout.strip() diff --git a/tests/unit/test_commit_tree.py b/tests/unit/test_commit_tree.py new file mode 100644 index 0000000..7c672de --- /dev/null +++ b/tests/unit/test_commit_tree.py @@ -0,0 +1,77 @@ +import os +import zlib +from pathlib import Path +import json +from typer.testing import CliRunner +from git_scratch.main import app + +runner = CliRunner() + +def test_commit_tree_creates_valid_commit_object(tmp_path, monkeypatch): + original_cwd = os.getcwd() + 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 + + finally: + os.chdir(original_cwd) \ No newline at end of file diff --git a/tests/unit/test_hash_object.py b/tests/unit/test_hash_object.py new file mode 100644 index 0000000..d013ac6 --- /dev/null +++ b/tests/unit/test_hash_object.py @@ -0,0 +1,58 @@ + +import subprocess +from git_scratch.main import app +from typer.testing import CliRunner +import zlib +import os + +runner = CliRunner() + +def test_hash_object_matches_git_and_writes_blob(tmp_path): + file = tmp_path / "test.txt" + content = "Hello Git Scratch" + file.write_text(content) + + # Initialiser un dépôt Git dans tmp_path + subprocess.run(["git", "init"], cwd=tmp_path, check=True) + + # 1. Comparaison du hash sans --write + result_git = subprocess.run( + ["git", "hash-object", str(file)], + cwd=tmp_path, + capture_output=True, + text=True, + check=True, + ) + hash_git = result_git.stdout.strip() + + # 🔧 On change manuellement le dossier courant + current_dir = os.getcwd() + os.chdir(tmp_path) + + try: + result_pit = runner.invoke(app, ["hash-object", str(file.name)]) + assert result_pit.exit_code == 0 + hash_pit = result_pit.stdout.strip() + print("Hash Git : ",hash_git, " Hash Pit : ",hash_pit) + assert hash_pit == hash_git, "Le hash généré par pit ne correspond pas à celui de Git" + + # 2. Avec l’option --write + result_write = runner.invoke(app, ["hash-object", str(file.name), "--write"]) + assert result_write.exit_code == 0 + hash_written = result_write.stdout.strip() + + obj_dir = tmp_path / ".git" / "objects" / hash_written[:2] + obj_file = obj_dir / hash_written[2:] + assert obj_file.exists(), f"Le blob compressé {obj_file} n'a pas été écrit" + + with open(obj_file, "rb") as f: + compressed_data = f.read() + full_data = zlib.decompress(compressed_data) + + expected_header = f"blob {len(content)}\0".encode() + expected_blob = expected_header + content.encode() + assert full_data == expected_blob, "Le contenu du blob est incorrect" + + finally: + os.chdir(current_dir) # On revient dans le dossier original à la fin + diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py new file mode 100644 index 0000000..7d82d51 --- /dev/null +++ b/tests/unit/test_init.py @@ -0,0 +1,45 @@ +import os +import pytest +from pathlib import Path +from typer.testing import CliRunner +from git_scratch.main import app +from subprocess import run + +runner = CliRunner() + + +EXPECTED_PATHS = [ + "config", + "description", + "HEAD", + "hooks", + "info", + "objects", + "refs", + "refs/heads", + "refs/tags", +] + +def test_pit_init(tmp_path): + repo_path = tmp_path / "repo" + repo_path.mkdir() + os.chdir(repo_path) + + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0 + + git_result = run(["git", "init"], cwd=repo_path, capture_output=True, text=True) + assert git_result.returncode == 0 + + expected_path = str(repo_path / ".git").replace("\\", "/") + output = git_result.stdout.replace("\\", "/") + + assert "Reinitialized existing Git repository" in output + assert expected_path in output + + git_dir = repo_path / ".git" + assert git_dir.exists() and git_dir.is_dir() + + for relative_path in EXPECTED_PATHS: + full_path = git_dir / relative_path + assert full_path.exists(), f"Missing: {relative_path}" diff --git a/tests/unit/test_log.py b/tests/unit/test_log.py new file mode 100644 index 0000000..a06be19 --- /dev/null +++ b/tests/unit/test_log.py @@ -0,0 +1,64 @@ +import os +import subprocess +from pathlib import Path +from typer.testing import CliRunner +from git_scratch.main import app # adapte selon où est défini ton Typer app + +runner = CliRunner() + +def init_test_repo(tmp_path: Path): + """ + Initialise un repo git temporaire avec 2 commits. + """ + os.chdir(tmp_path) + + # Init repo + subprocess.run(["git", "init"], check=True) + + # Commit 1 + (tmp_path / "file1.txt").write_text("Hello World") + subprocess.run(["git", "add", "file1.txt"], check=True) + subprocess.run( + ["git", "-c", "user.name=Test User", "-c", "user.email=test@example.com", + "commit", "-m", "Initial commit"], + check=True + ) + + # Commit 2 + (tmp_path / "file2.txt").write_text("Another file") + subprocess.run(["git", "add", "file2.txt"], check=True) + subprocess.run( + ["git", "-c", "user.name=Test User", "-c", "user.email=test@example.com", + "commit", "-m", "Second commit"], + check=True + ) + + +def test_pit_log_matches_git_log(tmp_path): + # 1. Créer un repo de test avec deux commits + init_test_repo(tmp_path) + + # 2. Récupère la sortie officielle de git log + git_output = subprocess.run( + ["git", "log", "--no-decorate", "--no-color"], + capture_output=True, + text=True, + check=True, + cwd=tmp_path + ).stdout.strip() + + # 3. Exécute pit log dans le même repo + # On change le cwd pour être dans le repo test + os.chdir(tmp_path) + result = runner.invoke(app, ["log"]) + pit_output = result.stdout.strip() + + # Debug si ça diffère + if git_output != pit_output: + print("\n=== GIT LOG ===") + print(git_output) + print("\n=== PIT LOG ===") + print(pit_output) + + # 4. Vérifie que les deux sorties sont identiques + assert pit_output == git_output diff --git a/tests/unit/test_ls_files.py b/tests/unit/test_ls_files.py new file mode 100644 index 0000000..1d7cc46 --- /dev/null +++ b/tests/unit/test_ls_files.py @@ -0,0 +1,27 @@ + +import json +from typer.testing import CliRunner +from git_scratch.main import app + + +runner = CliRunner() + +def test_ls_files(monkeypatch, tmp_path): + git_dir = tmp_path / ".git" + git_dir.mkdir() + index_path = git_dir / "index.json" + + entries = [ + {"path": "file1.txt", "mode": "100644", "oid": "abc123"}, + {"path": "src/main.py", "mode": "100644", "oid": "def456"}, + ] + + index_path.write_text(json.dumps(entries)) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["ls-files"]) + + assert result.exit_code == 0 + assert "file1.txt" in result.output + assert "src/main.py" in result.output + diff --git a/tests/unit/test_ls_tree.py b/tests/unit/test_ls_tree.py new file mode 100644 index 0000000..a03aae0 --- /dev/null +++ b/tests/unit/test_ls_tree.py @@ -0,0 +1,57 @@ +from typer.testing import CliRunner +from git_scratch.main import app +import subprocess +import os +runner = CliRunner() + + +def make_tree_entry(mode: str, name: str, oid_hex: str) -> bytes: + """ + Construct a correct binary entry for a Git object of type "tree". + """ + mode_bytes = mode.encode() + name_bytes = name.encode() + oid_bytes = bytes.fromhex(oid_hex) + return mode_bytes + b" " + name_bytes + b"\x00" + oid_bytes + + +def test_ls_tree_valid(monkeypatch): + def mock_read_object(oid): + assert oid == "abc123" + entry = make_tree_entry("100644", "file.txt", "0123456789abcdef0123456789abcdef01234567") + return "tree", entry + + monkeypatch.setattr("git_scratch.commands.ls_tree.read_object", mock_read_object) + + result = runner.invoke(app, ["ls-tree", "abc123"]) + assert result.exit_code == 0 + + expected_output = "100644 blob 0123456789abcdef0123456789abcdef01234567\tfile.txt" + assert expected_output in result.output + + +def test_ls_tree_matches_git(tmp_path): + # Crée un dépôt git temporaire + repo = tmp_path + os.chdir(repo) + subprocess.run(["git", "init"], check=True) + + # Ajoute un fichier et crée un commit + file_path = repo / "file.txt" + file_path.write_text("Hello, Git!\n") + subprocess.run(["git", "add", "file.txt"], check=True) + subprocess.run(["git", "commit", "-m", "Initial commit"], check=True) + + # Récupère l'OID de l'arbre racine HEAD avec Git + tree_oid = subprocess.check_output(["git", "rev-parse", "HEAD^{tree}"]).decode().strip() + + # Exécute la commande git ls-tree + git_output = subprocess.check_output(["git", "ls-tree", tree_oid]).decode().strip() + + # Exécute ta commande personnalisée + result = runner.invoke(app, ["ls-tree", tree_oid]) + pit_output = result.output.strip() + + # Affiche les deux résultats en cas d’échec + assert result.exit_code == 0, f"pit command failed: {result.output}" + assert pit_output == git_output, f"\nExpected:\n{git_output}\n\nGot:\n{pit_output}" diff --git a/tests/unit/test_reset.py b/tests/unit/test_reset.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_rev_parse.py b/tests/unit/test_rev_parse.py new file mode 100644 index 0000000..2bf8bd3 --- /dev/null +++ b/tests/unit/test_rev_parse.py @@ -0,0 +1,44 @@ +import pathlib +import subprocess +from typer.testing import CliRunner +from git_scratch.main import app + +runner = CliRunner() + + +def test_rev_parse_head_and_abbrev(tmp_path: pathlib.Path, monkeypatch): + repo = tmp_path + + # Prépare un dépôt Git minimal + subprocess.run(["git", "init"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "Pytest"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "pytest@example.com"], cwd=repo, check=True + ) + + (repo / "file.txt").write_text("hello\n") + subprocess.run(["git", "add", "file.txt"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=repo, check=True) + + # SHA complet de HEAD + expected_sha_head = ( + subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip().lower() + ) + + # Test rev-parse HEAD + monkeypatch.chdir(repo) + result_full = runner.invoke(app, ["rev-parse", "HEAD"]) + assert result_full.exit_code == 0, result_full.output + assert result_full.stdout.strip().lower() == expected_sha_head + + # Test rev-parse abbre sha + abbrev5 = expected_sha_head[:5] + result_short = runner.invoke(app, ["rev-parse", abbrev5]) + assert result_short.exit_code == 0, result_short.output + assert result_short.stdout.strip().lower() == expected_sha_head diff --git a/tests/unit/test_rmfile.py b/tests/unit/test_rmfile.py new file mode 100644 index 0000000..3dec1f3 --- /dev/null +++ b/tests/unit/test_rmfile.py @@ -0,0 +1,110 @@ +import os +import json +from pathlib import Path +from typer.testing import CliRunner +from git_scratch.main import app + +runner = CliRunner() + +def test_rmfile_removes_file_and_index(tmp_path): + # Crée un faux repo temporaire avec un .git + git_dir = tmp_path / ".git" + git_dir.mkdir() + index_file = git_dir / "index.json" + + # Crée un fichier à supprimer + file_path = tmp_path / "test.txt" + file_path.write_text("hello world") + + # Ajoute l'entrée dans l'index simulé + index_data = [ + { + "mode": "100644", + "oid": "fake_oid_for_test", + "path": "test.txt" + } + ] + index_file.write_text(json.dumps(index_data, indent=2)) + + # Se place dans le bon dossier pour exécuter le test + os.chdir(tmp_path) + + # Appelle pit rm test.txt + result = runner.invoke(app, ["rm", "test.txt"]) + + # Vérifie que le fichier a bien été supprimé + assert not file_path.exists() + + # Vérifie que l'entrée a bien été retirée de l'index + updated_index = json.loads(index_file.read_text()) + assert updated_index == [] + + # Vérifie le message en sortie + assert "removed from working directory" in result.output + assert "removed from staging area" in result.output + +def test_rmfile_file_not_in_index(tmp_path): + # Crée un faux repo temporaire avec un .git + git_dir = tmp_path / ".git" + git_dir.mkdir() + index_file = git_dir / "index.json" + + # Crée un fichier qui va être supprimé + file_path = tmp_path / "orphan.txt" + file_path.write_text("I'm not in the index") + + # Initialise l’index avec un autre fichier seulement + index_data = [ + { + "mode": "100644", + "oid": "some_other_oid", + "path": "not_me.txt" + } + ] + index_file.write_text(json.dumps(index_data, indent=2)) + + # Se place dans le bon dossier + os.chdir(tmp_path) + + # Appelle pit rm orphan.txt + result = runner.invoke(app, ["rm", "orphan.txt"]) + + # Vérifie que le fichier a bien été supprimé + assert not file_path.exists() + + # L’index ne doit pas avoir changé + updated_index = json.loads(index_file.read_text()) + assert updated_index == index_data + + # Vérifie que l'on a bien le bon message + assert "removed from working directory" in result.output + assert "was not in the index" in result.output + +def test_rmfile_file_not_found(tmp_path): + # Crée un faux repo temporaire avec un .git + git_dir = tmp_path / ".git" + git_dir.mkdir() + index_file = git_dir / "index.json" + + # Initialise l’index avec un fichier + index_data = [ + { + "mode": "100644", + "oid": "some_oid", + "path": "existing.txt" + } + ] + index_file.write_text(json.dumps(index_data, indent=2)) + + # Se place dans le bon dossier + os.chdir(tmp_path) + + # Appelle pit rm non_existent.txt + result = runner.invoke(app, ["rm", "non_existent.txt"]) + + # Vérifie que le message d'erreur est correct + assert "does not exist in working directory" in result.output + + # L’index ne doit pas avoir changé + updated_index = json.loads(index_file.read_text()) + assert updated_index == index_data \ No newline at end of file diff --git a/tests/unit/test_show_ref.py b/tests/unit/test_show_ref.py new file mode 100644 index 0000000..62fd162 --- /dev/null +++ b/tests/unit/test_show_ref.py @@ -0,0 +1,79 @@ + +import subprocess +import pytest +from typer.testing import CliRunner +from git_scratch.main import app + +runner = CliRunner() + +@pytest.fixture +def setup_fake_git(tmp_path): + """ + Setup a minimal .git structure in a tempory folder. + """ + git_dir = tmp_path / ".git" + refs_heads = git_dir / "refs" / "heads" + refs_remotes = git_dir / "refs" / "remotes" / "origin" + + refs_heads.mkdir(parents=True) + refs_remotes.mkdir(parents=True) + + # Créer une ref locale + (refs_heads / "main").write_text("1111111111111111111111111111111111111111") + + # Créer une ref distante + (refs_remotes / "dev").write_text("2222222222222222222222222222222222222222") + + # Créer une ref symbolique + head_path = git_dir / "refs" / "remotes" / "origin" / "HEAD" + head_path.write_text("ref: refs/remotes/origin/dev") + + # Créer un fichier packed-refs avec une ref supplémentaire + (git_dir / "packed-refs").write_text( + """# pack-refs with: peeled fully-peeled +3333333333333333333333333333333333333333 refs/remotes/origin/feature +""" + ) + + return tmp_path + +def test_show_ref_output(setup_fake_git, monkeypatch): + # Redirige le répertoire courant vers notre dépôt temporaire + monkeypatch.chdir(setup_fake_git) + + result = runner.invoke(app, ["show-ref"]) + assert result.exit_code == 0 + + lines = result.output.strip().splitlines() + assert "1111111111111111111111111111111111111111 refs/heads/main" in lines + assert "2222222222222222222222222222222222222222 refs/remotes/origin/dev" in lines + assert "2222222222222222222222222222222222222222 refs/remotes/origin/HEAD" in lines + assert "3333333333333333333333333333333333333333 refs/remotes/origin/feature" in lines + + # Vérifie l'ordre lexicographique + assert lines == sorted(lines) + + +def test_show_ref_matches_git_output(tmp_path, monkeypatch): + # Initialiser un vrai dépôt Git + subprocess.run(["git", "init"], cwd=tmp_path, check=True) + (tmp_path / "README.md").write_text("hello") + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, check=True) + subprocess.run(["git", "checkout", "-b", "dev"], cwd=tmp_path, check=True) + + # Changer temporairement de répertoire courant + monkeypatch.chdir(tmp_path) + + # Appeler ta commande pit show-ref + result = runner.invoke(app, ["show-ref"]) + + assert result.exit_code == 0 + + pit_output = result.output.strip().splitlines() + + # Appeler git show-ref pour comparaison + git_output = subprocess.check_output(["git", "show-ref"], cwd=tmp_path).decode().strip().splitlines() + + # Comparaison stricte + assert sorted(pit_output) == sorted(git_output) diff --git a/tests/unit/test_status.py b/tests/unit/test_status.py new file mode 100644 index 0000000..f62e5b4 --- /dev/null +++ b/tests/unit/test_status.py @@ -0,0 +1,77 @@ +import os +import json +from pathlib import Path +import pytest +from typer.testing import CliRunner +from git_scratch.main import app +from git_scratch.commands.status import git_hash_object +from git_scratch.utils.index_utils import get_index_path + +@pytest.fixture + +def runner(tmp_path, monkeypatch): + """ + Fournit un CliRunner configuré dans un dépôt Git simulé. + """ + # Change le répertoire courant vers tmp_path + monkeypatch.chdir(tmp_path) + # Crée un dossier .git et un index vide + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "index.json").write_text("[]") + # Retourne le runner Typer + return CliRunner() + + +def test_status_no_change(runner): + # Aucun changement dans le dépôt + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + assert "aucun fichier modifié détecté" in result.stdout.lower() + + +def test_status_shows_modified_file(runner): + # Crée et indexe un fichier tracké puis le modifie + tracked = Path("tracked.txt") + tracked.write_text("version 1") + oid = git_hash_object(str(tracked)) + idx = get_index_path() + idx.parent.mkdir(parents=True, exist_ok=True) + idx.write_text(json.dumps([{"path": str(tracked), "oid": oid}])) + # Modification du fichier + tracked.write_text("version 2") + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + out = result.stdout.lower() + assert "modified" in out + assert "tracked.txt" in out + + +def test_status_shows_untracked_file(runner): + # Crée un fichier non tracké + untracked = Path("new_file.txt") + untracked.write_text("content") + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + out = result.stdout.lower() + assert "untracked" in out + assert "new_file.txt" in out + + +def test_status_multiple_changes(runner): + # Fichier tracké modifié + tracked = Path("t1.txt") + tracked.write_text("v1") + oid = git_hash_object(str(tracked)) + idx = get_index_path() + idx.parent.mkdir(parents=True, exist_ok=True) + idx.write_text(json.dumps([{"path": str(tracked), "oid": oid}])) + tracked.write_text("v2") + # Fichier non tracké + untracked = Path("u1.txt") + untracked.write_text("u") + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + out = result.stdout.lower() + assert "modified" in out and "t1.txt" in out + assert "untracked" in out and "u1.txt" in out diff --git a/tests/unit/test_write_tree.py b/tests/unit/test_write_tree.py new file mode 100644 index 0000000..c50c145 --- /dev/null +++ b/tests/unit/test_write_tree.py @@ -0,0 +1,44 @@ +import json +import zlib +import hashlib +from typer.testing import CliRunner +from git_scratch.main import app + + +runner = CliRunner() + +def setup_git_repo(tmp_path): + git_dir = tmp_path / ".git" + objects_dir = git_dir / "objects" + objects_dir.mkdir(parents=True) + + entries = [ + { + "mode": "100644", + "oid": hashlib.sha1(b"blob 11\0hello world").hexdigest(), + "path": "hello.txt" + } + ] + + obj_path = objects_dir / entries[0]["oid"][:2] / entries[0]["oid"][2:] + obj_path.parent.mkdir(exist_ok=True) + with open(obj_path, "wb") as f: + compressed = zlib.compress(b"blob 11\0hello world") + f.write(compressed) + + index_path = git_dir / "index.json" + with open(index_path, "w") as f: + json.dump(entries, f) + + return git_dir + +def test_write_tree(tmp_path, monkeypatch): + git_dir = setup_git_repo(tmp_path) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["write-tree"]) + assert result.exit_code == 0, f"CLI failed with exit code {result.exit_code}" + oid = result.stdout.strip().split()[-1] + + obj_path = git_dir / "objects" / oid[:2] / oid[2:] + assert obj_path.exists(), f"Tree object file does not exist at {obj_path}"