如何使用 Git 和 Git 工作流——实用指南

每个人都说你应该学习 Git——你确实应该——但是说实话:Git 有点难。

甚至在我近 10 年的软件开发生涯后,我还在学习 Git 的基本原理和如何更有效地使用 Git。

就在不久前我意识到我对一个已经使用了无数次的关键命令存在最基本的误解。

就像编程的其他很多领域一样,我相信最好的学习方法就是 “实践”。

只要开始使用这个工具,就会有成效——随着时间的推移,基本原理和边缘案例最终会得到解决的。

这正是我们在这个教程中要做的。我们会通过一些系列的实例来全面了解如何使用 Git 以及与队友协作。

在这个过程中,我们会使用简单的命令并且解释它们的基本概念,因为它们有用——但是只限于有助于理解的程度。

Git 的内容肯定比这里介绍的多得多,但这些都是你在长期使用过程中会学到东西。

我也不会使用树状图(像下面这个),因为它们会把我搞糊涂,而且作为一名软件开发者,我从来不需要像这样来理解 Git。

https://www.atlassian.com/git/tutorials/using-branches/git-checkout

这些是我们要涉及到的内容。不要被这份清单吓到,我们会一步一步的进行。

  • 安装 git 并创建一个 GitHub 帐户

  • 如何在 GitHub 上创建一个新的仓库(repository)

  • 克隆仓库

  • Git 分支(branch)

  • 如何查看一个 Git 项目的状态

  • 如何做出我们的第一个提交(commit)

  • 如何将我们的第一个提交推送(push)到 GitHub

  • 如何在 Git 中添加另一个提交

  • 如何在 Git 中缓存修改(stage change)

  • 如何查看 Git 差异(diff)

  • 如何在 Git 中与他人协作

  • Git 中的功能分支(feature branch)

  • 用于协作的 Git 工作流程

  • 如何在 Git 中合并(merge)分支

  • 拉取请求(pull request)工作流程

  • 如何使本地保持同步

  • 如何获取远端数据

  • 如何修复 Git 中的合并冲突(merge conflict)

  • 回顾:如何启动一个新功能的工作流程

  • 总结

说了这么多,我鼓励你在自己的机器上跟着例子一起进行——让我们开始吧!

如何安装 Git 并创建一个 GitHub 账号

首先,在开始前我们需要处理一些无聊的事情。

如果你已经安装了 Git,注册了 GitHub 账户(或者使用任何其他的服务商,像是 GitLab 或者 Bitbucket),并且已经设置了 SSH 密匙的话,可以跳过这个章节。

否则,你将首先需要安装 Git。

其次,在本教程里我们会使用 GitHub,所以需要注册一个 GitHub 帐户。

GitHub 账户注册完成后,你将会需要创建一个 SSH 密匙以便将你的代码从本地推送到 GitHub (在推送代码时这个密匙可以向 GitHub 证明你是 “你”)。

这并不难——只要遵循这里的步骤就可以了。

如何在 GitHub 里创建一个新的仓库(repository)

下一步我们会在 GitHub 里创建一个新的仓库。

这很简单。只需要点击你的主页上的 “New” 仓库按钮。

创建一个新仓库

接下来,为仓库选择一个名字,以及你希望仓库是公开的还是私有的。你还可以选择添加一个 README 文件,然后点击 “Create repository”。

设置新仓库

我把我的仓库命名为 practical-git-tutorial。这个仓库里已经有了这个教程所有的完成步骤,你可以把它作为参考。

如何克隆一个 Git 仓库

首先,我们将 “克隆” 这个仓库。克隆一个仓库意味着从源头(在这里就是指 GitHub)上下载所有项目的代码和元数据。

我们使用 git clone <URL> 来克隆仓库。

我使用了我刚刚创建的仓库的 URL,但是你应该使用你自己仓库的 URL:

$ git clone [email protected]:johnmosesman/practical-git-tutorial.gitCloning into 'practical-git-tutorial'...remote: Enumerating objects: 6, done.remote: Counting objects: 100% (6/6), done.remote: Compressing objects: 100% (3/3), done.remote: Total 6 (delta 0), reused 0 (delta 0), pack-reused 0Receiving objects: 100% (6/6), done.

注意: 在你终端运行的命令将以 $ 作为前缀。

我们很快会详细介绍 git clone 的作用,但现在只需知道它会下载项目并将其放在你当前工作目录的一个文件夹中。

接下来,让我们用 cd 切换到新的目录:

