Git-精要第二版-全-

Git 精要第二版(全)

原文:annas-archive.org/md5/caf5bec21eb385196206b5dfe6836df5

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

如果你正在阅读本书,你可能是一个软件开发人员和专业人士。那么,什么让一个专业人士成为优秀的呢?他的文化和经验,当然,但不仅仅如此——一个好的专业人士是一个掌握多种工具的人,能够为任务选择最佳工具,并且具有必要的纪律性来养成良好的工作习惯。

版本控制是开发人员的基本技能之一,而 Git 是适合这项工作的工具之一。然而,Git 不是螺丝刀那种只有基本功能的简单工具;Git 提供了一个完整的工具箱来管理你的代码,其中也有一些尖锐的工具,需要小心使用。

本书的最终目的是帮助读者以最安全的方式开始使用 Git 及其命令,顺利完成任务而不出错。话虽如此,如果你没有养成正确的习惯,你将无法从 Git 命令中获得最大收益;就像其他工具一样,最终决定差异的是使用工具的人。

这是一本需要在电脑前阅读的书;与第一版相比,书中包含了更多的命令、示例和练习来进行测试;在前四章中,我们将通过实践来学习。

本书将涵盖所有基本的 Git 主题,允许读者即使没有版本控制系统的经验也能开始使用 Git;他们只需要了解版本控制的一般知识,因此阅读相关的维基百科页面就足够了。

本书内容简介

第一章,Git 入门,向读者展示了安装 Git 并进行第一次提交所需的所有(简单)步骤。

第二章,Git 基础 - 本地工作,讨论了本地工作如何揭示 Git 的本质,它是如何管理你的文件的,以及你如何管理和组织代码。

第三章,Git 基础 - 远程工作,讲解了远程工作如何将注意力转向工具的协作方面,逐一解释了与远程仓库协作时使用的基本命令和选项。

第四章,Git 基础 - 小众概念、配置和命令,集中讲解小众概念和命令,完善你需要掌握的 Git 基本命令集,给予读者在困难情境下使用的更多工具。

第五章, 获取最佳 - 良好的提交和工作流,为读者提供了一些关于如何在 Git 中组织源代码的常见方法,帮助他们培养每个开发者都应该养成的良好习惯。

第六章,迁移到 Git,带你了解如何帮助那些使用其他版本控制系统(如 Subversion)的开发者,顺利过渡到 Git。

第七章,Git 资源,根据作者的个人经验提供了一些可能对读者有兴趣的提示。

本书所需条件

为了跟随本书中的示例并进行一些 Git 实践,您只需要一台计算机和有效的 Git 安装。Git 适用于所有平台(Linux、Windows 和 macOS),并且可以免费使用。

示例基于写作时最新版本的 Git for Windows,即 2.11.0。

本书适用人群

本书主要面向开发人员。此书不要求特定编程语言的经验,也不需要广泛的软件开发经验。如果您已经使用其他版本控制系统,您将能够轻松阅读本书,但即使这是您第一次使用版本控制工具,只要您至少了解版本控制的基础知识,您也能理解。

约定

在本书中,您将看到多种文本样式,以区分不同类型的信息。以下是这些样式的示例,以及它们的含义解释。

任何命令行输入或输出如下所示:

$ git log --oneline

任何命令行输入或输出如下所示:

$ mkdir css $ cd css

新术语重要词汇以粗体显示,而重要句子则以斜体显示。

警告或重要提示会以这种框的形式出现。

提示框如下:

Git log是您的最佳朋友:每当您需要查看仓库历史时,使用它。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对本书的看法——您喜欢或不喜欢的内容。读者的反馈对我们非常重要,因为它帮助我们开发出您真正能从中受益的书籍。如果您想向我们发送一般反馈,请简单地通过电子邮件发送至feedback@packtpub.com,并在邮件主题中注明书名。如果您在某个领域有专业知识,并且有兴趣撰写或贡献一本书,请参见我们的作者指南:www.packtpub.com/authors

客户支持

现在,作为 Packt 书籍的骄傲拥有者,我们为您提供了多项服务,帮助您最大程度地利用您的购买。

下载本书的彩色图片

我们还为您提供了一份 PDF 文件,里面包含了本书中使用的截图/图表的彩色图片。彩色图片将帮助您更好地理解输出结果的变化。您可以从www.packtpub.com/sites/default/files/downloads/GitEssentialsSecondEdition_ColorImages.pdf下载该文件。

勘误

尽管我们已尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在我们的书籍中发现错误——可能是文本或代码中的错误——我们会感激你能向我们报告。这样,你可以帮助其他读者避免困扰,并且帮助我们改进后续版本。如果你发现任何勘误,请访问www.packtpub.com/submit-errata报告,选择你的书籍,点击“勘误提交表格”链接,并填写勘误详情。一旦你的勘误被验证,提交将被接受,勘误会被上传到我们的网站或添加到该书籍的勘误列表中。要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索框中输入书名。所需信息将在勘误部分显示。

盗版

网络盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视版权和许可的保护。如果你在互联网上发现任何形式的非法复制品,请立即提供位置地址或网站名称,以便我们采取措施。请通过copyright@packtpub.com联系我们,并附上可疑盗版材料的链接。感谢你帮助我们保护作者和我们提供有价值内容的能力。

问题

如果你在本书的任何方面遇到问题,可以通过questions@packtpub.com联系我们,我们会尽力解决问题。

第一章:Git 入门指南

无论你是专业开发者还是业余爱好者,你可能都听说过版本控制的概念。你可能知道,添加新功能、修复破损的功能或者回溯到以前的状态是日常工作的一部分。

这需要使用一个强大的工具来帮助你管理工作,使你能够快速而无摩擦地在项目中移动。

市场上有很多此类工具,既有专有的也有开源的。通常,你会找到版本控制系统VCS)和分布式版本控制系统DVCS)。一些集中式工具的例子有并发版本系统CVS)、SubversionSVN)、Team Foundation ServerTFS)和Perforce Helix。而在分布式系统中,你可以找到BazaarMercurialGit。两者的主要区别在于,集中式系统中必须有一个远程服务器用于获取和存放文件;不用说,如果网络断开,你就麻烦了。而在分布式系统中,你可以有或者没有远程服务器(甚至可以有多个),但你也可以离线工作。所有的修改都会被本地记录,因此你可以在其他时间同步它们。如今,Git 是获得更多公众青睐的 DVCS,迅速从一个小众工具发展为主流。

Git 作为事实上的源代码版本控制工具迅速成长起来。它是Linus Torvalds的第二个著名作品,他在创建Linux内核之后,打造了这个版本控制软件来跟踪他数百万行代码的变化。

在本章中,我们将从最基础的开始,假设你的机器上还没有安装 Git。这本书面向那些从未使用过 Git 或者只是略微使用过的开发者,但他们害怕全身心投入其中。

如果你从未安装过 Git,那么这是你的章节。如果你已经有一个工作正常的 Git 环境,你可以快速浏览一下以确保一切正常。

第二版前言

欢迎来到 Git Essentials 的第二版!

这一段专为已经阅读过第一版的人士准备;在这里,你将了解到这个全新版本中的变化和新内容的概述。

首先,我们听取了你们的反馈:在第二章 Git 基础 - 本地操作 和 第三章 Git 基础 - 远程操作 中,我们将更深入地探讨一些技术细节,更准确地描述 Git 的内部工作原理;这需要读者额外的努力,但作为回报,他或她将获得对 Git 架构更加深刻的理解,从而有助于掌握这个强大工具的命令。

自 2015 年 4 月以来,Git 生态系统取得了巨大进步,但 Git 的核心始终不变。以下是一些新增功能和改进的未完整列表:

  • 对 Windows 的重大改进(例如,完整的凭证子系统、性能提升等——参见github.com/git-for-windows/git)。

  • Git 大文件存储(LFS)——来自 GitHub 的附加工具(参见git-lfs.github.com)。

  • 微软同仁提供的 Git 虚拟文件系统(参见github.com/Microsoft/GVFS)。

  • git worktree命令和功能。工作树是 Git 2.5 中首次引入的一个功能,它让你可以在不同目录中同时检出并处理多个仓库分支——参见git-scm.com/docs/git-worktree

  • 许多常用命令得到了改进和新增了选项,改进内容太多,无法一一列举。

所以这本书的目标是让你开始使用版本控制,并学习如何熟练地操作它。

开始吧!

安装 Git

Git 是开源软件。你可以从git-scm.com免费下载安装,在那里你可以找到适用于所有常见环境(GNU-Linux、macOS 和 Windows)的安装包。写本书时,Git 的最新版本是 2.11.0。

在 GNU-Linux 上安装 Git

如果你是 Linux 用户,你可能已经预装了 Git。

如果没有,你可以使用发行版包管理器来下载和安装它;apt-get install git命令或等效命令会在几秒钟内为你安装 Git 以及所有必要的依赖,如下图所示:

在 Kubuntu 上安装 Git

在 macOS 上安装 Git

在 macOS 上安装 Git 有几种方式。最简单的方式是安装Xcode 命令行工具。从Mavericks10.9)开始,你只需要第一次在终端中尝试运行git。如果尚未安装,它会提示你进行安装,如下图所示:

点击安装按钮将开始安装过程。

如果你想要一个更新版本,也可以通过*.dmg二进制安装程序进行安装,该安装程序可以从git-scm.com下载(文件名中有mavericks,但可以忽略这个)。安装从互联网下载的软件包时,请留意 macOS 的安全策略;要允许执行,你需要按住CTRL并点击包图标来打开它:

按住 CTRL 并点击,让 macOS 提示你打开包文件

安装完成后,安装过程非常简单——只需点击继续按钮并按照下面的截图中的步骤操作:

开始安装过程

点击继续按钮,然后继续;如以下图片所示的窗口将会出现:

在这里,你可以更改安装位置,如果不确定,可以直接点击安装

现在点击安装按钮以开始安装。几秒钟后,安装将完成:

安装完成

在 Windows 上安装 Git

当你点击git-scm.com上的Download按钮时,你将自动下载x86x64版本的 Git。我不会详细介绍安装过程本身,因为它非常简单;我只会在以下截图中提供一些建议:

启用 Windows 资源管理器集成通常是有用的;通过右键点击上下文菜单,你可以在任何文件夹中方便地打开 Git 提示符。

你还应该启用 Git 在经典的 DOS 命令提示符下使用,如下图所示:

Git 提供了一个内嵌的、与 Windows 兼容的著名Bash shell(参见 en.wikipedia.org/wiki/Bash_(Unix_shell)),我们将广泛使用它。通过这样做,我们还将使 Git 可供第三方应用程序使用,如 GUI 等。当我们尝试一些 GUI 工具时,它将派上用场。

对行尾使用默认设置。这将避免你在处理多平台仓库时遇到的未来烦恼:

现在是选择 Git 终端模拟器的时候了;我推荐使用MinTTY(参见 mintty.github.io),因为它是一个非常好的 shell,完全可定制且用户友好:

现在,让我们来看一下最新 Git 版本中的一些新特性——即文件系统缓存、Git 凭据管理器和符号链接:

文件系统缓存在 Git for Windows v2.7.4(2016 年 3 月 18 日)之前被认为是实验性的,但现在它已稳定,并且自 Git 2.8 起默认启用。这是一个仅适用于 Windows 的配置选项,允许 Git 在处理底层读写操作时更快。我建议你启用此功能以获得最佳性能。

Git 凭证管理器(见github.com/Microsoft/Git-Credential-Manager-for-Windows)自 v2.7.2 版(2016 年 2 月 23 日)起已包含在 Git for Windows 安装程序中。多亏了微软,现在你可以像在其他平台上一样轻松处理 Git 用户和密码。它要求.NET 框架 v4.5 或更高版本,并且可以完美集成到 Visual Studio(见www.visualstudio.com/)和 GitHub for Windows(见desktop.github.com)图形界面中。我建议你启用它,因为它在工作时节省了一些时间。

符号链接是 Windows 从一开始就缺失的功能,即便在 Windows Vista 引入该功能时,它们也暴露了许多与 Unix-like 符号链接的不兼容问题。

无论如何,Git 及其 Windows 子系统可以处理这些(有一些限制),所以如果需要,你可以尝试安装此功能并在配置选项中启用它(默认情况下是禁用的)。不过现在,最好的做法是,如果你需要在 Windows 平台上工作,最好完全不要在你的仓库中使用它们。你可以在github.com/git-for-windows/git/wiki/Symbolic-Links找到更多信息。

Git for Windows v2.10.2(2016 年 11 月 2 日)引入了一个新的内置 difftool,承诺能更快地进行差异比较。我每天都在使用它,觉得它相当稳定且快速。如果你想尝试,可以启用它,但本书的目的并不要求必须使用它。

Git for Windows 将把它安装在默认的Program Files文件夹中,就像所有 Windows 程序一样。

在整个过程中,我们将安装 Git,并且所有它的*nix伙伴将准备好使用它。

请注意查看发布说明,以了解最新版本的新增内容。

运行我们的第一个 Git 命令

从现在开始,为了方便起见,我们将以 Windows 作为参考平台。我们的截图将始终以该平台为准。无论如何,我们将使用的所有 Git 主命令都能在我们之前提到的平台上正常工作。

现在是测试我们安装的时刻。Git 准备好迎接挑战了吗?让我们来看看!

使用 Shell 集成,在桌面上的空白处右键单击,选择新的菜单项“Git Bash Here”。它将作为一个新的 MinTTY Shell 出现,为你提供一个适用于 Windows 的 Git-ready bash:

这是一个典型的 Bash 提示符:我们可以看到用户名 nando 和主机名 computer。接着是一个 MINGW64 字符串,它表示我们使用的实际平台,叫做 Minimalist GNU for Windows(请参见 www.mingw.org),最后是实际的路径,以更符合 *nix 风格的方式呈现,/c/Users/nando。稍后我们将更详细地探讨这个参数。

现在我们有了一个崭新的 Bash 提示符,只需输入 git(或者等效命令 git --help),如下面的截图所示:

如果 Git 已正确安装,输入 git 而不指定任何内容,将显示一个简短的帮助页面,列出常用命令(如果没有显示,请尝试重新安装 Git)。

所以,我们的 Git 已经启动并运行了!你激动吗?让我们开始输入命令吧!

做演示

Git 需要知道你是谁。这是因为在 Git 中,你在仓库中的每次修改都必须用作者的名字和电子邮件进行签名。所以,在做任何其他事情之前,我们必须告诉 Git 这些信息。

输入这两个命令:

使用 git config 命令,我们设置了两个配置变量——user.nameuser.email。从现在开始,Git 会在你所有的仓库中使用这些信息来签署你的提交。暂时不用担心这些;在接下来的章节中,我们会更详细地探索 Git 配置系统。

设置新仓库

第一步是设置一个新的仓库。一个 仓库 是你的整个项目的容器;其中的每个文件或子文件夹都属于该仓库,并保持一致性。物理上,一个仓库就是一个包含特殊 .git 文件夹的文件夹,这个文件夹就是魔法发生的地方。

让我们尝试创建我们的第一个仓库。选择一个你喜欢的文件夹(例如,C:\Repos\MyFirstRepo),并输入 git init 命令,如下所示:

如你所见,我稍微修改了默认的 Git Bash 提示符,以更好地满足演示命令的需求;我去除了用户和主机名,并在每个命令前添加了一个递增的编号,这样在我讲解时更便于引用,也方便你在阅读时查找。

让我们回到正题。刚刚在 MyFirstRepo 文件夹中发生了什么?Git 创建了一个 .git 子文件夹。这个子文件夹(在 Windows 中通常是隐藏的)包含了一些其他的文件和文件夹,如下一个截图所示:

在此时此刻,我们无需理解这个文件夹中的内容。你只需要知道的是,你永远不需要碰它!如果你删除它或者手动修改其中的文件,可能会导致麻烦。我吓到你了吗?

既然我们已经有了一个代码库,就可以开始将文件放入其中。Git 可以高效地跟踪任何类型文件的历史,不论是文本文件还是二进制文件,小文件还是大文件(大文件始终是个问题)。

添加文件

我们创建一个文本文件,试试看:

现在怎么办?就这样吗?不!我们还需要告诉 Git,将该文件放入你的代码库中,明确地Git 不会做任何你不希望它做的事情。如果你的代码库中有一些临时文件或备用文件,Git 不会处理它们,只会提醒你有些文件不在版本控制之下(在下一章,我们将看到如何指示 Git 在必要时忽略这些文件)。

好的,回到正题。我希望将 file.txt 纳入 Git 的管理之中,所以我们将它添加进去,如下所示:

git add 命令告诉 Git,我们希望它管理该文件,并在未来检查该文件的修改。

针对这个命令,可能会看到 Git 返回以下消息:

warning: LF will be replaced by CRLF in file.txt.

该文件将在你的工作目录中保留其原始行结尾格式。

这是由于我们在安装 Git 时选择的选项:检出 Windows 样式,提交 Unix 样式的行结尾。目前不用担心这个问题,我们稍后会处理它。

现在,让我们看看 Git 是否听从了我们的指令。

使用 git status 命令,我们可以查看代码库的状态,如下所示:

如我们所见,Git 按预期完成了它的工作。在这张图中,我们可以看到诸如 branchmastercommitunstage 等字眼。我们稍后会简单了解它们,但现在先忽略它们:这个首次实验的目的是克服我们的恐惧,并开始使用 Git 命令;毕竟,我们有整本书可以学习其中的重要细节。

提交添加的文件

到此为止,Git 已经知道 file.txt 的存在,但我们还需要执行另一个步骤,以修正其内容的快照。我们需要使用适当的 git commit 命令提交它。这一次,我们将在命令中添加一些额外内容,使用 --message(或 -m)子命令,如下所示:

通过提交 file.txt,我们终于启动了我们的代码库。在完成第一次提交(也叫做根提交,如截图所示)后,代码库现在拥有一个包含提交的 master 分支。接下来的章节中我们会玩转分支。现在,可以将它看作是代码库的路径,并记住一个代码库可以有多条交叉的路径。

修改已提交的文件

现在,我们可以尝试修改文件,并查看如何处理它,如下图所示:

如你所见,Bash shell 会警告我们有些文件已被修改,并且将修改的文件名标红。在这里,git status 命令告诉我们有一个文件进行了修改,如果我们想将该修改保存到仓库历史中,需要提交它。

然而,**no changes added to commit**是什么意思呢?很简单。Git 会让你重新审视你想要包含在下一个提交中的内容。如果你修改了两个文件,但只想提交其中一个,你可以只添加那个文件。

如果你尝试跳过 add 步骤进行提交,什么也不会发生(见下图)。我们将在下一章深入分析这种行为。

所以,让我们再次添加文件,以便为下一个提交做好准备:

好的,让我们再做一个提交,这次避免使用 --message 子命令。输入 git commit 然后按 Enter 键:

系好安全带!你现在进入了代码历史的一部分!

那是什么?它是 VimVi IMproved),一个古老而强大的文本编辑器,直到今天仍被数百万用户使用。你可以将 Git 配置为使用你自己喜欢的编辑器,但如果你没有这么做,这就是你必须面对的。Vim 很强大,但对于新手来说,使用起来可能会让人头疼。它处理文本的方式很独特。要开始输入,你必须按 I 键进入插入模式,如下图所示:

一旦你输入了提交信息,就可以按 Esc 键退出编辑模式。然后,你可以输入 :w 命令保存更改,并输入 :q 命令退出。你也可以将这两个命令合并为 :wq,正如我们在这张截图中所做的,或者使用等效的 :x 命令:

之后,按下 Enter,再一次完成提交,如下所示:

请注意,当你退出 Vim 时,Git 会自动提交,你可以在之前的截图中看到这一点。

做得好!现在,是时候回顾一下了。

总结

在这一章中,你学到 Git 并不难安装,即使是在非 Unix 平台上,如 Windows。

一旦你选择了一个目录来包含在 Git 仓库中,你会发现初始化一个新的 Git 仓库就像执行一个 git init 命令一样简单。现在,不用担心将其保存到远程服务器等问题。保存并不是强制要求的;你可以在需要时再执行保存操作,保留仓库的完整历史记录。这是 Git 和分布式版本控制系统(DVCS)的一项杀手级功能。你可以在离线状态下舒适地工作,并在网络可用时将你的工作推送到远程位置,毫不麻烦。

最后,我们发现了 Git 最重要的一个特点:如果你没有明确提到,它什么都不会做。你还学到了一点关于add命令的知识。当我们第一次将文件提交到 Git 时,必须执行 git add 命令。然后,当我们修改文件时,又使用了另一个命令。这是因为,如果你修改了一个文件,Git 默认不会将其自动添加到下次提交中(我认为它这么假设是对的)。

在下一章,我们将学习 Git 的一些基本概念。

第二章:Git 基础 - 本地工作

在本章中,我们将深入探讨 Git 的一些基础知识;了解 Git 如何处理文件,它跟踪提交历史的方式,以及我们需要掌握的所有基本命令,这些都是成为熟练使用 Git 的关键。

深入挖掘 Git 内部结构

在本版 Git 基础 的第二版中,我稍微改变了我解释 Git 工作方式的方法;这次我不再用文字和图示解释,而是通过仅使用 shell 来展示 Git 是如何内部工作的,允许你在自己的电脑上跟随这些步骤,希望这些能足够清晰,帮助你理解。

一旦你掌握了 Git 工作系统的基础,我认为剩下的命令和模式会变得更清晰,让你能够熟练地完成日常工作,在需要时解决问题。

所以,现在是时候深入了解 Git 的真正本质了;在本章中,我们将接触到这个强大工具的核心。

Git 对象

在第一章,Git 入门,我们创建了一个空文件夹(在 C:\Repos\MyFirstRepo),然后使用 git init 命令初始化了一个新的 Git 仓库。

让我们创建一个新的仓库来刷新一下记忆,然后开始学习更多关于 Git 的知识。

在这个例子中,我们使用 Git 来追踪去超市前的购物清单;所以,创建一个新的购物文件夹,然后初始化一个新的 Git 仓库:

[1] ~
$ mkdir grocery

[2] ~
$ cd grocery/

[3] ~/grocery
$ git init
Initialized empty Git repository in C:/Users/san/Google Drive/Packt/PortableGit/home/grocery/.git/

正如我们之前所见,git init 命令的结果是创建一个 .git 文件夹,Git 在这里存储它管理仓库所需的所有文件:

[4] ~/grocery (master)
$ ll
total 8
drwxr-xr-x 1 san 1049089 0 Aug 17 11:11 ./
drwxr-xr-x 1 san 1049089 0 Aug 17 11:11 ../
drwxr-xr-x 1 san 1049089 0 Aug 17 11:11 .git/ 

所以,我们可以把这个 grocery 文件夹移动到任何地方,数据不会丢失。另一个重要的点是,我们不需要任何服务器:我们可以在本地创建一个仓库,并随时使用它,甚至在没有局域网或互联网连接的情况下。只有在我们想要与他人共享仓库时,才需要它们,无论是直接共享还是通过中央服务器。

事实上,在这个例子中,我们不会使用任何远程服务器,因为这并非必要。

继续创建一个新的 README.md 文件来记住这个仓库的目的:

[5] ~/grocery (master)
$ echo "My shopping list repository" > README.md

然后将一根香蕉添加到购物清单中:

[6] ~/grocery (master)
$ echo "banana" > shoppingList.txt

此时,正如你已经知道的那样,在执行 commit 之前,我们必须将文件添加到 暂存区;使用快捷命令 git add . 将两个文件添加进去:

[7] ~/grocery (master)
$ git add .

使用这个技巧(git add 命令后的点),你可以一次性将所有新增或修改的文件添加进来。

此时,如果你没有像在第一章,Git 入门中那样设置全局的用户名和电子邮件,那么可能会发生以下情况:

[8] ~/grocery (master)
$ git commit -m "Add a banana to the shopping list"
[master (root-commit) c7a0883] Add a banana to the shopping list
Committer: Santacroce Ferdinando <san@intre.it> 
Your name and email address were configured automatically based on your username and hostname. Please check that they are accurate. 
You can suppress this message by setting them explicitly:
git config --global user.name "Your Name"
git config --global user.email you@example.com
After doing this, you may fix the identity used for this commit with:
git commit --amend --reset-author

 2 files changed, 2 insertions(+)
 create mode 100644 README.md
 create mode 100644 shoppingList.txt

首先,看看第二行,Git 会显示类似 root commit 的内容;这意味着这是你仓库的第一次提交,就像树的根(或者磁盘分区的根;或许你们这些极客能更好地理解这个比喻)。稍后我们会回到这个概念。

然后,Git 会显示一条信息:“你没有设置全局用户名和电子邮件;我使用了在你的系统中找到的配置,如果你不喜欢的话,你可以回去重新提交,使用另一组数据”。

我不喜欢在 Git 中设置全局的用户名和密码,因为我通常在不同的仓库中使用不同的用户名和电子邮件;如果我不注意,我最终可能会用我的个人资料做了工作提交,或者反过来,这非常烦人。所以,我更喜欢为每个仓库单独设置用户名和电子邮件;在 Git 中,你可以在三个层级设置配置变量:仓库级别(使用 --local 选项,默认选项)、用户级别(使用 --global 选项)和 系统级别(使用 --system 选项)。稍后我们会进一步学习配置内容,但现在你只需要知道这些就可以继续操作。

那么,让我们来更改这些设置并修改我们的提交(修改提交是一种重新做上次提交并修正一些小错误的方法,比如添加一个遗忘的文件、更改提交信息或作者,正如我们接下来要做的那样;稍后我们会详细学习这是什么意思):

[9] ~/grocery (master)
$ git config user.name "Ferdinando Santacroce"

[10] ~/grocery (master)
$ git config user.email ferdinando.santacroce@gmail.com

因为我没有指定配置级别,所以这些参数将在 仓库级别 设置(也就是 --local);从现在开始,我在这个仓库中做的所有提交都会由 "Ferdinando Santacroce" 签名,电子邮件为 ferdinando.santacroce@gmail.com(现在你知道怎么联系我了,万一有需要的话)。

现在是时候输入这个命令了,git commit --amend --reset-author。当以这种方式修改提交时,Git 会打开默认编辑器,让你更改提交信息(如果你愿意的话);正如我们在第一章《Git 入门》中所看到的,在 Windows 中,默认的编辑器是 Vim。为了这个练习,请保持信息不变,按 Esc 键,然后输入 :wq(或 :x)命令并按 Enter 键保存并退出:

[11] ~/grocery (master)
$ git commit --amend --reset-author #here Vim opens
[master a57d783] Add a banana to the shopping list
 2 files changed, 2 insertions(+)
 create mode 100644 README.md
 create mode 100644 shoppingList.txt

好的,现在我已经有了一个正确的作者和电子邮件的提交。

提交

现在是时候开始研究提交了。

要验证我们刚刚创建的提交,我们可以使用 git log 命令:

[12] ~/grocery (master)
$ git log
commit a57d783905e6a35032d9b0583f052fb42d5a1308
Author: Ferdinando Santacroce <ferdinando.santacroce@gmail.com>
Date:   Thu Aug 17 13:51:33 2017 +0200

Add a banana to the shopping list

如你所见,git log 显示了我们在这个仓库中所做的提交;git log 会按时间倒序显示所有的提交;目前我们只有一个提交,但接下来我们会看到这一行为是如何在实际操作中体现的。

哈希值

现在是时候分析提供的信息了。第一行包含了提交的SHA-1en.wikipedia.org/wiki/SHA-1),这是一个包含 40 个字符的字母数字序列,表示一个十六进制数字。这个代码,或通常称为哈希,唯一标识了仓库中的提交,正是由于它,从现在起我们可以通过它执行一些操作来引用该提交。

作者和提交创建日期

我们在前几段已经谈到了作者;作者是执行提交的人,日期是提交生成时的完整日期。自那时起,这个提交成为了仓库的一部分。

提交消息

在作者和日期下方,空一行后,我们可以看到附加到我们所做提交上的消息;实际上,消息本身也是提交的一部分。

但还有更多的东西,我们来尝试使用git log命令并加上--format=fuller选项:

[13] ~/grocery (master)
$ git log --format=fuller
commit a57d783905e6a35032d9b0583f052fb42d5a1308
Author: Ferdinando Santacroce <ferdinando.santacroce@gmail.com>
AuthorDate: Thu Aug 17 13:51:33 2017 +0200
Commit: Ferdinando Santacroce <ferdinando.santacroce@gmail.com>
CommitDate: Thu Aug 17 13:51:33 2017 +0200

Add a banana to the shopping list

提交者和提交日期

除了作者外,提交还保留了提交者提交日期;与作者和作者日期相比,有什么不同呢?首先,不用太担心:在你的仓库中,99%的提交会有相同的作者和提交者信息,以及相同的日期。

在某些情况下,例如cherry-pick,你会将一个已有的提交应用到另一个分支上,形成一个全新的提交,应用之前提交的相同更改。在这种情况下,作者和作者日期将保持不变,而提交者和提交日期将与执行此操作的人以及他们执行该操作的日期相关。稍后我们将接触到这个有用的 Git 命令。

深入分析

我们分析了一个提交,并通过简单的git log获取了相关信息;但我们还不满意,接下来深入了解一下里面的内容。

再次使用git log命令,我们可以通过启用--format=raw选项来获得透视视图:

[14] ~/grocery (master)
$ git log --format=raw
commit a57d783905e6a35032d9b0583f052fb42d5a1308
tree a31c31cb8d7cc16eeae1d2c15e61ed7382cebf40
author Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1502970693 +0200
committer Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1502970693 +0200

Add a banana to the shopping list

这次输出格式有所不同;我们可以看到作者和提交者的信息,和之前看到的一样,不过是以更紧凑的形式呈现;接下来是提交信息,但有些新东西出现了:它是一个。请耐心等待,我们将在接下来的段落中讨论树。

