无论学习什么技术,都需要了解该技术的本质。若是靠死记硬背该技术提供的方法或者语法,终归是知其然而不知其所以然,当发现错误时,你根本不知道是什么原因导致的。我在使用Git时,就处于这种知其然而不知其所以然的状态。现在,再来补补课。
Git有三个工作区域,分别为:工作目录(Working Directory)、暂存区(Stage或Index)以及资源库(Repository或Git Directory)。下图是文件在这三个工作区域之间的关系:
参考Pro Git一书,它给出了Git的几个要点: * 直接快照,而非比较差异:Git与其他版本管理系统的主要差别在于,Git只关心文件数据的整体是否发生了变化,而其他多数版本管理系统则只关心文件内容的具体差异。Git并不保存文件前后变化的差异数据,更像是把变化的文件做一个快照,然后记录在一个微型的文件系统中。每次提交更新时,会比较这个快照。若文件没有变化,Git则只对上次保存的快照作一个链接。你可以理解Git就是一个小型的文件系统。 * 近乎所有操作都可本地执行:无需多说,这本身就是分布式版本管理系统的特征。 * 时刻保持数据完整性:保存到Git前,所有数据都要进行内容的校验和(checksum),并将该结果作为数据的唯一标识。Git使用了SHA-1算法计算数据的校验和,并将该结果作为索引,而非文件名。
* 多数操作仅添加数据
Pro Git一书认为任何一个文件在Git内部可以被分为三种状态:已提交(Committed)、已修改(Modified)和已暂存(Staged)。然而,这并不足以说明一个文件在不同的工作区域所展现的状态。我认为两种状态足以表达Git中的文件,即:未跟踪(Untracked)和已跟踪(Tracked)。而对于已跟踪状态,我又将其分为:未修改的(Unmodified),Modified(已修改的),暂存的(Staged)和已提交的(committed)。下图基本表达了我的思路:
这个图表现了多种场景,满足了我们在使用Git时耳濡目染的操作情形。
场景1:暂存文件以及取消已暂存的文件
可以参考上图中上面部分黑色箭头标示。当我们通过git init在本地初始化了Git工作目录后,新增了一个README.txt文件时,此时该文件处于Untracked状态。接下来执行命令:
- git add README.txt
add命令可以暂存此文件,此时,状态变更为Staged状态,被放到了Git暂存区中。若我们要提交此文件到Git资源库,就可以执行git commit命令,文件状态变为committed。例如:
- git commit -m "first commit"
有时候,我们希望取消已暂存的文件。例如说,我在工作目录中增加了两个文件,然后暂存了它们。后来发现其中一个文件并不需要在Git中管理,希望能够取消暂存。由于此时的文件处于Staged状态,我们只需要删掉Stage中对此文件的跟踪即可。这时需要执行的命令是:
- git rm --cached README.txt
注意:此时取消暂存的文件从来就不曾提交过,也即是说没有在Git Repository留下过它的身影。这时的取消暂存实则是删掉暂存的信息。与后面场景演示的取消暂存并不相同。
场景2:修改已提交文件以及取消已暂存的内容
一旦文件被提交,就会在Git Repository形成提交记录(以hash作为键)。倘若我们此时push提交到远程Git服务器,Git服务器应与本地库保持一致。
现在,让我们看看图中红色箭头展现的流程。我们修改了已提交的README.txt文件,于是文件状态就变更为Modified。这部分修改的内容并没有被放入暂存区,若要提交此次修改,就还需要再次执行git add命令,将这次新的修改放入到暂存区。这个流程包括后面的提交都与场景1相似。唯一不同的是“取消已暂存的内容”。
虽然同样是取消暂存,但它与场景1是完全不同的概念。场景1实则是要取消暂存区的文件,因此使用了git rm –cached,本质上讲其实是删除。而这里的取消,其实是希望取消暂存区中已经被添加的修改内容,文件本身仍然保留在暂存区中。故而执行的命令为:
- git reset HEAD README.txt
HEAD是何意呢?在Git中,HEAD是一个特别的指针,指向你正在工作的本地分支。当前分支就是master。如下图所示:
而reset命令的意思是重新设置当前的HEAD指针到特定的状态。由于当前的README.txt还没有提交到master分支的Repository中。因此,这条命令实则就是将HEAD指向README.txt文件在当前master分支的Repository状态,从而保证了对README.txt文件而言,暂存区与Repository的一致——取消了README.txt文件在暂存区的内容。
场景3:修改文件以及撤销修改内容
再看图中的绿色箭头与蓝色箭头展现的流程。我们不是初始化git工作目录,而是通过git clone从远程Repository克隆了项目,此时会在当前目录建立git工作目录。此时的文件全部处于Unmodified状态。
现在,我们修改文件,例如hello.java。一旦被修改,文件状态就迁移到Modified状态。倘若需要暂存此次修改,甚至提交到Git Repository,则执行的流程与场景1相同(如蓝色箭头线所示)。
然而,我们可能希望放弃此次修改,即不将修改的内容放入暂存区。这时,应执行checkout命令:
- git checkout -- hello.java
在执行checkout命令时要慎重。因为它要撤销的内容并没有被放入到暂存区或Repository。一旦撤销,就一去不复返了。
概念区分:fetch vs. pull
fetch命令只是将远端数据拉到本地仓库,并不自动合并到当前工作分支。若要合并,还需手动合并。例如,执行git fetch origin,就会抓取自上次克隆以来别人上传到此远程仓库中的所有更新。
pull命令则除了会抓取数据,还能将远端分支自动合并到本地仓库中当前分支。
场景4:撤销提交
在Git中若要撤销提交,可以使用reset或者revert命令。但二者有着显著的区别:
revert命令可以撤销已经提交的快照,但它并不会将该提交从项目的提交历史中移除,而是会判断要撤销的这次提交引入了哪些变化,然后将此变化撤销(此次撤销事实上还是一种变化),再将这次撤销作为一个提交。因此,在执行revert命令后,如果通过git log查看提交历史,可以看到会新增一个revert提交。命令为:
- git revert <commit>
这个commit可以是指定提交对应的hash code。我们也可以用HEAD指针:
- git revert HEAD~n
如果是revert当前提交,则不需要HEAD后的~n。
reset命令就字面意义已经表达了该操作的含义为“重置”。由于Git的提交记录是由HEAD指针指向当前分支。重置就是搬动这个指针到指定的snapshot。如果说revert是一种 安全的撤销方式,则reset就是一种 危险的撤销方式。默认情况下,如果使用reset命令,会将当前的分支回退到指定commit,然后自指定commit到***commit之间的内容会放在工作目录下,使得我们可以再提交。这个命令为:
- git reset <commit>
与前相同,这个commit就是提交对应的hash code。同样,也可以使用HEAD指针。不过如果是撤销当前提交,与revert不同的是,需要指定为:HEAD~1。这是因为HEAD指针指向了当前提交。reset与revert的意义不一样。revert对应的commit为目标提交,意思为:“撤销目标提交”,因而git revert HEAD,代表的就是“将当前提交撤销”。而reset对应的commit表示将指针移向给定的Commit。如果执行git reset HEAD,代表的就是“将当前指针指向当前提交”,相当于没做任何操作。所以应该执行git reset HEAD~1。
如果确实要撤销操作,而前面的内容并不需要,在使用reset命令时,可以添加–hard参数:
- git reset --hard <commit>
**注意:针对远程的提交记录,应尽量避免使用git reset命令。倘若在本地进行了reset之后,又进行了另外的修改并提交。此时,本地的提交记录与远程的提交记录在reset的那个点产生了分叉。如下图所示:
此时,如果执行git push,会在本地合并后提交,并同步远程提交记录。则团队其他成员会因为这个变化的提交记录而困惑。由于一部分变更消失,甚至可能导致一些数据被破坏。因此,使用reset命令要格外当心,通常情况,应尽量针对本地提交(未push到远程)进行reset。优先考虑使用revert命令。