
1. 分支分叉后的一地鸡毛一次误操作把协作分支弄丢的完整抢救过程先说结论Git的reflog是比log更底层的操作痕迹记录只要你在本地执行过任何移动HEAD的操作它都会留底。遇到分支被删除、被reset、被rebase改写先别急着找运维大多数情况下reflog都能救回来。但前提是你得知道它到底记录了什么、怎么解读那堆哈希值。1.1 事故现场还原一个reset命令引发的连锁反应有个同事A当时在某项目上要回退一个已经合并到本地feature/login分支的错误提交。他执行的是git reset --hard HEAD~2这条命令本身没有错问题出在他没注意到feature/login是从远程origin/feature/login拉下来的而且本地已经领先远程3个提交里面有两个人合作开发的成果。他这一reset直接把本地分支回退了两个提交等于把别人刚推上来的公共提交也一起抹掉了。紧接着他做了第二件蠢事——发现回退过头之后又执行了git push --force把本地回退后的状态硬推到了远程。远程分支瞬间被改写了另一位同事B拉取时直接报fatal: refusing to merge unrelated histories整个团队协作直接停摆。1.2 根因定位reset --hard到底动了什么很多人把git reset --hard理解成删除提交这是最大的误解。它本质上是移动当前分支的HEAD指针同时把暂存区和工作区都重置到目标提交的状态。被跳过的那些提交并不会立即消失它们只是变成了没有任何分支引用的悬空提交。Git的机制决定了不会立刻把悬空提交当作垃圾清掉默认情况下reflog会保留30天内的所有HEAD移动记录。这就是为什么git reset --hard之后还能通过git reflog找回丢失的提交因为reset本身也是一次HEAD移动会被记录下来。当时排查的第一步就是让A先恢复本地分支git reflog输出内容长这样ab12cd4 HEAD{0}: reset: moving to HEAD~2 cd34ef5 HEAD{1}: commit: 修复登录态的token刷新逻辑 ef56ab7 HEAD{2}: commit: 对接用户中心的注册接口 gh78ij9 HEAD{3}: commit: 调整布局细节每行最后的描述是我们的操作和状态。要找回到错之前的状态直接看HEAD{1}或者HEAD{3}搞清哪些是要恢复的提交。A当时领先远程的三个提交分别是cd34ef5、ef56ab7、gh78ij9。恢复操作的逻辑是找到那个还包含三个提交的状态然后让分支重新指向它git reset --hard HEAD{3}这一步做完本地分支就回到reset之前的状态了三个提交全部回来了。1.3 远程分支已经被强推过怎么干净地收场本地恢复并不意味着结束。因为远程分支已经被A的push --force改写过了B那边的历史也已经和远程不一致。如果只是简单让A再次push --forceB那头一样是冲突而且很容易把别人的新提交再次搞乱。真正稳妥的做法是分两步走第一步确认远程分支当前状态和期望状态之间的差异。让A执行git fetch origin git log --oneline --graph --all --decorate把远程分支的当前指向和本地恢复后的指向对比清楚。这时候原作者的三个提交在本地是存在的远程上可能还能通过git fsck --lost-found找回但既然本地已经有完整副本就直接用本地为准。第二步和B沟通好让B把本地分支也同步到恢复后的状态。B那边如果只有A误推的记录可以这样处理git fetch origin git reset --hard origin/feature/login但要注意如果B本地也有未推送的提交必须先备份分支比如git branch feature/login-backup-20240918然后再reset避免把自己的工作成果也搭进去。整件事的教训就一句话在共享分支上永远不要用git push --force至少要用--force-with-lease。两者的区别在于--force-with-lease会检查远程分支的当前值是否和你本地记录的远程引用一致如果远程在你fetch之后又有人推送了新提交它会直接拒绝推送从根上避免覆盖别人的工作。1.4 这类事故的预防清单经历过这次事故之后我在团队里定了几条规矩现在分享给大家共享分支回退之前先确认有没有人也在用这个分支。最简单的方式是看一眼远程分支的上次提交时间如果最近几个小时内有人动过一律不硬回退改用git revert追加反向提交虽然历史会多一条记录但不会破坏协作。git push和git push --force之间用alias隔离。我在配置文件里把强推单独写成一个命令pf每次使用前会跳出提示要求输入分支名逼自己多看一眼。团队协作中打标签比记hash实在。每次发布前在分发节点打个tag回退时优先找tag而不是数HEAD~的层级能省掉很多数错层数的低级失误。git reflog只会记录本地操作不会同步到远程。所以如果你在另一台机器上找分支历史是找不到的。换机器干活之前先把本地状态同步好。2. 误提交大文件之后的仓库膨胀清理历史比你想象中麻烦得多第二个让我印象深刻的Git问题是给某项目做版本库体检时发现的。某个仓库的.git目录达到了2.3GB而整个工作区文件才200多MB。八成是有人往仓库里提交过大文件而Git的机制决定了这个文件一旦进了历史常规删除根本没意义它会在.git目录里持续占据体积每一次clone都会让你体会什么叫”等半天“。2.1 先判断体积究竟被什么吃掉了排查仓库体积第一步是用git count-objects -vH看看当前松散对象和打包对象的大小但更直观的其实是看那些大文件藏在哪些历史提交里。我用的是git rev-list --objects --all | git cat-file --batch-check%(objecttype) %(objectname) %(objectsize) %(rest) | awk /^blob/ {print $3, $4} | sort -rn | head -20这条命令把仓库里所有历史提交涉及的blob对象按大小排序直接拉出前20个最大的文件。当时跑完第一名是一个500MB的MySQL备份压缩包第二名是个300MB的日志文件都是当年“临时提交一下”留下的。用git log --all --oneline -- path/to/file能定位到具体哪个提交引入了这个大文件这里不过多展开知道方向就行。2.2 为什么普通删除对历史提交无用很多人遇到这种情况第一反应是git rm --cached把文件从暂存区删掉再提交。这确实能去掉文件在当前快照中的引用但注意Git的存储模型——每个提交都保存完整快照只要某个历史提交包含这个blob对象它就一直存在。也就是说除非你重写所有包含该文件的历史提交否则它依然会在.git里躺着。刷屏的git filter-branch就是因为操作复杂度高、速度慢、脚本难写而渐渐被淘汰现在推荐用git filter-repo一个Python写的官方推荐替代品。2.3 用git filter-repo做历史干净的实操先安装pip install git-filter-repo注意它不会随git自带必须单独装。然后进入仓库先备份git clone --bare /path/to/original-repo repo-backup.git备份完再执行清理。比如删除所有历史中命名为data.sql.gz的文件git filter-repo --invert-paths --path data.sql.gz如果确定要按路径目录清理比如把整个build/目录从所有历史里移除git filter-repo --path build/ --invert-paths这个命令会重写所有受影响的历史提交并且自动清理掉已经变成垃圾的引用、tag、remote配置。做完之后最好再跑一次git reflog expire --expirenow --all git gc --prunenow --aggressive把悬空的旧对象彻底回收仓库体积才真正降下来。2.4 清理之后团队同步的坑git filter-repo是真正的历史重写操作整个仓库的commit哈希全会变。如果你有多个协作者必须同时通知所有人让他们用新的远程地址重新clone或者至少用git fetch --all git reset --hard origin/main的方式整体切换。那次清理之后仓库从1.8GB降回180MBclone从三分钟变成七八秒团队所有人都明显感觉到变化。但过程中的教训是历史重写最大的成本不是命令本身而是团队的协调成本。所以我后来都会在团队里配一个.gitignore白名单机制同时在CI层面加一道防线用脚本检查提交中是否包含超过50MB的文件超过就拒绝合并请求。如果你不想安装额外的工具也可以用git自带的git filter-branch处理但说实话不好用跑起来非常慢而且有各种目录权限的坑建议能上filter-repo就直接上。3. rebase冲突处理不是解完就完三个最常见的半途而废现场rebase是Git里我觉得最容易被”浅尝辄止“的功能。不少教程告诉你它能把提交历史整理成一条直线但很少有人仔细讲清楚rebase冲突和三方合并冲突的区别。我第一次正经用rebase给某功能分支同步主干时就栽在冲突处理上整整折腾了一个下午后来总结下来其实是三个最常见的坑接力。3.1 把rebase冲突当成merge冲突来解rebase的机制是把你分支上的每次提交逐个”摘下来“再按顺序依次应用到目标分支之上。每应用一个提交都相当于做一次三方合并意味着可能出现多次冲突。而merge冲突只有一个最终合并点只需要处理一次。第一次用rebase时我在第一个提交上解完冲突就执行了git add然后git rebase --continue结果马上在第二个提交上又遇到相同文件的冲突。当时一脸懵以为前面没解干净。后来才理解rebase每次应用一个提交都在新基线上做一次合并后面的提交很可能因为前面提交改变了上下文而再次冲突这是完全正常的。正确处理方式是逐个提交来每个冲突都解完后git add对应文件然后git rebase --continue直到提示Successfully rebased and updated。永远不要在rebase中途用git commit直接提交这会打乱rebase过程导致整个变基状态错乱。3.2 解完冲突直接git add没注意rebase正在修改提交作者某次给转过来的分支做rebase解完冲突之后推进到一半发现提交信息全都变成了我的名字。当前协作者的提交全部被标成我提交的当时还以为是Git在rebase过程中统一改了作者信息。实际原因是git没有默认保留原作者。正确做法是rebase时加上--committer-date-is-author-date参数同时让Git尊重原始作者git rebase --committer-date-is-author-date origin/main但这只是保留提交者日期作者的姓名和邮箱在rebase时默认是会被保留的因为Git会把每个提交的Author字段带上。之所以出现全部变成我的名字大概率是那边把user.name和user.email配成了全局配置rebase过程中应用提交时覆盖了作者信息。如果已经发生了修正方式是用git rebase --exec批量调整或者用git filter-branch --env-filter改回原作者这里的建议是rebase之前先看一眼git config user.name和user.email确保当前仓库使用的是项目级配置而不是全局的。3.3 rebase进行到一半想退出随便执行reset把分支搞乱还有一种典型翻车现场是rebase到一半发现冲突太多想放弃直接跑git reset --hard。结果rebase过程还没完全终止分支被硬重置到一个不明状态。正确做法是git rebase --abort。这个命令会干净地把分支恢复到rebase开始之前的状态。如果已经误执行了reset还是可以通过git reflog找回之前的分支位置但过程比较绕。说直白点rebase进行中永远是三件事--continue继续、--skip跳过当前提交、--abort全部放弃。某次有个同学卡在rebase中间请教我怎么退出去。我看他的分支状态发现当前HEAD处于一个悬空位置正是执行了一半的临时rebase分支。让他跑git rebase --abort瞬间回到原点省了一堆力气。把这三个命令记熟比任何花哨的Git技巧都管用。3.4 rebase之前先看一眼分支有没有人共用共享分支上不要轻易rebase。如果你把一个已经被推到远程的提交重新变基整个提交哈希都会变凡是基于旧分支拉过代码的同事全部会撞上冲突。这种情况下直接用merge或者git pull --rebase反而更安全。判断标准很简单这个分支是不是只有你自己在用如果是随便rebase如果已经push过且其他人拉取过就别乱动。如果你必须rebase先和所有人沟通好让他们把本地改动提交或stash然后重新从远程拉取新的分支状态。我在实际项目中通常只对两类分支做rebase一是本地还没推送过的特性分支二是经过团队确认、可以改写历史的临时分支。其他情况下尽量用merge来集成少给自己和同事挖坑。解rebase冲突的时候遇到同一个文件反复冲突我的经验是直接看一眼这个文件在目标分支上到底改成了什么样。很多时候你把目标分支的代码拉出来看就明白为什么两个提交都会在这个文件上打架了。4. 工作区文件消失不等于世界末日恢复也有硬边界有一种情况比分支丢失更让人恐慌——工作区里改了半天的文件因为一次误操作消失了。Git里有个流传的段子只要commit过的东西都能找回。但这句话哄骗了很多人真相是commit过的是能找回没commit过的就只能看命。4.1 一次在编辑器里误删未保存文件的经历有次给某项目调样式某个文件里改了快上百行还没保存结果在IDE里手滑点到了Revert整个文件瞬间回到上次保存的状态。当时下意识地打开终端想用Git找回然后才发现那些改动根本没有被Git跟踪过工作区和暂存区都没记录。这种场景其实很常见我们习惯把Git当成万能后悔药但Git归根结底只跟踪它见过的内容它没有见过的东西自然找不到。4.2 已经被Git跟踪的文件删了能救吗如果你改过文件、也保存过就算没有commit只要这个文件最近一次的状态还在Git对象库里就有很大概率救回来git checkout -- path/to/file这条命令从当前HEAD提取文件到工作区相当于用最近的commit状态覆盖工作区。如果你误删的文件在暂存区或者HEAD里存在这一招就能恢复。更复杂的情况是删除之后又接着编辑了别的文件这时候可能你想找的是删除前的暂存状态可以尝试git fsck --lost-found它会列出所有悬空对象其中包括你删除前暂存过的文件内容。然后去.git/lost-found/other或.git/lost-found/commit里翻找运气好能翻出来。4.3 那些连Git也无能为力的场景未跟踪文件未跟踪文件分两种完全新建还没git add过的文件以及已经git add但还没commit的文件。第一种是真无解Git对象库里压根不存在它的任何快照除了文本编辑器自带的本地历史功能谁也救不回来。第二种还有救因为git add会把文件内容写入对象库git fsck --lost-found可以在lost-found里找到对应的blob对象。所以我后来给自己定了一条规矩凡是超过十分钟工作量的大改动先养成分步保存的习惯。文件一保存就不怕丢配合Git的stash机制至少能让工作区随时回到一个可恢复的状态。4.4 利用stash做工作区快照如果你临时要处理别的事又担心当前工作区改动丢失最合适的做法是git stash push -m 登录页大改未完成这会把工作区所有已跟踪文件的变化暂存起来工作区恢复干净。回头想继续改的时候git stash pop就全部恢复回来了。stash本质上也是一个commit对象通过git fsck --lost-found也能找到它。我自己经常用stash当快照用尤其是在改代码改到一半又不想马上提交的时候。但是注意一点git stash默认不会打包未跟踪的新文件如果工作区里有你的新建文件需要加-u参数git stash push -u -m 包含新文件的快照这样它会把未跟踪文件一起纳入stash。这是很关键的一个细节很多人不知道导致临时切换分支时不带新文件过去回来后发现不见了其实是被留在了原来的工作区里。4.5 我的兜底方案定期在编辑器里开本地历史在用Git之前被工作区丢失坑过一次之后我在自己常用的编辑器比如VS Code、IntelliJ里都会开本地历史Local History功能。它的优点是即使文件还没保存过编辑器在后台也会定期记录内容快照。有一次格式化工具把整个文件格式搞乱了我靠编辑器本地历史直接还原到格式化前的内容Git都做不到这种事。所以如果你是个经常在文件里写写改改的人我建议不要把全部赌注押在Git上编辑器的本地历史是你的第二道防线。5. detached HEAD不是灵异事件弄懂HEAD的指向逻辑就全通了Git里最让人摸不着头脑的问题之一是突然发现自己处于所谓的detached HEAD状态。这一般发生在一个开发同学直接在某个commit哈希上做了代码改动一提交发现当前分支显示HEAD detached at ab12cd4感觉像是代码掉进了异次元。5.1 脱落的HEAD到底是什么情况Git的HEAD是一个特殊的指针通常指向分支名。比如refs/heads/main然后分支再指向某个具体提交。当你checkout一个commit哈希而不是一个分支名时HEAD就直接指向这个提交不经过任何命名分支这就是detached HEAD。关键在于此时你在该状态下做的commit不会属于任何现有分支。如果你在这个状态下继续工作并提交然后重新checkout到别的分支这些提交就变成悬空的只在reflog里留个影子。这并不是Git出了bug而是设计如此。它允许你临时查看某个历史版本但在这个版本上继续开发不是它的本意。5.2 从detached HEAD恢复的三种场景场景一你只是查看了某个历史提交没做任何改动。那好办git checkout main直接回到分支detached状态解除。场景二你在detached HEAD状态下做了提交现在想把这份工作保存下来。最稳妥的是基于detached HEAD位置新建一个分支git switch -c temp-work或者git checkout -b temp-work这个新分支会指向当前所在提交你刚提交的几个commit就全部被接住了。之后再把这个分支合并到你想去的目标分支。场景三你在detached HEAD状态下改的东西还没提交想带回原分支。这时候用stash是最干净的办法git stash push git checkout main git stash pop这样工作区里的改动就跟着你回到主干了。5.3 为什么git switch比git checkout更适合日常分支操作Git 2.23之后官方推荐用git switch和git restore来替代部分checkout场景。git switch专门管分支切换语义清晰比如git switch -c new-branch就是新建并切换分支。而git checkout既要管分支又想管文件当你想切分支但输入了一个文件名它可能会直接帮你还原文件所以说实践过程中分清楚这两个命令能省不少事。5.4 detached HEAD状态下的一个实用技巧考古虽然detached HEAD有坑但也有一个明显的用处——看历史代码很方便。你想看看某个版本号的源代码长什么样直接git checkout v1.2.3这就是一个detached HEAD浏览整个项目的旧代码完全没问题。只要记住别在这个状态下做新的开发提交或者做完开发提交就立刻拉分支就不会有负担。我当初在某个老项目中排查问题需要对比两个历史版本的行为差异就是靠反复checkout不同的tag来对照配合git log -p看关键提交的diff最后定位到一个由依赖升级导致的回归。在这个场景下detached HEAD不但不是问题反而是最强工具之一。6. 几个高频小问题整理日常使用中反复踩到的零碎坑除了上述几个大问题之外日常使用中还有一些零碎问题被问到的频率非常高这里一起整理出来当作补漏清单。6.1 git pull报fatal: Need to specify how to reconcile divergent branches这个报错通常是因为本地分支和远程分支出现了分叉Git不知道你想用merge还是rebase去整合。解决办法是配置本仓库的分支拉取行为git config pull.rebase false这样git pull默认执行merge如果改成git config pull.rebase truegit pull默认执行rebase。团队协作建议统一成rebase模式这样集成历史更干净。我曾经在面对这个报错时干脆让团队每个人都用git pull --rebase后来大家习惯后历史线确实顺眼很多。6.2 误添加.DS_Store或target目录到版本管理macOS或Java项目里推荐把.DS_Store、target/等路径加进.gitignore。但如果你已经把它们提交过.gitignore是不会自动帮你移除的必须手动从Git索引中移除git rm -r --cached .DS_Store git rm -r --cached target/ git commit -m chore: remove accidental files from tracking--cached参数很关键它只从Git索引中移除文件不会动你磁盘上的实际文件。之后这些文件就会变成未跟踪状态由.gitignore接管。6.3 提交信息写错想改最后一次提交信息写错了用git commit --amend -m 新的提交信息这会修改当前HEAD提交的信息。注意如果这个提交已经推到远程同样会引发历史重写使用前确认是否只有自己在用这个分支。6.4 突然想找回一个已删除的分支如果你记得删除之前分支指向的提交哈希直接重新创建就行git branch recover-branch commit-hash如果不记得哈希照样可以从git reflog里翻找回删除分支之前的分支名称对应的操作记录找到那个提交哈希。我把这部分内容整理成一张小表方便日常对照问题推荐处理方式频率本地分支被错误resetgit refloggit reset --hard 旧hash高误提交大文件导致仓库膨胀git filter-repo重写历史 全团队重新clone中rebase冲突处理逐个提交解冲突--abort安全退出高工作区文件丢失分情况已跟踪用checkout已add用fsck未跟踪无解中detached HEAD用git switch -c 分支名保留提交低git pull分叉报错配置pull.rebase或pull.merge中6.5 给git配置一个顺手的alias集最后分享一组成品级的Git alias配置我在多台开发机上都是这样配的用起来非常顺手git config --global alias.co checkout git config --global alias.br branch git config --global alias.ci commit git config --global alias.st status git config --global alias.lg log --oneline --graph --all --decorate git config --global alias.unstage reset HEAD -- git config --global alias.last log -1 HEAD --stat git config --global alias.pf push --force-with-lease其中pf这个alias几乎是我每天都会碰到的。它把危险的强推命令锁在了--force-with-lease模式下远程一旦有新提交传入就不会成功执行。给所有团队成员都配上这一条之后因为强推引发的协作事故基本绝迹了。至于git lg就是一行一个提交的图形化日志配合--all参数能一眼看全所有分支的关系排查问题时比默认的git log舒服得多。使用--force-with-lease不等于高枕无忧如果你在本地进行了rebase而远程分支恰好又没变它依然会允许你推送。所以强推之前最好还是用git fetch先确认远程状态至少做到知情。关于Git我最大的体会是它提供很多工具但真正决定事故大小的不是命令而是操作之前的判断。分支历史可以重写丢失的提交大概率可以找回但与其事后花时间翻reflog不如每次操作前多花十秒确认当前分支做到平时谨慎、熟练使用“后悔药”。