$ cd practical-git-tutorial//practical-git-tutorial (main)$

我们已经切换到了这个文件夹(和其他文件夹一样),你的终端可能会在目录名旁边显示一些东西:(main)

Git 分支(branch)

这个 (main) 意味着我们现在位于一个叫做 main分支 上。你可以把一个 Git 分支看作是项目 在某一特定时间点 的副本,可以独立于其他分支进行修改。

例如,如果我们用 Git 来跟踪一本书的写作,我们可能会有这样的分支:

  • main 分支

  • table-of-contents 分支

  • chapter-1 分支

  • chapter-2 分支

  • 等等。

main 分支是,嗯,“主” 分支——我们将把书中的所有内容合并成一本最终完成的书的地方。

我们可以创建其他分支来分离和跟踪特定的工作。

如果我在写第一章,而你在写第二章,我们可以创建两个不同的分支, chapter-1chapter-2 ——实际上就是这本书当前状态的两个不同副本。

这样我们就可以在各自的章节上工作,而互不影响,也不会改动到对方的内容——我们都有自己的工作副本,彼此是分开的。

当我们中的任何一个人完成了自己的章节,我们就可以把我章节分支的内容加回到 main 分支中。当我们两个都完成后, main 分支就会同时包含第一章和第二章。

然而,有些时候,你 覆盖或更改和别人的内容相同的内容,我们必须想办法解决这些分歧——很快就会看到。

注意: 根据项目的不同,你可能会看到一个分支被命名为 master 而不是 main 。它们没有任何功能上的区别,只需要根据你的项目中的内容输入 mastermain 即可。

如何查看一个 Git 项目的状态

我们经常会做的一件事是检查我们项目的状态,已经做了哪些改动,我们想用它们做什么?

我们使用 git status 查看项目的状态:

(main)$ git statusOn branch mainYour branch is up to date with 'origin/main'.nothing to commit, working tree clean

这个命令的结果中有一些东西,让我们把它们分解一下。

git status 告诉我们的第一件事是我们在 main 分支上:

 On branch main

第二句话比较有意思:

Your branch is up to date with 'origin/main'.

Git 告诉我们,我们的分支是与 origin/main “同步” 的。

origin 是一个新的概念,被称为远端(remote)。远端是一个不同于你本地机器的 “远程源”。

在这个项目中,我们有自己的本地副本,但我们也可以添加可以协作的远程源。毕竟,这是 Git 最大的好处之一:与他人的可控协作。

继续我们写书的例子,如果我在我的机器上写第一章,你在你的机器上写第二章,我们都可以把对方的电脑添加为 “远端” 并发送和接收对方的修改。

在实践中,编程社区广泛认同最好有一个代码的可信单一数据源(SSOT)。一个代码库的当前状态总是 “正确” 的地方。按照惯例,我们称这个地方为 源(origin)

在这种情况下,GitHub 是我们的 “源”。

事实上,我们可以通过运行 git remote -v-v 代表 “verbose”)命令看到这一点:

(main)$ git remote -v
origin  [email protected]:johnmosesman/practical-git-tutorial.git (fetch)
origin  [email protected]:johnmosesman/practical-git-tutorial.git (push)

这个命令列出我们所有的远端。从结果我们们可以看到我们有一个远端叫做 origin ,这个远端的 Git URL 指向我们在 Github.com 上的仓库。它是在我们运行 git clone 时自动设置的。

回到 git status 的结果中的这句话:

Your branch is up to date with 'origin/main'.

当我们查询项目状态时, Git 告诉我们,我们本地的 main 分支与源(即 GitHub )的 main 分支是同步的。

事实上, git clone 自动为我们在本地创建了一个 main 分支,因为它看到我们克隆的源有一个叫 main 的分支作为主分支。

基本上,我们本地机器上没有与 GitHub 不同的变化,反之亦然——我们的本地 main 分支和 GitHub 上的 main 分支是相同的。

随着我们进行修改,我们会看到这条信息的变化,来反映我们本地仓库和源(GitHub)仓库的差异。

git status 的最后一条信息是关于本地项目的状态:

nothing to commit, working tree clean

当我们进行修改时会在这里进行更详细的说明,但这个消息基本上是说我们没有做任何事——所以没有变化要报告。

总结一下 git status 的结果:

  • 我们在 main 分支上

  • 我们的本地 main 分支与 origin 的(GitHub 的) main 分支相同

  • 我们还没有对该项目做出任何修改

如何做出我们的第一次提交(commit)

现在我们了解了我们项目的初始状态,让我们做一些修改并看看结果。

