环境配置

Git个人使用指南

基于自身的实践,总结了关于git的系列经验,包括安装、配置、常用方法等。

一、什么是Git?我们为什么要使用Git?

Git是一个版本管理系统

Git is a free and open source distributed version control system designed to handle everything from small to very large projects with speed and efficiency.[1]

Git 是一个开源、免费的分布式版本控制系统,旨在快速、高效地处理从小型到大型的各类项目。[1]

Version control (also known as revision control, source control, and source code management) is the software engineering practice of controlling, organizing, and tracking different versions in history of computer files – primarily source code text files, but generally any type of file.[2]

版本控制(也称为修订控制、源代码控制和源代码管理)是一种软件工程实践,用于控制、组织和跟踪计算机文件历史中的不同版本(主要是源代码文本文件,但通常是任何类型的文件)。[2]

distributed version control (also known as distributed revision control) is a form of version control in which the complete codebase, including its full history, is mirrored on every developer's computer.

分布式版本控制(也称为分布式修订控制)是版本控制的一种形式,其中完整的代码库(包括其完整历史记录)镜像在每个开发人员的计算机上。

[1] Git. Git-scm [EB/OL]. (2026-05-13) [2026-05-13]. https://git-scm.com/.

[2] Wikipedia. Version control [EB/OL]. (2026-05-13) [2026-05-13]. https://en.wikipedia.org/wiki/Version_control.

[3] Wikipedia. Distributed version control [EB/OL]. (2026-05-13) [2026-05-13]. https://en.wikipedia.org/wiki/Distributed_version_control.

以上是git的官网及维基百科对git以及git为代表的(分布式)版本管理系统的描述,下文是基于我自身不断的实践整理而来的个人经验记录与理解描述,是更符合我自身工作内容及需求的文本描述,会随着我的阅历增长,会不断补充、完善。

打个比方,你可以把git理解为一个“允许存储多个存档的游戏存档系统”,它完整记录着一些重要时刻(你告知它要记录的)项目内容的快照,比如源代码、配置文件以及存档读档这些行为本身。并且允许你在不同的“存档”间进行灵活地比较,来发现这一个时间段中代码的改动。同时,它也允许你随时切换恢复至任何一个存档,并尽可能当让你的所有行为变的“可撤销”。此外,它还允许两个“存档”间进行有规则的合并,你可以去攻克BOSS_A,同时你的朋友在另一条机器上同步你的存档去攻克BOSS_B,之后在进行合并,这样两个BOSS的攻克记录都会保存下来,这意味着你可以在维护项目运行的同时,放心地在另一个或多个“分支”开发新的功能,在进行完测试后合并到主“分支”,这样同时保证了项目的稳定性、多人协作的效率并避免了冲突。而“分布式”则体现在你电脑上的“仓库”本身就是一个完整的“仓库”,包含全部历史,不依赖中央服务器。你和朋友协作时,GitHub/GitLab 只是一个"约定俗成的同步点",不是必须的。

Git最初为程序员设计,所以对于有代码项目管理需求的人来说,Git是一个必需的工具,来让你轻松进行代码的版本管理,对于我自己来说,我有进行数据分析、自然语言处理、产品开发等一系列需要进行代码版本控制的需求,Git是必要的。此外,Git也可以帮助Obsidian实现文档的同步与版本管理,帮助我的这个自建网站进行Posts的管理...

二、Git的安装

访问Git官网下载安装包

在安装后,使用git -v命令查看Git版本,即可验证是否安装成功,安装成功的命令反馈如下:

# 输入命令
git -v
# 命令反馈
git version 2.43.0

三、Git 使用前的配置

(一)Git的本地配置

对于一台未配置过Git的新机器,推荐按照以下步骤来进行配置。事实上,Git 在你第一次 commit 时如果检测不到 user.name 和 user.email,会报错并拒绝提交,并提示你去配置,但它本身不会弹出交互式输入框让你填,这会让人感觉到困扰。

下列命令中的--global参数指的是对当前用户的所有 Git 仓库生效(适合个人开发者,在自己的私有机器上配置),也可替换为--system参数,实现对这台机器上所有用户生效(很少用),还可替换为--local参数,表明只对当前仓库生效(为不同的实体工作时使用)。

git config --global user.name "Your Name"

设置你的姓名。这个名字会附在你每一次提交(commit)记录上,相当于在日记本上签名,标明"这次修改是我做的"。

git config --global user.email email@mail.com

设置你的邮箱。作用和上面类似,也是附在提交记录上的身份信息。如果你之后要把代码推送到GitHub等平台,这个邮箱最好和平台注册邮箱保持一致。这是因为GitHub 用这个邮箱来把 commit 关联到你的账号(显示头像、计入贡献图)。如果不一致,commit 照样能推上去,但在 GitHub 上会显示为一个"陌生人"的提交,不会算进你的 contribution graph。

(二)使用SSH密钥关联到GitHub

这里推荐使用SSH密钥的方式,来一劳永逸地完成与GitHub的关联。所谓SSH密钥,是一对由加密算法生成的、配套使用的文件,分为私钥(保存在本地,绝不外泄)和公钥(可以公开分享)。它们之间存在一种特殊的数学关系:用公钥加密的内容,只有对应的私钥才能解开;反过来,私钥可以生成一段"签名",任何持有公钥的人都可以验证这段签名确实出自对应的私钥持有者,但无法伪造。这种"一对一锁定、不可伪造"的特性,让 SSH 密钥可以作为一种比密码更安全的身份凭证。

所谓密钥关联,目的是建立一种"本地机器 ↔ GitHub 账户"的可信通道。具体来说:你把公钥上传到 GitHub 账户里,相当于告诉 GitHub:"今后任何持有对应私钥的机器,都可以代表我"。之后每次你从本地推送或拉取代码时,SSH 会自动用本地私钥完成一次身份验证握手,整个过程对你来说是无感的——既不用输入密码,也不用反复处理 token。

以下的流程基于WSL-Ubuntu以及Windows。需要注意的是,我习惯于使用Windows与Wsl的环境搭配,而两个环境的 SSH 密钥是相互独立,需要分别配置。

1、检查是否创建过 SSH 密钥

# Windows (PowerShell)
ls $env:USERPROFILE\.ssh
# WSL
ls -al ~/.ssh

# 如果命令反馈中,存在id_ed25519 和 id_ed25519.pub(或 id_rsa 与 id_rsa.pub,请在后续步骤三中将id_ed25519 替换为 id_rsa)文件,则意味着已经创建过密钥了,可以跳过步骤二。

2、生成 SSH 密钥

# Windows(Powershell)
## 1. 生成密钥 (一路回车,路径默认会保存在 C:\Users\用户名\.ssh\id_ed25519)
ssh-keygen -t ed25519 -C "your_email@example.com"
## 2. 启动 Windows 的 ssh-agent 服务 (必须以管理员身份运行 PowerShell)
Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent
## 3. 添加私钥
ssh-add $env:USERPROFILE\.ssh\id_ed25519
# WSL
## 1. 生成密钥 (一路回车)
ssh-keygen -t ed25519 -C "your_email@example.com"
## 2. 在后台启动 ssh-agent
eval "$(ssh-agent -s)"
## 3. 添加私钥
ssh-add ~/.ssh/id_ed25519

