首页 / 资讯中心 / 文章详情

git add 不只是添加文件:暂存区原理、拆分提交与回退实践

git add 不只是添加文件:暂存区原理、拆分提交与回退实践 ★ FEATURED ARTICLE
一次代码评审我看到一个提交把「修 Bug、调格式、加日志、顺手改了配置」四件事塞在一起diff 拉了三百多行只好逐行猜意图。问了才发现不是他不想拆而是他只会git add .所有改动一次性进了暂存区后面想拆也无从下手。这个场景我见过太多次git add是所有人学 Git 敲的第一条命令也是最容易被跳过细节的一条。它真正管的不是「把文件提交上去」而是决定下一次提交里究竟装什么内容——这一步做对了提交粒度、评审体验、回滚成本全都跟着变好。这篇就把git add从「把文件加进来」那句话往下拆讲清楚它动了哪份数据、路径参数怎么影响范围、怎么把一团改动切碎,以及误 add 之后怎么收场适合刚上手 Git 的读者也适合天天敲但没细看选项的人。1. 暂存区到底存了什么从 .git/index 看 add 的真实产物1.1 三棵树模型与 index 的物理形态很多人把「暂存区」当成一个抽象概念其实它就是一个实实在在的文件.git/index。它是一份二进制表每行记录一个路径的元信息包括文件模式普通文件 100644、可执行 100755、符号链接 120000、子模块 160000、内容对应的 blob 对象哈希、暂存阶段号正常是 0冲突时是 1/2/3外加一组 stat 缓存字段inode、大小、修改时间等。Git 靠这些 stat 字段判断「工作区文件有没有变」这也是为什么大仓库里git status有时慢得像卡住——它要遍历工作树并对每个文件做一次 stat 比对。于是仓库里其实存在三份内容HEAD指向的最后一次提交、.git/index描述的待提交快照、以及工作目录里你正在编辑的磁盘文件。git add的工作就是把工作区某个路径的当前内容写成 blob 对象塞进对象库再把 index 里对应的那一行更新成新哈希。所以 add 不是「标记」它真的往仓库里写东西了——只是写进去的是对象不是提交。理解了这点很多现象就顺了add 之后删掉源文件git commit照样能把内容提交上去因为内容早就在.git/objects里了。你可以亲手验证一遍。随便改一个已跟踪文件然后执行git add src/main.js git ls-files -s src/main.js输出大致长这样100644 3f786850e387550fdab836ed7e6dc881de23001b 0 src/main.js三列分别是模式、blob 哈希、阶段号。接着用git cat-file -p 3f78685就能把暂存的内容原样打出来跟你工作区那份是不是一致一目了然。这个动作我建议每个新人都做一次比看十篇解释「暂存区长什么样」的文章都直观。1.2 一次 add 背后的对象写入顺序git add内部并不是简单地覆盖一行记录它的顺序大致是读取文件内容 → 计算blob size\0content的 SHA-1或 SHA-256看仓库初始化时的配置→ 若对象库中不存在该哈希则压缩写入.git/objects/xx/yyyy...→ 更新.git/index中该路径的条目 → 必要时重写整个 index 文件Git 用的是「写临时文件再原子重命名」的方式所以中途断电通常不会把索引彻底搞坏。这个顺序解释了两个常见疑问。第一为什么同一个文件反复 add 不会产生无数垃圾对象因为 Git 是内容寻址的内容没变哈希就不变对象库不会重复存储。第二为什么 add 了又改回来git status显示的还是「已暂存」因为 index 里存的是你 add 那一刻的内容快照工作区后来怎么改都不影响它只会让「未暂存」那一栏多出一份差异。如果这两栏同时出现同一个文件说明你的 index 和工作区已经分叉了这时候要先想清楚是「再 add 一次覆盖暂存」还是「撤销暂存」别闭着眼乱敲。1.3 为什么改完文件不 add 就 commit 不进去这个问题听着傻但真的有人踩改完文件直接git commit -m xxx结果提示 nothing to commit回头一看文件确实改了。原因是git commit构建提交树时读的是index不是工作区。index 里那份还是旧的提交自然没有变化。想绕过这一步只有两个办法老老实实 add或者用git commit -a——后者的语义是「先对所有已跟踪文件执行一次类似git add -u的动作再提交」。commit -a用起来爽坑也在这儿它会把暂存区里你精心拆分的内容一并覆盖掉。我见过有人用git add -p挑了半天只把第一段逻辑放进暂存区然后顺手敲了git commit -a -m fix结果三段逻辑一起提交白拆。所以我的习惯很固定一旦开始拆提交这一轮就坚决不用-a。这不是风格问题-a会把你前面所有暂存工作直接抹平。2. 路径怎么写决定了 add 什么.、-A、-u的行为边界2.1 三种写法的行为差异git add .、git add -A、git add -u是询问率最高的三个写法差异主要在「覆盖哪些文件」和「作用范围」两个维度上。先看对照写法新文件修改删除默认作用范围git add path是是否2.x 中指定路径时也不记录删除指定路径git add .是是是Git 2.0 起当前目录及子目录git add -A是是是整个工作树git add -u否是是整个工作树不带路径时git add --no-all .是是否当前目录关键的历史包袱在于Git 2.0 之前git add .是不记录删除的你rm掉一个文件再git add .删除操作会留在工作区状态里提交上去文件还在。2.0 之后统一了行为.和-A都包含删除区别只剩范围。如果你的环境里有老版本 Git某些长期不升级的构建机很常见脚本里写git add .就得小心最稳的写法是显式写git add -A .。我自己的默认选择是在仓库根目录干活用git add -A在子目录里改局部逻辑就用git add .因为它天然把范围限制在当前子树内不容易把隔壁模块的临时改动带进去。这个习惯救过我几次——某次在packages/api里调试随手-A把根目录一个临时脚本也提交了虽然只是多一个文件评审时却得额外解释一轮。2.2 用--分隔路径避免歧义有个很隐蔽的坑当你的分支名和文件名撞了Git 可能分不清你要的是哪个。比如仓库里有个分支叫release同时有个目录也叫release你敲git add releaseGit 会先按路径解释——大多数情况没问题但在git checkout、git log这类命令里歧义很明显。统一的做法是路径前加--git add -- release/--之后的参数一律按路径解释不再当选项或引用名。这条规则对所有 Git 命令都适用养成习惯之后脚本的健壮性会明显提升。同理文件名以-开头时虽然罕见也必须靠--隔开否则会被当成选项解析报一堆看不懂的错。2.3 pathspec 魔术字把 add 的范围精确到「除了某些文件」Git 的路径参数支持一套叫 pathspec magic 的前缀用于表达「排除」「大小写不敏感」「从根目录算起」这类语义。日常最有用的是排除git add :(exclude)*.min.js . git add :(exclude)vendor/ :(exclude)*.lock .注意引号不能省括号和星号会被 shell 先吃掉。另一个好用的是:(glob)让*不再跨目录匹配git add :(glob)src/*.js # 只匹配 src 下的一层不进子目录还有:(icase)用于大小写不敏感匹配、:(top)让路径从仓库根算起在子目录里特别有用。这些写法不是炫技实际价值在于把「我想提交什么」写成可复现的表达式而不是靠人肉记得「刚才我好像只 add 了这三个文件」。如果你经常在同一棵树上同时改业务代码和生成产物把排除规则固化进脚本或别名比每次手抖靠谱得多。2.4 删除、重命名与 add 的关系git add -A会记录删除但不是所有「删除」都是你以为的那种删除。三种情况要分清你rm了文件git add -A或git add -u会把删除登记进 index下次提交就是真删。你用git mv改名这本身就是mvgit rm --cachedgit add的组合index 里会体现为「删一个、加一个」Git 在 diff 时按内容相似度识别成重命名git diff -M或log --follow才能看到 R 状态。你在文件系统层面改名mv a b这时git status会显示deleted: a和untracked: b必须git add -A两件事才会一起进暂存区。只 add 新文件的话旧文件仍然保留在仓库里等于多了一份冗余。第三种情况最容易出问题。有人在 IDE 里重命名了几十个文件然后只git add .新路径提交上去旧文件还在编译直接炸。稳妥做法是重命名之后先git status看一眼有没有deleted条目确认没有遗留再提交。这个检查只要三秒能省掉一次返工。3. 把一坨改动切成三个提交-p补丁级暂存的完整链路3.1-p的交互界面与 hunk 拆分git add -p等价于git add --patch是我认为 Git 里性价比最高的一个功能。它把每个文件的差异切成一堆 hunk逐个问你「这块要不要进暂存区」。常用按键按键含义y暂存这个 hunkn跳过这个 hunkq退出已做的选择保留a暂存本文件剩余所有 hunkd跳过本文件剩余所有 hunks把当前 hunk 拆成更小的块e手工编辑当前 hunk 的补丁?帮助实操里最有价值的是s。Git 默认按「上下文行间隔」切 hunk两处改动如果挨得近默认上下文 3 行内会被合并成一个 hunk。按s就尝试从这个大块里再切开。但如果改动行本身紧挨着s会告诉你「无法拆分」这时候只能上e。举一个我常遇到的场景一个函数里既改了参数校验又加了一行日志两行相邻。想拆成两个提交s拆不动就按e进入编辑器看到类似这样的内容 -12,7 12,9 function createOrder(payload) { if (!payload) { - throw new Error(empty); throw new Error(payload is empty); } logger.info(createOrder called); - } return doCreate(payload);我要做的是把日志那一行删掉只留校验的改动然后保存退出。这样暂存区只拿到第一处改动第二处仍在工作区可以再 add 一次单独提交。手工编辑补丁的本质是「造一个你想要的中间状态」只要保证剩下的行号和上下文还能对上Git 就接受。3.2 编辑补丁时容易翻车的几个点e用起来爽但失败率不低最常见的三种翻车把上下文行也改了。以空格开头的行是上下文以-/开头的是增删改动上下文行会导致补丁对不上Git 直接拒绝。行数头没同步。 -12,7 12,9 里的数字是旧文件起止行数和新文件起止行数删掉一行之后新文件的行数要相应减一。严格说 Git 对行数头的容忍度还行但差太多就会报patch does not apply。连着改多个 hunk。一次只编辑一个 hunk改完一个确认一个别贪多。如果编辑完之后报错退出别慌暂存区没被破坏git status还是原样重来一遍就行。我踩过一次更隐蔽的编辑器里手滑把行尾的换行删了结果整个 hunk 被当成一个超长行Git 提示补丁格式错误。所以用e的时候建议关掉编辑器的自动格式化纯文本模式改。3.3-i交互菜单与-N预告登记git add -i是-p的「大而全」版本进去是一个菜单status、update、revert、add untracked、patch、diff、quit。它比-p多了两个实用能力一是能把「未跟踪文件」列表拉出来挑着加add untracked二是能对已经暂存的内容做revert反向操作把某些 hunk 从暂存区撤出来。在改动特别杂的时候用-i一次性浏览全貌比反复敲-p舒服。另一个冷门但好用的选项是git add -N file--intent-to-add。它的效果是在 index 里登记这个新文件但内容留空。带来的直接好处是git diff终于能看到新文件的差异了——默认情况下未跟踪文件在git diff里是隐身的加了-N之后它会以「全新增行」的形式出现你就能用git add -p对新文件做分块暂存了。这个技巧在「新建一个大文件但只想先提交其中一部分」时特别管用。撤销也很简单git restore --staged file或者git reset file都能把这条预告登记去掉。4. 误暂存之后回退路线与索引修复4.1 撤销暂存的两种写法及版本差异git reset path是老写法git restore --staged path是 Git 2.23 引入的新写法语义更清晰。两者效果在大多数场景一致把 index 里该路径恢复成HEAD的样子工作区文件不动。区别在于reset顺手还能移动分支指针带--hard时更危险而restore --staged只动索引边界更干净。我在脚本里一律用restore --staged因为它不会因为少敲一个参数就把提交历史改了。有一个高频困惑必须说清楚如果文件是被删除的restore --staged只恢复索引不恢复文件。举个具体例子你rm src/old.js然后git add -A把删除登进了暂存区现在想反悔git restore --staged src/old.js # 索引恢复但文件仍是删除状态 git restore src/old.js # 这一步才把文件从 HEAD 拿回来第一条执行完git status会显示deleted: src/old.js未暂存第二条执行完才彻底复原。很多人只做了第一条就以为完事了结果提交时发现文件没了。判断标准很简单看git status里这个文件出现在哪一栏红色「未暂存」就还得再 restore 一次绿色「已暂存」才是索引层面的问题。4.2 误把大文件或敏感配置 add 进去的处理顺序这是我最想强调的一段因为顺序错了会很麻烦。假设你git add -A之后发现把一个几百 MB 的数据文件加进去了正确的处理顺序是先撤暂存别提交git restore --staged data/big.bin。立刻加进.gitignore否则下次-A又会被带进来。确认没进过任何提交git log --all -- data/big.bin输出为空才安全。只要没提交三步搞定对象库里那几个 blob 会在git gc时被回收。一旦提交了事情就变成另一回事对象已经进入历史单纯删文件没用需要重写历史filter-repo之类这是团队级操作得协调所有人重新拉取。所以我的习惯是在add和commit之间强制加一道检查提交前跑一次git diff --cached --stat看一眼文件数量和体积。这个命令输出很短扫一眼只要两秒但拦住过好几次事故。至于敏感配置本地密钥、连接串处理逻辑一样但要额外确认它有没有出现在别的分支上。有时候主分支干净某个特性分支里早就提交过了只清理当前分支等于没清。4.3.gitignore对已跟踪文件为什么无效这条坑几乎人人都踩明明写进了.gitignoregit status里那个文件还是顽固地显示为已修改git add .也照样把它带进去。原因很直接——.gitignore只管「未跟踪文件要不要被自动发现」不管「已被跟踪的文件」。文件一旦进过 indexignore 规则对它就没有约束力了。解法是先把它从索引里摘出来但保留工作区文件git rm --cached config/local.env然后提交这次「删除」。注意这个操作在别人拉取时会把文件从他们的工作区删掉因为他们那边是已跟踪状态所以要么提前打招呼要么用git rm --cached之后立刻在文档里写清楚「本地需要自己重建这个文件」。我见过更稳妥的做法是仓库里放一份local.env.example真正的那份从一开始就不入库用初始化脚本生成能省掉后面所有麻烦。顺带说一个相关细节git add -f file可以强制添加被 ignore 的文件。它的正确用途是「这个文件平时该忽略但这次我确实要提交一份」比如模板文件、锁文件的一次性更新。用完记得心里有数下次-A它不会自动跟着走——因为它已经在 index 里了ignore 对它失效反而会一直被跟踪。逻辑有点绕记住一句话就行ignore 决定「能不能被发现」index 决定「是不是被管理」两者是两道独立的闸门。5. 那些很少用但关键时刻救命的选项5.1--dry-run、--verbose与--ignore-errorsgit add -n--dry-run只打印「将会添加什么」不真的动索引。在不确定-A会波及多大范围时先跑一次-n是最省事的确认手段输出就是一串add path/to/file。我把它当成 add 的「预览模式」尤其在清理遗留分支、准备一次性提交大量改动之前必跑。git add -v--verbose会边加边打印每个文件适合和-n配合看清细节。--ignore-errors则是「遇到加不进去的文件也继续往下走」——典型场景是工作区里有权限不足或路径过长的文件导致整条git add -A直接中断。加上这个选项其他文件能正常进暂存区最后 Git 会把出错的那几个列出来你单独处理。CI 脚本里这个选项值得加上避免一个坏文件卡死整条流水线。5.2--chmod、--renormalize与权限位Git 只记录一个权限位可执行与否100644 vs 100755。所以「文件权限变了」在 Git 眼里只有两种结果变了或者没变。如果你在跨平台环境里发现脚本提交后丢了执行权限可以用git add --chmodx scripts/build.sh反过来是--chmod-x。这个选项比chmod x再 add 更直接因为它把「模式变更」显式写进了操作里不受本地文件系统 umask 影响。--renormalize解决的是换行符问题。当仓库里已经有了一批 CRLF 污染的文件改了.gitattributes之后想让它们按新规则重新入库就得靠git add --renormalize .它会对所有已跟踪文件重新应用一次「clean」过滤器也就是把工作区内容按textauto之类的规则规范化然后更新索引。这个操作会一次性产生大量「仅换行符变化」的改动提交时建议单独成一个 commit并在提交信息里写清楚否则评审的人会以为你疯了。5.3 符号链接、子模块与空目录三个特殊对象在 add 时的行为值得单独记一笔符号链接Git 存的是链接指向的字符串不是目标内容模式是 120000。跨平台同步时 Windows 上可能变成普通文本文件这时候 add 进去会把别人的链接破坏掉遇到git status里出现意料之外的 mode change先查这个。子模块git add sub记录的是一个 gitlink模式 160000指向子模块的某个 commit。所以子模块里改了东西父仓库的 add 只会体现「指针变了」不会带上子模块内部的代码改动。想让子模块改动也进版本得先进子模块提交。空目录Git 根本不跟踪目录只跟踪文件。所以「建了个空目录想提交」是不可能的必须往里面放一个.gitkeep之类的占位文件。这条规则简单但新人第一次遇到时还是会愣一下。把这些边角搞明白你在别人仓库里看到诡异的 mode 变化、突然冒出来的160000条目时就不会一脸茫然了。这些不是我编的教科书条目每一条我都至少在生产仓库里撞过一次。6. 把 add 变成提交质量的第一道闸门6.1 提交粒度其实是在 add 阶段定下来的有个认知上的偏差很普遍很多人以为「提交拆得细不细」是 commit 阶段的事写提交信息时才考虑怎么描述。实际上粒度在 add 阶段就锁死了——你把什么放进暂存区提交里就只能有什么。等敲git commit的时候再想拆唯一的办法是reset重来。所以我在带人的时候第一课不是教怎么写提交信息而是教怎么用-p把一次改动切成三份。判断标准我给一个特别土的办法如果提交信息里出现了「和」「顺便」「以及」这类词说明该拆了。一个提交只做一件事评审的人能在三十秒内看懂回滚的时候能精准摘掉git log看起来才像一份变更记录而不是流水账。具体怎么落地我通常按这个顺序走先扫git status列出所有改动 → 按「属于哪个逻辑单元」分类 → 对每类用git add 具体路径或-p精确暂存 →git diff --cached自查一遍 → 提交 → 重复。走熟之后一轮下来也就多花两三分钟但它换来的是整个团队 review 效率的提升。6.2 与钩子、暂存区自查的配合pre-commit钩子在git commit时运行而它检查的对象正是暂存区内容不是工作区。这个特性经常被人误解你在工作区里修好了 lint 报错但如果没重新git add钩子跑的还是 index 里那份旧代码照样报错。所以钩子失败之后的标准动作是「改文件 → 重新 add → 再 commit」而不是「再 commit 一次」后者必然是同样的失败。更麻烦的一种情况是钩子自动修改了文件比如格式化工具自动 fix。这时候暂存区和工作区会分叉你需要把修复后的内容重新 add 进去。我的习惯是钩子失败后先git status看到同一个文件同时出现在「已暂存」和「未暂存」两栏就说明是这么回事直接git add -u补一刀再提交。提交前那道git diff --cached我建议变成肌肉记忆。它跟git diff的区别值得反复强调git diff看的是「工作区 vs 暂存区」git diff --cached看的是「暂存区 vs 最后一次提交」也就是你即将提交的那份内容。只看前者你永远不知道真正要提交的是什么。我有一次就是靠这个命令发现暂存区里混进了一个调试用的console.log本来打算提交的当场摘掉。6.3 一些长期有效的操作习惯最后分享几个我用了很多年、几乎没改过的习惯都是围绕 add 的第一个少用git add .多用显式路径。改动范围明确的时候写git add src/order/比git add .安全得多尤其在同时开着好几个任务的仓库里。.会把你忘记的临时文件一起吞进去而这种「忘记」通常发生在你注意力最分散的时候。第二个建立「暂存区永远是可提交状态」的意识。任何时候git diff --cached的输出都应该是一份完整的、能独立成立的改动。如果中途被打断去处理别的事回来先看一眼暂存区里躺着什么别接着往下敲。第三个善用git stash --keep-index。当你已经拆好了一部分暂存内容突然需要切分支处理紧急问题这个命令能把工作区剩下的改动藏起来同时保留暂存区不动。回来之后git stash pop接着干活暂存的东西一点没丢。这个用法知道的人不算多但在「拆到一半被打断」的场景里几乎是唯一的干净解法。这三条没有一条是 Git 文档里会重点强调的全是踩过坑之后固化下来的动作。git add本身只有那么几个选项真正决定它价值的是你有没有把它当成「组织提交内容」的工具而不是「让文件变绿」的开关。
阅读完成 · 觉得有帮助?
咨询建站