Git 这东西,很多人用了一两年,还是在“黑盒模式”里:add、commit、push 三连,遇到问题就靠搜索引擎和同事救命。尤其是git add .、git commit -m "fix"、git push这套流程一旦出问题,比如误删了分支、提交信息写错、.gitignore明明写了却不生效,很多人就懵了。这篇我打算换个打法,直接带你拆开.git目录,把 Git 的“内脏”看清楚,然后把日常最高频的文件管理操作逐个过一遍。第三篇的内容不是给刚装好 Git 的小白准备的,但如果你已经能完成最基础的提交推送,却总在“稍微深一点”的场景里翻车,这篇应该能帮你跨过那道坎。
1. 为什么第三篇要死磕 .git 目录和文件管理
1.1 把 .git 当黑盒,迟早被它坑一次
我见过太多人把 Git 当成一个“上传下载工具”,从来没打开过项目根目录下那个隐藏的.git文件夹。但 Git 的所有秘密都藏在里面:版本历史、分支指针、暂存区、对象数据库,甚至你每次git commit时记下的提交信息,全在.git目录里。
举一个最常见的翻车场景:在 GitHub、Gitee 或 GitLab 上创建仓库时,很多人会“无脑”勾选“初始化 README”或添加.gitignore。之后你在本地git init再关联远程,立刻遇到fatal: refusing to merge unrelated histories。如果你不理解.git里记录的是两套完全独立的历史,你根本想不通为什么“明明是一个仓库却合并不了”。类似这种问题,只要把.git目录拆开看一次,原理就非常直观。
再比如git clone时,很多人以为是把“网页上的文件”拉到本地,其实clone是把你远程仓库的.git完整复制一份,然后 git 在本地重建工作区。这个认知一旦建立,你就明白为什么 clone 下来的仓库,git log能看到所有历史。Git 之所以叫“分布式版本控制”,核心就是这个.git仓库可以独立存在、独立工作,不依赖服务器。
1.2 文件管理是日常最高频的 Git 操作
如果你留意过自己的操作频率,会发现大部分 Git 命令最终都在处理“文件”——新增、修改、删除、移动、忽略、改名。相比分支合并、rebase 这些重操作,文件管理的场景看起来简单,但翻车率极高。
最常见的就是.gitignore:试图忽略node_modules、忽略target、忽略.env,怎么写都不生效。还有更隐蔽的:你改了文件名大小写,Git 完全感知不到;你在 Windows 上给脚本加了执行权限,push 到 Linux 服务器上直接“没权限”;你删了个文件,结果没有git add这个删除操作,最后把文件又“复活”了。
这些问题单个看不大,但每一个都能耗掉你半小时。而且它们都有一个共同点:问题的根源不在命令本身,而在于 Git 对文件的“追踪”方式与人的直觉存在偏差。要修正这种偏差,就得从.git对象模型和 index(暂存区)机制入手。
1.3 这篇适合谁:从“会用”到“敢修”的过渡
如果你已经会最基本的四连操作:git init、git add、git commit、git push/pull,但遇到以下任何一种情况会心里发怵,这篇就很适合你:
- 提交错了分支,想把改动“搬”到另一个分支;
- 手滑
git reset --hard后想找回丢失的提交; .gitignore规则写了半天还是失效;- 看到一个报错只能靠搜,搜完也看不懂原理;
- 好奇
.git里到底长什么样,但不敢乱动。
与其说这篇是“原理文章”,不如说它是一份“拆机指南”。拆过机之后,你以后再遇到 Git 报错,至少能判断出“问题出在本地仓库、远程还是工作区”,这个判断力比记住一百条命令都值钱。
2. 深入 .git:把版本库拆开看一遍
2.1 .git 目录到底装了些什么
在项目根目录下执行ls -a,就能看到.git文件夹。我建议你跟着操作,新建一个临时目录,初始化仓库,然后逐个看里面的内容:
mkdir git-demo && cd git-demo git init ls -la .git一个刚初始化的.git目录,核心成员有这些:
| 路径 | 作用 | 备注 |
|---|---|---|
HEAD | 当前分支指针,指示“现在工作在哪个分支上” | 文本文件,内容类似ref: refs/heads/main |
config | 仓库级配置 | 你的用户名、邮箱、远程地址、别名等都在这 |
index | 暂存区快照 | 二进制文件,记录文件路径、SHA1 哈希与状态 |
objects/ | Git 的对象数据库 | blob、tree、commit、tag 全部存这里 |
refs/ | 引用目录 | heads/存本地分支,tags/存标签,remotes/存远程跟踪分支 |
logs/ | 引用变更日志 | reflog 的来源,救命的日志 |
hooks/ | 钩子脚本 | 如 pre-commit、post-merge 等示例脚本 |
description | 仓库描述 | 仅用于 GitWeb 工具,平时用不上 |
branches/是老版本遗留目录,现在基本不关心。COMMIT_EDITMSG在每次提交时会保存最近一次的提交信息,内部工具用的。
这些文件里,index是最容易被忽略但极其重要的存在。它本质上是一个二进制索引文件,保存着“下一次提交将包含哪些内容”的快照。你执行git add时,并没有把文件“复制”到 .git 里,而是把文件的 hash 和路径等信息写进了index。这一点在后面讲文件状态时会反复用到。
如果你想直接看暂存区的内容,可以执行:
git ls-files --stage输出的每一行对应一个已跟踪文件,格式大致是:
100644 7898192265132d5a1b2b3c4d5e6f7a8b9c0d1e2 0 a.txt第一列是文件模式(100644 表示普通文件,100755 表示可执行文件),第二列是 blob 对象的哈希,第三列是合并状态,最后一列是路径。你会立刻发现一个事实:Git 存的是文件的“内容快照”,而不是“差异补丁”。每次git add后,暂存区记录的是整个文件的哈希,不是“改了几行”。
2.2 三个核心对象:blob、tree、commit
Git 的objects/目录里存着四种对象:blob、tree、commit、tag。理解这四种对象之间的关系,等于拿到了 Git 的地图。
我用一个类比来解释:blob 是“货箱”,里面装的是文件内容;tree 是“货架清单”,描述目录里有哪些文件(文件名 + 对应的 blob 哈希)以及有哪些子目录(对应另一个 tree);commit 是“快递单”,记录这一次发货的货架编号(tree 哈希)、上一次的发货单(parent commit)、发货人和备注。
也就是说,一次提交的形成过程是:
git add时,Git 把每个文件的内容压缩成 blob 对象;- Git 根据目录结构生成 tree 对象,tree 会引用对应的 blob 或子树 tree;
git commit时,Git 创建一个 commit 对象,让它指向顶层 tree,并记录父提交。
亲手验证一下。在刚才的临时目录里创建两个文件并提交:
echo "hello" > a.txt mkdir sub echo "world" > sub/b.txt git add . git commit -m "first commit" git rev-parse HEADgit rev-parse HEAD打印的是当前提交的完整 SHA1 哈希。拿到这个哈希后,用git cat-file命令拆开它:
git cat-file -t <hash> # 查看对象类型,应该输出 commit git cat-file -p <hash> # 打印对象内容-p输出会显示出 commit 对象的内容:
tree 2fd4e1c67a2d28fced849ee1bb76e7391b93eb12 author Your Name <you@example.com> 1700000000 +0800 committer Your Name <you@example.com> 1700000000 +0800 first commit接着把里面的 tree 哈希再拿去git cat-file -p:
100644 blob 3f3c6c... a.txt 040000 tree d9b1e5... sub你可以看到a.txt是一个 blob,sub是另一个 tree。继续往下拆,sub这个 tree 里面就是b.txt的 blob。整个结构一目了然:Git 用一张“内容寻址”的树状表来管理文件。这种设计的好处是:两个文件内容相同,Git 只需存一个 blob,天然支持去重;提交时只需生成新的 tree 和 commit,未变动的文件根本不需要重写。
2.3 HEAD、index 和引用:Git 的心脏跳法
.git里比对象更容易被忽略的是“引用”和“指针”。HEAD文件决定了你当前所在的分支。执行:
cat .git/HEAD正常情况下输出ref: refs/heads/main或ref: refs/heads/master。当你执行git checkout dev时,HEAD 的内容就变成ref: refs/heads/dev。而refs/heads/dev这个文件里存的是一个提交哈希,即该分支最新的提交。
index文件相当于“暂存区的内容清单”,而HEAD对应“当前分支最后一次提交的清单”。工作区、暂存区、版本库这三者的关系,是 Git 使用中最重要的一张图:
- 工作区:你肉眼看到的文件;
- 暂存区(index):你已经
git add进去的内容; - 版本库(objects + refs):已经
git commit保存的历史快照。
执行git status时,Git 其实是在做两次对比:先对比“工作区 vs 暂存区”,再对比“暂存区 vs HEAD”。如果工作区和暂存区有差异,文件出现在Changes not staged for commit区域;如果暂存区和 HEAD 有差异,文件出现在Changes to be committed区域。多了解一下这个判定逻辑,你就能解释为什么有时候文件明明没改,git status却认为它是 modified——多半是文件权限或换行符的问题,后面第三部分会细讲。
2.4 用 git cat-file 亲手拆一次提交
对初学者,我强烈建议把git cat-file当成“解剖工具”。它有三个常用参数:
git cat-file -t <hash> # 查看类型 git cat-file -s <hash> # 查看大小 git cat-file -p <hash> # 打印内容在 git-demo 里,你可以逐个查看对象:
git rev-parse HEAD "a.txt" # 注意:rev-parse 可以同时解析多个对象git ls-tree HEAD则可以列出当前 HEAD 对应的顶层 tree:
git ls-tree HEAD输出与git cat-file -p类似,但更简洁。如果你创建了多个提交,还可以用git rev-parse HEAD~1、HEAD~2来查看历史提交引用。Git 的引用语法~、^、@{2}都建立在对象图的基础上,不只是“魔法符号”。
举个实战例子:如果你误删了分支,但还记得它最后一次提交的哈希,可以用git branch recover <hash>把这个分支重新拉回来。分支本质上就是一个“指向提交的引用”,只要对象没有被 gc 清理,找回分支只是写一行文本的事。
2.5 .git 泄露风险:为什么它绝对不能出现在生产环境
.git目录值得多提一嘴的安全风险:现在的部署流程里,不少人喜欢直接把项目根目录整个传到服务器,或者用静态网站托管。一旦.git文件夹被当作静态资源暴露在 web 服务器上,攻击者就能通过访问/.git/HEAD、/.git/config等路径,把整个仓库历史扒下来。很多源码泄露事故,根源就是打包时把.git一起打进去了。
从原理上看,.git里的对象都是压缩存储的,但不代表不能被读取。别人拿到.git目录,完全可以顺着refs/heads找到提交,再顺着 tree 和 blob 还原全部源码,甚至包括你曾经提交过又删掉的“敏感信息”。所以涉及部署时,我的几点建议:
# 常见做法:构建产物里排除 .git rsync -av --exclude='.git' ./ user@server:/var/www/project或者配置 web 服务器拒绝一切以.git/开头的请求。另外,永远不要把密钥、密码、token 这类信息提交到 Git 仓库。就算你后来删掉了,只要提交历史还在,它就在.git的 objects 里躺着。最务实的做法是:任何机密文件都该放进.gitignore,同时定期使用工具扫描仓库中的敏感信息。
3. 文件管理实战:追踪、忽略、移动与删除
3.1 文件状态流转:从 untracked 到 committed
理解了.git内部结构,再看文件管理就顺理成章了。一个文件在 Git 里有几种状态:
| 状态 | 含义 | 常见触发命令 |
|---|---|---|
| Untracked | 文件在工作区,但从未被 Git 跟踪 | 新建文件后 |
| Tracked / Unmodified | 文件已被跟踪,且内容与暂存区一致 | 刚提交完 |
| Modified (staged) | 内容已加入暂存区 | git add |
| Modified (unstaged) | 工作区与暂存区不一致 | 修改文件后未git add |
git status --short是最高效的查看方式,输出两列状态码,例如:
M a.txt A b.txt D c.txt第一列是“暂存区相对 HEAD 的状态”,第二列是“工作区相对暂存区的状态”。M(前面一个空格)表示a.txt在工作区有未暂存的修改;A表示b.txt已被 add 进入暂存区;D表示c.txt已被删除且删除动作已暂存。
这个细节值得死记:第一列管“提交将发生什么”,第二列管“你还没 add 的东西”。很多人git status看半天分不清该git add还是git commit,其实只要盯住第一列的 A/M/D,那是“下一次提交会包含的变更”。
3.2 .gitignore 写了很多却没生效?问题出在这几个地方
.gitignore是文件管理里最容易被误用的功能。“我明明把node_modules写进 .gitignore 了,为什么 git status 还是能看得到?”这个问题我回答了无数遍,九成原因是:规则只是对尚未被跟踪的文件生效,已经 tracked 的文件不受 .gitignore 管。
也就是说,如果一个文件已经被git add提交过了,之后再往.gitignore里加它的规则,Git 会无视。此时的正确操作是先把它从索引里移除,但保留工作区文件:
git rm --cached node_modules -r git commit -m "stop tracking node_modules"之后.gitignore规则才会真正接管。另外.gitignore的匹配规则语法,也经常有人写错。几个高频误区的快速对照:
# 正确写法 node_modules/ # 忽略所有 node_modules 目录 *.log # 忽略所有 .log 文件 /temp/ # 只忽略根目录下的 temp 目录 build/ # 忽略任意层级下的 build 目录 # 容易踩坑的地方 *.log # 这行可以 !important.log # 这行可能不生效:如果父目录被忽略,无法重新包含更精确的规则是这样:无法重新包含文件,如果它的父目录已被排除。比如你想忽略src/但保留src/keep.txt,直接写:
src/ !src/keep.txt这是不生效的。正确写法的思路是忽略目录内所有内容,再排除特定文件:
src/* !src/keep.txt.gitignore的匹配规则以/结尾表示目录,以/开头表示相对根目录。建议新仓库在git init之后立刻创建.gitignore,避免提交了之后再来补救。项目级.gitignore之外,还有全局.gitignore(~/.gitignore_global)可以用来忽略.DS_Store、Thumbs.db这类系统文件,不用每个仓库都写一遍:
git config --global core.excludesfile ~/.gitignore_global3.3 git rm 与 git mv 的正确操作姿势
文件删除和移动在 Git 里的语义,和文件管理器完全不同。文件管理器的删除是“把文件从磁盘移除”,Git 的删除是“在下一次提交中记录这个文件不再存在”。
如果你直接rm a.txt,git status会显示:
D a.txt注意D在第二列,说明这个删除还没有暂存。此时你有两种选择,效果一样:
git rm a.txt # 删除文件并暂存删除动作 # 或 rm a.txt git add a.txt # 把删除动作暂存很多人只知道第一种,不知道第二种,导致遇到“文件已经被手动删了”的时候不知道怎么办。其实你只需要git add或git add -u即可。git add -u的含义是“把所有已跟踪文件的修改和删除加入暂存区”,比git add .更精准,毕竟.会把一堆 untracked 文件也带进来。
文件移动同样如此。git mv old.txt new.txt本质上是三条命令合体:
mv old.txt new.txt git add old.txt new.txt # Git 会自动识别为 rename改名的识别靠的是内容相似度。也就是说,即使你不用git mv,只要在同一个提交里删除旧文件、新增内容相同的新文件,Git 大概率也会识别为 rename。这个机制告诉我们:不必为“我改了文件名会不会丢历史”而担心,只要文件内容可比对,历史就能串起来。
关于git add .和git add -u的选择,我是这样掌握的:新写的一堆文件用git add .,已有跟踪文件的修改和删除用git add -u;想看暂存后的实际差异用git diff --cached,不要只看git status。
3.4 文件权限与换行符的暗坑
文件管理里最容易神不知鬼不觉出问题的,两类:一是可执行权限,二是换行符。
先看权限。在 Linux/macOS 上给脚本加执行权限:
chmod +x deploy.sh这个权限变化会被 Git 识别为文件模式变化(100644 变成 100755),从而让git status显示deploy.sh被 modified。但在 Windows 上,很多编辑器不会保留可执行位,于是出现这种情况:同一个项目,你在 mac 上提交时带了执行权限,队友在 Windows 上 clone 后未做任何改动,git status却显示一堆文件 modified。
解决办法是在仓库里设置:
git config core.filemode false这个配置会让 Git 忽略文件可执行位的变化。但要注意,这只是“本地忽略”,如果别人已经提交了权限变化,你拉下来还是会存在。如果彻底不想让权限差异干扰团队协作,可以在.gitattributes里统一声明脚本文件的换行和模式:
*.sh text eol=lf再来看换行符。Windows 用 CRLF,Linux/macOS 用 LF。Git 默认有个core.autocrlf机制处理转换,但这种“自动转换”常常导致两种情况:要么提交时把 CRLF 转成 LF,checkout 时再转回 CRLF;要么因为配置不一致,明明只改了一行,git diff却显示整个文件被改。后者是“换行符灾难”的经典症状。
更现代的做法是通过.gitattributes显式声明:
* text=auto *.sh text eol=lf *.bat text eol=crlftext=auto让 Git 自行判断文本文件并进行换行符转换;有明确要求的文件类型再单独指定。新的 Git 项目建议直接采用这种方式,比依赖各人本机的core.autocrlf更可控。
3.5 大文件、二进制文件与配置文件的分治策略
文件管理还要有“分类意识”。Git 最擅长的是文本文件,对大文件、二进制文件、频繁变化的配置文件,都有各自的处理侧重点。
先说大文件。Git 本身不是为“存储大型文件”设计的。每次提交都会把文件整体快照写进.git/objects,一个大视频、一个大压缩包会直接把仓库体积撑大,而且这种体积膨胀是不可逆的——就算删掉大文件,它仍然留在历史里。如果你要提交超过 100MB 的文件,服务端(GitHub 限制 100MB)甚至会直接拒绝。
解决方案:
- 用 Git LFS(Large File Storage),针对大文件做指针引用 + 独立存储;
- 把大文件排除在仓库外,放在对象存储或网盘,通过脚本下载;
- 在
.gitignore里明确忽略*.zip、*.tar、*.mp4、node_modules、dist/这类产物。
这里分享一个我常用的判别标准:凡是能通过构建、下载或生成得到的文件,都不应该进 Git 仓库。源码是仓库的主角,构建产物是“别人”,别让它们抢戏。
二进制文件(图片、字体、PDF)虽然不能 diff,但设计稿、图标等必要素材还是可以进仓库,只是要注意:二进制文件的每个版本都会完整保存,体积增长快,尽量别频繁更新大图。
配置文件要格外小心两类,一是.env这类包含密钥的局部环境文件,二是 IDE 或系统特定文件。前者必须用.gitignore排除,后者(如.idea/、.vscode/、.DS_Store)建议全局忽略。至于类似.editorconfig、.gitattributes这类团队统一配置文件,恰恰应该提交到仓库里,让所有人都受益。
4. 高频问题与现场排查实录
4.1 一表速查:12 个高频报错与解决思路
下面这份速查表,是我在实际项目和社区问题里收集整理的,可以直接拿来解决大部分日常“疑难杂症”。
| 现象/报错 | 常见原因 | 解决思路 |
|---|---|---|
Permission denied (publickey) | SSH 密钥未配置或未加载 | 用ssh -T git@github.com测试;检查本地密钥路径、ssh-agent,确认远程地址是 SSH 而非 HTTPS |
failed to push some refs | 远程有本地没有的提交 | 先git pull --rebase,解决冲突后再git push |
refusing to merge unrelated histories | 两个仓库历史无关 | 确定要合并时用--allow-unrelated-histories;但如果是不小心关联了错误远程,应修改 remote |
.gitignore写了没反应 | 文件已经被跟踪 | 用git rm --cached解除跟踪,再提交 |
git open /dev/null or dup failed | Windows 上 Git Bash 环境异常 | 关闭重启终端,尝试升级 Git for Windows |
rejected: unable to lock ref | 另一个进程或本地引用锁冲突 | 删除.git/refs/...lock文件,但先确保没有其他 Git 进程在运行 |
cannot lock ref | 分支名大小写冲突 | 检查本地远程跟踪分支是否和现有分支重名 |
| 提交后发现问题想改信息 | 提交未推送或已推送但没被他人拉取 | 未推送用git commit --amend;已推送用git push --force-with-lease |
| 合并后想撤销 | 错误合并 | 未推送用git reset --hard <坏之前的hash>;已推送用git revert更安全 |
fatal: bad object | 对象缺失或仓库损坏 | 优先找备份/远端重新 clone;不要手动删 objects |
git status显示大量修改 | 换行符/权限变化 | 配置.gitattributes或core.filemode false |
| fetch 和 pull 分不清 | 概念混淆 | fetch只下载不合并,pull = fetch + merge;cherry-pick是挑选单个提交应用到当前分支 |
4.2 案例复盘:分支写错了,怎么把 master 的改动安全搬到 dev
这个场景几乎每个人都会遇到:本该在新功能分支开发,结果一不留神在master上连改带提交了两个 commit,而且还没有 push。现在希望把这几个 commit 安全搬到dev分支上。
首先看当前状态:
git log --oneline # master 上有两个想搬走的提交:c111111 和 c222222方法一:直接合并。
git checkout dev git merge master这样dev就会包含master的所有提交(包括你想搬的和不想搬的)。如果你的master只多了这两个 commit,问题不大。如果master上还有其他不相关的 commit,这就不是最佳方案。
方法二:选择性搬运。
git checkout dev git cherry-pick c111111 c222222cherry-pick会把指定提交“复制”一份应用到当前分支。这个命令的本质是:读取目标提交的 diff,然后在当前分支上重新应用一次。如果被搬的提交还碰过别的文件,会产生冲突,需要手动解决。我个人常用的场景是“只想搬走其中某个修复”,比合干净得多。
方法三:如果master上那几次提交本来就不该存在,搬完还想让master回到远程原状:
# 确认远程 master 是好的 git checkout master git reset --hard origin/master注意,这条命令会丢掉master上所有未推送的提交。使用前务必确认被丢的提交已经通过 cherry-pick 或 merge 备份到了其他分支。
4.3 案例复盘:误删提交之后,如何用 reflog 捞回来
只要你用过git reset --hard,大概率有过“后悔”的瞬间。好消息是,Git 为本地仓库维护了一个 reflog,记录 HEAD 和分支引用的每一次变动。只要 commit 对象没有被 GC 清理,理论上都能找回来。
场景还原:你执行了git reset --hard HEAD~2,发现丢掉了两个提交,但你想反悔。此时:
git reflog输出结果类似:
a1b2c3d HEAD@{0}: reset: moving to HEAD~2 e4f5g6h HEAD@{1}: commit: 完成登录功能 i7j8k9l HEAD@{2}: commit: 完成接口联调HEAD@{1}就是 reset 之前的提交e4f5g6h。要回到那个状态,直接:
git reset --hard e4f5g6h或者写成git reset --hard HEAD@{1}。但这里有个必须强调的细节:reflog 默认只保留 90 天(通过gc.reflogExpire配置),而且它只记录本地操作。如果你的误删是发生在另一台机器或 clone 出来的仓库,reflog 里不会有那个提交。此时只能寄希望于远程的 reflog 或者其他同事的本地仓库。
建议把git reflog当成“后悔药”随身携带。遇到任何reset、checkout、merge操作之后的异常,先git reflog看一下,通常比网上搜索报错更直接有效。
4.4 关于提交信息与合并的几个实用性习惯
最后绕回提交信息。很多人不重视git commit -m "fix"这个操作,直到某天需要从 1000 条 “fix” 里找一条改动。一个好的提交信息格式,建议是:
type(scope): description body...其中type常见的有feat(新功能)、fix(修复)、docs(文档)、refactor(重构)、chore(杂项);scope 是影响范围,比如模块名;description 简短说明这次改动。
如果你已经提交了,但信息写得不好:
git commit --amend这会修改最新一次提交的信息(也可以把新改动并入上一次提交)。注意,amend会生成新的提交哈希,如果这个提交已经 push 且他人已拉取,不建议再用amend硬改,否则会造成历史分叉。
合并分支方面,我想提一个理念:merge保留真实历史,rebase整理线性历史。日常开发中我的习惯是:
- 功能分支合并回主线用
--no-ff,保留一个合并节点; - 同步远程最新代码用
git pull --rebase,避免多余的 merge commit。
git pull --rebase这个命令会把本地未推送的提交“摘下来”,更新到远程最新提交之后,再重新放上去。优势是历史变得线性,缺点是如果本地提交和远程提交改了同一处,会产生冲突,需要逐个解决。相比之下,直接git pull的 merge 也能解决冲突,但会额外形成一个 merge 节点。两者没有绝对的优劣,但建议团队里约定一致。
IDEA 这类 IDE 里集成的 Git 图形界面,本质上调用的是同一套 Git 命令。如果你在 IDEA 里遇到合并分支选项看不懂,建议先在命令行里把git merge、git rebase、git cherry-pick三个命令吃透,再回来看界面,你会觉得“原来是同一个东西”。
另外,git revert和git reset的区别也值得最后再强调一遍。reset是移动分支指针,会让提交从历史里“消失”;revert是生成一个反向提交,让历史完整保留。如果操作已经 push 到远程,用revert更安全;如果只是本地操作,想彻底清理,用reset更干净。
说到底,Git 命令虽然多,但核心思想并不复杂:版本历史是一棵可溯源的树,分支只是指针,工作区、暂存区、对象库各司其职。绝大多数“诡异问题”,往这三个层面一套,都能找到归属。
最后分享一个我坚持了很多年的习惯:每次提交前,不要急着git commit -m "fix",先花 10 秒跑一下git status --short和git diff --cached --stat,确认“我要提交的确实是我想提交的东西”。特别是当你觉得自己改了 3 个文件,git status却列了 20 个文件时,这个检查能救命。先快速看状态,再精确暂存,最后提交——这套流程看起来慢,实际上帮你省下的返工时间远不止这些。