3、利用密钥与GitHub关联

# WSL
## 打印公钥内容到屏幕,你需要手动用鼠标选中并复制
cat ~/.ssh/id_ed25519.pub

# Windows(PowerShell)
## 打印密钥内容
Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub
## 或直接复制到剪贴板
Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub | Set-Clipboard

前往 GitHub 网页端 -> Settings -> SSH and GPG keys -> New SSH key -> 粘贴公钥。

测试连接的命令两边也都一样:ssh -T git@github.com,如若绑定成功,命令的反馈如下:

# 命令输入
ssh -T git@github.com

# 命令反馈
Hi <Github_ID>! You've successfully authenticated, but GitHub does not provide shell access.

四、Git的区域划分与文件状态

(一)Git的区域划分

Git将文件分为四个区域:工作区、暂存区、本地仓库、远程仓库。

  • 工作区:你直接操作文件的地方,就是你在电脑上看到的项目文件夹。你对文件的所有编辑都发生在这里。它包含了你项目当前所有的实体文件和目录结构。
  • 暂存区:一个"待提交的清单"。你可以把工作区里修改好的文件,有选择地添加到暂存区,等攒够了一批相关改动,再一起提交。它并不是一个你能直接点进去看的真实文件夹,而是一个虚拟的、动态的索引清单(物理上存储在 .git/index 文件中)。
  • 本地仓库:历史记录库(即 .git 文件夹),存有文件快照、目录树、提交、标签等信息。每次提交,暂存区的内容就会被永久保存到这里,形成一条稳定的历史记录。
  • 远程仓库:托管在网络服务器上的项目副本(如 GitHub、GitLab、Gitee 等平台上的仓库)。它的本质和本地仓库一样,也是一个完整的 Git 仓库,存储着项目的全部历史记录。它的核心作用有两个:一是作为协作的中枢,团队成员通过它来交换各自的提交;二是作为异地备份,即使本地机器损坏,代码和历史也不会丢失。

Git区域划分与文件状态

(二)文件状态

Git 所谓的“文件状态”,本质上是以工作区里的物理文件为主角,描述的是 Git 系统对这些文件的“认知、关注和差异对比程度”。文件的状态分为以下四种,并随着编辑与命令在这四个状态中轮转:

  • untracked(未跟踪):指的是工作区中,新创建且从未被Git跟踪的文件所处的状态。
  • unmodified(未修改):指的是工作区中,已经被Git跟踪,且内容与仓库中的文件相比较,没有改动的文件所处的状态。
  • modified(已修改):指的是工作区中,已经被Git跟踪,且内容与仓库中的文件相比较,有改动的文件所处的状态。
  • staged(已暂存):指的是工作区中,原本处于untracked(未跟踪)或modified(已修改)的文件,在暂存入到暂存区后且未提交入库时所处的状态。

这里将构建一个场景来说明文件是如何在以上四种状态之间流转的,试想一下,你有一个项目,项目已经初具规模,并且在上传修改后,你已经完成了commit与push,也就是说工作区的文件相对于仓库没有变动,所有的文件在此时都是unmodified状态。而今天你希望为其添加一个新功能,这需要创建几个新的文件并对几个旧文件进行修改,当你完成一系列修改工作后,新创建的文件会被标记为untracked,这代表此文件从未被Git跟踪,而修改过的旧文件会被标记为modified,这表明Git跟踪发现其相对于仓库有差异。你完成改动后,希望对项目目前的版本进行“存档”,于是你输入git add .命令将所有的unmodified与untracked状态的文件添加到暂存区,此时这些文件的状态就会被更新为staged,表示此文件已经进入暂存区,等待被提交。然后,你在最终确认完改动后,输入git commit -m "commit message"命令将暂存区中的文件提交到仓库,此时这些文件状态就会被重新更新为unmodified,表示这些文件已经与仓库完全同步没有差异了(这就是你的新提交所实现的,将仓库更新为你所保存的这个版本)。

五、核心命令:在本地仓库中让区域和状态流转起来

紧接着上述的场景,这里将更加详细地介绍常用的Git命令,这些命令可以让文件在本地仓库中流转起来。学会这些命令,本地仓库就可以成为你的本地“存档”库,你可以轻松利用其来进行修改的保存、恢复、比较等等功能。

(一)初始化仓库

当你创建一个新的项目时,一般会创建一个新的目录(文件夹),在文件夹中,执行git init命令即可完成仓库的初始化。

在执行完命令后,文件夹中就会生成一个隐藏的.git目录,它就是你本地仓库的存储文件。而你执行命令所在的目录,就是你的工作区。

在不进行额外配置之前(指的是创建并编辑.gitignore文件),Git会默认将工作区中的所有文件纳入扫描范围但不会跟踪任何文件,在初始化仓库之前已经存在的文件、在初始化仓库后创建的文件都会被Git标记为untracked——意味着 Git 知道它们在那里,但不会管它们的内容变化。只有通过 git add 把文件纳入跟踪后,Git 才会开始记录它们的状态。

(二)查看状态

这里将介绍两种查看工作区状态的方式,一种是命令的方式,一种利用Vscode自带功能+插件实现的图形化方式。

首先是命令方式,使用git status命令,你会被告知以下内容:当前所在的分支、哪些文件是未跟踪的、哪些文件是已修改的、哪些文件是已暂存的。因为图形化界面更为便捷好用,这里不过多对这个命令进行描述。

图形化方式,这里是利用了Vscode中自带的“源代码管理” (Source Control)功能,只要自己的PC上安装了Git,Vscode就会默认启用这个面板。这里推荐将其与插件Git Graph想配合使用,后者的主要功能是将 Git 的提交历史、分支(Branches)和合并(Merges)记录以彩色的节点树状图形式可视化展示出来,方便你直观地查看项目的历史走向和分支结构,关于这些内容,将在本帖子的后半部分,“分支”与“远程仓库与协作”两个章节介绍。

Vscode源代码管理功能展示

上图就是Vscode中“源代码管理”对应的界面,可以看到,这里有三个文件被扫描并发现了与仓库的差异,一个是已经使用git add命令暂存的 test_暂存.md 文件,一个是新创建并未被跟踪的 test_未跟踪.md ,其右侧有一绿色的U,这代表untracked(未跟踪),还有一个是已经被跟踪但是做出修改的 Git个人使用指南.md 文件(也就是这篇帖子本身),它被标记为已修改,其右侧有一黄色的M,这代表modified(已修改)。

如果你已经在PC上安装了Git,却发现自己Vscode的侧边栏并没有“源代码管理”功能,可以试着在 VS Code 左侧的侧边栏图标区域(活动栏)点击右键。在弹出的列表中,检查 “源代码管理” (Source Control) 前面是否勾选。

还有,删除一个文件时,Git会对其标注为什么状态呢?事实上,删除一个 tracked 文件,Git 会把这个"删除操作"本身标记为一种 modified(已修改)——但 git status 的输出里会用更直观的 deleted 字样来描述,方便你识别。

(三)从工作区到暂存区

当发现有未跟踪的文件时,如果你想将其纳入版本控制,那么就可以使用git add命令,来将其添加到暂存区中。同理,你也可以将modified(已修改)的文件纳入暂存区。

git add命令的几种常用方法如下:

git add <file> # 添加指定文件,<file>表示文件的地址
git add <dir>/ # 添加指定目录下的所有文件,<dir>表示目录地址
git add . # 添加当前目录下的所有文件
git add -A # 添加整个项目范围内的所有变化文件
git add -u   # 只添加已跟踪文件的变化,不添加 untracked 文件

也许你会好奇,当一个文件已经被添加到暂存区后,再次对其进行修改会发生什么?事实上,如果你做了上述操作,会发现同一个文件在“源代码管理”中出现了两次,一个是已暂存,一个已修改。Git 扫描发现工作区中的此文件相对于暂存区又产生了新差异时,会把这部分新差异(与暂存区的文件想对比)标记为 modified。注意这并不是"覆盖"原来的 staged 状态——已经进入暂存区的那份快照依然保留,所以同一个文件同时处于 staged(暂存区的旧版本)和 modified(工作区相对暂存区的新改动)两种状态。

紧跟着在“查看状态”那一小节提到的对“删除”这一操作的解释,在完成删除操作后,Git 会将其标注为 deleted(已删除),你可以将其想象成 modified(已修改),因为如果你想把“文件的删除”也记录进仓库,你仍需要使用git add命令。git add 不只是"添加",更准确的语义是"把工作区的当前状态记录到暂存区"。文件已经不存在了,所以 git add 实际上是把"这个文件被删除"这个事实暂存起来。

(四)从暂存区到本地仓库

在积攒一批改动后,你可以使用git commit -m "commit message"命令将暂存区中的改动提交到本地仓库中。-m 后面跟的是提交信息(commit message),描述这次提交做了什么。这条信息会永久附在历史记录里,写得好的提交信息能让未来的自己(和队友)省下大量翻代码的时间。如果不加 -m,Git 会弹出默认编辑器让你写,如果遇到了,按 Esc 然后输入 :wq 回车可以保存退出。

在完成一次提交之后,暂存区就会被清空,而未被添加到暂存区的文件差异仍会被保留。一次完整的正向流转流程如下所示:

untracked/modified ──(git add)──▶ staged ──(git commit)──▶ unmodified

(五)逆操作

Git 本身提供了强大的撤销机制,这也是使用本地仓库的用处所在。下面的常用命令,都可以在Vcode的源代码管理窗口,找到对应按钮,点击就能操作。

1. 想要撤销修改本身(修改包括编辑、删除操作)

你在工作区修改了文件(比如写错了一段代码,或者误删了一个重要文件),但这些修改还没有 git add(也就是还没放进暂存区)。此时,你想彻底丢弃这些修改,让工作区的内容回到上一次提交时的样子。

你可以使用git restore <file>命令,来让一个固定的文件恢复到上一次提交的版本。如果是编辑错误,这个命令会把文件内容恢复到上一次 commit 的状态。如果是误删文件,这个命令会把删除的文件重新拿回来。

!!!但是要注意的是,git restore <file>本身是完全不可逆的,一旦你执行了这个命令,你在工作区所做的未加入暂存区的修改都会被彻底覆盖,Git 也无法帮你找回。

创建新文件这个操作所带来的untracked(未跟踪)状态的文件,无法被git restore <file>命令删除,你可以配合git clean -fd命令来将所有untracked(未跟踪)状态的文件删除。(!!!慎用,你可以先用git clean -n -d命令罗列一下会被删除的文件看看)

2. 想要撤销暂存

如果你想将暂存区中的一个文件从中移除,可以根据目的选择这两种方式其中一个.

  1. 如果你想将某个文件从暂存区中取出,同时保留对其的修改(恢复到modified/如果是新文件就恢复的untracked),可以使用git restore --staged <file>命令,

  2. 如果你想将某个文件从暂存区中取出,并丢弃对其的修改(恢复到unmodified),可以使用git restore --staged <file> + git restore <file> 命令。(!!!慎用,无法反悔)

3. 想要撤销提交

如果不仅修改了文件,add 到了暂存区,甚至已经执行了 git commit 将其保存到了本地仓库。现在你发现这次提交有问题(比如提交信息写错了,或者少提交了一个文件,或者干脆提交错了代码),你想撤销这次 commit ,可以根据目的不同选择三种方式中的一种。

(注:HEAD1以及HEAD^ 表示当前分支的上一次提交,HEAD2 表示上上次,以此类推。)

  1. 如果你只是想撤销 commit ,同时把文件放回暂存区,可以使用git reset --soft HEAD^命令,这适用于你想修改提交信息,或者想往这次提交中补充一些文件。

  2. 如果你想撤销撤销这次 commit ,同时撤销 add 把文件全部返回到工作区,使用git reset --mixed HEAD^命令。

  3. 如果你想撤销这次 commit ,并且想把这段时间的改动完全删除掉推倒重来(将所有提交的文件恢复到上次提交的状态),可以使用git reset --hard HEAD^命令。(!!!注意,千万要慎用)

4. 撤销已推送的提交(连接远程仓库后的用法)

前面讲的三种 reset 都有一个共同的本质——它们都在改写历史。HEAD^ 那个 commit 在执行 reset 后从你的分支历史上"消失了"(虽然对象还在 .git 里,能通过 reflog 找回,但分支看不到它了)。

这在本地、未推送的场景下完全没问题——历史是你自己的,想怎么改怎么改。但一旦这次提交已经 push 到了远程仓库,情况就完全变了。

为什么已推送的提交不能用 reset 撤销

假设你提交了一个有问题的 commit,并已经 push 到 GitHub。这时你在本地 git reset --hard HEAD^ 撤销了它,然后想 push 上去——你会遇到两种结果,都不是好结果:

情况一:被远程拒绝

! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to '...'

Git 发现你的本地历史比远程"短"了一截(少了那个被 reset 掉的 commit),且不是简单的快进关系,于是拒绝推送,防止你覆盖远程历史。

情况二:用 --force 强推上去了

这才是真正的灾难。假设你的同事在你推送那个有问题的 commit 之后,已经基于它做了新的工作并 push 上来。现在你强推一个"不包含那个 commit"的历史上去,会发生什么?

  • 远程的那个 commit 被你"抹掉"了
  • 但你同事本地还基于它继续工作着
  • 你同事下次 git pull 时会发现历史完全错乱
  • 最坏的情况下,你同事的工作可能直接丢失

结论:一旦提交被推送到了和别人共享的远程仓库,就不要再用 reset 去撤销它。

git revert 的工作方式

