1. 先把 Git 的底层模型吃透,别急着背命令
我见过太多人用 Git 用了三四年,一出事就抓瞎——reset --hard敲下去之前根本没想清楚会丢什么,rebase冲突一冒出来就慌着abort。问题不在命令记不住,在于脑子里没有一张"Git 内部到底怎么运转"的图。这篇 Git 进阶指南不打算罗列命令大全,而是想先把模型讲透,再谈历史和救援。适合两类人:一类是日常add、commit、push已经很熟练,但碰到分支重写、误删代码就束手无策的;另一类是团队里被推出来"管 Git"、需要定规范和处理事故的。核心就三件事:Git 存了什么、历史怎么整理、出事怎么捞回来。
1.1 Git 存的是快照,不是差异
多数人对版本控制的第一直觉是"记录每次改了哪几行",这个直觉放在 Git 上是错的。Git 每次提交保存的是整棵目录树在那一刻的完整快照,只是它足够聪明,内容没变的文件会复用上一版的对象,所以仓库不会因为提交次数多而爆炸。你进.git/objects看一眼,那些以两位十六进制命名的目录里躺着四类对象:blob 存文件内容,tree 存目录结构和"文件名到 blob"的映射,commit 存一个 tree 指针外加父提交、作者、时间戳和说明,tag 是带注解的引用对象。
这套设计的直接好处,就是你能解释清楚很多"玄学"现象。切分支之所以快,是因为它只是把工作区换成另一个 tree,不需要逐个文件算差异;同一个文件内容在十个分支里只存一份,因为对象是按内容哈希寻址的,内容一样哈希就一样;重命名能追踪到历史,是因为 diff 时默认开了相似度检测,按内容比对而不是看文件名。反过来说,如果你改了一个文件 90% 的内容,Git 就会认为是"删除旧文件 + 新增新文件",重命名检测失效,这也是有些人抱怨git log --follow追不到历史的原因。
提示:想直观感受对象结构,可以在任意仓库跑
git cat-file -p HEAD,再顺着 tree 的哈希一层层cat-file -p下去。花十分钟看一遍,比你读十篇教程都管用。
1.2 分支只是一个几十字节的指针文件
"分支"这个词害了不少人。在 Git 里,分支不是一份代码拷贝,而是.git/refs/heads/下面的一个文本文件,内容就是一个 commit 的哈希值加一个换行。四十个十六进制字符加换行,四十一字节——一个分支的全部成本就这么点。所以你建一百个分支也不会让仓库变大,删一个分支也不会删掉任何提交,只是把这个指针文件拿掉了。
明白这个之后,几个操作的性质就变了。git branch -d删的只是指针,提交对象还在,只要 reflog 里还有记录就能找回来;git branch -D强制删也一样,区别只在于跳过了"是否已合并"的检查。而"分支落后了"这种说法也不准确,指针本身没有落后一说,它指向哪就是哪,是你看的那个 commit 比别人旧而已。合并的本质,是让当前分支的指针移动到某个新的 commit 上,那个新 commit 有两个父。
存储分支指针的文件里,哈希值指向的对象就是"分支的末端"。这也解释了一个常见困惑:为什么git push origin :branch(推一个空引用)能删远程分支?因为它就是在告诉远端"把这个引用删掉",跟本地删分支是同一件事。
1.3 HEAD、索引、工作区,三者关系理不清就永远会踩坑
Git 的工作状态可以粗分成三层:工作区是你眼睛看到的文件,索引(也叫暂存区,落在.git/index这个二进制文件里)是"下次提交要包含什么"的清单,HEAD 指向当前所在的分支或提交。日常命令基本都在这三层之间搬运数据,搞清楚谁动谁不动,reset那三种模式就再也不会记混。
git add是把工作区的改动写进索引;git commit是把索引里的内容做成一个 tree 并生成 commit,然后让 HEAD 指向的新分支往前挪一格;git checkout或git restore是把索引或某个 commit 的内容写回工作区。所以你会遇到"改了但没 add,切分支时改动被带过去了"这种情况——因为那些改动只在工作区,没进索引,Git 切分支时不动工作区里没被跟踪的变更。
HEAD 有两种状态。正常状态下.git/HEAD的内容是ref: refs/heads/main,这是一个"符号引用",指向分支;另一种是 HEAD 里直接写着哈希值,叫分离头指针(detached HEAD),常见于checkout某个 commit 或 tag、rebase过程中的中间态。分离状态下提交的 commit 不属于任何分支,一旦切走就只剩 reflog 能找回来——这不是危险,但你得知道自己在干什么。
1.4 reflog:你唯一敢乱来的底气
git reflog记录的是 HEAD 和各个分支引用的移动历史,默认保留 90 天,HEAD单独保留 30 天(gc.reflogExpire和gc.reflogExpireUnreachable控制)。它跟git log是两回事:log 顺着 commit 的父指针走,reflog 记录的是"引用被移动过"这件事,所以像reset、checkout、rebase、commit --amend这些会让引用跳动的操作,都会在 reflog 里留一行。
这一行行的记录意味着什么?意味着你reset --hard把三个提交干掉了,只要没到清理周期,git reflog就能看到HEAD@{3}那个旧位置,git reset --hard HEAD@{3}就回来了。意味着你rebase到一半发现搞砸了,git reflog里能找到 rebase 开始前的那个点,直接 reset 回去。我个人的习惯是,任何一次"重写历史"的操作之前,先扫一眼git reflog -5,确认自己知道当前停在哪个位置。
真正会丢东西的场景只有两个:一是 reflog 过期(默认三十天到九十天,且期间跑过gc清理),二是你手动执行了git reflog expire --expire=now --all && git gc --prune=now。所以新手阶段,我建议先别碰gc --prune=now这种命令,等你能说清楚它在删什么再说。
2. 历史整理:把一团乱麻的提交捋成一条线
真实的开发过程里,提交历史很少是干净的。改 bug 时顺手提交了三次"临时保存",格式化工具跑一遍又多出两个提交,评审意见回来后又是一堆 fix。这些提交如果原样推上去,后来人看 log 会很难受,做二分排查(bisect)时更难受。整理历史不是洁癖,是给未来的自己和同事省时间。这一节讲的几个手段,核心思路都是"先在本地理顺,再推出去",一旦推到了公共分支,再整理就要付出协调成本。
2.1 merge 还是 rebase,先想清楚你要什么样的历史
这是被问得最多的问题,也是最没有标准答案的问题。merge保留真实的分叉结构,生成一个有两父的合并提交,历史是"如实记录";rebase把你的提交一个个摘下来,重新接到目标分支的末端,历史是"看起来像线性开发"。两者没有对错,只有适用场景。
我的判断标准很朴素:这个分支是不是只有我一个人在用。如果是,rebase 更舒服,git pull --rebase拉一下,冲突在本地解完,历史干净,不会出现那种"merge 了又 merge"的蜘蛛网。如果是多人共享的分支,比如长期维护的发布线,那就用 merge,因为 rebase 会重写提交哈希,别人基于旧提交做的工作全部要重新对齐,代价远大于历史好看的收益。
还有一个折中方案值得一试:功能分支上用 rebase 同步主干,合回主干时用--no-ff强制生成合并提交。这样既保证了功能分支内部提交的整洁,又在主干上留下了一个清晰的"这个功能从哪到哪"的边界。用git log --first-parent --oneline看主干,每个功能就是一行,非常好读。
注意:已经推送到公共分支的提交不要 rebase。你 rebase 之后必须 force push,别人本地还指着旧提交,一拉就是一堆冲突。如果非推不可,用
git push --force-with-lease,它在远端引用被别人更新过时会拒绝推送,比--force安全得多。
2.2 交互式 rebase 的四种动作,够用九成场景
git rebase -i <base>打开的那个编辑器里,每一行是一个提交,前面有个动作词。常用的就四个:pick保留、reword改提交说明、squash合并到上一个提交并编辑说明、fixup合并到上一个提交但丢弃本次说明。剩下的edit(停下来改内容)、drop(删掉)、exec(执行命令)在特定场景有用,但日常用得少。
一次典型的整理流程是这样:git log --oneline main..HEAD先看清楚自己这条分支上有几个提交,然后git rebase -i main,把"临时保存"那几条改成fixup,把想改说明的那条改成reword,保存退出,Git 就会按顺序重放。重放过程中如果冲突,停下来解决,git add之后git rebase --continue;如果发现某一步搞错了,git rebase --abort回到起点,什么都不丢。
这里有个容易忽略的点:squash和fixup是把当前提交合到列表里的上一行,所以列表的顺序很重要,被合并的提交必须放在目标提交的下面。还有,rebase 的 base 参数是"不包含"的,git rebase -i main意味着 main 上的提交不动,只重放你在 main 之后的那几个——这一点跟git log main..HEAD的范围是一致的,记不住就先用 log 确认范围。
2.3 fixup 加 autosquash:让评审意见自动归位
代码评审提了三条意见,你改完提交了三个 "fix review comments",这三条提交推到分支上,评审者看起来会很痛苦。正确的做法是:改完之后用git commit --fixup=<被修改的提交哈希>提交,Git 会自动把提交说明写成fixup! 原提交说明,然后执行git rebase -i --autosquash main,这些 fixup 提交会自动排到对应提交的后面并标记为fixup,你连手动拖动顺序都不需要。
如果只是想快速应用,不想打开编辑器,可以加一个环境变量:GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash main。这里的冒号是个空命令,表示"编辑序列文件时不打开任何编辑器",等于接受了 autosquash 自动排好的顺序。这个技巧在脚本里特别顺手,我把整条命令包进了一个 alias:
git config --global alias.fix '!GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash $(git merge-base HEAD main)'用的时候直接git fix,前提是你当前分支是从 main 拉出来的。要注意$(...)里如果用main硬编码,遇到主干叫master或者别的名字就会失败,稳妥一点可以先看git branch -r确认远端主干的真实名字。
2.4 cherry-pick 能救命,但它不是合并的替代品
git cherry-pick <sha>把某个提交的改动"复制"到当前分支,生成一个新的提交(哈希不同)。最典型的用途是热修复:你在开发分支上修了一个线上问题,想把这一处改动摘到发布分支上,而不带入开发分支的其他内容。这时cherry-pick是最干净的手段。
但它的坑也很明确。第一,它是复制不是引用,同一个改动在两条分支上各有一份提交,将来两条分支合并时,Git 的三个合并基础算法可能会把这些改动当成冲突,需要人工确认。第二,cherry-pick一个合并提交时必须加-m 1指定以哪个父为准,-m 1表示以第一父(通常是主分支侧)为基础,只看这个合并"带进来了什么"。第三,冲突解决后要用git cherry-pick --continue,别习惯性commit,否则状态会乱。
我踩过最典型的一次坑是连续cherry-pick一个区间:git cherry-pick A..B。这个语法的含义是"从 A 之后到 B(含 B)",不包含 A 本身,跟log A..B一致,但很容易记反成"包含 A"。如果摘错了,git cherry-pick --abort可以整体撤销这一串操作,前提是还没continue完。
3. 撤销与恢复:按"事情进行到哪一步"来选工具
Git 的撤销命令多到让人头晕:restore、checkout、reset、revert、stash、clean。它们的区别不在功能重叠,而在于"你要撤销的事情发生在哪一层"。把场景按阶段分清楚,选命令就是查表的事。我一般按四个阶段划分:改动还没进暂存区、已进暂存区但没提交、提交了但没推送、已经推送出去了。
3.1 还没提交:restore 管工作区,reset 管索引
新版 Git 把checkout的职责拆成了两部分,git restore管文件内容恢复,git switch管分支切换,语义清晰很多。想丢弃工作区里某个文件的改动,git restore <file>就够;如果改动已经add进去了,先git restore --staged <file>撤回暂存,再git restore <file>丢弃工作内容,两步走。
如果想一次性把索引退回某个提交的状态而不动工作区,git reset <commit>是最直接的,它的默认模式是--mixed。注意这个命令不会删你的工作区改动,只是把暂存区清空成指定提交的样子,改动会重新变成"未暂存"状态。这个行为很多人不清楚,以为reset就等于丢代码,其实只有加了--hard才会动工作区。
还有一个极端场景:你新建了一堆没被跟踪的文件,想全清掉。git clean -n先干跑一遍看看会删什么,确认无误再git clean -fd。-f是必须的(安全阀),-d表示连目录一起删,-x会把.gitignore里忽略的文件也删掉——这个参数慎用,我见过有人把本地配置文件全清了。
3.2 已提交但没推送:reset 三种模式的分工
git reset的三个模式,本质上就是"分支指针、索引、工作区"这三样,重置几样。--soft只移动分支指针,索引和工作区原封不动,效果是"这些提交没了,但改动全在暂存区",适合"提交早了想重新组织";--mixed(默认)移动指针并重置索引,工作区保留,适合"提交早了想重新挑文件提交";--hard三样全重置,工作区改动直接没,这是唯一真正会丢未提交代码的模式。
实际用的时候,--soft最适合"提交完发现漏了文件或者说明写错了":git reset --soft HEAD~1,然后补上文件重新commit。--hard最适合"这条分支彻底不要了,回到某个干净点":先git reflog确认目标位置,再git reset --hard <sha>。敲--hard之前,建议先git stash -u把未提交的东西存一份,哪怕大概率用不上,这是一种低成本保险。
HEAD~1和HEAD^都是"上一个提交",区别在于~后面跟数字表示往上数几代(HEAD~3),^后面跟数字表示选第几个父(合并提交才有多个父,HEAD^2是第二父)。日常撤销基本只用~,^更多出现在处理合并提交的场景里。
3.3 已经推送出去:用 revert,别用 reset
推到公共分支的提交,撤销要用git revert <sha>,它会生成一个新提交,内容是"把那个提交的改动反向应用一遍"。这样做的好处是历史只增不改,别人拉下来不会出现分叉。用reset再 force push 到公共分支,会让所有基于旧提交工作的人全部对不上,团队协作里这是大忌。
revert一个普通提交很简单,难的是revert合并提交。合并提交有两个父,Git 不知道你要回到哪一边,所以必须加-m:git revert -m 1 <merge-sha>表示以第一父为主干,撤销这个合并带进来的改动。第一父通常是你合并时所在的分支,第二父是被合进来的分支,用git show --format='%P' <sha>可以看到两个父的哈希,按顺序对应 1 和 2。
这里有个经典陷阱:revert一个合并提交之后,如果以后想把那条被撤销的分支重新合并进来,Git 会认为"这些改动已经合过了",什么都不做。解决办法是要么把当初那个 revert 提交也 revert 掉(相当于取消撤销),要么换一条新的分支重新提交。这个坑我在两个团队都见过,每次都要解释半天。
3.4 stash 不只是临时存一下那么简单
git stash的默认行为只存已跟踪文件的改动,未跟踪的新文件不会进去。这个默认值坑过无数人——存完切分支,回来stash pop,发现新建的文件还在工作区里杵着,跟别的改动混在一起。要连未跟踪文件一起存,用git stash -u;要连.gitignore忽略的文件也一起存,用git stash -a。
存多条 stash 时,git stash list会列出一串stash@{0}、stash@{1},编号越小越新。git stash pop弹出并删除,git stash apply只应用不删除,在不确定能不能干净应用的时候,先用apply更稳妥,成功了再drop。想只应用某一条,git stash apply stash@{2}。
真正需要记住的是:stash不是提交,它存在refs/stash下面,清理机制跟普通提交不一样。如果你pop的时候冲突了,又手贱git stash drop,这条记录就从引用上摘掉了。补救办法是git fsck --unreachable或者git fsck --lost-found,在输出里找unreachable commit,然后用git show <sha>一个个看哪个是你要的,找到之后git stash apply <sha>就能恢复。
3.5 reflog 救援实录:一次误删分支的完整过程
说个真实操作序列,你可以照着在测试仓库里演练一遍。先建个分支提交两次,然后切走,用git branch -D强删。此时git branch里已经看不到它了,但git reflog里还能搜到那个分支最后一次提交的记录——注意分支被删后,reflog 里对这个分支的条目会逐渐过期,但 HEAD 的 reflog 里通常还留着checkout的记录。
具体做法是:git reflog --all | grep <分支名或提交说明>,找到那条提交的短哈希;或者更粗暴一点,git fsck --lost-found把所有悬空提交列出来,进.git/lost-found/commit/挨个git show。找到目标哈希后,git branch <分支名> <哈希>,分支就回来了。整个过程五分钟以内,前提是你没跑过清理命令。
我在实际使用中的体会是,把git reflog当成"操作日志"而不是"救命工具"来用,心态会好很多。每次做完大动作扫一眼,知道自己在时间线的哪个位置,出事的时候就不会抓瞎。这比背一百条命令都实在。
4. 协作场景下的分支治理与冲突处理
单机用 Git 和团队用 Git 是两套东西。单机只要自己看得懂就行,团队要考虑的是:分支怎么命名、谁有权合、冲突怎么解、历史怎么保持可读。这部分没有银弹,但有一些经过验证的做法能显著减少沟通成本。下面这几条都是我待过的团队里实际跑通的,不需要额外工具,纯靠约定加几条配置。
4.1 分支命名和生命周期,约定比工具更重要
命名这件事看着无关紧要,实际上直接影响排查效率。我倾向的约定是"类型/简短描述-工单号",比如fix/login-timeout-1234、feat/export-csv-5678。类型用固定的几个前缀,好处是git branch -r --list 'origin/fix/*'能一把筛出所有修复分支,清理时不用一个个确认。工单号放末尾,是为了让分支能跟任务系统对上,一年后回头看这个分支在干什么,不用问人。
生命周期方面,功能分支从主干切出,合回去之后立刻删。本地删用git branch -d,远端删用git push origin --delete <分支名>。远端分支不清理的后果是git branch -r输出几百行,git fetch --prune也救不了——它只是清理本地记录的远端引用,远端那个分支还在。所以定期跑一遍"列出所有已合并到主干的远端分支然后批量删"是值得的。
# 列出已合并进 main 的远端分支(排除 main 本身) git branch -r --merged origin/main | grep -v 'origin/main$'这条命令输出的结果再逐个push --delete就行。跑之前一定先确认origin/main是最新的,本地记录的合并状态可能过时,先git fetch --prune一下。
4.2 冲突不可怕,可怕的是同一处冲突解八遍
合并冲突本身没什么技术含量,就是两边改了同一片区域,Git 不知道听谁的。真正让人崩溃的是:你解完一次冲突,rebase 到下一个提交,同样的冲突又来一遍;或者你 rebase 完之后合回主干,合并时同样的冲突再解一遍。解八遍冲突的时间里,你本可以写完那个功能。
git rerere就是干这个的。开启方式:
git config --global rerere.enabled true git config --global rerere.autoupdate true开启之后,Git 会把你每次手动解决冲突的结果记下来(存在.git/rr-cache),下次遇到完全相同的冲突片段时自动套用,并提示Resolved 'file' using previous resolution。autoupdate让它自动把解决结果加进索引,连git add都省了。这个功能默认关闭,很多人不知道,但它对长期分支和反复 rebase 的场景提升巨大。
注意:rerere 是基于"冲突片段的哈希"匹配的,如果上下文变了,它不会乱套用,但也意味着不保证每次都命中。另外,如果你某次解决得不对,它会把错误结果也记下来,需要
git rerere forget <file>手动清除那一条记录。
4.3 merge 的几个参数,值得提前定下来
--no-ff强制生成合并提交,即使快进合并也能做。好处是主干上每个功能都有一个明确的合并节点,git log --first-parent看得出来边界;坏处是提交图多了一层。团队里通常约定:功能分支合主干用--no-ff,主干同步到功能分支用 rebase 或者普通 merge,具体选哪个提前说好,别一人一个习惯。
-X ours和-X theirs是合并策略的参数,不是"全用我的/全用他的",而是"遇到冲突时偏向某一方"。这个区别很关键:非冲突的部分照常合并,只有冲突块按指定方向取值。它适合那种"配置文件两边都改了但我知道该保留哪边"的场景。不到万不得已别用,因为用完之后你无法从合并结果看出哪些冲突被自动吞掉了。
对于压缩提交的场景,merge --squash会把整个分支的改动压成一个未提交的变更放进工作区,需要你再commit一次。它不产生合并关系,所以这条分支不会被标记为"已合并",删分支时git branch -d会拒绝,得用-D。这是很多人踩过的坑,以为合并完了,其实 Git 不认。
4.4 保护主干和提交信息的底线
主干分支必须设保护,禁止直接 push,所有改动走合并请求。这条不是形式主义:有保护的分支才能跑自动化检查,才能强制要求评审,才能避免有人本地reset --hard之后误推。至于"谁能直接推",我的建议是零,包括管理员。确实遇到紧急情况,临时解除保护再恢复,留一条记录,比开一个永久口子好。
提交信息方面,别强制要求多复杂的格式,但至少要求"第一行说清楚这个提交做了什么",控制在五十个字符以内,正文空一行再写原因。这个要求的价值在半年后才体现:git log --oneline扫一眼就知道每个提交干了什么,而不是一屏的 "update"、"fix bug"、"修改"。如果有条件,加上提交信息格式校验的钩子,后面第 6 节会讲具体写法。
5. 大仓库的性能调优,别让 clone 等一上午
当仓库大到一定程度——几万个文件、十几年的历史、几十 GB 的对象——常规操作会开始变慢,clone要等半小时,status卡三秒,checkout之后磁盘直接红。这些问题有解,而且解法不需要改工作习惯。核心思路是:只下载你需要的部分,只检出你关心的目录,多余的历史和文件用的时候再取。
5.1 部分克隆:把不需要的历史挡在门外
git clone --filter=blob:none <url>表示"只克隆提交和目录树,不下载文件内容(blob)",等你checkout或者diff真正需要某个文件时再按需拉取。这个模式对"平时只读代码、偶尔看历史"的场景非常合适,初始下载量能降一个数量级。代价是首次检出会慢一点,因为要边检边拉,网络不好的时候体验一般。
更激进的--filter=tree:0连目录树都不预取,适合那种"只想看提交历史"的仓库,但日常开发用起来会很难受,每次切分支都要拉一大堆东西。还有一个--filter=blob:limit=1m表示"小于 1MB 的文件照常下载,大的按需拉",这个折中很适合包含大量图片、模型文件的仓库,能挡掉体积大头又不影响日常。
提示:部分克隆需要服务端支持,主流托管平台基本都行。如果 clone 时报
filtering not recognized by server,说明服务端关掉了这个能力,那就只能退回普通 clone 加浅克隆(--depth)组合。
5.2 sparse-checkout:只要我关心的那几个目录
部分克隆解决的是"下载量",sparse-checkout 解决的是"检出量"。一个单仓多项目的仓库,你只负责其中一个子目录,却要检出全部几万个文件,磁盘和编辑器索引都受不了。做法是先开启 sparse 模式,再指定要检出的目录:
git clone --filter=blob:none --sparse <url> cd <repo> git sparse-checkout set services/payment libs/commonsparse-checkout有两种模式,新版默认是 cone 模式,只支持指定目录(以及目录下的所有内容),这个模式性能好、语义简单,日常够用。非 cone 模式可以写类似.gitignore的路径模式,灵活但容易写错,除非有特殊需求,不建议折腾。
想临时看一个不在稀疏范围内的文件,不用改配置,git show HEAD:path/to/file直接看内容,或者git checkout -- path/to/file单文件检出。要调整范围,重新set一次即可,把新目录加进列表。要关掉这个模式,git sparse-checkout disable,所有文件回到工作区。
5.3 worktree:同一个仓库,多个工作目录并行
修 bug 修到一半,线上告急要切去修复,常规做法是stash然后切分支,修完再切回来stash pop。这个流程不难,但重复几次就很烦,而且 stash 多了容易搞混。git worktree是更干净的做法:它允许同一个仓库在磁盘上开出多个工作目录,各自检出不同分支,共享同一份.git数据。
git worktree add ../hotfix-1234 hotfix/urgent-1234执行完,../hotfix-1234就是一个独立的工作目录,里面是hotfix/urgent-1234分支的内容,你可以在这个目录里编译、提交、推送,完全不影响主目录。用完git worktree remove ../hotfix-1234清理掉。这个功能特别适合"一边跑着长时间测试、一边要改代码"的场景,因为测试进程占着主目录的文件,切分支会打断它。
要注意的是,同一个分支不能在两个 worktree 里同时检出,Git 会直接拒绝。这是保护机制,避免两边各提交一次导致状态打架。另外 worktree 的元数据存在.git/worktrees/下面,如果手动删了目录没执行remove,需要用git worktree prune清理残留记录。
5.4 日常维护:gc 和 fsck 别乱跑
git gc负责把松散对象打包、清理不可达对象、压缩引用,是仓库维护的核心命令。它默认会在松散对象超过一定数量时自动触发(gc.auto默认 6700),所以多数人不需要手动跑。手动跑的时机一般是:大量删分支之后、filter-branch之类的历史重写之后、仓库明显变慢之后。
git gc --aggressive会做更深度的重新打包,耗时可能是普通 gc 的几十倍,收益却不一定明显。我的建议是除非仓库真的很大并且长期没整理过,否则不用。git maintenance start是更新的方案,它会注册后台任务定期做增量维护,比每次手动跑 gc 更平滑,适合开发机常开的场景。
git fsck是检查对象完整性的,日常不需要跑,只在怀疑仓库损坏(比如突然报object file is empty、bad object)时用。它默认会输出一堆dangling commit、dangling blob,那是正常的悬空对象,不用紧张。加--lost-found会把悬空对象写到.git/lost-found/目录,方便你逐个检查——这就是前面说的"捞 stash、捞误删提交"的底层手段。
6. 提效配置:把重复劳动交给 Git 自己
命令行效率的提升,往往来自那些"少打几个字"的积累。一天敲五十次git status,能省下的时间不多,但心态上的顺畅感是实打实的。这一节讲三类配置:别名、钩子、提交规范校验。都是全局配置,一次配好长期受益。
6.1 别名配置,挑自己真会用的
别一上来就抄一份几十行的别名大全,那玩意儿过两周你自己都记不住。先从最常敲的命令开始,用一周觉得顺手了再加。我个人的最小集是这样:
git config --global alias.st 'status -sb' git config --global alias.co 'checkout' git config --global alias.br 'branch -vv' git config --global alias.lg "log --oneline --graph --decorate --all -20" git config --global alias.last 'log -1 --stat' git config --global alias.unstage 'restore --staged'status -sb用短格式加分支信息,输出只有几行,比默认的啰嗦版本清爽。branch -vv会显示每个分支跟踪的远端和领先落后几个提交,比裸branch有用得多。lg那个是我用得最多的,一张图看完最近二十条提交的分支走向。
别名有个限制:不能覆盖已有命令。你想把git commit定义成别的行为是不行的,Git 会报错。真要改,得用 shell 层的函数包装,或者用!开头定义成执行外部命令的形式,比如git config --global alias.root '!git rev-parse --show-toplevel'。
6.2 钩子:让机器做那些你总会忘的检查
.git/hooks/目录下有一堆.sample后缀的示例脚本,去掉后缀就生效。常用的三个:pre-commit在提交前跑,可以做格式检查、跑单元测试的快速子集;commit-msg校验提交信息格式;pre-push在推送前跑更重的检查。注意这些钩子是本地生效的,不会跟着仓库走,团队要统一就得放到仓库里再让大家手动装(或者用配置指定钩子目录)。
commit-msg校验提交信息的一个实用写法:
#!/bin/sh # 检查提交信息第一行长度和是否为空 msg_file="$1" first_line=$(head -n 1 "$msg_file") if [ -z "$first_line" ]; then echo "提交信息不能为空" >&2 exit 1 fi if [ ${#first_line} -gt 72 ]; then echo "提交信息首行超过 72 字符,请精简" >&2 exit 1 fi这类脚本的价值不在于严格,而在于"统一的底线"。长度限制的意义是让git log --oneline在终端里不折行,72 是个常见约定,你也可以定成 50。要绕过钩子(比如紧急修复时),git commit --no-verify可以跳过,但用完记得补上。
注意:钩子脚本要有可执行权限,
chmod +x .git/hooks/commit-msg。另外钩子是在提交时同步执行的,写得慢会明显拖慢提交速度,别在里面跑完整测试套件,跑个 lint 加几个关键用例就够了。
6.3 提交签名和身份配置,一次配好别反复改
提交里记录的作者信息来自user.name和user.email,这两个值配错了,历史里就永远留着错误信息。公司项目和个人项目分开的话,建议在仓库级别覆盖全局配置,而不是每次都临时改。查看当前生效的值用git config --show-origin --get user.email,--show-origin会告诉你这个值来自哪个配置文件,排查"为什么我的提交显示成别人"时很有用。
需要更强的身份保证时,可以给提交加签名。生成密钥之后配置user.signingkey,然后git commit -S或直接git config commit.gpgsign true全局开启。签名后的提交在 log 里会显示gpg: Good signature,托管平台也会显示一个"已验证"标记。这个过程有点麻烦,但如果你的项目需要追溯"这个提交确实是某人提交的",值得配一次。
还有个小配置容易被忽略:core.autocrlf。跨平台团队里,Windows 和 Linux 对换行符的处理不一致,会导致整文件都被标记为修改。新版 Git 推荐用.gitattributes统一管理,而不是靠autocrlf全局猜测。在仓库根目录放一行* text=auto,让 Git 自己判断哪些是文本文件并统一成 LF 存储,检出时再按平台转换。
7. 排查实录:那些让人想砸键盘的时刻
Git 出问题的时候,报错信息往往语焉不详,网上一搜一堆过时答案。这一节整理几个高频症状和排查路径,都是我实际遇到并解决过的。用法上建议先看症状匹配,再按思路一步步来,别一上来就搜个命令往终端里粘。
7.1 先学会用二分和搜索定位问题
遇到"这个 bug 不知道哪个提交引入的"这种情况,肉眼翻几百条 log 是效率最低的做法。git bisect是专门干这个的:给一个已知的好提交和一个已知的坏提交,Git 每次折半检出一个中间提交,你测完告诉它 good 还是 bad,十几轮就能锁定到具体某个提交。对于几十上百个提交的范围,bisect 通常两三分钟就能出结果。
git bisect start git bisect bad # 当前 HEAD 是坏的 git bisect good v1.2.0 # 这个 tag 是好的 # 之后每次 Git 检出中间提交,测试后执行: git bisect good # 或 git bisect bad git bisect reset # 结束后回到原分支如果测试过程可以脚本化,git bisect run ./test.sh能全自动跑完,脚本退出码 0 表示 good,非 0 表示 bad。这个用法在有稳定测试用例的项目里威力很大,泡杯咖啡回来结果就出来了。
另一类问题不是"哪个提交",而是"这行代码什么时候被改的、为什么改"。git log -S"某个字符串"会找出引入或删除这个字符串的所有提交,-G则是按 diff 内容里的正则匹配。想追一个函数的演变,git log -L :函数名:文件路径直接把该函数的每次改动列出来,比翻 diff 快得多。
7.2 常见症状速查表
| 症状 | 常见原因 | 处理思路 |
|---|---|---|
push被拒,提示 non-fast-forward | 远端有你本地没有的提交 | 先git pull --rebase或git fetch后手动合并,别直接 force |
| 切分支时提示会覆盖本地改动 | 目标分支和当前工作区有冲突文件 | 先git stash -u存起来,切过去再pop |
rebase中途不知道该干嘛 | 停在冲突状态 | 解决后git add再git rebase --continue,想放弃就--abort |
| 提交里混进了不该提交的文件 | 用了git add .没检查 | git reset --soft HEAD~1后重新挑文件提交 |
| 历史里有个大文件导致仓库巨大 | 曾经提交过二进制大文件 | 用git log --all --oneline -- 大文件路径定位,再评估是否重写历史 |
detached HEAD状态下提交了 | checkout了某个 commit 或 tag | 立刻git switch -c 新分支名把提交接住 |
| 分支删了找不回 | 已执行过清理或过期 | 先git reflog,再git fsck --lost-found |
status显示文件已修改但内容没变 | 换行符或文件权限变化 | 检查core.filemode和.gitattributes的换行配置 |
表里最后一条特别值得说。core.filemode在跨系统共享目录(比如挂载的虚拟磁盘)下经常误报,表现为一堆文件权限从 644 变成 755。设git config core.filemode false可以忽略权限位的变化。换行符问题则优先用.gitattributes解决,全局autocrlf是历史遗留方案,容易在混合平台团队里出乱子。
7.3 几条踩出来的经验
第一条,任何--hard、--force、--prune之前,先跑一遍只读的确认命令。reset --hard前先git log --oneline -5加git status,push --force前先git log origin/分支..HEAD看清楚会覆盖掉什么。这个习惯养成之后,我几乎没再丢过东西。
第二条,别在 rebase 过程中开新终端乱敲命令。rebase 进行中仓库处于特殊状态,此时在另一个窗口commit、checkout很容易把状态搞乱。要么等它结束,要么先--abort。同理,rebase 卡住的时候不要在 IDE 里点来点去,图形工具对中间态的处理不一定和命令行一致。
第三条,冲突解决时 ours 和 theirs 的含义在 rebase 下是反的。这是最容易出错的地方:merge 时ours是当前分支,theirs是被合进来的分支;rebase 时ours是"你正在 rebase 到的那条基线",theirs才是"你正在重放的提交"。也就是说 rebase 冲突里,你要保留自己的改动,反而要选theirs。我第一次遇到时也懵了,后来记住一句话:rebase 是"站在别人的地基上重放我的提交",所以地基是 ours。
第四条,定期的git fetch --prune和分支清理,比任何优化都有效。仓库用久了,远端分支几百个,本地分支几十个,branch -a输出一屏都看不完,找东西全靠 grep。每周花两分钟清一遍,长期收益远大于折腾各种性能参数。
第五条,遇到完全不理解的状态,先复制一份.git目录再动手。cp -r .git /tmp/backup-git,花几秒钟,能救命。我至今保留这个习惯,尤其是在处理别人的仓库或者做历史重写之前。等你能不假思索地说清楚每条命令会改哪一层,这个习惯自然就可以省了。
最后分享一个我常年在用的自检流程:做完任何可能丢数据的操作后,立刻开一个新终端,在仓库的父目录重新clone一份到临时路径,看看远端到底是什么状态。这个动作能暴露出"本地看着好好的、推上去发现漏了文件"这类问题,比事后补救划算得多。