Git仓库作为API端点(4):这个只存在十天的世界
Brendan Eich十天作成JavaScript的都市传说广为人知。
Linus Torvalds从开始编写Git到投入Linux项目使用也不过十来天。
这个世界的时间,只够编写难用的软件。
文件树对象
git cat-file -p
中的p选项是pretty-print的意思;单纯print的命令需要指定对象类型,有git cat-file commit
、git cat-file blob
等。对于其他所有类型的对象,美不美观没什么区别。
$ git cat-file -p 209ffbc
tree ad382a30f5f3f330b85f2e719f42e976f1779afc
parent f9e7acd46c5a03e19d8c23379f66bdd29d2448d7
author someone <someone@example.com> 2000000000 +0000
committer someone <someone@example.com> 2000000000 +0000
未来的提交
$ git cat-file commit 209ffbc
tree ad382a30f5f3f330b85f2e719f42e976f1779afc
parent f9e7acd46c5a03e19d8c23379f66bdd29d2448d7
author someone <someone@example.com> 2000000000 +0000
committer someone <someone@example.com> 2000000000 +0000
未来的提交
$ git cat-file -p 980a0d5
Hello World!
$ git cat-file blob 980a0d5
Hello World!
唯独文件树对象不同。这种情况下,git cat-file -p
等价于git ls-tree
。
$ git cat-file -p ad382a3
100644 blob 980a0d5f19a64b4b30a87d4206aade58726b60e3 index.txt
$ git ls-tree ad382a3
100644 blob 980a0d5f19a64b4b30a87d4206aade58726b60e3 index.txt
$ git cat-file tree ad382a3 | xxd
00000000: 3130 3036 3434 2069 6e64 6578 2e74 7874 100644 index.txt
00000010: 0098 0a0d 5f19 a64b 4b30 a87d 4206 aade ...._..KK0.}B...
00000020: 5872 6b60 e3 Xrk`.
为什么其他对象都是纯文本,只有文件树对象是文本和二进制混合的格式?我没有找到说法。文件树作为Git最基本的元素之一,格式早在Git最初编写时就已定下,在Git源代码仓库中还能找到当时输出文件树对象的程序(write-tree.c@e83c516
main()),这套格式沿用至今。
for (i = 0; i < entries; i++) {
struct cache_entry *ce = active_cache[i];
if (check_valid_sha1(ce->sha1) < 0)
exit(1);
if (offset + ce->namelen + 60 > size) {
size = alloc_nr(offset + ce->namelen + 60);
buffer = realloc(buffer, size);
}
offset += sprintf(buffer + offset, "%o %s", ce->st_mode, ce->name);
buffer[offset++] = 0;
memcpy(buffer + offset, ce->sha1, 20);
offset += 20;
}
修改格式意味着重算全部散列,时至今日已无修改格式的可能。文本与二进制混合的文件与传输格式仅仅因为C处理方便的缘故深深根植于Git底层。即使协议升级,这一点也绝不会改变。
我只能认为,当时的Linus彻底沉浸在自己的拼好file的艺术里了,C有什么就用什么,想到什么就写什么。
还写了个O(n2)的文件名排序(update-cache.c@e83c516
add_cache_entry())。
static int cache_name_pos(const char *name, int namelen)
{
int first, last;
first = 0;
last = active_nr;
while (last > first) {
int next = (last + first) >> 1;
struct cache_entry *ce = active_cache[next];
int cmp = cache_name_compare(name, namelen, ce->name, ce->namelen);
if (!cmp)
return -next-1;
if (cmp < 0) {
last = next;
continue;
}
first = next+1;
}
return first;
}
static int add_cache_entry(struct cache_entry *ce)
{
int pos;
pos = cache_name_pos(ce->name, ce->namelen);
/* existing match? Just replace it */
if (pos < 0) {
active_cache[-pos-1] = ce;
return 0;
}
/* Make sure the array is big enough .. */
if (active_nr == active_alloc) {
active_alloc = alloc_nr(active_alloc);
active_cache = realloc(active_cache, active_alloc * sizeof(struct cache_entry *));
}
/* Add it in.. */
active_nr++;
if (active_nr > pos)
memmove(active_cache + pos + 1, active_cache + pos, (active_nr - pos - 1) * sizeof(ce));
active_cache[pos] = ce;
return 0;
}
说到排序——文件树是无序集合,为了保证每次生成的对象散列一致,列表必须按文件名排序。
排序字符串是一件极其困难的事。此处排序的目的是加速机器查询,因此Git采用了memcmp比较文本。依UTF-8编码,这等价于按码点排序,但不等价于按UTF-16编码排序。在JavaScript中直接调用Array.prototype.sort排序会在文件名包含BMP外字符时得到错误的结果,目前只能通过专门编写一个按码点比较字符串的函数来解决。已经有提案希望JS内置这样的函数,不过该提案目前的状态如图所示:

我通过奇怪的方法实现了按码点排序,在这个LLM时代写了一条StackOverflow答案。
火上浇油,雪上加霜,Git还有自己的怪癖。光靠观察很难发现的一个细节是,文件夹名隐含尾缀斜杠,同名文件和文件夹排列顺序不同。这也是魔法芝士的一个侧面……
$ git ls-tree 90d2c89
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 a.0
040000 tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 a
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 a0
$ git ls-tree 82bdf8c
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 a
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 a.0
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 a0
(2E .
,2F /
,30 0
。e69de29
和4b825dc
是著名对象的散列。)
type TreeEntry = {
mode: number,
filename: string,
hash: string,
}
const tree = (entries: TreeEntry[]): GitObject => {
entries = entries.map(({ mode, filename, hash }) => ({
hash,
order: (filename + (((mode & 0o770000) === 0o040000) ? '/' : '')).replace(/[\0-\uffff]/gu, ' $&'),
toString: () => `${mode.toString(8)} ${filename}\0${'?'.repeat(hash.length >>> 1)}`,
})).sort((a, b) => a.order > b.order ? 1 : a.order < b.order ? -1 : 0)
const bytes = new TextEncoder().encode(entries.join(''))
let i = 0
for (const { hash } of entries) {
bytes.subarray(i = bytes.indexOf(0, i) + 1, i += hash.length >>> 1).setFromHex(hash)
}
return { type: 'tree', data: bytes }
}
const t = tree([
{ mode: 0o100644, hash: 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', filename: '😾.😾' },
{ mode: 0o040000, hash: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', filename: '😾' },
])
算上前几回写的代码,至此,提交、文件树、文件内容对象都可以生成、散列、上传了。这个Git客户端已经拥有了创建任意内容的提交并推送到远端的能力。
可它易碎得令人不敢触碰。诚然,Git软件表现出很强的向前兼容性,却不能消解我的担忧。原作能用就行的开发态度,缺乏规范和替代实现,注定了覆水难收的抽象泄露,互操作性的覆灭。
没了Linux地球就不转了
Git最初的目的只有一个:管理Linux的源代码,故一切设计皆以Linux为中心。
Linux的文件系统很简单:文件名无关文本编码,允许除了NUL和/以外的任何字符,区分大小写,类型加权限只用一个数字就能表示。
其他哪个操作系统都见不到这些特性。默认了系统优秀性质的程序,到了其他系统上必然漏洞频出,Git也不例外。
十天糊的不可移植文物,要花十年弥补跨平台支持。适用于Linux的Git于2005年启用;可直到2015年,Git for Windows才发布首个版本2.5.0。如今,又是十年过去,Git迎来了二十周年。直到现在,许多根本设计上不兼容带来的问题仍困扰着Windows用户。为什么100644变成了100755?为什么CRLF进库了?为什么读写数据库很慢?在Windows彻底被Linux同化之前,这些问题将永远相随。
这是一个只存在十天的世界。
(本系列完结)