高级技巧
下面介绍几个 Git 中非常强大的命令,借助这些命令我们可以完成一些非常有用的操作。
git reflog
Git 对于本地仓库的操作都进行了日志记录,日志文件存储在目录.git/logs
:
$ tree .git/logs
.git/logs
├── HEAD # HEAD 指针变更记录
└── refs
├── heads # 本地分支记录
│ ├── master
├── remotes # 远程分支记录
│ └── origin
│ └── HEAD
│ └── master
└── stash # stash 记录
从 Git 的日志目录中可以看出,Git 主要对一些引用文件进行了日志记录,包括HEAD
指针、分支和储藏更改记录。当本地更改了这些指针指向时,该操作就会被记录到对应的日志文件中。比如,切换分支会导致HEAD
指针指向变化,则该操作会被记录到日志文件.git/logs/HEAD
中,比如,master
分支添加或删除commit
时会同时导致master
指针和HEAD
指针指向变化,则该操作会同时被记录到.git/logs/refs/heads/master
和.git/logs/HEAD
日志文件中...
由于日志记录了所有指针、分支和储藏的变更操作,因此,本地仓库的所有提交都不会丢失,可随时从这些日志文件中索取得回。比如,假设我们在当前分支指向了回退操作,那么当前分支的日志记录git log
会丢失被回退的那些提交,如果想找回这些提交,搜索该分支日志文件即可。当然,实际操作中,我们无需手动查询这些日志文件(虽然这些日志文件都是文本文件),因为 Git 提供了相关命令可以让我们直接查询相应日志记录,该命令为git reflog
,其具体语法如下所示:
# 查询日志
git reflog [show] [<ref>]
# 删除过期日志
git reflog expire [<ref>]
# 删除日志条目
git reflog delete ref@{specifier}
# 检测引用是否存在日志文件
git reflog exists <ref>
大多数情况下我们都是使用git reflog [show]
来查看日志记录,因此下面我们具体介绍常用的一些查询操作:
-
查询
HEAD
指针变更操作:查询HEAD
指针变更日志,如下所示:# 日志呈栈结构,即最近的操作显示在前(栈顶),初始操作显示在最后面(栈底) $ git reflog show HEAD 5279645 (HEAD -> master, dev) HEAD@{0}: checkout: moving from dev to master 5279645 (HEAD -> master, dev) HEAD@{1}: reset: moving to HEAD db8348b HEAD@{4}: commit: feat: 222 5279645 (HEAD -> master, dev) HEAD@{5}: commit (initial): feat: 111
注:由于查询
HEAD
指针变更日志是最常使用的操作,因此,默认情况下,执行不带参数的git reflog
就相当于执行git reflog show HEAD
。 -
查询分支提交变更:分支增加提交或回退提交等操作都会被记录到日志中,因此我们查询分支的变更记录:
# 查询 master 指针变更记录 $ git reflog show master 5279645 (HEAD -> master, dev) master@{0}: reset: moving to HEAD~1 # 回退操作 db8348b master@{1}: commit: feat: 222 # 增加提交 5279645 (HEAD -> master, dev) master@{2}: commit (initial): feat: 111 # 初始提交
-
查询储藏变更操作:当我们执行
git stash
命令时,stash
指针会被更改,因此这些操作也都会被记录到日志中。查询储藏变更命令如下:$ git reflog show stash 0ba206e (refs/stash) stash@{0}: WIP on master: 5279645 feat: 111 54044f5 stash@{1}: WIP on dev: 5279645 feat: 111
-
日志时间过滤:日志文件每条记录都带有一个时间戳,因此我们可以对日志记录进行时间过滤,只显示某个时间范围内的记录。
Git 提供了一些时间标识符方便我们进行时间过滤,常见的时间标识符如下表所示:
时间标识符 含义 1.minute.ago
1分钟之前 1.hour.ago
1小时之前 yesterday
昨天 1.week.ago
一周之前 1.month.ago
一月之前 1.year.ago
1小时之前 2020-05-18.09:00:00
2020-05-18 09:00:00 注:时间标识符也支持复数形式,比如:
5.hours.ago
表示 5 小时之前。
注:时间标识符支持联合使用,比如:1.day.2.hours.ago
表示 1 天 2 小时之前。举个例子:比如查询
master
分支 1 小时 17 分钟之前的操作:$ git reflog master@{1.hour.17.minutes.ago} 5279645 (HEAD -> master, dev) master@{Sat Jan 9 23:18:05 2021 +0800}: commit (initial): feat: 111
最后,日志文件只存在于本地仓库中,不会上传到远程仓库,并且,日志条目默认只有 90 天有效期限,过期条目可能会被自动删除(因为某些命令会触发git gc
操作),也可以通过手动执行git gc
或git reflog expire
删除过期条目。如果想更改日志条目有效期限,可以设置gc.reflogExpire
配置或执行git reflog expire --expire=<time>
手动指定时间。比如,下面的命令将删除 1 分钟之前的所有日志条目:
# --all 表示清除所有日志,也可以指定清除特定日志文件,比如 HEAD、master...
$ git reflog expire --expire=1.minute.ago --all
git rebase
git rebase
是 Git 提供的一个具备非常强大功能的命令,rebase
中文翻译为『变基』,见名知意,即git rebase
可以更改基准点,比如,一个分支从另一个分支某个提交上创建,使用git rebase
可以更改该分支基准点,使新分支从另一个提交上创建延伸,扩展来说,git rebase
不仅仅可以用于修改分支基准点,它还具备修改分支提交历史记录,比如删除、合并、更换提交...
git rebase
的具体语法如下所示:
git rebase [-i | --interactive] [<options>] [--exec <cmd>] [--onto <newbase> | --keep-base] [<upstream> [<branch>]]
git rebase [-i | --interactive] [<options>] [--exec <cmd>] [--onto <newbase>] --root [<branch>]
git rebase (--continue | --skip | --abort | --quit | --edit-todo | --show-current-patch)
上述命令可以简化为如下格式:
# 将 topic_branch 变基到 base_branch 的最新提交,
# 当未指定 topic_branch 时,则默认变基当前分支
git rebase [base_branch] [topic_branch]
# 交互式变基
git rebase -i [branch]
# 变基(继续 | 跳过 | 停止 | 退出 | 继续编辑 | 显示当前差异包)
git rebase (--continue | --skip | --abort | --quit | --edit-todo | --show-current-patch)
下面我们主要对git rebase
的两个主要功能进行讲解:
-
分支合并:可以使用
rebase
模式进行分支合并。默认情况下,Git 使用
merge
模式进行分支合并,该方式会基于当前合并的两个分支最新提交以及两者之间的公共提交做一个简单三路合并,生成一个合并提交点,这种情况下,当我们查看历史记录时,会看到有向无环图存在,且各分支结点以提交时间顺序进行排列。比如对于如下示意图:如果我们想
master
分支合并dev
分支,执行如下命令:$ git switch master Switched to branch 'master' $ git merge dev -m 'C5: merge branch dev' Merge made by the 'recursive' strategy. 3.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 3.txt $ git log --format='%h - %cd : %s' --date=format:'%Y-%m-%d %H:%M:%S' --graph * 1069fb9 - 2021-01-11 01:10:01 : C5: merge branch dev |\ | * 7ee751d - 2021-01-11 01:09:28 : dev: C3 * | 3c29c30 - 2021-01-11 01:09:50 : master: C4 |/ * 44b275c - 2021-01-11 01:08:47 : master: C2 * 5454078 - 2021-01-11 01:08:29 : master: C1
注:如果我们使用
--graph
进行查看,可以看到,不同分支之间的提交不一定按时间顺序进行排列(比如上述代码中C3
早于C4
,但却排在C4
后面),这是因为分支未打平,所以显示效果有点异常,如果不使用--graph
,则可以以时间顺序正常排列各个提交。此时的示意图如下所示:
以上就是
merge
模式合并结果,接下来我们来看下rebase
模式合并分支效果:-
首先现在我们将仓库回退到未合并之前的状态:
$ git reset --hard HEAD~1 HEAD is now at 3c29c30 master: C4
此时仓库的示意图如下所示:
-
然后将
dev
变基,将基准点移动到master
分支最新提交点上:$ git switch dev Switched to branch 'dev' # 变基前的分支提交记录 $ git log --format='%h - %cd : %s' --date=format:'%Y-%m-%d %H:%M:%S' 7ee751d - 2021-01-11 01:09:28 : dev: C3 44b275c - 2021-01-11 01:08:47 : master: C2 5454078 - 2021-01-11 01:08:29 : master: C1 # 变基 $ git rebase master Successfully rebased and updated refs/heads/dev. # 变基后的分支提交记录 $ git log --format='%h - %cd : %s' --date=format:'%Y-%m-%d %H:%M:%S' ca22a79 - 2021-01-11 01:12:55 : dev: C3 # 注意 C3 的哈希值被更改了 3c29c30 - 2021-01-11 01:09:50 : master: C4 44b275c - 2021-01-11 01:08:47 : master: C2 5454078 - 2021-01-11 01:08:29 : master: C1
注:
git rebase
时可能会存在冲突,此时解决完冲突后,只需执行git add .
,然后执行git rebase --continue
继续变基过程即可。通过查看变基前和变基后的
dev
分支提交历史,我们可以看到,变基前,dev
分支是基于C2
提交点的,而变基后,dev
分支是基于C4
提交点的,也就是git rebase master
会将dev
分支基准点移动到master
分支最新提交处。此时仓库的示意图如下所示:
这里简单介绍下
git rebase
的实现原理:以本例子进行阐述,当执行git rebase master
时,Git 首先会找到这两个分支(即dev
和master
分支)的最近共同祖先提交C2
,然后将当前分支(即dev
分支)超前共同祖先C2
的所有提交一一打散并提取出相应的修改保存为临时patch
文件,文件存储在.git/rebase
目录下;然后将当前分支指向master
分支最新提交点C4
上;最后,依序将patch
文件应用到dev
分支上,这个步骤相当于重新播放之前dev
分支的各个提交点进行的修改操作,这样就保证了生成新的提交的内容是一致的(假设不存在冲突)。 -
此时
master
分支就可以合并dev
分支:# 切换到 master 分支 $ git switch master Switched to branch 'master' # 合并 dev 分支 $ git merge dev -m 'C5: merge branch dev with rebase' Updating 3c29c30..ca22a79 Fast-forward (no commit created; -m option ignored) # 采用 Fast-forward 模式合并 3.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 3.txt # 查看提交历史,没有分支合并信息 $ git log --oneline --graph * ca22a79 (HEAD -> master, dev) dev: C3 * 3c29c30 master: C4 * 44b275c master: C2 * 5454078 master: C1
由于变基后,
dev
分支和master
分支位于同一条时间线上,因此,git merge
默认采用Fast-forward
模式合并分支,这样分支合并信息就被消除了,提交历史记录呈现一条线,非常简洁。注:前面我们说过,尽量禁用
Fast-forward
模式,以保留合并分支信息,而rebase
模式却是打平分支,消除分支合并信息,与我们的建议截然相反。其实,分支合并需要依据具体场景进行选择,当我们在本地进行开发时,我们最好保留自己的分支合并信息,而在协同工作时,如果我们直接git pull origin master
,master
分支就会前进(假设拉取到新提交),这样当我们合并分支到master
时,其他人的提交就交织到我们的分支中,默认以merge
模式生成一个合并提交,通常来说,我们不希望合并其他人的提交,因为这样会污染我们的分支,并且随着合并次数增多,提交历史会非常混乱,在这种情况下,采用rebase
模式进行分支合并就是一个不错的选择。具体来说,
rebase
模式常用于协同开发,常见的场景有如下两种:变基本地私有分支:当我们基于
master
分支创建一个私有分支,并做了一些提交后,此时通常会先执行git pull origin master
拉取新提交,这样本地master
分支就前进了,然后对私有分支执行git rebase master
,将私有分支变基到master
分支最新提交点处,最后再合并到master
分支,这样就能消除其他人的提交对我们分支的污染,提交记录呈一条线,没有分叉,并且在一个区间内都是我们自己的提交,不会在中间夹杂其他人的提交,这样历史记录就非常清晰。-
变基追踪分支:当我们对本地追踪分支修改并进行了多个提交后,如果要这些提交上传到远程仓库,首先都会
git pull
拉取远程仓库更新,但这样做远程仓库该分支的新提交(假设成功拉取到新提交)需要与我们的提交进行一个merge
操作,如下图所示:当我们执行
git pull
时,上述示意图中,本地追踪分支dev
就会与远程分支origin/dev
进行一个分支合并,生成一个新的合并提交,如下图所示:我们并不想让自己的提交与其他的提交交织在一起,因为这样相当于自己的提交被污染了,因此,对于追踪分支的上传,推荐使用
git pull --rebase
方式拉取更新,这种方式相当于执行git fetch && git rebase origin/dev
,Git 会将我们的提交变基到远程分支origin/dev
的最新提交点,如下图所示:这样,所有人的提交都不会互相污染,分支提交历史呈一条线展示,且每个人的多个提交都集中在一个连续区间内,方便查阅。
注:变基追踪分支指的是更改本地已提交但未上传到服务器的提交,千万不要变基已存在于服务器上的分支提交,因为这相当于修改了远程仓库分支历史,会导致协同开发产生问题。
注:如果项目比较大,协同开发的人比较多,这种情况下,可能每天远程分支都有很多次新提交,此时如果使用变基追踪分支,可能存在过多冲突,解决这些冲突会非常耗时耗力,这种情况下,也许直接合并会更加简单。
-
-
交互式变基:交互式变基主要的作用就是用于更改分支提交历史记录。其语法如下所示:
# 编辑 commit 之后的所有提交(不包含 commit) git rebase { -i | --interactive } <commit>
交互式变基提供了一系列操作可以让我们修改分支提交历史记录,具体操作如下表所示:
操作 描述 pick
保留该提交 reword
修改该提交信息 edit
编辑该提交 squash
将该提交合并到前一个提交中(即合并到当前提交的上一个旧提交中) fixup
与 squash
作用一致,但直接丢弃该提交信息exec
(该行剩余部分)使用 shell 运行命令 break
到该提交处暂停变基(后续使用 git rebase --contine
从此处继续开始变基)drop
移除该提交 label
为当前 HEAD
打一个标记reset
重置 HEAD
到该标记merge
合并提交 git rebase -i <commit>
需要指定一个提交commit
,然后会弹出vi
编辑模式以提交时间顺序展示该commit
之后的所有提交(即展示比该commit
新的提交),比如,倒数第三个提交可以使用HEAD~2
表示,则命令git rebase -i HEAD~2
会展示最新的两个提交,不包含HEAD~2
。在编辑模式中,可以移动提交行来更改提交顺序,也可以删除某个提交行,这样改提交就会从提交历史中进行移除(但是不支持删除全部提交,此时 Git 会自动停止变基)。下面列举一些常用的分支历史记录修改操作:
-
reword
:该操作可以修改提交信息。举个例子:比如本地仓库存在如下提交记录:
$ git log --oneline a75e892 (HEAD -> master) C3 8837671 c2 c830b70 C1
可以看到,
8837671
提交的信息使用了小写字母,如果我们希望将其修改为大写,则可以如下进行操作:# 首先启动交互式变基,展示前两条提交 $ git rebase -i HEAD~2
上面命令执行完后,会弹出
vi
编辑模式,其内容如下所示:pick 8837671 c2 pick a75e892 C3
此时,我们将
c2
对应的提交8837671
修改为reword
,然后保存退出:reword 8837671 c2 # 修改提交信息 pick a75e892 C3 # 保留该提交
此时,另一个
vi
编辑模式窗口会自动弹出,我们可在此修改提交信息,此处我们将c2
修改为C2
,保存并退出。到此,我们就成功修改了
8837671
的提交信息,如下所示:$ git log --oneline 2de4c14 (HEAD -> master) C3 35e3b4c C2 # 已成功修改 c830b70 C1
-
squash
:该操作可以压缩提交,也即将多个提交压缩到前一个提交中。举个例子:比如对于本地仓库,其提交历史记录如下所示:
$ git log --oneline 7b8e084 (HEAD -> master) C4 2de4c14 C3 35e3b4c C2 c830b70 C1
假设现在我们想将
C2
和C3
压缩成一个提交,此时可以如下操作:# 启动交互式变基,展示 C1 之后的所有提交 $ git rebase -i c380b70
此时会弹出编辑窗口,我们将
C3
设置为squash
,表示它会压缩到C2
中:pick 35e3b4c C2 # 保留 C2 squash 2de4c14 C3 # 压缩 C3(压缩到上一个提交,即 C2) pick 7b8e084 C4 # 保留 C4
此时会弹出另一个窗口,该窗口同时包含
C2
和C3
的提交信息,我们可以手动编辑压缩合并生成的新提交信息:# 默认采用上一个提交信息,这里我将其修改为如下 squash C2 and C3
此时,我们就完成了
C2
和C3
的合并,效果如下所示:$ git log --oneline cfed515 (HEAD -> master) C4 fb5e7f3 squash C2 and C3 # 合并成功 c830b70 C1
-
fixup
:该操作也squash
作用一样,也是用于压缩提交。但与squash
不同的是,该操作会丢弃被压缩提交(即被fixup
标注的提交)的提交信息。举个例子:比如我们还是压缩
C2
和C3
,但是直接将C3
压缩到C2
:# 仓库初始状态 $ git log --oneline 3ba4ca0 (HEAD -> master) C4 31b3716 C3 b32f587 C2 c830b70 C1 # 启动变基 $ git rebase -i HEAD~3
此时将
C3
标注为fixup
,表示将C3
直接压入到上一个提交(即C2
)中:pick b32f587 C2 # 保留 fixup 31b3716 C3 # 压入到上一个 pick 3ba4ca0 C4 # 保留
由于是直接压缩,因此直接就返回了,不会像
squash
需要再弹出一个窗口合并提交信息,此时的效果如下所示:$ git log --oneline 1a32d3c (HEAD -> master) C4 fd46dfc C2 c830b70 C1
-
edit
:该操作可以用于编辑提交。该操作具体的执行逻辑为:当提交被标注为
edit
时,Git 会自动变基到该提交上,然后我们就可以编辑该提交,比如增加、删除一些内容,然后使用git comit --amend
修改此次提交,也可以在该提交上增添新提交,这样就相当于在之前的提交历史记录中插入一些新提交...举个例子:除了重置、增加提交外,
edit
操作也常常用来将提交切分为多个小提交,其实就是重置+增加提交的操作,比如,前面我们将仓库提交C2
和C3
压缩到一起,如下所示:$ git log --oneline cfed515 (HEAD -> master) C4 fb5e7f3 squash C2 and C3 c830b70 C1
现在如果我们想重新分离开
C2
和C3
,则可以借助edit
操作,如下所示:# 启动变基 $ git rebase -i HEAD~2
我们将
fb5e7f3
标注为edit
:edit fb5e7f3 squash C2 and C3 pick cfed515 C4
保存退出编辑窗口后,Git 会自动变基到
fb5e7f3
中:$ git show HEAD --oneline -s fb5e7f3 (HEAD) squash C2 and C3
我们将该提交重新拆分为两个小提交
C2
和C3
:# 暂存区移除 C3 内容 $ git rm --cached 3.txt rm '3.txt' # 拆分出 C2 内容并重新提交 $ git commit --amend -m 'C2' [detached HEAD b32f587] C2 Date: Tue Jan 12 11:37:16 2021 +0800 1 file changed, 1 insertion(+) create mode 100644 2.txt # 添加 C3 内容 $ git add 3.txt # 提交 C3 内容 $ git commit -m 'C3' [detached HEAD 31b3716] C3 1 file changed, 1 insertion(+) create mode 100644 3.txt # 拆分完成后,继续变基过程 $ git rebase --continue Successfully rebased and updated refs/heads/master.
此时,变基结束,我们成功将一个大提交拆分为多个小提交,如下所示:
$ git log --oneline 3ba4ca0 (HEAD -> master) C4 31b3716 C3 # 拆分 b32f587 C2 # 拆分 c830b70 C1
-
drop
:如果要删除某个提交,只需将该提交标注为drop
,或者直接在编辑窗口中删除该提交即可。举个例子:假设我们当前仓库本地分支提交历史记录如下所示:
$ git log --oneline 45fcf79 (HEAD -> master) C3 6992e50 C2 c830b70 C1
假设现在我们想要删除
C2
和C3
这两个提交,其步骤如下所示:# 启动交互式变基 $ git rebase -i HEAD~2
此时弹出的编辑窗口内容如下:
pick 6992e50 C2 pick 45fcf79 C3
这里我们将
C2
标注为drop
,然后直接删除C3
,同时验证这两种删除方法:drop 6992e50 C2
保存并退出编辑窗口,此时查看仓库提交历史记录:
$ git log --oneline c830b70 (HEAD -> master) C1
可以看到,我们已经成功删除了
C2
和C3
。 -
更换提交顺序:更换提交顺序只需在编辑窗口中直接更换提交的顺序即可。
举个例子:比如我们当前本地仓库提交历史记录如下所示:
$ git log --oneline 45fcf79 (HEAD -> master) C3 6992e50 C2 c830b70 C1
假设现在我们想更换
C2
和C3
的顺序,其步骤如下:# 启动交互式变基 $ git rebase -i HEAD~2
此时的编辑窗口如下所示:
pick 6992e50 C2 pick 45fcf79 C3
要更换
C2
和C3
提交顺序,只需在编辑窗口中更换两者顺序即可:pick 45fcf79 C3 pick 6992e50 C2
如此我们就已经完成提交顺序更换,此时的提交历史记录如下所示:
$ git log --oneline 0486bd0 (HEAD -> master) C2 afa4a24 C3 c830b70 C1
-
git cherypick
在多分支工作流中,当我们需要获取另一个分支的所有变动时,通常采用的都是分支合并(git merge
)策略,但是如果我们只对分支的一个或某几个提交感兴趣,那么也可以只摘取这几个提交,将他们各自的修改一一应用到我们当前分支上,Git 中,具备提交摘取的命令为git cherry-pick
,其具体语法如下所示:
# 支持摘取多个 commit
git cherry-pick [<options>] <commit-ish>...
git cherry-pick (--continue | --skip | --abort | --quit)
git cherry-pick
的本质是摘取提交,将其修改应用到当前分支上。
git cherry-pick
支持摘取一个或多个提交,每一个提交应用到当前分支,都会生成一个新的提交,该提价的修改完全与摘取的提交一致。其实git cherry-pick
就是将摘取的提交在当前分支上进行重复播放。
git cherry-pick
常用的命令选项有如下:
-n, --no-commit
:应用摘取提交时,只进行更新,但不提交。
注:默认情况下,git cherry-pick
在应用摘取提交完成时,会自动进行提交,生成一个新提交。-e, --edit
:如果想更改提交信息,可以添加-e, --edit
。
注:默认情况下,git cherry-pick
直接将摘取的提交信息作为新生成提交的提交信息。-m parent-number, --mainline parent-number
:当摘取的提交是一个合并提交时,此时git cherry-pick
无法区分应当使用哪个分支上进行的修改,因此默认失败处理。此时必须指定一个parent-number
,表示要摘取的变动分支。parent-number
取值由1
开始,具体查找方法可参考:高级技巧 - 查看合并提交的parent-number
举个例子:假设本地仓库存在master
和dev
分支,现在突然发现线上版本出现漏洞,因此紧急从master
分支上创建一个hotfix/add_file
分支,然后做了两个提交,如下图所示:
简单起见,每个提交都只是增加了相应数字的文件。
当漏洞修改完成后,就需要将hotfix/add_file
分支合并到master
分支中:
$ git switch master
Switched to branch 'master'
# 合并 hotfix 分支
$ git merge --no-ff hotfix/add_file -m 'fix: C6 => merge branch hotfix/add_file'
Merge made by the 'recursive' strategy.
4.txt | 1 +
5.txt | 1 +
2 files changed, 2 insertions(+)
create mode 100644 4.txt
create mode 100644 5.txt
此时的示意图如下所示:
同样的,hotfix/add_file
分支上的修改也要合并到dev
分支上,此时,dev
分支可以git cherry-pick
或直接合并hotfix/add_file
分支,也可以git cherry-pick
主分支master
上的合并提交C6
,下面对这两种方法分别进行讲解:
-
git cherry-pick
摘取hotfix/add_file
分支所有提交:这里我们不采用合并方式,而是将hotfix/add_file
分支的所有提交,即C4
和C5
直接摘取到dev
分支中:# 切换到 dev 分支 $ git switch dev Switched to branch 'dev' # 查看 hotfix/add_file 分支所有提交 $ git log --oneline hotfix/add_file 35a10e5 (hotfix/add_file) fix: C5 # 目标提交 f79b3b1 fix: C4 # 目标提交 d0b972f feat: C2 537ba3c feat: C1 # cherry-pick C4 和 C5 $ git cherry-pick f79b3b1 35a10e5 [dev 8bf6c28] fix: C4 # 应用 C4 Date: Tue Jan 12 21:40:29 2021 +0800 1 file changed, 1 insertion(+) create mode 100644 4.txt [dev 6e3d3ef] fix: C5 # 应用 C5 Date: Tue Jan 12 21:40:46 2021 +0800 1 file changed, 1 insertion(+) create mode 100644 5.txt # 合并成功 $ git log --oneline 6e3d3ef (HEAD -> dev) fix: C5 8bf6c28 fix: C4 12b54a1 feat: C3 d0b972f feat: C2 537ba3c feat: C1
从提交历史中,我们已经可以看到成功摘取
C4
和C5
到dev
分支上了,此时的示意图如下所示: -
cherry-pick
合并提交:第二种方法是摘取合并提交,即摘取C6
应用到dev
分支上。具体步骤如下:-
首先我们将
dev
分支重置到C3
提交:# 回退到 C3 $ git reset --hard 12b54a1 HEAD is now at 12b54a1 feat: C3 # 回退成功 $ git log --oneline 12b54a1 (HEAD -> dev) feat: C3 d0b972f feat: C2 537ba3c feat: C1
此时的仓库示意图如下所示:
-
此时
dev
分支可以摘取C6
,需要注意的是,由于C6
是一个合并提交,因此需要指定摘取分支,对于C6
而言,其是由master
分支合并hotfix/add_file
分支生成的合并提交,这里我们应当选择摘取分支为hotfix/add_file
:# 查找 C6 的哈希值 $ git log --oneline master | grep C6 02d311d fix: C6 => merge branch hotfix/add_file # 查看 C6 的 parent-number $ git cat-file -p 02d311d | grep -i parent parent d0b972f09705aaf330c59be6eedbd69a1e49ccbc # parent-number = 1 parent 35a10e51533ae17c42ecdf3ad9598334cdaeca08 # parent-number = 2 # 比对 C6 和 parent_commit1 的差异,可以看到,parent_commit1 就是 hotfix/add_file, # 因此 parent-number = 1 $ git diff --stat d0b972f 02d311d 4.txt | 1 + 5.txt | 1 + 2 files changed, 2 insertions(+) # 比对 C6 和 parent_commit2 的差异(此步可忽略,因为上一步已找出 parent-number) $ git diff --stat 35a10e5 02d311d # 摘取 parent-number = 1 的提交 $ git cherry-pick -m 1 02d311d [dev fdfc2c9] fix: C6 => merge branch hotfix/add_file Date: Tue Jan 12 21:55:29 2021 +0800 2 files changed, 2 insertions(+) create mode 100644 4.txt create mode 100644 5.txt # 查看摘取合并结果 $ git log --oneline fdfc2c9 (HEAD -> dev) fix: C6 => merge branch hotfix/add_file 12b54a1 feat: C3 d0b972f feat: C2 537ba3c feat: C1
可以看到,我们成功将
C6
的修改应用到了dev
分支,此时的示意图如下所示:注:可以看到,
git cherry-pick
合并提交的操作还是相对麻烦的,建议尽量避免对合并提交进行摘取。
-
git bisect
git bisect
命令可以让我们很方便快速查找到出现 bug 的提交,它的原理是对给定范围的提交进行二分查找,由用户判断当前提交是否存在 bug,依次迭代不断缩小规模进行二分查找,这样我们就可以很快从一个大范围提交区间找到引入 bug 的那个提交。
git bisect
命令的具体语法如下所示:
# 启动二分查找
git bisect start [<paths>...]
# 当前提交存在 bug
git bisect bad [<rev>]
# 当前提价良好(即不存在 bug)
git bisect good [<rev>...]
# 退出二分查找
git bisect reset [<commit>]
git bisect terms [--term-good | --term-bad]
git bisect skip [(<rev>|<range>)...]
git bisect (visualize|view)
git bisect replay <logfile>
git bisect log
git bisect run <cmd>...
git bisect help
可以看到,git bisect
携带了很多的子命令选项,但是通常我们只会使用git bisect { start | good | bad | reset }
这四个子命令来进行二分查找。其中:
-
git bisect start
:该命令会启动二分查找过程。其具体语法如下所示:git bisect start [end_point] [start_point]
其中,
end_point
表示最近的提交,start_point
表示最早之前的提交,如果两者都指定了,那么二分查找第一个提交就是start_point
和end_point
的中间提交,如果未指定start_point
和end_point
,则进入二分搜索时,还需手动使用git bisect bad <commit>
和git bisect good <commit>
手动指定end_point
和start_point
。 -
git bisect good
:执行该命令会将当前提交设置为良好状态,即表示当前提交不存在 bug。其语法如下所示:git bisect good [<rev>...]
我们也可以在启动二分查找后,手动指定一个提交设置为良好状态(比如
git bisect good 31af8d
,表示提交31af8d
未引入 bug),这样可以人为缩短搜索范围。 -
git bisect bad
:执行该命令会将当前提交设置为出错状态,即表示当前提交存在 bug。其语法如下所示:git bisect bad [<rev>]
我们也可以在启动二分查找后,手动指定一个提交设置为出错状态(比如
git bisect bad 31af8d
,表示提交31af8d
存在 bug),这样可以人为缩短搜索范围。 -
git bisect reset
:该命令会结束二分查找过程。其语法如下所示:git bisect reset [<commit>]
默认情况下,该命令会退出二分查找过程,然后回到先前的提交,即执行
git bisect start
时的提交。如果想退出时回到其他提交,直接在后面添加目标提交<commit>
即可。
举个例子:假设我们当前仓库存在 5 个提交历史,为了方便演示,我们假设某个提交删除了ReadMe.md
文件,现在想找出删除该文件的提交,操作过程如下:
# 所有提交
$ git log --oneline
15c3cdb (HEAD -> master) feat: 555
7b93852 feat: 444
c40d88f feat: 333
b9ce941 docs: add ReadMe.md
58bdc1d feat: 111
# HEAD 58bdc1d 表示对所有提交进行二分查找,这里也可以忽略不写
$ git bisect start HEAD 58bdc1d
Bisecting: 1 revision left to test after this (roughly 1 step)
[c40d88fc907538e7392509c7b221ebf78ae42516] feat: 333 # 表示当前处于 c40d88f,也就第三个提交
# 查看当前提交,可以看到,确实是处于第三个提交
$ git show HEAD --oneline --stat -s
c40d88f (HEAD) feat: 333
# 当前提交存在 ReadMe.md
$ ls
1.txt 3.txt ReadMe.md
# 由于当前提交存在 ReadMe.md,故设置为良好状态
$ git bisect good
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[7b9385209361db20ce6b1d4e1e7c81d999ae84b5] feat: 444 # 此时处于 7b93852,即第四个提交
# 当前提交不存在 ReadMe.md
$ ls
1.txt 3.txt 4.txt
# 由于当前提交不存在 ReadMe.md,故将其设置为 bad 状态
$ git bisect bad
7b9385209361db20ce6b1d4e1e7c81d999ae84b5 is the first bad commit # 这里表示当前提交就是第一个引入 bug 的提交
commit 7b9385209361db20ce6b1d4e1e7c81d999ae84b5
Author: Why8n <Why8n@gmail.com>
Date: Sun Jan 10 18:18:08 2021 +0800
feat: 444
4.txt | 1 +
ReadMe.md | 1 -
2 files changed, 1 insertion(+), 1 deletion(-)
create mode 100644 4.txt
delete mode 100644 ReadMe.md # 删除了文件 ReadMe.md
# 找到引入 bug 的提交后,就可以退出二分查找了
$ git bisect reset
Previous HEAD position was 7b93852 feat: 444
Switched to branch 'master'
# 因为删除了 ReadMe.md 的提交还进行了其他修改,因此这里不能直接使用 git revert
# 但是既然找到了删除 ReadMe.md 的提交,那么我们只需从该提交之前的提交获取 ReadMe.md 文件,进行恢复即可
# 7b93852 提交删除了 ReadMe.md,该提交之前的提交为 c40d88f
$ git log --oneline | grep 7b93852 -A 1
7b93852 feat: 444
c40d88f feat: 333
# 查询 c40d88f 所有文件,获取 ReadMe.md 的对象文件
$ git ls-tree c40d88f
100644 blob 58c9bdf9d017fcd178dc8c073cbfcbb7ff240d6c 1.txt
100644 blob 55bd0ac4c42e46cd751eb7405e12a35e61425550 3.txt
100644 blob c200906efd24ec5e783bee7f23b5d7c941b0c12c ReadMe.md # 目标文件
# 将目标文件内容写到当前工作目录
$ git cat-file -p c20090 > ReadMe.md
以上,就是git bisect
的整个基本操作过程。
查看合并提交的parent-number
前面我们介绍过的git revert
和git cherry-pick
等命令,在遇到合并提交时,都需要指定主线分支,也即parent-number
。Git 似乎并没有直接提供查询parent-number
的命令,因此我们只能手动进行查找。
我们以一个例子来驱动讲解查找合并提交的parent-number
,假设现在我们有一个合并提交02d311d
,可以通过如下命令查看其内容:
# 法一
git show --pretty=raw <merge_commit>
# 法二
git cat-file -p <merge_commit>
比如,查看合并提交02d311d
,结果如下:
$ git show --pretty=raw 02d311d
commit 02d311d5c9bb0989c1285e068fc3c2a4de02b027
tree 03c45eebbff1c1fa9f9152d9800d4e65f4602052
parent d0b972f09705aaf330c59be6eedbd69a1e49ccbc # parent1_commit,其 parent-number = 1
parent 35a10e51533ae17c42ecdf3ad9598334cdaeca08 # parent2_commit,其 parent-number = 2
author Why8n <Why8n@gmail.com> 1610459729 +0800
committer Why8n <Why8n@gmail.com> 1610459729 +0800
fix: C6 => merge branch hotfix/add_file
该命令会显示合并提交的父提交,第一个parent1_commit
的parent-number
就是1
,第二个parent2_commit
的paretn-number
就是2
,依次类推...
然后,我们只需一一比对parent_commit
与合并提交之间的差别,就可以判断得出应当使用哪个parent_commit
了:
git diff --stat <parent_commit> <merge_commit>
更多详细内容,请参考:Git cherry-pick syntax and merge branches
其他
git blame
如果我们想查看文件每一行对应的版本以及最后修改的作者时,则可以使用git blame
命令。其具体语法如下所示:
git blame [<options>] [<rev-opts>] [<rev>] [--] <file>
下面列举几种常用的git blame
操作:
-
指定显示行数:可以通过添加
-L <start>,<end>
选项来指定只显示文件start
到end
之间的行:# 只显示 .gitignnore 文件第 1 到 3 行的内容 $ git blame -L 1,3 .gitignore aa658574bfc (Josh Steadmon 2019-01-15 14:25:50 -0800 1) /fuzz-commit-grap 5e472150800 (Josh Steadmon 2018-10-12 17:58:40 -0700 2) /fuzz_corpora 5e472150800 (Josh Steadmon 2018-10-12 17:58:40 -0700 3) /fuzz-pack-header # 只显示 .gitignnore 文件第 3 行修改信息 $ git blame -L 3,+1 HEAD~1 .gitignore 5e472150800 (Josh Steadmon 2018-10-12 17:58:40 -0700 3) /fuzz-pack-headers
-
显示特定版本的文件修改:
git blame
默认只显示文件最后一个版本的修改(当然文件中每一行内容都可能处于不同的版本中),如果想显示某个提交该文件的信息时,可以指定该提交版本:# 显示 .gitignore 文件倒数第二次提交的第一行修改信息 $ git blame -L 1,+1 HEAD~1 .gitignore aa658574bfc (Josh Steadmon 2019-01-15 14:25:50 -0800 1) /fuzz-commit-graph
git gc
前文说过,Git 本质是一个全量快照的文件系统,因此当我们暂存次数过多时,Git 对象数据库会存储很多对象文件,有些对象文件实际上没有被任何提交对象直接或间接进行引用,这些对象称为『松散对象(loose objects)』。
我们使用命令git gc
来垃圾回收这些松散对象,减小仓库大小。简单来说,当运行git gc
时,Git 会收集所有松散对象并将它们存入一个packfile
文件中,并将多个packfile
文件合并成一个大的packfile
文件,然后移除不被任何提交引用且超过一定期限的对象文件。除此之外,git gc
还会将所有的引用文件(即.git/refs
)打包到另一个单独的文件中。
更多 Git 垃圾回收相关内容,可参考如下文章:
分页器
Git 中几乎所有命令都提供了分页器(Pager)功能,当命令输出超出一页时,会自动启动分页器。
分页器的交互方式并不人性化,可通过如下几种方法进行分页:
-
--no-pager
:手动为 Git 添加--no-pager
选项,可禁止启动分页器:$ git --no-pager log -n 10 --oneline
-
全局配置分页器,使用
less
命令进行翻页:$ git config --global core.pager "less -FRSX"
别名
可以通过git config alias
来为其他命令设置一个简短的别名,方便使用,比如:
$ git config --global alias.br branch
$ git br # ==> 扩展为:git branch
上述命令为branch
设置了一个别名br
,此时使用git br
就相当于使用git branch
。
注:Git 的别名就是一个字符串,使用时会自动扩展为设置的内容,自动拼接到git
指令后面。
Git 也可以为外部命令指定别名,只需在命令前面添加!
即可:
$ git config --global alias.ls '!ls -alrt'
$ git ls # ==> ls -alrt
举个例子:这里我们设置一个别名(命令),来显示本地仓库对象数据库所有对象文件及其类型:
# 由于命令太长,我们选择直接在全局配置文件中进行修改,方便很多
$ git config --global --edit
然后在标签[alias]
下设置如下内容:
[alias]
; 注释:sdo represent show data objects
sdo = "!find .git/objects -type f | awk -F '/' '{ hash=$3$4; cmd = sprintf(\"git cat-file -t %s\", hash); printf(\"%s\t\", hash); system(cmd); }'"
此时使用命令git sdo
就可以显示对象数据库中所有的对象文件及其类型:
# sdo represents show data objects
$ git sdo
0767f3e206a0a431633b2063bbda680026c33f70 commit
10f86d6b803b8962653f16a9967a4578215dcb22 tree
778d49177a4b6da0e967ac3e9308076ad500e6e7 blob
git clean
如果需要移除工作区中未被追踪的文件或文件夹,可以使用git clean
命令,其语法如下所示:
git clean [-d] [-f] [-i] [-n] [-q] [-e <pattern>] [-x | -X] [--] [<paths>]...
其中,常用的选项有:
-n, --dry-run
:表示只显示将要被移除的文件/文件夹,而不进行真正的删除。-d
:表示移除文件和文件夹。
注:默认情况下,为了尽可能减少文件删除,git clean
不会删除未被追踪的文件夹。-f, --force
:表示强制执行删除操作。
注:如果配置了选项clean.requireForce
为false
的话,git clean
默认不进行删除动作,此时可通过添加-f
选项真正执行删除操作。
版本及范围表示法
大多数 Git 命令都会携带一个revision
(修订版本)作为参数,因此 Git 也内置了一些版本及其范围的简便引用方法,大致有如下:
-
版本指定:版本指定可引用一个提交或多个提交:
-
<sha1>
:表示对象文件的哈希字符串。比如:dae86e1950b1277e545cee180551750029cfe735
或dae86e
。 -
<refspec>
:表示符号引用。比如:master
、heads/master
和refs/heads/master
都表示master
分支,比如HEAD
表示HEAD
引用文件... -
@
:一个单独的@
等同于HEAD
。 -
<refspec>@{<date>}
:表示指定时间段的引用。比如:master@{yesterday}
,HEAD@{5 minutes ago}
。 -
<refspec>@{<number>}
:表示引用refspec
之前的第number
个提交。比如:master@{0}
等同于master
,master@{1}
为master
分支第二个最新提交。 -
@{<number>}
:与<refspec>@{<number>}
功能一致,只是refspec
为HEAD
,即表示当前分支的最新第number
个提交。 -
<rev>^[n]
:表示提交rev
的前n
个父提交,n
表示字符^
的重复个数。比如:a95fabe^
表示提交a95fabe
的前一个提交,HEAD^^
表示HEAD
的前第二个提交...
注:n
也可以为数字,但只能为0
和1
。比如a95fabe^0
等同于a95fabe
,HEAD^1
等同于HEAD^
。 -
<rev>~<number>
:表示提交rev
的第number
祖先提交。比如:a95fabe~0
等同于a95fabe
,a95fabe~1
表示a95fabe
的前一个提交,HEAD~5
表示当前分支的最新第 5 个提交。
注:<rev>~<number>
也支持<rev>~<n>
操作,比如:HEAD~
等同于HEAD~1
,HEAD~~
等同于HEAD~2
...
注:该模式也支持<rev>^{tree}
,表示获取提交rev
的树对象。 -
<rev>:<path>
:表示版本rev
下的文件。比如:HEAD:1.txt
表示当前版本下的1.txt
文件内容,a95fabe:1.txt
表示版本a95fabe
下的1.txt
文件内容。
注:该模式可以让我们很方便对不同版本的同一文件进行比对:# HEAD:1.txt 相对于 HEAD~1:1.txt 的文件差异 $ git diff HEAD~1:1.txt HEAD:1.txt
-
-
范围指定:当分支提交历史记录包含多个提交时,可以指定提交范围:
-
^<rev>:表示不包含
rev`的提交。 -
<rev1>..<rev2>
:表示包含rev2
,但是不包含rev1
,即(rev1,rev2]
。 -
<rev1>...<rev2>
:表示同时包含rev1
和rev2
,即[rev1,rev2]
。
-
更多其他版本与版本范围表示法,可参考:Git 版本及版本范围表示法
显示引用哈希值
可以通过git rev-parse
查看引用或对象文件哈希值:
# 显示 master 最新提交哈希值
$ git rev-parse master
378a269ba0c11542ade35eef1df88e094d935548
# 显示 HEAD 树对象简短哈希值
$ git rev-parse --short HEAD^{tree}
5466733
显示所有对象文件
其命令为:
git rev-list [OPTION] <commit-id>... [ -- paths... ]
举个例子:
-
显示最新版本所有的对象文件
$ git rev-list --objects HEAD
-
显示仓库所有对象文件
$ git rev-list --objects --all
附录
本人配置
以下是本人的 Git 配置选项:
# 用户名
git config --global user.name Why8n
# 邮箱
git config --global user.email whyncai@gmail.com
# 默认编辑器
git config --global core.editor nvim
# 解决 git status 中文乱码
git config --global core.quotepath false
# 设置 git gui 界面编码
git config --global gui.encoding utf-8
# 设置 git log 提交内容编码
git config --global i18n.commitencoding utf-8
# 分页器替换
git config --global core.pager "less -FRSX"