章节 ▾ 第二版

2.2 Git 基础 - 记录仓库中的更改

记录仓库中的更改

此时,你应该已经在本地机器上拥有了一个真正的 Git 仓库,并且面前有它所有文件的一个检出版本或工作副本。通常情况下,你想要开始进行更改,并在项目达到你想要记录的状态时,将这些更改的快照提交到仓库中。

请记住,工作目录中的每个文件要么处于已跟踪状态,要么处于未跟踪状态。已跟踪的文件是指那些被包含在上一次快照中的文件,以及任何新暂存的文件;它们可以是未修改、已修改或已暂存状态。简而言之,已跟踪文件就是 Git 知道的文件。

未跟踪的文件是指除此之外的所有文件——即工作目录中那些既不在上一次快照中,也不在暂存区中的文件。当你首次克隆一个仓库时,所有的文件都将是已跟踪且未修改的,因为 Git 刚刚将它们检出,而你尚未编辑任何内容。

当你编辑文件时,Git 会将它们视为已修改,因为你自上次提交后更改了它们。在工作过程中,你有选择地暂存这些已修改的文件,然后提交所有这些暂存的更改,如此循环往复。

The lifecycle of the status of your files
图 8. 文件状态的生命周期

检查文件状态

你用来确定哪些文件处于何种状态的主要工具是 git status 命令。如果你在克隆后直接运行此命令,应该会看到类似这样的输出

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working tree clean

这意味着你有一个干净的工作目录;换句话说,你没有任何已跟踪的文件被修改。Git 也没有看到任何未跟踪的文件,否则它们会列在这里。最后,该命令会告诉你当前所处的分支,并告知该分支与服务器上的同名分支没有分叉。目前,该分支始终是默认的 master 分支;你在这里暂时不用担心它。Git 分支 将详细介绍分支和引用。

注意

GitHub 在 2020 年年中将默认分支名称从 master 更改为 main,其他 Git 托管平台也纷纷效仿。因此,你可能会发现在某些新创建的仓库中,默认分支名称是 main 而不是 master。此外,默认分支名称是可以更改的(正如你在 你的默认分支名称 中所见),所以你可能会看到一个不同的默认分支名称。

然而,Git 本身仍然使用 master 作为默认设置,因此我们将在全书中沿用该名称。

假设你向项目中添加了一个新文件,一个简单的 README 文件。如果该文件之前不存在,且你运行 git status,你将看到如下未跟踪的文件

$ echo 'My Project' > README
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Untracked files:
  (use "git add <file>..." to include in what will be committed)

    README

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

你可以看到新的 README 文件是未跟踪的,因为它出现在状态输出的“Untracked files”(未跟踪的文件)标题下。未跟踪基本上意味着 Git 发现了一个你在上一个快照(提交)中没有的文件,且尚未被暂存;在你明确告诉 Git 之前,它不会将该文件包含在你的提交快照中。Git 这样做是为了防止你意外地开始包含生成的二进制文件或其他你不打算包含的文件。你确实想开始包含 README,所以让我们开始跟踪该文件。

跟踪新文件

为了开始跟踪一个新文件,你需要使用 git add 命令。要开始跟踪 README 文件,你可以运行

$ git add README

如果你再次运行状态命令,可以看到 README 文件现在已受到跟踪并已暂存以备提交

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)

    new file:   README

你可以确定它已暂存,因为它位于“Changes to be committed”(要提交的变更)标题下。如果你此时进行提交,则你运行 git add 时的文件版本将被存入后续的历史快照中。你可能还记得,早先运行 git init 时,你接着运行了 git add <files> —— 那是为了开始跟踪目录中的文件。git add 命令接受文件或目录的路径名;如果是目录,该命令会递归地添加该目录中的所有文件。

暂存已修改的文件

让我们修改一个已经受跟踪的文件。如果你更改了之前受跟踪的文件 CONTRIBUTING.md,然后再次运行 git status 命令,你会得到类似这样的输出

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   README

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:   CONTRIBUTING.md

CONTRIBUTING.md 文件出现在“Changes not staged for commit”(尚未暂存以备提交的变更)部分——这意味着一个受跟踪的文件在工作目录中已被修改,但尚未暂存。要暂存它,你需要运行 git add 命令。git add 是一个多用途命令——你可以用它开始跟踪新文件、暂存文件,以及做其他事情,比如将存在合并冲突的文件标记为已解决。把它理解为“将此内容精确地添加到下一次提交中”,而不是“将此文件添加到项目中”,可能会更有帮助。现在让我们运行 git add 来暂存 CONTRIBUTING.md 文件,然后再运行一次 git status

$ git add CONTRIBUTING.md
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   README
    modified:   CONTRIBUTING.md

