分支提交错误

有时我们会遇到这种情况:我们从develop 分支新建一个名为feat/home 分支去做A功能,然后由于一些其他原因A 功能需要延后,然后我们再从develop分支新建一个分支去做B功能或者C功能,在多分支多功能开发时,就容易出现做B功能时,忘记切换分支,一直等做完了提交了push之后才发现 push 错了远端的分支,并且 push 的改动与该分支需要开发的功能并没有交集,因此我们需要将已经提交错的分支内容回滚并提交push 到正确的远端分支。

已经提交到本地

使用 git reset 命令,可以在提交层面在私有分支舍弃一些没有提交的更改:

1
2
# 回退到上一个版本 
git reset --hard HEAD^  

git reset 命令主要有三个选项: –soft、–mixed 、–hard,默认参数为 –mixed。

git reset –soft

–soft 这个版本的命令有“最小”影响,只改变一个符号引用的状态使其指向一个新提交,不会改变其索引和工作目录, 具体体现如下:

1
2
3
4
5
6
# 模拟一份提交历史
git add 1.js && git commit -m "update part 1"
git add 2.js && git commit -m "update part 2"
git add 3.js && git commit -m "update part 3"
git add 4.js && git commit -m "update part 4"
git log --oneline --graph -4 --decorate

1
2
# 用 --soft 参数尝试回退一个版本
git reset --soft HEAD~1

当我们执行 –soft 命令后,可以看到控制台无任何输出,此时再次查看当前提交历史:

1
git log --oneline --graph -4 --decorate

如下图,可以看到版本库已经回退了一个版本:

执行 git status,可以看到SHA1为54b1941 的commit 上的更改回到了缓存区:

因此我们可以认为 –soft 操作是软重置,只撤销了git commit操作,保留了 git add 操作。

git reset –hard

此时接上面的流程,我们这次执行 –hard 操作,尝试回退两个版本:

1
git reset --hard HEAD~2

如下图,可以看到版本库回退了两个版本,并且将本地版本库的头指针全部重置到了指定版本,暂存区也会被重置,工作区的代码也回退到了这一版本:

执行git status 可以看到 我们的 SHA1 为 54b1941的 commit 上做的修改都“丢失”了,新的文件也被删除了。

因此可以知道,git commit –hard 是具有破坏性,是很危险的操作,它很容易导致数据丢失,如果我们真的进行了该操作想要找回丢失的数据,那么此时可以使用git reflog 回到未来,找到丢失的commit。这个命令的具体使用会在文章后面介绍。

git reset –mixed

我们重新造一系列 commit 历史:

1
2
3
4
5
6
git add 1.js && git commit -m "update 1.js"
git add 2.js && git commit -m "update 2.js"
git add 3.js && git commit -m "update 3.js"
git add 4.js && git commit -m "update 4.js"
git add 5.js && git commit -m "update 5.js"
git log --oneline --graph -4 --decorate

可以看到当前的 commit 历史如下:

此时执行–mixed 操作,尝试回退两个版本:

1
2
# 等价于 git reset HEAD~2
git reset --mixed HEAD~2

提交历史此时改变为下图所示:

此时执行 git status ,命令行输出如下:

HEAD、索引被更改,工作目录未被更改

可以看出,该命令加上 –mixed 参数会保留提交的源码改动,只是将索引信息回退到了某一个版本,如果还需要继续提交,再次执行 git add 和 git commit

解决问题

介绍完git reset,那么我们来说一下如何用该命令解决提交分支错误的问题:

第一种方法:

适用于多个分支一起开发的时候将A分支的改动错误的提交到B的场景:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 将该分支的本不应该提交的commit撤销
git reset HEAD^

# 按需选择想要回到哪个版本
# 回到HEAD
git reset --soft HEAD

# 回到HEAD的前一个版本
git reset --soft HEAD^

# 回到HEAD的前5个版本
git reset --soft HEAD~5 

# 利用id回到指定版本
git reset --soft a06ef2f

# 将撤销的代码暂存起来
git stash

# 切换到正确的分支
git checkout feat/xxx

# 重新应用缓存
git stash pop

# 在正确的分支进行提交操作
git add . && git commit -m "update xxxx"

第二种方法:

适用于在不小心在 master 分支上提交了代码,而实际想要在 feature 分支上提交代码的场景:

