0


MissingSemester-版本控制系统Git


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>defstore(object):id= sha1(object)
    objects[id]=objectdefload(id):return objects[id]

Blobs、tree 或 commit 都一样,它们都是对象。当它们引用其他对象时,它们并没有真正的在硬盘上保存这些对象,而是仅仅保存了它们的哈希值作为引用。

git commit

后会得到

$ git commit
[master (root-commit) 15cc653] Add hello.txt
 1file changed, 1 insertion(+)
 create mode 100644 hello.txt

这里的15cc653就是commit的快照的哈希值,通过

$ git cat-file -p 15cc653

可以得到

tree 68aba62e560c0ebc3396e8ae9335232cd93a3f60
author Jenwein <[email protected]>1719917775 +0800
committer Jenwein <[email protected]>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>defupdate_reference(name,id):
    references[name]=iddefread_reference(name):return references[name]defload_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 <[email protected]>| Date:   Wed Jul 317:47:12 2024 +0800
||     x
|
* commit 15cc65399db4357b57bdf408ef23ba78dd41396a (HEAD)
  Author: Jenwein <[email protected]>
  Date:   Tue Jul 218: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 <[email protected]>
Date:   Tue Oct 818:42:09 2024 +0800

    add animal.py

当前正在master分支,如果

git checkout cat

将会切换到cat分支指向的内容,就会变成

commit 51cbcebc7fba8f0314834099205550d3245dc30c (HEAD -> cat, master)

此时在cat分支修改animal.py文件

import sys

defcat():print('Meow!')defdefault():print('Hello')defmain():if sys.argv[1]=='cat':
        cat()else:
        default()if __name__ =='__main__':
    main();

git diff显示:

$ gitdiffdiff --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)addcat 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)addcat 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)addcat 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 +++++++-
 1file changed, 7 insertions(+), 1 deletion(-)

查看提交记录:

* 6f9ecc6 (dog)add dog functionality
| * 3a6634b (HEAD -> master, cat)addcat 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
defcat():print('Meow!')=======defdog():print('woof!')>>>>>>> dog

defdefault():print('Hello')defmain():<<<<<<< HEAD
    if sys.argv[1]=='cat':
        cat()=======if sys.argv[1]=='dog':
        dog()>>>>>>> dog
    else:
        default()if __name__ =='__main__':
    main();

这里的

<<<<<<< HEAD
defcat():print('Meow!')=======defdog():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)addcat 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 threadsCompressing 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 0To ../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)addcat 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)addcat functionality|/* 51cbceb add animal.py* c856d2a yes* 49ae43f x| * 7713b36 (otherbranch1) historyqweq|/* 15cc653 Add hello.txt

​ 远程仓库仍停留在之前的快照,所以查看远程仓库的人只能看到之前的内容,不会获得这个修改

  • git clone克隆一个仓库到本地$ git clone ./remote demo2Cloning 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/masterbranch 'master'set up to track 'origin/master'.具体来说,这条命令的作用是将当前分支的上游分支设置为远程的 origin/master 分支- origin 是远程仓库的默认名称,master 是它的主分支。- 这样,你以后在当前分支执行 git pullgit push 时,不用手动指定远程分支,Git 会自动知道需要与 origin/master 交互。新版本的 Git 中,--set-upstream-to 的作用与 git branch -u 等价。通过git branch -vv可以看到: $ git branch -vv cat 3a6634b addcat functionality dog 6f9ecc6 add dog functionality* master 054953e [origin/master] x otherbranch1 7713b36 historyqweq这时我们就可以直接使用git push来推送更改。远程仓库已经被更改,现在来关注demo2
  • git fetchGit 中用于从远程仓库获取最新更改的命令。它会从远程仓库下载所有的提交、文件和引用(如分支、标签等),但不会自动合并或修改你当前的工作目录。git fetch [remote] [branch]``````$ git fetchremote: 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 0Unpacking 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 addcat 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 pullUpdating 60a7285..054953eFast-forward animal.py |4 ++-- 1file 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 addcat functionality|/* 51cbceb add animal.py* c856d2a yes* 49ae43f x* 15cc653 Add hello.txt

至此,所有仓库之间的更改已经同步。

other

  • 当想要git clone一个巨大的仓库时,可以添加参数--shallow,只会clone最新的快照而不会获取完整的历史提交。
  • 如果当前修改了文件,比如在文件中有两处修改:import sysdefcat():print('meow!')defdog():print('Woof!')# place 1defdefault():print('hello')defmain():print('debug')# place 2if 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

标签: git

本文转载自: https://blog.csdn.net/weixin_62486437/article/details/143027629
版权归原作者 Hah317 所有, 如有侵权,请联系我们删除。

“MissingSemester-版本控制系统Git”的评论:

还没有评论