Git 教程

61 阅读30分钟

Git Immersion

原文:gitimmersion.com

1. 设置

目标

  • 配置 Git 和 Ruby,以便为工作做好准备。

设置 Git 用户名和邮箱

如果你之前从未使用过 Git,需要先进行一些配置。运行以下命令,让 Git 知道你的名字和邮箱。如果你已经配置过 Git,可以跳过这部分,直接进入设置换行符的部分。

执行:

git config --global user.name "Your Name"
git config --global user.email "your_email@whatever.com"

设置换行符偏好

如果你是 Unix/Mac 用户:

执行:

git config --global core.autocrlf input
git config --global core.safecrlf true

对于 Windows 用户:

执行:

git config --global core.autocrlf true
git config --global core.safecrlf true

设置 Ruby

这个教程需要一个正常工作的 Ruby 解释器。如果你还没有安装 Ruby,现在是时候设置了:

下载 Ruby

2. 更多设置

目标

  • 设置并准备好教程材料,确保可以顺利运行。

获取教程包

解压教程包

解压后,你将得到一个名为 “git_tutorial” 的主目录,里面包含三个子目录:

  • html — 这些是 HTML 文件。你可以在浏览器中打开 html/index.html 查看教程内容。
  • work — 一个空的工作目录。你将在这里创建你的 Git 仓库。
  • repos — 预先准备好的 Git 仓库,方便你在教程的任何部分开始。如果你卡住了,只需将需要的实验拷贝到你的工作目录中。

3. 创建一个项目

目标

  • 学习如何从零开始创建一个 Git 仓库。

创建一个 “Hello, World” 程序

从一个空的工作目录开始,创建一个名为 “hello” 的目录,然后创建一个文件 hello.rb,文件内容如下。

执行:

mkdir hello
cd hello

hello.rb 文件内容

puts "Hello, World"

创建仓库

现在,你已经有了一个包含单个文件的目录。接下来,通过运行 git init 命令在该目录下创建一个 Git 仓库。

执行:

git init

输出:

$ git init
Initialized empty Git repository in /Users/jim/Downloads/git_tutorial/work/hello/.git/

将程序添加到仓库

现在,让我们将 “Hello, World” 程序添加到仓库中。

执行:

git add hello.rb
git commit -m "First Commit"

你应该会看到……

输出:

$ git add hello.rb
$ git commit -m "First Commit"
[main (root-commit) f7c41d3] First Commit
 1 file changed, 1 insertion(+)
 create mode 100644 hello.rb

4. 检查状态

目标

  • 学习如何检查仓库的状态。

检查仓库状态

使用 git status 命令检查当前仓库的状态。

执行:

git status

你应该会看到……

输出:

$ git status
On branch main
nothing to commit, working tree clean

git status 命令报告显示当前没有要提交的内容。这意味着仓库已经与工作目录的当前状态同步,所有的更改都已经记录,没有未提交的更改。

我们将继续使用 git status 命令来监控仓库和工作目录之间的状态变化。

5. 进行更改

目标

  • 学会如何监控工作目录的状态

更改 “Hello, World” 程序

现在是时候更改我们的 hello 程序,要求它从命令行接受一个参数。将文件更改为:

hello.rb

puts "Hello, #{ARGV.first}!"

检查状态

现在检查工作目录的状态。

执行:

git status

你应该会看到……

输出:

$ git status
On branch 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:   hello.rb

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

首先需要注意的是,git 已经知道 hello.rb 文件已被更改,但 git 还没有被通知这些变化。

另外,注意状态信息中给出了一些提示,告诉你接下来需要做什么。如果你想将这些变化添加到版本库中,可以使用 git add 命令。否则,可以使用 git restore 命令来丢弃这些更改。

接下来

让我们暂存这个更改。

6. 暂存更改

目标

  • 学习如何暂存更改以便后续提交

添加更改

现在告诉 Git 暂存更改。检查状态。

执行:

git add hello.rb
git status

你应该会看到……

输出:

$ git add hello.rb
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   hello.rb

hello.rb 文件的更改已经被暂存。这意味着 Git 现在已经知道这些更改,但这些更改还没有被永久记录到版本库中。下次执行提交操作时,将包括这些已暂存的更改。

如果你决定再提交这个更改,git status 命令会提醒你可以使用 git restore 命令来取消暂存这些更改。

7. 暂存和提交

Git 中的暂存步骤与其理念相符,即在你需要处理源代码控制时才介入。你可以继续更改工作目录中的文件,直到你准备与源代码控制系统互动为止,Git 允许你通过小的提交来记录你的变化,精确地记录你所做的更改。

例如,假设你编辑了三个文件(a.rbb.rbc.rb)。现在你想提交所有的更改,但你希望 a.rbb.rb 的更改合并为一个提交,而 c.rb 的更改与前两个文件的更改没有逻辑关系,应作为单独的提交。

你可以按以下步骤操作:

git add a.rb
git add b.rb
git commit -m "Changes for a and b"
git add c.rb
git commit -m "Unrelated change to c"

通过分离暂存和提交,你可以轻松地微调每个提交中包含的内容。

8. 提交更改

目标

  • 学习如何将更改提交到仓库。

提交更改

好了,关于暂存的部分已经够多了。现在我们将已暂存的更改提交到仓库。

当你之前使用 git commit 命令提交 hello.rb 文件的初始版本到仓库时,你使用了 -m 标志在命令行中提供了提交信息。git commit 命令还允许你交互式地编辑提交信息。现在我们就来试试。

如果你省略命令行中的 -m 标志,Git 会打开你选择的编辑器。这个编辑器是根据以下优先级顺序选择的:

  • GIT_EDITOR 环境变量
  • core.editor 配置设置
  • VISUAL 环境变量
  • EDITOR 环境变量

假设我将 EDITOR 环境变量设置为 emacsclient

现在执行提交,并检查状态。

执行:

git commit

你应该会看到以下内容出现在编辑器中:

输出:

|
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# On branch main
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#	modified:   hello.rb
#

在第一行输入提交信息:“Using ARGV”。然后保存文件并退出编辑器。你应该会看到……

输出:

git commit
Waiting for Emacs...
[main 569aa96] Using ARGV
 1 files changed, 1 insertions(+), 1 deletions(-)

“Waiting for Emacs...” 这一行是 emacsclient 程序的输出,它会将文件发送到一个正在运行的 Emacs 程序并等待文件关闭。剩下的输出是标准的提交信息。

检查状态

最后,让我们再次检查仓库状态。

执行:

git status

你应该会看到……

输出:

$ git status
On branch main
nothing to commit, working tree clean

工作目录干净,准备好继续下一步了。

9. 更改,而不是文件

目标

  • 学习 Git 是通过更改来工作的,而不是通过文件。

大多数源代码管理系统是基于文件的。你将一个文件添加到源代码管理中,系统会从那时起跟踪文件的变化。

Git 则关注文件的变化,而不是文件本身。当你使用 git add file 时,你并不是告诉 Git 将文件添加到仓库。相反,你是在告诉 Git 记录该文件的当前状态,以便稍后提交。

我们将在这个实验中探索这种差异。

第一个更改:允许默认名称

更改 “Hello, World” 程序,使其在没有提供命令行参数时使用默认值。

hello.rb

name = ARGV.first || "World"

puts "Hello, #{name}!"

添加这个更改

现在将这个更改添加到 Git 的暂存区。

执行:

git add hello.rb

第二个更改:添加注释

现在给 “Hello, World” 程序添加一个注释。

hello.rb

# Default is "World"
name = ARGV.first || "World"

puts "Hello, #{name}!"

查看当前状态

执行:

git status

你应该会看到……

输出:

$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   hello.rb

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:   hello.rb

注意,hello.rb 出现了两次。第一次更改(添加默认值)已经被暂存并准备提交。第二次更改(添加注释)尚未暂存。如果你现在提交,注释的更改将不会被保存到仓库中。

