你需要从清晰的定义开始。特别是:
Git仓库本质上是一组提交的集合——Git称之为提交对象的数据库,以及支持这些提交实际有用(使它们包含文件)的其他对象,再加上一个辅助数据库来帮助你和Git 找到这些提交。
这意味着Git关注的是提交,而不是文件!提交包含文件,这就是为什么我们使用提交的原因。但是,从高层次来看,Git并不太关心文件,也不是真正关于分支,但我们通过分支名称来组织和——最重要的是——找到这些提交,这就是分支进入图片的方式。
Git实际上没有项目的概念。这个概念由你来定义。Git有仓库的概念,仓库由提交和其他对象组成。因此,如果你有一个现有的仓库,你就有了一堆提交(以及它们的其他对象)。
在你的更新中,你说:
“我想将这个项目更新到一个完全不同的语言(REACT NATIVE),目前仍在开发中。”
你仍然在谈论一个“项目”。Git没有这些,所以你必须将自己对“项目”的概念映射到Git拥有的东西:包含提交的存储库。
“我一直在本地计算机上工作的React Native版本,因为我克隆了一个存储库......”
在这里,你提到了另一个——显然是不同的——存储库。这是Git可以理解的。现在你似乎至少有三个存储库:
- 一个在GitHub上;
- 另一个你从某个地方克隆的(GitHub上的其他地方?);和
- 第三个与第二个密切相关。
“并且还没有将它推送到Github。”
这里的代词“it”似乎指的是您的第三个存储库。但是您不可以推送一个存储库。您可以推送一些提交集(及其支持对象),它们存在于存储库中,并且您和Git使用分支名称和其他名称来查找它们。
现在是时候更多地谈论存储库、提交和克隆了。
存储库作为一对数据库
数据库有许多形式,但构成任何Git存储库的两种形式是简单的key-value stores。键值数据库(请参阅维基百科链接了解更多信息)以键作为输入,并使用它来检索值。
对于一个提交对象来说,关键是提交的名称。一个提交的“名称”是一个又长又丑、看起来像随机数(但实际上并不是随机数)的哈希 ID,例如
4af7188bc97f70277d0f10d56d5373022b1fa385
。这些哈希 ID 特别神奇,因为:
- 它们是唯一的:没有两个不同的提交会有相同的哈希 ID;
- 每个 Git 系统在宇宙中都以相同的方式计算它们,所以即使你还没有进行过提交,一旦你进行了提交并获得了某个哈希 ID,宇宙中的每个 Git 系统都会同意,没错,你刚刚做的那个提交,现在就应该获得那个哈希 ID,其他任何提交都不能再使用它。
这种深度魔法与密码学有各种关联,Git [滥]用它的方式实际上不能永远工作。哈希 ID 空间的巨大大小旨在确保它能够工作足够长的时间,以至于没有人会在意。但你真正需要知道的是,哈希 ID - Git 正式称之为对象 ID 或 OID - 是提交的“真名”。Git 实际上需要这个哈希 ID 来在其所有对象的大型数据库中找到提交。
如果这是 Git 唯一的数据库,我们都必须记住这些看起来随机的哈希 ID。这将非常糟糕,因为人类对这些事情很不擅长。因此,Git 有一个第二个数据库。这个第二个数据库也是一个简单的键值存储,但其中的键是诸如分支和标签名称之类的东西,这些名称对人类来说是可读的并且有意义:像main
或master
、develop
等名称。Git 将某个分支下的最新提交的哈希 ID 存储在该分支名称下,在这个第二个数据库中。
这意味着所有你需要记住的就是你使用的分支名称。你说“给我最新的main
提交”或者“给我最新的dev
提交”,Git从名称数据库中找出哈希ID,然后使用它来从大型所有对象数据库中找到提交。
换句话说,你不需要记忆哈希ID。但你仍然需要知道这些有趣而奇怪的{{link1:十六进制}}OID或哈希ID标识号对应每个提交。但你不必记忆它们:比如运行命令git log
,Git本身将向你展示它们,如果需要哈希ID,你可以使用鼠标复制和粘贴(可能很少用到,也许一周一次或一天两次,但某一天,很可能会需要)。
关于提交的了解
除了像这样编号外,提交还有:
第一个子项目似乎与提交通常非常小的说法相矛盾。如果每个提交都包含每个文件,那么仓库数据库不会膨胀得很大吗?实际上,Git 采用了一些非常巧妙的技巧。其中最重要的技巧是提交中的文件以一种特殊的、只读的、仅限于 Git 的格式(实际上是作为所有对象数据库中的对象)存储,其中重复的文件内容被去重。
由于大多数提交通常会从以前的提交中重复使用它们的大部分文件,并且这些文件会自动地进行去重,因此它们不占用空间!实际上只有更改过的文件才会占用任何空间。 Git稍后——而不是立即——也会压缩那些文件;只要它们是正常的文本或编程语言内容,这通常非常有效。(对于二进制文件,通常会失败,这就是为什么Git不适合存储大多数二进制文件的原因。)
因此,这些压缩和去重的文件作为Git对象就是Git如何在每个提交中存储每个文件而不实际存储每个文件,因此不会使存储库变得极端臃肿。您不需要知道这一点来使用Git,您只需要知道每个文件似乎都被永久地存储在每个提交中。也就是说,进行提交,您可以永远地获取所有文件,或者更确切地说,只要您能够找到该提交。您将需要其哈希ID!(现在你明白哈希ID为什么很重要了吗?)
但是我刚才说过,你不需要记住哈希 ID,这是真的。那么这是怎么做到的呢?好吧,让我们更仔细地看一下元数据。每个提交都存储有关自身的信息,包括制作提交的人的姓名和电子邮件地址、一些日期和时间戳等等。但是对于 Git 本身来说,这里有一个至关重要的信息:每个提交都存储了它之前的提交的哈希 ID。
更准确地说,每个提交都有一个先前提交哈希 ID 的列表。但是这个列表通常只有一个元素。这个列表条目中保存着父提交的哈希 ID。这意味着提交之间存在父子关系,大多数提交只有一个父提交(母亲?父亲?在这里选择任何你喜欢的,Git 是性别中立的)。
因为提交是完全只读的,所以子提交可以记住其父提交的“名称”(哈希 ID),因为在创建子提交时父提交已经存在。但是父提交无法记住其子提交的名称,因为它们的子提交——如果有的话——尚不存在。一旦子提交出生,它就被低温冷冻,无法了解其未来子提交的名称。
我们说子提交“指向”其父提交,如果需要,我们可以用这种方式绘制一些提交。使用单个大写字母代替真实的哈希 ID,我们将称分支中的“最后一个”提交为 H
(哈希)并像这样绘制:
<-H
那个从H
出来的箭头代表了H
如何使用其元数据中的父哈希ID存储指向其父节点。现在,我们将绘制它的父节点,作为字母G
,因为它位于H
之前:
<-G <-H
当然,G
指向它的父节点:
... <-F <-G <-H
正如你所看到的,这会形成一个无尽的向后指向的链条,除了当我们回到有史以来的第一个提交时,历史最终会耗尽。那个第一个提交没有父级,就像某种没有母亲的处女诞生一样,因此那个提交——让我们称其为A
——根本不会向后指:
A--B--...--G--H
我们很容易就会懒得将箭头画出来,因为我们知道它们是提交的一部分,无法更改,因此 必须 指向后面。
分支名称查找提交
以上方法的问题在于,您仍然需要记住哈希ID H
,即链中的最后一个提交:链的末尾。 但是,正如我们已经看到的,Git存储库包含第二个名称数据库,并且分支名称保留了最后提交的哈希ID。 与提交指向早期提交类似,我们说分支名称指向末尾提交,并将其绘制如下:
...--G--H <-- branch
要向分支添加一个新的提交,Git只需将新的提交与其完整的快照和元数据一起写入,并在此过程中获取新的唯一哈希ID。这个哈希ID看起来是随机的;我们在这里称之为I,它在H之后。提交I将指向提交H,就像任何提交都必须向后指向其父提交一样。
...--G--H <-- branch
\
I
由于新的提交I
是提交链的新尖端,Git现在会将I
的哈希ID写入数据库中的分支名称,以便该名称现在指向I
:
...--G--H branch
\ ↙︎
I
我在这里使用的箭头有点糟糕;这就是为什么我懒得在提交之间画箭头。实际上,我们并不需要在单独的一行上放置I
:将其绘制为以下更合理:
...--G--H--I <-- branch
请注意分支名称上突出的箭头
可以并且会不断移动。 这使它与指向提交的父项的僵硬的向后指向箭头非常不同。
Git仅将分支名称定义为“这是最后一次提交”。也就是说,无论分支名称中的哈希ID是什么,都是该分支上的最后一次提交。 因此,要更改
最后一次提交是哪个提交,您需要让Git将新的哈希ID放入分支名称中。这不仅使您能够将提交
添加到分支,还使您能够从分支中
删除提交:
H--I ???
/
...--G <-- branch
如果我们在分支名称中存储 Git 存储库的哈希 ID,那么我们已经从分支中删除了提交 H 和 I。它们仍然在存储库中,但现在您需要知道它们的哈希 ID!如果您不知道提交 I 的哈希 ID,则永远无法找到它。Git 可以向后工作 - 给定指向提交 I 的名称 branch,Git 可以为您跟随提交到父级的箭头;这就是 git log 的工作原理 - 但 Git 无法向前工作。没有向前指向的箭头!
克隆
除上述功能外,Git 还提供了克隆存储库的能力。以下是克隆的工作方式:
- 你运行
git clone
并提供一个URL。
- Git创建了一个全新的、完全空的仓库:两个数据库(用于提交和其他对象,以及分支和标签和其他名称),但这两个数据库都没有任何内容。
- 你的Git软件将此URL保存在新仓库中(在一个辅助“数据库”中,实际上只是一个像INI文件一样的简单文件)。
- 你的Git软件使用你提供的URL调用一些其他的Git软件。它们会列出所有的分支、标签和其他名称,以及与之对应的哈希ID。
- 你的Git软件说:“把所有这些对象以及它们的父级和祖先和构成历史记录的所有东西都给我”——也就是说,提交和其他对象数据库的全部内容。
- 它们发送所有这些内容,你的Git(你的软件与你的仓库一起工作)将其插入到你的数据库中。
- 对于他们的每个分支名称,你的Git软件将这些名称更改为你的远程跟踪名称。他们的
main
或master
变成了你的origin/main
或origin/master
;如果他们有develop
,那么它就变成了你的origin/develop
;等等。
你最终会得到两个数据库都充满了东西:
- 你拥有他们的对象数据库的完整副本(嗯,大部分是完整的:如果他们有一些对象找不到,比如因为在分支末尾删除提交,他们不需要将这些对象发送给你)。
- 你没有分支名称。(是否有分支取决于你对分支一词的理解。)相反,你拥有以
origin/
开头的远程跟踪名称。 现在,这些是你找到提交的方式。
最后,你的Git现在创建了一个自己的分支名称。例如,如果你告诉git clone
使用git clone -b develop
,你的Git将创建你自己的分支名称develop
。如果你没有使用-b
选项——大多数人都没有——你的Git会询问他们的Git推荐什么名称,然后创建该名称。
无论哪种情况,你的新分支所提交的commit,作为其顶部的commit hash ID,与他们那边拼写相同的名称上的commit hash ID是相同的。也就是说,如果你让Git创建main
,因为他们推荐使用main
,那么你的main
所提交的last commit就是你的origin/main
所选择的相同的commit,这也是在你运行git clone
时他们的main
所选择的commit。
现在——可能只是几秒钟,但对于计算机来说已经很长时间了——他们的分支名称可能选择了不同的提交。但是在你运行git clone
时,他们的分支名称选择了特定的提交。你的Git会记住所有这些,使用你的存储库的远程跟踪origin/*
名称。你的存储库有自己的分支名称,你可以随意创建和更新它们,即使使用相同的拼写,它们也与他们的分支名称不同。这是因为他们的数据库不是你的数据库。
你和他们共享的是提交。这些是只读的——你们都不能更改它们——并且具有哈希ID,每个Git在宇宙中都同意这些哈希ID是正确的,通过哈希ID魔法。因此,原始(origin
)存储库的克隆和该原始存储库非常密切相关。你有自己的分支名称,但你共享提交。
获取和推送
给定任何两个仓库——通常是相关的,尽管在开始时这并不是必需的——我们可以将提交从一个仓库转移到另一个仓库:
- 获取它们的提交的方法是使用
git fetch
。
- 将提交给它们的方法是使用
git push
。
在这两种情况下,我们必须向我们的 Git(运行在我们的仓库中的软件)提供另一个 Git(他们的软件和他们的仓库)的 URL。如果我们有一个通过克隆创建的密切相关的仓库,我们已经拥有了 URL,因为我们的 Git 将其保存在名称为 origin
的位置。所以我们只需要运行:
git fetch origin
我们的Git调用他们的Git。他们列出他们的名称和哈希ID,我们的Git可以确定我们是否已经有了一些提交——因为哈希ID是对象的真实名称,并且提交具有唯一的哈希ID——或者我们需要它们。如果我们需要它们,我们的Git会请求它们,这将自动要求发送方发送父哈希ID,以便我们可以查看我们是否也有它们。通过这种方式,我们的Git从他们那里获取到了所有我们没有的提交。我们不会请求任何我们已经拥有的提交。Git使用这些信息来确定哪些提交对我们是新的,以及这些提交中的哪些文件是新的,并将新的内容发送给我们。
然后,我们的Git将所有新提交和支持对象都放入我们的对象数据库中。现在我们拥有他们的所有提交,以及我们从未提供给他们的任何提交。然后,我们的Git更新我们的远程跟踪名称,因为我们知道当我们运行“git fetch”命令时他们的分支名称所包含的哈希ID,而我们拥有所有这些提交,所以我们的Git可以更新我们对他们分支名称的记忆。
事实上,
git fetch
是
git clone
的主要部分如何工作的方式:所有
git clone
做的就是创建空仓库,将 URL 存储在名称为
origin
的位置下,运行
git fetch
,并运行最后一个操作以创建和检查一个分支名称。因此,如果您使用
git fetch
连接两个
不相关的 仓库,则一旦完成提取,这两个仓库现在就相关了。
git push
命令是 Git 最接近
git fetch
相反的命令。请记住:push 的相反是 fetch,而不是 pull。 这是 Git 在早期命名时犯的错误,现在我们只能忍受它。 您只需要记住这一点:push/fetch,而不是 push/pull。但是有一些重大的区别:
使用
git fetch
,操作是“获取提交并更新远程跟踪名称”。默认情况下,是所有新的提交和所有远程跟踪名称。
使用
git push
,操作是“发送”新的提交。我们必须选择我们的一侧将从哪个提交开始,以向他们提供新的提交。因此,在这里运行
git push origin somebranch
,以向他们提供任何我们在
我们的分支名称somebranch
中找到的新提交。我们将从仅提供一个
提示提交开始。他们可能没有它,所以他们会说“哦,是的,请发送那个”,这意味着我们必须提供其父级。如果他们没有,他们也会要求它,这意味着我们提供祖先等等。最终,我们回到了他们已经拥有的某个提交——因为我们通过克隆从他们那里得到了它——或者我们回到了我们的历史记录,并到达了我们的第一个提交,它没有父级,所以我们说“就这些”。
一旦我们知道哪些提交是新的,我们的Git会使用其智能功能仅打包那些提交和新文件,并将其发送到他们的存储库对象数据库中。但现在,我们或他们有点进退两难。他们将如何
找到这些对象?他们将需要一个
名称。
当我们使用
git fetch
时,我们创建或更新了一个
远程跟踪名称,以记住其中一个
分支名称的提示提交。使用
git push
,他们没有我们的远程跟踪名称。相反,我们请求他们——默认情况下是礼貌地——请看看他们是否可以,请
创建或更新其中一个分支名称,以记住我们的最新提交。
如果
somebranch
对他们来说是一个
新名称,他们可以只创建该名称。这不会干扰他们的任何
现有名称。所以这很容易,他们可以说“好的”,这样做,我们就都很好了。但是,如果
somebranch
是他们的一个
分支名称,他们现在要做的是检查我们是否只是
向他们的分支添加提交。
让我们回到之前的例子。假设我们有这些名称:
...--G--H <-- main
\
I <-- develop
假设我们进一步假设我们有一些远程跟踪名称,这些名称是几秒钟、几小时或几天前从
origin
获取的:
...--G--H <-- main, origin/main
\
I <-- develop, origin/develop
我们现在使用 git switch develop
来选择 名称 为develop
的分支作为我们的当前分支名称:
...
\
I <
这个技巧是将特殊名称HEAD
添加到绘图中,并将其“附加”到其中一个分支名称,以显示我们在自己的Git存储库中使用的分支名称。
现在让我们按照通常的方式(编辑文件,git add
,git commit
,编写提交消息等)在我们的Git存储库中进行新的提交。我们得到了这个:
...
\
I <
\
J <
我们现在运行
git push origin develop
。我们将向他们提供提交
J
,然后他们会说:
嗯,新的哈希ID,好的,给我发送J
。接下来,我们将提供提交
I
,因为那是
J
的父提交。他们会说:
不用了,已经有了那个。现在我们知道了他们所有或几乎所有的提交!他们有
I
,但这意味着他们有
H
,这意味着他们有
G
,以此类推,一直回溯到第一个提交!
所以,我们的Git将打包提交
J
和任何新的对于
J
而言不是
H
中文件副本的文件(也许我们将某个文件恢复到之前的状态)。我们将发送它,然后我们会礼貌地询问他们:
如果可以的话,请将您的名称develop
设置为指向提交J
(当然是通过提交
J
的真实哈希ID)。
如果他们的
develop
- 正如我们的内存所表示的那样,
origin/develop
仍然指向提交
I
,提交
J
会在其上添加。因此,他们会对我们的礼貌请求说“好的”,我们将知道
他们的develop
现在命名为提交
J
,我们的Git将更新我们的图片,如下所示:
...
\
I
但是假设在这些秒、小时或天内,其他人向他们的develop
添加了一些其他新提交K
。也就是说,他们已经:
...--G--H <-- main
\
I--K <-- develop
在他们的代码库中(这些是他们的名称,所以我们不需要知道或关心他们的HEAD
在哪里,而且他们还没有J
)。
我们向他们发送一个新的提交J
,他们将其放入他们的数据库:
...--G--H <-- main
\
I--K <-- develop
\
J
然后我们会礼貌地要求他们将
develop
指向
J
。但这一次
不行!如果他们这样做,他们将“丢失”他们的提交
K
。所以他们会说:
不,如果我这样做,我会失去一些东西。(Git以其惯常的晦涩方式称之为“非快进”)。我们会收到一个错误:
! [rejected] develop -> develop (non-fast-forward)
我们通常需要针对这个错误采取的措施是运行
git fetch
,这将获取提交
K
,以便
我们现在拥有:
...--G
\
I
\
J <-- develop (HEAD)
一旦我们有了这个,我们就可以决定:
- 提交
K
好吗?还是应该要求他们放弃它?
- 如果提交
K
是好的,我们想对此做什么?
我们主要选择“我们想做什么”的方式是使用 git rebase
或 git merge
。非常常见的情况是运行 git fetch
,然后想要运行 git rebase
或 git merge
。这就是为什么存在 git pull
:它运行 git fetch
,然后运行 git rebase
或 git merge
。 git pull
的缺点很多:
- 我们无法查看提交的代码
K
。我们只能假设它是好的。
- 在决定合并或变基之前,我们无法查看提交的代码
K
。
- 如果我们是Git的初学者,甚至不知道我们正在做什么。
- 我们不知道
git merge
和git rebase
是什么!
- 当
git merge
或git rebase
无法完成时,我们不知道该怎么办,这种情况经常发生,所以很重要。
- 我们甚至不知道选择的是
git merge
还是git rebase
!我们不能知道下一步该怎么做,因为我们不知道发生了什么。
如果你是Git的新手,不要使用git pull
(至少现在不要)。等到你把所有这些东西都熟记于心后,你可能会想用它。我自己仍然大多不使用它,虽然我已经使用Git将近二十年了。我不太喜欢git pull
。(在过去的一年左右,它增加了一个新模式,可以做我想要的事情,但我仍然更愿意运行两个命令。)
结论
在你担心是否应该将另一个项目推入存储库之前(当然你可以,但也许你不应该,这是一个判断问题),先学习一下存储库为你做了什么。决定你是否想要将“项目”映射到“存储库”一对一、多对多、多对一、一对多或其他方式。采用“单一存储库”(将所有内容放在一个存储库中)和“多存储库”(为一个或多个项目使用多个存储库)方法各有优缺点。你不会总是第一次就做对所有事情,但请注意这就是你在这里所做的。