satgo1546’s ocean

Any sufficiently primitive magic is indistinguishable from technology.

Git仓库作为API端点(2):0 files changed, 0 insertions(+), 0 deletions(−)

上回实现了不克隆修改远端分支指向。用同样的代码也能创建和删除分支和轻量标签。这些操作不需要创建新对象,也不需要读取已有对象内容,传输空的pack就能完成。

要想修改仓库内容就需要创建新对象。任何非平凡的仓库都有提交(commit)、文件树(tree)、文件内容(blob)三类对象,此外还有附注标签对象(tag)。Pro Git § 10.2 Git Objects深入浅出地介绍了对象的概念、磁盘上的存储格式、散列值的计算方法,关于这些话题的详细信息请参照书中讲解。

type GitObject = {
	type: 'commit' | 'tree' | 'blob' | 'tag',
	data: string | Uint8Array,
}

本次目标是在不下载任何文件内容(甚至不知道仓库中任何文件名)的情况下,向远端推送一个空提交。“空”的含义与git commit --allow-empty命令中的empty相同,也就是不修改任何文件的提交。

既然不修改文件,就可复用上一提交引用的文件树,所以这个操作总共只需创建并推送一个提交对象。

git clone --bare

先来看看git命令行的表现:结论是,很难抑制git命令行下载额外文件的冲动。GitHub博文Get up to speed with partial clone and shallow clone中介绍的应对大型仓库的方法,无论是浅层克隆(git clone --depth=1)还是无树克隆(git clone --filter=tree:0),都保留当前工作树,仍需下载工作树中所有文件。在无历史文件树的仓库中,每走一步就会请求一次远端,以补齐缺失的文件——无树不像是一种优化,更像是一种负债。

git clone --bare结合--depth--filter参数,确实能做到只下载提交对象而不下载文件树和文件了,但得到的仓库也相应地没装陶瓷外壳,只剩马桶搋子可用。

$ git clone --bare --depth=1 --filter=tree:0 https://gist.github.com/008e50722174267a95bd6c033c4c5d3d.git b
Cloning into bare repository 'b'...
remote: Enumerating objects: 1, done.
remote: Counting objects: 100% (1/1), done.
remote: Total 1 (delta 0), reused 1 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (1/1), done.
$ cd b
$ git rev-parse HEAD
f9e7acd46c5a03e19d8c23379f66bdd29d2448d7
$ git cat-file -p HEAD
tree ad382a30f5f3f330b85f2e719f42e976f1779afc

$ echo '未来的提交' | GIT_AUTHOR_DATE=2033-05-18T03:33:20Z GIT_COMMITTER_DATE=2033-05-18T03:33:20Z git -c user.name=someone -c user.email=someone@example.com commit-tree ad382a3 -p f9e7acd
remote: Enumerating objects: 1, done.
remote: Counting objects: 100% (1/1), done.
remote: Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (1/1), 79 bytes | 79.00 KiB/s, done.
209ffbc589f3afa43ae98a5b7ceb40a970bdd19f
$ git update-ref refs/heads/main 209ffbc
$ git push

……而且就连马桶搋子也会下载文件树。

那行特别长的恐怖命令是在创建提交对象。执行命令前,仓库中只有一个提交对象,没有文件树对象。执行命令时,git发现指定的文件树对象在仓库中不存在,就去远端获取,随后将完整文件树对象保存到本地仓库中。

虽然创建提交并不需要文件树的内容,只需要散列,但即使在命令行中完整提供40位散列,也不能打消git连接远端的念头。

git commit-tree

命令中列出了提交对象中的全部要素,不过必要参数只有文件树的散列值和上一提交的散列值。作者和日期通常会根据配置自动填写,而提交消息可以为空。

产生的提交对象如下。对象格式是纯文本,没有\0作祟,用字符串插值就能构造出来。这里暂且将所有提交信息都写死。

tree ad382a30f5f3f330b85f2e719f42e976f1779afc
parent f9e7acd46c5a03e19d8c23379f66bdd29d2448d7
author someone <someone@example.com> 2000000000 +0000
committer someone <someone@example.com> 2000000000 +0000

未来的提交

