Git 学习笔记(CheatSheet)(二)

Git 思维导图

高级技巧

下面介绍几个 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 gcgit 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模式进行分支合并,该方式会基于当前合并的两个分支最新提交以及两者之间的公共提交做一个简单三路合并,生成一个合并提交点,这种情况下,当我们查看历史记录时,会看到有向无环图存在,且各分支结点以提交时间顺序进行排列。比如对于如下示意图:

    separate branch to be merged

    如果我们想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,则可以以时间顺序正常排列各个提交。

    此时的示意图如下所示:

    git merge

    以上就是merge模式合并结果,接下来我们来看下rebase模式合并分支效果:

    1. 首先现在我们将仓库回退到未合并之前的状态:

      $ git reset --hard HEAD~1
      HEAD is now at 3c29c30 master: C4
      

      此时仓库的示意图如下所示:

      separate branch to be rebase
    2. 然后将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的实现原理:以本例子进行阐述,当执行git rebase master时,Git 首先会找到这两个分支(即devmaster分支)的最近共同祖先提交C2,然后将当前分支(即dev分支)超前共同祖先C2的所有提交一一打散并提取出相应的修改保存为临时patch文件,文件存储在.git/rebase目录下;然后将当前分支指向master分支最新提交点C4上;最后,依序将patch文件应用到dev分支上,这个步骤相当于重新播放之前dev分支的各个提交点进行的修改操作,这样就保证了生成新的提交的内容是一致的(假设不存在冲突)。

    3. 此时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 mastermaster分支就会前进(假设拉取到新提交),这样当我们合并分支到master时,其他人的提交就交织到我们的分支中,默认以merge模式生成一个合并提交,通常来说,我们不希望合并其他人的提交,因为这样会污染我们的分支,并且随着合并次数增多,提交历史会非常混乱,在这种情况下,采用rebase模式进行分支合并就是一个不错的选择。

      具体来说,rebase模式常用于协同开发,常见的场景有如下两种:

      • 变基本地私有分支:当我们基于master分支创建一个私有分支,并做了一些提交后,此时通常会先执行git pull origin master拉取新提交,这样本地master分支就前进了,然后对私有分支执行git rebase master,将私有分支变基到master分支最新提交点处,最后再合并到master分支,这样就能消除其他人的提交对我们分支的污染,提交记录呈一条线,没有分叉,并且在一个区间内都是我们自己的提交,不会在中间夹杂其他人的提交,这样历史记录就非常清晰。

      • 变基追踪分支:当我们对本地追踪分支修改并进行了多个提交后,如果要这些提交上传到远程仓库,首先都会git pull拉取远程仓库更新,但这样做远程仓库该分支的新提交(假设成功拉取到新提交)需要与我们的提交进行一个merge操作,如下图所示:

        track branch to be merged

        当我们执行git pull时,上述示意图中,本地追踪分支dev就会与远程分支origin/dev进行一个分支合并,生成一个新的合并提交,如下图所示:

        track branch merged

        我们并不想让自己的提交与其他的提交交织在一起,因为这样相当于自己的提交被污染了,因此,对于追踪分支的上传,推荐使用git pull --rebase方式拉取更新,这种方式相当于执行git fetch && git rebase origin/dev,Git 会将我们的提交变基到远程分支origin/dev的最新提交点,如下图所示:

        track branch rebased

        这样,所有人的提交都不会互相污染,分支提交历史呈一条线展示,且每个人的多个提交都集中在一个连续区间内,方便查阅。

        :变基追踪分支指定是更改本地已提交但未上传到服务器的提交,千万不要变基已存在于服务器上的分支提交,因为这相当于修改了远程仓库分支历史,会导致协同开发产生问题。

        :如果项目比较大,协同开发的人比较多,这种情况下,可能每天远程分支都有很多次新提交,此时如果使用变基追踪分支,可能存在过多冲突,解决这些冲突会非常耗时耗力,这种情况下,也许直接合并会更加简单。

  • 交互式变基:交互式变基主要的作用就是用于更改分支提交历史记录。其语法如下所示:

    # 编辑 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
      

      假设现在我们想将C2C3压缩成一个提交,此时可以如下操作:

      # 启动交互式变基,展示 C1 之后的所有提交
      $ git rebase -i c380b70
      

      此时会弹出编辑窗口,我们将C3设置为squash,表示它会压缩到C2中:

      pick 35e3b4c C2 # 保留 C2
      squash 2de4c14 C3 # 压缩 C3(压缩到上一个提交,即 C2)
      pick 7b8e084 C4   # 保留 C4
      

      此时会弹出另一个窗口,该窗口同时包含C2C3的提交信息,我们可以手动编辑压缩合并生成的新提交信息:

      # 默认采用上一个提交信息,这里我将其修改为如下
      squash C2 and C3
      

      此时,我们就完成了C2C3的合并,效果如下所示:

      $ git log --oneline
      cfed515 (HEAD -> master) C4
      fb5e7f3 squash C2 and C3    # 合并成功
      c830b70 C1
      
    • fixup:该操作也squash作用一样,也是用于压缩提交。但与squash不同的是,该操作会丢弃被压缩提交(即被fixup标注的提交)的提交信息。

      举个例子:比如我们还是压缩C2C3,但是直接将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操作也常常用来将提交切分为多个小提交,其实就是重置+增加提交的操作,比如,前面我们将仓库提交C2C3压缩到一起,如下所示:

      $ git log --oneline
      cfed515 (HEAD -> master) C4
      fb5e7f3 squash C2 and C3
      c830b70 C1
      

      现在如果我们想重新分离开C2C3,则可以借助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
      

      我们将该提交重新拆分为两个小提交C2C3

      # 暂存区移除 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
      

      假设现在我们想要删除C2C3这两个提交,其步骤如下所示:

      # 启动交互式变基
      $ git rebase -i HEAD~2
      

      此时弹出的编辑窗口内容如下:

      pick 6992e50 C2
      pick 45fcf79 C3
      

      这里我们将C2标注为drop,然后直接删除C3,同时验证这两种删除方法:

      drop 6992e50 C2
      

      保存并退出编辑窗口,此时查看仓库提交历史记录:

      $ git log --oneline
      c830b70 (HEAD -> master) C1
      

      可以看到,我们已经成功删除了C2C3

    • 更换提交顺序:更换提交顺序只需在编辑窗口中直接更换提交的顺序即可。

      举个例子:比如我们当前本地仓库提交历史记录如下所示:

      $ git log --oneline
      45fcf79 (HEAD -> master) C3
      6992e50 C2
      c830b70 C1
      

      假设现在我们想更换C2C3的顺序,其步骤如下:

      # 启动交互式变基
      $ git rebase -i HEAD~2
      

      此时的编辑窗口如下所示:

      pick 6992e50 C2
      pick 45fcf79 C3
      

      要更换C2C3提交顺序,只需在编辑窗口中更换两者顺序即可:

      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

