Published on

Git 二三事

Authors

概述

git 伴随着 linux 内核开源项目而诞生。linux 内核项目在 91 年到 02 年之间的版本管理非常原始,很多时候是贡献者把 patch 文件通过邮件发送给 Linus 本人,然后由 Linus 手工合并。02 年之后,有个商业公司为这个项目免费提供了分布式版本管理系统 BitKeeper,从此项目组在版本管理方面的效率有所提升。然而在 05 年的时候,有成员违反了 BitKeeper 提供者和 Linux 内核开发团队之间的协议,而 Linus 的应对不是带着项目成员道歉,而是花了两个星期写出来 git 的原型。git 在 BitKeeper 的使用权限被收回后迅速接替,成为 linux 内核代码的版本控制系统。

man git

大家可以去看看git 项目的第一个 commit。git 的描述是git - the stupid content tracker,这其中也许有项目最初带着的怨气,但的确如描述一样,对 linux 系开发者来说是非常“傻瓜式”的版本控制系统。

git vs svn

要讲清楚 git,可以拿大家比较熟悉的 svn 来做参照。

分布式 vs 集中式

首先,我们常常听到 git 和 mercurial(hg) 是分布式版本控制系统,而 svn 和 cvs 则是集中式的版本控制系统。两者的区别在哪里?

主要的区别有两点:

  • 代码提交方式
  • 远程和本地的信息对称性

分布式版本控制系统都是本地 commit。生成代码提交信息的时候是不需要联网的。最终和远程代码库同步需要通过 push 或者 pull 命令。而集中式版本控制系统则是远程 commit,每次生成代码提交信息都需要联网进行。从这一点上看,分布式比集中式灵活度高很多。

远程和本地的信息对称方面,首先 git 的远程代码库本身充当的角色仅仅是方便不同的开发版本库之间的“内容交换”,因为所有的本地版本都拥有从一开始到最新同步那一刻的所有历史提交纪录。远程和本地的提交信息是对称的,所有的代码库副本都是完整的。而集中式版本管理系统通常 checkout 都只能签出单一分支,本地代码库信息是不完整的。这一点差别带来的特性差异有时候很致命,譬如安全性上。集中式版本管理系统一旦远程代码库出了问题,几乎就是不可恢复的。而分布式版本管理系统即使远程服务器被物理销毁,所有代码都可以恢复。

元数据 vs 文件

那为什么集中式代码管理系统不采取这种全量提交信息推送的方式,保证每一个客户端都有完整的记录呢?

git 的.git 只会出现在项目的根目录下,其中不仅有每次提交的元数据,也保有所有的 commit、branch、tag 等信息,并且分支和 tag 都基于 commit。可以这么理解:一次提交的元数据保存后,会生成一个对应的 hash 码,这个 hash 码就是一个 commit。从 commit 上衍生了所有的东西,譬如 branch,事实上就是保存了一个 commit 的文件,tag 也是指向某一个 commit 的文件指针。

而 svn 的.svn 只存放了当前分支的提交信息 db 和当前分支的 commit 历史。因为 svn 可以从远程代码库 checkout 某个分支的某个子文件夹,而这些信息都是动态生成的,因此实现全量的版本拷贝非常困难。所以基本上 svn 获取分支、获取更新、切换分支等操作都只能基于文件拷贝的实现方式来做,而不能单纯依赖提交信息(也就是差分)。甚至 svn 的分支、tag 都是物理上的另一个目录。从这一点上看 svn 的设计和与 git 相比非常原始。

权限?安全?

看完上面的差异会有人问:svn 的实现机制让它可以做到对路径级别进行精细的权限控制,是不是意味着 svn 事实上比 git 更安全?

首先是权限控制的问题。

  • 分布式版本控制的理念和路径权限管理是冲突的,目前找不到能够精确控制路径权限的分布式代码控制系统

分布式意味着代码库的备份需要对等,而路径权限的存在意味着内容不对等,那么严重依赖元数据和 hash 码的分布式版本控制系统将不能正常工作。因为本身元数据和 hash 码的计算中很有可能涉及到某一个本地没有权限的路径。

  • 分布式版本控制系统通常基于 commit 来控制读写权限

你可以设置某个用户对版本库的某个状态的编辑权限,譬如某个 commit,某个分支,某个 tag 等。gitolite让这些权限管理变得非常简单。

  • 有解决的办法

