Git 的命令我一直是「用到再查」的状态,直到有一次把改了一整天的代码 git reset --hard 掉,才认真坐下来把这些命令的边界搞清楚。后来发现绝大多数慌张的时刻,起因都是同一个问题:不知道自己现在这次改动躺在哪个区域里,是工作区、暂存区,还是已经进了本地仓库。
搞清楚这四个区域之后,撤销操作就变成了一道选择题,选哪条命令看的是「我要退回到哪一层」。这篇按这个思路把日常会用到的 Git 命令过一遍,重点放在出错之后怎么救,以及 rebase 和 merge 这两条路各自适合什么场景。
如果你想看的是分支模型层面的约定,可以配合Git Flow 工作流总结一起看,那篇讲的是团队怎么分支,这篇讲的是命令怎么用。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- Git 的四个区域,以及每条命令在这四个区域之间怎么搬运数据
git add之后反悔了,两种场景分别怎么退回去- commit 信息写错、漏提交文件、提交了不该提交的东西,分别怎么补救
git reset和git revert到底差在哪,什么时候不能用 resetgit pull --rebase和git merge --no-ff这对相反的策略各自解决什么问题- SSH 公钥的生成与配置
git stash暂存现场的用法.gitignore改了不生效、克隆时文件名过长这类杂症- 怎么把 fork 出来的仓库同步上游更新
# 一、必备知识点
先把地图铺开。


这两张图说的是同一件事,四个区域各自是什么:
Remote:远程主仓库Repository/History:本地仓库Stage/Index:Git 追踪树,也就是暂存区workspace:本地工作区,即你编辑器里看到的代码
代码从左到右走一遍就是完整的提交流程:在工作区改完,git add 送进暂存区,git commit 落到本地仓库,git push 推到远程。
反过来,所有的「撤销」都是在问同一个问题:我要把改动从哪一层退回到哪一层。想通这一点,下面的命令就不用死记了。
# 二、git add 提交到暂存区,出错怎么办
一般的代码提交流程是这样:工作区改动,git status 查看状态,git add . 把所有修改加入暂存区,git commit -m "提交描述" 提交到本地仓库,git push 更新到远程仓库。
在这条链路上反悔,分两种情况。
场景 1
改乱了工作区某个文件,还没 add,想直接丢弃修改:
# 丢弃工作区的修改
git checkout -- <文件名>
这条命令是用暂存区(或者最近一次 commit)的版本覆盖工作区文件,被覆盖的内容 Git 从来没记录过,找不回来。所以执行之前一定要确认这些改动真的不要了。
场景 2
不但改乱了内容,还 git add 进了暂存区。这时候分两步:先用 git reset HEAD <文件名> 把它从暂存区退回工作区,就回到了场景 1;然后按场景 1 处理。
这两条命令都用了 checkout 和 reset 这种一词多义的老接口,Git 2.23 之后提供了语义更清楚的替代写法:git restore <文件名> 对应场景 1,git restore --staged <文件名> 对应场景 2 的第一步。老命令仍然可用,新项目建议直接上新的,少一次「这个 checkout 到底在干嘛」的思考。
# 三、git commit 提交到本地仓库,出错怎么办
# 3.1 提交信息写错了
改最近一次 commit 的信息:
git commit --amend -m "新提交消息"
--amend 是把这次提交重写一遍,生成的是一个全新的 commit 对象,哈希会变。只在还没 push 的时候用它,已经推到远程再 amend,别人拉下来就会冲突。
# 3.2 漏提交了文件
commit 完发现有个文件忘了带上,有两种解决方案。
方案一,再提交一次:
git commit -m "提交消息"
代价是 Git 上会出现两次 commit,历史里多一条没什么信息量的记录。
方案二,把遗漏的文件补到之前那次 commit 上:
git add missed-file # missed-file 为遗漏提交的文件
git commit --amend --no-edit
--no-edit 表示提交消息保持不变,不弹编辑器。最终在 Git 上仅为一次提交,历史干净。
小改动我基本都走方案二,但同样受前面那条限制,push 之前用。
# 3.3 提交了错误文件,想回退版本
这里有两条路,reset 和 revert,它们的差别是这一节的重点。
git reset 删除指定的 commit
# 修改版本库,修改暂存区,修改工作区
# 把暂存区的修改撤销掉(unstage),重新放回工作区
git reset HEAD <文件名>
# 版本回退,回退到特定的 commit_id 版本
# 可以通过 git log 查看提交历史,确定要回退到哪个版本(commit 之后的即为 ID)
git reset --hard commit_id
# 将版本库回退 1 个版本
# 不仅把本地版本库的头指针重置到指定版本,也会重置暂存区,并把工作区代码一起回退
git reset --hard HEAD~1
# 修改版本库,保留暂存区,保留工作区
# 软回退 1 个版本,头指针重置到指定版本,且把这次提交之后的所有变更移动到暂存区
git reset --soft HEAD~1
三个参数的区别记住一句话就行:--soft 只动版本库,--mixed(默认值)动版本库和暂存区,--hard 三个区域全动。
--hard 是这份清单里最危险的一条,它会直接抹掉工作区的未提交改动。这个我踩过,当时以为只是回退提交记录,结果一天的活没了。真的执行错了也别急着放弃,git reflog 里还留着 HEAD 的移动记录,能找回刚才那个 commit 的哈希再 reset 回去,前提是那些改动至少 commit 过一次。没 commit 过的工作区内容,谁也救不了。
git revert 撤销某次操作
这次操作之前和之后的 commit 和 history 都会保留,撤销动作本身也会作为一次新的提交记录下来。
# 撤销前一次 commit
git revert HEAD
# 撤销前前一次 commit
git revert HEAD^
# 撤销指定的版本(比如 fa042ce57ebbe5bb9c8db709f719cec2c58ee7ff)
# 撤销也会作为一次提交进行保存
git revert commit
git revert 是提交一个新的版本,把需要 revert 的那个版本的内容反向修改回去,版本号继续递增,不影响之前提交的内容。
git revert 和 git reset 的区别
git revert是用一次新的 commit 来回滚之前的 commit,git reset是直接删除指定的 commit- 单看回滚这一步,两者效果差不多。区别出现在日后 merge 老分支的时候。
git revert是用一次逆向的 commit 中和之前的提交,日后合并老 branch 时,这部分改变不会再次出现;而git reset是直接把某些 commit 在某个 branch 上删掉,再和老 branch 合并时,这些被回滚的 commit 还会被引入 git reset是把 HEAD 往后移,git revert是 HEAD 继续往前走,只是新 commit 的内容和要 revert 的内容正好相反,能够抵消掉被 revert 的部分
实际用的时候有一条简单的判断:改动已经 push 到共享分支上了,就只能用 revert。因为 reset 会改写历史,你本地删掉的那些 commit 在别人的仓库里还在,下次同步会互相打架。只在自己本地没推出去的分支上,才可以放心 reset。
# 四、常用命令
# 4.1 初始开发的 git 操作流程
新进项目时基本就是这一串:
- 克隆最新主分支项目代码
git clone 地址 - 创建本地分支
git branch 分支名 - 查看本地分支
git branch - 查看远程分支
git branch -a - 切换分支
git checkout 分支名(一般修改未提交则无法切换,大小写问题经常会有,可强制切换git checkout 分支名 -f,非必须慎用) - 将本地分支推送到远程分支
git push <远程仓库> <本地分支>:<远程分支>
强制切换那条要格外小心,-f 会丢掉未提交的改动。有改动又想切分支,正确做法是先 git stash 收起来,后面第七节会讲。
# 4.2 git fetch
把某个远程主机的更新全部或按分支取回本地,此时只更新了 Repository,取回的代码对你本地的开发代码没有影响。想彻底更新还需要手动合并,或者直接用 git pull。
平时想看看远程有什么新东西又不想动自己的代码,fetch 是最安全的选择。
# 4.3 git pull
拉取远程主机某分支的更新,再与本地的指定分支合并,相当于 fetch 加上了合并分支的操作。
# 4.4 git push
将本地分支的更新推送到远程主机,命令格式与 git pull 相似。
# 4.5 分支操作
这一段是纯清单,用到查一下就行:
- 下载指定分支:
git clone -b <分支名> <仓库地址> - 拉取远程新分支:
git checkout -b serverfix origin/serverfix - 合并本地分支:
git merge hotfix(将hotfix分支合并到当前分支) - 合并远程分支:
git merge origin/serverfix - 删除本地分支:
git branch -d hotfix - 删除远程分支:
git push origin --delete serverfix - 上传新命名的本地分支:
git push origin newName - 创建新分支:
git branch branchName - 切换到新分支:
git checkout branchName - 创建并切换分支:
git checkout -b branchName(相当于以上两条命令的合并) - 查看本地分支:
git branch - 查看远程仓库所有分支:
git branch -a - 本地分支重命名:
git branch -m oldName newName - 把修改后的本地分支与远程分支关联:
git branch --set-upstream-to origin/newName
删除本地分支时 -d 会检查这个分支是否已经合并过,没合并会拒绝删除,这是一道保护。确实要删没合并的分支才用 -D。
同样地,Git 2.23 之后 git switch <分支名> 和 git switch -c <新分支> 是更清楚的写法,把「切分支」从 checkout 里独立了出来。
# 五、优化操作
# 5.1 拉取代码用 pull --rebase
团队协作时,假设你和同伴在本地分别有各自的新提交,而同伴先于你 push 了代码到远程分支,所以你必须先执行 git pull 获取同伴的提交,然后才能 push 自己的提交。
按照 Git 的默认策略,如果远程分支和本地分支之间的提交线图有分叉(即不是 fast-forwarded),Git 会执行一次 merge 操作,因此产生一次没意义的提交记录,把线图搞得很乱。
其实在 pull 的时候加上 --rebase 就能很好地解决这个问题。加上这个参数的作用是,提交线图有分叉的话,Git 会用 rebase 策略代替默认的 merge 策略。
假设提交线图在执行 pull 前是这样的: