前言

Git 作为当前应用最广泛的代码管理和版本控制工具,在实际开发时有很多的注意事项,本篇的目的就是在已经会使用 Git 的基础上来详细总结一下这些注意事项

分离头指针

在日常开发时,我们经常都是在某一个分支下进行的,commit 操作也是在某些分支上进行,换句话说我们的每一个提交一般来讲都是与分支挂钩的,在 Git 中有一种情况,我们所修改的代码不与任何一个分支有关连,这种情况下叫做分离头指针。

那么如何操作才能实现分离头指针呢,在开发时我们可能会对某一个 commit 非常的感兴趣,并希望在这个 commit 下去做一些事情,可以执行下面命令实现。

$ git checkout ef5aaed0707989ebc069efcd842424f6315ab4e2

当切换分支后对某些文件做一些修改,并重新 commit

$ git add .
$ git commit -m '分离头指针测试'
$ git log

执行上面命令后我们发现新的 commit 信息后面不在对应某一个分支,而是 HEAD,这种情况下就代表着我们的 Git 目前已经处于分离头指针的状态了。

分离头指针是 “双刃剑” 有好处也有坏处,在分离头指针的状态下所有的 commit 在重新切换分支时,会被 Git 当作无用提交回收掉,因为这些提交没有跟任何分支有所联系。

分离头指针优缺点:

  • 优点:尝试性的 commit 可以在分离头指针的状态下进行;
  • 缺点:当发布需要到其他分支修复问题或紧急发布时,切分支后会导致分离头指针状态下的 commit 丢失。

在切换分支后,如果还想保留分离头指针状态下的提交,可以为这个提交创建一个新的分支。

$ git branch 分支名 分离头指针状态的提交(哈希值)

修改本地 commit

修改最近一次提交

$ git commit --amend

--amend 可以将暂存区新存入的内容同时提交到最近的一次 commit 中,而不会生成新的 commit,同时也可以修改 commit 时的提交信息。

修改任意一次提交

$ git rebase -i a4d56bb

该操作为 git rebase 命令的交互模式,即输入 -i 命令,后面所输入的 commit 哈希值并不是要修改的 commit,而是要修改的 commit 的父级 commit 哈希值,在执行命令后会弹出修改的交互界面如下。

第一个交互界面:

pick 52f3935 add css file
- pick 91bd053 change css
+ reword 91bd053 change css

# Rebase a4d56bb..91bd053 onto a4d56bb (2 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# ...

从上面的信息来看第一条代表当前要修改的 commit,第二条代表该分支最新的 commit,下面注释为修改参数,由于要修改提交信息,所以此处将第一行的 pick 修改成 reword 并保存,保存后会弹出下一个修改提交信息的界面如下。

第二个交互界面:

- css content
+ add css content

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Tue Mar 19 14:48:22 2019 +0800
#
# interactive rebase in progress; onto a4d56bb
# Last command done (1 command done):
#    reword 9e4f711 add css content
# Next command to do (1 remaining command):
#    pick c220cf2 change css
# You are currently editing a commit while rebasing branch 'test' on 'a4d56bb'.
#
# Changes to be committed:
#       new file:   index.css

该界面上为 commitmessage,修改后保存,就完成了对该 commit 的修改,值得注意的是,使用 git log 查看历史可以发现,修改 commit 时指定的父级 commit 后所有的 commit 哈希值都会发生变化。

修改 commit 后的提示信息:

[detached HEAD de48b04] add css content
 Date: Tue Mar 19 14:48:22 2019 +0800
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 index.css
Successfully rebased and updated refs/heads/test.

可以看出,其实使用 git rebase 命令修改 commit 的原理也是分离头指针,只是在分离头指针修改 commit 后又重新将当前分支的指针指回了最新的 commit

将多个连续的 commit 合并成一个

将多个 commit 合并成一个的原理与修改任意一个 commit 的原理相同,都是通过 git rebase 命令的交互模式实现的(-i),参数为合并几个 commit 的父级 commit 哈希值。

