Git学习记录(1):Git入门

成元 2026-04-27 { 教程, 笔记 } [ Git ]

前言

最近开始比较认真地用 Git 了。以前虽然也会 cloneaddcommitpush 这些命令,但很多时候其实是“会敲,不算真懂”。尤其是遇到 branchcheckoutresetrebase 这些命令时,脑子里经常会冒出一个问题:这条命令到底改了什么?改的是文件、分支,还是提交历史?

后来我看了 Explain Git with D3 这个网站,感觉它最大的好处不是教人死记命令,而是把 Git 里那些平时看不见的东西可视化了,比如提交图、分支指针、HEAD 的位置,以及 mergerebase 之后提交历史是怎么变化的。

这篇文章想同时满足两种阅读方式:

如果是第一次系统地看 Git,我会更建议按下面这个顺序理解:

一条最小的 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

这表示:

不过仅仅理解提交历史还不够,因为平时真正让人犯晕的,往往不是提交本身,而是工作区、暂存区、分支这些附加概念。

先搞清楚三个区域

Git 日常最常打交道的三个区域可以粗略理解成下面这样:

工作区 Working Tree   ->   暂存区 Index   ->   提交历史 Commit History
正在修改的文件             准备提交的内容        已经提交的快照

我目前觉得这样记比较顺:

平时最基础的一套流程,其实就是在这三者之间搬运内容:

git add file.c
git commit -m "update file.c"

其中:

所以 git commit 不是直接提交工作区,而是提交“暂存区当前长什么样”。这一点在后面理解 reset 时尤其重要。

git statusgit 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

这几块可以直接理解成:

如果把它和前面的“三个区域”对应起来,其实就是在回答三个问题:

所以很多时候,遇到“为什么这个文件没提交进去”或者“为什么我明明改了却看不到”的问题,先看 git status 往往就够了。

如果想让输出更短一点,也常用:

git status --short

它会把状态压缩成更适合频繁查看的形式。比如:

M  a.txt
 M b.txt
?? c.txt

这里可以粗略记成:

也就是说:

git log 到底该怎么看

看提交历史时,我最常用的还是:

git log --oneline --graph --decorate

这几个参数放在一起时特别适合初学:

例如输出可能长这样:

* 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”,最直接的答案通常就是:

  1. 先跑 git log --oneline --graph --decorate
  2. 找到目标提交那一行
  3. 复制最前面的那串短 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

这里:

所以 git rebase -i <commit-id>^ 这种写法,本质上是为了把目标提交本身也包含进交互式列表里。

例如执行:

git rebase -i HEAD~3

编辑器里可能会看到类似这样的内容:

pick a1b2c3d add intro
pick d4e5f6a fix typo
pick 1a2b3c4 add summary

这里每一行都表示一条即将被重新处理的提交:

如果只是想改其中某一次提交的 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 可以粗略理解成:

  1. 先选出要重写的那一段历史
  2. 告诉 Git 哪一条提交需要改 message
  3. Git 重新生成那一段提交历史

从效果上说,这相当于让 Git 从那个位置开始,重新生成后面的提交历史。

为什么修改旧 message 会有风险

这里最关键的一点是:修改旧提交的 message,本质上是在重写历史

原因并不复杂:

也就是说,哪怕你只是把很早以前某个提交里的一个错别字改掉,那个提交以及它后面整串提交的 commit ID 通常都会变化。

这就会带来几个问题:

这里如果你还没用过 --force-with-lease,暂时可以先把它理解成“比单纯 --force 更稳妥一点的强制推送”。
更细的区别可以通过 git push --help 查询。

所以我目前的做法倾向是:

如果只是最近一次提交写错了,而且还没推送,一般问题不大;但如果是公共分支上的旧提交,就要非常谨慎。

从暂存区移除,但保留修改

这种情况本质上是“我不想这次提交带上它了,但文件里的修改我还想留着继续改”。

现在更推荐的写法是:

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

也就是:

这也是 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,所以这条命令还是得认识。
如果想进一步看 switchcheckout 的边界,也可以通过 git help switchgit help checkout 继续查官方手册。

合并两条开发线

到了分支这里,后面最常见的问题就变成:两条线怎么合起来。最常见的两个命令就是 mergerebase

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

mergerebase 都能把两条开发线合起来,但思路不太一样。

还是看这个例子:

A <- B <- C <- D   (main)
      \
       E <- F      (dev)

如果在 dev 上执行:

git rebase main

可以把它理解成:dev 独有的提交摘下来,再接到 main 当前末尾后面

结果会更像这样:

A <- B <- C <- D <- E' <- F'   (dev)

这里的 E'F' 不是原来的 EF,而是“内容相似但提交 ID 已经变了的新提交”。

如果写成:

git rebase -i <commit-id>
git rebase -i HEAD~5

这里的 -i 就是 interactive,也就是“交互式”。
它的意思并不是 Git 会弹出一个图形界面,而是:Git 会先把即将处理的那几次提交列成一个可编辑列表,让你决定每个提交接下来要怎么处理。

交互式列表里最常见的几个动作是:

这也是为什么很多资料都会强调:不要随便 rebase 已经共享给别人的提交。因为它本质上是在改写历史,不只是改个名字那么简单。

mergerebase 的区别

我目前对这两个命令的理解可以简化成:

所以很多时候:

当然这也和团队规范有关,不同项目习惯可能不一样。

撤销、回退与改历史

Git 最容易让人头疼的一点是,很多命令都像是在“撤销”,但撤销的对象并不一样。

我目前觉得最重要的区分是:

git reset

如果当前历史是:

A <- B <- C   (main, HEAD)

执行:

git reset --soft HEAD^

可以理解成先把 mainC 挪回 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 了。

也就是说:

所以在很多情况下,reset 之后的旧提交其实暂时还是可以找回来的,并不是马上彻底消失。

想找回 reset 之前的位置怎么办

最常见的办法是看:

git reflog

reflog 可以理解成 HEAD 和分支指针移动历史。
你执行过 checkoutcommitresetrebase 之类操作后,Git 往往会把“你之前站在哪里”记下来。

例如可能看到:

abc1234 HEAD@{0}: reset: moving to B
def5678 HEAD@{1}: commit: add reset notes

这时如果你发现刚才 reset 过头了,通常就还能切回去:

git checkout def5678

或者直接把分支挪回去:

git reset --hard def5678

所以从实用角度看:

那旧提交会永久保留吗

通常也不能把这件事理解成“永远安全”。

如果一个旧提交已经没有任何分支、标签等引用再指向它,而且经过一段时间后 Git 做垃圾回收,那么这些提交最终是可能真的被清理掉的。

所以更稳妥的理解是:

也正因为这样,执行 reset --hard 之前最好先确认:

checkout <commit-id>reset <commit-id> 到底是什么关系

这两个命令很容易一起让人犯晕,因为它们都能让你“看到旧提交”,但动作其实不一样。

还是看原始状态:

A <- B <- C   (main, HEAD)

如果执行:

git checkout B

结果更像是:

A <- B <- C   (main)
     ^
   HEAD

也就是说:

而如果执行:

git reset --hard B

结果更像是:

A <- B   (main, HEAD)

这时:

所以我现在更愿意这样区分:

如果只是想看看旧版本、做对比、排查问题,优先想到 checkout 会更自然。
如果是明确要把当前分支回退到某个旧提交,再根据是否保留改动选择 reset --soft--mixed--hard

从这个角度看,也能更容易理解一句常见的话:
checkout 更像“切过去看看”,reset 更像“把分支真的拨回去”。

git revert

如果一个提交已经推送出去了,或者已经被别人拉取了,这时一般就不应该随便 reset 改写公共历史。

这时候更合适的往往是:

git revert <commit-id>

它不会把历史里的那个提交“删除”,而是会新建一个提交,专门用来抵消之前那个提交的效果。

也就是说:

从协作角度看,revert 通常比 reset 更安全。

这几个命令怎么粗略区分

为了避免后面再把这些命令混起来,我这里单独记一个简化版本:

如果只是本地自己整理还没共享的提交,resetrebase 的使用空间会更大一些。

如果提交已经进入公共历史,通常就要更保守,优先考虑 revert

标签:给某个提交起一个稳定名字

前面说分支本质上是“会继续移动的指针”,那 tag 可以理解成另一种引用:它也指向某个提交,但通常不会随着后续提交继续移动。

这很适合拿来标记版本节点,比如:

如果当前历史是:

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"

如果只是自己本地做个临时标记,轻量标签已经够用。
如果是正式版本发布,附注标签通常更合适,因为信息更完整。

tagbranch 的区别

这两个概念也容易混在一起。

所以:

标签与远程仓库

标签不会像普通分支提交那样总是自动跟着 git push 一起上去。
如果想把某个标签推到远程,常见写法是:

git push origin v1.0.0

如果想一次推送本地所有标签:

git push origin --tags

远程仓库:fetchpullpush

本地仓库理解得差不多之后,远程相关命令就没那么玄学了。

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

这种场景一般出现在下面几种情况:

也就是说,submodule 适合“我想引用另一个项目,但又不想把它彻底并入当前仓库”的情况。

添加子模块

添加一个子模块的常见命令是:

git submodule add <repo-url> <path>

例如:

git submodule add https://example.com/libfoo.git third_party/libfoo

执行之后,通常会发生两件事:

其中 .gitmodules 记录的是子模块路径和 URL 的映射关系。

克隆和更新带子模块的仓库

如果一个仓库本身带有子模块,那么普通 git clone 往往只会把主仓库拉下来,但子模块目录可能还是空的,或者没有初始化到正确状态。

比较省事的方式通常是:

git clone --recurse-submodules <repo-url>

如果仓库已经 clone 下来了,再补做初始化和更新,也常用:

git submodule update --init --recursive

这里:

子模块仓库更新后,主项目怎么跟上

这也是 submodule 最容易让人误会的一点:子模块上游仓库变了,不代表你的主项目会自动跟着变。

原因是前面提到过,主仓库记录的不是“永远跟着子模块最新版本走”,而是“这个子模块目录当前固定对应哪个 commit”。

也就是说,即使子模块所有者已经在它自己的仓库里提交并推送了新代码,你当前主项目里看到的子模块,通常还是停在原来记录的那个 commit。

如果只是想把本地子模块更新到主仓库已经记录好的版本,常见命令还是:

git submodule update --init --recursive

但如果你的问题是:上游子模块仓库已经更新了,我想让当前主项目也升级到那个新版本,那往往还要多做一步“修改主仓库记录的子模块 commit”。

一种比较直观的做法是:

  1. 进入子模块目录
  2. 拉取子模块仓库最新历史
  3. 切到你想跟随的分支并更新到最新提交
  4. 回到主仓库,把这个“子模块指针变化”提交一次

例如:

cd third_party/libfoo
git checkout main
git pull
cd ../..
git add third_party/libfoo
git commit -m "Update libfoo submodule"

这里发生的事本质上是:

所以只做 git pull 子模块仓库本身还不够。
如果不回到主仓库再 addcommit 一次,别人拉你的主仓库时,仍然只会得到旧的子模块指针。

如果你希望 Git 按 .gitmodules 里配置的分支去更新子模块,也经常会看到:

git submodule update --remote --recursive

不过初学时更容易理解的还是前面那套“进入子模块、更新、回主仓库提交指针”的流程,因为它更直观,也更容易看清每一步到底改了哪里。

在子模块里开发时要注意什么

submodule 还有一个很容易踩坑的点:进入子模块目录后,很多时候会看到它停在一个 detached HEAD 上。

这意味着:

这背后的原因是:主仓库往往只是把子模块检出到某个特定 commit,而不是自动把它放到子模块仓库自己的某个分支上。

所以如果你一进子模块就直接开始改代码,常见风险有几个:

如果确实要在子模块里开发,通常更稳妥的做法是:

  1. 进入子模块目录
  2. 先用 git statusgit branch 看清当前是不是 detached HEAD
  3. 切到子模块自己的工作分支,或者从当前 commit 新建一个分支
  4. 在子模块里修改、addcommit
  5. 把子模块仓库自己的提交先推送出去
  6. 回到主仓库,再把“子模块指针更新到新的 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"

这里有两个很容易漏掉的点:

也就是说,对子模块的修改,往往至少涉及两个提交

如果中途改乱了,回到主仓库执行:

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、工作区和暂存区这几个概念分开,很多原本看起来很乱的命令就会清楚不少。至少现在再看 checkoutmergerebaseresetrevert 这些命令时,我脑子里已经能大概想象出它们会把那张“提交图”改成什么样子,而不是只会硬背命令格式。

如果只是日常开发,其实也不需要一开始就把 Git 所有细节吃透。先把“工作区、暂存区、提交历史”这三层和“分支只是指针”这件事想清楚,后面再回头看这些命令,通常会顺很多。

参考文章