layout | title | date | ready | sync | syncdate | video | solution | ||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
lecture |
版本控制(Git) |
2020-01-22 |
true |
true |
2021-04-24 |
|
|
版本控制系统 (VCSs) 是一类用于追踪源代码(或其他文件、文件夹)改动的工具。顾名思义,这些工具可以帮助我们管理代码的修改历史;不仅如此,它还可以让协作编码变得更方便。VCS 通过一系列的快照将某个文件夹及其内容保存了起来,每个快照都包含了顶级目录中所有的文件或文件夹的完整状态。同时它还维护了快照创建者的信息以及每个快照的相关信息等等。
为什么说版本控制系统非常有用?即使您只是一个人进行编程工作,它也可以帮您创建项目的快照,记录每个改动的目的、基于多分支并行开发等等。和别人协作开发时,它更是一个无价之宝,您可以看到别人对代码进行的修改,同时解决由于并行开发引起的冲突。
现代的版本控制系统可以帮助您轻松地(甚至自动地)回答以下问题:
- 当前模块是谁编写的?
- 这个文件的这一行是什么时候被编辑的?是谁作出的修改?修改原因是什么呢?
- 最近的 1000 个版本中,何时/为什么导致了单元测试失败?
尽管版本控制系统有很多, 其事实上的标准则是 Git 。而这篇 XKCD 漫画 则反映出了人们对 Git 的评价:
因为 Git 接口的抽象泄漏(leaky abstraction)问题,通过自顶向下的方式(从命令行接口开始)学习 Git 可能会让人感到非常困惑。很多时候您只能死记硬背一些命令行,然后像使用魔法一样使用它们,一旦出现问题,就只能像上面那幅漫画里说的那样去处理了。
尽管 Git 的接口有些丑陋,但是它的底层设计和思想却是非常优雅的。丑陋的接口只能靠死记硬背,而优雅的底层设计则非常容易被人理解。因此,我们将通过一种自底向上的方式向您介绍 Git。我们会从数据模型开始,最后再学习它的接口。一旦您搞懂了 Git 的数据模型,再学习其接口并理解这些接口是如何操作数据模型的就非常容易了。
进行版本控制的方法很多。Git 拥有一个经过精心设计的模型,这使其能够支持版本控制所需的所有特性,例如维护历史记录、支持分支和促进协作。
Git 将顶级目录中的文件和文件夹作为集合,并通过一系列快照来管理其历史记录。在 Git 的术语里,文件被称作 Blob 对象(数据对象),也就是一组数据。目录则被称之为“树”,它将名字与 Blob 对象或树对象进行映射(使得目录中可以包含其他目录)。快照则是被追踪的最顶层的树。例如,一个树看起来可能是这样的:
<root> (tree)
|
+- foo (tree)
| |
| + bar.txt (blob, contents = "hello world")
|
+- baz.txt (blob, contents = "git is wonderful")
这个顶层的树包含了两个元素,一个名为 "foo" 的树(它本身包含了一个 blob 对象 "bar.txt"),以及一个 blob 对象 "baz.txt"。
版本控制系统和快照有什么关系呢?线性历史记录是一种最简单的模型,它包含了一组按照时间顺序线性排列的快照。不过出于种种原因,Git 并没有采用这样的模型。
在 Git 中,历史记录是一个由快照组成的有向无环图。有向无环图,听上去似乎是什么高大上的数学名词。不过不要怕,您只需要知道这代表 Git 中的每个快照都有一系列的“父辈”,也就是其之前的一系列快照。注意,快照具有多个“父辈”而非一个,因为某个快照可能由多个父辈而来。例如,经过合并后的两条分支。
在 Git 中,这些快照被称为“提交”。通过可视化的方式来表示这些历史提交记录时,看起来差不多是这样的:
o <-- o <-- o <-- o
^
\
--- o <-- o
上面是一个 ASCII 码构成的简图,其中的 o
表示一次提交(快照)。
箭头指向了当前提交的父辈(这是一种“在...之前”,而不是“在...之后”的关系)。在第三次提交之后,历史记录分岔成了两条独立的分支。这可能因为此时需要同时开发两个不同的特性,它们之间是相互独立的。开发完成后,这些分支可能会被合并并创建一个新的提交,这个新的提交会同时包含这些特性。新的提交会创建一个新的历史记录,看上去像这样(最新的合并提交用粗体标记):
o <-- o <-- o <-- o <---- o
^ /
\ v
--- o <-- o
Git 中的提交是不可改变的。但这并不代表错误不能被修改,只不过这种“修改”实际上是创建了一个全新的提交记录。而引用(参见下文)则被更新为指向这些新的提交。
以伪代码的形式来学习 Git 的数据模型,可能更加清晰:
// 文件就是一组数据
type blob = array<byte>
// 一个包含文件和目录的目录
type tree = map<string, tree | blob>
// 每个提交都包含一个父辈,元数据和顶层树
type commit = struct {
parents: array<commit>
author: string
message: string
snapshot: tree
}
这是一种简洁的历史模型。
Git 中的对象可以是 blob、树或提交:
type object = blob | tree | commit
Git 在储存数据时,所有的对象都会基于它们的 SHA-1 哈希 进行寻址。
objects = map<string, object>
def store(object):
id = sha1(object)
objects[id] = object
def load(id):
return objects[id]
Blobs、树和提交都一样,它们都是对象。当它们引用其他对象时,它们并没有真正的在硬盘上保存这些对象,而是仅仅保存了它们的哈希值作为引用。
例如,上面 例子中的树(可以通过 git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d
来进行可视化),看上去是这样的:
100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85 baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87 foo
树本身会包含一些指向其他内容的指针,例如 baz.txt
(blob) 和 foo
(树)。如果我们用 git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85
,即通过哈希值查看 baz.txt 的内容,会得到以下信息:
git is wonderful
现在,所有的快照都可以通过它们的 SHA-1 哈希值来标记了。但这也太不方便了,谁也记不住一串 40 位的十六进制字符。
针对这一问题,Git 的解决方法是给这些哈希值赋予人类可读的名字,也就是引用(references)。引用是指向提交的指针。与对象不同的是,它是可变的(引用可以被更新,指向新的提交)。例如,master
引用通常会指向主分支的最新一次提交。
references = map<string, string>
def update_reference(name, id):
references[name] = id
def read_reference(name):
return references[name]
def load_reference(name_or_id):
if name_or_id in references:
return load(references[name_or_id])
else:
return load(name_or_id)
这样,Git 就可以使用诸如 "master" 这样人类可读的名称来表示历史记录中某个特定的提交,而不需要在使用一长串十六进制字符了。
有一个细节需要我们注意, 通常情况下,我们会想要知道“我们当前所在位置”,并将其标记下来。这样当我们创建新的快照的时候,我们就可以知道它的相对位置(如何设置它的“父辈”)。在 Git 中,我们当前的位置有一个特殊的索引,它就是 "HEAD"。
最后,我们可以粗略地给出 Git 仓库的定义了:对象
和 引用
。
在硬盘上,Git 仅存储对象和引用:因为其数据模型仅包含这些东西。所有的 git
命令都对应着对提交树的操作,例如增加对象,增加或删除引用。
当您输入某个指令时,请思考一下这条命令是如何对底层的图数据结构进行操作的。另一方面,如果您希望修改提交树,例如“丢弃未提交的修改和将 ‘master’ 引用指向提交 5d83f9e
时,有什么命令可以完成该操作(针对这个具体问题,您可以使用 git checkout master; git reset --hard 5d83f9e
)
Git 中还包括一个和数据模型完全不相关的概念,但它确是创建提交的接口的一部分。
就上面介绍的快照系统来说,您也许会期望它的实现里包括一个 “创建快照” 的命令,该命令能够基于当前工作目录的当前状态创建一个全新的快照。有些版本控制系统确实是这样工作的,但 Git 不是。我们希望简洁的快照,而且每次从当前状态创建快照可能效果并不理想。例如,考虑如下场景,您开发了两个独立的特性,然后您希望创建两个独立的提交,其中第一个提交仅包含第一个特性,而第二个提交仅包含第二个特性。或者,假设您在调试代码时添加了很多打印语句,然后您仅仅希望提交和修复 bug 相关的代码而丢弃所有的打印语句。
Git 处理这些场景的方法是使用一种叫做 “暂存区(staging area)”的机制,它允许您指定下次快照中要包括那些改动。
为了避免重复信息,我们将不会详细解释以下命令行。强烈推荐您阅读 Pro Git 中文版 或可以观看本讲座的视频来学习。
{% comment %}
The git init
command initializes a new Git repository, with repository
metadata being stored in the .git
directory:
$ mkdir myproject
$ cd myproject
$ git init
Initialized empty Git repository in /home/missing-semester/myproject/.git/
$ git status
On branch master
No commits yet
nothing to commit (create/copy files and use "git add" to track)
How do we interpret this output? "No commits yet" basically means our version history is empty. Let's fix that.
$ echo "hello, git" > hello.txt
$ git add hello.txt
$ git status
On branch master
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: hello.txt
$ git commit -m 'Initial commit'
[master (root-commit) 4515d17] Initial commit
1 file changed, 1 insertion(+)
create mode 100644 hello.txt
With this, we've git add
ed a file to the staging area, and then git commit
ed that change, adding a simple commit message " Initial commit ". If we
didn't specify a -m
option, Git would open our text editor to allow us type a
commit message.
Now that we have a non-empty version history, we can visualize the history. Visualizing the history as a DAG can be especially helpful in understanding the current status of the repo and connecting it with your understanding of the Git data model.
The git log
command visualizes history. By default, it shows a flattened
version, which hides the graph structure. If you use a command like git log --all --graph --decorate
, it will show you the full version history of the
repository, visualized in graph form.
$ git log --all --graph --decorate
* commit 4515d17a167bdef0a91ee7d50d75b12c9c2652aa (HEAD -> master)
Author: Missing Semester <[email protected]>
Date: Tue Jan 21 22:18:36 2020 -0500
Initial commit
This doesn't look all that graph-like, because it only contains a single node. Let's make some more changes, author a new commit, and visualize the history once more.
$ echo "another line" >> hello.txt
$ git status
On branch master
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: hello.txt
no changes added to commit (use "git add" and/or "git commit -a")
$ git add hello.txt
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
modified: hello.txt
$ git commit -m 'Add a line'
[master 35f60a8] Add a line
1 file changed, 1 insertion(+)
Now, if we visualize the history again, we'll see some of the graph structure:
* commit 35f60a825be0106036dd2fbc7657598eb7b04c67 (HEAD -> master)
| Author: Missing Semester <[email protected]>
| Date: Tue Jan 21 22:26:20 2020 -0500
|
| Add a line
|
* commit 4515d17a167bdef0a91ee7d50d75b12c9c2652aa
Author: Anish Athalye <[email protected]>
Date: Tue Jan 21 22:18:36 2020 -0500
Initial commit
Also, note that it shows the current HEAD, along with the current branch (master).
We can look at old versions using the git checkout
command.
$ git checkout 4515d17 # previous commit hash; yours will be different
Note: checking out '4515d17'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:
git checkout -b <new-branch-name>
HEAD is now at 4515d17 Initial commit
$ cat hello.txt
hello, git
$ git checkout master
Previous HEAD position was 4515d17 Initial commit
Switched to branch 'master'
$ cat hello.txt
hello, git
another line
Git can show you how files have evolved (differences, or diffs) using the git diff
command:
$ git diff 4515d17 hello.txt
diff --git c/hello.txt w/hello.txt
index 94bab17..f0013b2 100644
--- c/hello.txt
+++ w/hello.txt
@@ -1 +1,2 @@
hello, git
+another line
{% endcomment %}
git help <command>
: 获取 git 命令的帮助信息git init
: 创建一个新的 git 仓库,其数据会存放在一个名为.git
的目录下git status
: 显示当前的仓库状态git add <filename>
: 添加文件到暂存区git commit
: 创建一个新的提交git log
: 显示历史日志git log --all --graph --decorate
: 可视化历史记录(有向无环图)git diff <filename>
: 显示与暂存区文件的差异git diff <revision> <filename>
: 显示某个文件两个版本之间的差异git checkout <revision>
: 更新 HEAD 和目前的分支
{% comment %}
Branching allows you to "fork" version history. It can be helpful for working
on independent features or bug fixes in parallel. The git branch
command can
be used to create new branches; git checkout -b <branch name>
creates and
branch and checks it out.
Merging is the opposite of branching: it allows you to combine forked version
histories, e.g. merging a feature branch back into master. The git merge
command is used for merging.
{% endcomment %}
git branch
: 显示分支git branch <name>
: 创建分支git checkout -b <name>
: 创建分支并切换到该分支- 相当于
git branch <name>; git checkout <name>
- 相当于
git merge <revision>
: 合并到当前分支git mergetool
: 使用工具来处理合并冲突git rebase
: 将一系列补丁变基(rebase)为新的基线
git remote
: 列出远端git remote add <name> <url>
: 添加一个远端git push <remote> <local branch>:<remote branch>
: 将对象传送至远端并更新远端引用git branch --set-upstream-to=<remote>/<remote branch>
: 创建本地和远端分支的关联关系git fetch
: 从远端获取对象/索引git pull
: 相当于git fetch; git merge
git clone
: 从远端下载仓库
git commit --amend
: 编辑提交的内容或信息git reset HEAD <file>
: 恢复暂存的文件git checkout -- <file>
: 丢弃修改git restore
: git2.32 版本后取代 git reset 进行许多撤销操作
git config
: Git 是一个 高度可定制的 工具git clone --depth=1
: 浅克隆(shallow clone),不包括完整的版本历史信息git add -p
: 交互式暂存git rebase -i
: 交互式变基git blame
: 查看最后修改某行的人git stash
: 暂时移除工作目录下的修改内容git bisect
: 通过二分查找搜索历史记录.gitignore
: 指定 故意不追踪的文件
- 图形用户界面: Git 的 图形用户界面客户端 有很多,但是我们自己并不使用这些图形用户界面的客户端,我们选择使用命令行接口
- Shell 集成: 将 Git 状态集成到您的 shell 中会非常方便。(zsh, bash)。Oh My Zsh 这样的框架中一般已经集成了这一功能
- 编辑器集成: 和上面一条类似,将 Git 集成到编辑器中好处多多。fugitive.vim 是 Vim 中集成 Git 的常用插件
- 工作流: 我们已经讲解了数据模型与一些基础命令,但还没讨论到进行大型项目时的一些惯例 ( 有 很多 不同的 处理方法)
- GitHub: Git 并不等同于 GitHub。 在 GitHub 中您需要使用一个被称作 拉取请求(pull request) 的方法来向其他项目贡献代码
- 其他 Git 提供商: GitHub 并不是唯一的。还有像 GitLab 和 BitBucket 这样的平台。
- Pro Git ,强烈推荐!学习前五章的内容可以教会您流畅使用 Git 的绝大多数技巧,因为您已经理解了 Git 的数据模型。后面的章节提供了很多有趣的高级主题。(Pro Git 中文版);
- Oh Shit, Git!?! ,简短的介绍了如何从 Git 错误中恢复;
- Git for Computer Scientists ,简短的介绍了 Git 的数据模型,与本文相比包含较少量的伪代码以及大量的精美图片;
- Git from the Bottom Up 详细的介绍了 Git 的实现细节,而不仅仅局限于数据模型。好奇的同学可以看看;
- How to explain git in simple words;
- Learn Git Branching 通过基于浏览器的游戏来学习 Git ;
- 如果您之前从来没有用过 Git,推荐您阅读 Pro Git 的前几章,或者完成像 Learn Git Branching 这样的教程。重点关注 Git 命令和数据模型相关内容;
- 克隆 本课程网站的仓库
- 将版本历史可视化并进行探索
- 是谁最后修改了
README.md
文件?(提示:使用git log
命令并添加合适的参数) - 最后一次修改
_config.yml
文件中collections:
行时的提交信息是什么?(提示:使用git blame
和git show
)
- 使用 Git 时的一个常见错误是提交本不应该由 Git 管理的大文件,或是将含有敏感信息的文件提交给 Git 。尝试向仓库中添加一个文件并添加提交信息,然后将其从历史中删除 ( 这篇文章也许会有帮助);
- 从 GitHub 上克隆某个仓库,修改一些文件。当您使用
git stash
会发生什么?当您执行git log --all --oneline
时会显示什么?通过git stash pop
命令来撤销git stash
操作,什么时候会用到这一技巧? - 与其他的命令行工具一样,Git 也提供了一个名为
~/.gitconfig
配置文件 (或 dotfile)。请在~/.gitconfig
中创建一个别名,使您在运行git graph
时,您可以得到git log --all --graph --decorate --oneline
的输出结果; - 您可以通过执行
git config --global core.excludesfile ~/.gitignore_global
在~/.gitignore_global
中创建全局忽略规则。配置您的全局 gitignore 文件来自动忽略系统或编辑器的临时文件,例如.DS_Store
; - Fork 本课程网站的仓库,找找有没有错别字或其他可以改进的地方,在 GitHub 上发起拉取请求(Pull Request);