首先看一下git-demo的提交日志,使用git log
查看提交日志(附加--stat
参数可以看到每次提交的文件变更统计)。
1 | $ git log --stat |
通过日志可以看到第一次提交对文件welcome.txt
有一行的变更,而第二次提交因为使用了--allow-empty
参数进行的空提交,所以提交说明中看不到任何对实质内容的修改。
修改不能直接提交?
首先更改welcome.txt
文件的内容,在文件后面追加一行。
1 | $ echo "很高兴见到你。" >> welcome.txt |
这时就可以通过git diff
命令看到修改后的文件和版本库中文件的差异。实质和本地比较的不是版本库,而是一个中间状态的文件。
1 | $ git diff |
接下来执行提交,看能否提交成功。
1 | $ git commit -m "追加一行:很高兴见到你。" |
提交成功了吗?好像没有。
提交没有成功的证据:
- 先看看提交日志,如果提交成功就会有新的提交记录。
1 | $ git log --pretty=oneline |
使用了精简输出来显示日志,以便更简洁和清晰地看到提交历史。可以看到版本库中只有两个提交,针对刚才修改文件的提交没有成功!
- 执行
git diff
可以看到和之前相同的差异输出,这也说明提交没有成功。 - 执行
git status
查看文件状态,可以看到文件处于未提交状态。 - 执行
git status -s
可以显示精简格式的状态输出。
提交为什么会失败呢?原因是没有对修改的welcome.txt
文件执行git add
命令,将修改的文件添加到“提交任务”,然后才能提交。
现在就将修改的文件“添加”到提交任务。
1 | $ git add welcome.txt |
现在再执行一些Git命令,看看当执行“添加”后会发生什么变化。
- 执行
git diff
没有输出。 - 执行
git diff head
或git diff master
进行比较会发现有差异,这个差异是正常的,因为还没有提交。
1 | $ git diff head |
- 执行
git status
,状态输出和之前不一样了。
1 | $ git status |
对新的Git状态输出做下翻译:
1 | 位于分支master上 |
不得不说,Git太人性化了,它把各种情况下可以使用到的命令都告诉了用户,虽然这显得有点啰嗦。
如果不要这么啰嗦,可以使用简洁方式:
1 | $ git status -s |
上面精简状态输出与执行git add
之前的精简状态的输出相比,有细微的差别。
- 虽然都是M(Modified)标识,但是位置不同。在执行
git add
之前,这个M位于第二列(第一列是个空格),执行完git add
之后,字符M位于第一列(第二列是空白)。 - 位于第一列的字符M的含义:版本库中的文件和处于中间状态–提交任务(提交暂存区,stage)中的文件相比有改动;
- 位于第二列的字符M的含义:工作区当前的文件和处于中间状态–提交任务(提交暂存区,stage)中的文件相比有改动。
这时如果直接提交git commit
,加入提交任务的welcome.txt
文件的更改就被提交入库了。但是先不忙着提交,再进行一些操作,看看能否被彻底搞糊涂。 - 继续修改
welcome.txt
文件(在文件后面追加一行)。
1 | $ echo "Good-bye" >> welcome.txt |
- 然后执行
git status
查看一下状态:
1 | git status |
状态输出中居然是之前出现的两种不同状态的输出。
- 使用简洁模式输出,也会看到两种精简输出的杂合。
1 | git status -s |
上面Git状态的输出可以这么理解:不但版本库中最新提交的文件和处于中间状态(提交暂存区,stage)中文件
相比有改动,而且工作区当前的文件和处于中间状态(提交暂存区,stage)中的文件相比也有改动。
即现在welcome.txt
中有三个不同的版本:一个在工作区,一个在暂存区,一个是版本库中最新版本。通过
不同的参数调用git diff
命令可以看到不同版本库之间的差异。
- 不带任何选项和参数调用
git diff
显示工作区最新改动,即工作区和提交暂存区中相比的差异。 - 将工作区和HEAD(当前工作分支)相比,会看到更多差异。
- 通过参数
--cached
或者--staged
参数调用git diff
命令,看到的是提交暂存区和版本库中文件的差异。
好了,现在提交下看看。
1 | $ git commit -m "哪个版本的数据会被提交?" |
这次提交终于成功了,查看下日志:
1 | $ git log --pretty=oneline |
查看精简状态输出:
1 | $ git status -s |
状态输出中文件名的前面出现了一个字母M,即只位于第二列的字母M。那么第一列的M去哪了?很明显被提交了。
即提交任务(提交暂存区,stage)中的内容被提交到版本库,所以第一列因为提交暂存区和版本库中的状态一致,所以显示空白。
那提交的welcome.txt
是哪个版本呢?通过执行git diff
或者git diff head
命令查看差异。虽然命令git diff
和git diff head
的比较过程并不同,但是会看到下面相同的差异输出结果。
1 | $ git diff |
理解Git暂存区(stage)
Git暂存区(stage,或称为index)的设计是Git最成功的设计之一,也是最难理解的一个设计。
在版本库.git
目录下,有一个index
文件,下面针对这个文件做个实验。
首先执行git checkout
命令,撤销工作区中welcome.txt文件尚未提交的修改。
1 | $ git checkout -- welcome.txt |
通过状态输出可以看到工作区已经没有改动了,查看下.git/index
文件的时间戳: 2019-06-10 22:00:43
1 | $ ls --full-time .git/index |
再次执行git status
命令,然后显示.git/index
文件的时间戳:2019-06-10 22:00:43,和上面的一样。
1 | $ git status -s |
现在更改一下welcome.txt文件的时间戳,但不改变它的内容。然后再执行git status
命令,然后查看.git/index
文件时间戳为:22:06:32
1 | $ touch welcome.txt |
可以看到时间戳改变了。
这个实验说明当执行git status
或者git diff
命令扫描工作区改动的时候,先依据.git/index
文件中记录的
(工作区跟踪文件)时间戳、长度等信息判断工作区文件是否改变。如果工作区的文件时间戳改变,说明文件的
内容可能被改变了,需要打开文件,读取文件内容,和更改前的原始文件相比,判断文件内容是否被更改。如果
文件内容没有改变,则将该文件新的时间戳记录到.git/index
文件中。因为判断文件是否更改使用的是时间戳、
文件长度等信息进行比较,要比通过文件内容比较快的多,所以GIt这样的实现方式可以让工作区状态扫描更快
执行,这也是Git高效的因素之一。
文件.git/index
实际上就是一个包含文件索引的目录树,像是一个虚拟的工作区。在这个虚拟工作区的目录树中,
记录了文件名、文件的状态信息(时间戳、文件长度等)。文件的内容并不存储其中,而是保存在Git对象库.git/objects
目录中,文件索引建立了文件和对象库中对象实体之间的对应。
下面这张图展示了工作区、版本库中的暂存区和版本库之间的关系。
- 图中左侧为工作区,右侧为版本库。在版本库中标记为index的区域是暂存区(stage,亦称index),标记为master的是master分支所代表的目录树。
- 图中可以看出此时HEAD实际上是指向master分支的一个“游标”,所以图示的命令中出现HEAD的地方可以用master替换。
- 图中的objects标识的区域为Git的对象库,实际位于
.git/objects
目录下。 - 当对工作区修改(或新增)的文件执行
git add
命令时,暂存区的目录树被更新,同时工作区修改(或新增)的文件
内容被写入到对象库中的一个新的对象中,而对象的ID被记录在暂存区的文件索引中。 - 当执行提交操作时,暂存区的目录树写到版本库(对象库)中,master分支会做出相应的更新。即master最新指向的目录树就是提交时原暂存区的目录树。
- 当执行
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
来查看。
1 | $ git ls-tree -l HEAD |
- 使用
-l
参数,可以显示文件大小。上面welcome.txt
大小33字节。 - 输出的条目从左至右,第一个字段是文件的属性(rw-r–r–),第二个字段说明是Git对象库中的一个blob对象(文件),
第三个字段则是该文件在对象库中对应的ID–一个40位的SHA1哈希值格式的ID,第四个字段是文件大小,第五个字段是文件名。
在浏览暂存区中的目录树之前,首先清除工作区当中的改动。通过 git clean -fd
命令清除当前工作区中没有加入
版本库的文件和目录(非跟踪文件和目录),然后执行git checkout .
命令,用暂存区刷新工作区。
1 | $ git clean -fd |
然后在工作区中做出一些修改(修改welcome.txt
,增加一个子目录和文件),然后添加到暂存区。最后再对工作区做出修改。
1 | $ echo "( ^_^ )/~~拜拜" >> welcome.txt |
上面的命令运行完毕后,通过精简的状态输出,可以看出工作区、暂存区、版本库当前分支的最新版本(HEAD)各不相同。先来看看工作区中文件的大小:
1 | $ find . -path ./.git -prune -o -type f -printf "%-20p\t%s\n" |
要显示暂存区的目录树,可以使用git ls-files
命令。
1 | $ git ls-files -s |
这个输出和之前使用git ls-tree
命令的输出不一样,如果想要使用git ls-tree
命令,需要先将暂存区的
目录树写入Git对象库git write-tree
命令,然后在针对git write-tree
命令写入的tree执行git ls-tree
。
1 | $ git write-tree |
从上面的命令可以看出:
- 到处都是40位的SHA1哈希值格式的ID,可以用于指代文件内容(blob),用于指代目录树(tree),还可以用于指代提交。
git write-tree
的输出就是写入Git对象库中的Tree ID,这个ID将作为下一条命令的输入。git ls-tree
命令中,没有把40位的ID写全,而是使用了前几位,实际上只要不和其他的对象ID冲突,可以随心所欲的使用缩写ID。git ls-tree
的输出显示的第一条是一个tree对象,即刚才创建的一级目录a
。
如果想要递归显示目录内容,则使用-r
参数调用。使用参数-t
可以把递归过程中遇到的每棵树都显示出来,而不是只显示最终的文件。下面执行递归操作显示目录树的内容。
1 | $ git write-tree | xargs git ls-tree -l -r -t |
通过使用不同的参数调用git diff
命令,可以对工作区、暂存区、HEAD中的内容两两比较。下面的图展示了git diff
命令的作用范围。
通过上面的图就不难理解下面命令的输出。
- 工作区和暂存区比较。
1 | $ git diff |
- 暂存区和HEAD比较。
1 | $ git diff --cached |
- 工作区和HEAD比较。
1 | $ git diff HEAD |
不要使用git commit -a
实际上Git的提交命令git commit
可以带上-a
参数,对本地所有变更的文件执行提交操作,包括本地修改的文件,删除的文件,但不包括未被版本库跟踪的文件。
这个命令的确可以简化一些操作,但同时会丢掉Git暂存区带给用户最大的好处:对提交内容进行控制的能力。