[Git][基本原理与命令]
引言
Git是工作中最常用的版本控制工具,本文中将介绍其常用的命令。
根据作用的不同,可以分为基本命令、撤销命令、合并命令与远程仓库命令,下面将依次介绍这些命令。
基本原理
git 中提供了底层api供我们直接对底层数据结构进行调用,其中
git cat-file [-t] [-p] hashcode
,其中-t
可以查看hash值对应文件的类型,-p
可以查看hash之对应的内容
基本对象
在最初的版本里实际上只有3种对象,分别是blob,tree和changeset(后续被改名为commit).本文主要对这三种对象进行介绍。基本的对象有tag域与binary data域。
Blob
在blob对象中,其tag的取值为blob,并在binary data域中存储了文件的内容,是最简单的object。但并不会保存文件名相关的信息。那么在一个工程项目中,我们有时候仅会对文件的名字进行改动,这时候则需要引出tree对象对这一变化进行追踪。
tree
tree的tag值为tree,binary data中维护了一个list。list的每一个item可以是一个tree对象,也可以是一个blob对象。 在每一个item中,有如下的属性:
- 权限和类型
- 路径
- 对象的sha1(类似于指针)
从这个角度来看,tree的list中既可以引用blob对象,也可以引用tree对象,循环往复,构成一颗文件结构树。
tree结构图[1]
利用tree与blob进行项目的追踪与维护
我们知道,若文件内容发生变化,则会生成新的blob对象与新的sha1值。对于tree对象,我们使用sha1值作为对blob对象的引用,那么该目录中的文件内容发生变化而引起对应的blob对象发生变化是,会导致tree维护的list中的内容发生变化,那么tree本身的sha1值也会发生变化。这就意味着,我们需要生成一个新的tree对象,来维护这一变化。
举个例子
对于如下结构的目录:
.
├── foo
│ └── a.txt
└── b.txt
如果我们将a.txt中的内容从"111"修改为"222", 那么整体的tree将会有如下结构:
可以看到新内容与旧内容分别维护了各自的根目录对应的tree,并复用没有修改内容的blob节点。
commit
仅靠tree对象与blob对象并不能记录与提交版本相关的信息。为了回溯任意一个版本,我们还需要一个对象对版本的提交信息进行维护——commit对象。
commit对象的tag是commit,binaray data域有以下内容:
- tree相关的信息
- parent
- author
- committer
其中,当我们提交一个新的commit时,会有一个对应的tree对象的与之关联。parent的个数可以是0个或者多个,维护着到上一个commit的sha1指针。可以通过该指针回溯到任意一个历史版本。从这个角度看,我们可以将git理解为基于一个key-value的数据库与默克尔树形成的有向无环图(DAG)的存储系统[3]。
tag
tag代表将分支永久化,生成tag后,这个tag对应的内容将永不可变,所以一般应用或者软件版本的发布一般用tag。
索引区
在我们的tree结构中,如果要访问一个具体的blob对象,需要使用深度优先遍历,通过路径上的信息来得到最终blob的完整路径。如果我们想要对比不同commit版本的文件之间的差异的话,就需要对两棵不同版本的tree进行深度遍历,当树很深时会涉及大量磁盘i/o。
为了提高i/o的速度,可以引入一个缓存,来提高文件读取的速度,这就是暂存区。通常相关的数据存储在./git/index。git使用mmap技术,访问文件内容。这里的文件内容指的是一系列entry对象,每一个entry对象中维护了对应的blob对象的映射与完整路径与其他相关的信息,entry对象在内存中按完整路径进行升序排列。这样,在后续的比较中就可以使用二分查找快速定位到相同文件名的不同版本完整地址,并进行比较。
命令
1. 基本命令
1.1 git add
当输入git add命令后,git会将文件生成对应的blob文件,并保存在git仓库中。但是并没有在tree上进行相应的结构映射,而只是在索引区建立了直接的索引。而tree结构的建立,需要等到commit命令才执行。
1.2 git commit
git commit后就会生成本次版本的对应树结构,涉及到一系列tree对象与commit对象的生成。(注意,blob对象在git add时就已经生成)
1.3 git checkout
该命令根据参数的不同,有许多效果。其主要的作用是在不同的分支之间切换。
查看代码[4]
git checkout # 列出本地所有分支
git checkout [<branch>] # 切换到该分支
git checkout -b [<branch>] # 创建并切换到该分支
git checkout -d [<branch>] # 删除该分支
-f # 强制执行
2. 撤销命令
git reset
这条命令指定退回到某一个版本的提交。
查看代码
--mixed # 工作区不变,其余内容退回到指定版本
-hard # 所有内容退回到指定版本
-soft # 回退到指定版本
该命令后可以指定文件名,用于对指定文件进行版本回退
需要谨慎使用 --hard参数,该参数会删除回退版本后的commit版本。另外永远不要将已经push并merge到origin中的branch的内容进行回退,否则会导致本地仓库的不同步。如果非要使用,后续则需要git push -f进行强制推送
git revert
其用法与 git reset类似,但是与reset不同的是,其不会删除一些历史commit记录,而是使用创建一个新的commit的方式来达到撤销历史修改的记录。对于一些已经push或产生其他副作用的修改,最好使用revert方式进行撤销操作。而reset则多用于本地的撤销修改。注:revert的commitid选则回退版本后一个的commitid。
3. 合并命令
git merge
会在两个parent commit上创建一个新的分支,若不存在冲突,则直接fast-forward merge。
查看代码
git merge [<branch>] # 将指定的分支合并到当前分支上
若合并产生冲突,会在日志中提示,并且在合并操作产生的commit中,会标出冲突的部位。当我们以手动的方式修改完冲突后,使用git add 通知git已完成冲突合并,并用commit完成这次merge(此时会自动带上 merge的message)
git rebase
从字面上来理解,是为当前分支重新确定一个基。达到的效果与merge类似,但是其commit的拓扑结构是不一致的。当执行完rebase后,是将当前分支以“类似”嫁接的形式,嫁接到某个指定的分支上,而不是类似于merge,将两个分支合并起来。
举个例子
当前我们的分支结构如下:
我们正在dev分支上,想将dev的修改记录应用到master分支上,但又不想以merge的方式进行。此时我们可以使用rebase,为dev分支重新确定一个基。执行完git rebase master
后,效果如下:
完成了类似嫁接的操作, 将dev的原始分叉点重新定位在master分支的最新的commit上,并生成了与dev分支长度相同的新的一系列commit。
4. 远程仓库命令
git fetch
该命令会在本地仓库中更新所有在远程端被追踪的分支,并用灰色的标记来标记这些属于remote的分支
举个例子
远程与本地分支的原始状态
执行完git fetch
后本地仓库的状态:
git pull
该命令相当于一条组合命令,在默认的参数下相当于git fetch + git merge [<remote_branch>]
,当然我们可以指定参数git>]
,当然我们可以指定参数git pull --rebase
,那么就会使用git rebase
来替代第二条命令。注意,默认只会将当前分支对应的远程分支合并进来。
举个例子
原始状态
执行完git pull
后
git push
该命令会将本地分支的内容推送到对应的远程分支上。从理论上来讲,所有的push操作都应该进行fast-forward merge,若仍存在一些不同则会发生reject错误。遇到这种情况时,通常需要先将远程的内容pull到本地进行合并后,再进行push。
参考
[2]Explain Git with D3 (onlywei.github.io)