【Git 权威指南】“初识 Git” + “独奏"两章的精炼合并本:背景、安装、初始化、暂存区、对象模型、引用游标(reset / checkout / stash)、里程碑、文件操作、历史查看、改变历史、克隆。
本文沿用书中
master作为默认分支名。Git 2.28+ 默认改为main,行为完全一致。
前传:diff、patch、分布式
Git 之前,源码协作靠 diff + patch 这套老组合:
diff -u hello.old hello.new > hello.patch # -u: unified 格式,带上下文,是补丁的必备参数
patch hello.old < hello.patch # 升级到 new
patch -R hello.old < hello.patch # -R: 反向,回到 old
patch -p1 < changes.patch # -p1: 跳过路径首层,用于跨目录补丁
补丁文件长这样:
--- hello.old 2026-05-14 10:00:00
+++ hello.new 2026-05-14 10:05:00
@@ -1,3 +1,3 @@
hello
-world
+git
!
Git 的 git diff / git apply / git format-patch / git am 就是这套机制的强化版 —— 加了对象寻址、历史图谱、合并算法。
分布式是 Git 与 CVS / SVN 的根本分野:克隆即完整版本库,提交、查日志、建分支、合并、回退全部本地完成,不依赖网络。这是 Git “快"的根因,也是为什么提交必须用 SHA-1 寻址 —— 没有中心写入点能分配递增编号(详见下文「SHA-1 是怎么算出来的」)。
安装
brew install git # macOS
sudo apt install git git-doc git-svn git-email gitk # Debian / Ubuntu
git-svn / git-email / gitk 依赖 Perl / Tk,发行版常单独打包。装好 git-doc 后可在浏览器查帮助:
git help -w <subcommand>
Git 初始化
全局配置:用户身份与别名
提交时记入 author / committer 信息,全局配置一次即可(写入 ~/.gitconfig):
git config --global user.name "Jiang Xin"
git config --global user.email jiangxin@ossxp.com
常用别名:
git config --global alias.br branch
git config --global alias.ci commit
git config --global alias.co checkout
git config --global alias.st status
中文文件名不要被转义成八进制:
git config --global core.quotepath false
否则 git status 会把 中文.md 显示成 \344\270\255\346\226\207.md。
创建仓库
git init demo # 等价于 mkdir demo && cd demo && git init
cd demo
ls -aF # → ./ ../ .git/
.git 是版本库目录,其所在目录称为 工作区。
子目录中执行 Git 命令时,Git 沿目录向上递归查找 .git。可用 strace 验证:
strace -e 'trace=file' git status
定位版本库与工作区根:
git rev-parse --git-dir # → /path/to/demo/.git
git rev-parse --show-toplevel # → /path/to/demo
三层配置文件
| 层级 | 路径 | 命令 | 优先级 |
|---|---|---|---|
| 仓库 | <repo>/.git/config | git config -e | 最高 |
| 全局 | ~/.gitconfig | git config -e --global | 中 |
| 系统 | /etc/gitconfig | git config -e --system | 最低 |
配置文件是 INI 格式,可读可写:
git config core.bare # 读
git config a.b something # 写 → [a] b = something
git config x.y.z others # 写 → [x "y"] z = others
git config 也能操作任意 INI 文件:
GIT_CONFIG=test.ini git config a.b.c.d "hello, world"
修补错误的提交者信息
漏配 user.name 时已经提交了?重置后 amend:
git config --global user.name "Jiang Xin"
git commit --amend --allow-empty --reset-author
--amend:修补最近一次提交,不产生新提交。--allow-empty:允许空白提交(修补后无文件变化时需要)。--reset-author:同时重置 Author 与 AuthorDate;不加只会改 Committer。
Git 不做提交者身份认证,“谁都能写任意 name/email” 是分布式特性。权限控制交给 GitLab / GitHub 等服务端。
Git 暂存区(stage / index)
工作区 → 暂存区 → 版本库
修改文件后必须先 add 再 commit:
git add welcome.txt
git diff # 工作区 vs 暂存区
git diff --cached # 暂存区 vs HEAD
git status -s # 简洁状态:?? 未跟踪 / A 新增 / M 暂存 / M 仅工作区改 / MM 都改
git commit -m "which version checked in?"
git log --pretty=oneline
git commit -a自动 add 所有已跟踪文件的改动后提交,会丢掉"挑选改动入提交"的能力,慎用。 一次提交只做一件事。把一天改动一锅端,等于把 Git 当文件备份系统。
index 文件的本质
.git/index 是一棵虚拟工作区目录树,记录每个跟踪文件的名字、时间戳、长度,以及指向 .git/objects/ 中实际内容的对象 ID。

