【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/configgit config -e最高
全局~/.gitconfiggit config -e --global
系统/etc/gitconfiggit 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)

工作区 → 暂存区 → 版本库

修改文件后必须先 addcommit

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 stage

关键操作的边界:

命令暂存区工作区
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 diff

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

git objects

HEAD 与 master 的关系

cat .git/HEAD                          # → ref: refs/heads/master
cat .git/refs/heads/master             # → e695606fc5e31b2ff9038a48a3d363f4c21a3d86
  • HEAD 是符号引用,指向当前分支。
  • refs/heads/master 是分支文件,内容为最新提交的 SHA-1。
  • 在 master 分支上时,HEADmasterrefs/heads/master

.git/refs/ 是引用命名空间,refs/heads/ 下是分支,refs/tags/ 下是标签。

git refs

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 / treesize 换成对应字节数,同样成立。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

常用配方:

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 替代 checkoutswitch 只切分支,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 –