优雅地使用Git
保持清晰的提交记录
统一规范的提交信息
Git强制commit必须有一个summary信息,但是并没有要求开发者怎么写,看看以下几种提交历史:
- 随意版
- 较明确版(django-oscar)
- 规范版(Vim)
- 规范版(React)
我想大部分开发者应当认同,commit message至少应该描述下本次提交做了些什么,那么相比之下,其实第一种写了等于没写,至少得做到第二种的形式,才能算有用的提交记录。
在众多提交信息规范中,由前端框架Angular
团队的提出的规范应该是最受欢迎的,该规范将提交summary分成三个部分:header
、body
、footer
,其中header
为必填。
header
包含三个部分:
type
:提交类型,test
、feat
、fix
等scope
:作用域subject
:主题,对修改的简述,小写字母开头,现在时态,结尾无句号
body
是对subject
的补充,包括本次修改的动机,与之前行为的对比。
footer
主要是关于Breaking Changes的描述或者是关闭某个相关issue
这个格式看上去有些复杂,不过可以通过工具辅助完成,例如我曾写的辅助脚本commit-formatter。
清理无用的提交信息
amend
有时候完成了git commit
操作,却突然发现有个拼写错误,这时候可以修改后再次提交,但是这样一个小改动没必要多创建一条提交记录(当然这可以通过lint、git-hook避免,但那是另一个问题),这时候可以先将改动的文件加入暂存区,再使用git commit --amend
改写提交,将这次的小改动加入到上次的提交中。这个操作会打开默认编辑器让你编辑提交信息,如果不需要改动提交记录,还可以使用git commit --amend --no-edit
。
squash
有时候我们需要压缩多个提交信息到一个,例如在开发某个功能时,对一个小范围改动产生了多次不必要提交,或者在参与开源项目时,我们需要基于自己的分支提交PR,而Reviewer对我们提出了一些改动意见。这时候可以使用squash
。
例如有如下提交:
这时使用命令git rebase -i HEAD~3
,会在终端打开默认编辑器:
每个提交信息前有个单词pick
,在下面的注释中,解释了pick
以前其他单词的意义,可以看到s
或squash
的意义为保留信息但合并进上一个提交,现在编辑后面两个pick
,改成:
保存并确定后,再次使用git log
查看提交历史,可以发现三次commit信息被合并了。
使用rebase同步
有时候,一些项目的提交历史混乱的原因可能是开发者使用了不恰当的操作,例如只知道对远端分支使用pull
和push
。
应该有很多人在使用git pull
时见过这个警告:
现在假设A和B在同一个dev分支上开发,A修改了代码并创建提交commit1,通过git push
推送到了服务器,这时B在本地也创建了commit2,他使用git push
就会收到报错,因为B没有同步远端dev分支最新的更改。
图片来自Gitbucket
此时如果他pull远端分支,就会产生一个额外的合并commit。为什么呢?实际上,这里的pull操作就等价于git fetch <remote> && git merge <remote>/branch
,将远端的分支修改下载到本地,然后合并到本地分支。
怎么避免这个merge提交呢?可以使用git pull --rebase
,
rebase看上去像是先将本地的提交先拿出来,再插到另一个分支的最顶端去,这样就得到了一条线性的提交历史。注意图中原本本地的E F G变成了E' F' G',后面会提到。
回看前面的警告,通过git config pull.rebase true
可以设置默认的pull操作为git pull --rebase
。
同样的,对于同一个机器上的不同分支,其实也可以用git rebase other-branch
操作来代替merge。
rebase的黄金法则
rebase操作有一个黄金法则:不要在共享分支使用rebase!
或许就因为这个法则,让一些程序员不敢使用rebase。那么,rebase在什么情况下危险呢?
正如前面提到的,本地的提交,经过rebase之后,实际上是生成了内容一样的新提交,E‘ F' G'的hash与原来的E F G是不一样的。假设现在分支情况如下:
A -> B -> C # remote/dev
A -> B -> C # 甲/dev
A -> B -> D -> E # 甲/feature
A -> B -> C -> F # 乙/dev
如果甲在本地的dev分支rebase了feature:
A -> B -> C # remote/dev
A -> B -> D -> C' # 甲/dev
A -> B -> D # 甲/feature
A -> B -> C -> F # 乙/dev
接着甲要push本地的dev到远端,麻烦来了,甲本地的dev和远端在B之后就对不上了,如果甲不管不顾,使用git push --force
,这下乙要push他本地的改动将会遇到报错,乙使用git pull
,Git会尝试合并分支:
A -> B -> D -> C'
| /
| /
-> C -> F ---> M
如果所有人都像甲一样操作,那这个共享的dev分支最后会变得非常混乱。
但是如果是像前面提到的,甲本地的dev是A -> B -> C -> D
,远端原本是A -> B -> C
,经过乙push后变成A -> B -> C -> E
,甲使用git pull --rebase
是没有问题的,这时本地变成了A -> B -C -> E -> D'
,为什么这个操作是安全的呢?这里远端的dev分支是共享的,但是本地的dev可以视作私有的分支,git pull --rebase
相当于rebase了远端的dev分支,最后push的结果其实是向远端push了一个新的提交,这时乙再使用git pull
后的结果就是A -> B -> C -> E -> D'
。
再比如,在Github上fork一个仓库,checkout一个dev
分支做了一些更改后创建了一个PR,虽然这个dev
分支在一个公开的代码托管平台上,所有人都可以看到,但是它只是为了最终合并进目标仓库的主线而建立的,仍然可以视为私有分支,在这个PR被合并前,可以通过rebase同步目标主分支的改动,用squash压缩提交信息,这些都是安全操作。
综上,安全使用amend、squash、rebase等操作的前提就是,不要改动已经共享了的提交,如果将共享的远端分支上的A -> B -C
变成A -> B -> D -> F
,那就会造成混乱了。
辅助工具
Git hooks
Git提供了hook机制,可以在特定事件前后触发特定操作。例如,在代码提交前检查测试覆盖率,检查代码格式化等等。Python的开源工具pre-commit就提供了很多好用的Hooks。
Git子命令
如果你为Git写了一个扩展脚本,那么你可以用git-foo
来命名你的可执行文件,Git允许你使用git boo
的子命令形式调用自定义脚本。
Git别名
可以为一些常用且比较长的命令配置一个短的别名,例如:
EditorConfig
不同的编辑器/IDE都会有自己的项目配置文件,如JetBrains系列的.idea
,VSCode的.vscode
,我个人认为这种文件不应该提交到公共仓库里,因为不应该强制所有开发者使用相同的工具(Android开发这类与IDE高度绑定的项目也许是例外)。
那这时候怎么保证不同开发者使用不同的编辑器,同时保持统一的代码风格呢?一个办法是使用前面提到的git hooks,在提交前做格式化;另一个办法就是使用EditorConfig,在项目里放置一个.editorconfig
文件,配置缩进、换行符等,基本上主流编辑器都会尊重这个配置。
杂项
日志查询
Git命令行提供了一些选项去快速查找提交:
- 根据commit信息查找:
git log --all --grep='<pattern>'
- 根据提交人查找:
git log --committer=<pattern>
- 根据日期:
git log --since=<date>
、git log --before=<date>
更多查询条件,可以查看官方文档。
追踪空文件夹
Git本身是不能追踪空的目录的,但是有时候确实会有需要将一个空目录放到仓库的需求,这时可以在这个目录下放一个空的.gitkeep
文件,这个文件名只是一个命名惯例,并没有特殊意义,接下来要去修改.gitignore
文件:
这样就可以让Git忽略该目录下除了.gitkeep
外所有文件,但是保留这个目录。
大文件
LFS
Git是为文本文件设计的,但是有时需要在仓库中放一些大的二进制文件,如图片、音频等设计资源,这会让仓库体积变得庞大,如果二进制文件变更,变更历史也会变得很大,要解决这个问题,就可以用LFS(Large File Storage)扩展,简单说就是它允许将大文件保存在另外的仓库,在本地保留一个指针。详情见LFS
gc
git gc
命令可以帮助清理Git数据库中不需要的文件,减少磁盘占用,在nixpkgs这样有着巨量提交的大型仓库上工作时这个命令很有用。
只需要最近的一次提交
有时我们暂时只需要一个仓库最新的代码,不需要所有的Git提交历史,那么可以使用git clone --depth 1 repo-url
来克隆仓库,这可以节省下载时间和本地磁盘占用。
删除未追踪文件
2024/11/01添加
某次我在git仓库下执行了一个批量重命名文件的操作,发现不小心敲错了文件名,这时使用git restore
可以快速恢复原文件,但是产生的错误文件并没有被清理掉,这时可以用git clean
清理未被追踪的文件。
默认情况下直接git clean
会被拒绝,需要用git clean -i
交互式处理或git clean -f
强制删除,也可以先用git clean -n
查看哪些文件会被删除。
二分查找定位问题
2024/11/01添加
如果需要在git仓库里确认一个bug具体是什么时候引入的,可以使用git bisect
命令,比如某个应用的新版本距离上个版本有100个commit,新版出现了一个bug,但不知道是从哪次commit开始有的,可以这样操作: