Git原理浅析及实用技巧
前言
Git是一个开源的分布式版本控制系统,可以快速高效地进行项目版本管理,作为日常使用中的基础工具,大家都不陌生。更好地使用需要更多的了解,我们结合着Git的实现原理去学习回顾Git命令,文末会附上常用的git命令。
了解Git
Git存储结构
当我们执行命令git init时,会在当前目录下生成一个隐藏文件夹 .git,Git会将整个数据库储存在
.git/目录下。
执行如下命令
➜ git init
➜ echo "1" > a.txt
➜ echo "2" > b.txt
➜ git add a.txt
➜ git add b.txt
每次执行git add .git/objects目录下都会新增一个文件。
➜ tree .git/objects
.git/objects
├── 0c
│ └── fbf08886fca9a91cb753ec8734c84fcbe52c9f
├── d0
│ └── 0491fd7e5bb6fa28c517a0bb32b8b506539d4d
├── info
└── pack
新增的这两个文件是git将数据压缩成的一个二进制文件, git cat-file [-t] [-p], -t可以查看object的类型,-p可以查看object储存的具体内容。
➜ git cat-file -t 0cfb
blob
➜ git cat-file -p 0cfb
2
这里我们看到object的类型是blob,blob类型,它只储存的是一个文件的内容,不包括文件名等其他信息,git将这些信息经过SHA1哈希算法得到对应的哈希值。
执行git commit会新增两个文件
➜ git commit -m "init git"
➜ tree .git/objects
.git/objects
├── 99
│ └── 59835d90e1f2a652da437586f6e867581f8a1d
├── 9c
│ └── e769527cef798b85b8c0b841f2cfdb757b9794
通过git cat-file -t会发现是tree和commit两种类型。
tree,将当前的目录结构打了一个快照。从储存的内容来看可以发现其储存了一个目录结构(类似于文件夹),以及每一个文件(或者子文件夹)的权限、类型、对应的身份证(SHA1值)、以及文件名。
➜ git cat-file -p 9ce7
100644 blob d00491fd7e5bb6fa28c517a0bb32b8b506539d4d a.txt
100644 blob 0cfbf08886fca9a91cb753ec8734c84fcbe52c9f b.txt
commit,储存的是一个提交的信息,包括对应目录结构的快照tree的哈希值,上一个提交的哈希值(这里由于是第一个提交,所以没有父节点。在一个merge提交中还会出现多个父节点),提交的作者以及提交的具体时间,最后是此次提交的信息。
➜ git cat-file -p 9959
tree 9ce769527cef798b85b8c0b841f2cfdb757b9794
author sunhongfa <sunhongfa@kuaishou.com> 1633960408 +0800
committer sunhongfa <sunhongfa@kuaishou.com> 1633960408 +0800
Git的主要的存储结构就是这三个object,图示如下:
image.png除了blob 、tree 、commit外还有一个tag的object,git tag -a时会创建
以上默认是在master分支,分支的信息存储在.git/HEAD下
➜ cat .git/HEAD
ref: refs/heads/master
➜ cat .git/refs/heads/master
9959835d90e1f2a652da437586f6e867581f8a1d
在Git仓库里面,HEAD、分支、普通的Tag可以简单的理解成是一个指针,指向对应commit的SHA1值。
图示如下:
image.png
至此我们知道了Git是怎样储存一个文件的内容、目录结构、commit信息和分支的。其本质上是一个key-value的数据库加上默克尔树形成的有向无环图(DAG)
默克尔树
最下面的叶节点包含存储数据或其哈希值。
非叶子节点(包括中间节点和根节点)都是它的两个孩子节点内容的哈希值。
简单总结
Git 在原有文件版本的基础上重新生成一份新的文件快照,类似于备份。为了效率,如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件。
- 缺点:占用磁盘空间较大
- 优点:版本切换时非常快,因为每个版本都是完整的文件快照,切换版本时直接恢复目标版本的快照即可
- 特点:空间换时间
Git三个分区
image.pngGit有三个分区:工作目录、暂存目录(也叫做索引)和仓库
- 工作目录 ( working directory ):操作系统上的文件,所有代码开发编辑都在这上面完成。
- 索引( index or staging area ):可以理解为一个暂存区域,这里面的代码会在下一次commit被提交到Git仓库。
- Git仓库( git repository ):由Git Object记录着每一次提交的快照,以及链式结构记录的提交变更历史。
上面的四条命令在工作目录、暂存目录(也叫做索引)和仓库之间复制文件。
- git add files 在仓库里面新建了一个blob object,储存了新的文件内容,并且更新了索引指向了新建的blob object
- git commit 给暂存区域生成快照(tree&commit)并提交,parent指向上一个commit,组成一条链记录变更历史,并将对应分支的指针移动到新的节点。
- git reset -- files 用来撤销最后一次git add files,你也可以用git reset 撤销所有暂存区域文件。
- git checkout -- files 把文件从暂存区域复制到工作目录,用来丢弃本地修改。
Git常用命令分析
前文介绍了Git的基本工作原理,接下来会分析一些常用的Git命令。
commit
# 更改一次提交
git commit --amend -m "<new commit message>"
git会使用与当前提交相同的父节点进行一次新提交,旧的提交会被取消。
image.png注意:如果已经push到远程的commit,无法更改
checkout
# 创建分支
git checkout -b branch
# 切换分支
git checkout stable
HEAD标识会移动到那个分支(也就是说,我们“切换”到那个分支了),然后暂存区域和工作目录中的内容会和HEAD对应的提交节点一致。新提交节点中的所有文件都会被复制(到暂存区域和工作目录中);只存在于老的提交节点中的文件会被删除;不属于上述两者的文件会被忽略,不受影响。
reset
reset命令把当前分支指向另一个位置,并且有选择的变动工作目录和索引,也可用来在从历史仓库中复制文件到索引,而不动工作目录。
# 分支指向不变,但是索引会回滚到最后一次提交,并更新工作目录
git reset --hard
# 当前分支指向前三次递交
git reset HEAD~3
# 当前分支指向前三次递交,并更新工作目录
git reset HEAD~3 --hard
image.png
git reset --hard 因为会更新工作目录,会直接将文件删除掉,所以需慎用,没有后悔药可以吃
git clean同样慎用,git clean -df 会删除当前目录下没有被track(没有git add)的文件或文件夹
merge
merge 命令把不同分支合并起来。合并前,索引必须和当前提交相同。如果另一个分支是当前提交的祖父节点,那么合并命令将什么也不做。 另一种情况是如果当前提交是另一个分支的祖父节点,就导致fast-forward合并。指向只是简单的移动,并生成一个新的提交。
image.png
否则就是一次真正的合并。默认把当前提交(ed489 如下所示)和另一个提交(33104)以及他们的共同祖父节点(b325c)进行一次三方合并。结果是先保存当前目录和索引,然后和父节点33104一起做一次新提交。
image.pnggit merge --ff
Git 合并两个分支时,如果顺着一个分支走下去可以到达另一个分支的话,那么 Git 在合并两者时,只会简单地把指针右移,叫做“快进”(fast-forward)不过这种情况如果删除分支,则会丢失merge分支信息。
git merge –squash
把一些不必要commit进行压缩,比如说,你的feature在开发的时候写的commit很乱,那么我们合并的时候不希望把这些历史commit带过来,于是使用–squash进行合并,此时文件已经同合并后一样了,但不移动HEAD,不提交。需要进行一次额外的commit来“总结”一下,然后完成最终的合并。
git merge –no-ff
关闭fast-forward模式,在提交的时候,会创建一个merge的commit信息,然后合并的和master分支
merge其实最终都会将代码合并到master分支,而区别仅仅只是分支上的简洁清晰的问题,使用git merge --no-ff时,再使用reset 的时候,就会发现,会直接回到merge前的commit,防止搅乱commit历史
如果merge过程不顺利,可以dry run 放弃这次merge,执行git merge --abort
cherry pick
git cherry-pick 1ab62
常用又好用的一个命令,cherry-pick命令"复制"一个提交节点并在当前分支做一次完全一样的新提交。
rebase
衍合是合并命令的另一种选择。合并把两个父分支合并进行一次提交,提交历史不是线性的。衍合在当前分支上重演另一个分支的历史,提交历史是线性的。 本质上,这是线性化的自动的 cherry-pick
image.png上面的命令都在topic分支中进行,而不是main分支,在main分支上重演,并且把分支指向新的节点。注意旧提交没有被引用,将被回收。
git rebase期间可能会有多次解决冲突的操作
# resove conflicts
git add .
git rebase --continue
# dry-run 放弃这次rebase
git rebase --abort
git rebase合并commit
# 合并提交,从HEAD版本开始往过去number个版本
# 提交的范围
git rebase -i/--interactive HEAD~<number of commits>
# 该hash之后的所有提交
git rebase -i/--interactive <commit hash>
# 执行完之后会弹出vim
# 将要合并的commit的pick改为squash或者s,之后保存并关闭文本编辑窗口
git add .
git rebase --continue
在配置的编辑器中倒序列出所有的 commit,像这样:
# <command> <commit hash> <commit message>
pick 5df8fbc commit message
pick ca5154e commit message
pick a104aff commit message
# p, pick = 使用提交而不更改
# r, reword = 修改提交信息
# e, edit = 编辑提交
# s, squash = 汇合提交
# f, fixup = 类似"squash",但是会丢弃提交信息
# x, exec = 运行命令 (其余行)
# d, drop = 移除提交
保存编辑器后,git 将运行该方案以重写历史记录。
e, edit会暂停 rebase,就可以编辑代码仓库的当前状态。完成编辑后,运行
git add .
git rebase --continue。
# 如果过程中出现问题(例如合并冲突),我们需要重新开始,可以使用
git rebase --abort
git pull --rebase也是一种rebase
rebase的好处是commit log更清晰直观,缺点是本地的分叉提交已经被修改过了,merge会有分叉
rebase的目的是使得我们在查看历史提交的变化时更容易,因为分叉的提交需要三方对比。
git实用技巧
配置
# 全局设置
git config --global <keypath> <value>
# 本地设置
git config <keypath> <value>
# 显示当前设置及其来源
git config --list --show-origin
# 设定身份
git config --global user.name "<your name>"
git config --global user.email <your email>
# 首选编辑器
git config --global core.editor vim
TBD
基于主干的开发(TBD)通常是最好的分支模型,最适合连续交付工作流。
git checkout origin/release # 切换到release分支
git checkout -b feat_branch # 创建你自己的分支
git branch -u origin/release # 追溯origin/release
注意使用kdev,如果有多个CR要核准,需要另外创建一个分支去同步开发,比如一个本地分支下的有两个CR,一个核准一个未核准,无法 kdev land
切换远程分支
# To update the remote url in your local repository run (for ssh):
git remote set-url origin git@git.corp.kuaishou.com:exploration-client/overseaads.git
# or for http(s):
git remote set-url origin https://git.corp.kuaishou.com/exploration-client/overseaads.git
查找Commits或更改
# 通过commit信息查找(所有分支)
git log --all --grep='<search term>'
# 通过commit信息查找(包含reflog)
git log -g --grep='<search term>'
# 通过更新的内容查找
git log -S '<search term>'
# 通过日期范围查找
git log --after='DEC 15 2019' --until='JAN 10 2020'
stash
stash 将当前的更改临时搁置起来。可以返回当前状态的索引,并能在稍后应用已储藏的更改。
# 创建新的STASH
git stash
# 创建新的STASH (包含未追踪的更改)
git stash -u/--include-untracked
# 创建新的STASH并命名
git stash save "<stash name>"
# 列出所有的STASH
git stash list
# 浏览STASH内容
git stash show
# 浏览STASH差异
git stash show -p
# 应用上一个STASH (删除stash)
git stash pop
# 应用上一个STASH (保留stash)
git stash apply
# 应用特定的STASH (n = stash列表序号)
git stash pop/apply stash@{n}
# 从STASH创建新的分支 (n = stash列表序号)
git stash branch <new branch name> stash@{n}
# 删除特定的STASH (n = stash列表序号)
git stash drop stash@{n}
# 删除所有的STASH
git stash clear
diff
diff常用来查看两次提交之间的变动
image.png# 不加参数即默认比较工作区与暂存区
git diff
# 比较暂存区与最新本地版本库(本地库中最近一次commit的内容)
git diff --cached [<path>...]
# 比较工作区与最新本地版本库,如果HEAD指向的是master分支,那么HEAD还可以换成master
git diff HEAD [<path>...]
# 比较工作区与指定commit-id的差异
git diff commit-id [<path>...]
# 比较暂存区与指定commit-id的差异
git diff --cached [<commit-id>] [<path>...]
# 比较两个commit-id之间的差异
git diff [<commit-id>] [<commit-id>]
使用git diff打补丁
# 将暂存区与版本库的差异做成补丁,在当前目录下生成diff.patch文件
git diff --cached > diff.patch
# 将工作区与版本库的差异做成补丁
git diff --HEAD > diff.patch
# 将单个文件做成一个单独的补丁
git diff file > diff.patch
# 应用补丁
git apply diff.patch
.gitignore
在项目中一般都会添加 .gitignore 文件,表示git要忽略的文件,比如build文件夹的下文件;但有时会发现,规则不生效。 原因是 .gitignore 只能忽略那些原来没有被track的文件,如果某些文件已经被纳入了版本管理中,则修改.gitignore是无效的。
解决方式:把本地缓存删除(改变成未track状态),然后再提交
git rm -r --cached .
git add .
git commit -m 'update .gitignore'
常用Git命令总结
- git config --global user.name "你的名字" 让你全部的Git仓库绑定你的名字
- git config --global user.email "你的邮箱" 让你全部的Git仓库绑定你的邮箱
- git init 初始化你的仓库
- git add . 把工作区的文件全部提交到暂存区
- git add ./<file>/ 把工作区的<file>文件提交到暂存区
- git commit -m "xxx" 把暂存区的所有文件提交到仓库区,暂存区空空荡荡
- git remote add origin https://git.corp.kuaishou.com/exploration-client/overseaads.git 把本地仓库与远程仓库连接起来
- git push -u origin master 把仓库区的主分支master提交到远程仓库里
- git push -u origin <其他分支> 把其他分支提交到远程仓库
- git status查看当前仓库的状态
- git diff 查看文件修改的具体内容
- git log 显示从最近到最远的提交历史
- git clone + 仓库地址下载克隆文件
- git reset --hard + 版本号 回溯版本,版本号在commit的时候与master跟随在一起
- git reflog 显示命令历史
- git checkout -- <file> 撤销命令,用版本库里的文件替换掉工作区的文件。我觉得就像是Git世界的ctrl + z
- git rm 删除版本库的文件
- git branch 查看当前所有分支
- git branch <分支名字> 创建分支
- git checkout <分支名字> 切换到分支
- git merge <分支名字> 合并分支
- git branch -d <分支名字> 删除分支,有可能会删除失败,因为Git会保护没有被合并的分支
- git branch -D + <分支名字> 强行删除,丢弃没被合并的分支
- git log --graph 查看分支合并图
- git merge --no-ff <分支名字> 合并分支的时候禁用Fast forward模式,因为这个模式会丢失分支历史信息
- git stash 当有其他任务插进来时,把当前工作现场“存储”起来,以后恢复后继续工作
- git stash list 查看你刚刚“存放”起来的工作去哪里了
- git stash apply 恢复却不删除stash内容
- git stash drop 删除stash内容
- git stash pop 恢复的同时把stash内容也删了
- git remote 查看远程库的信息,会显示origin,远程仓库默认名称为origin
- git remote -v 显示更详细的信息
- git pull 把最新的提交从远程仓库中抓取下来,在本地合并,和git push相反
- git rebase 把分叉的提交历史“整理”成一条直线,看上去更直观
- git tag 查看所有标签,可以知道历史版本的tag
- git tag <name> 打标签,默认为HEAD。比如git tag v1.0
- git tag <tagName> <版本号> 把版本号打上标签,版本号就是commit时,跟在旁边的一串字母数字
- git show <tagName> 查看标签信息
- git tag -a <tagName> -m "<说明>" 创建带说明的标签。-a指定标签名,-m指定说明文字
- git tag -d <tagName> 删除标签
- git push origin <tagname> 推送某个标签到远程
- git push origin --tags 一次性推送全部尚未推送到远程的本地标签
- git push origin :refs/tags/<tagname> 删除远程标签<tagname>
- git config --global color.ui true 让Git显示颜色,会让命令输出看起来更醒目
- git add -f <file> 强制提交已忽略的的文件
- git check-ignore -v <file> 检查为什么Git会忽略该文件