这两个文件都已暂存,并将进入你的下一次提交。此时,假设你记起在提交 CONTRIBUTING.md 之前还有一个小改动想要做。你再次打开它并进行了修改,现在准备提交了。然而,让我们再运行一次 git status

$ vim CONTRIBUTING.md
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   README
    modified:   CONTRIBUTING.md

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:   CONTRIBUTING.md

怎么回事?现在 CONTRIBUTING.md 同时被列为已暂存未暂存。这怎么可能?事实证明,Git 会在你运行 git add 命令时,确切地按照文件的当前状态进行暂存。如果你现在提交,进入提交的版本将是你上次运行 git add 命令时的 CONTRIBUTING.md 版本,而不是你在运行 git commit 时工作目录中看到的那个版本。如果你在运行 git add 后修改了文件,则必须再次运行 git add 以暂存该文件的最新版本

$ git add CONTRIBUTING.md
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   README
    modified:   CONTRIBUTING.md

简洁的状态输出

虽然 git status 的输出非常全面,但也相当冗长。Git 还有一个简洁状态标志,让你能以更紧凑的方式查看变更。如果你运行 git status -sgit status --short,将获得该命令非常简化的输出

$ git status -s
 M README
MM Rakefile
A  lib/git.rb
M  lib/simplegit.rb
?? LICENSE.txt

未跟踪的新文件旁边会有 ??,已添加到暂存区的新文件会有 A,已修改的文件有 M 等等。输出有两列——左列表示暂存区的状态,右列表示工作树的状态。例如,在该输出中,README 文件在工作目录中被修改但尚未暂存,而 lib/simplegit.rb 文件已修改并已暂存。Rakefile 被修改、暂存,然后又被修改了,所以它既有已暂存的变更,也有未暂存的变更。

忽略文件

通常,你会有一些不需要 Git 自动添加甚至不需要显示为未跟踪的文件类别。这些通常是自动生成的文件,如日志文件或构建系统产生的文件。在这种情况下,你可以创建一个名为 .gitignore 的文件,列出与它们匹配的模式。这是一个 .gitignore 文件的示例

$ cat .gitignore
*.[oa]
*~

第一行告诉 Git 忽略任何以“.o”或“.a”结尾的文件——即可能是构建代码时产生的对象文件和归档文件。第二行告诉 Git 忽略所有以波浪号(~)结尾的文件,许多文本编辑器(如 Emacs)使用它来标记临时文件。你还可以包含 log、tmp 或 pid 目录;自动生成的文档等。在开始之前为你的新仓库设置一个 .gitignore 文件通常是个好主意,这样你就不会意外提交那些你确实不希望出现在 Git 仓库中的文件。

可以在 .gitignore 文件中使用的模式规则如下

  • 空行或以 # 开头的行将被忽略。

  • 可以使用标准 glob 模式,并且这些模式将递归地应用于整个工作树。

  • 可以在模式开头使用正斜杠(/)以避免递归。

  • 可以在模式末尾使用正斜杠(/)以指定目录。

  • 可以通过在模式开头加上感叹号(!)来对模式取反。

Glob 模式类似于 shell 使用的简化正则表达式。星号(*)匹配零个或多个字符;[abc] 匹配方括号内的任何字符(在这种情况下是 a、b 或 c);问号(?)匹配单个字符;包含用连字符分隔的字符的方括号([0-9])匹配它们之间的任何字符(在这种情况下是 0 到 9)。你还可以使用两个星号来匹配嵌套目录;a/**/z 将匹配 a/za/b/za/b/c/z 等。

这是另一个 .gitignore 文件的示例

# ignore all .a files
*.a

# but do track lib.a, even though you're ignoring .a files above
!lib.a

# only ignore the TODO file in the current directory, not subdir/TODO
/TODO

# ignore all files in any directory named build
build/

# ignore doc/notes.txt, but not doc/server/arch.txt
doc/*.txt

# ignore all .pdf files in the doc/ directory and any of its subdirectories
doc/**/*.pdf
提示

如果你想为你的项目找一个起点,GitHub 在 https://github.com/github/gitignore 上维护了一份相当全面的、适用于数十种项目和语言的优秀 .gitignore 文件示例列表。

注意

在简单的情况下,仓库在其根目录下可能只有一个 .gitignore 文件,该文件递归地应用于整个仓库。然而,也可以在子目录中拥有额外的 .gitignore 文件。这些嵌套的 .gitignore 文件中的规则仅适用于其所在目录下的文件。Linux 内核源代码仓库有 206 个 .gitignore 文件。

深入探讨多个 .gitignore 文件的细节超出了本书的范围;详情请参阅 man gitignore

查看已暂存和未暂存的变更

