热身运动

如果你没有设置一些惯用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操作:

[alias]
	fixup = "!f() { TARGET=$(git rev-parse "$1"); git commit --fixup=$TARGET ${@:2} && 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.

[email protected] 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, “[email protected]” 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. [email protected] Expands to the positional parameters, starting from one. When the expansion occurs within double quotes, each parameter expands to a separate word. $* vs. [email protected] The implementation of “$*” has always been a problem and realistically should have been replaced with the behavior of “[email protected]”. In almost every case where coders use “$*”, they mean “[email protected]”. “$*” Can cause bugs and even security holes in your software.

[email protected] 表示的是Bash里面的位置参数,但是它跟$*还是有些不同的。 简单来说,$*是单个的长字符串,而[email protected]实际上是一个数组。

所以, ${@:2} 实际上是访问 @这个数组, 根据Bash的语法, ${array[@]:first:length}, 由于这里省略了length,因此默认表示到末尾。

所以, ${@:2}表示从index为2开始的所有位置参数。

对于git fixup xxx来说,${@:0} 为整个命令,${@:1} 为除去git本身之后的部分,自然${@:2}就表示git命令的第二个(包含)及之后的参数,而这些参数都会传递给git commit命令。

然后,我们再看看--fixup参数的作用:

–fixup= 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/book/en/v2/Customizing-Git-Git-Configuration#_core_editor

core.editor By default, Git uses whatever you’ve set as your default text editor via one of the shell environment variables VISUAL or EDITOR, or else falls back to the vi editor to create and edit your commit and tag messages. To change that default to something else, you can use the core.editor setting: $ git config --global core.editor emacs Now, no matter what is set as your default shell editor, Git will fire up Emacs to edit messages.

当没有配置core.editor的时候,Git会自动从环境变量中取VISUALEDITOR的值来做编辑器,如果这两个都是空,它会退回到默认的vi. 反过来,当core.editor有显式地设置时,Git永远只会取core.editor设定的编辑器。

因此,为了使alias中的EDITOR=true生效,我们需要将core.editor配置项注释掉,或者直接删除,都 OK,如图:

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 \"[email protected]\" | 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"); git commit --fixup=$TARGET ${@:2} && 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