关键操作的边界:
| 命令 | 暂存区 | 工作区 |
|---|---|---|
git reset HEAD | 重置为 HEAD | 不变 |
git rm --cached <file> | 删除 | 不变 |
git checkout -- <file> | 不变 | 用暂存区覆盖 ⚠ |
git checkout HEAD -- <file> | 用 HEAD 覆盖 | 用 HEAD 覆盖 ⚠ |
Git 2.23+ 推荐
git restore/git restore --staged替代checkout,语义更清晰:restore只动文件,switch只切分支。
查看暂存区与 HEAD 的目录树
git ls-tree -l HEAD # HEAD 的 tree
git ls-files -s # 暂存区的 tree

Git 对象模型
Git 对象库 .git/objects/ 中只有四种对象:blob(文件内容)、tree(目录)、commit(提交)、tag(附注标签)。每个对象用 40 位 SHA-1 唯一寻址。
三种对象的关系
git log -1 --pretty=raw
# commit e695606fc5e31b2ff9038a48a3d363f4c21a3d86 ← 本次提交
# tree f58da9a820e3fd9d84ab2ca2f1b467ac265038f9 ← 提交对应的目录快照
# parent a0c641e92b10d8bcca1ed1bf84ca80340fdefee6 ← 上一次提交
git cat-file 查类型与内容:
git cat-file -t e695606 # → commit
git cat-file -t f58d # → tree
git cat-file -t fd3c06 # → blob
git cat-file -p fd3c06 # → 文件内容
对象在磁盘上按 SHA-1 前 2 位分目录、后 38 位作文件名:
ls .git/objects/e6/95606fc5e31b2ff9038a48a3d363f4c21a3d86

HEAD 与 master 的关系
cat .git/HEAD # → ref: refs/heads/master
cat .git/refs/heads/master # → e695606fc5e31b2ff9038a48a3d363f4c21a3d86
HEAD是符号引用,指向当前分支。refs/heads/master是分支文件,内容为最新提交的 SHA-1。- 在 master 分支上时,
HEAD≡master≡refs/heads/master。
.git/refs/ 是引用命名空间,refs/heads/ 下是分支,refs/tags/ 下是标签。

SHA-1 是怎么算出来的
Git 对象 ID = SHA1("<type> <size>\0<content>")。
注意 Git 不是直接对内容做 SHA-1,而是先在前面拼一个 header:<type> 是 commit / tree / blob / tag 之一,<size> 是内容字节数,\0 是分隔符。因为 header 里带 type,同样的字节当 blob 和当 tree 算出的 ID 不同 —— 这就是 Git 区分四类对象的方式。
手工复算 commit,验证公式:
git cat-file commit HEAD | wc -c # → 234 content 字节数
( printf "commit 234\000"; git cat-file commit HEAD ) | sha1sum
# → e695606fc5e31b2ff9038a48a3d363f4c21a3d86 手工算的
git rev-parse HEAD
# → e695606fc5e31b2ff9038a48a3d363f4c21a3d86 Git 自己给的,完全一致
小括号 ( ; ) 把 header 和 content 串成一段流喂给 sha1sum。把 commit 换成 blob / tree、size 换成对应字节数,同样成立。git hash-object 就是这套"拼 header + SHA-1"的官方封装。
由此可推出 Git 几个核心性质:
- 任何字节级改动都会产生新 ID;commit 里又包含 parent ID,所以改一处历史会让后续所有 commit ID 雪崩 —— 这是 Git 不可篡改的根。
- 不同 type 的对象 ID 永不冲突,因为 header 不同。
- 部分 SHA-1 前缀(默认 7 位)就能寻址,是因为单仓库的对象数远小于 16⁷ ≈ 2.7 亿。
SHA-1 不是数学上唯一,但 160 位空间 + 抗碰撞设计使实际工程中可视为唯一。Git 2.29+ 已在向 SHA-256 演进。
提交的引用语法
集中式系统能用递增编号,是因为只有一个写入点;Git 是分布式的,必须用全球可重现的对象 ID 寻址。
| 写法 | 含义 |
|---|---|
master / refs/heads/master | 分支最新提交 |
HEAD | 当前分支最新提交 |
HEAD^ / HEAD~1 | 父提交 |
HEAD^^ / HEAD~2 | 祖父提交 |
HEAD^2 | 第二个父提交(merge 才有) |
a573106^{tree} | 该提交对应的 tree |
a573106:path/to/file | 该提交里某个文件的 blob |
:path/to/file | 暂存区里的 blob |
:/Commit A | 在提交日志中搜索字串的提交 |
部分 SHA-1 前缀(不冲突即可)也能寻址:
git cat-file -p e695
git cat-file -p e695^
git rev-parse e695^{tree}
引用游标:reset / checkout / stash
分支是一个游标
引用 refs/heads/master 就是一个游标,每次新 commit 就指向新的提交 ID。git reset 可以把它拨向任意已存在的提交:
git reset --hard HEAD^ # 拨回到上一个 commit
--hard会同时改写引用、暂存区、工作区。改之前先git stash或 commit 当前改动。
reset 的三个动作
git reset [--soft|--mixed|--hard] [<commit>] 内部按需执行三步,参数决定走到第几步(不带参数等价 --mixed):
| 参数 | ① 移动引用 | ② 重置暂存区 | ③ 重置工作区 |
|---|---|---|---|
--soft | ✓ | ||
--mixed | ✓ | ✓ | |
--hard | ✓ | ✓ | ✓ |

