主要内容:【Git 初始化】、【Git 暂存区】、【Git 对象】

Git 初始化

设置一下 Git 的环境变量,这个设置是一次性的工作。即这些设置会在全局文件(用户主目录下的 ~/.gitconfig)或系统文件(/etc/gitconfig)中做永久的记录。

配置的用户名和邮件地址将在版本库提交时作为提交者的用户名和邮件地址。

$ git config --global user.name "Jiang Xin"
$ git config --global user.email jiangxin@ossxp.com

设置一些 Git 别名,以便可以使用更为简洁的子命令

只在本用户的全局配置中添加 Git 命令别名:

$ git config --global alias.br branch
$ git config --global alias.ci "commit -s"
$ git config --global alias.co checkout
$ git config --global alias.st "-p status"

版本库的初始化

$ mkdir demo
$ cd demo
$ git init

初始化空的 Git 版本库于 /path/to/my/workspace/demo/.git/

git init 命令的后面直接输入目录名称

$ cd /path/to/my/workspace
$ git init demo

$ ls -aF
./ ../ .git/

这个隐藏的 .git 目录就是 Git 版本库(又叫仓库,repository)。

.git 版本库目录所在的目录,即 /path/to/my/workspace/demo 目录称为 工作区

将新建立的文件添加到版本库

$ git add welcome.txt

再执行一次提交操作,使用 -m 参数直接给出了提交说明。

$ git ci -m "initialized"
[master (root-commit) 7f0b2be] init
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 welcome.txt

git ci 是上面配置的别名,我个人觉得 -s 这个参数比较冗余。

思考:为什么工作区下有一个 .git 目录?

Git 的这种设计,将版本库放在工作区根目录下,所有的版本控制操作(除了和其他远程版本库之间的互操作)都在本地即可完成,不像 Subversion 只有寥寥无几的几个命令才能脱离网络执行。而且 Git 也没有 CVS 和 Subversion 的安全泄漏问题(只要保护好 .git 目录),也没有 Subversion 在本地文件搜索时出现搜索结果混乱的问题,甚至 Git 还提供了一条 git grep 命令来更好地搜索工作区的文件内容。

$ git grep "工作区文件内容搜索"

当工作区中包含了子目录,在子目录中执行 Git 命令时,如何定位版本库呢?

当在 Git 工作区目录下执行操作的时候,会对目录依次向上递归查找 .git 目录,找到的 .git 目录就是工作区对应的版本库,.git 所在的目录就是工作区的根目录,文件 .git/index 记录了工作区文件的状态(实际上是 暂存区 的状态)。

如果跟踪一下执行 git status 命令时的磁盘访问,会看到沿目录依次向上递归的过程。

$ strace -e 'trace=file' git status

那么有什么办法知道 Git 版本库的位置,以及工作区的根目录在哪里呢?

显示版本库 .git 目录所在的位置。

$ git rev-parse --git-dir
/path/to/my/workspace/demo/.git

显示工作区根目录。

$ git rev-parse --show-toplevel
/path/to/my/workspace/demo

把版本库 .git 目录放在工作区,是不是太不安全了?

Git 克隆可以降低因为版本库和工作区混杂在一起导致的版本库被破坏的风险。在本机另外的磁盘/目录中建立 Git 克隆,并在工作区有改动提交时,手动或自动地执行向克隆版本库的推送 git push 操作。如果使用网络协议,还可以实现在其他机器上建立克隆,这样就更安全了(双机备份)。

思考:git config 命令参数的区别?

将打开 /path/to/my/workspace/demo/.git/config 文件进行编辑:

$ cd /path/to/my/workspace/demo/
$ git config -e

将打开 /home/jiangxin/.gitconfig(用户主目录下的 .gitconfig 文件)全局配置文件进行编辑:

$ git config -e --global

将打开 /etc/gitconfig 系统级配置文件进行编辑:

$ git config -e --system

Git 的三个配置文件分别是 版本库级别的配置文件全局配置文件(用户主目录下)和 系统级配置文件(/etc目录下)。

其中 版本库级别配置文件 的优先级最高,全局配置文件 其次,系统级配置文件 优先级最低。

Git 配置文件采用的是 INI 文件格式。

$ cat /path/to/my/workspace/demo/.git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true

