我如何教授 Git

2025-05-28

我如何教授 Git

我使用 Git 已有十二年了。八年前,我为一家即将创建开源项目的合作公司开设了一堂 Git(和 GitHub)培训课程,我将在这里分享我的教学方法。顺便提一下,从那时起,我们在公司内部也开设了采用相同(或类似)方法的培训课程。话虽如此,我并没有发明任何东西:这本书很大程度上受到了其他人之前所写内容的启发,包括《Pro Git》一书尽管顺序不同,但在我看来,这本书确实有所裨益。

我之所以写这篇文章,是因为多年来,我不断看到人们在使用Git 时,却并不真正理解自己在做什么;他们要么被困在一套特定的工作流程中,无法适应其他工作流程,比如某个开源项目正在使用的工作流程(开源维护者也并不真正了解外部贡献者如何使用 Git),要么一旦出现任何异常,或者在调用 Git 命令时犯了错误,他们就会完全不知所措。Julia Evans对Git 的(重燃)兴趣激发了我写下这篇文章,因为她有时会在社交网络上征求意见。

我的目标并非真正教你 Git,而是分享我教授 Git 的方法,以便其他想要学习 Git 的人能够从中汲取灵感。所以,如果你正在学习 Git,这篇文章并非为你而写(抱歉),因此可能不够完整,但希望其中提供的其他学习资源链接能够填补你的空白,使其成为一个有用的学习资源。如果你是视觉学习者,这些外部学习资源通常图文并茂,甚至更倾向于视觉学习。

心智模型

一旦我们清楚了为什么使用 VCS(版本控制系统)来记录提交中的更改(或者换句话说,我们将更改提交到历史记录中;我假设对这个术语有一定的熟悉),让我们更具体地了解一下 Git。

我认为理解 Git 的关键之一是对其背后的概念有一个准确的心理模型。