1
2
3
4
5
6
7
8
# 新检出一个新分支,但是仍在master 分支上,并不会切换到新分支
git branch feat/update

# 恢复master本身提交的状态
git reset --hard origin/master

# 提交错的代码已经在新检出的分支上面了,可以继续进行开发或者push
git checkout feat/update

第三种方法:

适用于想要对特定的某一个或几个commit 进行“嫁接”,使其复制一份到正确的 feature 分支的场景; 在功能性迭代开发中发现一个bug,并提交了一个commit 进行修复,但是发现该bug也存在线上的发布版本上,必须要尽快对线上进行修复,此时可以使用git cherry-pick 将bug 修复的commit 嫁接到 fix 分支上进行代码修复,并及时发布,解决线上bug。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 先切换到正确的分支
git checkout feat/update

# 取出提交错误的或bug fix的 commit 引入到feat/update 分支中
git cherry-pick a06ef2f

# 回到错误的分支
git checkout feat/feedback

# 将 a06ef2f 的改动从当前分支销毁
git reset --head a06ef2f

上面演示的是“嫁接” 一个commit,如果想要嫁接多个 commit 可以这样做:

1
2
# 将三个commit 合并过来
git cherry-pick b9dabf9 e2c739d dad9e51

如果想加个一个应用范围内的 commit,可以这样做:

1
git cherry-pick 422db47..e2c739d

需要注意的是无论是对单个 commit 进行 git cherry-pick ,还是批量处理,注意一定要根据时间线,依照 commit 的先后顺序来处理。

如果你只想把改动转移到目标分支,但是并不想提交,可以这样做:

1
2
# --no-commit 参数会使嫁接过来的改动不会提交,只会放在暂存区
git cherry-pick b9dabf9 --no-commit

第四种方法:

适用于当多个文件被缓存时,发现其中一个文件是其他分支的功能性改动,想直接取消该文件的缓存:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 编辑了 1.js 2.js 3.js
# 缓存所有改动的文件
git add .

# 发现 3.js 不应该出现在此时提交的功能上,要取消它的缓存
git reset 3.js

# 此时3.js 被取消了缓存,我们继续提交1.js 2.js
git commit -m "Update 1.js 2.js"

# 将3.js 暂存起来
git stash

# 切换到提交 3.js 改动的分支
git checkout feat/update

# 重新应用缓存起来的 stash(3.js)
# pop 参数会将缓存栈的第一个stash删除,并将对应修改应用到当前分支目录下
git stash pop

# 继续提交
git add && git commit -m "update 3.js"

已经推送到远端

场景:假设我们在 feat/feedback 分支上发现最后一次 commit 的功能是feat/update 分支的改动,此时想要取消这次commit(update 2.js)

下图是feat/feedback 的提交历史:

此时我们需要借助 git revert 命令来撤销我们的操作。

解决方式:

1
2
# 撤销最近的一次提交
git revert HEAD --no-edit

接着我们使用 sourceTree 查看撤销之后的提交历史:

我们看到想要撤销的 SHA1 为 db6bb3 的 commit(Update 2.js)记录还在,并且多了一个SHA1 为 6e1d7ee 新的 commit(Revert “Update 2.js”)。因此可以看出,git revert 是对给定的 commit 提交进行逆过程,该命令会引入一个新的提交来抵消给定提交的影响。 和 git cherry-pick 一样,revert命令不修改版本库的现存历史记录,相反它只会在记录添加新的提交。

接下来我们已经解决了错误分支的提交,但是还要把这次提交放到正确的分支上,依然可以使用 git cherry pick 去操作:

1
2
3
4
5
# 将revert commit push到远端
git push origin feat/feedback

# 切换到正确的分支
git checkout feat/update

将目标commit 嫁接到当前分支

1
git cherry pick db6bb3f

git revert 后面可以加不同的参数达到不同的撤销效果,常用的如下:

–edit :该参数为git revert 的默认参数,它会自动创建提交日志提醒,此时会弹出编辑器会话,可以在里面修改提交消息,然后再提交。

1
git revert 6ac5152 --edit 

–no-edit :表示不编辑 commit 信息,revert 的 commit 会直接自动变回 ‘Revert + 想要撤销的commit 的message’ 的格式。上面例子中使用的就是这种方式。

