第 8 章 · Git 进阶与救急
一句话:整理提交、在分支间复制提交、找出引入 bug 的提交、同时开两份工作目录,以及做错之后怎么撤销。 用它还是用别的:自己分支上还没推送的提交随便整理;已经推送到公共分支的提交只用
git revert撤,不变基、不reset。 核对版本:git 2.45,2026-10
8.1 安装与版本
Section titled “8.1 安装与版本”安装方法同第 7 章 7.1。本章的命令要求 Git 2.38 以上,先查版本:
git --version几个版本门槛:git switch 和 git restore 从 2.23 起有,push.useForceIfIncludes 从 2.30,冲突样式 zdiff3 从 2.35,rebase --update-refs 从 2.38。Windows 的 winget 和 macOS 的 Homebrew 装的都是最新版。Ubuntu 22.04 用 apt 装的是 2.34,不够用,先加 git-scm.com 推荐的 PPA(Ubuntu Git Maintainers 维护)再装:
sudo add-apt-repository ppa:git-core/ppasudo apt updatesudo apt install git从历史里彻底删除文件要用 git filter-repo,它不随 Git 安装,是一个 Python 程序。需要时再装:
pipx install git-filter-repo8.2 五分钟概念
Section titled “8.2 五分钟概念”第 7 章讲过:提交是一份快照,分支是指向提交的指针。本章的命令大多是在移动指针、或者重新生成提交。
- 变基(Rebase):把一串提交拿下来,在新的基础上逐个重放。内容一样,但父提交变了,所以每个提交都生成新的哈希。旧提交并没有立刻消失,只是没有分支再指向它们。
- 引用日志(Reflog):本地记录 HEAD 和每个分支去过哪些提交,每移动一次记一行,
HEAD@{1}就是上一步的位置。reset、变基、删分支之后后悔,都靠它找回。它只在你自己的机器上,不会推送,也只记录提交过的东西。 ORIG_HEAD:git reset、git merge、git rebase这类会大幅移动 HEAD 的命令,动手前把原来的位置记在这里。刚做完就后悔,git reset --hard ORIG_HEAD一步回去;之后再做一次同类操作,它就被覆盖了。- 改写历史:
--amend、变基、reset都会让分支指向新的提交。只在你本地或只有你一个人用的分支上,这没问题;已经推送到公共分支的历史一旦改写,别人拉取时会冲突、提交会重复。公共分支上撤销用git revert。 - 强制推送(Force Push):改写过历史的分支,普通
git push会被拒绝,必须强推。--force-with-lease只在远程分支还停在你上次看到的位置时才覆盖,比--force安全,但有一个漏洞,见 8.6。
同一组分叉的提交(main 上有 B,feature 上有 C、D),合并和变基之后的样子不同。上半张:在 main 上合并 feature,两条线都保留,多出一个有两个父提交的合并提交 M。下半张:在 feature 上变基到 main,C 和 D 被重放成 C′、D′,历史变成一条直线,旧的 C、D 只能从引用日志里找到。
8.3 常用命令
Section titled “8.3 常用命令”<提交> 可以写哈希前七位、分支名,或 HEAD~2、HEAD@{1} 这类相对写法。git rebase -i 打开的待办列表里,每行开头的词决定这个提交怎么处理,表里“编辑器里”那几行就是这些词。
| 任务 | 命令 | 说明 |
|---|---|---|
| 变基 | ||
把当前分支接到 main 最新提交之后 |
git rebase main |
你的提交逐个重放,哈希全变 |
| 整理最近三个提交 | git rebase -i HEAD~3 |
打开待办列表,最早的提交在最上面 |
| 编辑器里:保留 | pick |
原样保留这个提交 |
| 编辑器里:并入上一个,合写说明 | squash |
两条说明都给你,编辑后保存 |
| 编辑器里:并入上一个,丢掉说明 | fixup |
只留上一个提交的说明,不再问 |
| 编辑器里:只改说明 | reword |
内容不动,会再打开一次编辑器 |
| 编辑器里:停下来改内容 | edit |
停在这个提交,改完 git commit --amend |
| 编辑器里:删掉这个提交 | drop |
这个提交的改动一起丢掉 |
| 解决冲突后继续 | git rebase --continue |
先 git add 改好的文件 |
| 放弃这次变基 | git rebase --abort |
回到变基开始前,什么都不丢 |
| 跳过当前这个提交 | git rebase --skip |
这个提交的改动不要了 |
| 记一个“修正某提交”的提交 | git commit --fixup <提交> |
说明自动写成 fixup! 原说明 |
| 把修正提交自动归位 | git rebase -i --autosquash main |
fixup! 提交自动排到目标下面并标成 fixup |
| 挑拣(cherry-pick) | ||
| 把另一个分支的一个提交复制过来 | git cherry-pick <提交> |
生成内容相同、哈希不同的新提交 |
| 复制几个 | git cherry-pick <提交1> <提交2> |
按写的顺序依次应用 |
| 复制连续一段 | git cherry-pick <起>^..<止> |
包含起止两个提交 |
| 在说明里记下来源 | git cherry-pick -x <提交> |
末尾加一行 (cherry picked from commit …) |
| 解决冲突后继续 | git cherry-pick --continue |
先 git add;放弃用 --abort |
| 后悔药 | ||
| 看 HEAD 去过哪里 | git reflog |
最新在上,每行一个 HEAD@{n} |
| 回到上一步之前 | git reset --hard HEAD@{1} |
工作区里没提交的改动会丢 |
| 找回已删除的分支 | git branch <名> <哈希> |
哈希在删除时的提示 (was …) 里,或在 git reflog 里 |
找回 reset --hard 丢掉的已暂存内容 |
git fsck --lost-found |
写进 .git/lost-found/other/,只对 git add 过的内容有效 |
| 排查 | ||
| 开始二分查找(bisect) | git bisect start |
之后先标一个坏的、一个好的 |
| 标记当前提交有 bug | git bisect bad |
不写提交就是当前这个 |
| 标记某个旧版本没 bug | git bisect good <提交> |
Git 自动切到中间的提交等你测试 |
| 让脚本自动判断 | git bisect run <测试命令> |
退出码 0 算好,125 跳过,1–127 其余算坏,128 及以上中止二分 |
| 结束二分 | git bisect reset |
回到开始前所在的分支 |
| 某几行是谁改的 | git blame -L 10,20 <文件> |
只看第 10 到 20 行 |
| 某个字符串是哪次加进来或删掉的 | git log -S "<字符串>" --oneline |
列出改变了它出现次数的提交 |
| 多工作区(worktree) | ||
| 在另一个目录检出某个分支 | git worktree add ../<目录> <分支> |
共用同一个仓库,不用再 clone |
| 新建分支并检出到另一个目录 | git worktree add -b <新分支> ../<目录> origin/main |
从远程最新的 main 分出去 |
| 列出所有工作区 | git worktree list |
每行一个目录和它所在的分支 |
| 删除工作区 | git worktree remove ../<目录> |
有没提交的改动时拒绝,分支保留 |
| 子模块(submodule) | ||
| 克隆时连子模块一起下载 | git clone --recurse-submodules <url> |
一步到位 |
| 已经克隆了,补下载子模块 | git submodule update --init --recursive |
子模块里还有子模块也一起下 |
| 清理 | ||
| 预览会删哪些未跟踪文件 | git clean -nd |
只列出,不删除 |
| 删除未跟踪的文件和目录 | git clean -fd |
删了找不回;.gitignore 里的不删 |
| 压缩仓库、清理无用对象 | git gc |
Git 平时会自动跑,一般不用手动 |
| 改写历史 | ||
| 从全部历史里删掉一个文件 | git filter-repo --invert-paths --path <文件> |
需单独安装(见 8.1);只在新克隆的仓库里运行 |
8.4 典型工作流
Section titled “8.4 典型工作流”我做错了怎么撤销
Section titled “我做错了怎么撤销”先找到自己的情况,照做就行。“会丢东西吗”一列说的是做完这条命令后还能不能找回。
| 情况 | 命令 | 会丢东西吗 |
|---|---|---|
| 改了文件,还没 add,想恢复原样 | git restore <文件> |
丢,这个文件没提交的改动找不回 |
| add 了还没 commit,想取消暂存 | git restore --staged <文件> |
不丢,改动留在工作区 |
| commit 了还没 push,想撤销提交但保留改动 | git reset --soft HEAD~1 |
不丢,改动回到暂存区 |
| 想彻底丢掉最后一次提交 | git reset --hard HEAD~1 |
提交能用 git reflog 找回;工作区和暂存区里没提交的改动一起丢 |
| 提交说明写错了(没 push) | git commit --amend -m "<新说明>" |
不丢,旧提交被替换 |
| 提交漏了一个文件(没 push) | git add <文件>,再 git commit --amend --no-edit |
不丢 |
| 在错误的分支上提交了(没 push) | 正确分支还不存在:git branch <正确分支>,再 git reset --hard HEAD~1;已存在:切过去 git cherry-pick <提交>,回原分支 git reset --hard HEAD~1 |
不丢,提交到了正确分支;先提交或收起其他改动 |
| 已经 push 的提交要撤销 | git revert <提交> |
不丢,新增一个反向提交,历史不改 |
| 合并出了冲突,不想合了 | git merge --abort |
不丢,回到合并前 |
| 合并已经提交,还没 push | git reset --hard ORIG_HEAD |
不丢,合并提交消失,两边原来的提交都在;工作区没提交的改动先收起(--hard 会丢) |
| 合并已经提交,也 push 了 | git revert -m 1 <合并提交> |
当下不丢,-m 1 表示以合并前的当前分支为准;但以后再合并这个分支,被撤掉的改动不会回来,见表下说明 |
| 变基搞砸了 | 还在进行中:git rebase --abort;已完成:git reset --hard ORIG_HEAD,或在 git reflog 里找 rebase (start) 下面一行的哈希,git reset --hard <哈希> |
不丢 |
| 误删了分支 | git branch <名> <哈希>,哈希看删除时的 (was …) 或 git reflog |
不丢,但别拖:没有分支指向的提交,默认 30 天后可能被 git gc 清掉 |
reset --hard 之后后悔 |
git reflog 找到位置,git reset --hard HEAD@{1} |
提交都能找回;没提交的改动找不回,已 add 的见 8.6 |
| 最后一次提交带进了密码或大文件(没 push) | git rm --cached <文件>,加进 .gitignore,再 git commit --amend --no-edit |
不丢,文件留在磁盘上,只是不再被跟踪 |
| 更早的提交带进了密码或大文件(没 push) | git rebase -i <那个提交>~1,把那一行改成 edit,同上处理后 git rebase --continue;或用 git filter-repo |
不丢;只 --amend 没用,文件还在更早的提交里 |
| 密码或大文件已经 push 了 | 先在服务端作废密钥,再在新克隆里 git filter-repo --invert-paths --path <文件>,git remote add origin <url>,git push --force --all,git push --force --tags |
已推送的密钥一律当作泄露;同事要重新克隆 |
用 git revert -m 1 撤掉一次合并后,Git 仍认为那个分支已经合并过。之后修好了要重新合并这个分支时,先 git revert <这次 revert 的提交>(把 revert 再 revert 掉),否则原来的改动不会回来。
提 PR 前把五个零碎提交整理成两个
Section titled “提 PR 前把五个零碎提交整理成两个”功能分支上攒了 wip 1 到 wip 5 五个提交,还没合并,要整理成“功能主体”和“测试”两个:
-
看分支上有哪几个提交:
git log --oneline main..HEAD。 -
打开待办列表:
git rebase -i HEAD~5。 -
按下面这样改每行开头的词,最早的提交在最上面:第 1 行改成
reword好重写说明,第 2、3 行改成fixup并入第 1 行,第 5 行改成squash并入第 4 行。reword f1bbc44 wip 1fixup adf5eef wip 2fixup 0c32cab wip 3pick 1c48357 wip 4squash 79f363a wip 5 -
保存并关闭编辑器。Git 先为
reword打开一次编辑器,写第一个提交的说明;再为squash打开一次,写第二个提交的说明。 -
确认只剩两个提交:
git log --oneline main..HEAD。 -
确认代码和整理前一样:
git diff ORIG_HEAD,没有输出就对了。 -
推送:
git push --force-with-lease。
用二分法找出哪个提交引入了 bug
Section titled “用二分法找出哪个提交引入了 bug”已知 v1.0 没这个 bug,现在有。中间有几百个提交也只要测十来次:
- 开始:
git bisect start。 - 标记当前有 bug:
git bisect bad。 - 标记已知好的版本:
git bisect good v1.0。Git 切到中间的一个提交。 - 测试,没问题就
git bisect good,有问题就git bisect bad。重复这一步,直到输出<哈希> is the first bad commit。 - 能用一条命令判断好坏时,第 4 步改成自动跑:
git bisect run npm test。 - 记下哈希,看那次改了什么:
git show <哈希>。 - 结束,回到原来的分支:
git bisect reset。
同时修线上 bug 和继续开发
Section titled “同时修线上 bug 和继续开发”正在功能分支上改到一半,线上出了 bug。不用收起改动,另开一个目录修:
- 取回远程最新提交:
git fetch origin。 - 在旁边的目录开一个修复分支:
git worktree add -b hotfix/<名> ../hotfix origin/main。 - 进入新目录:
cd ../hotfix。 - 修好后提交:
git commit -am "<说明>"。 - 推送并开 PR(详见第 9 章):
git push -u origin hotfix/<名>。 - 回到原来的项目目录:
cd ../<项目>,功能分支的改动原样还在。 - 删掉修复用的目录:
git worktree remove ../hotfix。
第 2 步新建 hotfix/<名> 分支,是为了推送后开 PR;同一个分支也不能同时在两个工作区检出,所以不直接检出 main。
8.5 配置要点
Section titled “8.5 配置要点”配置文件的位置同第 7 章 7.5。下面这些项都和本章的命令有关:
| 配置项 | 建议值 | 效果 |
|---|---|---|
rebase.autoSquash |
true |
git rebase -i 默认带 --autosquash |
rebase.autoStash |
true |
工作区有改动时也能变基,先自动收起,结束后放回 |
rebase.updateRefs |
true |
变基时,指向被重放提交的其他分支一起移过去 |
rerere.enabled |
true |
记住冲突是怎么解决的,同样的冲突再出现时自动套用 |
merge.conflictStyle |
zdiff3 |
冲突标记里多显示一段共同祖先的原文 |
fetch.prune |
true |
git fetch 时删掉远程已删除分支的本地记录 |
push.useForceIfIncludes |
true |
配合 --force-with-lease,补上 git fetch 之后保护失效的漏洞 |
core.editor |
"code --wait" |
待办列表和提交说明在 VS Code 里改;第 7 章设过就不用再设 |
开了 zdiff3 后,冲突在 ||||||| 和 ======= 之间多出一段双方改之前的原文,看得出两边各改了什么:
<<<<<<< HEAD你本地的内容||||||| 01d8c77改之前的原文=======对方的内容>>>>>>> feature最小配置,追加到全局配置文件:
[rebase] autoSquash = true # fixup! 提交自动归位 autoStash = true # 有未提交改动也能变基 updateRefs = true # 叠在一起的分支一起移动[rerere] enabled = true # 记住冲突的解决方式[merge] conflictStyle = zdiff3 # 冲突里显示共同祖先[fetch] prune = true # 清掉远程已删除的分支[push] useForceIfIncludes = true # 强推前确认远程的新提交你已经看过8.6 注意事项
Section titled “8.6 注意事项”- 变基后 push 被拒,
! [rejected] feature -> feature (non-fast-forward)。 原因:变基改写了历史,远程的提交不再是你本地的祖先。处理:确认是自己的分支后用git push --force-with-lease,不要用--force。 --force-with-lease还是覆盖了同事刚推的提交。 原因:推之前跑过git fetch,本地的origin/<分支>已更新,它以为你看过同事的提交。处理:git config --global push.useForceIfIncludes true,这时会被拒绝并提示先git pull。- 同事拉取后满屏冲突,同样的提交出现了两遍。 原因:已推送的公共分支被变基,他手上的旧提交和你的新提交哈希不同。处理:公共分支只用
git revert撤销;只对自己的功能分支变基。 git rebase -i打开编辑器,不知道怎么改。 原因:这是待办列表,每行开头的词决定这个提交怎么处理。处理:只改开头的词(含义见 8.3),保存并关闭编辑器才生效;全删光或git rebase --abort都是放弃。- 同一个冲突每次变基或合并都要重新解决。 原因:Git 默认不记冲突的解决方式。处理:
git config --global rerere.enabled true,之后会提示Resolved '<文件>' using previous resolution.,检查后git add。 cherry-pick过的分支再合并,git log里同一个改动出现两次,或在同一处冲突。 原因:挑拣生成的是内容相同、哈希不同的新提交。处理:同一组改动只选一种方式,要合并整个分支就别先挑拣。git reset --hard之后,还没提交的改动不见了。 原因:git reflog只记录提交过的内容。处理:git fsck --lost-found,git add过的文件内容会出现在.git/lost-found/other/里;没 add 的找不回。- 克隆下来的项目,子模块目录是空的。 原因:普通
git clone不下载子模块的内容。处理:git submodule update --init --recursive,以后克隆加--recurse-submodules。 - 删掉大文件并提交后,仓库还是那么大。 原因:大文件还在历史提交里,每次克隆都会下载。处理:用
git filter-repo --invert-paths --path <文件>从历史里删除,再强推,见 8.4 的撤销表。
8.7 深入阅读
Section titled “8.7 深入阅读”- git rebase 参考:交互式变基的全部指令、–autosquash、–update-refs 和冲突处理。
- git reflog 参考:HEAD@{n} 的写法、查看和清理引用日志、条目保留多久。
- git bisect 参考:二分查找的完整流程,bisect run 的退出码约定和 skip 的用法。
- git worktree 参考:多工作区的增删、加锁、清理失效目录。
- git filter-repo:安装方法和从历史中删除文件、替换密钥文本的示例。
- GitHub:关于 Git 变基:从 GitHub 角度讲交互式变基的各个指令,有中文。