继续我们写书的比喻,让我们创建一个新的文件,命名为 chapter-1.txt 并在其中插入一个句子。

(你可以使用下面的终端命令(terminal command),或者在任何文本编辑器中创建和编辑该文件——这无关紧要。)

(main)$ touch chapter-1.txt(main)$ echo "Chapter 1 - The Beginning" >> chapter-1.txt(main)$ cat chapter-1.txtChapter 1 - The Beginning

上面的命令用 touch 创建了一个名为 chapter-1.txt 的新文件,用 echo>> 操作符插入句子 “第一章——开端”,并且为了检查我们的工作,用 cat 现实文件内容。

结果是一个里面有一句话的简单文本文件。

让我们再次运行 git status ,看看它的输出有什么不同:

(main)$ git statusOn branch mainYour branch is up to date with 'origin/main'.Untracked files:  (use "git add <file>..." to include in what will be committed)        chapter-1.txtnothing added to commit but untracked files present (use "git add" to track)

在这里我们看到一个与之前不同的输出结果。我们看到一个描述 “未跟踪文件(untracked file)” 的部分,我们的新文件 chapter-1.txt 被列在那里。

在 Git 开始跟踪(track)一个文件的变化之前,我们首先需要告诉 Git 去跟踪它——正如消息底部所显示的——我们可以用 git add 来实现:

(main)$ git add chapter-1.txt

(除了为 git add 制定文件名外,你可以使用(.)来添加目录中的所有修改)。

让我们再检查一下状态:

(main)$ git statusOn branch mainYour branch is up to date with 'origin/main'.Changes to be committed:  (use "git restore --staged <file>..." to unstage)        new file:   chapter-1.txtjohn:~/code/practical-git-tutorial (main)$

信息又变了。它现在说我们有一些修改,已经准备好 “提交”。

Git 中的 commit 是一个被保存的工作块,但它与你在文本编辑器中保存一个文本文件时的保存有点不同。

你可以把提交想成是一个 完成的想法或工作单元(unit of work)

例如,如果我们继续写书中第一章的内容,它可能看起来是这样的:

  • 写下这一章的标题,在我们的编辑器中点击保存

  • 写下这一章的第一段,在我们的编辑器中点击保存

  • 写下这一章的第二段,再次点击保存

  • 写下这一章的最后一段,再次点击保存

在这里,这个文件我们已经 “保存” 了四次,但在这四次保存结束后,我们现在有了这一章的第一稿,这一稿就是一个“工作单元”。

我们想把这个文件保存在我们的电脑上,但是我们也想表明这是一个已经完成的工作单元——即使它只是一个草稿。这是值得被保留的一大块工作。将来我们可能会想重温它、再次编辑它、或者把这个草稿合并到整本书的当前草稿中。

为此,我们创建一个新的提交来表示这个里程碑。每个提交有自己独特的标识符,而且提交的顺序也被保留下来。

要提交我们的修改,必须先用  git add 把它们添加到缓存区(staging area)

(我们很快会讨论更多关于缓存区的问题。)

接下来,我们需要 git commit 来最终完成提交。

最佳实践是提供一个详细的信息,说明你做了_那些修改_ ——更重要的是——你 为什么 要提交这些修改。

一旦提交历史达到几百或几千条,如果没有一个好的提交信息(commit message)就几乎不可能理解 为什么 会有这些修改。Git 会显示哪些文件有修改,以及这些修改是什么,但这些_修改的意义_ 要靠我们自己提供。

让我们来提交我们制作的新文件并用 -m 或 "message" 标志来附上提交信息:

(main)$ git commit -m "New chapter 1 file with chapter heading"[main a8f8b95] New chapter 1 file with chapter heading 1 file changed, 1 insertion(+) create mode 100644 chapter-1.txt

我们现在已经提交了那块工作,可以通过 git log 查看 Git 日志:

(main)$ git logcommit a8f8b95f19105fe10ed144fead9cab84520181e3 (HEAD -> main)Author: John Mosesman <[email protected]>Date:   Fri Mar 19 12:27:35 2021 -0500    New chapter 1 file with chapter headingcommit 2592324fae9c615a96f856a0d8b8fe1d2d8439f8 (origin/main, origin/HEAD)Author: John Mosesman <[email protected]>Date:   Wed Mar 17 08:48:25 2021 -0500    Update README.mdcommit 024ea223ee4055ae82ee31fc605bbd8a5a3673a0Author: John Mosesman <[email protected]>Date:   Wed Mar 17 08:48:10 2021 -0500    Initial commit