git revert 用另一种思路解决"撤销已推送提交"的问题:它不删除有问题的 commit,而是生成一个新的 commit,这个新 commit 的内容恰好是原 commit 的"反向操作"。

举个例子,假设有问题的 commit 做了这些事:

  • 在 a.txt 中新增了第 5 行
  • 删除了 b.txt 的第 8 行
  • 创建了新文件 c.txt

执行 git revert <那个 commit 的哈希> 后,会生成一个新的 commit:

  • 删除 a.txt 的第 5 行
  • 恢复 b.txt 的第 8 行
  • 删除 c.txt

净效果:仓库的最终状态等价于"那个 commit 从未发生",但历史链上多了两条 commit(原 commit 和反向 commit),而不是少了一条。

基本用法
git revert <commit_hash>

<commit_hash> 可以是完整哈希、缩写哈希(前 7 位通常够用),或者 HEAD^、HEAD~2 之类的相对引用。

执行后,Git 会自动生成一个反向 commit,并弹出编辑器让你确认提交信息(默认会写好 "Revert ...",可以直接保存退出)。之后正常 git push 即可。

如果不想弹编辑器,直接用默认信息:

git revert <commit_hash> --no-edit

六、提交(commit)的本质

前面几节里,我们一直在用一个朴素的比喻——"commit 就是给项目拍一张快照"。这个理解对入门够用,但要继续往下学(特别是分支、合并、回滚这些操作),我们需要更精确地理解 commit 到底是什么。

这一节会回答三个问题:

  • 一个 commit 在 Git 内部是什么样子?
  • commit 之间是如何串成历史的?
  • HEAD 是什么?

(一)commit 不是"差异",而是"快照"

很多人初学 Git 时会以为:每次 commit 记录的是"这次相对于上次改了什么"——也就是一份差异(diff)。这个直觉是错的。

Git 的每一个 commit 实际上保存的是项目在那一刻的完整快照——包含暂存区里所有 tracked 文件的完整内容。

听起来很浪费空间?其实不会。Git 在底层做了一个聪明的优化:对于内容相同的文件,Git 只会存储一份。如果你的项目里有 100 个文件,这次 commit 只改了 1 个,那么 Git 实际上只会新存这 1 个文件的内容,剩下 99 个文件指向上一次 commit 里已经存在的内容。这种"内容寻址"的存储方式让快照机制既保留了"完整性",又避免了空间浪费。

但这是底层优化,逻辑上你完全可以把每个 commit 理解为一个独立的、完整的项目快照——这个心智模型对后面理解分支至关重要。

(二)commit 是什么:一个对象

在 Git 内部,每个 commit 都是一个对象(object),存储在 .git/objects/ 目录里。一个 commit 对象包含以下信息:

tree       <项目根目录的快照引用>
parent     <上一次 commit 的哈希>     ← 第一次 commit 没有这一行
author     <作者姓名> <邮箱> <时间戳>
committer  <提交者姓名> <邮箱> <时间戳>

<提交信息>

每个字段的含义:

  • tree:指向一个"树对象",这个树对象记录了项目根目录在当时的完整结构(包括所有文件和子目录的快照)。这就是"快照"的存储入口。
  • parent:指向上一个 commit 的哈希。这条引用让 commit 之间能串成一条链。
  • author 和 committer:通常是同一个人,但在某些场景下会不同(比如别人帮你提交了你写的代码)。
  • 提交信息:你 git commit -m 时写的那段文字。

(三)commit 的"身份证":SHA-1 哈希

每个 commit 都有一个 40 位的 SHA-1 哈希作为唯一标识符,长这样:

a3f2c91d8e7b4f5a6c8d9e0f1a2b3c4d5e6f7890

日常使用时,通常只需要写前 7 位(Git 会自动识别),比如 a3f2c91。

这个哈希不是随机生成的,而是由 commit 的所有内容计算出来的——包括 tree 引用、parent 哈希、作者、时间戳、提交信息等等。这带来两个重要特性:

1. 内容完全决定身份。两个 commit 只要有任何一处不同(哪怕只是提交时间差了一秒),哈希就完全不一样。这意味着哈希既是"身份证",也是"内容校验码"——只要哈希对得上,就能保证 commit 的内容一字不差。

2. 历史不可篡改性来自哈希链。每个 commit 的哈希里编码了它的 parent 哈希,而 parent 的哈希里又编码了 parent 的 parent……一直追溯到第一个 commit。如果有人偷偷修改了历史中某个老 commit 的内容,那个 commit 的哈希就变了,于是它的子 commit 里记录的 parent 哈希对不上了,整条链就断裂——Git 会立刻发现。

(四)commit 之间的关系:一条链

每个 commit 通过 parent 字段指向它的上一个 commit。第一次 commit 没有 parent(它是历史的起点),之后每个 commit 都至少有一个 parent。

把它们画出来大概是这样(最新的在右边):

A ← B ← C ← D

箭头方向是 parent 引用的方向——D 指向 C,C 指向 B,以此类推。注意箭头指向过去:每个 commit 只知道自己的父辈,不知道自己的子辈。

这条链有几个重要性质:

  • 单向追溯:从任何一个 commit 出发,可以一路向前追溯到第一个 commit。这就是 git log 的工作原理。
  • 不可逆向:commit 不知道自己有没有子 commit。删除一个 commit 不会"通知"它的子 commit。
  • 多 parent 的情况:合并(merge)操作会产生有两个 parent 的 commit——一个 parent 来自被合并的目标分支,另一个来自被并入的分支。这个等讲到分支时再细说。

(五)HEAD:你当前在哪儿

讲了这么多 commit 的结构,还差最后一个关键概念:Git 怎么知道"当前"是哪个 commit?

答案是 HEAD——它是一个特殊的指针,永远指向你当前所在的 commit。

具体来说,HEAD 通常指向一个分支(比如 main),而那个分支指向某个具体的 commit。所以严格说,HEAD 是个"间接指针"——它指向分支,分支指向 commit。

HEAD → main → D (最新的 commit)
                ↑
              C ← B ← A

当你执行 git commit 创建新提交时,Git 在背后做了这几件事:

  1. 把暂存区的内容打包成一个 tree 对象
  2. 创建一个新的 commit 对象,parent 指向 HEAD 当前所指的那个 commit(也就是 D)
  3. 把 main 分支的指针"前移"到这个新 commit(比如叫 E)
  4. HEAD 仍然指向 main,于是间接地也指向了 E
HEAD → main → E (新的 commit)
                ↑
              D ← C ← B ← A

HEAD 的这种"间接指向"机制是分支系统的核心——下一章讲分支时会反复用到。

(六)一个直观的实验

理论讲完了,动手玩一下,把上面的概念落地。

# 1. 看当前的 HEAD 指向哪里
cat .git/HEAD
# 输出类似 "ref: refs/heads/main"
# 说明 HEAD 指向 main 分支

# 2. 看 main 分支指向哪个 commit
cat .git/refs/heads/main
# 输出一个 40 位的哈希,这就是最新 commit 的哈希

# 3. 看这个 commit 对象的内容
git cat-file -p HEAD
# 输出 tree、parent、author、committer 和提交信息