上文提到的gitolite是一个办法,它对写操作的限制可以精确到目录级别。当然,如果是担心代码泄露,想设置读权限的话,更推荐的办法是把项目拆分成不同的子系统,各个子系统分别设置权限。总的项目可以用submodule来依赖子系统。

另外,如果需要对某些开发者设置不可读权限,那么他们的工作完全可以作为新的项目来开发。git 代码库拷贝的最小单位就是项目,正是基于这个考虑。

  • svn 精细授权背后的陷阱

svn 各个分支之间的授权(包括目录授权)并不能继承,原因很简单,上文提过了 svn 不同分支甚至是不同的目录存放的,分支之间物理隔离。这对系统管理员来说是一个巨大的隐患。因为每一个分支都必须维护一个授权文件,并且一旦授权文件出了问题(譬如格式问题),管理员之前的工作都变得毫无意义。随着分支的增多、标签的增多(都是物理拷贝!),这个坑只会越挖越深。

然后是安全问题。

  1. 代码完整性、提交历史的完整性方面之前提过,git 完胜。
  2. 代码泄露方面的安全问题,git 有替代 svn 的方案,并且更加彻底、符合实际情况。
  3. 提交的安全方面,gitolite 可以完美解决,并且更加方便。

分支!

git 鼓励分支,鼓励 commit。生成分支和 commit 在 git 中代价实在太小,以至于完全不必要考虑“分支太多速度会不会变慢啊,会不会占很多存储空间啊”这种问题。开发者只需要考虑怎么样的分支命名、commit 信息可以有利于团队交流即可。越详尽的 commit 历史,分工越明确的分支无疑可以带来越流畅、可控的开发体验。

svn 每建一个分支就是拷贝代码生成一个新的目录。而 git 建一个分支则只是多一个保存 hash 码的指针文件而已。这个特性无疑是 git 远超 svn 最为关键的一点。

大家可以通过这个命令去确认一下:

less $path/to/a/git-repo/.git/refs/heads/master

团队协作

基于超低成本的代码提交、创建分支特性,加上非常方便的查看差分的体验,git 甚至衍生出好几种流派的工作流程(这个后面具体讲)。这些工作流程的核心就是如何确保在各种实际情况(网络情况、机器性能等)下有一个流畅的沟通协作的开发体验。

传统集中式的版本控制系统最大的问题会出在最后的合并流程。因为事实上开发分支和合并分支甚至是物理隔离的,而拥有合并权限的人和具体分支开发者往往不是同一个人,最终解决冲突的过程会有各种意料之外的情况。这是 svn、cvs 这些版本控制系统避免不了的问题。

而 git 里你随时可以建新分支,随时可以合并分支,操作的都是同一份物理备份,你基于这个物理备份做过的所有变更都有迹可循。并且基于强大的分支功能,我们甚至可以把分支的粒度降到单个类、甚至单个函数的级别,这样粒度的分支开发合并起来丝毫体会不到迟滞的感觉。

git 的问题

如果用 git 来管理非常庞大的历史项目,那么有可能会碰到 git 的一个问题:它没办法像 svn 一样 checkout 一个子目录,因此第一次 clone 的时候速度会非常慢。并且当项目规模迅速膨胀的时候,有可能会因为元数据剧增而自动走清理 cache、压缩元数据的流程。所以这里有一个成名的机会。如果你能够给 git 加上p2p 数据传输或者断点续传的特性,或者你可以解决GB 或者更大级别数据计算 hash时的性能问题,你都能在开源界青史留名。

安装&配置

下面是一些基本的安装配置流程。

命令行

mac

brew install git

windows

git-for-windows

linux

yum install git-core
apt-get install git-core

GUI

日常使用基本上 GUI 客户端也能满足。

自动补全

在 git 的代码目录下有一个专门做自动补全的文件夹。

git/contrib/completion

全局配置

全局配置可以通过命令行,也可以通过直接改文件。

.gitconf:
  [user]
    name  = 文森
    email = leungwensen@gmail.com

  [color]
    diff   = auto
    status = auto
    branch = auto
    ui     = auto

  [push]
    default = simple

  [core]
    editor = vim
    pager  = less -R
    excludesfile = ~/.gitignore

  [alias]
    diverges = !bash -c 'diff -u <(git rev-list --first-parent "${1}") <(git rev-list --first-parent "${2:-HEAD}") | sed -ne \"s/^ //p\" | head -1' -
    st       = status
    ci       = commit
    br       = branch
    co       = checkout
    df       = diff
    lg       = log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit
    up       = !sh -c 'git pull --rebase --prune && git log --pretty=format:\"%Cred%ae %Creset- %C(yellow)%s %Creset(%ar)\" HEAD@{1}..'
    lol      = log --graph --decorate --pretty=oneline --abbrev-commit
    lola     = log --graph --decorate --pretty=oneline --abbrev-commit --all
    ls       = ls-files

修改某一个单项的方法:

git config --global user.name yourname

用户区一定要配置,添加别名也可以显著提升效率。

另一个全局配置文件是 .gitignore 这个文件的作用是指定哪些路径或者文件不纳入版本控制。例子:

.*.cfg
.DS_Store
.config
.dat*
.grunt
.last_cover_stats
.lock-wscript
.repl_history
.rvmrc
Build.bat
MANIFEST.bak
META.json
META.yml
MYMETA.*
coverage

几个概念

接下来在玩转 git 之前,我们先看几个概念。

文件状态

在一个 git 版本控制库里,一个文件有这几种基本状态。

  • 未入库 (untracked)
  • 草稿 (unstaged)
  • 待 commit(staged)
  • 已提交 (committed)
  • 已同步 (remote)

可以试着执行这个命令看看:

git status

Untracked files:这个类目下的,就是未入库文件;

Changes not staged for commit:这个类目下的文件有未添加到待 commit 区的变更,并且文件名前会有这几个状态提示:

  • modified
  • added
  • removed
  • renamed

Changes to be committed:里是待 commit 的变更,状态提示和上述一致,但颜色不一样。

commit 完成之后,就是已提交状态,这时会有类似这样的提示:

Your branch is ahead of 'origin/dev' by 1 commit.

这个表示有已经提交的变更,但未和远程同步。

这时执行git push就可以把本地的变更同步到远程服务器了。

指针和分支

大家可以证实一下之前所说分支和 tag 都是 commit 的 hash 码这个事情。

事实上一个分支就是一个文件,这个文件里保存着一个 commit 的 hash 码。譬如:

less $path/to/a/git-repo/.git/refs/heads/master

这个文件没有别的内容,只有一个 hash 码。

而 git 所谓的切换分支是怎么回事呢?.git 文件夹下有一个 HEAD 文件,这个文件的内容如下:

ref: refs/heads/dev

内容很简洁易懂,就是一个指向 dev 分支的指针。HEAD 指向哪个分支,我们当前就处于哪个分支。

并且这个 HEAD 可以作为一个 commit 来使用(事实上一个分支里保存的就是这个分支的最新 commit)。由此还延伸出这样的用法:

符号含义
HEAD当前分支最新 commit
HEAD^ (HEAD^1)(HEAD~1)第二新的 commit
HEAD^^ (HEAD^2)(HEAD~2)第三新的 commit

基本使用

以下是一些常用的命令:

git clone       # 克隆版本库
git fetch       # 同步远程版本库状态
git pull        # 同步当前分支并检出最新commit
git log         # 查看历史
git diff        # 查看差分
git checkout    # 签出分支|commit|tag等
git checkout -b # 新建分支
git branch -d   # 删除分支
git add         # 把文件或目录加入版本哭或者把变更加入到staged列表
git add -A      # 把所有untracked或者unstaged的文件或变更都加到staged列表
git rm          # 把文件或者目录移出版本库
git reset       # 把staged中的文件或者变更恢复到之前的状态
git revert      # 回滚到某一个commit
git stash       # 把所有uncommited的内容保存到缓存区域(.git/refs/stash)
git commit      # 生成commit
git merge       # 合并分支|commit等
git rebase      # “重新基于”一个分支
git tag         # 添加标签(tag)
git push        # 推送到远程版本库
git push -u     # 推送新分支
git config      # 配置

具体的用法可以这样看

man git

也可以在线查看progit

下面介绍几个有用或者要注意的命令。

  • 查看所有分支
git branch    # 本地
git branch -r # 远程
  • 查看 2 次 log
git log -2
  • 查看当前分支比和 other 分支差异的 commit
git log other..  # 当前分支比other分支多了哪些commit
git log ..other  # other分支比当前分支多了哪些commit
  • 热切换分支之前和之后可以用 stash 子命令来缓存和恢复工作状态
git stash       # 缓存
git stash apply # 恢复
git stash list  # 查看缓存列表
  • 强制覆盖远程分支 注意,只用于恢复代码
git push -f
  • 删除远程分支
git push origin --delete <branchName>
git push origin :<branchName>
  • 删除远程 tag
git push origin --delete tag <tagname>
git tag -d <tagname>
git push origin :refs/tags/<tagname>

工作流程

前面提过,得益于 git 的强大与简洁,业界已经衍生出几种基于 git 的工作流程。下面介绍几种有代表性的。

集中式流程 (centralized workflow)

这种工作流程称得上是 git 对集中式版本控制系统的降维攻击。一般来说比较适合用于个人项目、小团队项目或者充当从旧的版本控制系统迁移到 git 的过渡方案。

它大致的理念就是完全只用一个 master 分支作为开发、测试、发布分支,这样就相当于使用传统的集中式版本控制系统了。并且加上了一部分强大的 git 特性。

如果你熟悉git-svn,那么你甚至可以继续使用原有的 svn 服务器,改用 git 来做版本管理。

特性分支 (feature branch)

这个是云数据实验室现用工作流程。事实上也是流传最广的一种。它是广大码农在探索 git 最佳实践的过程中沉淀下来的东西,可以参考这篇文章:a-successful-git-branching-model

其主要的理念是把分支的创建粒度细化到功能点,提倡多多创建分支、提倡结对编程。实践这个工作流程最重要的一点就是约定分支管理的规范。下面列举云数据实验室的分支管理细则。

  • 主分支 master 为发布分支,只用于打标签(tag)和发布
  • dev 分支为开发分支,用于开发和部署开发机
  • feature 分支为功能点开发分支,从 dev 分支 checkout,分支以feature-开头,后半部分命名采用驼峰式说明要开发的 feature
  • bugfix 分支可以从 dev 或者 master 分支 checkout,以bugfix-开头,后半部分命名规则如上
  • 非代码资源、文档资源和其它静态资源可以建 asset 分支专门维护,也可以从 asset 中 checkout 以asset-开头的自分支
  • 每次 checkout 新分支之前要先同步远程版本库(git pull
  • 开发功能点前最好新建 issue,开发完成后新建一个从 feature 分支到 dev 分支的 merge request,并邀请其他成员进行 code review,review 人确认过后合并分支并且关闭 issue。
  • 发布之前把 dev 分支合并到 master 分支,测试通过后打上 tag(git tag -a),新的开发流程开始时 dev 分支要重新基于 master(git checkout dev & git rebase master

forking 工作流程 (forking workflow)

这种工作流程值得一提的原因是,它是github以及其它在线 git 版本库托管服务广泛支持的一种工作流程。这种工作流程一般是前一种工作流程的补充:它适合于核心开发者以外的人为项目提供补丁。

譬如你在用ipython的过程中发现了一个 bug,并且你修复了它,你想给 ipython 团队提供你的补丁代码,那么你可以把这个项目 fork 过来,加上补丁代码之后发起一个 pull request。事实上,像 ipython 这样流行的开源项目,一般都有一个规范的代码贡献流程,这个流程事实上就是一个 forking 工作流程的描述。ipython 的代码贡献细则可以在这里看到:ipython-pull-request

这种工作流程给有价值的开源项目提供了巨大的助力:一般一个开源项目贡献者越多,大家就越觉得它鲁棒、可靠,用的人也就越多;用的人多了,发现的 bug 也越详尽,pull request 也越多,项目越来越完善,反过来贡献者和新用户也就越多。这样就形成了一个良性循环。

github 这个聚拢了巨量软件开发人员的开源社区之所以取得成功,这种工作流程功不可没。

扩展阅读

a-successful-git-branching-model

atlassian-git-tutorials

git-scm

gitolite

progit

pull-requests-of-git-project

tig

糟糕,出了个贻笑大方的 bug,要怎样才能把 committer 改成自己的主管?

救火救场请用

$ git filter-branch -f --env-filter

或者直接报警 警察叔叔就是这个人