我们来试试提交。

提交

提交已暂存的更改(即默认值),然后重新检查状态。

执行:

git commit -m "Added a default value"
git status

你应该会看到……

输出:

$ git commit -m "Added a default value"
[main a6b268e] Added a default value
 1 file changed, 3 insertions(+), 1 deletion(-)
$ git status
On branch 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:   hello.rb

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

git status 命令告诉你,hello.rb 仍然有未记录的更改,但它已经不在暂存区了。

添加第二个更改

现在将第二个更改添加到暂存区,然后运行 git status

执行:

git add .
git status

注意: 我们使用当前目录(‘.’)作为文件来添加。这是一个非常方便的快捷方式,可以将当前目录及以下所有文件的更改都添加进来。但由于它会添加所有更改,所以在使用 add . 之前,最好先检查一下状态,以确保不会添加不想加入的文件。

我想让你看到 “add .” 这个技巧,但接下来的教程中我们将继续显式地添加文件,以确保安全。

你应该会看到……

输出:

$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   hello.rb

现在第二次更改已经被暂存并准备提交了。

提交第二个更改

执行:

git commit -m "Added a comment"

这就完成了第二次更改的提交。

10. 历史记录

目标

  • 学习如何查看项目的历史记录。

获取项目历史记录的变化列表是 git log 命令的功能。

执行:

git log

你应该会看到……

输出:

$ git log
commit e4e3645637546103e72f0deb9abdd22dd256601e
Author: Jim Weirich <jim (at) edgecase.com>
Date:   Sat Jun 10 03:49:13 2023 -0400

    Added a comment

commit a6b268ebc6a47068474bd6dfb638eb06896a6057
Author: Jim Weirich <jim (at) edgecase.com>
Date:   Sat Jun 10 03:49:13 2023 -0400

    Added a default value

commit 174dfabb62e6588c0e3c40867295da073204eb01
Author: Jim Weirich <jim (at) edgecase.com>
Date:   Sat Jun 10 03:49:13 2023 -0400

    Using ARGV

commit f7c41d3ce80ca44e2c586434cbf90fea3a9009a5
Author: Jim Weirich <jim (at) edgecase.com>
Date:   Sat Jun 10 03:49:13 2023 -0400

    First Commit

这是我们到目前为止提交的四个版本。

单行历史

你可以通过不同的选项控制 git log 显示的内容。我喜欢使用单行格式:

执行:

git log --pretty=oneline

你应该会看到……

输出:

$ git log --pretty=oneline
e4e3645637546103e72f0deb9abdd22dd256601e Added a comment
a6b268ebc6a47068474bd6dfb638eb06896a6057 Added a default value
174dfabb62e6588c0e3c40867295da073204eb01 Using ARGV
f7c41d3ce80ca44e2c586434cbf90fea3a9009a5 First Commit

控制显示哪些条目

git log 提供了许多选项来选择显示哪些条目。你可以尝试以下选项:

git log --pretty=oneline --max-count=2
git log --pretty=oneline --since='5 minutes ago'
git log --pretty=oneline --until='5 minutes ago'
git log --pretty=oneline --author=<your name>
git log --pretty=oneline --all

有关所有选项的详细信息,请查阅 git log 的手册。

进阶使用

以下是我用来查看过去一周更改的命令。如果我只想查看自己做的更改,我会加上 --author=jim

git log --all --pretty=format:'%h %cd %s (%an)' --since='7 days ago'

最终日志格式

随着时间的推移,我发现以下日志格式适合我大部分的工作:

执行:

git log --pretty=format:'%h %ad | %s%d [%an]' --graph --date=short

你应该会看到……

输出:

$ git log --pretty=format:'%h %ad | %s%d [%an]' --graph --date=short
* e4e3645 2023-06-10 | Added a comment (HEAD -> main) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

我们来逐项解释一下:

  • --pretty="..." 定义了输出的格式。
  • %h 是提交的简短哈希值。
  • %d 是该提交的任何装饰(例如,分支头或标签)。
  • %ad 是作者日期。
  • %s 是提交的注释。
  • %an 是作者名称。
  • --graph 会使 Git 以 ASCII 图形的形式显示提交树。
  • --date=short 会将日期格式设置为简短格式。

每次查看日志时输入这些命令会很麻烦。幸运的是,在下一章我们将学习如何使用 Git 别名来简化这个过程。

其他工具

gitx(适用于 Mac)和 gitk(适用于所有平台)是查看历史记录的有用工具。

11. 别名

目标

  • 学习如何为 Git 命令设置别名和快捷方式。

常见的别名

git statusgit addgit commitgit checkout 是非常常见的命令,给它们设置简写非常有用。

可以将以下内容添加到位于 $HOME 目录下的 .gitconfig 文件中。

.gitconfig

[alias]
  co = checkout
  ci = commit
  st = status
  br = branch
  hist = log --pretty=format:'%h %ad | %s%d [%an]' --graph --date=short
  type = cat-file -t
  dump = cat-file -p

我们已经覆盖了 commitstatus 命令。在之前的实验中,我们也已经介绍过了 log 命令。而 checkout 命令将会在之后讲解。

通过在 .gitconfig 文件中定义这些别名,你可以输入 git co 来代替以前的 git checkout,同理,使用 git st 代替 git status,使用 git ci 代替 git commit。最棒的是,git hist 可以让你避免输入冗长的 log 命令。

你可以尝试一下这些新的命令。

.gitconfig 文件中定义 hist 别名

大多数情况下,我将在这些说明中继续输入完整的命令。唯一的例外是,每当我们需要查看 Git 日志输出时,我将使用上面定义的 hist 别名。确保在继续之前,你的 .gitconfig 文件中已经设置了 hist 别名,如果你希望跟随实验的话。

typedump

我们添加了一些我们还没有讲解过的命令别名。git branch 命令将很快介绍。而 git cat-file 命令对于探索 Git 很有用,我们稍后会看到。

12. 获取旧版本

目标

  • 学习如何将任何先前的快照签出到工作目录中。

回到历史版本非常简单。checkout 命令将会把仓库中的任何快照复制到工作目录中。

获取先前版本的哈希值

执行:

git hist

注意: 你记得在 .gitconfig 文件中定义 hist 吗?如果没有,回顾一下别名的相关实验。

输出:

$ git hist
* e4e3645 2023-06-10 | Added a comment (HEAD -> main) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

检查日志输出,找到第一个提交的哈希值。它应该是 git hist 输出的最后一行。然后使用该哈希值(前 7 个字符即可)在下面的命令中替换 <hash>,并查看 hello.rb 文件的内容。

执行:

git checkout <hash>
cat hello.rb

注意: 这里给出的命令是 Unix 命令,适用于 Mac 和 Linux 系统。不幸的是,Windows 用户需要将其转换为本地命令。

注意: 许多命令依赖于仓库中的哈希值。由于你的哈希值与我的不同,每当你看到类似 <hash><treehash> 的内容时,记得用你仓库中的正确哈希值替代。

你应该会看到……

输出:

$ git checkout f7c41d3
Note: switching to 'f7c41d3'.

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 switching back to a branch.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false

HEAD is now at f7c41d3 First Commit
$ cat hello.rb
puts "Hello, World"

checkout 命令的输出已经很清楚地解释了当前状态。较老版本的 Git 会警告你没有处于本地分支上。但不必担心,先不考虑这个问题。

注意,hello.rb 文件的内容是最初的内容。

返回到 main 分支的最新版本

执行:

git checkout main
cat hello.rb

你应该会看到……

输出:

$ git checkout main
Previous HEAD position was f7c41d3 First Commit
Switched to branch 'main'
$ cat hello.rb
# Default is "World"
name = ARGV.first || "World"

puts "Hello, #{name}!"

main 是默认分支的名称。通过按分支名称来执行 checkout,你将切换到该分支的最新版本。

13 标签版本

