最近遇到一个 git 的问题:

我在某个文件里写了一段不应该提交上去的内容,没注意,提交上去了。

后来又提交了很多个 commit。

之后我发现了这个,又把它去掉了,提交了一个新的 commit。

398d359f31c6686b00e44175d65c2065.png

这样虽然新的 commit 没有这段内容了,但老的 commit 里依然有这个内容。

可我不想保留这段内容的记录,也就是想修改历史 commit。

这种问题大家会怎么解决呢?

我能想到的有三种方案,分别来试一下。

ff87d42c1ff6c803dae852a90ed2ad19.png

我们先创建了个 git 项目。

9212cf6f0ca66d9ece6230f53d280eb8.png

写了个 index.md,每行内容提交一个 commit。

git log 可以看到,一共 5 个 commit:

0c269a289772e515cc042c4715d5608f.png

git show 看下 222 和 333 那个 commit:

git show f5482b
d20693b923b9ae7764129d92cc2cce27.png 110fbba782472ad9d0969236f5e8b867.png

可以看到,这个 333 的 commit 就是我们想改掉的。

但是现在后面提交了 444、555 这俩 commit 了,怎么改掉它呢?

很容易想到的是 reset 到 333 那个 commit,重新提交,然后把后面的 commit 再一个个 cherry-pick 回去。

我们试一下:

首先把 444、555 这俩 commit 记下来,待会还要用

0d830230eded284f11f720e0d916abe4.png

然后 git reset 到 333 那个 commit:

git reset --hard 65dfee
8aa3ca7c2bef3e2ef049a998722b5c8e.png

把私密信息去掉,重新提交:

git add .
git commit --amend
166337726466d0f495642da0c58e14bf.png

这样,这个 commit 就干净了。

然后把后面的 444 和 555 再 cherry-pick 回来。

cherry-pick 就是单独取一个 commit 过来。

git cherry-pick 0b700f
4228633988753a98700537c6b515c047.png

会有冲突,解决之后 continue 就好:

git add .
git cherry-pick --continue
acec3671e711b07ae32752736067ea10.png

再 cherry-pick 555 的 commit 的时候依然有冲突,因为历史 commit 改了:

75ae262ee0f42e7068e5dc3333aeb1cf.png

同样是解决之后重新 add 和 cherry-pick --continue

6982eabebd8a2b277abea48ed93e284a.png

这样再看下 333 那个 commit,就干净了:

git show 9aded3
b78108c1c47172bd322805309dd9707d.png

不过这样还是挺麻烦的,git reset 到那个 commit,修改之后重新提交。

之后 cherry-pick 每个 commit 的时候都需要解决一次冲突,因为历史 commit 变了。

当 commit 多的时候就不合适了。

这时候可以用第二种方案:git rebase。

很多同学只会 git merge 不会 git rebase,其实这个很简单。

merge 就是只合并最新 commit,所以只要解决一次冲突,然后生成一个新的 commit 节点。

aede85af5437033f0e38e1ee1d504cfa.png

而 rebase 则是把所有 commit 按顺序一个个的合并,所以可能要解决多次冲突,但不用生成新 commit 节点。

ddbcac543c2ce9ef45f624e4cd5a8d86.png

merge 是合并最新的,所以只要处理一次就行。

rebase 是要一个个 commit 合并,所以要处理多次。

rebase 除了用来合并两个分支外,还可以在某个分支回到某个 commit,把后面 commit 重新一个个合并回去。

很适合用来解决我们这个问题。

首先回到初始状态:

0c38bba53625b144a8c9ab86cde1b11a.png

然后找到 222 的 commit:

git rebase -i f5482ba

这样就是重新处理从 333 到 HEAD 的 commit,一个个合并回去。

-i 是交互式的合并。

87d3c52cf1c5dd10c04a35cd3f90a04f.png

可以看到,三个 commit 都列了出来,前面的 pick 就是指定怎么处理这个 commit。

下面有很多命令:

pick 是原封不动使用这个 commit

reword 是使用这个 commit,但是修改 commit message

edit 是使用这个 commit,但是修改这个 commit 的内容,然后重新 amend。

squash 是合并这个 commit 到之前的 commit

后面的命令就不看了,很明显,这里我们要用的是 edit 命令。

10daa4901689c4f69707b454df396383.png

改成 edit,然后输入 :wq 退出

提示现在停在了 333 这个 commit,你可以修改之后重新 commit --amend:

d60735ea7e7a6fbb3414af740aef925c.png

之后再 rebase --continue 继续处理下个 commit。

c417c3839bc15f17a67a21813f81bccc.png

这时候会提示冲突,因为历史 commit 变了。