举个例子:假设本地仓库存在masterdev分支,现在突然发现线上版本出现漏洞,因此紧急从master分支上创建一个hotfix/add_file分支,然后做了两个提交,如下图所示:

cherrypick - initial

简单起见,每个提交都只是增加了相应数字的文件。

当漏洞修改完成后,就需要将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

此时的示意图如下所示:

cherrypick - master merge hotfix

同样的,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分支的所有提交,即C4C5直接摘取到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
    

    从提交历史中,我们已经可以看到成功摘取C4C5dev分支上了,此时的示意图如下所示:

    cherrypick - dev pick hotfix
  • cherry-pick合并提交:第二种方法是摘取合并提交,即摘取C6应用到dev分支上。具体步骤如下:

    1. 首先我们将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
      

      此时的仓库示意图如下所示:

      cherrypick - reset dev
    2. 此时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分支,此时的示意图如下所示:

      cherrypick - pick merge commit

      :可以看到,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_pointend_point的中间提交,如果未指定start_pointend_point,则进入二分搜索时,还需手动使用git bisect bad <commit>git bisect good <commit>手动指定end_pointstart_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 revertgit 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_commitparent-number就是1,第二个parent2_commitparetn-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>选项来指定只显示文件startend之间的行:

    # 只显示 .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.requireForcefalse的话,git clean默认不进行删除动作,此时可通过添加-f选项真正执行删除操作。

版本及范围表示法

大多数 Git 命令都会携带一个revision(修订版本)作为参数,因此 Git 也内置了一些版本及其范围的简便引用方法,大致有如下:

  • 版本指定:版本指定可引用一个提交或多个提交:

    • <sha1>:表示对象文件的哈希字符串。比如:dae86e1950b1277e545cee180551750029cfe735dae86e
    • <refspec>:表示符号引用。比如:masterheads/masterrefs/heads/master都表示master分支,比如HEAD表示HEAD引用文件...
    • @:一个单独的@等同于HEAD
    • <refspec>@{<date>}:表示指定时间段的引用。比如:master@{yesterday}HEAD@{5 minutes ago}
    • <refspec>@{<number>}:表示引用refspec之前的第number个提交。比如:master@{0}等同于mastermaster@{1}master分支第二个最新提交。
    • @{<number>}:与<refspec>@{<number>}功能一致,只是refspecHEAD,即表示当前分支的最新第number个提交。
    • <rev>^[n]:表示提交rev的前n个父提交,n表示字符^的重复个数。比如:a95fabe^表示提交a95fabe的前一个提交,HEAD^^表示HEAD的前第二个提交...
      n也可以为数字,但只能为01。比如a95fabe^0等同于a95fabeHEAD^1等同于HEAD^
    • <rev>~<number>:表示提交rev的第number祖先提交。比如:a95fabe~0等同于a95fabea95fabe~1表示a95fabe的前一个提交,HEAD~5表示当前分支的最新第 5 个提交。
      <rev>~<number>也支持<rev>~<n>操作,比如:HEAD~等同于HEAD~1HEAD~~等同于HEAD~2...
    • <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>:表示同时包含rev1rev2,即[rev1,rev2]

更多其他版本与版本范围表示法,可参考:Git 版本及版本范围表示法

附录

本人配置

以下是本人的 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"

参考

推荐阅读更多精彩内容

  • 公众号内发送Git获取Git官方PDF教程 版本控制 版本控制是一种记录一个或若干个文件内容变化、以便将来查阅特定...
    公众号_CoderLi阅读 42评论 0 1
  • 一、Git是什么? 是一个开源的分布式版本控制系统,可以有效、高速的处理从很小到非常 大的项目版本管理。 Git ...
    名字谁不会取阅读 549评论 0 0
  • 一·Git是什么 是一种版本控制工具,可以管理文件的历史,很快的回溯到历史的修改,并且可以看到别人的修改。 Git...
    浅草织阅读 106评论 0 0
  • 1.直接进入沙盒 2. 本地操作相关篇节2.1 基础篇2.2 处理复杂问题2.2.1 修改提交树2.3 杂项 3....
    徽先生阅读 97评论 0 0
  • 久违的晴天,家长会。 家长大会开好到教室时,离放学已经没多少时间了。班主任说已经安排了三个家长分享经验。 放学铃声...
    飘雪儿5阅读 3,693评论 13 21