把 Git 想成项目的“拍照机”就够了:你先挑好这张照片要包含哪些改动,再按下快门;以后随时可以回到任意一张照片,或者从某张照片开始开一条新分支。

真正需要分清的只有三处:工作目录是你正在修改的文件,暂存区是下一张照片的候选内容,版本库是已经拍好的历史照片。git add 是挑选内容,git commit 才是按下快门。

数据模型

不必先记 blobtree 这些名字。先跟着一次最普通的修改走一遍。

假设仓库里只有两个文件:README.mdsrc/app.js。你修改了 app.js,然后依次执行:

1
2
git add src/app.js
git commit -m "修正登录逻辑"

这两步做的事并不一样:

  1. git add:告诉 Git,下一次提交要采用现在这个 app.js。其他没有加入暂存区的改动不会被带进去。
  2. git commit:把“此刻暂存区指定的整套文件”保存成一个不可修改的快照,并记下提交说明、作者和上一张快照。
  3. main:从旧快照移到新快照。以后执行 git log 时看到的,就是这条不断向前的历史。

所以,Git 记录的不是“第 12 行改成了什么”,而是“提交完成时,整个项目应该长什么样”。git diff 里的逐行变化,是 Git 在比较两张快照时临时算出来给人看的,并不是提交里保存的一份补丁。

一张照片是怎样存下来的

Git 会把一张项目快照拆成几张更小的“卡片”,再让它们互相指向:

1
2
3
4
5
6
7
8
9
10
main(当前分支名)


commit(这次提交的说明、作者、上一张提交、项目根目录)


tree(项目根目录的清单)
├── README.md ──► blob(README.md 的内容)
└── src ───────► tree(src 目录的清单)
└── app.js ──► blob(app.js 的内容)

名字看起来抽象,但职责很单纯:

名称把它当成它解决的问题
blob一份文件内容只保存内容本身;不知道文件名,也不知道自己属于哪个目录
tree一个目录的目录清单记录文件名、子目录和它们分别对应哪份内容
commit一张带编号和说明的项目照片把根目录清单接到上一张照片后面,形成历史
分支(如 main指向最新照片的书签标出你当前这条历史走到了哪里

tag 是补充概念:附注标签会额外保存标签说明和打标人,常用来标记 v1.0.0 这类发布点。日常理解提交、分支和回退时,可以先不用管它。

为什么每次提交不需要复制整个项目

接着看上面的例子:这次只改了 src/app.jsREADME.md 一点没动。Git 会新建 app.js 的内容卡片、src 的新目录清单和新的提交;README.md 仍然复用旧的内容卡片。

1
2
3
旧提交:README.md ──► 旧内容      app.js ──► 旧内容
新提交:README.md ──► 旧内容 app.js ──► 新内容
↑ 直接复用

这就是 Git 能把每次提交看成完整快照、又不必反复拷贝未修改文件的原因。每张内容卡片都有由内容算出的对象 ID:内容一样,ID 一样,Git 就能复用;内容变了,便创建一张新的卡片。已经写入的卡片不会被原地修改。

只要记住:内容不动,书签会动

提交、目录和文件内容一旦写入就不会改变;会改变的是分支这个“书签”。新建分支只是多放一个书签,指向已有提交,因此不会复制代码。提交一次后,当前分支书签向前移动一格;HEAD 可以理解为“我现在正使用哪个书签”。

这也解释了常见操作:--amendrebase 会生成新的提交,因为它们改变了快照或其父提交;reset --hard 是把书签移回旧提交;误移后仍可能借助 reflog 找回原位置。对象在磁盘上如何打包、压缩属于实现细节,先掌握这套“快照 + 书签”的模型就足够应付日常使用。

1
2
3
4
5
6
cat .git/HEAD                # ref: refs/heads/main
cat .git/refs/heads/main # 当前提交的完整哈希
git rev-parse main # 把引用解析成哈希
git cat-file -p HEAD # 看当前这张项目照片的元信息
git cat-file -p HEAD^{tree} # 看项目根目录里有哪些文件和目录
git cat-file -p HEAD:app.js # 取出当前版本 app.js 的内容
Git 四种对象(blob、tree、commit、tag)构成的对象图,以及分支、HEAD、标签引用的指向关系
图:把一次提交从当前分支往下展开,就能看到提交、目录清单与文件内容的关系;新增提交时,未修改的内容仍会被复用。

配置

1
2
3
4
5
6
git config --global user.name "name"
git config --global user.email "a@b.com"
git config --global init.defaultBranch main
git config --global core.editor vim
git config --list # 查看生效的全部配置
git config --list --show-origin # 附带来源文件路径

优先级:仓库级 .git/config > 全局 ~/.gitconfig > 系统级,后面覆盖前面的同名项由前者胜出。

基础工作流

1
2
3
4
5
6
7
8
9
10
11
12
git init                    # 新建仓库
git clone <url> [dir] # 克隆远程仓库
git status # 工作区/暂存区状态
git status -s # 简洁格式

git add file.txt # 加入暂存区
git add -p # 交互式挑选改动的片段暂存
git add -A # 暂存全部改动(含删除)

git commit -m "msg" # 提交暂存区
git commit -am "msg" # 跳过 add,直接提交已跟踪文件的改动
git commit --amend # 修改上一次提交(含未推送时改历史)

-p 是精细控制提交粒度的关键:一次改动里混了多个逻辑变更时,按块拆成多次提交。

Git 工作目录、暂存区和版本库之间的 add、commit、restore 与 diff 对应关系
图:先辨认内容在哪一层,再选择命令。git diff 的参数本质上是在指定要比较的两份快照。

查看历史

1
2
3
4
5
6
7
8
9
10
11
12
git log                     # 完整提交历史
git log --oneline --graph # 一行一条,带分支图
git log -p -- file.txt # 该文件每次提交的具体 diff
git log --since="2 weeks" # 时间范围
git log --author="name" # 按作者过滤

git diff # 工作目录 vs 暂存区
git diff --staged # 暂存区 vs 上次提交
git diff HEAD~3 HEAD # 任意两次提交之间

git show <commit> # 某次提交的详情
git blame file.txt # 逐行标注最后修改的提交

分支

1
2
3
4
5
6
7
8
9
10
11
git branch                  # 本地分支列表
git branch -a # 含远程跟踪分支
git branch feature-x # 新建分支(不切换)
git switch feature-x # 切换分支
git switch -c feature-x # 新建并切换
git branch -d feature-x # 删除已合并的分支
git branch -D feature-x # 强制删除

git merge feature-x # 将 feature-x 合入当前分支
git rebase main # 把当前分支的提交搬到 main 之后
git rebase -i HEAD~3 # 交互式变基:改写/合并最近 3 次提交

merge 保留真实历史,产生合并提交;rebase 重写提交、得到线性历史,但改写的是尚未共享的提交——已推送到公共分支的提交不要 rebase。

Git merge 与 rebase 的历史图比较,以及已共享分支不应 rebase 的原因
图:merge 保留原提交和分叉关系;rebase 复制提交到新基线,因而会产生新提交 ID。选择依据是共享边界,而不是日志是否“好看”。

merge、rebase、cherry-pick:三种搬运提交的方式

这三个命令做的事可以概括成一句话:把别处的改动搬到当前分支。区别在于搬多少、怎么放,以及搬完之后的提交是不是原来那几个。

merge:把整条分支并进来

1
2
3
4
5
6
7
8
9
10
合并前:                git switch main
git merge feature-x
A───B───C main
\
D───E feature-x

合并后:
A───B───C───────M main
\ /
D───E───┘ feature-x

merge 不改写任何已有提交,而是新建一个有两个父提交的合并提交 M:一个父是 C,另一个是 E。M 的项目快照是两边内容的合并结果。分叉的形状被完整保留下来——以后看历史,仍能看出 D、E 是在分支上开发的。

rebase:把自己的提交搬到新基线上

1
2
3
4
5
6
7
8
9
10
变基前:                git switch feature-x
git rebase main
A───B───C main
\
D───E feature-x

变基后:
A───B───C main
\
D'──E' feature-x

rebase 把当前分支独有的提交(D、E)逐个“重放”到新基线 C 的后面。重放不是移动:D 的父提交从 B 换成了 C,父提交变了,提交 ID 必然跟着变,所以产生了新提交 D’、E’。内容一样,身份证换了。原来的 D、E 变成无人引用的孤儿,等待被回收。

结果是一条直线:git log 里看不到曾经有过分叉。代价是历史被改写——所以已推送、别人可能基于它工作的提交不要 rebase。

cherry-pick:只摘走指定的提交

1
2
3
4
5
6
7
8
9
10
摘取前:                git switch main
git cherry-pick F
A───B───C main
\
D───E───F feature-x

摘取后:
A───B───C───F' main
\
D───E───F feature-x

cherry-pick 不在意分支,只按提交 ID 取:把 F 引入的那组改动,在当前分支上重新生成一个内容相同的新提交 F’。源分支上的 F 原封不动地留在那里。它也可以一次摘多个提交(git cherry-pick A B CA..C),按顺序逐个重放。

对照与选择

mergerebasecherry-pick
搬什么整条分支当前分支独有的提交指定的若干提交
怎么放新建合并提交,保留分叉重放到新基线,拉成直线在当前分支末端生成副本
提交 ID原有提交全部不变被搬运的提交全部变生成新 ID,源提交不变
典型场景功能分支合回主干,保留开发脉络合入前同步主干、整理未推送的本地提交把一个 hotfix 摘到发布分支

选择的出发点不是“历史好不好看”,而是这些提交是否已被别人使用:未共享的提交,rebase 随意;已共享的提交,用 merge 或 cherry-pick,让它们以新提交的形式进入,而不是改写旧提交。

冲突处理

冲突不是 Git 出错,而是 Git 发现“两边都改了同一处”,无法替你判断最终该保留哪种业务含义。它最常出现在 mergerebasepull --rebasecherry-pick 和应用 stash 时。

先执行 git status。它会明确告诉你当前处于哪一种操作,以及哪些文件尚未解决;也可以只列出冲突文件:

1
2
git status
git diff --name-only --diff-filter=U

假设 src/app.js 发生冲突,文件中会出现这样的标记:

<<<<<<< HEAD
当前这一边的代码
=======
正在合入的另一边代码
>>>>>>> feature-x

不要只凭“删掉一边”来解决。先读清两段代码各自想解决什么问题,再编辑为最终需要的内容,并删除 全部 <<<<<<<=======>>>>>>> 标记。保存后,按下面的固定流程走:

1
2
3
4
5
6
7
8
git diff -- src/app.js       # 检查自己最终留下的改动
git add src/app.js # 告诉 Git:这个文件已解决
git status # 确认没有 unmerged paths

# 根据开始时的操作,选择一个继续命令
git merge --continue
git rebase --continue
git cherry-pick --continue

git add 在这里不是“接受某一边”,而是标记“我已经人工处理完这个文件”。若一次有多个冲突文件,全部 add 完后才能继续。rebase 可能逐个重放多个提交,因此继续后还可能遇到下一轮冲突;重复同样流程即可。

git stash pop 发生冲突时没有 --continue:解决并 add 后,先检查结果;确认无需保留这条暂存改动,再执行 git stash drop 删除它。冲突时 Git 会保留该 stash,避免内容丢失。

想整份采用某一边时

对于确实应该完整保留某一边的文件,可以让 Git 直接取用版本,再 add

1
2
3
git checkout --ours src/app.js    # 取“当前这一边”
git checkout --theirs src/app.js # 取“另一边”
git add src/app.js

这里的名字容易误导:普通 merge 中,ours 是你执行 git merge 时所在的当前分支,theirs 是正要合入的分支;但 rebase 中 Git 正在把你的提交重放到新基线,二者在命令层面的含义会反过来。遇到 rebase 冲突时,优先看冲突块和 git status,不要机械地使用 ours / theirs

不想继续时,安全退出

在确认本次合并或变基不该继续时,使用与操作对应的 --abort 回到开始前:

1
2
3
git merge --abort
git rebase --abort
git cherry-pick --abort

rebase --skip 会丢弃当前正在重放的那一个提交,只有确认它的改动已经在新基线中存在或不再需要时才使用。解决完冲突、操作完成后,仍应运行测试并查看 git log --oneline --graph,确认代码和历史都符合预期。

远程仓库

1
2
3
4
5
6
7
8
9
10
git remote -v                # 查看远程地址
git remote add origin <url> # 添加远程

git fetch origin # 拉取远程更新,不合并
git pull origin main # fetch + merge
git pull --rebase origin main # fetch + rebase,历史更干净

git push origin feature-x # 推送分支
git push -u origin feature-x # 推送并建立跟踪关系,之后可省略参数
git push --force-with-lease # 强推,但对方有新提交时会拒绝

--force-with-lease 优于 --force:只在远程分支未被他人更新的前提下才允许覆盖,避免误删同事的提交。

撤销与恢复

按“改动所处位置”选择命令:

想撤销的内容命令
工作目录的改动git restore file.txt
暂存区的改动(保留工作目录)git restore --staged file.txt
上一次提交(本地未推送)git reset --soft HEAD~1
提交与暂存区,保留工作目录git reset --mixed HEAD~1
提交、暂存区、工作目录全部git reset --hard HEAD~1
已推送的提交(保留历史)git revert <commit>
1
2
3
git restore --staged --worktree file.txt  # 等价于彻底还原该文件
git reflog # HEAD 移动记录,救命稻草
git reset --hard <reflog中的commit> # 找回“丢失”的提交

reflog 是本地安全网:reset --hardrebase、删除分支之后只要提交对象还在(默认 90 天内不被 gc),都能靠 reflog 找回原来的 HEAD 位置。

Git 工作目录、暂存区、本地 HEAD 与公共历史的撤销命令边界,以及 reflog 恢复路径
图:撤销并非一个命令。未共享时可以按范围使用 restorereset;公共历史上优先 revert,误操作后再用 reflog 定位旧提交。

暂存改动

1
2
3
4
5
6
git stash                   # 暂存当前改动,恢复干净工作目录
git stash push -m "wip" # 带说明
git stash list # 查看暂存栈
git stash pop # 恢复最近一条并从栈中移除
git stash apply stash@{1} # 恢复指定一条,保留在栈中
git stash drop stash@{1} # 丢弃指定一条

临时切分支处理紧急问题、又不想提交半成品代码时用它。

标签

1
2
3
4
5
git tag v1.0.0               # 轻量标签
git tag -a v1.0.0 -m "msg" # 附注标签,含作者/日期/说明
git tag # 列出所有标签
git push origin v1.0.0 # 推送单个标签
git push origin --tags # 推送全部标签

发布版本用附注标签,它是独立的对象,携带元信息;轻量标签只是指针,适合临时标记。

排查问题

1
2
3
4
5
git bisect start
git bisect bad # 当前提交有问题
git bisect good v1.0.0 # 该提交是好的
# git 自动二分,逐次 checkout,标记 good/bad 直至定位
git bisect reset # 结束,回到最初分支

bisect 用二分查找定位引入 bug 的提交,比线性翻历史快得多,尤其历史提交数量大时。