–no-commit:该命令会使撤销的 commit 里面的改动放到暂存区,不进行提交,用户可以自行再次提交。这种参数并且适用于将多个 commit 结果还原到索引中,集体放置在缓冲区,进行用户自定义的操作。

1
git revert 13b7faf --no-commit

已经合到主仓库

当我们把本不属于该分支的代码或者不需要提交的改动提交到主仓库,并合并到了develop 仓库之后,这时想要撤销合到主仓库的改动,解决方式如下:

推荐的工作流程是如在一个新分支中恢复错误的提交。在这里有人会问,为什么不直接在 develop 分支进行 git revert 操作,岂不是更方便,何必麻麻烦烦的去多建一个分支出来?

这么做的原因是:在拥有大量开发人员的团队中, develop、master 分支为保护分支,为了安全不允许或不建议去直接修改。

通过这次操作我们可以了解到:revert 分支的操作实际上是合并进develop 分支的逆操作,它会新产生一个新的分支,将 feat/feedback 的改动还原。

当你使用 git revert 撤销一个 merge commit 时,如果除了 commit 号而不加任何其他参数,git 将会提示错误:

1
2
3
$ git revert g
error: Commit g is a merge but no -m option was given.
fatal: revert failed

在你合并两个分支并试图撤销时,Git 并不知道你到底需要保留哪一个分支上所做的修改。从 Git 的角度来看,master 分支和 dev 在地位上是完全平等的,只是在 workflow 中,master 被人为约定成了「主分支」。

于是 Git 需要你通过 m 或 mainline 参数来指定「主线」。merge commit 的 parents 一定是在两个不同的线索上,因此可以通过 parent 来表示「主线」。m 参数的值可以是 1 或者 2,对应着 parent 在 merge commit 信息中的顺序。

举个例子,通常,我们的稳定代码都在 master 分支,而开发过程使用 dev 分支,当开发完成后,再把 dev 分支 merge 进 master 分支:

1
2
3
a -> b -> c -> f -- g -> h (master)
           \      /
            d -> e  (dev)

以上面那张图为例,我们查看 commit g 的内容:

1
2
3
$ git show g
commit g
Merge: f e

那么,$ git revert -m 1 g 将会保留 master 分支上的修改,撤销 dev 分支上的修改。

撤销成功之后,Git 将会生成一个新的 Commit,提交历史就成了这样:

1
2
3
a -> b -> c -> f -- g -> h -> G (master)
           \      /
            d -> e  (dev)

其中 G 是撤销 g 生成的 commit。通过 $ git show G 之后,我们会发现 G 是一个常规提交,内容就是撤销 merge 时被丢弃的那条线索的所有 commit 的「反操作」的合集。

下面我们看一下具体操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 添加三个文件
echo 1 > 1.html
echo 2 > 2.html
echo 3 > 3.html

# 以为提交的是1.html 2.html,将改动推到了远端分支
git add . && git commit -m "Add 1.html 2.html"
git push origin feat/update

# 将feat/update的改动创建一个“合并提交”合入develop 分支,生成的 Merge commit 的SHA1 为 f439c6f
git checkout develop
git merge feat/update --no-ff

# 如果存在冲突,先解决冲突,然后继续请求合并
git add . && git merge --continue

# 将develop 合并的最后结果提交到远端
git push origin develop

# 合并之后发现不应该将3.html 不应该放入功能迭代中。需要撤销本次合并
# 做任何操作前,先保证本地的develop 代码是最新状态
git pull --rebase origin develop

# 从develop分支新建一个 revert 分支
git checkout -b revert-feat/update

# 用 -m 参数指定父编号(从1开始),因为它是“合并提交”
git revert -m 1 f439c6f

# push revert 的改动
git push origin revert-feat/update

# 切换回 develop 分支,将 revert-feat/update 分支进行合并
git checkout develop
git merge revert-feat/update --no-ff
git push origin develop

图为新建revert 分支:

图为git revert 弹出编辑器编辑 revert commit message 过程:

图为执行完git revert 之后的 commit 历史记录:

接下来我们想将 3.html 的改动撤销的操作就变成了上面场景 2 的操作流程了。

主仓库回退错误

