git
把 Git 想成项目的“拍照机”就够了:你先挑好这张照片要包含哪些改动,再按下快门;以后随时可以回到任意一张照片,或者从某张照片开始开一条新分支。
真正需要分清的只有三处:工作目录是你正在修改的文件,暂存区是下一张照片的候选内容,版本库是已经拍好的历史照片。git add 是挑选内容,git commit 才是按下快门。
数据模型
不必先记 blob、tree 这些名字。先跟着一次最普通的修改走一遍。
假设仓库里只有两个文件:README.md 和 src/app.js。你修改了 app.js,然后依次执行:
1 | |
这两步做的事并不一样:
git add:告诉 Git,下一次提交要采用现在这个app.js。其他没有加入暂存区的改动不会被带进去。git commit:把“此刻暂存区指定的整套文件”保存成一个不可修改的快照,并记下提交说明、作者和上一张快照。main:从旧快照移到新快照。以后执行git log时看到的,就是这条不断向前的历史。
所以,Git 记录的不是“第 12 行改成了什么”,而是“提交完成时,整个项目应该长什么样”。git diff 里的逐行变化,是 Git 在比较两张快照时临时算出来给人看的,并不是提交里保存的一份补丁。
一张照片是怎样存下来的
Git 会把一张项目快照拆成几张更小的“卡片”,再让它们互相指向:
1 | |
名字看起来抽象,但职责很单纯:
| 名称 | 把它当成 | 它解决的问题 |
|---|---|---|
blob | 一份文件内容 | 只保存内容本身;不知道文件名,也不知道自己属于哪个目录 |
tree | 一个目录的目录清单 | 记录文件名、子目录和它们分别对应哪份内容 |
commit | 一张带编号和说明的项目照片 | 把根目录清单接到上一张照片后面,形成历史 |
分支(如 main) | 指向最新照片的书签 | 标出你当前这条历史走到了哪里 |
tag 是补充概念:附注标签会额外保存标签说明和打标人,常用来标记 v1.0.0 这类发布点。日常理解提交、分支和回退时,可以先不用管它。
为什么每次提交不需要复制整个项目
接着看上面的例子:这次只改了 src/app.js,README.md 一点没动。Git 会新建 app.js 的内容卡片、src 的新目录清单和新的提交;README.md 仍然复用旧的内容卡片。
1 | |
这就是 Git 能把每次提交看成完整快照、又不必反复拷贝未修改文件的原因。每张内容卡片都有由内容算出的对象 ID:内容一样,ID 一样,Git 就能复用;内容变了,便创建一张新的卡片。已经写入的卡片不会被原地修改。
只要记住:内容不动,书签会动
提交、目录和文件内容一旦写入就不会改变;会改变的是分支这个“书签”。新建分支只是多放一个书签,指向已有提交,因此不会复制代码。提交一次后,当前分支书签向前移动一格;HEAD 可以理解为“我现在正使用哪个书签”。
这也解释了常见操作:--amend 和 rebase 会生成新的提交,因为它们改变了快照或其父提交;reset --hard 是把书签移回旧提交;误移后仍可能借助 reflog 找回原位置。对象在磁盘上如何打包、压缩属于实现细节,先掌握这套“快照 + 书签”的模型就足够应付日常使用。
1 | |
配置
1 | |
优先级:仓库级 .git/config > 全局 ~/.gitconfig > 系统级,后面覆盖前面的同名项由前者胜出。
基础工作流
1 | |
-p 是精细控制提交粒度的关键:一次改动里混了多个逻辑变更时,按块拆成多次提交。
git diff 的参数本质上是在指定要比较的两份快照。查看历史
1 | |
分支
1 | |
merge 保留真实历史,产生合并提交;rebase 重写提交、得到线性历史,但改写的是尚未共享的提交——已推送到公共分支的提交不要 rebase。
merge 保留原提交和分叉关系;rebase 复制提交到新基线,因而会产生新提交 ID。选择依据是共享边界,而不是日志是否“好看”。merge、rebase、cherry-pick:三种搬运提交的方式
这三个命令做的事可以概括成一句话:把别处的改动搬到当前分支。区别在于搬多少、怎么放,以及搬完之后的提交是不是原来那几个。
merge:把整条分支并进来
1 | |
merge 不改写任何已有提交,而是新建一个有两个父提交的合并提交 M:一个父是 C,另一个是 E。M 的项目快照是两边内容的合并结果。分叉的形状被完整保留下来——以后看历史,仍能看出 D、E 是在分支上开发的。
rebase:把自己的提交搬到新基线上
1 | |
rebase 把当前分支独有的提交(D、E)逐个“重放”到新基线 C 的后面。重放不是移动:D 的父提交从 B 换成了 C,父提交变了,提交 ID 必然跟着变,所以产生了新提交 D’、E’。内容一样,身份证换了。原来的 D、E 变成无人引用的孤儿,等待被回收。
结果是一条直线:git log 里看不到曾经有过分叉。代价是历史被改写——所以已推送、别人可能基于它工作的提交不要 rebase。
cherry-pick:只摘走指定的提交
1 | |
cherry-pick 不在意分支,只按提交 ID 取:把 F 引入的那组改动,在当前分支上重新生成一个内容相同的新提交 F’。源分支上的 F 原封不动地留在那里。它也可以一次摘多个提交(git cherry-pick A B C 或 A..C),按顺序逐个重放。
对照与选择
| merge | rebase | cherry-pick | |
|---|---|---|---|
| 搬什么 | 整条分支 | 当前分支独有的提交 | 指定的若干提交 |
| 怎么放 | 新建合并提交,保留分叉 | 重放到新基线,拉成直线 | 在当前分支末端生成副本 |
| 提交 ID | 原有提交全部不变 | 被搬运的提交全部变 | 生成新 ID,源提交不变 |
| 典型场景 | 功能分支合回主干,保留开发脉络 | 合入前同步主干、整理未推送的本地提交 | 把一个 hotfix 摘到发布分支 |
选择的出发点不是“历史好不好看”,而是这些提交是否已被别人使用:未共享的提交,rebase 随意;已共享的提交,用 merge 或 cherry-pick,让它们以新提交的形式进入,而不是改写旧提交。
冲突处理
冲突不是 Git 出错,而是 Git 发现“两边都改了同一处”,无法替你判断最终该保留哪种业务含义。它最常出现在 merge、rebase、pull --rebase、cherry-pick 和应用 stash 时。
先执行 git status。它会明确告诉你当前处于哪一种操作,以及哪些文件尚未解决;也可以只列出冲突文件:
1 | |
假设 src/app.js 发生冲突,文件中会出现这样的标记:
<<<<<<< HEAD
当前这一边的代码
=======
正在合入的另一边代码
>>>>>>> feature-x不要只凭“删掉一边”来解决。先读清两段代码各自想解决什么问题,再编辑为最终需要的内容,并删除 全部 <<<<<<<、=======、>>>>>>> 标记。保存后,按下面的固定流程走:
1 | |
git add 在这里不是“接受某一边”,而是标记“我已经人工处理完这个文件”。若一次有多个冲突文件,全部 add 完后才能继续。rebase 可能逐个重放多个提交,因此继续后还可能遇到下一轮冲突;重复同样流程即可。
git stash pop 发生冲突时没有 --continue:解决并 add 后,先检查结果;确认无需保留这条暂存改动,再执行 git stash drop 删除它。冲突时 Git 会保留该 stash,避免内容丢失。
想整份采用某一边时
对于确实应该完整保留某一边的文件,可以让 Git 直接取用版本,再 add:
1 | |
这里的名字容易误导:普通 merge 中,ours 是你执行 git merge 时所在的当前分支,theirs 是正要合入的分支;但 rebase 中 Git 正在把你的提交重放到新基线,二者在命令层面的含义会反过来。遇到 rebase 冲突时,优先看冲突块和 git status,不要机械地使用 ours / theirs。
不想继续时,安全退出
在确认本次合并或变基不该继续时,使用与操作对应的 --abort 回到开始前:
1 | |
rebase --skip 会丢弃当前正在重放的那一个提交,只有确认它的改动已经在新基线中存在或不再需要时才使用。解决完冲突、操作完成后,仍应运行测试并查看 git log --oneline --graph,确认代码和历史都符合预期。
远程仓库
1 | |
--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 | |
reflog 是本地安全网:reset --hard、rebase、删除分支之后只要提交对象还在(默认 90 天内不被 gc),都能靠 reflog 找回原来的 HEAD 位置。
restore 或 reset;公共历史上优先 revert,误操作后再用 reflog 定位旧提交。暂存改动
1 | |
临时切分支处理紧急问题、又不想提交半成品代码时用它。
标签
1 | |
发布版本用附注标签,它是独立的对象,携带元信息;轻量标签只是指针,适合临时标记。
排查问题
1 | |
bisect 用二分查找定位引入 bug 的提交,比线性翻历史快得多,尤其历史提交数量大时。