查看历史:

$ git log --oneline
# edd2400 (HEAD -> test) add content to readme
# 50a015c add background css
# 15237d2 change css
# 4a8fd80 add css content
# 5149bad new READ.md
# 7f73a76 new html

现在我们尝试将 50a015c15237d24a8fd80 这三个 commit 合并成一个,与修改 commit 唯一不同的是被修改的 commit 参数不再是 reword,而是 squash,多个要合并的 commit 之中有一个目标 commit,这个 commit 的参数必须是 pick

第一个交互界面:

pick 4a8fd80 add css content
- pick 15237d2 change css
- pick 50a015c add background css
+ squash 15237d2 change css
+ squash 50a015c add background css
pick edd2400 add content to readme

# Rebase 5149bad..edd2400 onto 5149bad (4 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# ...

对上面的交互界面保存后同样会弹出第二个交互界面,合并多个 commit 与修改单个 commit 不同的是,第二个界面会展示所有被合并 commit 的信息,我们可以为合并后的 commit 添加一个新的 message

第二个交互界面:

# This is a combination of 3 commits.
+
+ css changes
+
# This is the 1st commit message:

add css content

# This is the commit message #2:

change css

# This is the commit message #3:

add background css

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Tue Mar 19 14:48:22 2019 +0800
#
# interactive rebase in progress; onto 5149bad
# Last commands done (3 commands done):
#    squash 15237d2 change css
#    squash 50a015c add background css
# Next command to do (1 remaining command):
#    pick edd2400 add content to readme
# You are currently rebasing branch 'test' on '5149bad'.
#
# Changes to be committed:
#       new file:   index.css

查看合并提交后的历史:

$ git log --oneline
# 2c84584 (HEAD -> test) add content to readme
# ac001bc css changes
# 5149bad new READ.md
# 7f73a76 new html

将多个间隔的 commit 合并成一个

上面的 commit 合并方式可能满足不了需求,有些时候我们想把对同一个文件的提交或同一类操作的提交合并成一个,但是在历史 commit 中要合并的树是间隔的,使用 git rebase 命令同样可以做到。

查看历史:

$ git log --oneline
# 23d6939 (HEAD -> test) append content into readme
# 178ea29 link css in html
# 2c84584 add content to readme
# ac001bc css changes
# 5149bad new READ.md
# 7f73a76 new html

在之前使用 git rebase 命令时都是将操作 commit 的父级 commit 作为参数,如果我们要操作的 commit 已经没有父级 commit,接下来在合并多个间隔的 commit 时来测试一下这样的情况,接下来将 7f73a76178ea29 两个关于 html 文件的操作合并成一个。

执行命令:

$ git rebase -i 7f73a76

由于我们要操作的 commit 已经没有了父级,所以我们就在执行命令时传入这个 commit,在弹出的第一个交互界面我么明显能看到其实上面是少了我们要操作的 commit,所以需要手动补上,而间隔的 commit 要移动到和合并的目标 commit 连续的位置。

第一个交互界面:

+ pick 7f73a76
+ squash 178ea29 link css in html
pick 4a8fd80 add css content
pick 5149bad new READ.md
pick ac001bc css changes
pick 2c84584 add content to readme
- pick 178ea29 link css in html
pick 23d6939 append content into readme

# Rebase 7f73a76..23d6939 onto 7f73a76 (5 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# ...

在保存后出现如下报错信息,是因为 commit 的父节点是我们新增上去导致的。

提示信息:

The previous cherry-pick is now empty, possibly due to conflict resolution.
If you wish to commit it anyway, use:

    git commit --allow-empty

Otherwise, please use 'git reset'
interactive rebase in progress; onto 7f73a76
Last command done (1 command done):
   pick 7f73a76
Next commands to do (5 remaining commands):
   squash 178ea29 link css in html
   pick 5149bad new READ.md
You are currently rebasing branch 'test' on '7f73a76'.

nothing to commit, working tree clean
Could not apply 7f73a76...

执行 git status 提示信息:

Last command done (1 command done):
   pick 7f73a76
Next commands to do (5 remaining commands):
   squash 178ea29 link css in html
   pick 5149bad new READ.md
  (use "git rebase --edit-todo" to view and edit)
You are currently rebasing branch 'test' on '7f73a76'.
  (all conflicts fixed: run "git rebase --continue")

nothing to commit, working tree clean

想继续合并:

$ git rebase --continue

想还原回合并之前:

$ git rebase --abort

如果在继续合并后没有出现第二个交互界面(与合并连续 commit 类似,用来新增合并后 commit 的信息),说明合并时出现冲突,此时需要解决冲突后将新的变更提交到暂存区,再重新执行合并命令。

查看合并后的历史:

$ git log --oneline
# 4d4f771 (HEAD -> test) append content into readme
# a83f526 add content to readme
# 463fd85 css changes
# 7e44e19 new READ.md
# 753ebcd about html changes

注意:当前对 commit 的变更和合并操作只是对 commit 做了整理,并没有改变文件内容,并且这些操作仅限于要修改或合并的 commit 还没有共享到集成分支上去,如果已经推送到远端,进行上面操作会对其他协同开发的人员造成麻烦和困扰。

删除后提交的 commit

在开发中有这样一种情景,就是我们在修改代码时提交了一个或者几个新的 commit,但是发现有更好的方案,想要删除这些 commit,这时可以通过将 HEAD 指针重新指向这些 commit 之前的提交,命令如下。

$ git reset --hard 版本号

这样的操作会导致工作区、暂存区的代码都会到这个 commit 的状态,当然也有 “后悔药”,可以使用 git reflog 找到所有的 commit 版本号 包含已删除),再通过同样的方式将 HEAD 的指针指回去。

$ git reflog
$ git reset --hard 已删除的版本号

忽略上传的文件

在开发过程中,有些文件是不需要我们上传到远端的,可能因为这个文件对于开发项目来讲是无用的,如编辑器自动生成的 .idea 等,或者这个文件夹非常的大,如 node_modules,我们可以通过 .gitignore 文件来配置忽略上传的文件。

.gitignore 文件中有很多规则,在此不去讨论,在这里我们要说的是如果某些想要忽略的文件由于失误没有被写进 .gitignore,被推送到远端后,想忽略这个文件,并在下次推送的时候让远端不再有这个文件该怎么做。

先将要忽略的文件添加到 .gitignore,然后执行下面命令对之前添加的文件进行删除操作。

$ git rm --cached 文件名/文件夹

在开发当中处理紧急发布(CR)任务

在开发时经常有这样一种场景,在上一版本代码上线以后,突然发现线上出现 Bug 需要修复并紧急上线,而这个时候刚好又在同一个分支上已经有了其他的新代码,此时需要将代码还原到线上版本,并保证当前开发代码不丢失,待问题修复后,将新开发的代码合并到修复后的代码上继续开发,当然根据实际情况的不同,复杂程度也会有所差别,下面是一些思路。

当前代码跟要修复代码在同一条分支:

  • 将当前代码暂存;
  • 修复代码后合并到 dev 发布测试环境验证,通过后发布;
  • 恢复暂存代码继续开发;
$ git stash
$ git stash pop

当前代码跟要修复的代码不在同一条分支:

  • 提交当前分支代码;
  • 切换到 master 分支创建一条新分支;
  • 修复问题并提测;
  • 验证通过后合并到 masterdev 分支发布;
  • 回到开发新功能 feature 分支将修复代码(hotfix)集成进来并继续开发。
$ git add .
$ git commit -m 'message'

$ git checkout master
$ git checkout -b 修复问题分支

$ git checkout 测试分支
$ git merge 修复问题分支

$ git checkout 开发分支
$ git rebase 测试分支

当然上面的思路仅供参考,因为不同的团队规范有所差异,问题的复杂度也不尽相同,在某些特殊时候可能要本地代码回退版本,需要借助 git reset 命令实现。

