- Published on
超实用的 Git fixup 神技 -- 一键修复任意commit
- Authors
- Name
- ttyS3
热身运动
如果你没有设置一些惯用alias, 比如ci
之类的,自行将ci
替换成 commit
。
像命令中用到的lg1
别名,请直接跳到文章最后取配置。
为了方便理解,老灯会创建一个简单的Git仓库,用如下命令可完成:
# 新建一个演练仓库
mkdir gittest
cd gittest
# 随便添加一个文件
touch hello.txt
git add hello.txt
git ci -m 'add hello.txt'
# 再随便添加一个文件
touch world.txt
git add world.txt
git ci -m 'add world.txt'
# 现在的提交是这样的
❯ git lg1
* 95863b6 - (62 seconds ago) add world.txt - 荒野無燈 (HEAD -> master)
* a956ae3 - (72 seconds ago) add hello.txt - 荒野無燈
amend神技
我们先熟悉下--amend
的用法,这个也是老灯日常开发中用得特别多的一个参数。
# 现在我们要修改最后这个提交,比如突然想起来, world.txt 文件里我们应该写一个 "test"
echo test > ./world.txt
# 添加进stage
git add -u
# 提交
git ci --amend
# 然后我们再看看, 发现最后那个提交的commit id变了, 并且在这个提交里world.txt文件默认有内容 "test"
# 可以用git show查看, 没错,这就是`--amend`的神奇效果
❯ git lg1
* c17a18a - (4 minutes ago) add world.txt - 荒野無燈 (HEAD -> master)
* a956ae3 - (4 minutes ago) add hello.txt - 荒野無燈
但是,人是很容易犯错误的, 特别是有时候,你已经提交过两次了,才发现上上个提交还有东西没有加上。 此时你又不想再提交一个新的commit, 因为这样整个commit就不整洁了,并且,提交记录看起来会很散乱。
为了接下来的演示,我们要在之前的两个提交的基础上再加一个:
touch README.md
git add README.md
git ci -m 'add README.md'
# 现在的仓库看起来像这样了
❯ git lg1
* 76091ea - (42 seconds ago) add README.md - 荒野無燈 (HEAD -> master)
* c17a18a - (13 minutes ago) add world.txt - 荒野無燈
* a956ae3 - (13 minutes ago) add hello.txt - 荒野無燈
commit拆分大法的歪用
没错,买一送一啦。取fixup大法,还顺便送了个Git commit拆分大法。
在没有看到这个tips之前,老灯先说一下之前一直在用的"超级麻烦做法"(实际上是对"Git commit拆分大法"的应用):
# <commit>是需要修改的那个提交, 然后标记<commit>为 edit (改写为`e`即可)
git rebase -i <commit>^
git reset HEAD^
# 进行修改操作(或者commit拆分操作)
# 添加需要提交的文件到index
git add 需要添加的文件
# 提交修改
git ci -m 'blah blah'
# ... 继续添加第二个commit,如果有需要(当然,在拆分时肯定会有第2个commit了)
# 完成commit之后,继续rebase
git rebase --continue
具体到我们这个demo仓库,则是:
# 我们要修改的是 c17a18a
git rebase -i c17a18a^
然后Git会调用默认文件编辑器,弹出以下类似的内容:
pick c17a18a add world.txt
pick 76091ea add README.md
# Rebase a956ae3..76091ea onto a956ae3 (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
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# . create a merge commit using the original merge commit's
# . message (or the oneline, if no original merge commit was
# . specified). Use -c <commit> to reword the commit message.
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
我们把c17a18a
标记为 edit (改写为e
即可), 然后保存:
e c17a18a add world.txt
pick 76091ea add README.md
然后执行git reset HEAD^
就可以开始修改了, 比如,随便改点东西:
echo 'test older commit fixup' > world.txt
提交:
git add world.txt
git ci -m 'udpate world.txt'
❯ git rebase --continue
Successfully rebased and updated refs/heads/master.
# 现在我们再看看commit history
❯ git lg1
* 3562e20 - (10 minutes ago) add README.md - 荒野無燈 (HEAD -> master)
* ade3438 - (57 seconds ago) update world.txt - 荒野無燈
* a956ae3 - (23 minutes ago) add hello.txt - 荒野無燈
# git show ade3438 可以看到如下内容
diff --git a/world.txt b/world.txt
new file mode 100644
index 0000000..ea956fe
--- /dev/null
+++ b/world.txt
@@ -0,0 +1 @@
+test older commit fixup
OK了, 目的是达到了。但是,是不是特别麻烦?
fixup神技出世
我们给Git配置文件(.gitconfig
)增加一个叫fixup
的alias操作:
老灯修正: Debian/Ubuntu 默认使用Dash(POSIX兼容)作为默认的sh, 不像Fedora/CentOS 使用的是bash(支持一些非POSIX的特性), 因此为了兼容性更好,我们不能使用非POSIX的
${@:2}
GIT_EDITOR
的优先级最高,原EDITOR=true
换成GIT_EDITOR=true
兼容性更好
[alias]
#fixup = "!f() { TARGET=$(git rev-parse "$1"); git commit --fixup=$TARGET ${@:2} && EDITOR=true git rebase -i --autostash --autosquash $TARGET^; }; f"
fixup = "!f() { TARGET=$(git rev-parse \"$1\"); shift; git commit --fixup=$TARGET ${@} && GIT_EDITOR=true git rebase -i --autostash --autosquash $TARGET^; }; f"
有了这个fixup
之后,我们可以非常方便的修改任意提交.
操作步骤:
- 做修改
- git add -u
git fixup 需要修改的commit id
没错, 三步就搞定了。
好了,现在假设我们还是要修改关于world.txt那个提交。我们可以直接就开始修改了:
echo 'quick fixup demo' > world.txt
git add -u
# 先看一下我们要修改的是哪个commit
❯ git lg1
* 3562e20 - (16 minutes ago) add README.md - 荒野無燈 (HEAD -> master)
* ade3438 - (7 minutes ago) update world.txt - 荒野無燈
* a956ae3 - (29 minutes ago) add hello.txt - 荒野無燈
# OK, 找到了,我们要修改的是 ade3438
❯ git fixup ade3438
[master 6b1d0b1] fixup! update world.txt
1 file changed, 1 insertion(+), 1 deletion(-)
Successfully rebased and updated refs/heads/master.
# 再看下commit history, 可以看到被修改的commit及其之后的commit的commit id全rewrite了
❯ git lg1
* a2bb0e7 - (17 minutes ago) add README.md - 荒野無燈 (HEAD -> master)
* 6b02dd5 - (8 minutes ago) update world.txt - 荒野無燈
* a956ae3 - (29 minutes ago) add hello.txt - 荒野無燈
# git show 6b02dd5 确认下修改效果
diff --git a/world.txt b/world.txt
new file mode 100644
index 0000000..5db415f
--- /dev/null
+++ b/world.txt
@@ -0,0 +1 @@
+quick fixup demo
这个超级实用的alias我是在 https://blog.filippo.io/git-fixup-amending-an-older-commit/ 看到的。
作者是意大利籍的Filippo Valsorda, Google安全领域的专家,Go 团队的 security lead.
use git fixup COMMIT to change a specific "COMMIT", exactly like you would use
git commit --amend
to change the latest one. You can use allgit commit
arguments, like-a
,-p
and filenames. It will respect your index, so you can use git add. It won't touch the changes you are not committing.
没错,除了刚才的用法,这个fixup
还可以加参数,比如:
git fixup HEAD^ Makefile
工作原理
我们把这个命令拆解开来, 可以发现实际上它是两条Git命令的结合。
TARGET=$(git rev-parse "$1"); \
git commit --fixup=$TARGET ${@:2} && \
EDITOR=true git rebase -i --autostash --autosquash $TARGET^
第一行TARGET=$(git rev-parse "$1")
只是设置一下环境变量,它记住了我们要修改的是哪个commit. 注意这里用到了rev-parse
解析出commit id, 因为我们输入的需要修改的commit, 可能是 @
或 HEAD
, 或 @^
及 @^^
这样的东西。 而HEAD^
或 @^
之类的会在真实的HEAD变动时其值随之变动的,所以,用rev-parse
是必要的。要不然我们后面要用到$TARGET^
参数值就可能是错的。
要理解git commit --fixup=$TARGET ${@:2}
, 首先我们要理解${@:2}
是个啥。
根据文档 http://tldp.org/LDP/abs/html/internalvariables.html#APPREF
$#
Number of command-line arguments [4] or positional parameters (see Example 36-2)
$*
All of the positional parameters, seen as a single word Note: "$*
" must be quoted.
$@
Same as$*
, but each parameter is a quoted string, that is, the parameters are passed on intact, without interpretation or expansion. This means, among other things, that each parameter in the argument list is seen as a separate word. Note: Of course, "$@
" should be quoted.
又见 http://tldp.org/LDP/Bash-Beginners-Guide/html/sect_03_02.html#sect_03_02_05
$*
Expands to the positional parameters, starting from one. When the expansion occurs within double quotes, it expands to a single word with the value of each parameter separated by the first character of the IFS special variable.$@
Expands to the positional parameters, starting from one. When the expansion occurs within double quotes, each parameter expands to a separate word.$*
vs.$@
The implementation of "$*
" has always been a problem and realistically should have been replaced with the behavior of "$@
". In almost every case where coders use "$*
", they mean "$@
". "$*
" Can cause bugs and even security holes in your software.
$@
表示的是Bash里面的位置参数,但是它跟$*
还是有些不同的。 简单来说,$*
是单个的长字符串,而$@
实际上是一个数组。
所以, ${@:2}
实际上是访问 @
这个数组, 根据Bash的语法, ${array[@]:first:length}
, 由于这里省略了length,因此默认表示到末尾。
所以, ${@:2}
表示从index为2开始的所有位置参数。
对于git fixup xxx
来说,${@:0}
为整个命令,${@:1}
为除去git
本身之后的部分,自然${@:2}
就表示git命令的第二个(包含)及之后的参数,而这些参数都会传递给git commit
命令。
然后,我们再看看--fixup
参数的作用:
--fixup=<commit>
Construct a commit message for use with rebase --autosquash. The commit message will be the subject line from the specified commit with a prefix of "fixup! ". See git-rebase(1) for details.
它是用于构造一个commit消息给rebase --autosquash
使用的,这个commit消息的subject(标题)为指定commit的标题前面加上 "fixup! ".
比如commit 6b02dd55fcc392fac951e7cba6b60cf1bd60d427
的subject是update world.txt
, 我们执行一下,subject就会自动变成fixup! update world.txt
, 如:
❯ git commit --fixup=6b02dd55fcc392fac951e7cba6b60cf1bd60d427
[master 66f03bc] fixup! update world.txt
1 file changed, 1 insertion(+)
然后,EDITOR=true git rebase -i --autostash --autosquash $TARGET^
启动了一个交互式的rebase
操作(git rebase -i
), 并且带了--autosquash
参数,它会标记所有提交信息里有fixup! FOO
之类的字符的commit自动以fixup
的方式合并到名为FOO
的commit记录. fixup
跟squash
类似,只不过fixup
会忽略提交信息。没错,这个--autosquash
就是为git commit --fixup
而生的。 如果你不是经常做rebase操作,可能会不是很容易理解这里。
--autostash
的作用又是啥呢?它确保在rebase之前所有未提交的修改都自动stash
, 而在rebase完成之后,这些修改又会自动从stash栈里面弹出来。 (没错,又涉及到stash命令了,如果不太了解,赶紧查手册看看吧)
rebase操作都要有一个base commit的,一般来说,这个base就是我们要操作的那个提交的前一个提交,因此是$TARGET^
(^
符号表示在它左边的commit往前一个提交)。
EDITOR=true
在这里也是一个绝妙的存在。别看它看上去不起眼,好像在设置一个什么东西为“真”一样。如果你这样想可错了。 Git在进行交互式rebase的时候,需要调用编辑器弹出一个列表,让你编辑哪个commit要f
(fixup), 哪个要s
(squash),哪个要直接删除等。 因此,这里实际上是临时把Git的编辑器设置成了一个名为true
的命令:
❯ which true
/usr/bin/true
可以看到,在一般的Linux发行版里,这个命令的位置为/usr/bin/true
,但是也有可能有不同,因此这里没有使用绝对路径,而是直接给出了命令名称。 true
命令的运行效果就是它执行完马上退出了,其退出代码为 0 (跟false正好相反,false是exit code 为1)。 这里我们当然不能用false,因为false会返回1, Git会认为这个编辑器出问题,意外退出了.
另外,这里之所以能用EDITOR=true
的原因是,所有信息都不需要我们再次编辑,Git原来也只是给你看一眼而已,然后让你保存退出(由于不存在修改,其实也不存在要保存)。 这跟我们手动rebase的情况是不同的。
所以,这里EDITOR=true
的作用是,避免Git给你显示一个交互式rebase操作的列表,然后还要你手动按一次w
保存。
然后,将这些命令组合起来,包裹成一个Bash 函数, 因为只有这样,才方便访问像 $1
和 ${@:2}
之类的位置参数。
最后,在最前面加一个!
, 它就变成了一个shell alias了,能为Git所用了.
Troubleshoot
交互式窗口还是弹出来了
也许你也是像老灯一样,给Git显式地指定了editor, 然后发现,EDITOR=true
没生效啊,Git还是每次在我fixup
时弹出一个黑东东来烦我。 老灯起初也是郁闷了好久,直到有一天突然想明白,文件配置的优先级,可能比环境变量配置的优先级要高。
我们还是看下文档确认下吧 https://git-scm.com/docs/git-var#Documentation/git-var.txt-GITEDITOR
GIT_EDITOR
Text editor for use by Git commands. The value is meant to be interpreted by the shell when it is used. Examples: ~/bin/vi,
$SOME_ENVIRONMENT_VARIABLE
, "C:\Program Files\Vim\gvim.exe" --nofork. The order of preference is the VISUAL, then $EDITOR, and then the default chosen at compile time, which is usually vi.
根据Git文档,这个编辑器配置的优先级是:
GIT_EDITOR > core.editor > VISUAL > EDITOR > vi
因此,为了在alias中临时地使用true
作为编辑器,我们应该修正一下这个alias, 将EDITOR=true
替换成GIT_EDITOR=true
, 问题解决。
Git commit id被重写
没错,由于这里实际上是一个自动的rebase操作,所以你要知晓它的副使用:
所有在$TARGET
之后(当然包括$TARGET
本身)的commit id都将被重写. 这是git rebase的特性,不是bug.
如果你介意commit id的改变,此时你可能不会想要用这一招。
需要注意的是,如果某些commit已经被push到了远程仓库,那么这些commit最好不要再进行rebase, 除非你确认了团队中没有任何人pull了那些提交, 否则即使你用了强制push(-f
参数)把rebase后重写之后的记录提交上去了,其它人在pull这些代码的时候也有可能会遇到无尽的冲突。
常用alias
你上面命令中的ci
啊,lg1
啊,是个啥东西啊?
没错,这些是老灯日常使用的alias, 这里老灯也干脆一并把它们分享了。
注意,pager这里老灯使用了diffr
difftool
和 merge
老灯默认用了开源的meld
所有配置仅供参考,除非你明白你在做什么,否则不要直接copy全部配置。
[user]
name = 荒野無燈
email = 不告诉你
signingkey = 不告诉你
[color]
ui = true
[core]
# editor = vim -f
#pager = $HOME/.yarn/bin/diff-so-fancy | less --tabs=4 -RFX
autocrlf = false
quotepath = false
[pager]
log = diffr | less -RFX
show = diffr | less -RFX
diff = diffr | less -RFX
branch = less -RFX
tag = less -RFX
[interactive]
diffFilter = diffr
[diff]
tool = bc3
renameLimit = 37901
[difftool]
prompt = false
[difftool "meld"]
#path = /usr/bin/meld
cmd = meld $LOCAL $REMOTE
[difftool "bc3"]
path = /usr/bin/bcompare
[difftool "vimdiff"]
cmd = gvimdiff $REMOTE $LOCAL $BASE
[merge]
tool = meld
[mergetool]
prompt = false
keepbackup = false
[mergetool "bc3"]
path = /usr/bin/bcompare
trustExitCode = true
[mergetool "meld"]
path = /usr/bin/meld
trustExitCode = true
keepBackup = false
#the first is the default
#cmd = meld "$LOCAL" "$BASE" "$REMOTE" --output "$MERGED"
cmd = meld $LOCAL $MERGED $REMOTE --output $MERGED
[alias]
co = checkout
ci = commit
st = status
stn = status -uno
br = branch
cp = cherry-pick
unstage = reset HEAD --
dt = difftool
dtd = difftool --dir-diff
mt = mergetool
last = log -1 HEAD
lg1 = log --graph --abbrev-commit --decorate --date-order --date=relative --format=format:'%C(bold blue)%h%C(reset) - %C(bold green)(%ar)%C(reset) %C(white)%s%C(reset) %C(dim white)- %aN%C(reset)%C(bold yellow)%d%C(reset)' --all
lg2 = log --graph --abbrev-commit --decorate --date-order --format=format:'%C(bold blue)%h%C(reset) - %C(bold cyan)%aD%C(reset) %C(bold green)(%ar)%C(reset)%C(bold yellow)%d%C(reset)%n'' %C(white)%s%C(reset) %C(dim white)- %aN%C(reset)' --all
lg = !git lg1
up = "!git remote update -p; git merge --ff-only @{u}"
zipmod = !git archive -o update.zip HEAD $(git diff --name-only HEAD^)
dsf = "!f() { [ -z \"$GIT_PREFIX\" ] || cd \"$GIT_PREFIX\" && git diff --color \"$@\" | diff-so-fancy | less --tabs=4 -RFX; }; f"
findandblame = "!f() { git blame $(git log --pretty=%H --diff-filter=AM -1 -- \"$1\") -- \"$1\"; }; f"
find-merge = "!sh -c 'commit=$0 && branch=${1:-HEAD} && (git rev-list $commit..$branch --ancestry-path | cat -n; git rev-list $commit..$branch --first-parent | cat -n) | sort -k2 -s | uniq -f1 -d | sort -n | tail -1 | cut -f2'"
show-merge = !sh -c 'merge=$(git find-merge $0 $1) && [ -n \"$merge\" ] && git show $merge'
lm = log --use-mailmap
pushall = !git remote | xargs -L1 git push --all
cs = commit --signoff
# https://blog.filippo.io/git-fixup-amending-an-older-commit/
fixup = "!f() { TARGET=$(git rev-parse \"$1\"); shift; git commit --fixup=$TARGET ${@} && GIT_EDITOR=true git rebase -i --autostash --autosquash $TARGET^; }; f"
[pull]
# rebase = true
[color "diff-highlight"]
oldNormal = red bold
oldHighlight = red bold 52
newNormal = green bold
newHighlight = green bold 22
[filter "lfs"]
clean = git-lfs clean -- %f
smudge = git-lfs smudge -- %f
process = git-lfs filter-process
required = true
[filter "dater"]
smudge = expand_date
clean = perl -pe \"s/\\\\\\$Date[^\\\\\\$]*\\\\\\$/\\\\\\$Date\\\\\\$/\"
[gc]
autoDetach = false
#[url "[email protected]:"]
# insteadOf = https://github.com/
# git clone https://bitbucket.org/ 时默认替换成ssh访问
# [url "[email protected]:"]
# insteadOf = https://bitbucket.org/
# http 代理设置
[http]
lowSpeedLimit = 1000
lowSpeedTime = 300
proxy = http://127.0.0.1:7070
sslVerify = false
# [commit]
# gpgsign = true
[tag]
forcesignannotated = true
参考文档
https://blog.filippo.io/git-fixup-amending-an-older-commit/
http://stackoverflow.com/questions/6217156/break-a-previous-commit-into-multiple-commits
http://tldp.org/LDP/abs/html/internalvariables.html#APPREF
http://tldp.org/LDP/Bash-Beginners-Guide/html/sect_03_02.html#sect_03_02_05
https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration#_core_editor