fromscratch-pygit 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fromscratch_pygit-0.3.0.dist-info/METADATA +169 -0
- fromscratch_pygit-0.3.0.dist-info/RECORD +16 -0
- fromscratch_pygit-0.3.0.dist-info/WHEEL +4 -0
- fromscratch_pygit-0.3.0.dist-info/entry_points.txt +2 -0
- fromscratch_pygit-0.3.0.dist-info/licenses/LICENSE +21 -0
- pygit/__init__.py +1 -0
- pygit/_version.py +24 -0
- pygit/cli.py +127 -0
- pygit/commit.py +87 -0
- pygit/diff.py +79 -0
- pygit/errors.py +21 -0
- pygit/hashing.py +50 -0
- pygit/objects.py +110 -0
- pygit/py.typed +0 -0
- pygit/repository.py +394 -0
- pygit/tree.py +62 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fromscratch-pygit
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: An educational version control system built from scratch in Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/robcox2910/pygit
|
|
6
|
+
Project-URL: Repository, https://github.com/robcox2910/pygit
|
|
7
|
+
Project-URL: Issues, https://github.com/robcox2910/pygit/issues
|
|
8
|
+
Project-URL: Documentation, https://robcox2910.github.io/pygit/
|
|
9
|
+
Author: Rob Cox
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: education,git,learning,version-control
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Education
|
|
19
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: commitizen>=4.4.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: hypothesis>=6.170; extra == 'dev'
|
|
25
|
+
Requires-Dist: pip-audit>=2.10.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pre-commit>=4.0.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pyright>=1.1.400; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.3.4; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.9.3; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# PyGit
|
|
34
|
+
|
|
35
|
+
**Build your own version control system from scratch!**
|
|
36
|
+
|
|
37
|
+
PyGit is a simplified version of Git, written entirely in Python. It teaches
|
|
38
|
+
you how version control really works under the hood -- hashing, blobs, trees,
|
|
39
|
+
commits, branches, and diffs -- by building each piece yourself.
|
|
40
|
+
|
|
41
|
+
## What is version control?
|
|
42
|
+
|
|
43
|
+
Imagine you are writing a story. Every time you finish a chapter, you save a
|
|
44
|
+
copy so you can go back to it later. Version control does the same thing for
|
|
45
|
+
code -- it remembers every change you make, who made it, and why.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pygit.repository import Repository
|
|
51
|
+
|
|
52
|
+
# Create a new repository
|
|
53
|
+
repo = Repository.init("my-project")
|
|
54
|
+
|
|
55
|
+
# Create a file and stage it
|
|
56
|
+
with open("my-project/hello.txt", "w") as f:
|
|
57
|
+
f.write("Hello, world!")
|
|
58
|
+
|
|
59
|
+
repo.add("hello.txt")
|
|
60
|
+
|
|
61
|
+
# Commit the change
|
|
62
|
+
commit_hash = repo.commit("Add greeting")
|
|
63
|
+
print(f"Created commit: {commit_hash}")
|
|
64
|
+
|
|
65
|
+
# Make another change
|
|
66
|
+
with open("my-project/hello.txt", "w") as f:
|
|
67
|
+
f.write("Hello, everyone!")
|
|
68
|
+
|
|
69
|
+
repo.add("hello.txt")
|
|
70
|
+
repo.commit("Update greeting")
|
|
71
|
+
|
|
72
|
+
# See the history
|
|
73
|
+
for entry in repo.log():
|
|
74
|
+
print(f"{entry.commit_hash[:8]} {entry.message}")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Features
|
|
78
|
+
|
|
79
|
+
- **Hashing** -- Every piece of content gets a unique fingerprint using SHA-1,
|
|
80
|
+
with Git's `blob <size>` label in front -- so file (blob) hashes match
|
|
81
|
+
`git hash-object` exactly. (Trees and commits use a simpler JSON layout than
|
|
82
|
+
real Git, so their hashes differ.)
|
|
83
|
+
- **Nested trees** -- Every folder gets its own tree, just like real Git.
|
|
84
|
+
- **Object store** -- Files are stored by their hash, so identical content is
|
|
85
|
+
never duplicated.
|
|
86
|
+
- **Trees** -- Snapshots of your directory structure, like a table of contents.
|
|
87
|
+
- **Commits** -- Diary entries that record what changed, when, and why. Each
|
|
88
|
+
commit points to its parent, forming a chain of history.
|
|
89
|
+
- **Branches** -- Create parallel timelines to work on different ideas without
|
|
90
|
+
affecting the main project.
|
|
91
|
+
- **Staging area** -- Choose exactly which changes go into the next commit.
|
|
92
|
+
- **Diff** -- Compare two versions of a file to see what was added or removed.
|
|
93
|
+
- **Status** -- See what files are staged and ready to commit.
|
|
94
|
+
|
|
95
|
+
## Installation
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
uv sync --all-extras
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Running tests
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
uv run pytest
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Project structure
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
src/pygit/
|
|
111
|
+
__init__.py -- Package marker
|
|
112
|
+
hashing.py -- SHA-1 fingerprinting
|
|
113
|
+
objects.py -- Content-addressed object store
|
|
114
|
+
tree.py -- Directory snapshots
|
|
115
|
+
commit.py -- History entries
|
|
116
|
+
diff.py -- Line-by-line comparison
|
|
117
|
+
repository.py -- Ties everything together
|
|
118
|
+
errors.py -- Custom exceptions
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Releasing
|
|
122
|
+
|
|
123
|
+
Other projects in the series ([PyStack](https://github.com/robcox2910/pystack)) use a pinned release of this one,
|
|
124
|
+
not whatever is on `main`. To publish a change for them:
|
|
125
|
+
|
|
126
|
+
1. Merge the change to `main`.
|
|
127
|
+
2. Tag it: `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`.
|
|
128
|
+
A change to the public API bumps the minor version (`v0.1.0` → `v0.2.0`);
|
|
129
|
+
anything else bumps the patch (`v0.1.0` → `v0.1.1`).
|
|
130
|
+
3. In each project that uses it, edit the tag in `[tool.uv.sources]`, run
|
|
131
|
+
`uv lock --upgrade-package <name>`, and open a pull request so CI checks
|
|
132
|
+
the pair together.
|
|
133
|
+
|
|
134
|
+
<!-- series:start -->
|
|
135
|
+
## Related Projects
|
|
136
|
+
|
|
137
|
+
This project is one of a series that builds real systems "from scratch in Python" to teach how they work. See the [series home](https://robcox2910.github.io/) for the full story.
|
|
138
|
+
|
|
139
|
+
| Project | What it teaches | Repository | Docs |
|
|
140
|
+
| --- | --- | --- | --- |
|
|
141
|
+
| PyOS | Operating systems | [py-os](https://github.com/robcox2910/py-os) | [Docs](https://robcox2910.github.io/py-os/) |
|
|
142
|
+
| Pebble | Compilers and programming languages | [pebble-lang](https://github.com/robcox2910/pebble-lang) | [Docs](https://robcox2910.github.io/pebble-lang/) |
|
|
143
|
+
| PyDB | Relational databases | [pydb](https://github.com/robcox2910/pydb) | [Docs](https://robcox2910.github.io/pydb/) |
|
|
144
|
+
| PyKV | Key-value stores | [pykv](https://github.com/robcox2910/pykv) | [Docs](https://robcox2910.github.io/pykv/) |
|
|
145
|
+
| PyDocDB | Document databases | [pydoc-db](https://github.com/robcox2910/pydoc-db) | [Docs](https://robcox2910.github.io/pydoc-db/) |
|
|
146
|
+
| PyGraphDB | Graph databases | [pygraphdb](https://github.com/robcox2910/pygraphdb) | [Docs](https://robcox2910.github.io/pygraphdb/) |
|
|
147
|
+
| PyTSDB | Time-series databases | [pytsdb](https://github.com/robcox2910/pytsdb) | [Docs](https://robcox2910.github.io/pytsdb/) |
|
|
148
|
+
| PyVecDB | Vector databases | [pyvecdb](https://github.com/robcox2910/pyvecdb) | [Docs](https://robcox2910.github.io/pyvecdb/) |
|
|
149
|
+
| PyColDB | Column-family databases | [pycoldb](https://github.com/robcox2910/pycoldb) | [Docs](https://robcox2910.github.io/pycoldb/) |
|
|
150
|
+
| PyCrypt | Cryptography | [pycrypt](https://github.com/robcox2910/pycrypt) | [Docs](https://robcox2910.github.io/pycrypt/) |
|
|
151
|
+
| PyNet | Computer networking | [pynet](https://github.com/robcox2910/pynet) | [Docs](https://robcox2910.github.io/pynet/) |
|
|
152
|
+
| PyWeb | HTTP web servers | [pyweb](https://github.com/robcox2910/pyweb) | [Docs](https://robcox2910.github.io/pyweb/) |
|
|
153
|
+
| **PyGit** | **Version control** | [pygit](https://github.com/robcox2910/pygit) | [Docs](https://robcox2910.github.io/pygit/) |
|
|
154
|
+
| PySearch | Search engines | [pysearch](https://github.com/robcox2910/pysearch) | [Docs](https://robcox2910.github.io/pysearch/) |
|
|
155
|
+
| PyMQ | Message queues | [pymq](https://github.com/robcox2910/pymq) | [Docs](https://robcox2910.github.io/pymq/) |
|
|
156
|
+
| PyLLM | Language models (the brain) | [pyllm](https://github.com/robcox2910/pyllm) | [Docs](https://robcox2910.github.io/pyllm/) |
|
|
157
|
+
| PyScheduler | Job scheduling | [pyscheduler](https://github.com/robcox2910/pyscheduler) | [Docs](https://robcox2910.github.io/pyscheduler/) |
|
|
158
|
+
| PyStack | Full-stack integration | [pystack](https://github.com/robcox2910/pystack) | [Docs](https://robcox2910.github.io/pystack/) |
|
|
159
|
+
<!-- series:end -->
|
|
160
|
+
|
|
161
|
+
<!-- docs:start -->
|
|
162
|
+
## Documentation
|
|
163
|
+
|
|
164
|
+
Full docs (with kid-friendly explanations of every idea): https://robcox2910.github.io/pygit/
|
|
165
|
+
<!-- docs:end -->
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pygit/__init__.py,sha256=xS83fE7qID3qyidvXkbURqbBpqkpJCp7_crYu_LIemI,83
|
|
2
|
+
pygit/_version.py,sha256=RCY04NY0kBCyn6ttH6lnVL6Gz2gceOrt45f0yDOOU8A,520
|
|
3
|
+
pygit/cli.py,sha256=r6NrBkPJ-KuB-IA3t-KoyUmLBeKRssO4icPDYYCRhKY,3922
|
|
4
|
+
pygit/commit.py,sha256=CPFqbRRjXRykgNH6pcwf8wLbOS-8jaj_vsD8N0z6wdQ,2380
|
|
5
|
+
pygit/diff.py,sha256=DVrYUFj_TwsgOXo2Gl9OM5pdeZO1cAcIbV2ULWTjbIM,2366
|
|
6
|
+
pygit/errors.py,sha256=JRMCi7FKVFoMYjQeh8vYPj9r4RNpEStS03TjS23t1uY,531
|
|
7
|
+
pygit/hashing.py,sha256=A1e4FlpsXaDqdZfptV2vDTMqNIrmYCP-zdDMurogfgM,1665
|
|
8
|
+
pygit/objects.py,sha256=U-7lieBHYe8wXAOX3fywUglTUEF27KZBoynJPDaZPqY,4116
|
|
9
|
+
pygit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
pygit/repository.py,sha256=uNeAfnXw8lPeIhojlTmisy0pUeC_AXeHOo-QSftY0e0,14083
|
|
11
|
+
pygit/tree.py,sha256=7wH8nuVYDFVT0sF0zr6kyFRwiR5dV1a7EJB9rpQU9ik,1696
|
|
12
|
+
fromscratch_pygit-0.3.0.dist-info/METADATA,sha256=qqbc7WLrpE-8nQLz45HJplxqyFJ42RL4zHUHxI0nPz4,7396
|
|
13
|
+
fromscratch_pygit-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
14
|
+
fromscratch_pygit-0.3.0.dist-info/entry_points.txt,sha256=GDL2QmULHR1NnRsbzAIxB019nLk_chDr54Tx2qS2ZLw,41
|
|
15
|
+
fromscratch_pygit-0.3.0.dist-info/licenses/LICENSE,sha256=NtHM2ak--oE71VCcY50kcCoC1cKS0LRUsxq_xA89RNc,1064
|
|
16
|
+
fromscratch_pygit-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Cox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pygit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PyGit -- an educational version control system built from scratch in Python."""
|
pygit/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pygit/cli.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Command line -- the control panel for PyGit.
|
|
2
|
+
|
|
3
|
+
Like the row of buttons on a machine, this lets you drive PyGit from
|
|
4
|
+
your terminal: press a button (type a command) and PyGit does the work.
|
|
5
|
+
It wires the ``pygit`` command to the :class:`~pygit.repository.Repository`
|
|
6
|
+
so you can init, add, commit, log, and check status without writing any
|
|
7
|
+
Python.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from pygit.errors import PyGitError
|
|
14
|
+
from pygit.repository import Repository
|
|
15
|
+
|
|
16
|
+
SHORT_HASH_LENGTH = 8
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
20
|
+
"""Create a new repository in the given directory."""
|
|
21
|
+
repo = Repository.init(args.path)
|
|
22
|
+
print(f"Initialized empty pygit repository in {repo.path}")
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cmd_add(args: argparse.Namespace) -> int:
|
|
27
|
+
"""Stage a file for the next commit."""
|
|
28
|
+
repo = Repository(args.path)
|
|
29
|
+
blob_hash = repo.add(args.file)
|
|
30
|
+
print(f"Staged {args.file} ({blob_hash[:SHORT_HASH_LENGTH]})")
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cmd_commit(args: argparse.Namespace) -> int:
|
|
35
|
+
"""Record the staged files as a new commit."""
|
|
36
|
+
repo = Repository(args.path)
|
|
37
|
+
commit_hash = repo.commit(args.message, author=args.author)
|
|
38
|
+
print(f"[{repo.current_branch()} {commit_hash[:SHORT_HASH_LENGTH]}] {args.message}")
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _cmd_log(args: argparse.Namespace) -> int:
|
|
43
|
+
"""Print the commit history, newest first."""
|
|
44
|
+
repo = Repository(args.path)
|
|
45
|
+
for commit in repo.log():
|
|
46
|
+
print(f"commit {commit.commit_hash}")
|
|
47
|
+
print(f"Author: {commit.author}")
|
|
48
|
+
print(f"Date: {commit.timestamp}")
|
|
49
|
+
print(f"\n {commit.message}\n")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
54
|
+
"""Print the files currently staged for commit."""
|
|
55
|
+
repo = Repository(args.path)
|
|
56
|
+
staged = repo.status()
|
|
57
|
+
if not staged:
|
|
58
|
+
print("Nothing staged for commit.")
|
|
59
|
+
return 0
|
|
60
|
+
print("Staged files:")
|
|
61
|
+
for name in sorted(staged):
|
|
62
|
+
print(f" {name}")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
67
|
+
"""Build the command-line argument parser."""
|
|
68
|
+
parser = argparse.ArgumentParser(
|
|
69
|
+
prog="pygit",
|
|
70
|
+
description="A tiny version control system built from scratch.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"-C",
|
|
74
|
+
dest="path",
|
|
75
|
+
default=".",
|
|
76
|
+
help="Run as if pygit was started in this directory.",
|
|
77
|
+
)
|
|
78
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
79
|
+
|
|
80
|
+
init_parser = subparsers.add_parser("init", help="Create a new repository.")
|
|
81
|
+
init_parser.set_defaults(func=_cmd_init)
|
|
82
|
+
|
|
83
|
+
add_parser = subparsers.add_parser("add", help="Stage a file for the next commit.")
|
|
84
|
+
add_parser.add_argument("file", help="Path to the file to stage.")
|
|
85
|
+
add_parser.set_defaults(func=_cmd_add)
|
|
86
|
+
|
|
87
|
+
commit_parser = subparsers.add_parser("commit", help="Record staged changes.")
|
|
88
|
+
commit_parser.add_argument("-m", "--message", required=True, help="The commit message.")
|
|
89
|
+
commit_parser.add_argument("--author", default="Anonymous", help="The author's name.")
|
|
90
|
+
commit_parser.set_defaults(func=_cmd_commit)
|
|
91
|
+
|
|
92
|
+
log_parser = subparsers.add_parser("log", help="Show the commit history.")
|
|
93
|
+
log_parser.set_defaults(func=_cmd_log)
|
|
94
|
+
|
|
95
|
+
status_parser = subparsers.add_parser("status", help="Show staged files.")
|
|
96
|
+
status_parser.set_defaults(func=_cmd_status)
|
|
97
|
+
|
|
98
|
+
return parser
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main(argv: list[str] | None = None) -> int:
|
|
102
|
+
"""Run the ``pygit`` command-line tool.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
argv: The command-line arguments (defaults to ``sys.argv[1:]``).
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A process exit code: 0 on success, 1 on error.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
parser = _build_parser()
|
|
112
|
+
args = parser.parse_args(argv)
|
|
113
|
+
|
|
114
|
+
if args.command is None:
|
|
115
|
+
parser.print_help()
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
result: int = args.func(args)
|
|
120
|
+
except PyGitError as error:
|
|
121
|
+
print(f"error: {error}", file=sys.stderr)
|
|
122
|
+
return 1
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
raise SystemExit(main())
|
pygit/commit.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Commits -- diary entries for your project.
|
|
2
|
+
|
|
3
|
+
A commit records a moment in time: what the project looked like (the
|
|
4
|
+
tree), who made the change (the author), when it happened (the
|
|
5
|
+
timestamp), and why (the message). Each commit points back to its
|
|
6
|
+
parent, forming a chain of history.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
|
|
13
|
+
from pygit.hashing import hash_object
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Commit:
|
|
18
|
+
"""A snapshot of the project at a moment in time.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
tree_hash: The hash of the tree representing the project state.
|
|
22
|
+
parent_hash: The hash of the previous commit (None for first commit).
|
|
23
|
+
author: Who made this commit.
|
|
24
|
+
timestamp: When this commit was made.
|
|
25
|
+
message: Why this commit was made.
|
|
26
|
+
commit_hash: The hash of this commit itself.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
tree_hash: str
|
|
31
|
+
parent_hash: str | None
|
|
32
|
+
author: str
|
|
33
|
+
timestamp: str
|
|
34
|
+
message: str
|
|
35
|
+
commit_hash: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_commit(
|
|
39
|
+
tree_hash: str,
|
|
40
|
+
parent_hash: str | None,
|
|
41
|
+
message: str,
|
|
42
|
+
author: str = "Anonymous",
|
|
43
|
+
) -> tuple[str, str]:
|
|
44
|
+
"""Create a new commit object.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
tree_hash: The hash of the root tree.
|
|
48
|
+
parent_hash: The hash of the parent commit (None for first).
|
|
49
|
+
message: The commit message.
|
|
50
|
+
author: The author's name.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A tuple of (commit_hash, serialized_commit_content).
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
timestamp = datetime.now(tz=UTC).isoformat()
|
|
57
|
+
data = {
|
|
58
|
+
"tree": tree_hash,
|
|
59
|
+
"parent": parent_hash,
|
|
60
|
+
"author": author,
|
|
61
|
+
"timestamp": timestamp,
|
|
62
|
+
"message": message,
|
|
63
|
+
}
|
|
64
|
+
content = json.dumps(data)
|
|
65
|
+
commit_hash = hash_object("commit", content.encode())
|
|
66
|
+
return commit_hash, content
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_commit(content: str) -> Commit:
|
|
70
|
+
"""Deserialize a commit from its stored content.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
content: The JSON-serialized commit content.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A Commit object.
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
data: dict[str, str | None] = json.loads(content)
|
|
80
|
+
return Commit(
|
|
81
|
+
tree_hash=str(data["tree"]),
|
|
82
|
+
parent_hash=str(data["parent"]) if data.get("parent") else None,
|
|
83
|
+
author=str(data.get("author", "Unknown")),
|
|
84
|
+
timestamp=str(data.get("timestamp", "")),
|
|
85
|
+
message=str(data.get("message", "")),
|
|
86
|
+
commit_hash=hash_object("commit", content.encode()),
|
|
87
|
+
)
|
pygit/diff.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Diff -- spot the differences between two versions.
|
|
2
|
+
|
|
3
|
+
A diff compares two strings line by line and shows what was added,
|
|
4
|
+
removed, or unchanged. Lines starting with `+` were added, lines
|
|
5
|
+
starting with `-` were removed, and lines with a two-space prefix are
|
|
6
|
+
unchanged.
|
|
7
|
+
|
|
8
|
+
This is a simple educational diff -- real Git uses more sophisticated
|
|
9
|
+
algorithms (like Myers diff) for better results on large files.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def diff_lines(old: str, new: str) -> list[str]:
|
|
14
|
+
"""Compare two strings line by line and return a diff.
|
|
15
|
+
|
|
16
|
+
Walks both texts once from top to bottom with a greedy scan: when
|
|
17
|
+
the current lines match they are kept as unchanged; otherwise the
|
|
18
|
+
line that is missing from the other side is marked removed or added.
|
|
19
|
+
This is simpler (and less precise) than the longest-common-
|
|
20
|
+
subsequence algorithms real diff tools use.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
old: The original text.
|
|
24
|
+
new: The modified text.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A list of diff lines, each prefixed with "+", "-", or " ".
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
old_lines = old.splitlines()
|
|
31
|
+
new_lines = new.splitlines()
|
|
32
|
+
|
|
33
|
+
# Build a set for quick membership tests.
|
|
34
|
+
old_set = set(old_lines)
|
|
35
|
+
new_set = set(new_lines)
|
|
36
|
+
|
|
37
|
+
result: list[str] = []
|
|
38
|
+
old_idx = 0
|
|
39
|
+
new_idx = 0
|
|
40
|
+
|
|
41
|
+
while old_idx < len(old_lines) and new_idx < len(new_lines):
|
|
42
|
+
if old_lines[old_idx] == new_lines[new_idx]:
|
|
43
|
+
result.append(f" {old_lines[old_idx]}")
|
|
44
|
+
old_idx += 1
|
|
45
|
+
new_idx += 1
|
|
46
|
+
elif old_lines[old_idx] not in new_set:
|
|
47
|
+
result.append(f"- {old_lines[old_idx]}")
|
|
48
|
+
old_idx += 1
|
|
49
|
+
elif new_lines[new_idx] not in old_set:
|
|
50
|
+
result.append(f"+ {new_lines[new_idx]}")
|
|
51
|
+
new_idx += 1
|
|
52
|
+
else:
|
|
53
|
+
result.append(f"- {old_lines[old_idx]}")
|
|
54
|
+
old_idx += 1
|
|
55
|
+
|
|
56
|
+
# Remaining old lines were removed.
|
|
57
|
+
while old_idx < len(old_lines):
|
|
58
|
+
result.append(f"- {old_lines[old_idx]}")
|
|
59
|
+
old_idx += 1
|
|
60
|
+
|
|
61
|
+
# Remaining new lines were added.
|
|
62
|
+
while new_idx < len(new_lines):
|
|
63
|
+
result.append(f"+ {new_lines[new_idx]}")
|
|
64
|
+
new_idx += 1
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def format_diff(diff: list[str]) -> str:
|
|
70
|
+
"""Format a diff as a single string for display.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
diff: The list of diff lines from ``diff_lines()``.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A newline-joined string.
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
return "\n".join(diff)
|
pygit/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Custom exceptions for PyGit.
|
|
2
|
+
|
|
3
|
+
Clear error messages help you understand what went wrong. Each
|
|
4
|
+
exception type tells you the category of the problem.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PyGitError(Exception):
|
|
9
|
+
"""Base exception for all PyGit errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ObjectNotFoundError(PyGitError):
|
|
13
|
+
"""Raise when a requested object hash doesn't exist."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RepositoryError(PyGitError):
|
|
17
|
+
"""Raise when a repository operation fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ObjectTypeError(PyGitError):
|
|
21
|
+
"""Raise when an object's type label isn't the type we asked for."""
|
pygit/hashing.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Hashing -- fingerprints for content.
|
|
2
|
+
|
|
3
|
+
Every piece of content gets a unique fingerprint (hash). The same
|
|
4
|
+
content always gets the same fingerprint. Different content gets
|
|
5
|
+
different fingerprints. This is the foundation of everything in Git.
|
|
6
|
+
|
|
7
|
+
We use SHA-1 (like real Git) to produce 40-character hex strings.
|
|
8
|
+
|
|
9
|
+
Before taking the fingerprint, Git sticks a little **label** on the
|
|
10
|
+
front saying what kind of object it is and how big it is -- for a file
|
|
11
|
+
saying ``hello``, the label is ``blob 6`` followed by a zero byte. We do
|
|
12
|
+
exactly the same, which means:
|
|
13
|
+
|
|
14
|
+
- a file and a tree can never get the same fingerprint by accident, even
|
|
15
|
+
if their bytes happen to match (the labels differ), and
|
|
16
|
+
- our file (blob) fingerprints are *identical* to real Git's -- try
|
|
17
|
+
``git hash-object`` on the same file!
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
|
|
22
|
+
HASH_LENGTH = 40
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def object_header(object_type: str, data: bytes) -> bytes:
|
|
26
|
+
r"""Make the label Git puts in front of every object: ``<type> <size>`` and a zero byte.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
object_type: What kind of object this is ("blob", "tree" or "commit").
|
|
30
|
+
data: The object's content.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The header bytes, for example ``b"blob 6\x00"``.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
return f"{object_type} {len(data)}\0".encode()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hash_object(object_type: str, data: bytes) -> str:
|
|
40
|
+
"""Take the fingerprint of an object: SHA-1 of its label plus its content.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
object_type: What kind of object this is ("blob", "tree" or "commit").
|
|
44
|
+
data: The object's content.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A 40-character hexadecimal hash string.
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
return hashlib.sha1(object_header(object_type, data) + data).hexdigest() # noqa: S324
|
pygit/objects.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Object store -- the photo album for blobs, trees, and commits.
|
|
2
|
+
|
|
3
|
+
Git stores everything as "objects" identified by their hash. This
|
|
4
|
+
module handles reading and writing those objects to disk. Objects
|
|
5
|
+
are stored in `.pygit/objects/` using the first two characters of
|
|
6
|
+
the hash as a subdirectory name (to avoid one huge folder).
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
Hash: 943a702d06f34599aee1f8da8ef9f7296031d699
|
|
10
|
+
Path: .pygit/objects/94/3a702d06f34599aee1f8da8ef9f7296031d699
|
|
11
|
+
|
|
12
|
+
Each object file holds a small label -- its type and size, like
|
|
13
|
+
``blob 13`` and a zero byte -- followed by the content, exactly as real
|
|
14
|
+
Git does. (Real Git also squashes the file with zlib compression; we
|
|
15
|
+
skip that so you can open the files and look inside.)
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from pygit.errors import ObjectNotFoundError, ObjectTypeError
|
|
22
|
+
from pygit.hashing import hash_object, object_header
|
|
23
|
+
|
|
24
|
+
PREFIX_LENGTH = 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ObjectStore:
|
|
28
|
+
"""Read and write content-addressed objects.
|
|
29
|
+
|
|
30
|
+
Each object is stored as a file named by its hash, inside a
|
|
31
|
+
subdirectory named by the first two characters of the hash.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
objects_dir: Path to the `.pygit/objects` directory.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("_objects_dir",)
|
|
39
|
+
|
|
40
|
+
def __init__(self, objects_dir: str | Path) -> None:
|
|
41
|
+
"""Create an object store at the given path."""
|
|
42
|
+
self._objects_dir = Path(objects_dir)
|
|
43
|
+
self._objects_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
def _object_path(self, object_hash: str) -> Path:
|
|
46
|
+
"""Return the filesystem path for an object hash."""
|
|
47
|
+
prefix = object_hash[:PREFIX_LENGTH]
|
|
48
|
+
rest = object_hash[PREFIX_LENGTH:]
|
|
49
|
+
return self._objects_dir / prefix / rest
|
|
50
|
+
|
|
51
|
+
def write_object(self, object_type: str, data: bytes) -> str:
|
|
52
|
+
"""Store an object and return its hash.
|
|
53
|
+
|
|
54
|
+
Think of it like filing a photo in an album -- once it's
|
|
55
|
+
filed under its label (hash), you can always find it again.
|
|
56
|
+
Works for any object type: blobs, trees, or commits.
|
|
57
|
+
|
|
58
|
+
If the object already exists (same type and content), this is a
|
|
59
|
+
no-op (content-addressable means no duplicates).
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
object_type: What kind of object this is ("blob", "tree" or "commit").
|
|
63
|
+
data: The object's content.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The SHA-1 hash of the labelled object.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
object_hash = hash_object(object_type, data)
|
|
70
|
+
path = self._object_path(object_hash)
|
|
71
|
+
if not path.exists():
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
path.write_bytes(object_header(object_type, data) + data)
|
|
74
|
+
return object_hash
|
|
75
|
+
|
|
76
|
+
def read_object(self, object_hash: str, object_type: str) -> bytes:
|
|
77
|
+
"""Retrieve an object's content by its hash, checking its type label.
|
|
78
|
+
|
|
79
|
+
Like looking up a photo in the album by its label -- hand over the
|
|
80
|
+
hash and the kind of thing you expect, and get back its content.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
object_hash: The SHA-1 hash of the object.
|
|
84
|
+
object_type: The type you expect ("blob", "tree" or "commit").
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The object's content, without its label.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ObjectNotFoundError: If no object with that hash exists.
|
|
91
|
+
ObjectTypeError: If the object is labelled with a different type
|
|
92
|
+
(or its label is damaged).
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
path = self._object_path(object_hash)
|
|
96
|
+
if not path.exists():
|
|
97
|
+
msg = f"Object not found: {object_hash}"
|
|
98
|
+
raise ObjectNotFoundError(msg)
|
|
99
|
+
header, _, data = path.read_bytes().partition(b"\0")
|
|
100
|
+
# Compare the label as bytes: a damaged file's label may not be text.
|
|
101
|
+
stored_type = header.split(b" ", 1)[0]
|
|
102
|
+
if stored_type != object_type.encode():
|
|
103
|
+
label = stored_type.decode(errors="replace")
|
|
104
|
+
msg = f"Object {object_hash} is a {label}, not a {object_type}"
|
|
105
|
+
raise ObjectTypeError(msg)
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
def exists(self, object_hash: str) -> bool:
|
|
109
|
+
"""Check whether an object with the given hash exists."""
|
|
110
|
+
return self._object_path(object_hash).exists()
|
pygit/py.typed
ADDED
|
File without changes
|
pygit/repository.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Repository -- the diary that holds everything.
|
|
2
|
+
|
|
3
|
+
The repository is the `.pygit` folder in your project. It stores all
|
|
4
|
+
objects (blobs, trees, commits), tracks branches, and maintains the
|
|
5
|
+
staging area. This module ties everything together.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import unicodedata
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pygit.commit import Commit, create_commit, parse_commit
|
|
13
|
+
from pygit.errors import RepositoryError
|
|
14
|
+
from pygit.objects import ObjectStore
|
|
15
|
+
from pygit.tree import TreeEntry, build_tree, parse_tree
|
|
16
|
+
|
|
17
|
+
PYGIT_DIR = ".pygit"
|
|
18
|
+
OBJECTS_DIR = "objects"
|
|
19
|
+
REFS_DIR = "refs/heads"
|
|
20
|
+
HEAD_FILE = "HEAD"
|
|
21
|
+
INDEX_FILE = "index"
|
|
22
|
+
DEFAULT_BRANCH = "main"
|
|
23
|
+
# Most file systems refuse a file name longer than 255 bytes.
|
|
24
|
+
MAX_BRANCH_NAME_BYTES = 255
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _check_branch_name(name: str) -> None:
|
|
28
|
+
r"""Refuse a branch name that isn't one plain, visible file name.
|
|
29
|
+
|
|
30
|
+
A branch is a file in ``.pygit/refs/heads``, named after the branch, so
|
|
31
|
+
the name must be safe to use as a file name there: not empty, not ``.``
|
|
32
|
+
or ``..``, no ``/`` or ``\`` (they would reach into other folders), no
|
|
33
|
+
spaces or invisible characters (HEAD is a line of text, and they get
|
|
34
|
+
lost), and short enough for the file system.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
RepositoryError: If the name isn't allowed.
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
if (
|
|
41
|
+
name in {"", ".", ".."}
|
|
42
|
+
or "/" in name
|
|
43
|
+
or "\\" in name
|
|
44
|
+
or not name.isprintable()
|
|
45
|
+
or any(char.isspace() for char in name)
|
|
46
|
+
or len(name.encode()) > MAX_BRANCH_NAME_BYTES
|
|
47
|
+
):
|
|
48
|
+
msg = f"Invalid branch name: {name!r}"
|
|
49
|
+
raise RepositoryError(msg)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _same_name_key(name: str) -> str:
|
|
53
|
+
"""Return a name as a Mac compares file names: ignoring capitals and how accents are spelled.
|
|
54
|
+
|
|
55
|
+
(Windows ignores capitals too, but not accent spelling. Folding both here
|
|
56
|
+
means PyGit refuses the same names on every computer.)
|
|
57
|
+
"""
|
|
58
|
+
return unicodedata.normalize("NFD", unicodedata.normalize("NFD", name).casefold())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Repository:
|
|
62
|
+
"""A PyGit repository -- your project's diary.
|
|
63
|
+
|
|
64
|
+
Like a diary that keeps every version of your story, the repository
|
|
65
|
+
remembers all your project's snapshots, tracks its branches, and
|
|
66
|
+
holds the staging area where you gather changes. It ties everything
|
|
67
|
+
together and manages the complete version control lifecycle: init,
|
|
68
|
+
add, commit, log, branch, and status.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
__slots__ = ("_path", "_pygit_dir", "_store")
|
|
73
|
+
|
|
74
|
+
def __init__(self, path: str | Path) -> None:
|
|
75
|
+
"""Open an existing repository.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
path: The project root directory.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
RepositoryError: If no `.pygit` directory exists.
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
self._path = Path(path).resolve()
|
|
85
|
+
self._pygit_dir = self._path / PYGIT_DIR
|
|
86
|
+
if not self._pygit_dir.exists():
|
|
87
|
+
msg = f"Not a pygit repository: {self._path}"
|
|
88
|
+
raise RepositoryError(msg)
|
|
89
|
+
self._store = ObjectStore(self._pygit_dir / OBJECTS_DIR)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def init(cls, path: str | Path) -> Repository:
|
|
93
|
+
"""Create a new repository in the given directory.
|
|
94
|
+
|
|
95
|
+
Creates the `.pygit` directory structure with objects, refs,
|
|
96
|
+
HEAD, and an empty index.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
path: The project root directory.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The newly created Repository.
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
root = Path(path).resolve()
|
|
106
|
+
pygit_dir = root / PYGIT_DIR
|
|
107
|
+
|
|
108
|
+
if pygit_dir.exists():
|
|
109
|
+
msg = f"Repository already exists: {root}"
|
|
110
|
+
raise RepositoryError(msg)
|
|
111
|
+
|
|
112
|
+
# Create directory structure.
|
|
113
|
+
(pygit_dir / OBJECTS_DIR).mkdir(parents=True)
|
|
114
|
+
(pygit_dir / REFS_DIR).mkdir(parents=True)
|
|
115
|
+
|
|
116
|
+
# Create HEAD pointing to main.
|
|
117
|
+
(pygit_dir / HEAD_FILE).write_text(f"ref: {REFS_DIR}/{DEFAULT_BRANCH}\n", encoding="utf-8")
|
|
118
|
+
|
|
119
|
+
# Create empty index.
|
|
120
|
+
(pygit_dir / INDEX_FILE).write_text("{}", encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
return cls(root)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def path(self) -> Path:
|
|
126
|
+
"""Return the project root path."""
|
|
127
|
+
return self._path
|
|
128
|
+
|
|
129
|
+
def add(self, file_path: str) -> str:
|
|
130
|
+
"""Stage a file for the next commit.
|
|
131
|
+
|
|
132
|
+
Read the file's content, store it as a blob, and record it
|
|
133
|
+
in the index (staging area).
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
file_path: Path to the file (relative to project root).
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The blob hash of the staged file.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
RepositoryError: If the file doesn't exist.
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
full_path = self._path / file_path
|
|
146
|
+
if not full_path.is_file():
|
|
147
|
+
msg = f"File not found: {file_path}"
|
|
148
|
+
raise RepositoryError(msg)
|
|
149
|
+
|
|
150
|
+
# Files are stored as raw bytes, so pictures and other non-text
|
|
151
|
+
# files work too.
|
|
152
|
+
blob_hash = self._store.write_object("blob", full_path.read_bytes())
|
|
153
|
+
|
|
154
|
+
# Update the index.
|
|
155
|
+
index = self._read_index()
|
|
156
|
+
index[Path(file_path).as_posix()] = blob_hash # "./src/a.py" -> "src/a.py"
|
|
157
|
+
self._write_index(index)
|
|
158
|
+
|
|
159
|
+
return blob_hash
|
|
160
|
+
|
|
161
|
+
def commit(self, message: str, author: str = "Anonymous") -> str:
|
|
162
|
+
"""Create a commit from the current staging area.
|
|
163
|
+
|
|
164
|
+
A commit is a *full snapshot*, like a photo of every room in
|
|
165
|
+
the house -- not just the room you tidied. So we start from the
|
|
166
|
+
parent commit's snapshot and lay the staged changes on top,
|
|
167
|
+
which means files committed earlier stay in the picture. Then
|
|
168
|
+
we create a commit pointing to the new tree, update the current
|
|
169
|
+
branch, and clear the index.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
message: The commit message.
|
|
173
|
+
author: The author's name.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
The commit hash.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
RepositoryError: If the staging area is empty.
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
index = self._read_index()
|
|
183
|
+
if not index:
|
|
184
|
+
msg = "Nothing to commit (staging area is empty)"
|
|
185
|
+
raise RepositoryError(msg)
|
|
186
|
+
|
|
187
|
+
# Get the parent commit (current branch tip).
|
|
188
|
+
parent_hash = self._get_branch_tip()
|
|
189
|
+
|
|
190
|
+
# Start from the parent's snapshot, then overlay the staged
|
|
191
|
+
# changes so previously committed files are not lost.
|
|
192
|
+
snapshot = self._parent_tree_entries(parent_hash)
|
|
193
|
+
snapshot.update(index)
|
|
194
|
+
|
|
195
|
+
# Build a tree for every folder, from the full snapshot.
|
|
196
|
+
tree_hash = self._write_tree(snapshot)
|
|
197
|
+
|
|
198
|
+
# Create the commit.
|
|
199
|
+
commit_hash, commit_content = create_commit(
|
|
200
|
+
tree_hash=tree_hash,
|
|
201
|
+
parent_hash=parent_hash,
|
|
202
|
+
message=message,
|
|
203
|
+
author=author,
|
|
204
|
+
)
|
|
205
|
+
self._store.write_object("commit", commit_content.encode())
|
|
206
|
+
|
|
207
|
+
# Update the current branch to point to the new commit.
|
|
208
|
+
self._set_branch_tip(commit_hash)
|
|
209
|
+
|
|
210
|
+
# Clear the index.
|
|
211
|
+
self._write_index({})
|
|
212
|
+
|
|
213
|
+
return commit_hash
|
|
214
|
+
|
|
215
|
+
def log(self) -> list[Commit]:
|
|
216
|
+
"""Return the commit history, newest first.
|
|
217
|
+
|
|
218
|
+
Walk the parent chain from the current branch tip back to
|
|
219
|
+
the first commit.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
A list of Commit objects, newest first.
|
|
223
|
+
|
|
224
|
+
"""
|
|
225
|
+
commits: list[Commit] = []
|
|
226
|
+
current_hash = self._get_branch_tip()
|
|
227
|
+
|
|
228
|
+
while current_hash:
|
|
229
|
+
content = self._store.read_object(current_hash, "commit").decode()
|
|
230
|
+
commit = parse_commit(content)
|
|
231
|
+
commits.append(commit)
|
|
232
|
+
current_hash = commit.parent_hash
|
|
233
|
+
|
|
234
|
+
return commits
|
|
235
|
+
|
|
236
|
+
def status(self) -> dict[str, str]:
|
|
237
|
+
"""Return the current staging area contents.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
A dict mapping file paths to blob hashes.
|
|
241
|
+
|
|
242
|
+
"""
|
|
243
|
+
return self._read_index()
|
|
244
|
+
|
|
245
|
+
def create_branch(self, name: str) -> None:
|
|
246
|
+
"""Create a new branch pointing to the current commit.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
name: The branch name.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
RepositoryError: If the name isn't allowed, or the branch already
|
|
253
|
+
exists, or another branch has the same name in different
|
|
254
|
+
capitals or a different spelling of an accent (on a Mac or
|
|
255
|
+
Windows they would share one file).
|
|
256
|
+
|
|
257
|
+
"""
|
|
258
|
+
_check_branch_name(name)
|
|
259
|
+
existing = self.list_branches()
|
|
260
|
+
if name in existing:
|
|
261
|
+
msg = f"Branch already exists: {name}"
|
|
262
|
+
raise RepositoryError(msg)
|
|
263
|
+
for other in sorted({*existing, self.current_branch()}):
|
|
264
|
+
if other != name and _same_name_key(other) == _same_name_key(name):
|
|
265
|
+
msg = f"Branch {name!r} differs from {other!r} only in capitals or accents"
|
|
266
|
+
raise RepositoryError(msg)
|
|
267
|
+
tip = self._get_branch_tip()
|
|
268
|
+
try:
|
|
269
|
+
(self._pygit_dir / REFS_DIR / name).write_text(tip or "", encoding="utf-8")
|
|
270
|
+
except OSError as exc: # e.g. a Mac refuses letters newer than it knows
|
|
271
|
+
msg = f"Invalid branch name: {name!r} ({exc.strerror})"
|
|
272
|
+
raise RepositoryError(msg) from exc
|
|
273
|
+
|
|
274
|
+
def list_branches(self) -> list[str]:
|
|
275
|
+
"""Return all branch names."""
|
|
276
|
+
refs_dir = self._pygit_dir / REFS_DIR
|
|
277
|
+
return sorted(p.name for p in refs_dir.iterdir() if p.is_file())
|
|
278
|
+
|
|
279
|
+
def current_branch(self) -> str:
|
|
280
|
+
"""Return the name of the current branch.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
RepositoryError: If HEAD names something that isn't a valid
|
|
284
|
+
branch, such as a hand-edited ``ref: refs/heads/../../x``.
|
|
285
|
+
|
|
286
|
+
"""
|
|
287
|
+
head = (self._pygit_dir / HEAD_FILE).read_text(encoding="utf-8").strip()
|
|
288
|
+
name = head.removeprefix(f"ref: {REFS_DIR}/")
|
|
289
|
+
# HEAD is a file anyone can edit, so check it the way a new name is
|
|
290
|
+
# checked: otherwise the next commit could write anywhere.
|
|
291
|
+
try:
|
|
292
|
+
_check_branch_name(name)
|
|
293
|
+
except RepositoryError as exc:
|
|
294
|
+
msg = f"HEAD is damaged: {head!r}"
|
|
295
|
+
raise RepositoryError(msg) from exc
|
|
296
|
+
return name
|
|
297
|
+
|
|
298
|
+
def switch_branch(self, name: str) -> None:
|
|
299
|
+
"""Switch to a different branch.
|
|
300
|
+
|
|
301
|
+
Note: this only moves HEAD to point at the branch -- it does
|
|
302
|
+
not yet update the files in your working directory to match that
|
|
303
|
+
branch's snapshot (a real ``checkout``). Restoring files on
|
|
304
|
+
switch is a great exercise to try yourself!
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
name: The branch name to switch to.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
RepositoryError: If the name isn't allowed, or the branch doesn't exist.
|
|
311
|
+
|
|
312
|
+
"""
|
|
313
|
+
_check_branch_name(name)
|
|
314
|
+
# Look for the exact name in the list: asking the file system would
|
|
315
|
+
# let "MAIN" find "main" on a Mac.
|
|
316
|
+
if name not in self.list_branches():
|
|
317
|
+
msg = f"Branch not found: {name}"
|
|
318
|
+
raise RepositoryError(msg)
|
|
319
|
+
(self._pygit_dir / HEAD_FILE).write_text(f"ref: {REFS_DIR}/{name}\n", encoding="utf-8")
|
|
320
|
+
|
|
321
|
+
# -- Internal helpers --
|
|
322
|
+
|
|
323
|
+
def _parent_tree_entries(self, parent_hash: str | None) -> dict[str, str]:
|
|
324
|
+
"""Return a commit's snapshot as a mapping of file path to blob hash."""
|
|
325
|
+
if parent_hash is None:
|
|
326
|
+
return {}
|
|
327
|
+
parent = parse_commit(self._store.read_object(parent_hash, "commit").decode())
|
|
328
|
+
return self._read_tree(parent.tree_hash)
|
|
329
|
+
|
|
330
|
+
def _write_tree(self, files: dict[str, str]) -> str:
|
|
331
|
+
"""Store a tree for a folder (and, inside it, one for every sub-folder).
|
|
332
|
+
|
|
333
|
+
``files`` maps paths like ``"src/lib/util.py"`` to blob hashes. Files
|
|
334
|
+
directly in this folder become blob entries; everything under a
|
|
335
|
+
sub-folder is gathered up and turned into that sub-folder's own tree
|
|
336
|
+
first -- like a table of contents with a mini table of contents for
|
|
337
|
+
each chapter.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
The hash of this folder's tree.
|
|
341
|
+
|
|
342
|
+
"""
|
|
343
|
+
entries: list[TreeEntry] = []
|
|
344
|
+
sub_folders: dict[str, dict[str, str]] = {}
|
|
345
|
+
for path, blob_hash in files.items():
|
|
346
|
+
first, slash, rest = path.partition("/")
|
|
347
|
+
if slash:
|
|
348
|
+
sub_folders.setdefault(first, {})[rest] = blob_hash
|
|
349
|
+
else:
|
|
350
|
+
entries.append(TreeEntry(name=first, entry_type="blob", hash=blob_hash))
|
|
351
|
+
for name, contents in sub_folders.items():
|
|
352
|
+
entries.append(TreeEntry(name=name, entry_type="tree", hash=self._write_tree(contents)))
|
|
353
|
+
_tree_hash, tree_content = build_tree(entries)
|
|
354
|
+
return self._store.write_object("tree", tree_content.encode())
|
|
355
|
+
|
|
356
|
+
def _read_tree(self, tree_hash: str, prefix: str = "") -> dict[str, str]:
|
|
357
|
+
"""Flatten a tree (and its sub-trees) back into ``{path: blob hash}``."""
|
|
358
|
+
files: dict[str, str] = {}
|
|
359
|
+
for entry in parse_tree(self._store.read_object(tree_hash, "tree").decode()):
|
|
360
|
+
path = f"{prefix}{entry.name}"
|
|
361
|
+
if entry.entry_type == "tree":
|
|
362
|
+
files.update(self._read_tree(entry.hash, prefix=f"{path}/"))
|
|
363
|
+
else:
|
|
364
|
+
files[path] = entry.hash
|
|
365
|
+
return files
|
|
366
|
+
|
|
367
|
+
def _read_index(self) -> dict[str, str]:
|
|
368
|
+
"""Read the staging area from disk."""
|
|
369
|
+
index_path = self._pygit_dir / INDEX_FILE
|
|
370
|
+
if not index_path.exists():
|
|
371
|
+
return {}
|
|
372
|
+
content = index_path.read_text(encoding="utf-8")
|
|
373
|
+
result: dict[str, str] = json.loads(content)
|
|
374
|
+
return result
|
|
375
|
+
|
|
376
|
+
def _write_index(self, index: dict[str, str]) -> None:
|
|
377
|
+
"""Write the staging area to disk."""
|
|
378
|
+
index_path = self._pygit_dir / INDEX_FILE
|
|
379
|
+
index_path.write_text(json.dumps(index), encoding="utf-8")
|
|
380
|
+
|
|
381
|
+
def _get_branch_tip(self) -> str | None:
|
|
382
|
+
"""Return the commit hash at the tip of the current branch."""
|
|
383
|
+
branch = self.current_branch()
|
|
384
|
+
branch_path = self._pygit_dir / REFS_DIR / branch
|
|
385
|
+
if not branch_path.exists():
|
|
386
|
+
return None
|
|
387
|
+
tip = branch_path.read_text(encoding="utf-8").strip()
|
|
388
|
+
return tip or None
|
|
389
|
+
|
|
390
|
+
def _set_branch_tip(self, commit_hash: str) -> None:
|
|
391
|
+
"""Update the current branch to point to a commit."""
|
|
392
|
+
branch = self.current_branch()
|
|
393
|
+
branch_path = self._pygit_dir / REFS_DIR / branch
|
|
394
|
+
branch_path.write_text(commit_hash, encoding="utf-8")
|
pygit/tree.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Trees -- snapshots of directory structure.
|
|
2
|
+
|
|
3
|
+
A tree is like a table of contents: it lists which files (blobs) and
|
|
4
|
+
subdirectories (other trees) exist, along with their names and hashes.
|
|
5
|
+
When you commit, Git builds a tree of the entire project.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from pygit.hashing import hash_object
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class TreeEntry:
|
|
16
|
+
"""One entry in a tree (a file or subdirectory).
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
name: The filename or directory name.
|
|
20
|
+
entry_type: "blob" for files, "tree" for directories.
|
|
21
|
+
hash: The SHA-1 hash of the blob or sub-tree.
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
entry_type: str
|
|
27
|
+
hash: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_tree(entries: list[TreeEntry]) -> tuple[str, str]:
|
|
31
|
+
"""Build a tree object from a list of entries.
|
|
32
|
+
|
|
33
|
+
Sorts entries by name for consistency (same entries always produce
|
|
34
|
+
the same tree hash, regardless of insertion order).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
entries: The tree entries (files and directories).
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A tuple of (tree_hash, serialized_tree_content).
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
sorted_entries = sorted(entries, key=lambda e: e.name)
|
|
44
|
+
content = json.dumps(
|
|
45
|
+
[{"name": e.name, "type": e.entry_type, "hash": e.hash} for e in sorted_entries]
|
|
46
|
+
)
|
|
47
|
+
tree_hash = hash_object("tree", content.encode())
|
|
48
|
+
return tree_hash, content
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_tree(content: str) -> list[TreeEntry]:
|
|
52
|
+
"""Deserialize a tree from its stored content.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
content: The JSON-serialized tree content.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A list of TreeEntry objects.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
raw: list[dict[str, str]] = json.loads(content)
|
|
62
|
+
return [TreeEntry(name=e["name"], entry_type=e["type"], hash=e["hash"]) for e in raw]
|