用Git进行版本控制 李博杰 2012-05-20 bojieli@gmail.com
版本控制的史前时代 用存储介质拷贝代码 代码相互覆盖,不知道哪个版本是正确的 搞错了无法恢复,需要定期手工备份 diff & patch 1991~2002,Linux内核 能看到文件之间的差异,知道哪里修改了 更改历史需要手工维护 GNU diff不支持二进制文件
版本控制的诞生 将diff和patch的过程自动化 单一文件版本管理工具RCS 只保留一个版本的完全拷贝,其他历次更改仅保 留差异:V3=V1+△1+△2 CVS:脚本实现的RCS文件容器(1985, 1986 publish, 1989 rewriten in C) 版本库中任意一个目录拿出来是一个新的版本库 Commit log, checkin, checkout, tag, branch 文件的版本号相互独立,全局版本号只能不停地 打tag
CVS原理示意
SVN 索引的是二维信息:文件、版本 以顺序数字编号命名 CVS:工作区中的每个文件对应版本库中的一个文件 db/revs下的:与上一提交的差异 db/revprogs下的:提交日志、作者、提交时间 SVN实现了全局版本号、原子提交、跟踪重命名
SVN原理示意
Linus为何迟迟不用版本控制 集中式版本控制系统 版本库存储在服务器端,每次提交、查看日志能 操作都要与服务器连接 代码的稳定性高度依赖中心服务器 历史不容篡改,无法做试验性提交,“一失足成千 古恨” Linus认为这不符合开源项目的精神,因此直到2002年才使用商业版 本控制系统BitKeeper管理Linux代码
分布式版本控制系统 每个人拥有一个完整的版本库 查看日志、提交、创建tag和分支等操作可以在本地 完成,不再时刻需要网络连接 在推送到远程版本库前可以做很多试验性的提交, 反复悔改而不必担心干扰其他人 多样的协同工作模型使开源项目的参与度爆发式增 长
Git是逼出来的 此时git代码只有1244行,只有一些底层命令 2005年4月,Andrew Tridgell试图对BitKeeper进行反向工程,以开发 一个能与之交互的开源工具 BitMover公司要求收回对Linux社区的免费授权 Linus只好“自力更生”: 2005-04-03,开始开发 2005-04-06,项目发布 2005-04-07,Git作为自身的版本控制工具 此时git代码只有1244行,只有一些底层命令
Git是逼出来的 (cont'd) 最初的Git除了一些核心命令,都用脚本语言写成 (现在为了效率大多用C语言重写) 2005-04-18,第一次分支合并 2005-04-29,Git的性能达到Linus的预期 2005-06-16,Linux 2.6.12开始采用Git 2005-07-26,Linus功成身退,将Git的维护交给另一位主要贡献者 Junio C Hamano 最初的Git除了一些核心命令,都用脚本语言写成 (现在为了效率大多用C语言重写) Android, Debian, Eclipse, Git, Gnome, KDE, Linux kernel, Perl, PHP, PostgreSQL, Qt, Ruby on Rails, X.org...
Git config 不要逐字照抄,不然功劳就算到我头上了~ git是分布式版本控制系统,不存在“验证”用户名、密码的中央版本库 git根据提交者设置的name和email记录更改是谁做出的,因此安装git 后的第一件事就是设置个人信息: git config --global user.email “bojieli@gmail.com” git config --global user.name “boj” 不要逐字照抄,不然功劳就算到我头上了~
Git初始化 初始化版本库:git init 得到隐藏的.git,版本库信息都存储在.git中 版本库在服务器端? 版本库在本地? 集中存储在全局的“数据库”中? 在工作区每个目录中?(CVS,Subversion) 在工作区根目录下?(git)
首次提交 要将已有的内容纳入版本控制,需要 将当前目录中的所有文件添加到索引 然后提交到版本库。 查看提交历史是否正常 git init只是建立了一个空的版本库 要将已有的内容纳入版本控制,需要 将当前目录中的所有文件添加到索引 git add . 然后提交到版本库。 git commit -m “initial commit” 查看提交历史是否正常 git log
git commit 此次提交是在master(主分支)上的,是该分支上 的根提交(首个提交),提交ID为…… 此次提交修改了11个文件,增加了1244行 创建了若干个文件,其权限均为644 Git规定提交必须输入提交说明 git commit -m “commit message” 或git commit,进入编辑器编辑提交说明(至少学 会一种编辑器吧,不然保存退出都成问题)
Git config的配置哪里去了 当前版本库是如何确定的? 没有全局“数据库”,只能找.git哦 git config --system /etc/gitconfig 系统 git config --global ~/.gitconfig 当前用户 git config .git/config 当前版本库 git config默认只针对当前版本库 当前版本库是如何确定的? 没有全局“数据库”,只能找.git哦 Git配置文件使用ini文件格式 读:git config <section>.<key> 写:git config <section>.<key> <value>
Git config试验 如果不设置user, email会怎么样? 如果设置了当前版本库的user, email会怎么样? git config --unset --global user.name git config --unset --global user.email git commit --allow-empty -m “anonymous commit”(加参数以允许空 提交) 如果设置了当前版本库的user, email会怎么样? git config user.email “bojieli@gmail.com” git commit --amend --allow-empty --reset-author amend:重新进行最近一次提交 reset-author:默认不更新提交日志中的Author
git add与暂存区 首先对版本库做一些修改 用git diff查看有哪些改动 输出就是GNU diff的样式 支持二进制文件哦 git commit -m “unstaged commit”,成功了吗? git log没有增加新的提交日志 git status有点似曾相识的感觉 git diff与commit前相比没有变化
git add与暂存区 (cont'd) 只有暂存的修改才会被提交。 Changes not staged for commit? 只有暂存的修改才会被提交。 如果同时在修改两个不同功能模块,一起commit 不如把两个模块的修改分开commit 如果正在开发的两个模块一个已经编好,另一个 尚未完成,如何提交阶段性成果? git的“暂存区”设计赋予用户对提交内容进行控制的能力,使得“按需 提交”成为可能。 工作区(working dir) => 暂存区(index) => 版本库 (repo)
git add与暂存区 (cont'd)
查看状态:git status 工作目录中的每个文件可能处于四种状态之一: untracked:未被跟踪,新添加的文件不会被自动跟踪哦~ unmodified:已被跟踪,但未被修改 modified:已被修改,但尚未被暂存 staged:修改已被暂存 git add就是把文件或目录加入暂存区的方法
文件的四种状态及转化
查看状态:git status (cont'd) git add的几种偷懒方法 git add . 将当前目录下的所有文件变更(包括新文件)放入暂存区 git add -u 将当前目录下被版本库跟踪的所有文件变更(不包括新文 件)放入暂存区 git add -A 将当前目录下所有文件变更(包括新文件)放入暂存区, 并查找重命名情况 git add -i 使用交互式界面选择需要添加的文件
查看状态:git status (cont'd) 每次提交前add太麻烦? git commit -a = git add -u + git commit Git可以跟踪文件的移动 git mv,直接将文件移动写入暂存区 使用mv移动,只要使用git add -A添加,git也能检 测到文件移动 git rm可以将文件的删除写入暂存区
查看状态:git status (cont'd) 根据.git/index记录的时间戳、长度等信息判断工作 区文件是否被改变 如果时间戳改变了,需要读取文件内容,计算其 SHA1 hash值,与版本库中的相比较;如果文件内 容没有改变,则更新.git/index时间戳 .git/index是包含文件索引的目录树,记录文件名、时间戳、文件长度 等。文件内容存储于git对象库.git/objects中,文件索引建立了文件与 对象库中文件内容间的对应。
查看状态:git status (cont'd) 扫描暂存区相对工作区的改动(只看索引) 扫描暂存区相对版本库的改动 扫描未被版本库跟踪的文件(扫描目录) 修改一个文件,添加到暂存区;再修改它一下,执 行git status。根据前面的原理,你能想到会发生什么 吗? 暂存区在.git/index,版本库在哪里?
走进版本库的老巢 对象存储在.git/objects/ID的前两位/ID的后38位 强大的git cat-file: .git/HEAD: “ref: refs/heads/master” .git/refs/heads/master: 一个提交ID 对象存储在.git/objects/ID的前两位/ID的后38位 强大的git cat-file: git cat-file -t 3eb72cf (commit) git cat-file -p 3eb72cf (tree, parent) tree对象:本次提交的文件列表(文件名、属性、内容所在对象)
走进版本库的老巢 (cont'd) 由于对象是按照hash值存储的,相同内容的文件只 会出现一个副本。 blob对象存储的就是文件内容 由于对象是按照hash值存储的,相同内容的文件只 会出现一个副本。 commit对象中的parent指向上一提交 Git中每个提交的tree是对于上一提交的增量 通过commit对象间的parent关联,可以识别出一条 跟踪链:git log --pretty=raw --graph
Git object hash 首先看commit的ID生成规则 Commit, tree, blob对象的40位ID是如何生成的? git cat-file -p HEAD等于git cat-file commit HEAD git rev-parse HEAD (echo -n "commit "; echo -n `git cat-file -p HEAD | wc -c`; printf "\000"; git cat-file -p HEAD) | sha1sum
Git object hash (cont'd) 文件内容blob对象 git rev-parse HEAD:Makefile (echo -n "blob "; echo -n `git cat-file -p HEAD:Makefile | wc -c`; printf "\000"; git cat-file -p HEAD:Makefile) | sha1sum tree对象 git cat-file -p HEAD^{tree}不等于git cat-file tree HEAD^{tree} git rev-parse HEAD^{tree} (echo -n "tree "; echo -n `git cat-file tree HEAD^{tree} | wc -c`; printf "\000"; git cat-file tree HEAD^{tree}) | sha1sum
Git object hash (cont'd) 如果提交采用顺序编号,在分布式版本控制系统中 无法做到不同人的提交编号不同 Git通过SHA1密码学意义上的无重复特性使得不同内容、不同时间、 不同作者的提交“全球唯一” 每个对象的ID与对象类型、长度、内容关联 一个提交的ID与其父提交关联,使得修改历史会 产生连锁效应,谁也不能篡改历史
Hash collision 160位的SHA1 hash冲突的概率微乎其微,因而Git并没有考虑hash冲 突的问题 Linus在Git邮件列表中回应此问题时说: 在commit时,git如果发现待添加的新对象SHA1值 已经存在,则会认为这个对象已经存在,因而引起 冲突的新文件不会被保存。用户执行checkout时才会 发现得到的文件与所期望的不同。
Hash collision 因此,即使恶意者提交了引起冲突的文件,也不会 改变历史。(构造SHA1冲突是很难的!) 如果真的在很偶然的情况下发生了冲突,则只要修 改待添加的文件(如加个空行)即可避开。 如果提前获知一个patch,并构造冲突包抢先发给 Linus,则能让真正的patch被“虚假”提交进去 例如commit message在显示时被认为是\0结尾的, 因此可以用hash-object手工制作commit对象,在\0 后面藏一些好玩的东西
访问对象的方法 在不引起冲突的情况下,只要把SHA1 hash值的前 几位写出即可 “基址” master = refs/heads/master = heads/master HEAD “偏移” HEAD^, HEAD^^, HEAD^:父提交 HEAD^2:第二个父提交(有多个父提交时用) HEAD~5:祖先提交
访问对象的方法 内容 树对象:HEAD^{tree} 文件对象:HEAD:Makefile 暂存区中的文件对象::Makefile 命令 git show:查看提交或对象的详细信息 git rev-parse:查询对象ID git cat-file:查看对象内容和类型
研究Git常用命令 Git cat-file Git rev-parse Git show Git ls-tree Git ls-files Git hash-object Git rev-list
分支游标 cat .git/refs/heads/master Echo “Hello World” >> README Git commit -a -m “Does master follow this new commit?” refs/heads/master就像一个游标,总是指向master(主分支)上“最新 ”的提交。 refs/tags/tagxxx指向tag xxx(里程碑) refs/heads/branchxxx指向branch xxx(分支)
git reset 有了游标,我们就可以在git历史中任意穿梭了 1.将HEAD游标指向新的提交ID 2.用游标所指向的提交替换暂存区 3.用暂存区替换工作区 git reset git reset --soft <commit> (1) git reset --hard <commit> (1,2,3) 危险! git reset <commit> (1,2) git reset --mixed <commit> (1,2)
git reset (cont'd) 不指定commit,默认为HEAD git reset:丢弃已暂存的更改 git reset --hard丢弃工作区和已暂存的更改(危险) git reset -- filename:git add的反向操作 git commit --amend = git reset --soft HEAD^ + git commit -e -F .git/COMMIT_EDITMSG保存了上次的提交说明 git reset --hard HEAD^:丢弃最近一次提交及此后的更改(危险)
最后一道防线:git reflog 上张slides中带--hard的都被标为“危险”,因为工作 区没有备份,一旦覆盖永久丢失;那么版本库中被 reset丢弃的提交还能找回来吗? .git/logs/HEAD, .git/logs/refs/heads/master 游标的更改是有历史记录的,因此这些被“丢弃”的 提交事实上记录在册 不过不能高枕无忧,默认90天过期,过期后的更 改记录会被清理掉 git reflog:查看游标历史
最后一道防线:git reflog (cont'd) 访问对象的方法又多了一种: HEAD@{n}:HEAD游标的第n次变化 master@{n}:master分支游标的第n次变化 HEAD@{0}就是刚刚进行的操作 git reset HEAD@{1}就能把刚刚被reset的提交找回来啦 利用git reset对分支游标和工作区的强大控制能 力,我们可以在历史中自由穿梭,甚至改变历史
破坏git的防线 不小心commit了一个大文件,无论如何reset --hard 版本库也不会变小 只要被引用,对象就会一直保留 直接删除对应的object,版本库的一致性被破坏 git fsck --no-reflog:dangling的对象就是没有被引用的对象,随时可 能被清理掉 git reflog expire --expire=now --all git fsck git prune
Git checkout 将某个特定的提交检出:更新HEAD指向 <commit>,用此提交更新暂存区和工作区 git reset虽然强大,但针对的都是HEAD,而HEAD指向的是 refs/heads/master,这就意味着只能修改当前分支。那么HEAD本身该 如何修改呢? git checkout <commit> 将某个特定的提交检出:更新HEAD指向 <commit>,用此提交更新暂存区和工作区 危险,暂存区和工作区的未提交改动会被覆盖 git checkout <commit> [--] <path> 不更新HEAD,只覆盖<path>指定路径的文件
git checkout (cont'd) You are in 'detached HEAD' state? 分离头指针,就是HEAD头指针指向了一个具体的 提交,如果再次执行checkout,就会覆盖掉这个 HEAD,从而丢失这一串提交。 事实上可以通过reflog这个神器找回来 推荐的方式是创建新的分支: git checkout -b new_branch_name <commit> 省略commit,则默认为HEAD
Git stash 如果有一些未提交的改动,现在希望参考一下原来 的版本,怎么办? 又一种访问对象的方法:stash@{n} git stash可以保存当前进度,把工作区和暂存区尚未提交的改动照个 快照保存起来,然后reset --hard git stash list查看保存的进度列表(栈) 又一种访问对象的方法:stash@{n} git stash pop把栈顶的进度“出栈” git stash apply应用栈顶的进度,但不出栈
git stash原理 一个提交如何同时表示工作区和暂存区? 顺藤摸瓜,发现一个commit,它有两个parent stash之后,发现多了个refs/stash,内有提交ID 一个提交如何同时表示工作区和暂存区? 顺藤摸瓜,发现一个commit,它有两个parent git cat-file不要这么快就忘了哦~ 这是一个合并提交,内容为工作区进度 (Work In Progress, WIP) 一个parent是原来的HEAD 另一个parent是暂存区进度(它的parent是原来的 HEAD)
git checkout (cont'd) 用暂存区覆盖工作区 git checkout [--] <paths> 用暂存区覆盖工作区 git checkout <branch> 切换到已有分支:更新HEAD指向 refs/heads/branch,用分支的最新提交覆盖暂存区和 工作区 git checkout -b <new_branch> [<commit>] 从<commit>创建新分支,覆盖暂存区和工作区 git checkout之前忘记git stash,欲哭无泪
git中的时光穿梭机 如果说git log是版本库历史的一张平面图,那么git 图形工具就是一张全息图,能更直观地展示各提交 间的相互关系。 gitk(原生) qgit(QT) gitg(GTK+) 在命令行下,git log --graph也可以显示提交关系
查看指定范围的历史 git log中不仅可以指定一个提交,还可以指定提交范围。 git log --oneline A:A的所有历史提交(一棵树) git log --oneline D F:两棵树的并集 git log --oneline ^G D:^是取反,即不包括树G git log --oneline G..D:与上面相同 git log --oneline D..G:与上面不同
查看指定范围的历史 (cont'd) 使用git rev-list可列出匹配的提交ID git log --oneline B...C:两个版本能够共同访问到的除外= B C --not $(git merge-base --all B C) git log --oneline B^@:不包括自身的历史提交 git log --oneline B^!:只包括自身,不包括历史提交 使用git rev-list可列出匹配的提交ID
定制git log的输出 如:git log -p -1 head git log -3:显示最近的3条日志 git log -p:显示日志时同时显示GNU diff样式改动 如:git log -p -1 head git log --stat:显示日志时同时显示diffstat改动摘要 git log --pretty=raw:显示commit的原始数据 git log --pretty=fuller:同时显示Author和Committer git show:显示单个提交
git diff git diff B A:比较两个commit或tag git diff A:比较工作区和A git diff --cached A:比较暂存区和A git diff:比较工作区和暂存区 git diff --cached:比较暂存区和HEAD git diff HEAD:比较工作区和HEAD
git blame 查看这个文件的每行最早是在什么版本、由谁引入 的,以便定位引起bug的版本和开发者 git blame <filename> 只查看某几行:(6,10中间不能有空格) git blame -L 6,10 README git blame -L 6,+5 README
多步悔棋 前面提到修补最近提交使用git commit --amend,根 据其原理,“多步悔棋”也不难实现。 例如,我们希望把过去的多个提交压缩成一个提 交,隐藏反复试验的过程,使版本库更干净: git reset --soft HEAD^^ git status(看看现在成什么样了) git commit -m “finish the new feature”
git rebase 如果开发进行了一段时间才想到要整理之前的提 交,该怎么办? git rebase --onto <newbase> <since> <till> git checkout D(要把C和D融为一体) git reset --soft HEAD^^ git commit -C C(使用C的提交说明) git tag newbase(新提交打上标签多方便) git rebase --onto newbase E^ master(这里用master取代F,就能直接 修改master的指向而无须再对其进行reset HEAD@{1})
git rebase -i 使用git rebase -i,可以通过编辑文件的方式,方便 地“定制”提交历史 git rebase -i <since> <till> <till>可以省略,默认为HEAD 将希望合并的提交的pick修改为squash(或 fixup),保存退出即可完成rebase操作。
git revert 在合作开发的过程中,一旦推送到了远程版本库, 就无法改变历史了。如何修正错误提交呢? 重新做一次新的提交,即错误的历史提交的反向提 交,这样就达到了git reset HEAD^的效果。 git revert HEAD
Git clone 通过clone的方式实现版本库的备份 生成一个“看起来一样”的版本库 向有工作区的版本库中推送(push)是不允许的, 因为这样会搞乱工作区和暂存区(除非设置 receive.denyCurrentBranch=ignore) git clone --bare 生成一个裸版本库,即不包含工作区的版本库
git协议 不同git版本库间进行数据交换的方式: 智能协议(在数据传输过程中有进度显示) 本地协议(file://) SSH Git HTTP(git-http-backend)
git协议 (cont'd) 哑协议(远程版本库方没有运行程序,全靠客户端 主动发现) FTP rsync HTTP(普通) 哑协议的传输速度较慢,因为客户端需要通过网 络获得.git/info/refs获取当前版本库的引用列表,再 根据提交ID访问对象库目录下的文件。
Git pull & push 从远程服务器获取版本库的更新 把本地的版本库推送到远程版本库 git pull = git fetch + git merge 把本地的版本库推送到远程版本库 git push pull和push时如何知道远程版本库在哪里? .git/config: remote “origin” pull时如何知道该和本地的哪个分支合并? .git/config: branch “master”
非快进式推送 如果当前分支的每一个提交都已经存在于另一个分 支,git执行“fast-forward”操作,即不创建新的提 交,只是将当前分支指向合并进来的分支。 non-fast-forward推送,即远程版本库有一个commit,而自己没有这 个commit;亦即在上次git pull之后有人推送了代码 git push -f(危险,会覆盖他人的修改) git pull; merge and resolve conflict; git push;
git分支 git branch:分支列表 git checkout <branch>:切换到分支 git checkout -b <branch>:新建分支 git branch -d <branch>:删除已被当前分支合并的其他分支 git branch -D <branch>:强制删除分支
git分支合并 将<branch>分支合并到当前分支 git merge <branch> 将<branch>分支合并到当前分支 如果发生冲突,且自动合并没有成功,则暂存区和 工作区内有一个特殊的状态,必须手动解决冲突并 提交它到暂存区,否则commit会失败 只需编辑发生冲突的文件(像diff的样式) git add git commit -m “resolve conflict”
git分支合并(cont'd) 如果发现不小心合并错了怎么办? 如果已经把合并后的代码commit,但还想撤销: git reset --hard HEAD HEAD指向当前commit 如果已经把合并后的代码commit,但还想撤销: git reset --hard ORIG_HEAD(危险) ORIG_HEAD指向“危险操作”前的HEAD。执行merge后, ORIG_HEAD就是HEAD@{1}
Git tag git tag:列出版本库中的现有标签 git tag -l <exp>:模糊匹配某些标签 git tag <tag> [<commit>]:新建轻量级标签 git tag <tag>:以HEAD建立标签,最常用 git tag -m <tag> <commit>:为新建的标签添加消息 git tag -a <tag> <commit>:创建一个标签对象,并需要标签消息。此 时标签引用指向一个标签对象,而不是一个commit。 git tag -d <tag>:删除标签
tag为什么push不上去 如果tag名字和分支名字相同,需要指定refs: 把所有的tags都push到远程版本库: git push origin <tagname> git push origin :<tagname>(删除标签) 如果tag名字和分支名字相同,需要指定refs: refs/tags/<tagname> refs/heads/branches/<branchname> 把所有的tags都push到远程版本库: git push origin --tags
tag为什么pull不下来 与push同理。 git pull origin <tagname> git pull origin --tags
Git hooks 在.git/hooks目录下有一些脚本,在特定的事件被触 发后调用。 远程版本库在pre-receive时检查提交者的用户名和 E-mail的合法性,gitosis和gitolite就是这样做的 利用git部署Web站点: 建立一个bare版本库作为remote origin Web目录做一个clone,并禁止Web访问.git 在origin的post-update脚本中:进入Web目录执行 git checkout,更新代码
Git基本工作流程 git config; git clone; while (project not finished) { cd 工作目录; git pull; 编写代码; git add 修改的文件; git status; git commit -m “message”; if (conflict) { 手动修改发生冲突的文件; git add 发生冲突的文件; git commit -m “resolve conflict”; } git push; sleep(); //休息一会儿 } //end while
Git基本操作 Git config Git init Git add Git status Git commit Git checkout Git reset Git log Git clone Git pull Git push
Git常用操作 Git tag Git stash Git reflog Git diff Git grep Git blame Git mv Git rm Git show Git fetch Git merge Git branch Git revert Git rebase
推荐资料 《Git权威指南》,蒋鑫著 本slides中的大部分内容是从此书中摘抄重组的 Git Community Book Pro Git (progit.org) 《Git权威指南》,蒋鑫著 本slides中的大部分内容是从此书中摘抄重组的 Google