前言

Git 作为全球软件开发者的标配代码管理工具,是程序员离不开的日常伙伴,除了基本的几条命令外,git其实还有很多日常会用到的option,这里以我的个人经验做个总结

Git Merge vs Git rebase

我们要知道的第一件事是,git rebasegit merge 做的事其实是一样的;它们都被设计来将一个分支的更改并入另一个分支,只不过方式有些不同

想象一下,你刚创建了一个专门的分支开发新功能,然后团队中另一个成员在 master 分支上添加了新的提交。这就会造成提交历史被 fork 一份,用 Git 来协作的开发者应该都很清楚

现在,如果 master 中新的提交和你的工作是相关的;为了将新的提交并入你的分支,你有两个选择:merge 或 rebase

Merge

将 master 分支合并到 feature 分支最简单的办法就是用下面这些命令:

1
2
git checkout feature
git merge master

或者,你也可以把它们压缩在一行里

1
git merge master feature

最终创建出这样的 workflow:

image-20220926153512343

Rebase

作为 merge 的替代选择,你可以像下面这样将 feature 分支并入 master 分支:

1
2
git checkout feature
git rebase master

它会把整个 feature 分支移动到 master 分支的后面,有效地把所有 master 分支上新的提交并入过来。但是,rebase 为原分支上每一个提交创建一个新的提交,重写了项目历史,并且不会带来合并提交

rebase最大的好处是你的项目历史会非常整洁。首先,它不像 git merge 那样引入不必要的合并提交。其次,如上图所示,rebase 导致最后的项目历史呈现出完美的线性——你可以从项目终点到起点浏览而不需要任何的 fork。这让你更容易使用 git loggit bisectgitk 来查看项目历史

rebase 创建出的 workflow 是像这样的:

image

Rebase的黄金准则

首先一定要注意的一点是:永远不要在公共/共享的分支上使用git rebase(尤其是对于主干分支保护不好的仓库); rebasemerge最大的一点不同就是作用的对象不同,rebase是作用于"rebase on"的分支的,而merge是作用于当前分支的

假设现在Bob和Anna在同一个项目组中工作,项目所属的仓库和分支大概是下图这样:

rebase-1

现在Bob为了图一时方便打破了原则,正巧这时Anna在特征分支上进行了新的提交,此时的结构图大概是这样的:

rebase-2

当Bob推送自己的分支到远端的时候,现在的分支情况如下:

rebase-3

然后呢,当Anna也进行推送的时候,她会得到如下的提醒,Git提醒Anna她本地的版本与远程分支并不一致,需要向远端服务器拉取代码进行同步:

rebase-4

在Anna提交之前,分支中的Commit序列是如下这样的:

1
2
3
A--B--C--D'   origin/feature // GitHub

A--B--D--E feature // Anna

在进行Pull操作之后,Git会进行自动地合并操作,结果大概是这样的:

rebase-5

这个第M个提交即代表着合并的提交,也就是Anna本地的分支与Github上的特征分支最终合并的点,现在Anna解决了所有的合并冲突并且可以Push她的代码,在Bob进行Pull之后,每个人的Git Commit结构为:

rebase-6

看到上面这个混乱的流线图,相信你对于Rebase和所谓的黄金准则也有了更形象深入的理解

假设下还有一哥们Emma,第三个开发人员,在他进行了本地Commit并且Push到远端之后,仓库变为了:

rebase-7

  • 这还只是仅有几个人,一个特征分支的项目因为误用rebase产生的后果。如果你团队中的每个人都对公共分支进行rebase操作,那么后果就是乱成一片
  • 另外,相信你也注意到,在远端的仓库中存有大量的重复的Commit信息,这会大大浪费我们的存储空间
  • 因此,**不能在一个共享的分支上进行Git rebase操作,避免出现项目分支代码提交记录错乱和浪费存储空间的现象

总结

  • 融合代码到公共分支的时使用git merge,而不用git rebase
  • 融合代码到个人分支的时候使用git rebase,可以不污染分支的提交记录,形成简洁的线性提交历史记录

Git submodule

当你在一个Git 项目上工作时,你需要在其中使用另外一个Git 项目。也许它是一个第三方开发的Git 库或者是你独立开发和并在多个父项目中使用的。这个情况下一个常见的问题产生了:你想将两个项目单独处理但是又需要在其中一个中使用另外一个

