走进 .git:从 HEAD、对象和引用看懂仓库内部

原作者与漫画作者:Julia Evans;原文发表于2024年1月26日。中文翻译与技术审校:未完纪。 原文:Inside .git。正文完整翻译,原始命令与示例输出保留;针对源文的简化和配置笔误,另加明确标识的编辑说明。

大家好!这周我在 Mastodon 发了一张漫画,介绍 .git 目录里有什么。有人想要文字版,于是就有了这篇文章,我还补充了一些说明。先看这张图:它用大约15个英文单词,解释 .git 目录中的每个部分。

Julia Evans 手绘漫画,展示 .git 内部对象、引用、日志、钩子、配置和暂存区的结构
图1:Inside .git。原图与文字:Julia Evans / Wizard Zines,获授权转载;下文提供中文逐项解释。

如果想亲自尝试本文所有例子,可以运行 git clone https://github.com/jvns/inside-git 获取示例仓库。

前五个部分——HEAD、分支、提交、树和 blob——构成了 Git 的核心。

HEAD:.git/HEAD

HEAD 是一个很小的文件,通常只保存你当前所在分支的名称。

内容示例:

$ cat .git/HEAD
ref: refs/heads/main

HEAD 也可以直接包含一个提交 ID,这种情况称为“分离 HEAD 状态”(detached HEAD)。

分支:.git/refs/heads/main

分支可以存为一个只包含一个提交 ID 的小文件,位于 refs/heads 目录中。

内容示例:

$ cat .git/refs/heads/main
1093da429f08e0e54cdc2b31526159e745d98ce0

提交:.git/objects/10/93da429...

提交是一个小文件,包含父提交、提交消息、树对象和作者等信息。

内容示例:

$ git cat-file -p 1093da429f08e0e54cdc2b31526159e745d98ce0
tree 9f83ee7550919867e9219a75c23624c92ab5bd83
parent 33a0481b440426f0268c613d036b820bc064cdea
author Julia Evans <julia@example.com> 1706120622 -0500
committer Julia Evans <julia@example.com> 1706120622 -0500

add hello.py

这些文件经过压缩。查看对象内容,最好使用 git cat-file -p HASH。

树:.git/objects/9f/83ee7550...

树对象是保存目录列表的小文件。其中的普通文件内容对应 blob 对象;子目录则对应其他树对象。

内容示例:

$  git cat-file -p 9f83ee7550919867e9219a75c23624c92ab5bd83
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391	.gitignore
100644 blob 665c637a360874ce43bf74018768a96d2d4d219a	hello.py
040000 tree 24420a1530b1f4ec20ddb14c76df8c78c48f76a6	lib

这里的权限看起来像 Unix 权限,但允许的范围很有限。就普通文件的权限而言,只有 644 和 755 两种。

blob:.git/objects/5a/475762c...

blob 对象保存实际的文件内容,也就是你的代码。

内容示例:

$ git cat-file -p 665c637a360874ce43bf74018768a96d2d4d219a	
print("hello world!")

每次修改都保存一个新 blob,体积可能越来越大。因此,git gc 会在仓库维护时把对象打包到 .git/objects/pack 中,提高存储效率。

reflog:.git/logs/refs/heads/main

reflog 用于记录分支、HEAD 等引用的变化历史。原文将它概括为保存各分支、标签和 HEAD 的历史,并说明 .git/refs 中的大多数引用可以在 .git/logs/refs 中找到对应日志;具体哪些引用有日志,取决于引用类型和仓库配置,见文末补充。

main 分支的内容示例:

$ tail -n 1 .git/logs/refs/heads/main
33a0481b440426f0268c613d036b820bc064cdea
1093da429f08e0e54cdc2b31526159e745d98ce0
Julia Evans <julia@example.com>
1706119866 -0500
commit: add hello.py

reflog 中的每一行包含:

  • 变化前、变化后的提交 ID;
  • 用户;
  • 时间戳;
  • 日志消息。

正常情况下,这些内容都在同一行。我这里只是为了便于阅读,将它们拆成了多行。

远端跟踪分支:.git/refs/remotes/origin/main

远端跟踪分支保存的是:最近一次观察到的某个远端分支的提交 ID。

内容示例:

$ cat .git/refs/remotes/origin/main
fcdeb177797e8ad8ad4c5381b97fc26bc8ddd5a2

当 git status 提示“你与 origin/main 保持同步”时,它查看的只是这个本地记录。这个记录可能已经过时,你可以用 git fetch origin main 更新它。

标签:.git/refs/tags/v1.0

这里展示的是轻量标签:它可以是 .git/refs/tags 中一个保存提交 ID 的小文件。

内容示例:

$ cat .git/refs/tags/v1.0
1093da429f08e0e54cdc2b31526159e745d98ce0

与分支不同,创建新提交时,标签不会随之更新。

stash:.git/refs/stash

stash 的入口是名为 .git/refs/stash 的小文件。它保存运行 git stash 时创建的提交的 ID。

cat .git/refs/stash
62caf3d918112d54bcfa24f3c78a94c224283a78

stash 是一个栈,之前的值保存在 .git/logs/refs/stash 中,也就是 stash 对应的 reflog。

cat .git/logs/refs/stash
62caf3d9 e85c950f Julia Evans <julia@example.com> 1706290652 -0500	WIP on main: 1093da4 add hello.py
00000000 62caf3d9 Julia Evans <julia@example.com> 1706290668 -0500	WIP on main: 1093da4 add hello.py

与通常的分支或标签操作不同,如果通过 git stash pop 成功应用并移除一个 stash 条目,它会从 stash 的 reflog 中删除,因此之后很难再找到。原文强调,stash 的条目可能刚加入不久就被删除,而分支 reflog 的条目也会过期,但通常是90天之后。这里的删除与恢复边界,见文末补充。

关于引用的一点说明:

读到这里,你大概已经发现,分支、远端跟踪分支、标签和 stash 等很多东西,都是 .git/refs 中保存的对象 ID。它们统称为 references,简称 refs,也就是“引用”。本文的常见例子中,它们指向提交,但 Git 对不同类型的引用处理方式差别很大。因此,即使它们使用相同的文件形式,我仍觉得把它们分别理解更有帮助。例如,Git 会删除 stash reflog 中的条目,而一般分支或标签操作并不会以同样的方式删除日志。

.git/config

.git/config 是仓库的配置文件,你会在这里配置远端仓库。

原文的内容示例如下,保留其原有格式;其中的格式问题和可用写法见紧随其后的编辑说明:

[remote "origin"] 
url = git@github.com: jvns/int-exposed 
fetch = +refs/heads/*: refs/remotes/origin/* 
[branch "main"] 
remote = origin 
merge refs/heads/main

编辑说明:原样例的SSH远端地址在冒号后多了空格,fetch refspec的冒号后也多了空格,最后一行缺少等号。按原示例仓库名,仅修正这三处语法可写为:

[remote "origin"]
    url = git@github.com:jvns/int-exposed
    fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
    remote = origin
    merge = refs/heads/main

这里保留原文的 jvns/int-exposed 地址用于对照;它与开头供练习的 jvns/inside-git 是不同仓库,不要把这个配置直接覆盖到自己的仓库。

Git 有仓库级和全局设置。仓库级设置保存在这里,全局设置通常保存在 ~/.gitconfig。

钩子:.git/hooks/pre-commit

钩子是可选脚本,可以配置为在某个时机运行,例如提交之前;脚本可以执行你安排的任意命令。

内容示例:

#!/bin/bash 
any-commands-you-want

显然,这只是示意,并不是一个真正可用的 pre-commit 钩子。

暂存区:.git/index

暂存区记录你准备提交的文件状态。它是一个二进制文件,不同于 Git 中许多基本可以视为纯文本的文件。

据我所知,查看 index 内容最方便的方法是 git ls-files --stage:

$ git ls-files --stage
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0	.gitignore
100644 665c637a360874ce43bf74018768a96d2d4d219a 0	hello.py
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0	lib/empty.py

这不是完整清单

.git 中还有一些其他内容,例如 FETCH_HEAD、worktrees 和 info。我只列出了自己觉得值得理解的部分。

这也不是对 Git 的完整解释

我最常听到的一条 Git 建议是:“只要搞清楚 .git 目录的结构,你就全懂了!”

我非常喜欢弄清事物的内部原理,但光靠“.git 目录是如何组织的”,还有很多问题解释不了,例如:

  • merge 和 rebase 如何工作,又可能在哪里出错,例如原文链接列出的那些 rebase 问题;
  • 你的同事究竟如何使用 Git,以及你应该遵守什么规则,才能顺利协作;
  • 从其他仓库推送和拉取代码是怎么回事;
  • 如何处理合并冲突。

不过,希望这篇文章仍能帮助到一些人。

其他参考资料

  • James Coglan 的《Building Git》。(原文旁注:当年1月剩余时间似乎有五折优惠;这是2024年的历史信息,不代表当前价格。)
  • Mary Rose Cook 的《Git from the inside out》。
  • Git 官方的仓库布局文档。

编辑补充:理解这些例子的适用范围

上文以传统的松散对象和松散引用布局解释Git。引用也可能存储在 packed-refs 等引用后端中,对象则可能位于pack文件;因此,某个单独路径不存在,不等于对应分支或对象不存在。本文的40位对象ID来自SHA-1仓库,不代表所有仓库都必须使用这一长度。工作树中的 .git 也可能是指向实际Git目录的文件。可参阅官方仓库布局文档。

标签部分只展示轻量标签。带注释标签通常先指向tag对象;引用一般保存对象ID,并不保证直接指向提交。树条目的 100644、100755 表示普通文件,此外还有树、符号链接和gitlink等类型,不能把整个mode字段理解为只有644和755。

reflog并非默认为所有标签保留历史。日志的建立、保留与过期由配置及操作决定。通常可达条目的默认过期时间为90天,不可达条目默认30天,均可调整,见git-reflog手册。成功的 git stash pop 会丢弃条目;发生冲突时条目通常仍保留。被丢弃的对象在尚未清理时有时能够恢复,但不能依赖这一点,见git-stash手册。为了保留条目,可在理解后采用apply再另行drop的工作方式。

本文的查看命令用于观察,不建议手动修改真实仓库的内部文件。git clone 会联网并创建目录,git fetch 会联网并更新本地远端跟踪状态,git gc 可能清理不可达对象,git stash pop 会改变工作区并可能移除stash条目。钩子能执行任意代码,安装前应检查来源和内容。示例中的邮箱是 example.com 占位地址,并非发布的私密凭据。本次仅静态审查,没有运行这些命令。

原文中的参考链接

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容