Git与GitHub版本控制实战:从对象模型到远程协作与故障恢复
发布时间:2026/10/27 3:41:52 锦皓数字建站

接手一个别人做了半年的项目打开目录先看到七个文件夹项目、项目_改、项目_改2、项目_最终、项目_最终_确认版、项目_最终_确认版_真的最终、项目_备份。每个文件夹里还有一堆内容差不多的代码谁也说不出哪个能跑。这种场面我见过太多次它的本质不是“人懒”而是缺一套能把“每次改动”本身记录下来的机制。Git 就是干这件事的工具GitHub 则是把这套机制搬到网络上、让多人协作有地方落脚的平台。这篇内容不讲“版本控制很重要”这种正确的废话而是把 Git 的对象模型、命令闭环、分支合并、远程协作和几个救命命令拆开讲清楚顺带把团队里最容易出事的地方摊开说。适合两类人看刚接触 Git、命令靠背的新人以及用了两三年但只会 add、commit、push 三连、遇到冲突就复制同事命令的人。1. 版本控制真正解决的问题不是备份是“可解释的历史”很多人第一次接触 Git脑子里把它等同于“云端备份”。这个理解会让你在后面的所有操作里都走偏。备份只回答“现在是什么样”版本控制要回答的是“为什么变成现在这样、是谁在什么时候因为什么改的、如果改错了能不能精确退回那一步”。这三件事的差别决定了你用 Git 的方式。1.1 手工管理文件的三个死结第一个死结是改动无法解释。你把两个文件夹用对比工具打开能看出哪几行不一样但看不出为什么不一样——是修了一个 bug还是顺手改了格式还是把别人加的逻辑删掉了。没有提交信息diff 就是一堆无源头的加减号。第二个死结是回退没有精度。复制文件夹式的备份只能退到“某个时间点的整体状态”你没法只回退某一个文件的某一次修改更没法把某次修复单独摘出来应用到另一个分支上。真要这么干只能靠人肉比对。第三个死结是并发即冲突。两个人同时改同一个文件最后一个保存的人赢前一个人的工作直接消失而且没有任何提示。文件夹共享、网盘同步都一样它们只保证“最后写入生效”不保证“两次写入都被记录”。这三个死结不是靠“大家小心一点”能解决的它需要一层抽象把每一次有意义的改动打包成一个不可变的记录并且让这些记录之间有父子关系。这就是提交commit这个概念的全部意义。1.2 快照模型Git 存的是“那一刻的完整样子”这里有个常见误解必须先澄清。很多人以为 Git 存的是“差异”每次提交只记录改了哪几行。真实情况是Git 的提交在概念上是整棵目录树的完整快照。你提交一次它就记录下当时所有文件的状态形成一个 tree 对象。那为什么仓库不会爆炸式增长因为 Git 用了内容寻址文件内容算出一个哈希值内容相同的文件只存一份。你改了一个文件只有这个文件产生新的 blob其他没动的文件仍然指向原来那份数据。所以在存储层面它做了大量复用和打包压缩但在模型层面你完全可以把它理解成“每次都拍一张完整照片”。这个理解很重要因为它解释了很多现象。比如为什么切换分支那么快——因为切分支不是“把文件改回去”而是“把工作区换成另一个快照对应的内容”为什么提交之后再去改文件不影响已经提交的内容——因为提交指向的是那一刻的 blob你后来改的是工作区里的另一份数据。1.3 集中式与分布式本地有完整历史意味着什么早期的版本控制工具大多是集中式的历史只存在服务器上你提交一次就要连一次服务器服务器挂了你就什么都干不了本地只有一个当前版本的工作副本。Git 是分布式的clone下来的那一刻你本地就有完整的提交历史、完整的分支引用。这不是“高级功能”而是底线。它带来三个实际好处断网时你照样能提交、能建分支、能看历史服务器出问题时任何一个人的本地副本都能作为完整恢复源分支操作变成纯本地行为创建和切换几乎没有成本于是你才敢频繁建分支。代价也有本地和远程是两份数据需要显式同步。这就是后面fetch、pull、push存在的理由也是新手最容易困惑的地方——“我提交了为什么别人看不到”因为你只是提交到了本地仓库。2. 四类对象和三个区理解了这层命令就不用背了Git 的命令数量确实多官方收录的常用命令就有几十个。但底层其实只有四类对象和三个区域绝大多数命令都是在这几个东西之间搬数据。把这层搞清楚你遇到没见过命令时也能推断出它在干什么而不是去搜“这个命令是干嘛的”。2.1 blob、tree、commit、tag仓库里只存这四样东西对象类型存的是什么类比blob文件的内容不含文件名一张纸上的字tree目录结构记录文件名到 blob/tree 的映射一份目录清单commit指向一个 tree、零个或多个父 commit、作者、时间、提交信息一张带签名的快照封面tag指向某个 commit 的具名引用可附注贴在封面上的标签关键点在于 blob 里不存文件名。文件名存在 tree 里。所以你把a.txt改名成b.txt内容没变Git 不会产生新的 blob只会产生新的 tree。这也解释了为什么 Git 不记录“重命名”这个动作本身——它是通过比对前后两个 tree 推断出来的推断不出来时就当成“删一个加一个”。每个对象都用内容算哈希默认 SHA-140 位十六进制也支持 SHA-256 仓库64 位。哈希由内容决定内容一样哈希就一样。这带来一个很硬的性质历史不可篡改。你改了任何一个历史提交的内容它的哈希就变了后面所有提交的父指针全部对不上。所以“悄悄改历史”在 Git 里做不到只能重写出一段新历史。2.2 工作区、暂存区、版本库add 和 commit 之间隔了一层这是新手最容易忽略的一层。很多人以为git add是“标记要提交”git commit是“提交”中间没有实体。实际上中间有一个真实存在的文件.git/index也就是暂存区。数据流是这样的你在工作区改文件这份改动只存在于磁盘上Git 完全不知道。git add把工作区当前的内容写成一个 blob 存进对象库然后在 index 里记下“这个路径现在对应这个 blob”。git commit把 index 里的内容固化成 tree生成 commit并让当前分支指向这个新 commit。这个设计有个非常实用但也非常容易踩的后果add之后继续改文件提交进去的是add那一刻的版本。我见过不少人add完顺手又改了两行然后commit以为都进去了结果线上跑的还是旧逻辑。排查半天才发现是暂存区里躺着旧内容。git status会明确告诉你“已暂存”和“未暂存”是两份不同的改动养成提交前扫一眼的习惯能省很多事。暂存区的另一个价值是让你能挑着提交。你一次改了三件事——修 bug、改格式、加日志理想情况是拆成三个提交。有了暂存区你可以只add修 bug 涉及的那几个文件甚至用git add -p按代码块挑选先提交一个干净的修复再提交格式改动。这个能力在排查问题时价值极高因为将来git bisect能不能定位准确取决于你的提交是不是“一个提交一件事”。2.3 HEAD、分支、引用指针套指针HEAD是一个特殊的引用正常情况下它指向当前分支当前分支再指向某个 commit。所以“HEAD 在哪个提交上”这件事是间接的HEAD → 分支 → commit。分支本身简单到让人意外.git/refs/heads/下面一个以分支名命名的文本文件内容就是那个 commit 的 40 位哈希加一个换行。创建分支就是写一个几十字节的小文件所以它几乎是零成本的。这也是为什么 Git 鼓励你“为一个改动建一个分支”因为它真的不花钱。HEAD不指向分支而是直接指向某个 commit 时叫分离头指针detached HEAD。这种状态常见于你 checkout 了某个历史提交或者某个 tag。在这个状态下提交新提交不属于任何分支切走之后很容易“找不到”。补救办法是记住哈希或者用后面要讲的reflog把它捞回来再建个分支接住。除了分支还有几类引用值得知道refs/remotes/origin/*是远程分支在本地的一份只读镜像你自己不能直接提交上去只能通过push/fetch更新refs/tags/*是标签refs/stash是暂存起来的改动。理解“引用就是指向提交的指针”你就不会再把“分支”和“文件夹”混为一谈了。3. 本地仓库从零到第一次提交最小闭环和撤销的三档退路真正让人卡住的往往不是概念而是第一次动手时的几个细节身份没配、换行符乱了、中文文件名显示成一串八进制、提交完发现提交错了不知道怎么退。这一节把从init到能正常工作的最小闭环走一遍重点放在那些文档里一句话带过、实际会咬人的地方。3.1 初始化配置这几项不设后面一定返工装完 Git 第一件事是配身份因为每个提交都要记录作者没配的话在某些环境下提交会直接失败在另一些环境下会用一串自动生成的占位信息事后想把作者改回来非常麻烦。git config --global user.name 你的名字 git config --global user.email 你的邮箱用--global写进用户级配置对这台机器上所有仓库生效。如果某个仓库要用不同身份比如工作项目和个人项目分开进到那个仓库目录执行不带--global的版本会写进仓库的.git/config优先级更高。第二个是换行符。Windows 用 CRLF类 Unix 系统用 LF。不做处理的话同一份文件在不同系统上会显示“整个文件都改了”diff 全是噪音冲突排查难度直接翻倍。实践中的配置是# Windows git config --global core.autocrlf true # macOS / Linux git config --global core.autocrlf inputtrue的意思是“检出时转成 CRLF提交时转回 LF”仓库里永远存 LF。input是“提交时转成 LF检出时不转”。这样仓库里统一是 LF跨平台协作就不会因为换行符产生假改动。第三个是中文文件名显示。默认配置下中文路径在git status里会被显示成\344\270\255\346\226\207.txt这种八进制转义不是乱码是 Git 为了兼容性做的转义。关掉它git config --global core.quotepath false第四个是默认分支名。新版 Git 支持git config --global init.defaultBranch main避免每次init都出现一条“默认分支名即将变更”的提示也避免团队里一半人用 master 一半人用 main。最后顺手设一下默认编辑器git config --global core.editor vim或者你习惯的任何编辑器。不设的话遇到需要写多行提交信息或者交互式 rebase 的场景会弹出一个你不认识的编辑器很多人就是在这一步被卡住然后直接关窗口的。3.2 init、add、commit、status、log五条命令的闭环mkdir demo cd demo git init echo hello readme.md git status # 看到 readme.md 是 untracked git add readme.md git status # 变成 changes to be committed git commit -m 初始化项目说明 git log --onelinegit status值得单独说它是你用得最多的命令没有之一。它会明确区分“已暂存的改动”“未暂存的改动”“未跟踪的文件”三类。我建议在任何一次commit、push、reset之前都先跑一遍成本一秒能挡掉大量误操作。git log默认输出很啰嗦加参数会舒服很多git log --oneline --graph --decorate --all--oneline一行一个提交--graph画出分支合并的形状--decorate显示分支和标签名--all显示所有分支而不只是当前分支。这四个参数组合起来基本就是你日常看历史的标准姿势。git diff有三个常用层次很多人一直没分清命令比较对象典型用途git diff工作区 vs 暂存区看还没 add 的改动git diff --staged暂存区 vs 最后一次提交提交前确认将要提交什么git diff HEAD工作区 vs 最后一次提交看这次改动全貌提交前跑一次git diff --staged是我认为性价比最高的习惯。它让你在按下回车之前确确实实看到“即将进入历史的是什么”。3.3 撤销的三档退路先分清改动在哪一层撤销是新手最慌的地方因为网上答案五花八门照抄一条reset --hard可能直接丢掉半天工作。其实只要先判断“要撤销的东西现在在哪一层”答案就唯一了。第一档改动只在工作区还没 add。想让某个文件回到上次提交的样子git restore readme.md # 老版本写法git checkout -- readme.md注意这条命令会直接丢掉工作区改动不可恢复。跑之前确认这个文件里没有你想要的东西。第二档已经 add 进暂存区想撤出来但保留工作区改动。git restore --staged readme.md # 老版本写法git reset HEAD readme.md这条只动 index工作区文件一个字不变。撤出来之后文件变回“未暂存”你可以继续改。第三档已经 commit 但还没 push。这时用git reset三种模式差别很大模式分支指针暂存区工作区适用场景--soft回退保留保留想重新组织提交改动都还在暂存区--mixed默认回退重置保留想重新 add改动都还在工作区--hard回退重置重置彻底丢弃谨慎使用--hard是唯一会真正丢数据的模式用之前务必确认。真误操作了还有救靠的是下一节要讲的reflog但那是最后一道保险不是常规手段。第四档已经 push 到远程。这时候不要reset再强推因为别人可能已经基于那些提交在工作了。正确做法是git revert它生成一个“反向提交”把之前的改动抵消掉历史往前走所有人都能正常拉取。4. 分支与合并并行开发不靠复制文件夹两个人同时开发两个功能最原始的办法是各自复制一份代码做完再手工合并。这套流程在文件少的时候勉强能用一旦涉及几十个文件、几百行改动合并就变成一场灾难。分支解决的正是这个问题让两条线各自往前走最后用一个明确定义的动作合到一起。4.1 分支便宜到什么程度前面说过分支就是一个存了哈希值的小文件。git branch feature-a这个动作本质上是在.git/refs/heads/下新建一个文件写上当前提交的哈希。磁盘占用几十字节耗时以微秒计。这个事实值得反复强调因为它直接决定了工作习惯。如果你以为建分支是“重”操作你就会倾向于在一个分支上堆所有改动历史上线之后一团乱麻如果你知道它几乎免费你就会自然地“一个任务一个分支”做完就删主干始终保持可发布状态。切换分支用git switch feature-a老版本是git checkout feature-a。切换时 Git 会把工作区内容换成目标分支指向的那个快照。如果两个分支差异很大你会看到大量文件被改写这不是“复制”是“替换”。也正因为如此切换前要保证工作区干净否则 Git 会拦你怕覆盖掉未提交的改动。4.2 merge 的三种结果对应三种处理方式第一种快进fast-forward。你从 main 切出 feature 分支在 feature 上提交了三次期间 main 没有任何新提交。这时候把 feature 合回 mainmain 只需要把指针往前移动三个提交不需要产生新的合并提交。历史是一条直线最干净。如果你希望即使能快进也保留“这里合并过一个功能分支”的痕迹加--no-ffgit switch main git merge --no-ff feature-a这样会强制生成一个合并提交历史图上能清楚看到功能的边界。团队里对这一点通常有约定主流做法是主干合并功能分支时用--no-ff方便日后回溯“这个功能是哪一批提交”。第二种三方合并。两个分支各自都有新提交分叉了。Git 会找到两者的共同祖先把祖先到各自顶端的两组改动合并生成一个有两个父提交的 merge commit。这是最常见的场景。第三种冲突。两个分支改了同一个文件的同一片区域Git 无法判断该保留哪个就会停下来让你决定。冲突不是错误是“需要人来判断”的信号。处理流程后面单独讲。4.3 rebase 和 merge 的取舍整洁度和真实性的权衡同样是把 feature 合进 mainrebase走的是另一条路它把 feature 上的提交一个个“摘下来”重新应用到 main 的最新位置生成一批新的提交哈希全变了。两种方式的差别用一句话概括merge 保留真实的分叉历史rebase 制造一条线性历史。维度mergerebase历史形状有分叉和合并点一条直线提交哈希原提交不变全部重写冲突处理一次性解决可能每个提交都要解决适用场景合入主干、公共分支个人分支同步主干最新代码这里有一条必须记住的红线不要 rebase 已经推送出去、并且别人可能在其上继续工作的分支。因为 rebase 会重写哈希别人本地的提交和远程的对不上一拉取就是一堆莫名其妙的冲突和重复提交。个人分支自己用随便 rebase团队共享的分支只用 merge。配置上有个小技巧可以让pull默认走 rebase避免每次拉取都产生一个毫无意义的合并提交git config --global pull.rebase true4.4 冲突处理的完整动作冲突发生时git status会把文件标成both modified。打开文件你会看到这样的标记 HEAD 当前分支的内容 要合并进来的分支的内容 feature-a处理动作是固定的四步手工编辑把、、三行标记连同你不要的那部分一起删掉留下最终想要的内容。注意是删标记不是留着标记。git add 冲突文件告诉 Git 这个文件已经解决好了。全部冲突文件都 add 完之后git commit完成合并提交rebase 过程中则是git rebase --continue。如果搞乱了想重来git merge --abort或git rebase --abort回到冲突前的状态重新来一遍。实操中有两个经验。一是冲突多的时候别硬啃先用git diff看清楚每个冲突块两侧分别是什么逻辑再决定保留哪个比直接看标记块清楚。二是解决完一个文件就先add一个别攒着git status里both modified的数量减少会让你心里有底。另外可以配git config --global merge.conflictstyle diff3冲突块里会多显示一段共同祖先的内容判断“这行为什么被改成这样”时帮助很大。5. 远程协作GitHub 在这套体系里的位置到这一步所有操作都还在本地。GitHub 的角色是提供一个所有人在网络上都够得着的中央节点让“把我的提交给别人”和“把别人的提交拿过来”这两件事有个固定场所。它本身不是 Git 的一部分但没有它多人协作的同步成本会高很多。5.1 remote、fetch、pull、push四条通道各管什么git remote add origin 地址是给远程仓库起个名字origin只是默认约定你叫它什么都行。这个名字会被用来组织本地的远程分支引用也就是refs/remotes/origin/*。git fetch是最容易被低估的命令。它只做一件事把远程的新提交下载到本地更新origin/*这些引用不碰你的工作区和当前分支。也就是说 fetch 是绝对安全的跑一百次也不会丢东西。想看远程有什么新东西又不想被打断手头工作就 fetch。git pull等于fetch加一次合并或 rebase取决于配置。它方便但把两步合成一步出问题时不好定位。我个人的习惯是重要操作前先fetch看一眼确认远程有什么变化再决定怎么合。git push是把本地分支的提交送到远程。基本形式是git push origin 分支名。第一次推送一个新分支时加-u建立跟踪关系之后就可以直接git pushgit push -u origin feature-a还有一条经常被忽略的push 不是同步工作区是同步提交。你本地有未提交的改动push 不会把它们带上去你本地有提交但没 push别人也看不到。所谓“我改了怎么同事那边没变”九成是这两件事之一。5.2 认证失败和权限报错的排查顺序push 报错的场景非常多按这个顺序查基本能覆盖报错关键词大概率原因处理方向Permission denied (publickey)SSH 密钥没配或没加载检查密钥是否存在、是否加到平台账号Authentication failed密码/令牌错误或已过期重新生成访问令牌清理本地凭据缓存403 Forbidden账号对该仓库没有写权限确认是否被邀请为协作者、分支是否受保护rejected (non-fast-forward)远程有你本地没有的提交先fetch再合并不要直接强推failed to push some refs同上或触发了分支保护规则看完整报错信息通常会写明具体规则SSH 密钥的配置流程不复杂关键是别把私钥泄露出去ssh-keygen -t ed25519 -C 你的邮箱 # 一路回车默认生成在 ~/.ssh/ # 把 ~/.ssh/id_ed25519.pub 的内容贴到平台账号设置里non-fast-forward这个报错值得单独说。它的意思是“你推的这条线不是远程当前状态的直接延续”通常发生在别人先推了提交。正确做法是先git pull或fetchmerge把远程的合并进来解决可能的冲突再推。直接push --force是错误做法除非你百分之百确认这个分支只有你一个人在用而且你清楚会覆盖什么。5.3 分支策略从“主干 短分支”开始分支模型的花样很多但对绝大多数团队一套简单的规则就够用主干main始终保持可发布状态任何改动都从主干切出短生命周期的功能分支通过 Pull Request 合回去。Pull Request简称 PR的价值不在“合并”这个动作——合并本地一条命令就能做。它的价值在于三件事代码评审让别人在合并前看一眼自动化检查CI 跑测试、跑静态检查不通过就合不了讨论留痕为什么这样改、当时考虑过哪些方案都记在这条 PR 里半年后回头看还在。配套的几条实践经验分支要短。一个功能分支活超过一周跟主干的差异就会大到难以评审冲突也会变多。切小、快合。提交要小。一个提交只做一件事提交信息写清楚“为什么改”而不只是“改了什么”。fix: 修复空列表时的越界比update有用一百倍。合并前先同步主干。合并前把主干最新代码合进来在本地把冲突解决掉别把冲突留到 PR 页面上。保护主干。把 main 设成受保护分支禁止直接推送必须走 PR 并且通过检查。这条规则挡住的事故比任何口头约定都多。5.4 .gitignore 和“已经提交了不该提交的文件”.gitignore的规则很直白写在里面的路径Git 不会主动跟踪。常见内容就是构建产物、依赖目录、本地配置文件、日志、编辑器临时文件。但这里有个坑几乎人人都踩过.gitignore只对未被跟踪的文件生效。如果一个文件已经被提交过你后来把它加进.gitignoreGit 依然会继续跟踪它改了照样出现在git status里。正确做法是先把它从索引里移除但保留本地文件git rm --cached config.local.js echo config.local.js .gitignore git commit -m 停止跟踪本地配置文件注意--cached这个参数没有它会把本地文件也一起删掉。更麻烦的情况是敏感信息或超大文件已经进了历史。这时候光删文件不够因为历史提交里还留着。处理方式有两类一是用git filter-repo之类的工具重写历史把那个文件从所有提交里剔除二是如果影响不大接受历史里留着但立刻更换泄露的凭据。前者会重写所有提交哈希所有协作者都必须重新克隆属于“要提前打招呼”的操作别一个人闷头干。6. 三个救命命令出事了先别慌Git 之所以让人又爱又恨很大一部分原因是“看起来会造成不可逆后果”的操作太多。实际上除了极少数情况比如从未提交过的工作区改动被--hard覆盖绝大多数“事故”都能靠这三条命令救回来。6.1 reflog所有指针移动的黑匣子reflog记录的是 HEAD 和分支引用的每一次移动包括提交、切换分支、合并、重置、变基。默认保留 90 天。它的价值在于只要那个提交对象还在对象库里你就能找回来。最典型的场景手滑git reset --hard回退了三个提交工作区也被覆盖了心一凉。这时候git reflog # 输出类似 # a1b2c3d HEAD{0}: reset: moving to HEAD~3 # e4f5g6h HEAD{1}: commit: 加入缓存层 # ... git reset --hard e4f5g6h一步回到误操作之前。同理误删的分支也能救git reflog # 找到那个分支最后一次提交的哈希 git branch 恢复的分支名 e4f5g6hreflog 是纯本地的不会推送到远程所以它记录的是“你在这台机器上的所有动作”。换台机器就看不到了这是它唯一的局限。6.2 bisect二分定位“哪次提交引入了问题”项目有八百个提交某个功能在上个月的某个版本还是好的现在坏了但不知道是哪次改坏的。靠人肉一个个 checkout 去试成本高到没人愿意做。git bisect用二分查找把范围从八百次缩到十次左右。git bisect start git bisect bad # 当前版本是坏的 git bisect good v1.2.0 # 这个 tag 是好的 # Git 会自动切到中间某个提交你测试一下 git bisect good # 或 git bisect bad # 重复大约 log2(800) ≈ 10 次 git bisect reset # 结束回到原来的分支如果问题可以用一条命令验证比如跑某个测试脚本可以全自动git bisect run ./check.sh脚本返回 0 表示这个提交正常非 0 表示有问题Git 会自己跑完所有轮次最后直接告诉你“第一个坏提交是哪个”。有两个前提条件必须满足否则结果不准第一问题必须是可稳定复现的偶发问题 bisect 会给出错误答案第二提交历史要相对干净如果一个提交里混了五件事bisect 只能告诉你“是这一批改动里出的问题”还得再拆。6.3 stash 和 cherry-pick临时寄存与跨分支搬运stash解决的是“手头改到一半突然要切分支处理别的事”。直接切会被拦工作区不干净提交又不想交半成品。这时候git stash push -m 登录页改到一半 git switch hotfix # 处理完紧急问题 git switch - git stash poppop是“取出并删除记录”apply是“取出但保留记录”。git stash list看所有寄存的条目。一个必须知道的坑默认的stash不包含未跟踪文件。你新建了一个文件还没add直接stash的话它不会被存进去切分支时它还在工作区待着。要连未跟踪文件一起存用git stash push -u。这个细节不知道的话很容易出现“我明明 stash 了怎么文件还在”的困惑。cherry-pick是把某一个特定提交“复制”到当前分支git cherry-pick a1b2c3d典型场景你在开发分支上顺手修了一个线上 bug 并提交了现在需要把这个修复也放到发布分支上但不想把开发分支上其他半成品带过去。cherry-pick 只搬那一个提交。它会生成一个新的提交哈希不同如果遇到冲突处理方式和 merge 一样。6.4 一张表记住“出事了该用哪个”症状用什么关键点误 reset / 误删分支reflog提交对象还在就能救默认 90 天不知道哪次提交引入 bugbisect问题必须稳定复现改到一半要切分支stash未跟踪文件要加-u只搬某一个提交cherry-pick会生成新哈希已推送的提交要撤销revert生成反向提交不改历史提交信息写错了未推送commit --amend会改哈希--amend这条也顺便提一下。刚提交完发现漏了一个文件或者提交信息有错字git add补上文件后git commit --amend就能改写最后一次提交。但如果已经 push 了amend 会改变哈希在共享分支上会造成别人的历史对不上得用push --force-with-lease且提前沟通。--force-with-lease比--force安全它会在远程有你不知道的新提交时拒绝推送。7. 团队协作里那些文档不会写的坑命令会用了、流程跑通了并不代表不会出事。真正让项目难受的往往是几个长期积累的问题它们不会立刻报错而是在某一天集中爆发。7.1 换行符和文件权限两个隐形的 diff 制造机换行符前面讲过配置这里说它造成的后果。团队里如果 Windows 和 macOS 混用而没人统一core.autocrlf你会看到这样的现象某个人只改了一行代码diff 显示整个文件都被重写。评审的人直接崩溃冲突也特别难解。补救办法是仓库根目录放一个.gitattributes把规则写进仓库而不是依赖每个人的本地配置* textauto *.sh text eollf *.bat text eolcrlftextauto让 Git 自动判断文本文件并统一存 LF后面两行是针对特定类型的强制规则。这个文件提交一次所有人克隆下来就生效比口头通知靠谱。文件权限是另一个隐形来源。Git 只记录“是否可执行”这一个权限位不记录其他权限。但如果跨系统尤其是涉及挂载盘或者不同文件系统filemode的判定可能不一致导致文件内容没变却显示为已修改。遇到这种莫名其妙的改动检查git config core.filemode必要时设成 false。7.2 提交粒度为了三个月后的自己提交粒度这件事短期看不出差别三个月后差别巨大。一个“一次改二十个文件、提交信息写 update”的历史等于没有历史。出了问题bisect定位不到blame查出来是个大杂烩回滚也不知道该回滚哪一部分。实践标准其实很简单一个提交对应一个可以独立描述的改动。判断方法是想一下“如果把这个提交单独回滚项目会不会坏”。如果会说明它混了不相关的改动应该拆。提交信息建议约定一个简单格式不一定要上完整的规范类型: 一句话说明 可选的正文说明为什么这么改、有什么取舍类型用feat、fix、refactor、docs、chore这几个就够。关键是正文写“为什么”因为“改了什么”diff 里已经有了代码本身能说明而“为什么”只存在于你的脑子里不写下来就永久丢失了。7.3 强推覆盖别人的提交最常见也最伤人的事故事故场景是这样的A 在共享分支上git push --force把 B 刚推上去的两个提交冲掉了。B 第二天pull发现自己的提交没了但本地还在一通折腾才恢复。更糟的情况是 B 本地也做过 reset那就真丢了。预防这件事靠三层第一层是保护分支在平台上把主干和发布分支设成禁止强推第二层是用--force-with-lease替代--force它会在远程有你没见过的提交时拒绝执行第三层是约定共享分支不做 rebaserebase 之后必然要强推源头就断了。真发生了也有救。被覆盖的提交只要还在某个人的本地 reflog 里就能重新推上去。所以事故发生后第一件事是让所有人先别动、别清理把现场保住。7.4 仓库体积失控二进制文件和大文件的历史包袱Git 对文本文件的压缩和增量存储做得很好但对二进制文件基本无能为力。一个 50MB 的安装包提交十次仓库就多出 500MB而且每次克隆所有人都要下这 500MB。几个月后仓库大到 clone 要十分钟问题就很难逆转了——因为它在历史里。预防比补救容易得多。设计文件、编译产物、数据集、依赖包一律进.gitignore需要共享就走专门的存储或者发布附件。真要有大文件必须进仓库用 Git LFS它把大文件内容存在别处仓库里只留指针。已经发生了怎么办用git filter-repo从历史里剔除那个路径然后所有人重新克隆。这个操作会重写所有哈希必须提前通知、约定时间窗口做完之后所有人的旧克隆都不能再用了。所以能早拦就早拦别等到仓库几十个 G 才想起来处理。7.5 分支不删另一种形式的仓库腐烂最后说一个不起眼但很普遍的问题功能分支合完了没人删。半年下来远程躺着两百个分支git branch -a输出刷屏谁也不知道哪个还活着。约定很简单PR 合并时勾选删除源分支。本地分支定期清理git branch --merged main | grep -v main | xargs git branch -d这条命令会删掉所有已经合并进 main 的本地分支-d是安全删除没合并的会拒绝。远程分支的清理用git push origin --delete 分支名或者直接在平台上批量处理。我自己在实际操作中的体会是Git 的学习曲线陡峭期其实很短卡人的从来不是命令数量而是“不知道现在数据在哪一层”。一旦你把工作区、暂存区、版本库这三层和“引用就是指针”这件事想通了剩下的命令都是在这几个东西之间搬来搬去遇到没见过的也能猜个八九不离十。真正需要时间积累的是习惯——提交前看一眼diff --staged、一个提交只做一件事、共享分支不强推这几条坚持下来能挡掉的麻烦比学会一百条命令都多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。