写在开篇
参考资料
操作环境
文章中会涉及一些实际操作,操作系统为macos,git工作目录为临时新建的目录,想要实操的同学可以在任意文件夹执行git init命令后跟随实操。
Git学习
什么是Git?
Git的工作原理
1. Git内容寻址文件系统
2. Git主要对象类型及核心设计思路
Git版本控制的核心概念包括:引用,缓冲区和三种特殊的对象。三种特殊的对象具体来说为:文件内容(blob),树(tree),提交(commit)。我们先简单看一下Git的目录结构
1 |
ll .git |
HEAD文件、index文件、objects目录、refs目录是我们这里需要重点关注的。其中,HEAD属于一种特殊的引用,其余所有引用在未压缩的情况下都存储于refs目录下。index文件为一个二进制文件,即为缓冲区,记录了工作目录下所有需要跟踪的文件的索引和状态(记住!!这里是索引,不记录实际存储对象)。三种特殊的对象(blob,tree,commit)在未压缩的情况下存储于objects目录下。我们先从最基础的Git仓库的存储单元说起:blob对象。
blob对象
1 |
echo "test content" > test.txt |
hash-object命令先会给text.txt计算出文件头,然后将文件头加上文件本身内容计算出一个SHA-1哈希值
d67046
,通过-w参数,git会将test.txt文件进行zlib压缩,生成一个blob对象,存入git仓库,最后再返回对应的SHA-1值。在Git中,该SHA-1值即为text.txt文件的索引。(如上面所示,我们可以使用
cat-file -p
命令查看索引
d67046
指向的blob对象的具体内容,
cat-file -p
命令是Git查看文件的瑞士军刀,只要是Git对象都可以通过SHA-1值查看存储的对象内容)。我们可以进入到Git的仓库目录
.git
文件夹下,看看我们的这个文件的版本记录被转存到了哪里。
1 |
find .git/objects -type f |
可以看到Git以SHA-1值的前两位作为文件目录,将blob对象存储在了.git/objects文件夹。那么一个文件的多个版本Git怎么存储呢?我们可以试验一下
1 |
echo "text content version 2" > test.txt |
可以看到git将两个版本的test.txt文件分别进行了存储,而非增量的形式存在,这是Git的默认存储方式,松散存储。好处是不用追溯多个版本去还原一个文件的内容,但同时也会牺牲一定的存储空间,Git为了解决空间利用的问题,提供了文件gc的功能,但这不是重点,我们略过不谈。 有了上面的两个blob对象,我们其实就可以进行版本管理了,向下面这样,我们可以轻松的一个文件的两个版本上进行切换。
1 |
git cat-file -p 2638ce0f549ff134a334e415c6e3a9fbc5ae2 > test.txt |
看,我们成功的将文件恢复至了第一个版本。
tree对象
我们可以尝试一下手动创建一个tree对象。tree对象可以借助Git的缓冲区(index文件)进行创建。我们首先将文件的索引加入缓冲区,而后将缓冲区中的索引集合转化为tree对象。通过Git的
update-index
和
write-tree
命令,我们可以轻松的做到这一点。
1 |
git update-index --add test.txt |
我们将Git缓冲区中的所有索引以类似快照的形式生成了一个SHA-1值为
19600d
的tree对象,该tree对象即为此次缓冲区的历史版本。这里细心的同学会注意到,我们并没有对 test.txt 对象做
hash-object -w
操作,但是同样在objects目录下生成了
2638ce
对象。我们可做这样的猜想,Git的
update-index
命令在执行时,会先执行
hash-object
命令,为文件生成SHA-1值,如果SHA-1值不存在于缓冲区,或者与现有缓冲区中的SHA-1值不同,则会先执行命令
hash-object -w
操作,生成blob对象,然后再新增或者更新文件的索引至缓冲区。 我们再尝试增加一个文件验证一下。
1 |
echo "new text" > new.txt |
这一次,我们在执行
update-index -add
命令后,执行了
ls-files --stage
命令,该命令可以查看缓冲区中的索引情况。这里,我们看到在
update-indxe -add
命令之后,objects目录新生成了一个索引为
eee417
的blob对象,而后该索引被加入到缓冲区中。在执行
write-tree
命令后,我们看到生成的tree对象的内容即为缓冲区的一个快照。
有了tree对象,我们就可以方便的将整个项目的工作目录进行版本管理了。首先将整个工作目录的文件加入缓冲区(即加入索引),然后再通过缓冲区生成tree对象,这样就不用一个个的为项目中的文件使用
hash-object
命令,添加版本控制了。但一个项目在迭代过程中,肯定会产生很多的tree对象,怎么组织这些tree对象,串联出一个项目的时间线(即历史信息)呢?怎么样能够随时在一个项目不同时期的版本上随意切换呢?Git为我们提供了一个很好的解决方案:commit对象。
commit对象
commit对象提供了一个更人性化的方式管理所有的tree对象,其包含的主要信息有:
每个commit对象必然携带一个tree对象,即一一对应。这样,commit对象就像是tree对象的一个“说明书”,提供了tree对象的提交时间和提交描述,最重要的是,提供了父亲tree对象的“说明书”的地址(即父亲commit对象指针)。这样零散的tree对象就通过commit对象,在时间线上被前后串联了起来。我们想要查看一个文件的历史版本,只需要在commit对象上追溯,找到这个文件相关的所有commit对象指向的tree对象,接着再找到所有tree对象中,该文件的索引值,就可以随意翻看这个文件的所有历史改动。
我们可以通过Git的
commit-tree
命令来模拟一下commit的串联。
我们先将我们刚才获取到的第一个tree对象
19600d
进行一次提交
1 |
echo "first commit" | git commit-tree 19600deac4f0ab9b5fa0fc61c9ee03647cfe61ce |
这样我们得到了一个 d6da9c commit对象,继续将第二个tree对象进行提交,并指定 d6da9c 为其父commit
1 |
echo "second commit" | git commit-tree 369fe222da08762cb4f02ddee8efc4637afbc5a2 -p d6da9c |
通过
git log
命令,我们发现,两个commit对象已经串联了起来,我们已经有了一个工作目录的历史版本记录,如下图所示。
references
1 |
git update-ref refs/heads/master 8cbf9de25c7bf88fa3cb55718d90da9601162ed3 |
我们通过
git update-ref
命令创建了一个
master
引用,现在我们分别使用
master
引用和SHA-1哈希值查看一次log。
1 |
git log --pretty=oneline master |
效果完全一样,还有一个有意思的地方,执行一下
git branch
命令看看
1 |
git branch |
其实通过
git update-ref
命令,我们已经创建了一个 branch 。实际上,每当你执行
git branch (分支名称)
这样的命令,Git 基本上就是执行
update-ref
命令,把你现在所在分支中最后一次提交的 SHA-1 值,添加到你要创建的分支的引用。同样的原理也适用于
git tag
命令,区别仅是 branch 的引用存储在
refs/heads
而 tag 的引用存储在
refs/tags
。 现在的问题是,在上面执行命令时,我们指定了SHA-1值为
8cbf9d
,所以一切顺利。但是当你执行
git branch (分支名称)
这条命令的时候,如果你不知道最后一次提交的SHA-1值,该怎么办呢?
这里Git增加了一个比较特殊的引用:HEAD。可以观察一下HEAD文件的内容
1 |
cat .git/HEAD |
可以发现HEAD文件,实际是一个指向引用的引用。Git将它设定为始终指向了当前工作目录的最后一次提交的 SHA-1 值。例如现在,HEAD指向了 master 引用,实际也就是指向了当前工作目录的最新一次commit: 8cbf9d 。这样的做法,对于打出新的tag和branch来说非常方便,只需把HEAD指向的commit的SHA-1值添加到你要创建的 branch 或者 tag 的引用上即可。
最后,我们还有一类特殊的我们一带而过:remotes。这类引用指代了本地仓库关联的远程仓库的引用情况。如果你在进行团队协作可发,那么很可能你会使用到到remotes引用。
所以,全局的来看,Git的核心结构大致是如下这样的
3. Git应用实战
Git基本命令
git clone
1 |
git clone git@10.3.4.127:ucarapi/test.git |
这样,
test.git
仓库的所有对象以及引用都已同步至本地了。有一点不同的是,本地仓库的分支结构,并没有和我们预期的一样和远程仓库保持一致。我们的远程仓库上存在了两个分支,一个master一个newFeature。我们可以在本地仓库上通过
git branch
查看一下分支情况。
1 |
git branch |
我们会发现,本地仓库里并没有newFeature分支。这时怎么回事呢?实际上
git clone
将test仓库的所有对象信息都同步至了本地仓库。但是对于引用,Git对于远程仓库的引用做了特殊处理,在本地将这些引用信息保存为了remotes引用(前面有提到过)。为了解释这一点,我们可以看看git仓库中的情况。
1 |
cat .git/packed-refs |
这里我们查看了.git/packed-refs信息,Git在进行远程仓库之间的信息交互时,为了节省流量会将对象和引用进行压缩,所以现在远程仓库中的引用信息被压缩在了packed-refs目录。我们看到,远程仓库的分支引用都完好的保存了下来。我们继续看一下分支引用所指向的commit的日志信息。
1 |
git log a4a5b5526bf0dcec1cb1d34076d0f2b7ae60e49a |
可以看到 这个commit关联了3个commit,我们去Git仓库中看看这些commit是否存在。
1 |
git verify-pack -v .git/objects/pack/pack-7868e822bc1d53620a707a9ed966be65cf75301e.idx |
前面说到了,Git在进行远程仓库同步时会压缩对象和引用以节省流量。这里对象都被压缩在了
.git/objects/pack
包的.pack文件下,同时Git还为pack文件创建了索引文件,.idx文件。我们可以通过
git verify-pack
访问索引文件,来检查pack包中的文件内容。这里前四条索引恰好就是我们要找的4条commit记录,可见newFeature分支已经被我们完整的从远程仓库克隆了下来。为了证明这一点,我们可以断网尝试切换分支到newFeature。
1 |
git branch |
我们并没有经过网络传输,在本地成功切换到了newFeature分支。
git branch & git checkout
1 |
git branch newFeature1 |
这样,我们就已经切换至最新的分支了。我们尝试在新分支上做点改动。
git add & git commit
我们可以使用
git add
命令将更改内容的索引更新至缓冲区,然后通过
git commit
命令将缓冲区中的索引输出成tree对象,生成commit对象,最后更新HEAD引用。
1 |
echo "newFeature1" > newFeature1.txt |
我们成功在分支 newFeature1 上提交了改动。
git rm
1 |
rm newFeature1.txt |
这样,我们就成功删除了newFeature1.txt文件同时移除了该文件的版本控制。
git reset & git revert
1 |
git revert HEAD |
看,newFeature1.txt文件又回来了,同时log日志中多出了一次commit记录,标识了我们对本地Git仓库进行了一次revert操作。
git merge & git rebase
而
git rebase
命令则是将master分支上打出newFeature1分支之后的commit记录全部挂起,然后将master分支重新定位至newFeature1分支的最后一次commit记录,最后再将挂起的commit记录依次添加到重新定位的commit记录之后。这样将产生一个直线形的提交树,就像下面这样
二者的特性可以从两个命令的语义上清晰的体现出来:merge将改变 融合 到当前的分支中从而产生新的提交,这样这个提交会像新生的提交一样,排列在提交记录的最前面;rebase不产生新的提交,而是将分支 重定向 到了新的提交树上,通过 改变历史 达到改变分支代码的效果。二者依其特性各有各自的适用场景:
git merge
适用于在开发完成后合并自己的代码至发布分支时使用,因为rebase不能很好的记录分支的合并的时间点——一旦rebase相当于就改变了历史,没法追溯是在哪个commit节点时进行的代码合并。从团队代码管理的角度来说,管理员会更希望看到发布分支上的每一个合并动作发生的时间,提交人,合并的代码内容等等。这些都是rebase没办法做到的。
git rebase
适用于在开发过程中同步别人的变更代码时使用,因为从时间上来看变更的代码在你开发的代码之前进入了远程仓库,同步时采用rebase不会产生多余的commit记录,且历史记录看上去也更加自然——因为变更的历史记录都会自然而然的排列在你的提交之前。
我们试着将我们的代码合并会master分支,既然是合并回master分支,那么我们使用
git merge
命令来完成这一操作。
1 |
git checkout master |
可以看到我们产生了一个梯形的提交树(前面的提交记录太长未予展示),同时多了一个因为merge产生的commit 479b62 同时提示我们这个merge是因为两个commit记录 5fc6e12 和 e50edf7 融合产生的。
Git同步本地仓库
接下来我们准备推送本地代码至远程仓库,推送之前,我们需要同步一次本地仓库,以确保本地仓库代码为远程仓库的最新代码。
我们先查看一下我们需要推送的远程仓库地址是否正确,这一步可以使用
git remote -v
来查看。
1 |
git remote -v |
可以看到 我们现在有一个叫做
origin
的远程仓库地址。现在我们从 origin 上抓取最新的代码至本地仓库。这里我们可以使用
git pull
命令 或者
git fetch
命令 。 二者的区别在于
git pull
命令会在将commit同步至本地仓库的同时,尝试在指定的分支上(或者默认追踪的分支上)进行
git merge
操作。即
git pull
相当于
git fetch
+
git merge
。这里我们为了节省操作步骤使用
git pull
(命令的参数格式请自行查阅文档,此处不再详述)
1 |
git pull origin master |
好了,我们成功同步了本地的代码。此时我们可以保证我们的代码在同步至远程分支时不再产生冲突。
Git推送本地代码
我们通过
git push
命令完成本地仓库代码发布至远程服务器的动作
1 |
git push origin master |
这样我们成功的将代码发布至了远程仓库。
到此为止,我们就基本完成了开发工作过程中的整个生命周期。当然,在开发过程中还会遇到一些不在上述描述范围内的状况。例如代码冲突(在pull阶段可能发生),解决代码冲突的方式有很多,这里碍于篇幅原因,不再详述。
Git删除/创建/更新远程分支
Git删除/推送远程tag
Git默认不会推送本地tag至远程仓库。如果想要对远程的tag执行删除,创建的操作,需要在
git push
命令中显示申明。
推送一个tag至远程仓库:
git push origin v1.0
推送所有tag至远程仓库:
git push origin --tags
Git团队协作建议
综合Git应用实战中的描述,这里有两组命令容易混用:
git reset & git revert
和
git merge & git rebase
,这里针对这两个命令,提供了几个建议:
git reset
命令,因为这样会导致你的提交记录丢失,无法追溯。
pull
命令,而使用
fetch+rebase
命令来同步别人新提交的代码。往往我们希望保持开发分支的历史记录能够按照开发提交的时间顺序排列在一条时间线上,
fetch+rebase
命令能够保证这一点,而直接使用
pull
命令将会使开发分支因为不同开发的之间的代码合并而变得混乱。
注意
,这里需要确保,rebase的分支必须是你本地分支打出初始时跟踪的远程分支,一旦不在最初跟踪的分支上进行操作,一切将会变得混乱,具体原因可以参看
Git分支的衍合
一文。
merge --no-ff
命令来保证提交历史记录的足够清晰。
GitIDE集成使用及插件介绍
这里我使用的是Macos系统下的IntellJ IDEA,所以主要介绍IDEA上的集成使用操作。
首先要使用Git,需要下载Git客户端,可以从官网下载。下载安装完成后,在IDEA中配置执行文件路径。