技术: 高质量Git工作

当稳定性和可维护性开始成为一个优先考虑的事情后,清理提交、坚持分支策略和提交信息的规范性就变得很重要。
分支管理, 项目管理, 仓库管理一直是一个大问题, 本文尝试从最基本的使用开始, 分享一些心得体会, 比如库大了可以使用子模块等.

新手的Git

我是从新手走过来的, 我知道新手最喜欢用什么, 如下:

1
2
3
git add --all
git commit -am "<message>"
git push origin master

工作流, 本地一般是这样:

一个人玩, 足够了(因为库的维护, 分支的管理, 都不用你操心). 之后进阶就需要了解更多的常用命令. 是的, 不用学, 只管用就对了.
(当然看看前辈们分享的经验更好)

忘了说了, 可能你需要先配置你的环境, 比如ssh keys, 然后上传到服务器的列表之类的:

1
ssh-keygen -t rsa -C "youremail@example.com"

一直回车,将自动生成id_rsa和id_rsa.pub文件。使用以下命令验证连接是否成功.
或者你验证github:

1
ssh -T git@github.com

不过, 我一般都是用 HTTPS 去拉去或者上传的(怕长久不输密码, 忘记了)

当然一般不会出现让你去为某个项目配置用户信息的, 所以你配置user.name, user.email的时候, 放心--global.
但是如果要为当前项目.git/config配置, 可以这样:

1
2
git config user.name "Your Name"
git config user.email "your@email.com"

工作中的Git

简单的工作流

大概是这样的, 见下图

本地端

相比远程端, 本地端更容易玩的很6, 而远程端涉及到 teamwork, 所以冲突会有一些, 先说本地端.

  • Workspace:工作区
  • Index / Stage:暂存区
  • Repository:仓库区(或本地仓库) 又称为history
  • Remote:远程仓库

其实很长一段时间, 我也没有弄清楚暂存区本地仓库是怎么回事儿.

暂存区和本地仓库是怎么回事儿呢?

既然git是版本管理工具, 那么看看它怎么管理和追踪记录文件的就知道两者的区别了(stupid way tracing file).
去查看一下git init出来的work库(注意不是--bare的裸库, 裸库你是提交不了内容的, 没有工作区, 它仅仅是追踪作用), 即.git目录:

1
2
3
4
5
6
7
8
9
10
$>tree -L 1
.
|-- HEAD # git项目当前处在哪个分支里的提交点
|-- config # 项目的配置信息,git config命令会改动它
|-- description # 项目的描述信息
|-- hooks/ # 系统默认钩子脚本目录
|-- index # 索引文件
|-- logs/ # 各个refs的历史信息
|-- objects/ # Git本地仓库的所有对象 (commits, trees, blobs, tags)
|-- refs/ # 标识你项目里的每个分支指向了哪个提交(commit)。

看到index文件, .git目录下的index文件, 暂存区会记录 git add 添加文件的相关信息(文件名、大小、timestamp…),不保存文件实体, 通过id指向每个文件实体。
也就是说, 暂存区其实只是在标记你工作区, 也就是工作目录的文件, 但是并没有保存文件实体.(可以使用 git status 查看暂存区的状态, 暂存区标记了你当前工作区中,哪些内容是被git管理的)

但是本地残酷就不一样了, git commit 后同步index的目录树到本地仓库, 此时objects子目录会记录当前文件实体, 怎么记录? 一种压缩格式拷贝记录(stupid way), 并且refs保留索引标志(相当于指向).

总结起来, 就一句话:

任何对象都是在工作区中诞生和被修改, 任何修改都是从进入index区才开始被版本控制, 只有把修改提交到本地仓库,该修改才能在仓库中留下痕迹.

但是本地端, 还没有完, 我还没有说 HEAD, 一般reset, revert等, 都喜欢用 HEAD 操作, 有时候用commit id和用 HEAD效果一样.


HEAD,它始终指向当前所处分支的最新的提交点。你所处的分支变化了,或者产生了新的提交点,HEAD就会跟着改变。
好吧, 我还是强调一下:(因为它后面出现频率稍高)

HEAD指向的是本地仓库的最新提交点

上一次提交是HEAD^,或者写成 HEAD~1; 上上次是HEAD^^,也可以写成HEAD~2 ,依次类推

远程端

与协作者分享本地的修改,可以把它们push到远程仓库来共享. 但实际上就只是多了一个git push, 命令, 如下:

1
2
3
git push <remote> <branch>    上传某个分支上去
git push <remote> --force 本地分支比远端新, 或者与远端同一个妈, 但当前分支的最新提交点不一致; 强行推
git push <remote> --all 上传所有分支

但是值得提醒的是, 远端就会涉及到权限, 也就是说, 可能需要review.

如果只是这么简单的工作流, 那么你基本只能在小公司了, 大公司, 项目数&分支数, 非常多, 这些命令不用想太多, 肯定不够.

正常的工作流

正常工作流, 就是日常工作中, 最常用的, 再上面的基础上, 还要加上很多命令:

  • branch/checkout
  • rebase/merge
  • cherry-pick
  • reset/revert
  • log/reflog/blame
  • diff
  • push/fetch/pull
  • remote

当然创建分支, 发布release, 打标签, 可能还轮不到你(会有专门的人负责, 他们一般还负责追踪软件进度)

但是即便就这些命令, 也是会有选择和偏好的(甚至, 一般项目组或者上面都是有规定的, 哪些命令哪些时候是禁止使用的).
不管怎么说, 现在的工作流也是可控的&不复杂的, 见下图:

当然也会有人口味不同, 喜欢用不同的命令, 比如git log:(我不喜欢用git blame, 感觉像是摊上事儿了)

1
2
3
4
git log             从最新提交开始显示所有提交历史

git log -p <file> 显示指定文件的所有修改
git blame <file> 谁,在什么时间,修改了文件的那些内容

根据我的经验, 把常用命令滤一遍.

常用命令详解

给你一个笼统的图(具体的命令不是很详细)

详细说说:


git add

1
2
3
git add .
git add <dir>
git add <file1> [<file2>] ...

提交目录时, 会主动检查子目录的(包括子目录).

下面是commit

1
2
3
git commit -m <message>
git commit <file1> -m <message>
git commit --amend -m <message> 使用一次新的commit代替上一次提交.

通常 --amend 代替上一次提交的时候, 不用再-m, 直接使用上次的 commit message 即可.

实际上, 只要是review才能入库的代码, 最好不要-m选项, 而是认真按照规范写commit message.

branch 和 checkout

其实你不指定, 追踪的远程分支, 也会在push的时候自动要求你指定.

再就是merge, 即 merge from: git merge <branch>; 但是merge不好的一点在于, 会产生新的节点(例如下图master分支的M节点):

这还不是最惨的, 最惨的是, merge一般都会冲突, 即merge前的俩commitx修改了同一文件的同一区域, 需要你手动解决冲突文件后再提交.
所以一般公司都会推荐使用rebase.

rebase

这个 rebase 还可以整理提交信息, 以交互的方式.

假设你在本地历史记录上有 4 个提交(没有推送到 GitHub),你要回退这是个提交。你的提交记录看起来很乱很拖拉。这时你可以使用 rebase 将所有这些提交合并到一个简单的提交中:

1
git rebase -i HEAD~4

得到信息如下:

1
2
3
4
pick 130deo9 oldest commit message
pick 4209fei second oldest commit message
pick 4390gne third oldest commit message
pick bmo0dne newest commit message

一般只保留修改(提交), 而舍弃 commit message

1
2
3
4
pick 130deo9 oldest commit message
fixup 4209fei second oldest commit message
fixup 4390gne third oldest commit message
fixup bmo0dne newest commit message

之后还要使用git commit --amend进行提交信息commit message的重新改写, 例如:

1
2
3
feat: add stripe checkout button to payments page
- add stripe checkout button
- write tests for checkout

关于 commit 的规范, 可以参考我的另一篇文章.

checkout
这个命令也比较好理解, 我修改了工作区文件, MD改坏了, 不要了, 回到最初拿到库的状态.

1
2
git checkout -- <filename> 用 HEAD 中的最新内容替换掉你的工作目录中的文件
git checkout HEAD 当前所有修改的都不要了, 直接抛弃工作区的所有修改

但是, 已经add到暂存区的, 则不受影响; 但是我暂存区也不想要了怎么办? 也就是说暂存区, 工作目录都不要了, 我指向恢复到上一次提交.
那, 你就用 git reset --hard HEAD吧, 或者你知道commit id, git reset --hard <commit id> , 之后你弄乱的东西全没有了.

当然你也可以使用checkout切换到某个branch的commit ID, 这样你没有暂存的修改可能会丢失(暂存了不怕, checkout动不了暂存区)如下图:

总结就是, checkout可以恢复你的工作区, 但是暂存区它动不了; 要想暂存区也清空, 那么就git reset --hard吧.

稍微注意下, git reset HEAD -- <file> 是把暂存区的内容放回(注意参数是HEAD), 但它不能清空工作区.

revert
撤销之前提交的一个commit, revert 命令会创建一个新的 commit id, 具体你可以通过git reflog查看

1
2
git revert HEAD  撤销当前提交(不是回退到暂存区)
git revert <commitid> 回到commit id之前的那个提交点

reset
这个是救命稻草, 分为软和硬(功能和revert类似, 个人觉得更加强大)

1
2
3
4
git reset --soft HEAD~1      撤销本地提交(但是不会影响你当前的缓存区和工作目录)

git reset --hard HEAD~1 撤销一个提交(会清空当前的缓存区和工作区, 和上次提交点保持一致, 即干净的)
git reset --hard <commit-id> 撤销提交, commit-id 之后的提交都会被抛弃, 强制回到某个点

使用reset, 最好指名是soft还是hard, 即不要这样用git reset HEAD~1或者git reset HEAD^, 不指定时操作的是暂存区(其实是mixed选项, 它会让你的缓存区同步于某一个提交, 通常都是传入HEAD, 但工作区的修改还在, 然后HEAD指向你的提供的commit点), 见上面的checkout和下面的aliases.
即相当于:

只是当你传入HEAD的时候, 即git reset HEAD <filename>, 从库里退回到暂存区的内容为空(当前没有最新的commit, 回退到上一次, 自然为空), 看上去效果就是, 清空了暂存区. 实际上, 如果不指定soft, 或者hard, 一般都是这种用途, 相当于 unstage.

git reset –mixed HEAD 将你当前的改动从缓存区中移除,但是这些改动还留在工作目录中。 见下面的 aliases.

响应范围总结如下:

记住这个图即可.

特别注意一下 reset 会会写历史记录, 而不像revert, 让历史记录继续向前变化一次(中和反应), 保留历史追踪.

diff
其中注意区分一下不同:

  • git diff --cached 查看暂存区和commit库的不同(不暂存, 什么都看不到)
  • git diff <commit id> 比较当前所在的工作区文件和指定commit id有何不同

特殊的, git diff HEAD 查看工作区和commit库的不同(新建文件貌似不行, 一定是修改已经在追踪的文件), 一般都是和没有提交前进行比较.

哦, 还有一个cherry-pick, 即把某一个commit摘取到当前分支:

1
git cherry-pick <commit-id>

非常用命令

这些命令, 一般你是用不到, 可能是你没有权限, 或者有专门的人负责了.

比如 init , remote, tag等, 但总有一天你会用到的:

init

1
2
git init
git init --bare

使用裸库建立的仓库的, 一般是在远端服务器上, 只给大家共享(你不能在此目录进行修改文件,或者提交文件啥的).

remote远端管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
显示远端信息(默认会显示origin)
git remote show <remote_name>