目标

  • 学会如何为提交添加标签,以便将来引用。

我们将当前版本的 hello 程序称为版本 1(v1)。

标记版本 1

执行:

git tag v1

现在,您可以将当前版本的程序称为 v1。

标记先前版本

接下来,我们将标记当前版本 v1 之前的版本为 v1-beta。首先,我们需要切换到上一个版本。我们可以使用 ^ 符号来表示 “v1 的父版本”。

如果 v1^ 符号让你遇到任何问题,你也可以尝试使用 v1~1,这将引用相同的版本。这个符号表示 “v1 的第一个祖先”。

执行:

git checkout v1^
cat hello.rb

输出:

$ git checkout v1^
Note: switching to 'v1^'.

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 switching back to a branch.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false

HEAD is now at a6b268e Added a default value
$ cat hello.rb
name = ARGV.first || "World"

puts "Hello, #{name}!"

这是在我们添加注释之前的默认值版本。我们将其标记为 v1-beta。

执行:

git tag v1-beta

按标签名称切换

现在,尝试在这两个标签版本之间来回切换。

执行:

git checkout v1
git checkout v1-beta

输出:

$ git checkout v1
Previous HEAD position was a6b268e Added a default value
HEAD is now at e4e3645 Added a comment
$ git checkout v1-beta
Previous HEAD position was e4e3645 Added a comment
HEAD is now at a6b268e Added a default value

使用 git tag 命令查看标签

您可以使用 git tag 命令查看当前有哪些标签可用。

执行:

git tag

输出:

$ git tag
v1
v1-beta

在日志中查看标签

您还可以在日志中查看标签。

执行:

git hist main

输出:

