From b380cf46e1987853a07864816f10378f78eca530 Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Thu, 12 Jun 2025 16:28:12 +0200 Subject: [PATCH 01/54] First Commit with structure --- .gitignore | 196 ++++++++++++++++++++++++++++++++++++++++ commands/hash_object.py | 36 ++++++++ main.py | 9 ++ pyproject.toml | 11 +++ 4 files changed, 252 insertions(+) create mode 100644 .gitignore create mode 100644 commands/hash_object.py create mode 100644 main.py create mode 100644 pyproject.toml 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/commands/hash_object.py b/commands/hash_object.py new file mode 100644 index 0000000..17255e2 --- /dev/null +++ b/commands/hash_object.py @@ -0,0 +1,36 @@ + +import hashlib +import os +import zlib +import typer + +app = typer.Typer() + +@app.command() +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() + + header = f"blob {len(content)}\0".encode() + full_data = header + content + oid = hashlib.sha1(full_data).hexdigest() + + if write: + 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)) + + typer.echo(oid) + diff --git a/main.py b/main.py new file mode 100644 index 0000000..11ab64d --- /dev/null +++ b/main.py @@ -0,0 +1,9 @@ +import typer +from commands.hash_object import app as hash_object_app + +app = typer.Typer(help="Git from scratch in Python.") +app.add_typer(hash_object_app, name="hash-object") + +if __name__ == "__main__": + app() + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b4fb560 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "git_scratch" +version = "0.1.0" +description = "Reimplémentation de Git en Python" +dependencies = [ + "typer[all]", +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" From 3c8c15e52a4eeddb9f7150c00b446a205243a403 Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Fri, 13 Jun 2025 11:34:12 +0200 Subject: [PATCH 02/54] feat(cat-file): support -t and -p options Allows inspecting Git object type with -t and content with -p. --- commands/cat_file.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ main.py | 4 ++++ 2 files changed, 58 insertions(+) create mode 100644 commands/cat_file.py diff --git a/commands/cat_file.py b/commands/cat_file.py new file mode 100644 index 0000000..b19137a --- /dev/null +++ b/commands/cat_file.py @@ -0,0 +1,54 @@ +import os +import zlib +import typer + +def cat_file( + oid: str = typer.Argument(..., help="SHA-1 object ID to inspect."), + type: 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 or pretty): + typer.secho("Error: You must specify either -t or -p.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if len(oid) != 40 or not all(c in "0123456789abcdef" for c in oid.lower()): + typer.secho(f"Error: Invalid OID format: {oid}", fg=typer.colors.RED) + raise typer.Exit(code=1) + + obj_path = os.path.join(".git", "objects", oid[:2], oid[2:]) + if not os.path.exists(obj_path): + typer.secho(f"Error: Object {oid} not found in .git/objects.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + with open(obj_path, "rb") as f: + compressed_data = f.read() + try: + full_data = zlib.decompress(compressed_data) + except zlib.error: + typer.secho("Error: Failed to decompress Git object.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + try: + header_end = full_data.index(b'\x00') + header = full_data[:header_end].decode() + content = full_data[header_end + 1:] + obj_type, size_str = header.split() + size = int(size_str) + except Exception: + typer.secho("Error: Invalid Git object format.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if len(content) != size: + typer.secho("Error: Size mismatch in object.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if type: + typer.echo(obj_type) + elif pretty: + if obj_type != "blob": + typer.secho(f"Error: Pretty-print not supported for object type '{obj_type}'.", fg=typer.colors.RED) + raise typer.Exit(code=1) + typer.echo(content.decode(errors="replace")) diff --git a/main.py b/main.py index 11ab64d..801f599 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,13 @@ import typer from commands.hash_object import app as hash_object_app +from commands.cat_file import cat_file app = typer.Typer(help="Git from scratch in Python.") app.add_typer(hash_object_app, name="hash-object") +app.command("cat-file")(cat_file) + + if __name__ == "__main__": app() From 973a1cc74763c58d8eca4fcac97b294195d533be Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Fri, 13 Jun 2025 12:07:42 +0200 Subject: [PATCH 03/54] feat: Adding the scratch_git folder and setting up the alias 'pit' to use command and adding the command cat-file --- git_scratch/__init__.py | 0 git_scratch/commands/__init__.py | 0 git_scratch/commands/cat_file.py | 54 +++++++++++++++++++ .../commands}/hash_object.py | 3 +- git_scratch/main.py | 12 +++++ main.py | 9 ---- pyproject.toml | 3 ++ 7 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 git_scratch/__init__.py create mode 100644 git_scratch/commands/__init__.py create mode 100644 git_scratch/commands/cat_file.py rename {commands => git_scratch/commands}/hash_object.py (97%) create mode 100644 git_scratch/main.py delete mode 100644 main.py 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/cat_file.py b/git_scratch/commands/cat_file.py new file mode 100644 index 0000000..b19137a --- /dev/null +++ b/git_scratch/commands/cat_file.py @@ -0,0 +1,54 @@ +import os +import zlib +import typer + +def cat_file( + oid: str = typer.Argument(..., help="SHA-1 object ID to inspect."), + type: 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 or pretty): + typer.secho("Error: You must specify either -t or -p.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if len(oid) != 40 or not all(c in "0123456789abcdef" for c in oid.lower()): + typer.secho(f"Error: Invalid OID format: {oid}", fg=typer.colors.RED) + raise typer.Exit(code=1) + + obj_path = os.path.join(".git", "objects", oid[:2], oid[2:]) + if not os.path.exists(obj_path): + typer.secho(f"Error: Object {oid} not found in .git/objects.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + with open(obj_path, "rb") as f: + compressed_data = f.read() + try: + full_data = zlib.decompress(compressed_data) + except zlib.error: + typer.secho("Error: Failed to decompress Git object.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + try: + header_end = full_data.index(b'\x00') + header = full_data[:header_end].decode() + content = full_data[header_end + 1:] + obj_type, size_str = header.split() + size = int(size_str) + except Exception: + typer.secho("Error: Invalid Git object format.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if len(content) != size: + typer.secho("Error: Size mismatch in object.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + if type: + typer.echo(obj_type) + elif pretty: + if obj_type != "blob": + typer.secho(f"Error: Pretty-print not supported for object type '{obj_type}'.", fg=typer.colors.RED) + raise typer.Exit(code=1) + typer.echo(content.decode(errors="replace")) diff --git a/commands/hash_object.py b/git_scratch/commands/hash_object.py similarity index 97% rename from commands/hash_object.py rename to git_scratch/commands/hash_object.py index 17255e2..849fab3 100644 --- a/commands/hash_object.py +++ b/git_scratch/commands/hash_object.py @@ -1,4 +1,3 @@ - import hashlib import os import zlib @@ -6,7 +5,7 @@ app = typer.Typer() -@app.command() +# @app.command("hash-object") 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.") diff --git a/git_scratch/main.py b/git_scratch/main.py new file mode 100644 index 0000000..7f90683 --- /dev/null +++ b/git_scratch/main.py @@ -0,0 +1,12 @@ +import typer +from git_scratch.commands.hash_object import hash_object +from git_scratch.commands.cat_file import cat_file + +app = typer.Typer(help="Git from scratch in Python.") + +app.command("hash-object")(hash_object) +app.command("cat-file")(cat_file) + + +if __name__ == "main": + app() diff --git a/main.py b/main.py deleted file mode 100644 index 11ab64d..0000000 --- a/main.py +++ /dev/null @@ -1,9 +0,0 @@ -import typer -from commands.hash_object import app as hash_object_app - -app = typer.Typer(help="Git from scratch in Python.") -app.add_typer(hash_object_app, name="hash-object") - -if __name__ == "__main__": - app() - diff --git a/pyproject.toml b/pyproject.toml index b4fb560..afe4038 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,3 +9,6 @@ dependencies = [ [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" + +[project.scripts] +pit = "git_scratch.main:app" From 8d92e9beba32ca6b2082702701a87f6feedf65f9 Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Fri, 13 Jun 2025 11:34:12 +0200 Subject: [PATCH 04/54] feat(cat-file): support -t and -p options Allows inspecting Git object type with -t and content with -p. --- main.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..801f599 --- /dev/null +++ b/main.py @@ -0,0 +1,13 @@ +import typer +from commands.hash_object import app as hash_object_app +from commands.cat_file import cat_file + +app = typer.Typer(help="Git from scratch in Python.") +app.add_typer(hash_object_app, name="hash-object") + +app.command("cat-file")(cat_file) + + +if __name__ == "__main__": + app() + From 7bfe4ee217ec70ee1f1b848b3dabb28af9c81391 Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Fri, 13 Jun 2025 14:23:57 +0200 Subject: [PATCH 05/54] chore(sync): update branch with latest changes from main --- main.py | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 main.py diff --git a/main.py b/main.py deleted file mode 100644 index 801f599..0000000 --- a/main.py +++ /dev/null @@ -1,13 +0,0 @@ -import typer -from commands.hash_object import app as hash_object_app -from commands.cat_file import cat_file - -app = typer.Typer(help="Git from scratch in Python.") -app.add_typer(hash_object_app, name="hash-object") - -app.command("cat-file")(cat_file) - - -if __name__ == "__main__": - app() - From 5243b1bb26e928d2e1dd2376c8f302efad780143 Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Sat, 14 Jun 2025 19:36:16 +0200 Subject: [PATCH 06/54] feat(commit): implement commit creation using git commit-tree Manually create a commit from a tree object built after git add simulation. --- git_scratch/commands/add.py | 56 ++++++++++++++++++++++++++++++ git_scratch/commands/cat_file.py | 2 +- git_scratch/commands/write_tree.py | 48 +++++++++++++++++++++++++ git_scratch/main.py | 4 +++ index.json | 7 ++++ 5 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 git_scratch/commands/add.py create mode 100644 git_scratch/commands/write_tree.py create mode 100644 index.json diff --git a/git_scratch/commands/add.py b/git_scratch/commands/add.py new file mode 100644 index 0000000..9c06511 --- /dev/null +++ b/git_scratch/commands/add.py @@ -0,0 +1,56 @@ +import json +import os +import typer +import hashlib +import zlib + +def add(file_path: str = typer.Argument(..., help="Path to the file to stage.")): + """ + Adds a file to the staging area (index.json). + """ + 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) + + # Read the file content + with open(file_path, "rb") as f: + content = f.read() + + # Create the blob object + header = f"blob {len(content)}\0".encode() + full_data = header + content + oid = hashlib.sha1(full_data).hexdigest() + + # Save to .git/objects/ + 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)) + + # Load or create index.json + index_path = "index.json" + if os.path.exists(index_path) and os.path.getsize(index_path) > 0: + with open(index_path, "r") as f: + index = json.load(f) + else: + index = [] + + # Create the entry with mode, oid, and path + entry = { + "mode": "100644", # standard mode for a normal file + "oid": oid, + "path": file_path + } + + # Remove any existing entry with the same path + index = [e for e in index if e["path"] != file_path] + + # Add the new entry + index.append(entry) + + # Write to index.json + with open(index_path, "w") as f: + json.dump(index, f, indent=2) + + typer.echo(f"{file_path} added to index with OID {oid}") diff --git a/git_scratch/commands/cat_file.py b/git_scratch/commands/cat_file.py index b19137a..b30a86a 100644 --- a/git_scratch/commands/cat_file.py +++ b/git_scratch/commands/cat_file.py @@ -51,4 +51,4 @@ def cat_file( if obj_type != "blob": typer.secho(f"Error: Pretty-print not supported for object type '{obj_type}'.", fg=typer.colors.RED) raise typer.Exit(code=1) - typer.echo(content.decode(errors="replace")) + typer.echo(content.decode(errors="replace")) \ 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..a5d042e --- /dev/null +++ b/git_scratch/commands/write_tree.py @@ -0,0 +1,48 @@ +import os +import json +import typer +import hashlib +import zlib + +app = typer.Typer() + +@app.command() +def write_tree(): + """ + Writes a tree object from the index and returns its OID. + """ + index_path = "index.json" + if not os.path.exists(index_path) or os.path.getsize(index_path) == 0: + typer.secho("Error: the index is empty or missing.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + with open(index_path, "r") as f: + index = json.load(f) + + entries = [] + + for entry in sorted(index, key=lambda e: e["path"]): + mode = entry["mode"] + path = entry["path"] + oid = bytes.fromhex(entry["oid"]) + + # Format: " \0" + entry_line = f"{mode} {path}".encode() + b"\x00" + oid + entries.append(entry_line) + + tree_content = b"".join(entries) + + # Prefix with "tree {size}\0" + header = f"tree {len(tree_content)}\0".encode() + full_data = header + tree_content + + oid = hashlib.sha1(full_data).hexdigest() + + # Save to .git/objects/ + 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)) + + typer.echo(oid) diff --git a/git_scratch/main.py b/git_scratch/main.py index 7f90683..2d3fd63 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -1,11 +1,15 @@ import typer from git_scratch.commands.hash_object import hash_object from git_scratch.commands.cat_file import cat_file +from git_scratch.commands.add import add +from git_scratch.commands.write_tree import write_tree 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) if __name__ == "main": diff --git a/index.json b/index.json new file mode 100644 index 0000000..d24e2db --- /dev/null +++ b/index.json @@ -0,0 +1,7 @@ +[ + { + "mode": "100644", + "oid": "baa779394009d9ec4f62d42c3f742f72956e94eb", + "path": "README.md" + } +] \ No newline at end of file From 27050dc39532e850547226af2a8866b9395819de Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Mon, 16 Jun 2025 15:17:25 +0200 Subject: [PATCH 07/54] feat: add init command --- git_scratch/commands/init.py | 38 ++++++++++++++++++++++++++++++++++++ git_scratch/main.py | 2 ++ 2 files changed, 40 insertions(+) create mode 100644 git_scratch/commands/init.py diff --git a/git_scratch/commands/init.py b/git_scratch/commands/init.py new file mode 100644 index 0000000..155ab54 --- /dev/null +++ b/git_scratch/commands/init.py @@ -0,0 +1,38 @@ +import os +import typer + +app = typer.Typer() + +def init(): + """ + Initialize a new, empty pit repository. + """ + # os.getcwd() = current path + git_dir = os.path.join(os.getcwd(), ".git") + + if os.path.exists(git_dir): + typer.secho("Reinitialized existing Pit repository in {}/.git/".format(os.getcwd()), 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/main\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" + ) + 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(os.getcwd()), fg=typer.colors.GREEN) diff --git a/git_scratch/main.py b/git_scratch/main.py index 7f90683..c7b2be0 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -1,11 +1,13 @@ import typer from git_scratch.commands.hash_object import hash_object from git_scratch.commands.cat_file import cat_file +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("init")(init) if __name__ == "main": From 36784a05d7a469da6d5de95ac1a7312ceed1e034 Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Thu, 19 Jun 2025 13:56:57 +0200 Subject: [PATCH 08/54] feat: Adding the test for the command hash-object and including pytest in the dev dependencies --- README.md | 19 +++++++++- git_scratch/commands/hash_object.py | 3 -- pyproject.toml | 3 ++ tests/unit/test_hash_object.py | 58 +++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_hash_object.py 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/commands/hash_object.py b/git_scratch/commands/hash_object.py index 849fab3..4ab285c 100644 --- a/git_scratch/commands/hash_object.py +++ b/git_scratch/commands/hash_object.py @@ -3,9 +3,6 @@ import zlib import typer -app = typer.Typer() - -# @app.command("hash-object") 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.") diff --git a/pyproject.toml b/pyproject.toml index afe4038..8d3e164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,9 @@ dependencies = [ "typer[all]", ] +[project.optional-dependencies] +dev = ["pytest"] + [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" 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 + From d55c2ab2c9d22dc4bc9e40b961b5d7f7111dfeab Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Thu, 19 Jun 2025 16:48:36 +0200 Subject: [PATCH 09/54] test(cat-file): add unit test for cat-file functionality --- git_scratch/commands/add.py | 2 +- pyproject.toml | 4 +++ tests/unit/test_cat_file.py | 59 +++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_cat_file.py diff --git a/git_scratch/commands/add.py b/git_scratch/commands/add.py index 9c06511..3d00e02 100644 --- a/git_scratch/commands/add.py +++ b/git_scratch/commands/add.py @@ -53,4 +53,4 @@ def add(file_path: str = typer.Argument(..., help="Path to the file to stage.")) with open(index_path, "w") as f: json.dump(index, f, indent=2) - typer.echo(f"{file_path} added to index with OID {oid}") + typer.echo(f"{file_path} added to index with OID {oid}") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index afe4038..4fa7ee2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,10 @@ dependencies = [ "typer[all]", ] +[project.optional-dependencies] +dev = ["pytest"] + + [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" diff --git a/tests/unit/test_cat_file.py b/tests/unit/test_cat_file.py new file mode 100644 index 0000000..bb5f1cf --- /dev/null +++ b/tests/unit/test_cat_file.py @@ -0,0 +1,59 @@ +from pathlib import Path +from typer.testing import CliRunner +import subprocess + +from git_scratch.main import app + +runner = CliRunner() + +def test_cat_file_matches_git(tmp_path): + file = tmp_path / "hello.txt" + file.write_text("Hello from test") + + result_git_oid = subprocess.run( + ["git", "hash-object", "-w", str(file)], + capture_output=True, text=True + ) + oid = result_git_oid.stdout.strip() + + runner.invoke(app, ["hash-object", str(file)]) + + result_git_cat = subprocess.run( + ["git", "cat-file", "-p", oid], + capture_output=True, text=True + ) + result_pit_cat = runner.invoke(app, ["cat-file", "-p", oid]) + + # Affichage des résultats pour inspection + print("\n[cat-file -p]") + print("GIT:", repr(result_git_cat.stdout)) + print("PIT:", repr(result_pit_cat.stdout)) + + assert result_git_cat.stdout.strip() == result_pit_cat.stdout.strip() + + +def test_cat_file_type_matches_git(tmp_path): + file = tmp_path / "hello.txt" + file.write_text("Hello from test") + + result_git_oid = subprocess.run( + ["git", "hash-object", "-w", str(file)], + capture_output=True, text=True + ) + oid = result_git_oid.stdout.strip() + + runner.invoke(app, ["hash-object", str(file)]) + + result_git_type = subprocess.run( + ["git", "cat-file", "-t", oid], + capture_output=True, text=True + ) + result_pit_type = runner.invoke(app, ["cat-file", "-t", oid]) + + # Affichage des résultats pour inspection + print("\n[cat-file -t]") + print("git:", repr(result_git_type.stdout)) + print("pit:", repr(result_pit_type.stdout)) + + assert result_git_type.stdout.strip() == result_pit_type.stdout.strip() + From f6281fd796db2fbc6340993e97f07b38c99fc181 Mon Sep 17 00:00:00 2001 From: Amaury057 Date: Thu, 19 Jun 2025 19:00:55 +0200 Subject: [PATCH 10/54] feat(rm): remove file and test --- git_scratch/commands/rmfile.py | 65 ++++++++++++++++++ git_scratch/main.py | 5 +- git_scratch/tests/test_rmfile.py | 110 +++++++++++++++++++++++++++++++ pyproject.toml | 3 + 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 git_scratch/commands/rmfile.py create mode 100644 git_scratch/tests/test_rmfile.py diff --git a/git_scratch/commands/rmfile.py b/git_scratch/commands/rmfile.py new file mode 100644 index 0000000..450ce75 --- /dev/null +++ b/git_scratch/commands/rmfile.py @@ -0,0 +1,65 @@ +import os +import json +from pathlib import Path +import typer + +def get_index_path(): + """ + Returns the path to the .git/index.json file dynamically, + based on the current working directory. + """ + return Path(os.getcwd()) / ".git" / "index.json" + +def load_index(): + """ + Load the index.json file from .git/ and return its content. + If the file does not exist, return an empty list. + """ + 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 rmfile(file_path: str): + """ + Remove a file from the working directory and from the index. + """ + index = load_index() + filename = os.path.basename(file_path) + + # --- Step 1: Remove the file from the 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) + + # --- Step 2: Remove from index --- + new_index = [] + removed = False + for entry in index: + if entry["path"] == filename: + removed = True + else: + new_index.append(entry) + + 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/main.py b/git_scratch/main.py index 7f90683..ac4dac1 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -1,12 +1,15 @@ import typer from git_scratch.commands.hash_object import hash_object from git_scratch.commands.cat_file import cat_file +from git_scratch.commands.rmfile import rmfile + app = typer.Typer(help="Git from scratch in Python.") app.command("hash-object")(hash_object) app.command("cat-file")(cat_file) +app.command("rm")(rmfile) -if __name__ == "main": +if __name__ == "__main__": app() diff --git a/git_scratch/tests/test_rmfile.py b/git_scratch/tests/test_rmfile.py new file mode 100644 index 0000000..3dec1f3 --- /dev/null +++ b/git_scratch/tests/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/pyproject.toml b/pyproject.toml index afe4038..8d3e164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,9 @@ dependencies = [ "typer[all]", ] +[project.optional-dependencies] +dev = ["pytest"] + [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" From cccc55c196d22873abdebf4dafc8c30f56bf2d94 Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Fri, 20 Jun 2025 20:42:45 +0200 Subject: [PATCH 11/54] fix: add file param init cmd & feat: pytest for init cmd --- git_scratch/commands/init.py | 14 +++++++---- pyproject.toml | 1 + tests/unit/test_init.py | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_init.py diff --git a/git_scratch/commands/init.py b/git_scratch/commands/init.py index 155ab54..4214e29 100644 --- a/git_scratch/commands/init.py +++ b/git_scratch/commands/init.py @@ -3,15 +3,18 @@ app = typer.Typer() -def init(): +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 - git_dir = os.path.join(os.getcwd(), ".git") + 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(os.getcwd()), fg=typer.colors.YELLOW) + typer.secho("Reinitialized existing Pit repository in {}/.git/".format(target_dir), fg=typer.colors.YELLOW) raise typer.Exit() # Create basic Git structure @@ -24,15 +27,16 @@ def init(): # Create essential files with open(os.path.join(git_dir, "HEAD"), "w") as f: - f.write("ref: refs/heads/main\n") + 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(os.getcwd()), fg=typer.colors.GREEN) + typer.secho("Initialized empty Pit repository in {}/.git/".format(target_dir), fg=typer.colors.GREEN) diff --git a/pyproject.toml b/pyproject.toml index afe4038..ea5bcb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "Reimplémentation de Git en Python" dependencies = [ "typer[all]", + "pytest" ] [build-system] 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}" From 8323a757cd89fef738eed72ed7d068bbe6c0e156 Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Sun, 22 Jun 2025 16:21:23 +0200 Subject: [PATCH 12/54] feat: Adding the command ls-files and its test --- git_scratch/commands/ls_files.py | 26 ++++++++++++++++++++++++++ git_scratch/main.py | 2 ++ tests/unit/test_ls_files.py | 27 +++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 git_scratch/commands/ls_files.py create mode 100644 tests/unit/test_ls_files.py 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/main.py b/git_scratch/main.py index 7f90683..75762c8 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -1,4 +1,5 @@ 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 @@ -6,6 +7,7 @@ app.command("hash-object")(hash_object) app.command("cat-file")(cat_file) +app.command("ls-files")(ls_files) if __name__ == "main": 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 + From aa129448d4f47f44d47e87793a897d5aca418e31 Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Mon, 23 Jun 2025 12:52:54 +0200 Subject: [PATCH 13/54] refactor(write-tree): update logic and adjust cat-file test and add.py accordingly --- git_scratch/commands/add.py | 17 +++--- git_scratch/commands/write_tree.py | 87 +++++++++++++++++++----------- index.json | 7 --- tests/unit/test_cat_file.py | 60 ++++++--------------- 4 files changed, 81 insertions(+), 90 deletions(-) delete mode 100644 index.json diff --git a/git_scratch/commands/add.py b/git_scratch/commands/add.py index 3d00e02..2d0f9b3 100644 --- a/git_scratch/commands/add.py +++ b/git_scratch/commands/add.py @@ -6,7 +6,7 @@ def add(file_path: str = typer.Argument(..., help="Path to the file to stage.")): """ - Adds a file to the staging area (index.json). + Adds a file to the staging area (.git/index.json). """ if not os.path.isfile(file_path): typer.secho(f"Error: {file_path} is not a valid file.", fg=typer.colors.RED) @@ -28,29 +28,32 @@ def add(file_path: str = typer.Argument(..., help="Path to the file to stage.")) with open(obj_path, "wb") as f: f.write(zlib.compress(full_data)) - # Load or create index.json - index_path = "index.json" + # Load or create .git/index.json + index_path = os.path.join(".git", "index.json") if os.path.exists(index_path) and os.path.getsize(index_path) > 0: with open(index_path, "r") as f: index = json.load(f) else: index = [] + # Use relative path (optional, depends où tu exécutes) + rel_path = os.path.relpath(file_path) + # Create the entry with mode, oid, and path entry = { "mode": "100644", # standard mode for a normal file "oid": oid, - "path": file_path + "path": rel_path } # Remove any existing entry with the same path - index = [e for e in index if e["path"] != file_path] + index = [e for e in index if e["path"] != rel_path] # Add the new entry index.append(entry) - # Write to index.json + # Write to .git/index.json with open(index_path, "w") as f: json.dump(index, f, indent=2) - typer.echo(f"{file_path} added to index with OID {oid}") \ No newline at end of file + typer.echo(f"{rel_path} added to index with OID {oid}") diff --git a/git_scratch/commands/write_tree.py b/git_scratch/commands/write_tree.py index a5d042e..4aa11df 100644 --- a/git_scratch/commands/write_tree.py +++ b/git_scratch/commands/write_tree.py @@ -1,48 +1,73 @@ import os import json -import typer import hashlib import zlib +import typer +from typing import List, Dict, Tuple app = typer.Typer() +def store_object(data: bytes, type_: str) -> str: + header = f"{type_} {len(data)}\0".encode() + full_data = header + data + 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)) + + return oid + +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 = store_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 + @app.command() def write_tree(): """ - Writes a tree object from the index and returns its OID. + Écrit un arbre Git récursif à partir de .git/index.json et affiche son OID. """ - index_path = "index.json" - if not os.path.exists(index_path) or os.path.getsize(index_path) == 0: - typer.secho("Error: the index is empty or missing.", fg=typer.colors.RED) + index_path = os.path.join(".git", "index.json") + if not os.path.exists(index_path): + typer.secho("Erreur : .git/index.json introuvable.", fg=typer.colors.RED) raise typer.Exit(code=1) with open(index_path, "r") as f: index = json.load(f) - entries = [] - - for entry in sorted(index, key=lambda e: e["path"]): - mode = entry["mode"] - path = entry["path"] - oid = bytes.fromhex(entry["oid"]) - - # Format: " \0" - entry_line = f"{mode} {path}".encode() + b"\x00" + oid - entries.append(entry_line) - - tree_content = b"".join(entries) - - # Prefix with "tree {size}\0" - header = f"tree {len(tree_content)}\0".encode() - full_data = header + tree_content - - oid = hashlib.sha1(full_data).hexdigest() - - # Save to .git/objects/ - 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)) + tree_data = build_tree(index) + oid = store_object(tree_data, "tree") + typer.echo(f"Tree OID: {oid}") - typer.echo(oid) +if __name__ == "__main__": + app() diff --git a/index.json b/index.json deleted file mode 100644 index d24e2db..0000000 --- a/index.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "mode": "100644", - "oid": "baa779394009d9ec4f62d42c3f742f72956e94eb", - "path": "README.md" - } -] \ No newline at end of file diff --git a/tests/unit/test_cat_file.py b/tests/unit/test_cat_file.py index bb5f1cf..d96e618 100644 --- a/tests/unit/test_cat_file.py +++ b/tests/unit/test_cat_file.py @@ -6,54 +6,24 @@ runner = CliRunner() -def test_cat_file_matches_git(tmp_path): +def test_cat_file_all_modes(tmp_path): file = tmp_path / "hello.txt" file.write_text("Hello from test") - result_git_oid = subprocess.run( - ["git", "hash-object", "-w", str(file)], - capture_output=True, text=True - ) - oid = result_git_oid.stdout.strip() + # 🔄 Calcul du hash avec l'application locale (PIT) au lieu de Git + result_pit_oid = runner.invoke(app, ["hash-object", str(file)]) + oid = result_pit_oid.stdout.strip() - runner.invoke(app, ["hash-object", str(file)]) + # Test des deux modes : -p (print content) et -t (type) + 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]) - result_git_cat = subprocess.run( - ["git", "cat-file", "-p", oid], - capture_output=True, text=True - ) - result_pit_cat = runner.invoke(app, ["cat-file", "-p", oid]) - - # Affichage des résultats pour inspection - print("\n[cat-file -p]") - print("GIT:", repr(result_git_cat.stdout)) - print("PIT:", repr(result_pit_cat.stdout)) - - assert result_git_cat.stdout.strip() == result_pit_cat.stdout.strip() - - -def test_cat_file_type_matches_git(tmp_path): - file = tmp_path / "hello.txt" - file.write_text("Hello from test") - - result_git_oid = subprocess.run( - ["git", "hash-object", "-w", str(file)], - capture_output=True, text=True - ) - oid = result_git_oid.stdout.strip() - - runner.invoke(app, ["hash-object", str(file)]) - - result_git_type = subprocess.run( - ["git", "cat-file", "-t", oid], - capture_output=True, text=True - ) - result_pit_type = runner.invoke(app, ["cat-file", "-t", oid]) - - # Affichage des résultats pour inspection - print("\n[cat-file -t]") - print("git:", repr(result_git_type.stdout)) - print("pit:", repr(result_pit_type.stdout)) - - assert result_git_type.stdout.strip() == result_pit_type.stdout.strip() + print(f"\n[cat-file {flag}]") + print("GIT:", repr(result_git.stdout)) + print("PIT:", repr(result_pit.stdout)) + assert result_git.stdout.strip() == result_pit.stdout.strip() From bd5bebaf95e98af7360b8bbce940247c49df2be6 Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Mon, 23 Jun 2025 17:47:02 +0200 Subject: [PATCH 14/54] fix app useless --- git_scratch/commands/init.py | 1 - 1 file changed, 1 deletion(-) diff --git a/git_scratch/commands/init.py b/git_scratch/commands/init.py index 4214e29..63d3e71 100644 --- a/git_scratch/commands/init.py +++ b/git_scratch/commands/init.py @@ -1,7 +1,6 @@ import os import typer -app = typer.Typer() def init( folder: str = typer.Argument(None, help="Path to the folder where to initialize the repository") From 2520cac7b7047473c32953e016943f5fedb03ab0 Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Mon, 23 Jun 2025 21:07:33 +0200 Subject: [PATCH 15/54] feat: Adding the command show-ref without its test --- git_scratch/commands/show_ref.py | 33 ++++++++++++++++++++++++++++++++ git_scratch/main.py | 3 ++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 git_scratch/commands/show_ref.py diff --git a/git_scratch/commands/show_ref.py b/git_scratch/commands/show_ref.py new file mode 100644 index 0000000..f592d06 --- /dev/null +++ b/git_scratch/commands/show_ref.py @@ -0,0 +1,33 @@ + +import os +import typer + +def show_ref(): + """ + List all references and their OIDs. + """ + refs_dir = os.path.join(".git", "refs") + + if not os.path.isdir(refs_dir): + typer.secho("Error: .git/refs directory not found.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + for root, _, files in os.walk(refs_dir): + for file in files: + ref_path = os.path.join(root, file) + with open(ref_path, "r") as f: + oid = f.read().strip() + # Get relative path from .git (e.g., refs/heads/main) + rel_path = os.path.relpath(ref_path, os.path.join(".git")) + typer.echo(f"{oid} {rel_path}") + + # Optionally: read packed-refs + packed_refs = os.path.join(".git", "packed-refs") + if os.path.exists(packed_refs): + with open(packed_refs) as f: + for line in f: + if line.startswith("#") or line.strip() == "": + continue + if " " in line: + oid, ref = line.strip().split(" ") + typer.echo(f"{oid} {ref}") diff --git a/git_scratch/main.py b/git_scratch/main.py index 75762c8..605036f 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -2,12 +2,13 @@ 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 app = typer.Typer(help="Git from scratch in Python.") app.command("hash-object")(hash_object) app.command("cat-file")(cat_file) app.command("ls-files")(ls_files) +app.command("show-ref")(show_ref) if __name__ == "main": From 47ed77186efbafe8d4c42eb240c156c2b481bc2a Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Mon, 23 Jun 2025 21:37:36 +0200 Subject: [PATCH 16/54] update: adding init command in main.py --- git_scratch/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/git_scratch/main.py b/git_scratch/main.py index ef5f63a..984802f 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -8,6 +8,7 @@ app.command("hash-object")(hash_object) app.command("cat-file")(cat_file) +app.command("init")(init) app.command("ls-files")(ls_files) From b2da71ff46a05c6ce003918925425796e544ebec Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Tue, 24 Jun 2025 15:20:17 +0200 Subject: [PATCH 17/54] feat(pit-add): add method handling and support for 'pit add .' command Implemented method management in pit add. Added tests for add.py and write-tree functionality. --- git_scratch/commands/add.py | 61 ++++++++++++++++++++---------- git_scratch/commands/write_tree.py | 4 +- tests/unit/test_add.py | 48 +++++++++++++++++++++++ tests/unit/test_cat_file.py | 6 --- tests/unit/test_write_tree.py | 44 +++++++++++++++++++++ 5 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 tests/unit/test_add.py create mode 100644 tests/unit/test_write_tree.py diff --git a/git_scratch/commands/add.py b/git_scratch/commands/add.py index 2d0f9b3..7b4228b 100644 --- a/git_scratch/commands/add.py +++ b/git_scratch/commands/add.py @@ -3,32 +3,33 @@ import typer import hashlib import zlib +import stat -def add(file_path: str = typer.Argument(..., help="Path to the file to stage.")): - """ - Adds a file to the staging area (.git/index.json). - """ - 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) +app = typer.Typer() - # Read the file content +def compute_mode(file_path): + 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" + +def add_file_to_index(file_path: str): with open(file_path, "rb") as f: content = f.read() - # Create the blob object header = f"blob {len(content)}\0".encode() full_data = header + content oid = hashlib.sha1(full_data).hexdigest() - # Save to .git/objects/ 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)) - # Load or create .git/index.json index_path = os.path.join(".git", "index.json") if os.path.exists(index_path) and os.path.getsize(index_path) > 0: with open(index_path, "r") as f: @@ -36,24 +37,46 @@ def add(file_path: str = typer.Argument(..., help="Path to the file to stage.")) else: index = [] - # Use relative path (optional, depends où tu exécutes) rel_path = os.path.relpath(file_path) + mode = compute_mode(file_path) - # Create the entry with mode, oid, and path entry = { - "mode": "100644", # standard mode for a normal file + "mode": mode, "oid": oid, "path": rel_path } - # Remove any existing entry with the same path index = [e for e in index if e["path"] != rel_path] - - # Add the new entry index.append(entry) - # Write to .git/index.json with open(index_path, "w") as f: json.dump(index, f, indent=2) - typer.echo(f"{rel_path} added to index with OID {oid}") + 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). + """ + 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) + + if os.path.isfile(file_path): + 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) + if ".git" in full_path: + 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/write_tree.py b/git_scratch/commands/write_tree.py index 4aa11df..2c82cd5 100644 --- a/git_scratch/commands/write_tree.py +++ b/git_scratch/commands/write_tree.py @@ -55,11 +55,11 @@ def build_tree(entries: List[Dict], base_path: str = "") -> bytes: @app.command() def write_tree(): """ - Écrit un arbre Git récursif à partir de .git/index.json et affiche son OID. + Writes a recursive Git tree from .git/index.json and displays its OID. """ index_path = os.path.join(".git", "index.json") if not os.path.exists(index_path): - typer.secho("Erreur : .git/index.json introuvable.", fg=typer.colors.RED) + typer.secho("Erreur : .git/index.json not found..", fg=typer.colors.RED) raise typer.Exit(code=1) with open(index_path, "r") as f: 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 index d96e618..4b4cca4 100644 --- a/tests/unit/test_cat_file.py +++ b/tests/unit/test_cat_file.py @@ -10,11 +10,9 @@ def test_cat_file_all_modes(tmp_path): file = tmp_path / "hello.txt" file.write_text("Hello from test") - # 🔄 Calcul du hash avec l'application locale (PIT) au lieu de Git result_pit_oid = runner.invoke(app, ["hash-object", str(file)]) oid = result_pit_oid.stdout.strip() - # Test des deux modes : -p (print content) et -t (type) for flag in ["-p", "-t"]: result_git = subprocess.run( ["git", "cat-file", flag, oid], @@ -22,8 +20,4 @@ def test_cat_file_all_modes(tmp_path): ) result_pit = runner.invoke(app, ["cat-file", flag, oid]) - print(f"\n[cat-file {flag}]") - print("GIT:", repr(result_git.stdout)) - print("PIT:", repr(result_pit.stdout)) - assert result_git.stdout.strip() == result_pit.stdout.strip() diff --git a/tests/unit/test_write_tree.py b/tests/unit/test_write_tree.py new file mode 100644 index 0000000..862e399 --- /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}" + assert "Tree OID: " in result.stdout, "Expected Tree OID in CLI output" + 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}" From 78ecd44e48b114148356f5f1503d67406e30b5cd Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Wed, 25 Jun 2025 14:50:23 +0200 Subject: [PATCH 18/54] feat: Refactoring the show-ref command and adding its tests --- git_scratch/commands/show_ref.py | 46 +++++++++++-------- tests/unit/test_show_ref.py | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 tests/unit/test_show_ref.py diff --git a/git_scratch/commands/show_ref.py b/git_scratch/commands/show_ref.py index f592d06..ed60c3b 100644 --- a/git_scratch/commands/show_ref.py +++ b/git_scratch/commands/show_ref.py @@ -1,33 +1,39 @@ - import os import typer -def show_ref(): - """ - List all references and their OIDs. - """ - refs_dir = os.path.join(".git", "refs") +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 - if not os.path.isdir(refs_dir): - typer.secho("Error: .git/refs directory not found.", fg=typer.colors.RED) - raise typer.Exit(code=1) +def show_ref(): + 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) - with open(ref_path, "r") as f: - oid = f.read().strip() - # Get relative path from .git (e.g., refs/heads/main) - rel_path = os.path.relpath(ref_path, os.path.join(".git")) - typer.echo(f"{oid} {rel_path}") + rel_path = os.path.relpath(ref_path, ".git") + oid = resolve_ref(ref_path) + refs[rel_path] = oid - # Optionally: read packed-refs - packed_refs = os.path.join(".git", "packed-refs") - if os.path.exists(packed_refs): - with open(packed_refs) as f: + # 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, ref = line.strip().split(" ") - typer.echo(f"{oid} {ref}") + 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/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) From 4b60fad6435e5e169e2330a72220c903b614342d Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Wed, 25 Jun 2025 17:51:20 +0200 Subject: [PATCH 19/54] init cmd rev-parse --- git_scratch/commands/rev_parse.py | 7 +++++++ git_scratch/main.py | 2 ++ {git_scratch/tests => tests/unit}/test_rmfile.py | 0 3 files changed, 9 insertions(+) create mode 100644 git_scratch/commands/rev_parse.py rename {git_scratch/tests => tests/unit}/test_rmfile.py (100%) diff --git a/git_scratch/commands/rev_parse.py b/git_scratch/commands/rev_parse.py new file mode 100644 index 0000000..f99ab77 --- /dev/null +++ b/git_scratch/commands/rev_parse.py @@ -0,0 +1,7 @@ +import os +import typer + +def rev_parse( + folder: str = typer.Argument(None, help="Path to the folder where to initialize the repository") +): + print("Initializing repository...") \ No newline at end of file diff --git a/git_scratch/main.py b/git_scratch/main.py index 2ec1b95..9105269 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -5,6 +5,7 @@ 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.init import init @@ -17,6 +18,7 @@ app.command("rm")(rmfile) app.command("init")(init) app.command("ls-files")(ls_files) +app.command("rev-parse")(rev_parse) if __name__ == "__main__": diff --git a/git_scratch/tests/test_rmfile.py b/tests/unit/test_rmfile.py similarity index 100% rename from git_scratch/tests/test_rmfile.py rename to tests/unit/test_rmfile.py From 0d0b7c48aa59ea3bd51701340bb3ac2c04b53140 Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Wed, 25 Jun 2025 17:53:31 +0200 Subject: [PATCH 20/54] fix:test file folder --- {git_scratch/tests => tests/unit}/test_rmfile.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {git_scratch/tests => tests/unit}/test_rmfile.py (100%) diff --git a/git_scratch/tests/test_rmfile.py b/tests/unit/test_rmfile.py similarity index 100% rename from git_scratch/tests/test_rmfile.py rename to tests/unit/test_rmfile.py From e83f2ba701c22ff4da80900aa9ca6576ee389994 Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Thu, 26 Jun 2025 13:53:48 +0200 Subject: [PATCH 21/54] fix: merging main and fixing test_cat_file.py --- tests/unit/test_cat_file.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/test_cat_file.py b/tests/unit/test_cat_file.py index 4b4cca4..90b20fa 100644 --- a/tests/unit/test_cat_file.py +++ b/tests/unit/test_cat_file.py @@ -1,4 +1,3 @@ -from pathlib import Path from typer.testing import CliRunner import subprocess @@ -10,7 +9,7 @@ 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", str(file)]) + result_pit_oid = runner.invoke(app, ["hash-object", "-w", str(file)]) oid = result_pit_oid.stdout.strip() for flag in ["-p", "-t"]: From 189597e73156b21098aa19c3b90bd91d0ea5d42c Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Thu, 26 Jun 2025 14:29:59 +0200 Subject: [PATCH 22/54] refactor: Extract the logic to read an object in utils.py --- git_scratch/commands/cat_file.py | 33 +++++++-------------------- git_scratch/commands/ls_tree.py | 38 ++++++++++++++++++++++++++++++++ git_scratch/main.py | 2 ++ git_scratch/utils.py | 23 +++++++++++++++++++ 4 files changed, 71 insertions(+), 25 deletions(-) create mode 100644 git_scratch/commands/ls_tree.py create mode 100644 git_scratch/utils.py diff --git a/git_scratch/commands/cat_file.py b/git_scratch/commands/cat_file.py index b30a86a..84e5b35 100644 --- a/git_scratch/commands/cat_file.py +++ b/git_scratch/commands/cat_file.py @@ -1,6 +1,6 @@ -import os -import zlib + import typer +from git_scratch.utils import read_object # Assure-toi que le chemin est correct def cat_file( oid: str = typer.Argument(..., help="SHA-1 object ID to inspect."), @@ -18,31 +18,13 @@ def cat_file( typer.secho(f"Error: Invalid OID format: {oid}", fg=typer.colors.RED) raise typer.Exit(code=1) - obj_path = os.path.join(".git", "objects", oid[:2], oid[2:]) - if not os.path.exists(obj_path): - typer.secho(f"Error: Object {oid} not found in .git/objects.", fg=typer.colors.RED) - raise typer.Exit(code=1) - - with open(obj_path, "rb") as f: - compressed_data = f.read() try: - full_data = zlib.decompress(compressed_data) - except zlib.error: - typer.secho("Error: Failed to decompress Git object.", fg=typer.colors.RED) + 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) - - try: - header_end = full_data.index(b'\x00') - header = full_data[:header_end].decode() - content = full_data[header_end + 1:] - obj_type, size_str = header.split() - size = int(size_str) except Exception: - typer.secho("Error: Invalid Git object format.", fg=typer.colors.RED) - raise typer.Exit(code=1) - - if len(content) != size: - typer.secho("Error: Size mismatch in object.", fg=typer.colors.RED) + typer.secho("Error: Failed to read Git object.", fg=typer.colors.RED) raise typer.Exit(code=1) if type: @@ -51,4 +33,5 @@ def cat_file( if obj_type != "blob": typer.secho(f"Error: Pretty-print not supported for object type '{obj_type}'.", fg=typer.colors.RED) raise typer.Exit(code=1) - typer.echo(content.decode(errors="replace")) \ No newline at end of file + typer.echo(content.decode(errors="replace")) + diff --git a/git_scratch/commands/ls_tree.py b/git_scratch/commands/ls_tree.py new file mode 100644 index 0000000..04772f8 --- /dev/null +++ b/git_scratch/commands/ls_tree.py @@ -0,0 +1,38 @@ + +import typer +from git_scratch.utils 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 + + typer.echo(f"{mode} {oid_hex} {name}") diff --git a/git_scratch/main.py b/git_scratch/main.py index 91c04d3..a3fa662 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -6,6 +6,7 @@ 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.ls_tree import ls_tree from git_scratch.commands.init import init @@ -19,6 +20,7 @@ app.command("init")(init) app.command("ls-files")(ls_files) app.command("show-ref")(show_ref) +app.command("ls-tree")(ls_tree) if __name__ == "__main__": diff --git a/git_scratch/utils.py b/git_scratch/utils.py new file mode 100644 index 0000000..751f52c --- /dev/null +++ b/git_scratch/utils.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 From 0ea404772da5c4a21af73db5d2acc362eb00e321 Mon Sep 17 00:00:00 2001 From: christopher DE PASQUAL Date: Thu, 26 Jun 2025 14:36:49 +0200 Subject: [PATCH 23/54] =?UTF-8?q?feat:=20impl=C3=A9menter=20la=20commande?= =?UTF-8?q?=20commit-tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cette fonctionnalité ajoute la commande `commit-tree` qui permet de créer un object commit. Détails techniques: - Utilise `build_commit_object` pour la logique métier. - Récupère l'identité via `get_author_identity`. - Gère l'OID du parent. --- git_scratch/commands/commit_tree.py | 13 +++++ git_scratch/main.py | 4 ++ git_scratch/utils/__init__.py | 0 git_scratch/utils/logique_commit_tree.py | 38 ++++++++++++++ git_scratch/utils/pit_identity.py | 41 +++++++++++++++ git_scratch/utils/write_object.py | 34 +++++++++++++ pyproject.toml | 4 +- tests/unit/test_commit_tree.py | 65 ++++++++++++++++++++++++ 8 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 git_scratch/commands/commit_tree.py create mode 100644 git_scratch/utils/__init__.py create mode 100644 git_scratch/utils/logique_commit_tree.py create mode 100644 git_scratch/utils/pit_identity.py create mode 100644 git_scratch/utils/write_object.py create mode 100644 tests/unit/test_commit_tree.py diff --git a/git_scratch/commands/commit_tree.py b/git_scratch/commands/commit_tree.py new file mode 100644 index 0000000..5ef9efe --- /dev/null +++ b/git_scratch/commands/commit_tree.py @@ -0,0 +1,13 @@ +import typer +from git_scratch.utils.logique_commit_tree 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. + """ + oid = build_commit_object(tree_oid=tree_oid, message=message, parent_oid=parent) + typer.echo(oid) diff --git a/git_scratch/main.py b/git_scratch/main.py index 2ec1b95..fcee006 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -5,6 +5,8 @@ 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.commit_tree import commit_tree + from git_scratch.commands.init import init @@ -17,6 +19,8 @@ app.command("rm")(rmfile) app.command("init")(init) app.command("ls-files")(ls_files) +app.command("commit-tree")(commit_tree) + if __name__ == "__main__": 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/logique_commit_tree.py b/git_scratch/utils/logique_commit_tree.py new file mode 100644 index 0000000..9d39364 --- /dev/null +++ b/git_scratch/utils/logique_commit_tree.py @@ -0,0 +1,38 @@ +from typing import Optional +from git_scratch.utils.pit_identity import get_author_identity, get_timestamp_info +from git_scratch.utils.write_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/pit_identity.py b/git_scratch/utils/pit_identity.py new file mode 100644 index 0000000..6b7c6eb --- /dev/null +++ b/git_scratch/utils/pit_identity.py @@ -0,0 +1,41 @@ +# git_scratch/pit_identity.py +import os +import configparser +from datetime import datetime +from typing import Tuple + +def get_author_identity() -> Tuple[str, str]: + """ + Get author name and email from environment, .gitconfig or fallback. + Priority: ENV > .gitconfig > defaults + """ + # Step 1: try ENV + name = os.getenv("GIT_AUTHOR_NAME") or os.getenv("GIT_COMMITTER_NAME") + email = os.getenv("GIT_AUTHOR_EMAIL") or os.getenv("GIT_COMMITTER_EMAIL") + + # Step 2: try .gitconfig + if not name or not email: + config = configparser.ConfigParser() + paths = [ + os.path.expanduser("~/.gitconfig"), # global config + os.path.join(os.getcwd(), ".git", "config") # project-level config + ] + for path in paths: + if os.path.exists(path): + 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) + + # Step 3: fallback + name = name or "John Doe" + email = email or "john@example.com" + + return name, email + + +def get_timestamp_info() -> Tuple[int, str]: + now = datetime.now().astimezone() + timestamp = int(now.timestamp()) + timezone = now.strftime('%z') + return timestamp, timezone diff --git a/git_scratch/utils/write_object.py b/git_scratch/utils/write_object.py new file mode 100644 index 0000000..4afb772 --- /dev/null +++ b/git_scratch/utils/write_object.py @@ -0,0 +1,34 @@ +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): + print(f"[write_object] Object {oid} already exists.") + + + os.makedirs(dir_path, exist_ok=True) + with open(file_path, "wb") as f: + f.write(zlib.compress(full_data)) + + return oid diff --git a/pyproject.toml b/pyproject.toml index 54a10db..abcc47f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,9 @@ version = "0.1.0" description = "Reimplémentation de Git en Python" dependencies = [ "typer[all]", - "pytest" + "pytest", + "configparser" + ] [project.optional-dependencies] diff --git a/tests/unit/test_commit_tree.py b/tests/unit/test_commit_tree.py new file mode 100644 index 0000000..94241d4 --- /dev/null +++ b/tests/unit/test_commit_tree.py @@ -0,0 +1,65 @@ +import os +import json +import subprocess +from typer.testing import CliRunner +from git_scratch.main import app + +runner = CliRunner() + +def test_commit_tree_oid_matches_git(tmp_path): + """ + Vérifie que pit commit-tree retourne le même OID que git commit-tree + """ + # Aller dans le bon dossier temporaire + os.chdir(tmp_path) + + # Initialiser le dépôt avec pit + result_init = runner.invoke(app, ["init"]) + assert result_init.exit_code == 0 + + # Créer un fichier + file = tmp_path / "file.txt" + content = "Hello Commit Tree" + file.write_text(content) + + # Ajouter avec Git + subprocess.run(["git", "init"], check=True) + subprocess.run(["git", "add", "file.txt"], check=True) + result_tree = subprocess.run(["git", "write-tree"], capture_output=True, text=True, check=True) + tree_oid_git = result_tree.stdout.strip() + + env = os.environ.copy() + env.update({ + "GIT_AUTHOR_NAME": "TestUser", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "TestUser", + "GIT_COMMITTER_EMAIL": "test@example.com", + }) + result_commit = subprocess.run( + ["git", "commit-tree", tree_oid_git, "-m", "Initial commit"], + capture_output=True, + text=True, + check=True, + env=env, + ) + commit_oid_git = result_commit.stdout.strip() + + # Créer index.json pour pit + index_path = tmp_path / ".git" / "index.json" + oid = runner.invoke(app, ["hash-object", "file.txt", "--write"]).stdout.strip() + index_path.write_text(json.dumps([ + { + "path": "file.txt", + "oid": oid, + "mode": "100644" + } + ])) + + # Générer avec pit + tree_oid_pit = runner.invoke(app, ["write-tree"]).stdout.strip() + commit_oid_pit = runner.invoke(app, [ + "commit-tree", tree_oid_pit, "-m", "Initial commit" + ]).stdout.strip() + + assert tree_oid_pit == tree_oid_git + assert commit_oid_pit == commit_oid_git From 8fcb35ff41189d864b8a1c25c3fb2a76d3ac2692 Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Thu, 26 Jun 2025 15:07:30 +0200 Subject: [PATCH 24/54] feat: Adding unit test for ls-tree (may need to be redone) --- tests/unit/test_ls_tree.py | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/unit/test_ls_tree.py diff --git a/tests/unit/test_ls_tree.py b/tests/unit/test_ls_tree.py new file mode 100644 index 0000000..0dd1c59 --- /dev/null +++ b/tests/unit/test_ls_tree.py @@ -0,0 +1,53 @@ + +import pytest +from typer.testing import CliRunner +from git_scratch.main import app + +runner = CliRunner() + + +def make_tree_entry(mode: str, name: str, oid_hex: str) -> bytes: + mode_bytes = mode.encode() + name_bytes = name.encode() + oid_bytes = bytes.fromhex(oid_hex) + return mode_bytes + b' ' + name_bytes + b'\x00' + oid_bytes + + +@pytest.fixture +def mock_read_object(monkeypatch): + def _mock(oid): + if oid == "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef": + entry = make_tree_entry("100644", "file.txt", "0123456789abcdef0123456789abcdef01234567") + return "tree", entry + raise FileNotFoundError() + + # patch au bon endroit : là où `ls_tree` importe read_object + monkeypatch.setattr("git_scratch.commands.ls_tree.read_object", _mock) + +def test_ls_tree_valid_oid(mock_read_object): + result = runner.invoke(app, ["ls-tree", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"]) + assert result.exit_code == 0 + assert "100644 0123456789abcdef0123456789abcdef01234567 file.txt" in result.output + + +def test_ls_tree_oid_not_found(monkeypatch): + def mock_not_found(oid): + raise FileNotFoundError() + + monkeypatch.setattr("git_scratch.commands.ls_tree.read_object", mock_not_found) + + result = runner.invoke(app, ["ls-tree", "notfound0000000000000000000000000000000000"]) + assert result.exit_code == 1 + assert "Error: Object notfound0000000000000000000000000000000000 not found." in result.output + + +def test_ls_tree_oid_wrong_type(monkeypatch): + def mock_wrong_type(oid): + return "blob", b"some content" + + monkeypatch.setattr("git_scratch.commands.ls_tree.read_object", mock_wrong_type) + + result = runner.invoke(app, ["ls-tree", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]) + assert result.exit_code == 1 + assert "Error: Object aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is not a tree." in result.output + From 753f26c0bc2ceaf542b2916a86a512f113d17e5a Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Sun, 29 Jun 2025 14:05:06 +0200 Subject: [PATCH 25/54] refactor: Creating the utils/ folder --- git_scratch/commands/cat_file.py | 2 +- git_scratch/{utils.py => utils/read_object.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename git_scratch/{utils.py => utils/read_object.py} (100%) diff --git a/git_scratch/commands/cat_file.py b/git_scratch/commands/cat_file.py index 84e5b35..2d9a4f8 100644 --- a/git_scratch/commands/cat_file.py +++ b/git_scratch/commands/cat_file.py @@ -1,6 +1,6 @@ import typer -from git_scratch.utils import read_object # Assure-toi que le chemin est correct +from git_scratch.utils.read_object import read_object # Assure-toi que le chemin est correct def cat_file( oid: str = typer.Argument(..., help="SHA-1 object ID to inspect."), diff --git a/git_scratch/utils.py b/git_scratch/utils/read_object.py similarity index 100% rename from git_scratch/utils.py rename to git_scratch/utils/read_object.py From 681b6993fbd8ffac4fb4c518fe0acda955d86c83 Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Sun, 29 Jun 2025 14:22:41 +0200 Subject: [PATCH 26/54] refactor: extrating the logic of hash-object to the utils/ folder --- git_scratch/commands/cat_file.py | 2 +- git_scratch/commands/hash_object.py | 14 +++----------- git_scratch/utils/hash.py | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 git_scratch/utils/hash.py diff --git a/git_scratch/commands/cat_file.py b/git_scratch/commands/cat_file.py index 2d9a4f8..7afb9ae 100644 --- a/git_scratch/commands/cat_file.py +++ b/git_scratch/commands/cat_file.py @@ -1,6 +1,6 @@ import typer -from git_scratch.utils.read_object import read_object # Assure-toi que le chemin est correct +from git_scratch.utils.read_object import read_object def cat_file( oid: str = typer.Argument(..., help="SHA-1 object ID to inspect."), diff --git a/git_scratch/commands/hash_object.py b/git_scratch/commands/hash_object.py index 4ab285c..3e06505 100644 --- a/git_scratch/commands/hash_object.py +++ b/git_scratch/commands/hash_object.py @@ -1,7 +1,6 @@ -import hashlib import os -import zlib import typer +from git_scratch.utils.hash import compute_blob_hash, write_object def hash_object( file_path: str = typer.Argument(..., help="Path to the file to hash."), @@ -17,16 +16,9 @@ def hash_object( 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() + oid, full_data = compute_blob_hash(content) if write: - 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)) + write_object(oid, full_data) typer.echo(oid) - diff --git a/git_scratch/utils/hash.py b/git_scratch/utils/hash.py new file mode 100644 index 0000000..cf871db --- /dev/null +++ b/git_scratch/utils/hash.py @@ -0,0 +1,17 @@ + +import hashlib +import os +import zlib + +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 + +def write_object(oid: str, full_data: bytes): + 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)) From 8a2a89cc6dc0629a12586648cf3db5fb80a7d80e Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Sun, 29 Jun 2025 14:52:12 +0200 Subject: [PATCH 27/54] refactor: Changing the output of the ls-tree command to match git and changing the tests --- git_scratch/commands/ls_tree.py | 7 ++-- tests/unit/test_ls_tree.py | 66 +++++++++++++++++---------------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/git_scratch/commands/ls_tree.py b/git_scratch/commands/ls_tree.py index 04772f8..c60b032 100644 --- a/git_scratch/commands/ls_tree.py +++ b/git_scratch/commands/ls_tree.py @@ -1,6 +1,6 @@ import typer -from git_scratch.utils import read_object +from git_scratch.utils.read_object import read_object def ls_tree( oid: str = typer.Argument(..., help="OID of the tree object") @@ -34,5 +34,6 @@ def ls_tree( oid_raw = content[i:i+20] oid_hex = oid_raw.hex() i += 20 - - typer.echo(f"{mode} {oid_hex} {name}") + + obj_type = "blob" if mode.startswith("10") else "tree" + typer.echo(f"{mode} {obj_type} {oid_hex}\t{name}") diff --git a/tests/unit/test_ls_tree.py b/tests/unit/test_ls_tree.py index 0dd1c59..a03aae0 100644 --- a/tests/unit/test_ls_tree.py +++ b/tests/unit/test_ls_tree.py @@ -1,53 +1,57 @@ - -import pytest 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 + return mode_bytes + b" " + name_bytes + b"\x00" + oid_bytes -@pytest.fixture -def mock_read_object(monkeypatch): - def _mock(oid): - if oid == "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef": - entry = make_tree_entry("100644", "file.txt", "0123456789abcdef0123456789abcdef01234567") - return "tree", entry - raise FileNotFoundError() +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 - # patch au bon endroit : là où `ls_tree` importe read_object - monkeypatch.setattr("git_scratch.commands.ls_tree.read_object", _mock) + monkeypatch.setattr("git_scratch.commands.ls_tree.read_object", mock_read_object) -def test_ls_tree_valid_oid(mock_read_object): - result = runner.invoke(app, ["ls-tree", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"]) + result = runner.invoke(app, ["ls-tree", "abc123"]) assert result.exit_code == 0 - assert "100644 0123456789abcdef0123456789abcdef01234567 file.txt" in result.output - -def test_ls_tree_oid_not_found(monkeypatch): - def mock_not_found(oid): - raise FileNotFoundError() + expected_output = "100644 blob 0123456789abcdef0123456789abcdef01234567\tfile.txt" + assert expected_output in result.output - monkeypatch.setattr("git_scratch.commands.ls_tree.read_object", mock_not_found) - result = runner.invoke(app, ["ls-tree", "notfound0000000000000000000000000000000000"]) - assert result.exit_code == 1 - assert "Error: Object notfound0000000000000000000000000000000000 not found." 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) -def test_ls_tree_oid_wrong_type(monkeypatch): - def mock_wrong_type(oid): - return "blob", b"some content" + # Récupère l'OID de l'arbre racine HEAD avec Git + tree_oid = subprocess.check_output(["git", "rev-parse", "HEAD^{tree}"]).decode().strip() - monkeypatch.setattr("git_scratch.commands.ls_tree.read_object", mock_wrong_type) + # Exécute la commande git ls-tree + git_output = subprocess.check_output(["git", "ls-tree", tree_oid]).decode().strip() - result = runner.invoke(app, ["ls-tree", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]) - assert result.exit_code == 1 - assert "Error: Object aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is not a tree." in result.output + # 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}" From 361c4d1fbfb3ec0d69450a32ff9fd7647035001f Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Sun, 29 Jun 2025 15:19:49 +0200 Subject: [PATCH 28/54] refactor: extracting the index logic from the 'add' and 'rm' commands and refactoring their code --- git_scratch/commands/add.py | 28 +++++-------------- git_scratch/commands/rmfile.py | 48 ++++++-------------------------- git_scratch/utils/index_utils.py | 44 +++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 61 deletions(-) create mode 100644 git_scratch/utils/index_utils.py diff --git a/git_scratch/commands/add.py b/git_scratch/commands/add.py index 7b4228b..f919fc4 100644 --- a/git_scratch/commands/add.py +++ b/git_scratch/commands/add.py @@ -1,25 +1,17 @@ -import json + import os import typer import hashlib import zlib -import stat +from git_scratch.utils.index_utils import load_index, save_index, compute_mode app = typer.Typer() -def compute_mode(file_path): - 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" - def add_file_to_index(file_path: str): with open(file_path, "rb") as f: content = f.read() + # Prépare et compresse l’objet blob header = f"blob {len(content)}\0".encode() full_data = header + content oid = hashlib.sha1(full_data).hexdigest() @@ -30,13 +22,8 @@ def add_file_to_index(file_path: str): with open(obj_path, "wb") as f: f.write(zlib.compress(full_data)) - index_path = os.path.join(".git", "index.json") - if os.path.exists(index_path) and os.path.getsize(index_path) > 0: - with open(index_path, "r") as f: - index = json.load(f) - else: - index = [] - + # Charge et modifie l’index + index = load_index() rel_path = os.path.relpath(file_path) mode = compute_mode(file_path) @@ -49,9 +36,7 @@ def add_file_to_index(file_path: str): index = [e for e in index if e["path"] != rel_path] index.append(entry) - with open(index_path, "w") as f: - json.dump(index, f, indent=2) - + save_index(index) typer.echo(f"{rel_path} added to index with OID {oid} and mode {mode}") @app.command() @@ -80,3 +65,4 @@ def add(file_path: str = typer.Argument(..., help="Path to file or directory to if __name__ == "__main__": app() + diff --git a/git_scratch/commands/rmfile.py b/git_scratch/commands/rmfile.py index 450ce75..bc7ea99 100644 --- a/git_scratch/commands/rmfile.py +++ b/git_scratch/commands/rmfile.py @@ -1,44 +1,16 @@ + import os -import json -from pathlib import Path import typer - -def get_index_path(): - """ - Returns the path to the .git/index.json file dynamically, - based on the current working directory. - """ - return Path(os.getcwd()) / ".git" / "index.json" - -def load_index(): - """ - Load the index.json file from .git/ and return its content. - If the file does not exist, return an empty list. - """ - 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) +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.basename(file_path) + filename = os.path.relpath(file_path) - # --- Step 1: Remove the file from the working directory --- + # --- É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: @@ -49,17 +21,13 @@ def rmfile(file_path: str): typer.echo(f"Error while removing file: {e}") raise typer.Exit(code=1) - # --- Step 2: Remove from index --- - new_index = [] - removed = False - for entry in index: - if entry["path"] == filename: - removed = True - else: - new_index.append(entry) + # --- É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/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" + From fd5a72b908052c5b1f9b396889f23267ebe63f21 Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Sun, 29 Jun 2025 15:39:19 +0200 Subject: [PATCH 29/54] refactor: adding the --help to the show-ref command and changing the index loading in the write-tree command --- git_scratch/commands/show_ref.py | 3 +++ git_scratch/commands/write_tree.py | 20 ++++++++------------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/git_scratch/commands/show_ref.py b/git_scratch/commands/show_ref.py index ed60c3b..929af4d 100644 --- a/git_scratch/commands/show_ref.py +++ b/git_scratch/commands/show_ref.py @@ -10,6 +10,9 @@ def resolve_ref(path): return content def show_ref(): + """ + List references in a local repository + """ refs = {} # Refs dans .git/refs diff --git a/git_scratch/commands/write_tree.py b/git_scratch/commands/write_tree.py index 2c82cd5..a354de6 100644 --- a/git_scratch/commands/write_tree.py +++ b/git_scratch/commands/write_tree.py @@ -1,13 +1,15 @@ + import os -import json import hashlib import zlib import typer from typing import List, Dict, Tuple - -app = typer.Typer() +from git_scratch.utils.index_utils import load_index def store_object(data: bytes, type_: str) -> str: + """ + Stocke un objet Git compressé et retourne son OID (SHA-1). + """ header = f"{type_} {len(data)}\0".encode() full_data = header + data oid = hashlib.sha1(full_data).hexdigest() @@ -52,22 +54,16 @@ def build_tree(entries: List[Dict], base_path: str = "") -> bytes: return result -@app.command() def write_tree(): """ Writes a recursive Git tree from .git/index.json and displays its OID. """ - index_path = os.path.join(".git", "index.json") - if not os.path.exists(index_path): - typer.secho("Erreur : .git/index.json not found..", fg=typer.colors.RED) + 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) - with open(index_path, "r") as f: - index = json.load(f) - tree_data = build_tree(index) oid = store_object(tree_data, "tree") typer.echo(f"Tree OID: {oid}") -if __name__ == "__main__": - app() From 12c66ee78f430cf366bf0cbbfc802e82bfed574b Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Mon, 30 Jun 2025 16:01:15 +0200 Subject: [PATCH 30/54] feat: add rev-parse cmd & test --- git_scratch/commands/rev_parse.py | 96 ++++++++++++++++++++++++++++++- tests/unit/test_rev_parse.py | 44 ++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_rev_parse.py diff --git a/git_scratch/commands/rev_parse.py b/git_scratch/commands/rev_parse.py index f99ab77..8d7fad3 100644 --- a/git_scratch/commands/rev_parse.py +++ b/git_scratch/commands/rev_parse.py @@ -1,7 +1,99 @@ import os +import re +import pathlib import typer + def rev_parse( - folder: str = typer.Argument(None, help="Path to the folder where to initialize the repository") + ref: str = typer.Argument(..., help="Reference to resolve (branch, tag, SHA, HEAD)"), ): - print("Initializing repository...") \ No newline at end of file + """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/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 From e1ec5ea99bdbc15be8ac1b3d9ae9d85b4a45742f Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Mon, 30 Jun 2025 16:23:06 +0200 Subject: [PATCH 31/54] feat: create workflows --- .github/workflows/pytest.yml | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/pytest.yml diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..f88da83 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,37 @@ +name: Tests Python + +on: + push: + branches: ["**"] + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + # 1) Récupération du code + - name: Checkout + uses: actions/checkout@v4 + + # 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('requirements.txt') }} + 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 From b0904f81c72f38612e309bde8eeed10a4eedc8d8 Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Mon, 30 Jun 2025 16:26:43 +0200 Subject: [PATCH 32/54] fix: add identity for pytest --- .github/workflows/pytest.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index f88da83..87d26d5 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -13,6 +13,11 @@ jobs: - 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 From 9c490a7ae3dc4f743a5103cba4f8ebef6fc3a11c Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Mon, 30 Jun 2025 16:31:40 +0200 Subject: [PATCH 33/54] fix: update cache pip step with correctly install --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 87d26d5..774c595 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -29,7 +29,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} + key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml', 'setup.cfg', 'setup.py') }} restore-keys: | ${{ runner.os }}-pip- From 1c4525a2eaf19930314e925ab304d1f6f3ee413e Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Mon, 30 Jun 2025 16:39:20 +0200 Subject: [PATCH 34/54] feat --- git_scratch/commands/cat_file.py | 59 ++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/git_scratch/commands/cat_file.py b/git_scratch/commands/cat_file.py index b30a86a..410ff12 100644 --- a/git_scratch/commands/cat_file.py +++ b/git_scratch/commands/cat_file.py @@ -2,34 +2,49 @@ import zlib import typer +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: bool = typer.Option(False, "-t", help="Show the type of the object."), + 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 or pretty): - typer.secho("Error: You must specify either -t or -p.", fg=typer.colors.RED) - raise typer.Exit(code=1) + 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()): - typer.secho(f"Error: Invalid OID format: {oid}", fg=typer.colors.RED) - raise typer.Exit(code=1) + error(f"Invalid OID format: {oid}") obj_path = os.path.join(".git", "objects", oid[:2], oid[2:]) if not os.path.exists(obj_path): - typer.secho(f"Error: Object {oid} not found in .git/objects.", fg=typer.colors.RED) - raise typer.Exit(code=1) + error(f"Object {oid} not found in .git/objects.") - with open(obj_path, "rb") as f: - compressed_data = f.read() try: - full_data = zlib.decompress(compressed_data) + with open(obj_path, "rb") as f: + full_data = zlib.decompress(f.read()) except zlib.error: - typer.secho("Error: Failed to decompress Git object.", fg=typer.colors.RED) - raise typer.Exit(code=1) + error("Failed to decompress Git object.") try: header_end = full_data.index(b'\x00') @@ -38,17 +53,17 @@ def cat_file( obj_type, size_str = header.split() size = int(size_str) except Exception: - typer.secho("Error: Invalid Git object format.", fg=typer.colors.RED) - raise typer.Exit(code=1) + error("Invalid Git object format.") if len(content) != size: - typer.secho("Error: Size mismatch in object.", fg=typer.colors.RED) - raise typer.Exit(code=1) + error("Size mismatch in object.") - if type: + if type_opt: typer.echo(obj_type) elif pretty: - if obj_type != "blob": - typer.secho(f"Error: Pretty-print not supported for object type '{obj_type}'.", fg=typer.colors.RED) - raise typer.Exit(code=1) - typer.echo(content.decode(errors="replace")) \ No newline at end of file + 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}'.") From e56edc2771c8887c9006b9961380e582d178955e Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Mon, 30 Jun 2025 22:36:02 +0200 Subject: [PATCH 35/54] feat adapted Cat-file to main logics --- git_scratch/commands/cat_file.py | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/git_scratch/commands/cat_file.py b/git_scratch/commands/cat_file.py index ea266b4..ff6a810 100644 --- a/git_scratch/commands/cat_file.py +++ b/git_scratch/commands/cat_file.py @@ -1,5 +1,5 @@ - import typer +from git_scratch.utils.read_object import read_object def error(msg: str): typer.secho(f"Error: {msg}", fg=typer.colors.RED) @@ -35,27 +35,12 @@ def cat_file( if len(oid) != 40 or not all(c in "0123456789abcdef" for c in oid.lower()): error(f"Invalid OID format: {oid}") - obj_path = os.path.join(".git", "objects", oid[:2], oid[2:]) - if not os.path.exists(obj_path): - error(f"Object {oid} not found in .git/objects.") - try: - with open(obj_path, "rb") as f: - full_data = zlib.decompress(f.read()) - except zlib.error: - error("Failed to decompress Git object.") - - try: - header_end = full_data.index(b'\x00') - header = full_data[:header_end].decode() - content = full_data[header_end + 1:] - obj_type, size_str = header.split() - size = int(size_str) + obj_type, content = read_object(oid) + except FileNotFoundError: + error(f"Object {oid} not found.") except Exception: - error("Invalid Git object format.") - - if len(content) != size: - error("Size mismatch in object.") + error("Failed to read Git object.") if type_opt: typer.echo(obj_type) From d3013ff488acb0d7ca0b830f6bed17f758234843 Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Mon, 30 Jun 2025 22:40:04 +0200 Subject: [PATCH 36/54] feat adapted Cat-file to main logics --- git_scratch/commands/cat_file.py | 39 ++++++++------------------------ 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/git_scratch/commands/cat_file.py b/git_scratch/commands/cat_file.py index 092e223..0bf71cd 100644 --- a/git_scratch/commands/cat_file.py +++ b/git_scratch/commands/cat_file.py @@ -1,4 +1,3 @@ - import typer from git_scratch.utils.read_object import read_object @@ -36,37 +35,19 @@ def cat_file( if len(oid) != 40 or not all(c in "0123456789abcdef" for c in oid.lower()): error(f"Invalid OID format: {oid}") - obj_path = os.path.join(".git", "objects", oid[:2], oid[2:]) - if not os.path.exists(obj_path): - typer.secho(f"Error: Object {oid} not found in .git/objects.", fg=typer.colors.RED) - raise typer.Exit(code=1) - - with open(obj_path, "rb") as f: - compressed_data = f.read() try: - full_data = zlib.decompress(compressed_data) - except zlib.error: - typer.secho("Error: Failed to decompress Git object.", fg=typer.colors.RED) - raise typer.Exit(code=1) - - try: - header_end = full_data.index(b'\x00') - header = full_data[:header_end].decode() - content = full_data[header_end + 1:] - obj_type, size_str = header.split() - size = int(size_str) + obj_type, content = read_object(oid) + except FileNotFoundError: + error(f"Object {oid} not found.") except Exception: - typer.secho("Error: Invalid Git object format.", fg=typer.colors.RED) - raise typer.Exit(code=1) - - if len(content) != size: - typer.secho("Error: Size mismatch in object.", fg=typer.colors.RED) - raise typer.Exit(code=1) + error("Failed to read Git object.") if type_opt: typer.echo(obj_type) elif pretty: - if obj_type != "blob": - typer.secho(f"Error: Pretty-print not supported for object type '{obj_type}'.", fg=typer.colors.RED) - raise typer.Exit(code=1) - typer.echo(content.decode(errors="replace")) \ No newline at end of file + 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}'.") \ No newline at end of file From de39e8b0e6d053c596de083e3014799d2a7fb48f Mon Sep 17 00:00:00 2001 From: christopher DE PASQUAL Date: Wed, 2 Jul 2025 01:12:10 +0200 Subject: [PATCH 37/54] Suppression de hetic_git dans le nom des imports --- git_scratch/commands/commit_tree.py | 2 +- git_scratch/utils/commit.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/git_scratch/commands/commit_tree.py b/git_scratch/commands/commit_tree.py index f67e937..2ec7396 100644 --- a/git_scratch/commands/commit_tree.py +++ b/git_scratch/commands/commit_tree.py @@ -1,5 +1,5 @@ import typer -from hetic_git.git_scratch.utils.commit import build_commit_object +from git_scratch.utils.commit import build_commit_object def commit_tree( tree_oid: str = typer.Argument(..., help="OID of the tree object."), diff --git a/git_scratch/utils/commit.py b/git_scratch/utils/commit.py index b2d9982..3266e6b 100644 --- a/git_scratch/utils/commit.py +++ b/git_scratch/utils/commit.py @@ -1,5 +1,5 @@ from typing import Optional -from hetic_git.git_scratch.utils.identity import get_author_identity, get_timestamp_info +from git_scratch.utils.identity import get_author_identity, get_timestamp_info from git_scratch.utils.write_object import write_object From 441c0e87bfad8ea5bbcd7b9895676ee887b530a1 Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Wed, 2 Jul 2025 14:15:27 +0200 Subject: [PATCH 38/54] feat: add new OS test workflow --- .github/workflows/pytest.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 774c595..2770e29 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -6,7 +6,11 @@ on: jobs: tests: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} steps: # 1) Récupération du code From f4296d426e8d24a2323364b5c79ce69e9718943f Mon Sep 17 00:00:00 2001 From: christopher DE PASQUAL Date: Wed, 2 Jul 2025 14:20:22 +0200 Subject: [PATCH 39/54] Fix : correction de l'erreur dans les tests Modification du fichier `write_tree` pour ajouter le mot avant l'OID Pas une bonne pratique --- git_scratch/commands/write_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git_scratch/commands/write_tree.py b/git_scratch/commands/write_tree.py index d9dbab1..a354de6 100644 --- a/git_scratch/commands/write_tree.py +++ b/git_scratch/commands/write_tree.py @@ -65,5 +65,5 @@ def write_tree(): tree_data = build_tree(index) oid = store_object(tree_data, "tree") - typer.echo(oid) + typer.echo(f"Tree OID: {oid}") From 83b73d0df5454b6e67d9b4f897cf09f5d24ecba1 Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Wed, 2 Jul 2025 14:21:42 +0200 Subject: [PATCH 40/54] fix: show_ref.py now compatible with windows os (after test) --- git_scratch/commands/show_ref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git_scratch/commands/show_ref.py b/git_scratch/commands/show_ref.py index 929af4d..277f5fa 100644 --- a/git_scratch/commands/show_ref.py +++ b/git_scratch/commands/show_ref.py @@ -20,7 +20,7 @@ def show_ref(): 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") + rel_path = os.path.relpath(ref_path, ".git").replace(os.sep, "/") oid = resolve_ref(ref_path) refs[rel_path] = oid From 054c23f14aa3826ff7937e9a78503c53cbb06efd Mon Sep 17 00:00:00 2001 From: Amaury057 Date: Mon, 7 Jul 2025 17:40:15 +0200 Subject: [PATCH 41/54] feat(status): creation de pit status --- git_scratch/commands/status.py | 74 ++++++++++++++++++++++++++++++++ git_scratch/main.py | 2 + tests/unit/test_status.py | 77 ++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 git_scratch/commands/status.py create mode 100644 tests/unit/test_status.py diff --git a/git_scratch/commands/status.py b/git_scratch/commands/status.py new file mode 100644 index 0000000..4e9561b --- /dev/null +++ b/git_scratch/commands/status.py @@ -0,0 +1,74 @@ +import os +import json +from pathlib import Path +import typer +import hashlib +import pathspec +from git_scratch.utils.index_utils import load_index, get_index_path + +def git_hash_object(file_path): + with open(file_path, "rb") as f: + content = f.read() + header = f"blob {len(content)}\0".encode() + store = header + content + return hashlib.sha1(store).hexdigest() + +def list_project_files(): + # Liste tous les fichiers du projet, sauf ceux dans .git + return [str(f) for f in Path(".").rglob("*") if f.is_file() and ".git" not in f.parts] + +def load_gitignore_spec(): + # Charge le .gitignore avec pathspec + 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 status(): + import time; t0 = time.time() + index = load_index() + tracked_files = {item['path']: item['oid'] for item in index} + project_files = list_project_files() + spec = load_gitignore_spec() + + modified = [] + staged = [] + untracked = [] + + for file_path in project_files: + # On ignore les fichiers dans .gitignore + if spec.match_file(file_path): + continue + + if file_path in tracked_files: + current_hash = git_hash_object(file_path) + if current_hash == tracked_files[file_path]: + staged.append(file_path) + else: + modified.append(file_path) + else: + untracked.append(file_path) + + if staged: + typer.echo(typer.style("Changes to be committed:", fg=typer.colors.GREEN)) + typer.echo(typer.style(" (use \"pit reset ...\" to unstage)\n", fg=typer.colors.GREEN)) + for f in staged: + typer.echo(typer.style(f" added: {f}", fg=typer.colors.GREEN)) + + if modified: + typer.echo(typer.style("Changes not staged for commit:", fg=typer.colors.RED)) + typer.echo(typer.style(" (use \"pit add ...\" to update what will be committed)", fg=typer.colors.RED)) + typer.echo(typer.style(" (use \"pit restore ...\" to discard changes in working directory)\n", fg=typer.colors.RED)) + for f in modified: + typer.echo(typer.style(f" modified: {f}", fg=typer.colors.RED)) + + if untracked: + typer.echo(typer.style("Untracked files:", fg=typer.colors.RED)) + typer.echo(typer.style(" (use \"pit add ...\" to include in what will be committed)\n", fg=typer.colors.RED)) + for f in untracked: + typer.echo(typer.style(f" {f}", fg=typer.colors.RED)) + + if not staged and not modified and not untracked: + typer.echo("Aucun fichier modifié détecté.") + print("Temps total:", time.time()-t0) diff --git a/git_scratch/main.py b/git_scratch/main.py index b3988f9..1fe1661 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -8,6 +8,7 @@ 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.status import status from git_scratch.commands.init import init @@ -23,6 +24,7 @@ app.command("rev-parse")(rev_parse) app.command("show-ref")(show_ref) app.command("ls-tree")(ls_tree) +app.command("status")(status) if __name__ == "__main__": 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 From 7d89f73f126fcd413e1ef87ac77e341423f4e7fe Mon Sep 17 00:00:00 2001 From: christopher DE PASQUAL Date: Mon, 7 Jul 2025 17:43:47 +0200 Subject: [PATCH 42/54] Fix : Utilisation de `object.py` dans les scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Séparation de la CLI de write-tree et utilisation de la fonction write_object dans les logiques métiers `hash_object.py`, `commit_tree.py` ainsi que `write_tree.py`. Correction aussi apporté au fichier de test de tree. Le but est de réutiliser la fonction `write_object` pour une meilleur maintenabilité. --- git_scratch/commands/commit_tree.py | 17 ++++- git_scratch/commands/hash_object.py | 11 ++-- git_scratch/commands/write_tree.py | 58 ++--------------- git_scratch/utils/commit.py | 2 +- git_scratch/utils/hash.py | 9 --- git_scratch/utils/identity.py | 26 ++++---- .../utils/{write_object.py => object.py} | 5 +- git_scratch/utils/porcelain_commit.py | 65 +++++++++++++++++++ git_scratch/utils/tree.py | 36 ++++++++++ tests/unit/test_write_tree.py | 2 +- 10 files changed, 144 insertions(+), 87 deletions(-) rename git_scratch/utils/{write_object.py => object.py} (91%) create mode 100644 git_scratch/utils/porcelain_commit.py create mode 100644 git_scratch/utils/tree.py diff --git a/git_scratch/commands/commit_tree.py b/git_scratch/commands/commit_tree.py index 2ec7396..2e2dd52 100644 --- a/git_scratch/commands/commit_tree.py +++ b/git_scratch/commands/commit_tree.py @@ -9,5 +9,18 @@ def commit_tree( """ Create a commit object pointing to a tree and optional parent commit. """ - oid = build_commit_object(tree_oid=tree_oid, message=message, parent_oid=parent) - typer.echo(oid) + 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 index 3e06505..06129da 100644 --- a/git_scratch/commands/hash_object.py +++ b/git_scratch/commands/hash_object.py @@ -1,6 +1,8 @@ import os import typer -from git_scratch.utils.hash import compute_blob_hash, write_object +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."), @@ -15,10 +17,9 @@ def hash_object( with open(file_path, 'rb') as f: content = f.read() - - oid, full_data = compute_blob_hash(content) - + + oid, _ = compute_blob_hash(content) if write: - write_object(oid, full_data) + write_object(content, "blob") typer.echo(oid) diff --git a/git_scratch/commands/write_tree.py b/git_scratch/commands/write_tree.py index a354de6..d493c73 100644 --- a/git_scratch/commands/write_tree.py +++ b/git_scratch/commands/write_tree.py @@ -1,58 +1,8 @@ -import os -import hashlib -import zlib import typer -from typing import List, Dict, Tuple from git_scratch.utils.index_utils import load_index - -def store_object(data: bytes, type_: str) -> str: - """ - Stocke un objet Git compressé et retourne son OID (SHA-1). - """ - header = f"{type_} {len(data)}\0".encode() - full_data = header + data - 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)) - - return oid - -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 = store_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 +from git_scratch.utils.tree import build_tree +from git_scratch.utils.object import write_object def write_tree(): """ @@ -64,6 +14,6 @@ def write_tree(): raise typer.Exit(code=1) tree_data = build_tree(index) - oid = store_object(tree_data, "tree") - typer.echo(f"Tree OID: {oid}") + oid = write_object(tree_data, "tree") + typer.echo(oid) diff --git a/git_scratch/utils/commit.py b/git_scratch/utils/commit.py index 3266e6b..4d1d35c 100644 --- a/git_scratch/utils/commit.py +++ b/git_scratch/utils/commit.py @@ -1,6 +1,6 @@ from typing import Optional from git_scratch.utils.identity import get_author_identity, get_timestamp_info -from git_scratch.utils.write_object import write_object +from git_scratch.utils.object import write_object def build_commit_object( diff --git a/git_scratch/utils/hash.py b/git_scratch/utils/hash.py index cf871db..25c89a7 100644 --- a/git_scratch/utils/hash.py +++ b/git_scratch/utils/hash.py @@ -1,17 +1,8 @@ import hashlib -import os -import zlib 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 - -def write_object(oid: str, full_data: bytes): - 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)) diff --git a/git_scratch/utils/identity.py b/git_scratch/utils/identity.py index 7c22244..0a83e55 100644 --- a/git_scratch/utils/identity.py +++ b/git_scratch/utils/identity.py @@ -12,7 +12,12 @@ def get_author_identity() -> Tuple[str, str]: 1. Environment variables 2. Repository config (.git/config) 3. Global config (~/.gitconfig) - If none found, raise an error similar to Git. + + 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") @@ -31,24 +36,21 @@ def get_author_identity() -> Tuple[str, str]: name = name or config.get("user", "name", fallback=None) email = email or config.get("user", "email", fallback=None) except configparser.Error as e: - typer.secho(f"[warn] Failed to parse config at {path}: {e}", fg=typer.colors.YELLOW) + print(f"[warn] Failed to parse config at {path}: {e}") if not name or not email: - typer.secho( - "*** Please tell me who you are.\n\n" - "Run\n" - ' git config --global user.email "you@example.com"\n' - ' git config --global user.name "Your Name"\n\n' - "to set your account's default identity.\n" - "Omit --global to set the identity only in this repository.\n", - fg=typer.colors.RED - ) - raise typer.Exit(code=1) + 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') diff --git a/git_scratch/utils/write_object.py b/git_scratch/utils/object.py similarity index 91% rename from git_scratch/utils/write_object.py rename to git_scratch/utils/object.py index 4afb772..add46e4 100644 --- a/git_scratch/utils/write_object.py +++ b/git_scratch/utils/object.py @@ -24,11 +24,10 @@ def write_object(content: bytes, obj_type: str) -> str: file_path = os.path.join(dir_path, oid[2:]) if os.path.exists(file_path): - print(f"[write_object] Object {oid} already exists.") - + return oid os.makedirs(dir_path, exist_ok=True) with open(file_path, "wb") as f: f.write(zlib.compress(full_data)) - return oid + 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/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/tests/unit/test_write_tree.py b/tests/unit/test_write_tree.py index 862e399..c50c145 100644 --- a/tests/unit/test_write_tree.py +++ b/tests/unit/test_write_tree.py @@ -4,6 +4,7 @@ from typer.testing import CliRunner from git_scratch.main import app + runner = CliRunner() def setup_git_repo(tmp_path): @@ -37,7 +38,6 @@ def test_write_tree(tmp_path, monkeypatch): result = runner.invoke(app, ["write-tree"]) assert result.exit_code == 0, f"CLI failed with exit code {result.exit_code}" - assert "Tree OID: " in result.stdout, "Expected Tree OID in CLI output" oid = result.stdout.strip().split()[-1] obj_path = git_dir / "objects" / oid[:2] / oid[2:] From ba26a7edb2695b26b293a70950bf068e3b94ccb0 Mon Sep 17 00:00:00 2001 From: Amaury057 Date: Mon, 7 Jul 2025 18:16:01 +0200 Subject: [PATCH 43/54] pytest --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 54a10db..90d6fce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,8 @@ version = "0.1.0" description = "Reimplémentation de Git en Python" dependencies = [ "typer[all]", - "pytest" + "pytest", + "pathspec" ] [project.optional-dependencies] From 370edce6d311663d681b28bd0a22fd59cec482fa Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Mon, 21 Jul 2025 12:25:24 +0200 Subject: [PATCH 44/54] feat: Adding the log command, not tracking every commit --- git_scratch/commands/log.py | 82 +++++++++++++++++++++++++++++++++++++ git_scratch/main.py | 4 +- 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 git_scratch/commands/log.py diff --git a/git_scratch/commands/log.py b/git_scratch/commands/log.py new file mode 100644 index 0000000..582b06d --- /dev/null +++ b/git_scratch/commands/log.py @@ -0,0 +1,82 @@ + +import typer +import os +from git_scratch.utils.read_object import read_object + +def parse_commit(content: bytes) -> dict: + 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 == "": + # Message starts after the empty line + commit_data["message"] = "\n".join(lines[i+1:]).strip() + break + i += 1 + + return commit_data + + +def log(): + 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 + + # Affiche l'oid pour vérifier + typer.echo(f"Starting from commit OID: {oid}") + + 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) + + typer.secho(f"commit {oid}", fg=typer.colors.GREEN) + typer.echo(f"Author: {commit['author']}") + typer.echo("") + typer.echo(f" {commit['message']}\n") + + oid = commit["parent"] + diff --git a/git_scratch/main.py b/git_scratch/main.py index 21905a9..a060ec0 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -10,7 +10,7 @@ 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.log import log from git_scratch.commands.init import init app = typer.Typer(help="Git from scratch in Python.") @@ -27,7 +27,7 @@ app.command("ls-tree")(ls_tree) app.command("commit-tree")(commit_tree) app.command("status")(status) - +app.command("log")(log) if __name__ == "__main__": app() From 5d82ca7035cb85dc8797eb692244b000cac7a29f Mon Sep 17 00:00:00 2001 From: Devanandhan Date: Mon, 21 Jul 2025 19:14:41 +0200 Subject: [PATCH 45/54] seperate logic .gitignore on status.py moved to utils & added on add.py --- git_scratch/commands/add.py | 16 +++++++++------- git_scratch/commands/status.py | 5 +++++ git_scratch/utils/gitignore_utils.py | 21 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 git_scratch/utils/gitignore_utils.py diff --git a/git_scratch/commands/add.py b/git_scratch/commands/add.py index f919fc4..490907b 100644 --- a/git_scratch/commands/add.py +++ b/git_scratch/commands/add.py @@ -1,9 +1,9 @@ - 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() @@ -11,7 +11,6 @@ def add_file_to_index(file_path: str): with open(file_path, "rb") as f: content = f.read() - # Prépare et compresse l’objet blob header = f"blob {len(content)}\0".encode() full_data = header + content oid = hashlib.sha1(full_data).hexdigest() @@ -22,7 +21,6 @@ def add_file_to_index(file_path: str): with open(obj_path, "wb") as f: f.write(zlib.compress(full_data)) - # Charge et modifie l’index index = load_index() rel_path = os.path.relpath(file_path) mode = compute_mode(file_path) @@ -42,21 +40,26 @@ def add_file_to_index(file_path: str): @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). + 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): - add_file_to_index(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) - if ".git" in full_path: + rel_path = os.path.relpath(full_path) + if is_ignored(rel_path, spec): continue add_file_to_index(full_path) else: @@ -65,4 +68,3 @@ def add(file_path: str = typer.Argument(..., help="Path to file or directory to if __name__ == "__main__": app() - diff --git a/git_scratch/commands/status.py b/git_scratch/commands/status.py index 4e9561b..dbfe7e8 100644 --- a/git_scratch/commands/status.py +++ b/git_scratch/commands/status.py @@ -5,6 +5,8 @@ import hashlib import pathspec from git_scratch.utils.index_utils import load_index, get_index_path +from git_scratch.utils.gitignore_utils import load_gitignore_spec, is_ignored + def git_hash_object(file_path): with open(file_path, "rb") as f: @@ -40,6 +42,9 @@ def status(): # On ignore les fichiers dans .gitignore if spec.match_file(file_path): continue + if is_ignored(file_path, spec): + continue + if file_path in tracked_files: current_hash = git_hash_object(file_path) diff --git a/git_scratch/utils/gitignore_utils.py b/git_scratch/utils/gitignore_utils.py new file mode 100644 index 0000000..f638612 --- /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) From 7502b8b0a8520e5678409f9c099bee11f633598b Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Tue, 22 Jul 2025 14:14:12 +0200 Subject: [PATCH 46/54] feat: Adding the test of the log command --- git_scratch/commands/log.py | 3 ++ tests/unit/test_log.py | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 tests/unit/test_log.py diff --git a/git_scratch/commands/log.py b/git_scratch/commands/log.py index 582b06d..c4c2801 100644 --- a/git_scratch/commands/log.py +++ b/git_scratch/commands/log.py @@ -34,6 +34,9 @@ def parse_commit(content: bytes) -> dict: def log(): + """ + Show commit logs + """ try: head_path = os.path.join(".git", "HEAD") with open(head_path) as f: diff --git a/tests/unit/test_log.py b/tests/unit/test_log.py new file mode 100644 index 0000000..b54bece --- /dev/null +++ b/tests/unit/test_log.py @@ -0,0 +1,56 @@ + +import subprocess +from typer.testing import CliRunner + +# On importe ta commande Typer (par exemple depuis git_scratch.main) +from git_scratch.main import app # adapte selon où est défini ton Typer app + +runner = CliRunner() + +def run(cmd, cwd): + """Helper pour exécuter une commande shell et retourner stdout.""" + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=True) + return result.stdout.strip() + +def test_pit_log_matches_git_with_typer(monkeypatch, tmp_path): + # 1️⃣ Créer un repo temporaire + repo_path = tmp_path / "repo" + repo_path.mkdir() + + # Initialiser un repo Git + run(["git", "init"], cwd=repo_path) + + # Faire quelques commits + f1 = repo_path / "file1.txt" + f1.write_text("hello world") + run(["git", "add", "file1.txt"], cwd=repo_path) + run(["git", "commit", "-m", "Initial commit"], cwd=repo_path) + + f1.write_text("update content") + run(["git", "add", "file1.txt"], cwd=repo_path) + run(["git", "commit", "-m", "Second commit"], cwd=repo_path) + + f2 = repo_path / "file2.txt" + f2.write_text("another file") + run(["git", "add", "file2.txt"], cwd=repo_path) + run(["git", "commit", "-m", "Third commit"], cwd=repo_path) + + # 2️⃣ Récupérer le log git dans un format simple + git_log = run(["git", "log", "--pretty=format:%H %s"], cwd=repo_path) + + # 3️⃣ Monkeypatcher le répertoire courant pour que pit log s’exécute dans repo_path + monkeypatch.chdir(repo_path) + + # 4️⃣ Exécuter `pit log` via Typer Testing + result = runner.invoke(app, ["log"]) + + assert result.exit_code == 0, f"pit log failed: {result.output}" + + pit_log_output = result.output + + # 5️⃣ Comparer les commits git avec ceux affichés par pit + for line in git_log.splitlines(): + commit_hash, message = line.split(" ", 1) + # On vérifie que pit log contient bien le hash et le message + assert commit_hash in pit_log_output + assert message in pit_log_output From d261cced779761a4587d7ef7fdebae1bfb60145c Mon Sep 17 00:00:00 2001 From: Amaury057 Date: Tue, 22 Jul 2025 15:00:51 +0200 Subject: [PATCH 47/54] first commti after refactored --- git_scratch/commands/add.py | 16 +++++++++------- git_scratch/commands/status.py | 9 ++++++--- git_scratch/utils/gitignore_utils.py | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 git_scratch/utils/gitignore_utils.py diff --git a/git_scratch/commands/add.py b/git_scratch/commands/add.py index f919fc4..490907b 100644 --- a/git_scratch/commands/add.py +++ b/git_scratch/commands/add.py @@ -1,9 +1,9 @@ - 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() @@ -11,7 +11,6 @@ def add_file_to_index(file_path: str): with open(file_path, "rb") as f: content = f.read() - # Prépare et compresse l’objet blob header = f"blob {len(content)}\0".encode() full_data = header + content oid = hashlib.sha1(full_data).hexdigest() @@ -22,7 +21,6 @@ def add_file_to_index(file_path: str): with open(obj_path, "wb") as f: f.write(zlib.compress(full_data)) - # Charge et modifie l’index index = load_index() rel_path = os.path.relpath(file_path) mode = compute_mode(file_path) @@ -42,21 +40,26 @@ def add_file_to_index(file_path: str): @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). + 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): - add_file_to_index(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) - if ".git" in full_path: + rel_path = os.path.relpath(full_path) + if is_ignored(rel_path, spec): continue add_file_to_index(full_path) else: @@ -65,4 +68,3 @@ def add(file_path: str = typer.Argument(..., help="Path to file or directory to if __name__ == "__main__": app() - diff --git a/git_scratch/commands/status.py b/git_scratch/commands/status.py index 4e9561b..46fc113 100644 --- a/git_scratch/commands/status.py +++ b/git_scratch/commands/status.py @@ -1,10 +1,10 @@ -import os -import json from pathlib import Path import typer import hashlib import pathspec -from git_scratch.utils.index_utils import load_index, get_index_path +from git_scratch.utils.index_utils import load_index +from git_scratch.utils.gitignore_utils import load_gitignore_spec, is_ignored + def git_hash_object(file_path): with open(file_path, "rb") as f: @@ -40,6 +40,9 @@ def status(): # On ignore les fichiers dans .gitignore if spec.match_file(file_path): continue + if is_ignored(file_path, spec): + continue + if file_path in tracked_files: current_hash = git_hash_object(file_path) 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 From 1067a55ad30a8fa5fd64ccfd9ee48e7f2febcecd Mon Sep 17 00:00:00 2001 From: Adrien Allard Date: Tue, 22 Jul 2025 15:01:20 +0200 Subject: [PATCH 48/54] refactor: command pit log and its test --- git_scratch/commands/log.py | 37 +++++++++---- tests/unit/test_log.py | 106 +++++++++++++++++++----------------- 2 files changed, 84 insertions(+), 59 deletions(-) diff --git a/git_scratch/commands/log.py b/git_scratch/commands/log.py index c4c2801..86131ff 100644 --- a/git_scratch/commands/log.py +++ b/git_scratch/commands/log.py @@ -1,9 +1,12 @@ - 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, @@ -25,7 +28,7 @@ def parse_commit(content: bytes) -> dict: elif line.startswith("committer "): commit_data["committer"] = line[10:] elif line == "": - # Message starts after the empty line + # Le message commence après la ligne vide commit_data["message"] = "\n".join(lines[i+1:]).strip() break i += 1 @@ -33,9 +36,18 @@ def parse_commit(content: bytes) -> dict: 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(): """ - Show commit logs + Réimplémente `git log` en lisant les commits dans .git/objects. """ try: head_path = os.path.join(".git", "HEAD") @@ -60,9 +72,6 @@ def log(): typer.secho("No commits found or repository not initialized.", fg=typer.colors.RED) return - # Affiche l'oid pour vérifier - typer.echo(f"Starting from commit OID: {oid}") - while oid: try: obj_type, content = read_object(oid) @@ -76,10 +85,18 @@ def log(): commit = parse_commit(content) - typer.secho(f"commit {oid}", fg=typer.colors.GREEN) - typer.echo(f"Author: {commit['author']}") - typer.echo("") + # 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/tests/unit/test_log.py b/tests/unit/test_log.py index b54bece..a06be19 100644 --- a/tests/unit/test_log.py +++ b/tests/unit/test_log.py @@ -1,56 +1,64 @@ - +import os import subprocess +from pathlib import Path from typer.testing import CliRunner - -# On importe ta commande Typer (par exemple depuis git_scratch.main) from git_scratch.main import app # adapte selon où est défini ton Typer app runner = CliRunner() -def run(cmd, cwd): - """Helper pour exécuter une commande shell et retourner stdout.""" - result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=True) - return result.stdout.strip() - -def test_pit_log_matches_git_with_typer(monkeypatch, tmp_path): - # 1️⃣ Créer un repo temporaire - repo_path = tmp_path / "repo" - repo_path.mkdir() - - # Initialiser un repo Git - run(["git", "init"], cwd=repo_path) - - # Faire quelques commits - f1 = repo_path / "file1.txt" - f1.write_text("hello world") - run(["git", "add", "file1.txt"], cwd=repo_path) - run(["git", "commit", "-m", "Initial commit"], cwd=repo_path) - - f1.write_text("update content") - run(["git", "add", "file1.txt"], cwd=repo_path) - run(["git", "commit", "-m", "Second commit"], cwd=repo_path) - - f2 = repo_path / "file2.txt" - f2.write_text("another file") - run(["git", "add", "file2.txt"], cwd=repo_path) - run(["git", "commit", "-m", "Third commit"], cwd=repo_path) - - # 2️⃣ Récupérer le log git dans un format simple - git_log = run(["git", "log", "--pretty=format:%H %s"], cwd=repo_path) - - # 3️⃣ Monkeypatcher le répertoire courant pour que pit log s’exécute dans repo_path - monkeypatch.chdir(repo_path) - - # 4️⃣ Exécuter `pit log` via Typer Testing +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"]) - - assert result.exit_code == 0, f"pit log failed: {result.output}" - - pit_log_output = result.output - - # 5️⃣ Comparer les commits git avec ceux affichés par pit - for line in git_log.splitlines(): - commit_hash, message = line.split(" ", 1) - # On vérifie que pit log contient bien le hash et le message - assert commit_hash in pit_log_output - assert message in pit_log_output + 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 From 5b8dfbdc6a7f057dd66065d233d92dfdfa601e22 Mon Sep 17 00:00:00 2001 From: Woodis Date: Tue, 22 Jul 2025 21:10:15 +0200 Subject: [PATCH 49/54] feat: add a new command reset with hard and soft --- .vscode/settings.json | 5 ++ git_scratch/commands/reset.py | 124 ++++++++++++++++++++++++++++++++++ git_scratch/main.py | 2 + 3 files changed, 131 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 git_scratch/commands/reset.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..82cf781 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.exclude": { + "**/.git": false + } +} \ No newline at end of file diff --git a/git_scratch/commands/reset.py b/git_scratch/commands/reset.py new file mode 100644 index 0000000..2e1833e --- /dev/null +++ b/git_scratch/commands/reset.py @@ -0,0 +1,124 @@ +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 + +_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 _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 + + # Example OID + +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."), +): + """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) + + mode = "mixed" + if soft: + mode = "soft" + elif hard: + mode = "hard" + + # 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})") + + +print(_entries_from_tree("76273146b6d01d6a68940935a3f445eebb324b32")) \ No newline at end of file diff --git a/git_scratch/main.py b/git_scratch/main.py index 21905a9..94474cb 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -10,6 +10,7 @@ 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.reset import reset from git_scratch.commands.init import init @@ -27,6 +28,7 @@ app.command("ls-tree")(ls_tree) app.command("commit-tree")(commit_tree) app.command("status")(status) +app.command("reset")(reset) if __name__ == "__main__": From 0623f97c478b9f0e3a2e3a7a02405d891ae0c2e6 Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Wed, 23 Jul 2025 02:15:49 +0200 Subject: [PATCH 50/54] fix: reset hard work with edit or deleted file --- .gitignore | 3 + git_scratch/commands/log.py | 102 ++++++++++++++++++++++++++++++ git_scratch/commands/reset.py | 6 +- git_scratch/main.py | 6 +- git_scratch/utils/find_git_dir.py | 18 ++++++ temp_git | 1 + tests/unit/test_log.py | 64 +++++++++++++++++++ 7 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 git_scratch/commands/log.py create mode 100644 git_scratch/utils/find_git_dir.py create mode 160000 temp_git create mode 100644 tests/unit/test_log.py diff --git a/.gitignore b/.gitignore index 7568683..09eb67d 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,6 @@ cython_debug/ .cursorignore .cursorindexingignore .DS_Store + +temp_git/ +temp/ \ No newline at end of file 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/reset.py b/git_scratch/commands/reset.py index 2e1833e..2cac519 100644 --- a/git_scratch/commands/reset.py +++ b/git_scratch/commands/reset.py @@ -76,7 +76,6 @@ def _entries_from_tree(tree_oid: str, base_path: str = "") -> List[dict]: return entries - # Example OID def _checkout_tree(tree_oid: str, dest_dir: str = ".") -> None: """Overwrite *dest_dir* with the blobs of *tree_oid* (tracked files only).""" @@ -118,7 +117,4 @@ def reset( if mode == "hard": _checkout_tree(tree_oid) - typer.echo(f"HEAD is now at {target_oid[:7]} ({mode})") - - -print(_entries_from_tree("76273146b6d01d6a68940935a3f445eebb324b32")) \ No newline at end of file + typer.echo(f"HEAD is now at {target_oid[:7]} ({mode})") \ No newline at end of file diff --git a/git_scratch/main.py b/git_scratch/main.py index 94474cb..a16a0d1 100644 --- a/git_scratch/main.py +++ b/git_scratch/main.py @@ -10,9 +10,9 @@ 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.reset import reset - +from git_scratch.commands.log import log from git_scratch.commands.init import init +from git_scratch.commands.reset import reset app = typer.Typer(help="Git from scratch in Python.") @@ -28,8 +28,8 @@ app.command("ls-tree")(ls_tree) app.command("commit-tree")(commit_tree) app.command("status")(status) +app.command("log")(log) app.command("reset")(reset) - if __name__ == "__main__": app() 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/temp_git b/temp_git new file mode 160000 index 0000000..8579255 --- /dev/null +++ b/temp_git @@ -0,0 +1 @@ +Subproject commit 85792552b428bceada4fb84c092d93b1e2280acb 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 From 0779c01367a3c106d66246829fb15c525ec57dd3 Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Wed, 23 Jul 2025 02:26:29 +0200 Subject: [PATCH 51/54] deleted temp folder --- temp_git | 1 - 1 file changed, 1 deletion(-) delete mode 160000 temp_git diff --git a/temp_git b/temp_git deleted file mode 160000 index 8579255..0000000 --- a/temp_git +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 85792552b428bceada4fb84c092d93b1e2280acb From 39837454131dd8545ed158373c41ab0a7777c4c7 Mon Sep 17 00:00:00 2001 From: stephanedescarpentries Date: Wed, 23 Jul 2025 05:07:24 +0200 Subject: [PATCH 52/54] add reset mixed --- git_scratch/commands/reset.py | 36 +++++++------------------------- git_scratch/utils/tree_walker.py | 28 +++++++++++++++++++++++++ tests/unit/test_reset.py | 0 3 files changed, 36 insertions(+), 28 deletions(-) create mode 100644 git_scratch/utils/tree_walker.py create mode 100644 tests/unit/test_reset.py diff --git a/git_scratch/commands/reset.py b/git_scratch/commands/reset.py index 2cac519..e13e16a 100644 --- a/git_scratch/commands/reset.py +++ b/git_scratch/commands/reset.py @@ -7,6 +7,7 @@ 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") @@ -51,35 +52,10 @@ def _get_tree_oid(commit_oid: str) -> str: raise typer.Exit(code=1) return first_line.split()[1] -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 - 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): + 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"]) @@ -90,17 +66,21 @@ 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) - mode = "mixed" 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) @@ -111,7 +91,7 @@ def reset( # 3. Index tree_oid = _get_tree_oid(target_oid) if mode in {"mixed", "hard"}: - save_index(_entries_from_tree(tree_oid)) + save_index(entries_from_tree(tree_oid)) # 4. Working directory if mode == "hard": 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/tests/unit/test_reset.py b/tests/unit/test_reset.py new file mode 100644 index 0000000..e69de29 From 46defc646d7020c280278c1ab4d0288481a5dfaf Mon Sep 17 00:00:00 2001 From: The-Leyn <127550818+The-Leyn@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:31:48 +0200 Subject: [PATCH 53/54] Delete .vscode directory --- .vscode/settings.json | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 82cf781..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "files.exclude": { - "**/.git": false - } -} \ No newline at end of file From 631a5960a968195ee250068f48abe5258bd44a8c Mon Sep 17 00:00:00 2001 From: Amaury057 Date: Wed, 23 Jul 2025 13:57:51 +0200 Subject: [PATCH 54/54] status def --- git_scratch/commands/status.py | 179 +++++++++++++++++++++------------ 1 file changed, 115 insertions(+), 64 deletions(-) diff --git a/git_scratch/commands/status.py b/git_scratch/commands/status.py index 46fc113..a2e7a50 100644 --- a/git_scratch/commands/status.py +++ b/git_scratch/commands/status.py @@ -1,77 +1,128 @@ -from pathlib import Path -import typer 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.gitignore_utils import load_gitignore_spec, is_ignored - - -def git_hash_object(file_path): - with open(file_path, "rb") as f: - content = f.read() - header = f"blob {len(content)}\0".encode() - store = header + content - return hashlib.sha1(store).hexdigest() - -def list_project_files(): - # Liste tous les fichiers du projet, sauf ceux dans .git - return [str(f) for f in Path(".").rglob("*") if f.is_file() and ".git" not in f.parts] - -def load_gitignore_spec(): - # Charge le .gitignore avec pathspec - 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 status(): - import time; t0 = time.time() - index = load_index() - tracked_files = {item['path']: item['oid'] for item in index} - project_files = list_project_files() +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() - modified = [] - staged = [] - untracked = [] + staged: List[Tuple[str, str]] = [] + unstaged: List[Tuple[str, str]] = [] + untracked: List[Tuple[str, str]] = [] - for file_path in project_files: - # On ignore les fichiers dans .gitignore - if spec.match_file(file_path): - continue - if is_ignored(file_path, spec): + 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 file_path in tracked_files: - current_hash = git_hash_object(file_path) - if current_hash == tracked_files[file_path]: - staged.append(file_path) - else: - modified.append(file_path) + 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: - untracked.append(file_path) + if not in_commit: + untracked.append(('?', fpath_str)) - if staged: - typer.echo(typer.style("Changes to be committed:", fg=typer.colors.GREEN)) - typer.echo(typer.style(" (use \"pit reset ...\" to unstage)\n", fg=typer.colors.GREEN)) - for f in staged: - typer.echo(typer.style(f" added: {f}", fg=typer.colors.GREEN)) - - if modified: - typer.echo(typer.style("Changes not staged for commit:", fg=typer.colors.RED)) - typer.echo(typer.style(" (use \"pit add ...\" to update what will be committed)", fg=typer.colors.RED)) - typer.echo(typer.style(" (use \"pit restore ...\" to discard changes in working directory)\n", fg=typer.colors.RED)) - for f in modified: - typer.echo(typer.style(f" modified: {f}", fg=typer.colors.RED)) + 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: - typer.echo(typer.style("Untracked files:", fg=typer.colors.RED)) - typer.echo(typer.style(" (use \"pit add ...\" to include in what will be committed)\n", fg=typer.colors.RED)) - for f in untracked: - typer.echo(typer.style(f" {f}", fg=typer.colors.RED)) - - if not staged and not modified and not untracked: - typer.echo("Aucun fichier modifié détecté.") - print("Temps total:", time.time()-t0) + 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