常用配方:
git reset HEAD <file> # 撤出暂存区(git add 的反操作),工作区不动
git reset --soft HEAD^ # 撤销最近一次 commit,改动留在暂存区,便于修改后重新 commit
git reset --hard HEAD^ # 彻底丢掉最近一次 commit + 所有未提交改动
git commit --amend等价于git reset --soft HEAD^ && git commit -e -F .git/COMMIT_EDITMSG。<paths>与引用同名时易冲突,可用--分隔:git reset HEAD -- <paths>。
用 reflog 救回错误的重置
带工作区的版本库默认开启 core.logallrefupdates=true,所有引用变更都记录在 .git/logs/:
git reflog show master | head -5
# 9e8a761 master@{0}: 9e8a761: updating HEAD
# e695606 master@{1}: HEAD^: updating HEAD
# 4902dc3 master@{2}: commit: does master follow this new commit?
git reset --hard master@{2} # 拨回到两步之前
只要 reflog 还在(默认 90 天),任何"丢失"的提交都能找回来。
检出与分离头指针
git reset 改的是分支游标;git checkout <commit> 改的是 HEAD 本身,分支游标不动。
git checkout HEAD^
# You are in 'detached HEAD' state.
cat .git/HEAD
# 3175afde9450a1dc40b09d05a012b45e967cb80f ← 直接是 SHA-1,不再是 "ref: refs/heads/xxx"
这就是 分离头指针:HEAD 指向具体提交而非引用。此时可以正常 commit,但一旦 checkout 到其他分支,这些提交没有任何引用追踪,过期后会被 gc 清除。要救回这些"游离提交”:
git checkout master
git merge <游离提交的 SHA-1>
Git 2.23+ 推荐
git switch/git restore替代checkout。switch只切分支,restore只动文件 ——checkout一词多义,新命令把这两个意图拆开了。
暂存进度 stash
中途要切分支,但工作区一堆改动不想提交:
git stash # 默认只暂存已跟踪文件;-u 含未跟踪,-a 含被忽略
git stash list # 查看 stash 栈
git stash pop # 恢复并删除最近一笔
git stash apply stash@{1} # 恢复但不删除指定 stash
git stash drop stash@{1} # 删除指定 stash
git stash branch <name> # 基于某次 stash 创建分支
机制:每次 stash 就是写在引用 refs/stash 下的一笔 commit,该引用的变更走 reflog(.git/logs/refs/stash),所以才有 stash@{n} 这种和 reflog 一样的语法。一次 stash 其实是两笔 commit —— 一个保存暂存区,一个 merge commit 保存工作区,恢复时分别还原。
里程碑 tag
git tag -m "v1.0 release" v1.0 # 附注 tag,会生成独立的 tag 对象
git tag v1.0-lw # 轻量 tag,只是引用文件指向某 commit
git rev-parse refs/tags/v1.0
git describe 给当前 HEAD 自动生成可读版本号:
git describe
# v1.0-2-g8861c65
# └┬─┘ │ └──┬──┘
# 最近tag │ 该提交 SHA-1 前缀
# 距 tag 的提交数
刚打完 tag 时只输出 tag 名;之后每次 commit 都会拼上"距离 + SHA-1"后缀。
文件操作
删除:rm vs git rm
| 命令 | 工作区 | 暂存区 | 版本库(commit 后) |
|---|---|---|---|
rm <file> | 删 | ─ | ─ |
git rm <file> | 删 | 删 | 下次删 |
git rm --cached <file> | ─ | 删 | 下次删 |
git add 两个聚合参数的差别:
git add -u # 已跟踪文件的所有改动(含删除),不含新文件
git add -A # 所有改动(含删除、含新文件)
恢复历史中被删的文件:
git cat-file -p HEAD~1:welcome.txt > welcome.txt
git add welcome.txt && git commit -m "restore welcome.txt"
也可以用 git ls-files --with-tree=HEAD~1 列出旧版本中的文件。
移动 / 重命名
git mv welcome.txt README # 等价于 mv + git rm + git add
.gitignore
语法要点:
#行首是注释。- 通配符:
*任意字符、?单字符、[abc]字符范围。 - 末尾
/只匹配目录;不带/同名文件和目录都忽略。 - 行首
!反向匹配(不忽略)。 - 路径以
/开头表示根锚定,否则任意层级都匹配。
*.a # 忽略所有 .a
!lib.a # 但 lib.a 例外
/TODO # 仅根目录的 TODO,子目录的不算
build/ # 任意层级的 build 目录
doc/*.txt # doc/notes.txt 命中,doc/server/arch.txt 不命中
.gitignore只对未跟踪文件有效。已入库的文件后续改动不会被忽略,得先git rm --cached <file>再加入忽略。
三种作用域,从私到公:
| 作用域 | 路径 | 共享性 |
|---|---|---|
| 本仓库私有 | .git/info/exclude | 仅本机此仓库 |
| 全局私有 | core.excludesfile 指向的文件 | 仅本机所有仓库 |
| 项目共享 | 仓库内 .gitignore | 入库后所有协作者 |
强制把被忽略的文件加入暂存:
git add -f hello.h
归档 git archive
直接 tar 工作区会把 .git/ 和忽略文件全打进去。用 git archive 从提交快照打包:
git archive -o latest.zip HEAD
git archive -o partial.tar HEAD src doc
git archive --format=tar --prefix=foo-1.0/ v1.0 | gzip > foo-1.0.tar.gz
历史查看
版本范围语法 git rev-list
git rev-parse 把表达式解析为 SHA-1;git rev-list 在此基础上列出版本范围。最常用是"取差集”:
git rev-list --oneline G..D # D 的历史 - G 的历史
git rev-list --oneline ^G D # 等价写法,^X = 排除 X 及其历史
git rev-list --oneline D F # D 和 F 的历史的并集
口诀:A..B 等价于 ^A B,看 B 比 A 多了什么。
git rev-parse 还有一组配置探测开关,写脚本经常用到:
git rev-parse --git-dir # .git 目录位置
git rev-parse --show-toplevel # 工作区根
git rev-parse --show-cdup # 到根的相对深度
git rev-parse --symbolic --branches # 列出所有分支名
浏览日志 git log
git log --oneline -5 # 最近 5 条单行
git log --graph --oneline # 字符版分支图
git log -p <file> # 含 diff
git log --stat # 含变更摘要
git log --pretty=raw -1 # 看 tree/parent/author 原始信息
常用别名:
git config --global alias.glog "log --graph --oneline --decorate"
差异比较 git diff
| 命令 | 比较 |
|---|---|
git diff | 工作区 vs 暂存区 |
git diff --cached | 暂存区 vs HEAD |
git diff HEAD | 工作区 vs HEAD |
git diff A | 工作区 vs 提交 A |
git diff --cached A | 暂存区 vs 提交 A |
git diff A B | 提交 A vs 提交 B |
git diff A B -- <paths> | 指定路径下的差异 |
git diff <path1> <path2> | 仓库外的两个文件 |
git diff --word-diff | 逐词比较,非逐行 |
文件追溯 git blame
git blame README # 每行首显示引入该行的 commit + author
git blame -L 6,+5 README # 只看第 6 行起的 5 行
二分查找 git bisect
二分定位"哪个提交引入了 bug":
git bisect start
git bisect bad HEAD # 当前是坏的
git bisect good v1.0 # v1.0 时是好的
# Git 自动 checkout 中间提交,跑测试后告诉它 good / bad
git bisect good
git bisect bad
# ... 直到锁定第一个坏提交
git bisect reset
改变历史
历史一旦推送到共享仓库就别再改 —— 协作者会全部出错。改历史只对未推送的本地分支或独享分支安全。
单步:amend
git commit --amend # 修补最近一次 commit(改文件或改 message)
git commit --amend --no-edit # 只补文件,不改 message
多步:reset –soft 重写
把最近 N 个 commit 揉成一个:
git reset --soft HEAD~3
git commit -m "squashed 3 commits"
拣选 cherry-pick
从别处挑一个 commit 重放到当前分支:
git cherry-pick <commit>
机制:把目标提交导出为补丁,在当前 HEAD 上重放,得到内容一致但 SHA-1 不同的新提交。
变基 rebase
把一段提交"嫁接"到新的基点上:
git rebase --onto <newbase> <since> <till>
# 把 (since, till] 这段提交摘出来,逐个重放到 newbase 之上
执行步骤:先 checkout <till>(detached HEAD),把范围内的提交写到临时文件,硬重置到 <newbase>,再按顺序重放。遇冲突暂停 —— 解决后 git rebase --continue;跳过当前 git rebase --skip;放弃 git rebase --abort。
交互式变基是日常最常用的"改历史瑞士军刀":
git rebase -i HEAD~5
# pick 保留
# reword 改 message
# edit 中途停下手改
# squash 合并到上一个
# fixup 合并到上一个,丢弃 message
# drop 删除该 commit
丢弃远古历史
只想保留最近 100 个提交,扔掉之前的:
# 1. 用某次提交的 tree 直接造一个孤儿提交(无 parent)
echo "Initial squashed commit" | git commit-tree HEAD~100^{tree}
# → 输出新 commit 的 SHA-1,记为 $ORPHAN
# 2. 把 HEAD~100 之后的所有提交嫁接到孤儿之上
git rebase --onto $ORPHAN HEAD~100 master
之前的历史与新历史完全断开,gc 后磁盘释放。
反转 revert
revert 生成一个反向提交来"撤销"指定提交,不改写历史,安全用于共享分支:
git revert HEAD # 撤销最近一次
git revert <commit> # 撤销指定提交
Git 克隆
git clone <url> <dir> # 含工作区
git clone --bare <url> <dir.git> # 裸库,无工作区
git clone --mirror <url> <dir.git> # 裸库 + 注册上游,可持续 git fetch 同步
裸库目录习惯加 .git 后缀,core.bare=true,用于服务端或备份点:
git init --bare /srv/git/demo.git # 新建空裸库
git push /srv/git/demo.git # 工作区可直接 push 进去
--mirror 比 --bare 多一步:把上游所有 refs 完整镜像(含 remote-tracking、tag、note),并设置 remote.origin.fetch = +refs/*:refs/*,所以可以反复 git fetch 增量同步 —— 这是用裸库做镜像 / 备份的标准姿势。
References
– EOF –