如果 git status 命令对你来说太笼统——你想确切知道你改变了什么,而不仅仅是哪些文件被改变了——你可以使用 git diff 命令。我们稍后会更详细地介绍 git diff,但你最常使用它来回答这两个问题:你改变了什么但尚未暂存?以及你暂存了什么即将提交的更改?虽然 git status 通过列出文件名非常概括地回答了这些问题,但 git diff 会向你显示添加和删除的确切行——也就是所谓的补丁(patch)。

假设你再次编辑并暂存了 README 文件,然后编辑了 CONTRIBUTING.md 文件而不暂存它。如果你运行 git status 命令,你会再次看到类似这样的内容

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    modified:   README

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:   CONTRIBUTING.md

要查看你改变了但尚未暂存的内容,请输入不带其他参数的 git diff

$ git diff
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8ebb991..643e24f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -65,7 +65,8 @@ branch directly, things can get messy.
 Please include a nice description of your changes when you submit your PR;
 if we have to read the whole diff to figure out why you're contributing
 in the first place, you're less likely to get feedback and have your change
-merged in.
+merged in. Also, split your changes into comprehensive chunks if your patch is
+longer than a dozen lines.

 If you are starting to work on a particular area, feel free to submit a PR
 that highlights your work in progress (and note in the PR title that it's

该命令将你的工作目录中的内容与暂存区中的内容进行比较。结果会告诉你你所做的尚未暂存的变更。

如果你想查看已暂存并将进入下一次提交的变更,可以使用 git diff --staged。该命令将你的暂存变更与最后一次提交进行比较

$ git diff --staged
diff --git a/README b/README
new file mode 100644
index 0000000..03902a1
--- /dev/null
+++ b/README
@@ -0,0 +1 @@
+My Project

需要注意的是,git diff 本身并不会显示自上次提交以来所做的所有更改——只显示尚未暂存的更改。如果你已经暂存了所有的更改,git diff 将不会有任何输出。

再举一个例子,如果你暂存了 CONTRIBUTING.md 文件,然后又编辑了它,你可以使用 git diff 查看文件中已暂存和未暂存的变更。如果我们的环境看起来是这样的

$ git add CONTRIBUTING.md
$ echo '# test line' >> CONTRIBUTING.md
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    modified:   CONTRIBUTING.md

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:   CONTRIBUTING.md

现在你可以使用 git diff 查看仍然未暂存的内容

$ git diff
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 643e24f..87f08c8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -119,3 +119,4 @@ at the
 ## Starter Projects

 See our [projects list](https://github.com/libgit2/libgit2/blob/development/PROJECTS.md).
+# test line

使用 git diff --cached 查看迄今为止已暂存的内容(--staged--cached 是同义词)

$ git diff --cached
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8ebb991..643e24f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -65,7 +65,8 @@ branch directly, things can get messy.
 Please include a nice description of your changes when you submit your PR;
 if we have to read the whole diff to figure out why you're contributing
 in the first place, you're less likely to get feedback and have your change
-merged in.
+merged in. Also, split your changes into comprehensive chunks if your patch is
+longer than a dozen lines.

 If you are starting to work on a particular area, feel free to submit a PR
 that highlights your work in progress (and note in the PR title that it's
注意
在外部工具中使用 Git Diff

在本书的后续部分,我们将继续以各种方式使用 git diff 命令。如果你更喜欢图形化或外部的差异查看程序,还有另一种查看这些差异的方法。如果你运行 git difftool 而不是 git diff,你可以在诸如 emerge、vimdiff 等软件(包括商业产品)中查看这些差异。运行 git difftool --tool-help 查看你系统上可用的工具。

提交你的更改

现在你的暂存区已经设置完毕,可以提交你的更改了。请记住,任何尚未暂存的内容——即你创建或修改了但自编辑以来尚未运行 git add 的任何文件——都不会进入本次提交。它们将作为已修改的文件保留在你的磁盘上。在这种情况下,假设你上次运行 git status 时看到所有内容都已暂存,现在准备提交你的更改。最简单的提交方式是输入 git commit

$ git commit

这样做会启动你选择的编辑器。

注意

这是由你的 shell 的 EDITOR 环境变量设置的——通常是 vim 或 emacs,尽管你可以按照 入门 中所见,使用 git config --global core.editor 命令将其配置为你想要的任何编辑器。

编辑器会显示以下文本(此示例为 Vim 屏幕)

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# On branch master
# Your branch is up-to-date with 'origin/master'.
#
# Changes to be committed:
#	new file:   README
#	modified:   CONTRIBUTING.md
#
~
~
~
".git/COMMIT_EDITMSG" 9L, 283C

你可以看到默认的提交信息包含 git status 命令的最新输出(已注释掉)以及顶部的一个空行。你可以删除这些注释并键入你的提交信息,也可以将它们留在那里,以帮助你记住你正在提交的内容。

注意

为了更明确地提醒你修改了什么,你可以向 git commit 传递 -v 选项。这样做还会将你的变更差异(diff)放入编辑器中,以便你能确切地看到你正在提交哪些更改。

当你退出编辑器时,Git 会用该提交信息创建你的提交(注释和差异会被剔除)。

或者,你可以在 commit 命令后使用 -m 标志直接输入提交信息,如下所示

$ git commit -m "Story 182: fix benchmarks for speed"
[master 463dc4f] Story 182: fix benchmarks for speed
 2 files changed, 2 insertions(+)
 create mode 100644 README

现在你已经创建了第一个提交!你可以看到提交输出了一些关于它自己的信息:你提交到了哪个分支(master)、提交具有什么 SHA-1 校验和(463dc4f)、更改了多少文件,以及提交中添加和删除行的统计信息。

请记住,提交记录的是你在暂存区设置的快照。任何你没有暂存的内容仍然留在那里,处于已修改状态;你可以进行另一次提交将其添加到历史记录中。每次执行提交时,你都在记录项目的快照,以后可以回退到该快照或与该快照进行比较。

跳过暂存区

虽然暂存区对于精心制作你想要的提交非常有用,但有时你的工作流程可能不需要这么复杂。如果你想跳过暂存区,Git 提供了一个简单的快捷方式。在 git commit 命令中添加 -a 选项会使 Git 在提交前自动暂存每个已受跟踪的文件,从而让你跳过 git add 部分

$ git status
On branch master
Your branch is up-to-date with 'origin/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:   CONTRIBUTING.md

no changes added to commit (use "git add" and/or "git commit -a")
$ git commit -a -m 'Add new benchmarks'
[master 83e38c7] Add new benchmarks
 1 file changed, 5 insertions(+), 0 deletions(-)

注意在这个例子中你不需要对 CONTRIBUTING.md 文件运行 git add 即可提交。这是因为 -a 标志包含了所有已更改的文件。这很方便,但要小心;有时这个标志会导致你包含不必要的更改。

删除文件

要从 Git 中删除文件,你必须从已跟踪的文件中将其删除(更准确地说是从暂存区中删除),然后提交。git rm 命令可以做到这一点,它还会从你的工作目录中删除该文件,这样你在下次运行时就不会看到它作为未跟踪文件出现。

如果你只是从工作目录中删除文件,它会出现在 git status 输出的“Changes not staged for commit”(即未暂存)区域下

$ rm PROJECTS.md
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        deleted:    PROJECTS.md

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

然后,如果你运行 git rm,它会暂存文件的删除操作

$ git rm PROJECTS.md
rm 'PROJECTS.md'
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    deleted:    PROJECTS.md

下一次提交时,该文件将消失且不再受跟踪。如果你修改了该文件或已经将其添加到暂存区,则必须使用 -f 选项强制删除。这是一项安全功能,旨在防止意外删除尚未记录在快照中且无法从 Git 中恢复的数据。

你可能想做的另一件有用的事情是保留工作树中的文件,但将其从暂存区中删除。换句话说,你可能希望将文件保留在硬盘上,但不再让 Git 跟踪它。如果你忘记将某些内容添加到 .gitignore 文件中并意外暂存了它(例如大日志文件或一堆 .a 编译文件),这特别有用。要做到这一点,请使用 --cached 选项

$ git rm --cached README

你可以将文件、目录和文件 glob 模式传递给 git rm 命令。这意味着你可以执行诸如

$ git rm log/\*.log

注意 * 前面的反斜杠(\)。这是必要的,因为除了 shell 的文件名扩展外,Git 还会进行自己的文件名扩展。此命令删除 log/ 目录中所有具有 .log 扩展名的文件。或者,你可以执行类似这样的操作

$ git rm \*~

该命令删除所有名称以 ~ 结尾的文件。

移动文件

与其他许多版本控制系统(VCS)不同,Git 不会显式跟踪文件移动。如果你在 Git 中重命名文件,Git 中不会存储说明你重命名了该文件的元数据。然而,Git 在事后确定这一点方面非常聪明——我们稍后会处理检测文件移动的问题。

因此,Git 拥有 mv 命令有点令人困惑。如果你想在 Git 中重命名文件,你可以运行类似

$ git mv file_from file_to

这样的命令,并且它能很好地工作。事实上,如果你运行类似这样的命令并查看状态,你会看到 Git 将其视为重命名文件

$ git mv README.md README
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    renamed:    README.md -> README

然而,这等同于运行类似这样的命令

$ mv README.md README
$ git rm README.md
$ git add README

Git 会隐式推断出这是一个重命名,所以用这种方式还是用 mv 命令重命名文件并不重要。唯一的实际区别是 git mv 是一个命令而不是三个——它是一个便捷函数。更重要的是,你可以使用任何你喜欢的工具来重命名文件,并在提交之前处理 add/rm