1. 从三区模型理解 Git 命令的设计逻辑
1.1 工作区、暂存区和历史区的真实关系
我见过太多开发者用 Git 用了两三年,add、commit、push闭着眼都能敲,但一遇到"改了文件却不想提交""提交错了想撤回""rebase 到一半想放弃"这类场景就发怵。根子不在命令背得少,而是没弄懂 Git 在底层到底把文件分成了几块地方。
Git 的文件状态,说白了就是三个区域之间的流动:
- 工作区:就是你打开编辑器正在改的那个目录,这里的内容跟硬盘上一个字一个字对应。
- 暂存区:Git 内部叫 index,是一个中间层。你
git add之后文件进入这里,相当于"打包待发"。 - 历史区:也就是
.git目录里的一堆 commit 对象,每次commit都会把暂存区的快照永久落盘。
日常 90% 的混乱来自大家默认"commit 完就等于推到远端了"。其实不是。commit只是往你本地历史区写了一个新提交,远端仓库根本不知道这件事。真正同步靠的是pull和push这一对动作。我经常拿快递来打比方:工作区是你家茶几上的材料,add是把材料装进快递盒,commit是叫了快递员上门取件并贴好面单,push才是让快递车真正发车。面单贴了没发车,包裹还在你手里,随时能改。
理解了这三个区域,再回头看命令就会非常有条理:status是在问"三区之间的差异";diff是在问"具体差异内容";reset是在移动历史区 / 暂存区 / 工作区之间的指针;checkout本质上是在切换"历史区里的某个快照"。
1.2 HEAD、分支指针和 commit 对象
第二个需要搞懂的概念是:分支并不是目录副本,它只是一个"指针",指向某一条提交链上的最新 commit。每次新提交,Git 会生成一个 40 位的哈希值,同时把当前分支的指针往后拨一格。
HEAD则是一个更特殊的指针,它指的不是 commit,而是"当前检出的分支"。正常情况下 HEAD 指向refs/heads/main,而 main 指向某个 commit。当你执行git checkout <commit_hash>进入"分离头指针(detached HEAD)"状态时,HEAD 直接指向某个 commit 而不是分支,此时如果你新提交了内容,这个提交不属于任何分支,很容易在切回分支后找不到。很多初学者在这卡住,其实解决办法就一句:在分离状态下别急着写代码,先git switch -c temp把它挂到一个新分支上。
理解了这个机制,你就能解释很多反直觉现象:
- 为什么删除分支后 commit 还在?因为只要没有被
gc清掉,那些 commit 对象仍存在于.git/objects里,通过reflog或fsck还能找回。 - 为什么
rebase之后本地提交哈希全部变化?因为 rebase 本质是"重放一遍",每个提交带上了新的父提交和时间戳,哈希必然改变。 - 为什么
merge会产生一个"八字眉"般的图形?因为合并提交有两个父提交,Git 的历史是 DAG(有向无环图),不是一条直线。
这些底层印象建立起来后,你再看命令行输出里的->、HEAD、origin/main就会有画面感,而不是盯着字面猜。
1.3 一条命令背后到底发生了什么
拿最常用的git add file.txt举例,很多教程只会说"把文件加入暂存区",但内部动作其实是:
- 计算文件内容的 SHA-1 哈希;
- 把内容压缩成 blob 对象写入
.git/objects; - 把文件名、权限、哈希等更新到 index 文件里。
所以哪怕你只是把文件内容改了一行,再次git add后,它生成的也是一个全新的 blob 对象。Git 用这种方式保证历史不可变——它不是"修改"旧文件,而是"新增"快照,再用指针串起来。这就是为什么 Git 可以轻松回退到任何一个历史状态,也是为什么它被称为内容寻址文件系统。
当你看懂了这一层,git add -p、git restore --staged这些命令的思路也顺了:它们操作的本质都是在 index 文件里调整"哪些内容对应哪个 blob",而不会动你的工作区文件。
2. 每天都会敲的高频命令,值得再抠一下细节
2.1 初始化与第一轮身份配置
新机器拿到手,第一件事不是装完 Git 就跑,而是配置身份。我见过太多人 commit 之后发现作者显示成 "user@DESKTOP-XXX",或者公司代码审查系统不认人,追根溯源都是提交者邮箱不规范。
git config --global user.name "zhangsan" git config --global user.email "zhangsan@example.com" git config --global init.defaultBranch main git config --global core.autocrlf input邮箱这里多说一句:如果你在 GitHub/Gitee 上开启了邮箱隐私保护,提交邮箱最好用平台提供的noreply地址,否则你的真实邮箱会出现在每次公开提交记录里。core.autocrlf在 Windows 上建议设成true,在 macOS/Linux 上设成input,否则跨平台项目容易出现"整个文件都被标成修改"的换行符灾难。
如果只是在某个仓库里想临时换身份,不推荐改全局,直接在对应仓库目录执行不带--global的配置即可,Git 配置的优先级是仓库配置 > 全局配置 > 系统配置。想知道当前仓库生效的配置,用git config --list --show-origin就能看到每项配置的来源文件,排查诡异行为时特别管用。
2.2 status 和 diff:动手之前先看清
我强烈推荐大家把git status当成日常第一道肌肉记忆——在add之前敲一次,在commit之前再敲一次。它输出的三块内容分别对应:尚未暂存的改动、尚未提交的暂存改动、未被跟踪的新文件。
status默认输出太啰嗦,可以看精简版:
git status -sbs是 short 模式,b是显示当前分支与上游的关系(比如领先 origin/master 几个提交)。配合git diff使用:
git diff # 工作区 vs 暂存区 git diff --cached # 暂存区 vs 历史区(等价于 --staged) git diff HEAD # 工作区 vs 历史区 git diff --stat # 只看改动文件列表和行数我实际工作中最常用的是git diff --stat加git diff --cached,前者快速确认"我这次改动了哪些文件、量级多大",后者在提交前仔细核查"我即将提交的内容是不是我想要的"。很多人习惯把add .一把梭,提交前又从来看一眼,直到代码评审被人家指出"你把调试日志也提上来了",才开始理解为什么 Git 要分暂存区——它就是给你一个"提交前最后反悔"的机会。
2.3 add 的几种姿势与场景化使用
git add我会分成四个场景:
git add . # 全部新增和修改,不含删除 git add -u # 全部修改和删除,不含新文件 git add -A # 全部新增、修改、删除,推荐这个 git add -p # 交互式逐个选择是否暂存前三个好理解,重点是git add -p。它会把每个文件的改动拆成若干 hunk,逐段问你 "Stage this hunk?",回答y、n、s分别表示暂存、跳过、切分。比如你一个文件里既有 bug 修复又有代码格式化,就能把格式化部分留下,只提交修复部分。刚开始用会觉得麻烦,最多两周就能养成习惯,提交粒度会变得非常干净。
还有一个细节:git add会把文件从"未跟踪(untracked)"变成"已暂存(staged)",但如果你修改了一个已跟踪文件后没有add就提交,Git 只会提交上一次暂存的版本。这正是很多新人疑惑"我明明改了啊,为什么 commit 里没有"的答案。
2.4 commit:写清楚提交信息的价值
关于git commit,我最想强调的不是参数,而是提交信息。-m "update"这种写法,半年后再看根本不知道当时改了什么。我采用的格式一般是:
类型(范围): 简短描述 更详细的说明,可以解释为什么这么做,以及副作用。比如:
git commit -m "fix(auth): 修复 token 过期后静默失败的问题" -m "原逻辑在 401 后直接返回 null,改为跳转登录页并清理本地缓存"两条-m会分别成为标题和正文,不用进编辑器也能写出结构化信息。如果真的需要写更完整的提交信息,就裸执行git commit,会打开默认编辑器。默认 vim 的话,i进入插入,Esc退到命令模式,:wq保存退出,这几条 vim 命令是每个 Git 用户必须背下来的。
git commit --amend是一个被问烂但很好用的命令,它的作用是"修改最近一次提交"。注意两个角色完全不同:我刚才说的是"改提交信息",它还能帮你把漏掉的文件补进上一个提交。比如你提交了fix: 修改登录接口,结果发现还漏改了一个配置文件,这时候:
git add 漏掉的文件 git commit --amend --no-edit--no-edit表示保持原提交信息不变,只是把漏掉的内容追加进同一个提交。这会让历史更干净,不需要多出一条"fix fix"提交。但前提是这个提交还没 push 到远端,如果已经推送了,amend会改变提交哈希,直接破坏其他同事的工作副本,这时候就别用了。
2.5 log 与 show:快速定位历史
git log全家桶属于那种"每次用都能发现新惊喜"的命令,我日常依赖这几条:
git log --oneline --graph --decorate -10 git log --oneline --all --since="2 weeks ago" git log --grep="fix(auth)" --oneline git log --follow -p src/utils/format.ts--graph会把分支分叉画成线路,--decorate标注出标签、分支和 HEAD,这两项配合--oneline基本就是"一屏看懂仓库结构"最佳实践。--grep适合按提交信息搜功能;--follow -p能追踪某个文件的历史修改记录,哪怕是文件换过路径也能继续跟。
定位到某个提交后,看它当时改了什么:
git show 2f3a1b7 git show 2f3a1b7 --stat git show 2f3a1b7:src/main.py # 查看该提交时某个文件的内容这三个命令是排查"功能是什么时候变成这样的"最顺手的工具,比去平台上翻 UI 点来点去快得多。
3. 分支管理与合流:merge、rebase 到底怎么选
3.1 分支只是指针,别把它想得太重
结合第一节的原理,分支的增删其实非常轻。
git branch # 列出本地分支 git branch -vv # 列出分支及其上游对应关系 git branch -a # 加上远端分支 git branch --merged # 显示已经合并进当前分支的分支 git branch -d feature/x # 删除已合并分支;没合并要用 -D 强制删 git branch -m old new # 重命名分支 git switch -c new # 创建并切换(等价于 checkout -b)--merged这个参数我每次清完旧分支都会用一次,能减少误删。git switch和git restore是 Git 2.23 以后从checkout拆出来的两个命令,一个只管切换分支,一个只管恢复文件,语义更清晰。老项目里还写git checkout -b也没问题,新项目建议直接用switch/restore,命令出错时提示也更友好。
不要害怕频繁开分支,Git 设计上就是鼓励低成本分支。一个修复一个分支,合并完及时删,比所有人都挤在 master 上安全得多。
3.2 merge 的两种形态和冲突处理
git merge有两种结果,初学者经常看到Fast-forward和Merge made by the 'ort' strategy两行字,却不知道区别。
Fast-forward 发生在被合并分支领先当前分支、且没有分叉时。比如当前在 main,feature 直接基于 main 往后走了三个提交,那 main 合并 feature 只需把指针直接拨过去,不需要新建提交,历史是一条直线。另一种情况是没有快速前进条件(比如 main 自己也有了新提交),Git 会生成一个合并提交,把两个分支的历史交汇在一起。
合并冲突是每个开发者绕不过去的坎。冲突发生时,Git 会把冲突文件里同时写上双方的版本:
<<<<<<< HEAD 当前分支的版本 ======= feature 分支的版本 >>>>>>> feature处理方式没有捷径,就是把<<<<<<<、=======、>>>>>>>这三行删掉,留下你想要的内容。特别容易忽略的是"双方都新增了同一个功能但实现位置不同"这种冲突,Git 有可能不报冲突但留下重复代码,所以合并完一定要跑测试。工具方面我推荐git mergetool配合 IDE 的合并界面,但底层逻辑还是你懂得选择保留哪些内容。
合并之后发现弄错了,只要合并前没 push,直接git reset --hard ORIG_HEAD就能退回去。Git 在merge时会把执行前的 HEAD 记录在ORIG_HEAD里,这是一个隐藏的分支指针,非常救命。
3.3 rebase 的场景、姿势和安全红线
rebase和merge解决的是同一个问题:把分叉的历史整合起来。区别在于merge保留"实际发生过合并"这个事实,rebase则把当前分支的提交一个个"拔下来",依次重放到目标分支的最新提交之后,看起来像一条直线。
git switch feature git rebase main比如 feature 基于 main 拉的,main 后来又走了 5 个提交,rebase 之后 feature 相当于"重新基于最新 main 生长",提交信息保留但哈希全部变化。好处是历史干净,坏处是它改写了提交,绝不能对已经推送到远端且他人正在使用的分支执行。
交互式 rebase 是整理历史的大杀器:
git rebase -i HEAD~5Git 会打开一个提交列表,你可以对每个提交执行pick、squash、reword、edit、drop。我最常用的是squash——把连续几个琐碎的提交压缩成一个有意义的提交,让 PR(合并请求)评审者不用看"改 typo"这类刷屏记录。
rebase 过程中随时可以git rebase --abort直接放弃,回到 rebase 前的状态;也可以用--continue解决完冲突后继续。如果你中途发现搞不清楚状况,先 abort 再重新来,比硬着头皮往里冲节省时间。
工作流选择上,我的建议是:团队 PR 集成用 merge 或 squash merge,保留合并节点;个人分支日常同步用 rebase,保持我自己的提交链平整。两者配合,而不是互相替代。
3.4 cherry-pick:精确拿走一个提交
cherry-pick解决的问题是"我只想要那个分支上的某一个提交,不想合并整个分支"。典型场景:hotfix 在 release 分支上提了个修复,你要把它拿回到 master。
git cherry-pick 3f8a1c2如果目标分支和来源分支的上下文差距很大,可能会冲突,处理方式和 merge 冲突一样。还有一种坑是 cherry-pick 会生成一个全新提交,原提交的哈希没了,但原始作者信息会被保留。所以当有人问你"你为什么要协调重新提交时,作者却显示别人",原因就在这里。
多选几个提交可以直接给范围git cherry-pick A^..B,但这写法很容易记混,我更推荐老老实实一个个来。
4. 撤销、回滚与恢复:给错误操作兜底
4.1 先分清 reset、revert、restore 的分工
这个环节的学问最大,无数人的代码事故都发生在"想回滚却用错了命令"。我给一张最简单的对照表:
| 命令 | 对象 | 是否改写历史 | 典型场景 |
|---|---|---|---|
git restore | 工作区文件 | 不改写 | 改乱了想恢复原样 |
git restore --staged | 暂存区状态 | 不改写 | 反悔了,不想暂存 |
git reset --soft | 移动 HEAD 指针 | 改写 | 想重新提交最后一次 commit |
git reset --mixed | 移动 HEAD 并重置暂存区 | 改写 | 取消暂存,保留改动 |
git reset --hard | 移动 HEAD、重置暂存区和工作区 | 改写 | 彻底丢弃所有改动 |
git revert | 生成一个新提交反向改动 | 不改写历史 | 撤销已推送的提交 |
restore只碰文件不碰历史,revert只碰历史记录里的内容但不改写历史,reset才是真正让历史"消失"。处理已推送内容的铁律是:别人可能已经基于那个提交做了开发,你只能用 revert,不能用 reset 再 push -f。
4.2 不同阶段的"反悔"操作
场景一:我改了一个文件,改得稀烂,想回到最近一次提交的状态。
git restore src/utils.ts场景二:我不小心把不该提交的文件add进暂存区了。
git restore --staged src/utils.ts场景三:我提交了一个 commit,但只过了半小时就发现漏了文件,这个提交还没推送。
git add 漏掉的文件 git commit --amend --no-edit场景四:我想彻底丢弃最近三次提交,并且不保留任何改动,这个提交未推送。
git reset --hard HEAD~3一旦执行了reset --hard,工作区里所有未提交的改动和已提交但被跳过的提交都会从当前可见视图里消失。注意我说的是"从当前可见视图",没有说"彻底没了",只要还没被 gc,reflog里还能捞回来,后面专门讲。
4.3 amend 和 reset 的组合拳
分享一个我很常用的组合:修改最近一次提交的提交信息,或者对最后一次提交做局部修正。比如我写错了提交信息,或者发现最后提交里有个无伤大雅的小问题:
git commit --amend # 修改提交信息 git add fix-file && git commit --amend --no-edit # 补充文件如果想"拆开"最后一次提交——比如发现我刚才把两个独立的功能写进了一个提交,更优雅的操作是软回退:
git reset --soft HEAD~1--soft只移动 HEAD,暂存区和工作区都保持原样,也就是说你立刻又回到了"所有修改都已经暂存"的状态,可以重新分文件git add -p后重新提交。个人认为是--soft是最高频使用的 reset 变体,安全且容易理解。
4.4 reflog:找回你以为已经删掉的提交
Git 里有一个几乎没人主动看、但关键时刻能救命的记录,叫reflog。它记录的是 HEAD 指针在本地仓库里的每一次移动历史,注意是"本地自有仓库"的完整历史,和分支历史是两码事。
git reflog输出类似:
a1b2c3d (HEAD -> main) HEAD@{0}: reset: moving to HEAD~3 f9e8d7c HEAD@{1}: commit: fix(auth): 修复 token 过期问题 e5d4c3b HEAD@{2}: pull: Fast-forward如果我reset --hard之后后悔了,找到 reset 之前的那个哈希f9e8d7c,直接:
git reset --hard f9e8d7c全部回来。我做过多次"事故演练",结论是只要本地仓库还在,reflog 能恢复绝大多数操作。唯一保不住的是在git gc自动清理后太久没操作,那才可能真正丢失。所以遇到误删,第一反应应该是:别继续在这个仓库里敲一堆命令,先git reflog看看有没有可恢复的节点。
4.5 clean 删除未跟踪文件
git clean是一个危险系数很高的命令,因为它删除的文件没法通过 Git 找回。我的建议是先把-n当成肌肉记忆:
git clean -nd # 只看会删除哪些文件,不真删 git clean -fd # 删除所有未跟踪的文件和目录 git clean -fdx # 连 .gitignore 忽略的文件也一起删-x是"连被忽略的一起删",常用于清理构建产物、缓存目录。执行前必看-n的输出,尤其你在仓库目录里放了.env、本地配置这类文件时,-x会毫不留情把它们删掉。
5. 远端协作与账号配置:从 clone 到发布一条龙
5.1 clone、remote 与显式推送
远端操作最容易被误解的是"commit 完就完事了",经常看到有人问为什么同事看不到自己的提交。因为远端同步需要显式命令:
git clone git@gitee.com:user/repo.git git remote -v git remote add upstream git@github.com:upstream/repo.git git remote set-url origin git@gitee.com:new-address.git git push origin HEAD多仓库协作时,我会给"自己的 fork"命名origin,给"官方源仓库"命名upstream,每天开始工作前先git fetch upstream && git rebase upstream/main,这样永远不会把自己的提交建立在过时的主干上。
push 时最重要的一件事是第一次推送当前分支并绑定上游:
git push -u origin feature/login-u会把本地分支和远端分支建立跟踪关系,之后在这个分支上直接敲git push、git pull就行,不用再输完整命令。查看当前分支跟远端的状态可以git status -sb,它会显示[ahead 2, behind 1],含义是你本地领先两个提交、落后远端一个提交。
删除远端分支:
git push origin --delete feature/old这个操作只删远端分支,本地分支要单独git branch -d feature/old。
5.2 SSH 密钥和 Gitee 配置,Windows 用户的顺滑姿势
Windows 用户推荐直接装 Git for Windows,它自带 Git Bash,很多 Unix 命令在 Windows 上也能用。安装时默认选项即可,唯一建议是 PATH 环境变量选"仅在 Git Bash 中使用",避免和系统里的其他工具冲突。
SSH 密钥配置是整个远端协作里最容易卡住的第一步,流程其实很固定:
ssh-keygen -t ed25519 -C "zhangsan@example.com"一路回车会生成私钥~/.ssh/id_ed25519和公钥~/.ssh/id_ed25519.pub。查看公钥:
cat ~/.ssh/id_ed25519.pub把公钥内容复制到 Gitee / GitHub 的 SSH 公钥设置页面。验证是否成功:
ssh -T git@gitee.com看到 "Hi xxx! You've successfully authenticated" 就是通了。这一步通过后再执行git clone git@gitee.com:user/repo.git,以后 push/fetch 都不用输密码。
常见坑有两个:一是 Windows 用户~/.ssh路径其实对应C:\Users\你的用户名\.ssh,在 Git Bash 里打cd ~/.ssh && ls能进就说明没问题;二是公司电脑可能有多把密钥,需要在~/.ssh/config里指定:
Host gitee.com HostName gitee.com User git IdentityFile ~/.ssh/id_ed255195.3 fetch 与 pull 的区别,以及 pull --rebase 的取舍
我在团队里天天强调 fetch 和 pull 不是等价操作。git pull其实是git fetch+git merge FETCH_HEAD的合体。如果你只想知道远端有什么变化,不想动本地,就只执行git fetch;如果你想直接把远端新提交合进来,才用pull。
pull默认是 merge 模式,会产生合并节点,个人分支上比较丑。我推荐在个人分支交接同步时用:
git pull --rebase它会先把本地未推送的提交临时取出,拉到远端提交后放回顶端,形成直线历史。团队协作的共享主干分支上我反而建议git pull --ff-only,只允许快进更新,如果远端已经分叉就直接报错,倒逼你去手动处理,而不是偷偷生成奇怪的历史。
5.4 标签、worktree 与版本发布
发布版本时,标签是比"记个哈希"靠谱得多的方式。创建附注标签:
git tag -a v1.2.0 -m "release v1.2.0" git push origin v1.2.0 # 推送单个标签 git push origin --tags # 推送所有标签 git tag -d v1.2.0 # 删除本地标签 git push origin --delete v1.2.0 # 删除远端标签附注标签(-a)会存打标签人、时间、消息,强烈建议用它替代轻量标签。收到新版本查询标签对应的代码时,可以用git show v1.2.0查看,或者在标签处切出一个分支继续开发。
git worktree则是我这两年越来越依赖的功能。它的价值在于:同一个仓库可以在不同目录同时检出不同分支,互不干扰。比如我正在 main 上开发,突然线上需要紧急修复 v1.2.0 分支,不想 stash 、不想 commit 半成品:
git worktree add ../hotfix-fix v1.2.0 cd ../hotfix-fix这边就是一个干净的 v1.2.0 工作副本,改完提交推送到 hotfix 分支,然后:
git worktree remove ../hotfix-fix清理掉。好处是再也不用经历"切分支一时爽,回来发现 stash 找不到了"的痛苦。查看有哪些 worktree 用git worktree list,清理失效的用git worktree prune。
5.5 容易忽略却实用的几个小命令
列几个我日常离不开、但新手通常不知道的命令,统一打包:
git stash # 暂存当前改动,让工作区变干净 git stash list # 查看暂存列表 git stash pop # 恢复最近一次暂存 git stash drop stash@{0} # 删除某条暂存 git shortlog -sn # 统计每个人的提交数 git blame file.txt # 查看每一行最后改动人和提交git stash遇到的最大坑是它默认不包含未跟踪文件,需要git stash -u才会把新建文件一起带走。还有一次我git stash pop遇到冲突,Git 会把改动留在工作区,手动解决后记得git stash drop清理,否则下次 pop 又会冒出来。
另外推荐一个很反直觉但好用的查看命令:git log --all --oneline --graph --decorate,如果你所在分支浑然不知道旁边还有什么分支在活跃,这行命令能一屏看清全貌。
写到最后想分享一个实操体会:Git 命令学的不是"背多少",而是"遇到某个状况知道该用哪一类命令"。我见过太多人把命令当字典背,遇到问题还是懵。最好的练习方式就是自己在本地搭个测试仓库,把 reset、revert、rebase、cherry-pick 全部故意操作一遍,配合 reflog 观察每个动作前后的变化。只要把这些底层动作亲手摸过一遍,以后在真实项目里无论遇到多乱的仓库状态,你都不会慌。