假设有两份文件:src/a.txt和copy/b.txt,内容都是same加一个换行。
Git给它们计算出的blob ID会一样。那它以后怎么知道哪一份叫a,哪一份叫b,又该放回哪个目录?
如果只说“blob保存内容”,问题还没回答完。我们把这两个文件真的放进一个临时仓库,从它们共用的blob往上找。
1. 先确认:名字不同,blob真的可以相同
以下使用标准Git,不用安装mini-git;在Git Bash或Linux/WSL的Bash中依次执行,目录是新建的,可丢弃。
lab=$(mktemp-d"${TMPDIR:-/tmp}/git-tree-lab.XXXXXX")||exit1cd"$lab"||exit1gitinit-q--object-format=sha1gitconfig core.autocrlffalsemkdirsrc copyprintf'same\n'>src/a.txtprintf'same\n'>copy/b.txtgithash-object src/a.txtgithash-object copy/b.txtgitaddsrc/a.txt copy/b.txt两个hash都是:
1275430f1765c63e539cb0452565563bd6aef6a6这里显式选SHA-1格式。它不是说所有现代Git仓库都必须用40位十六进制ID;另一种对象格式不能套用本文的20字节长度。
blob知道的是same\n这5字节内容。它没有两个“所属目录”字段,也不需要为两个路径复制出不同内容。
2. 再问index:这份内容在哪两个位置
gitls-files--stageroot=$(gitwrite-tree)gitls-tree"$root"ls-files能列出两个完整路径,ID却相同。index还记着路径,接下来write-tree就从它生成这一次目录快照。
根tree里会看到copy和src两个目录条目,却看不到直接写成src/a.txt的条目。这不是文件丢了,而是只显示了第一层。
继续往下一层读:
gitls-tree"$root:src"gitls-tree"$root:copy"这次才分别看到a.txt和b.txt,两条记录都指向刚才的blob。
沿src再沿a.txt,得到第一个路径;沿copy再沿b.txt,得到第二个路径。完整路径是沿途名字拼出来的,不是blob自己保存的。
所以tree不是“再存一份文件内容”。每层条目把名字、模式和对象ID连在一起:指向blob的条目表示文件,指向子tree的条目表示子目录。
3. 只改文件名,哪一层应该变化
先保存当前根tree,再把a改名为renamed,内容一字不动:
old_root=$rootgitmvsrc/a.txt src/renamed.txtnew_root=$(gitwrite-tree)githash-object src/renamed.txtgitls-tree"$new_root:src"gitls-tree"$old_root:src"文件hash仍是1275430开头。但新src tree里的名字已变,tree本身的内容也就不同了,因而产生新的ID。
根tree记录着src的ID,src一变,根tree也会跟着变;copy没动,它的子tree可以继续复用。
这次变化沿着哪条路传播?a.txt的名字→src tree→根tree。blob没有变,copy那边也不需要跟着重建成不同对象。
我们甚至没有创建commit。两个root变量都能作为历史快照入口读取;commit的作用之一,就是把根tree接到有作者、说明和parent的提交记录上。
4. 那write-tree会不会把刚改的磁盘文件也收进去
刚才改名之后,index与磁盘内容仍然一致。现在只改工作区,不add:
printf'changed\n'>src/renamed.txtwithout_add=$(gitwrite-tree)test"$without_add"="$new_root"&&printf'worktree edit not included\n'gitaddsrc/renamed.txtafter_add=$(gitwrite-tree)test"$after_add"!="$new_root"&&printf'staged edit included\n'第一次tree没变,因为index还指向same的blob。第二次add以后,index换成changed的blob,新的tree才把它接进去。
这也解释了为什么提交前要看暂存区:write-tree用index生成目录快照,不是扫描一下工作目录就把所有修改打包。
5. 真的从index构建时,递归在做什么
到这里,tree不是一个只供背诵的定义了。现在回到我的mini-git固定提交44f6a6524350a4b94bb446bedf616a46d72d0e97,看看怎样把扁平路径组装成刚才的层级。
先在当前实验里加一个src下的文件,保留刚才已经改名、改内容的renamed.txt:
printf'same\n'>src/extra.txtgitaddsrc/extra.txtgitls-files--stage现在有copy/b.txt、src/extra.txt、src/renamed.txt三条记录。extra与b的内容都是same,renamed已经是changed。下面按这三条路径手工走一次本项目的函数:标准Git实验核对对象结果,函数展开来自mini-git源码。
入口build_tree_level的prefix先是空字符串,意思是正在构建根层。它对每条路径取去掉prefix以后的部分:
constchar*rest=path+plen;constchar*slash=strchr(rest,'/');遇到copy/b.txt,剩余部分还有斜杠:这一层先放一个叫copy的目录项,不直接把b.txt放进根层。
接着src/extra.txt也产生目录项src。再遇到src/renamed.txt,不能再添加第二个src;源码会在已收集的目录项中检查它是否已经存在。
这一步结束,根层只有copy和src两个目录项。三个文件不等于三个根层条目。
进入src这一层时,prefix改成src/。同一条src/extra.txt去掉前缀,rest只剩extra.txt,没有斜杠,于是这次收集的是文件项;src/renamed.txt同理。copy/b.txt则不匹配这个前缀,被跳过。
源码递归部分的关键动作是:
snprintf(sub_prefix,sizeof(sub_prefix),"%s%s/",prefix,entries[i].name);Hash sub_hash;if(build_tree_level(idx,store,sub_prefix,&sub_hash)!=0){free(entries);return-1;}entries[i].hash=sub_hash;子层写出自己的tree,把hash交回来,父层才能填上“src指向哪一棵tree”。不是递归这个词产生了目录,而是子层结果正好是父层条目缺少的信息。
6. 会构建出来,还不等于实现已经完善
固定源码每次调用都扫描整个index,再做当前层目录去重;本层排序用插入排序。路径变深、目录变多,就可能反复扫描。不要因为输入index是有序的,就宣称这份实现已经把每个文件仅处理一次。
排序本身也不是纯粹为了输出好看:条目次序参与tree的序列化,影响对象ID。这里的比较器把目录当成带斜杠后缀参与比较,不能无条件换成对裸名字的strcmp。
它适合帮助我们理解对象模型与递归,不代表已经覆盖标准Git的全部性能策略、mode或异常输入。原样本tree能被cat-file读到,只能证明该样本可读。
SHA-1 tree条目的基本表示是mode、空格、本层name、NUL、20字节原始ID。末尾不是40个十六进制字符;ls-tree打印时才转成给人读的形式。完整路径则沿每一层name拼接,不在blob里。
继续读固定版本的index.c。前面的标准Git实验不需要构建mini-git;源码部分是在实验已经解释清楚的目录关系上,再看实现如何落下去。