non-fast-forwards 和 fast-forwards

在实际项目开发中我们将本地代码推送到远端的时候可能会遇到下面这样的报错信息。

推送代码时的错误信息:

error: failed to push some refs to 'git@github.yourRepository.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

fast-forwards 是指将本地分支推送到远端,tree 上两个分支拥有共同的 “祖先”,可以自动合并成一个 tree,而 non-fast-forwards 正好相反,两个分支的 tree 是完全独立的,没有任何联系,一般会造成这种现象的原因是推送的目标分支和我们当前分支拥有不同的代码,所以我们需要将推送的目标分支和本地分支的 tree 整理成 fast-forwards 的状态。

实现方式就是先拉取远端分支在本地进行处理(如果有冲突先处理冲突),变成 fast-forwards 状态后再进行推送,拉取远端分支可以使用 fetchpull,区别在于 fetch 拉取回来的代码仍然是 non-fast-forwards 状态,需要手动 merge 进行合并或 rebase(因为有些团队比较喜欢线性的提交记录以便追溯)操作,而 pullfetchmerge 这两个步骤合二为一。

注意:使用 fetch 拉取代码在进行 merge 时存在一种特殊情况,就是这个仓库的代码是第一次被拉取到本地(与本地分支没有共同的提交),且与本地代码的差异是新建仓库时添加 README.md 等文件造成的,则需要在 merge 时加上 --allow-unrelated-histories 参数去允许历史上完全独立的两棵树进行合并,达到 fast-forwards 的状态。

合并不相关的树:

$ git merge 本地分支 --allow-unrelated-histories 远端分支

执行命令后会弹出交互界面可以修改本次合并的 message

Git 多人单分支集成协作

多人协同开发时本地仓库与远端的同步

在项目的开发迭代中,我们习惯每一个版本迭代都新建一个分支开发,并推送到远端,如果多个人同时要在这个分支开发该迭代的新功能,而以前又已经克隆过这个项目到本地,此时除了这条分支的创建者以外,其他人查看远端分支时是看不见这个新建分支的,需要执行以下命令对仓库进行同步并开发。

同步新分支信息:

$ git fetch 地址别名

查看新分支:

$ git branch -av

拉取新分支到本地:

$ git checkout -b 新分支名 地址别名/新分支名

还有一种场景也需要通过上面的方式来同步仓库信息,就是在 Github 中帮助别人的项目贡献代码或修复 Issue 时,首先需要 Fork 别人的仓库,但是 Fork 过来的仓库代码并不会随着原作者仓库的代码更新而更新,为了在开发之前使 Fork 的仓库和原作者仓库代码及分支保持一致,执行上面命令,开发完毕后再通过给原作者提交 push request 的方式让原作者进行代码审核并合并到原始仓库。

不同人修改不同文件的处理方式

在实际开发中,两个人在一条分支开发,当 A 同学修改了 a 文件,B 同学修改了 b 文件时,此时 B 同学先进行了提交,A 同学并不知道的情况下,在 A 同学推送代码到远端时会变成 non-fast-forwards 状态(推送失败),并提示超前一个版本,落后一个版本,意思是本地代码有一个提交远端没有,远端代码有一个提交本地没有,一般情况下大多数的处理是选择先拉去远端代码进行合并,再推送到远端。

由于两个人修改的是不同文件,在拉取远端代码后合并会比较顺利,并不会产生冲突,但同时产生新的问题,就是多了一条关于合并的提交记录,如果想让提交的历史树更干净整洁,也有另一种做法,就是推送失败的一方主动将本地 commit 回退到与远端完全一致的 commit 版本,主动拉取代码与工作区合并,再重新提交到本地版本库并推送到远端。

撤销本地新的提交:

$ git reset 与远端相同的提交

不同的人修改相同文件不同区域的处理方式

我们将上面 AB 两个同学的操作场景稍微做些改动,就是两个人同时操作了同一个文件的不同区域,此时如果 B 先提交到远端,A 不知情的情况下推送代码到远端,一样会变成 non-fast-forwards,同样可以通过上面的方式处理,Git 比较智能,可以将两个平行的修改过不同区域的文件进行合并,变成 fast-forwards 状态。

