Published on

超实用的 Git fixup 神技 -- 一键修复任意commit

Authors
  • avatar
    Name
    ttyS3
    Twitter

热身运动

如果你没有设置一些惯用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之后,我们可以非常方便的修改任意提交.

操作步骤:

  1. 做修改
  2. git add -u
  3. 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 all git 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记录. fixupsquash类似,只不过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 GITEDITORenvironmentvariable,thencore.editorconfiguration,thenGIT_EDITOR environment variable, then core.editor configuration, then 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

difftoolmerge 老灯默认用了开源的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