git commit
要点速览:git commit 会获取暂存区(index)的快照,并将其永久写入 Git 的对象数据库。在幕后,Git 会把目录结构打包成树对象,写入一个列出元数据(作者、提交者、父提交哈希、消息)的提交对象,然后更新当前分支引用,使其指向新提交的哈希。
git commit 到底做了什么?
要理解 git commit,首先要放下一个常见误解:Git 并不是以“差异”或“增量”的形式跟踪变化。在 Git 中,每次提交都是整个项目的完整快照。
提交时,你保存的不是逐行编辑的列表,而是给暂存区拍一张照片,并为它分配一个唯一且永久的标识符。如果某个文件在一次提交中没有变化,Git 不会复制它,而是直接指向之前提交中的现有 blob 对象。这叫作结构共享。
graph TD
subgraph "A Git Commit Object"
C[Commit Object<br>Hash: 8a4c10...] -->|Points to| T[Root Tree Object<br>Hash: 4a2b99...]
C -->|Points to| P[Parent Commit Hash<br>Hash: 55a420...]
C -.->|Metadata| M[Author, Committer, Timestamp, Message]
end
提交对象的结构
提交在内部表现为一个包含结构化元数据的简单纯文本文件。Git 使用 SHA-1 对这段文本进行哈希处理后,会生成一个 40 位十六进制标识符。
一个原始提交对象包含以下字段:
- **tree:**指向顶层(根)树对象的指针(哈希);该树对象代表仓库的根目录。
- **parent:**上一个提交(或多个提交)的哈希。
- 初始提交没有父提交。
- 普通提交有一个父提交。
- 合并提交有两个或更多父提交。
- **author:**实际编写代码者的姓名、电子邮件和 Unix 时间戳。
- **committer:**执行提交者的姓名、电子邮件和 Unix 时间戳(例如,维护者应用补丁时)。
- gpgsig:(可选)提交经过签名时的加密签名。
- **message:**说明为何进行这些更改的文字描述。
.git/ 内部发生了什么(动手探索)
下面我们深入仓库内部,逐步追踪 Git 如何构建这些树对象和提交对象。
第 1 步:暂存文件并检查索引
首先,确保我们已经初始化仓库,并使用 git add 暂存了一个文件。
$ mkdir git-commit-demo && cd git-commit-demo
$ git init
Initialized empty Git repository in /git-commit-demo/.git/
$ echo "console.log('hello world');" > app.js
$ git add app.js
此时,文件已经被压缩成 blob 对象,并记录在 .git/index 文件中。我们来验证一下:
$ git ls-files --stage
100644 6be02374db118b9fb99cd98c6b403e5a558d0d57 0 app.js
40 位哈希 6be02374db118b9fb99cd98c6b403e5a558d0d57 是 Git 为包含 console.log('hello world'); 的 blob 生成的 SHA-1 ID(27 个字节,加上 echo 写入的换行符,总共 28 个字节)。如果编辑器或 shell 写入这一行的方式不同,你得到的 blob 哈希也会不同——内容寻址意味着哈希是根据文件的精确字节计算出来的。
**关于本课哈希值的说明:**上面的
app.jsblob 哈希(6be02374…)是精确且可复现的,因为 blob 只取决于文件内容。提交和树的哈希会包含作者/提交者的时间戳及其时区,因此下面输出中显示的缩写提交哈希(例如d4a123f)仅用于示意。在全新的仓库中运行这些完全相同的命令,会得到相同的结构,但提交 ID 和树 ID 会不同。
第 2 步:创建提交
现在来写入第一个提交。
$ git commit -m "feat: introduce logging script"
[main (root-commit) d4a123f] feat: introduce logging script
1 file changed, 1 insertion(+)
create mode 100644 app.js
我们检查一下 .git/refs/heads/ 中的分支引用。当前分支文件 main 现在应该包含提交的完整 40 位哈希:
$ cat .git/refs/heads/main
d4a123f4c7b8d80c0ef14815a5bb1d6a362bf2c9
.git/HEAD 则告诉 Git 当前激活的是哪个分支:
$ cat .git/HEAD
ref: refs/heads/main
第 3 步:拆解提交对象
我们使用底层命令 git cat-file 检查刚创建的提交对象:
# Print type of the object
$ git cat-file -t d4a123f4c7b8d80c0ef14815a5bb1d6a362bf2c9
commit
# Print raw content of the commit object
$ git cat-file -p d4a123f4c7b8d80c0ef14815a5bb1d6a362bf2c9
tree b8a27d0ef093ab7e20cf7b68a417534431e21b7b
author Purna Pattela <purna@example.com> 1772960000 +0000
committer Purna Pattela <purna@example.com> 1772960000 +0000
feat: introduce logging script
注意,提交对象不包含文件内容,也不包含差异。它指向哈希为 b8a27d0... 的树对象。我们来检查这棵树:
$ git cat-file -p b8a27d0ef093ab7e20cf7b68a417534431e21b7b
100644 blob 6be02374db118b9fb99cd98c6b403e5a558d0d57 app.js
树对象就像目录列表:它把文件名 app.js 映射到文件权限(100644)和经过 zlib 压缩的内容 blob(6be0237...)。
第 4 步:添加子目录并验证父提交
我们添加一个子目录,看看 Git 如何处理嵌套目录。
$ mkdir src
$ echo "const port = 3000;" > src/config.js
$ git add src/config.js
$ git commit -m "chore: add source config"
[main 7f2b58c] chore: add source config
1 file changed, 1 insertion(+)
create mode 100644 src/config.js
我们读取第二个提交:
$ git cat-file -p 7f2b58c
tree a832dbf521b4a56c071d2b512c98a3e74211a512
parent d4a123f4c7b8d80c0ef14815a5bb1d6a362bf2c9
author Purna Pattela <purna@example.com> 1772960100 +0000
committer Purna Pattela <purna@example.com> 1772960100 +0000
chore: add source config
注意新增的这一行:parent d4a123f...。它指回第一个提交,把两个提交连接成一条历史链。
我们再看看这个新提交的树:
$ git cat-file -p a832dbf521b4a56c071d2b512c98a3e74211a512
100644 blob 6be02374db118b9fb99cd98c6b403e5a558d0d57 app.js
040000 tree f3b3a3c9e6db5832a875a6c11d2b270a2936274b src
根树对象不再列出扁平的文件列表,而是指向另一个树对象(代表 src/ 目录,其模式为 040000)。如果我们查看嵌套的 src 树:
$ git cat-file -p f3b3a3c9e6db5832a875a6c11d2b270a2936274b
100644 blob c8384df24a5611dfd9a1db4b86270a1a8a25c156 config.js
这种树中有树的架构,完美对应了你的文件系统。
命令选项详解与高级用法
1. 修改最近一次提交(git commit --amend)
如果提交消息中有错字,或者提交前忘记暂存某项更改,可以修改最近一次提交:
$ git add forgotten_file.txt
$ git commit --amend -m "chore: add source config and settings"
**底层原理:**由于 Git 中的提交是不可变的(其 SHA-1 哈希由自身内容计算得出),Git 无法编辑已有提交。相反,--amend 会创建一个全新的提交对象,指向新的树和修改后的消息,并将其父提交设为旧提交的父提交。随后,Git 会把分支指针更新到新提交,让旧提交变成孤立提交。
graph TD
subgraph "How Amend Works"
C1[Commit 1] --> C2[Commit 2: Original]
C1 --> C3[Commit 3: Amended]
Branch[Branch Ref: main] -->|Moved from C2 to C3| C3
end
style C2 fill:#faa,stroke:#333
style C3 fill:#afa,stroke:#333
2. 跳过暂存区(git commit -a)
你可以使用 -a(或 --all)标志跳过暂存。这会让 Git 自动暂存所有已修改和已删除的文件,然后立即写入提交:
$ git commit -am "refactor: simplify logger interface"
[!WARNING] 此命令不会暂存新的未跟踪文件。如果你创建了全新文件,必须先运行
git add,再运行git commit -a。
3. 使用 GPG 或 SSH 签名提交(git commit -S)
在生产环境中,确认提交确实来自所列作者非常重要。你可以为提交添加加密签名:
$ git commit -S -m "feat: release critical payment gateway integration"
此命令使用本地私钥为提交签名。GitHub 或 GitLab 随后可以将签名与公钥匹配,并在提交旁显示“Verified”标记。
真实场景:编写原子提交
健康的代码库依赖原子提交。原子提交是指一次提交只做一件有逻辑边界的事情。它应包含实现单个功能或修复单个 bug 所需的全部更改,而不应夹带其他内容。
例如,如果你正在构建身份验证系统,不要写出 implemented login and fixed layout issues on homepage 这样的提交消息。如果首页布局修复引发了其他问题,回滚该提交时也会一并删除登录系统!
相反,可以使用 git add 拆分工作,分别提交:
# First, stage only the layout changes
$ git add src/styles.css
$ git commit -m "fix(styles): repair home page navigation alignment"
# Next, stage the login files
$ git add src/auth/
$ git commit -m "feat(auth): implement user authentication mechanism"
这种清晰的结构让你可以轻松使用 git log 审查历史,也能让开发者安全地隔离问题。
常见陷阱与错误
1. 修改已经推送的提交
不要对已经推送到与其他开发者共享的远程仓库(如 GitHub)的提交运行 git commit --amend。
- **原因:**修改会改变提交哈希。如果你强制推送修改后的提交,就会覆盖其他人的历史;他们拉取时会遇到棘手的合并冲突。
- **经验法则:**只修改完全存在于本地计算机上的提交。
2. 空提交错误
如果暂存区没有任何更改时尝试运行 git commit,Git 会拒绝该命令:
$ git commit -m "nothing changes"
On branch main
nothing to commit, working tree clean
- **解决办法:**如果你确实需要创建一个不修改文件的提交(例如触发 CI/CD 构建运行器),可以使用覆盖安全检查的标志:
$ git commit --allow-empty -m "trigger: run test suite in pipeline"
3. 混淆作者与提交者
在一些项目历史中,你会看到一个提交列出一个人作为 Author,另一个人作为 Committer。
- **Author:**实际编写代码更改的人。
- **Committer:**应用该提交的人(例如使用
git rebase或应用补丁的人)。 - 通常两者是同一个人。不过,如果你在 GitHub 上 rebase 或 squash 其他人的 pull request,你会成为 Committer,而对方仍然是 Author。
命令速查
| 命令 | 作用 |
|---|---|
git commit | 打开终端编辑器,编写多行提交消息。 |
git commit -m "msg" | 使用简短的行内消息提交已暂存的更改。 |
git commit -a -m "msg" | 暂存已修改/已删除的文件,并一步完成提交。 |
git commit --amend | 修改最近一次提交(添加新的已暂存文件和/或更新消息)。 |
git commit --amend --no-edit | 使用新文件修改最近一次提交,但保留完全相同的消息。 |
git commit --allow-empty -m "msg" | 即使没有暂存或修改的更改,也强制创建提交。 |
git commit -S -m "msg" | 为提交添加加密签名。 |
常见问题(大家也会问)
git commit 和 git push 有什么区别?
git commit 是本地命令。它会在本地目录数据库(.git/objects/)中记录快照,不会联系任何外部服务器。git push 是网络命令,会把这些本地提交、树和 blob 上传到远程仓库(如 GitHub、GitLab 或自托管服务器),以便与他人共享。
Git 如何计算 40 位提交哈希?
Git 使用 SHA-1 对提交对象的头部和内容进行哈希。内容包括树哈希、父哈希(可能有多个)、作者信息、提交者信息和提交消息。由于哈希计算包含当前时间戳,即使使用完全相同的文件和消息创建两个提交,它们的哈希也仍然会不同。
我可以撤销或删除最近一次提交吗?
可以。如果你已经提交了更改,但想撤销提交并保留工作树中的更改(取消暂存),请运行:
git reset --soft HEAD~1
如果你想彻底删除最近一次提交并丢弃所有修改,请运行:
git reset --hard HEAD~1
(警告:git reset --hard 不容易撤销;运行前请确认你的工作。)
什么是分离的 HEAD 状态?
如果你检出某个特定提交哈希(例如 git checkout a1b2c3d——链接到 git-checkout),而不是检出某个分支,那么你的 HEAD 指针会直接指向提交对象,而不是分支引用。如果你在这种状态下提交,新提交不会被任何分支指向。切换分支后,新提交会变成孤立提交,最终被 Git 的垃圾回收删除。提交前请始终确认自己处于某个分支上(链接到 git-branch)。
检验你的理解
正在加载测验…