例如读取 [core] 小节的 bare 的属性值

git config <section>.<key>

$ git config core.bare
false

更改或设置 INI 文件中某个属性

git config <section>.<key> <value>

$ git config a.b something
$ git config x.y.z others

打开 .git/config 文件

[a]
b = something
[x "y"]
z = others

可以用 git config 命令操作任何其他的 INI 文件

$ GIT_CONFIG=test.ini git config a.b.c.d "hello, world"

$ GIT_CONFIG=test.ini git config a.b.c.d
hello, world

思考:是谁完成的提交?

当最新的提交删除了 user.nameuser.email,提交时 Git 对提交者的用户名和邮件地址做了大胆的猜测,这个猜测可能是错的。

重新设置 user.nameuser.email,然后执行下面的命令,重新修改最新的提交,改正作者和提交者的错误信息。

$ git commit --amend --allow-empty --reset-author

  • 参数 --amend 是对刚刚的提交进行修补,这样就可以改正前面错误的提交(用户信息错误),而不会产生另外的新提交。
  • 参数 --allow-empty 是因为要进行修补的提交实际上是一个空白提交,Git 默认不允许空白提交。
  • 参数 --reset-author 的含义是将 Author(提交者)的 ID 重置,否则只会影响最新的 Commit(提交者)的 ID。这条命令也会重置 AuthorDate 信息。

思考:随意设置提交者姓名,是否太不安全?

Git 可以随意设置提交的用户名和邮件地址信息,这是分布式版本控制系统的特性使然,每个人都是自己版本库的主人,很难也没有必要进行身份认证从而使用经过认证的用户名作为提交的用户名。

但是可以使用 GitLab 等服务管理权限。

思考:命令别名是干什么的?

命令别名可以帮助用户解决从其他版本控制系统迁移到 Git 后的使用习惯问题。

备份本章的工作成果

$ cd /path/to/my/workspace
$ git clone demo demo-step-1
Cloning into demo-step-1...
done.

Git 暂存区

$ git log --stat

可以用 git log 查看提交日志,附加的 --stat 参数看到每次提交的文件变更统计。

修改不能直接提交?

现在就将修改的文件“添加”到提交暂存区:

$ git add welcome.txt

这时如果和 HEAD(当前版本库的头指针)或者 master 分支(当前工作分支)进行比较,会发现有差异。这个差异才是正常的,因为尚未真正提交么。

$ git diff HEAD

用简洁方式显示状态

$ git status -s
M welcome.txt

通过参数 --cached 或者 --staged 参数调用 git diff 命令,看到的是提交暂存区 stage 和版本库中文件的差异。不然看到的是工作区的变动。

$ git diff --cached

现在执行 git commit 命令进行提交。

$ git commit -m "which version checked in?"

如何证明提交成功了呢?通过查看提交日志,看到了新的提交。

$ git log --pretty=oneline

理解 Git 暂存区 stage

当执行 git status 命令(或者 git diff 命令)扫描工作区改动的时候,先依据 .git/index 文件中记录的(工作区跟踪文件的)时间戳、长度等信息判断工作区文件是否改变。

文件 .git/index 实际上就是一个包含文件索引的目录树,像是一个虚拟的工作区。在这个虚拟工作区的目录树中,记录了文件名、文件的状态信息(时间戳、文件长度等)。文件的内容并不存储其中,而是保存在 Git 对象库 .git/objects 目录中,文件索引建立了文件和对象库中对象实体之间的对应。

理解 Git 暂存区 stage

  • 图中可以看出此时 HEAD 实际是指向 master 分支的一个“游标”。所以图示的命令中出现 HEAD 的地方可以用 master 来替换。
  • 图中的 objects 标识的区域为 Git 的对象库,实际位于 .git/objects 目录下,会在后面的章节重点介绍。
  • 当执行 git reset HEAD 命令时,暂存区的目录树会被重写,被 master 分支指向的目录树所替换,但是工作区不受影响。
  • 当执行 git rm --cached <file> 命令时,会直接从暂存区删除文件,工作区则不做出改变。
  • 当执行 git checkout . 或者 git checkout -- <file> 命令时,会用暂存区全部或指定的文件替换工作区的文件。这个操作很危险,会清除工作区中未添加到暂存区的改动。
  • 当执行 git checkout HEAD . 或者 git checkout HEAD <file> 命令时,会用HEAD指向的 master 分支中的全部或者部分文件替换暂存区和以及工作区中的文件。这个命令也是极具危险性的,因为不但会清除工作区中未提交的改动,也会清除暂存区中未提交的改动。

