前言
最近开始比较认真地用 Git 了。以前虽然也会 clone、add、commit、push 这些命令,但很多时候其实是“会敲,不算真懂”。尤其是遇到 branch、checkout、reset、rebase 这些命令时,脑子里经常会冒出一个问题:这条命令到底改了什么?改的是文件、分支,还是提交历史?
后来我看了 Explain Git with D3 这个网站,感觉它最大的好处不是教人死记命令,而是把 Git 里那些平时看不见的东西可视化了,比如提交图、分支指针、HEAD 的位置,以及 merge、rebase 之后提交历史是怎么变化的。
这篇文章想同时满足两种阅读方式:
- 如果是刚开始接触 Git,可以从头顺着往下看
- 如果已经会基本使用,也可以把它当一份偏原理向的快速手册
如果是第一次系统地看 Git,我会更建议按下面这个顺序理解:
- 先搞清楚工作区、暂存区、提交历史
- 再搞清楚分支、
HEAD、checkout - 然后再看
merge、rebase、reset这些会让人混起来的命令 - 最后再补远程仓库、
tag、submodule
一条最小的 Git 工作流
如果只保留最常见的一条主线,我觉得 Git 的日常使用大概就是下面这些动作:
git clone <repo-url>
git status
git add <file>
git commit -m "message"
git pull --rebase
git push
它们分别对应:
- 拿到仓库
- 看当前状态
- 把改动加入暂存区
- 生成一次提交
- 先同步远程最新历史
- 再把本地提交发到远程
如果是新建仓库而不是克隆仓库,则起点通常会变成:
git init
不过如果只是记住这一串命令,后面遇到分支、撤销、改历史时还是容易发晕。所以还得先把 Git 里几个最核心的对象分开。
Git 到底在管理什么
刚开始接触 Git 时,很容易把它理解成“一个能保存代码历史的工具”。这个理解当然没错,但还是太粗了。
更具体一点地说,Git 管理的是一系列提交快照,以及指向这些快照的引用。
可以先把一次提交想成“当前项目某一时刻的完整状态”,而不是某个单独文件的补丁。每次 commit,Git 都会记录一份新的项目快照,并把它挂到已有历史后面。
如果只看最基本的情况,Git 仓库的历史大概像这样:
A <- B <- C
^
HEAD
这表示:
A、B、C是三个提交C是当前所在的提交HEAD表示“我当前正站在哪里”
不过仅仅理解提交历史还不够,因为平时真正让人犯晕的,往往不是提交本身,而是工作区、暂存区、分支这些附加概念。
先搞清楚三个区域
Git 日常最常打交道的三个区域可以粗略理解成下面这样:
工作区 Working Tree -> 暂存区 Index -> 提交历史 Commit History
正在修改的文件 准备提交的内容 已经提交的快照
我目前觉得这样记比较顺:
- 工作区:磁盘上你正在编辑的真实文件
- 暂存区:下一次提交准备带上的内容
- 提交历史:已经写进 Git 历史里的内容
平时最基础的一套流程,其实就是在这三者之间搬运内容:
git add file.c
git commit -m "update file.c"
其中:
git add:把工作区的改动放进暂存区git commit:把暂存区的内容保存成一个新的提交
所以 git commit 不是直接提交工作区,而是提交“暂存区当前长什么样”。这一点在后面理解 reset 时尤其重要。
git status 和 git log
从零开始用 Git 时,我觉得最应该养成的一个习惯是:别急着敲修改历史的命令,先看状态。
最常看的两个命令是:
git status
git log --oneline --graph --decorate
前者主要看:
- 工作区有没有改动
- 哪些改动已经进入暂存区
- 当前在哪个分支
后者主要看:
- 提交历史现在长什么样
- 当前分支和别的分支分别指到哪里
很多 Git 相关问题,只要先跑一下这两个命令,心里就会清楚不少。
git status 到底该怎么看
git status 乍看输出很多字,但初学时其实可以只盯住几类信息。
最常见的是:
git status
它通常会告诉你:
- 当前在哪个分支
- 工作区有没有修改但还没暂存
- 暂存区里有哪些内容准备提交
- 有没有未跟踪的新文件
例如你经常会看到类似这样的提示:
On branch main
Changes to be committed:
modified: a.txt
Changes not staged for commit:
modified: b.txt
Untracked files:
c.txt
这几块可以直接理解成:
Changes to be committed:已经进暂存区,下一次commit会带上Changes not staged for commit:工作区改了,但还没addUntracked files:Git 目前还没跟踪的新文件
如果把它和前面的“三个区域”对应起来,其实就是在回答三个问题:
- 工作区现在乱不乱
- 暂存区现在装了什么
- 当前分支是不是你以为的那个分支
所以很多时候,遇到“为什么这个文件没提交进去”或者“为什么我明明改了却看不到”的问题,先看 git status 往往就够了。
如果想让输出更短一点,也常用:
git status --short
它会把状态压缩成更适合频繁查看的形式。比如:
M a.txt
M b.txt
?? c.txt
这里可以粗略记成:
- 左边那一列看暂存区
- 右边那一列看工作区
??表示未跟踪文件
也就是说:
M a.txt:已经暂存的修改M b.txt:只在工作区修改了,还没暂存?? c.txt:新文件,还没被 Git 跟踪
git log 到底该怎么看
看提交历史时,我最常用的还是:
git log --oneline --graph --decorate
这几个参数放在一起时特别适合初学:
--oneline:每个提交只显示一行,方便扫一眼历史--graph:用字符画出分支分叉和合并--decorate:把HEAD、分支名、标签名也一起标出来
例如输出可能长这样:
* a1b2c3d (HEAD -> main) fix typo
* d4e5f6a add reset notes
* 1a2b3c4 init post
这里每一行开头那串像 a1b2c3d 的东西,就是常说的 commit id 的缩写。
很多命令里写的:
git checkout <commit-id>
git reset --hard <commit-id>
git revert <commit-id>
git tag v1.0.0 <commit-id>
这里的 <commit-id>,很多时候就可以直接从 git log 里抄这一串短 ID。
如果想看完整 commit id,也可以用:
git log --oneline --graph --decorate --no-abbrev-commit
或者看某一个提交的完整信息:
git show <commit-id>
所以如果问题是“我怎么知道某个提交的 commit id”,最直接的答案通常就是:
- 先跑
git log --oneline --graph --decorate - 找到目标提交那一行
- 复制最前面的那串短 ID
刚开始时,短 ID 一般已经完全够用。只有在仓库特别大、短 ID 可能不够唯一时,才需要更长甚至完整的 commit id。
文中提到的命令如果忘了,怎么继续查
Git 的命令很多,不太可能第一次就把所有参数都记住。
所以我现在更倾向于把两件事一起养成习惯:
- 先建立大致的心智模型,知道这条命令在改哪一层
- 真要敲细节时,再去查官方手册
最常用的查询方式一般是:
git help <command>
git <command> --help
例如:
git help status
git help log
git help commit
git help rebase
git help submodule
如果只是想快速看某个命令现在有哪些子命令或参数,不想直接跳到完整手册页,也可以先试:
git <command> -h
比如:
git rebase -h
git submodule -h
所以后面如果我提到某个命令但没有把所有参数都展开,默认都可以顺着这条路继续查。
从零开始的本地操作
这一部分按最常见的本地使用顺序整理:先修改文件,再暂存,再提交。
也就是说,这里先只围绕“一次本地提交本身”展开,暂时不碰分支和远程。
添加到暂存区
最常见的加入暂存区方式当然还是:
git add file.c
如果想把当前目录下已经跟踪的改动和新文件一起加入暂存区,也经常会看到:
git add .
或者:
git add -A
它们的细节略有差别,但对初学阶段来说,先记住 git add <file> 和 git add . 已经够用了。
提交
提交最常见的写法是:
git commit -m "update file.c"
这条命令做的事并不是“把当前所有文件保存一下”,而是把暂存区当前的状态保存成一个新的提交。
如果暂存区里没有内容,那么这个提交通常也不会成功。
例如如果当前只有 a.txt 已经进了暂存区,而 b.txt 还只是工作区修改,那么:
git add a.txt
git commit -m "update a.txt"
这次提交通常只会包含 a.txt,不会自动带上 b.txt。
这也是为什么很多 Git 问题最后都要回到一句话:
commit 提交的是暂存区,不是工作区。
修改 commit message
刚开始用 Git 时,最常见的一个后悔场景大概就是:提交已经做了,结果发现 commit message 写得太随便,甚至写错了。
如果只是想修改最近一次提交的日志,最直接的方式通常是:
git commit --amend
如果你已经想好了新的 message,也可以直接写成:
git commit --amend -m "better message"
它会让 Git 重新生成最近一次提交,并允许修改提交信息。
如果只是单纯改 message,不改文件内容,也可以这样理解:
- 提交内容基本不变
- 但这已经不是原来那个提交对象了
因为 Git 提交对象里本来就包含提交信息、父提交、作者、时间戳等元数据,所以只要这些内容变了,提交 ID 也会变。
如果要改的不是最近一次,而是更早之前的某个提交,常见做法一般是交互式 rebase:
git rebase -i <commit-id>^
或者如果只是想改最近几次提交中的某一个,也常写成:
git rebase -i HEAD~5
这里:
HEAD~5可以粗略理解成“当前提交往前数 5 个提交”<commit-id>^则可以粗略理解成“这个提交的父提交”
所以 git rebase -i <commit-id>^ 这种写法,本质上是为了把目标提交本身也包含进交互式列表里。
例如执行:
git rebase -i HEAD~3
编辑器里可能会看到类似这样的内容:
pick a1b2c3d add intro
pick d4e5f6a fix typo
pick 1a2b3c4 add summary
这里每一行都表示一条即将被重新处理的提交:
- 最前面的
pick表示“按原样保留这次提交” - 后面的短 ID 是 commit id
- 最后是这次提交的 message
如果只是想改其中某一次提交的 message,最常见的做法就是把对应那一行前面的 pick 改成 reword。
例如:
pick a1b2c3d add intro
reword d4e5f6a fix typo
pick 1a2b3c4 add summary
保存并退出编辑器后,Git 会开始重新播放这些提交。
当它走到 reword 标记的那一条时,会再次打开编辑器,让你修改那次提交的 message。
改完并再次保存退出后,rebase 通常就会继续执行,直到完成。
如果这一步让你有点迷惑,通常不是 Git 卡住了,而是 Git 正在调用你当前配置的编辑器等你修改并保存。
如果不确定自己默认用了什么编辑器,也可以通过 git help config 再去查 core.editor 相关配置。
所以如果只看“修改旧 message”这个场景,交互式 rebase 可以粗略理解成:
- 先选出要重写的那一段历史
- 告诉 Git 哪一条提交需要改 message
- Git 重新生成那一段提交历史
从效果上说,这相当于让 Git 从那个位置开始,重新生成后面的提交历史。
为什么修改旧 message 会有风险
这里最关键的一点是:修改旧提交的 message,本质上是在重写历史。
原因并不复杂:
- 一个提交对象里包含自己的 message
- 一个提交对象还会记录父提交
- 只要某个旧提交变了,它后面的提交因为“父提交变了”,也都得跟着重新生成
也就是说,哪怕你只是把很早以前某个提交里的一个错别字改掉,那个提交以及它后面整串提交的 commit ID 通常都会变化。
这就会带来几个问题:
- 如果这些提交已经
push到远程了,你后续通常需要git push --force或git push --force-with-lease - 别人如果已经基于旧历史继续开发,那么他们本地历史会和你改写后的历史分叉
- 后续再
pull、merge、rebase时会更麻烦,严重时甚至会让协作方误以为你“删掉又重新提交了一大串内容”
这里如果你还没用过 --force-with-lease,暂时可以先把它理解成“比单纯 --force 更稳妥一点的强制推送”。
更细的区别可以通过 git push --help 查询。
所以我目前的做法倾向是:
- 只改自己本地、还没共享出去的提交历史
- 如果提交已经进入公共分支,就尽量不要为了改 message 去重写历史
如果只是最近一次提交写错了,而且还没推送,一般问题不大;但如果是公共分支上的旧提交,就要非常谨慎。
从暂存区移除,但保留修改
这种情况本质上是“我不想这次提交带上它了,但文件里的修改我还想留着继续改”。
现在更推荐的写法是:
git restore --staged file.c
它的效果是:
- 暂存区回退
- 工作区修改保留
在很多旧资料里,也会看到:
git reset HEAD file.c
它也常被用来取消暂存,不过 restore 在语义上更直观一点,因为它明确是在“恢复暂存区状态”,不像 reset 那样一上来就让人联想到改历史。
从暂存区移除,同时丢弃修改
如果不仅不想提交,而且连本地修改也不要了,那么就要同时把工作区也恢复掉。
现在更推荐的写法是:
git restore --staged --worktree file.c
如果只是丢弃工作区修改,而文件本来没有暂存,也可以直接写:
git restore file.c
这类命令一定要小心,因为它们的结果通常很难撤销。尤其是 --worktree 相关操作,本质上就是把工作区文件恢复成某个已有快照的样子。
分支、HEAD 与当前所在位置
很多教程一上来就说“分支是 Git 最强大的功能”,但如果只是记这句话,实际用起来还是容易乱。
前面把“单次提交到底提交了什么”理顺之后,再看分支会顺很多。因为分支本质上不是一堆特殊文件,而是“指向某个提交的位置”。
我现在更愿意把分支理解成:一个指向某个提交的可移动标签。
例如:
A <- B <- C
^
main
这里 main 只是指向 C 的一个名字。
如果此时再提交一次:
A <- B <- C <- D
^
main
并不是“修改了 main 里面的内容”,而是 Git 先创建了一个新提交 D,然后把 main 这个指针从 C 挪到了 D。
HEAD 通常表示“当前检出的分支”或者“当前检出的提交”。例如:
A <- B <- C <- D
^
main
^
HEAD
这表示当前在 main 分支上工作。此时如果继续 commit,新的提交会接在 main 后面。
分支的创建、切换与提交
git branch dev 的作用并不神秘,它只是新建一个分支指针:
A <- B <- C
^
main, dev
注意这时候只是多了一个名字,并没有切换过去。当前如果还是在 main 上提交,新提交依旧会长在 main 这条线上。
真正负责切换位置的是 git checkout:
git checkout dev
切换后可以理解成:
A <- B <- C
^
dev
^
HEAD
main 仍然也指向 C
此时如果再提交:
A <- B <- C <- E
^ ^
main dev
^
HEAD
这样分支才真正“分叉”出来。
git checkout -b
git checkout -b dev 本质上就是把下面两步合在一起:
git branch dev
git checkout dev
也就是:
- 新建
dev分支 - 立刻切换到
dev
这也是 Explain Git with D3 里我感觉最直观的一部分,因为它把“建分支”和“切分支”这两个动作分开演示了。刚开始学时很容易把这两个动作混成一件事。
checkout 到底在切什么
git checkout 不只可以切分支,也可以切到某个具体提交:
git checkout <commit-id>
这时 HEAD 会直接指向某个提交,而不是某个分支,这通常叫做 detached HEAD 状态。
可以把它理解成“人站到了某个历史提交上观察”,但并没有站在某个正常分支的末尾。
例如原来是:
A <- B <- C (main, HEAD)
执行:
git checkout B
之后更像这样:
A <- B <- C (main)
^
HEAD
这里最关键的一点是:main 并没有移动,移动的只是你当前所在的位置。
所以 checkout <commit-id> 的主要用途更像是:
- 临时查看旧版本代码
- 在某个旧提交上做实验
- 排查“从哪个提交开始出问题了”
这个状态不是不能用,只是如果在这里继续提交,后续管理起来会比较容易迷路。因为这时新提交不会自动长在 main 这种正常分支名后面,而是会变成一段“只有 HEAD 暂时指着它”的历史。
如果确实想从某个旧提交继续正式开发,更稳妥的写法通常是:
git checkout -b fix-old-bug <commit-id>
这样相当于:
- 先站到那个旧提交上
- 再立刻从那里长出一个新分支
也就是说,checkout 切到旧提交,本身并不等于“把当前分支回退到了旧提交”。这点正好和后面的 reset 构成对比。
顺便一提,新的 Git 版本里通常也推荐用:
git switch <branch>
git switch -c <branch>
来替代一部分 checkout 的分支切换用途。不过很多旧资料和很多实际仓库里的说明,还是会继续使用 checkout,所以这条命令还是得认识。
如果想进一步看 switch 和 checkout 的边界,也可以通过 git help switch 或 git help checkout 继续查官方手册。
合并两条开发线
到了分支这里,后面最常见的问题就变成:两条线怎么合起来。最常见的两个命令就是 merge 和 rebase。
merge
假设当前历史是这样:
A <- B <- C <- D (main)
\
E <- F (dev)
如果切回 main 后执行:
git merge dev
可以粗略理解成:把 dev 这条线上的成果合到 main 来。
如果两边确实已经分叉,Git 通常会新建一个 merge commit:
A <- B <- C <- D ------- M (main)
\ /
E <- F --------' (dev)
这个 M 有两个父提交,所以它明确表示“这里发生过一次合并”。
Fast-forward
有时不会生成新的 merge commit。
例如当前历史是:
A <- B <- C (main)
\
D <- E (dev)
如果 main 其实一直没继续往前走,而 dev 只是单纯比它多了几个提交,那么:
git checkout main
git merge dev
Git 往往只需要把 main 指针直接挪到 E,这就叫 fast-forward:
A <- B <- C <- D <- E
^
main, dev
这种情况下没有真正的“分叉后再汇合”,所以也不需要额外新建一个 merge commit。
rebase
merge 和 rebase 都能把两条开发线合起来,但思路不太一样。
还是看这个例子:
A <- B <- C <- D (main)
\
E <- F (dev)
如果在 dev 上执行:
git rebase main
可以把它理解成:把 dev 独有的提交摘下来,再接到 main 当前末尾后面。
结果会更像这样:
A <- B <- C <- D <- E' <- F' (dev)
这里的 E'、F' 不是原来的 E、F,而是“内容相似但提交 ID 已经变了的新提交”。
如果写成:
git rebase -i <commit-id>
git rebase -i HEAD~5
这里的 -i 就是 interactive,也就是“交互式”。
它的意思并不是 Git 会弹出一个图形界面,而是:Git 会先把即将处理的那几次提交列成一个可编辑列表,让你决定每个提交接下来要怎么处理。
交互式列表里最常见的几个动作是:
pick:保留这次提交reword:保留提交内容,但修改提交 messageedit:停下来,让你手动修改这次提交squash:把这次提交压到前一条提交里,并合并 messagedrop:丢掉这次提交
这也是为什么很多资料都会强调:不要随便 rebase 已经共享给别人的提交。因为它本质上是在改写历史,不只是改个名字那么简单。
merge 和 rebase 的区别
我目前对这两个命令的理解可以简化成:
merge:保留分叉与汇合的痕迹rebase:把自己的提交重新排到另一条线后面,让历史看起来更直
所以很多时候:
- 如果是公共分支、共享历史,
merge往往更稳妥 - 如果是自己本地分支还没推送,
rebase常常能把历史整理得更干净
当然这也和团队规范有关,不同项目习惯可能不一样。
撤销、回退与改历史
Git 最容易让人头疼的一点是,很多命令都像是在“撤销”,但撤销的对象并不一样。
我目前觉得最重要的区分是:
restore:更偏向恢复文件状态reset:更偏向移动当前位置revert:更偏向额外新建一次反向提交
git reset
如果当前历史是:
A <- B <- C (main, HEAD)
执行:
git reset --soft HEAD^
可以理解成先把 main 从 C 挪回 B,但保留改动在暂存区。
这里的 HEAD^ 只是一个常见写法,不是 reset 的限制。
实际上它也可以直接指向任意提交:
git reset --soft <commit-id>
git reset --mixed <commit-id>
git reset --hard <commit-id>
也就是说,reset 的本质并不是“只能回退一步”,而是:把当前分支指针重置到你指定的位置。
如果是:
git reset --mixed HEAD^
则通常是把分支挪回去,并把改动留在工作区,但不再保留为“已暂存”。--mixed 也是默认模式。
如果是:
git reset --hard HEAD^
那就更激进了:分支、暂存区、工作区一起回到 B 的状态。
这条命令很方便,但也最危险,因为当前工作区里尚未保存的改动可能会直接丢掉。
reset 之后,原来的提交是不是就没了
这也是 reset 最容易让人误会的地方。
假设原来是:
A <- B <- C (main, HEAD)
如果执行:
git reset --hard B
表面上会变成:
A <- B (main, HEAD)
C
这里更准确的说法不是“C 被立刻删除了”,而是:当前已经没有常规分支名指向 C 了。
也就是说:
main被挪回了BC这个提交对象往往还会暂时留在 Git 仓库里- 只是它变成了一个“不再被当前分支引用”的提交
所以在很多情况下,reset 之后的旧提交其实暂时还是可以找回来的,并不是马上彻底消失。
想找回 reset 之前的位置怎么办
最常见的办法是看:
git reflog
reflog 可以理解成 HEAD 和分支指针移动历史。
你执行过 checkout、commit、reset、rebase 之类操作后,Git 往往会把“你之前站在哪里”记下来。
例如可能看到:
abc1234 HEAD@{0}: reset: moving to B
def5678 HEAD@{1}: commit: add reset notes
这时如果你发现刚才 reset 过头了,通常就还能切回去:
git checkout def5678
或者直接把分支挪回去:
git reset --hard def5678
所以从实用角度看:
git log主要看“当前可见的正式历史”git reflog更像看“我最近把HEAD和分支挪到过哪里”
那旧提交会永久保留吗
通常也不能把这件事理解成“永远安全”。
如果一个旧提交已经没有任何分支、标签等引用再指向它,而且经过一段时间后 Git 做垃圾回收,那么这些提交最终是可能真的被清理掉的。
所以更稳妥的理解是:
reset后旧提交通常不会立刻消失- 短时间内往往还能靠
reflog找回来 - 但如果一直没有任何引用再指向它,它并不是保证永久保留的
也正因为这样,执行 reset --hard 之前最好先确认:
- 这个提交是不是已经推送给别人了
- 你是不是还可能后悔,想回到 reset 之前
- 有没有必要先记一下
git log或git reflog
checkout <commit-id> 和 reset <commit-id> 到底是什么关系
这两个命令很容易一起让人犯晕,因为它们都能让你“看到旧提交”,但动作其实不一样。
还是看原始状态:
A <- B <- C (main, HEAD)
如果执行:
git checkout B
结果更像是:
A <- B <- C (main)
^
HEAD
也就是说:
main还停在C- 你只是临时站到了
B
而如果执行:
git reset --hard B
结果更像是:
A <- B (main, HEAD)
这时:
HEAD仍然附着在main上- 但
main自己已经被挪到了B - 同时工作区和暂存区也被一起重置成
B
所以我现在更愿意这样区分:
checkout <commit-id>:切换“我现在站在哪”,重点是切换观察位置reset <commit-id>:修改“当前分支指到哪”,重点是移动分支指针
如果只是想看看旧版本、做对比、排查问题,优先想到 checkout 会更自然。
如果是明确要把当前分支回退到某个旧提交,再根据是否保留改动选择 reset --soft、--mixed 或 --hard。
从这个角度看,也能更容易理解一句常见的话:
checkout 更像“切过去看看”,reset 更像“把分支真的拨回去”。
git revert
如果一个提交已经推送出去了,或者已经被别人拉取了,这时一般就不应该随便 reset 改写公共历史。
这时候更合适的往往是:
git revert <commit-id>
它不会把历史里的那个提交“删除”,而是会新建一个提交,专门用来抵消之前那个提交的效果。
也就是说:
reset更像“把历史指针往回拨”revert更像“承认那次提交存在过,但再提交一次反向修改把它抵消”
从协作角度看,revert 通常比 reset 更安全。
这几个命令怎么粗略区分
为了避免后面再把这些命令混起来,我这里单独记一个简化版本:
restore:恢复工作区或暂存区里的文件状态checkout:切换当前所在的分支或提交reset:移动当前分支指针,并可能重置暂存区和工作区revert:不改已有历史,额外追加一个“反向提交”
如果只是本地自己整理还没共享的提交,reset 和 rebase 的使用空间会更大一些。
如果提交已经进入公共历史,通常就要更保守,优先考虑 revert。
标签:给某个提交起一个稳定名字
前面说分支本质上是“会继续移动的指针”,那 tag 可以理解成另一种引用:它也指向某个提交,但通常不会随着后续提交继续移动。
这很适合拿来标记版本节点,比如:
v1.0.0v1.1.0release-2026-04
如果当前历史是:
A <- B <- C (main, HEAD)
执行:
git tag v1.0.0
可以粗略理解成:
A <- B <- C (main, HEAD, tag: v1.0.0)
也就是说,你只是给当前提交 C 额外挂了一个名字。以后即使 main 继续往前走,这个标签通常还是留在原来的提交上。
常见的 tag 用法
最常见的是给当前提交打标签:
git tag v1.0.0
也可以给指定提交打标签:
git tag v1.0.0 <commit-id>
查看已有标签:
git tag
查看标签对应的提交:
git show v1.0.0
轻量标签和附注标签
初学阶段先知道 Git 里常见有两种标签就够用了:
- 轻量标签:更像“给某个提交起个别名”
- 附注标签:除了名字,还会额外记录标签说明、作者、时间等信息
创建附注标签的常见写法是:
git tag -a v1.0.0 -m "release v1.0.0"
如果只是自己本地做个临时标记,轻量标签已经够用。
如果是正式版本发布,附注标签通常更合适,因为信息更完整。
tag 和 branch 的区别
这两个概念也容易混在一起。
branch:会随着新提交继续往前移动tag:通常只是固定标记某一个提交
所以:
- 想从某个点继续开发,用
branch - 想永久标记“这个提交就是某个版本”,用
tag
标签与远程仓库
标签不会像普通分支提交那样总是自动跟着 git push 一起上去。
如果想把某个标签推到远程,常见写法是:
git push origin v1.0.0
如果想一次推送本地所有标签:
git push origin --tags
远程仓库:fetch、pull、push
本地仓库理解得差不多之后,远程相关命令就没那么玄学了。
git fetch
fetch 的作用可以理解成:把远程最新历史抓到本地,但先不自动合并到当前分支。
它主要更新的是远程跟踪分支,比如 origin/main。
所以执行完:
git fetch origin
通常意味着:
- 本地知道远程仓库现在走到哪了
- 但你当前工作的分支内容还没变
git pull
pull 可以粗略看成两步:
git fetch + git merge
也就是说它先取回远程更新,再把对应远程分支合并到当前分支。
如果使用:
git pull --rebase
那么第二步就会从 merge 变成 rebase。
所以 pull 并不是一个“神秘的一步到位命令”,它本质上只是把两个动作打包了。
git push
push 的作用则是把本地分支上、而远程还没有的提交发到远程。
不过 Git 一般不会允许你随便把远程公共历史改得面目全非。最典型的情况就是:如果远程分支已经比你本地分支多了一些提交,而你本地又没有先把它们整合进来,那么 push 往往会被拒绝。
这时通常要先:
git fetch
git merge origin/main
或者:
git pull --rebase
把历史整理好之后再推送。
子模块:在一个仓库里引用另一个仓库
除了普通文件和目录,Git 还有一种比较特殊的用法,就是 submodule。
我目前对它的理解是:主仓库并不是把子仓库的历史整个吞进来,而是只记录“这个目录此刻应该对应子仓库的哪个提交”。
这点很关键,因为它解释了为什么 submodule 经常让人第一次接触时感到迷惑。主仓库关心的不是子模块目录里有哪些普通文件,而是“这个子仓库现在应该停在什么 commit 上”。
为什么会需要 submodule
这种场景一般出现在下面几种情况:
- 一个项目需要依赖另一个独立维护的 Git 仓库
- 想把公共组件拆成单独仓库复用
- 想保留上下游各自独立的提交历史,而不是直接把代码拷进来
也就是说,submodule 适合“我想引用另一个项目,但又不想把它彻底并入当前仓库”的情况。
添加子模块
添加一个子模块的常见命令是:
git submodule add <repo-url> <path>
例如:
git submodule add https://example.com/libfoo.git third_party/libfoo
执行之后,通常会发生两件事:
- 仓库里多出一个
.gitmodules文件 - 指定目录下出现子模块内容
其中 .gitmodules 记录的是子模块路径和 URL 的映射关系。
克隆和更新带子模块的仓库
如果一个仓库本身带有子模块,那么普通 git clone 往往只会把主仓库拉下来,但子模块目录可能还是空的,或者没有初始化到正确状态。
比较省事的方式通常是:
git clone --recurse-submodules <repo-url>
如果仓库已经 clone 下来了,再补做初始化和更新,也常用:
git submodule update --init --recursive
这里:
--init:初始化本地子模块配置--recursive:如果子模块内部还有子模块,就继续递归处理
子模块仓库更新后,主项目怎么跟上
这也是 submodule 最容易让人误会的一点:子模块上游仓库变了,不代表你的主项目会自动跟着变。
原因是前面提到过,主仓库记录的不是“永远跟着子模块最新版本走”,而是“这个子模块目录当前固定对应哪个 commit”。
也就是说,即使子模块所有者已经在它自己的仓库里提交并推送了新代码,你当前主项目里看到的子模块,通常还是停在原来记录的那个 commit。
如果只是想把本地子模块更新到主仓库已经记录好的版本,常见命令还是:
git submodule update --init --recursive
但如果你的问题是:上游子模块仓库已经更新了,我想让当前主项目也升级到那个新版本,那往往还要多做一步“修改主仓库记录的子模块 commit”。
一种比较直观的做法是:
- 进入子模块目录
- 拉取子模块仓库最新历史
- 切到你想跟随的分支并更新到最新提交
- 回到主仓库,把这个“子模块指针变化”提交一次
例如:
cd third_party/libfoo
git checkout main
git pull
cd ../..
git add third_party/libfoo
git commit -m "Update libfoo submodule"
这里发生的事本质上是:
- 子模块仓库自己前进到了新的 commit
- 主仓库发现“这个目录对应的子模块 commit 变了”
- 你再把这个变化作为主仓库的一次普通提交保存下来
所以只做 git pull 子模块仓库本身还不够。
如果不回到主仓库再 add 和 commit 一次,别人拉你的主仓库时,仍然只会得到旧的子模块指针。
如果你希望 Git 按 .gitmodules 里配置的分支去更新子模块,也经常会看到:
git submodule update --remote --recursive
不过初学时更容易理解的还是前面那套“进入子模块、更新、回主仓库提交指针”的流程,因为它更直观,也更容易看清每一步到底改了哪里。
在子模块里开发时要注意什么
submodule 还有一个很容易踩坑的点:进入子模块目录后,很多时候会看到它停在一个 detached HEAD 上。
这意味着:
- 你可以查看代码
- 但如果直接在这里提交,又没有切到一个正常分支,后面管理这些提交会比较麻烦
这背后的原因是:主仓库往往只是把子模块检出到某个特定 commit,而不是自动把它放到子模块仓库自己的某个分支上。
所以如果你一进子模块就直接开始改代码,常见风险有几个:
- 改完之后提交落在 detached HEAD 上,不容易继续管理
- 只在子模块里提交了,但忘了回主仓库更新子模块指针
- 只更新了主仓库指针,但子模块自己的提交还没推送,别人拉主仓库后会拿不到那个 commit
如果确实要在子模块里开发,通常更稳妥的做法是:
- 进入子模块目录
- 先用
git status、git branch看清当前是不是 detached HEAD - 切到子模块自己的工作分支,或者从当前 commit 新建一个分支
- 在子模块里修改、
add、commit - 把子模块仓库自己的提交先推送出去
- 回到主仓库,再把“子模块指针更新到新的 commit”提交一次
例如一个更完整的流程可以是:
cd third_party/libfoo
git status
git branch
git switch -c fix-bug
# 修改文件
git add .
git commit -m "Fix bug in libfoo"
git push -u origin fix-bug
cd ../..
git add third_party/libfoo
git commit -m "Point libfoo submodule to fix-bug commit"
这里有两个很容易漏掉的点:
-
先推子模块,再推主仓库
否则别人虽然能拉到主仓库里新的子模块指针,但远程子模块仓库里还没有那个 commit,他们更新 submodule 时就会失败。 -
主仓库里看到的是“子模块目录变了”,本质上其实是“子模块 commit 记录变了”
也就是说,主仓库不是在重新保存子模块内部全部文件,而是在记录“现在应该指向子模块的哪个提交”。
也就是说,对子模块的修改,往往至少涉及两个提交:
- 子模块仓库自己的提交
- 主仓库里对子模块指针变化的提交
如果中途改乱了,回到主仓库执行:
git submodule update --init --recursive
通常可以把子模块重新恢复到“当前主仓库记录的那个版本”。
这也是为什么 submodule 用起来会比普通目录更绕一些。它本质上不是一个普通目录,而是“目录 + 另一个仓库 + 一个被主仓库记录的 commit 指针”。
快速手册
如果只是想快速回忆某个常用动作,可以先看这一节。
新建或获取仓库
git init
git clone <repo-url>
git clone --recurse-submodules <repo-url>
查看状态与历史
git status
git status --short
git log --oneline --graph --decorate
git log --oneline --graph --decorate --no-abbrev-commit
git show <commit-id>
git reflog
查看帮助
git help <command>
git <command> --help
git <command> -h
暂存与取消暂存
git add <file>
git add .
git restore --staged <file>
git restore <file>
git restore --staged --worktree <file>
提交与修改提交信息
git commit -m "message"
git commit --amend
git rebase -i HEAD~5
分支操作
git branch <branch>
git checkout <branch>
git checkout -b <branch>
git checkout <commit-id>
git switch <branch>
git switch -c <branch>
合并与整理历史
git merge <branch>
git rebase <branch>
撤销与回退
git restore <file>
git reset --soft <commit-id>
git reset --mixed <commit-id>
git reset --hard <commit-id>
git reset --soft HEAD^
git reset --mixed HEAD^
git reset --hard HEAD^
git revert <commit-id>
标签
git tag
git tag <tag-name>
git tag <tag-name> <commit-id>
git tag -a <tag-name> -m "message"
git show <tag-name>
git push origin <tag-name>
git push origin --tags
远程同步
git fetch
git pull
git pull --rebase
git push
子模块
git submodule add <repo-url> <path>
git submodule update --init --recursive
git submodule update --remote --recursive
git submodule sync --recursive
总结
Git 入门最难的地方,我感觉不是命令多,而是很多命令表面看起来都像“移动文件”或者“撤销改动”,但实际上它们操作的对象并不一样。有的在改工作区,有的在改暂存区,有的在改分支指针,还有的在改提交历史。
一旦把提交图、分支、HEAD、工作区和暂存区这几个概念分开,很多原本看起来很乱的命令就会清楚不少。至少现在再看 checkout、merge、rebase、reset、revert 这些命令时,我脑子里已经能大概想象出它们会把那张“提交图”改成什么样子,而不是只会硬背命令格式。
如果只是日常开发,其实也不需要一开始就把 Git 所有细节吃透。先把“工作区、暂存区、提交历史”这三层和“分支只是指针”这件事想清楚,后面再回头看这些命令,通常会顺很多。
参考文章
- Explain Git with D3
- Git - Reset Demystified
- Git - 子模块
- Pro Git 中文版
- git-status 官方文档
- git-log 官方文档
- git-checkout 官方文档
- git-switch 官方文档
- git-config 官方文档
- git-reset 官方文档
- git-reflog 官方文档
- git-restore 官方文档
- git-commit 官方文档
- git-push 官方文档
- git-rebase 官方文档
- git-submodule 官方文档
- git-tag 官方文档