阅读本文的前提:
- 读者知道git工作区、暂存区的概念
- 读者会用git基本命令
本文概要:
- 受git管理的目录,会有.git子目录
- .git子目录有着完整的历史数据,其下的objects目录,以二进制的形式,保存了文档、目录在所有提交节点的完整信息
- 分支名称是历史提交记录的引用
主要参考文档:
- git官方中文文档: Git - 底层命令与上层命令
目录
git低级命令
常用的git add、git commit等命令,称作“高级命令”,实际上做了不止一个操作。git内部还有做基础操作的“低级命令”,平时并不会用到。
可以认为,高级命令是串行调用若干个低级命令。
git init
在一个目录下执行git init,或者是git clone一个远程仓库到本地,目录下会出现.git子目录,表示该目录受git管理,目录中除.git子目录外,都是“工作区”。
在一个空目录下执行git init,以下是.git子目录的结构示例:
$ tree .git
.git
├── HEAD
├── config
├── hooks
│ └── commit-msg
├── objects
│ ├── info
│ └── pack
└── refs
├── heads
└── tags
在以下各常用命令的讲解过程中,会逐个说明其中各个文件的作用。
git add
命令的作用:将工作区中的内容移动到暂存区。
在刚才的目录下新增若干文件(内容都是只有一行的文本),结构如下:
$ tree
.
├── lib
│ ├── liba.txt
│ └── libc.txt
└── readme.txt
执行git add .命令之后,.git目录变成如下图所示:
$ tree .git
.git
├── HEAD
├── config
├── hooks
│ └── commit-msg
├── index
├── objects
│ ├── 94
│ │ └── 5592fff7cc0b96426bf58f5a1537e9cb1fef68
│ ├── c1
│ │ └── 024330cfb9c6ba5285423aa4df8cf34fc41cf2
│ ├── e6
│ │ └── 3ae029b9c966ec4c0a00b80f26c25ed99da120
│ ├── info
│ └── pack
└── refs
├── heads
└── tags
文件内容存入objects目录
工作区中的每个文件,都已二进制的形式存入了objects目录中:objects目录下多出来的3个文件,正好对应工作区中的三个文件。
objects文件名:“构造的文件头部”+文件内容,计算出的SHA-1值,取前两个字母作为子目录的名称,剩下的作为文件名
objects文件内容:“构造的文件头部”+文件内容,zlib压缩得到
参考git官方文档:Git - Git 对象 (“对象存储”一节)
SHA-1值冲突的概率不高,如果真的出现冲突,git diff可以发现(其内容会很奇怪,此时可以加个注释、加个空行等方法解决)。
相关的低级命令:
- git hash-object <filename>,计算其hash值,在终端显示。
- 这里的计算结果是945592fff7cc0b96426bf58f5a1537e9cb1fef68,目录名为94,文件名为剩下的5592fff7cc0b96426bf58f5a1537e9cb1fef68
$ git hash-object readme.txt
945592fff7cc0b96426bf58f5a1537e9cb1fef68
- git hash-object -w <filename>,计算其hash值,在终端显示;计算objects文件内容,存入objects目录中
- git cat-file -t <hash值>,得到objects文件对应的原类型(此时只有blob,表示这个objects文件对应的是一个文件,后面还会看到别的类型)
$ git cat-file -t 945592fff7cc0b96426bf58f5a1537e9cb1fef68
blob
- git cat-file -p <hash值>,得到objects文件对应的内容(注意,只有文件内容,没有文件名)
- 如果hash值不冲突的,短一些也可以
$ git cat-file -p 945592fff7cc0b96426bf58f5a1537e9cb1fef68
content abcd
# 注:这就是readme.txt文件的内容
$ git cat-file -p 945592
content abcd
index文件
index文件就是git中的“暂存区”,这也是一个二进制文件,保存了git add .之后,暂存区中所有文件的列表及对应的hash值。
相关的低级命令:
- git update-index --add --cacheinfo <rwx> <hash> <filename>,将文件加入暂存区
$ git update-index --add --cacheinfo 100644 945592fff7cc0b96426bf58f5a1537e9cb1fef68 readme.txt
可参考:Git - git-update-index Documentation update-index命令的用法(英文)
git update-index (Plumbing Commands) - Git 中文开发手册 - 开发者手册 - 云+社区 - 腾讯云
- git ls-files -s 查看index文件中,工作区中文件的状态,其中四列分别是:rwx、hash、commit time、带路径的文件名
$ git ls-files -s
100644 e63ae029b9c966ec4c0a00b80f26c25ed99da120 0 lib/liba.txt
100644 c1024330cfb9c6ba5285423aa4df8cf34fc41cf2 0 lib/libc.txt
100644 945592fff7cc0b96426bf58f5a1537e9cb1fef68 0 readme.txt
git commit
命令的作用:创建一次提交。
执行git commit -m 'first cccommit' 命令之后,.git目录变化如下:
$ tree .git
.git
├── COMMIT_EDITMSG
├── HEAD
├── config
├── hooks
│ └── commit-msg
├── index
├── logs
│ ├── HEAD
│ └── refs
│ └── heads
│ └── master
├── objects
│ ├── 0a
│ │ └── 844532018cfec018d05a717e0e6f530a72fe8e
│ ├── 6d
│ │ └── f54361d4e32fab7aad3b1f34ad41e0b5f25d6c
│ ├── 94
│ │ └── 5592fff7cc0b96426bf58f5a1537e9cb1fef68
│ ├── c1
│ │ └── 024330cfb9c6ba5285423aa4df8cf34fc41cf2
│ ├── e6
│ │ └── 3ae029b9c966ec4c0a00b80f26c25ed99da120
│ ├── ef
│ │ └── 216e815c7efacda84cc7d844f529bf53694e5e
│ ├── info
│ └── pack
└── refs
├── heads
│ └── master
└── tags
index文件没有变化
再次执行git ls-files -s 命令,查看index文件内容,和commit之前完全一样:
$ git ls-files -s
100644 e63ae029b9c966ec4c0a00b80f26c25ed99da120 0 lib/liba.txt
100644 c1024330cfb9c6ba5285423aa4df8cf34fc41cf2 0 lib/libc.txt
100644 945592fff7cc0b96426bf58f5a1537e9cb1fef68 0 readme.txt
objects目录多出来的文件
objects目录刚才只有3个文件,现在多出3个文件。执行git cat-file -p 命令,查看这三个objects的类型:
$ git cat-file -t 6df54361d4e32fab7aad3b1f34ad41e0b5f25d6c
commit
$ git cat-file -t 0a844532018cfec018d05a717e0e6f530a72fe8e
tree
$ git cat-file -t ef216e815c7efacda84cc7d844f529bf53694e5e
tree
相比之前的blob(文件object),多出了tree(目录object)、commit(提交object)。查看这几个文件的内容:
$ git cat-file -p 6df54361d4e32fab7aad3b1f34ad41e0b5f25d6c
tree 0a844532018cfec018d05a717e0e6f530a72fe8e
author xxxx <xxx@xxx.com> 1634464533 +0800
committer xxxx <xxx@xxx.com> 1634464533 +0800
first cccommit
$ git cat-file -p 0a844532018cfec018d05a717e0e6f530a72fe8e
040000 tree ef216e815c7efacda84cc7d844f529bf53694e5e lib
100644 blob 945592fff7cc0b96426bf58f5a1537e9cb1fef68 readme.txt
$ git cat-file -p ef216e815c7efacda84cc7d844f529bf53694e5e
100644 blob e63ae029b9c966ec4c0a00b80f26c25ed99da120 liba.txt
100644 blob c1024330cfb9c6ba5285423aa4df8cf34fc41cf2 libc.txt
后2个object都是tree,表示一个目录,其中保存了下级各个文件/目录的rwx、类型、hash、文件/目录名称(注意,这里是树状结构,和index中保存带路径的文件名称不一样)。
第一个object是一个提交object,其中第一行保存了本次提交的根目录object,后面是本次提交的信息。
相关的低级命令:
- git write-tree,根据index建立各个tree object
- git read-tree <hash值>,将hash值代表的tree object还原,并写入index中;工作区无任何改动
参考:git write-tree (Plumbing Commands) - Git 中文开发手册 - 开发者手册 - 云+社区 - 腾讯云
git read-tree (Plumbing Commands) - Git 中文开发手册 - 开发者手册 - 云+社区 - 腾讯云
- (补充生成commit object的命令)
引用
查看refs目录、HEAD文件的内容:
$ more .git/refs/heads/master
6df54361d4e32fab7aad3b1f34ad41e0b5f25d6c
$ more .git/HEAD
ref: refs/heads/master
这表明:master分支目前指向 6df54361d4e32fab7aad3b1f34ad41e0b5f25d6c 这个commit object,而HEAD(当前的活动分支)是master。
可以将分支的“名字”,看作是某个commit object的引用。发生git commit或者git reset的时候,分支“名字”指向的commit object发生变化。
相关的低级命令:
- git update-ref <要更新的引用路径> <commit object hash>,更新分支引用指向的commit object hash
$ git update-ref refs/heads/master 6df54361d4e32fab7aad3b1f34ad41e0b5f25d6c
从当前分支创建分支,实际上也用到这个命令,创建一个新的引用,指向当前的commit object 。
- git symbolic-ref HEAD <分支名>,更新HEAD分支的指向
$ git symbolic-ref HEAD refs/heads/master
参考:Git - Git 引用
logs目录
.git目录下,新增了logs目录:
├── logs
│ ├── HEAD
│ └── refs
│ └── heads
│ └── master
查看其中logs/refs/heads/master文件的内容:
$ more logs/refs/heads/master
0000000000000000000000000000000000000000 6df54361d4e32fab7aad3b1f34ad41e0b5f25d6c xxx <xxx@xxx.com> 1634464533 +0800 commit (initial): first cccommit
目前认为:其中保存了master分支的提交历史记录。
再次git commit
增加readme.md文件,再次git add、git commit
$ echo xxx >> readme.md
$ git add .
$ git commit -m 'second commit'
[master fe39e76] second commit
1 file changed, 1 insertion(+)
create mode 100644 readme.md
objects中commit object的变化
找到最新的commit object,查看其中的内容:
$ git cat-file -p fe39e76df285901e38be1bca675f99cc1f359ba0
tree cb2c335799c256f9be3000d7ff9d7d09e30d29be
parent 6df54361d4e32fab7aad3b1f34ad41e0b5f25d6c
author xxx <xxx@xxx.com> 1634999223 +0800
committer xxx <xxx@xxx.com> 1634999223 +0800
second commit
Change-Id: I450abec88e8f6ad1aa2f5bd510e56501189217c9
可见,其中除了指向目录tree object的指针之外,还有一个parent,指向上一个提交的commit object。
log目录的变化
查看 .git/logs/refs/heads/master 文件的内容:
$ more .git/logs/refs/heads/master
0000000000000000000000000000000000000000 6df54361d4e32fab7aad3b1f34ad41e0b5f25d6c xxx <xxx@xxxx.com> 1634464533 +0800 commit (initial): first cccommit
6df54361d4e32fab7aad3b1f34ad41e0b5f25d6c fe39e76df285901e38be1bca675f99cc1f359ba0 xxx <xxx@xxxx.com> 1634999223 +0800 commit: second commit
记录了提交的历史。但如果从现在的master分支,创建一个名为test的分支呢?
$ git checkout -b test
$ more .git/logs/refs/heads/test
0000000000000000000000000000000000000000 fe39e76df285901e38be1bca675f99cc1f359ba0 xxx <xxxx@xxx.com> 1634999624 +0800 branch: Created from HEAD
test分支的log文件只有一行,并没有完整的信息。
这说明git log获取提交信息的时候,分支的上一个版本是从commit hash object中获取到的,而非从.git/logs中获取到。
至于.git/logs/HEAD 文件中的内容就更杂了,记录了HEAD指针的变化(commit、checkout、reset……),不等于当前分支对应的 .git/logs/xxx文件。
回顾
.git
├── COMMIT_EDITMSG
├── HEAD HEAD引用,当前的活动分支
├── config git仓库的远程配置、分支对应关系
├── hooks
├── index 暂存区,保存暂存区内所有文件的object的hash
├── logs 保存分支的提交历史,只能追溯到分支创建时
│ ├── HEAD
│ └── refs
│ └── heads 本地分支
│ └── master
│ └── remotes 远程分支
│ └── master
├── objects 所有提交的二进制的文件、目录、提交信息
│ ├── 0a
│ │ └── 844532018cfec018d05a717e0e6f530a72fe8e
│ ├── ......略
│ ├── info
│ └── pack 二进制文件太多时,使用此目录压缩保存
└── refs
├── heads 本地所有分支的最近一次commit的hash
│ └── master
├── remotes 远程所有分支的最近一次commit的hash
│ └── master
└── tags 标签对应的commit的hash
在git pull的时候,要更新所有远程分支的信息:
- 所有远程分支的最近一次提交的hash值,到refs/remotes
- 所有标签对应的hash值,到refs/tags
- 所有远程分支的提交历史,logs/refs/remotes
- 所有远程分支的所有提交的文件对应的二进制文件,到objects
- 如果当前的分支也在git pull中拉取到更新,更新index
所以修改提交频率较高的库,分支多,objects文件也多,git pull一次要花费很多时间。
git pull origin <分支名>可解,只要拉取一个分支的信息。
git diff
用法:git diff <提交> <filename>,使用指定的提交对比指定文件
<filename>如果不传,则默认为.(当前目录)。"提交"可以是:
- 某次提交的hash
- 分支名,等效于分支最新一次commit对应的hash
- HEAD,等效于HEAD中的分支名
- 如果不填,默认值就是index(暂存区)
取“比较基准”的路径:
- 确定比较基准的commit hash,或者是暂存区
- 如果是commit hash,使用hash值在objects中找到对应的提交,根据要对比的文件的路径,一步步解析到对应文件的hash,找到对应的二进制文件
- 如果是暂存区,解析index文件,找到要对比的文件的hash,在objects中找到对应的二进制文件
- 反序列化找到的objects blob文件,并与工作区中的文件对比
git回退
git checkout
用法1:git checkout <提交 不可为空>,此时更换工作分支
- HEAD引用变化,但如果<提交> 是一个commit hash,则进入“分离头指针状态”,此时不在任何一个分支上,可以查看、修改,但是无法提交
- 暂存区变化为本次<提交>对应的状态
- 工作区变化为本次<提交>对应的状态
- .git/logs、git/refs都不变,即所有分支的提交状态都不发生变化
用法2:git checkout <提交> <filename>,此时将文件更新为某次提交的状态,但不更改提交历史
- 还在当前分支上,HEAD引用不变,.git/logs、git/refs都不变
- <提交>可以没有,如果没有就是暂存区
- 工作区的对应文件,更新到指定提交的状态
- 如果<提交>不为空,暂存区中,此文件也会更新到指定提交的状态
例如,某个文件被git add <file> 加入暂存区,现在想直接回退到最开始的版本,可以git checkout HEAD <file>,工作区、暂存区中的此文件都会被还原。
git reset
用法1:git reset [--soft / --hard] <提交>,将当前的分支回退到某次提交的状态,且回退提交历史
是git commit的逆操作。
- HEAD引用不变,还在此分支上
- 但是提交历史回退,因此.git/logs、git/refs都发生变化
- 暂存区、工作区的变化,根据参数而定:
工作区 | 暂存区 | 效果 | |
---|---|---|---|
--hard | 倒退到指定提交 | 倒退到指定提交 | 相当于改动彻底没了,彻底倒退回指定提交的状态 |
无 | 保持原状 | 倒退到指定提交 | 相当于修改了、但没有git add的时候 |
--soft | 保持原状 | 保持原状 | 只是提交没了,相当于git add之后、还没git commit的时候 |
用法2:git reset <filename>,将暂存区中的指定文件倒退回HEAD,但保留文件改动
相当于上一改动中不带--hard,也不带--soft的用法,是git add <filename> 的逆操作。
如果想当暂存区中的文件彻底退回到一开始的样子,改动也不要了,应当使用git checkout HEAD <filename>。
区别
git checkout不更改提交历史,只是切换分支、更新文件、更新暂存区,但是git checkout如果是倒退了未提交的改动,就找不回来了。
git reset会修改提交历史,根据参数选择是否保留改动。但是git reset --hard了没有git push的分支,就找不回来了。