在Git 中你可以用子模块submodule来管理这些项目,submodule允许你将一个Git 仓库当作另外一个Git 仓库的子目录。这允许你克隆另外一个仓库到你的项目中并且保持你的提交相对独立

添加子模块

此文中统一将远程项目https://github.com/maonx/vimwiki-assets.git克隆到本地assets文件夹

1
git submodule add https://github.com/maonx/vimwiki-assets.git assets

添加子模块后运行git status, 可以看到目录有增加1个文件.gitmodules, 这个文件用来保存子模块的信息

1
2
3
4
5
6
7
8
9
10
git status
On branch master

Initial commit

Changes to be committed:
(use "git rm --cached <file>..." to unstage)

new file: .gitmodules
new file: assets

查看子模块

1
2
git submodule
e33f854d3f51f5ebd771a68da05ad0371a3c0570 assets (heads/master)

更新子模块

  • 更新项目内子模块到最新版本

    1
    git submodule update
  • 更新子模块为远程项目的最新版本

    1
    git submodule update --remote

克隆包含子模块的项目

克隆包含子模块的项目有二种方法:一种是先克隆父项目,再更新子模块;另一种是直接递归克隆整个项目。

克隆父项目,再更新子模块

克隆父项目

1
git clone https://github.com/maonx/vimwiki-assets.git assets

查看子模块

1
2
git submodule
-e33f854d3f51f5ebd771a68da05ad0371a3c0570 assets

子模块前面有一个-,说明子模块文件还未检入(空文件夹)。

初始化子模块