解决之后,重新 add、commit。

452900defb734700fedf1fc654840e97.png

然后 git rebase --continue 继续处理下个 commit:

9f356a5b2d28c104b4e17b1f6f84e9ef.png

历史 commit 变了,依然会冲突。

合并之后重新 add、commit.

588cfa1e0a37c0f8037f9ecde4f18aa6.png

然后再次 git rebase --continue

0c4b0ae01e61b3f3428691fc09ecac34.png

因为所有 commit 都处理完了,这时候会提示 rebase 成功。

这时候 git show 看下 333 那个 commit,就已经修改了:

f8ca668faeb127101426fcafb7ced322.png

大家有没有发现,其实 git rebase 和我们第一种方案 git reset 回去再一个个 cherry-pick 是一样的?

确实,其实 git rebase 就是对这个过程的封装,提供了一些命令。

你完全可以用 cherry-pick 处理一个个 commit 来代替 git rebase。

这两种方案都要解决冲突,还是挺麻烦的。

又没有什么不用解决冲突的方案呢?

有,就是 filter-branch。

它可以在一系列 commit 上自动执行脚本。

比如 --tree-filter 指定的脚本就是用来修改 commit 里的文件的。

我们再回到初始状态:

63ef5273183aa97dff8d475c90898d46.png

创建了一个 script.js

const fs = require('fs');

const content = fs.readFileSync('./index.md', {
    encoding: 'utf-8'
});

console.log(content);

就是读取 index.md 的内容并打印。

然后执行 filter-branch 命令:

git filter-branch --tree-filter 'node 绝对路径/script.js' 9aded3..HEAD

这里指定用 --tree-filter,也就是处理每个 commit 的文件,执行 script 脚本。

也就是从 222 那个 commit 到当前 HEAD 的 commit,每个 commit 执行一次 script 脚本。

大家觉得执行结果一样么?

b1d5fa1905a0d0348073729e118cb584.png

答案是不一样,因为每个 commit 这个文件的内容不同。

那我们在这个 script 里改变了文件的内容不就行了?

const fs = require('fs');

const content = fs.readFileSync('./index.md', {
    encoding: 'utf-8'
});

const newContent = content.replace('私密信息', '');

fs.writeFileSync('./index.md', newContent);

再跑下试试:

a82aaabb6c159201666e6eaf80b1b482.png

执行成功,提示 main 分支已经被重写了。

然后我们再 git show 看下 333 那个 commit

0cd64d497841ba1183d12f7669b0e7a9.png 7a1817b1ec888b11201a2249e0552c9d.png

确实去掉了私密信息。

再看看 444 的 commit:

7427c8415d8c7a7e213a313460598db7.png

这就是 filter-branch 的方案。

相比 reset + cherry-pick 或者 rebase 的方案,这种不需要一个个合并 commit,解决冲突。

只需要写个修改内容的脚本,然后自动执行脚本来改 commit 就行,便捷很多。

但是,要注意的是,改历史 commit 肯定是需要 git push -f 才能推到远程仓库的。

而改了历史 commit 的结果我们也都看到了,需要把后面的 commit 一个个重新合并,解决冲突。

这对于多人合作的项目来说,是很不好的。

可以让所有组员先把代码 push,在修改完历史 commit 之后,再重新 clone 代码就好了。

总结

当你不小心把私密信息提交到了某个历史 commit,就需要修改这个 commit 去掉私密信息。

我们尝试了 3 种方案:

第一种是 git reset --hard 到那个分支,然后改完之后 git commit --amend,之后再把后面的 commit 一个个 cherry-pick 回来。

第二种是 git rebase -i 这些 commit,它提供了一些命令,比如 pick 是使用这个 commit,edit 是重新修改这个 commit。我们在要改的那个 commit 使用 edit 命令,之后 git rebase --continue,依次处理后面的 commit。

其实 reabse 就是对 cherry-pick 的封装,也就是自动处理一个个 commit。

但不管是 cherry-pick 还是 rebase ,合并后面的 commit 的时候都需要解决冲突,因为改了历史 commit 肯定会导致冲突。

第三种方案是用 filter-branch 的 --tree-filter,他可以在多个 commit 上自动执行脚本,你可以在脚本里修改文件内容,这样就不用手动解决冲突了,可以批量修改 commit。

但改了历史 commit 需要 git push -f,如果大项目需要这么做,要提前和组员共同好,先把代码都 push,然后集中修改,之后再重新 clone。

这就是修改历史 commit 的 3 种方案,你还有别的方案么?

Logo

瓜分20万奖金 获得内推名额 丰厚实物奖励 易参与易上手

更多推荐