Wasserpuncher/nanoclone
GitHub: Wasserpuncher/nanoclone
仅用 Python 标准库从零实现的 git clone,覆盖 Git Protocol v2 通信、packfile 解析与 delta 解析的完整流程,旨在揭示版本控制工具背后的底层协议机制。
Stars: 0 | Forks: 0
# nanoclone
**从头开始编写的 `git clone`。零依赖——甚至不需要 `git`。**
nanoclone 直接使用 Git 的通信协议:它通过 Smart HTTP v2 进行协商,
下载 packfile,解析其中的 delta 链,根据 SHA-1 校验每一个 object,
并写入工作树以及有效的 `.git` 索引。
最终得到的仓库**被真正的 Git 视为干净状态**:
```
$ python -m nanoclone clone https://github.com/psf/requests
Cloning into 'requests'...
branch main -> 4c800e9aea20
received pack: 13232.5 KiB in 2.8s
resolved 25624 objects
checked out 130 files
$ cd requests && git status --porcelain # no output: Git sees nothing to do
$ git fsck --strict # the object store is consistent
```
这里没有任何地方去调用 `git`。仅导入了 `hashlib`、`zlib`、
`struct`、`urllib`、`os`、`pathlib`、`argparse`、`sys` 和 `time`。
## 为什么会有这个项目
Git 通常是一个黑盒。克隆一个仓库看起来只是一条命令,但
在其底层是四种堆叠在一起的不同格式,而且
它们中的每一个都简单到足以在一个下午内读完:
| 层级 | 它是什么 |
| --- | --- |
| **pkt-line** | 每条消息前面的 4 字节十六进制长度前缀。其中三个长度被保留作为控制数据包。 |
| **Protocol v2** | 基于 HTTP 的请求/响应 RPC。`ls-refs` 查询现有内容,`fetch` 请求 object。 |
| **Packfile** | 包含每个 object,并经过 zlib 压缩。大多数存储为相对于另一个 object 的 *delta*。 |
| **Object 模型** | commit 指向 tree,tree 指向 blob。每个 object 的名称是其内容的 SHA-1 哈希值。 |
最后一个特性使得这个项目是可验证的,而不仅仅是看起来
可行:如果我们解析时错了一个 bit,哈希值就会改变,
并且我们计算出的 object id 就会停止与 Git 计算出的相匹配。在这里,
正确性不是凭主观判断的。
## 安装
要求 Python 3.10 或更高版本。无需安装:
```
$ git clone https://github.com/Wasserpuncher/nanoclone && cd nanoclone
$ python -m nanoclone --help
```
## 用法
```
# Clone 远程的默认分支
$ python -m nanoclone clone https://github.com/octocat/Hello-World
# 选择一个分支和目标位置
$ python -m nanoclone clone https://github.com/octocat/Hello-World hi -b test
# 列出远程通告的 refs,而不下载任何内容
$ python -m nanoclone ls-remote https://github.com/octocat/Hello-World
7fd1a60b01f91b314f59955a4e4d4e80d8edf11d HEAD -> refs/heads/master
7fd1a60b01f91b314f59955a4e4d4e80d8edf11d refs/heads/master
# 拆解 packfile:类型、大小、偏移量,以及每个 delta 修补的是哪个 object
$ python -m nanoclone verify-pack .git/objects/pack/pack-*.pack
```
## clone 实际上是如何工作的
1. **发现。** 发送带有请求头
`Git-Protocol: version=2` 的 `GET /info/refs?service=git-upload-pack`。服务器会响应其能力。
2. **`ls-refs`。** 发送一个 `POST` 询问存在哪些 ref。回复会告诉我们
每个分支的 commit id,并且——通过 `symref-target`——指出 `HEAD` 指向哪个分支。
3. **`fetch`。** 发送一个 `POST` 说明 `want ` 和 `done`。直接发送 `done`
会跳过协商,因此服务器会回复一个完整的
packfile。它的字节多路复用在 sideband 通道 1 上传输,进度信息在
通道 2 上,而错误在通道 3 上。
4. **解包。** packfile 的结构是 `PACK`、一个版本号、一个 object 数量,接着是
对应数量的压缩 object,最后是对其前面所有内容的 SHA-1 校验和。
5. **解析 delta。** 七种 object 类型中有两种根本不是 object,而是
补丁:`OFS_DELTA` 通过向后偏移量来命名其 base,而 `REF_DELTA` 通过
base 的 SHA-1 来命名。delta 是一个微型程序,包含 *从 base 复制此范围* 和
*插入这些字面字节*。因为 `REF_DELTA` 可能指向存储在
pack 后面的 base,所以解析过程是作为一个不动点运行的,而不是单次遍历。
6. **检出 (Check out)。** 遍历 commit 的 tree,以正确的 mode 写入 blob
(包括符号链接和可执行位),并写入一个 `.git/index`,它
缓存每个文件的 `stat` 数据——这正是让 `git status` 能够
即时返回的原因,也是使其报告 *干净* 的树而不是已修改的树的原因。
## 我如何知道它是正确的
声称“它能用”很容易。测试套件 (`python -m unittest discover -s tests`)
让 Git 自己成为裁判——包含 39 个测试,没有对格式的任何 mock:
- **针对 `git` 的差分测试。** 使用真正的
`git` 二进制文件构建一个 fixture 仓库,并重新打包两次——一次强制使用 `OFS_DELTA`,一次强制使用
`REF_DELTA`。nanoclone 解析生成的 packfile,并将每一个 object id、
类型和内容的每一个字节都与 `git cat-file --batch-all-objects` 进行比较。
在所有 42 个 SHA-1 哈希值上与 Git 达成一致,意味着在每一个字节上都达成一致。
- **`verify-pack` 交叉检查。** 我们基于偏移量的解析会与
`git verify-pack -v` 逐条进行比较,包括哪些条目是 delta。
- **Git 审查我们的检出。** 我们手动写入一个仓库,然后询问 Git:
`git fsck --strict` 必须通过,`git status --porcelain` 必须为空,并且
`git rev-parse HEAD` 必须一致。符号链接、可执行位、空文件和
UTF-8 文件名都经过了明确检查。
- **真实的 clone。** 选择性开启 (`NANOCLONE_NETWORK_TESTS=1`) 的测试通过网络克隆一个公开的
仓库,并断言生成的 tree 哈希值与在同一 URL 上执行
`git clone` 产生的哈希值相等。
- **对抗性输入。** 损坏的校验和、截断的流、保留的 pkt-line
长度 `0003`、保留的 delta 操作码 `0x00`、运行超出其
base 末尾的复制指令,以及无法解析的 delta,全都会以
特定的错误被拒绝,而不是给出错误的答案。
## 性能
在 `git clone` 为 `psf/requests` 获取的 13.8 MB packfile
(26 818 个 object,其中 17 560 个是 delta)上测量,使用 Python 3.14,页面缓存已预热:
| 阶段 | 时间 |
| --- | --- |
| 解压并解析每个 object | 0.28 s |
| 解析所有 17 560 个 delta | 1.16 s |
| 写入 26 818 个松散的 object | 7.27 s |
完整克隆该仓库(包含网络耗时)大约需要 14 秒。
早期版本在解析阶段花费了 **63 秒**。原因仅仅在于一
行代码:交给 zlib 处理的 `buf[pos:]` 会拷贝整个 packfile 的剩余部分,每处理一个
object 就拷贝一次,复杂度呈平方级增长。改为传入 `memoryview` 的固定大小块
后,该阶段的速度提升了 225 倍。这在 `packfile.py` 中做了文档说明,以便
接下来的读者不会重新引入这个问题。
## 限制
这些都是刻意为之的——我们的目标是理解协议,而不是取代 Git:
- **仅支持 HTTP(S)。** 不支持 SSH,不支持 `git://`。`scp` 风格的 URL 会被重写为 HTTPS。
- **必须使用 Protocol v2。** 每个现代主机(GitHub, GitLab, Gitea, Codeberg)
都支持它;仅支持 v0 的服务器会被拒绝并返回明确的提示信息。
- **仅限 clone。** 不支持 push,不支持增量 fetch,不进行带有 `have`
行的协商,不支持浅克隆或部分克隆。
- **仅限 SHA-1 仓库。** 不支持实验性的 SHA-256 仓库。
- **Object 以松散形式写入,** 而不是带有索引的 packfile。这样更容易
阅读,但占用了磁盘空间:`psf/requests` 的 clone 在 `.git` 中占据了 124 MB,而
真正的 Git 只需要 15 MB。这也意味着不会生成 `.idx` 文件。
- **不进行 `.gitattributes` 处理:** 不进行 CRLF 转换,没有 clean/smudge
过滤器,不支持 Git LFS。Submodule 的 commit 会记录在索引中,但不会拉取它们
的内容。
## Auf Deutsch
`nanoclone` implementiert `git clone` von Grund auf neu — ausschließlich mit der
Python-Standardbibliothek, ohne jede Abhängigkeit und ohne das `git`-Binary
aufzurufen. Umgesetzt sind das pkt-line-Format, der Smart-HTTP-Transport
(Protokoll v2), der Packfile-Parser samt Auflösung von OFS- und REF-Deltas sowie
ein Checkout, der einen gültigen Git-Index schreibt.
Der Korrektheitsbeweis liegt in den Tests: Ein Repository wird mit echtem `git`
gebaut und neu gepackt, nanoclone liest es, und **jede einzelne Objekt-ID, jeder
Typ und jedes Byte** wird gegen `git cat-file` verglichen. Anschließend prüft
`git` selbst unser Ergebnis — `git fsck --strict` muss bestehen und
`git status` muss ein sauberes Arbeitsverzeichnis melden.
## 参考
实现遵循了官方的格式规范:
- [pkt-line 格式](https://git-scm.com/docs/protocol-common#_pkt_line_format)
- [Protocol v2](https://git-scm.com/docs/protocol-v2)
- [Smart HTTP transport](https://git-scm.com/docs/http-protocol)
- [Packfile 格式](https://git-scm.com/docs/gitformat-pack)
- [Index 格式](https://git-scm.com/docs/index-format)
## 许可证
MIT — 见 [LICENSE](LICENSE)。
标签:Git, Python, SOC Prime, 内核驱动, 开发工具, 无后门, 网络协议, 解析器, 逆向工具