看一下这个日志,我们会发现在项目历史中有三个提交。

最新的提交是我们刚刚做的那个。我们可以看到刚才使用的提交信息:"New chapter 1 file...".

还有两个之前的提交:一个是我初始化项目的时候,另一个是我更新 GitHub 上的 README.md 文件的时候。

注意每个提交都有一长串与之相关的数字和字符:

commit a8f8b95f19105fe10ed144fead9cab84520181e3 (HEAD -> main)

这一串数字和字符叫作安全散列函数(SHA)——它是由散列算法(也叫哈希算法)为该提交生成的唯一 ID。现在只需要注意一下,我们很开就会再来讨论这个问题。

在提交 SHA 后面,我们还看到另外两个有趣的东西:

  • (HEAD -> main) 旁边是我们最新的提交内容

  • (origin/main, origin/HEAD) 则在该提交之前的提交旁边。

这些信息告诉我们 我们的分支和远程的当前状态 (据我们所知——稍后我们再详细讨论)。

关于最新的提交,我们看到 HEAD (也就是项目历史中的 “我们现在的位置”)只想我们的 本地 main 分支——由 HEAD -> main 表示。

这是有道理的,因为我们刚刚做了那个提交,并且我们还没有做其他事情——我还在做那个提交的时间点上。

如果我们看一下以 25923 开头的前一个提交,我们可以看到 (origin/main, origin/HEAD)。这告诉我们,  在源点(即 GitHub) , GitHub 的 HEAD 或 “当前位置” 在我们之前的提交上。

基本上,我们的本地机器认为本地 main 分支的最新改动是我们添加第一章的提交,而我们的本地机器认为 GitHub 上 的最新改动是我们在写这篇文章之前更新 README 的提交。

这也是合理的——我们还没有告诉 GitHub 我们最新的提交。GitHub 仍然认为这个仓库是它看到的最新的。

现在让我们把我们的新提交推送(push)到 GitHub。

如何将我们的第一个提交推送(push)到 GitHub

我们在本地机器上有一个新的提交,我们需要更新我们的 “可信单一数据源”—— origin 远端——即 GitHub。

我们目前在本地的 main 分支上,所以我们需要告诉 GitHub 用我们的新提交来更新它自己的 main

我们使用 git push 命令做到这一点,我们可以指定 我们要推送到哪里 以及 我们要推送到哪个分支

(main)$ git push origin mainEnumerating objects: 4, done.Counting objects: 100% (4/4), done.Delta compression using up to 16 threadsCompressing objects: 100% (2/2), done.Writing objects: 100% (3/3), 326 bytes | 326.00 KiB/s, done.Total 3 (delta 0), reused 0 (delta 0)To github.com:johnmosesman/practical-git-tutorial.git   2592324..a8f8b95  main -> main

这里我们推送到 origin 远端(GitHub)的 main 分支。

输出结果告诉我们 Git 为此所做的一些文件操作,最后一行告诉我们它推送了哪些提交,推送到了哪里。

To github.com:johnmosesman/practical-git-tutorial.git
   2592324..a8f8b95  main -> main

这里显示我们把我们的 main 分支推送到了 GitHub 的 main 分支。

如果我们回头开一下 git log 的输出,会发现我们的本地和 origin 现在都指向同一个提交:

(main)$ git logcommit f5b6e2f18f742e2b851e38f52a969dd921f72d2f (HEAD -> main, origin/main, origin/HEAD)Author: John Mosesman <[email protected]>Date:   Mon Mar 22 10:07:35 2021 -0500    Added the intro line to chapter 1

简而言之,在 origin (GitHub)上 main 分支(也写成 origin/main )现在已经将我们的新提交标记为了历史中的最新提交。

如果我们和其他合作者一些工作,现在他们可以从 GitHub 上拉取(pull)我们的最新修改并开始编辑第一章。

如何在 Git 中添加另一个提交

在我们和其他人合作之前,让我们再做一个小改动,看看当我们编辑一个现有文件时会发生什么。

让我们在第一章的文件里再添加一行:

(main)$ echo "It was the best of times, it was the worst of times" >> chapter-1.txt(main)$ cat chapter-1.txtChapter 1 - The BeginningIt was the best of times, it was the worst of times

使用 cat 我们可以看到我们的文件现在包含两行。

让我们再看看我们的 Git 仓库的状态:

(main)$ git statusOn branch mainYour branch is up to date with 'origin/main'.Changes not staged for commit:  (use "git add <file>..." to update what will be committed)  (use "git restore <file>..." to discard changes in working directory)        modified:   chapter-1.txtno changes added to commit (use "git add" and/or "git commit -a")

从顶部开始,我们会注意到输出结果显示 Your branch is up to date with 'origin/main'.

这可能看起来比较奇怪,我们刚刚修改了一个文件,但是 Git 只是将我们做的 提交origin/main 中的提交进行比较。

输出的下一部分内容做了更多解释:

Changes not staged for commit:  (use "git add <file>..." to update what will be committed)  (use "git restore <file>..." to discard changes in working directory)        modified:   chapter-1.txt

在这里 Git 告诉我们,我们有 “修改未为提交缓存 (changes not staged for commit)”。

在我们提交一组修改之前,我们首先需要把它们 缓存(stage)

如何在 Git 中缓存修改(stage change)

为了说明缓存区域(staging area)的作用,让我们先用  git add 来缓存我们的修改:

(main)$ git add .(main)$ git statusOn branch mainYour branch is up to date with 'origin/main'.Changes to be committed:  (use "git restore --staged <file>..." to unstage)        modified:   chapter-1.txt

这些修改现在可以提交了,但在提交之前,让我们在 chapter-1.txt 文件中再添加一个修改。

我要把 chapter-1.txt 的内容完全替换成新的文本:

注意: 我在这里使用 > 而不是 >> ,这将替换文件的内容而不是追加到文件中。

(main)$ echo "New file contents" > chapter-1.txt(main)$ cat chapter-1.txtNew file contents(main)$ git statusOn branch mainYour branch is up to date with 'origin/main'.Changes to be committed:  (use "git restore --staged <file>..." to unstage)        modified:   chapter-1.txtChanges not staged for commit:  (use "git add <file>..." to update what will be committed)  (use "git restore <file>..." to discard changes in working directory)        modified:   chapter-1.txt

从输出中我们可以看到我们现在有 已缓存的修改(staged changes) ,和 未缓存的修改(not staged changes)

虽然文件本身只能包含一个东西,但 Git 为我们记录了这两个修改——尽管它们是对同一行的修改!

然而,从上面的输出中我们无法真正知道这些修改是什么——我们只知道它们存在。

我们首先看一下使用命令行(command line)怎么查看这些修改(我从来不用),然后看一下使用图形用户界面(GUI)(100% 更好用)。

如何查看 Git 差异(diff)

要查看这些修改,我们需要查看 Git 差异

差异 是两组修改之间的差异。这些修改可以是从已缓存的修改到未缓存的修改再到提交中的任何一个。

使用命令行的方式查看需要用到 git diff

为了全面的展示,我们将在这里看一下这个简单案例的输出。但是,正如我之前提到的,我们对有效的 Git 工作流程感兴趣,一旦你要在多个文件中进行任何大规模的修改,这种命令行输出就变得无效了。

但是为了全面,请看下面:

(main)$ git diffdiff --git a/chapter-1.txt b/chapter-1.txtindex 0450d87..4cbeaee 100644--- a/chapter-1.txt+++ b/[email protected]@ -1,2 +1 @@-Chapter 1 - The Beginning-It was the best of times, it was the worst of times+New file contents

我的终端试图对这个输出进行着色来帮助提高可读性,但是需要注意的地方是,它告诉我们这在比较的文件是 chapter-1.txt ,并且在底部显示了实际差异。让我们来看看这几行输出:

-Chapter 1 - The Beginning-It was the best of times, it was the worst of times+New file contents

以减号( - )开始的几行是我们完全或部分删除的行,以加号( + )开头的行代表完全或部分添加的行。

随着多个文件和修改行的增加,这种输出会很快变得不方便。有一个更好的方法,在我近十年的编程生涯中,我一直使用一个简单的 GUI 程序来帮助查看和管理差异。

我使用的程序叫做 GitX,它是一个老旧过时的软件,甚至已经没有了真正的维护。然而,我只是用它来查看和管理文件差异,所以它对我有用。

我不会着重推荐这个软件,它是免费的。虽然我没有用过,但 GitHub Desktop client 很可能是一个不错的选择。

现在把这个小问题解决了,这是差异在我的工具中的样子。

首先,右侧的已缓存修改显示了我们对第二句话的原始添加:

GitX 中的已缓存修改 (staged changes)

在左侧的未缓存修改 (unstaged changes) 中,我们可以看到完全删除了这两行并添加了新的一行: