title: Git的底层及基础使用
date: 2024-05-16 12:00:00
categories:
- MissingSemester
tags: 版本控制系统Git
版本控制系统Git
什么是Git
版本控制系统 (VCSs) 是一类用于追踪源代码(或其他文件、文件夹)改动的工具。顾名思义,这些工具可以帮助我们管理代码的修改历史;不仅如此,它还可以让协作编码变得更方便。VCS通过一系列的快照将某个文件夹及其内容保存了起来,每个快照都包含了文件或文件夹的完整状态。同时它还维护了快照创建者的信息以及每个快照的相关信息等等。
现代的版本控制系统可以帮助您轻松地(甚至自动地)回答以下问题:
- 当前模块是谁编写的?
- 这个文件的这一行是什么时候被编辑的?是谁作出的修改?修改原因是什么呢?
- 最近的1000个版本中,何时/为什么导致了单元测试失败?
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 {
parent: array<commit>
author: string
message: string
snapshot: tree
}
这是一种简洁的历史模型。
对象和内存寻址
Git 中的对象可以是 blob、tree 或 commit:
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、tree 或 commit 都一样,它们都是对象。当它们引用其他对象时,它们并没有真正的在硬盘上保存这些对象,而是仅仅保存了它们的哈希值作为引用。
git commit
后会得到
$ git commit
[master (root-commit) 15cc653] Add hello.txt
1 file changed, 1 insertion(+)
create mode 100644 hello.txt
这里的15cc653就是commit的快照的哈希值,通过
$ git cat-file -p 15cc653
可以得到
tree 68aba62e560c0ebc3396e8ae9335232cd93a3f60
author Jenwein <rgw127310@gmail.com> 1719917775 +0800
committer Jenwein <rgw127310@gmail.com> 1719917775 +0800
Add hello.txt
也就是之前所讲的一个snapshot的内容,这个tree后面的长串则是这个tree的哈希值,同样使用
$ git cat-file -p 68aba62e560c0ebc3396e8ae9335232cd93a3f60
得到
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad hello.txt
我们已经看到了这个tree中的Blob的哈希,继续git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
我们就得到了
最终这个Blob的内容hello world
树本会包含一些指向其他内容的指针,例如 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”。
实际上HEAD指向的是快照,而不是工作目录,当前工作目录是独立于快照存在的。HEAD是一个引用,映射到当前的快照,并通过这个引用可以进行查看历史快照(git checkout)
git checkout hello.txt表示切换到当前head指向的hello.txt的状态,相当于撤销当前对文件内容的修改
git checkout 15cc65... #不需要完整哈希值即可找到,当然可以直接checkout引用名
这样会使HEAD指向对应的快照,当通过git log --all --graph --decorate
来查看可视化历史记录(有向无环图)时,可以看到以下内容:
* commit 49ae43f240b4ef1461e5bd20d0c06c1656929bf5 (master)
| Author: Jenwein <rgw127310@gmail.com>
| Date: Wed Jul 3 17:47:12 2024 +0800
|
| x
|
* commit 15cc65399db4357b57bdf408ef23ba78dd41396a (HEAD)
Author: Jenwein <rgw127310@gmail.com>
Date: Tue Jul 2 18:56:15 2024 +0800
Add hello.txt
有一个问题,如果在当前工作目录修改了文件内容但未提交快照,直接checkout会报错,或-f
强制提交将会撤销当前的修改。
git diff
用来显示与暂存区文件的差异,或者git diff <revision> <filename>
: 显示某个文件两个版本之间的差异。直接git diff filename
显示当前的工作目录下与HEAD的差异,等同与git diff HEAD filename
不存在说如果当前的HEAD指向的是一个历史快照,当前工作目录与这个HEAD的差异,因为之前提到,修改文件内容但为提交快照是无法
checkout
到某个历史快照的。
再如果,你checkout到了某个历史分支,然后这时候的工作目录也会被修改,所以git diff不会有问题。
仓库
最后,我们可以粗略地给出 Git 仓库的定义了:对象
和 引用
。
在硬盘上,Git 仅存储对象和引用:因为其数据模型仅包含这些东西。所有的 git
命令都对应着对提交树的操作,例如增加对象,增加或删除引用。
当您输入某个指令时,请思考一下这条命令是如何对底层的图数据结构进行操作的。另一方面,如果您希望修改提交树,例如“丢弃未提交的修改和将 ‘master’ 引用指向提交 5d83f9e
时,有什么命令可以完成该操作(针对这个具体问题,您可以使用 git checkout master; git reset --hard 5d83f9e
)
暂存区
Git 中还包括一个和数据模型完全不相关的概念,但它确是创建提交的接口的一部分。
就上面介绍的快照系统来说,您也许会期望它的实现里包括一个 “创建快照” 的命令,该命令能够基于当前工作目录的当前状态创建一个全新的快照。有些版本控制系统确实是这样工作的,但 Git 不是。我们希望简洁的快照,而且每次从当前状态创建快照可能效果并不理想。例如,考虑如下场景,您开发了两个独立的特性,然后您希望创建两个独立的提交,其中第一个提交仅包含第一个特性,而第二个提交仅包含第二个特性。或者,假设您在调试代码时添加了很多打印语句,然后您仅仅希望提交和修复 bug 相关的代码而丢弃所有的打印语句。
Git 处理这些场景的方法是使用一种叫做 “暂存区(staging area)”的机制,它允许您指定下次快照中要包括那些改动。
合并多分支到同一快照
checkout到历史快照,并对文件进行修改并提交,将处于一个分离的HEAD状态,在这种状态下,如果您切换回其他分支,对文件的修改可能会丢失。如果您想要保留这些更改并在主分支上继续工作,可以创建一个新的分支,如果想要查看或恢复之前在分离HEAD状态下的提交,可以使用 git reflog
命令来查找那些提交的引用。然后,您可以使用找到的提交ID来检出或创建新分支。也就是
git reflog
git checkout -b new-branch-name <commit-id>
git add .
git commit -m "描述您的更改"
git branch
仅执行git branch
用来查看当前分支的信息,-vv
以显示更详细的内容
执行git branch cat
后指定具体的名字以创建新的分支cat,但目前cat只是一个指向当前位置的引用,所以现在有一个新的引用cat,和HEAD指向同一个提交(两个引用指向同一个提交)
commit 51cbcebc7fba8f0314834099205550d3245dc30c (HEAD -> master ,cat)
Author: Jenwein <rgw127310@gmail.com>
Date: Tue Oct 8 18:42:09 2024 +0800
add animal.py
当前正在master分支,如果git checkout cat
将会切换到cat分支指向的内容,就会变成
commit 51cbcebc7fba8f0314834099205550d3245dc30c (HEAD -> cat, master)
此时在cat分支修改animal.py文件
import sys
def cat():
print('Meow!')
def default():
print('Hello')
def main():
if sys.argv[1] == 'cat':
cat()
else:
default()
if __name__ == '__main__':
main();
git diff显示:
$ git diff
diff --git a/animal.py b/animal.py
index ffe2c2e..3abbabe 100644
--- a/animal.py
+++ b/animal.py
@@ -1,10 +1,16 @@
import sys
+def cat():
+ print('Meow!')
+
def default():
print('Hello')
def main():
- default()
+ if sys.argv[1] == 'cat':
+ cat()
+ else:
+ default()
if __name__ == '__main__':
main();
然后git add & git commit
后查看
$ git log --all --graph --decorate --oneline
* 3a6634b (HEAD -> cat) add cat functionality
* 51cbceb (master) add animal.py
* c856d2a yes
* 49ae43f x
| * 7713b36 (otherbranch1) historyqweq
|/
* 15cc653 Add hello.txt
会发现仍然是线性的历史,checkout 到master查看animal,py则是没有cat函数的版本
所以我们可以在不同的开发分支之中跳转。此时如果需要有一个dog方法,dog分支也和cat分支一样是正在开发的分支,可能有人正在分支上开发,我们想要的是基于master分支开始构建dog函数,所以checkout到master分支重新开始
直接git branch dog;git checkout dog
有一个简短的命令可以做到同样的效果:git checkout -b dog
此时的效果就是:
$ git log --all --graph --decorate --oneline
* 3a6634b (cat) add cat functionality
* 51cbceb (HEAD -> dog, master) add animal.py
* c856d2a yes
* 49ae43f x
| * 7713b36 (otherbranch1) historyqweq
|/
* 15cc653 Add hello.txt
在当前分支为animal.py添加dog函数后,git add & git commit
后查看当前提交记录:
$ git log --all --graph --decorate --oneline
* 6f9ecc6 (HEAD -> dog) add dog functionality
| * 3a6634b (cat) add cat functionality
|/
* 51cbceb (master) add animal.py
* c856d2a yes
* 49ae43f x
| * 7713b36 (otherbranch1) historyqweq
|/
* 15cc653 Add hello.txt
这样就可以并行开发多个分支,各分支开发完后是要合并的,合并的命令是git merge cat
将cat分支合并到当前分支,合并后提示:
$ git merge cat
Updating 51cbceb..3a6634b
Fast-forward
animal.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
查看提交记录:
* 6f9ecc6 (dog) add dog functionality
| * 3a6634b (HEAD -> master, cat) add cat functionality
|/
* 51cbceb add animal.py
* c856d2a yes
* 49ae43f x
| * 7713b36 (otherbranch1) historyqweq
|/
* 15cc653 Add hello.txt
此时如果合并dog分支:git merge dog
会提示:
$ git merge dog
Auto-merging animal.py # 尽力自行合并
CONFLICT (content): Merge conflict in animal.py
Automatic merge failed; fix conflicts and then commit the result.
合并冲突,当你尝试合并两个分支,存在一种并行开发方式可能与当前的一系列变化不兼容。
当打开animal.py时会看见:
import sys
<<<<<<< HEAD
def cat():
print('Meow!')
=======
def dog():
print('woof!')
>>>>>>> dog
def default():
print('Hello')
def main():
<<<<<<< HEAD
if sys.argv[1] == 'cat':
cat()
=======
if sys.argv[1] == 'dog':
dog()
>>>>>>> dog
else:
default()
if __name__ == '__main__':
main();
这里的
<<<<<<< HEAD
def cat():
print('Meow!')
=======
def dog():
print('woof!')
>>>>>>> dog
表示两个分支中冲突的内容,需要手动解决。解决后保存文件,再次将文件git add
后输入 git merge --continue
就会完成合并,最终提交记录为:
$ git log --all --graph --decorate --oneline
* 60a7285 (HEAD -> master) Merge branch 'dog'
|\
| * 6f9ecc6 (dog) add dog functionality
* | 3a6634b (cat) add cat functionality
|/
* 51cbceb add animal.py
* c856d2a yes
* 49ae43f x
| * 7713b36 (otherbranch1) historyqweq
|/
* 15cc653 Add hello.txt
远程仓库
是什么
当想要使用git进行多人协同开发时,应当是别人那里也会有一个你的当前git仓库的副本,并且你的本地git仓库知道这个副本的存在,这就是远程仓库。git remote
可以列出当前仓库所知道的远程仓库。
常用的github就可以看作是一个远程仓库,在配置其作为你的远程仓库后可以被git感知,那么就会有一些命令用来实现:将本地的代码推送到远程仓库,或将远程仓库的更改拉取到本地仓库。
这里以电脑中的另一个文件夹作为远程仓库。
在另一个位置创建一个文件夹remote
回到之前的仓库内,使用git remote add <name> <url>
来配置远程仓库,让本地仓库知道远程仓库的存在。如果只使用了一个仓库,那么使用origin
作为<name>
(约定俗成的名字),<url>
则用来填地址,github的url,在这里则只是刚才创建的远程仓库的目录,所以最终为
git remote add origin ../remote
然后再查看当前已知的远程仓库,发现成功配置。
$ git remote
origin
与远程仓库交互的命令
-
git push
:将更改从本地发送到远程仓库使用:
git push <remote> <local branch>:<remote branch>
将
<local branch>
的更改push到<remote>
仓库的<remote branch>
分支,如果远程仓库没有该分支将创建分支。在这里将是:
$ git push origin master:master # 在远程仓库上创建一个名为master的分支 Enumerating objects: 21, done. Counting objects: 100% (21/21), done. Delta compression using up to 12 threads Compressing objects: 100% (15/15), done. Writing objects: 100% (21/21), 1.96 KiB | 400.00 KiB/s, done. Total 21 (delta 2), reused 0 (delta 0), pack-reused 0 To ../remote * [new branch] master -> master
成功推送后,log会有一些新信息:
$ git log --all --graph --decorate --oneline * 60a7285 (HEAD -> master, origin/master) Merge branch 'dog' |\ | * 6f9ecc6 (dog) add dog functionality * | 3a6634b (cat) add cat functionality |/ * 51cbceb add animal.py * c856d2a yes * 49ae43f x | * 7713b36 (otherbranch1) historyqweq |/ * 15cc653 Add hello.txt
标志在origin上有一个分支master,与当前的master分支指向相同的位置。
现在试着修改本地仓库文件的内容,这里我修改了print的信息的大小写,然后提交:
$ git log --all --graph --decorate --oneline * 054953e (HEAD -> master) x * 60a7285 (origin/master) Merge branch 'dog' |\ | * 6f9ecc6 (dog) add dog functionality * | 3a6634b (cat) add cat functionality |/ * 51cbceb add animal.py * c856d2a yes * 49ae43f x | * 7713b36 (otherbranch1) historyqweq |/ * 15cc653 Add hello.txt
远程仓库仍停留在之前的快照,所以查看远程仓库的人只能看到之前的内容,不会获得这个修改
-
git clone
克隆一个仓库到本地
$ git clone ./remote demo2 Cloning into 'demo2'... done.
那么此时就可以把demo和demo2看作是两个人在各自机器上的仓库,其中一个人正在与远程仓库交互,对于之前的在demo中的最后一次的修改只存在与demo中,demo2并不会有最新的提交
然后就可以使用git push
来将demo的最新提交同步到remote中,但是每次都输入git push origin master:master
是很累的,git中有多种方法可以将本地分支与远程仓库的分支对应起来
-
git branch --set-upstream-to=origin/master
用来设置当前分支的上游分支(upstream branch)在 Git 中,上游分支是指当前分支要跟踪的远程分支,也就是你通常会从那里拉取(pull)更新,或者向它推送(push)更改的分支
$ git branch --set-upstream-to=origin/master branch 'master' set up to track 'origin/master'.
具体来说,这条命令的作用是将当前分支的上游分支设置为远程的
origin/master
分支origin
是远程仓库的默认名称,master
是它的主分支。- 这样,你以后在当前分支执行
git pull
或git push
时,不用手动指定远程分支,Git 会自动知道需要与origin/master
交互。
新版本的 Git 中,
--set-upstream-to
的作用与git branch -u
等价。通过git branch -vv
可以看到:$ git branch -vv cat 3a6634b add cat functionality dog 6f9ecc6 add dog functionality * master 054953e [origin/master] x otherbranch1 7713b36 historyqweq
这时我们就可以直接使用
git push
来推送更改。远程仓库已经被更改,现在来关注demo2
-
git fetch
Git 中用于从远程仓库获取最新更改的命令。它会从远程仓库下载所有的提交、文件和引用(如分支、标签等),但不会自动合并或修改你当前的工作目录。
git fetch [remote] [branch]
$ git fetch remote: Enumerating objects: 5, done. remote: Counting objects: 100% (5/5), done. remote: Compressing objects: 100% (3/3), done. remote: Total 3 (delta 1), reused 0 (delta 0), pack-reused 0 Unpacking objects: 100% (3/3), 284 bytes | 5.00 KiB/s, done. From E:/./remote 60a7285..054953e master -> origin/master
查看log:
$ git log --all --graph --decorate --oneline * 054953e (origin/master, origin/HEAD) x * 60a7285 (HEAD -> master) Merge branch 'dog' |\ | * 6f9ecc6 add dog functionality * | 3a6634b add cat functionality |/ * 51cbceb add animal.py * c856d2a yes * 49ae43f x * 15cc653 Add hello.txt
发现当前仍是指向master,master并没有被修改/合并,但是origin/master是已经指向这个最新的提交的,然后就可以通过
git merge
将master上移到最新位置。有一个组合命令git pull
相当于先执行git fetch
再执行git merge
$ git pull Updating 60a7285..054953e Fast-forward animal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-)
最后在log可以看到
$ git log --all --graph --decorate --oneline * 054953e (HEAD -> master, origin/master, origin/HEAD) x * 60a7285 Merge branch 'dog' |\ | * 6f9ecc6 add dog functionality * | 3a6634b add cat functionality |/ * 51cbceb add animal.py * c856d2a yes * 49ae43f x * 15cc653 Add hello.txt
至此,所有仓库之间的更改已经同步。
other
-
当想要git clone一个巨大的仓库时,可以添加参数
--shallow
,只会clone最新的快照而不会获取完整的历史提交。 -
如果当前修改了文件,比如在文件中有两处修改:
import sys def cat(): print('meow!') def dog(): print('Woof!') # place 1 def default(): print('hello') def main(): print('debug') # place 2 if sys.argv[1] == 'cat': cat() if sys.argv[1] == 'dog': dog() else: default() if __name__ == '__main__': main();
对于这次的修改,我只想要保留第一处的修改,而不保留第二处的调试信息,一可以直接修改文件删除调试信息,二可以交互式暂存
git add -p animal.py
不想要全部保存,所以
s
,分为多个判断,保留第一处,丢弃第二处。 -
git stash
将工作目录恢复到上一次提交的状态,但是已经的修改并没有被删除,可以通过git stash pop
重新展示这个修改 -
git bisect
检索历史 -
.gitignore
将你不关心的文件加入gitignore文件内,避免提交。
内容来自https://missing-semester-cn.github.io/2020/version-control/#snapshots