不同的人修改相同文件相同区域的处理方式

依然沿用上面 AB 同学的操作场景,不同的是这次两人修改了相同文件的相同区域,B 先提交到远端,A 在提交到远端时有因为状态为 non-fast-forwards 被拒绝,同样的方式处理时发现了新的问题,代码虽然成功拉合并,但是控制台报错了。

合并后报错:

...
Auto-merging yourfile
CONFLICT (content): Merge conflict in yourfile
Automatic merge failed; fix conflicts and then commit the result.

由于两个人操作了同一个区域导致 Git 无法判断两个内容应该怎样去保留或替换,所以将合并失败的错误抛出让开发者认为的介入。

在解决冲突时可能存在的情况:

  • 两人将都要保留的功能代码写在了相同文件的相同区域,这种情况需要都保留;
  • 两个人开发功能重复了,需要进行沟通协商决定保留哪一个。

在手动处理冲突对文件进行合并时,可以通过 git status 查看合并后的状态,如果这个人为的合并是需要的可以创建一个新的提交推送到远端,如果觉得没有处理好,可以执行下面命令还原到合并之前。

撤销合并:

$ git merge --abort

不同的人同时变更文件名的处理方式

在不同人同时修改同一个文件名时,Git 时无法处理的,当然会变成 non-fast-forwards 状态,在通过常规的处理后,本地会出现两个文件,分别为两人所更改的文件名,这时需要两个人进行协商,保留协商后的文件名,删除多余的文件并推送到远端让其他人进行同步。

$ git rm oldfilename
$ git add newfilename
$ git commit -m 'merge message'
$ git push

在一个人修改文件名,其他人修改内容的情况下,Git 的文件内容都是通过 blob 对象进行存储,而非文件的形式,所以当多人协同某个人对文件名进行变更时 Git 可以非常智能的检测并同步。

禁止在已共享的集成分支使用强推

“强推” 是指使用 git push -f 将本地分支推送到远端,之前在多人写作中远程分支拒绝推送的原因都是因为 non-fast-forwards 状态,我们可以理解为这是 Git 防止代码被推送到远端而产生冲突的一种保护机制,而 “强推” 就是忽略了 non-fast-forwards 状态强行将代码推送到远端。

在大部分团队中都是禁止在集成分支使用这条命令的,可能会在远端产生冲突只是原因之一,操作不正确也可能导致远端集成分支整个团队的提交历史丢失的严重后果,比如当前本地分支版本远远落后于远端,此时直接推送会进入 non-fast-forwards 状态,远端拒绝推送,而向远端 “强推”,远端在这个本地版本库 HEAD 指向的 commit 之后所有的提交历史都将丢失。

禁止在已共享的集成分支上做变基操作

还记得前面 修改本地 commit 一节中强调 rebase 操作只适用于修改本地还未同步到远端的 commit,这是因为如果对已经同步到远端的进行了变基操作会导致 commit 的版本号发生变化,如果推送到远端,此时协同开发的人是基于远端旧的 commit 之上在做新的开发,会导致无法将本地代码推送到远端。

有些团队严令禁止对集成分支做变基操作,被称作 “rebase 黄金定律”,如果一定要对集成分支做变基操作的,一定要在当前远端最后的 commit 之后做变基操作。

如果不幸真的有同事这样去做了,我们虽然会很恼火,但也还是有办法去解决这样的问题,可以直接执行下面命令:

$ git pull --rebase

或者分为两步走,把远端变基后的分支 fetch 到本地,再把本地的当前分支基于 fetch 下来的远端分支做 rebase 操作,命令如下:

$ git fetch
$ git rebase 地址别名/分支名

总结

本文内容是自己在对 Git 的学习和工作中总结的笔记,另外想了解 rebasemerge 更详细的信息推荐阅读 git rebase vs git merge 详解