当我们的代码合到主仓库,并且成功发布到生产环境,此时发现线上有集中报错,必须马上将线上代码回滚到最新版本。这是我们需要进行revert 操作。revert 的代码发布到生产之后,发现错误仍旧存在,最后排查到是某个外部服务依赖出现问题,本次revert 的改动无关,并且外部服务已经恢复。此时需要将 revert 的改动再次发布上生产环境。

我们可以再用一次git revert,revert 掉我们之前的 revert commit:

1
git revert HEAD --no-edit

这样 revert 撤销的改动又回来了,此时会发现提交历史上又会出现一个新的revert commit。

暂存区回退错误

如果我们真的使用了git reset –hard 之后,发现某些修改还有必要的,这时候就需要借助时光机 git reflog “回来未来”了。

git reflog 是非常好用的“后悔药”,它几乎可以恢复我们 commit 过的改动,即使这条 commit 记录已经被我们 reset 掉了。

具体演示如下:

如上图,在当前提交历史中,我们认为最新的两个commit 已经没有用了,想直接reset 到 SHA1 为 c48a245 这个 commit:

1
2
# 回到 c48a245 commit
git reset --hard c48a245

此时提交历史变为现在这样:

此时可以看到SHA1 为c48a245 的 commit 时间线之后的改动都已经被撤销了。 这时候我们突然想到:commit 信息为 “Add 1.html 2.html” 的提交里面的改动很重要,需要被找回,但是我们使用 git log 查看过去提交历史,已经找不到这条被我们 reset 掉的历史记录了。这时候进行如下操作:

1
git reflog

我们如愿以偿的看到了曾经提交过的这个想要找回的commit(commit: Add 1.html 2.html),它的 SHA1 为 cf2e245。

接下来怎么做取决于你具体想要达到什么目的:

想要回到cf2e245 这个特定的commit:

1
git reset --hard cf2e245

想要暂存 cf2e245 中的改动,并且不想马上提交:

1
git reset --soft cf2e245

想要把cf2e245 嫁接到某个分支目录下:

1
2
git checkout feat/xxx
git cherry-pick cf2e245

想要找回 cf2e245 某个文件的改动,暂存起来:

1
git checkout cf2e245 1.html

对于 git reflog 需要注意的是: 它不是万能的。Git 会定期清理那些你已经不再用到的“对象”,如果你想找到几个月以前的提交,可能会指望不上它。

文件修改错误

git reset 和 git checkout 命令接受文件路径作为参数。这时它的行为就大为不同了。它不会作用于整份提交,参数将它限制于特定文件。

Reset

当检测到文件路径时,git reset 将缓存区同步到你指定的那个提交。比如,下面这个命令会将倒数第二个提交中的 foo.py 加入到缓存区中,供下一个提交使用。

1
git reset HEAD~2 foo.py

和提交层面的 git reset 一样,通常我们使用HEAD而不是某个特定的提交。运行 git reset HEAD foo.py 会将当前的 foo.py 从缓存区中移除出去,而不会影响工作目录中对 foo.py 的更改。

将一个文件从 commit 历史中移动到 stage 缓存中

–soft、–mixed 和 –hard 对文件层面的 git reset 毫无作用,因为缓存区中的文件一定会变化,而工作目录中的文件一定不变。

Checkout

Checkout 一个文件和带文件路径 git reset 非常像,除了它更改的是工作目录而不是缓存区。不像提交层面的 checkout 命令,它不会移动 HEAD引用,也就是你不会切换到别的分支上去。

将文件从提交历史移动到工作目录中

比如,下面这个命令将工作目录中的 foo.py 同步到了倒数第二个提交中的 foo.py。

1
git checkout HEAD~2 foo.py

和提交层面相同的是,它可以用来检查项目的旧版本,但作用域被限制到了特定文件。

如果你缓存并且提交了 checkout 的文件,它具备将某个文件回撤到之前版本的效果。注意它撤销了这个文件后面所有的更改,而 git revert 命令只撤销某个特定提交的更改。

和 git reset 一样,这个命令通常和 HEAD 一起使用。比如 git checkout HEAD foo.py 等同于舍弃 foo.py 没有缓存的更改。这个行为和 git reset HEAD –hard 很像,但只影响特定文件。

参考:
https://zhuanlan.zhihu.com/p/42929114
http://blog.psjay.com/posts/git-revert-merge-commit/
https://github.com/geeeeeeeeek/git-recipes