Git diff 魔法

有什么办法能够像查看工作区一样的,直观的查看暂存区以及 HEAD 当中的目录树么?

$ git ls-tree -l HEAD
100644 blob fd3c069c1de4f4bc9b15940f490aeb48852f3c42 25 welcome.txt

要显示暂存区的目录树,可以使用 git ls-files 命令。

$ git ls-files -s
100644 18832d35117ef2f013c4009f5b2128dfaeff354f 0 a/b/c/hello.txt

理解 Git 暂存区 stage

不要使用 git commit -a

提交命令 git commit 可以带上 -a 参数,对本地所有变更的文件执行提交操作,包括本地修改的文件,删除的文件,但不包括未被版本库跟踪的文件。

这个“偷懒”的提交命令,就会丢掉 Git 暂存区带给用户最大的好处:对提交内容进行控制的能力。

Git 对象

什么是 HEAD?什么是 master?为什么它们二者可以相互替换使用?为什么 Git 中的很多对象像提交、树、文件内容等都用 40 位的 SHA1 哈希值来表示?

Git 对象库探秘

40 位十六进制数字组成的 SHA1 哈希值

$ git log -1 --pretty=raw
# 这是本次提交的唯一标识。
commit e695606fc5e31b2ff9038a48a3d363f4c21a3d86
# 这是本次提交所对应的目录树。
tree f58da9a820e3fd9d84ab2ca2f1b467ac265038f9
# 这是本地提交的父提交(上一次提交)。
parent a0c641e92b10d8bcca1ed1bf84ca80340fdefee6

which version checked in?

研究 Git 对象 ID 的命令是 git cat-file,用下面的命令可以查看一下这三个 ID 的类型。

$ git cat-file -t e695606
commit
$ git cat-file -t f58d
tree
$ git cat-file -t fd3c06
blob

再用 git cat-file 命令查看一下这几个对象的内容。对于 blob 对象,这个对象保存着文件 welcome.txt 的内容。

$ git cat-file -p fd3c06
Hello.
Nice to meet you.

这个写对象都存在 Git 库中的 objects 目录下,ID 的前两位作为目录名,后 38 位作为文件名。

$ for id in e695606 f58da9a a0c641e fd3c069; do \
ls .git/objects/${id:0:2}/${id:2}*; done
.git/objects/e6/95606fc5e31b2ff9038a48a3d363f4c21a3d86
.git/objects/f5/8da9a820e3fd9d84ab2ca2f1b467ac265038f9
.git/objects/a0/c641e92b10d8bcca1ed1bf84ca80340fdefee6
.git/objects/fd/3c069c1de4f4bc9b15940f490aeb48852f3c42

Git 对象库探秘

HEAD 和 master 的奥秘

$ git log -1 HEAD
$ git log -1 master
$ git log -1 refs/heads/master

在当前版本库中,HEADmasterrefs/heads/master 具有相同的指向。现在到版本库 .git 中一探它们的究竟:

$ find .git -name HEAD -o -name master
.git/HEAD
.git/logs/HEAD
.git/logs/refs/heads/master
.git/refs/heads/master

显示一下 .git/HEAD

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

指向一个引用:refs/heads/master

$ cat .git/refs/heads/master
e695606fc5e31b2ff9038a48a3d363f4c21a3d86

显示该提交的内容

$ git cat-file -p e695606fc5e31b2ff9038a48a3d363f4c21a3d86
tree f58da9a820e3fd9d84ab2ca2f1b467ac265038f9
parent a0c641e92b10d8bcca1ed1bf84ca80340fdefee6

which version checked in?

原来分支 master 指向的是一个提交ID(最新提交)。

这样的分支实现是多么的巧妙啊:既然可以从任何提交开始建立一条历史跟踪链,那么用一个文件指向这个链条的最新提交,那么这个文件就可以用于追踪整个提交历史了。

这个文件就是 .git/refs/heads/master 文件。

