1. 三个区与对象模型:所有git命令的底层逻辑刚上手git的时候,我犯过一个很典型的错误:以为git就是一个简单的"保存历史版本"工具,于是满脑子只记add、commit、push三个词。结果一到团队协作,同事问我"你刚那个hotfix分支推了吗""你rebase一下再提PR",我整个人都是懵的。后来花了不少时间才明白,git之所以有那么多命令,是因为它内部有一套非常清晰的对象模型,所有命令本质上都是在操作这套模型。git的核心不是"记录文件的副本",而是一个内容寻址的文件系统。它把文件内容、目录结构、提交信息、分支指向全部抽象成对象,这些对象通过SHA-1哈希值唯一标识。理解了这个前提,你会发现git命令其实没有那么多"魔法",每个命令背后都对应着对对象图的一次操作。常说的"三个区"——工作区(Working Directory)、暂存区(Index/Staging Area)、版本库(Repository)——对应的就是git内部对象的不同状态。工作区是你正在编辑的普通文件夹,暂存区是下一次commit的"候选名单",版本库里则存放着真正不可变的commit对象。git add做的事情,是把工作区文件的快照写入对象库,并更新暂存区的索引;git commit做的事情,是把当前暂存区的索引固化成一个新的commit对象。有个很形象的类比:工作区是你桌面上的草稿纸,暂存区是你要放进文件袋的页面,而版本库是上锁的文件柜。你可以在草稿纸上随便涂改,只有放进文件袋的页面才会被盖章归档,归档之后的内容就不能再改了——commit对象一旦创建,就完全不可变,这就是git保证历史可追溯的基础。# 查看三个区的状态,这是最高频的命令 git status # 工作区 -> 暂存区 git add <file> # 暂存区 -> 版本库 git commit -m "commit message"提示:git commit --amend之所以能"修改上一次提交",本质上是创建了一个全新的commit对象来替换旧的commit,旧的commit仍然留在对象库里,只是不再被任何分支引用。HEAD指针指向当前所在的分支引用,而分支引用指向某个commit对象。每次commit不是"覆盖"上一个commit,而是在commit对象图中追加一个子节点,并移动当前分支的引用。所以git的历史是一棵有向无环图(DAG),不是一条直线。这个认知对后面理解merge、rebase、reset都至关重要。2. 日常提交的完整链路:从add到commit的细节与规范2.1 别再用git add .了,试试路径和交互式添加很多新手习惯性git add .,把所有改动一次性塞进暂存区。这种方式在个人项目里没大问题,但一旦进入多人协作,或者你的改动跨越了多个逻辑模块,这种方式会给code review带来很大麻烦。评审人需要从一堆杂糅的改动里分辨出哪些属于功能A、哪些属于修复B,体验极差。更推荐的做法是按逻辑拆分提交。比如我改了一个接口又顺手修了一个样式bug,我会分两次提交:# 只添加某个文件 git add src/api/user.js # 只添加某个目录下的所有改动 git add docs/ # 交互式添加,按hunk(改动块)逐个选择 git add -pgit add -p是我比较常用的命令,它会将文件的每个改动块单独列出,询问你是stage、skip还是split。这样同一个文件里如果包含两个不同目的的改动,就能分别提交到不同的commit里。对于追求提交粒度干净的团队,这个命令几乎是必备技能。2.2 commit message规范:让历史可读提交信息是写给未来的自己和其他协作同事看的,不是形式主义。我见过太多仓库的commit message写成update、fix、修改,一个月后回头看,完全不知道当时改了什么东西。推荐的格式是type(scope): subject,这也是社区里比较常用的约定:类型含义典型例子feat新功能feat(login): 增加手机号登录方式fix修复bugfix(cart): 修复库存不足时仍可下单的问题docs文档变更docs(readme): 补充部署说明refactor重构(不改功能)refactor(api): 提取公共请求拦截器style代码格式调整style(button): 调整组件缩进perf性能优化perf(list): 使用虚拟滚动优化渲染写完提交信息后,用git log --oneline看历史,一眼就能明白项目的演进脉络。如果发现提交信息写错了,commit还没有push到远程,可以用git commit --amend修改最近一次提交的信息。2.3 合并多个提交:commit --amend与交互式rebasegit commit --amend除了修改message,还常用于"想在上一次提交里加一个漏掉的文件"。操作方式:# 漏掉了一个配置文件,不想新增一个commit git add config.yml git commit --amend --no-edit--no-edit表示沿用原来的提交信息,不加这个参数会进入编辑器让你重新编辑message。需要注意:amend一定要在push之前用,一旦push到远程,再amend就会导致本地和远程的历史分叉,需要force push才能同步,这对团队协作来说是件麻烦事。如果你想合并的不只是最近一次,而是最近三次提交,就要用到交互式rebase:git rebase -i HEAD~3编辑器会列出最近三次提交,你可以:把pick改成squash,将当前提交合并进上一个提交调整提交顺序删除某个提交(直接删除对应行)修改某个提交的message(把pick改成reword)你还可以把一个功能分支上十几个零碎的提交,整理成两三个语义清晰的提交,再合回主分支。这样主分支的历史会非常干净。但是注意,rebase也是在push之前做才有意义,push之后rebase会导致远程和本地分叉。3. 分支操作与HEAD游离:团队协作的第一次冲击3.1 分支的本质是"可移动的指针"git branch系列命令,核心就是在commit对象图上创建、移动、删除指针。创建分支的成本极低,因为它只是新建一个指针指向当前commit,不做任何文件拷贝。这也意味着git的分支特别轻量,可以随便开。# 创建并切换到新分支 git switch -c feature/payment # 查看本地分支 git branch # 查看所有分支(含远程分支) git branch -a # 删除本地分支(-D强制删除) git branch -d feature/payment注意git switch是git 2.23引入的命令,比git checkout语义更清晰。checkout既负责切换分支又负责恢复文件,职责混乱,新手容易搞混。现在的项目我基本只用git switch来切换分支。3.2 HEAD游离状态:为什么切到历史commit后你的新提交"不见了"这是很多人会遇到的惊吓场景:执行git checkout <某个commit的hash>,然后改了代码、提交了,接着想切回自己的分支,发现刚才的提交不见了。原因在于HEAD此时没有指向任何分支,而是直接指向了一个具体的commit。你在这个状态下提交,新建的commit没有任何分支引用它,切走之后它就成了"悬空对象"。# 看到 detached HEAD 提示,说明你处于游离状态 git checkout <commit-hash> # 此时如果做了提交,需要这样挽救:基于当前HEAD创建一个新分支 git switch -c rescue-branch如果你的本意是"临时看看历史版本的代码,不如直接基于它改一版",那正确做法是开头就先创建分支:git switch -c archive/legacy-ui <commit-hash>这样HEAD始终指向archive/legacy-ui分支,提交不会丢失。游离状态本身不是错误,但最好只在查看历史文件时使用,不要在游离状态下进行代码修改。3.3 merge和rebase:同样是合并,差别在哪里合并两个分支的改动,git提供了git merge和git rebase两种手段,这是git里最让人纠结的选择。merge的机制是创建一个新的"合并提交"(merge commit),它的父节点有两个,分别指向两个分支的最新提交。历史会如实记录"这里发生过一次分叉合并",优点是不改变任何已有commit,安全可追溯,缺点是历史图上会多出很多杂乱的合并点。rebase的机制是把当前分支的commit"搬移"到目标分支的最新提交之后,逐个重放。重放不是复制原commit,而是基于新的父节点重新生成commit,所以commit的hash会改变。rebase之后的历史是一条干净的直线,但代价是重写了历史,如果分支已经push到远程,这种重写会带来协作问题。我的使用原则很简单:个人分支、还在本地、没人基于它开发 → 用rebase,保持历史干净共享分支、多人协作用的分支 → 用merge,或者干脆不直接rebase有一次同事把一个已经push到远程的feature分支rebase了主分支,然后force push,结果另一位基于旧feature分支开发的同事在pull时冲突到崩溃。那次之后,我在团队里定了一条规矩:rebase只允许在本地进行,凡是push过的分支一律merge。4. 回滚与修正:reset、revert、stash的取舍逻辑4.1 reset的三种模式:soft、mixed、hard分别动了什么git reset是git里最容易造成数据丢失的命令之一,理解它的三种模式,才能真正理解它在做什么。reset做的事情是移动当前分支的指针,并对三个区产生不同程度的影响:模式移动HEAD指针更新暂存区更新工作区典型用途--soft是否否撤销commit但保留改动在暂存区--mixed(默认)是是否撤销commit且取消暂存,改动回到工作区--hard是是是彻底丢弃commit及所有改动# 撤销最近一次提交,但保留改动在暂存区(重新提交时方便改message) git reset --soft HEAD~1 # 撤销最近三次提交,改动全部回到工作区(重新组织提交) git reset HEAD~3 # 彻底回滚到某个commit,丢弃之后所有改动(危险) git reset --hard <commit-hash>--hard一旦执行,工作区、暂存区都会被覆盖,如果那些改动没有别的备份(比如其他分支、stash),基本就找不回来了。所以建议在用--hard前,先把当前改动stash起来或者开个备份分支。4.2 revert:不重写历史的安全回滚reset会改写历史,这在远程共享分支上是不可接受的。如果你要撤销一个已经push到远程的提交,应该用git revert。revert的本质不是"把指针挪回去",而是生成一个新的提交,这个提交的内容是反向应用目标提交的改动。比如某人提交了一个删除了某函数的功能,revert那个提交会把这个函数加回来,同时保留所有人后来的改动。# 撤销最近一次提交,并新生成一个revert提交 git revert HEAD # 撤销指定的提交 git revert <commit-hash>revert不改变任何已有commit,因此永远不会产生历史分叉,是协作中撤销代码的标准方案。如果有多个需要撤销的提交,可以按从新到旧的顺序依次revert。4.3 stash:临时藏起改动开发到一半,突然需要切分支处理个紧急问题,又不想提交半成品——这是git stash最典型的场景。# 把当前所有改动存入stash,工作区恢复到干净状态 git stash push -m "wip: 购物车优惠计算" # 查看stash列表 git stash list # 恢复最近一次stash并删除记录 git stash pop # 恢复指定stash但不删除记录 git stash apply stash@{1} # 查看stash里具体改了哪些内容 git stash show -p stash@{0}需要注意:stash默认不包含未跟踪的新文件(untracked files),如果你新建了一个文件且还没add,git stash不会保存它。想一并存入需要加上-u参数(即--include-untracked):git stash push -u -m "包含未跟踪文件的改动"另外,不要长期保留在同一个stash里反复切换,过了一段时间你基本会忘记stash里存的什么。建议stash的存活时间不超过当天,紧急问题处理完就立刻pop回来。5. 远程协作:push、pull、fetch与冲突的正面处理5.1 fetch与pull的本质区别:别忽略那个"隐式merge"git pull是git fetch+git merge的组合命令。fetech只做一件事:把远程仓库的最新提交和分支信息拉取到本地的远程追踪分支(如origin/main),不会动你本地的工作区和当前分支。pull则会在fetch之后,自动将远程的更改合并到当前分支。理解了这一点,你就知道为什么有时候同事说"我pull了但是什么都没变"——因为pull可能因为没有可合并内容而没有任何变化。我更推荐显式执行fetch,再决定merge还是rebase:# 拉取远程所有分支和提交信息 git fetch origin # 查看本地与远程的差异 git log --oneline HEAD..origin/main # 确认差异无误后,再进行合并 git merge origin/main这样做的额外好处是:可以在合并前先检查远程的改动是否与自己的改动冲突,并且如果远程有多个提交,你还可以把fetch出来的内容先用一个临时分支做验证,确认没影响再真正合并到自己的主干。5.2 冲突的本质与解决流程:读分隔符而不是瞎删冲突不是git搞坏了你的文件,而是git在自动合并时发现两边的改动在同一位置,且无法判断应该保留哪一边。此时git会在冲突文件中插入标记:<<<<<<< HEAD 当前分支的改动 ======= 另一侧分支(或远程)的改动 >>>>>>> feature/login解决冲突的正确姿势:打开冲突文件,阅读<<<<<<<到>>>>>>>之间的内容根据业务逻辑决定保留哪一段、删除哪一段,或手动混合两段删除所有的冲突标记行git add <file>标记为已解决如果是merge,执行git commit;如果是rebase,执行git rebase --continue注意:不要图省事直接git checkout --theirs或--ours整个文件。冲突往往只有一小段,整文件覆盖容易把对方无关的改动也丢掉。逐个hunk解决,细心一点。5.3 远程推送失败的常见原因与应对git push报错大概是新人在协作中遇到最多的场景。常见原因有三种:远程有本地没有的提交:需要先pull(或fetch+merge/rebase),解决完冲突再push非快进推送被拒绝:如果你push的分支历史与远程不一致,git会拒绝推送,避免覆盖别人的提交。你本地如果和远程分叉了,需要把远程的改动合并回本地,然后再push权限不足:确认你是否被添加为仓库的协作者,或者是否有相应的写权限# 查看push会被拒绝的原因 git remote -v # 查看本地领先/落后远程几个提交 git status -sb # 推送并设置上游分支 git push -u origin feature/payment-u参数第一次推送时用,建立本地分支与远程分支的追踪关系,之后直接git push就能推送到对应的远程分支。6. 查看历史的多种姿势:log的筛选、图形化与diff定位6.1 常用log参数:让提交历史图更直观git log是最基础的,但参数非常丰富。我常用的组合是:# 单行简洁输出 git log --oneline # 带图形化的分支结构 git log --graph --oneline --all # 查看某个文件的所有提交历史 git log --oneline -- src/utils/date.js # 按作者过滤 git log --author="某开发者" # 按时间范围过滤 git log --since="2024-01-01" --until="2024-03-01" # 搜索提交信息 git log --grep="bugfix"--graph配合--all能从全局视角看到所有分支的关系图,这比在网页端看草稿要直观得多。我在review大型分支时,经常用它来梳理合并结构,确认每个提交的来源。6.2 diff的进阶用法:不要看到一堆改动就头大git diff用于查看改动内容。基础用法是:# 查看工作区与暂存区的差异 git diff # 查看暂存区与最近一次提交的差异 git diff --staged # 查看两个commit之间的差异 git diff <commit-A> <commit-B> # 只看某个文件的差异 git diff -- src/App.js有经验的开发者还有一个技巧:git diff加上--stat参数,可以只显示每个文件的增删行数,快速了解改动的分布范围,不必钻进具体内容里。git diff HEAD~1 HEAD --stat6.3 show与blame:定位某行代码是什么时候被谁改的排查问题时,经常需要确认"这一行是哪个提交引入的"。git log -p <file>可以查看文件每次commit的具体改动,但更直接的定位工具是git blame:# 查看文件每一行最后一次被修改的commit、作者、时间 git blame src/components/Button.jsxblame输出里,第一列是提交hash,第二列是作者加时间,之后是行号和代码内容。看到某一行有问题,可以用hashgit show <hash>查看那个提交的完整上下文,了解当时为什么要这样改。另外,git show还可以直接查看某个提交的具体内容和改动统计:git show <commit-hash> --stat7. 事故救援手册:从误删分支到丢失commit的保命手段7.1 reflog:git最被低估的救命命令如果你误用了git reset --hard,或者误删了分支,变化发生的一瞬间会觉得自己完了。但git其实有两年内的所有"HEAD历史移动记录",存在.git/logs/下的reflog里。git reflog能列出你执行过的每一次HEAD移动、切换、reset等操作:git reflog输出里每一行都记录了"HEAD在某时刻指向了哪个commit"。假设你误操作reset --hard回到了一个旧commit,原来的新commit的hash在reflog里仍然清晰可见:# 从reflog里找到丢失前的commit hash,然后 git reset --hard <那个hash>reflog的保留期限通常是90天(默认30天,可通过gc.pruneExpire或gc.reflogExpire调整),这意味着误操作后的恢复窗口其实很长。7.2 误删分支的恢复:用reflog和分支重建删除分支只是删除了指针,commit对象不会立刻消失,它们仍然在对象库里,直到被gc清理。恢复思路:# 假设你误删了 feature/login 分支 git branch -D feature/login # 查看reflog,找到feature/login最后一次指向的commit git reflog # 用那个commit重建分支 git branch feature/login <commit-hash>有一个细节:如果你删分支之前还操作了很多次checkout、switch,reflog里的记录会被其他操作顶掉,找起来稍微麻烦点。所以误删分支后,最好马上停下来,不要东点点西点点,直接用git reflog | grep feature/login找到最后一条相关记录。7.3 彻底丢失工作区改动的最后一根稻草git stash的栈记录也存在reflog类似的机制里。如果你git stash pop之后发生了冲突,pop失败了,改动不会立刻消失,你可以去stash里再拉回来:# pop冲突后改动还在stash里,重新应用 git stash apply stash@{0} # 如果应用后冲突解决了但想要保留原来的stash,不用drop还有一个冷门但有效的技巧:所有commit对象都会保存在对象库里,直到gc真正运行。如果某个提交的hash你知道(哪怕是别人发你的),你可以通过:git cat-file -t <hash> # 查看对象类型 git checkout <hash> # 意外恢复8. 效率技巧:别名、子模块与常用的工作流建议8.1 配置常用alias:把高频命令缩短git允许在全局配置里设置命令别名: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"配置完之后可以直接git co、git br、git st、git lg。我自己还配了:git config --global alias.unstage "reset HEAD --" git config --global alias.last "log -1 HEAD --stat"git unstage会增加一个稍微直观的"取消暂存"命令,对刚入门的人来说比git reset HEAD --更友好。8.2 子模块的正确打开方式:什么时候该用,什么时候别用git submodule用于在一个仓库里嵌套另一个仓库,常见的使用场景是多个项目共享一个公共组件库,或者项目依赖某个固定版本的SDK的源码。# 添加子模块 git submodule add <url> path/to/module # 克隆包含子模块的仓库(需要递归拉取) git clone --recurse-submodules <url> # 更新子模块到最新提交 git submodule update --remote但子模块的traps非常明显:子模块的提交状态在父仓库里只是一个commit指针,如果子模块改了代码但不更新父仓库的指针,其他人clone下来看到的子模块代码就不会同步。团队协作中,子模块经常引发"我这明明是更新的代码,你那边怎么是老版本"这类问题。我的建议是:能用包管理器(npm、cargo、Go mod等)管理依赖,就不用submodule。只有在你需要把整个源码引入项目里做深度定制、且定制内容需要和主仓库分开版本控制时,submodule才值得用。8.3 个人项目的分支工作流建议结合我自己的实践,最后给出一套简单实用的分支策略参考:main:永远保持可发布状态,只允许合入经过review的代码feature/*:每个功能一个分支,从main切出,开发完合回mainfix/*:临时修复分支,快速解决线上问题提交频率:功能开发中按逻辑拆分多次提交,不要憋着一个巨大commit塞进去合入方式:规模小的改动用rebase合(保持线性),规模大、多人参与的用merge合这套流程简单直接,没有多余的复杂度,对三五个人的团队、甚至个人项目都适用。等团队规模上去了,再考虑更严格的分层策略也不迟。最后聊一个我踩过多次的坑:不要长时间不pull、然后在本地闷头改。我在某个项目里曾经连续两周没有fetch过主分支,结果合并时积压了上百个冲突,光是清理就花了整整一天。现代git工作流里,"频繁同步、小块提交、及时解决冲突"是减少痛苦的核心原则。与其把精力花在背命令上,不如把这些高频命令练成肌肉记忆,真正遇到复杂问题时,你已经拥有了理解它们底层逻辑的能力。
1. 三个区与对象模型:所有git命令的底层逻辑刚上手git的时候,我犯过一个很典型的错误:以为git就是一个简单的"保存历史版本"工具,于是满脑子只记add、commit、push三个词。结果一到团队协作,同事问我"你刚那个hotfix分支推了吗""你rebase一下再提PR",我整个人都是懵的。后来花了不少时间才明白,git之所以有那么多命令,是因为它内部有一套非常清晰的对象模型,所有命令本质上都是在操作这套模型。git的核心不是"记录文件的副本",而是一个内容寻址的文件系统。它把文件内容、目录结构、提交信息、分支指向全部抽象成对象,这些对象通过SHA-1哈希值唯一标识。理解了这个前提,你会发现git命令其实没有那么多"魔法",每个命令背后都对应着对对象图的一次操作。常说的"三个区"——工作区(Working Directory)、暂存区(Index/Staging Area)、版本库(Repository)——对应的就是git内部对象的不同状态。工作区是你正在编辑的普通文件夹,暂存区是下一次commit的"候选名单",版本库里则存放着真正不可变的commit对象。git add做的事情,是把工作区文件的快照写入对象库,并更新暂存区的索引;git commit做的事情,是把当前暂存区的索引固化成一个新的commit对象。有个很形象的类比:工作区是你桌面上的草稿纸,暂存区是你要放进文件袋的页面,而版本库是上锁的文件柜。你可以在草稿纸上随便涂改,只有放进文件袋的页面才会被盖章归档,归档之后的内容就不能再改了——commit对象一旦创建,就完全不可变,这就是git保证历史可追溯的基础。# 查看三个区的状态,这是最高频的命令 git status # 工作区 -> 暂存区 git add <file> # 暂存区 -> 版本库 git commit -m "commit message"提示:git commit --amend之所以能"修改上一次提交",本质上是创建了一个全新的commit对象来替换旧的commit,旧的commit仍然留在对象库里,只是不再被任何分支引用。HEAD指针指向当前所在的分支引用,而分支引用指向某个commit对象。每次commit不是"覆盖"上一个commit,而是在commit对象图中追加一个子节点,并移动当前分支的引用。所以git的历史是一棵有向无环图(DAG),不是一条直线。这个认知对后面理解merge、rebase、reset都至关重要。2. 日常提交的完整链路:从add到commit的细节与规范2.1 别再用git add .了,试试路径和交互式添加很多新手习惯性git add .,把所有改动一次性塞进暂存区。这种方式在个人项目里没大问题,但一旦进入多人协作,或者你的改动跨越了多个逻辑模块,这种方式会给code review带来很大麻烦。评审人需要从一堆杂糅的改动里分辨出哪些属于功能A、哪些属于修复B,体验极差。更推荐的做法是按逻辑拆分提交。比如我改了一个接口又顺手修了一个样式bug,我会分两次提交:# 只添加某个文件 git add src/api/user.js # 只添加某个目录下的所有改动 git add docs/ # 交互式添加,按hunk(改动块)逐个选择 git add -pgit add -p是我比较常用的命令,它会将文件的每个改动块单独列出,询问你是stage、skip还是split。这样同一个文件里如果包含两个不同目的的改动,就能分别提交到不同的commit里。对于追求提交粒度干净的团队,这个命令几乎是必备技能。2.2 commit message规范:让历史可读提交信息是写给未来的自己和其他协作同事看的,不是形式主义。我见过太多仓库的commit message写成update、fix、修改,一个月后回头看,完全不知道当时改了什么东西。推荐的格式是type(scope): subject,这也是社区里比较常用的约定:类型含义典型例子feat新功能feat(login): 增加手机号登录方式fix修复bugfix(cart): 修复库存不足时仍可下单的问题docs文档变更docs(readme): 补充部署说明refactor重构(不改功能)refactor(api): 提取公共请求拦截器style代码格式调整style(button): 调整组件缩进perf性能优化perf(list): 使用虚拟滚动优化渲染写完提交信息后,用git log --oneline看历史,一眼就能明白项目的演进脉络。如果发现提交信息写错了,commit还没有push到远程,可以用git commit --amend修改最近一次提交的信息。2.3 合并多个提交:commit --amend与交互式rebasegit commit --amend除了修改message,还常用于"想在上一次提交里加一个漏掉的文件"。操作方式:# 漏掉了一个配置文件,不想新增一个commit git add config.yml git commit --amend --no-edit--no-edit表示沿用原来的提交信息,不加这个参数会进入编辑器让你重新编辑message。需要注意:amend一定要在push之前用,一旦push到远程,再amend就会导致本地和远程的历史分叉,需要force push才能同步,这对团队协作来说是件麻烦事。如果你想合并的不只是最近一次,而是最近三次提交,就要用到交互式rebase:git rebase -i HEAD~3编辑器会列出最近三次提交,你可以:把pick改成squash,将当前提交合并进上一个提交调整提交顺序删除某个提交(直接删除对应行)修改某个提交的message(把pick改成reword)你还可以把一个功能分支上十几个零碎的提交,整理成两三个语义清晰的提交,再合回主分支。这样主分支的历史会非常干净。但是注意,rebase也是在push之前做才有意义,push之后rebase会导致远程和本地分叉。3. 分支操作与HEAD游离:团队协作的第一次冲击3.1 分支的本质是"可移动的指针"git branch系列命令,核心就是在commit对象图上创建、移动、删除指针。创建分支的成本极低,因为它只是新建一个指针指向当前commit,不做任何文件拷贝。这也意味着git的分支特别轻量,可以随便开。# 创建并切换到新分支 git switch -c feature/payment # 查看本地分支 git branch # 查看所有分支(含远程分支) git branch -a # 删除本地分支(-D强制删除) git branch -d feature/payment注意git switch是git 2.23引入的命令,比git checkout语义更清晰。checkout既负责切换分支又负责恢复文件,职责混乱,新手容易搞混。现在的项目我基本只用git switch来切换分支。3.2 HEAD游离状态:为什么切到历史commit后你的新提交"不见了"这是很多人会遇到的惊吓场景:执行git checkout <某个commit的hash>,然后改了代码、提交了,接着想切回自己的分支,发现刚才的提交不见了。原因在于HEAD此时没有指向任何分支,而是直接指向了一个具体的commit。你在这个状态下提交,新建的commit没有任何分支引用它,切走之后它就成了"悬空对象"。# 看到 detached HEAD 提示,说明你处于游离状态 git checkout <commit-hash> # 此时如果做了提交,需要这样挽救:基于当前HEAD创建一个新分支 git switch -c rescue-branch如果你的本意是"临时看看历史版本的代码,不如直接基于它改一版",那正确做法是开头就先创建分支:git switch -c archive/legacy-ui <commit-hash>这样HEAD始终指向archive/legacy-ui分支,提交不会丢失。游离状态本身不是错误,但最好只在查看历史文件时使用,不要在游离状态下进行代码修改。3.3 merge和rebase:同样是合并,差别在哪里合并两个分支的改动,git提供了git merge和git rebase两种手段,这是git里最让人纠结的选择。merge的机制是创建一个新的"合并提交"(merge commit),它的父节点有两个,分别指向两个分支的最新提交。历史会如实记录"这里发生过一次分叉合并",优点是不改变任何已有commit,安全可追溯,缺点是历史图上会多出很多杂乱的合并点。rebase的机制是把当前分支的commit"搬移"到目标分支的最新提交之后,逐个重放。重放不是复制原commit,而是基于新的父节点重新生成commit,所以commit的hash会改变。rebase之后的历史是一条干净的直线,但代价是重写了历史,如果分支已经push到远程,这种重写会带来协作问题。我的使用原则很简单:个人分支、还在本地、没人基于它开发 → 用rebase,保持历史干净共享分支、多人协作用的分支 → 用merge,或者干脆不直接rebase有一次同事把一个已经push到远程的feature分支rebase了主分支,然后force push,结果另一位基于旧feature分支开发的同事在pull时冲突到崩溃。那次之后,我在团队里定了一条规矩:rebase只允许在本地进行,凡是push过的分支一律merge。4. 回滚与修正:reset、revert、stash的取舍逻辑4.1 reset的三种模式:soft、mixed、hard分别动了什么git reset是git里最容易造成数据丢失的命令之一,理解它的三种模式,才能真正理解它在做什么。reset做的事情是移动当前分支的指针,并对三个区产生不同程度的影响:模式移动HEAD指针更新暂存区更新工作区典型用途--soft是否否撤销commit但保留改动在暂存区--mixed(默认)是是否撤销commit且取消暂存,改动回到工作区--hard是是是彻底丢弃commit及所有改动# 撤销最近一次提交,但保留改动在暂存区(重新提交时方便改message) git reset --soft HEAD~1 # 撤销最近三次提交,改动全部回到工作区(重新组织提交) git reset HEAD~3 # 彻底回滚到某个commit,丢弃之后所有改动(危险) git reset --hard <commit-hash>--hard一旦执行,工作区、暂存区都会被覆盖,如果那些改动没有别的备份(比如其他分支、stash),基本就找不回来了。所以建议在用--hard前,先把当前改动stash起来或者开个备份分支。4.2 revert:不重写历史的安全回滚reset会改写历史,这在远程共享分支上是不可接受的。如果你要撤销一个已经push到远程的提交,应该用git revert。revert的本质不是"把指针挪回去",而是生成一个新的提交,这个提交的内容是反向应用目标提交的改动。比如某人提交了一个删除了某函数的功能,revert那个提交会把这个函数加回来,同时保留所有人后来的改动。# 撤销最近一次提交,并新生成一个revert提交 git revert HEAD # 撤销指定的提交 git revert <commit-hash>revert不改变任何已有commit,因此永远不会产生历史分叉,是协作中撤销代码的标准方案。如果有多个需要撤销的提交,可以按从新到旧的顺序依次revert。4.3 stash:临时藏起改动开发到一半,突然需要切分支处理个紧急问题,又不想提交半成品——这是git stash最典型的场景。# 把当前所有改动存入stash,工作区恢复到干净状态 git stash push -m "wip: 购物车优惠计算" # 查看stash列表 git stash list # 恢复最近一次stash并删除记录 git stash pop # 恢复指定stash但不删除记录 git stash apply stash@{1} # 查看stash里具体改了哪些内容 git stash show -p stash@{0}需要注意:stash默认不包含未跟踪的新文件(untracked files),如果你新建了一个文件且还没add,git stash不会保存它。想一并存入需要加上-u参数(即--include-untracked):git stash push -u -m "包含未跟踪文件的改动"另外,不要长期保留在同一个stash里反复切换,过了一段时间你基本会忘记stash里存的什么。建议stash的存活时间不超过当天,紧急问题处理完就立刻pop回来。5. 远程协作:push、pull、fetch与冲突的正面处理5.1 fetch与pull的本质区别:别忽略那个"隐式merge"git pull是git fetch+git merge的组合命令。fetech只做一件事:把远程仓库的最新提交和分支信息拉取到本地的远程追踪分支(如origin/main),不会动你本地的工作区和当前分支。pull则会在fetch之后,自动将远程的更改合并到当前分支。理解了这一点,你就知道为什么有时候同事说"我pull了但是什么都没变"——因为pull可能因为没有可合并内容而没有任何变化。我更推荐显式执行fetch,再决定merge还是rebase:# 拉取远程所有分支和提交信息 git fetch origin # 查看本地与远程的差异 git log --oneline HEAD..origin/main # 确认差异无误后,再进行合并 git merge origin/main这样做的额外好处是:可以在合并前先检查远程的改动是否与自己的改动冲突,并且如果远程有多个提交,你还可以把fetch出来的内容先用一个临时分支做验证,确认没影响再真正合并到自己的主干。5.2 冲突的本质与解决流程:读分隔符而不是瞎删冲突不是git搞坏了你的文件,而是git在自动合并时发现两边的改动在同一位置,且无法判断应该保留哪一边。此时git会在冲突文件中插入标记:<<<<<<< HEAD 当前分支的改动 ======= 另一侧分支(或远程)的改动 >>>>>>> feature/login解决冲突的正确姿势:打开冲突文件,阅读<<<<<<<到>>>>>>>之间的内容根据业务逻辑决定保留哪一段、删除哪一段,或手动混合两段删除所有的冲突标记行git add <file>标记为已解决如果是merge,执行git commit;如果是rebase,执行git rebase --continue注意:不要图省事直接git checkout --theirs或--ours整个文件。冲突往往只有一小段,整文件覆盖容易把对方无关的改动也丢掉。逐个hunk解决,细心一点。5.3 远程推送失败的常见原因与应对git push报错大概是新人在协作中遇到最多的场景。常见原因有三种:远程有本地没有的提交:需要先pull(或fetch+merge/rebase),解决完冲突再push非快进推送被拒绝:如果你push的分支历史与远程不一致,git会拒绝推送,避免覆盖别人的提交。你本地如果和远程分叉了,需要把远程的改动合并回本地,然后再push权限不足:确认你是否被添加为仓库的协作者,或者是否有相应的写权限# 查看push会被拒绝的原因 git remote -v # 查看本地领先/落后远程几个提交 git status -sb # 推送并设置上游分支 git push -u origin feature/payment-u参数第一次推送时用,建立本地分支与远程分支的追踪关系,之后直接git push就能推送到对应的远程分支。6. 查看历史的多种姿势:log的筛选、图形化与diff定位6.1 常用log参数:让提交历史图更直观git log是最基础的,但参数非常丰富。我常用的组合是:# 单行简洁输出 git log --oneline # 带图形化的分支结构 git log --graph --oneline --all # 查看某个文件的所有提交历史 git log --oneline -- src/utils/date.js # 按作者过滤 git log --author="某开发者" # 按时间范围过滤 git log --since="2024-01-01" --until="2024-03-01" # 搜索提交信息 git log --grep="bugfix"--graph配合--all能从全局视角看到所有分支的关系图,这比在网页端看草稿要直观得多。我在review大型分支时,经常用它来梳理合并结构,确认每个提交的来源。6.2 diff的进阶用法:不要看到一堆改动就头大git diff用于查看改动内容。基础用法是:# 查看工作区与暂存区的差异 git diff # 查看暂存区与最近一次提交的差异 git diff --staged # 查看两个commit之间的差异 git diff <commit-A> <commit-B> # 只看某个文件的差异 git diff -- src/App.js有经验的开发者还有一个技巧:git diff加上--stat参数,可以只显示每个文件的增删行数,快速了解改动的分布范围,不必钻进具体内容里。git diff HEAD~1 HEAD --stat6.3 show与blame:定位某行代码是什么时候被谁改的排查问题时,经常需要确认"这一行是哪个提交引入的"。git log -p <file>可以查看文件每次commit的具体改动,但更直接的定位工具是git blame:# 查看文件每一行最后一次被修改的commit、作者、时间 git blame src/components/Button.jsxblame输出里,第一列是提交hash,第二列是作者加时间,之后是行号和代码内容。看到某一行有问题,可以用hashgit show <hash>查看那个提交的完整上下文,了解当时为什么要这样改。另外,git show还可以直接查看某个提交的具体内容和改动统计:git show <commit-hash> --stat7. 事故救援手册:从误删分支到丢失commit的保命手段7.1 reflog:git最被低估的救命命令如果你误用了git reset --hard,或者误删了分支,变化发生的一瞬间会觉得自己完了。但git其实有两年内的所有"HEAD历史移动记录",存在.git/logs/下的reflog里。git reflog能列出你执行过的每一次HEAD移动、切换、reset等操作:git reflog输出里每一行都记录了"HEAD在某时刻指向了哪个commit"。假设你误操作reset --hard回到了一个旧commit,原来的新commit的hash在reflog里仍然清晰可见:# 从reflog里找到丢失前的commit hash,然后 git reset --hard <那个hash>reflog的保留期限通常是90天(默认30天,可通过gc.pruneExpire或gc.reflogExpire调整),这意味着误操作后的恢复窗口其实很长。7.2 误删分支的恢复:用reflog和分支重建删除分支只是删除了指针,commit对象不会立刻消失,它们仍然在对象库里,直到被gc清理。恢复思路:# 假设你误删了 feature/login 分支 git branch -D feature/login # 查看reflog,找到feature/login最后一次指向的commit git reflog # 用那个commit重建分支 git branch feature/login <commit-hash>有一个细节:如果你删分支之前还操作了很多次checkout、switch,reflog里的记录会被其他操作顶掉,找起来稍微麻烦点。所以误删分支后,最好马上停下来,不要东点点西点点,直接用git reflog | grep feature/login找到最后一条相关记录。7.3 彻底丢失工作区改动的最后一根稻草git stash的栈记录也存在reflog类似的机制里。如果你git stash pop之后发生了冲突,pop失败了,改动不会立刻消失,你可以去stash里再拉回来:# pop冲突后改动还在stash里,重新应用 git stash apply stash@{0} # 如果应用后冲突解决了但想要保留原来的stash,不用drop还有一个冷门但有效的技巧:所有commit对象都会保存在对象库里,直到gc真正运行。如果某个提交的hash你知道(哪怕是别人发你的),你可以通过:git cat-file -t <hash> # 查看对象类型 git checkout <hash> # 意外恢复8. 效率技巧:别名、子模块与常用的工作流建议8.1 配置常用alias:把高频命令缩短git允许在全局配置里设置命令别名: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"配置完之后可以直接git co、git br、git st、git lg。我自己还配了:git config --global alias.unstage "reset HEAD --" git config --global alias.last "log -1 HEAD --stat"git unstage会增加一个稍微直观的"取消暂存"命令,对刚入门的人来说比git reset HEAD --更友好。8.2 子模块的正确打开方式:什么时候该用,什么时候别用git submodule用于在一个仓库里嵌套另一个仓库,常见的使用场景是多个项目共享一个公共组件库,或者项目依赖某个固定版本的SDK的源码。# 添加子模块 git submodule add <url> path/to/module # 克隆包含子模块的仓库(需要递归拉取) git clone --recurse-submodules <url> # 更新子模块到最新提交 git submodule update --remote但子模块的traps非常明显:子模块的提交状态在父仓库里只是一个commit指针,如果子模块改了代码但不更新父仓库的指针,其他人clone下来看到的子模块代码就不会同步。团队协作中,子模块经常引发"我这明明是更新的代码,你那边怎么是老版本"这类问题。我的建议是:能用包管理器(npm、cargo、Go mod等)管理依赖,就不用submodule。只有在你需要把整个源码引入项目里做深度定制、且定制内容需要和主仓库分开版本控制时,submodule才值得用。8.3 个人项目的分支工作流建议结合我自己的实践,最后给出一套简单实用的分支策略参考:main:永远保持可发布状态,只允许合入经过review的代码feature/*:每个功能一个分支,从main切出,开发完合回mainfix/*:临时修复分支,快速解决线上问题提交频率:功能开发中按逻辑拆分多次提交,不要憋着一个巨大commit塞进去合入方式:规模小的改动用rebase合(保持线性),规模大、多人参与的用merge合这套流程简单直接,没有多余的复杂度,对三五个人的团队、甚至个人项目都适用。等团队规模上去了,再考虑更严格的分层策略也不迟。最后聊一个我踩过多次的坑:不要长时间不pull、然后在本地闷头改。我在某个项目里曾经连续两周没有fetch过主分支,结果合并时积压了上百个冲突,光是清理就花了整整一天。现代git工作流里,"频繁同步、小块提交、及时解决冲突"是减少痛苦的核心原则。与其把精力花在背命令上,不如把这些高频命令练成肌肉记忆,真正遇到复杂问题时,你已经拥有了理解它们底层逻辑的能力。