在.git/objects下面看到许多两位名字的目录,很容易把“一个对象对应一个小文件”当成Git永远不变的规则。打包后,小文件没了,旧提交却依然能打开。
真正不变的是对象身份,不是文件路径。这次在临时仓库做两个对照:先把完整对象装进pack,再允许Git为相似对象选择delta。分别观察ID、存储位置和压缩表示,不用一个“压缩”把三件事糊在一起。
实验只需要标准Git和PowerShell,使用Git for Windows 2.45.2.windows.1、SHA-1仓库。以下代码在同一个窗口按顺序运行,不在已有项目里打包,也不把一次文件大小当性能基准。
1. 为什么不用两个三字节文件测delta
很小的对象可能没有差量收益。这里造1000行内容,两次提交只改一行,给delta搜索一个明确的相似性条件。显式写无BOM的UTF-8和LF,避免PowerShell默认重定向编码影响字节。
$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false function G { & git @args; if ($LASTEXITCODE -ne 0) { throw "git failed: $args" } } $lab = Join-Path $env:TEMP ('git-pack-lab-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $lab | Out-Null Set-Location $lab G init --object-format=sha1 -b master G config user.name 'Blog experiment' G config user.email 'blog@example.invalid' G config core.autocrlf false G config gc.auto 0 $utf8 = New-Object System.Text.UTF8Encoding($false) $lines = @(1..1000 | ForEach-Object { 'row-{0:D4}: stable payload for delta experiment' -f $_ }) [IO.File]::WriteAllText((Join-Path $lab 'f.txt'), ($lines -join "`n") + "`n", $utf8) G add f.txt G commit -m c1 $blob1 = G rev-parse 'HEAD:f.txt' $lines[499] = 'row-0500: changed payload for delta experiment' [IO.File]::WriteAllText((Join-Path $lab 'f.txt'), ($lines -join "`n") + "`n", $utf8) G add f.txt G commit -m c2 $tip = G rev-parse HEAD $blob2 = G rev-parse 'HEAD:f.txt' $beforeIds = @(G cat-file --batch-all-objects '--batch-check=%(objectname)' | Sort-Object) G count-objects -v本例有两个不同blob、两个tree、两个commit,共六个对象。不是说“两次提交必定六个”:文件内容复用、目录结构或额外引用都会改变这个数。
2. 没有delta,也可以有pack
G repack -a -d --window=0 G prune-packed $indexes = @(Get-ChildItem .git/objects/pack/*.idx) if ($indexes.Count -ne 1) { throw 'expected one pack in this fresh lab' } $fullReport = @(G verify-pack -v $indexes[0].FullName) $fullReport $fullRows = @($fullReport | Where-Object { $_ -match '^[0-9a-f]{40}\s' }) $fullDeltas = @($fullRows | Where-Object { ($_ -split '\s+').Count -eq 7 }) 'full_pack_objects=' + $fullRows.Count 'full_pack_delta_objects=' + $fullDeltas.Count G count-objects -v 'tip_unchanged=' + ((G rev-parse HEAD) -eq $tip) 'object_ids_unchanged=' + (-not [bool](Compare-Object $beforeIds @(G cat-file --batch-all-objects '--batch-check=%(objectname)' | Sort-Object)))window=0禁用这次打包的delta搜索;prune-packed只清除已在包中存在的松散副本,不是删除唯一对象。新仓库此时只有一组pack/idx,才可以明确选择那个idx;不能把“取第一个文件”的写法照搬到已有多包仓库。
本轮得到六个对象、零个delta;对象集合和HEAD都不变。即使每个对象仍采用完整内容表示,pack已经成立。Git repack手册
3. idx提供的是位置,不是另一份文件内容
verify-pack里普通对象行包含对象ID、类型、对象大小、包内大小和offset。delta行还给出深度及base对象ID;其中size不能直接当作重建后的完整对象长度,本轮旧blob的delta行显示18,cat-file读取其完整内容长度却是46000。offset是包内位置,不是对象的新名字,也不是文件在工作区里的位置。
| 层 | 稳定或变化的东西 | 本例检查手段 |
|---|---|---|
| 身份 | 请求哪个对象ID | 打包前后比较完整ID集合 |
| 定位 | loose路径或idx中的pack偏移 | 列出pack/idx,查看verify-pack |
| 表示 | 完整压缩对象或delta | 检查delta行与base信息 |
| 内容 | 调用者最终读到的字节 | 从包读blob,重新计算其ID |
v2 idx用fan-out和排序ID表帮助定位条目,再取得offset;pack里才存对象的表示。这里是查询路径示意,不把图当字节格式说明。SHA-1长度也不能套到所有对象格式。Git pack格式
4. 证明能读,不只证明还有一个ID
从包里读取第二版blob,按实验规定的编码和换行重建一个文件,再重新hash。它比“log还能显示提交说明”更接近我们的目标:确认文件内容也正确。
$readBack = @(G cat-file blob $blob2) [IO.File]::WriteAllText((Join-Path $lab 'read-back.txt'), ($readBack -join "`n") + "`n", $utf8) 'packed_blob_round_trip=' + ((G hash-object read-back.txt) -eq $blob2) 'old_blob_readable=' + ((G cat-file -t $blob1) -eq 'blob') G fsck --full两项为True。读回文件没有add,不会给后续对象集合增加一次提交。这个文本拼接只适用于我们特意构造的ASCII/LF样例,不是通用二进制文件恢复方法:任意字节不能先当PowerShell文本读再重写。
5. 重新打包,为什么先没有得到delta
G repack -a -d --window=50 --depth=50 $indexes = @(Get-ChildItem .git/objects/pack/*.idx) if ($indexes.Count -ne 1) { throw 'expected one reused pack' } $reuseReport = @(G verify-pack -v $indexes[0].FullName) $reuseDeltaRows = @($reuseReport | Where-Object { $_ -match '^[0-9a-f]{40}\s' -and ($_ -split '\s+').Count -eq 7 }) 'delta_objects_without_recompute=' + $reuseDeltaRows.Count G repack -a -d -f --window=50 --depth=50 G prune-packed $indexes = @(Get-ChildItem .git/objects/pack/*.idx) if ($indexes.Count -ne 1) { throw 'expected one replacement pack' } $deltaReport = @(G verify-pack -v $indexes[0].FullName) $deltaReport $deltaRows = @($deltaReport | Where-Object { $_ -match '^[0-9a-f]{40}\s' -and ($_ -split '\s+').Count -eq 7 }) 'delta_detected=' + ($deltaRows.Count -gt 0) 'delta_pack_objects=' + (@($deltaReport | Where-Object { $_ -match '^[0-9a-f]{40}\s' }).Count) 'ids_after_delta_unchanged=' + (-not [bool](Compare-Object $beforeIds @(G cat-file --batch-all-objects '--batch-check=%(objectname)' | Sort-Object))) 'delta_blob_content_equal=' + ((@(G cat-file blob $blob2) -join "`n") -eq ($lines -join "`n")) $oldBlobRow = @($deltaRows | Where-Object { ($_ -split '\s+')[0] -eq $blob1 }) if ($oldBlobRow.Count -ne 1) { throw 'expected one old blob delta row in this sample' } 'old_blob_delta_base_is_new=' + ($oldBlobRow.Count -eq 1 -and ($oldBlobRow[0] -split '\s+')[6] -eq $blob2) 'old_blob_delta_payload_size=' + ($oldBlobRow[0] -split '\s+')[2] 'old_blob_full_size=' + (G cat-file -s $blob1) G fsck --full本轮中间检查仍是零个delta,加入-f后才出现delta。-f要求底层不复用既有delta选择而重新计算;仅扩大搜索窗口,不能直接推断现有包已经按新条件重新选择所有表示。这个失败过的中间状态保留在正文里,读者才知道要排查什么。Git pack-objects手册
这不是说-f及这些参数保证任意仓库都产生delta。对象大小、相似性、搜索限制和实现版本都会影响选择;本例是给定数据下的观察。
本轮还检查到,旧blob的delta以新blob为base。delta需要base才能重建内容,Git完成重建后仍向上层返回原来那个对象。一个blob有了delta表示,不等于它变成另一个版本,也不意味着它的base就是历史上的“上一次提交”。pack选择的是存储依赖,commit表达的是历史依赖,两套关系不能等同。size字段更应结合cat-file确认,不应把18字节说成原文件变成18字节了。
| 同一个旧blob | verify-pack的size | cat-file -s的完整大小 | delta base |
|---|---|---|---|
| 完整对象表示 | 46000 | 46000 | 无 |
| 本轮delta表示 | 18 | 46000 | 新blob |
这组数仅对应本篇固定样例;大小字段的含义不同,就不能直接拿来比较“文件缩小了多少”。
6. 把对照条件写成断言
if ($fullRows.Count -ne 6 -or $fullDeltas.Count -ne 0) { throw 'full-object control failed' } if ($reuseDeltaRows.Count -ne 0) { throw 'reuse control changed on this environment' } if ($deltaRows.Count -eq 0) { throw 'this sample did not produce a delta' } if ((G rev-parse HEAD) -ne $tip) { throw 'tip changed' } if (Compare-Object $beforeIds @(G cat-file --batch-all-objects '--batch-check=%(objectname)' | Sort-Object)) { throw 'object IDs changed' } if ((G hash-object read-back.txt) -ne $blob2) { throw 'read-back mismatch' } 'PACK_IDENTITY_CHECKS_PASSED'图是实际stdout节选排版后截图,不是桌面终端窗口。验证先固定内容与ID集合,再控制delta搜索,最后检查读取、重新hash与fsck,而不是只看压缩包是否变小。
小对象可能因为格式开销占用更多空间,运行一次也不能推出性能结论。这个实验真正要澄清的是:对象身份、定位索引和压缩表示可以分开变化。objects下面的小文件不再存在,不应该先被解释为“Git把提交删掉了”。