为了只凭散列值就能区分对象类型,散列不仅涵盖对象内容,还覆盖了一个可变长度的头。变长的原因是其中有无意义十进制数字 😾

const hashObject = async ({ type, data }: GitObject) => {
	const raw = typeof data === 'string' ? new TextEncoder().encode(data) : data
	const header = `${type} ${raw.length}\0` // ASCII only
	const buffer = new Uint8Array(header.length + raw.length)
	new TextEncoder().encodeInto(header, buffer)
	buffer.set(raw, header.length)
	return new Uint8Array(await crypto.subtle.digest('SHA-1', buffer)).toHex()
}

const commit: GitObject = {type: 'commit', data:
`tree ad382a30f5f3f330b85f2e719f42e976f1779afc
parent f9e7acd46c5a03e19d8c23379f66bdd29d2448d7
author someone <someone@example.com> 2000000000 +0000
committer someone <someone@example.com> 2000000000 +0000

未来的提交
`}
console.log(await hashObject(commit))
// ⇒ 209ffbc589f3afa43ae98a5b7ceb40a970bdd19f

这就做完了git commit-tree执意连接远端才完成的任务。

git pack-objects

要上传对象到远端,需要将对象打包成pack,携带在POST请求中。

git提供了打包命令git pack-objects,可将指定的对象打包为pack文件。

$ echo 209ffbc589f3afa43ae98a5b7ceb40a970bdd19f | git pack-objects p
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
97184f253a5d57dbf566563c0e29d4bc061f1a4f
Writing objects: 100% (1/1), done.
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
$ xxd p-97184f2*.pack
00000000: 5041 434b 0000 0002 0000 0001 9e0d 789c  PACK..........x.

上回已经介绍了pack头尾。头尾之间的对象连续存储。每个对象由变长的type–length头和zlib流构成。类型–长度按ULEB128编码,但是二进制3位类型插在长度第3和第4位之间,长度还是解压后长度,数据流长度要靠zlib解析。神人编码 😾

为了省那么几个字节——省了吗?如省——要多写十行代码。

const pack = async (objects: GitObject[]) => {
	const body = await new Blob([
		new Uint8Array([
			0x50, 0x41, 0x43, 0x4b, 0, 0, 0, 2,
			objects.length >> 24, objects.length >> 16, objects.length >> 8, objects.length,
		]),
		...await Promise.all(objects.flatMap(({ type, data }) => {
			const raw = typeof data === 'string' ? new TextEncoder().encode(data) : data
			const typeLengthBytes = [
				{ commit: 0x10, tree: 0x20, blob: 0x30, tag: 0x40 }[type]
				| raw.length & 0xf
			]
			for (let l = raw.length >> 4; l; l >>= 7) {
				typeLengthBytes[typeLengthBytes.length - 1] |= 0x80
				typeLengthBytes.push(l & 0x7f)
			}
			return [
				new Uint8Array(typeLengthBytes),
				new Response(new Response(raw).body.pipeThrough(new CompressionStream('deflate'))).arrayBuffer(),
			]
		}))
	]).arrayBuffer()
	return await new Blob([body, await crypto.subtle.digest('SHA-1', body)]).arrayBuffer()
}

又见CompressionStream 😾 虽然HTTP下载支持先进且透明的压缩,但HTTP上传以及Git支持的其他协议都无此能力,所以pack文件的压缩仍有必要。

pack中不记载对象散列值,由服务器接收后自行计算。但为了在请求中指定修改后的分支指向,客户端仍需计算散列值。

fetch('https://gist.github.com/008e50722174267a95bd6c033c4c5d3d.git/git-receive-pack', {
, // method、headers同上回
	body: new Blob([
		pktLine(`5e5eda9c6e9897844aa0e56f40c9423ff95386ce ${await hashObject(commit)} refs/heads/main\0 report-status-v2\n`),
		new Uint8Array([0x30, 0x30, 0x30, 0x30]),
		await pack([
			commit,
		]),
	]),
})

端点返回下列成功响应。这个请求完成了一次推送操作,在main分支上追加了一个没有文件变化的空提交。

000eunpack ok
0017ok refs/heads/main
00000000
Revisions
someone revised this gist on May 18, 2033.
No changes.
在GitHub上查看和发表评论