首先,这其实并不重要,但 Git 实际上并不记录变更,而是记录文件的快照(至少在概念上如此;它会使用打包文件来高效地存储文件,并且在某些情况下会实际存储变更(差异),并根据需要生成差异。但这有时会在某些命令的结果中体现出来(例如,为什么有些命令显示一个文件被删除而另一个文件被添加,而其他命令则显示文件被重命名)。

现在让我们深入了解一些 Git 概念,或者 Git 如何实现一些常见的 VCS 概念。

犯罪

Git提交是:

  • 一个或多个父提交,或者对于第一个提交(root)没有父提交
  • 提交消息
  • 作者和作者日期(实际上是带有时区偏移的时间戳)
  • 提交者和提交日期
  • 和我们的文件:它们相对于存储库根目录的路径名、它们的模式(UNIX 文件系统权限)以及它们的内容

每个提交都会被赋予一个标识符,该标识符是通过计算该信息的 SHA1 哈希值来确定的:更改一个逗号,您将获得不同的 SHA1,即不同的提交对象。(顺便说一句,Git 正在慢慢转向使用 SHA-256作为哈希函数)。

另外:SHA1 是如何计算的?

Git 的存储是内容寻址的,这意味着每个对象都以其 SHA1 哈希的形式存储一个直接来自其内容的名称。

过去,Git 将所有内容都存储在文件中,我们仍然可以这样理解。文件的内容存储为blob对象,目录存储为tree 对象(一个文本文件,其中列出了目录中的文件及其名称、模式、代表其内容的blob对象的 SHA1 值,以及子目录及其名称和tree对象的 SHA1 值)。

如果您想了解详细信息,Julia Evans 写了一篇令人惊奇的(再次)博客文章;或者您可以Pro Git书中阅读它

一个包含 5 个框的图表,分为 3 列,每个框都标有 5 位 SHA1 前缀;左侧的框子标记为“提交”,包含元数据“树”,中间框的 SHA1 为“作者”,“提交者”和“提交者”的值均为“Scott”,文本为“我的项目的初始提交”;中间的框子标记为“树”,包含三行,每行标记为“blob”,其余 3 个框的 SHA1 和类似文件名的内容:“README”、“LICENSE”和“test.rb”;最后 3 个框在右侧垂直对齐,都标记为“blob”,包含类似 README、LICENSE 和 Ruby 源文件内容的开头;框之间有箭头:提交指向树,树指向 blob。

提交及其树(来源:Pro Git

提交中的提交创建一个有向无环图来表示我们的历史:有向无环图由节点(我们的提交)通过有向边链接在一起组成(每个提交链接到其父提交,有一个方向,因此是有向的)并且不能有循环/循环(提交永远不会是它自己的祖先,它的任何祖先提交都不会将其作为父提交链接到它)。

一个图表,其中 6 个框排列成 2 行 3 列;第一行上的每个框都标有 5 位 SHA1 前缀,子标签为“提交”,元数据为“树”和“父”,两者都带有 5 位 SHA1 前缀 - 每次都不同 - “作者”和“提交者”的值均为“Scott”,还有一些表示提交消息的文本;左侧的框没有“父”值,另外两个框的“父”值为左侧框的 SHA1;这些框之间有一个指向左侧的箭头,代表“父”;顺便说一下,左侧的框具有与上图中的提交框相同的 SHA1 和相同的内容;最后,每个提交框还指向其下方的框,每个框标记为“快照 A”、“快照 B”等,可能代表从每个提交链接的“树”对象。

提交及其父母(来源:Pro Git

引用、分支和标签

现在,SHA1 哈希值对于人类来说已经不切实际了,虽然 Git 允许我们使用唯一的 SHA1 前缀而不是完整的 SHA1 哈希值,但我们需要更简单的名称来引用我们的提交:输入引用。这些是我们自己选择的提交标签(而不是 Git 的标签)。

参考文献有以下几种类型

  • 分支移动引用(请注意main,或master并不特殊,它们的名称只是一种约定)
  • 标签不可变的引用
  • HEAD是指向当前提交 (commit)的特殊引用。它通常指向一个分支,而不是直接指向一个提交 (我们稍后会解释原因)。当一个引用指向另一个引用时,这被称为符号引用 (symbolic referencing )。
  • Git 在某些操作过程中会为你设置其他特殊引用(FETCH_HEAD、等)ORIG_HEAD

一个包含 9 个框的图表;其中 6 个框的排列方式与上图相同,并带有相同的标签(三个提交及其 3 棵树);最右边(最新)提交上方的两个框带有指向它的箭头,分别标记为“v1.0”和“master”;最后一个框位于“master”框上方,带有指向它的箭头,标记为“HEAD”。

分支及其提交历史(来源:Pro Git

三个州

在 Git 仓库中工作时,您操作并记录在 Git 历史记录中的文件位于您的工作目录 (working directory)中。要创建提交,您需要将文件暂存到索引 (index)暂存区 (staging area)。完成后,您可以附加提交消息,并将暂存的文件移动到历史记录 (history)中。

为了关闭循环,工作目录将从历史记录中的给定提交进行初始化

具有 3 个参与者的序列图:“工作目录”、“暂存区”和“.git 直接窥探(存储库)”;有一个从“.git 目录”到“工作目录”的“检出项目”消息,然后从“工作目录”到“暂存区”的“暂存修复”,最后从“暂存区”到“.git 目录”的“提交”。

工作树、暂存区和 Git 目录(来源:Pro Git

忽略文件

并非所有文件都需要跟踪其历史记录:由您的构建系统生成的文件(如果有)、特定于您的编辑器的文件以及特定于您的操作系统或其他工作环境的文件。

Git 允许定义要忽略的文件或目录的命名模式。这并不意味着 Git 会忽略它们,也无法跟踪它们,而是说,如果它们没有被跟踪,一些 Git 操作将无法显示它们或对其进行操作(但你可以手动将它们添加到历史记录中,从此它们将不再被忽略)。

忽略文件是通过将文件的路径名(可能使用 globs)放入忽略文件中来完成的:

  • .gitignore存储库中任何位置的文件都定义了包含目录的忽略模式;这些忽略文件会在历史记录中被跟踪,以便在开发人员之间共享它们;在这里您将忽略由构建系统生成的那些文件(build/对于 Gradle 项目、_site/对于 Eleventy 网站等)
  • .git/info/excludes位于您机器上的存储库本地;很少使用,但有时很有用,所以了解一下
  • 最后~/.config/git/ignore是针对机器的全局文件(对于您的用户而言);在这里,您将忽略特定于您的机器的文件,例如特定于您使用的编辑器的文件,或特定于您的操作系统的文件(例如.DS_Store在 macOS 或Thumbs.dbWindows 上)

总结

以下是所有这些概念的另一种表示:

一个包含 10 个框的图表;5 个框在中心排列成一条线,标有 5 位 SHA1 前缀,框之间有从右到左的箭头;一个注释将它们描述为“提交对象,由 SHA-1 哈希值标识”,另一个注释将其中一个箭头描述为“子指向父”;一对框(看起来像一个水平分成两个框的单个框)位于最右边(最新)提交的上方,有一个指向该提交的向下箭头,该对的上方框标记为“HEAD”,描述为“对当前分支的引用”;下方框标记为“main”,描述为“当前分支”;第七个框位于另一个提交的上方,有一个指向该提交的向下箭头;它标记为“稳定”,描述为“另一个分支”;最后两个框位于提交历史记录下方,一个在另一个之上;最底部的框标记为“工作目录”,并描述为“您‘看到’的文件”,它和提交历史记录之间的另一个框标记为“阶段(索引)”,并描述为“下次提交的文件”。

提交、引用和区域(来源:Visual Git 参考,Mark Lodato)

基本操作

这是我们开始讨论 Git 命令以及它们如何与图表交互的地方:

  • git init初始化新的存储库
  • git status获取文件状态摘要
  • git diff显示工作目录、索引HEAD、或任何提交之间的任何更改
  • git log显示和搜索您的历史记录
  • 创建提交
    • git add将文件添加到索引
    • git commit将索引转换提交(添加提交消息
    • git add -p以交互方式将文件添加到索引中:逐个文件、逐部分(称为)地选择要添加的更改以及仅将哪些更改保留在工作目录中
  • 管理分支机构
    • git branch显示分支,或创建分支
    • git switch(也git checkout)将分支(或任何提交,实际上是任何)检出到您的工作目录
    • git switch -b(也git checkout -b)作为git branch和的快捷方式git switch
  • git grep搜索你的工作目录、索引或任何提交;这是一种增强的grep -R,可以感知 Git
  • git blame了解最后一次修改给定文件每一行的提交(那么,谁应该为错误负责)
  • git stash将未提交的更改放在一边(包括暂存文件以及工作目录中的跟踪文件),然后取消存储它们。

提交、分支切换和 HEAD

当你使用 创建一个提交时git commit,Git 不仅会创建提交对象,还会将 移动到HEAD指向它的位置。如果HEAD指向一个分支(通常情况下),Git 会将该分支移动到新的提交(并HEAD继续指向该分支)。当当前分支是另一个分支的祖先(该分支指向的提交也是另一个分支的一部分)时,提交操作也会移动HEAD相同的提交,并且分支将分叉

当您切换到另一个分支(使用git switchgit checkout)时,HEAD将移动到新的当前分支,并且您的工作目录和索引将设置为类似于该提交的状态(未提交的更改暂时保留;如果 Git 无法做到这一点,它将拒绝切换)。

欲了解更多详情和可视化展示,请参阅Mark Lotato 所著《A Visual Git Reference》的提交签出部分(请注意,该参考资料写于多年前,当时Git还不存在,我们只有 Git 版本;因此签出部分涵盖的内容会比预期更多)。 当然,《Pro Git》一书也是一本不错的可视化展示参考资料;其中的“分支概述”子章节涵盖了上述所有内容的大部分内容。git switchgit restoregit checkoutgit switch

附言:Git 比较保守

正如我们上面所见,由于 Git 采用内容寻址存储,对提交的任何“更改”(git commit --amend例如,使用 )实际上都会导致不同的提交(不同的 SHA1)。旧的提交不会立即消失:Git 会使用垃圾回收机制最终删除那些无法从任何引用访问到的提交。这意味着,如果你设法找回提交的 SHA1(git reflog这里可以提供帮助,或者使用 符号<branch-name>@{<n>},例如,指向更改之前指向的main@{1}最后一个提交main),许多错误都可以被恢复。

使用分支

上文我们已经了解了分支是如何分叉的。
但分叉最终需要将更改合并回去(使用git merge)。Git 在这方面非常擅长(我们稍后会看到)。

合并的一个特殊情况是当前分支是要合并到的分支的祖先。在这种情况下,Git 可以执行快进合并

由于两个分支之间的操作通常都指向同一对分支,Git 允许你设置一个分支来跟踪另一个分支。该分支将被称为跟踪它的分支的上游。设置后,它会告诉你两个分支之间的差异程度:当前分支是否与其上游分支保持同步,是否落后于上游分支并且可以快进,是否领先于上游分支一定数量的提交,或者它们是否已经各自存在一定数量的提交差异。其他命令会使用这些信息为参数提供合适的默认值,以便可以省略它们。git status

要整合来自另一个分支的更改,而不是合并,另一种选择是挑选(使用同名命令)单个提交,而不考虑其历史记录:Git 将计算该提交带来的更改并将相同的更改应用到当前分支,创建一个类似于原始提交的新提交(如果您想了解有关 Git 实际如何执行此操作的更多信息,请参阅 Julia Evans 的《如何使用三向合并进行 git cherry-pick 和 revert》)。

最后,工具箱中的另一个命令是rebase
你可以将它视为一种一次性执行多次 Cherry-Pick 操作的方法,但它实际上功能更强大(我们将在下文中看到)。它的基本用法如下:你给它一个提交范围(从任意提交作为起点,到现有分支作为终点,默认为当前分支)和一个目标,它会在目标之上 Cherry-Pick 所有这些提交,并最终更新作为终点的分支。此处命令的形式为git rebase --onto=<target> <start> <end>。与许多 Git 命令一样,参数可以省略,并具有默认值和/或特定含义:因此,当前分支上游的简写(我在这里忽略git rebase的影响很微妙,在日常使用中并不重要),它本身是必须指向一个分支)的简写,它本身是的简写,是的简写,并且将重新设置一方面的最后一个共同祖先和当前分支与另一方面的当前分支(即自它们分叉以来的所有提交)之间的所有提交,并将它们重新应用到之上,然后更新当前分支以指向新的提交。实际上,明确使用(使用与起点不同的值)的情况很少见,有关一个用例,请参阅我之前的帖子git rebase --fork-point upstreamupstream--fork-pointgit rebase upstream HEADHEADgit rebase --onto=upstream upstream HEADgit rebase --onto=upstream $(git merge-base upstream HEAD) HEADupstreamupstream--onto

我们不得不介绍git rebase它的交互式版本git rebase -i:它的初始行为与非交互式版本完全相同,但在计算出需要执行的操作后,它将允许您对其进行编辑(以编辑器中的文本文件形式,每行一个操作)。默认情况下,所有选定的提交都是精选的,但您可以重新排序,跳过某些提交,甚至将某些提交合并为一个提交。您实际上可以精选最初未选中的提交,甚至可以创建合并提交,从而完全重写整个历史记录!最后,您还可以在提交上停下来进行编辑使用git commit --amendthen,和/或在继续进行变基之前创建新的提交),和/或在两次提交之间运行给定的命令。最后一个选项非常有用(例如,验证您在历史记录的每个点都没有破坏您的项目),您可以在选项中传递该命令--exec,Git 将在每个重新定基的提交之间执行它(这也适用于非交互式重新定基;在交互模式下,当您能够编辑重新定基方案时,您将看到在每个 cherry-pick 行之间插入执行行)。

有关更多详细信息和可视化表示,请参阅Mark Lodato 的《Git 可视化参考》中的合并挑选变基部分,以及《Pro Git》一书中的“基础分支与合并”“变基”“重写历史记录”子章节。您还可以查看 David Drysdale 的《Git 可视化参考》 中的“分支与合并”图表

与他人合作

目前,我们只在本地仓库中工作。
但 Git 专为与其他仓库协作而设计。

让我来介绍一下遥控器

遥控器

当您克隆一个存储库时,该存储库将成为本地存储库的远程origin存储库,名为(就像分支一样main,这只是默认值,名称本身没有什么特殊之处,除了有时在省略命令参数时用作默认值)。然后,您将开始工作,创建本地提交和分支(因此从远程分叉),同时远程可能还会从其作者那里获得更多提交和分支。因此,您需要将这些远程更改同步到本地存储库,并希望快速了解与远程相比您在本地所做的更改。Git 处理这个问题的方式是通过在一个特殊的命名空间中记录它所知道的远程(主要是分支)的状态:refs/remote/。这些被称为远程跟踪分支。Fwiw,本地分支存储在refs/heads/命名空间中,标签存储在中refs/tags/(远程的标签通常直接导入refs/tags/到中,因此例如您会丢失它们来自哪里的信息)。您可以根据需要拥有任意数量的遥控器,每个遥控器都有一个名称。 (请注意,远程不一定位于其他机器上,它们实际上可以位于同一台机器上,直接从文件系统访问,因此您无需设置任何东西即可使用远程。)

获取

每当您从远程仓库获取git fetch(使用、git pullgit remote update)时,Git 都会通知远程仓库下载它尚不知道的提交,并更新远程仓库的远程跟踪分支。需要获取的引用的确切集合及其获取位置将传递给git fetch命令(作为refspecs),默认值则定义在仓库的 中.git/config,默认情况下由git clone或配置git remote add为获取所有分支(远程仓库中的所有内容refs/heads/)并将其放入refs/remote/<remote>(远程仓库中的 so refs/remote/origin/origin,并使用相同的名称(远程refs/heads/main仓库中的 so 变为refs/remote/origin/main本地仓库中的 so)。

带有 3 个大框的图表,代表机器或存储库,其中包含代表提交历史记录的小框和箭头;一个框标记为“git.outcompany.com”,子标记为“origin”,包含名为“master”的分支中的提交;另一个框标记为“git.team1.outcompany.com”,子标记为“teamone”,包含名为“master”的分支中的提交;提交的 SHA1 哈希值在“origin”和“teamone”中相同,但“origin”在其“master”分支上多一个提交,即“teamone”位于“后面”;第三个框标记为“我的电脑”,它包含与其他两个框相同的提交,但这次分支名为“origin/master”和“teamone/master”;它还包括名为“master”的分支中的另外两个提交,与远程分支的早期点不同。

远程和远程跟踪分支(来源:Pro Git

然后,您将使用与分支相关的命令将远程跟踪分支中的更改发布到本地分支(git mergegit rebase),或者git pull只不过是 后面git fetch跟着git merge或 的简写git rebase顺便说一句,在很多情况下,当您创建远程跟踪分支时,Git 会自动将远程跟踪分支设置为本地分支的上游(当发生这种情况时,它会告诉您)。

推动

要与他人共享你的更改,他们可以将你的仓库添加为远程仓库并从中拉取(这意味着可以通过网络访问你的机器),或者你可以将更改推送到远程仓库。(如果你请求某人从你的远程仓库拉取更改,这被称为……拉取请求,你可能在 GitHub 或类似服务上听说过这个术语。)

推送与获取类似,只是操作相反:你将提交发送到远程,并更新其分支以指向新的提交。出于安全考虑,Git 仅允许快速转发远程分支;如果要以非快速转发的方式推送更新远程分支的更改,则必须强制执行此操作,使用git push --force-with-lease(或git push --force,但要小心:--force-with-lease将首先确保远程跟踪分支与远程分支保持同步,以确保自上次获取以来没有人将更改推送到该分支不会--force执行该检查,而是按照你的指示执行,风险自负)。

与 一样git fetch,你需要将要更新的分支传递给git push命令,但如果你不指定,Git 会提供良好的默认行为。如果你不指定任何内容,Git 会从当前分支的上游推断远程分支,因此大多数情况下git push它相当于git push origin。这实际上是 的简写git push origin main(假设当前分支是main),它本身是 的简写git push origin main:main,是 的简写git push origin refs/heads/main:refs/heads/main,意思是将本地分支推送到refs/heads/main远程origin分支的refs/heads/main。请参阅我之前的文章,了解一些使用不同源和目标指定引用规范的用例

表示“git push”命令的图表,有四个 git graph 图(点,一些带标签,通过线连接)排列成两行两列;列之间的箭头表示左列是“之前”状态,右列是“之后”状态;上面一行上的图位于云内,表示远程存储库,并且有两个分支“master”和“other”,它们由共同的祖先分支而来;左下图的形状与上面的图相同,只是标签更改为“origin/master”和“origin/other”,并且每个分支都有更多提交:“master”分支与“origin/master”相比有两个额外的提交,“other”比“origin/other”多一个提交;右上图的“master”分支与左上图相比有两个提交;右下图与左下图相同,只是“origin/master”现在指向与“master”相同的提交;换句话说,在“之前”状态下,远程缺少三个提交,而在“git push”之后,来自本地“master”分支的两个提交被复制到远程,而“其他”则保持不变。

git push(来源:Git 视觉参考,David Drysdale)

有关更多详细信息和视觉表示,请参阅Pro Git书中的远程分支使用远程为项目做贡献子章节,以及 David Drysdale 的Git 可视化参考中的“处理远程存储库”图表。Pro Git为项目做贡献 章节还涉及在 GitHub 等平台上为开源项目做贡献,在这些平台上,您必须先分叉存储库,然后通过拉取请求(或合并请求)进行贡献。

最佳实践

这些都是针对初学者的,希望不会有太多争议。

尝试保持干净的历史记录:

  • 明智地使用合并提交
  • 清晰且高质量的提交信息(请参阅Pro Git中的提交指南
  • 进行原子提交:每个提交都应该独立于历史记录中其后的提交进行编译和运行

这仅适用于你与他人分享的历史记录。
在本地,你想怎么做就怎么做。不过,对于初学者,我给出以下建议:

  • 不要直接在main(或master,或任何您在远程上不明确拥有的分支)上工作,而是创建本地分支;它有助于分离不同任务上的工作:在等待当前分支上指令的更多详细信息的同时,是否要开始处理另一个错误或功能?切换到另一个分支,稍后您将通过切换回来回到该分支;它还可以更轻松地从远程进行更新,因为如果您的本地分支只是同名远程分支的副本,而没有任何本地更改(除非您想将这些更改推送到该分支),则您确定不会发生冲突
  • 不要犹豫重写你的提交历史(git commit --amend和/或git rebase -i),但不要太早;在工作时堆叠许多小提交是完全可以的,并且只在共享之前重写/清理历史记录
  • 同样,不要犹豫重新调整本地分支以集成上游更改(直到您共享该分支,此时您将遵循项目的分支工作流程)

如果遇到任何问题,不知所措,我建议使用gitkgitk HEAD @{1},当然也可以gitk --all(我gitk这里用的是,但你可以根据自己的喜好选择工具)来可视化你的 Git 历史记录,并尝试了解发生了什么。这样,你可以回滚到之前的状态(git reset @{1})或尝试修复问题(例如,挑选一个提交)。如果你正在执行 rebase 操作,或者合并失败,你可以使用git rebase --abort或 之类的命令中止并回滚到之前的状态git merge --abort

为了让事情变得更简单,在执行任何可能造成破坏的命令( )之前,请毫不犹豫地git rebase创建一个分支或标签作为“书签”,以便在事情进展不顺时轻松重置。当然,执行此类命令后,请检查历史记录和文件,以确保结果符合您的预期。

高级概念

这只是其中的几个,还有更多等待探索!

  • 分离HEADgit checkout手册页上有关于该主题的很好的部分,另请参阅我之前的帖子,并且为了获得良好的视觉表现,请参阅Mark Lodato 的A Visual Git Reference中的Committing with a Detached HEAD部分。
  • 钩子:这些是可执行文件(大多数时候是 shell 脚本),Git 将在对存储库的操作做出反应时运行它们;人们使用它们在每次提交之前检查代码(如果失败则中止提交)、生成或后处理提交消息,或者在有人推送到存储库后触发服务器上的操作(触发构建和/或部署)。
  • 一些很少用到的命令可以在您真正需要的时候节省您的时间:
    • git bisect:一个高级命令,通过测试多个提交(手动或通过脚本)来帮助您查明哪个提交引入了错误;对于线性历史记录,这是使用二分法并且可以手动完成的,但是一旦您有许多合并提交,这就会变得更加复杂,最好完成git bisect繁重的工作。
    • git filter-repo:实际上是一个第三方命令,可以替代 Git 自己的命令filter-branch,它允许重写存储库的整个历史记录以删除错误添加的文件,或者帮助将存储库的一部分提取到另一个存储库。

我们完成了。

有了这些知识,人们应该能够将任何 Git 命令映射到它将如何修改提交的有向无环图,并了解如何修复错误(在错误的分支上运行合并?在错误的分支上重新建立?)我并不是说理解这些事情很容易,但至少应该是可能的。

文章来源:https://dev.to/tbroyer/how-i-teach-git-3nj3
PREV
如何在 React 中使用 Storybook
NEXT
GraphQL 与 REST 对比——结论