跳转到内容

第 8 章 · Git 进阶与救急

一句话:整理提交、在分支间复制提交、找出引入 bug 的提交、同时开两份工作目录,以及做错之后怎么撤销。 用它还是用别的:自己分支上还没推送的提交随便整理;已经推送到公共分支的提交只用 git revert 撤,不变基、不 reset。 核对版本:git 2.45,2026-10

安装方法同第 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/ppa
sudo apt update
sudo apt install git

从历史里彻底删除文件要用 git filter-repo,它不随 Git 安装,是一个 Python 程序。需要时再装:

终端窗口
pipx install git-filter-repo

第 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 只能从引用日志里找到。

合并保留分叉并多出合并提交 M;变基把 C、D 重放到 B 之后成为一条直线,C′、D′ 是新哈希

<提交> 可以写哈希前七位、分支名,或 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);只在新克隆的仓库里运行

先找到自己的情况,照做就行。“会丢东西吗”一列说的是做完这条命令后还能不能找回。

情况 命令 会丢东西吗
改了文件,还没 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 五个提交,还没合并,要整理成“功能主体”和“测试”两个:

  1. 看分支上有哪几个提交:git log --oneline main..HEAD。

  2. 打开待办列表:git rebase -i HEAD~5。

  3. 按下面这样改每行开头的词,最早的提交在最上面:第 1 行改成 reword 好重写说明,第 2、3 行改成 fixup 并入第 1 行,第 5 行改成 squash 并入第 4 行。

    reword f1bbc44 wip 1
    fixup adf5eef wip 2
    fixup 0c32cab wip 3
    pick 1c48357 wip 4
    squash 79f363a wip 5
  4. 保存并关闭编辑器。Git 先为 reword 打开一次编辑器,写第一个提交的说明;再为 squash 打开一次,写第二个提交的说明。

  5. 确认只剩两个提交:git log --oneline main..HEAD。

  6. 确认代码和整理前一样:git diff ORIG_HEAD,没有输出就对了。

  7. 推送:git push --force-with-lease。

用二分法找出哪个提交引入了 bug

Section titled “用二分法找出哪个提交引入了 bug”

已知 v1.0 没这个 bug,现在有。中间有几百个提交也只要测十来次:

  1. 开始:git bisect start。
  2. 标记当前有 bug:git bisect bad。
  3. 标记已知好的版本:git bisect good v1.0。Git 切到中间的一个提交。
  4. 测试,没问题就 git bisect good,有问题就 git bisect bad。重复这一步,直到输出 <哈希> is the first bad commit。
  5. 能用一条命令判断好坏时,第 4 步改成自动跑:git bisect run npm test。
  6. 记下哈希,看那次改了什么:git show <哈希>。
  7. 结束,回到原来的分支:git bisect reset。

正在功能分支上改到一半,线上出了 bug。不用收起改动,另开一个目录修:

  1. 取回远程最新提交:git fetch origin。
  2. 在旁边的目录开一个修复分支:git worktree add -b hotfix/<名> ../hotfix origin/main。
  3. 进入新目录:cd ../hotfix。
  4. 修好后提交:git commit -am "<说明>"。
  5. 推送并开 PR(详见第 9 章):git push -u origin hotfix/<名>。
  6. 回到原来的项目目录:cd ../<项目>,功能分支的改动原样还在。
  7. 删掉修复用的目录:git worktree remove ../hotfix。

第 2 步新建 hotfix/<名> 分支,是为了推送后开 PR;同一个分支也不能同时在两个工作区检出,所以不直接检出 main。

配置文件的位置同第 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 # 强推前确认远程的新提交你已经看过
  • 变基后 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 的撤销表。
  • git rebase 参考:交互式变基的全部指令、–autosquash、–update-refs 和冲突处理。
  • git reflog 参考:HEAD@{n} 的写法、查看和清理引用日志、条目保留多久。
  • git bisect 参考:二分查找的完整流程,bisect run 的退出码约定和 skip 的用法。
  • git worktree 参考:多工作区的增删、加锁、清理失效目录。
  • git filter-repo:安装方法和从历史中删除文件、替换密钥文本的示例。
  • GitHub:关于 Git 变基:从 GitHub 角度讲交互式变基的各个指令,有中文。