现在我想展示另一个命令,这次稍微有点难懂;它是git cat-file -p

让我们尝试这个命令。为了使其生效,我们需要指定我们要调查的对象;在这种情况下,我们可以使用对象的哈希值,也就是我们的第一次提交。你不需要指定完整的哈希值,但对于小型仓库来说,前五到六个字符就足够了。Git 足够聪明,即使哈希值不到 40 个字符,它也能理解是什么对象;最少需要四个字符,随着仓库中 Git 对象的数量增多,这个数字会增加。举个例子,Linux 内核目前有 1500 万行代码,成千上万的已跟踪文件和文件夹;在这个 Git 仓库中[1],你需要指定 12 个字符才能获取正确的对象。

在需要时,我通常只尝试输入前五个字符;如果它们不足以让 Git 识别我需要的对象,Git 会提示我再输入一两个字符。

话题回到正题;输入命令,指定提交哈希值的前几个字符(在我的例子中是a57d7):

[15] ~/grocery (master)
$ git cat-file -p a57d7
tree a31c31cb8d7cc16eeae1d2c15e61ed7382cebf40
author Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1502970693 +0200
committer Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1502970693 +0200

Add a banana to the shopping list

好的,正如你所看到的,输出与git log --format=raw相同。

这在 Git 中并不罕见:有不同的命令和选项,最终做的是相同的事情;这是 Git 的一个常见特性,源于其多年来的有机发展。Git 不断地变化(和更改),因此开发人员在引入新命令时必须保证一定的向后兼容性;这就是其中的一个副作用。

我引入这个命令只是为了有机会介绍 Git 的另一个特点,即瓷器命令管道命令之间的区分。

瓷器命令与管道命令

Git,如我们所知,拥有大量的命令,其中一些几乎从不被普通用户使用;例如,之前的git cat-file。这些命令被称为管道命令,而我们已经学过的命令,如git addgit commit等,则属于所谓的瓷器命令

这个比喻直接来源于 Git 之父 Linus Torvalds 的丰富想象力,与管道工有关。众所周知,管道工也负责维修厕所;在这里,Linus 指的是马桶。马桶是一个瓷器制品,使我们能够舒适地坐下;然后,通过一系列的管道和设备,它能够将我们知道的废物正确地排放到下水道系统中。

Linus 利用这个精妙的比喻,将 Git 命令分为两类:一种是高阶命令,适合用户进行常见操作(瓷器命令),另一种是内部使用的命令(但经验更丰富的用户可以根据需要使用)来执行低级操作(管道命令)。

因此,我们可以把 porcelain 命令看作是面向用户的接口命令,而 plumbing 命令则是在底层工作的。这也意味着,porcelain 命令随着时间的推移会保持更稳定(使用模式和选项随着时间的推移会更谨慎,延迟出现),因为它们是直接使用的,但也被许多图形工具、编辑器等所实现,而 plumbing 命令则一般以较少的限制进行演变。

这两类命令之间没有明确的划分,因为它们的边界通常非常活跃;我们仍然会使用它们,以更好地理解 Git 的内部运作。

现在回到主题;我们之前在讲 Git 对象。

Git 使用四种不同类型的对象,其中 commit 就是其中之一。然后还有 treeblobannotated tag。暂时不讨论 annotated tags(任何已经使用版本控制系统的人都知道标签是什么),我们专注于 blobs 和 trees。

为了方便起见,这里是之前输入的 git cat-file -p 命令的输出:

[15] ~/grocery (master)
$ git cat-file -p a57d7
tree a31c31cb8d7cc16eeae1d2c15e61ed7382cebf40
author Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1502970693 +0200
committer Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1502970693 +0200

如今我们可以理解,这个 plumbing 命令让你查看 Git 对象;使用 -p 选项(在这里意味着漂亮打印),我们让 Git 以更易读的方式显示对象的内容。

现在是时候了解在 Git 中树(tree)是什么了;事实上,在命令输出中,我们可以看到这一行:tree a31c31cb8d7cc16eeae1d2c15e61ed7382cebf40

这是什么意思?让我们一起来看看。

Trees

树(tree)是用来容纳 blobs 和其他树的容器。理解它如何工作最简单的方法是把它想象成操作系统中的文件夹,文件夹内也可以包含文件和其他子文件夹。

让我们再次使用 git cat-file -p 命令,看看这个额外的 Git 对象包含了什么:

[16] ~/grocery (master)
$ git cat-file -p a31c3
100644 blob 907b75b54b7c70713a79cc6b7b172fb131d3027d README.md
100644 blob 637a09b86af61897fb72f26bfb874f2ae726db82 shoppingList.txt

这个树,我们之前说过它是 Git 用来标识文件夹的东西,它还包含一些额外的对象,称为blobs

Blobs

如你所见,在前一个命令输出的右侧,我们有 README.mdshoppinglist.txt,这让我们猜测 Git 的 blobs 代表了文件。和之前一样,我们可以验证它的内容;让我们看看 637a0 里面是什么:

[17] ~/grocery (master)
$ git cat-file -p 637a0
banana

哇!它的内容正是我们 shoppingFile.txt 文件的内容。

为了确认,我们可以使用 cat 命令,这个命令在 *nix 系统中允许你查看文件内容:

[18] ~/grocery (master)
$ cat shoppingList.txt
banana

如你所见,结果是一样的。

Blobs 是二进制文件,没什么特别的。这些无法用肉眼解释的字节序列,内部包含了任何文件的信息,无论是二进制文件还是文本文件,图像、源代码、档案等等。所有内容在归档到 Git 仓库之前都被压缩并转化为一个 blob。

如前所述,每个文件都有一个哈希值;这个哈希值在我们的仓库中唯一标识该文件,正是通过这个 ID,Git 可以在需要时检索文件,并在文件被更改时检测到任何变化(内容不同的文件将具有不同的哈希值)。

我们说 SHA-1 哈希值是唯一的;但这意味着什么呢?

让我们通过一个例子来更好地理解它。

打开一个 shell,试着玩一下另一个低级命令git hash-object

[19] ~/grocery (master)
$ echo "banana" | git hash-object --stdin
637a09b86af61897fb72f26bfb874f2ae726db82

git hash-object命令是用来计算任何对象哈希值的低级命令;在这个例子中,我们使用了--stdin选项,将前面的命令echo "banana";的结果作为命令参数,简而言之,我们计算了字符串"banana"的哈希值,结果是637a09b86af61897fb72f26bfb874f2ae726db82

那你在你的计算机上试过了吗?结果是什么?

一点悬念... 太不可思议了,竟然是一样的!

你可以尽情地重新运行这个命令,结果的哈希值总是相同的(如果不相同,可能是因为操作系统或 shell 中不同的换行符导致的)。

这让我们明白了一件非常重要的事情:一个对象,无论它是什么,在任何仓库中,任何计算机上,地球上的任何地方,都会始终有相同的哈希值

有经验的人和聪明的人可能早就嗅到了一丝不对劲,但我希望在剩下的读者中,我能引起和我第一次做这件事时一样的惊讶。这种行为有一些有趣的影响,我们很快就会看到。

最后但同样重要的是,我想强调Git 计算的是文件内容的哈希值,而不是文件本身的哈希值;事实上,使用git hash-object计算的637a09b86af61897fb72f26bfb874f2ae726db82哈希值与我们之前使用git cat-file -p检查的 blob 是相同的。这教会我们一个重要的经验:如果你有两个内容相同的不同文件,即使它们的名称和路径不同,在 Git 中最终只有一个 blob。

更深入地 - Git 存储对象模型

好的,现在我们知道 Git 中有不同的对象,我们可以使用一些低级命令来检查它们。但 Git 是如何存储这些对象的呢?

你记得.git文件夹吗?让我们把鼻子伸进去看看:

[20] ~/grocery (master)
$ ll .git/
total 13
drwxr-xr-x 1 san 1049089   0 Aug 18 17:22 ./
drwxr-xr-x 1 san 1049089   0 Aug 18 17:15 ../
-rw-r--r-- 1 san 1049089 294 Aug 17 13:52 COMMIT_EDITMSG
-rw-r--r-- 1 san 1049089 208 Aug 17 13:51 config
-rw-r--r-- 1 san 1049089  73 Aug 17 11:11 description
-rw-r--r-- 1 san 1049089  23 Aug 17 11:11 HEAD
drwxr-xr-x 1 san 1049089   0 Aug 18 17:15 hooks/
-rw-r--r-- 1 san 1049089 217 Aug 18 17:22 index
drwxr-xr-x 1 san 1049089   0 Aug 18 17:15 info/
drwxr-xr-x 1 san 1049089   0 Aug 18 17:15 logs/
drwxr-xr-x 1 san 1049089   0 Aug 18 17:15 objects/
drwxr-xr-x 1 san 1049089   0 Aug 18 17:15 refs/

在其中,有一个objects子文件夹;让我们看一下:

[21] ~/grocery (master) 
$ ll .git/objects/
total 4
drwxr-xr-x 1 san 1049089 0 Aug 18 17:15 ./
drwxr-xr-x 1 san 1049089 0 Aug 18 17:22 ../
drwxr-xr-x 1 san 1049089 0 Aug 18 17:15 63/
drwxr-xr-x 1 san 1049089 0 Aug 18 17:15 90/
drwxr-xr-x 1 san 1049089 0 Aug 18 17:15 a3/
drwxr-xr-x 1 san 1049089 0 Aug 18 17:15 a5/
drwxr-xr-x 1 san 1049089 0 Aug 18 17:15 c7/
drwxr-xr-x 1 san 1049089 0 Aug 17 11:11 info/
drwxr-xr-x 1 san 1049089 0 Aug 18 17:12 pack/

除了infopack文件夹(它们现在对我们来说并不重要),如你所见,还有一些其他文件夹,其名称是两个字符的奇怪组合;我们进入63文件夹看看:

[22] ~/grocery (master) 
$ ll .git/objects/63/
total 1
drwxr-xr-x 1 san 1049089 0 Aug 18 17:15 ./
drwxr-xr-x 1 san 1049089 0 Aug 18 17:15 ../
-r--r--r-- 1 san 1049089 20 Aug 17 13:34 7a09b86af61897fb72f26bfb874f2ae726db82

嗯...

看看其中的文件,并思考:63 + 7a09b86af61897fb72f26bfb874f2ae726db82实际上是我们shoppingList.txt blob 的哈希值!

Git 非常智能且简单:为了在文件系统中更快地搜索,Git 创建了一组文件夹,其中文件夹名由两个字符组成,这两个字符代表哈希码的前两个字符;在这些文件夹中,Git 使用哈希的其他 38 个字符作为文件名,存储所有的对象,不论是何种类型的 Git 对象。

所以,a31c31cb8d7cc16eeae1d2c15e61ed7382cebf40 树保存在 a3 文件夹中,而 a57d783905e6a35032d9b0583f052fb42d5a1308 提交保存在 a5 文件夹中。

这是不是你见过的最聪明、最简单的事情?

现在,如果你尝试使用普通的 cat 命令查看这些文件,你会被骗:这些文件是纯文本文件,但 Git 使用 zlib 库对它们进行压缩,以节省磁盘空间。这就是为什么我们使用 git cat-file -p 命令,它可以即时解压缩这些文件。

这再次突显了 Git 的简单性:没有元数据,没有内部数据库或无用的复杂性,简单的文件和文件夹足以管理任何仓库。

到目前为止,我们知道 Git 是如何存储对象的,以及它们存储的位置;我们还知道没有数据库、没有中央仓库或类似的东西。那么,Git 是如何重建我们仓库的历史的呢?它是如何定义哪个提交先于或跟随另一个提交的呢?

要了解这一点,我们需要一个新的提交。所以,现在让我们开始修改 shoppingList.txt 文件:

[23] ~/grocery (master)
$ echo "apple" >> shoppingList.txt

[24] ~/grocery (master)
$ git add shoppingList.txt

[25] ~/grocery (master)
$ git commit -m "Add an apple"
[master e4a5e7b] Add an apple
 1 file changed, 1 insertion(+)

使用 git log 命令查看新的提交;--oneline 选项让我们以更简洁的方式查看日志:

[26] ~/grocery (master)
$ git log --oneline
e4a5e7b Add an apple
a57d783 Add a banana to the shopping list

好的,我们有了一个新的提交和它的哈希。是时候查看它里面的内容了:

[27] ~/grocery (master)
$ git cat-file -p e4a5e7b
tree 4c931e9fd8ca4581ddd5de9efd45daf0e5c300a0
parent a57d783905e6a35032d9b0583f052fb42d5a1308
author Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1503586854 +0200
committer Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1503586854 +0200

Add an apple

有一些新东西!

我在谈论的是 parent a57d783905e6a35032d9b0583f052fb42d5a1308 这一行;你看到了吗?一个提交的父提交就是它之前的那个提交。事实上,a57d783 哈希实际上是我们第一次提交的哈希。所以,每个提交都有一个父提交,通过跟踪这些提交之间的关系,我们可以从任意一个提交一直导航到第一个提交,之前提到的根提交

如果你还记得,第一次提交没有父提交,这也是所有提交与第一次提交之间的主要(也是唯一)区别。Git 在浏览和重建我们的仓库时,当它发现一个没有父提交的提交时,简单地知道任务完成了。

Git 不使用增量

现在是时候研究 Git 与其他版本控制系统之间的另一个著名差异了。以 Subversion 为例:当你进行新提交时,Subversion 会创建一个新的编号修订,只包含与前一个修订之间的增量;这种方法非常适合归档文件的更改,特别是对于大文本文件来说,因为如果只有一行文本发生变化,新提交的大小将大大减少。

相反,在 Git 中,即使你只修改了一个大文本文件中的一个字符,它总是会存储文件的新版本:Git 不做增量更新(至少在这个案例中是如此),每次提交实际上都是整个仓库的快照

到这个时候,人们通常会惊呼:“天哪,Git 浪费了大量的磁盘空间!”嗯,这完全不是真的。

在一个常见的源代码仓库中,经过一定数量的提交后,Git 通常不会占用比其他版本控制系统更多的空间。举个例子,当 Mozilla 从 Subversion 迁移到 Git 时,原本需要 12GB 的仓库在 Git 中只需要 420MB 的磁盘空间;请查看这个对比页面了解更多:git.wiki.kernel.org/index.php/GitSvnComparsion

此外,Git 有一个巧妙的方法来处理文件;让我们再次查看最后一个提交:

[28] ~/grocery (master)
$ git cat-file -p e4a5e7b
tree 4c931e9fd8ca4581ddd5de9efd45daf0e5c300a0
parent a57d783905e6a35032d9b0583f052fb42d5a1308
author Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1503586854 +0200
committer Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1503586854 +0200

Add an apple

好的,现在来看树结构:

[29] ~/grocery (master)
$ git cat-file -p 4c931e9
100644 blob 907b75b54b7c70713a79cc6b7b172fb131d3027d README.md
100644 blob e4ceb844d94edba245ba12246d3eb6d9d3aba504 shoppingList.txt

在记事本上标注两个哈希值;现在我们需要查看第一个提交的树结构;查看提交的内容:

[30] ~/grocery (master)
$ git cat-file -p a57d783
tree a31c31cb8d7cc16eeae1d2c15e61ed7382cebf40
author Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1502970693 +0200
committer Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1502970693 +0200
Add a banana to the shopping list 

然后查看树的内容:

[31] ~/grocery (master)
$ git cat-file -p a31c31c
100644 blob 907b75b54b7c70713a79cc6b7b172fb131d3027d README.md
100644 blob 637a09b86af61897fb72f26bfb874f2ae726db82 shoppingList.txt

猜猜看!README.md文件的哈希值在第一次和第二次提交的两棵树中是相同的;这使我们能够理解 Git 采用的另一个简单但巧妙的策略来管理文件;当文件没有被修改时,Git 在提交时创建一个树结构,其中该文件的对象指向已存在的对象,重复利用它并避免浪费磁盘空间。

同样的情况也适用于树结构:如果我的工作目录中有一些文件夹和文件,它们将保持不变,当我们进行新的提交时,Git 会循环利用相同的树结构。

总结

现在是时候总结一下到目前为止说明的所有概念了。

如人们所说,一张图片胜过千言万语,所以在这里你可以找到一张通过git-draw工具(github.com/sensorflo/git-draw)展示我们仓库当前状态的图像:

在这个图形表示中,你将看到一个详细的图示,展示新创建仓库的当前结构;你可以看到树(黄色)、对象(白色)、提交(绿色),以及它们之间的所有关系,通过有向箭头表示。

注意箭头的方向,连接提交的箭头从第二个提交指向第一个提交,或者从后代指向其祖先;这看似是一个细节,但在图形表示中正确定义这些关系非常重要,以便正确突出提交之间的依赖关系(总是子级依赖于父级)。

我只是想强调一些其他的东西;例如:

  • 这两个不同的树结构指向同一个README.md对象。

  • 对于shoppingList.txt文件,有两个不同的对象,一个只包含banana的文本行,另一个包含bananaapple

  • 第二个提交引用了第一个提交。

  • 第一个提交没有父提交。

  • 有三个提交!

这是什么情况?!

好的,别慌张。看看图片右侧的提交,并阅读作者和邮箱:那是我们第一次使用错误的用户和邮箱所做的提交;之后我们修改了该提交,改变了作者,记得吗?

好吧,但它为什么已经在那里?为什么我们在这张图片中能看到它,却在git log中看不到它?

这是关于提交可达性的话题,我们将在接下来的章节中讨论它。

Git 引用

在上一节中,我们看到一个 Git 仓库可以被想象成一棵树,从根节点(根提交)开始,向上生长,通过一个或多个分支。

这些分支通常由一个名称区分。在这方面 Git 也不例外;如果你还记得,到目前为止的实验都导致我们提交到了我们测试仓库的master分支。Master恰好是 Git 仓库的默认分支的名称,就像 Subversion 中的trunk一样。

但 Subversion 的类比到此为止:我们现在将看到 Git 如何处理分支,对于 Subversion 用户来说,这可能会有些惊讶。

一切都与标签有关。

在 Git 中,分支不过是一个标签,是一个可移动的标签,指向一个提交。

事实上,每个 Git 分支上的叶子都必须用一个有意义的名称标记,以便我们能够到达它,然后在其上进行移动、回退、合并、变基或在需要时丢弃某些提交。

让我们通过检查当前grocery仓库的状态来开始探索这个话题;我们使用著名的git log命令,这次添加了一些新的选项:

[1] ~/grocery (master)
$ git log --oneline --graph --decorate
* e4a5e7b (HEAD -> master) Add an apple
* a57d783 Add a banana to the shopping list

让我们详细查看这些选项:

  • --graph:在这种情况下,它只是在左侧的提交哈希前加上一个星号,但当你有更多分支时,这个选项将为我们绘制分支,提供一个简单但有效的仓库图形表示。

  • --decorate:此选项会显示与任何提交关联的标签;在此例中,它会在e4a5e7b提交上显示(HEAD -> master)标签。

  • --oneline:这个选项很容易理解:它用一行展示每个提交,必要时简化内容。

现在我们来做一个新的提交,看看会发生什么:

[2] ~/grocery (master)
$ echo "orange" >> shoppingList.txt

[3] ~/grocery (master)
$ git commit -am "Add an orange"
[master 0e8b5cf] Add an orange
 1 file changed, 1 insertion(+)

你注意到了吗?在向shopingList.txt添加了一个橙子后,我没有先执行git add就做了提交;窍门在于git commit命令中添加了-a--add)选项,这意味着将所有我之前至少提交过一次的修改文件添加到这个提交中。在我们的例子中,这个选项让我们能更快地跳过git add命令。

无论如何,在学习和使用 Git 的初期,特别要小心:你很容易提交更多文件,而不是你想要的。

好的,继续看一下当前仓库的情况:

[4] ~/grocery (master)
$ git log --oneline --graph --decorate
* 0e8b5cf (HEAD -> master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

有趣!现在HEADmaster都指向了最后一个提交,即第三个提交;这意味着什么?

分支是可移动的标签。

我们在之前的章节中已经看到了,提交是通过父子关系彼此连接的:每个提交都包含对前一个提交的引用。

这意味着,例如,要在一个仓库内导航,我不能从第一个提交开始并尝试跳到下一个提交,因为一个提交并没有指向下一个提交的引用,而是指向前一个提交的引用。继续使用我们树木的比喻,这意味着我们的树只能从叶子开始导航,从分支的最顶端开始,然后一直向下到根提交。

所以,分支不过是标签,标注在顶端提交上,也就是最后一个提交。这个提交,我们的叶子,必须总是由一个标签来标识,以便在浏览仓库时能够访问它的祖先提交。否则,我们每次切换分支时,都需要记住该分支的顶端提交的哈希码,想象一下人类要如何做到这一点。

引用如何工作

所以,每当我们向一个分支提交时,标识该分支的引用会相应地移动,以始终与顶端提交保持关联。

但是 Git 是如何处理这个功能的呢?我们回到.git文件夹中,继续探究:

[5] ~/grocery (master)
$ ll .git/
total 21
drwxr-xr-x 1 san 1049089   0 Aug 25 11:20 ./
drwxr-xr-x 1 san 1049089   0 Aug 25 11:19 ../
-rw-r--r-- 1 san 1049089  14 Aug 25 11:20 COMMIT_EDITMSG
-rw-r--r-- 1 san 1049089 208 Aug 17 13:51 config
-rw-r--r-- 1 san 1049089  73 Aug 17 11:11 description
-rw-r--r-- 1 san 1049089  23 Aug 17 11:11 HEAD
drwxr-xr-x 1 san 1049089   0 Aug 18 17:15 hooks/
-rw-r--r-- 1 san 1049089 217 Aug 25 11:20 index
drwxr-xr-x 1 san 1049089   0 Aug 18 17:15 info/
drwxr-xr-x 1 san 1049089   0 Aug 18 17:15 logs/
drwxr-xr-x 1 san 1049089   0 Aug 25 11:20 objects/
drwxr-xr-x 1 san 1049089   0 Aug 18 17:15 refs/

这里有一个refs文件夹:让我们看一下里面的内容:

[6] ~/grocery (master)
$ ll .git/refs/
total 4
drwxr-xr-x 1 san 1049089 0 Aug 18 17:15 ./
drwxr-xr-x 1 san 1049089 0 Aug 25 11:20 ../
drwxr-xr-x 1 san 1049089 0 Aug 25 11:20 heads/
drwxr-xr-x 1 san 1049089 0 Aug 17 11:11 tags/

现在,进入heads

[7] ~/grocery (master)
$ ll .git/refs/heads/
total 1
drwxr-xr-x 1 san 1049089  0 Aug 25 11:20 ./
drwxr-xr-x 1 san 1049089  0 Aug 18 17:15 ../
-rw-r--r-- 1 san 1049089 41 Aug 25 11:20 master

里面有一个master文件!让我们看看它的内容:

[8] ~/grocery (master)
$ cat .git/refs/heads/master
0e8b5cf1c1b44110dd36dea5ce0ae29ce22ad4b8

正如你可以想象的那样,Git 通过一个简单的文本文件来管理所有这个复杂的引用系统!它包含了该分支上最后一次提交的哈希值;实际上,如果你查看之前的git log输出,你可以看到最后一个提交的哈希值是0e8b5cf

如今,自从第一次接触以来已经过去了很长时间,但我仍然对 Git 的内部结构感到惊叹,它既简洁又高效。

创建一个新分支

现在我们已经热身好了,真正有趣的部分开始了。让我们看看当你请求 Git 创建一个新分支时会发生什么。既然我们准备做一份美味的水果沙拉,是时候为浆果口味的变种食谱设立一个分支了:

[9] ~/grocery (master)
$ git branch berries

就这样!要创建一个新分支,只需要使用git branch命令,后面跟上你想要使用的分支名称。这是非常快速的;由于 Git 总是本地操作,它在眨眼之间就完成了这样的工作。

事实上,有一些(复杂的)规则需要遵守,关于分支名称的一些知识(你所需要了解的都在这里:git-scm.com/docs/git-check-ref-format),但目前这些不太相关。

所以,再次执行git log

[10] ~/grocery (master)
$ git log --oneline --graph --decorate
* 0e8b5cf (HEAD -> master, berries) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

太棒了!现在 Git 告诉我们有一个新的分支berries,并且它指向与master分支相同的提交。

无论如何,目前我们仍然位于master分支;事实上,正如你在终端输出提示中看到的,它依然显示(master),括号中的内容:

[10] ~/grocery (master)

如何切换分支?可以使用git checkout命令:

[11] ~/grocery (master)
$ git checkout berries
Switched to branch 'berries'

执行git log查看:

[12] ~/grocery (berries)
$ git log --oneline --graph --decorate
* 0e8b5cf (HEAD -> berries, master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

Mmm, interesting! 现在在 shell 提示符中有一个(berries)标志,并且更重要的是,HEAD指向的东西发生了变化:箭头现在指向berries,而不是master。这是什么意思?

HEAD,或者你在这里

在之前的练习中,我们在使用git log时一直看到HEAD,现在是时候稍微调查一下了。

首先,HEAD是什么?和分支一样,HEAD是一个引用。它代表着我们当前所处位置的指针,没有多余的,也没有少的。实际上,它只是另一个普通的文本文件:

[13] ~/grocery (berries)
$ cat .git/HEAD
ref: refs/heads/berries

HEAD文件和分支文本文件之间的区别在于,HEAD文件通常指的是一个分支,而不像分支一样直接指向一个提交。ref:部分是 Git 内部使用的声明指向另一个分支的指针的约定,而refs/heads/berries当然是指向berries分支文本文件的相对路径。

因此,当我们检出berries分支时,实际上我们将指针从master分支移动到berries分支;从现在开始,我们每做一次提交都将是berries分支的一部分。让我们试试看。

在购物清单中添加一个黑莓:

[14] ~/grocery (berries)
$ echo "blackberry" >> shoppingList.txt

然后执行一个提交:

[15] ~/grocery (berries)
$ git commit -am "Add a blackberry"
[berries ef6c382] Add a blackberry
 1 file changed, 1 insertion(+)

看一下使用常规git log命令发生了什么:

[16] ~/grocery (berries)
$ git log --oneline --graph --decorate
* ef6c382 (HEAD -> berries) Add a blackberry
* 0e8b5cf (master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

很好!这里发生了一些变化:

  • berries分支移动到我们执行的最后一个提交,确认了我们之前说的:一个分支只是一个标签,在做新的提交时会跟随你,粘在最后一个提交上

  • HEAD指针也移动了,现在跟随它实际指向的分支,即berries分支

  • master分支仍然停留在原地,粘在倒数第二个提交上,也就是我们切换到berries分支之前做的最后一个提交

好的,现在我们的shoppingList.txt文件似乎包含了这些文本行:

[17] ~/grocery (berries)
$ cat shoppingList.txt
banana
apple
orange
blackberry

如果我们回到master分支会发生什么?让我们看看。

检查一下 master 分支:

[18] ~/grocery (berries)
$ git checkout master
Switched to branch 'master'

查看shoppingFile.txt的内容:

[19] ~/grocery (master)
$ cat shoppingList.txt
banana
apple
orange

实际上我们回到了添加黑莓之前的地方;由于它在berries分支中被添加了,在master分支中它并不存在:听起来不错,是吧?

甚至HEAD文件也相应更新了:

[20] ~/grocery (master)
$ cat .git/HEAD
ref: refs/heads/master

但是此时,有人可能会举手说:"这很奇怪!在 Subversion 中,我们通常为每个不同的分支有不同的文件夹;而在这里 Git 似乎总是覆盖同一个文件夹的内容,对吗?"

当然了。这就是 Git 的工作原理。当你切换一个分支时,Git 会到分支指向的提交去,根据父子关系和分析树和对象,相应地重新构建工作目录的内容,抓住那些文件和文件夹(实际上这就是 Subversion 实际上可以通过切换分支功能来做的)。

这是 Git 和 Subversion(以及其他类似版本控制系统)之间的一个重要区别;习惯使用 Subversion 的人常常争论,在这种方式下,你无法像在 Subversion 中那样轻松地按文件逐一比较分支,或者在你喜欢的 IDE 中同时打开你正在开发软件的两个不同 版本。是的,这是真的,在 Git 中你不能做到这一点,但有一些技巧可以绕过这个问题(如果这个问题对你来说是个问题的话)。

另一个需要说明的重要事项是,在 Git 中,你不能像在 Subversion 中那样仅检出某个仓库的文件夹;当你检出一个分支时,你将获得该分支的所有内容。

现在回到仓库;让我们执行常见的 git log

[21] ~/grocery (master)
$ git log --oneline --graph --decorate
* 0e8b5cf (HEAD -> master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

哎呀:berries 分支去哪儿了?别担心:git log 通常只显示你所在的分支和属于它的提交。要查看所有分支,你只需要添加 --all 选项:

[22] ~/grocery (master)
$ git log --oneline --graph --decorate --all
* ef6c382 (berries) Add a blackberry
* 0e8b5cf (HEAD -> master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

好的,让我们看一下:我们现在在 master 分支上,正如 shell 提示和 HEAD 提示我们所看到的,那个箭头指向 master;然后有一个 berries 分支,它比 master 多一个提交。

可达性和撤销提交

现在让我们设想这样一个场景:我们在 berries 分支上有一个新的提交,但我们意识到这是一个错误的提交,因此我们希望将 berries 分支回退到 master 分支的位置。实际上,我们想要丢弃 berries 分支上的最后一次提交。

首先,检出 berries 分支:

[23] ~/grocery (master)
$ git checkout -
Switched to branch 'berries'

新技巧:使用破折号(-),实际上是在告诉 Git:“把我移回到我切换之前所在的分支”;Git 会遵从,将我们移回 berries 分支。

现在执行一个新命令,git reset(暂时不需要关心 --hard 选项):

[24] ~/grocery (berries)
$ git reset --hard master
HEAD is now at 0e8b5cf Add an orange

在 Git 中,这就是这么简单。git reset 实际上是将一个分支从当前位置移动到一个新的位置;在这里,我们让 Git 将当前的 berries 分支移动到 master 分支所在的位置,结果是现在我们有两个分支都指向相同的提交:

[25] ~/grocery (berries)
$ git log --oneline --graph --decorate --all
* 0e8b5cf (HEAD -> berries, master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

你可以通过查看 refs 文件来再次确认这一点;这是 berries 分支的文件:

[26] ~/grocery (berries)
$ cat .git/refs/heads/berries
0e8b5cf1c1b44110dd36dea5ce0ae29ce22ad4b8

这是 master 分支的内容:

[27] ~/grocery (berries)
$ cat .git/refs/heads/master
0e8b5cf1c1b44110dd36dea5ce0ae29ce22ad4b8

相同的哈希值,相同的提交。

这次操作的一个 副作用 是丢失了我们在 berries 分支上做的最后一次提交,正如我们之前所说:但是为什么呢?又是如何发生的?

这是因为可达性的原因。一个提交在没有任何分支直接指向它,或者它不再作为其他提交的父提交时,就不再是可达的。我们的 黑莓提交berries 分支上的最后一个提交,因此将 berries 分支移开它,使得该提交变得不可达,并且它从我们的仓库中 消失 了。

但是你确定它已经消失了吗?想打个赌吗?

让我们再试一个技巧:我们可以使用 git reset 将当前分支直接移动到某个提交。为了让事情更有趣,我们来试试将 黑莓提交 (如果你向上滚动终端窗口,你可以看到它的哈希值,对我来说是 ef6c382)指向该提交,所以我们使用 git resetberries 分支移到 ef6c382 提交:

[28] ~/grocery (berries)
$ git reset --hard ef6c382
HEAD is now at ef6c382 Add a blackberry

接着执行常见的 git log

[29] ~/grocery (berries)
$ git log --oneline --graph --decorate --all
* ef6c382 (HEAD -> berries) Add a blackberry
* 0e8b5cf (master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

真神奇!我们实际上恢复了丢失的提交!

好的,开个玩笑,Git 里没有什么魔法;它只是不删除无法访问的提交,至少不会立刻删除。它会在某个时刻自动进行一些清理,因为它具有强大的垃圾回收功能(如果你感兴趣,可以查看git gc命令的帮助页面;我希望你记住,任何 Git 命令后跟--help选项,都将打开它的内部手册页)。

所以,我们已经了解了提交的可达性是什么意思,然后学会了如何使用git reset命令撤销一个提交,这是在处理 Git 仓库时非常有用的功能。

但是让我们继续尝试不同的分支操作。

假设你想把一个西瓜添加到购物清单中,但后来你意识到你把它添加到了错误的berries分支;于是,向shoppingList.txt文件中添加"watermelon"

[30] ~/grocery (berries)
$ echo "watermelon" >> shoppingList.txt

然后执行提交:

[31] ~/grocery (berries)
$ git commit -am "Add a watermelon"
[berries a8c6219] Add a watermelon
 1 file changed, 1 insertion(+)

执行git log查看结果:

[32] ~/grocery (berries)
$ git log --oneline --graph --decorate --all
* a8c6219 (HEAD -> berries) Add a watermelon
* ef6c382 Add a blackberry
* 0e8b5cf (master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

现在我们的目标是:创建一个新的melons分支,它必须包含西瓜提交,然后整理一下,将berries分支回退到黑莓提交。为了保留西瓜提交,首先用著名的git branch命令创建一个指向它的melon分支:

[33] ~/grocery (berries)
$ git branch melons

让我们检查一下:

[34] ~/grocery (berries)
$ git log --oneline --graph --decorate --all
* a8c6219 (HEAD -> berries, melons) Add a watermelon
* ef6c382 Add a blackberry
* 0e8b5cf (master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

好的,现在我们有了berriesmelons两个分支都指向了西瓜提交。

现在我们可以将berries分支回退到之前的提交;让我们利用这个机会来学习一些新东西。

在 Git 中,你经常需要指向一个之前的提交,就像在这个例子中,指向那个前一个提交;为了实现这个目的,我们可以使用HEAD引用,并跟随两个特殊字符中的一个,波浪线~插入符号^插入符号基本上意味着回退一步,而两个插入符号意味着回退两步,依此类推。由于你可能不想打出一堆插入符号,当你需要回退很多步时,可以使用波浪线:同样,~1表示回退一步,而~25表示回退 25 步,依此类推。

这个机制还有更多内容需要了解,但现在就足够了;要查看所有的细节,请访问www.paulboxley.com/blog/2011/06/git-caret-and-tilde

那么,使用插入符号(caret)将我们的berries分支回退;执行git reset --hard HEAD^

[35] ~/grocery (berries)
$ git reset --hard HEAD^
HEAD is now at ef6c382 Add a blackberry

我们来看一下结果:

[36] ~/grocery (berries)
$ git log --oneline --graph --decorate --all
* a8c6219 (melons) Add a watermelon
* ef6c382 (HEAD -> berries) Add a blackberry
* 0e8b5cf (master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

做得好!我们成功地恢复了错误,并学会了如何使用HEAD引用和git reset命令来在分支间进行切换。

仅仅为了说明概念,我们来看看berries分支中的shoppingList.txt文件:

[37] ~/grocery (berries)
$ cat shoppingList.txt
banana
apple
orange
blackberry

好的,这里我们看到了黑莓,除了之前添加的其他水果。

切换到master分支,再次查看;检查一下master分支:

[38] ~/grocery (berries)
$ git checkout master
Switched to branch 'master'

然后cat文件:

[39] ~/grocery (master)
$ cat shoppingList.txt
banana
apple
orange

好的,这里没有黑莓,只有berries分支创建之前添加的水果。

然后最后检查一下melons分支;查看该分支:

[40] ~/grocery (master)
$ git checkout melons
Switched to branch 'melons'

然后使用cat查看shoppingList.txt文件:

[41] ~/grocery (melons)
$ cat shoppingList.txt
banana
apple
orange
blackberry
watermelon

太棒了!这里有西瓜,除了之前在berriesmaster分支中添加的水果。

小提示:写分支名称时,使用Tab键进行自动补全:Git 会为你写出完整的分支名称。

Detached HEAD

现在是时候探索 Git 及其引用的另一个重要概念——detached HEAD状态了。

为了说明,返回到master分支,看看当我们检出上一个提交时会发生什么,将HEAD向后移动;执行git checkout HEAD^

[42] ~/grocery (master)
$ git checkout HEAD^
Note: checking out 'HEAD^'. 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 e4a5e7b... Add an apple

哇,这里有很多新东西可以看。但不要害怕,其实并没有那么复杂:让我们一步步了解 Git 给我们展示的长消息。

首先,思考一下:Git 非常友好,经常在其输出信息中告诉我们很多有用的信息。不要低估这种行为:尤其是在刚开始时,阅读 Git 的信息可以让你学到很多,所以要仔细阅读。

在这里,Git 告诉我们我们处于detached HEAD状态。处于这个状态基本上意味着HEAD不再引用一个分支,而是直接指向一个提交,在这个例子中是e4a5e7b那个提交;执行git log并查看:

[43] ~/grocery ((e4a5e7b...))
$ git log --oneline --graph --decorate --all
* a8c6219 (melons) Add a watermelon
* ef6c382 (berries) Add a blackberry
* 0e8b5cf (master) Add an orange
* e4a5e7b (HEAD) Add an apple
* a57d783 Add a banana to the shopping list

首先,在 Shell 提示符中,你会看到,在现在加倍的回合之间,并没有分支名称,而是提交的前七个字符,((e4a5e7b...))

然后,HEAD现在被固定在那个提交上,而分支,特别是master分支,仍然在它们自己的位置。因此,HEAD文件现在包含该提交的哈希值,而不是像以前那样引用一个分支:

[44] ~/grocery ((e4a5e7b...))
$ cat .git/HEAD
e4a5e7b3c64bee8b60e23760626e2278aa322f05

接下来,Git 表示在这个状态下,我们可以四处查看,做实验,如果喜欢,还可以进行新的提交,然后通过检出一个已有分支来简单丢弃它们,或者如果你愿意,可以创建一个新分支来保存它们。你能说出为什么这样做是对的吗?

当然是由于提交的可达性。如果我们做了一些提交,然后将HEAD移动到一个已有的分支上,这些提交就变得不可达了。它们会保持在一个可达状态,直到HEAD位于它们的最后一个提交之上,但当你使用git checkout移动HEAD时,它们就会消失。与此同时,如果你在移动HEAD之前创建一个新分支,会有一个标签,Git 可以用来指向这些提交,因此它们是安全的。

想试试吗?

好的,来点乐趣;修改shoppingList.txt文件,添加一个bug

[45] ~/grocery ((e4a5e7b...))
$ echo "bug" > shoppingList.txt

然后commit这个自愿的错误:

[46] ~/grocery ((e4a5e7b...))
$ git commit -am "Bug eats all the fruits!"
[detached HEAD 07b1858] Bug eats all the fruits!
 1 file changed, 1 insertion(+), 2 deletions(-)

让我们cat这个文件:

[47] ~/grocery ((07b1858...))
$ cat shoppingList.txt
bug

哎呀,实际上我们删除了你所有的购物清单文件!

那么,仓库中发生了什么呢?

[48] ~/grocery ((07b1858...))
$ git log --oneline --graph --decorate --all

 * 07b1858 (HEAD) Bug eats all the fruits!
 | * a8c6219 (melons) Add a watermelon
 | * ef6c382 (berries) Add a blackberry
 | * 0e8b5cf (master) Add an orange
 |/ * e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

很棒!我们有了一个新的提交,就是那个错误提交,我们可以看到HEAD跟随了我们,所以现在它指向了它。然后,控制台绘制了两条不同的路径,因为从apple commit开始,我们追踪了两条路线:一条指向master分支(然后是berriesmelons),另一条指向我们刚刚做的bug commit

好的,如果我们现在再次检出master,会发生什么?试试看吧:

[49] ~/grocery ((07b1858...))
$ git checkout master

Warning: you are leaving 1 commit behind, not connected to
any of your branches:

 07b1858 Bug eats all the fruits!

If you want to keep it by creating a new branch, this may be a good time to do so with:
 git branch <new-branch-name> 07b1858

Switched to branch 'master'

好的,我们已经看到过这条信息:Git 知道我们正在丢弃一个提交;但在这种情况下,这对我们来说不是问题,事实上,这正是我们真正想要的。

让我们检查一下情况:

[50] ~/grocery (master)
$ git log --oneline --graph --decorate --all
* a8c6219 (melons) Add a watermelon
* ef6c382 (berries) Add a blackberry
* 0e8b5cf (HEAD -> master) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

耶!bug提交已经消失了,所以没有什么被破坏。在之前的消息中,Git 很友好地提醒我们如何恢复那个提交,以防万一;诀窍是直接创建一个指向该提交的分支,Git 甚至给我们提供了完整的命令。让我们试试看,创建一个bug分支:

[51] ~/grocery (master)
$ git branch bug 07b1858

让我们看看发生了什么:

[52] ~/grocery (master)
$ git log --oneline --graph --decorate --all
* 07b1858 (bug) Bug eats all the fruits!
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
| * 0e8b5cf (HEAD -> master) Add an orange
|/
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

哇,真是太简单了!提交又回来了,现在我们甚至有了一个分支可以切换了,如果我们愿意的话。

引用日志

好的,但是如果我们第一次忽略了 Git 的消息,然后时间过去,最终我们记不起我们想要恢复的提交的哈希值怎么办?

Git 从不忘记你。它有另一个强大的工具在其工具箱里,叫做引用日志,简称 reflog。基本上,reflog(或者更准确地说是 reflogs,因为每个引用都有一个)记录了在你提交、重置、检出等操作时发生的所有事情。更具体地说,每个 reflog 记录了分支和其他引用(如HEAD)的指针更新的所有时间。

我们可以通过一个方便的 Git 命令git reflog show来看一下它:

[53] ~/grocery (master)
$ git reflog show
0e8b5cf HEAD@{0}: checkout: moving from 07b18581801f9c2c08c25cad3b43aeee7420ffdd to master
07b1858 HEAD@{1}: commit: Bug eats all the fruits!
e4a5e7b HEAD@{2}: checkout: moving from master to HEAD^
0e8b5cf HEAD@{3}: reset: moving to 0e8b5cf
e4a5e7b HEAD@{4}: reset: moving to HEAD^
0e8b5cf HEAD@{5}: checkout: moving from melons to master
a8c6219 HEAD@{6}: checkout: moving from master to melons
0e8b5cf HEAD@{7}: checkout: moving from berries to master
ef6c382 HEAD@{8}: reset: moving to HEAD^
a8c6219 HEAD@{9}: commit: Add a watermelon
ef6c382 HEAD@{10}: reset: moving to ef6c382
ef6c382 HEAD@{11}: reset: moving to ef6c382
0e8b5cf HEAD@{12}: reset: moving to master
ef6c382 HEAD@{13}: checkout: moving from master to berries
0e8b5cf HEAD@{14}: checkout: moving from berries to master
ef6c382 HEAD@{15}: commit: Add a blackberry
0e8b5cf HEAD@{16}: checkout: moving from master to berries
0e8b5cf HEAD@{17}: commit: Add an orange
e4a5e7b HEAD@{18}: commit: Add an apple
a57d783 HEAD@{19}: commit (amend): Add a banana to the shopping list
c7a0883 HEAD@{20}: commit (initial): Add a banana to the shopping list

实际上,这里记录了自从一开始以来,HEAD引用在我的仓库中所做的所有变动,按倒序排列,正如你可能已经注意到的那样。

事实上,最后一条(HEAD@{0})显示的是:

checkout: moving from 07b18581801f9c2c08c25cad3b43aeee7420ffdd to master

实际上,这是我们做的最后一件事,除了在bug分支上的创建。由于我们从未切换到该分支,HEAD引用日志并没有记录任何关于bug分支创建的信息。

引用日志是一个相当复杂的主题,深入讨论它超出了这个范围,因此我们这里只学习如何打开和读取它,并如何解读其中的信息。

我只想让你知道的事情是,这个日志会在某个时刻被清除;默认的保留期限是 90 天。然后,每个引用都有一个 reflog;我们现在看到的是HEAD的 reflog(HEAD@是一个提示),但是如果你输入git reflog show berries,你会看到berries分支过去的变动:

[54] ~/grocery (master)
$ git reflog berries ef6c382 berries@{0}: reset: moving to HEAD^
a8c6219 berries@{1}: commit: Add a watermelon
ef6c382 berries@{2}: reset: moving to ef6c382
0e8b5cf berries@{3}: reset: moving to master
ef6c382 berries@{4}: commit: Add a blackberry
0e8b5cf berries@{5}: branch: Created from master

回到我们的问题,如果我们想检出一个当前无法访问的提交,我们可以进入HEAD的 reflog,寻找我们做提交的那一行(在这个例子中,我会寻找一个commit:的日志行,查找提交信息中提到的、帮助我记起的内容,比如这次是bug)。

做得好,暂时就到这里;稍后我们会再次使用 reflog。

标签是固定的标签

标签是你可以附加到提交上的标签,但与分支不同的是,它们会一直存在。

创建标签很简单:你只需要git tag命令,后面跟上标签名;我们可以在bug分支的最新提交上创建一个标签来试试看;切换到bug分支:

[1] ~/grocery (master)
$ git checkout bug
Switched to branch 'bug'

然后使用git tag命令,后面跟上有趣的bugTag名称:

[2] ~/grocery (bug)
$ git tag bugTag

让我们看看git log怎么说:

[3] ~/grocery (bug)
$ git log --oneline --graph --decorate --all
* 07b1858 (HEAD -> bug, tag: bugTag) Bug eats all the fruits!
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
| * 0e8b5cf (master) Add an orange
|/ * e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

正如您在日志中所看到的,现在在bug分支的末端甚至有一个名为bugTag的标签。

如果您在此分支进行提交,您将看到bugTag将保持其位置不变;向同一个旧购物清单文件添加一行:

[4] ~/grocery (bug)
$ echo "another bug" >> shoppingList.txt

执行一个commit

[5] ~/grocery (bug)
$ git commit -am "Another bug!"
[bug 5d605c6] Another bug!
 1 file changed, 1 insertion(+)

然后查看当前情况:

[6] ~/grocery (bug)
$ git log --oneline --graph --decorate --all
* 5d605c6 (HEAD -> bug) Another bug!
* 07b1858 (tag: bugTag) Bug eats all the fruits!
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
| * 0e8b5cf (master) Add an orange
|/
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

这正是我们预测的。

标签对于赋予某些特定提交特定意义非常有用;例如,作为开发者,您可能希望为您的软件的每个发布打上标签:在这种情况下,这就是您需要知道的所有内容来完成这项工作。

即使标签也是引用,并且像分支一样,它们作为简单的文本文件存储在.git文件夹中的 tags 子文件夹中;在.git/refs/tags文件夹下看一下,您会看到一个bugTag文件;查看其内容:

[7] ~/grocery (bug)
$ cat .git/refs/tags/bugTag
07b18581801f9c2c08c25cad3b43aeee7420ffdd

正如您可能已经预测的那样,它包含了它所指向的提交的哈希值。

要删除一个标签,只需添加 -d 选项:git tag -d <标签名>

由于您不能移动标签,如果需要移动它,则必须删除先前的标签,然后创建一个新的同名标签,指向您希望的提交;您可以创建一个标签,指向任何您想要的提交,例如,git tag myTag 07b1858

注释标签

Git 有两种类型的标签;这是因为在某些情况下,您可能希望向标签添加消息,或者因为您喜欢作者坚持不变。

我们已经看到了第一种类型,更简单的那种;包含这些额外信息的标签属于第二种类型,注释标签

注释标签既是一个引用,也是git 对象,如提交、树和 blob。

要创建一个注释标签,只需将-a追加到命令中;让我们再创建一个来试试看:

[8] ~/grocery (bug)
$ git tag -a annotatedTag 07b1858

此时,Git 打开默认编辑器,允许您编写标签消息,就像下面的截图中所示:

保存并退出,然后查看日志:

[9] ~/grocery (bug)
$ git log --oneline --graph --decorate --all
* 5d605c6 (HEAD -> bug) Another bug!
* 07b1858 (tag: bugTag, tag: annotatedTag) Bug eats all the fruits!
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
| * 0e8b5cf (master) Add an orange
|/ * e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

好的,现在同一个提交上有两个标签了。

已创建一个新引用:

[10] ~/grocery (bug)
$ cat .git/refs/tags/annotatedTag
17c289ddf23798de6eee8fe6c2e908cf0c3a6747

但是即使是一个新对象:试着 cat-file 这个引用中看到的哈希值:

[11] ~/grocery (bug)
$ git cat-file -p 17c289
object 07b18581801f9c2c08c25cad3b43aeee7420ffdd
type commit
tag annotatedTag
tagger Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 150376226 4 +0200

This is an annotated tag

这就是注释标签的样子。

显然,git tag命令还有许多其他选项,但我只强调了我认为目前值得知道的那些选项。

如果您想查看命令的所有选项,请记住您始终可以使用 git <命令> --help 查看完整指南。

是时候多说几句关于缓存区了,因为我们只是刚刚触及了表面。

缓存区、工作树和 HEAD 提交

到目前为止,我们几乎只提到了缓存区(也称为索引),用 git add 命令准备文件以进行新的提交。

缓存区的目的实际上就是这样。当您更改文件的内容、添加新文件或删除现有文件时,您必须告诉 Git 这些修改中哪些将成为下一个提交的一部分:缓存区就是存储这类数据的容器。

现在我们专注于这个;如果尚未处于 master 分支,请切换到该分支,然后输入 git status 命令;它让我们可以查看暂存区的实际状态:

[1] ~/grocery (master)
$ git status
On branch master
nothing to commit, working tree clean

Git 显示没有内容需要提交,我们的工作树是干净的。但什么是工作树呢?它和我们之前讨论过的工作目录是一样的吗?嗯,既是也不是,这点确实令人困惑,我知道。

Git 曾经(并且现在仍然有)一些命名上的问题;事实上,正如我们之前提到的,甚至对于暂存区,我们有两个名字(另一个是 index)。Git 在其消息和命令输出中都使用这两个名字,人们、博客以及像这本书一样的书籍在谈论 Git 时也经常这样做。为同一件事使用两个名字并不总是个好主意,尤其是当它们确切地表示相同的东西时,但意识到这一点就足够了(时间会带给我们一个不那么混乱的 Git,我相信)。

关于工作树和工作目录,情况是这样的。曾有人争论过:如果我在仓库的根目录下,我就处于一个工作目录,但如果我走进一个子文件夹,我就处于另一个工作目录。从文件系统的角度来看,这种说法是对的,但在 Git 中,进行一些操作,如 checkout 或 reset,并不会影响当前工作目录,而是影响整个……工作树。因此,为了避免混淆,Git 停止在其消息中使用工作目录这个术语,而是将其“重命名”为工作树。如果你想深入了解,可以查看这个提交记录:github.com/git/git/commit/2a0e6cdedab306eccbd297c051035c13d0266343。希望我已经稍微澄清了一些。

现在回到正题。

shoppingList.txt 文件添加一个 peach

[2] ~/grocery (master)
$ echo "peach" >> shoppingList.txt

然后再次使用这个新学到的命令 git status

[3] ~/grocery (master)
$ 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:   shoppingList.txt

no changes added to commit (use "git add" and/or "git commit -a")

好的,现在是时候学习已暂存的更改了;当 Git 提到 staged 时,它指的是我们已经添加到暂存区的修改,这些修改将成为下一个提交的一部分。在当前情况下,我们修改了 shoppingList.txt 文件,但尚未将其添加到暂存区(使用经典的 git add 命令)。

所以,Git 会告诉我们:它显示有一个修改过的文件(以红色显示),然后提供两个选择:暂存它(将其添加到暂存区),或放弃该修改,使用 git checkout -- <file> 命令。

让我们尝试添加它;第二个选项稍后会讲到。

所以,尝试一个git add命令,不加其他内容:

[4] ~/grocery (master)
$ git add
Nothing specified, nothing added.
Maybe you wanted to say 'git add .'?

好的,学到新东西了:git add 需要你指定要添加的内容。常见的做法是使用点 . 作为通配符,默认情况下,这意味着将此文件夹及子文件夹中的所有文件添加到暂存区。这与 git add -A(或 --all)相同,所谓的“所有”是指:

  • 我之前至少添加过一次的此文件夹及子文件夹中的文件:这组文件也被称为已追踪文件

  • 新文件:这些称为未追踪文件

  • 标记为删除的文件

请注意,这种行为随时间发生了变化:在 Git 2.x 之前,git add .git add -A 的效果不同。这里有一个表格,帮助快速理解它们之间的差异。

Git 版本 1.x:

新文件 修改过的文件 已删除的文件
git add -A 暂存所有(新建、修改、删除)文件
git add . 仅暂存新建和修改的文件
git add -u 仅暂存已修改和已删除的文件

Git 版本 2.x:

新文件 修改过的文件 已删除的文件
git add -A 暂存所有(新建、修改、删除)文件
git add . 暂存所有(新建、修改、删除)文件
git add --ignore-removal . 仅暂存新建和修改的文件
git add -u 仅暂存已修改和已删除的文件

正如你所看到的,在 Git 2.x 中,有了一种新的方法来仅暂存新文件和修改过的文件,即 git add --ignore-removal .,然后 git add . 就变成了与 git add -A 相同。如果你在想,-u 选项相当于 --update

另一个基本用法是指定我们想要添加的文件;让我们试试:

[5] ~/grocery (master)
$ git add shoppingList.txt

正如你所看到的,当 git add 执行成功时,Git 什么也不说,没有消息:我们可以认为这是一个默许的批准。

另一种添加文件的方式是指定一个目录,将其中所有更改过的文件添加进来,使用通配符(如星号 *),可以有或没有其他内容(例如,*.txt 用于添加所有 txt 文件,foo* 用于添加所有以 foo 开头的文件,依此类推)。

请参阅 git-scm.com/docs/git-add#git-add-ltpathspecgt82308203 以获取所有相关信息。

好的,现在是时候回顾我们的仓库了;现在用 git status 看看:

[6] ~/grocery (master)
$ git status
On branch master
Changes to be committed:
 (use "git reset HEAD <file>..." to unstage)

modified:   shoppingList.txt

很好!我们的文件已经被添加到暂存区,现在它是下次提交的一部分,实际上是唯一的一部分。

现在看看 Git 后来的输出:如果你想要 unstage 这个更改,可以使用 git reset HEAD 命令:这是什么意思呢?Unstage 是一个表示 从暂存区移除更改 的词,例如,因为我们意识到我们不想在下次提交中添加这个更改,而是想在以后添加。

目前,保持现状,然后进行一次 commit

[7] ~/grocery (master)
$ git commit -m "Add a peach"
[master 603b9d1] Add a peach
 1 file changed, 1 insertion(+)

检查状态:

[8] ~/grocery (master)
$ git status
On branch master
nothing to commit, working tree clean

好的,现在我们有了一个新的 commit,并且我们的工作区再次干净了;是的,因为 git commit 的效果是创建一个包含暂存区内容的新提交,然后清空暂存区。

现在我们可以进行一些实验,看看如何处理暂存区和工作区,并在需要时撤销更改。

所以,跟我来,让我们让事情变得更有趣;往购物清单上加个洋葱,然后将它添加到暂存区,再加个大蒜,看看会发生什么:

[9] ~/grocery (master)
$ echo "onion" >> shoppingList.txt

[10] ~/grocery (master)
$ git add shoppingList.txt

[11] ~/grocery (master)
$ echo "garlic" >> shoppingList.txt

[12] ~/grocery (master)
$ git status
On branch master
Changes to be committed:
 (use "git reset HEAD <file>..." to unstage)

modified:   shoppingList.txt

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:   shoppingList.txt

好的,很好!我们现在处于一个非常有趣的状态。我们的shoppingList.txt文件已经修改了两次,但只有第一次修改被添加到暂存区。这意味着,如果我们现在提交文件,只有onion的修改会被包含在提交中,而garlic的修改不会。这一点很值得注意,因为在其他版本控制系统中,做这种操作并没有这么简单。

为了突出显示我们所做的修改,并简单查看,我们可以使用git diff命令;例如,如果你想查看工作树版本和暂存区版本之间的差异,只需输入git diff命令,不加任何选项或参数:

[13] ~/grocery (master)
$ git diff
diff --git a/shoppingList.txt b/shoppingList.txt
index f961a4c..20238b5 100644
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@ -3,3 +3,4 @@ apple
 orange
 peach
 onion
+garlic

如你所见,Git 突出了工作树中比暂存区版本多出的garlic

git diff命令输出的最后部分并不难理解:以加号+开头的绿色行是新增的行(被删除的行会有以减号-开头的红色行)。修改的行通常会通过红色的减号删除行和绿色的加号新增行来突出显示;要做到准确,Git 可以被指示使用不同的diff算法,但这超出了本书的范围。

除此之外,git diff输出的第一部分有点难以用几句话解释;请参考git-scm.com/docs/git-diff了解所有细节。

但是如果你想查看最后提交的shoppingList.txt文件与已经添加到暂存区的文件之间的差异呢?

我们需要使用git diff --cached HEAD命令:

[14] ~/grocery (master)
$ git diff --cached HEAD
diff --git a/shoppingList.txt b/shoppingList.txt
index 175eeef..f961a4c 100644
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@ -2,3 +2,4 @@ banana
 apple
 orange
 peach
+onion

我们需要拆解这个命令,更好地理解它的目的;通过添加HEAD参数,我们要求使用我们最后一次提交的版本作为比较对象。要做到准确,在这种情况下,HEAD引用是可选的,因为它是默认的:git diff --cached会返回相同的结果。

另一方面,--cached选项表示,将参数(在本例中是 HEAD)与暂存区中的版本进行比较

是的,亲爱的朋友们:暂存区,也被称为索引,有时也叫做缓存,因此有了--cached选项。

我们可以做的最后一个实验是将HEAD版本与工作树版本进行比较;我们来用git diff HEAD来试试:

[15] ~/grocery (master)
$ git diff HEAD
diff --git a/shoppingList.txt b/shoppingList.txt
index 175eeef..20238b5 100644
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@ -2,3 +2,5 @@ banana
 apple
 orange
 peach
+onion
+garlic

好的,效果如预期。

现在是从控制台休息一下,花点时间聊聊我们比较过的这三个位置

Git 的三个区域

在 Git 中,我们在三个不同的层级进行工作:

  • 工作树(或工作目录)

  • 暂存区(或索引,或缓存)

  • HEAD 提交(或当前分支上的最后提交或头部提交)

当我们修改文件时,我们是在工作区层面进行的;当我们执行 git add 时,实际上是在将更改从工作区复制到暂存区。最终,当我们执行 git commit 时,我们将更改从暂存区移到一个全新的提交中,该提交由 HEAD 引用,最终成为我们仓库历史的一部分:这就是我所说的 HEAD 提交。

下图展示了这三个区域:

我们可以将更改从这些区域向前移动,从工作区到 HEAD 提交,但我们甚至可以向后移动,撤销更改。

我们已经知道如何通过使用 git add 然后 git commit 向前推进;让我们来看看撤销的命令。

从暂存区移除更改

假设你已经将更改添加到暂存区,然后你意识到这些更改更适合放到未来的提交中,而不是当前正在编写的提交中。

要从暂存区中移除一个或多个文件的更改,可以使用 git reset HEAD <file> 命令;回到命令行,跟着我来。

查看仓库的当前状态:

[16] ~/grocery (master)
$ git status
On branch master
Changes to be committed:
 (use "git reset HEAD <file>..." to unstage)

modified:   shoppingList.txt

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:   shoppingList.txt

这就是实际情况,记得吗?我们在暂存区有一个 onion,在工作区有一个 garlic

现在使用 git reset HEAD

[17] ~/grocery (master)
$ git reset HEAD shoppingList.txt
Unstaged changes after reset:
M   shoppingList.txt

好的,Git 确认我们已取消暂存更改。左侧的 M 表示 Modified;这里 Git 告诉我们已经取消暂存了一个文件的修改。如果你创建了一个新文件并将其添加到暂存区,Git 会知道这是一个新文件;如果你尝试取消暂存它,Git 会在左侧显示 A 代表 Added,以便记住你刚刚取消暂存了对新文件的添加。同样,如果你取消暂存了删除一个已存在的文件,左侧会显示 D 代表 Deleted

好吧,检查一下发生了什么:

[18] ~/grocery (master)$ 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:   shoppingList.txtno changes added to commit (use "git add" and/or "git commit -a")

好的,通过使用 git status,我们看到现在暂存区是空的,没有任何暂存的文件。我们只有一些未暂存的修改,但是什么修改呢?git reset HEAD 是否真的删除了洋葱?

让我们通过 git diff 命令来验证这一点:

[19] ~/grocery (master)
$ git diff
diff --git a/shoppingList.txt b/shoppingList.txt
index 175eeef..20238b5 100644
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@ -2,3 +2,5 @@ banana
 apple
 orange
 peach
+onion
+garlic

不,幸运的是!git reset HEAD 命令不会销毁你的修改;它只是将它们从暂存区移开,这样它们就不会成为下一次提交的一部分。

下图展示了 git diff 不同行为的简要总结:

现在假设我们完全搞砸了:我们对 shoppingList.txt 文件所做的修改是错误的(事实上是错误的,没有洋葱和大蒜的美味水果沙拉),所以我们需要撤销它们。

该命令是 git checkout -- <file>,正如 Git 在 git status 输出信息中温柔地提醒我们。试试这个:

[20] ~/grocery (master)
$ git checkout -- shoppingList.txt

查看状态:

[21] ~/grocery (master)
$ git status
On branch master
nothing to commit, working tree clean

查看文件内容:

[22] ~/grocery (master)
$ cat shoppingList.txt
banana
apple
orange
peach

就这样!我们实际上已经从购物清单文件中删除了洋葱和大蒜。但请注意:我们丢失了它们!由于这些修改仅存在于工作区中,无法恢复它们,因此要小心:git checkout -- 是一个破坏性命令,使用时要小心。

除此之外,我们还需要记住,git checkout 会覆盖暂存区;如前图所示,工作区和 HEAD 提交是直接相关的:更改总是经过暂存区。稍后我们在深入研究 git reset 选项时,会更好地理解这个概念。

到此,你可能已经注意到这里我们使用 git resetgit checkout 命令的方式与之前章节中的不同,确实是这样。

一开始,对于新手来说,这可能有点混乱,因为你无法将一个单独的命令与一个操作关联,因为它可以用来执行多个操作。例如,你不能说,git checkout 只是用于切换分支(或者用于提交检查,进入 HEAD 分离状态),因为它甚至可以用于丢弃工作区的更改,就像我们刚刚做的那样。

你可以通过考虑双破折号 -- 符号来区分这两个命令的不同变体。所以,你可以记住 git checkout 用于切换分支git checkout -- 用于丢弃本地更改

这对于 git reset 命令也是真的;实际上,执行 git reset -- <file> 与执行 git reset HEAD <file> 是一样的。

说实话,双破折号 -- 符号并不是强制性的;如果你在没有 -- 的情况下执行 git checkout <file>git reset <file>,在 99% 的情况下 Git 会做你期望的操作。双破折号是在出现文件和分支同名的情况下需要的:在这种情况下,Git 需要知道你是想处理分支,例如通过 git checkout 切换到另一个分支,还是想处理文件。在这种情况下,双破折号是告诉 Git 我想处理文件,而不是分支 的方式。

以下图表总结了在这三个区域之间移动更改的命令:

现在是时候完成我们关于文件状态生命周期的文化知识了,深入了解一下 Git 仓库中的文件状态。

文件状态生命周期

在 Git 仓库中,文件会经历一些不同的状态。当你第一次在工作区创建一个文件时,Git 会注意到它,并告诉你有一个新的未跟踪文件;我们来尝试在 grocery 仓库中创建一个新的 file.txt 文件,看看 git status 的输出:

[23] ~/grocery (master)
$ git status
On branch master
Untracked files:
 (use "git add <file>..." to include in what will be committed)

file.txt

nothing added to commit but untracked files present (use "git add" to track)

如你所见,Git 明确表示存在一个 未跟踪 文件;未跟踪的文件基本上是一个 Git 从未见过的新文件。

当你添加它时,它变成了一个 已跟踪 文件。

如果你提交了文件,它将进入 未修改 状态;这意味着 Git 知道它,并且工作区中文件的当前版本与 HEAD 提交中的版本相同。

如果你做了一些更改,文件会进入 已修改 状态。

将已修改的文件添加到暂存区,使其成为一个 已暂存 文件。

以下图表总结了这些状态:

了解这些术语对于更好地理解 Git 消息很重要,它有助于我和你在讨论 Git 仓库中的文件时更加顺利。

现在是时候更深入地了解 git resetgit checkout 命令了。

你需要了解的关于 checkout 和 reset 的所有内容

首先,我们需要做一些清理工作。返回到 grocery 仓库并清理工作树;再次确认你处于 master 分支,然后执行 git reset --hard master

[24] ~/grocery (master)
$ git reset --hard master
HEAD is now at 603b9d1 Add a peach

这让我们可以丢弃所有最新的更改,回到 master 上的最新提交,甚至清理暂存区。

然后,删除我们之前创建的 bug 分支;删除分支的命令依然是 git branch 命令,这次后面跟一个 -d 选项,然后是分支名称:

[25] ~/grocery (master)
$ git branch -d bug
error: The branch 'bug' is not fully merged.
If you are sure you want to delete it, run 'git branch -D bug'.

好的,Git 提出了异议。它说该分支没有完全合并,换句话说,如果你删除它,里面的提交将丢失。没问题,我们不需要那个提交;因此,使用大写的 -D 选项强制删除:

[26] ~/grocery (master)
$ git branch -D bug
Deleted branch bug (was 07b1858).

好的,现在完成了,仓库状态良好,正如 git log 命令所显示的那样:

[27] ~/grocery (master)
$ git log --oneline --graph --decorate --all
* 603b9d1 (HEAD -> master) Add a peach
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

Git checkout 会覆盖所有的树区

现在使用 git checkout 命令切换到 melons 分支:

[28] ~/grocery (master)
$ git checkout melons
Switched to branch 'melons'

查看日志:

[29] ~/grocery (melons)
$ git log --oneline --graph --decorate --all
* 603b9d1 (master) Add a peach
| * a8c6219 (HEAD -> melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

好的,发生了什么?

Git 从 melons 分支获取了提示提交,分析了它,然后将该提交所代表的快照重新构建到我们的工作树中。它基本上将所有这些文件和文件夹复制到暂存区,然后再复制到工作树中。

记住,git checkout 可能会覆盖你在工作树中的更改;实际上,如果你有一些本地修改,Git 会阻止你。

我们可以试试;在购物清单文件中添加一个 potato

[30] ~/grocery (melons)
$ echo "potato" >> shoppingList.txt

然后检出 master

[31] ~/grocery (melons)
$ git checkout master
error: Your local changes to the following files would be overwritten by checkout:
shoppingList.txt
Please commit your changes or stash them before you switch branches.
Aborting

如你所见,如果你不处于干净的状态下,不能切换分支。

现在请通过编辑器或 Git 删除购物清单文件中的土豆(我将这部分留给你作为练习)。

Git reset 可以是硬重置、软重置或混合重置

最后,你将看到 git reset --hard 的含义,以及我们所拥有的其他重置选项。

为了避免再次弄乱我们的仓库,进入一个脱离的 HEAD 状态,这样最终就更容易丢弃所有内容。要做到这一点,直接检出 master 分支上的倒数第二个提交:

[32] ~/grocery (master)
$ git checkout HEAD~1
Note: checking out 'HEAD~1'.

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 0e8b5cf... Add an orange

好的,这是这个提交中 shoppingList.txt 文件的内容:

[33] ~/grocery ((0e8b5cf...))
$ cat shoppingList.txt
banana
apple
orange

现在就复制我们之前使用过的 oniongarlic 情况:在文件中追加一个 onion 并将其添加到暂存区,然后再添加一个 garlic

[34] ~/grocery ((0e8b5cf...))
$ echo "onion" >> shoppingList.txt

[35] ~/grocery ((0e8b5cf...))
$ git add shoppingList.txt

[36] ~/grocery ((0e8b5cf...))
$ echo "garlic" >> shoppingList.txt

[37] ~/grocery ((0e8b5cf...))
$ git status
HEAD detached at 0e8b5cf
Changes to be committed:
 (use "git reset HEAD <file>..." to unstage)

modified:   shoppingList.txt

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:   shoppingList.txt

现在使用 git diff 命令确认我们处于所期望的状态;检查与暂存区的差异:

[38] ~/grocery ((0e8b5cf...))
$ git diff --cached HEAD
diff --git a/shoppingList.txt b/shoppingList.txt
index edc9072..063aa2f 100644
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@ -1,3 +1,4 @@
 banana
 apple
 orange
+onion

检查工作树和 HEAD 提交之间的差异:

[39] ~/grocery ((0e8b5cf...))
$ git diff HEAD
diff --git a/shoppingList.txt b/shoppingList.txt
index edc9072..93dcf0e 100644
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@ -1,3 +1,5 @@
 banana
 apple
 orange
+onion
+garlic

好的,我们有一个只有水果的 HEAD 提交,然后暂存区有一个洋葱,并且工作树中还有一个大蒜。

现在尝试使用 git reset --soft master 命令做一个 软重置master 分支:

[40] ~/grocery ((0e8b5cf...))
$ git reset --soft master

和暂存区的差异:

[41] ~/grocery ((603b9d1...))
$ git diff --cached HEAD
diff --git a/shoppingList.txt b/shoppingList.txt
index 175eeef..063aa2f 100644
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@ -1,4 +1,4 @@
 banana
 apple
 orange
-peach
+onion

Git 做了什么?它基本上将 HEAD 引用移到了 master 分支上的最后一个提交 603b9d1。稍作休息:注意,当处于分离的 HEAD 状态时,即使你重置到带有分支标签的提交,Git 仍然会直接引用该提交,而不是分支。

好吧,完成这个操作后,现在 HEAD 提交和暂存区之间的差异是我们在输出中看到的内容:shoppingList.txt 文件中的桃子内容在 HEAD 提交中有,但当前暂存的 shoppingList.txt 文件中没有,因此 Git 用红色标记一个带有前导减号的 peach 行,表示该行实际上已经被删除,而 onion 行则被添加了。

同样,如果你将 HEAD 提交与工作区进行比较:

[42] ~/grocery ((603b9d1...))
$ git diff HEAD
diff --git a/shoppingList.txt b/shoppingList.txt
index 175eeef..93dcf0e 100644
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@ -1,4 +1,5 @@
 banana
 apple
 orange
-peach
+onion
+garlic

在这种情况下,Git 甚至指出添加了两行新内容,分别是 oniongarlic

这种软重置技巧可以帮助你快速比较两个提交之间的差异,因为它只会覆盖 HEAD 提交区域。

另一种选项是 混合重置;你可以使用 --mixed 选项(或者直接不加选项,因为这是默认选项)来实现:

[43] ~/grocery ((603b9d1...))
$ git reset --mixed master
Unstaged changes after reset:
M   shoppingList.txt

好吧,这里有一些不同:Git 告诉我们有未暂存的变化。事实上,--mixed 选项让 Git 覆盖了暂存区,而不仅仅是 HEAD 提交。如果你使用 git diff 检查 HEAD 提交和暂存区之间的差异,你会发现没有任何差异:

[44] ~/grocery ((603b9d1...))
$ git diff --cached HEAD

相反,HEAD 提交和工作区之间会产生差异:

[45] ~/grocery ((603b9d1...))
$ git diff HEAD
diff --git a/shoppingList.txt b/shoppingList.txt
index 175eeef..93dcf0e 100644
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@ -1,4 +1,5 @@
 banana
 apple
 orange
-peach
+onion
+garlic

这种混合重置技巧可以很有用,例如,用一个简单的 git reset HEAD 清理所有暂存的更改。

在这一点上,你可以推测 --hard 选项的作用:它会覆盖三个区域:

[46] ~/grocery ((603b9d1...))
$ git reset --hard master
HEAD is now at 603b9d1 Add a peach

[47] ~/grocery ((603b9d1...))
$ git diff --cached HEAD

[48] ~/grocery ((603b9d1...))
$ git diff HEAD

事实上,现在在任何级别上都没有差异。

这种硬重置技巧用于完全丢弃我们所做的所有更改,使用 git reset --hard HEAD 命令,就像我们之前的实验一样。

我们完成了。现在我们对 git checkoutgit reset 命令有了更多了解;但在离开之前,回到非分离的 HEAD 状态,切换到 master 分支:

[49] ~/grocery ((603b9d1...))
$ git checkout master
Switched to branch 'master'

变基

现在我想告诉你一些关于 git rebase 命令的内容;rebase 是使用版本控制系统时常见的术语,即便在 Git 中,这也是一个热门话题。

基本上,使用 git rebase 你可以重写历史;通过这句话,我的意思是你可以使用 rebase 命令来实现以下操作:

  • 将两个或更多提交合并成一个新的提交

  • 丢弃你之前做的提交

  • 更改分支的起点,拆分分支,以及更多操作

重新整理提交

git rebase 命令最广泛的用途之一是重新排序或合并提交。对于这种方法,假设你需要合并两个不同的提交。

假设我们错误地在 shoppingList.txt 文件中添加了一半的葡萄,然后又添加了另一半,但最终我们希望只有一个提交记录整个葡萄;跟着我做这些步骤。

在购物清单文件中添加一个 gr

[1] ~/grocery (master)
$ echo -n "gr" >> shoppingList.txt

-n 选项用于不添加新行。

使用 cat 命令查看文件以确认:

[2] ~/grocery (master)
$ cat shoppingList.txt
banana
apple
orange
peach
gr

现在执行第一个提交:

[3] ~/grocery (master)
$ git commit -am "Add half a grape"
[master edac12c] Add half a grape
 1 file changed, 1 insertion(+)

好的,我们有一个提交,里面只有半个葡萄。接着加上另一半,ape

[4] ~/grocery (master)
$ echo -n "ape" >> shoppingList.txt

查看文件:

[5] ~/grocery (master)
$ cat shoppingList.txt
banana
apple
orange
peach
grape

执行第二个提交:

[6] ~/grocery (master)
$ git commit -am "Add the other half of the grape"
[master 4142ad9] Add the other half of the grape
 1 file changed, 1 insertion(+), 1 deletion(-)

查看日志:

[7] ~/grocery (master)
$ git log --oneline --graph --decorate --all
* 4142ad9 (HEAD -> master) Add the other half of the grape
* edac12c Add half a grape
* 603b9d1 Add a peach
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

好吧,这有点不方便:我想只保留一个包含整个grape的提交。

我们可以通过交互式变基(interactive rebase)来修复这个错误。为此,我们需要变基最后两个提交,创建一个新的提交,实际上它是这两个提交的总和。

所以,输入 git rebase -i HEAD~2 看看会发生什么;-i 表示交互式,而 HEAD~2 参数表示我想要变基最后两个提交

这是控制台的截图:

正如你在前面的截图中看到的,Git 会打开默认的编辑器 Vim。然后它会告诉我们如何编辑这个临时文件(你可以看到截图底部的位置),并给出一些注释行(以 # 开头)。

让我们仔细阅读这个信息。

在这里我们可以重新排序提交行;仅仅这样做,我们实际上是改变了仓库中提交的顺序。这看起来可能是一个不太有用的功能,但如果你计划在这个变基之后创建新分支,并且希望在此之前清理一下环境,它还是有用的。

然后你可以删除行:如果删除一行,基本上就是丢弃对应的提交。

最后,对于每一行(每个提交),你可以使用 Vi 编辑器中注释所示的以下命令之一:

  • # p, pick = 使用提交:如果你选择一个提交,该提交将继续成为你仓库的一部分。可以理解为,好吧,我想保留这个提交不变

  • # r, reword = 使用提交,但编辑提交信息:Reword 允许你更改提交信息,如果你发现自己写错了某些内容,它会很有用。可以理解为,好吧,我想保留这个提交,但我要修改提交信息

  • # e, edit = 使用提交,但停下来进行修改:当你修改一个提交时,你基本上是想重新组合它。例如,你忘记将一个文件包含在其中,或者你多添加了一个。如果你标记一个提交为编辑,Git 会停止后续的变基操作,让你做需要的修改。所以,提交将会被保留,但它会被修改。

  • # s, squash = 使用提交,但合并到前一个提交:Squash 是我们会再次看到的一个术语,它基本上意味着将两个或更多提交合并。在这种情况下,如果你 squash 一个提交,它会被删除,但其中的更改会成为前一个提交的一部分。这可能是我们需要的命令?

  • # f, fixup = 类似于“squash”,但丢弃此提交的日志信息:Fixup 类似于 squash,但它会为你提供一个新的提交信息。这肯定是我需要的,因为我希望新的 grape 提交有一个新的信息。

  • # x, exec = 使用 shell 运行命令(该行的其余部分):Exec 是高级内容。你基本上是在告诉 Git,在接下来的变基操作中,它将操作该提交时运行一个特定的命令。这可以用来做一些你在两次提交之间忘记做的事情,重新运行一些测试,或者其他什么操作。

  • # d, drop = 删除提交:Drop 简单地删除提交,就像删除整行一样。

好的,现在我们可以继续了。我们必须使用这些命令修改这个文件,然后保存并退出:Git 接着会继续变基过程,按顺序执行每一个命令,从上到下。

为了解决我们的这个问题,我将重新编写第一个提交,然后修正第二个提交;以下是我的控制台截图:

请注意,你可以使用命令的长格式或短格式(例如,f ->为短格式,fixup ->为长格式)。

好的,现在 Git 开始工作,并打开一个新的临时文件,允许我们为我们决定要重新编写的提交(也就是第一个提交)写入新的信息。以下是截图:

注意,Git 会逐字告诉我们它将要做什么。

现在编辑消息,然后保存并退出,像下面的截图那样:

ENTER键,我们完成了。

这是 Git 的最终消息:

[8] ~/grocery (master)
$ git rebase -i HEAD~2
unix2dos: converting file C:/Users/san/Google Drive/Packt/PortableGit/home/grocery/[detached HEAD 53c73dd] Add a grape
 Date: Sat Aug 26 14:00:58 2017 +0200
 1 file changed, 1 insertion(+)
Successfully rebased and updated refs/heads/master.

看一下日志:

[9] ~/grocery (master)
$ git log --oneline --graph --decorate --all
* 6409527 (HEAD -> master) Add a grape
* 603b9d1 Add a peach
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

太棒了!我们刚刚完成了任务。

现在,让我们做一个关于变基(rebasing)分支的小实验。

变基分支

使用git rebase命令,你还可以修改分支的历史;在仓库中,你经常做的一件事就是改变——或者更确切地说——移动一个分支的起点,把它带到树的另一个点上。这项操作使得可以保持较低的分支层级,否则使用git merge命令时,分支层级会较高,稍后我们会看到这个命令。

为了更好地理解这一点,让我给你举个例子。

假设在添加橙色的提交时,过去已经创建了一个nuts分支,向其中添加了一个核桃。

此时,让我们假设我们想将这个分支向上移动到现在master所在的位置,就像这个分支本应从那里而不是从橙色提交开始一样。

让我们看看如何使用git rebase命令来实现这一点。

让我们从创建一个指向提交0e8b5cf的新分支开始,这个提交是橙色的:

[1] ~/grocery (master)
$ git branch nuts 0e8b5cf

这次我使用了git branch命令,后面跟着两个参数:分支的名称和要贴上标签的提交。结果,一个新的nuts分支已经创建:

[2] ~/grocery (master)
$ git log --oneline --graph --decorate --all
* 6409527 (HEAD -> master) Add a grape
* 603b9d1 Add a peach
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
|/
* 0e8b5cf (nuts) Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

使用git checkout命令将HEAD移动到新分支:

[3] ~/grocery (master)
$ git checkout nuts
Switched to branch 'nuts'

好的,现在是时候添加一个walnut了;把它添加到shoppingList.txt文件中:

[4] ~/grocery (nuts)
$ echo "walnut" >> shoppingList.txt

然后进行提交:

[5] ~/grocery (nuts)
$ git commit -am "Add a walnut"
[master 3d3ae9c] Add a walnut
 1 file changed, 1 insertion(+), 1 deletion(-)

查看日志:

[6] ~/grocery (nuts)
$ git log --oneline --graph --decorate --all
* 9a52383 (HEAD -> nuts) Add a walnut
| * 6409527 (master) Add a grape
| * 603b9d1 Add a peach
|/
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

如你所见,图形现在稍微复杂了一些;从orange提交开始,有三条分支:berriesmasternuts

现在我们想要移动nuts分支的起始点,将其从orange提交移到grape提交,就好像nuts分支只有一个提交,紧接在master之后。

让我们继续,进行将nuts分支变基到master分支上;请再次确认你确实在nuts分支上,因为变基命令基本上是将当前分支(nuts)变基到目标分支master上;所以:

[7] ~/grocery (nuts)
$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: Add a walnut
Using index info to reconstruct a base tree...
M   shoppingList.txt
Falling back to patching base and 3-way merge...
Auto-merging shoppingList.txt
CONFLICT (content): Merge conflict in shoppingList.txt
Patch failed at 0001 Add a walnut
The copy of the patch that failed is found in: .git/rebase-apply/patch

When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".

error: Failed to merge in the changes.

好吧,别担心:变基失败了,但这不是问题。实际上,它失败了是因为 Git 无法自动合并shoppingList.txt文件版本之间的差异。

阅读提示信息:现在你有三种选择:

  1. 修复合并冲突后,继续执行git rebase --continue命令。

  2. 跳过此步骤,并使用git rebase --skip丢弃修改。

  3. 使用git rebase --abort终止变基操作。

我们将选择第一个选项,但我想先告诉你关于第二个和第三个选项的一些信息。

在进行变基时,Git 会在内部创建补丁并将其应用到我们正在移动的提交上;实际上,在变基分支时,你实际上是将所有的提交移到另一个你选择的提交上,在这个例子中是master分支上的最后一个提交。

在这种情况下,nuts分支只有一个提交,因此 Git 将目标提交(master上的 grape 提交)与nuts分支上的 walnut 提交进行比较。最终,只需要一个比较和补丁步骤(这就是控制台中REBASE 1/1消息的含义:你正在变基第 1 个提交,总共需要变基 1 个提交)。

话虽如此,你现在可以理解git rebase --skip的含义了:如果你发现当前的补丁步骤既不有用也不必要,你可以跳过它,继续进行下一个步骤。

最后,使用git rebase --abort你只是简单地停止当前的变基操作,回到变基前的状态。

现在,回到我们的仓库;如果你用 Vim 打开文件,你会看到生成的冲突:

[8] ~/grocery (nuts|REBASE 1/1)
$ vi shoppingList.txt

walnut已添加到第 4 行,但在master分支中,这一行被peach占据,然后是grape

我将通过在文件末尾添加walnut来解决这个问题:

现在,下一步是使用git addshoppingList.txt文件添加到暂存区,然后按照之前的提示继续执行git rebase --continue命令:

[9] ~/grocery (nuts|REBASE 1/1)
$ git add shoppingList.txt

[10] ~/grocery (nuts|REBASE 1/1)
$ git rebase --continue
Applying: Add a walnut

[11] ~/grocery (nuts)
$

如你所见,执行git rebase --continue命令后,变基成功完成(没有错误,且在步骤[11]的命令行提示符中不再出现REBASE信息)。

现在像往常一样使用git log查看仓库:

[12] ~/grocery (nuts)
$ git log --oneline --graph --decorate --all
* 383d95d (HEAD -> nuts) Add a walnut
* 6409527 (master) Add a grape
* 603b9d1 Add a peach
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

做得好!nuts分支现在只比master分支多了一个提交。

好的,现在为了保持最简洁、最紧凑的仓库,我们取消walnut提交,并将一切恢复到这个小实验之前的状态,甚至删除nuts分支:

[13] ~/grocery (nuts)
$ git reset --hard HEAD^
HEAD is now at 6409527 Add a grape

[14] ~/grocery (nuts)
$ git checkout master
Switched to branch 'master'

[15] ~/grocery (master)
$ git branch -d nuts
Deleted branch nuts (was 6409527).

干得好。

变基(Rebasing)是一个广泛而相当复杂的话题;我们需要另一个章节(甚至一本书)来讲述它的全部内容,但这基本上是我们需要了解的关于重写历史的内容。

合并分支

是的,我知道,你可能会想:“从我们开始操作分支时,为什么他不讲讲合并?”

现在时刻到了。

在 Git 中,合并两个(或多个!)分支是让它们的历史合并在一起。当它们合并时,可能会发生两件事:

  • 文件在它们的最新提交中不同,因此会出现一些冲突。

  • 文件没有冲突

  • 目标分支的提交直接位于我们要合并的分支提交之后,所以会发生快进合并。

在前两种情况下,Git 将指导我们组装一个新的提交,这就是所谓的合并提交;而在快进的情况下,则不需要新的提交:Git 只会将目标分支标签移动到我们要合并的分支的最新提交。

让我们试试吧。

我们可以尝试将melons分支合并到master分支;为此,你需要先检出目标分支,这里是master,然后执行git merge <branch name>命令;由于我已经在master分支上,所以直接执行merge命令:

[1] ~/grocery (master)
$ git merge melons
Auto-merging shoppingList.txt
CONFLICT (content): Merge conflict in shoppingList.txt
Automatic merge failed; fix conflicts and then commit the result.

哎呀,这里有冲突。Git 总是尝试自动合并文件(它使用复杂的算法来减少你对文件的手动处理),但如果你有疑问,可以假装自己手动修复这些问题。

查看git diff中的冲突:

[2] ~/grocery (master|MERGING)
$ git diff
diff --cc shoppingList.txt
index 862debc,7786024..0000000
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@@ -1,5 -1,5 +1,10 @@@
 banana
 apple
 orange
++<<<<<<< HEAD
 +peach
- grape

++grape
++=======
+ blackberry
+ watermelon
++>>>>>>> melons

好的,很明显我们shoppingList.txt的第四和第五行在两个分支中发生了分歧:在master中,它们分别是peachgrape,而在melons分支中,这两行被blackberrywatermelon占据。

注意 Shell 提示符:它在分支名称后面有MERGING字样,提醒我们正在进行合并。而- grape ++grape部分不用理会:那是我的 Windows 电脑与 GNU/Linux Git 子系统之间的行尾符号不匹配。

要解决合并,你需要相应地编辑文件,然后添加并提交它;我们开始吧。

我将在文件中按以下截图的顺序,排队blackberrywatermelon,将它们放在peachgrape之后:

保存文件后,将它添加到暂存区,然后提交:

[3] ~/grocery (master|MERGING)
$ git add shoppingList.txt

[4] ~/grocery (master|MERGING)
$ git commit -m "Merged melons branch into master"
[master e18a921] Merged melons branch into master

提交完成,合并也结束了。完美!

现在看一下日志:

[5] ~/grocery (master)
$ git log --oneline --graph --decorate --all
*   e18a921 (HEAD -> master) Merged melons branch into master
|\
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
* | 6409527 Add a grape
* | 603b9d1 Add a peach
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

哇,这真酷!

看看绿色路径,右侧的那条:这就是现在master分支的新历史。从最开始的香蕉提交开始,然后是appleorange,接着是peachgrapeblackberrywatermelon提交。

master分支上的尖端提交是合并提交,合并的结果。你能看出来 Git 是如何绘制这个图表的吗?

建议:使用git cat-file -p查看合并提交:

[6] ~/grocery (master)
$ git cat-file -p HEAD
tree 2916dd995ee356351c9b49a5071051575c070e5f
parent 6409527a1f06d0bbe680d461666ef8b137ac7135
parent a8c62190fb1c54d1034db78a87562733a6e3629c
author Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1503754221 +0200
committer Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 1503754221 +0200

Merged melons branch into master

啊哈!这个提交有两个父提交!实际上,这是两个先前提交合并的结果,这也是 Git 处理合并的方式。通过将两个父提交存储在提交中,Git 可以跟踪合并,并利用这些信息绘制图表,帮助你记住,哪怕过了几年,什么时候、如何合并了两个分支。

快速前进

合并并不总是会生成新的提交;为了测试这种情况,尝试将melons分支合并到berries分支:

[7] ~/grocery (master)
$ git checkout berries
Switched to branch 'berries'

[8] ~/grocery (berries)
$ git merge melons
Updating ef6c382..a8c6219
Fast-forward
 shoppingList.txt | 1 +
 1 file changed, 1 insertion(+)

[9] ~/grocery (berries)
$ git log --oneline --graph --decorate --all
*   e18a921 (master) Merged melons branch into master
|\
| * a8c6219 (HEAD -> berries, melons) Add a watermelon
| * ef6c382 Add a blackberry
* | 6409527 Add a grape
* | 603b9d1 Add a peach
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

由于melons分支只比berries分支多了一个提交,并且它们之间的变化没有冲突,因此在这里进行合并仅仅是一瞬间的事:Git 只需要将berries标签移到melons分支的相同提交位置。

这就是所谓的快速前进

这次没有合并提交,因为这不是必须的;有人会争辩说,这样做你会丢失合并了两个分支的时间信息。如果你希望 Git 总是创建一个新的合并提交,可以使用--no-ff(无快速前进)选项。

想试试吗?好的,这是进行另一个练习的好机会。

使用git resetberries分支移回原位:

[10] ~/grocery (berries)
$ git reset --hard HEAD^
HEAD is now at ef6c382 Add a blackberry

[11] ~/grocery (berries)
$ git log --oneline --graph --decorate --all
*   e18a921 (master) Merged melons branch into master
|\
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (HEAD -> berries) Add a blackberry
* | 6409527 Add a grape
* | 603b9d1 Add a peach
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

我们刚刚撤销了一个合并,你意识到了吗?

好的,现在使用--no-ff选项再次进行合并:

[12] ~/grocery (berries)
$ git merge --no-ff melons

Git 现在会打开你的默认编辑器,允许你指定一个提交信息,正如下面的截图所示:

如你所见,当 Git 能够自动合并更改时,它会自动进行;然后它会要求你输入一个提交信息,建议一个默认的消息。

接受默认信息,保存并退出:

[13] ~/grocery (berries)
Merge made by the 'recursive' strategy.--all
 shoppingList.txt | 1 +
 1 file changed, 1 insertion(+)

合并完成。

Git 告诉我们自动合并时采用了什么合并策略,然后告诉我们在文件和文件更改(插入或删除)方面发生了哪些变化。

现在执行git log

[14] ~/grocery (berries)
$ git log --oneline --graph --decorate --all
*   cb912b2 (HEAD -> berries) Merge branch 'melons' into berries
|\
| | *   e18a921 (master) Merged melons branch into master
| | |\
| | |/
| |/|
| * | a8c6219 (melons) Add a watermelon
|/ /
* | ef6c382 Add a blackberry
| * 6409527 Add a grape
| * 603b9d1 Add a peach
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

好的,现在图表突出显示了两个分支之间的合并。正如你所看到的,图表现在变得稍微复杂了一些,这也是为什么通常更倾向于做快速前进合并:它会以一个更紧凑且简单的仓库结构结束。

我们已经完成了这些实验;无论如何,我想撤销这个合并,因为我想保持仓库尽可能简单,以便你能更好地理解我们一起做的练习;执行git reset --hard HEAD^

[15] ~/grocery (berries)
$ git reset --hard HEAD^
HEAD is now at ef6c382 Add a blackberry

[16] ~/grocery (berries)
$ git log --oneline --graph --decorate --all
*   e18a921 (master) Merged melons branch into master
|\
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (HEAD -> berries) Add a blackberry
* | 6409527 Add a grape
* | 603b9d1 Add a peach
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

好的,现在撤销我们在master分支上做的那个合并:

[17] ~/grocery (master)
$ git reset --hard HEAD^
HEAD is now at 6409527 Add a grape

[18] ~/grocery (master)
$ git log --oneline --graph --decorate --all
* 6409527 (HEAD -> master) Add a grape
* 603b9d1 Add a peach
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

我相信你现在明白了:在 Git 中撤销合并是很简单的。我想向你展示这一点,因为有时候合并分支会让人感到害怕;做完之后,有时你会意识到自己搞砸了项目,感觉崩溃。其实不用担心:从这种情况恢复是很简单的。

樱桃挑选(Cherry picking)

有时候你不想合并两个分支,而只是想将一个提交中的更改应用到另一个分支的顶部。这种情况在处理 bug 时非常常见:你在一个分支中修复了一个 bug,然后你想将相同的修复应用到另一个分支的顶部。

Git 提供了一种方便的方式来处理这个问题;这就是 git cherry-pick 命令。

让我们稍微玩一下这个。

假设你想从 berries 分支中挑选出 blackberry,然后将其应用到 master 分支;方法如下:

[1] ~/grocery (master)
$ git cherry-pick ef6c382
error: could not apply ef6c382... Add a blackberry
hint: after resolving the conflicts, mark the corrected paths
hint: with 'git add <paths>' or 'git rm <paths>'
hint: and commit the result with 'git commit'

对于参数,你通常需要指定你想挑选的提交的哈希值;在这种情况下,由于该提交甚至被 berries 分支标签引用,执行 git cherry-pick berries 将是相同的。

好的,cherry-pick 引发了一个冲突,当然:

[2] ~/grocery (master|CHERRY-PICKING)
$ git diff
diff --cc shoppingList.txt
index 862debc,b05b25f..0000000
--- a/shoppingList.txt
+++ b/shoppingList.txt
@@@ -1,5 -1,4 +1,9 @@@
 banana
 apple
 orange
++<<<<<<< HEAD
 +peach
- grape
++grape
++=======
+ blackberry
++>>>>>>> ef6c382... Add a blackberry

shoppingList.txt 文件版本的第四行已被不同的水果修改。解决冲突后再添加提交:

[3] ~/grocery (master|CHERRY-PICKING)
$ vi shoppingList.txt

以下是我 Vim 控制台的截图,文件按照我喜欢的方式排列:

[4] ~/grocery (master|CHERRY-PICKING)
$ git add shoppingList.txt

[5] ~/grocery (master|CHERRY-PICKING)
$ git status
On branch master
You are currently cherry-picking commit ef6c382.
 (all conflicts fixed: run "git cherry-pick --continue")
 (use "git cherry-pick --abort" to cancel the cherry-pick operation)

Changes to be committed:

modified:   shoppingList.txt

注意 git status 输出:你总会看到一些建议;在这种情况下,如果你想中止一个 cherry-pick 并撤销你所做的所有操作,你可以执行 git cherry-pick --abort(即使在变基或合并时,你也可以执行相同的操作)。

现在继续提交吧:

[6] ~/grocery (master)
$ git commit -m "Add a cherry-picked blackberry"
On branch master
nothing to commit, working tree clean

[7] ~/grocery (master)
$ git log --oneline --graph --decorate --all
* 99dd471 (HEAD -> master) Add a cherry-picked blackberry
* 6409527 Add a grape
* 603b9d1 Add a peach
| * a8c6219 (melons) Add a watermelon
| * ef6c382 (berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

好的,如你所见,出现了一个新的提交,但图表中没有新的路径。与合并功能不同,使用 cherry-picking,你只选择指定提交中所做的更改,并且不会在被选择的提交和新创建的提交之间存储任何关系。

如果你想追踪你挑选的提交是什么,你可以在 git cherry-pick 命令中加上 -x 选项;然后,在提交时,不要使用 -m 选项附加消息,而是输入 git commit 然后按 ENTER,让 Git 打开编辑器:它会建议一个包含 cherry-pick 提交哈希值的消息,如下图所示:

这是追踪 cherry-pick 的唯一方法,如果你想的话。

总结

我知道这一章非常长。

但现在我想你已经知道了所有在本地仓库中熟练使用 Git 所需的知识。你了解工作树、暂存区和 HEAD 提交;你知道如何使用分支和 HEAD 引用;你知道如何合并、变基和进行 cherry-pick;最后,你知道 Git 是如何在后台工作的,这将帮助你从这里开始。

在下一章,我们将学习如何处理远程仓库,以及如何从像 GitHub 这样的服务器推送和拉取更改。

第三章:Git 基础 - 使用远程仓库

在上一章中,我们学习了很多关于 Git 的知识;我们了解了 Git 的内部工作原理以及如何管理本地仓库,但现在是时候学习如何共享我们的代码了。

在这一章中,我们终于开始以分布式的方式工作,使用远程服务器作为不同开发者之间的联络点。我们将关注以下主要内容:

  • 处理远程仓库

  • 克隆远程仓库

  • 使用在线托管服务,例如 GitHub

正如我们之前所说,Git 是一个分布式版本控制系统:本章涉及的是分布式部分。

使用远程仓库

Git 是一个文件版本控制工具,正如你所知道的,但它是为了协作而构建的。2005 年,Linus Torvalds 需要一个轻量且高效的工具来共享 Linux 内核代码,使他和其他几百人能够共同工作而不崩溃;这种务实的开发理念给我们带来了一个非常强大的数据共享层,不需要中央服务器。

基本上,Git 的远程仓库是另一个“位置”,它拥有和你电脑上相同的仓库。如图所示,你可以将其视为同一仓库的不同副本,它们可以相互交换数据:

所以,远程 Git 仓库只是我们在本地创建的 Git 仓库的远程副本;如果你通过 SSH、git://协议或其他常见协议可以访问该远程仓库,你就可以与它同步修改。

甚至你电脑上的另一个文件夹也可以充当远程仓库:对 Git 而言,文件系统就像任何其他通信协议,如果你愿意,可以使用它。

这是我们掌握远程仓库基本概念的方法。

克隆本地仓库

在你的磁盘上创建一个新文件夹来克隆我们的grocery仓库:

[1] ~
$ mkdir grocery-cloned

然后使用git clone命令克隆grocery仓库:


[2] ~ $ cd grocery-cloned [3] ~/grocery-cloned $ git clone ~/grocery . Cloning into '.'...

完成。

命令末尾的点符号 .参数意味着将仓库克隆到当前文件夹,而~/grocery参数实际上是 Git 查找仓库的路径。

现在,直接使用git log命令查看:

[4] ~/grocery-cloned (master)
$ git log --oneline --graph --decorate --all
* 6409527 (HEAD -> master, origin/master, origin/HEAD) Add a grape
* 603b9d1 Add a peach
| * a8c6219 (origin/melons) Add a watermelon
| * ef6c382 (origin/berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

正如你所见,除了绿色的master分支标签,我们在日志输出中还看到一些红色的origin/<branch>标签。

origin

什么是origin

Git 默认将 origin 作为远程仓库的名称。就像分支的master一样,origin只是一个约定:你可以根据需要将远程仓库命名为任何你喜欢的名字。

这里有一个有趣的地方需要注意,那就是 Git 通过git log命令中的--all选项,向我们展示了远程仓库中还有其他分支,但正如你所见,它们并没有出现在本地克隆的仓库中。在克隆的仓库中,只有master分支。

但是不用担心:你可以通过简单地检出创建一个本地分支来进行本地工作:

[5] ~/grocery-cloned (master)
$ git checkout berries
Branch berries set up to track remote branch berries from origin.
Switched to a new branch 'berries'

看看消息,Git 说本地分支已设置为跟踪远程分支;这意味着,从现在开始,Git 会主动跟踪本地分支与远程分支之间的差异,并在输出消息中通知你这些差异(例如,在使用 git status 命令时)。

话虽如此,如果你在这个分支上做一个提交,你可以将它发送到远程,它将成为远程 origin/berries 的一部分。

这看起来显而易见,但在 Git 中,你可以根据需要配对分支;例如,如果你愿意,可以通过本地的 bar 分支来跟踪远程的 origin/foo 分支。或者,你也可以有一些本地分支,它们在远程并不存在。稍后我们将看看如何处理远程分支。

现在,再次查看日志:

[6] ~/grocery-cloned (berries)
$ git log --oneline --graph --decorate --all
* 6409527 (origin/master, origin/HEAD, master) Add a grape
* 603b9d1 Add a peach
| * a8c6219 (origin/melons) Add a watermelon
| * ef6c382 (HEAD -> berries, origin/berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

现在,一个绿色的 berries 标签出现在靠近红色的 origin/berries 标签的位置;这让我们意识到,本地的 berries 分支和远程的 origin/berries 分支指向相同的提交。

如果我做了一个新的提交,会发生什么?

让我们尝试:

[7] ~/grocery-cloned (berries)
$ echo "blueberry" >> shoppingList.txt

[8] ~/grocery-cloned (berries)
$ git commit -am "Add a blueberry"
[berries ab9f231] Add a blueberry
Committer: Santacroce Ferdinando <san@intre.it>

你的姓名和电子邮件地址已根据你的用户名主机名自动配置。请检查它们是否准确。

你可以通过显式设置来抑制此消息:

git config --global user.name "Your Name"
git config --global user.email you@example.com

完成此操作后,你可以通过以下代码修复此提交所使用的身份:

git commit --amend --reset-author

1 file changed, 1 insertion(+)

与上一章一样,Git 提示我关于作者和电子邮件的问题;这次我将使用建议的设置。

好的,让我们看看发生了什么:

[9] ~/grocery-cloned (berries)
$ git log --oneline --graph --decorate --all
* ab9f231 (HEAD -> berries) Add a blueberry
| * 6409527 (origin/master, origin/HEAD, master) Add a grape
| * 603b9d1 Add a peach
| | * a8c6219 (origin/melons) Add a watermelon
| |/
|/|
* | ef6c382 (origin/berries) Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

不错!本地的 berries 分支向前推进,而 origin/berries 仍然停留在原地。

使用 git push 分享本地提交

如你所知,Git 在本地工作;不需要远程服务器。

所以,当你做一个提交时,它仅在本地可用;如果你想与远程对等体共享它,你必须以某种方式发送它。

在 Git 中,这叫做 push

现在,我们将尝试将 berries 分支中的修改推送到 origin;命令是 git push,后跟远程名称和目标分支:

[10] ~/grocery-cloned (berries)
$ git push origin berries
Counting objects: 3, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 323 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To C:/Users/san/Google Drive/Packt/PortableGit/home/grocery
 ef6c382..ab9f231  berries -> berries

哇!这个输出信息包含了很多内容。基本上,Git 在发送提交到远程之前和过程中向我们提供了操作的相关信息。

请注意,Git 只会将它知道的远程不存在的对象(在这种情况下是三个:一个提交、一个树和一个 Blob)发送到远程,它不会发送无法访问的提交,也不会发送与无法访问的提交相关的其他对象(例如树、Blob 或注释,这些对象仅与无法访问的提交相关)。

最后,Git 告诉我们它正在将对象发送到哪里,目标是我的计算机上的另一个文件夹:To C:/Users/san/Google Drive/Packt/PortableGit/home/grocery。然后,它告诉我们 origin/berries 原本的位置的提交哈希值,以及与本地 berries 分支相同的新提交哈希值 ef6c382..ab9f231。最后,它给出了两个分支的名称,本地分支和远程分支,berries -> berries

现在,我们显然想查看在远程仓库中,berries 分支是否有新的提交;所以,打开 grocery 文件夹,在新的控制台中执行 git log

[11] ~
$ cd grocery

[12] ~/grocery (master)
$ git log --oneline --graph --decorate --all
* ab9f231 (berries) Add a blueberry
| * 6409527 (HEAD -> master) Add a grape
| * 603b9d1 Add a peach
| | * a8c6219 (melons) Add a watermelon
| |/
|/|
* | ef6c382 Add a blackberry
|/
* 0e8b5cf Add an orange
* e4a5e7b Add an apple
* a57d783 Add a banana to the shopping list

是的,太棒了!

提个小提醒:通常,远程仓库副本被管理为 裸仓库;在第四章中,Git 基础 - 小众概念、配置与命令,我们将对其做一些介绍。因为你通常不会直接在其上工作,裸仓库只包含 .git 文件夹;它没有已检出的工作树,也没有 HEAD 引用。相反,我们使用一个普通仓库作为远程仓库。这并不是问题;你只需要记住一件事:你不能向实际检出的远程分支推送更改。

事实上,在那个 grocery 仓库中,我们实际上是在 master 分支上,而在 grocery-cloned 仓库中,我们推送了 berries 分支。

这样做的原因非常简单易懂:通过推送到一个远程的已检出的分支,你会影响该仓库中的工作进展,可能会破坏正在进行的更改,这是不公平的。

使用 git pull 获取远程提交

现在,是时候尝试相反的操作了:从远程仓库获取更新并将它们应用到我们的本地副本。

所以,在 grocery 仓库中做一个新的提交,然后我们将其下载到 grocery-cloned 仓库中:

[13] ~/grocery (master)
$ printf "\r\n" >> shoppingList.txt

我首先需要创建一个新行,因为由于之前的 葡萄 rebase,我们最终发现 shoppinList.txt 文件的末尾没有新的一行,这是 echo "" >> <file> 通常会做的事:

[14] ~/grocery (master)
$ echo "apricot" >> shoppingList.txt

[15] ~/grocery (master)
$ git commit -am "Add an apricot"
[master 741ed56] Add an apricot
 1 file changed, 2 insertions(+), 1 deletion(-)

好的,现在回到 grocery-cloned 仓库。

我们可以通过 git pull 从远程获取对象。

事实上,git pull 是一个 超级命令;实际上,它基本上是另外两个 Git 命令 git fetchgit merge 的合成。在从远程获取对象时,Git 不会强制你将它们合并到本地分支;这可能一开始会让人感到困惑,因为在其他版本控制系统中,如 Subversion,这是默认行为。

相反,Git 更加保守:可能有人在一个分支上推送了一个或多个提交,但你意识到这些提交对你来说不合适,或者它们根本就是错的。所以,通过使用 git fetch,你可以获取并检查这些提交,然后再通过 git merge 将它们应用到你的本地分支上。

现在先尝试一下 git pull,然后我们会分别尝试使用 git fetchgit merge

返回到 grocery-cloned 仓库,切换到 master 分支,然后执行 git pull

[16] ~/grocery-cloned (berries)
$ git checkout master
Your branch is up-to-date with 'origin/master'.
Switched to branch 'master'

Git 说我们的分支与 'origin/master' 是最新的,但这并不是真的,因为我们刚刚在那里做了一个新的提交。这是因为,对于 Git 来说,唯一能知道我们是否已更新相对于远程仓库的方式是执行 git fetch,而我们没有这么做。稍后我们将更加清楚地看到这一点。

目前,使用 git pull:该命令需要你指定要拉取的远程仓库名称,这里是 origin,然后是你想要合并到本地分支的分支,当然是 master

[17] ~/grocery-cloned (master)
$ git pull origin master
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From C:/Users/san/Google Drive/Packt/PortableGit/home/grocery
 * branch    master     -> FETCH_HEAD
 6409527..741ed56  master     -> origin/master
Updating 6409527..741ed56
Fast-forward
 shoppingList.txt | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

很好!Git 告诉我们有三个新的对象需要获取;在获取它们后,它会在本地 master 分支上执行合并,在这种情况下,它执行的是快进合并。

好的,现在我想让你分开执行这些步骤;在 grocery 仓库的 master 分支上创建一个新的提交:

[18] ~/grocery (master)
$ echo "plum" >> shoppingList.txt

[19] ~/grocery (master)
$ git commit -am "Add a plum"
[master 50851d2] Add a plum
1 file changed, 1 insertion(+)

现在在 grocery-cloned 仓库上执行 git fetch

[20] ~/grocery-cloned (master)
$ git fetch
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From C:/Users/san/Google Drive/Packt/PortableGit/home/grocery
 741ed56..50851d2  master     -> origin/master

如你所见,Git 在远程找到了新的对象,并下载了它们。

请注意,你可以在任何分支中执行 git fetch,因为它只是下载远程对象;它不会合并这些对象。而在执行 git pull 时,你必须确保自己处于正确的本地目标分支。

现在执行 git status

[21] ~/grocery-cloned (master)
$ git status
On branch master
Your branch is behind 'origin/master' by 1 commit, and can be fast-forwarded.
 (use "git pull" to update your local branch)
nothing to commit, working tree clean

好的,正如你所见,当有远程仓库时,git status 会告诉你本地仓库与远程仓库之间的 同步 状态;在这里它告诉我们,我们落后于远程仓库,因为远程仓库的 master 分支比我们多了一个提交,而这个提交可以通过快进合并。

现在,让我们通过 git merge 进行同步;为了合并一个远程分支,我们必须指定分支名称和远程仓库名称,正如我们之前在 git pull 命令中所做的那样:

[22] ~/grocery-cloned (master)
$ git merge origin master
Updating 741ed56..50851d2
Fast-forward
 shoppingList.txt | 1 +
 1 file changed, 1 insertion(+)

就这些!

这基本上就是你需要了解的远程仓库操作的全部内容。注意,如果远程仓库的某些更改与你本地的更改发生冲突,你需要像我们在前面的合并示例中一样解决这些冲突。

Git 如何跟踪远程仓库

Git 以类似存储本地分支标签的方式存储远程分支标签;它在 refs 目录下为作用域创建一个子文件夹,并使用我们为远程仓库指定的符号名称,在这种情况下是 origin,默认名称:

[23] ~/grocery-cloned (master)
$ ll .git/refs/remotes/origin/
total 3
drwxr-xr-x 1 san 1049089  0 Aug 27 11:25 ./
drwxr-xr-x 1 san 1049089  0 Aug 26 18:19 ../
-rw-r--r-- 1 san 1049089 41 Aug 26 18:56 berries
-rw-r--r-- 1 san 1049089 32 Aug 26 18:19 HEAD
-rw-r--r-- 1 san 1049089 41 Aug 27 11:25 master

处理远程仓库的命令是 git remote;你可以用它添加、删除、重命名、列出远程仓库,还可以做很多其他操作;这里没有空间展示所有选项。如果你需要了解更多关于 git remote 命令的信息,请参考 Git 指南。

现在,我们将在公共服务器上玩一点远程仓库;我们将使用免费的 GitHub 托管服务。

在 GitHub 上使用公共服务器

要开始使用公共托管远程仓库,我们需要先获得一个。如今,实现这一点并不困难;世界上有许多免费的在线服务提供 Git 仓库空间,其中最常用的之一是 GitHub

设置一个新的 GitHub 账户

GitHub 提供无限的免费公共仓库,因此我们可以在不投入一分钱的情况下使用它。在 GitHub 上,只有需要私有仓库时才需要付费;例如,用于存储你基于其业务的闭源代码。

创建新账户很简单:

  1. 访问 github.com

  2. 按照以下图片填写文本框,提供用户名、密码和你的电子邮件来注册:

完成后,我们可以创建一个全新的仓库,将我们的工作推送到其中:

转到“Repositories”标签页,点击绿色的“New”按钮,并为你的仓库选择一个名称,如下截图所示:

为了学习的目的,我将创建一个简单的个人食谱仓库,使用Markdown 标记语言编写(daringfireball.net/projects/markdown/):

然后,你可以为仓库编写描述;这有助于让访问你个人资料的人更好地了解你的项目用途。我们创建的是公共仓库,因为私有仓库有费用,正如我们之前所说的,然后我们初始化时选择了一个README文件;选择这一项时,GitHub 会为我们进行第一次提交,初始化仓库,现在它已准备好进行克隆。

克隆仓库

现在,我们已经有了一个远程仓库,是时候将它挂载到本地了。为此,Git 提供了git clone命令,正如我们已经看到的那样。

使用这个命令很简单;在这种情况下,我们需要知道的唯一信息就是要克隆的仓库 URL。这个 URL 由 GitHub 提供,在仓库主页的右下方:

要复制 URL,你可以简单地点击文本框右侧的剪贴板按钮。

那么,让我们一起尝试按以下步骤操作:

  1. 转到本地仓库的根文件夹。

  2. 在其中打开一个 Bash shell。

  3. 输入git clone https://github.com/fsantacroce/Cookbook.git

显然,你的仓库 URL 会有所不同;正如你所看到的,GitHub 的 URL 形式如下:https://github.com/<Username>/<RepositoryName>.git

[1] ~
$ git clone https://github.com/fsantacroce/Cookbook.git
Cloning into 'Cookbook'...
remote: Counting objects: 15, done.
remote: Total 15 (delta 0), reused 0 (delta 0), pack-reused 15
Unpacking objects: 100% (15/15), done.

[2] ~
$ cd Cookbook/

[3] ~/Cookbook (master)
$ ll
total 13
drwxr-xr-x 1 san 1049089   0 Aug 27 14:16 ./
drwxr-xr-x 1 san 1049089   0 Aug 27 14:16 ../
drwxr-xr-x 1 san 1049089   0 Aug 27 14:16 .git/
-rw-r--r-- 1 san 1049089 150 Aug 27 14:16 README.md

此时,Git 会创建一个新的Cookbook文件夹,其中包含我们仓库的下载副本;在里面,我们会找到一个README.md文件,这是一个经典的 GitHub 仓库文件。在该文件中,你可以使用常见的 Markdown 标记语言描述你的仓库,供其他偶然访问的人使用。

将修改上传到远程仓库

那么,让我们尝试编辑README.md文件并将修改上传到 GitHub:

  1. 使用你喜欢的编辑器编辑README.md文件,例如,添加一句新话。

  2. 将其添加到索引中,然后提交。

  3. 使用git push命令将你的提交推送到远程仓库。

但首先,设置用户和电子邮件,这样 Git 就不会输出我们在前面章节看到的消息:

[4] ~/Cookbook (master)
$ git config user.name "Ferdinando Santacroce"

[5] ~/Cookbook (master)
$ git config user.email "ferdinando.santacroce@gmail.com"

[6] ~/Cookbook (master)
$ vim README.md

Add a sentence then save and close the editor.

[7] ~/Cookbook (master)
$ git add README.md

[8] ~/Cookbook (master)
$ git commit -m "Add a sentence to readme"
[master 41bdbe6] Add a sentence to readme
 1 file changed, 2 insertions(+)

现在,试着输入git push并按下ENTER,无需指定其他内容:

[9] ~/Cookbook (master)
$ git push

在我的 Windows 10 工作站中,会出现如下窗口:

这是Git 凭证管理器;它允许你在 Windows 机器上设置凭证。如果你使用的是 Linux 或 macOS,情况可能有所不同,但基本概念是一样的:我们必须给 Git 提供凭证以访问远程的 GitHub 仓库;然后这些凭证将被存储到我们的系统中。

输入你的凭证,然后按下登录按钮;之后,Git 会继续执行:

[10] ~/Cookbook (master)
$ git push
Counting objects: 3, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 328 bytes | 0 bytes/s, done.
Total 3 (delta 1), reused 0 (delta 0)
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To https://github.com/fsantacroce/Cookbook.git
 e1e7236..41bdbe6  master -> master

git push命令允许你将本地工作内容上传到配置的远程位置;在这种情况下,上传到一个远程的 GitHub 仓库,如下图所示:

我们需要了解更多关于推送的内容;我们可以开始理解在执行git push命令后 Git 给我们的信息。

当我推送时,我会发送什么到远程仓库?

当你执行一个没有指定其他内容的git push时,Git 会将所有新提交以及你在当前分支本地做的所有相关对象发送到远程;对于新提交来说,意味着我们只会发送那些尚未上传的本地提交。

将新分支推送到远程

显然,我们可以创建并将新分支推送到远程,使我们的工作对其他协作者公开可见;例如,我将为一个新食谱创建一个新分支,然后推送到远程的 GitHub 服务器。按照以下简单步骤操作:

  1. 创建一个新分支,例如Risotti

  2. 给它添加一个新文件,例如Risotto-alla-Milanese.md,然后提交。

  3. 使用git push -u origin Risotti命令将分支推送到远程仓库:

 [11] ~/Cookbook (master)
      $ git branch Risotti

      [12] ~/Cookbook (master)
      $ git checkout Risotti

      [13] ~/Cookbook (Risotti)
      $ notepad Risotto-alla-Milanese.md

      [14] ~/Cookbook (Risotti)
      $ git add Risotto-alla-Milanese.md

      [15] ~/Cookbook (Risotti)
      $ git commit -m "Add risotto alla milanese recipe ingredients"
      [Risotti b62bc1f] Add risotto alla milanese recipe ingredients
       1 file changed, 15 insertions(+)
       create mode 100644 Risotto-alla-Milanese.md

      [16] ~/Cookbook (Risotti)
      $ git push -u origin Risotti
      Total 0 (delta 0), reused 0 (delta 0)
      Branch Risotti set up to track remote branch Risotti from origin.
      To https://github.com/fsantacroce/Cookbook.git
       * [new branch]      Risotti -> Risotti

在继续之前,我们需要深入检查一些在执行git push命令后发生的事情。

origin

通过git push -u origin Risotti命令,我们告诉 Git 将我们的Risotti分支(以及其中的提交)上传到origin;使用-u选项,我们设置本地分支以跟踪远程分支。

我们知道,origin是仓库的默认远程仓库,就像master是默认的分支名称一样;当你从远程克隆一个仓库时,那个远程仓库会变成你的origin别名。当你告诉 Git 推送或拉取某些内容时,你通常需要指定你想使用的远程仓库;使用别名origin,你告诉 Git 你想使用默认的远程仓库。

如果你想查看在你的仓库中实际配置的远程仓库,可以输入简单的git remote命令,然后加上-v--verbose)来获取更多细节:

[17] ~/Cookbook (master)
$ git remote -v
origin  https://github.com/fsantacroce/Cookbook.git (fetch)
origin  https://github.com/fsantacroce/Cookbook.git (push)

在详情中,你将看到远程仓库的完整 URL,并且发现 Git 存储了两个不同的 URL:

  • Fetch URL,指的是我们从其他地方获取更新的地址

  • Push URL,指的是我们将更新发送给其他人的地址

这使得我们可以从不同的远程仓库推送和拉取更改,如果你愿意的话,并且强调了 Git 如何被认为是一个对等版本控制系统。

你可以使用git remote命令来添加、更新和删除远程仓库。

跟踪分支

使用 -u 选项,我们告诉 Git 追踪远程分支。追踪远程分支是 将你的本地分支与远程分支关联起来的方式;请注意,这种行为不是自动的,如果你想要它,你必须手动设置。当一个本地分支追踪一个远程分支时,实际上你有一个本地和一个远程分支,它们可以轻松同步(请注意,一个本地分支只能追踪一个远程分支)。这在你需要与远程同事在同一分支上协作时非常有用,可以让所有人保持自己的工作与其他人的更改同步。

为了更好地理解我们当前仓库的配置方式,可以尝试输入 git remote show origin

[18] ~/Cookbook (master)
$ git remote show origin
* remote origin
 Fetch URL: https://github.com/fsantacroce/Cookbook.git
 Push  URL: https://github.com/fsantacroce/Cookbook.git
 HEAD branch: master
 Remote branches:
 Pasta   tracked
 Risotti tracked
 master  tracked
 Local branches configured for 'git pull':
 Risotti merges with remote Risotti
 master  merges with remote master
 Local refs configured for 'git push':
 Risotti pushes to Risotti (up to date)
 master  pushes to master  (fast-forwardable)

如你所见,Pasta , Risottimaster 分支都在被追踪。

你还会看到你的本地分支已配置为推送和拉取具有相同名称的远程分支,但请记住:本地和远程分支不需要具有相同的名称;本地分支 foo 可以追踪远程分支 bar,反之亦然;没有任何限制。

回退操作 – 将本地仓库发布到 GitHub

通常情况下,你会需要将本地仓库放置到一个共享的位置,以便其他人查看你的工作;在这一节中,我们将学习如何实现这个目的。

按照以下简单步骤创建一个新的本地仓库进行发布:

  1. 进入我们的本地仓库文件夹。

  2. 创建一个新的 HelloWorld 文件夹。

  3. 在其中创建一个新的仓库,像我们在第一章中做的那样。

  4. 添加一个新的README.md文件并提交:

 [19] ~
      $ mkdir HelloWorld

      [20] ~
      $ cd HelloWorld/

      [21] ~/HelloWorld
      $ git init
      Initialized empty Git repository in C:/Users/san/Google 
      Drive/Packt/PortableGit/home/HelloWorld/.git/

      [22] ~/HelloWorld (master)
      $ echo "Hello World!" >> README.md

      [23] ~/HelloWorld (master)
      $ git add README.md

      [24] ~/HelloWorld (master)
      $ git config user.name "Ferdinando Santacroce"

      [25] ~/HelloWorld (master)
      $ git config user.email "ferdinando.santacroce@gmail.com"

      [26] ~/HelloWorld (master)
      $ git commit -m "First commit"
      [master (root-commit) 5b41441] First commit
       1 file changed, 1 insertion(+)
       create mode 100644 README.md

      [27] ~/HelloWorld (master)

现在,像我们之前一样创建 GitHub 仓库;这次将它留空,不要初始化 README 文件,因为我们已经在本地仓库中有一个。以下是直接从 GitHub 仓库创建页面截取的截图:

此时,我们准备好将本地仓库发布到 GitHub,或者更好地说,向其添加一个远程仓库。

向本地仓库添加远程仓库

为了发布我们的 HelloWorld 仓库,我们只需添加它的第一个远程仓库;添加远程仓库非常简单:git remote add origin <remote-repository-url>

所以,这是我们需要在 Bash shell 中输入的完整命令:

[27] ~/HelloWorld (master)
$ git remote add origin https://github.com/fsantacroce/HelloWorld.git

向本地仓库添加远程仓库就像添加或修改其他配置参数一样,简单地编辑 .git 文件夹中的一些文本文件,所以速度非常快。

将本地分支推送到远程仓库

之后,使用 git push -u origin master 将本地更改推送到远程:

[28] ~/HelloWorld (master)
$ git push -u origin master
Counting objects: 3, done.
Writing objects: 100% (3/3), 231 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
Branch master set up to track remote branch master from origin.
To https://github.com/fsantacroce/HelloWorld.git
 * [new branch]      master -> master

就这样!

社交编程 - 使用 GitHub 协作

GitHub 的商标就是所谓的 社交编程 概念;从一开始,GitHub 就让分享代码、跟踪他人工作以及使用两种基本概念进行协作变得简单:分叉拉取请求。在这一节中,我将简要说明它们。

分叉一个仓库

分叉是开发者常见的概念;如果你曾经使用过基于 GNU-Linux 的发行版,你就会知道有一些先驱者,比如 Debian,以及一些衍生发行版,比如 Ubuntu,这些通常被称为原始发行版的 fork

在 GitHub 中也类似。某个时刻,你会发现一个你希望稍微修改、甚至完美契合你需求的有趣开源项目;同时,你还希望从原始项目中受益,获取 bug 修复和新功能,保持你的工作与原始项目的联系。在这种情况下,正确的做法是 fork 该项目。但首先,请记住:fork 并不是 Git 的术语,它是 GitHub 的术语。

当你在 GitHub 上进行分叉时,你实际上获得的是该仓库在你 GitHub 账户上的服务器端克隆;如果你将分叉后的仓库克隆到本地,在远程列表中,你会看到一个指向你账户仓库的 origin,而原始仓库则会使用 upstream 别名(反正你需要手动添加它):

为了更好地理解这个功能,去你的 GitHub 账户并尝试分叉一个名为 Spoon-Knife 的常见 GitHub 仓库,它由 GitHub 吉祥物用户 octocat 创建;所以:

  1. 登录到你的 GitHub 账户

  2. 使用位于页面左上方的搜索框搜索 spoon-knife

  3. 点击第一个结果,octocat/Spoon-Knife 仓库

  4. 使用页面右侧的 Fork 按钮分叉仓库:

  1. 在一次有趣的复印动画后,你将在 GitHub 账户中获得一个全新的 Spoon-Knife 仓库:

现在,你可以像之前那样将该仓库克隆到本地:

[1] ~
$ git clone https://github.com/fsantacroce/Spoon-Knife.git
Cloning into 'Spoon-Knife'...
remote: Counting objects: 19, done.
remote: Total 19 (delta 0), reused 0 (delta 0), pack-reused 19
Unpacking objects: 100% (19/19), done.

[2] ~
$ cd Spoon-Knife

[3] ~/Spoon-Knife (master)
$ git remote -v
origin  https://github.com/fsantacroce/Spoon-Knife.git (fetch)
origin  https://github.com/fsantacroce/Spoon-Knife.git (push)

如你所见,upstream 远程并未出现,你需要手动添加它;要添加它,可以使用 git remote add 命令:

[4] ~/Spoon-Knife (master)
$ git remote add upstream https://github.com/octocat/Spoon-Knife.git

[5] ~/Spoon-Knife (master)
$ git remote -v
origin https://github.com/fsantacroce/Spoon-Knife.git (fetch)
origin https://github.com/fsantacroce/Spoon-Knife.git (push)
upstream https://github.com/octocat/Spoon-Knife.git (fetch)
upstream https://github.com/octocat/Spoon-Knife.git (push)

现在,你可以保持本地仓库与远程仓库 origin 中的更改同步,同时你也可以获取来自 upstream 远程的更改,即你分叉的原始仓库的更改。此时,你可能会想知道如何处理两个不同的远程仓库;其实很简单:只需从 upstream 远程拉取并将这些修改合并到本地仓库,然后将它们与你的更改一起推送到 origin 远程。如果其他人克隆了你的仓库,他或她将收到你的工作与其他人在原始仓库上所做工作的合并结果。

提交拉取请求

如果你创建了一个仓库的分叉,那是因为你不是原始项目的直接贡献者,或者你只是想在熟悉代码之前避免在他人的工作中做出干扰。

然而,某个时候,你意识到你的工作甚至对原始项目也有帮助:你实现了之前代码的更好版本,增加了缺失的功能,等等。

所以,你发现自己需要通知原作者你做了一些有趣的事情,询问他是否想看看,或者是否可能整合你的工作。这时,拉取请求就派上用场了。

拉取请求是一种告诉原作者的方式,嘿!我用你的原始代码做了一些有趣的事情,你想看看并整合我的工作吗,如果你觉得足够好? 这不仅是一个技术手段来实现工作整合的目的,它甚至是一个推广代码审查(然后是所谓的社交编程)的强大实践,这种做法是极限编程(eXtreme Programming)倡导者推荐的(更多信息,请访问:en.wikipedia.org/wiki/Extreme_programming)。

使用拉取请求的另一个原因是,如果你不是原始项目的贡献者,你无法直接推送upstream远程仓库:拉取请求是唯一的方式。在一些小的场景下(比如一个由两三位开发人员组成的团队在同一房间工作),分叉和拉取模式可能显得有些额外开销,因此更常见的做法是直接与所有贡献者共享原始代码库,跳过分叉和拉取的过程。

创建一个拉取请求

要创建一个拉取请求,你必须进入你的 GitHub 账户,并直接在你的分叉账户中创建;但首先,你需要知道拉取请求只能从不同的分支创建。到目前为止,你应该已经习惯了为新的特性或重构目的创建一个新分支,所以这并不算新鲜事,对吧?

为了进行尝试,让我们在我们的代码库中创建一个本地的TeaSpoon分支,提交一个新文件,并推送到我们的 GitHub 账户:

[6] ~/Spoon-Knife (master)
$ git branch TeaSpoon

[7] ~/Spoon-Knife (master)
$ git checkout TeaSpoon
Switched to branch 'TeaSpoon'

[8] ~/Spoon-Knife (TeaSpoon)
$ vi TeaSpoon.md

[9] ~/Spoon-Knife (TeaSpoon)
$ git add TeaSpoon.md

[10] ~/Spoon-Knife (TeaSpoon)
$ git commit -m "Add a TeaSpoon to the cutlery"
[TeaSpoon 62a99c9] Add a TeaSpoon to the cutlery
1 file changed, 2 insertions(+)
 create mode 100644 TeaSpoon.md

[11] ~/Spoon-Knife (TeaSpoon)
$ git push origin TeaSpoon
Counting objects: 3, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 417 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To https://github.com/fsantacroce/Spoon-Knife.git
 d0dd1f6..62a99c9  TeaSpoon -> TeaSpoon

如果你查看你的账户,你会发现一个惊喜:在你的Spoon-Knife代码库中,现在有一个“新建拉取请求”按钮,目的是启动一个拉取请求,如下图所示:

点击该按钮后,GitHub 会打开一个新页面;现在你需要选择要与原始代码库进行比较的分支;请查看以下截图:

转到分支选择框(1),选择TeaSpoon分支(2),然后 GitHub 会显示类似下面的截图:

这是一个报告,你可以在其中看到你将要提议的内容:包含新文件的提交。

但让我来分析一下这个页面。

在前面的截图的左上角,你会看到 GitHub 正在为你比较哪些分支;请看下面的图片了解更多细节:

这意味着你即将比较你本地的TeaSpoon分支与octocat用户的原始master分支。页面底部,你可以看到所有不同的细节(添加、删除、修改的文件等):

现在,你可以点击绿色的“创建拉取请求”按钮;以下截图中的窗口将会出现:

在页面的中央部分,你可以描述你在分支上所做的工作。左上角的绿色“可以合并”文本通知你,这两个分支可以自动合并(没有未解决的冲突;如果你希望看到你的工作被采纳,这是非常好的)。

现在,最后一步:点击“创建拉取请求”按钮,将你的请求发送给原始作者,让他接收你的工作并在接受拉取请求之前进行分析。

此时,一个新的对话开始了,你和项目协作者可以开始讨论你的工作;在此期间,你和其他协作者可以修改代码,以更好地满足共同需求,直到原始仓库的协作者决定接受或丢弃你的请求,从而关闭拉取请求。

总结

在这一章中,我们终于接触到了 Git 管理多个远程仓库副本的能力。这为你提供了广泛的可能性,以更好地组织团队内部的协作工作流程。在下一章中,你将学习一些高级技巧,使用知名和小众的命令,这将使你成为一个更安全、更熟练的 Git 用户,轻松解决开发者生活中常见的问题。

第四章:Git 基础知识 - 特殊概念、配置和命令

本章是一些简短但实用的技巧集合,旨在让我们的 Git 使用体验更加舒适。在前面的三章中,我们学习了使用 Git 工具进行版本控制的基本概念;现在是时候深入了解 Git 中的一些强大武器以及如何使用它们(最好是避免自毁)。

分析 Git 配置

本章的第一部分,你将学习如何增强 Git 配置,使其更好地满足你的需求并加速日常工作:是时候熟悉配置的内部原理了。

配置架构

配置选项存储在纯文本文件中。git config 命令只是一个方便的工具,用于编辑这些文件,无需记住它们存储的位置并打开文本编辑器。

配置级别

在 Git 中,我们有三个配置级别

  • 系统

  • 全局(用户范围)

  • 仓库级别

每个不同的配置级别都有不同的配置文件。

基本上,你可以根据需要在每个级别设置所有参数。如果在不同级别设置相同的参数,最低级别的参数会覆盖上级的参数;例如,如果在全局级别设置了 user.name,它会覆盖系统级别设置的同一项,如果在仓库级别设置,它会覆盖全局级别和系统级别的设置。

下图将帮助你更好地理解这些级别:

系统级别

系统级别包含系统范围的配置;如果你在此级别编辑配置,所有用户和每个用户的仓库都会受到影响

此配置通常存储在 gitconfig 文件中,位置如下:

  • Windows: C:\Program Files\Git\etc\gitconfig

  • Linux: /etc/gitconfig

  • macOS: /usr/local/git/etc/gitconfig

要在此级别编辑参数,必须使用 --system 选项;请注意,它需要管理员权限(例如,在 Linux 和 macOS 上需要 root 权限)。

总之,作为一个经验法则,不建议在系统级别编辑配置,建议修改每个用户的配置。

全局级别

全局级别包含用户范围的配置;如果你在此级别编辑配置,每个用户的仓库都会受到影响

此配置通常存储在 .gitconfig 文件中,位置如下:

  • Windows: C:\Users\<UserName>\.gitconfig

  • Linux: ~/.gitconfig

  • macOS: ~/.gitconfig

要在此级别编辑参数,必须使用 --global 选项。

仓库级别

仓库级别包含仅限仓库的配置;如果你在此级别编辑配置,只有正在使用的仓库会受到影响

该配置存储在 .git 仓库子文件夹中的 config 文件里:

  • Windows: C:\<MyRepoFolder>\.git\config

  • Linux: ~/<MyRepoFolder>/.git/config

  • macOS: ~/<MyRepoFolder>/.git/config

要在此级别编辑参数,可以使用 --local 选项,或者直接避免使用任何选项,因为这是默认选项。

配置列表

要获取当前使用的所有配置列表,可以运行 git config --list 命令;如果你在一个仓库内,它将显示所有配置,从仓库级别到系统级别。为了过滤列表,可以选择性地附加 --system--global--local 选项,只显示所需级别的配置:

[1] ~/grocery (master) 
$ git config --list --local 
core.repositoryformatversion=0 
core.filemode=false 
core.bare=false 
core.logallrefupdates=true 
core.symlinks=false 
core.ignorecase=true 
user.name=Ferdinando Santacroce 
user.email=ferdinando.santacroce@gmail.com 

手动编辑配置文件

即使通常不推荐这么做,你也可以通过直接编辑文件来修改 Git 配置。Git 配置文件非常容易理解,所以当你在网上寻找想要设置的特定配置时,通常会找到完全对应的文本行;在这种情况下,唯一的小心之处是,在编辑之前备份文件,以防万一弄乱了它们。在接下来的段落中,我们将尝试以这种方式进行一些更改。

设置其他环境配置

如果你不能将 Git 方便地融入到你的工作环境中,使用 Git 可能是一个痛苦的经历。让我们开始使用一些自定义配置来修复一些粗糙的地方。

基本配置

在前面的章节中,我们已经看到可以使用 git config<variable.name> <value> 语法来更改 Git 变量的值。在本节中,我们将使用 config 命令来修改一些 Git 行为。

拼写错误自动修正

所以,让我们尝试解决一个关于输入命令的恼人问题:拼写错误。我经常发现自己不得不重复输入相同的命令两次或更多次;Git 可以帮助我们实现内嵌的 自动修正,但我们首先需要启用它。要启用它,必须修改 help.autocorrection 参数,定义 Git 在执行假定命令之前会等待多少十分之一秒;例如,设置 help.autocorrect 10,Git 将等待一秒钟:

[2] ~/grocery (master) 
$ git config --global help.autocorrect 10 

[3] ~/grocery (master) 
$ git chekcout 
WARNING: You called a Git command named 'chekcout', which does not exist. 
Continuing under the assumption that you meant 'checkout' 
in 1.0 seconds automatically... 

要中止自动修正,只需按 Ctrl+C

现在我们已经了解了配置文件,你可以注意到我们通过命令行设置的参数是这种形式:section.parameter_name。如果你查看配置文件,可以在 [] 中看到部分名称;例如,在 C:\Users\<UserName>\.gitconfig 中:

推送默认行为

我们已经讨论过 git push 命令及其默认行为。为了避免恼人的问题,最好为此命令设置一个更方便的默认行为。

我们有两种方法可以做到这一点。第一种方法是:设置 Git 每次都询问我们要推送的分支名称,这样简单的 git push 将不起作用。为了实现这一点,将 push.default 设置为 nothing

[1] ~/grocery-cloned (master) 
$ git config --global push.default nothing 

[2] ~/grocery-cloned (master) 
$ git push 
fatal: You didn't specify any refspecs to push, and push.default is "nothing". 

如你所见,现在 Git 假装你在每次推送时都指定了目标分支。

这可能太过严格,但至少你可以避免一些常见错误,例如将个人本地分支推送到远程,从而在团队中产生混乱。

另一个避免此类错误的方法是将push.default参数设置为simple,这样只有在远程分支与本地分支同名时,Git 才会进行推送:

[3] ~/grocery-cloned (master) 
$ git config --global push.default simple 

[4] ~/grocery-cloned (master) 
$ git push 
Everything up-to-date 

这将把本地跟踪分支推送到远程。

定义默认编辑器

有些人真的不喜欢vim,即便是仅用于编写提交信息;如果你是其中之一,好消息是:你可以通过设置core.default配置参数来更改它:

[1] ~/grocery (master) 
$ git config --global core.editor notepad 

显然,你可以设置市场上几乎所有可用的文本编辑器。如果你是 Windows 用户,记住编辑器的完整路径必须在PATH环境变量中;基本上,如果你能通过在 DOS 命令行中输入可执行文件的名称来运行你喜欢的编辑器,那么在 Git 的 Bash 命令行中也可以使用它。

其他配置

你可以在git-scm.com/docs/git-config浏览更多的配置变量列表。

Git 别名

我们已经提到过 Git 别名及其目的;在这一部分,我只会建议一些更多的别名,以帮助简化操作。

常用命令的快捷方式

有一个你可能会觉得有用的技巧是简化常用命令,例如git checkout等;因此,实用的别名可以包括以下内容:

[1] ~/grocery (master) 
$ git config --global alias.co checkout 

[2] ~/grocery (master) 
$ git config --global alias.br branch 

[3] ~/grocery (master) 
$ git config --global alias.ci commit 

[4] ~/grocery (master) 
$ git config --global alias.st status 

另一种常见做法是简化命令,添加一个或多个你经常使用的选项;例如,将git cm <commit message>命令快捷方式设置为git commit -m <commit message>

[5] ~/grocery (master) 
$ git config --global alias.cm "commit -m" 

[6] ~/grocery (master) 
$ git cm "My commit message" 
On branch master 
nothing to commit, working tree clean 

创建命令

另一个常见的自定义 Git 体验的方法是创建你认为应该存在的命令

git unstage

经典示例是git unstage别名:

[1] ~/grocery (master) 
$ git config --global alias.unstage 'reset HEAD --' 

使用这个别名,你可以比使用等效的git reset HEAD -- <file>语法更有意义地从索引中移除文件:

[2] ~/grocery (master)
$ git unstage myFile.txt

现在的行为和下面这个相同:

[3] ~/grocery (master) 
$ git reset HEAD -- myFile.txt 

git undo

想要快速撤销上次正在进行的提交吗?创建一个git undo别名:

[1] ~/grocery (master) 
$ git config --global alias.undo 'reset --soft HEAD~1' 

显然,你可以使用--hard代替--soft,或者使用默认的--mixed选项。

git last

git last别名非常有用,可以查看你最后的提交:

[1] ~/grocery (master) 
$ git config --global alias.last 'log -1 HEAD' 

[2] ~/grocery (master) 
$ git last
commit b25ffa60f44f6fc50e81181cab87ed3dbf3b172c 
Author: Ferdinando Santacroce <ferdinando.santacroce@gmail.com> 
Date:   Thu Jul 27 15:12:48 2017 +0200 

    Add an apricot 

git difflast

使用git difflast别名,你可以查看与最后一次提交的diff

[1] ~/grocery (master) 
$ git config --global alias.difflast 'diff --cached HEAD^' 

[2] ~/grocery (master) 
$ git difflast 
diff --git a/shoppingList.txt b/shoppingList.txt 
index d362b98..08e7361 100644 
--- a/shoppingList.txt 
+++ b/shoppingList.txt 
@@ -4,3 +4,4 @@ orange 
 peach 
 grape 
 blackberry 
+apricot 

使用外部命令的高级别名

如果你希望别名运行外部 shell 命令,而不是 Git 子命令,必须在别名前加上!

$ git config --global alias.echo !echo

假设你厌烦了经典的git add <file>git commit <file>命令序列,而你希望将其合并成一条命令;你可以通过创建这个别名来连续调用两次git命令:

$ git config --global alias.cm '!git add -A && git commit -m'

使用这个别名,你可以提交一个文件,必要时先将其添加。

你注意到我再次设置了cm别名吗?如果你设置一个已经配置过的别名,之前的别名将会被覆盖。

也有一些别名定义并使用复杂的功能或脚本,但我会把这些留给读者自己探索。如果你需要灵感,可以看看这个 GitHub 仓库:github.com/GitAlias/gitalias

删除别名

删除一个别名非常简单;你只需要使用 --unset 选项,并指定要删除的别名。例如,如果你想删除 cm 别名,可以运行:

$ git config --global --unset alias.cm 

请注意,你必须使用适当的选项指定配置级别;在这种情况下,我们正在从用户(--global)级别删除别名。

别名化 Git 命令本身

我已经说过我是个打字很慢的人;如果你也是的话,你可以给 Git 命令本身设置别名(使用 Bash 中的默认 alias 命令):

$ alias gti='git' 

通过这种方式,你将节省一些键盘操作。请注意,这不是 Git 别名,而是 Bash shell 别名。

有用的技巧

在本节中,我们将提高我们的技能,学习一些在不同情况下都很有用的技巧。

Git stash - 暂时搁置更改

有时你需要暂时切换分支,但当前分支有一些正在进行的更改。为了将这些更改暂时搁置,我们可以使用 git stash 命令:让我们在 grocery 仓库中尝试一下。

向购物清单添加一个新水果,然后尝试切换分支;Git 不允许这样做,因为在检出时你会丢失 shoppingList.txt 文件中尚未提交的本地更改。所以,输入 git stash 命令;你的更改将被暂存并从当前分支中移除,允许你切换到另一个分支(在这个例子中是 berries):

[1] ~/grocery (master) 
$ echo "plum" >> shoppingList.txt 

[2] ~/grocery (master) 
$ 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:   shoppingList.txt 

no changes added to commit (use "git add" and/or "git commit -a") 

[3] ~/grocery (master) 
$ git checkout berries 
error: Your local changes to the following files would be overwritten by checkout: 
        shoppingList.txt 
Please commit your changes or stash them before you switch branches. 
Aborting 

[4] ~/grocery (master) 
$ git stash 
Saved working directory and index state WIP on master: b25ffa6 Add an apricot 
HEAD is now at b25ffa6 Add an apricot 

[5] ~/grocery (master) 
$ git checkout berries 
Switched to branch 'berries' 

git stash 是如何工作的?实际上,git stash 是一个相当复杂的命令。它基本上保存了两到三个不同的提交:

  • 一个新的 WIP 提交,包含工作副本的实际状态;它包含所有已追踪的文件及其修改。

  • 一个 索引提交,作为 WIP 提交的父提交。它包含已添加到暂存区的内容。

  • 一个可选的第三个提交,称为 未追踪文件提交,它包含未追踪的文件(使用 --include-untracked 选项)或未追踪的文件加上之前被忽略的文件(使用 --all 选项)。

让我们使用 git log 命令来查看我们仓库的实际情况:

[6] ~/grocery (berries) 
$ git log --oneline --graph --decorate --all 
*   fedc4cf (refs/stash) WIP on master: b25ffa6 Add an apricot 
|\ 
| * 7312ff0 index on master: b25ffa6 Add an apricot 
|/ 
* b25ffa6 (master) Add an apricot 
* 280e7a8 Cherry picked the blackberry 
* 5dc3352 Add a grape 
* de8bcb9 Add a peach 
| * 362f8ec (HEAD -> berries) Add a strawberry 
| * f037469 (melons) Add a watermelon 
| * af9b640 Add a blackberry 
|/ 
* 00404b4 Add an orange 
* f583fdc Add an apple 
* 40d865b Add a banana to the shopping list

正如你所看到的,在这种情况下只有两个提交。WIP 提交fedc4cf,是带有以 WIP on master 开头的消息的那个提交,其中 master 当然是执行 git stash 命令时 HEAD 所在的分支。索引提交7312ff0,是带有以 index on master 开头的消息的那个提交。

WIP 提交包含对已跟踪文件所做的未暂存更改;如你所见,WIP 提交有两个父提交:一个是索引提交,包含已暂存的更改,另一个是master分支上的最后一个提交,也就是HEAD所在的位置,并且我们在此位置运行了git stash命令。

使用所有这些被暂存的信息,Git 可以在你完成berries分支上的工作后,将你的工作重新应用到master分支上;一个 stash 可以在任何地方应用,甚至可以多次应用。

使用git stash命令时,实际上我们使用了默认选项git stash save子命令。save子命令仅保存对已跟踪文件的更改,使用的是这些特殊提交的默认消息。

要检索一个 stash,使用命令git stash apply <stash>;它会应用这两个提交中的更改,最终修改你的工作副本和暂存区。应用后,stash 不会被删除;你可以通过git stash drop <stash>手动删除。另一种方式是使用git stash pop <stash>子命令:它会应用 stash 后再删除它。

在使用这些子命令时,你可以通过不同的标记来引用过去的各种 stash;最常见的是stash@{0},其中0表示你最后一次创建的 stash。要检索倒数第二个,可以使用stash@{1},以此类推。

为了做一个完整的示例,让我们在不应用的情况下删除当前的 stash,然后按照以下步骤进行新的操作:

  1. 使用git stash drop删除最后一个创建的 stash(git stash clear会删除所有的 stash)。

  2. 向购物清单中添加一个新的水果(例如,一个李子)并将其添加到暂存区。

  3. 然后添加另一个(例如,一个梨),但避免将其添加到暂存区。

  4. 现在创建一个新的未跟踪文件(例如,notes.txt)。

  5. 最后,使用-u--include-untracked选项)创建一个新的 stash。

这是完整的命令列表:

[1] ~/grocery (master) 
$ echo "plum" >> shoppingList.txt 

[2] ~/grocery (master) 
$ git add . 

[3] ~/grocery (master) 
$ echo "pear" >> shoppingList.txt 

[4] ~/grocery (master) 
$ echo "Reserve some tropical fruit for next weekend" > notes.txt 

[5] ~/grocery (master) 
$ git status 
On branch master 
Changes to be committed: 
  (use "git reset HEAD <file>..." to unstage) 

        modified:   shoppingList.txt 

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:   shoppingList.txt 

Untracked files: 
  (use "git add <file>..." to include in what will be committed) 

        notes.txt 

[6] ~/grocery (master) 
$ git stash -u 
Saved working directory and index state WIP on master: b25ffa6 Add an apricot 
HEAD is now at b25ffa6 Add an apricot 

好的,让我们通过git log命令来看看发生了什么:

 [7] ~/grocery (master) 
$ git log --oneline --graph --decorate --all 
*-.   87b1d8b (refs/stash) WIP on master: b25ffa6 Add an apricot 
|\ \ 
| | * b07c304 untracked files on master: b25ffa6 Add an apricot 
| * ad2efef index on master: b25ffa6 Add an apricot 
|/ 
* b25ffa6 (HEAD -> master) Add an apricot 
* 280e7a8 Cherry picked the blackberry 
* 5dc3352 Add a grape 
* de8bcb9 Add a peach 
| * 362f8ec (berries) Add a strawberry 
| * f037469 (melons) Add a watermelon 
| * af9b640 Add a blackberry 
|/ 
* 00404b4 Add an orange 
* f583fdc Add an apple 
* 40d865b Add a banana to the shopping list 

如你所见,这次有一个新的提交,即未跟踪的文件提交:这个提交包含了notes.txt文件,并且作为WIP 提交的额外父提交。

总结来说,你基本上是使用git stash save命令(如果需要,使用-u--all选项)来暂存你的修改,然后使用git stash apply来检索它们;我建议使用git stash apply后再使用git stash drop,而不是git pop,这样可以在需要时重新应用你的 stash,或者当你的 stash 不像平常那样简单时。

要查看此命令的所有选项,请参考git stash --help输出。

Git 提交修改 - 修改最后的提交

这个技巧适用于那些没有双重检查自己所做操作的人。如果你过早按下了回车键,有一种方法可以修改最后的提交消息或添加你忘记的文件,使用带有--amend选项的git commit命令:

$ git commit --amend -m "New commit message"

请注意,使用--amend选项时,你实际上是在重新提交一次,这将生成一个新的哈希值;如果你已经推送了之前的提交,更改最后一个提交并不推荐——实际上,这是不提倡的做法。

如果你修改了已经推送的提交并再次推送新的提交,实际上你是在丢弃分支上的最新提交,并用新提交替换它:对于那些将要拉取该分支的人来说,这可能会导致一些困惑,因为他们会看到自己的本地分支丢失了最后一个提交,并被一个新的提交所替代。

Git blame - 追踪文件中的更改

在团队中进行源代码工作时,查看特定文件的最后修改记录以更好地理解其随时间演变的情况并不罕见。为此,我们可以使用git blame <filename>命令。

让我们在Spoon-Knife仓库中尝试,查看在特定时间段内对README.md文件所做的更改:

[1] ~/Spoon-Knife (master) 
$ git blame README.md 
bb4cc8d3 (The Octocat 2014-02-04 14:38:36 -0800 1) ### Well hello there! 
bb4cc8d3 (The Octocat 2014-02-04 14:38:36 -0800 2) 
bb4cc8d3 (The Octocat 2014-02-04 14:38:36 -0800 3) This repository is meant to provide an example for *forking* a repository on GitHub. 
bb4cc8d3 (The Octocat 2014-02-04 14:38:36 -0800 4) 
bb4cc8d3 (The Octocat 2014-02-04 14:38:36 -0800 5) Creating a *fork* is producing a personal copy of someone else's project. Forks act as a sort of bridge between the original repository and your personal copy. You can submit *Pull Requests* to help make other people's projects better by offering your changes up to the original project. Forking is at the core of social coding at GitHub. 
bb4cc8d3 (The Octocat 2014-02-04 14:38:36 -0800 6) 
bb4cc8d3 (The Octocat 2014-02-04 14:38:36 -0800 7) After forking this repository, you can make some changes to the project, and submit [a Pull Request](https://github.com/octocat/Spoon-Knife/pulls) as practice. 
bb4cc8d3 (The Octocat 2014-02-04 14:38:36 -0800 8) 
d0dd1f61 (The Octocat 2014-02-12 15:20:44 -0800 9) For some more information on how to fork a repository, [check out our guide, "Forking Projects""](http://guides.github.com/overviews/forking/). Thanks! :sparkling_heart: 

如你所见,结果报告了README.md文件中所有受影响的行;对于每一行,你都可以看到提交哈希值、作者、日期和文本文件行号。

假设你现在发现你要查找的修改是发生在d0dd1f61提交中的;要查看其中发生了什么,可以输入git show d0dd1f61命令:

[2] ~/Spoon-Knife (master) 
$ git show d0dd1f61 
commit d0dd1f61b33d64e29d8bc1372a94ef6a2fee76a9 
Author: The Octocat <octocat@nowhere.com> 
Date:   Wed Feb 12 15:20:44 2014 -0800 

    Pointing to the guide for forking 

diff --git a/README.md b/README.md 
index 0350da3..f479026 100644 
--- a/README.md 
+++ b/README.md 
@@ -6,4 +6,4 @@ Creating a *fork* is producing a personal copy of someone else's project. Forks 

 After forking this repository, you can make some changes to the project, and submit [a Pull Request](https://github.com/octocat/Spoon-Knife/pulls) as practice. 

-For some more information on how to fork a repository, [check out our guide, "Fork a Repo"](https://help.github.com/articles/fork-a-repo). Thanks! :sparkling_heart: 
+For some more information on how to fork a repository, [check out our guide, "Forking Projects""](http://guides.github.com/overviews/forking/). Thanks! :sparkling_heart:

git show命令是一个多功能命令,可以显示一个或多个对象;在这里,我们使用它来展示通过git show <commit-hash>格式显示某个特定提交所做的修改。

git blamegit show命令有相当长的选项列表;本节的目的是引导读者找到追踪文件变更的方法;你可以通过随时使用git <command> --help命令来检查其他可能性。

我想建议的最后一个小技巧是使用 Git 图形界面:

[3] ~/Spoon-Knife (master) 
$ git gui blame README.md 

有了图形界面的帮助,理解起来更加容易。

小窍门

在本节中,我想提出一些我过去发现有用的小技巧。

裸仓库

裸仓库是指不包含工作副本文件,仅包含.git文件夹的仓库。裸仓库本质上是用于共享的:如果你以集中式方式使用 Git,推送和拉取到一个公共远程仓库(如本地服务器、GitHub 仓库等),你会发现远程仓库不需要查看你正在处理的文件;远程仓库的作用仅仅是作为团队的一个中心联络点,因此在其中拥有工作副本文件只会浪费空间,因为没有人会直接在远程修改这些文件。

如果你想设置一个裸仓库,只需要使用--bare选项:

$ git init --bare NewRepository.git

正如你可能注意到的,我将其命名为NewRepository.git,并使用.git扩展名;这并不是强制性的,但它是识别裸仓库的常见方式。如果你留意的话,你会发现即使在 GitHub 上,每个仓库的名字也以.git结尾。

将常规仓库转换为裸仓库

可能会发生您在本地仓库中开始工作,随后感到需要将其迁移到集中式服务器上,以便其他人或其他地方的人员也能使用。

您可以轻松地使用带有相同 --bare 选项的 git clone 命令将常规仓库转换为裸仓库:

$ git clone --bare my_project my_project.git

以这种方式,您可以在另一个文件夹中获得您仓库的 1:1 副本,但它是裸版本,准备好进行推送。

备份仓库

如果您需要备份,您可以使用两个命令:一个用于仅存档文件,另一个用于备份整个 bundle,包括版本信息。

存档仓库

要存档仓库而不包括版本信息,您可以使用 git archive 命令;有许多输出格式,但经典格式是 .zip 格式:

$ git archive master --format=zip --output=../repoBackup.zip 

请注意,使用此命令并不等同于在文件系统中备份文件夹;正如您所注意到的,git archive 命令可以更智能地生成存档,仅包括某个分支中的文件,甚至是某次提交中的文件;例如,执行此操作时,您仅存档最后一次提交:

$ git archive HEAD --format=zip --output=../headBackup.zip 

以这种方式存档文件在您需要与未安装 Git 的人共享代码时非常有用。

打包仓库

另一个有趣的命令是 git bundle 命令。使用 git bundle,您可以导出仓库的快照,然后将其恢复到任何您想要的地方。

假设您想在另一台计算机上克隆您的仓库,而网络中断或无法访问;使用此命令,您可以创建 master 分支的 repo.bundle 文件:

$ git bundle create ../repo.bundle master 

使用此命令,我们可以在另一台计算机上使用 git clone 命令恢复这个 bundle:

$ cd /OtherComputer/Folder 
$ git clone repo.bundle repo -b master 

总结

在这一章中,我们增强了对 Git 及其广泛命令集的理解。我们发现了配置层级的工作原理,以及如何通过 Git 设置我们的偏好,例如,向 shell 添加有用的命令别名。然后我们探讨了 Git 如何处理缓存,提供了暂存和重新应用更改的方法。

此外,我们向技能库中添加了一些其他技巧,学习了一些我们将很快在广泛使用 Git 时应用的内容。一些简单的技巧为激发读者好奇心提供了方式:Git 还有很多命令可以探索。

在下一章中,我们将暂时离开控制台,讨论一些更好的仓库组织策略。我们将学习如何进行有意义的提交,并了解一些可采用的流程,将 Git 与我们的工作方式结合起来。

第五章:获取最大效益 - 良好的提交和工作流程

现在我们已经对 Git 和版本控制系统有了一些了解,是时候从更高的视角来看待整个问题,意识到常见的模式和流程。

在本章中,我们将介绍一些最常见的组织和构建有意义的提交和代码库的方式,从而获得一个井井有条的代码栈,甚至是一个有意义的信息源。

提交的艺术

在使用 Git 时,提交似乎是工作中最简单的部分:你添加文件,写一个简短的评论,然后就完成了。但正因为它简单,尤其是在刚开始使用时,你往往会养成做出糟糕提交的坏习惯:提交得太晚、太大、太短,或者仅仅是带有糟糕的消息。

现在我们将花一些时间来识别可能的问题,比如没有意义的或过大的提交,并提出一些建议和提示,帮助大家摆脱这些坏习惯。

构建正确的提交

在编程过程中,一项更难掌握的技能是将工作分解为小而有意义的任务

我经常遇到这样的情况:你开始修复文件中的一个小问题;然后你看到另一段代码,可以轻松改进,尽管它与当前的工作无关——你忍不住,还是去修复了。最后,经过一段时间,你发现自己有一大堆并发文件和更改等待提交。

到此时,情况变得更糟,因为通常程序员是懒惰的人,所以他们不会在提交信息中写下所有重要的内容来描述所做的更改。在提交信息中,你开始写诸如“对这个和那个的一些修复”,“移除旧的东西”,“微调”之类的句子,但这些并没有帮助其他程序员理解你做了什么:

http://xkcd.com/1296/ 提供

最后,你会意识到你的代码库只不过是一个垃圾堆,你偶尔才会清理一次索引。我见过一些人只在一天结束时提交(而且不是每天都提交),只是为了备份数据,或者因为其他人需要在他们的计算机上反映这些更改。

另一个副作用是,结果的代码库历史变得毫无意义,除了在某个时间点检索内容外,几乎没有其他用途。

以下提示可以帮助你将版本控制系统(VCS)从一个备份系统转变为一个有价值的沟通和文档工具

每次提交只进行一次更改

在例行的早晨咖啡后,你打开编辑器,开始处理一个 bug:BUG42。在修复代码中的 bug 时,你意识到修复 BUG79 只需要修改一行代码,于是你进行修复,但你不仅修改了那个糟糕的类名,还给表单添加了一个漂亮的标签,并做了一些其他更改。现在损失已经造成了

现在你怎么将所有这些工作总结成一个有意义的提交呢?也许你在这段时间回家吃午饭,跟老板讨论了另一个项目,甚至你自己都不记得所有做过的细小事情了。

在这种情况下,只有一种方法可以限制损害将文件拆分成多个提交。有时这样做有助于减轻痛苦,但这只是一个权宜之计:你太频繁地因为不同的原因修改同一个文件,所以这样做是相当困难的,甚至是不可能的。

解决这个问题的唯一方法是每次提交时只做一次修改。这看起来简单,我知道,但要掌握这项能力却相当困难。没有工具能做到;除了你自己,没有人能帮忙,因为这需要自律这是创意工作者(比如程序员)最缺乏的美德

有一些技巧可以帮助你实现这个目标;让我们一起看看它们。

拆分功能和任务

如前所述,将事情分解是一个精细的艺术。如果你知道并采用了一些敏捷方法技巧,你可能已经遇到过这些问题,因此你占有优势;否则你将需要付出更多努力,但这不是你无法达成的。

假设你被指派在一个 web 应用的登录页上添加“记住我”选项框,像下面这样:

这个功能很小,但在不同层面上会涉及到改动。为了实现这一点,你必须:

  1. 修改 UI 以添加检查控制。

  2. 已检查的信息通过不同的层级传递。

  3. 将这些信息存储到某个地方。

  4. 在需要时获取这些信息。

  5. 根据某些政策使其失效(例如:15 天后,或登录 10 次后,等等)。

你认为你能在一次尝试中完成所有这些事情吗?是吗?你错了!即使你为一项普通任务估计几个小时,也要记住墨菲定律:你会接到四个电话,你的老板会找你参加三个不同的会议,而你的电脑会起火。

这是需要学习的第一件事之一:将每一项工作分解成小任务。无论你是否使用时间盒技术,比如番茄工作法,小任务更容易处理。我不是说要斤斤计较,但尽量将任务组织成可以在规定时间内完成的事情,最好是几段半小时,而不是几天。

欲了解更多关于番茄工作法的信息,可以访问cirillocompany.de/pages/pomodoro-technique

或者维基百科en.wikipedia.org/wiki/Pomodoro_Technique

所以,拿起纸和笔,写下所有任务,就像我们之前在登录页面的例子中做的那样。你现在觉得自己能在很短的时间内完成所有这些任务吗?也许能,也许不能:有些任务比其他任务要大。这没关系,这不是一种科学方法,这取决于经验;你能将一个任务拆分成两个有意义的任务吗?做吧。

你能做到吗?没问题,不要尝试拆分任务,如果这样会失去它们的意义

做一个小本子,像下面图片中的那样——它会成为你最宝贵的工具之一:

编写提交信息再开始编码

现在你有了一份任务清单,选择第一个任务并... 开始编码?不!再拿一张纸,用一句话描述每个任务的步骤;你会发现每一句话都可以成为一个提交信息,描述你在软件中删除、添加或更改的功能。

这种事先的准备帮助你定义要实现的修改(让更好的软件设计自然产生),专注于重要事项,并且减轻压力,让你在编码过程中不再过多考虑版本管理的部分。当你面对编程问题时,你的大脑会充斥着与正在编写代码相关的小细节,因此,分散注意力的事情越少越好。

这是我收到的与版本管理相关的最佳建议之一:如果你有十五分钟的空闲时间,我建议你阅读预先提交评论的博客文章,链接在arialdomartini.wordpress.com/2012/09/03/pre-emptive-commit-comments/,它是我学到这个技巧的地方,作者是Arialdo Martini

将所有更改放在一个提交中

每次提交做多于一个更改是坏事,但即使将单个更改拆分成多个提交也会被认为是有害的。正如你可能已经知道的,在一些训练有素的团队中,你不能简单地将代码推送到生产环境;首先你必须通过代码质量审查,其中其他人会尝试理解你所做的工作,以决定你的代码好不好(这就是为什么会有拉取请求的原因)。你可能是世界上最好的开发人员,但如果对方无法理解你的提交,你的工作很可能会被拒绝。

要避免这些不愉快的情况,你必须遵循一个简单的规则:不要进行部分提交。如果时间不够,如果你必须参加那该死的会议(程序员讨厌开会)或其他任何事情,请记住你可以随时保存你的工作而不提交,使用git stash命令。如果你想关闭提交,因为你想把它推送到远程分支进行备份,请记住Git 不是备份工具:在另一个磁盘上备份你的 stash,把它放在云上,或者在离开之前结束你的工作,但不要像看电视剧集一样提交。

再说一遍,Git 就像任何其他软件工具一样,甚至它也可能失败:不要认为使用 Git 或其他版本控制系统就不需要备份策略 - 本地和远程仓库的备份同样重要,就像你备份其他重要事物一样。

描述变更,而不是你做了什么

我经常读到(而且更经常写到)像“移除这个”、“修改那个”、“添加那个”之类的提交信息。

想象一下你要在你的网站上共同工作的“丢失密码”功能;你可能会觉得这样的消息足够了:“在登录页面添加了丢失密码找回链接”。这种提交消息不描述功能给你带来了什么修改,而是你做了什么(并非你做的一切)。试着真诚地回答:阅读仓库历史时,你想要读到每个开发者做了什么吗?或者读到每个提交中实现的功能会更好?

努力去做,开始编写以变更本身为主题的句子,不要描述你是如何实现它的。使用祈使现在时态(例如修复添加实现),在一个简短的主题句中描述变更,然后在需要时添加一些细节(如果需要的话),例如:“实现密码找回机制”是一个良好的提交信息主题;如果你觉得有用,那么可以添加一些其他信息来使其形成一个完整的消息,就像这样:

"Implement the password retrieval mechanism 

 - Add the "Lost password?" link into the login page
 - Send an email to the user with a link to renew the password" 

你有没有手动编写过软件的更新日志?我有过,那是最无聊的事情之一。如果你不喜欢写更新日志,就像我一样,把代码库历史看作是你的更新日志:只要你注意提交信息,你就会免费得到一个漂亮的更新日志!

在接下来的段落中,我将介绍一些关于良好提交信息的其他有用提示。

不要害怕提交

恐惧是最强烈的情感之一;它可以驱使一个人做地球上最疯狂的事情。对恐惧的最常见反应之一是崩溃你不知道该怎么做,最终什么也不做

这是一个常见的反应,特别是当你开始使用像 Git 这样的新工具时,建立信心可能很困难;因为害怕犯错,你通常不会提交,直到被迫提交。这才是真正的错误:害怕。在 Git 中,你不必害怕;也许解决方案并不显而易见,也许你得上网查找正确的做法,但你可以承受的小代价几乎没有(除非你是--hard选项的重度用户)。

相反,你需要努力频繁提交,尽可能早提交。你提交的越频繁,提交的内容就越小;提交越小,变更日志就越容易阅读和理解,挑选提交也变得更加容易,代码审查也更轻松。为了帮助自己习惯这种提交方式,我采用了这个简单的小技巧:在开始写任何代码之前,先在 Visual Studio 中写下提交信息:

尝试在你的 IDE 或者直接在 Bash 终端中做到这一点,这会有很大帮助。

隔离无意义的提交

黄金法则是避免这种情况,但有时候你确实需要提交不是真正实现的内容,而只是一些清理工作,比如删除旧的注释、调整格式等等。

在这些情况下,最好将这些代码变更隔离在单独的提交中。这样做可以防止其他团队成员带着刀冲向你,嘴里冒着泡沫。不要将无意义的更改与真正的更改混合在一起提交,否则其他开发者(以及几周后的你)在查看差异时将无法理解它们。

完美的提交信息

让我坦白地说:完美的提交信息是不存在的。如果你独自工作,你可能会找到最适合自己的方式,但在团队中有不同的思维方式和敏感度,所以对我来说合适的,可能对别人来说并不一定是最好的。

在这种情况下,你需要坐下来一起开会进行回顾,尽量达成一个共享的标准;这个标准可能不是你最喜欢的,但至少这是找到共同路径的方式。

一个好的提交信息的规则确实取决于你和你的团队的日常工作方式,但有一些常见的提示是每个人都可以应用的;它们如下。

写一个有意义的主题

提交的主题是最重要的部分:它的作用是清楚地表明提交包含了什么内容。避免涉及其他技术细节——普通开发者可以通过打开代码来理解,应该专注于大局:记住,每个提交都是仓库历史中的一句话。所以,戴上变更日志阅读者的帽子,尝试写出最便于他理解的句子,而不是为你自己写:使用现在时态,写一个最多 50 个字符的句子。

一个好的主题是这样的:“在主页中添加新闻通讯注册功能”。

如你所见,我使用了祈使过去式,更重要的是,我没有说我做了什么,而是说功能做了什么:它在我的网站上添加了一个新闻通讯订阅框。

50 字符规则源于你通过命令行或图形界面工具使用 Git 的方式;如果你开始写长句子,查看日志等操作可能会变成一场噩梦。所以,不要试图成为提交信息的斯蒂芬·金:避免使用形容词,直接进入主题,之后可以在附加的详细行中更深入地描述。

还有一件事:开始时使用大写字母,并且不要以句号结束句子——它们是多余的,甚至是危险的。

根据需要添加项目符号细节行

你通常可以在 50 个字符内表达你想说的所有内容;在这种情况下,使用详细的行。常见的规则是主题后留空一行,使用破折号,并且不要超过 72 个字符:

"
Add the newsletter signup in homepage

- Add textbox and button on homepage
- Implement email address validation
- Save email in database"

在这些行中稍微深入一点,但不要过多;尽量描述原始问题(如果你解决了它)或原始需求,为什么实现了这个功能(解决了什么问题)以及可能的限制或已知问题。

关联其他有用的信息

如果你使用问题和项目追踪系统,请写下问题编号、bug ID 或任何其他有用的信息:

"
Add the newsletter signup in homepage

- Add textbox and button on homepage
- Implement email address validation
- Save email in database 

#FEAT-123: closed"

发布的特别信息

另一件有用的事是,为发布写特殊格式的提交信息,这样会更容易找到它们。我通常会用一些特殊字符来装饰主题,但除此之外没有更多;为了突出某个特别的提交,比如发布版本,可以使用git tag命令,记得吗?

结论

最后,我的建议是,尝试制定你个人的提交信息标准,遵循之前的提示,并参考网络上优秀项目和团队所采用的信息策略,尤其是通过实际操作来发现它。你的标准一定会随着你作为软件开发者和 Git 用户的成长而变化,因此尽早开始,并让时间帮助你找到写出完美提交信息的方法。

至少,不要模仿这个链接:www.commitlogsfromlastnight.com

采用工作流——明智之举

既然我们已经学习了如何进行良好的提交,是时候更进一步,考虑一下工作流了。Git 是一个版本控制工具,但就像其他强大的工具(比如刀具)一样,你可以用它切出美味的生鱼片,也可能会受伤。

区分一个优秀代码库与垃圾堆的,是你如何管理发布、如何在某个特定版本的程序中修复 bug 以及当你需要让用户测试即将发布的功能时,你的反应方式。

这些操作属于现代软件项目的日常管理,但我常常看到团队因为糟糕的版本控制工作流而疲于应付。

在本章的第二部分,我们将快速浏览一些最常用的工作流和 Git 版本控制系统。

集中化工作流

正如我们在其他版本控制系统(如 Subversion)中常做的那样,即便是在 Git 中,采用集中式工作方式也是很常见的。如果你在团队中工作,通常需要与他人共享仓库,因此一个共同的联络点变得不可或缺。

我们可以假设,如果你在办公室里不是一个人,你将采用这种工作流的某种变体。正如我们所知,我们可以配置使同事们的所有电脑成为远程仓库的一部分,形成某种点对点的配置,但通常你不会这么做,因为这会很快变得太复杂,难以保持每个分支在每个远程仓库中的同步。

这一场景在下图中展示:

它们如何运作

在这种场景中,你通常会遵循以下简单步骤:

  1. 有人初始化了远程仓库(在本地 Git 服务器、GitHub、BitBucket 等上)

  2. 其他团队成员将原始仓库克隆到他们的电脑上并开始工作

  3. 工作完成后,你将推送到远程仓库,使其他同事能够访问

到这个时候,剩下的只是内部规则和模式的问题。

特性分支工作流

到这个阶段,你可能至少会选择特性分支的方法,每个开发者都在自己的分支上工作。当工作完成后,特性分支准备好被合并到主分支;在此之前,你可能需要从master分支合并回来,因为你的另一位同事在你开始自己的分支后已经合并了一个特性分支,但之后基本就完成了。

下图展示了仓库中分支的发展:

Gitflow

Gitflow工作流源自Vincent Driessen的思想,他是来自荷兰的一个热情的软件开发者;你可以在nvie.com/posts/a-successful-git-branching-model找到他关于该工作流的原始博客文章。

这种工作流多年来获得了成功,甚至许多其他开发者(包括我!)、团队和公司都开始使用它。Atlassian,一家知名公司,提供像BitBucket这样的 Git 相关服务,它们将 Gitflow 直接集成到他们的图形用户界面工具——漂亮的SourceTree中。

即使 Gitflow 工作流是一个集中式的,它也可以通过以下图片来很好地描述:

这个工作流基于使用一些主分支;这些分支之所以特别,完全是因为我们赋予它们的意义:在 Git 中并没有特殊分支特殊特性,但我们当然可以将它们用于不同的目的。

主分支

在 Gitflow 中,master 分支代表最终阶段;将你的工作合并到该分支就相当于发布一个 新版本 的软件。通常你不会从 master 分支开始新的分支;只有在你必须立即修复严重 bug 时,才会这样做,即使这个 bug 已经在另一个正在开发的分支中被发现并修复。这种操作方式在需要快速反应时非常高效。除此之外,master 分支就是你打标签发布版本的地方。

热修复分支

热修复分支 是只从 master 分支派生的分支,正如前面所提到的;一旦你修复了一个 bug,你就会将 hotfix 分支合并到 master,这样你就可以发布新的版本。如果 bug 在其他地方没有得到解决,那么策略是将 hotfix 分支合并到 develop 分支。之后,你可以删除 hotfix 分支,因为它已经达到了预期目标。

在 Git 中,有 一种分组相似分支的技巧:你需要使用一个共同的前缀并加上斜杠 / 来命名它们;对于热修复分支,作者建议使用 hotfix/<branchName> 前缀(例如 hotfix/LoginBughotfix/#123,对于使用 bug 跟踪系统的用户,其中 #123 是 bug ID)。

这些分支通常不会推送到远程;只有在你需要其他团队成员的帮助时,才会推送。

开发分支

develop 分支是一种 预发布 分支。当你开始实现新功能时,必须从 develop 分支创建一个新的分支;直到任务完成,你会一直在这个分支上工作。

任务完成后,你可以将代码合并回 develop 分支并删除你的 feature 分支:像热修复分支一样,这些分支只是临时性的分支。

master 分支一样,develop 分支是一个 长期存在的分支:你永远不会关闭或删除它。

这个分支会被推送并共享到远程 Git 仓库。

发布分支

在某些时候,你需要准备下一个版本的发布,包含过去几周你实现的一些功能。为了准备即将到来的版本,你必须从 develop 分支创建一个新的分支,并为该分支命名,名称由 release 前缀和你选择的版本数字(例如 release/1.0)组成。

注意:在这个阶段,不允许添加新功能!你不能再将 develop 合并到 release 分支;你只能从该分支创建新的分支来修复 bug;这个中间分支的目的是将软件交给 beta 测试人员,允许他们进行试用并向你反馈问题和 bug。

如果你在 release 分支上修复了某个 bug,唯一需要记住的是将其合并到 develop 分支,以避免丢失 bug 修复——release 分支不会被合并回 develop

你可以让这个分支保持有效,直到你认为软件已经成熟并经过足够的测试,准备进入生产环境:此时你将release分支合并到master分支,创建一个新的发布。

合并到master后,你有两个选择:如果你需要保持不同版本的发布,可以保持release分支开放,否则可以删除它。就我个人而言,我总是删除release分支(正如 Vincent 建议的那样),因为我通常会进行频繁的小规模增量发布(所以我很少需要修复已经发布的版本),而且,正如你一定记得的那样,你可以随时从一个提交(在这种情况下是一个标记的提交)打开一个全新的分支,因此,最多我也只是会在必要时从那个点重新打开它。

这个分支会推送并共享到一个公共的远程仓库。

特性分支

当你需要开始实现一个新特性时,你需要从develop分支创建一个新分支。特性分支以feature/为前缀(例如feature/NewAuthenitcationfeature/#987,如果你使用某些功能追踪软件,#987就是特性 ID)。

你会一直在特性发布上工作,直到完成你的任务;我建议你经常从develop分支合并回来:如果多个修改同时作用于同一文件,提前解决冲突能更快地解决问题;这样,一次解决一两个冲突比在特性工作结束时解决几十个冲突要容易得多。

一旦工作完成,你将特性分支合并到develop,工作就完成了;此时你可以删除feature分支。

特性分支主要是私有分支,但如果需要与其他团队成员合作,你可以将它推送到远程仓库。

结论

我真的推荐你看一下这个工作流,因为我可以向你保证,使用它时没有任何我无法解决的情况。

你可以在Vincent Driessen之前提到的博客上找到更深入的解释,里面有可以直接使用的 Git 命令。你甚至可以使用 Vincent 为定制 Git 体验而创建的gitflow 命令;可以在他的 GitHub 账户上查看,地址是github.com/nvie/gitflow

GitHub flow

前面描述的GitFlow有很多追随者,但这总是一个口味问题;也有人认为它对于他们的情况来说太复杂和死板,事实上,在过去几年中,也有其他管理软件仓库的方法获得了共识。

其中一种工作流是 GitHub 用于内部项目和仓库的工作流;这个工作流被称为GitHub flow,最早由著名的Scott Chacon(前 GitHub 员工以及ProGit书籍作者)在他的博客中描述,网址是scottchacon.com/2011/08/31/github-flow.html

与 Gitflow 相比,这种工作流更适合频繁发布,当我说频繁时,我指的是非常频繁,甚至一天两次。显然,这种工作流在网页项目中效果更好,因为要部署,你只需要将新版本放到生产服务器上;如果你开发的是桌面解决方案,则需要一个完美运作的更新机制来做到这一点。

GitHub 软件基本上没有发布版本,因为他们会定期将更新部署到生产环境,甚至一天多次。这是由于强大的持续交付结构,这并不容易实现;它需要一定的努力。

GitHub flow 基于以下简单规则。

master 分支中的任何内容都是可部署的

类似于 GitFlow,在 GitHub flow 中,部署也是从 master 分支进行的。

这是该工作流中唯一的分支;在 Gitflow 中没有 hotfixdevelop 或其他特殊分支。错误修复、新功能实现等都会不断地合并到 master 分支。

除此之外,master 分支中的代码始终处于可部署状态;当你在某个分支上修复或添加新内容,然后将其合并到 master 上时,你不会自动进行部署,但你可以假设你的更改将在几个小时内上线并运行。

不断地将分支合并到 master,即生产就绪分支,可能会带来风险:你很容易引入回归问题或错误,因为除了你自己,没有其他人能够检查你是否做得好。这个问题通过 GitHub 开发者常用的社会契约来避免;在这个契约中,你承诺在将代码合并到 master 之前进行测试,确保所有自动化测试都已成功完成。

master 分支创建描述性分支

在 GitFlow 中,你总是从 master 分支上创建分支,所以当你需要拉取某个分支时,很容易看到一片分支森林。为了更好地识别这些分支,在 GitHub flow 中,你需要使用描述性的名称来创建有意义的主题分支。这里也是一种良好的习惯;如果你开始创建名为 stuff-to-do 的分支,你很可能会在采用这种工作流时失败。一些示例包括 new-user-creationmost-starred-repositories 等等(注意使用Kebab Casewiki.c2.com/?KebabCase);通过使用一种常见的方式来定义主题,你将更容易通过查找关键词找到感兴趣的分支。

不断推送到命名分支

与 Gitflow 相比,GitHub flow 的另一个显著区别是,在 GitHub flow 中,即使你是唯一的开发者并且只对自己感兴趣,你也需要定期将功能分支推送到远程。这是为了持续集成和测试,或者可能也是为了备份;关于备份这一部分,尽管我已经在优点上表达了我的观点,但我不能说这是一件坏事。

我喜欢 GitFlow 的一个方面是,每次将分支推送到远程仓库的习惯让你能够通过简单的git fetch看到所有当前活动的分支,从而了解所有的进行中的工作,包括你团队成员的工作。

随时发起拉取请求

在第三章,Git 基础 - 远程工作中,我们讨论了 GitHub,并快速尝试了拉取请求。我们已经看到,基本上它们用于贡献:你 fork 了别人的仓库,创建了一个新分支,进行了一些修改,然后向原作者发起拉取请求。

在 GitHub Flow 中,你大量使用拉取请求,即便是请求团队中的其他开发人员来查看你的工作并提供帮助、建议,或审查已完成的工作。在这个阶段,你可以开始讨论,使用 GitHub 的拉取请求功能进行聊天,并通过 @提及 他们的用户名来让其他人参与。此外,拉取请求功能还允许你在差异视图中评论代码的单行内容,让相关用户能够讨论正在修改的工作。

仅在拉取请求审查后合并

你现在可以理解,前面我们看到的拉取请求分支阶段变成了一个审查阶段,在这个阶段,其他用户可以查看代码,甚至仅仅留下一个积极的评论,像是一个+1,让其他人知道他们对这项工作有信心,并且批准将其合并到 master 分支。

在这个步骤之后,当 CI 服务器表示该分支仍通过所有自动化测试时,你就准备好将该分支合并到 master

审查后立即部署

在这个阶段,你将分支合并到master,工作就完成了。虽然部署不是立即触发的,但在 GitHub,他们有一个非常直接且强大的部署程序,因此他们可以轻松地完成部署。无论是包含 50 次提交的大分支,还是只有一次提交和一行代码更改的小分支,他们都能很快且廉价地完成部署。

这就是为什么他们能够承担如此简单的分支策略的原因,首先将代码放到master,然后部署,而无需像 GitFlow 中那样通过developrelease阶段的分支。

结论

我认为这种流程对于基于 web 的项目非常高效和灵活,基本上你可以在没有太多关注软件版本的情况下直接部署到生产环境。只使用master分支来派生和集成其他分支,比光速还快,但这种策略仅在具备以下前提条件时才可应用:

  • 一个已准备好的集中式远程仓库来管理拉取请求(就像 GitHub 所做的那样)

  • 一个良好的分支命名和拉取请求使用约定

  • 一个非常强大的部署系统

这是这一流程的宏观图,图形化地表示在下图中;欲了解更多详情,我建议访问与 GitHub 相关的页面,guides.github.com/introduction/flow/index.html

基于主干的开发

如今,另一种策略在全球开发者中重新获得了一定的关注;它的名字已经说明了一切:停止使用分支,仅使用主分支

下图展示了这一流程的精髓:

这一趋势旨在解决所谓的合并地狱问题;当分支长时间分叉时,合并它们就变得非常痛苦。与 GitHub 流程类似,这里没有长时间存在的分支,甚至连功能分支也不推荐使用。

持续集成和持续交付在这里得到了体现,这种工作方式确实加强了我们已经熟悉的好实践,这些实践得益于极限编程(www.extremeprogramming.org/)的心态和实践。

这个运动太广泛且深刻,无法在几句话中讨论,但值得阅读它的原则,因为它让你反思开发者在日常工作中所面对的话题。所以请花一点时间,阅读更多内容,trunkbaseddevelopment.com

其他工作流程

显然,还有许多其他工作流程;我将花一点时间讲述这个(幸运的是!)说服 Linus Torvalds 意识到 Git 版本控制系统的工作流。

Linux 内核工作流程

Linux 内核使用的工作流程参考了Linus Torvalds在这些年中推动其演进的传统方式,基于军事化的层级结构

简单的内核开发者在他们个人的分支上工作,基于参考库将主分支进行重置,然后将他们的分支推送到中尉开发者master分支。中尉是 Linus 根据他们的经验指派负责内核特定话题和领域的开发者。当中尉完成工作时,他会将代码推送到仁慈独裁者master分支(Linus 分支),然后如果一切正常(他不容易被骗),Linus 会将他的主分支推送到祝福库,这是开发者在开始工作前用来重置的库。

这种工作流程并不常见;它是由 Linus 和 Linux 内核团队创造的,恰好反映了他们从一开始就采用的项目工作方式,当时开发者使用补丁和电子邮件将工作提交给 Linus Torvalds。

管理数百万行代码和成千上万的贡献者,我认为这种层级模型在工作范围、责任和补丁筛选方面是一个很好的折中方案。

以下图片有助于你更好地理解这个流程:

摘要

在本章中,我们了解了有效使用 Git 的方法;我个人认为本章对新 Git 用户来说最为重要,因为只有通过应用一些规则和纪律,你才能从这个工具中获得最大的收益。所以,请选择一个好的工作流程(如果需要,自己制定!),并注意你的提交:这才是成为一个优秀版本控制工具用户的唯一途径,不仅仅是在 Git 中。

在下一章中,我们将看到一些使用 Git 的技巧,即使你需要处理 Subversion 服务器,之后我们还将快速了解如何从 Subversion 完全迁移到 Git。

第六章:向 Git 迁移

你经常在使用其他版本控制系统后转向 Git;世界上有许多不同的版本控制系统,但其中最受欢迎的之一无疑是Subversion

Git 和 Subversion 可以共存,因为 Git 有一些专门的命令用于与 Subversion 交换数据。

本章的目的是帮助那些实际上使用 Subversion 的开发人员立即开始使用 Git,即使团队的其余成员继续使用 Subversion。

此外,本章还涵盖了决定放弃 Subversion 转而使用 Git 的人的最终迁移,以及保存更改历史记录的方法。

在开始之前

在本章的第一部分,我们将看看在没有麻烦的情况下如何保持安全,并在实际的 SVN 代码库上工作。

请记住,本章的目的是给读者一些提示;处理大型和复杂的代码库需要更加谨慎和细致的方法。

安装 Subversion 客户端

为了能够进行这些实验,你需要一个 Subversion 工具;在 Windows 上,最广泛使用的是著名的TortoiseSVNtortoisesvn.net),它提供了 GUI 和命令行工具的集成。

我推荐完整安装 TortoiseSVN,包括命令行工具,因为我们将需要其中一些工具来进行实验。

使用 Git 在 Subversion 代码库上工作

在第一部分,我们将看到在开始远离 Subversion 时最谨慎的方法,即保持原始的代码库,使用 Git 来获取和推送更改。

为了学习的目的,我们将创建一个本地的 Subversion 代码库,使用 Subversion 和 Git 来访问其内容。

创建一个本地 Subversion 代码库

在没有远程服务器麻烦的情况下,让我们创建一个本地的 Subversion 代码库,作为我们实验的容器:

$ cd C:\Repos
$ svnadmin create MySvnRepo

没有更多,也没有更少;代码库现在已经准备好可以填充文件夹和文件了。

使用 svn 客户端检出 Subversion 代码库

到此,我们已经有了一个工作的 Subversion 代码库;我们现在可以将其检出到我们选择的文件夹,这个文件夹将成为我们的工作副本;在我的例子中,我将使用C:\Sources文件夹:

$ cd C:\Sources\svn
$ svn checkout file:///Repos/MySvnRepo

现在,你在Sources文件夹下有了一个MySvnRepo文件夹,准备填充你的项目文件;但首先,让我提醒你一些事情。

正如你所知,Subversion 代码库通常有以下子文件夹结构:

  • /trunk,主文件夹,通常存放正在开发中的代码。

  • /tags,通常用于冻结并保持不变的快照根文件夹,例如/tags/v1.0

  • /branches,所有你为功能开发而创建的分支的根文件夹,例如/branches/NewDesign

Subversion 没有提供一个命令来初始化具有这种布局(通常称为标准布局)的代码库,因此我们必须手动构建它。

此时,我们可以导入一个已经包含三个子文件夹(/trunk/branches/tags)的骨架文件夹,命令如下:

$ cd \Sources\svn\MySvnRepo
$ svn import /path/to/some/skeleton/dir

否则,我们可以使用 svn mkdir 命令手动创建文件夹:

$ cd \Sources\svn\MySvnRepo
$ svn mkdir trunk
$ svn mkdir tags
$ svn mkdir branches  

提交我们刚刚创建的文件夹,仓库就准备好了:

svn commit -m "Initial layout"  

现在,添加并提交第一个文件:

$ cd trunk
$ echo "This is a Subversion repo" > readme.txt
$ svn add readme.txt
$ svn commit -m "Readme file"

如果你想复制一个更真实的情况,随时可以添加更多文件,或者导入一个现有项目;要导入文件到 Subversion 仓库中,你可以使用 svn import 命令,正如我们之前所看到的:

$ svn import \MyProject\Folder

稍后,我们将添加一个标签和一个分支,以验证 Git 如何与它们交互。

从 Git 克隆 Subversion 仓库

Git 提供了一套与 Subversion 协作的工具;基本命令实际上是 git svn;通过 git svn,你可以克隆 Subversion 仓库,检索和上传更改等。

所以,戴上 Git 的帽子,使用 git svn clone 命令克隆 Subversion 仓库:

$ cd \Sources\git
$ git svn clone file:///c/Repos/MySvnRepo  

如你所见,这次我在 file:/// 路径中添加了根驱动器字母 c;在 Windows 中,Git 假设你提供的路径是从驱动器字母开始的。

添加标签和分支

为了让情况更真实一些,我将向 Subversion 仓库添加一个标签和一个分支;这样,我们就可以看到如何在 Git 中处理它们。

所以,让我们继续添加一个新文件:

$ cd \Sources\svn\MySvnRepo\trunk
$ echo "This is the first file" > svnFile01.txt
$ svn add svnFile01.txt
$ svn commit -m "Add first file"  

然后,将这个仓库的快照标记为 v1.0;如你所知,在 Subversion 中,标签或分支是快照的副本:

$ svn copy file:///Repos/MySvnRepo/trunk file:///Repos/MySvnRepo/tags/v1.0 -m "Release 1.0" 

一旦我们有了标签,我们甚至可以创建一个分支,假设我们想为 v1.0 版本添加一个用于修复 Bug 的地方:

$ svn copy file:///Repos/MySvnRepo/trunk file:///Repos/MySvnRepo/branches/v1.x -m "Maintenance branch for release 1.0" 

使用 Git 作为客户端向 Subversion 提交文件

现在我们已经有了一个正在运行的原始 Subversion 仓库克隆,我们可以像使用 Subversion 客户端一样使用 Git。所以,使用 Git 添加一个新文件并提交它:

$ echo "This file comes from Git" >> gitFile01.txt 
$ git add gitFile01.txt 
$ git commit -m "Add a file using Git" 

现在,我们必须将这个文件推送到 Subversion 服务器:

$ git svn dcommit 

做得好!

从 Subversion 服务器检索新的提交

我们甚至可以使用 Git 通过 git svn fetch 命令来获取更改,或者直接使用 git svn rebase 更新本地工作副本,作为 svn update 命令的 Git 对应命令:

$ git svn rebase 

Git 将从远程 Subversion 服务器获取新的提交,类似于 git pull 命令;然后,它会将这些提交在你当前所在的分支上进行 rebase。也许你会好奇,为什么我们使用 rebase 而不是像 git pull 命令默认处理 Subversion 远程仓库时那样使用 merge。使用 merge 命令而不是 rebase 来应用远程提交可能会有害;过去,Git 在处理 Subversion 的 svn:mergeinfo 属性时遇到了一些问题(svnbook.red-bean.com/en/1.6/svn.ref.svn.c.mergeinfo.html),即使它支持这些属性(www.git-scm.com/docs/git-svn/2.11.1#git-svn---mergeinfoltmergeinfogt),rebase 被认为是更安全的选择。

Git 与 Subversion 的集成是一个广泛的话题;有关其他命令和选项,我建议你阅读主页面 git svn --help

使用 Git 作为 Subversion 客户端并不是我们能得到的最理想的方式,但至少它为你提供了一种方式,可以开始使用 Git,即使你无法立即放弃 Subversion。

使用 Git 作为 Subversion 仓库的客户端

使用 Git 作为 Subversion 客户端可能会引起一些混淆,因为 Git 比 Subversion 更灵活,Subversion 在文件组织上则较为严格。

为确保保持与 Subversion 兼容的工作方式,我建议你遵循一些简单的规则。

首先,确保你的 Git master 分支与 Subversion 中的 trunk 分支相关联;正如我们之前所说,Subversion 用户通常以这种方式组织仓库:

  • 一个 /trunk 文件夹,即主文件夹

  • 一个 /branches 根文件夹,存放所有分支,每个分支位于单独的子文件夹中(例如,/branches/feat-branch

  • 一个 /tags 根文件夹,存放你创建的所有标签(例如,/tags/v1.0.0

要遵循这种布局,你可以在克隆 Subversion 仓库时使用 --stdlayout 选项:

$ git svn clone <url> --stdlayout 

以这种方式,Git 会将 /trunk Subversion 分支与 Git master 分支连接起来,然后将所有的 /branches/tags 分支复制到你的本地 Git 仓库,使你能够在一个 1:1 同步的上下文中使用它们。

迁移 Subversion 仓库

如果可能,建议将 Subversion 仓库完全迁移到 Git;这很简单,主要取决于 Subversion 仓库的大小和组织方式。

如果仓库遵循前面描述的标准布局,迁移只需要几分钟的时间。

检索 Subversion 用户列表

如果你的 Subversion 仓库由不同的人使用,你可能会希望在新的 Git 仓库中保持提交作者的名称不变。

如果 awk 命令可用(可以在 Windows 系统下使用 Git Bash shell 或 Cygwin),这里有一个脚本,可以从 Subversion 日志中获取所有用户并将其附加到一个文本文件中,这样在克隆时,即使是转换为 Git 的提交,也能完美匹配 Subversion 用户:

$ svn log -q | awk -F '|' '/^r/ {sub("^ ", "", $2); sub(" $", "", $2); print $2" = "$2" <"$2">"}' | sort -u > authors.txt 

现在,我们将在下一个克隆步骤中使用 authors.txt 文件。

克隆 Subversion 仓库

要开始迁移,我们必须像之前一样在本地克隆 Subversion 仓库;我再次建议添加 --stdlayout 选项,以保持分支和标签,然后添加 -A 选项,让 Git 在克隆时转换提交作者:

$ git svn clone <repo-url> --stdlayout --prefix svn/ -A authors.txt 

如果 Subversion 仓库中的 trunk、branches 和 tags 位于其他路径中(即不是标准布局),Git 提供了一个方法,通过 --trunk--branches--tags 选项来指定它们:

$ git svn clone <repo-url> --trunk=<trunk-folder> --branches=<branches-folder> --tags=<tags-folder>

当你执行 clone 命令时,请记住此操作可能需要消耗较长时间;在一个有千次提交的仓库中,等待一两个小时是常见的情况。

保留被忽略的文件

为了保留之前在 Subversion 中被忽略的文件,我们可以将 svn:ignore 设置附加到 .gitignore 文件中:

$ git svn show-ignore >> .gitignore
$ git add .gitignore
$ git commit -m "Convert svn:ignore properties to .gitignore"

推送到本地裸 Git 仓库

既然我们已经有了本地的仓库副本,我们可以将其迁移到一个全新的 Git 仓库。这里,你可以使用你选择的服务器上的远程仓库,甚至是 GitHub 或 BitBucket,但我建议你使用一个本地裸仓库。我们可以在将文件推送到目标仓库之前做一些小的调整(比如重命名标签和分支)。所以,首先在你选择的文件夹中初始化一个裸仓库:

$ mkdir \Repos\MyGitRepo.git
$ cd \Repos\MyGitRepo.git
$ git init --bare

现在,将默认分支重命名为与 Subversion trunk 分支相匹配的名称:

**$ git symbolic-ref HEAD refs/heads/trunk**

然后,添加一个指向我们刚刚创建的裸仓库的 `bare` 远程仓库:

```
$ cd \Sources\MySvnRepo
$ git remote add bare file:///C/Repos/MyGitRepo.git
```

最后,将本地克隆的仓库推送到新的裸仓库:

```
$ git push --all bare
```

我们现在有了一个全新的裸仓库,它是原始 Subversion 仓库的完美副本。我们现在可以调整分支和标签,以更好地适应 Git 的常规布局。

# 排列分支和标签

现在,我们可以重命名分支和标签,以获得更适合 Git 的场景。

# 将 trunk 分支重命名为 master

Subversion 的主开发分支是 `/trunk`,但在 Git 中,通常我们称主分支为 `master`;这里有一种方法可以将其重命名:

```
$ git branch -m trunk master 
```

# 将 Subversion 标签转换为 Git 标签

Subversion 将标签视为分支;它们都是某个 trunk 快照的副本。而在 Git 中,分支和标签有不同的含义。

要将 Subversion 标签的分支转换为 Git 标签,这里有一个简单的脚本来完成这个工作:

```
$ git for-each-ref --format='%(refname)' refs/heads/tags |
cut -d / -f 4 |
while read ref
do
 git tag "$ref" "refs/heads/tags/$ref";
 git branch -D "tags/$ref";
done
```

# 将本地仓库推送到远程

现在,你已经拥有一个本地裸 Git 仓库,准备推送到远程服务器;转换的结果是一个完整的 Git 仓库,其中分支、标签和提交历史都得到了保留。你唯一需要手动操作的,就是最终调整 Git 用户的配置。

# 比较 Git 和 Subversion 命令

在接下来的页面中,你可以找到一张简短且部分的回顾表格,我试图将最常用的 Subversion 和 Git 命令配对,以帮助 Subversion 用户迅速转变思维,快速从 Subversion 过渡到 Git。

请记住,Subversion 和 Git 的行为不同,所以直接比较命令可能不是最好的做法,但对于从 Subversion 转到 Git 的新手来说,这能帮助他们在学习的过程中将 Subversion 的基本命令与 Git 对应的命令进行匹配:

![](https://github.com/OpenDocCN/freelearn-devops-pt7-zh/raw/master/docs/git-ess-2e/img/a3720c01-92ce-469c-a5d9-b2bba65385a6.png)

# 总结

本章仅仅是浅尝辄止,但我认为它对了解这个话题很有帮助。如果你有大规模的 Subversion 仓库,可能需要在开始转换到 Git 之前做更深入的培训,但对于小型到中型仓库,现在你已经知道了开始迁移的基本知识。

我唯一想与您分享的建议是不要急于求成;先让 Git 与您的 Subversion 服务器合作,在仓库混乱时进行重组,多做备份,最后尝试进行转换;您可能会像我一样多次进行转换,但最终您会获得满足感。

在下一章,我将与您分享我在作为 Git 用户的职业生涯中找到的一些有用资源。


# 第七章:Git 资源

本章是我在使用 Git 过程中的一些资源集合。我将分享一些关于 GUI 工具、Git 仓库的 Web 界面以及学习资源的想法,希望它们能作为你成功 Git 生涯的跳板。

# Git GUI 客户端

当开始学习一款新工具,特别是像 Git 这样广泛且复杂的工具时,利用一些 GUI 工具是非常有用的,这样你可以更简单地理解命令和模式。

Git 受益于各种 GUI 工具,因此这仅仅是一个选择的问题;我想马上告诉你,通常没有完美的工具,但有足够多的工具可以让你选择最适合你需求的一个。

# Windows

作为一个 Microsoft .NET 开发者,我 99% 的时间都在使用 Windows;在空闲时间,我会玩一些 Linux,但在那种情况下,我更倾向于使用命令行。在这一节中,你将找到我使用或曾经使用过的工具,而在其他平台部分,我会根据其他人的经验提供一些建议。

# Git GUI

Git 拥有一个集成的 GUI,正如我们在前几章中所学的那样。它可能不是你找到的最吸引眼球的解决方案,但它足够用。使用它的原因是,当你安装 Git 时,它已经预装好了,并且与命令提示符的集成非常好;因此,查看文件、查看历史记录或进行交互式合并都可以轻松启动(只需在你的 shell 中输入 `git gui `):

![](https://github.com/OpenDocCN/freelearn-devops-pt7-zh/raw/master/docs/git-ess-2e/img/33caad31-97ce-437b-a640-9ec8d07d34dc.png)

# TortoiseGit

如果你已经从 Subversion 迁移到 Git,你可能已经听说过 *TortoiseSVN*,这是一款精心设计的工具,可以通过右键菜单集成直接在资源管理器中处理 Subversion 命令。

TortoiseGit 将 Git 而不是 Subversion 带到同一个地方;通过安装 TortoiseGit,你将受益于与资源管理器的相同集成,使得最常用的 Git 命令只需一步即可完成。即使我不鼓励使用本地化版本,TortoiseGit 也提供了不同语言版本;请记住,你需要先安装 Git,因为它不包含在 TortoiseGit 的安装包中:

![](https://github.com/OpenDocCN/freelearn-devops-pt7-zh/raw/master/docs/git-ess-2e/img/99b858a9-1977-4ddd-8b0e-ad8841607ed8.png)

# GitHub for Windows

GitHub 提供了一个时尚的现代 UI 客户端。不得不承认,我一开始对它有些轻视,主要是因为我确信它只能用于 GitHub 仓库;事实上,你甚至可以将它用于其他远程仓库,但显然这个客户端是专为 GitHub 定制的——要使用其他远程仓库,你必须手动编辑 `config` 文件,将 GitHub 远程仓库替换为你想要的远程仓库。

如果你需要一个通用的客户端,这可能不是最适合你的工具,但如果你主要在 GitHub 上工作, chances are 它是最适合你需求的 GUI:

![](https://github.com/OpenDocCN/freelearn-devops-pt7-zh/raw/master/docs/git-ess-2e/img/bfbe0be7-f236-4f28-9ce9-2d7b6aba6f13.png)

# Atlassian SourceTree

这是我最喜欢的客户端。SourceTree 像其他所有工具一样是免费的;它来自**Atlassian**的创意,这家公司背后有*BitBucket*等知名服务,以及*Jira*和*Confluence*等其他流行的产品。SourceTree 能够处理各种远程仓库,并提供一些方便的功能(如记住密码),可以访问像 BitBucket 和 GitHub 这样的大多数流行服务。

它通过设计嵌入了 GitFlow 的组织方式,提供了一个便捷的按钮,可以用 Gitflow 分支初始化一个仓库,并集成了作者提供的 GitFlow 命令。我最初发现最有趣的地方是,你可以启用一个窗口,当你通过用户界面使用某些 Git 命令时,SourceTree 会显示相应的 Git 命令;这样,当你不确定该使用哪个命令时,你可以使用 SourceTree 完成任务,并查看它用来完成工作的命令:

![](https://github.com/OpenDocCN/freelearn-devops-pt7-zh/raw/master/docs/git-ess-2e/img/edb86ef2-ac0f-4f0b-8953-a46540da82ec.png)

SourceTree 也可以在 macOS 上使用。

# Cmder

Cmder 并不是一个 Git GUI,而是一个更为优雅的便携式控制台模拟器,你可以用它代替传统的 Bash shell:

![](https://github.com/OpenDocCN/freelearn-devops-pt7-zh/raw/master/docs/git-ess-2e/img/dd17efb5-7d40-4bcd-9da9-316e544f54a4.png)

它看起来比原始 shell 更漂亮;它支持多标签,并提供广泛的配置选项,让你根据自己的喜好进行定制,这要归功于*ConEmu*和*Clink*项目。最后但同样重要的是,它自带 Git。你可以在 GitHub 上下载,网址是[`github.com/bliker/cmder`](https://github.com/bliker/cmder)。

# macOS

正如我之前所说,我对 macOS 上的 Git 客户端没有经验;我唯一可以分享的信息是,GitHub 甚至为这个操作系统提供了免费的客户端,就像 Atlassian 为 SourceTree 提供的那样。虽然 Mac 上没有 TortoiseGit,但我听说有一款很酷的应用叫 Git Tower,考虑试一试吧,因为它似乎做得非常精致:

![](https://github.com/OpenDocCN/freelearn-devops-pt7-zh/raw/master/docs/git-ess-2e/img/078a978d-245c-486f-94bc-aa462fd01357.png)

# Linux

Linux 是 Git 的发源地,所以我认为它是使用 Git 的最佳平台。我时不时玩一下 Linux,通常使用 Bash shell 来操作 Git。

对于 ZSH shell 爱好者,我建议看看[`ohmyz.sh/`](http://ohmyz.sh/),这是一个有趣的开源项目,你可以在这里找到大量插件和主题。说到插件,有些插件可以增强你在这个著名的替代控制台中使用 Git 的体验。

最后,查看一下 Linux 上可用的 Git GUI,访问[`git-scm.com/download/gui/linux`](http://git-scm.com/download/gui/linux)

# 构建一个带有 Web 界面的个人 Git 服务器

在我曾工作的一家公司,我是第一个将 Git 用于生产代码的人;在经过几个月的空闲时间里小规模尝试后,我鼓起勇气将我通常独自使用的所有 Subversion 代码库转换为 Git 代码库。

不幸的是,严格的 IT 政策阻止了我使用外部源代码仓库,所以没有 *GitHub* 或 *BitBucket*;更糟糕的是,我也没办法获得一个 Linux 服务器,无法利用像 *Gitosis*、*Gitlab* 等优秀的 web 界面。因此,我开始在 Google 上寻找解决方案,最后找到一个即使对处于相同状况的人也有用的解决方案。

# SCM 管理器

**SCM 管理器** ([`www.scm-manager.org/`](https://www.scm-manager.org/)) 是一个非常简便的解决方案,允许你在本地 Windows 网络中共享 Git 仓库;它提供了一个独立的解决方案,可以在 Windows 上通过 Apache Web 服务器直接安装并运行。尽管它是用 Java 构建的,但你也可以在 Linux 或 Mac 上使用它。

它可以管理 Subversion、Git 和 Mercurial 仓库,允许你定义用户、组等;它还提供了丰富的插件列表,支持其他版本控制系统以及与开发相关的工具,如 Jenkins、Bamboo 等。还有一个 *Gravatar* 插件和一个 Active Directory 插件,允许你和你的同事使用默认域凭证访问内部仓库。

我已经使用这个解决方案大约两年了,除了在更新期间因为我的自定义路径设置而遇到的一些配置相关的小问题外,一直没有出现其他问题:

![](https://github.com/OpenDocCN/freelearn-devops-pt7-zh/raw/master/docs/git-ess-2e/img/3580be9d-a848-499a-862c-e920e539df76.png)

# 以可视化方式学习 Git

最后我想与读者分享的是,我在刚开始更好理解 Git 工作原理时发现的一个有用的 web 应用。

**Learn Git Branching** ([`learngitbranching.js.org/`](https://learngitbranching.js.org/)) 是一个非常有用的 web 应用,它提供了一些练习,帮助你提升 Git 技能。从一个基础的提交练习开始,你将学习如何创建分支、变基等,但最酷的是,在页面右侧,你会看到一个有趣的仓库图形实时变化,跟随你在模拟终端中输入的命令:

![](https://github.com/OpenDocCN/freelearn-devops-pt7-zh/raw/master/docs/git-ess-2e/img/13f5a27d-aee5-4bfc-8374-c73395ecaba0.png)

另一个类似的好资源是 **用 D3 可视化 Git 概念**,在这里你可以直观地掌握所有最重要的命令。可以在 [`onlywei.github.io/explain-git-with-d3/`](https://onlywei.github.io/explain-git-with-d3/) 找到它:

![](https://github.com/OpenDocCN/freelearn-devops-pt7-zh/raw/master/docs/git-ess-2e/img/2508eabd-d037-464e-9e92-052b5aaaee2c.png)

# 网络上的 Git

最后,我建议关注一些我常常关注的资源,这样可以学习新知识,并与其他聪明、有趣的 Git 用户在互联网上交流。

# 面向人类的 Git Google Group

这个小组经常有 Git 专业用户参与;如果你在遇到困难时需要帮助,最好的地方就是在 [`groups.google.com/forum/#!forum/git-users`](https://groups.google.com/forum/#!forum/git-users) 提问。

# Git 社区在 Google+

这个社区充满了乐于分享知识的人;我所知道的大多数关于 Git 的酷炫技巧,都是在[`plus.google.com/u/0/communities/112688280189071733518`](https://plus.google.com/u/0/communities/112688280189071733518)这个平台上发现的。

# Git 备忘单

互联网上有很多关于 Git 的优质备忘单;以下是我最喜欢的一些:

**Git 漂亮命令**: [`justinhileman.info/article/git-pretty/`](http://justinhileman.info/article/git-pretty/)

**Hylke Bons Git 备忘单**: [`github.com/hbons/git-cheat-sheet`](https://github.com/hbons/git-cheat-sheet)

# Git Minutes 和 Thomas Ferris Nicolaisen 博客

Thomas 是一个熟练的 Git 用户,也是一个非常友善的人。在他的博客上,你可以找到许多有趣的资源,包括他在德国本地编程活动中讨论 Git 的视频。除此之外,Thomas 还主持了*Git Minutes*播客系列,在这个系列中,他与其他人讨论 Git,分享工具、观点和一些常见话题。

请访问 [www.tfnico.com](http://www.tfnico.com/) 和 [www.gitminutes.com](http://www.gitminutes.com)。

# 在线视频

YouTube 和其他视频分享平台上有很多关于 Git 的优质免费教程;在学习 Git 时,千万不要低估这一机会。此外,*Packt*有一系列非常丰富的 Git 视频资源,更多信息请查看 [`www.packtpub.com/video?search=git`](https://www.packtpub.com/video?search=git)

# Ferdinando Santacroce 的博客

在我的个人博客 [jesuswasrasta.com](http://jesuswasrasta.com) 上,我最近开始了一个*Git Pills*系列,在这个系列中,我与读者分享一些我在使用 Git 时发现的技巧,快速完成任务的方法以及如何从奇怪情况中恢复过来。

# 总结

在本章中,我们介绍了一些 Git 的图形界面客户端。尽管我鼓励大家通过使用 shell 命令来理解 Git,但不得不承认,对于大多数常见任务,使用基于 GUI 的工具或 IDE 集成功能让我感觉更舒适,尤其是在进行差异比较或查看历史记录时。

然后我们发现可以通过一个精美的网页界面来获取个人 Git 服务器:互联网有许多优秀的软件可以实现这一目标。

最后,作为我的最后建议,我提到了一些很好的资源,可以增强你对 Git 的理解;聆听专家的意见并向他们提问,是完成工作的最有效方式。
posted @ 2025-07-02 17:45  绝不原创的飞龙  阅读(39)  评论(0)    收藏  举报