HEAD 和 master 的奥秘

目录 .git/refs 是保存引用的命名空间,其中 .git/refs/heads 目录下的引用又称为分支。对于分支既可以使用正规的长格式的表示法,如 refs/heads/master,也可以去掉前面的两级目录用 master 来表示。Git 有一个底层命令 git rev-parse 可以用于显示引用对应的提交 ID。

问题:SHA1哈希值到底是什么,如何生成的?

哈希(hash)是一种数据摘要算法(或称散列算法),是信息安全领域当中重要的理论基石。该算法将任意长度的输入经过散列运算转换为固定长度的输出。固定长度的输出可以称为对应的输入的数字摘要或哈希值。

$ echo -n Git |sha1sum
5819778898df55e3a762f0c5728b457970d72cae -

提交的 SHA1 哈希值生成方法:

$ git cat-file commit HEAD | wc -c
234

# 在提交信息的前面加上内容 `commit 234<null>`(`<null>`为空字符),然后执行 SHA1 哈希算法。
$ ( printf "commit 234\000"; git cat-file commit HEAD ) | sha1sum
e695606fc5e31b2ff9038a48a3d363f4c21a3d86 -

# 上面命令得到的哈希值和用 `git rev-parse` 看到的是一样的。
$ git rev-parse HEAD
e695606fc5e31b2ff9038a48a3d363f4c21a3d86

文件内容的 SHA1 哈希值生成方法:

# 文件总共包含 25 字节的内容。
$ git cat-file blob HEAD:welcome.txt | wc -c
25

# 在文件内容的前面加上blob 25<null>的内容,然后执行SHA1哈希算法。
$ ( printf "blob 25\000"; git cat-file blob HEAD:welcome.txt ) | sha1sum
fd3c069c1de4f4bc9b15940f490aeb48852f3c42 -

# 上面命令得到的哈希值和用git rev-parse看到的是一样的。
$ git rev-parse HEAD:welcome.txt
fd3c069c1de4f4bc9b15940f490aeb48852f3c42

树的SHA1哈希值的形成方法:

# HEAD对应的树的内容共包含39个字节。
$ git cat-file tree HEAD^{tree} | wc -c
39

# 在树的内容的前面加上tree 39<null>的内容,然后执行SHA1哈希算法。
$ ( printf "tree 39\000"; git cat-file tree HEAD^{tree} ) | sha1sum
f58da9a820e3fd9d84ab2ca2f1b467ac265038f9 -

# 上面命令得到的哈希值和用git rev-parse看到的是一样的。
$ git rev-parse HEAD^{tree}
f58da9a820e3fd9d84ab2ca2f1b467ac265038f9

问题:为什么不用顺序的数字来表示提交?

集中式版本控制系统因为只有一个集中式的版本库,可以很容易的实现依次递增的全局唯一的提交号。Git 作为分布式版本控制系统,开发可以是非线性的。这就要求提交的编号不能仅仅是本地局部有效,而是要“全球唯一”。

采用部分的 SHA1 哈希值。不必写全 40 位的哈希值,只采用开头的部分,不和现有其他的冲突即可。

使用 master 代表分支 master 中最新的提交,使用全称 refs/heads/master 亦可。

使用 HEAD 代表版本库中最近的一次提交。

符号 ^ 可以用于指代父提交。例如:

  • HEAD^ 代表版本库中上一次提交,即最近一次提交的父提交。
  • HEAD^^ 则代表 HEAD^ 的父提交。

对于一个提交有多个父提交,可以在符号 ^ 后面用数字表示是第几个父提交。例如:

  • a573106^2 含义是提交 a573106 的多个父提交中的第二个父提交。
  • HEAD^1 相当于 HEAD^ 含义是 HEAD 多个父提交中的第一个。
  • HEAD^^2 含义是 HEAD^(HEAD父提交)的多个父提交中的第二个。

符号 ~<n> 也可以用于指代祖先提交。效果等同:

a573106~5
a573106^^^^^

提交所对应的树对象:a573106^{tree}

某一此提交对应的文件对象:a573106:path/to/file

暂存区中的文件对象::path/to/file

$ git rev-parse HEAD
$ git cat-file -p e695
$ git cat-file -p e695^
$ git rev-parse e695^{tree}

Reference: