[TOC]
Git
@(24.1 Git)[git]
工作原理
直接记录快照,而非差异比较
Git 只关心文件数据的整体是否发生变化,大多数其他系统则只关心文件内容的具体差异,每次记录有哪些文件作了更新,以及更新了什么内容,每个版本都是每个文件相对于上个版本更新了哪些内容。
Git 并不保存这些前后变化的差异数据:实际上,Git 更像是把变化后的文件作快照,记录在一个微型的文件系统中。每次提交更新,都会纵览一遍所有文件的指纹信息并对文件作一次快照,然后保存一个指向这次快照的索引。为提高性能,若文件没有变化,Git 不会再次保存快照,而只对上次保存的快照作一链接(指针)。
Git 更像一个小型的文件系统,同时提供许多以此为基础的超强工具。
时刻保持数据完整性
Git 使用 SHA-1 算法计算数据的校验和,通过对文件的内容或目录的结构计算出一个 SHA-1 哈希值,作为指纹字符串,Git 的工作完全依赖于这类指纹字串,所有保存在 Git 数据库中的东西都使用此哈希值作为索引,而不是靠文件名。
文件的三种状态
- 工作目录
- 暂存区域
- 本地仓库
对于任何一个文件,在 Git 内都只有三种状态:已提交(committed),已修改(modified)和已暂存(staged)。已提交表示该文件已经被安全地保存在本地数据库中了;已修改表示修改了某个文件,但还没有提交保存;已暂存表示把已修改的文件放在下次提交时要保存的清单中。
从项目中取出某个版本的所有文件和目录,用以开始后续工作的叫做工作目录。这些文件实际上都是从 Git 目录中的压缩对象数据库中提取出来的,接下来就可以在工作目录中对这些文件进行编辑。
所谓的暂存区域只不过是个简单的文件,一般都放在 Git 目录中。有时候人们会把这个文件叫做索引文件,不过标准说法还是叫暂存区域。
基本的 Git 工作流程如下:
- 在工作目录中修改某些文件。
- 对修改后的文件进行快照,然后保存到暂存区域。
- 提交更新,将保存在暂存区域的文件快照永久转储到 Git 目录中。
配置
用户信息
第一个要配置的是你个人的用户名称和电子邮件地址。这两条配置很重要,每次 Git 提交时都会引用这两条信息,说明是谁提交了更新,所以会随更新内容一起被永久纳入历史记录:
$ git config --global user.name "John Doe"
$ git config --global user.email johndoe@example.com
如果用了 --global 选项,那么更改的配置文件就是位于你用户主目录下的那个,以后你所有的项目都会默认使用这里配置的用户信息。如果要在某个特定的项目中使用其他名字或者电邮,只要去掉 --global 选项重新配置即可,新的设定保存在当前项目的 .git/config 文件里。
文本编辑器
接下来要设置的是默认使用的文本编辑器。Git 需要你输入一些额外消息的时候,会自动调用一个外部文本编辑器给你用。默认会使用操作系统指定的默认编辑器,一般可能会是 Vi 或者 Vim。如果你有其他偏好,比如 Emacs 的话,可以重新设置:
$ git config --global core.editor emacs
差异分析工具
还有一个比较常用的是,在解决合并冲突时使用哪种差异分析工具。比如要改用 vimdiff 的话:
$ git config --global merge.tool vimdiff
查看配置信息
要检查已有的配置信息,可以使用 git config --list 命令
基础
初始化仓库:git init
从现有仓库克隆:git clone [url]
工作目录操作
工作目录下面的所有文件只有两种状态:已跟踪或未跟踪。
初次克隆某个仓库时,工作目录中的所有文件都属于已跟踪文件,且状态为未修改。
在编辑过某些文件之后,Git 将这些文件标为已修改。我们逐步把这些修改过的文件放到暂存区域,直到最后一次性提交所有这些暂存起来的文件,如此重复。
检查当前文件状态
git status
跟踪新文件&暂存已修改文件
使用命令 git add
开始跟踪一个新文件:
git add README
此时再运行 git status
命令,会看到 README 文件已被跟踪,并处于暂存状态:
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
new file: README
只要在 “Changes to be committed” 这行下面的,就说明是已暂存状态。如果此时提交,那么该文件此时此刻的版本将被留存在历史记录中。
git add
命令还可以将修改过的文件添加到暂存区。
忽略某些文件
创建一个名为 .gitignore
的文件在根目录下,在其中设置忽略文件的数据:
- 所有空行或者以注释符号 # 开头的行都会被 Git 忽略。
- 可以使用标准的 glob 模式匹配。
- 匹配模式最后跟反斜杠(/)说明要忽略的是目录。
- 要忽略指定模式以外的文件或目录,可以在模式前加上惊叹号(!)取反。
# 此为注释 – 将被 Git 忽略
# 忽略所有 .a 结尾的文件
*.a
# 但 lib.a 除外
!lib.a
# 仅仅忽略项目根目录下的 TODO 文件,不包括 subdir/TODO
/TODO
# 忽略 build/ 目录下的所有文件
build/
# 会忽略 doc/notes.txt 但不包括 doc/server/arch.txt
doc/*.txt
# ignore all .txt files in the doc/ directory
doc/**/*.txt
所谓的 glob 模式是指 shell 所使用的简化了的正则表达式。星号(*)匹配零个或多个任意字符;[abc] 匹配任何一个列在方括号中的字符(这个例子要么匹配一个 a,要么匹配一个 b,要么匹配一个 c);问号(?)只匹配一个任意字符;如果在方括号中使用短划线分隔两个字符,表示所有在这两个字符范围内的都可以匹配(比如 [0-9] 表示匹配所有 0 到 9 的数字)。
查看已暂存和未暂存的更新
git status
的显示比较简单,仅仅是列出了修改过的文件,如果要查看具体修改了什么地方,可以用git diff
命令。
此命令比较的是工作目录中当前文件和暂存区域快照之间的差异,也就是修改之后还没有暂存起来的变化内容。
若要看已经暂存起来的文件和上次提交时的快照之间的差异,可以用 git diff --cached
命令。
请注意,单单git diff
不过是显示还没有暂存起来的改动,而不是这次工作和上次提交之间的差异。所以有时候你一下子暂存了所有更新过的文件后,运行git diff
后却什么也没有,就是这个原因。
提交更新
git commit -m "..."
提交时记录的是放在暂存区域的快照,任何还未暂存的仍然保持已修改状态,可以在下次提交时纳入版本管理。每一次运行提交操作,都是对你项目作一次快照,以后可以回到这个状态,或者进行比较。
跳过使用暂存区域
尽管使用暂存区域的方式可以精心准备要提交的细节,但有时候这么做略显繁琐。Git 提供了一个跳过使用暂存区域的方式,只要在提交的时候,给 git commit
加上 -a
选项,Git 就会自动把所有已经跟踪过的文件暂存起来一并提交,从而跳过git add
步骤:
git commit -a -m "..."
移除文件
要从 Git 中移除某个文件,就必须要从已跟踪文件清单中移除(确切地说,是从暂存区域移除),然后提交。
先在文件系统中删除该文件,然后在 Git 中移除 git rm log.txt
。
如果删除之前修改过并且已经放到暂存区域的话,则必须要用强制删除选项 -f(即 force 的首字母),以防误删除文件后丢失修改的内容。
另外一种情况是,我们想把文件从 Git 仓库中删除(亦即从暂存区域移除),但仍然希望保留在当前工作目录中。换句话说,仅是从跟踪清单中删除。比如一些大型日志文件或者一堆 .a 编译文件,不小心纳入仓库后,要移除跟踪但不删除文件,以便稍后在 .gitignore
文件中补上,用 --cached
选项即可:
$ git rm --cached readme.txt
后面可以列出文件或者目录的名字,也可以使用 glob 模式。比方说:
$ git rm log/\*.log
注意到星号 * 之前的反斜杠 \,因为 Git 有它自己的文件模式扩展匹配方式,所以我们不用 shell 来帮忙展开。
$ git rm \*~
会递归删除当前目录及其子目录中所有 ~ 结尾的文件。
移动文件
$ git mv file_from file_to
其实,运行 git mv 就相当于运行了下面三条命令:
$ mv README.txt README
$ git rm README.txt
$ git add README
查看提交历史
git log
git log 有许多选项可以帮助你搜寻感兴趣的提交:
git log -p -2
-p
选项展开显示每次提交的内容差异,用 -2
则仅显示最近的两次更新。
单词层面的对比:--word-diff
上下文( context )行数从默认的 3 行,减为 1 行:-U1
仅显示简要的增改行数统计:--stat
指定使用完全不同于默认格式的方式展示提交历史:-pretty=oneline
还有 short,full 和 fuller 参数可以用
用 oneline 或 format 时结合 --graph
选项,可以看到开头多出一些 ASCII 字符串表示的简单图形。
撤销操作
任何时候,都有可能需要撤消刚才所做的某些操作。请注意,有些撤销操作是不可逆的,所以请务必谨慎小心,一旦失误,就有可能丢失部分工作成果。
修改最后一次提交
可以使用 --amend 选项重新提交:
$ git commit --amend
实例,如果刚才提交时忘了暂存某些修改,可以先补上暂存操作,然后再运行 --amend 提交:
$ git commit -m 'initial commit'
$ git add forgotten_file
$ git commit --amend
上面的三条命令最终只是产生一个提交,第二个提交命令修正了第一个的提交内容。
取消已经暂存的文件
$ git reset HEAD <file>
文件回到已修改未暂存状态
取消对文件的修改
$ git checkout -- <file>
远程仓库使用
查看当前远程仓库
$ git remote
在克隆完某个项目后,至少可以看到一个名为 origin
的远程库,Git 默认使用这个名字来标识你所克隆的原始仓库:
也可以加上 -v
选项(此为 --verbose
的简写,取首字母),显示对应的克隆地址:
$ git remote -v
origin git://github.com/schacon/ticgit.git (fetch)
origin git://github.com/schacon/ticgit.git (push)
添加远程仓库
git remote add [shortname] [url]
$ git remote add pb git://github.com/paulboone/ticgit.git
$ git remote -v
origin git://github.com/schacon/ticgit.git
pb git://github.com/paulboone/ticgit.git
现在可以用字符串 pb 指代对应的仓库地址了。比如说,要抓取所有 Paul 有的,但本地仓库没有的信息,可以运行 git fetch pb
:
$ git fetch pb
remote: Counting objects: 58, done.
remote: Compressing objects: 100% (41/41), done.
remote: Total 44 (delta 24), reused 1 (delta 0)
Unpacking objects: 100% (44/44), done.
From git://github.com/paulboone/ticgit
* [new branch] master -> pb/master
* [new branch] ticgit -> pb/ticgit
现在,Paul 的主干分支(master)已经完全可以在本地访问了,对应的名字是 pb/master
,你可以将它合并到自己的某个分支,或者切换到这个分支,看看有些什么有趣的更新。
从远程仓库抓取数据
$ git fetch [remote-name]
此命令会到远程仓库中拉取所有你本地仓库中还没有的数据。运行完成后,你就可以在本地访问该远程仓库中的所有分支,将其中某个分支合并到本地,或者取出某个分支,一探究竟。
如果是克隆了一个仓库,此命令会自动将远程仓库归于 origin 名下。所以,git fetch origin 会抓取从你上次克隆以来别人上传到此远程仓库中的所有更新(或是上次 fetch 以来别人提交的更新)。有一点很重要,需要记住,fetch 命令只是将远端的数据拉到本地仓库,并不自动合并到当前工作分支,只有当你确实准备好了,才能手工合并。
如果设置了某个分支用于跟踪某个远端仓库的分支(参见下节及第三章的内容),可以使用 git pull
命令自动抓取数据下来,然后将远端分支自动合并到本地仓库中当前分支。在日常工作中我们经常这么用,既快且好。实际上,默认情况下 git clone
命令本质上就是自动创建了本地的 master 分支用于跟踪远程仓库中的 master 分支(假设远程仓库确实有 master 分支)。所以一般我们运行 git pull
,目的都是要从原始克隆的远端仓库中抓取数据后,合并到工作目录中的当前分支。
推送数据到远程仓库
项目进行到一个阶段,要同别人分享目前的成果,可以将本地仓库中的数据推送到远程仓库。实现这个任务的命令很简单: git push [remote-name] [branch-name]
。如果要把本地的 master 分支推送到 origin 服务器上(再次说明下,克隆操作会自动使用默认的 master 和 origin 名字),可以运行下面的命令:
$ git push origin master
只有在所克隆的服务器上有写权限,或者同一时刻没有其他人在推数据,这条命令才会如期完成任务。如果在你推数据前,已经有其他人推送了若干更新,那你的推送操作就会被驳回。必须先把他们的更新抓取到本地,合并到自己的项目中,然后才可以再次推送。
查看远程仓库信息
通过命令 git remote show [remote-name]
查看某个远程仓库的详细信息,比如要看所克隆的 origin 仓库,可以运行:
$ git remote show origin
* remote origin
URL: git://github.com/schacon/ticgit.git
Remote branch merged with 'git pull' while on branch master
master
Tracked remote branches
master
ticgit
除了对应的克隆地址外,它还给出了许多额外的信息。它友善地告诉你如果是在 master
分支,就可以用 git pull
命令抓取数据合并到本地。另外还列出了所有处于跟踪状态中的远端分支。
远程仓库的删除和重命名
git remote rename pb paul
git remote rm paul
Git 分支
为了理解 Git 分支的实现方式,我们需要理解 Git 是如何储存数据的。
Git 保持的不是文件差异或者变化量,而是一系列文件快照
在 Git 中提交时,会保存一个提交(commit)对象,该对象包含一个指向暂存内容快照的指针,包含本次提交的作者等相关附属信息,包含零个或多个指向该提交对象的父对象指针:首次提交是没有直接祖先的,普通提交有一个祖先,由两个或多个分支合并产生的提交则有多个祖先。
当使用 git commit
新建一个提交对象时,Git 会先计算每一个子目录的校验和,然后在 Git 仓库中将这些目录保存为树(tree)对象。之后 Git 创建的提交对象,除了包含相关提交信息以外,还包含着指向这个树对象(项目根目录)的指针,如此它就可以在将来需要的时候,重现此次快照的内容了。
单个提交对象在仓库中的数据结构
作些修改后再次提交,那么这次的提交对象会包含一个指向上次提交对象的指针。
多个提交对象之间的链接关系
现在来谈谈分支,Git 中的分支,本质上仅仅是个指向提交对象的可变指针,默认的分支名字为 master。
分支其实就是从某个提交对象往回看的历史
创建分支
$ git branch testing
会在当前提交对象上新建一个分支指针
Git 如何知道当前在哪个分支上工作:很简单,Git 内部有个名为 HEAD 的特别指针,指向正在工作中的本地分支的指针,也就是传说中的指向指针的指针。
如何切换分支:
git checkout testing
这样 HEAD 就指向了 testing 分支
在 testing 分支上的提交会创建一个新的提交对象,而 master 依旧指向原来的提交对象。
由于 Git 中的分支实际上仅是一个包含所指对象校验和(40 个字符长度 SHA-1 字串)的文件,所以创建和销毁一个分支就变得非常廉价。说白了,新建一个分支就是向一个文件写入 41 个字节(外加一个换行符)那么简单,当然也就很快了。
这和大多数版本控制系统形成了鲜明对比,它们管理分支大多采取备份所有项目文件到特定目录的方式,所以根据项目文件数量和大小不同,可能花费的时间也会有相当大的差别,快则几秒,慢则数分钟。而 Git 的实现与项目复杂度无关,它永远可以在几毫秒的时间内完成分支的创建和切换。同时,因为每次提交时都记录了祖先信息(parent 指针),将来要合并分支时,寻找恰当的合并基础(共同祖先)的工作其实已经自然而然地摆在那里了,所以实现起来非常容易。Git 鼓励开发者频繁使用分支,正是因为有着这些特性作保障。
合并分支
一个简单的分支与合并的例子,实际工作中也会用到这样的工作流程:
- 开发某个网站;
- 为实现某个新需求,创建一个分支;
- 在这个分支上开展工作。
假设此时,你突然接到一个电话说有个很严重的问题需要紧急修补,那么可以按照下面的方式处理: - 返回到原先已经发布到生产服务器上到分支;
- 为这次紧急修补创建一个新分支,并在其中修复问题;
- 通过测试后,回到生产服务器所在的分支,将修补分支合并进来,然后再推送到生产服务器上;
- 切换到之前实现新需求的分支,继续工作。
$ git checkout -b iss53
相当于执行下面这两条命令:
$ git branch iss53
$ git checkout iss53
$ git checkout master
切换到生产服务器分支
$ git checkout -b hotfix
$ git checkout master
回到master分支
$ git merge hotfix
合并进来
Updating f42c576..3a0874c
Fast-forward
README | 1 -
1 file changed, 1 deletion(-)
合并时出现了“Fast forward”的提示。由于当前 master 分支所在的提交对象是要并入的 hotfix 分支的直接上游,Git 只需把 master 分支指针直接右移。换句话说,如果顺着一个分支走下去可以到达另一个分支的话,那么 Git 在合并两者时,只会简单地把指针右移,因为这种单线的历史分支不存在任何需要解决的分歧,所以这种合并过程可以称为快进(Fast forward)。
现在最新的修改已经在当前 master 分支所指向的提交对象中了,可以部署到生产服务器上去了
master 分支和 hotfix 分支指向同一位置
在那个超级重要的修补发布以后,你想要回到被打扰之前的工作。由于当前 hotfix 分支和 master 都指向相同的提交对象,所以 hotfix 已经完成了历史使命,可以删掉了。
$ git branch -d hotfix
Deleted branch hotfix (was 3a0874c).
现在回到之前未完成的 iss53 问题修复分支上继续工作
$ git checkout iss53
Switched to branch 'iss53'
$ vim index.html
$ git commit -a -m 'finished the new footer [issue 53]'
[iss53 ad82d7a] finished the new footer [issue 53]
1 file changed, 1 insertion(+)
值得注意的是之前 hotfix 分支的修改内容尚未包含到 iss53 中来。如果需要纳入此次修补,可以用 git merge master 把 master 分支合并到 iss53;或者等 iss53 完成之后,再将 iss53 分支中的更新并入 master。
$ git checkout master
$ git merge iss53
Auto-merging README
Merge made by the 'recursive' strategy.
README | 1 +
1 file changed, 1 insertion(+)
这次合并操作的底层实现,并不同于之前 hotfix 的并入方式。因为这次你的开发历史是从更早的地方开始分叉的。由于当前 master 分支所指向的提交对象(C4)并不是 iss53 分支的直接祖先,Git 不得不进行一些额外处理。就此例而言,Git 会用两个分支的末端(C4 和 C5)以及它们的共同祖先(C2)进行一次简单的三方合并计算。
Git 为分支合并自动识别出最佳的同源合并点。
这次,Git 没有简单地把分支指针右移,而是对三方合并后的结果重新做一个新的快照,并自动创建一个指向它的提交对象(C6)。这个提交对象比较特殊,它有两个祖先(C4 和 C5)。
既然之前的工作成果已经合并到 master 了,那么 iss53 也就没用了。你可以就此删除它,并在问题追踪系统里关闭该问题。
$ git branch -d iss53
遇到冲突时到分支合并
有时候合并操作并不会如此顺利。如果在不同的分支中都修改了同一个文件的同一部分,Git 就无法干净地把两者合到一起。如果你在解决问题 #53 的过程中修改了 hotfix 中修改的部分,将得到类似下面的结果:
$ git merge iss53
Auto-merging index.html
CONFLICT (content): Merge conflict in index.html
Automatic merge failed; fix conflicts and then commit the result.
Git 作了合并,但没有提交,它会停下来等你解决冲突。要看看哪些文件在合并时发生冲突,可以用 git status
查阅:
$ git status
On branch master
You have unmerged paths.
(fix conflicts and run "git commit")
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: index.html
no changes added to commit (use "git add" and/or "git commit -a")
任何包含未解决冲突的文件都会以未合并(unmerged)的状态列出。Git 会在有冲突的文件里加入标准的冲突解决标记,可以通过它们来手工定位并解决这些冲突。可以看到此文件包含类似下面这样的部分:
<<<<<<< HEAD
<div id="footer">contact : email.support@github.com</div>
=======
<div id="footer">
please contact us at support@github.com
</div>
>>>>>>> iss53
可以看到 ======= 隔开的上半部分,是 HEAD(即 master 分支,在运行 merge 命令时所切换到的分支)中的内容,下半部分是在 iss53 分支中的内容。解决冲突的办法无非是二者选其一或者由你亲自整合到一起。比如你可以通过把这段内容替换为下面这样来解决:
<div id="footer">
please contact us at email.support@github.com
</div>
这个解决方案各采纳了两个分支中的一部分内容,而且还删除了 <<<<<<<,======= 和 >>>>>>> 这些行。在解决了所有文件里的所有冲突后,运行 git add
将把它们标记为已解决状态(译注:实际上就是来一次快照保存到暂存区域。)。因为一旦暂存,就表示冲突已经解决。如果你想用一个有图形界面的工具来解决这些问题,不妨运行 git mergetool
,它会调用一个可视化的合并工具并引导你解决所有冲突,再运行一次 git status 来确认所有冲突都已解决:
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
modified: index.html
如果觉得满意了,并且确认所有冲突都已解决,也就是进入了暂存区,就可以用 git commit
来完成这次合并提交。
如果想给将来看这次合并的人一些方便,可以修改该信息,提供更多合并细节。比如你都作了哪些改动,以及这么做的原因。有时候裁决冲突的理由并不直接或明显,有必要略加注解。
管理分支
列出当前分支清单:git branch
查看各个分支最后一个提交对象的信息:git branch -v
查看哪些分支已被并入当前分支:git branch --merged
分布式工作流程
一般这种情况有个官方发布的项目仓库,开发者由此仓库克隆出一个自己的公共仓库,然后将自己的提交推送上去,请求官方仓库的维护者拉取更新合并到主项目。维护者在自己本地也有个克隆仓库,可以将你的公共仓库作为远程仓库添加进来,经过测试无误后合并到主干分支,在推送到官方仓库。
- 项目维护者可以推送数据到公共仓库 blessed repository。
- 贡献者克隆此仓库,修订或编写新代码。
- 贡献者推送数据到自己的公共仓库 developer public。
- 贡献者给维护者发送邮件,请求拉取自己的最新修订。
- 维护者在自己本地的 integration manger 仓库中,将贡献者的仓库加为远程仓库,合并更新并做测试。
- 维护者将合并后的更新推送到主仓库 blessed repository。