1
2
git submodule init
Submodule 'assets' (https://github.com/maonx/vimwiki-assets.git) registered for path 'assets'

初始化模块只需在克隆父项目后运行一次

更新子模块

1
2
3
4
5
6
7
8
9
git submodule update
Cloning into 'assets'...
remote: Counting objects: 151, done.
remote: Compressing objects: 100% (80/80), done.
remote: Total 151 (delta 18), reused 0 (delta 0), pack-reused 70
Receiving objects: 100% (151/151), 1.34 MiB | 569.00 KiB/s, done.
Resolving deltas: 100% (36/36), done.
Checking connectivity... done.
Submodule path 'assets': checked out 'e33f854d3f51f5ebd771a68da05ad0371a3c0570'

递归克隆整个项目

1
git clone https://github.com/maonx/vimwiki-assets.git assets --recursive 

递归克隆整个项目,子模块已经同时更新了,一步到位

修改子模块

在子模块中修改文件后,直接提交到远程项目分支

1
2
3
git add .
git ci -m "commit"
git push origin HEAD:master

删除子模块

删除子模块比较麻烦,需要手动删除相关的文件,否则在添加子模块时有可能出现错误
同样以删除assets文件夹为例:

删除子模块文件夹

1
2
git rm --cached assets
rm -rf assets

删除.gitmodules文件中相关子模块信息

1
2
3
[submodule "assets"]
path = assets
url = https://github.com/maonx/vimwiki-assets.git

删除.git/config中的相关子模块信息

1
2
[submodule "assets"]
url = https://github.com/maonx/vimwiki-assets.git

删除.git文件夹中的相关子模块文件

1
rm -rf .git/modules/assets

Git cherry-pick & Git stash

cherry-pick

在我们使用Git进行日常开发的过程中,常常需要进行的操作就是代码合并了。常见的操做命令是 git merge branch-name,这个命令会合并的是整个分支的commit,然而有时候我们需要的可能是仅仅某一个 commit或者某几个commit,这时候就需要用到git cherry-pick

git cherry-pick的作用就如它的名字一样,精心挑选。我们可以精心挑选其他分支上的 commit 合并到当前的分支上来

原理

git cherry-pick 可以把其他分支的某个commit应用到当前分支,并且自动生成一个新的 commit 进行提交,因此这两次commit的哈希值是不一样的,属于不同的commit

基本用法

单个commit合并

1
git cherry-pick commit-hash/branch-name

如果使用的是哈希值,则会把对应的commit合并过来,如果是分支名,则会把对应分支的**最新一次commit**合并过来。

多个commit合并

1
2
3
4
5
6
7
8
9
10
11
12
# 1、 分散的commit
git cherry-pick commit-hash1 commit-hash2

# 2、连续的commit
# Git 1.7.2 版本以后,新增了支持批量cherry-pick
# 可以将一个连续的时间序列内的连续commit,进行cherry-pick操作。

# 合并(start,end]之间的提交,不包含start
git cherry-pick start-commit-hash..end-commit-hash

# 合并[start,end]之间的提交,包含start
git cherry-pick start-commit-hash^..end-commit-hash

注意
无论是对单个 commit 进行 cherry-pick ,还是批量处理,注意一定要根据时间线,依照 commit 的先后顺序来处理,否则会有意想不到的问题。

如何处理冲突

代码合并不可避免的就是会遇到代码冲突了,git merge会遇到冲突,同样的git cherry-pick也会遇到代码冲突,那么遇到代码冲突的时候,该如何处理呢?

遇到冲突的时候,Git会给出报错信息,并停下来,要求用户解決 conflict 的问题。Git会把所有冲突的文件列在Unmerged paths的地方,可以通过git status查看,如下图
在这里插入图片描述
此时我们有以下处理方案:

  • 解决冲突
    • 修改冲突的地方,并通过命令git add .把文件重新加入暂存区。
    • 继续合并,git cherry-pick --continue
  • 回退所有修改:git cherry-pick --abort,此时会回到操作前的样子
  • 单纯退出cherry-pickgit cherry-pick --quit,此时不会回到操作前的状态

stash

当我们在需要紧急处理一个bug时,工作区内容又没有完成,不适合提交,这时候就需要使用git stash暂存起来

git stash 命令获取我们未提交的更改(暂存和未暂存),将它们保存以备后用,然后从工作副本中恢复它们

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

new file: style.css

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: index.html

$ git stash
Saved working directory and index state WIP on master: 4b56b56 init

$ git status
On branch master
nothing to commit, working tree clean

在我们完成了临时的工作后,我们可以使用git stash pop命令重新应用之前暂存的更改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ git stash pop
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

new file: style.css

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: index.html

Dropped refs/stash@{0} (f6f919bf50879b333b7fdc8ea3cb65ced20fce58)

或者,我们可以使用 git stash apply 将之前隐藏的更改重新应用到工作副本,但是和 git stash pop 不同, 并不会将他们从stash中删除

1
2
3
4
5
6
7
8
9
10
11
12
$ git stash apply
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

new file: style.css

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: index.html

Git revert & Git reset

revert

revert 会通过创建新的提交来撤消 commit,这是一种安全的撤消 commit 的方法,因为它是新建一个 commit 来逆向地恢复某一次commit;例如,下面的命令将找出倒数第二个 commit 中包含的更改,创建一个撤消这些更改的新提交,并将新提交附加到现有项目

1
git revert HEAD~2

工作流将是现在这样:

Reverting the 2nd to last commit

对比与git reset,后者确实会改变现有的提交历史;因此,应该使用Git revert来撤消公共分支上的更改,git reset仅仅应该被用于撤消专用分支上的更改

Git hook

Git hook是仓库中特定事件发生时 Git 自动运行的普通脚本。因此,Git hook安装和配置也非常容易

hook在本地或服务端仓库都可以部署,且只会在仓库中事件发生时被执行

配置hook

hook存在于每个 Git 仓库的 .git/hooks 目录中。当你初始化仓库时,Git 自动生成这个目录和一些示例脚本。当你观察 .git/hooks 时,你会看到下面这些文件:

1
2
3
4
5
applypatch-msg.sample       pre-push.sample
commit-msg.sample pre-rebase.sample
post-update.sample prepare-commit-msg.sample
pre-applypatch.sample update.sample
pre-commit.sample

这里已经包含了大部分可用的hook了,但是 .sample 拓展名防止它们默认被执行。为了安装一个hook,你只需要去掉 .sample 拓展名。或者你要写一个新的脚本,你只需添加一个文件名和上述匹配的新文件,去掉 .sample 拓展名。

比如说,试试安装一个 prepare-commit-msg hook。去掉脚本的 .sample 拓展名,在文件中加上下面这两行:

1
2
3
#!/bin/sh

echo "# Please include a useful commit message!" > $1

hook需要能被执行,所以如果你创建了一个新的脚本文件,你需要修改它的文件权限。比如说,为了确保 prepare-commit-msg 可执行,运行下面这个命令:

1
chmod +x prepare-commit-msg

接下来你每次运行 git commit 时,你会看到默认的提交信息都被替换了。我们会在「准备提交信息」一节中细看它是如何工作的。现在我们已经可以定制 Git 的内部功能,你只需要坐和放宽。

内置的样例脚本是非常有用的参考资料,因为每个hook传入的参数都有非常详细的说明(不同hook不一样)

hook的作用域

对于任何 Git 仓库来说hook都是本地的,而且它不会随着 git clone 一起复制到新的仓库。而且,因为hook是本地的,任何能接触得到仓库的人都可以修改。

对于开发团队来说,这有很大的影响。首先,你要确保你们成员之间的hook都是最新的。其次,你也不能强行让其他人用你喜欢的方式提交——你只能鼓励他们这样做。

在开发团队中维护hook是比较复杂的,因为 .git/hooks 目录不随你的项目一起拷贝,也不受版本控制影响。一个简单的解决办法是把你的hook存在项目的实际目录中(在 .git 外)。这样你就可以像其他文件一样进行版本控制。为了安装hook,你可以在 .git/hooks 中创建一个符号链接,或者简单地在更新后把它们复制到 .git/hooks 目录下

本地hook

本地hook只影响它们所在的仓库。因为开发者可以修改他们本地的hook,所以不要用它们来推行强制的提交规范。不过,它们确实可以让开发者更易于接受这些规范

常见的 6 个最有用的本地hook:

  • pre-commit
    pre-commit 脚本在每次你运行 git commit 命令时,Git 向你询问提交信息或者生产提交对象时被执行
  • prepare-commit-msg
    prepare-commit-msg hook在 pre-commit hook在文本编辑器中生成提交信息之后被调用
  • commit-msg
    commit-msg hook和 prepare-commit-msg hook很像,但它会在用户输入提交信息之后被调用。这适合用来提醒开发者他们的提交信息不符合你团队的规范
  • post-commit
    post-commit hook在 commit-msg hook之后立即被运行 。它无法更改 git commit 的结果,所以这主要用于通知用途
  • post-checkout
    post-checkout hook和 post-commit hook很像,但它在你用 git checkout 查看引用的时候被调用。这是用来清理你的工作目录中可能会令人困惑的生成文件
  • pre-rebase
    pre-rebase hook在 git rebase 发生更改之前运行,确保不会有什么糟糕的事情发生

前四个hook让你介入完整的提交生命周期,后两个允许你执行一些额外的操作,分别为 git checkoutgit rebase 的安全检查。

所有带 pre- 的hook允许你修改即将发生的操作,而带 post- 的hook只能用于通知

服务端hook

服务端hook和本地hook几乎一样,只不过它们存在于服务端的仓库中(比如说中心仓库,或者开发者的公共仓库)。当和官方仓库连接时,其中一些可以用来拒绝一些不符合规范的提交。

常用的 3 个服务端hook:

  • pre-receive
    pre-receive hook在有人用 git push 向仓库推送代码时被执行。它只存在于远端仓库中,而不是原来的仓库中
  • update
    update hook在 pre-receive 之后被调用,它可以分别被每个推送上来的引用分别调用
  • post-receive
    post-receive hook在成功推送后被调用,适合用于发送通知

这些hook都允许你对 git push 的不同阶段做出响应

服务端hook的输出会传送到客户端的控制台中,所以给开发者发送信息是很容易的。但要记住这些脚本在结束完之前都不会返回控制台的控制权,所以要小心那些长时间运行的操作

Git config

多用户

单台机器单个服务多用户的场景下,由于服务商通常不会允许同一对密钥认证多个用户,所以需要生成多对密钥,这时就需要指定Git使用的用户以及对应的私钥文件了

1
2
3
git config --local user.name "xxx"
git config --local user.email "xxx"
git config --local core.sshCommand "ssh -i [path to privateKey]"

注意加上--local,指定仅当前仓库生效

Git Sparse Checkout

很多时候我们只需要一个大型仓库中的个别文件或是文件夹,而不是直接 clone 整个仓库,这时候就需要用到sparse checkout

对于git 2.19以上的版本,我们可以使用git clone --filter来首先避免在 git clone 时下载对象

比如针对这样一个示例仓库,仓库中的内容为:

  • 包含10个10MB文件的大目录
  • 包含1000个1B大小文件的小目录

我们就可以使用以下方式仅仅获取到 small 目录中的内容

1
2
3
4
5
6
7
8
git clone \
--depth 1 \
--filter=blob:none \
--sparse \
https://github.com/cirosantilli/test-git-partial-clone-big-small \
;
cd test-git-partial-clone-big-small
git sparse-checkout set small

vscode git插件推荐

  • git-commit-plugin
    模版化 commit message 插件
    image-20221009113326907
  • Git Graph
    Git workflow 图形化插件
  • GitLens
    综合性的 Git 插件,覆盖大部分功能