$ git hist main
* e4e3645 2023-06-10 | Added a comment (tag: v1, main) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (HEAD, tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

你可以在日志输出中看到两个标签(v1v1-beta),以及分支名称(main)。此外,HEAD 显示的是当前检出的提交(此时是 v1-beta)。

14. 撤销本地更改(在 add 之前)

目标

  • 学习如何恢复工作目录中的更改

切换到 main 分支

在继续操作之前,确保你已经切换到 main 分支,并且是最新的提交。

执行:

git checkout main

修改 hello.rb 文件

有时你可能会在本地工作目录中修改文件,但你希望将它恢复到已经提交的版本。checkout 命令可以帮助你实现这一点。

修改 hello.rb 文件,加入一个不好的注释。

hello.rb 文件内容

# This is a bad comment.  We want to revert it.
name = ARGV.first || "World"

puts "Hello, #{name}!"

查看状态

首先,查看工作目录的状态。

执行:

git status

输出:

$ git status
On branch 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:   hello.rb

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

我们看到 hello.rb 文件已经被修改,但尚未被暂存。

恢复工作目录中的更改

使用 checkout 命令恢复 hello.rb 文件到仓库中的版本。

执行:

git checkout hello.rb
git status
cat hello.rb

输出:

$ git checkout hello.rb
Updated 1 path from the index
$ git status
On branch main
nothing to commit, working tree clean
$ cat hello.rb
# Default is "World"
name = ARGV.first || "World"

puts "Hello, #{name}!"

git status 命令显示工作目录没有未提交的更改。而且,“不好的注释”已经不在文件内容中了。

15. 撤销已暂存的更改(在 commit 之前)

目标

  • 学习如何撤销已经暂存的更改

修改文件并暂存更改

修改 hello.rb 文件,添加一个不需要的注释。

hello.rb 文件内容:

# This is an unwanted but staged comment
name = ARGV.first || "World"

puts "Hello, #{name}!"

然后将更改暂存。

执行:

git add hello.rb

检查状态

检查你不想要的更改的状态。

执行:

git status

输出:

$ git status
On branch  main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   hello.rb

状态输出显示更改已经被暂存,准备提交。

重置暂存区

reset 命令将暂存区重置为 HEAD 中的内容。这会清除我们刚刚暂存的更改。

执行:

git reset HEAD hello.rb

输出:

$ git reset HEAD hello.rb
Unstaged changes after reset:
M	hello.rb

reset 命令(默认情况下)不会更改工作目录。因此,工作目录中仍然有不需要的注释。我们可以使用前面实验中介绍的 checkout 命令来删除工作目录中的不需要更改。

注意:你也可以使用 git restore 命令来恢复单个文件。

恢复已提交版本

执行:

git checkout hello.rb
git status

输出:

$ git status
On branch  main
nothing to commit, working tree clean

现在,我们的工作目录已经恢复干净了。

16. 撤销已提交的更改

目标

  • 学习如何撤销已提交到本地仓库的更改。

撤销提交

有时候你会意识到,已经提交的更改并不正确,需要撤销那个提交。处理这个问题有几种方法,而我们在这个实验中使用的方法是始终安全的。

基本上,我们通过创建一个新的提交来撤销不想要的更改,从而达到撤销的目的。

更改文件并提交

首先更改 hello.rb 文件内容,修改为如下:

hello.rb

# This is an unwanted but committed change
name = ARGV.first || "World"

puts "Hello, #{name}!"

执行:

git add hello.rb
git commit -m "Oops, we didn't want this commit"

创建撤销提交

要撤销一个已提交的更改,我们需要生成一个提交,来移除不想要的更改。

执行:

git revert HEAD

这时会弹出编辑器。你可以编辑默认的提交信息,或者保持原样。保存并关闭文件。你应该会看到以下输出:

输出:

$ git revert HEAD --no-edit
[ main 8b71812] Revert "Oops, we didn't want this commit"
 Date: Sat Jun 10 03:49:14 2023 -0400
 1 file changed, 1 insertion(+), 1 deletion(-)

因为我们是撤销最近一次提交,所以可以使用 HEAD 作为参数来撤销该提交。我们也可以通过指定提交的哈希值,来撤销历史中任何特定的提交。

注意: 输出中的 --no-edit 可以忽略。它是为了在没有打开编辑器的情况下生成输出。

查看日志

查看日志会显示我们仓库中的两个提交,一个是不想要的提交,另一个是撤销该提交的提交。

执行:

git hist

输出:

$ git hist
* 8b71812 2023-06-10 | Revert "Oops, we didn't want this commit" (HEAD ->  main) [Jim Weirich]
* 146fb71 2023-06-10 | Oops, we didn't want this commit [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

这种技巧适用于任何提交(尽管可能需要解决冲突)。它即使在公开共享的远程仓库的分支上也可以安全使用。

接下来

接下来,我们将学习一种技术,用于从仓库历史中移除最近的提交。

17. 从分支中移除提交

目标

  • 学习如何移除分支中的最新提交

前一节中介绍的 revert 命令是一个非常强大的工具,允许我们撤销仓库中任何提交的效果。然而,撤销的提交和原始提交都会出现在分支历史中(使用 git log 命令查看)。

很多时候,我们做了一个提交后立刻意识到它是一个错误。我们希望有一个 “撤销” 命令,可以让我们假装这个错误的提交从未发生过。这个 “撤销” 命令甚至会阻止这个坏的提交出现在 git log 历史中,仿佛它从未存在过。

reset 命令

我们已经见过 reset 命令,并且用它来将暂存区与某个特定提交保持一致(我们在之前的实验中使用的是 HEAD 提交)。

reset 命令给定一个提交引用(例如,哈希值、分支名或标签名)时,它将会……

  1. 重写当前分支,使其指向指定的提交
  2. 可选地重置暂存区,使其与指定的提交一致
  3. 可选地重置工作目录,使其与指定的提交一致

查看提交历史

让我们先快速检查一下我们的提交历史。

执行:

git hist

输出:

$ git hist
* 8b71812 2023-06-10 | Revert "Oops, we didn't want this commit" (HEAD ->  main) [Jim Weirich]
* 146fb71 2023-06-10 | Oops, we didn't want this commit [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

我们看到,分支的最后两个提交是“ Oops” 提交和 “Revert Oops” 提交。接下来,让我们使用 reset 命令将它们移除。

给当前分支打标签

在移除提交之前,首先我们为最新的提交打个标签,以便之后能够找到它。

执行:

git tag oops

重置到 Oops 之前的提交

从上面的历史记录来看,标记为 v1 的提交正是坏提交之前的那个提交。我们接下来将分支重置到这个点。由于该提交已经打了标签,我们可以在 reset 命令中直接使用标签名(如果没有标签,我们也可以使用哈希值)。

执行:

git reset --hard v1
git hist

输出:

$ git reset --hard v1
HEAD is now at e4e3645 Added a comment
$ git hist
* e4e3645 2023-06-10 | Added a comment (HEAD ->  main, tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

现在,main 分支已经指向了 v1 提交,Oops 提交和 Revert Oops 提交已经从分支中移除。--hard 参数表示工作目录也会被更新为与新分支头一致。

其实什么也没丢失

但坏提交到底去哪儿了呢?事实上,这些提交仍然存在于仓库中。我们甚至可以再次引用它们。记得在实验开始时,我们为 “Revert Oops” 提交打上了标签 oops。让我们来看一下所有的提交记录。

执行:

git hist --all

输出:

$ git hist --all
* 8b71812 2023-06-10 | Revert "Oops, we didn't want this commit" (tag: oops) [Jim Weirich]
* 146fb71 2023-06-10 | Oops, we didn't want this commit [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (HEAD ->  main, tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

在这里,我们可以看到坏的提交并没有消失。它们依然存在于仓库中,只是它们不再出现在main 分支中。如果我们没有打标签,它们依然会存在于仓库中,但无法通过分支名引用它们,只能使用哈希值来访问。没有被引用的提交会在垃圾回收机制运行时被清理掉。

reset 的风险

在本地分支上使用 reset 一般是安全的。任何 “意外” 都可以通过再次重置到期望的提交来恢复。

然而,如果该分支已经共享到远程仓库,使用 reset 可能会导致其他共享该分支的用户困惑。

18. 移除 oops 标签

目标

  • 移除 oops 标签(清理工作)

移除 oops 标签

oops 标签已经完成了它的任务。现在让我们将其移除,并允许它所引用的提交被垃圾回收机制清理掉。

执行:

git tag -d oops
git hist --all

输出:

$ git tag -d oops
Deleted tag 'oops' (was 8b71812)
$ git hist --all
* e4e3645 2023-06-10 | Added a comment (HEAD ->  main, tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

oops 标签已经从仓库中删除。

19. 修改提交

目标

  • 学习如何修改现有的提交

修改程序并提交

在程序中添加作者注释。

hello.rb

# Default is World
# Author: Jim Weirich
name = ARGV.first || "World"

puts "Hello, #{name}!"

执行:

git add hello.rb
git commit -m "Add an author comment"

哎呀,应该加个电子邮件

提交后,你意识到任何好的作者注释都应该包括电子邮件。更新 hello 程序,添加电子邮件。

hello.rb

# Default is World
# Author: Jim Weirich (jim@somewhere.com)
name = ARGV.first || "World"

puts "Hello, #{name}!"

修改之前的提交

我们真的不想为了电子邮件单独创建一个提交。让我们修改之前的提交,将电子邮件更改包括进去。

执行:

git add hello.rb
git commit --amend -m "Add an author/email comment"

输出:

$ git add hello.rb
$ git commit --amend -m "Add an author/email comment"
[ main 186488e] Add an author/email comment
 Date: Sat Jun 10 03:49:14 2023 -0400
 1 file changed, 2 insertions(+), 1 deletion(-)

查看历史

执行:

git hist

输出:

$ git hist
* 186488e 2023-06-10 | Add an author/email comment (HEAD ->  main) [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

我们可以看到原来的 “author” 提交已经消失,取而代之的是 “author/email” 提交。你也可以通过将分支重置到前一个提交,然后重新提交新更改来实现相同的效果。

20. 移动文件

目标

  • 学习如何在仓库内移动文件。

hello.rb 文件移动到 lib 目录中

我们现在将构建一个小的仓库结构。我们把程序移动到 lib 目录下。

执行命令:

mkdir lib
git mv hello.rb lib
git status

输出结果:

$ mkdir lib
$ git mv hello.rb lib
$ git status
On branch  main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	renamed:    hello.rb -> lib/hello.rb

通过使用 git 来移动文件,我们通知了 git 两件事:

  1. 文件 hello.rb 被删除了。
  2. 文件 lib/hello.rb 被创建了。

这两项信息会立即被暂存,并准备好进行提交。git status 命令会报告文件已经被移动。

移动文件的另一种方式

git 的一个优点是,直到你准备好开始提交代码之前,你可以不必考虑源代码管理。如果我们使用操作系统命令而不是 git 命令来移动文件,会发生什么呢?

实际上,下面这组命令与我们刚才所做的完全相同。虽然稍微复杂一些,但结果是一样的。

我们本可以执行:

mkdir lib
mv hello.rb lib
git add lib/hello.rb
git rm hello.rb

提交新目录

现在我们来提交这个文件移动。

执行命令:

git commit -m "Moved hello.rb to lib"

21. 更多结构

目标

  • 向我们的仓库中添加另一个文件

现在添加一个 Rakefile

本实验假设你已经安装了 rake。请在继续之前进行安装。根据你的操作系统进行安装。如果未安装,请执行:

执行:

gem install rake

让我们向仓库中添加一个 Rakefile。下面的内容足够使用。

创建 Rakefile 文件。

#!/usr/bin/ruby -wKU

task :default => :run

task :run do
  require './lib/hello'
end

添加并提交更改。

执行:

git add Rakefile
git commit -m "Added a Rakefile."

现在你应该能够使用 Rake 来运行你的 hello 程序了。

执行:

rake

输出:

$ rake
Hello, World!

22. Git 内部结构:.git 目录

目标

  • 了解 .git 目录的结构

.git 目录

现在开始探索。首先,在你的项目根目录下…

执行:

ls .git

输出:

$ ls .git
COMMIT_EDITMSG	config		index		objects
HEAD		description	info		packed-refs
ORIG_HEAD	hooks		logs		refs

这是存储所有 Git "内容" 的神奇目录。接下来我们来看看 objects 目录。

对象存储

执行:

ls .git/objects

输出:

$ ls .git/objects
09	17	24	43	6b	97	af	c4	e7	pack
11	18	27	59	78	9c	b0	cd	f7
14	22	28	69	8b	a6	b5	e4	info

你会看到一些目录,这些目录的名字是 Git 存储对象的 SHA-1 哈希值的前两位字母。

更深入地查看对象存储

执行:

ls .git/objects/<dir>

输出:

$ ls .git/objects/09
6b74c56bfc6b40e754fc0725b8c70b2038b91e	9fb6f9d3a104feb32fcac22354c4d0e8a182c1

查看其中一个以两位字母命名的目录。你会看到一些 38 个字符长的文件名。这些文件存储了 Git 中的对象。文件内容是经过压缩和编码的,直接查看这些文件的内容不会有太大意义,但我们稍后会详细探讨。

配置文件

执行:

cat .git/config

输出:

$ cat .git/config
[core]
	repositoryformatversion = 0
	filemode = true
	bare = false
	logallrefupdates = true
	ignorecase = true
	precomposeunicode = true
[user]
	name = Jim Weirich
	email = jim (at) edgecase.com

这是一个项目特定的配置文件。这里的配置项会覆盖你家目录中 .gitconfig 文件中的配置项,至少对于当前这个项目来说是这样。

分支和标签

执行:

ls .git/refs
ls .git/refs/heads
ls .git/refs/tags
cat .git/refs/tags/v1

输出:

$ ls .git/refs
heads
tags
$ ls .git/refs/heads
 main
$ ls .git/refs/tags
v1
v1-beta
$ cat .git/refs/tags/v1
e4e3645637546103e72f0deb9abdd22dd256601e

你应该能识别出 tags 子目录中的文件。每个文件对应着你之前通过 git tag 命令创建的标签。文件内容就是与标签关联的提交的哈希值。

heads 目录也类似,但是它用于存储分支,而不是标签。此时我们只有一个分支,所以你只会看到 main 在该目录下。

HEAD 文件

执行:

cat .git/HEAD

输出:

$ cat .git/HEAD
ref: refs/heads/ main

HEAD 文件包含当前分支的引用。在此时,它应该引用 main 分支。

23. Git 内部原理:直接操作 Git 对象

目标

  • 探索对象存储的结构
  • 学习如何使用 SHA1 哈希值查找仓库中的内容

接下来,让我们使用一些工具直接探测 Git 对象。

查找最新的提交

执行:

git hist --max-count=1

这将显示仓库中最新的提交。你系统上的 SHA1 哈希值可能与我的不同,但你应该看到类似下面的输出:

输出:

$ git hist --max-count=1
* cdceefa 2023-06-10 | Added a Rakefile. (HEAD -> main) [Jim Weirich]

显示最新的提交

使用上面列出的提交的 SHA1 哈希值...

执行:

git cat-file -t <hash>
git cat-file -p <hash>

这里是我的输出:

输出:

$ git cat-file -t cdceefa
commit
$ git cat-file -p cdceefa
tree 096b74c56bfc6b40e754fc0725b8c70b2038b91e
parent 22273f2a02983d905df7b4154b00447934034338
author Jim Weirich <jim (at) edgecase.com> 1686383357 -0400
committer Jim Weirich <jim (at) edgecase.com> 1686383357 -0400

Added a Rakefile.

注意: 如果你在别名实验中定义了 typedump 别名,那么你可以输入 git typegit dump,而不是记不住的 cat-file 命令。

这是位于main 分支头部的提交对象的转储。它看起来很像我们之前演示的提交对象。

查找树对象

我们可以转储提交中引用的目录树。这应该是我们项目中(该提交时)顶级文件的描述。使用 “tree” 行中列出的 SHA1 哈希值。

执行:

git cat-file -p <treehash>

这是我的树对象输出:

输出:

$ git cat-file -p 096b74c
100644 blob 28e0e9d6ea7e25f35ec64a43f569b550e8386f90	Rakefile
040000 tree e46f374f5b36c6f02fb3e9e922b79044f754d795	lib

是的,我看到了 Rakefilelib 目录。

显示 lib 目录

执行:

git cat-file -p <libhash>

输出:

$ git cat-file -p e46f374
100644 blob c45f26b6fdc7db6ba779fc4c385d9d24fc12cf72	hello.rb

这里是 hello.rb 文件。

显示 hello.rb 文件

执行:

git cat-file -p <rbhash>

输出:

$ git cat-file -p c45f26b
# Default is World
# Author: Jim Weirich (jim@somewhere.com)
name = ARGV.first || "World"

puts "Hello, #{name}!"

这样,我们直接从 Git 仓库中转储了提交对象、树对象和 Blob 对象。Git 中就只有这些内容:Blob、Tree 和 Commit。

自主探索

你可以手动在 Git 仓库中进行探索,看看你能否通过手动跟踪 SHA1 哈希值,从最新的提交开始,找到最初的 hello.rb 文件。

24. 创建分支

目标

  • 学习如何在仓库中创建本地分支

现在是对 “Hello World” 功能进行重大重写的时候。由于这可能会花费一些时间,你应该把这些更改放到一个单独的分支中,以将其与 main 分支的其他更改隔离开来。

创建分支

我们将新分支命名为 greet

执行:

git checkout -b greet
git status

注意: git checkout -b <branchname>git branch <branchname>git checkout <branchname> 两个命令的快捷方式。

注意,git status 命令会显示你已经切换到 greet 分支。

greet 分支添加 Greeter 类

lib/greeter.rb

class Greeter
  def initialize(who)
    @who = who
  end
  def greet
    "Hello, #{@who}"
  end
end

执行:

git add lib/greeter.rb
git commit -m "Added greeter class"

修改 greet 分支: 更新主程序

更新 hello.rb 文件以使用 Greeter

lib/hello.rb

require 'greeter'

# Default is World
name = ARGV.first || "World"

greeter = Greeter.new(name)
puts greeter.greet

执行:

git add lib/hello.rb
git commit -m "Hello uses Greeter"

修改 greet 分支: 更新 Rakefile

更新 Rakefile,使其使用外部的 Ruby 进程

Rakefile

#!/usr/bin/ruby -wKU

task :default => :run

task :run do
  ruby '-Ilib', 'lib/hello.rb'
end

执行:

git add Rakefile
git commit -m "Updated Rakefile"

下一步

现在,我们有了一个名为 greet 的新分支,并且在该分支上有了 3 个新的提交。接下来,我们将学习如何在分支之间切换和导航。

25. 切换分支

目标

  • 学习如何在仓库的不同分支之间进行切换。

你现在有两个分支:

执行:

git hist --all

输出:

$ git hist --all
* c1a7120 2023-06-10 | Updated Rakefile (HEAD -> greet) [Jim Weirich]
* 959a7cb 2023-06-10 | Hello uses Greeter [Jim Weirich]
* cab1837 2023-06-10 | Added greeter class [Jim Weirich]
* cdceefa 2023-06-10 | Added a Rakefile. (main) [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

切换到 main 分支

切换分支可以使用 git checkout 命令。

执行:

git checkout main
cat lib/hello.rb

输出:

$ git checkout main
Switched to branch 'main'
$ cat lib/hello.rb
# Default is World
# Author: Jim Weirich (jim@somewhere.com)
name = ARGV.first || "World"

puts "Hello, #{name}!"

你现在已经切换到了 main 分支。可以通过查看 hello.rb 文件的内容来确认,因为它没有使用 Greeter 类。

切换回 greet 分支

执行:

git checkout greet
cat lib/hello.rb

输出:

$ git checkout greet
Switched to branch 'greet'
$ cat lib/hello.rb
require 'greeter'

# Default is World
name = ARGV.first || "World"

greeter = Greeter.new(name)
puts greeter.greet

文件内容确认了你已经切换回了 greet 分支,lib/hello.rb 文件现在包含了 Greeter 类的引用。

26. main 分支的变化

目标

  • 学习如何处理多个分支之间的不同(可能冲突的)更改。

在你修改 greet 分支时,其他人更新了 main 分支。他们添加了一个 README 文件。

切换到 main 分支。

执行:

git checkout main

创建 README 文件。

This is the Hello World example from the git tutorial.

README 提交到 main 分支。

执行:

git add README
git commit -m "Added README"

27. 查看分支的分歧

目标

  • 学习如何查看仓库中的分歧分支。

查看当前分支

现在仓库中有两个分歧的分支。使用以下日志命令查看分支及其分歧情况。

执行:

git hist --all

输出:

$ git hist --all
* 976950b 2023-06-10 | Added README (HEAD -> main) [Jim Weirich]
| * c1a7120 2023-06-10 | Updated Rakefile (greet) [Jim Weirich]
| * 959a7cb 2023-06-10 | Hello uses Greeter [Jim Weirich]
| * cab1837 2023-06-10 | Added greeter class [Jim Weirich]
|/  
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

这是我们第一次看到 git hist--graph 选项的效果。将 --graph 选项添加到 git log 命令中,导致它使用简单的 ASCII 字符绘制提交树。我们可以看到两个分支(greet 和 main),其中 main 分支是当前的 HEAD。两个分支的共同祖先是 “Added a Rakefile” 这一提交。

--all 标志确保我们能够看到所有的分支,默认情况下只显示当前分支。

28. 合并

目标

  • 学习如何合并两个分支,以便将更改合并回一个单一的分支。

合并分支

合并将两个分支的更改合并在一起。让我们回到 greet 分支并将 main 分支合并到 greet 中。

执行:

git checkout greet
git merge main
git hist --all

输出:

$ git checkout greet
Switched to branch 'greet'
$ git merge main
Merge made by the 'recursive' strategy.
 README | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 README
$ git hist --all
*   82a2988 2023-06-10 | Merge branch 'main' into greet (HEAD -> greet) [Jim Weirich]
|\  
| * 976950b 2023-06-10 | Added README (main) [Jim Weirich]
* | c1a7120 2023-06-10 | Updated Rakefile [Jim Weirich]
* | 959a7cb 2023-06-10 | Hello uses Greeter [Jim Weirich]
* | cab1837 2023-06-10 | Added greeter class [Jim Weirich]
|/  
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

通过定期将 main 合并到 greet 分支中,您可以获取 main 中的任何更改,并保持 greet 中的更改与主干中的更改兼容。

然而,这样会产生难看的提交图表。稍后我们将讨论使用 rebase 替代合并的选项。

接下来

但是,如果 main 中的更改与 greet 中的更改发生冲突,该怎么办呢?

29. 创建冲突

目标

  • main 分支中创建一个冲突。

切换回 main 分支并创建冲突

切换回 main 分支并进行如下更改:

执行:

git checkout main

lib/hello.rb 文件内容

puts "What's your name"
my_name = gets.strip

puts "Hello, #{my_name}!"

执行:

git add lib/hello.rb
git commit -m "Made interactive"

查看分支

执行:

git hist --all

输出:

$ git hist --all
*   82a2988 2023-06-10 | Merge branch 'main' into greet (greet) [Jim Weirich]
|\  
* | c1a7120 2023-06-10 | Updated Rakefile [Jim Weirich]
* | 959a7cb 2023-06-10 | Hello uses Greeter [Jim Weirich]
* | cab1837 2023-06-10 | Added greeter class [Jim Weirich]
| | * 3787562 2023-06-10 | Made interactive (HEAD -> main) [Jim Weirich]
| |/  
| * 976950b 2023-06-10 | Added README [Jim Weirich]
|/  
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

main 分支在 “Added README” 这次提交后被合并到 greet 分支,但现在 main 分支上有一个新提交尚未合并回 greet。

接下来的步骤

main 分支上的最新更改与 greet 分支中的一些现有更改发生了冲突。接下来我们将解决这些冲突。

30. 解决冲突

目标

  • 学习如何在合并过程中处理冲突

main 分支合并到 greet 分支

现在切换回 greet 分支并尝试合并最新的 main 分支。

执行:

git checkout greet
git merge main

输出:

$ git checkout greet
Switched to branch 'greet'
$ git merge main
Auto-merging lib/hello.rb
CONFLICT (content): Merge conflict in lib/hello.rb
Automatic merge failed; fix conflicts and then commit the result.

如果你打开 lib/hello.rb 文件,你会看到如下内容:

lib/hello.rb

<<<<<<< HEAD
require 'greeter'

# Default is World
name = ARGV.first || "World"

greeter = Greeter.new(name)
puts greeter.greet
=======
# Default is World

puts "What's your name"
my_name = gets.strip

puts "Hello, #{my_name}!"
>>>>>>> main

第一部分是当前分支(greet)头部的版本。第二部分是 main 分支的版本。

解决冲突

你需要手动解决冲突。修改 lib/hello.rb,使其变为如下内容:

lib/hello.rb

require 'greeter'

puts "What's your name"
my_name = gets.strip

greeter = Greeter.new(my_name)
puts greeter.greet

提交冲突解决结果

执行:

git add lib/hello.rb
git commit -m "Merged main fixed conflict."

输出:

$ git add lib/hello.rb
$ git commit -m "Merged main fixed conflict."
[greet 73db54b] Merged main fixed conflict.

高级合并

Git 本身并没有提供图形化的合并工具,但它可以与任何你喜欢的第三方合并工具配合使用。请参阅 git-scm.com/book/en/v2/… 了解如何在 Git 中使用 Perforce 合并工具。

31. 变基与合并

目标

  • 学习变基和合并之间的区别。

讨论

让我们探讨一下变基和合并之间的区别。为了做到这一点,我们需要将仓库回溯到第一次合并之前,然后重新执行相同的步骤,但这次使用变基而不是合并。

我们将使用 reset 命令将分支回滚到之前的状态。

32. 重置 greet 分支

目标

  • greet 分支重置到第一次合并之前的状态。

重置 greet 分支

让我们将 greet 分支回滚到合并 main 分支之前的状态。我们可以重置分支到任何一个我们想要的提交。实际上,这相当于修改分支的指针,使它指向提交树中的某个位置。

在这种情况下,我们需要找到合并 main 分支之前的最后一次提交。我们要重置 greet 分支到这个提交。

执行:

git checkout greet
git hist

输出:

$ git checkout greet
Already on 'greet'
$ git hist
*   73db54b 2023-06-10 | Merged main fixed conflict. (HEAD -> greet) [Jim Weirich]
|\  
| * 3787562 2023-06-10 | Made interactive (main) [Jim Weirich]
* | 82a2988 2023-06-10 | Merge branch 'main' into greet [Jim Weirich]
|| 
| * 976950b 2023-06-10 | Added README [Jim Weirich]
* | c1a7120 2023-06-10 | Updated Rakefile [Jim Weirich]
* | 959a7cb 2023-06-10 | Hello uses Greeter [Jim Weirich]
* | cab1837 2023-06-10 | Added greeter class [Jim Weirich]
|/  
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

这里的日志比较难以阅读,但从数据中可以看到,“Updated Rakefile” 提交是 greet 分支在合并之前的最后一次提交。接下来我们将 greet 分支重置到这个提交。

执行:

git reset --hard <hash>

输出:

$ git reset --hard c1a7120
HEAD is now at c1a7120 Updated Rakefile

检查分支状态

查看 greet 分支的日志,我们会发现分支历史中不再包含合并的提交。

执行:

git hist --all

输出:

$ git hist --all
* 3787562 2023-06-10 | Made interactive (main) [Jim Weirich]
* 976950b 2023-06-10 | Added README [Jim Weirich]
| * c1a7120 2023-06-10 | Updated Rakefile (HEAD -> greet) [Jim Weirich]
| * 959a7cb 2023-06-10 | Hello uses Greeter [Jim Weirich]
| * cab1837 2023-06-10 | Added greeter class [Jim Weirich]
|/  
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

33. 重置 main 分支

目标

  • main 分支重置到发生冲突的提交之前。

重置 main 分支

当我们向 main 分支添加交互模式时,发生了与 greet 分支的更改冲突。现在,我们将 main 分支重置到发生冲突之前的某个点,这样我们就可以在没有冲突的情况下演示 rebase 命令。

执行:

git checkout main
git hist

输出:

$ git hist
* 3787562 2023-06-10 | Made interactive (HEAD -> main) [Jim Weirich]
* 976950b 2023-06-10 | Added README [Jim Weirich]
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

“Added README” 这个提交是在冲突的交互模式之前的直接提交。我们将把 main 分支重置到这个 “Added README” 提交。

执行:

git reset --hard <hash>
git hist --all

查看日志。此时,应该会看到仓库已经回到合并之前的状态。

输出:

$ git hist --all
* 976950b 2023-06-10 | Added README (HEAD -> main) [Jim Weirich]
| * c1a7120 2023-06-10 | Updated Rakefile (greet) [Jim Weirich]
| * 959a7cb 2023-06-10 | Hello uses Greeter [Jim Weirich]
| * cab1837 2023-06-10 | Added greeter class [Jim Weirich]
|/  
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

34. 变基

目标

  • 使用 rebase 命令,而不是 merge 命令。

现在我们回到第一个合并之前的状态,并且希望将 main 分支的更改合并到 greet 分支中。

这次我们将使用 rebase 命令来将 main 分支的更改应用到 greet 分支上。

执行:

git checkout greet
git rebase main
git hist

输出:

$ git checkout greet
Switched to branch 'greet'
$
$ git rebase main
First, rewinding head to replay your work on top of it...
Applying: added Greeter class
Applying: hello uses Greeter
Applying: updated Rakefile
$
$ git hist
* 5f626c6 2023-06-10 | Updated Rakefile (HEAD -> greet) [Jim Weirich]
* 24d82d4 2023-06-10 | Hello uses Greeter [Jim Weirich]
* 619f552 2023-06-10 | Added greeter class [Jim Weirich]
* 976950b 2023-06-10 | Added README (main) [Jim Weirich]
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

合并与变基

变基的最终结果与合并非常相似。greet 分支现在包含了它的所有更改,以及 main 分支的所有更改。然而,提交历史却有所不同。greet 分支的提交历史被重写,以使 main 分支成为提交历史的一部分。这使得提交链变得线性,更易于阅读。

什么时候使用变基,什么时候使用合并?

不要使用变基...

  1. 如果分支是公开的,并且与其他人共享。重写公开共享的分支历史会导致其他团队成员的工作出现问题。
  2. 当分支的提交历史非常重要时(因为变基会重写提交历史)。

根据上述指南,我倾向于在短期的本地分支上使用变基,而在公开仓库中的分支上使用合并。

35. 合并回 main 分支

目标

  • 我们已经通过变基保持了 greet 分支与 main 分支同步,现在将 greet 分支的更改合并回 main 分支。

greet 分支合并到 main 分支

执行:

git checkout main
git merge greet

输出:

$ git checkout main
Switched to branch 'main'
$
$ git merge greet
Updating 976950b..5f626c6
Fast-forward
 Rakefile       | 2 +-
 lib/greeter.rb | 8 ++++++++
 lib/hello.rb   | 6 ++++--
 3 files changed, 13 insertions(+), 3 deletions(-)
 create mode 100644 lib/greeter.rb

因为 main 分支的头指针是 greet 分支头指针的直接祖先,所以 Git 能够执行快进合并。在快进合并时,分支指针只是简单地向前移动,指向与 greet 分支相同的提交。

快进合并时,不会发生冲突。

查看日志

执行:

git hist

输出:

$ git hist
* 5f626c6 2023-06-10 | Updated Rakefile (HEAD -> main, greet) [Jim Weirich]
* 24d82d4 2023-06-10 | Hello uses Greeter [Jim Weirich]
* 619f552 2023-06-10 | Added greeter class [Jim Weirich]
* 976950b 2023-06-10 | Added README [Jim Weirich]
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

现在,greetmain 分支已经完全相同。

36. 多个仓库

到目前为止,我们一直在使用单个 Git 仓库。然而,Git 在处理多个仓库时非常强大。这些额外的仓库可以存储在本地,也可以通过网络连接进行访问。

在接下来的部分,我们将创建一个名为 “cloned_hello” 的新仓库。我们将展示如何将更改从一个仓库移动到另一个仓库,以及如何处理在两个仓库之间发生冲突的情况。

img

目前,我们将处理本地仓库(即存储在你本地硬盘上的仓库)。不过,在这一部分学习到的大多数内容都适用于本地仓库和远程仓库,无论这些仓库是存储在本地还是通过网络进行访问的。

注意: 我们将对两个仓库中的副本进行更改。在接下来的操作中,请务必注意自己处于哪个仓库。

37. 克隆仓库

目标

  • 学习如何克隆仓库。

进入工作目录

进入工作目录并克隆你的 hello 仓库。

执行:

cd ..
pwd
ls

注意:现在在工作目录中。

输出:

$ cd ..
$ pwd
/Users/jim/Downloads/git_tutorial/work
$ ls
hello

此时你应该已经进入了 “work” 目录,目录中应该只有一个名为 “hello” 的仓库。

克隆 hello 仓库

现在让我们克隆这个仓库。

执行:

git clone hello cloned_hello
ls

输出:

$ git clone hello cloned_hello
Cloning into 'cloned_hello'...
done.
$ ls
cloned_hello
hello

现在,工作目录中应该有两个仓库:原始的 “hello” 仓库和新克隆的 “cloned_hello” 仓库。

38. 审查克隆的仓库

目标

  • 学习远程仓库中的分支。

查看克隆的仓库

让我们来看一下克隆的仓库。

执行:

cd cloned_hello
ls

输出:

$ cd cloned_hello
$ ls
README
Rakefile
lib

你应该能看到原始仓库的顶层文件列表(READMERakefilelib)。

审查仓库历史

执行:

git hist --all

输出:

$ git hist --all
* 5f626c6 2023-06-10 | Updated Rakefile (HEAD -> main, origin/main, origin/greet, origin/HEAD) [Jim Weirich]
* 24d82d4 2023-06-10 | Hello uses Greeter [Jim Weirich]
* 619f552 2023-06-10 | Added greeter class [Jim Weirich]
* 976950b 2023-06-10 | Added README [Jim Weirich]
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

你现在应该看到新仓库中的所有提交历史,并且它应该(大致上)与原始仓库的提交历史相匹配。唯一的区别应该是分支的名称。

远程分支

你应该能在历史列表中看到一个 main 分支(以及 HEAD)。但是,你也会看到一些名字比较奇怪的分支(origin/mainorigin/greetorigin/HEAD)。稍后我们会详细讨论它们。

39. 什么是 Origin?

目标

  • 了解如何命名远程仓库。

执行:

git remote

输出:

$ git remote
origin

我们可以看到,克隆的仓库知道有一个名为 origin 的远程仓库。接下来,让我们看看是否能获取更多关于 origin 的信息:

执行:

git remote show origin

输出:

$ git remote show origin
warning: more than one branch.main.remote
* remote origin
  Fetch URL: /Users/jim/Downloads/git_tutorial/work/hello
  Push  URL: /Users/jim/Downloads/git_tutorial/work/hello
  HEAD branch: main
  Remote branches:
    greet tracked
    main  tracked
  Local branches configured for 'git pull':
    main   merges with remote main
              and with remote main
    master merges with remote master
  Local ref configured for 'git push':
    main pushes to main (up to date)

现在我们可以看到,远程仓库 origin 实际上指向的是原始的 hello 仓库。远程仓库通常存放在单独的机器上,可能是一个集中式服务器。然而,如同我们看到的,远程仓库也可以指向同一台机器上的仓库。origin 这个名称并没有特别的含义,但在约定上,它通常用来表示主要的集中式仓库(如果有的话)。

40. 远程分支

目标

  • 了解本地与远程分支的区别

让我们查看一下在克隆的仓库中有哪些分支。

执行命令:

git branch

输出:

$ git branch
* main

仅列出了 main 分支。那 greet 分支在哪里?默认情况下,git branch 命令只列出本地分支。

列出远程分支

尝试使用以下命令查看所有分支:

执行命令:

git branch -a

输出:

$ git branch -a
* main
  remotes/origin/HEAD -> origin/main
  remotes/origin/greet
  remotes/origin/main

Git 已经从原始仓库拉取了所有提交,但远程仓库的分支并不会被当作本地分支来处理。如果我们想要自己的 greet 分支,我们需要自己创建它。接下来我们会看到如何操作。

41. 修改原始仓库

目标

  • 对原始仓库进行一些更改,以便我们可以尝试将更改拉取到本地

在原始 hello 仓库中进行更改

执行命令:

cd ../hello
# (现在应该进入了原始的 hello 仓库)

注意:现在在 hello 仓库中

对 README 文件进行以下更改:

README 文件内容

This is the Hello World example from the git tutorial.
(changed in original)

然后添加并提交这个更改

执行命令:

git add README
git commit -m "Changed README in original repo"

接下来

现在,原始仓库已经有了更新的更改,而这些更改还没有同步到克隆版本中。接下来,我们将把这些更改拉取到克隆的仓库中。

42 获取更改

目标

  • 学习如何从远程仓库拉取更改。

执行:

cd ../cloned_hello
git fetch
git hist --all

注意:当前位于 cloned_hello 仓库中

输出:

$ git fetch
From /Users/jim/Downloads/git_tutorial/work/hello
   5f626c6..5e2d55e  main       -> origin/main
$ git hist --all
* 5e2d55e 2023-06-10 | Changed README in original repo (origin/main, origin/HEAD) [Jim Weirich]
* 5f626c6 2023-06-10 | Updated Rakefile (HEAD -> main, origin/greet) [Jim Weirich]
* 24d82d4 2023-06-10 | Hello uses Greeter [Jim Weirich]
* 619f552 2023-06-10 | Added greeter class [Jim Weirich]
* 976950b 2023-06-10 | Added README [Jim Weirich]
* cdceefa 2023-06-10 | Added a Rakefile. [Jim Weirich]
* 22273f2 2023-06-10 | Moved hello.rb to lib [Jim Weirich]
* 186488e 2023-06-10 | Add an author/email comment [Jim Weirich]
* e4e3645 2023-06-10 | Added a comment (tag: v1) [Jim Weirich]
* a6b268e 2023-06-10 | Added a default value (tag: v1-beta) [Jim Weirich]
* 174dfab 2023-06-10 | Using ARGV [Jim Weirich]
* f7c41d3 2023-06-10 | First Commit [Jim Weirich]

此时,仓库已包含来自原始仓库的所有提交,但这些提交尚未合并到克隆仓库的本地分支中。

找到历史记录中的 “Changed README in original repo” 提交。注意到该提交中包含了 “origin/main” 和 “origin/HEAD”。

接下来查看 “Updated Rakefile” 提交。你会看到本地的 main 分支指向的是此提交,而不是刚刚获取的最新提交。

这意味着,“git fetch” 命令会从远程仓库获取新提交,但它不会将这些提交合并到本地分支中。

检查 README 文件

我们可以通过查看来证明克隆的 README 文件没有更改。

执行:

cat README

输出:

$ cat README
This is the Hello World example from the git tutorial.

如你所见,文件内容没有变化。

43. 合并拉取的更改

目标

  • 学习如何将拉取的更改合并到当前分支和工作目录中。

将获取的更改合并到本地主分支

执行:

git merge origin/main

输出:

$ git merge origin/main
Updating 5f626c6..5e2d55e
Fast-forward
 README | 1 +
 1 file changed, 1 insertion(+)

再次检查 README 文件

现在我们应该能看到更改。

执行:

cat README

输出:

$ cat README
This is the Hello World example from the git tutorial.
(changed in original)

这些就是更改。尽管 “git fetch” 不会自动合并更改,但我们仍然可以手动将远程仓库的更改合并进来。

接下来

接下来,我们将一起了解如何将 fetch 和 merge 过程合并为一个命令。

44. 拉取更改

目标

  • 了解 git pull 相当于 git fetchgit merge 的组合。

讨论

我们不打算再创建另一个更改并再次拉取它,但我们希望你了解执行:

git pull

实际上等价于以下两个步骤:

git fetch
git merge origin/main1

45. 添加追踪分支

目标

  • 学习如何添加一个本地分支来追踪远程分支。

remotes/origin 开头的分支是来自原始仓库的分支。注意,你现在已经没有名为 greet 的本地分支了,但它仍然知道原始仓库中存在 greet 分支。

添加一个本地分支来追踪远程分支

执行:

git branch --track greet origin/greet
git branch -a
git hist --max-count=2

输出:

$ git branch --track greet origin/greet
Branch 'greet' set up to track remote branch 'greet' from 'origin'.
$ git branch -a
  greet
* main
  remotes/origin/HEAD -> origin/main
  remotes/origin/greet
  remotes/origin/main
$ git hist --max-count=2
* 5e2d55e 2023-06-10 | Changed README in original repo (HEAD -> main, origin/main, origin/HEAD) [Jim Weirich]
* 5f626c6 2023-06-10 | Updated Rakefile (origin/greet, greet) [Jim Weirich]

现在,我们可以在分支列表和日志中看到 greet 分支。

46. Bare 仓库

目标

  • 学习如何创建 bare 仓库。

Bare 仓库(没有工作区)通常用于共享。

创建一个 bare 仓库

执行:

cd ..
git clone --bare hello hello.git
ls hello.git

注意:此时在工作目录中

输出:

$ git clone --bare hello hello.git
Cloning into bare repository 'hello.git'...
done.
$ ls hello.git
HEAD
config
description
hooks
info
objects
packed-refs
refs

约定上,仓库名以 .git 结尾的是 bare 仓库。从上面的输出可以看到,在 hello.git 仓库中没有工作目录。它本质上只是一个非 bare 仓库的 .git 目录。

47. 添加远程仓库

目标

  • 将 Bare 仓库作为远程仓库添加到我们的原始仓库中。

让我们把 hello.git 仓库添加到原始仓库中。

执行:

cd hello
git remote add shared ../hello.git

注意:现在在 hello 仓库中。

48. 推送更改

目标

  • 学习如何将更改推送到远程仓库。

由于 Bare 仓库通常是共享的,并且位于某个网络服务器上,因此通常无法直接进入仓库并拉取更改。因此,我们需要将更改推送到其他仓库。

让我们从创建一个更改开始并将其推送。编辑 README 文件并提交更改。

README 文件内容

This is the Hello World example from the git tutorial.
(Changed in the original and pushed to shared)

执行:

git checkout main
git add README
git commit -m "Added shared comment to readme"

现在将更改推送到共享仓库。

执行:

git push shared main

shared 是接收我们推送更改的仓库的名称。(记住,我们在之前的实验中已经将其作为远程仓库添加了。)

输出:

$ git push shared main
To ../hello.git
   5e2d55e..7b4a53a  main -> main

注意: 我们必须明确指定接收推送的分支是 main。虽然可以将其设置为自动推送,但我从来记不住如何设置相关命令。可以查看 “Git Remote Branch” 插件,帮助更方便地管理远程分支。

49. 拉取共享的更改

目标

  • 学习如何从共享仓库中拉取更改。

现在切换到克隆的仓库,拉取刚刚推送到共享仓库的更改。

执行:

cd ../cloned_hello

注意:现在在 cloned_hello 仓库中。

继续执行:

执行:

git remote add shared ../hello.git
git branch --track shared main
git pull shared main
cat README

50 托管你的 Git 仓库

目标

  • 学习如何设置 Git 服务器来共享仓库。

有许多方法可以通过网络共享 Git 仓库。这里介绍一种简单快捷的方法。

启动 Git 服务器

执行:

# (在工作目录下)
git daemon --verbose --export-all --base-path=.

现在,在一个新的终端窗口中,进入你的工作目录。

执行:

# (在工作目录下)
git clone git://localhost/hello.git network_hello
cd network_hello
ls

你应该能看到 hello 项目的副本。

推送到 Git Daemon

如果你想向 Git Daemon 仓库推送内容,可以在 git daemon 命令中添加 --enable=receive-pack 参数。但要小心,因为这个服务器没有身份验证,任何人都可以向你的仓库推送内容。

51. 分享代码仓库

目标

  • 学习如何通过 WIFI 共享代码仓库。

查看你的邻居是否正在运行 Git 守护进程。交换 IP 地址,看看是否能从彼此的仓库中拉取代码。

gitjour gem 在共享临时仓库时非常有用。

52. 高级 / 未来主题

以下是一些你可能想要自己研究的主题:

  • 协议
  • SSH 设置
  • 远程分支管理
  • 查找有问题的提交(git bisect)
  • 工作流
  • 非命令行工具(gitx, gitk, magit)
  • 使用 GitHub

53. 感谢

感谢你尝试 Git Immersion 实验室。欢迎随时发送反馈至 jim.weirich@gmail.com