显示远端仓库的地址url
git remote -v

设置远端仓库的url(一般用于更改)/或者添加url (如果远端仓库存在多个url的话)
git remote set-url origin git@github.com:einverne/repo.git
git remote set-url --add origin https://github.com/einverne/dotfiles.git


删除远端仓库, 例如origin
git remote rm origin

删除远端仓库某分支, 例如orgin的某个分支(本地仓库, 直接 git branch -d即可)
git push origin --delete <branchname>
git push origin :<branch name>

将本地分支推送到远端仓库(可以只推送一个)
git push <remote name> <local-branch-name>

当然删除分支, 一般你也没有权限; 重命名也是git branch -m <old-name> <new-name>.

如果我有一个库了, 让后想和远端建立关联, 之后这个库就相当于远端库的本地版本?

如果你初始化了自己的 Git 仓库,并希望将其与 GitHub 仓库相关联,则必须在 GitHub 上创建一个,复制新仓库提供的 URL,并使用 git remote add origin <URL> 命令,这里使用 GitHub 提供的 URL 替换 URL。这样,你就可以添加、提交和推送更改到你的远程仓库了。

打标签
一般打标签, 也是项目管理人员在做:

1
2
3
4
5
6
7
8
9
10
11
创建标签
git tag -a <tagname> <commit id>

显示标签
git show <tagname>

推送特定tag
git push origin <tagname>

推送所有tags
git push origin --tags

此外还有一些, 例如 git rm, git rm 之类的是针对你已经放入暂存区, 然后又想操作的, 例如干净的工作区, 我创建了一个新文件, 然后add加入了暂存区,
之后, 我直接 rm 删除了, 结果变成了, 我还要取消跟踪才可以, 这个时候就必须要用git rm 否则, 我还是相当于修改了没有提交, 见下图

这些用不多.

技巧

我个人有一点偷懒和选择的技巧:

aliases

比如, 放到缓存区, 移出缓存区:

1
git config --global alias.unstage 'reset HEAD --'

以后直接用git add <file>来将文件加入暂存区,使用git unstage <file>来将文件移出.
其他还有:

1
2
3
4
git config --global alias.co checkout  
git config --global alias.br branch
git config --global alias.ci commit
git config --global alias.st status

合并之选

其实只有rebasemerge之争(当然你也可以选择revert自己, 先pull别人, 再进行修改; 如果你不嫌麻烦的话).

rebase虽然线性, 并且提供整洁的提交; 但是它不如 merge 那样可以知道从别的分支引入了哪些提交(带有commit message), 让你可以追溯; 并且如果rebase的分支是公共分支, 那么它又会带来不小的安全性影响.
试想, 如果你的master分支, 要去rebase一个feature分支的某些修改, 那么rebase之后, 可能出现的结果就是, 其他基于master的分支, 之后就要强行合并了(改变一大波内容, 不仅不安全, 而且不能追踪以前的提交了). 因为别人原来是基于旧的master, 你现在整个master已经不是原来的master了, 或者说你在强行让master分支线性话. 如下图:

绝不要在公共的分支上使用它(除非不和别人共享master)

如果你想把 rebase 之后的 master 分支推送到远程仓库,Git 会阻止你这么做,因为两个分支包含冲突。但你可以传入 –force 标记来强行推送。 但这也势必造成基于这个master开发人员的重新合并.

如果是自己的提交, 好比在 feature 分支上提交了三个修改, 实际上想整合成一个, 那么可以使用交互式的rebase, 即git rebase -i HEAD~数字.
如果你想用这个方法重写整个 feature 分支,git merge-base 命令非常方便地找出 feature 分支开始分叉的基。下面这段命令返回基提交的 ID,你可以接下来将它传给 git rebase:

1
git merge-base feature master

这样你的feature不仅线性化, 而且整洁. 通常只是移动你的提交的话, 你想怎么改就怎么改, 甚至可以合并别人的feature, 只要保证和大家共享的master基于同一个分支即可, 见下图:(其中红色框标注了与主分支的分叉点)

如果你去merge, 那么就会出现这样的情况, merge会保留很多信息

相对于主分支来说, 它更希望看到你最好一个功能提交一个commit, 然后你内部的什么合并什么的, 它不关心;

所以使用rebase, 或者 rebase -i稍微整理一下, 才是上策.

总之: 来自其他开发者的任何更改都应该用 git merge 而不是 git rebase 来并入。

(其实你看看 github 针对 pull request 都是用的啥就知道了)

回滚之选

关于回滚或者重置, 其实上面说 reset , checkout, revert 的时候, 就比较过了. 这里再集中说一下. 其实主要是reset, checkout; 因为没有谁会让你随便去回滚某个commit的, 我们更多的是针对某个文件的恢复. 但是注意, reset用于修改提交时, 它会修改历史, 而revert会重新产生一个新提交来中和以前的提交, 即它会保留历史记录, 因而主分支这种公共共享分支, 一般会用revert的. 对个人而言, reset修改提交反而会比较多.

值得补充的是git checkout <branchname>的时候, 如果你当前工作区有内容, Git会强制你暂存或者提交, 不然就会丢失. 也就是说, git checkout会改变仓库分支和当前工作目录, 至于暂存区, 它影响不了.

并且当你在分离HEAD之后, 例如git checkout HEAD~2之后所做的任何修改, 都会在匿名分支上, 所以当你要在分离的HEAD上进行开发之前, 请先建立分支(不然你一旦切换到其他分支, 该部分开发内容将丢失), 下面是分离的HEAD.

但是如果你是git checkout HEAD~2 foo.py那么HEAD引用是不会改变的, 只是 foo.py 同步到了倒数第二个提交中的 foo.py.

总结起来就是, git checkout -- <file> 或者 git chekcout HEAD <file> 放弃当前工作区某个文件没有缓存的修改, 只是某个文件.

所有文件的话, 请用 git checkout HEAD <file> 或者 git reset HEAD --hard.

Log or Blame

这里要说的是, 如果有图形化工具, 如gitk, 请用图形化工具, 然后本条略过.

git log常用的参数如下:

  • -p 提交所有的删改都会被输出
  • --stat 显示每次提交的文件增删数量
  • --oneline 或者 --oneline=pretty
  • --decorate 显示指向这个提交的所有引用(比如说分支、标签等), 即查看响应范围
  • git shortlog 它把每个提交按作者分类,显示提交信息的第一行。这样可以容易地看到谁做了什么。
  • --graph --graph --oneline --decorate 显示一个图形化界面
  • -<num> 显示最近N此提交.
  • --after="2014-7-1" --before="2014-7-4", 还有--since, --until
    <since>..<until>
  • --author="John"
  • --grep="JRA-224:"
  • master..feature 查取分支差异
  • --merges 返回合并的两个父节点
  • --no-merges 过滤merge节点

清除未跟踪的文件

例如emacs产生的备份文件, git clean -dxf可以清除这些文件(前提是.gitignore要求忽略他们), 通常使用

1
git clean -df

表示保留ignore文件

大仓库

有些库非常大, 但是它又是一个逻辑主体, 不能使用repo, 例如:

  • 项目累积了非常长的历史 (git log你就知道了)
  • 项目包括了巨大的二进制资产,需要与代码一起跟踪配对

我工作中, 没必要拉取所有的分支, 所有的记录或者文件, 这个时候一般需要特殊处理一下.

大量历史记录的库

浅克隆

这是简单的的解决办法, 克隆所有的分支的近depth次提交

1
git clone --depth depth remote-url

当然, 也支持只克隆一个分支:

1
git clone URL --branch branch_name --single-branch [folder]

过滤分支

filter-branch (过滤分支)这个命令可以根据预先定义的模式对项目历史进行过滤、 整理 、修改,甚至跳过一些文件:

1
git filter-branch --tree-filter 'rm -rf /path/to/spurious/asset/folder' HEAD

但是, 这么做就已经重新写了整个项目的历史记录, 不再是原库了. 如果原库更新, 你还得重新更新.

这个操作危险系数大, 建议还是浅克隆吧

有巨大二进制资产的库

简单来说, 这种库资源size太大了, 而Git又是采用stupid拷贝的方式记录.

git 在处理二进制资产的时候并不是特别差劲,但它也不会干得特别好。默认情况下,git 会完整压缩存储二进制资产的所有后续版本,如果你有很多二进制资产的情况下,这显然不是最佳方案。 处理的时候, 要进行调整分类:

对于变化显著的二进制文件 - 这是指不仅只有元数据头变化 - 这时增量压缩可能没什么作用,建议对这些文件关闭 delta 选项,以避免不必要的增量压缩并重新打包

对于上述情形,就像某些文件通过 zlib 压缩并不会有多好的效果,你使用 core.compression 0 或 core.loosecompression 0 来关闭压缩功能一样;这是一个全局设置,它会对其它压缩效果不错的非二进制文件带来负面影响。因此建议你把二进制资产放在单独的库中。

一定要记住 git gc 将“重复的”松散的对象变成一个单独的包文件,除非以任何方式压缩文件都不会使生成的包文件有显著差异。

探索调整 core.bigFileThreshold 带来的效果。任何大于 512 MiB 都不会采用 delta 压缩 - 如果没有设置 .gitattributes 的话 - 所以这样的调整值得一试。

子模块

这里其实是借用了repo工具的思想.

把它们拆分到一个单独的库,然后在主项目是通过把它拉取为 子模块 。使用这种方法你可以控制资产的更新。需要了解子模块,可以看看: 核心概念与技巧 和 另一个选择 。如果你想继续使用子模块的方法,你可能需要检查 项目依赖 的复杂性。我提到的方法对解决大型二进制文件问题会有所帮助。

关于子模块的参考如下:

在已有库中, 添加子模块

1
git submodule add [url] [path]

例如: git submodule add git://github.com/wizardmerlin/xxx.git src/main/proj/xxxx

初始化子模块:git submodule init 只在首次检出仓库时运行一次就行
更新子模块:git submodule update 每次更新或切换分支后都需要运行一下

删除子模块

  • git rm –cached [path]
  • 编辑“.gitmodules”文件,将子模块的相关配置节点删除掉
  • 编辑“ .git/config”文件,将子模块的相关配置节点删除掉
  • 手动删除子模块残留的目录(就是你 add 的 path)

git三方扩展

git 中处理二进制可以依靠第三方扩展, 比如git-annex. 它可以使用 git 管理二进制文件,但不需要把文件内容检入库中。git-annex 使用一个特殊的键值库来保存文件,然后将符号链接像普通文件一样检入 git 库中进行版本管理。 参考

第二个扩展是 git-bigfiles ,一个 git 分支, 适合于使用 git 分享项目大文件的人 。

其他参考

给你一个写的更加啰嗦(详细)的参考: http://www.cnblogs.com/kenshinobiy/p/4543976.html

文章目录
  1. 1. 新手的Git
  2. 2. 工作中的Git
    1. 2.1. 简单的工作流
      1. 2.1.1. 本地端
      2. 2.1.2. HEAD
      3. 2.1.3. 远程端
    2. 2.2. 正常的工作流
      1. 2.2.1. 常用命令详解
      2. 2.2.2. 非常用命令
  3. 3. 技巧
    1. 3.1. aliases
    2. 3.2. 合并之选
    3. 3.3. 回滚之选
    4. 3.4. Log or Blame
    5. 3.5. 清除未跟踪的文件
  4. 4. 大仓库
    1. 4.1. 大量历史记录的库
      1. 4.1.1. 浅克隆
      2. 4.1.2. 过滤分支
    2. 4.2. 有巨大二进制资产的库
      1. 4.2.1. 子模块
      2. 4.2.2. git三方扩展
  5. 5. 其他参考
|