Git 基础
三种状态
Git 有三种状态,你的文件可能处于其中之一:已提交(committed)、已修改(modified)和已暂存(staged)。 已提交表示数据已经全的保存在本地数据库中。 已修改表示修改了文件,但还没保存到数据库中。 已暂存表示对一个已修改文件的当前版本做了标记,使之包含在下次提交的快照中。
基本的 Git 工作流程如下:
- 在工作目录中修改文件。
- 暂存文件,将文件的快照放入暂存区域。
- 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 仓库目录。
如果 Git 目录中保存着的特定版本文件,就属于已提交状态。 如果作了修改并已放入暂存区域,就属于已暂存状态。 如果自上次取出后作了修改但还没有放到暂存区域,就是已修改状态
记录每次更新
请记住,你工作目录下的每一个文件都不外乎这两种状态:已跟踪未跟踪。 已跟踪的文件是指那些被纳入了版本控制的文件,在上一次快照中有它们的记录,在工作一段时间后,它们的状态可能处于未修改,已修改或已放入暂存区。 工作目录中除已跟踪文件以外的所有其它文件都属于未跟踪文件,它们既不存在于上次快照的记录中,也没有放入暂存区。 初次克隆某个仓库的时候,工作目录中的所有文件都属于已跟踪文件,并处于未修改状态。
编辑过某些文件之后,由于自上次提交后你对它们做了修改,Git 将它们标记为已修改文件。我们逐步将这些修改过的文件放入暂存区然后提交所有暂存了的修改,如此反复。所以使用 Git 时文件的生命周期如下:(图片中Unmodified可以理解为上面的commited 已提交状态)
忽略文件
我们可以创建一个名为 .gitignore 的文件,列出要忽略的文件模式
文件 .gitignore 的格式规范如下:
- 所有空行或者以 # 开头的行都会被 Git 忽略。
- 可以使用标准的 glob 模式匹配。
- 匹配模式可以以( / )开头防止递归。
- 匹配模式可以以( / )结尾指定目录。
- 要忽略指定模式以外的文件或目录,可以在模式前加上惊叹号( ! )取反。
所谓的 glob 模式是指 shell 所使用的简化了的正则表达式。 星号()匹配零个或多个任意字符; [abc] 匹配任何一个列在方括号中字符(这个例子要么匹配一个 a,要么匹配一个b,要么匹配一个c);问号( ? )只匹配一个任意字符;如果在方括号中使用短划线分隔两个字符,表示所有在这两个字符范围内的都可以匹配(比如 [0-9] 示匹配所有 0 到 9 的数字)。 使用两个星号( ) 表示匹配任意中间目录,比如 a/**/z 可以匹配 a/z , a/b/z 或a/b/c/z 等。
我们再看一个 .gitignore 文件的例子:
# no .a files
*.a
# but do track lib.a, even though you're ignoring .a files above
!lib.a
# only ignore the TODO file in the current directory, not subdir/TODO
/TODO
# ignore all files in the build/ directory
build/
# ignore doc/notes.txt, but not doc/server/arch.txt
doc/*.txt
# ignore all .pdf files in the doc/ directory
doc/**/*.pdf
复制代码
比较差异
要查看尚未暂存的文件更新了哪些部分,不加参数直接输入 git diff
若要查看已暂存的将要添加到下次提交里的内容,可以用 git diff --cached 命令。(Git 1.6.1 及更高版本还允许使用 git diff --staged ,效果是相同的,但更好记些。)
请注意,git diff 本身只显示尚未暂存的改动,而不是自上次提交以来所做的所有改动。 所以有时候你一下子暂存了所有更新过的文件后,运行 git diff 后却什么也没有,就是这个原因。
提交更新
git commit -m 'xxxx'
复制代码
请记住,提交时记录的是放在暂存区域的快照。 任何还未暂存的然保持已修改状态,可以在下次提交时纳入版本管理。 每一次运行提交操作,都是对你项目作一次快照,以后可以回到这个状态,或者进行比较。
跳过使用暂存区域
git commit 加上 -a 选项,Git 就会自动把所有已经跟踪过的文件存起来一并提交,从而跳过 git add 步骤:
移除文件
要从 Git 中移除某个文件,就必须要从已跟踪文件清单中移除(确地说,是从暂存区域移除),然后提交。 可以用 git rm 命令完成此工作,并连带从工作目录中删除指定的文件,这样以后就不会出现在未跟踪文件清单中了。
撤消操作
有时候我们提交完了才发现漏掉了几个文件没有添加行带有 --amend 选项的提交命令尝试重新提交:
git commit --amend
复制代码
文本编辑器启动后,可以看到之前的提交信息。 编辑后保存会覆盖原来的提交信息。 例如,你提交后发现忘记了暂存某些需要的修改,可以像下面这样操作:
$ git commit -m 'initial commit'
$ git add forgotten_file
$ git commit --amend
复制代码
最终你只会有一个提交 - 第二次提交将代替第一次提交的结果。
取消暂存的文件
接下来的两个小节演示如何操作暂存区域与工作目录中已修改的文件。 这些命令在修改文件状态的同时,也会提示如何撤消操作。 例如,你已经修改了两个文件并且想要将它们作为两次独立的修改提交,但是却意外地输入了 git add * 暂存了它们两个。 如何只取消暂存两个中的一个呢? git status 命令提示了你:
$ git add *
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD ..." to unstage)
renamed: README.md -> README
modified: CONTRIBUTING.md
复制代码
在 Changes to be committed
文字正下方,提示使用 `git reset HEAD … 来取消暂存。 所 以,我们可以这样来取消暂存 CONTRIBUTING.md 文件:
$ git reset HEAD CONTRIBUTING.md
Unstaged changes after reset:
M CONTRIBUTING.md
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD ..." to unstage)
renamed: README.md -> README
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git checkout -- ..." to discard changes in working directory)
modified: CONTRIBUTING.md
复制代码
撤消对文件的修改
如果你并不想保留对 CONTRIBUTING.md 文件的修改怎么办? 你该如何方便地撤消修改 - 将它还原成上次提交时的样子(或者刚克隆完的样子,或者刚把它放入工作目录时的样子)? 幸运的是, git status 也告诉了你应该如何做。 在最后一个例子中,未暂存区域是这样:
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git checkout -- ..." to discard changes in working directory)
modified: CONTRIBUTING.md
复制代码
它非常清楚地告诉了你如何撤消之前所做的修改。 让我们来按照提示执行:
$ git checkout -- CONTRIBUTING.md
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD ..." to unstage)
renamed: README.md -> README
复制代码
远程仓库的使用
查看远程仓库
如果想查看你已经配置的远程仓库服务器,可以运行 git remote 命令
你也可以指定选项 -v ,会显示需要读写远程仓库使用的 Git 保存的简写与其对应的 URL
$ git remote -v
origin https://github.com/schacon/ticgit (fetch)
origin https://github.com/schacon/ticgit (push)
复制代码
添加远程仓库
运行 git remote add 添加一个新的远程 Git 仓库
从远程仓库中抓取与拉取
$ git fetch [remote-name]
复制代码
这个命令会访问远程仓库,从中拉取所有你还没有的数据。 执行完成后,你将会拥有那个远程仓库中所有分支的引用,可以随时合并或查看。
如果你使用 clone 命令克隆了一个仓库,命令会自动将其添加为远程仓库并默认以origin 为简写。 所以,git fetch origin 会抓取克隆(或上一次抓取)后新推送的所有工作。 必须注意 git fetch 命令会将数据拉取到你的本地仓库 - 它并不会自动合并或修改你当前的工作。 当准备好时你必须手动将其合并入你的工作。
推送到远程仓库
当你想分享你的项目时,必须将其推送到上游。 这个命令很简单: git push [remote-name][branch-name] 。 当你想要将 master 分支推送到 origin 服务器时(再次说明,克隆时通常会自动帮你设置好那两个名字),那么运行这个命令就可以将你所做的备份到服务器:
git push origin master
复制代码
打标签
像其他版本控制系统(VCS)一样,Git 可以给历史中的某一个提交打上标签,以示重要。 比较有代表性的是人们会使用这个功能来标记发布结点(v1.0 等等)。 在本节中,你将会学习如何列出已有的标签、如何创建新标签、以及不同类型的标签分别是什么。
列出标签
在 Git 中列出已有的标签是非常简单直观的。 只需要输入 git tag :
git tag
v0.1
v1.3
复制代码
你也可以使用特定的模式查找标签。 例如,Git 自身的源代码仓库包含标签的数量超过 500个。 如果只对 1.8.5 系列感兴趣,可以运行:
git tag -l 'v1.8.5*'
复制代码
创建标签
Git 使用两种主要类型的标签:轻量标签(lightweight)与附注标签(annotated)。
一个轻量标签很像一个不会改变的分支 - 它只是一个特定提交的引用。
然而,附注标签是存储在 Git 数据库中的一个完整对象。 它们是可以被校验的;其中包含打标签者的名字、电子邮件地址、日期时间;还有一个标签信息;并且可以使用 GNU PrivacyGuard (GPG)签名与验证。 通常建议创建附注标签,这样你可以拥有以上所有信息;但是如果你只是想用一个临时的标签,或者因为某些原因不想要保存那些信息,轻量标签也是可用的。
附注标签
在 Git 中创建一个附注标签是很简单的。 最简单的方式是当你在运行 tag 命令时指定 -a选项:
$ git tag -a v1.4 -m 'my version 1.4'
$ git tag
v0.1
v1.3
v1.4
复制代码
-m 选项指定了一条将会存储在标签中的信息。 如果没有为附注标签指定一条信息,Git 会运行编辑器要求你输入信息。
通过使用 git show
命令可以看到标签信息与对应的提交信息:
$ git show v1.4
tag v1.4
Tagger: Ben Straub
Date: Sat May 3 20:19:12 2014 -0700
my version 1.4
commit ca82a6dff817ec66f44342007202690a93763949
Author: Scott Chacon
Date: Mon Mar 17 21:52:11 2008 -0700
changed the version number
复制代码
输出显示了打标签者的信息、打标签的日期时间、附注信息,然后显示具体的提交信息。
轻量标签
另一种给提交打标签的方式是使用轻量标签。 轻量标签本质上是将提交校验和存储到一个文件中 - 没有保存任何其他信息。 创建轻量标签,不需要使用 -a 、 -s 或 -m 选项,只需要提供标签名字:
$ git tag v1.4-lw
$ git tag
v0.1
v1.3
v1.4
v1.4-lw
v1.5
复制代码
共享标签
默认情况下, git push 命令并不会传送标签到远程仓库服务器上。 在创建完标签后你必须显式地推送标签到共享服务器上。 这个过程就像共享远程分支一样 - 你可以运行 git push origin [tagname] 。
$ git push origin v1.5
Counting objects: 14, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (12/12), done.
Writing objects: 100% (14/14), 2.05 KiB | 0 bytes/s, done.
Total 14 (delta 3), reused 0 (delta 0)
To [email protected]:schacon/simplegit.git
* [new tag] v1.5 -> v1.5
复制代码
检出标签
在 Git 中你并不能真的检出一个标签,因为它们并不能像分支一样来回移动。 如果你想要工作目录与仓库中特定的标签版本完全一样,可以使用 git checkout -b [branchname][tagname] 在特定的标签上创建一个新分支:
$ git checkout -b version2 v2.0.0
Switched to a new branch 'version2'
复制代码
当然,如果在这之后又进行了一次提交, version2 分支会因为改动向前移动了,那么 version2 分支就会和 v2.0.0 标签稍微有些不同,这时就应该当心了。
总结
现在,你可以完成所有基本的 Git 本地操作-创建或者克隆一个仓库、做更改、暂存并提交这些更改、浏览你的仓库从创建到现在的所有更改的历史。 下一步,本书将介绍 Git 的杀手级特性:分支模型。
Git 分支
分支简介
Git 保存的不是文件的变化或者差异,而是一系列不同时刻的文件快照。
在进行提交操作时,Git 会保存一个提交对象(commit object)。知道了 Git 保存数据的方式,我们可以很自然的想到——该提交对象会包含一个指向暂存内容快照的指针。但不仅仅是这样,该提交对象还包含了作者的姓名和邮箱、提交时输入的信息以及指向它的父对象的指针。首次提交产生的提交对象没有父对象,普通提交操作产生的提交对象有一个父对象,而由多个分支合并产生的提交对象有多个父对象,
在进行提交操作时,Git 会保存一个提交对象(commit object)。知道了 Git 保存数据的方式,我们可以很自然的想到——该提交对象会包含一个指向暂存内容快照的指针。 但不仅仅是这样,该提交对象还包含了作者的姓名和邮箱、提交时输入的信息以及指向它的父对象的指针。首次提交产生的提交对象没有父对象,普通提交操作产生的提交对象有一个父对象,而由多个分支合并产生的提交对象有多个父对象,
$ git add README test.rb LICENSE
$ git commit -m 'The initial commit of my project'
复制代码
当使用 git commit 进行提交操作时,Git 会先计算每一个子目录(本例中只有项目根目录)的校验和,然后在 Git 仓库中这些校验和保存为树对象。 随后,Git 便会创建一个提交对象,它除了包含上面提到的那些信息外,还包含指向这个树对象(项目根目录)的指针。如此一来,Git 就可以在需要的时候重现此次保存的快照。
现在,Git 仓库中有五个对象:三个 blob 对象(保存着文件快照)、一个树对象(记录着目录结构和 blob 对象索引)以及一个提交对象(包含着指向前述树对象的指针和所有提交信息)。
做些修改后再次提交,那么这次产生的提交对象会包含一个指向上次提交对象(父对象)的指针。
Git 的分支,其实本质上仅仅是指向提交对象的可变指针。 Git 的默认分支名字是 master 。在多次提交操作之后,你其实已经有一个指向最后那个提交对象的 master 分支。 它会在每次的提交操作中自动向前移动。
分支创建
Git 是怎么创建新分支的呢? 很简单,它只是为你创建了一个可以移动的新的指针。 比如,创建一个 testing 分支, 你需要使用 git branch 命令:
$ git branch testing
复制代码
那么,Git 又是怎么知道当前在哪一个分支上呢? 也很简单,它有一个名为 HEAD 的特殊指针。 请注意它和许多其它版本控制系统(如 Subversion 或 CVS)里的 HEAD 概念完全不同。 在 Git 中,它是一个指针,指向当前所在的本地分支(译注:将 HEAD 想象为当前分支的别名)。 在本例中,你仍然在 master 分支上。 因为 git branch 命令仅仅 创建 一个新分支,并不会自动切换到新分支中去。
你可以简单地使用 git log 命令查看各个分支当前所指的对象。 提供这一功能的参数是 -- decorate 。
$ git log --oneline --decorate
f30ab (HEAD, master, testing) add feature #32 - ability to add new
34ac2 fixed bug #1328 - stack overflow under certain conditions
98ca9 initial commit of my project
复制代码
分支切换
要切换到一个已存在的分支,你需要使用 git checkout 命令。 我们现在切换到新创建的 testing 分支去:
git checkout testing
复制代码
这样 HEAD 就指向 testing 分支了。
那么,这样的实现方式会给我们带来什么好处呢? 现在不妨再提交一次:
$ vim test.rb
$ git commit -a -m 'made a change'
复制代码
如图所示,你的 testing 分支向前移动了,但是 master 分支却没有,它仍然指向运行 git checkout 时所指的对象。 这就有意思了,现在我们切换回 master 分支看看:
$ git checkout master
复制代码
这条命令做了两件事。 一是使 HEAD 指回 master 分支,二是将工作目录恢复成 master 分支所指向的快照内容。 也就是说,你现在做修改的话,项目将始于一个较旧的版本。 本质上来讲,这就是忽略 testing 分支所做的修改,以便于向另一个方向进行开发。
你可以简单地使用 git log 命令查看分叉历史。 运行 git log --oneline --decorate --graph --all ,它会输出你的提交历史、各个分支的指向以及项目的分支分叉情况。
$ git log --oneline --decorate --graph --all
* c2b9e (HEAD, master) made other changes
| * 87ab2 (testing) made a change
|/
* f30ab add feature #32 - ability to add new formats to the
* 34ac2 fixed bug #1328 - stack overflow under certain conditions
* 98ca9 initial commit of my project
复制代码
分支管理
远程分支
远程引用是对远程仓库的引用(指针),包括分支、标签等等。 你可以通过 git ls-remote (remote) 来显式地获得远程引用的完整列表,或者通过 git remote show (remote) 获得远程分支的更多信息。 然而,一个更常见的做法是利用远程跟踪分支。 远程跟踪分支是远程分支状态的引用。 它们是你不能移动的本地引用,当你做任何网络通信操作时,它们会自动移动。 远程跟踪分支像是你上次连接到远程仓库时,那些分支所处状态的书签。 它们以 (remote)/(branch) 形式命名。 例如,如果你想要看你最后一次与远程仓库 origin 通信时 master 分支的状态,你可以查看 origin/master 分支。 你与同事合作解决一个问题并且他们推送了一个 iss53 分支,你可能有自己的本地 iss53 分支;但是在服务器上的分支会指向 origin/iss53 的提交。
如果你在本地的 master 分支做了一些工作,然而在同一时间,其他人推送提交到 git.ourcompany.com 并更新了它的 master 分支,那么你的提交历史将向不同的方向前进。 也许,只要你不与 origin 服务器连接,你的 origin/master 指针就不会移动。
如果要同步你的工作,运行 git fetch origin 命令。 这个命令查找 origin'' 是哪一个服务器(在本例中,它是
git.ourcompany.com ),从中抓取本地没有的数据,并且更新本地数据库,移动 origin/master 指针指向新的、更新后的位置。
推送
当你想要公开分享一个分支时,需要将其推送到有写入权限的远程仓库上。 本地的分支并不会自动与远程仓库同步 - 你必须显式地推送想要分享的分支。 这样,你就可以把不愿意分享的内容放到私人分支上,而将需要和别人协作的内容推送到公开分支。如果希望和别人一起在名为 serverfix 的分支上工作,你可以像推送第一个分支那样推送它。 运行 git push (remote) (branch) :
$ git push origin serverfix
复制代码
这里有些工作被简化了。 Git 自动将 serverfix 分支名字展开为 refs/heads/serverfix:refs/heads/serverfix ,那意味着, 推送本地的 serverfix 分支来更新远程仓库上的 serverfix 分支。'' 我们将会详细学习 [_git_internals] 的 refs/heads/ 部分,但是现在可以先把它放在儿。 你也可以运行 git push origin serverfix:serverfix,它会做同样的事 - 相当于它说, 推送本地的 serverfix 分支,将其作为远程仓库的 serverfix 分支'' 可以通过这种格式来推送本地分支到一个命名不相同的远程分支。 如果并不想让远程仓库上的分支叫做 serverfix ,可以运行git push origin serverfix:awesomebranch 来将本地的 serverfix 分支推送到远程仓库上的awesomebranch 分支。
如何避免每次输入密码 如果你正在使用 HTTPS URL 来推送,Git 服务器会询问用户名与密码。 默认情 况下它会在终端中提示服务器是否允许你进行推送。 如果不想在每一次推送时都输入用户名与密码,你可以设置一个
credential cache''。 最简单的方式就是将其保存在内存中几分钟,可以简单地运行
git config --global credential.helper cache 来设置它。 想要了解更多关于不同验证缓存的可用选项
跟踪分支
从一个远程跟踪分支检出一个本地分支会自动创建一个叫做 跟踪分支''(有时候也叫做 上游分支'')。 跟踪分支是与远程分支有直接关系的本地分支。 如果在一个跟踪分支上输入 git pull ,Git 能自动地识别去哪个服务器上抓取、合并到哪个分支。
当克隆一个仓库时,它通常会自动地创建一个跟踪 origin/master 的 master 分支。 然而,如果你愿意的话可以设置其他的跟踪分支 - 其他远程仓库上的跟踪分支,或者不跟踪 master分支。 最简单的就是之前看到的例子,运行 git checkout -b [branch]
[remotename]/[branch]
。 这是一个十分常用的操作所以 Git 提供了 --track 快捷方式:
$ git checkout --track origin/serverfix
Branch serverfix set up to track remote branch serverfix from origin.
Switched to a new branch 'serverfix'
复制代码
设置已有的本地分支跟踪一个刚刚拉取下来的远程分支,或者想要修改正在跟踪的上游分 支,你可以在任意时间使用 -u 或 --set-upstream-to 选项运行 git branch 来显式地设 置。
$ git branch -u origin/serverfix
Branch serverfix set up to track remote branch serverfix from origin.
复制代码
如果想要查看设置的所有跟踪分支,可以使用 git branch 的 -vv 选项。 这会将所有的本 地分支列出来并且包含更多的信息,如每一个分支正在跟踪哪个远程分支与本地分支是否是领先、落后或是都有。
$ git branch -vv
iss53 7e424c3 [origin/iss53: ahead 2] forgot the brackets
master 1ae2a45 [origin/master] deploying index fix
* serverfix f8674d9 [teamone/server-fix-good: ahead 3, behind 1] this should do it
testing 5ea463a trying something new
复制代码
拉取
当 git fetch 命令从服务器上抓取本地没有的数据时,它并不会修改工作目录中的内容。 它只会获取数据然后让你自己合并。 然而,有一个命令叫作 git pull 在大多数情况下它的含义是一个 git fetch 紧接着一个 git merge 命令。 如果有一个像之前章节中演示的设置好的跟踪分支,不管它是显式地设置还是通过 clone 或 checkout 命令为你创建的, git pull 都会查找当前分支所跟踪的服务器与分支,从服务器上抓取数据然后尝试合并入那个远程分支。 由于 git pull 的魔法经常令人困惑所以通常单独显式地使用 fetch 与 merge 命令会更好 一些。
删除远程分支
假设你已经通过远程分支做完所有的工作了 - 也就是说你和你的协作者已经完成了一个特性并且将其合并到了远程仓库的 master 分支(或任何其他稳定代码分支)。 可以运行带有 --delete 选项的 git push 命令来删除一个远程分支。 如果想要从服务器上删serverfix分支,运行下面的命令:
$ git push origin --delete serverfix
To https://github.com/schacon/simplegit
- [deleted] serverfix
复制代码
变基
在 Git 中整合来自不同分支的修改主要有两种方法: merge 以及 rebase 。 在本节中我们将学习什么是“变基”,怎样使用“变基”,并将展示该操作的惊艳之处,以及指出在何种情况下你应避免使用它。
变基的基本操作
请回顾之前在 [_basic_merging] 中的一个例子,你会看到开发任务分叉到两个不同分支,又各自提交了更新。
之前介绍过,整合分支最容易的方法是 merge 命令。 它会把两个分支的最新快照( C3 和 C4 )以及二者最近的共同祖先( C2 )进行三方合并,合并的结果是生成一个新的快照(并提交)。
其实,还有一种方法:你可以提取在 C4 中引入的补丁和修改,然后在 C3 的基础上再应用一次。 在 Git 中,这种操作就叫做 变基。 你可以使用 rebase 命令将提交到某一分支上的所有修改都移至另一分支上,就好像“重新播放”一样。
在上面这个例子中,运行:
$ git checkout experiment
$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: added staged command
复制代码
它的原理是首先找到这两个分支(即当前分支 experiment 、变基操作的目标基底分支 master )的最近共同祖先 C2 ,然后对比当前分支相对于该祖先的历次提交,提取相应的修改并存为临时文件,然后将当前分支指向目标基底 C3 , 最后以此将之前另存为临时文件的修改依序应用。(译注:写明了 commit id,以便理解,下同)
现在回到 master 分支,进行一次快进合并。
$ git checkout master
$ git merge experiment
复制代码
此时, C4' 指向的快照就和上面使用 merge 命令的例子中 C5 指向的快照一模一样了。 这两种整合方法的最终结果没有任何区别,但是变基使得提交历史更加整洁。 你在查看一个经过变基的分支的历史记录时会发现,尽管实际的开发工作是并行的,但它们看上去就像是先后串行的一样,提交历史是一条直线没有分叉。
一般我们这样做的目的是为了确保在向远程分支推送时能保持提交历史的整洁——例如向某个别人维护的项目贡献代码时。 在这种情况下,你首先在自己的分支里进行开发,当开发完成时你需要先将你的代码变基到 origin/master 上,然后再向主项目提交修改。 这样的话,该项目的维护者就不再需要进行整合工作,只需要快进合并便可。
请注意,无论是通过变基,还是通过三方合并,整合的最终结果所指向的快照始终是一样 的,只不过提交历史不同罢了。 变基是将一系列提交按照原有次序依次应用到另一分支上,而合并是把最终结果合在一起。
变基的风险
呃,奇妙的变基也并非完美无缺,要用它得遵守一条准则:
不要对在你的仓库外有副本的分支执行变基。
变基操作的实质是丢弃一些现有的提交,然后相应地新建一些内容一样但实际上不同的提 交。 如果你已经将提交推送至某个仓库,而其他人也已经从该仓库拉取提交并进行了后续工作,此时,如果你用 git rebase 命令重新整理了提交并再次推送,你的同伴因此将不得不再次将他们手头的工作与你的提交进行整合,如果接下来你还要拉取并整合他们修改过的提交,事情就会变得一团糟。
然后,某人又向中央服务器提交了一些修改,其中还包括一次合并。 你抓取了这些在远程分支上的修改,并将其合并到你本地的开发分支,然后你的提交历史就会变成这样:
接下来,这个人又决定把合并操作回滚,改用变基;继而又用 git push --force 命令覆盖了服务器上的提交历史。 之后你从服务器抓取更新,会发现多出来一些新的提交。
结果就是你们两人的处境都十分尴尬。 如果你执行 git pull 命令,你将合并来自两条提交 历史的内容,生成一个新的合并提交,最终仓库会如图所示:
此时如果你执行 git log 命令,你会发现有两个提交的作者、日期、日志居然是一样的,这会令人感到混乱。 此外,如果你将这一堆又推送到服务器上,你实际上是将那些已经被变基抛弃的提交又找了回来,这会令人感到更加混乱。 很明显对方并不想在提交历史中看到 C4和 C6 ,因为之前就是他们把这两个提交通过变基丢弃的。
只要你把变基命令当作是在推送前清理提交使之整洁的工具,并且只在从未推送至共用仓库的提交上执行变基命令,你就不会有事。 假如你在那些已经被推送至共用仓库的提交上执行变基命令,并因此丢弃了一些别人的开发所基于的提交,那你就有大麻烦了,你的同事也会因此鄙视你。
如果你或你的同事在某些情形下决意要这么做,请一定要通知每个人执行 git pull --rebase命令,这样尽管不能避免伤痛,但能有所缓解。
总的原则是,只对尚未推送或分享给别人的本地修改执行变基操作清理历史,从不对已推送至别处的提交执行变基操作,这样,你才能享受到两种方式带来的便利。
Git工具
重置揭密
三棵树
理解 reset 和 checkout 的最简方法,就是以 Git 的思维框架(将其作为内容管理器)来管理三棵不同的树。 树'' 在我们这里的实际意思是 文件的集合'',而不是指特定的数据结构。 (在某些情况下索引看起来并不像一棵树,不过我们现在的目的是用简单的方式思考它。)
Git 作为一个系统,是以它的一般操作来管理并操纵这三棵树的:
树 | 用途 |
---|---|
HEAD | 上一次提交的快照,下一次提交的父结点 |
Index | 预期的下一次提交的快照 |
Working Directory | 沙盒 |
HEAD
HEAD 是当前分支引用的指针,它总是指向该分支上的最后一次提交。 这表示 HEAD 将是下 一次提交的父结点。 通常,理解 HEAD 的最简方式,就是将它看做 你的上一次提交 的快 照。
索引
索引是你的 预期的下一次提交。 我们也会将这个概念引用为 Git 的 暂存区域'',这就是当你运行
git commit 时 Git 看起来的样子。
Git 将上一次检出到工作目录中的所有文件填充到索引区,它们看起来就像最初被检出时的样 子。 之后你会将其中一些文件替换为新版本,接着通过 git commit 将它们转换为树来用作 新的提交。
工作目录
最后,你就有了自己的工作目录。 另外两棵树以一种高效但并不直观的方式,将它们的内容 存储在 .git 文件夹中。 工作目录会将它们解包为实际的文件以便编辑。 你可以把工作目录 当做 沙盒。在你将修改提交到暂存区并记录到历史之前,可以随意更改。
工作流程
Git 主要的目的是通过操纵这三棵树来以更加连续的状态记录项目的快照。
让我们来可视化这个过程:假设我们进入到一个新目录,其中有一个文件。 我们称其为该文 件的 v1 版本,将它标记为蓝色。 现在运行 git init ,这会创建一个 Git 仓库,其中的 HEAD 引用指向未创建的分支( master 还不存在)。
现在我们想要提交这个文件,所以用 git add 来获取工作目录中的内容,并将其复制到索引 中。
接着运行 git commit ,它首先会移除索引中的内容并将它保存为一个永久的快照,然后创建 一个指向该快照的提交对象,最后更新 master 来指向本次提交。
此时如果我们运行 git status ,会发现没有任何改动,因为现在三棵树完全相同。 现在我们想要对文件进行修改然后提交它。 我们将会经历同样的过程;首先在工作目录中修 改文件。 我们称其为该文件的 v2 版本,并将它标记为红色。
如果现在运行 git status ,我们会看到文件显示在 Changes not staged for commit,'' 下面并 被标记为红色,因为该条目在索引与工作目录之间存在不同。 接着我们运行
git add 来将它暂存到索引中。
此时,由于索引和 HEAD 不同,若运行 git status 的话就会看到 Changes to be committed'' 下的该文件变为绿色 ——也就是说,现在预期的下一次提交与上一次提交不同。 最后,我们运行
git commit 来完成提交。
现在运行 git status 会没有输出,因为三棵树又变得相同了。 切换分支或克隆的过程也类似。 当检出一个分支时,它会修改 HEAD 指向新的分支引用,将 索引 填充为该次提交的快照,然后将 索引 的内容复制到 工作目录 中。
重置的作用
在以下情景中观察 reset 命令会更有意义。 为了演示这些例子,假设我们再次修改了 file.txt 文件并第三次提交它。 现在的历史看起 来是这样的:
让我们跟着 reset 看看它都做了什么。 它以一种简单可预见的方式直接操纵这三棵树。 它 做了三个基本操作。
第 1 步:移动 HEAD
reset 做的第一件事是移动 HEAD 的指向。 这与改变 HEAD 自身不同( checkout 所做 的); reset 移动 HEAD 指向的分支。 这意味着如果 HEAD 设置为 master 分支(例如, 你正在 master 分支上),运行 git reset 9e5e64a 将会使 master 指向 9e5e64a 。
无论你调用了何种形式的带有一个提交的 reset ,它首先都会尝试这样做。 使用 reset -- soft ,它将仅仅停在那儿。 现在看一眼上图,理解一下发生的事情:它本质上是撤销了上一次 git commit 命令。 当你 在运行 git commit 时,Git 会创建一个新的提交,并移动 HEAD 所指向的分支来使其指向该 提交。 当你将它 reset 回 HEAD~ (HEAD 的父结点)时,其实就是把该分支移动回原来的 位置,而不会改变索引和工作目录。 现在你可以更新索引并再次运行 git commit 来完成 git commit --amend 所要做的事情了(见 [_git_amend])。
第 2 步:更新索引(--mixed)
注意,如果你现在运行 git status 的话,就会看到新的 HEAD 和以绿色标出的它和索引之 间的区别。 接下来, reset 会用 HEAD 指向的当前快照的内容来更新索引
如果指定 --mixed 选项, reset 将会在这时停止。 这也是默认行为,所以如果没有指定任 何选项(在本例中只是 git reset HEAD~ ),这就是命令将会停止的地方。 现在再看一眼上图,理解一下发生的事情:它依然会撤销一上次 提交 ,但还会 取消暂存 所 有的东西。 于是,我们回滚到了所有 git add 和 git commit 的命令执行之前。
第 3 步:更新工作目录(--hard)
reset 要做的的第三件事情就是让工作目录看起来像索引。 如果使用 --hard 选项,它将会 继续这一步。
现在让我们回想一下刚才发生的事情。 你撤销了最后的提交、 git add 和 git commit 命令 以及工作目录中的所有工作。
必须注意, --hard 标记是 reset 命令唯一的危险用法,它也是 Git 会真正地销毁数据的仅 有的几个操作之一。 其他任何形式的 reset 调用都可以轻松撤消,但是 --hard 选项不 能,因为它强制覆盖了工作目录中的文件。 在这种特殊情况下,我们的 Git 数据库中的一个 提交内还留有该文件的 v3 版本,我们可以通过 reflog 来找回它。但是若该文件还未提交, Git 仍会覆盖它从而导致无法恢复。
回顾
reset 命令会以特定的顺序重写这三棵树,在你指定以下选项时停止:
- 移动 HEAD 分支的指向 (若指定了 --soft ,则到此停止)
- 使索引看起来像 HEAD (若未指定 --hard ,则到此停止)
- 使工作目录看起来像索引
通过路径来重置
前面讲述了 reset 基本形式的行为,不过你还可以给它提供一个作用路径。 若指定了一个 路径, reset 将会跳过第 1 步,并且将它的作用范围限定为指定的文件或文件集合。 这样做 自然有它的道理,因为 HEAD 只是一个指针,你无法让它同时指向两个提交中各自的一部 分。 不过索引和工作目录 可以部分更新,所以重置会继续进行第 2、3 步。 现在,假如我们运行 git reset file.txt (这其实是 git reset --mixed HEAD file.txt 的简 写形式,因为你既没有指定一个提交的 SHA-1 或分支,也没有指定 --soft 或 --hard ), 它会:
- 移动 HEAD 分支的指向 (已跳过)
- 让索引看起来像 HEAD (到此处停止) 所以它本质上只是将 file.txt 从 HEAD 复制到索引中。
它还有 取消暂存文件 的实际效果。 如果我们查看该命令的示意图,然后再想想 git add 所 做的事,就会发现它们正好相反。
这就是为什么 git status 命令的输出会建议运行此命令来取消暂存一个文件。 (查看 [_unstaging] 来了解更多。)
我们可以不让 Git 从 HEAD 拉取数据,而是通过具体指定一个提交来拉取该文件的对应版 本。 我们只需运行类似于 git reset eb43bf file.txt 的命令即可。
它其实做了同样的事情,也就是把工作目录中的文件恢复到 v1 版本,运行 git add 添加 它,然后再将它恢复到 v3 版本(只是不用真的过一遍这些步骤)。 如果我们现在运行 git commit ,它就会记录一条“将该文件恢复到 v1 版本”的更改,尽管我们并未在工作目录中真正 地再次拥有它。
压缩
我们来看看如何利用这种新的功能来做一些有趣的事情 - 压缩提交。 假设你的一系列提交信息中有 oops.''、 WIP'' 和 forgot this file'', 聪明的你就能使用
reset 来轻松快速地将它们压缩成单个提交,也显出你的聪明。 ([_squashing] 展示了另一 种方式,不过在本例中用 reset 更简单。) 假设你有一个项目,第一次提交中有一个文件,第二次提交增加了一个新的文件并修改了第 一个文件,第三次提交再次修改了第一个文件。 由于第二次提交是一个未完成的工作,因此 你想要压缩它。
那么可以运行 git reset --soft HEAD~2 来将 HEAD 分支移动到一个旧一点的提交上(即你 想要保留的第一个提交):
然后只需再次运行 git commit :
现在你可以查看可到达的历史,即将会推送的历史,现在看起来有个 v1 版 file-a.txt 的提 交,接着第二个提交将 file-a.txt 修改成了 v3 版并增加了 file-b.txt 。 包含 v2 版本的 文件已经不在历史中了。
检出
最后,你大概还想知道 checkout 和 reset 之间的区别。 和 reset 一样, checkout 也操 纵三棵树,不过它有一点不同,这取决于你是否传给该命令一个文件路径。
不带路径
运行 git checkout [branch] 与运行 git reset --hard [branch] 非常相似,它会更新所有三 棵树使其看起来像 [branch] ,不过有两点重要的区别。
首先不同于 reset --hard , checkout 对工作目录是安全的,它会通过检查来确保不会将已 更改的文件吹走。 其实它还更聪明一些。它会在工作目录中先试着简单合并一下,这样所有 还未修改过的文件都会被更新。 而 reset --hard 则会不做检查就全面地替换所有东西。
第二个重要的区别是如何更新 HEAD。 reset 会移动 HEAD 分支的指向,而 checkout 只 会移动 HEAD 自身来指向另一个分支。
例如,假设我们有 master 和 develop 分支,它们分别指向不同的提交;我们现在在 develop 上(所以 HEAD 指向它)。 如果我们运行 git reset master ,那么 develop 自身 现在会和 master 指向同一个提交。 而如果我们运行 git checkout master 的话, develop 不会移动,HEAD 自身会移动。 现在 HEAD 将会指向 master 。 所以,虽然在这两种情况下我们都移动 HEAD 使其指向了提交 A,但做法是非常不同的。 reset 会移动 HEAD 分支的指向,而 checkout 则移动 HEAD 自身。
带路径
运行 checkout 的另一种方式就是指定一个文件路径,这会像 reset 一样不会移动 HEAD。 它就像 git reset [branch] file 那样用该次提交中的那个文件来更新索引,但是它也会覆盖 工作目录中对应的文件。 它就像是 git reset --hard [branch] file (如果 reset 允许你这 样运行的话)- 这样对工作目录并不安全,它也不会移动 HEAD。
Git 内部原理
从根本上来讲 Git是一个内容寻址(content-addressable)文件系统,并在此之上提供了一个版本控制系统的用户界面。
底层命令和高层命令
本书旨在讨论如何通过 checkout 、 branch 、 remote 等大约 30 个诸如此类动词形式的命令 来玩转 Git。 然而,由于 Git 最初是一套面向版本控制系统的工具集,而不是一个完整的、用 户友好的版本控制系统,所以它还包含了一部分用于完成底层工作的命令。 这些命令被设计 成能以 UNIX 命令行的风格连接在一起,抑或藉由脚本调用,来完成工作。 这部分命令一般 被称作“底层(plumbing)”命令,而那些更友好的命令则被称作“高层(porcelain)”命令。 本书前九章专注于探讨高层命令。 然而在本章,我们将主要面对底层命令。 因为,底层命令 得以让你窥探 Git 内部的工作机制,也有助于说明 Git 是如何完成工作的,以及它为何如此运 作。 多数底层命令并不面向最终用户:它们更适合作为新命令和自定义脚本的组成部分。
当在一个新目录或已有目录执行 git init 时,Git 会创建一个 .git 目录。 这个目录包含 了几乎所有 Git 存储和操作的对象。 如若想备份或复制一个版本库,只需把这个目录拷贝至 另一处即可。 本章探讨的所有内容,均位于这个目录内。 该目录的结构如下所示:
$ ls -F1
HEAD
config*
description
hooks/
info/
objects/
refs/
复制代码
description 文件仅供 GitWeb 程序使用,我们无需关心。 config 文件包含项目特有的配置选项。 info 目录包含一个全局性排除(global exclude)文件,用以放置那些不希望被记录在 .gitignore 文件中的忽略模式(ignored patterns)。 hooks 目录包含客户端或服务端的钩子脚本(hook scripts),在 [_git_hooks] 中这部分话题已被详细探讨过。
剩下的四个条目很重要: HEAD 文件、(尚待创建的) index 文件,和 objects 目 录、 refs 目录。 这些条目是 Git 的核心组成部分。 objects 目录存储所有数据内 容; refs 目录存储指向数据(分支)的提交对象的指针; HEAD 文件指示目前被检出的分 支; index 文件保存暂存区信息。 我们将详细地逐一检视这四部分,以期理解 Git 是如何运 转的。
Git 对象
Git 是一个内容寻址文件系统。 看起来很酷, 但这是什么意思呢? 这意味着,Git 的核心部 分是一个简单的键值对数据库(key-value data store)。 你可以向该数据库插入任意类型的 内容,它会返回一个键值,通过该键值可以在任意时刻再次检索(retrieve)该内容。 可以通 过底层命令 hash-object 来演示上述效果——该命令可将任意数据保存于 .git 目录,并返 回相应的键值。 首先,我们需要初始化一个新的 Git 版本库,并确认 objects 目录为空:
$ git init test
Initialized empty Git repository in /tmp/test/.git/
$ cd test
$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack
$ find .git/objects -type f
复制代码
可以看到 Git 对 objects 目录进行了初始化,并创建了 pack 和 info 子目录,但均为空。 接着,往 Git 数据库存入一些文本:
$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4
复制代码
-w 选项指示 hash-object 命令存储数据对象;若不指定此选项,则该命令仅返回对应的键 值。 --stdin 选项则指示该命令从标准输入读取内容;若不指定此选项,则须在命令尾部给 出待存储文件的路径。 该命令输出一个长度为 40 个字符的校验和。 这是一个 SHA-1 哈希值 ——一个将待存储的数据外加一个头部信息(header)一起做 SHA-1 校验运算而得的校验 和。后文会简要讨论该头部信息。 现在我们可以查看 Git 是如何存储数据的:
$ find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
复制代码
可以在 objects 目录下看到一个文件。 这就是开始时 Git 存储内容的方式——一个文件对应 一条内容,以该内容加上特定头部信息一起的 SHA-1 校验和为文件命名。 校验和的前两个字 符用于命名子目录,余下的 38 个字符则用作文件名。 可以通过 cat-file 命令从 Git 那里取回数据。 这个命令简直就是一把剖析 Git 对象的瑞士 军刀。 为 cat-file 指定 -p 选项可指示该命令自动判断内容的类型,并为我们显示格式友 好的内容:
$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content
复制代码
树对象
接下来要探讨的对象类型是树对象(tree object),它能解决文件名保存的问题,也允许我们 将多个文件组织到一起。 Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简 化。 所有内容均以树对象和数据对象的形式存储,其中树对象对应了 UNIX 中的目录项,数 据对象则大致上对应了 inodes 或文件内容。 一个树对象包含了一条或多条树对象记录(tree entry),每条记录含有一个指向数据对象或者子树对象的 SHA-1 指针,以及相应的模式、类 型、文件名信息。 例如,某项目当前对应的最新树对象可能是这样的:
$ git cat-file -p master^{tree}
100644 blob a906cb2a4a904a152e80877d4088654daad0c859 README
100644 blob 8f94139338f9404f26296befa88755fc2598c289 Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0 lib
复制代码
master^{tree} 语法表示 master 分支上最新的提交所指向的树对象。 请注意, lib 子目 录(所对应的那条树对象记录)并不是一个数据对象,而是一个指针,其指向的是另一个树 对象:
$ git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b simplegit.rb
复制代码
从概念上讲,Git 内部存储的数据有点像这样:
提交对象
现在有三个树对象,分别代表了我们想要跟踪的不同项目快照。然而问题依旧:若想重用这 些快照,你必须记住所有三个 SHA-1 哈希值。 并且,你也完全不知道是谁保存了这些快照, 在什么时刻保存的,以及为什么保存这些快照。 而以上这些,正是提交对象(commit object)能为你保存的基本信息。
可以通过调用 commit-tree 命令创建一个提交对象,为此需要指定一个树对象的 SHA-1 值, 以及该提交的父提交对象(如果有的话)。 我们从之前创建的第一个树对象开始
$ echo 'first commit' | git commit-tree d8329f
fdf4fc3344e67ab068f836878b6c4951e3b15f3d
复制代码
现在可以通过 cat-file 命令查看这个新提交对象:
$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon 1243040974 -0700
committer Scott Chacon 1243040974 -0700
first commit
复制代码
接着,我们将创建另两个提交对象,它们分别引用各自的上一个提交(作为其父提交对 象):
$ echo 'second commit' | git commit-tree 0155eb -p fdf4fc3
cac0cab538b970a37ea1e769cbbde608743bc96d
$ echo 'third commit' | git commit-tree 3c4e9c -p cac0cab
1a410efbd13591db07496601ebc7a059dd55cfe9
复制代码
这三个提交对象分别指向之前创建的三个树对象快照中的一个。 现在,如果对最后一个提交 的 SHA-1 值运行 git log 命令,会出乎意料的发现,你已有一个货真价实的、可由 git log 查看的 Git 提交历史了:
$ git log --stat 1a410e
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Author: Scott Chacon
Date: Fri May 22 18:15:24 2009 -0700
third commit
bak/test.txt | 1 +
1 file changed, 1 insertion(+)
commit cac0cab538b970a37ea1e769cbbde608743bc96d
Author: Scott Chacon
Date: Fri May 22 18:14:29 2009 -0700
second commit
new.txt | 1 +
test.txt | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d
Author: Scott Chacon
Date: Fri May 22 18:09:34 2009 -0700
first commit
test.txt | 1 +
1 file changed, 1 insertion(+)
复制代码
太神奇了: 就在刚才,你没有借助任何上层命令,仅凭几个底层操作便完成了一个 Git 提交 历史的创建。 这就是每次我们运行 git add 和 git commit 命令时, Git 所做的实质工作
——将被改写的文件保存为数据对象,更新暂存区,记录树对象,最后创建一个指明了顶层
树对象和父提交的提交对象。
这三种主要的 Git 对象——数据对象、树对象、提交对象—— 最初均以单独文件的形式保存在 .git/objects 目录下。 下面列出了目前示例目录内的所有 对象,辅以各自所保存内容的注释:
$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1
复制代码
如果跟踪所有的内部指针,将得到一个类似下面的对象关系图:
对象存储
前文曾提及,在存储内容时,会有个头部信息一并被保存。 让我们略花些时间来看看 Git 是 如何存储其对象的。 通过在 Ruby 脚本语言中交互式地演示,你将看到一个数据对象——本 例中是字符串“what is up, doc?”——是如何被存储的。 可以通过 irb 命令启动 Ruby 的交互模式:
$ irb
>> content = "what is up, doc?"
=> "what is up, doc?"
复制代码
Git 以对象类型作为开头来构造一个头部信息,本例中是一个“blob”字符串。 接着 Git 会添加 一个空格,随后是数据内容的长度,最后是一个空字节(null byte):
>> header = "blob #{content.length}\0"
=> "blob 16\u0000"
复制代码
Git 会将上述头部信息和原始数据拼接起来,并计算出这条新内容的 SHA-1 校验和。 在 Ruby 中可以这样计算 SHA-1 值——先通过require 命令导入 SHA-1 digest 库,然后对目 标字符串调用 Digest::SHA1.hexdigest() :
>> store = header + content
=> "blob 16\u0000what is up, doc?"
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"
复制代码
Git 会通过 zlib 压缩这条新内容。在 Ruby 中可以借助 zlib 库做到这一点。 先导入相应的库, 然后对目标内容调用 Zlib::Deflate.deflate() :
>> require 'zlib'
=> true
>> zlib_content = Zlib::Deflate.deflate(store)
=> "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D"
复制代码
最后,需要将这条经由 zlib 压缩的内容写入磁盘上的某个对象。 要先确定待写入对象的路径 (SHA-1 值的前两个字符作为子目录名称,后 38 个字符则作为子目录内文件的名称)。 如 果该子目录不存在,可以通过 Ruby 中的 FileUtils.mkdir_p() 函数来创建它。 接着,通过 File.open() 打开这个文件。最后,对上一步中得到的文件句柄调用 write() 函数,以向目 标文件写入之前那条 zlib 压缩过的内容:
>> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"
>> require 'fileutils'
=> true
>> FileUtils.mkdir_p(File.dirname(path))
=> ".git/objects/bd"
>> File.open(path, 'w') { |f| f.write zlib_content }
=> 32
复制代码
就是这样——你已创建了一个有效的 Git 数据对象。 所有的 Git 对象均以这种方式存储,区 别仅在于类型标识——另两种对象类型的头部信息以字符串“commit”或“tree”开头,而不 是“blob”。 另外,虽然数据对象的内容几乎可以是任何东西,但提交对象和树对象的内容却有 各自固定的格式。
Git 引用
我们可以借助类似于 git log 1a410e 这样的命令来浏览完整的提交历史,但为了能遍历那段 历史从而找到所有相关对象,你仍须记住 1a410e 是最后一个提交。 我们需要一个文件来保 存 SHA-1 值,并给文件起一个简单的名字,然后用这个名字指针来替代原始的 SHA-1 值。
在 Git 里,这样的文件被称为“引用(references,或缩写为 refs)”;你可以在 .git/refs 目 录下找到这类含有 SHA-1 值的文件。 在目前的项目中,这个目录没有包含任何文件,但它包 含了一个简单的目录结构:
$ find .git/refs
.git/refs
.git/refs/heads
.git/refs/tags
$ find .git/refs -type f
复制代码
若要创建一个新引用来帮助记忆最新提交所在的位置,从技术上讲我们只需简单地做如下操 作:
$ echo "1a410efbd13591db07496601ebc7a059dd55cfe9" > .git/refs/heads/master
复制代码
现在,你就可以在 Git 命令中使用这个刚创建的新引用来代替 SHA-1 值了:
$ git log --pretty=oneline master
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit
复制代码
我们不提倡直接编辑引用文件。 如果想更新某个引用,Git 提供了一个更加安全的命令 update-ref 来完成此事:
$ git update-ref refs/heads/master 1a410efbd13591db07496601ebc7a059dd55cfe9
复制代码
这基本就是 Git 分支的本质:一个指向某一系列提交之首的指针或引用。 若想在第二个提交 上创建一个分支,可以这么做:
$ git update-ref refs/heads/test cac0ca
复制代码
这个分支将只包含从第二个提交开始往前追溯的记录:
$ git log --pretty=oneline test
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit
复制代码
至此,我们的 Git 数据库从概念上看起来像这样:
当运行类似于 git branch (branchname) 这样的命令时,Git 实际上会运行 update-ref 命 令,取得当前所在分支最新提交对应的 SHA-1 值,并将其加入你想要创建的任何新引用中。
HEAD 引用
现在的问题是,当你执行 git branch (branchname) 时,Git 如何知道最新提交的 SHA-1 值 呢? 答案是 HEAD 文件。
HEAD 文件是一个符号引用(symbolic reference),指向目前所在的分支。 所谓符号引用, 意味着它并不像普通引用那样包含一个 SHA-1 值——它是一个指向其他引用的指针。 如果查 看 HEAD 文件的内容,一般而言我们看到的类似这样:
$ cat .git/HEAD
ref: refs/heads/master
复制代码
如果执行 git checkout test ,Git 会像这样更新 HEAD 文件:
$ cat .git/HEAD
ref: refs/heads/test
复制代码
当我们执行 git commit 时,该命令会创建一个提交对象,并用 HEAD 文件中那个引用所指 向的 SHA-1 值设置其父提交字段。
标签引用
前文我们刚讨论过 Git 的三种主要对象类型,事实上还有第四种。 标签对象(tag object)非 常类似于一个提交对象——它包含一个标签创建者信息、一个日期、一段注释信息,以及一 个指针。 主要的区别在于,标签对象通常指向一个提交对象,而不是一个树对象。 它像是一 个永不移动的分支引用——永远指向同一个提交对象,只不过给这个提交对象加上一个更友 好的名字罢了。 正如 [_git_basics_chapter] 中所讨论的那样,存在两种类型的标签:附注标签和轻量标签。 可以像这样创建一个轻量标签:
$ git update-ref refs/tags/v1.0 cac0cab538b970a37ea1e769cbbde608743bc96d
复制代码
这就是轻量标签的全部内容——一个固定的引用。 然而,一个附注标签则更复杂一些。 若要 创建一个附注标签,Git 会创建一个标签对象,并记录一个引用来指向该标签对象,而不是直 接指向提交对象。 可以通过创建一个附注标签来验证这个过程( -a 选项指定了要创建的是 一个附注标签):
$ git tag -a v1.1 1a410efbd13591db07496601ebc7a059dd55cfe9 -m 'test tag'
复制代码
下面是上述过程所建标签对象的 SHA-1 值:
$ cat .git/refs/tags/v1.1
9585191f37f7b0fb9444f35a9bf50de191beadc2
复制代码
现在对该 SHA-1 值运行 cat-file 命令:
$ git cat-file -p 9585191f37f7b0fb9444f35a9bf50de191beadc2
object 1a410efbd13591db07496601ebc7a059dd55cfe9
type commit
tag v1.1
tagger Scott Chacon Sat May 23 16:48:58 2009 -0700
test tag
复制代码
我们注意到,object 条目指向我们打了标签的那个提交对象的 SHA-1 值。 另外要注意的是, 标签对象并非必须指向某个提交对象;你可以对任意类型的 Git 对象打标签。
远程引用
我们将看到的第三种引用类型是远程引用(remote reference)。 如果你添加了一个远程版本 库并对其执行过推送操作,Git 会记录下最近一次推送操作时每一个分支所对应的值,并保存 在 refs/remotes 目录下。 例如,你可以添加一个叫做 origin 的远程版本库,然后把 master 分支推送上去:
$ git remote add origin [email protected]:schacon/simplegit-progit.git
$ git push origin master
Counting objects: 11, done.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (7/7), 716 bytes, done.
Total 7 (delta 2), reused 4 (delta 1)
To [email protected]:schacon/simplegit-progit.git
a11bef0..ca82a6d master -> master
复制代码
此时,如果查看 refs/remotes/origin/master 文件,可以发现 origin 远程版本库的 master 分支所对应的 SHA-1 值,就是最近一次与服务器通信时本地 master 分支所对应的 SHA-1 值:
$ cat .git/refs/remotes/origin/master
ca82a6dff817ec66f44342007202690a93763949
复制代码
远程引用和分支(位于 refs/heads 目录下的引用)之间最主要的区别在于,远程引用是只 读的。 虽然可以 git checkout 到某个远程引用,但是 Git 并不会将 HEAD 引用指向该远程 引用。因此,你永远不能通过 commit 命令来更新远程引用。 Git 将这些远程引用作为记录 远程服务器上各分支最后已知位置状态的书签来管理。
包文件
让我们重新回到示例 Git 版本库的对象数据库。 目前为止,可以看到有 11 个对象——4 个数 据对象、3 个树对象、3 个提交对象和 1 个标签对象:
$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/95/85191f37f7b0fb9444f35a9bf50de191beadc2 # tag
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1
复制代码
Git 使用 zlib 压缩这些文件的内容,而且我们并没有存储太多东西,所以上文中的文件一共只 占用了 925 字节。 接下来,我们会指引你添加一些大文件到版本库中,以此展示 Git 的一个 很有趣的功能。 为了便于展示,我们要把之前在 Grit 库中用到过的 repo.rb 文件添加进来 ——这是一个大小约为 22K 的源代码文件:
$ curl https://raw.githubusercontent.com/mojombo/grit/master/lib/grit/repo.rb > repo.r
b
$ git add repo.rb
$ git commit -m 'added repo.rb'
[master 484a592] added repo.rb
3 files changed, 709 insertions(+), 2 deletions(-)
delete mode 100644 bak/test.txt
create mode 100644 repo.rb
rewrite test.txt (100%)
复制代码
如果你查看生成的树对象,可以看到 repo.rb 文件对应的数据对象的 SHA-1 值:
$ git cat-file -p master^{tree}
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob 033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 repo.rb
100644 blob e3f094f522629ae358806b17daf78246c27c007b test.txt
复制代码
接下来你可以使用 git cat-file 命令查看这个对象有多大:
$ git cat-file -s 033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5
22044
复制代码
现在,稍微修改这个文件,然后看看会发生什么:
$ echo '# testing' >> repo.rb
$ git commit -am 'modified repo a bit'
[master 2431da6] modified repo.rb a bit
1 file changed, 1 insertion(+)
复制代码
查看这个提交生成的树对象,你会看到一些有趣的东西:
$ git cat-file -p master^{tree}
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
100644 blob b042a60ef7dff760008df33cee372b945b6e884e repo.rb
100644 blob e3f094f522629ae358806b17daf78246c27c007b test.txt
复制代码
repo.rb 对应一个与之前完全不同的数据对象,这意味着,虽然你只是在一个 400 行的文件后 面加入一行新内容,Git 也会用一个全新的对象来存储新的文件内容:
$ git cat-file -s b042a60ef7dff760008df33cee372b945b6e884e
22054
复制代码
你的磁盘上现在有两个几乎完全相同、大小均为 22K 的对象。 如果 Git 只完整保存其中一 个,再保存另一个对象与之前版本的差异内容,岂不更好? 事实上 Git 可以那样做。 Git 最初向磁盘中存储对象时所使用的格式被称为“松散(loose)”对 象格式。 但是,Git 会时不时地将多个这些对象打包成一个称为“包文件(packfile)”的二进制 文件,以节省空间和提高效率。 当版本库中有太多的松散对象,或者你手动执行 git gc 命 令,或者你向远程服务器执行推送时,Git 都会这样做。 要看到打包过程,你可以手动执行 git gc 命令让 Git 对对象进行打包:
$ git gc
Counting objects: 18, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (14/14), done.
Writing objects: 100% (18/18), done.
Total 18 (delta 3), reused 0 (delta 0)
复制代码
这个时候再查看 objects 目录,你会发现大部分的对象都不见了,与此同时出现了一对新文 件:
$ find .git/objects -type f
.git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
.git/objects/info/packs
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.idx
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.pack
复制代码
仍保留着的几个对象是未被任何提交记录引用的数据对象——在此例中是你之前创建的“what is up, doc?”和“test content”这两个示例数据对象。 因为你从没将它们添加至任何提交记录 中,所以 Git 认为它们是摇摆(dangling)的,不会将它们打包进新生成的包文件中。 剩下的文件是新创建的包文件和一个索引。 包文件包含了刚才从文件系统中移除的所有对象 的内容。 索引文件包含了包文件的偏移信息,我们通过索引文件就可以快速定位任意一个指 定对象。 有意思的是运行 gc 命令前磁盘上的对象大小约为 22K,而这个新生成的包文件大 小仅有 7K。 通过打包对象减少了 ⅔ 的磁盘占用空间。 Git 是如何做到这点的? Git 打包对象时,会查找命名及大小相近的文件,并只保存文件不同 版本之间的差异内容。 你可以查看包文件,观察它是如何节省空间的。 git verify-pack 这 个底层命令可以让你查看已打包的内容:
$ git verify-pack -v .git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.i
dx
2431da676938450a4d72e260db3bf7b0f587bbc1 commit 223 155 12
69bcdaff5328278ab1c0812ce0e07fa7d26a96d7 commit 214 152 167
80d02664cb23ed55b226516648c7ad5d0a3deb90 commit 214 145 319
43168a18b7613d1281e5560855a83eb8fde3d687 commit 213 146 464
092917823486a802e94d727c820a9024e14a1fc2 commit 214 146 610
702470739ce72005e2edff522fde85d52a65df9b commit 165 118 756
d368d0ac0678cbe6cce505be58126d3526706e54 tag 130 122 874
fe879577cb8cffcdf25441725141e310dd7d239b tree 136 136 996
d8329fc1cc938780ffdd9f94e0d364e0ea74f579 tree 36 46 1132
deef2e1b793907545e50a2ea2ddb5ba6c58c4506 tree 136 136 1178
d982c7cb2c2a972ee391a85da481fc1f9127a01d tree 6 17 1314 1 \
deef2e1b793907545e50a2ea2ddb5ba6c58c4506
3c4e9cd789d88d8d89c1073707c3585e41b0e614 tree 8 19 1331 1 \
deef2e1b793907545e50a2ea2ddb5ba6c58c4506
0155eb4229851634a0f03eb265b69f5a2d56f341 tree 71 76 1350
83baae61804e65cc73a7201a7252750c76066a30 blob 10 19 1426
fa49b077972391ad58037050f2a75f74e3671e92 blob 9 18 1445
b042a60ef7dff760008df33cee372b945b6e884e blob 22054 5799 1463
033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 blob 9 20 7262 1 \
b042a60ef7dff760008df33cee372b945b6e884e
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a blob 10 19 7282
non delta: 15 objects
chain length = 1: 3 objects
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.pack: ok
复制代码
此处, 033b4 这个数据对象(即 repo.rb 文件的第一个版本,如果你还记得的话)引用了数 据对象 b042a ,即该文件的第二个版本。 命令输出内容的第三列显示的是各个对象在包文件 中的大小,可以看到 b042a 占用了 22K 空间,而 033b4 仅占用 9 字节。 同样有趣的地方 在于,第二个版本完整保存了文件内容,而原始的版本反而是以差异方式保存的——这是因 为大部分情况下需要快速访问文件的最新版本。 最妙之处是你可以随时重新打包。 Git 时常会自动对仓库进行重新打包以节省空间。当然你也 可以随时手动执行 git gc 命令来这么做。
引用规格
纵观全书,我们已经使用过一些诸如远程分支到本地引用的简单映射方式,但这种映射可以更复杂。 假设你添加了这样一个远程版本库:
$ git remote add origin https://github.com/schacon/simplegit-progit
复制代码
上述命令会在你的 .git/config 文件中添加一个小节,并在其中指定远程版本库的名称 ( origin )、URL 和一个用于获取操作的引用规格(refspec):
[remote "origin"]
url = https://github.com/schacon/simplegit-progit
fetch = +refs/heads/*:refs/remotes/origin/*
复制代码
引用规格的格式由一个可选的 + 号和紧随其后的 : 组成,其中 是一个 模式(pattern),代表远程版本库中的引用; 是那些远程引用在本地所对应的位置。
、+号告诉 Git 即使在不能快进的情况下也要(强制)更新引用。
默认情况下,引用规格由 git remote add 命令自动生成, Git 获取服务器中 refs/heads/ 下面的所有引用,并将它写入到本地的 refs/remotes/origin/ 中。 所以,如果服务器上有一 个 master 分支,我们可以在本地通过下面这种方式来访问该分支上的提交记录:
$ git log origin/master
$ git log remotes/origin/master
$ git log refs/remotes/origin/master
复制代码
上面的三个命令作用相同,因为 Git 会把它们都扩展成 refs/remotes/origin/master 。 如果想让 Git 每次只拉取远程的 master 分支,而不是所有分支,可以把(引用规格的)获 取那一行修改为:
fetch = +refs/heads/master:refs/remotes/origin/master
复制代码
你也可以指定多个引用规格。 在命令行中,你可以按照如下的方式拉取多个分支:
引用规格推送
像上面这样从远程版本库获取已在命名空间中的引用当然很棒,但 QA 团队最初应该如何将 他们的分支放入远程的 qa/ 命名空间呢? 我们可以通过引用规格推送来完成这个任务。 如果 QA 团队想把他们的 master 分支推送到远程服务器的 qa/master 分支上,可以运行:
$ git push origin master:refs/heads/qa/master
复制代码
如果他们希望 Git 每次运行 git push origin 时都像上面这样推送,可以在他们的配置文件 中添加一条 push 值:
[remote "origin"]
url = https://github.com/schacon/simplegit-progit
fetch = +refs/heads/*:refs/remotes/origin/*
push = refs/heads/master:refs/heads/qa/master
复制代码
正如刚才所指出的,这会让 git push origin 默认把本地 master 分支推送到远程 qa/master 分支。
删除引用
你还可以借助类似下面的命令通过引用规格从远程服务器上删除引用:
$ git push origin :topic
复制代码
因为引用规格(的格式)是 : ,所以上述命令把 留空,意味着把远程版本 库的 topic 分支定义为空值,也就是删除它。
维护与数据恢复
有的时候,你需要对仓库进行清理 - 使它的结构变得更紧凑,或是对导入的仓库进行清理,或 是恢复丢失的内容。 这个小节将会介绍这些情况中的一部分。
维护
Git 会不定时地自动运行一个叫做 auto gc'' 的命令。 大多数时候,这个命令并不会产生效果。 然而,如果有太多松散对象(不在包文件中的对象)或者太多包文件,Git 会运行一个完整的 git gc 命令。 gc'' 代表垃圾回收,这个命令会做以下事情:收集所有松散对象并将它们放置到包文件中,将多个包文件合并为一个大的包文件,移除与任何提交都不相关的陈旧对象。 可以像下面一样手动执行自动垃圾回收:
$ git gc --auto
复制代码
就像上面提到的,这个命令通常并不会产生效果。 大约需要 7000 个以上的松散对象或超过 50 个的包文件才能让 Git 启动一次真正的 gc 命令。 你可以通过修改 gc.auto 与 gc.autopacklimit 的设置来改动这些数值。
数据恢复
在你使用 Git 的时候,你可能会意外丢失一次提交。 通常这是因为你强制删除了正在工作的 分支,但是最后却发现你还需要这个分支;亦或者硬重置了一个分支,放弃了你想要的提 交。 如果这些事情已经发生,该如何找回你的提交呢? 下面的例子将硬重置你的测试仓库中的 master 分支到一个旧的提交,以此来恢复丢失的提 交。 首先,让我们看看你的仓库现在在什么地方:
$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit
复制代码
现在,我们将 master 分支硬重置到第三次提交:
$ git reset --hard 1a410efbd13591db07496601ebc7a059dd55cfe9
HEAD is now at 1a410ef third commit
$ git log --pretty=oneline
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit
复制代码
现在顶部的两个提交已经丢失了 - 没有分支指向这些提交。 你需要找出最后一次提交的 SHA- 1 然后增加一个指向它的分支。 窍门就是找到最后一次的提交的 SHA-1 - 但是估计你记不起 来了,对吗? 最方便,也是最常用的方法,是使用一个名叫 git reflog 的工具。 当你正在工作时,Git 会 默默地记录每一次你改变 HEAD 时它的值。 每一次你提交或改变分支,引用日志都会被更 新。 引用日志(reflog)也可以通过 git update-ref 命令更新,我们在 [_git_refs] 有提到使 用这个命令而不是是直接将 SHA-1 的值写入引用文件中的原因。 你可以在任何时候通过执行 git reflog 命令来了解你曾经做过什么:
$ git reflog
1a410ef HEAD@{0}: reset: moving to 1a410ef
ab1afef HEAD@{1}: commit: modified repo.rb a bit
484a592 HEAD@{2}: commit: added repo.rb
复制代码
这里可以看到我们已经检出的两次提交,然而并没有足够多的信息。 为了使显示的信息更加 有用,我们可以执行 git log -g ,这个命令会以标准日志的格式输出引用日志。
$ git log -g
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Reflog: HEAD@{0} (Scott Chacon )
Reflog message: updating HEAD
Author: Scott Chacon
Date: Fri May 22 18:22:37 2009 -0700
third commit
commit ab1afef80fac8e34258ff41fc1b867c702daa24b
Reflog: HEAD@{1} (Scott Chacon )
Reflog message: updating HEAD
Author: Scott Chacon
Date: Fri May 22 18:15:24 2009 -0700
modified repo.rb a bit
复制代码
看起来下面的那个就是你丢失的提交,你可以通过创建一个新的分支指向这个提交来恢复 它。 例如,你可以创建一个名为 recover-branch 的分支指向这个提交(ab1afef):
$ git branch recover-branch ab1afef
$ git log --pretty=oneline recover-branch
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit
复制代码