# 4. 看 tree 对象的内容(项目根目录的快照)
git cat-file -p HEAD^{tree}
# 输出该次提交时根目录下的所有文件和它们的哈希

.git 目录看起来神秘,其实底层结构相当朴素:HEAD 是一个指向分支的指针文件,分支是一个指向 commit 的指针文件,commit 是一个对象,对象之间通过哈希互相引用。

七、分支(branch)

如果你理解了上一章(commit 是对象、commit 通过 parent 串成链、HEAD 是指向当前 commit 的指针),那么分支的本质可以用一句话讲完:

分支就是一个指向某个 commit 的指针。

就这样。没有比这更复杂的东西。所有看起来复杂的分支操作,本质上都是在挪动这些指针。

(一)分支不是"代码副本"

很多人初学 Git 时会以为:创建一个分支等于"把整个项目复制了一份",所以才能在分支上随便改不影响主分支。

这个直觉是错的,而且会让后面所有分支操作变得难以理解。

事实上,创建一个分支几乎不占任何空间——因为它只是在 .git/refs/heads/ 目录下创建了一个小文件,文件内容就是一个 40 位的 commit 哈希。仅此而已。

你可以亲眼验证一下:

ls .git/refs/heads/      # 看有哪些分支
cat .git/refs/heads/main # 看 main 分支指向哪个 commit

你会发现每个"分支"就是一个只包含一行哈希的文本文件。这就是分支的全部物理形态。

那为什么"在分支上修改不影响其他分支"?因为:

  • 不同分支的指针指向不同的 commit
  • HEAD 决定了你"当前在哪个分支上"
  • 当你 git commit 时,只有 HEAD 当前指向的那个分支的指针会前移,其他分支的指针纹丝不动

所以"分支隔离"不是因为代码被复制了,而是因为每个分支独立维护自己的指针。

(二)一个图示胜过千言万语

假设你的项目刚开始:

A ← B ← C
        ↑
       main
        ↑
       HEAD
  • 三个 commit:A、B、C
  • main 分支指向最新的 commit C
  • HEAD 指向 main 分支

现在执行 git branch feature,创建一个名为 feature 的新分支:

A ← B ← C
        ↑
       main, feature
        ↑
       HEAD

注意:没有任何 commit 被创建或复制,只是多了一个名叫 feature 的指针,恰好也指向 C。HEAD 还指向 main,所以你"当前还在 main 分支上"。

执行 git switch feature 切换到 feature 分支:

A ← B ← C
        ↑
       main, feature
              ↑
             HEAD

HEAD 现在指向 feature 了,所以你"当前在 feature 分支上"。注意 commit 历史完全没变——切换分支不会改变任何 commit。

在 feature 分支上做一次提交,产生新 commit D:

A ← B ← C ← D
        ↑   ↑
       main feature
              ↑
             HEAD

D 的 parent 是 C。feature 指针前移到 D,main 指针不动(因为 HEAD 指向的是 feature)。这就是"分支隔离"的全部秘密。

再切回 main:

A ← B ← C ← D
        ↑   ↑
       main feature
        ↑
       HEAD

工作区会自动变回 C 这个 commit 的快照——D 引入的所有改动从你眼前"消失"。但它们并没有真的消失,只是因为你现在所在的 main 分支不知道有 D 这个 commit 的存在。

在 main 上再提交,产生 E:

        ┌─ E ← main ← HEAD
A ← B ← C
        └─ D ← feature

历史出现了分叉——两个分支从 C 出发,走向了不同的未来。这就是真正意义上的"分支"。

(三)核心命令

1. 查看分支

git branch              # 列出所有本地分支,当前分支带 *
git branch -v           # 同上,并显示每个分支最新 commit 的简短信息
git branch -a           # 列出所有分支,包括远程分支

2. 创建分支

git branch <name>       # 创建分支,但不切换过去
git branch <name> <commit>  # 基于指定 commit 创建分支(不指定的话默认基于 HEAD)

3. 切换分支

git switch <name>       # 切换到指定分支
git switch -c <name>    # 创建新分支并切换(一步到位,最常用)

4. 删除分支

git branch -d <name>    # 删除分支(安全模式:未合并的分支会拒绝删除)
git branch -D <name>    # 强制删除(!! 注意:未合并的提交可能丢失)

"删除分支"也只是删掉那个指针文件——commit 对象本身依然在 .git/objects/ 里。但如果这些 commit 没有被任何其他分支或 HEAD 引用到,它们就成了"孤儿",Git 在垃圾回收时会清理掉。所以 -D 强删之前,确保那些 commit 你真的不要了。

5. 重命名分支

git branch -m <new_name>            # 重命名当前分支
git branch -m <old_name> <new_name> # 重命名指定分支

(四)合并(merge):把两条历史线汇合

创建分支是为了让工作能并行进行,但最终大多数情况下还是要把它们合并回主线。这就是 git merge 的工作。

合并的本质是:把另一个分支的修改整合到当前分支。具体怎么"整合",分两种情况。

1. 快进合并(Fast-forward)

回到这个状态:

A ← B ← C ← D
        ↑   ↑
       main feature
              ↑
             HEAD

注意:从 main 出发,沿着 parent 链能直接走到 feature 指向的 D。也就是说,main 的历史是 feature 历史的真子集——main 没有任何 feature 不知道的提交。

这时候切到 main 并合并 feature:

git switch main
git merge feature

Git 发现这种"直线超车"的情况,根本不需要创建新的 commit——直接把 main 指针"快进"到 D 即可:

A ← B ← C ← D
            ↑
       main, feature
        ↑
       HEAD

这就是 fast-forward merge——最简单、最干净的合并方式。

2. 三方合并(Three-way merge)

如果两个分支已经分叉,情况就不一样了:

        ┌─ E ← main ← HEAD
A ← B ← C
        └─ D ← feature

main 和 feature 都有对方没有的提交。这时候没法用"指针前移"来合并——必须创建一个新的 commit,把两边的修改整合起来。

git switch main
git merge feature

Git 会做这几件事:

  1. 找到两个分支的最近共同祖先(这里是 C)
  2. 基于 C 计算 main 这边的修改(C→E)和 feature 这边的修改(C→D)
  3. 把这两边的修改合并到一起
  4. 生成一个新的 merge commit,这个 commit 有两个 parent(一个是 E,一个是 D)
  5. main 指针前移到这个 merge commit
        ┌─ E ──┐
A ← B ← C       M ← main ← HEAD
        └─ D ──┘    ↑
              feature

这个 M 就是合并提交。它的"两个 parent"特性,正是上一章我们埋的钩子——现在你能看到它的实际用途了。

3. 合并冲突

如果 main 这边的修改(C→E)和 feature 这边的修改(C→D)改了同一个文件的同一个区域,Git 不知道该听谁的,会产生冲突(merge conflict)。

冲突发生时:

  • 合并操作会暂停
  • 受影响的文件里会出现冲突标记:
# <<<<<<< HEAD
main 这边的内容
=======
feature 这边的内容
# >>>>>>> feature

你需要手动编辑这些文件,决定保留哪边的内容(或者两边都保留、或者重写一段),然后删掉所有冲突标记。完成后:

git add <冲突文件>
git commit          # Git 会自动生成 merge commit 的提交信息

如果中途反悔不想合并了:

git merge --abort   # 回到合并前的状态

冲突是新手最害怕的,但它不是 Git "出错了"——而是 Git 在说:"这里有两套互相矛盾的修改,需要你来决定"。这是合并机制必须依赖人工判断的部分。

八、远程仓库与协作

到目前为止,我们所有的操作都发生在一台机器上——init、add、commit、branch、merge,无论怎么折腾,文件、历史、分支指针全都躺在你本地那个 .git 目录里。

但 Git 从一开始就是为协作设计的(还记得第一章那个"分布式"的比方吗)。这一章我们把视野扩展到网络的另一端:远程仓库。

这一章会回答这些问题:

  • 远程仓库到底是什么?它和本地仓库是什么关系?
  • 怎么建立"本地仓库 ↔ 远程仓库"的连接?
  • origin/main 这种名字到底代表什么?
  • push、fetch、pull 各自做了什么?它们有什么区别?
  • 多人协作时,应该遵循什么样的流程和规范?

好消息是:如果你真正理解了第六、七章——commit 是对象、commit 通过 parent 串成链、分支只是指向 commit 的指针——那么这一章不会引入任何"新的物理结构"。远程协作的全部复杂性,最后都会还原成指针的移动。

(一)远程仓库是什么:另一个完整的仓库

远程仓库的本质,和你本地的仓库完全一样——它就是一个完整的 Git 仓库,一个完整的 .git,存着项目的全部历史(所有 commit 对象、所有 tree、所有分支指针)。 它唯一的特殊之处,只是它恰好被放在了一个"大家都能通过网络访问到"的地方。

这正是第一章说的"分布式"的含义:不存在一个高高在上的"中央仓库",每一份仓库——你电脑上的、你同事电脑上的、GitHub 服务器上的——地位是平等的,都是完整的。GitHub、GitLab、Gitee 这些平台,只是恰好提供了一台 7×24 小时在线、大家都连得上的机器来放仓库而已。它们给 Git 增加了网页界面、权限管理、Issue、Pull Request 这些协作功能,但这些都是平台的增值服务,不是 Git 本身的一部分。Git 这个工具,即使没有 GitHub 也完全能协作(两个人之间直接通过 SSH 推拉就行)。

所以更准确的图景是这样的:

        ┌──────────────────┐
        │  远程仓库          │
        │  (GitHub 上的     │
        │   一个完整 .git)  │
        └──────────────────┘
           ↑              ↑
           │  push/fetch  │
           │              │
   ┌───────────────┐  ┌───────────────┐
   │  你的本地仓库   │  │ 同事的本地仓库  │
   │  (完整 .git)   │  │  (完整 .git)   │
   └───────────────┘  └───────────────┘

三个仓库,每一个都是完整的。它们之间通过 push(推送)和 fetch(获取)来交换 commit。远程仓库扮演的角色,就是第一章说的那个"约定俗成的同步点"——大家都往它那儿推、从它那儿取,于是彼此的工作就同步上了。

它的两个核心作用,在第四章其实已经提过:

  • 协作的中枢:团队成员通过它交换各自的提交。
  • 异地备份:即使本地机器损坏,代码和完整历史也不会丢失。

(二)三个仓库,两种起点

既然要协作,第一步就是建立"本地仓库 ↔ 远程仓库"的连接。这个连接的建立有两条路,取决于你的起点。

起点一:远程已经有仓库,克隆到本地

如果项目已经存在于远程(可能是别人创建的,也可能是你自己之前在 GitHub 网页上建的),你要做的是把它克隆下来:

git clone <远程仓库地址>

地址有两种形式,对应第三章配置的两种身份验证方式:

# SSH 形式(推荐,前提是你已按第三章配好了 SSH 密钥)
git clone git@github.com:用户名/仓库名.git

# HTTPS 形式
git clone https://github.com/用户名/仓库名.git

执行后,Git 会在当前目录下新建一个以仓库名命名的文件夹,里面是完整的项目文件和一个完整的 .git。

git clone 看起来是一个"下载"操作,但它实际上是好几个操作打包在一起。 理解这一点,这个命令就通透了。一次 clone 等价于:

  1. git init —— 在本地建一个空仓库
  2. git remote add origin <地址> —— 把你 clone 的那个地址记下来,起名叫 origin(下一节细说)
  3. git fetch origin —— 把远程的全部历史(所有 commit、分支)拉到本地
  4. git switch main(或 checkout)—— 切换到默认分支,把工作区文件铺开

所以 clone 完之后,你的本地仓库天生就配好了远程连接,可以直接 push、pull,不需要任何额外配置。这就是为什么 clone 是最省心的起点。

起点二:本地已经有仓库,关联到远程

另一种情况:你在本地用 git init 建好了仓库,也提交了若干 commit,现在想把它推到远程做备份或协作。这时远程那边需要先有一个空仓库——在 GitHub 网页上点 "New repository" 创建一个即可(创建时不要勾选 "Add a README",保持它全空,否则后面推送会有不必要的冲突)。

然后在本地把这个空的远程仓库关联上来:

git remote add origin <远程仓库地址>

这条命令做的事情很简单:在本地 .git/config 里记一笔——"有一个远程仓库,地址是 XXX,我给它起个名叫 origin"。它不会传输任何数据,只是登记了一个地址别名。

之后第一次推送,需要带上 -u 参数(原因在第四节讲):

git push -u origin main

git remote:管理远程地址

无论用哪种起点,"远程地址"在本地都是用 git remote 系列命令来管理的。常用的几条:

git remote                      # 列出所有已关联的远程仓库的名字
git remote -v                   # 同上,但同时显示每个远程的实际地址(-v = verbose)
git remote add <名字> <地址>     # 关联一个新的远程仓库
git remote remove <名字>         # 解除某个远程关联(只是删除本地的地址登记)
git remote rename <旧名> <新名>  # 给远程改名

这里要澄清一个新手常见的疑惑:origin 不是 Git 的关键字,只是一个名字而已。 它是 git clone 时默认给远程起的名字,纯粹是约定俗成。你完全可以叫它别的——git remote add backup ...、git remote add gitee ... 都行。一个本地仓库甚至可以同时关联多个远程(比如同时推到 GitHub 和 Gitee 做双备份)。只是因为大家都用 origin 作默认名,久而久之它就成了"那个主要远程仓库"的代名词。

(三)远程跟踪分支:远程在本地的"影子"

一个问题:你的本地凭什么知道远程长什么样?

设想这个场景:你在本地吭哧吭哧提交了三个 commit,这期间你没有联网。这时你执行 git status,Git 可能会告诉你:"你的分支比 origin/main 领先 3 个提交"。

奇怪——你没联网,Git 怎么知道你"领先"了?它怎么知道远程现在是什么状态?

答案是:它不知道远程的"现在",它知道的是远程的"上次"。

Git 在你本地,除了你自己的 main 分支指针,还额外存了一个叫 origin/main 的指针。这个指针记录的是:"我上一次和远程通信时,远程的 main 分支指向哪个 commit"。它是远程状态在你本地的一份快照、一个影子。

这种指针有个正式名字:远程跟踪分支(remote-tracking branch)。origin/main、origin/dev、origin/feature-x……每一个都对应远程上的一个分支,记录着"我上次看到它时的样子"。

用图来看

假设你刚 clone 完一个仓库,此刻本地和远程完全同步:

本地:
A ← B ← C
        ↑
       main  ← HEAD
       origin/main

main 和 origin/main 都指向 C——你的分支和"远程的影子"一致。

现在你在本地做了两次提交,产生 D、E:

本地:
A ← B ← C ← D ← E
        ↑       ↑
   origin/main  main ← HEAD

注意:只有 main 跟着 HEAD 前移了,origin/main 纹丝不动——因为你还没和远程通信过,那个"影子"还停留在上次的位置 C。这时 git status 说你"领先 origin/main 两个提交",依据就是这两个指针的差距。origin/main 不会自己更新,它只在你执行 fetch、pull、push 这些和远程通信的命令时才会移动。

所以你本地其实同时存在三类指针,务必区分清楚:

指针 含义 谁来移动它
main 你本地的工作分支 你 commit、merge 时
origin/main 远程 main 在本地的影子 fetch/pull/push 时自动更新
远程上的 main 真正在 GitHub 服务器上的那个分支 你或别人 push 成功时

理解了"origin/main 是个影子指针",下面三节就只是在讲:这三类指针,在不同命令下分别怎么动。

(四)推送:push

git push 做的事情:把本地分支的 commit 上传到远程,并移动远程的分支指针。

接着上一节的图,你本地是这样:

本地:                          远程:
A ← B ← C ← D ← E              A ← B ← C
        ↑       ↑                      ↑
   origin/main  main                  main

执行 git push,Git 会:

  1. 把远程缺失的 commit(D、E)传到远程
  2. 把远程的 main 指针前移到 E
  3. 把你本地的影子指针 origin/main 也同步前移到 E

结果三方一致:

本地:                          远程:
A ← B ← C ← D ← E              A ← B ← C ← D ← E
                ↑                              ↑
       origin/main, main                      main

upstream:-u 到底干了什么

完整的推送命令其实是:

git push <远程名> <分支名>      # 例如 git push origin main

但你肯定见过别人直接敲一个 git push 就完事。区别在于有没有配置 upstream(上游分支)。

upstream 是给本地分支登记的一个"默认推拉对象"——一旦本地 main 的 upstream 被设成了 origin/main,那么以后在 main 分支上,光敲 git push 和 git pull,Git 就知道"哦,是和 origin/main 打交道",不用每次都写全。

配置 upstream 用 -u(等价于 --set-upstream),只需在第一次推送时带上:

git push -u origin main

这条命令除了正常推送,还额外把 main 的 upstream 登记为 origin/main。之后这个分支再推,直接 git push 即可。

如果是用 git clone 起步的(起点一),clone 出来的分支天生就配好了 upstream,你从一开始就能直接 git push。需要手动 -u 的,主要是起点二(本地 init 后关联远程)那种情况下、第一次推送新分支的时候。

回收一个旧场景:被拒绝的推送

还记得第五章(五)第4小节那个 non-fast-forward 报错吗?

! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to '...'

现在你有能力精确理解它了。push 想成功,有一个前提:远程 main 当前指向的 commit,必须在你本地 main 的历史链上——也就是说,你的推送对远程而言得是一次"快进"(还记得第七章的 fast-forward 吗?是同一个概念)。

如果别人在你之前先推了新 commit,远程 main 就指向了一个你本地没有的 commit。这时你再推,就不是快进了——Git 会拒绝,因为接受你的推送就意味着把别人那个 commit 从远程"抹掉"。

正确的解法不是 --force 强推(那会真的抹掉别人的工作),而是先把别人的新提交拉下来、合并进你本地,再推。怎么"拉下来、合并",正是下一节的内容。

(五)获取与合并:fetch 与 pull

如果说 push 是"我 → 远程",那么 fetch 和 pull 就是"远程 → 我"。这一节是本章的重点,核心是讲清这两个命令的区别。

fetch:只更新影子,绝不碰你的工作

git fetch 做的事情非常"克制":它把远程的新 commit 下载到你本地的 .git 里,并把影子指针 origin/main 移动到最新位置——然后就停手了。

它不会碰你的 main 分支,不会碰你的工作区文件。

设想:你在本地提交了 E,与此同时同事推送了 F 到远程。在你 fetch 之前,本地是这样:

本地:                          远程:
        ┌─ E ← main ← HEAD     A ← B ← C ← F
A ← B ← C                              ↑
        └─ origin/main                main

你的影子 origin/main 还停在 C(上次通信时的位置),完全不知道 F 的存在。

执行 git fetch:

本地:                          远程:
        ┌─ E ← main ← HEAD     A ← B ← C ← F
A ← B ← C                              ↑
        └─ F ← origin/main            main

变化只有一个:F 被下载到本地,影子指针 origin/main 前移到了 F。 你的 main 还在 E,工作区文件一个字都没变。

这就是 fetch 的最大优点——它是绝对安全的。它从不修改你正在工作的东西,只是把"远程的最新情况"同步到那个影子上。fetch 之后,你可以从容地用 git log main..origin/main 看看远程到底多了什么、用 git diff main origin/main 看看具体差异,确认无误了再决定怎么合并。

pull = fetch + merge

git pull 做的事情,可以用一个等式精确概括:

git pull  =  git fetch  +  git merge

它先 fetch(下载远程新 commit、前移 origin/main),紧接着自动把 origin/main 合并进你当前的 main。

接着上面的图,如果你执行的是 git pull 而不是 git fetch,那么在 fetch 那一步之后,Git 会立刻再做一次 git merge origin/main。而"合并"——正是你第七章已经彻底搞懂的东西。这里完全没有新知识,合并的两种情况原样适用:

  • 如果你本地没有新提交(只有远程领先),那就是一次快进合并:main 指针直接前移到 origin/main 的位置,干净利落。
  • 如果两边都有新提交(像上图,你有 E、远程有 F,历史已分叉),那就是一次三方合并:Git 找到共同祖先 C,生成一个有两个 parent 的 merge commit。
  • 如果两边改了同一文件的同一区域,那就会触发合并冲突——处理方式和第七章讲的一模一样:Git 暂停,你手动编辑带冲突标记的文件,然后 git add + git commit。

所以 git pull 没有任何新魔法,它只是"fetch 这个网络操作"和"merge 这个你已会的操作"的快捷打包。

fetch 还是 pull?

既然 pull 更省事,为什么还要单独的 fetch?

