主要介绍个人开发提交记录的主要流程,包括以下内容:
- 索引- 提交的暂存区。
- 查看工作的状态和内部变更。
- 如何读取用于描述变更的已扩展统一diff格式。
- 支持查询和交互的提交,修改提交。
- 创建、显示和选择(切换)分支。
- 切换分支失败的原因,以及应对策略。使用git reset对分支复位。
- 与HEAD脱离的匿名分支(例如签出的标签等)。
1、新建提交
在使用Git开始一个新项目之前,用户需要先添加自己的姓名和电子邮件信息。上述信息主要用于标记开发人员的工作记录,无论作者还是提交者都是如此。该设置可以是适用于所有版本库的全局配置(使用git config --global命令,或者直接编辑~/.gitconfig文件),也可以是仅局限于用户本地版本库(使用git cofig命令或者编辑.git/config文件)。每个版本库的配置将会覆盖单个开发人员的配置。某些用户也许会希望在公司的版本库下工作时使用工作电子邮件,在某些公共版本库中使用非工作电子邮件。
相应的配置文件信息与下列内容类似:
[user]
name = Joe R. Hacker
email = joe@company.com
1.1、新建提交的DAG视图
为一个项目做贡献通常意味着为上述项目添加新的修订,然后将它们作为提交节点添加到修订视图上。
接下来假定我们现在处于master分支,如下图所示,然后我们想为项目添加一个新的修订版本。git commit命令将会创建一个新的提交对象——一个新的修订节点。该提交会创建一个特定的签出修订版本(本示例是c7cd3)。该修订会被追踪引用的起点指针HEAD引用,即当前图表中HEAD指向的节点c7cd3。
当前的分支是master,HEAD指针指向的修订版本是c7cd3,该修订也是目前签出的修订。
Git系统会移动master指针到新的节点,创建节点之后的情形如下图所示。在图中我们可以看到,新的提交节点用加粗的红框标记了出来,master分支旧的节点用半透明的形式表示。需要注意的是,HEAD指针并没有改变,它一直是指向master分支的:新的提交a3b79是用加粗的红框标记的,master分支上指针由提交c7cd3指向了提交a3b79代表分支发生了变更,如下图中的虚线所示:
1.2、索引——提交的暂存区
Git版本库对应的工作区中的每个文件对于Git系统来说包括两种,即已知的(跟踪文件)和未知的。其中未知文件对于Git系统来说又分为未跟踪和已忽略两类。
Git系统跟踪的文件一般有两种状态:已提交(未变化)和已修改。已提交状态意味着工作目录下的文件内容和最近一次提交的修订内容一致,很安全地存放在版本库中。
如果文件和最新提交的修订版本存在差异,则被认为是已修改的文件。
不过在Git系统内部,还存在另外一种状态。接下来让我看看当使用git add命令添加一个文件后会发生什么。版本控制系统都需要在某处存放上述状态信息。Git系统采用被称为索引(index)的机制实现此功能,它是存储将要提交信息的暂存区。git add 命令会暂存文件(当前版本)的当前内容,然后将之添加到索引中。
如果用户只是喜欢将某个文件标记为已添加,那么可以使用git add -N <file>
命令,这样一来上述文件在暂存区的内容就是空白的。
索引是项目的第三个存储拷贝,之前的拷贝一个是包含用户自己项目文件拷贝的工作目录,另外一个是用户本地版本库(存放项目历史记录,专门为用户同步其他开发人员的变更)。
下图中的箭头表示了Git命令拷贝内容的主要步骤,例如git add
命令会将工作区的文件内容拷贝到暂存区。
创建一个新的提交需要执行如下步骤:
- (1)在工作目录下面使用编辑器对文件进行编辑。
- (2)使用git add命令对上述文件进行暂存,为它们添加快照(文件当前的状态)。
- (3)使用git commit命令创建一个新的修订,这会把存放在暂存区的文件信息作为修订版本永久地存放到本地版本库中。
在项目启动之初,工作区的被跟踪文件、暂存区的文件和最近一次提交的修订版本在内容上都是一致的。
不过用户通常会采用提交记录命令的快捷方式,即git commit –a(是git commit --all的快捷方式),该命令会把被跟踪并且发生变更的文件添加到暂存区中(在当前的Git系统中和git add -u命令的效果一样),然后创建一个新的提交(如上图所示)。需要注意的是,新增的文件仍然需要使用git add命令告知Git系统对其进行跟踪,然后才能将之添加到新的提交对象中。
1.3、查看已提交的变更
在提交变更和创建新的修订(新的提交)之前,用户也许希望看看自己的工作成果。
除非用户在命令行中声明了提交注释信息,例如git commit -m"简短的描述信息",否则Git会在提交信息模版中显示将要提交的记录,并且该模版可以根据用户需要进行编辑配置。
用户还可以通过不修改文件或者使用一个空的提交信息终止提交操作(注释信息中以#号开头的内容会被忽略)。
1、工作目录状态
用户使用工具检查文件状态的目的在于查看哪个文件发生了变更、哪些地方新增了文件等,这就是git status
命令的主要用途。
上述命令默认输出结果的信息非常详尽。如果项目没有发生变更,例如直接克隆版本库之后,用户会看到类似以下内容的信息:
$ git status
On branch master
nothing to commit, working directory clean
如果上述分支(本示例中当前用户处于master分支)是一个本地分支,打算添加一些变更之后发布到公共版本库中,并且事先配置好了对应的上游分支origin/master,那么用户还会看到对应的远程跟踪分支的相关信息:
Your branch is up-to-date with 'origin/master'.
接下来的示例中,我们将会忽略上述信息。
例如用户打算给项目新增两个文件,一个是包含版权信息的文件,名字叫COPYING;另外一个是空白文件,名字叫NEWS。为了跟踪刚才引入的COPYING文件,需要执行git add COPYING命令。用户一不留神,使用git rm README命令将README文件从工作区删除了;然后编辑了Makefile文件,用git mv命令把文件rand.c的名字改成random.c(并没有修改其中的内容)。
一般来说,完整信息输出格式的好处是易读并且描述信息详尽:
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
new file: COPYING
renamed: src/rand.c -> src/random.c
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working
directory)
modified: Makefile
deleted: README
Untracked files:
(use "git add <file>..." to include in what will be committed)
NEWS
如你所见,Git不仅记录和描述了哪些文件发生了变更,而且还在提交中解释了文件的变更状态,以及哪些将要提交的变更被删除了。上述结果大致可以分为3个部分:
- 拟提交的变更:这是已经放入暂存区,准备使用git commit命令提交的变更(没有-a选项)。它是暂存区的文件快照,和最近一次提交(HEAD)是有显著差异的。
- 未暂存的变更:这些列表是工作区中和暂存区的快照存在差异的文件列表。这些变更将不会使用git commit命令提交,不过可以通过git commit -a命令将上述内容作为被跟踪文件的变更提交。
- 未跟踪的文件:这类文件对于Git系统来说是未知的,而且也是可以被忽略的(这类文件可以使用添加操作命令git add.在顶层目录中进行批量添加。用户还可以使用–untracked-files=no(简写为-uno)选项忽略该操作。
如果用户不希望充分利用暂存区提供的灵活性,那么可以简单地使用git add命令,只添加新文件,然后使用git add -a命令获取所有被跟踪文件的变更,继而创建提交。这种情况下,用户创建的提交包含将要提交的变更以及不在暂存区的变更。
还有一种简洁的输出格式使用–short选项声明。–porcelain选项适合脚本执行,因为在保证一致稳定性的同时,使用–short选项还支持用户对输出结果的编辑。对于某些相同的变更,输出结果和下列内容类似:
$ git status –short
A COPYING
M Makefile
D README
R src/rand.c -> src/random.c
?? NEWS
采用格式之后,每个路径的状态会用双字母状态码表示。第一个字母表示索引的状态(暂存区和最近一次提交的差异),第二个字母表示工作区的状态(工作区和暂存区的差异)。
并不是所有的状态码组合都是存在的,状态码A、R和C只可能出现在第一个列,即表示索引的状态。一个特别的情况是??符号,它主要用来表示未知(未跟踪)文件的,!!符号表示已忽略文件(当使用git status --short --ignored命令时)。
2、最新修订的差异比较
如果用户不仅希望知道哪些文件发生了变更(使用git status命令),而且还想知道具体发生了哪些变更,那么可以使用git diff命令。
Git系统中包含3个区域:工作目录、暂存区和版本库(通常是最近一次修订版本)。因此,我们不止有一组差异,而是有3组,如下图所示。
用户可以要求Git回答如下问题:
用户编辑了哪些内容但是还未暂存的?换句话说就是,暂存区和工作区之间的差异是什么?哪些内容是已暂存准备提交的?也就是说,最近一次提交(HEAD)和暂存区的差异是什么?
用户希望了解自己编辑了哪些文件但是还未将其暂存,那么可以使用不带任何参数的git diff命令。该命令会比较用户的工作目录和暂存区直接的差异。上述变更是可以被添加的,但是如果使用git commit命令提交变更却不会被显示:这些变更并未放入暂存区,因此执行git status命令之后,查询结果不会包含上述变更。
用户如果希望查看已经暂存的变更并且打算提交这些变更,那么可以使用git diff --staged(或者git diff –cached)命令,该命令会比较最近的一次提交和暂存区之间的差异。上述变更可以使用git commit命令(不带-a选项)进行添加,同时也是执行git status命令后,输出结果中将要提交的变更记录。还可以使用git diff --staged<提交记录> 命令比较暂存区和任意提交历史记录之间的差异;HEAD(最近的一次提交)是默认的比较对象。
可以使用git diff HEAD命令比较用户当前的工作目录和最近一次提交的修订之间的差异(可以使用git diff<提交记录>与任意历史修订记录比较差异)。上述变更可以使用git commit -a命令快速地添加到版本库中。如果用户没有使用git commit –a命令,而且也不需要充分利用暂存区,那么通常使用git diff命令查看将要提交的变更记录就可以满足需要了。
如果只使用命令git add,唯一需要处理的问题是新增文件,除非用户使用git add --intent-to-add命令新增文件到版本库(也可以使用git add -N),否则使用git diff命令之后,新增的文件将不会显示在查询结果中。
4、Git的统一diff格式
一般来说,Git系统在大部分情况下都会采用统一的diff输出格式显示变更记录。
理解这种输出格式对于用户来说是非常重要的,不仅在查看将要提交的变更记录时会用到,而且在审核和检查变更记录时也会用到(例如在代码审核审查中,或者执行git bisect命令之后,查找可疑提交记录)。
用户可以使用–stat或者–dirstat选项只统计变更记录数量,或者使用–name-only查看名字发生变更的文件数目,或者使用–name-status选项查看文件名类型发生变更的记录,或者使用–raw选项查看项目的目录结构发生的变化,或者使用–summary选项查看扩展首部信息的摘要。用户还可以使用–word-diff选项进行字符之间的差异比较,这比行间差异比较精确度更高,这样一来,即使文件内部的标题和段落标题相似,但是只改变段落的格式也可以检测出其中的差异。Diff生成工具还可以通过设置特定的gitattributes信息,实现特定文件或者某类文件的差异比较。用户甚至可以指定diff助手,即描述变更的命令,或者还可以为二进制文件指定文本转换过滤器。
如果用户喜欢使用图形化工具(通常支持逐行比较)查看上述变更记录,那么可以使用git的difftool代替使用git diff命令。这可能需要预先做适当的配置。
接下来介绍一个使用diff命令查看git项目历史记录的高级示例。首先使用diff命令查看git.git版本库中的提交1088261f。当然用户也可以使用浏览器查看该提交,例如通过GitHub。下列内容是该提交的第三条补丁记录:
diff --git a/builtin-http-fetch.c b/http-fetch.c
similarity index 95%
rename from builtin-http-fetch.c
rename to http-fetch.c
index f3e63d7..e8f44ba 100644
--- a/builtin-http-fetch.c
+++ b/http-fetch.c
@@ -1,8 +1,9 @@
#include "cache.h"
#include "walker.h"
-int cmd_http_fetch(int argc, const char **argv, const char *prefix)
+int main(int argc, const char **argv)
{
+ const char *prefix;
struct walker *walker;
int commits_on_stdin = 0;
int commits;
@@ -18,6 +19,8 @@ int cmd_http_fetch(int argc, const char **argv,
int get_verbosely = 0;
int get_recover = 0;
+ prefix = setup_git_directory();
+
git_config(git_default_config, NULL);
while (arg < argc && argv[arg][0] == '-') {
1.4、可查询的提交
有时,在看过一些未提交的变更记录之后,你也许会发现工作目录下面有两个(或者更多)无甚关联的变更分别属于不同的业务逻辑,它是引起工作拷贝混淆的诱因。用户需要将这些不相关的变更分别放到对应的提交中,即隔离变更集。这种做法也和软件开发的最佳实践不谋而合。一种做法是先创建提交,然后再修复它(将之一分为二)。
不过有时候,其中某些变更急需马上上线(例如一个在线网站的bug修复),同时其余变更还有待进一步完善。因此这时用户需要把上述变更分割成两个独立的提交。
1、文件提交查询
最简单的情形就是这些不相关的变更分布于若干文件中。例如说,如果bug只存在于文件view/entry.tmpl中,并且该文件不存在其他变更,那么可以专门为该文件创建一个修复bug的提交,相关命令如下:
$ git commit view/entry.tmpl
该命令会忽略已经暂存到索引中的变更(即暂存区的内容),取而代之的是提交当前给定文件或目录(工作目录中的变更)中的内容。
2、变更的交互式查询
不过有时变更无法以这种方式分类,文件中的变更都集中到了一起。用户可以尝试git commit命令和–interactive选项一起使用,对上述变更进行梳理:
$ git commit --interactive
staged unstaged path
1: unchanged +3/-2 Makefile
2: unchanged +64/-1 src/rand.c
*** Commands***
1: status 2: update 3: revert 4: add untracked
5: patch 6: diff 7: quit 8: help
What now>
这里Git系统为用户显示了工作区的状态和变更摘要信息,以及暂存区/索引(暂存的)-状态子命令的输出结果。描述变更的方式是添加和删除文件的数量(和git diff --numstat输出结果类似):
What now> h
status - show paths with changes
update - add working tree state to the staged set of changes
revert - revert staged set of changes back to the HEAD version
patch - pick hunks and update selectively
diff - view diff between HEAD and index
add untracked - add contents of untracked files to the staged set of
changes
*** Commands ***
1: status 2: update 3: revert 4: add untracked
5: patch 6: diff 7: quit 8: help
为了对这些变更进行梳理分类,用户需要用到patch子命令(例如使用5或者s)。之后Git系统会弹出一个Update>>对话框让用户选择如何处理这些文件,然后用户需要根据上面的状态信息,输入希望更新的文件对应的数字标记,然后按下回车键即可。用户还可以输入*选择所有文件。确认输入完毕目标文件的信息之后,可以按下回车键输入一个空行,告诉系统选择结束了(用户还可以使用–patch选项直接忽略批量文件的选择)。Git系统将会为用户逐个显示特定文件的变更区域,然后让用户选择分类,下面的选项是专门操作单个变更区域的:
y - stage this hunk(暂存该区域)
n - do not stage this hunk(不暂存该区域)
q - quit; do not stage this hunk or any of the remaining ones(退出,不暂存任何区域)
s - split the current hunk into smaller hunks(分隔该区域为更小的区域)
e - manually edit the current hunk(手工编辑当前区域)
? - print help (打印帮助)
区域的输出结果和对话提示和下列内容类似:
@@ -16,7 +15,6 @@ int main(int argc, char *argv[])
int max = atoi(argv[1]);
+ srand(time(NULL));
int result = random_int(max);
printf("%d\n", result);
Stage this hunk [y,n,q,a,d,/,j,J,g,e,?]? y
一般来说,上述选项可以应付大部分在提交内部进行区域选择的操作。不过在一些极端情况下,用户可以将上述区域分割成更小的区域,甚至手工编辑其中的差异。
3、提交创建入门
使用git commit --interactive进行交互式提交记录查询有一个缺点,那就是它无法对将要提交的变更进行测试。当然用户也可以在创建一个提交之后随时对其进行检查(编译或者运行测试),发现bug之后,及时修复它。不过这只是一种替代性的解决方案。用户可以使用git add --interactive命令将准备提交的变更放在暂存区中,或者采用类似的解决方案(使用Git的图形化提交工具,例如 git gui)。交互式的提交只是交互式添加变更然后提交的一种快捷方式。之后用户还可以使用git diff --cached命令查看这些提交,以及使用git add 、git checkout 和git reset 命令对这些提交做相应的修改。
理论上来说,不管这些变更正确与否,用户都可以对它们进行测试,至少可以确认它们是否可以通过程序编译。为此,首先要使用git stash save --keep-index命令保存当前的状态,然后将工作目录的状态存放到暂存区中(索引)。执行该命令之后,用户就可以执行测试程序了(至少确认一下程序是否能够通过编译)。如果测试通过,那么用户就可以执行git commit命令创建一个新的修订版本,如果测试不通过,用户就可以使用git stash pop --index命令,从暂存区读取工作目录的状态,将之恢复到之前的状态;也有可能会需要用到git reset --hard命令对工作区进行重置。后者可能是非常有必要的,因为Git在保存用户的工作记录时过于保守,而且并不知道用户只是将某些记录隐藏暂存了。首先,索引中未提交的变更不能阻止Git用其他变更将其覆盖。其次,工作区的变更和暂存区的变更大体相同,因此当然会发生冲突。
1.5、 修改提交
Git系统非常明显的一个优点是,用户可以随心所欲地恢复其中的任何东西。无论用户如何认真地编辑准备提交的变更记录,或多或少都会出现一些问题,例如忘记添加某个变更或者提交的信息有错别字等。这就是git commit命令的–amend选项大显身手的时候了,它能够帮助用户方便地修改最近的提交记录(如下图所示)。注意,用户还可以使用该选项修改合并提交(例如修复一个合并错误)。
修订的DAG图,C1到C2的变更代表对最近一次提交修改之前的状态,C5代表当前签出的提交。这里我们使用数字代替SHA-1码表示相关的提交记录。
如果用户希望对提交的历史记录做进一步的修改(假定该提交未发布,至少没有人在该提交的基础上提交新的修订),那么可能会用到交互式变基操作或者是某些特殊的工具,例如StGit(一个基于Git的提交历史批量管理工具)。
如果用户只是想修正提交备注信息中的错误,那么只需要再添加一条备注信息即可,不需要将它暂存(注意,我们使用git commit命令时并没有使用-a / --all选项):
$ git commit --amend
如果用户希望为最近一次的提交添加一些变更记录(见下图),那么可以先使用git add命令将这些变更暂存,然后像前面的示例一样再次提交该修订记录,或者使用git commit -a --amend命令提交这些变更:
经过编辑的最近一次修订记录(上上图)的DAG图,这里新的C5修订是基于添加了更多变更记录之后的C5,它替换了旧版本的提交对象。
这里有一个非常重要的忠告:你永远不要修改一个已经发布了的修订!这是因为修改操作会创建一个新的提交对象替换原有的对象,如上图所示。如果开发工作只有一个人的话,那么这么做不会出什么问题。不过如果是多人协作开发,当你把原有的修订记录发布到远程版本库中后,团队其他人员可能已经基于该修订版本做了一些开发工作了。使用经过修订的版本替换原有的版本之后,可能会导致下游出现问题。
如果你尝试将一个已经发布过的提交修订,然后将之推送(发布)到某个分支上,Git将会阻止用户重写已发布的历史提交记录,然后询问用户是否真的希望替换旧的版本进行强制推送(除非用户配置了默认强制推送选项)。修订的历史版本在被编辑之前在分支的引用日志和HEAD引用日志中仍然是有效的,例如刚经过修订后不久,它仍然可以通过@{1}的形式访问。一般来说,除非手动清除,Git将会保存旧版本修订一个月。
2、使用分支
分支是一系列开发工作的符号名称。在Git中,每个分支都可以看作修订DAG中指向某些提交的具名指针,因此它也被称为分支首部。
分支在Git中的表现形式:目前Git在硬盘中采用了两种截然不同的方式来表示分支:松散格式和压缩格式。例如master分支(该分支是Git采用的默认分支名,用户在创建新的版本库时默认的分支名就是它)。采用松散格式时,它是.git/refs/heads/master中的一行文本,其中指代分支的内容是十六进制的SHA-1码。在压缩格式中,它是.git/packed-refs中的一行文本,使用最顶部修订的SHA-1码和分支全名一起表示该分支。
一系列的开发工作就是指从分支首部为起点所有可达的修订集合。而且它并不一定是线性的修订,还可以是分支分流或者联合。
2.1、新建分支
用户可以使用git branch命令创建一个新分支,例如在当前分支上新建一个名为testing的分支(参见下图右上部分),可以执行如下命令:
$ git branch testing
新建一个testing分支,然后切换到该分支,或者新建一个分支并且马上切换到该分支上(使用命令)。
执行上述命令之后会发生什么呢? 该命令会为用户创建一个可供访问的新指针(一个新引用)。如果用户希望在创建新分支的同时将它指向某些修订提交,那么可以使用相应的参数。
不过需要注意的是,git branch命令并不会改变HEAD指针的位置(指向当前分支的符号引用),并且不会改变工作目录中的内容。
如果用户希望创建一个新分支并切换到该分支上(可以马上在新建分支上工作),那么可以使用如下快捷方式:
$ git checkout -b testing
如果我们在当前的版本库中创建分支,checkout -b命令的差异仅在于它还会将HEAD指针移动到新建的分支上,如上图所示。