区别在于控制权。pull 把"下载"和"合并"连成了一步,你来不及在中间插话——fetch 完立刻就 merge 了。多数情况下这没问题。但如果远程的改动很大、或者你本地有一堆未提交的修改、或者你只是想"看一眼远程有啥动静而暂时不想合并",pull 的自动合并就可能打你一个措手不及(比如突然弹出一堆冲突要你当场处理)。

一个稳妥的习惯是:拿不准的时候,先 fetch,看清楚 origin/main 多了什么,再决定是 merge 还是别的操作。 fetch 给你一个"先侦察、后行动"的缓冲。等你熟练了,日常同步直接用 pull 也完全没问题。

pull --rebase:另一种合并姿势

git pull 默认用 merge 来整合远程改动。当你本地和远程都有新提交时,它会生成一个 merge commit。如果这种情况频繁发生(多人协作时很常见),你的历史里就会塞满"Merge branch 'main' of ..."这种自动生成的、没什么信息量的合并提交,git log 看起来像一团乱麻。

git pull --rebase 提供了另一种思路。它在 fetch 之后,不是用 merge,而是用 rebase:把你本地那几个新提交"摘下来",先让分支快进到远程的最新状态,再把你的提交逐个重新接到后面。

效果对比,假设你有 E、远程有 F:

普通 pull (merge):
        ┌─ E ──┐
A ← B ← C       M ← main      ← 多出一个 merge commit M
        └─ F ──┘

pull --rebase:
A ← B ← C ← F ← E'  ← main    ← 没有 merge commit,历史是一条直线

--rebase 的好处是历史保持线性、干净、好读。代价是它改写了你本地提交的哈希(E 变成了内容相同但哈希不同的 E')——回想第六章讲的,commit 的哈希由它的全部内容包括 parent 决定,parent 从 C 换成了 F,哈希自然就变了。

这带来一条和第五章 reset 一样的红线:rebase 只能用在你自己本地的、还没推送出去的提交上。 已经 push 出去、可能被别人基于其工作的提交,绝不要 rebase——原因和第五章讲"不要 reset 已推送的提交"完全一致:你改写了历史,别人手上的旧历史就对不上了。

新手阶段,知道有 --rebase 这个选项、知道它能让历史更线性即可,不必急着用。日常老老实实 pull(merge)是完全安全的。

(六)多人协作的一般流程与规范

前面五节讲的都是机制——远程仓库是什么、指针怎么动、三个命令各做什么。这最后一节讲实践:把这些机制串成一套日常该怎么用的流程和规矩。

之所以放在最后,是因为这些规范不是凭空的教条,它们每一条都建立在前面的机制之上——理解了机制,你会发现这些规范都是"自然而然该这么做"的。

典型的协作循环

多人协作,日常工作基本上是这样一个循环:

  ┌─────────────────────────────────────────┐
  │                                         │
  ▼                                         │
git pull        ── 先同步远程最新进度          │
  │                                         │
  ▼                                         │
改代码 / 加功能  ── 在工作区干活               │
  │                                         │
  ▼                                         │
git add         ── 暂存改动                  │
  │                                         │
  ▼                                         │
git commit      ── 提交到本地仓库             │
  │                                         │
  ▼                                         │
git pull        ── 推送前再同步一次(关键!)   │
  │                                         │
  ▼                                         │
git push        ── 推送到远程 ───────────────┘

注意这里 pull 出现了两次:开工前一次,推送前又一次。后面那次尤其重要,下面专门说。

为什么 push 前一定要先 pull

回收第四节那个 non-fast-forward 被拒场景。push 要成功,前提是你的推送对远程是一次"快进";一旦在你工作期间别人先推了新提交,远程就领先了你,你的 push 必然被拒。

与其等到 push 被拒了再手忙脚乱,不如养成 push 前先 pull 的习惯:主动把别人的新提交拉下来、在本地合并好(顺便在本地把冲突解决干净)、让本地 main 重新领先于远程,然后再 push——这一推必然是快进,必然成功。

一句话:冲突要在本地解决,不要带到推送那一步。 先 pull 后 push,就是把这件事制度化。

不要对共享分支动用会改写历史的操作

这条在第五章和第五节其实都已经强调过,这里作为协作规范再钉一次,因为它是多人协作最致命的坑:

git reset、git rebase、git push --force 这类会改写历史的操作,绝不能用在已经推送、且被别人共享的分支(尤其是 main)上。

原因前面讲透了:你一旦改写了远程历史,别人本地基于旧历史的工作就会全部错乱,轻则一堆冲突,重则同事的提交直接丢失。

要撤销一个已推送的提交,正确做法是第五章讲过的 git revert——它不删除历史,而是追加一个"反向操作"的新提交。改写历史的操作留给自己本地的、没推送过的提交;面向共享分支,一律用 revert。

用分支来隔离开发(呼应第七章)

多人协作时,不要所有人都直接在 main 上提交。main 应该保持随时可用、稳定的状态。

正确的姿势是用第七章学的分支:每个人(或每个功能)开一个自己的分支干活,功能做完、测试通过,再合并回 main。

git switch -c feature-login   # 开一个功能分支
# ... 在这个分支上 add、commit 若干次 ...
git switch main
git pull                      # 先同步 main 的最新状态
git merge feature-login       # 把功能合并回来
git push                      # 推送

这样 main 永远是稳定的,每个人的半成品都隔离在各自的分支里,互不干扰——这正是第七章"分支隔离"那套机制在协作场景下的价值。在 GitHub/GitLab 上,这个"把功能分支合并回 main"的动作,通常不是本地 merge 后 push,而是走平台的 Pull Request / Merge Request 流程,好处是合并前可以让队友审查代码(Code Review)。PR 流程是平台功能,不属于 Git 本身,这里不展开,知道有这么个东西即可。

分支命名约定

分支名最好能"望文生义",让人一眼看出这个分支是干什么的。常见的约定是用 类型/简短描述 的形式:

  • feature/user-login —— 开发新功能
  • fix/login-crash —— 修复 bug
  • docs/update-readme —— 改文档

具体用什么前缀、怎么分隔,不同团队有不同规矩,关键是整个团队保持一致。

commit message 规范

第五章讲 git commit -m 时提过,好的提交信息能帮未来的自己和队友省下大量时间。多人协作时,这一点的价值被放大了——别人读你的提交历史,提交信息几乎是唯一的线索。

一些被广泛采用的习惯:

  • 第一行是简短的概括(50 字以内),用一句话说清这次提交干了什么。
  • 第一行常用一个动词开头,且用祈使句(像"Add login validation""Fix crash on empty input"),而不是"我加了……"这种过去时陈述。
  • 如果改动复杂,第一行空一行后,再写若干行详细说明为什么这么改。
  • 一次提交只做一件相对独立的事,不要把十个不相关的改动塞进一个 commit——这样万一要 revert,才能精准地撤掉某一件事。

同样,具体格式各团队不同(比如有的团队严格遵循 Conventional Commits 那套 feat: / fix: 前缀),核心精神是一致的:让历史可读、让每个提交自我解释。