Git 学习笔记
本笔记使用 Deepseek V4.1 Flash 辅助编写。
虽然并不是说没有使用过,但是我确实不太熟悉……感觉自己现在才开始认真学这个,有点落伍了。开始吧。
主线和 Missing Semester 的 Version Control 一节 对齐:自底向上,先讲数据模型,再讲命令,最后补上课程里没展开的 GitHub 和练习。
一、为什么要有版本控制
版本控制系统(VCS)用来追踪源码——或者任何一堆文件——的改动。顾名思义,它维持改动历史;顺带地,它让协作成为可能。
逻辑上,VCS 把顶层目录里的内容按快照序列记录下来:每个快照包含那一刻整个目录的状态;除了内容,还记元信息——谁做的、写了什么说明、什么时候。
哪怕只有自己在写代码,它也能让你翻旧的快照、留下"当时为什么这么改"的记录、并行开几条分支。跟别人协作时,它更是看别人改了什么、以及解决并发冲突的唯一靠谱工具。
现代 VCS 还能顺手回答这类问题:
- 这个模块是谁写的?
- 这一行是谁、什么时候、为什么改的?
- 过去一千次提交里,是哪一次让这个单元测试开始挂的?
Git 是这个领域的事实标准。它的风评也很差(xkcd 1597 画的就是"Git 出问题了就删掉重来")。但 Git 丑的是接口,不是设计:接口得背,设计可以理解。所以下面先讲数据模型,再讲命令——等模型清楚了,每条命令都只是在对同一张图上做某一类修改。
二、Git 的数据模型
快照(Snapshot)
Git 把顶层目录(以及它下面的一切)的历史,建模成一串快照。术语上:
- 文件叫 blob,就是一串字节;
- 目录叫 tree,把名字映射到 blob 或 tree(所以目录可以套目录);
- 一个快照就是被追踪的那个顶层 tree。
<root> (tree)
|
+- foo (tree)
| |
| + bar.txt (blob, contents = "hello world")
|
+- baz.txt (blob, contents = "git is wonderful")这里有个我一开始就理解错的地方:blob 只存内容,不存文件名。文件名和权限(可执行位、软链接)都存在 tree 里。所以两个不同目录下同名同内容的文件,在 Git 里是同一个 blob。
历史怎么连起来:DAG
最朴素的历史模型是线性表:快照按时间排成一行。Git 不这么干。
Git 里历史是一张 DAG。别被这个名字吓到,它只意味着:每个快照记着一组「父快照」,也就是它之前的样子。之所以是一组而不是一个,是因为一个快照可以同时从多个父快照派生出来——比如把两条并行的开发分支合并起来。
Git 管这些快照叫 commit。画出来大概是这样(箭头指向父提交,表示「在它之前」):
o <-- o <-- o <-- o
^
\
--- o <-- o第三条提交之后历史分成两支,对应两个并行开发的功能。将来把它们合起来,会得到一个新的快照,它同时把两条线收进怀里:
o <-- o <-- o <-- o <---- o ← 这个新提交有两个 parent
^ /
\ v
--- o <-- ocommit 是不可变的。 但这不代表改不了错——所谓"改历史",其实是造出一批全新的 commit,再把引用挪过去(--amend、rebase 都是这个套路)。
数据模型,写成伪代码
对着伪代码看,这个模型干净得有点过分:
// 文件就是一串字节
type blob = array<byte>
// 目录是「名字 → 子树或文件内容」的映射
type tree = map<string, tree | blob>
// 提交有父提交、元信息,和顶层快照
type commit = struct {
parents: array<commit>
author: string
message: string
snapshot: tree
}
// 上面三个统称 object
type object = blob | tree | commit内容寻址
Git 的存储层就是一张大表,键是对象的哈希,值是对象本身:
objects = map<string, object>
def store(object):
id = sha1(object)
objects[id] = object
def load(id):
return objects[id]也就是说:对象的 id 就是它内容的 SHA-1。同样的内容永远得到同样的 id(所以省空间),内容变了 id 一定变(所以能校验完整性)。
打开一个真实仓库看,tree 对象长这样:
$ git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d
100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85 baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87 foo注意 tree 里存的是指针(哈希),不是内容本身。再看那个 blob:
$ git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85
git is wonderfulSHA-1 早在 2017 年就被实际碰撞过(SHAttered 事件)。Git 现在会检测碰撞,也支持 git init --object-format=sha256 开 SHA-256 仓库,只是工具生态兼容性还一般。
引用(references)
用 40 位十六进制认快照太反人类,于是 Git 给哈希起了人话名字,叫引用。
references = map<string, string>
def update_reference(name, id):
references[name] = id
def read_reference(name):
return references[name]对象不可变,引用可变。 引用只是"指向某个 commit 的指针",移动它不产生新对象——这条和上一条拼起来,就是 Git 的全部能力。main、bugFix、v1.0 都是引用。
还有一个特殊的引用叫 HEAD,它记的是"我现在在哪"。为什么需要它?因为 git commit 得知道新提交的 parent 是谁。
仓库
于是可以说清楚了,一个 Git 仓库(roughly)就是:
objects + references磁盘上确实就这些。所有 git 命令,都是"往里加对象"或"改引用"这两件事的组合。 想不起该用什么命令的时候,先问自己:我想对这张图做什么?
比如「丢掉没提交的改动,并让 main 指向 5d83f9e」——这就是把 main 这个引用挪过去,对应 git reset --hard 5d83f9e。
用图的眼光看几条常见命令
| 命令 | 对图做了什么 |
|---|---|
git commit |
造一个 commit 对象(parent 是 HEAD),把当前分支引用挪过去 |
git branch X |
新增一个引用,指向当前提交 |
git switch X |
移动 HEAD(HEAD 指到 X 上) |
git merge X |
造一个双 parent 的 commit(快进时则是单纯移动引用) |
git rebase X |
造一批新 commit,把分支引用挪到新链上 |
git commit --amend |
造一个新 commit 顶替旧的 |
git reset --hard <rev> |
移动当前分支引用,工作区跟着同步 |
暂存区(staging area)
这是和上面那套模型正交的概念,属于"创建提交"这层的接口。
最直觉的实现方式是「抓拍当前工作目录」,有些 VCS 确实这样。但 Git 没有——因为它想要干净的快照:
- 你同时写了两个功能,想分两次提交(先功能 A,再功能 B);
- 你到处加了调试
print,中间顺手修了个 bug,只想提交 bug 修复,把 print 全扔掉。
暂存区就是为此存在的一张表:下一次提交要包含哪些内容,由你说了算。
三、命令行实操
起步
git help X
看某条命令的帮助。git help -a 列出全部命令。
git init
在目录下创建一个 .git 目录(是目录,不是文件),仓库的全部数据都在里面:
.git/
├── objects/ # 对象库,所有 blob / tree / commit
├── refs/ # 引用:refs/heads/ 是本地分支,refs/tags/ 是标签
├── HEAD # 当前在哪个分支
├── index # 暂存区(二进制文件)
├── config # 这个仓库的配置
├── hooks/ # 钩子脚本
└── logs/ # reflog,HEAD 每一步移动的记录.git 删了 = 历史全没了(工作区文件还在)。反过来说,只要 .git 还在,工作区怎么折腾都能还原——所以学 Git 的第一件事是别怕。
先配好署名
git config --global user.name "你的名字"
git config --global user.email "你的邮箱"
git config --global init.defaultBranch main状态与暂存
git status
当前状态:在哪个分支、哪些改动已暂存、哪些没暂存、哪些是 Git 完全不认识的新文件。
> git status
On branch main
No commits yet
nothing to commit (create/copy files and use "git add" to track)main 是默认分支名,可以按上面的 init.defaultBranch 改。
新建一个文件之后:
> nano foo.txt
> git status
On branch main
No commits yet
Untracked files:
(use "git add <file>..." to include in what will be committed)
foo.txtUntracked 的意思是:这个文件不在暂存区里,也不在最近一次提交里,Git 目前完全不管它。
git add
把它加进暂存区:
> git add foo.txt
> git status
On branch main
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: foo.txtgit add 只是「准备提交」,还没有产生任何历史。它也接受目录和通配符:git add . 全加;git add -p 一块一块挑着加(很值得学)。
.gitignore
不想被追踪的文件(编译产物、编辑器配置、密钥)写进 .gitignore:
*.o
build/
.vscode/
node_modules/
.env注意它只对还没被追踪的文件生效。已经提交过的文件,得先 git rm --cached 文件名 再忽略。
想全局忽略(比如 macOS 的 .DS_Store、Vim 的 *.swp),配一个全局忽略文件:
git config --global core.excludesfile ~/.gitignore_global
# 然后自己创建 ~/.gitignore_global,写规则提交
git commit
创建一次提交。不加参数会打开编辑器(由 core.editor 决定,默认是 vim)让你写提交信息;-m 直接写。
git commit -m "add foo.txt"
git commit -am "fix typo" # 已跟踪文件的改动,跳过 add
git commit --amend # 改掉上一次提交(信息或内容)提交信息值得认真写:第一行是摘要(50 字以内),空一行,下面写"为什么这么改"。git log 里看到的第一行就是它。
看历史
git log
默认输出扁平的、按时间倒序的提交列表。
git log # 默认
git log --oneline --graph --all --decorate # 看分叉和分支名(最有用)
git log -p 文件名 # 连同改动一起看
git log --author="xxx" --since="2 weeks ago"
git log -1 README.md # 最后一次改这个文件的人几个细节:
git log bigFix # 只看 bigFix 的历史(从它所在的提交往回走)
git log --branches --oneline # 所有本地分支(不含远程分支)
git log main..bigFix # 只看 bigFix 有、main 没有的提交
git branch --contains 某提交 # 这个提交被哪些分支包含git log 默认只沿 HEAD 的祖先链走,所以别的分支上的提交不会出现在 git log 里。
git show
看某一个对象的内容:给 commit 就显示它的元信息和这次改动,给 tree 就列出目录,给 blob 就打印内容。
git show HEAD # 最近一次提交改了什么
git show 2826eb9:foo.txt # 某个提交里 foo.txt 的内容 git diff
三种最常用的形态,区别在于「比的是哪两个东西」:
| 命令 | 比较的是 | 用途 |
|---|---|---|
git diff |
工作区 vs 暂存区 | 还没 add 的改动 |
git diff --staged |
暂存区 vs 最近一次提交 | 已经 add、还没 commit 的改动 |
git diff HEAD |
工作区 vs 最近一次提交 | 两者的总和 |
也可以指定两次提交来比:git diff e49b5b9 2826eb9 -- foo.txt。
--staged 的老名字是 --cached,两个都能用。合并冲突时 git diff 显示的是「合并差异」,想只看某一方可以用 git diff --ours / --theirs / --base。
分支
git branch
git branch # 列出本地分支,* 是当前分支
git branch X # 新建分支 X(不切换)
git branch -d X # 删除 X(没合并过会拒绝)
git branch -D X # 强制删除
git branch -m 新名字 # 重命名当前分支 git switch
git switch X # 切换到 X
git switch -c Y # 新建 Y 并切过去(老写法 git checkout -b Y)switch 是 git 2.23 之后拆出来的命令,专门管「切换分支」。老的 checkout 同时管切分支和还原文件,太容易误操作,新代码里基本用 switch + restore 代替。
另外 git checkout <某个提交> 能让 HEAD 直接指向一个提交而不是分支,这就是分离头指针(detached HEAD):能看、能建分支,但此刻提交的东西不属于任何分支,切走就找不到了(reflog 里还有)。
git merge
把 X 的提交合进当前分支:
git switch main
git merge bugFix如果 main 从分叉点之后没有新提交,Git 就快进(fast-forward),把 main 指针挪到 X 上,不产生合并提交;两边都有新提交时才真正生成一个有两个 parent 的 merge commit。想强制留一个合并提交:git merge --no-ff X。
我自己那个练习仓库最后的形状:
$ git log --graph --oneline --decorate --all
* f8255cd (HEAD -> main) Merge branch 'bugFix'
|\
| * 2826eb9 (bugFix) just fix a gramatic error
* | e49b5b9 adding the third line!
|/
* 729b96a adding an extra line!
* ebf8d27 Initial commit!冲突:完整流程
两边改了同一个文件的同一块区域,Git 不知道怎么选,于是把决定权交给你,并在文件里留下标记:
<<<<<<< HEAD
so this is a new line:)
=======
and this is a fix:)
>>>>>>> bugFix配了 merge.conflictStyle=zdiff3 的话,中间还会多一段 |||||||,那是分叉前的原始内容,对照着看更容易判断该留什么。
解决步骤就四步:
git status找到冲突文件(状态是UU,即 both modified)- 打开文件,删掉
<<<<<<<=======>>>>>>>这些标记行,留下你想要的内容 git add 冲突文件—— 告诉 Git「这个我解决完了」git commit—— 提交合并结果(信息 Git 已经预填好,直接保存即可;也可以git merge --continue)
不想解决了,退回合并前:git merge --abort。

VS Code 打开冲突文件时会给出 Accept Current Change / Accept Incoming Change / Accept Both Changes 的按钮,点完记得保存 + git add。命令行里也可以用 git mergetool 调出合并工具。
冲突标记没删干净 Git 不会拦你(它只看你有没有 add),所以 add 之前一定通读一遍,搜一下 <<<<<<<。
撤销(后悔药)
| 想干的事 | 命令 |
|---|---|
| 丢掉工作区某个文件的改动 | git restore 文件 |
把 add 过的文件撤出暂存区(改动保留) |
git restore --staged 文件 |
| 撤销最近一次提交,改动留在暂存区 | git reset --soft HEAD~1 |
| 同上,改动退回工作区 | git reset HEAD~1(--mixed 是默认值) |
| 连改动一起丢掉 | git reset --hard HEAD~1 |
| 已经推送出去的提交,用新提交抵消 | git revert 提交 |
| 找回被 reset 冲掉的提交 | git reflog |
| 临时把手头改动收起来 | git stash / git stash pop |
reset 是移动分支指针,revert 是新增一个反向提交。改公共分支用 revert,本地还没推的用 reset。
git reflog 记录 HEAD 的每一次移动(默认保留 90 天),是真正的兜底:reset --hard 弄丢的提交,只要 reflog 里还有那行哈希,git reset --hard 那个哈希 就能捞回来。
四、进阶命令
课程列的这张表值得先收藏,用到再细查:
| 命令 | 干什么 |
|---|---|
git add -p |
交互式暂存,一块一块挑 |
git stash |
把手头改动临时收起来,工作区回到干净状态 |
git blame 文件 |
逐行显示最后一次改它的人、提交和时间 |
git bisect |
二分查找历史,定位是哪次提交引入了 bug |
git rebase -i |
交互式整理最近几个提交(改信息、合并、重排、删除) |
git worktree add |
同时检出多个分支到不同目录 |
git config |
Git 的配置,别名、编辑器、颜色都在这里 |
两个我觉得最有用的:
git blame 是"这行是谁写的"的答案来源:
git blame -L '/collections:/,+1' _config.yml # 只看匹配行的 blame
git show <blame 输出的哈希> # 再看那次提交到底干了什么git bisect 是"哪一次提交开始坏的":
git bisect start
git bisect bad # 当前版本是坏的
git bisect good v1.0 # v1.0 是好的
# git 会不断 checkout 中间的提交,你测完告诉它 good / bad
git bisect reset # 结束,回到原来的分支一百次提交的 bug,bisect 七次左右就能定位。
顺手配个别名,省得每次敲一长串:
git config --global alias.graph "log --all --graph --decorate --oneline"
# 之后 git graph 就是上面那串五、GitHub:课程没讲的那部分
Missing Semester 只在 Misc 里写了一句「Git is not GitHub」,配上一句 pull request 的链接。可日常协作基本绕不开它,这一节补上。
Git ≠ GitHub
Git 是版本控制工具(本地程序),GitHub 是托管 Git 仓库的网站。Git 的"远端"是个通用概念,任何支持 git 协议或 HTTP 的服务器都能当远端。同类还有 GitLab、Gitee、BitBucket、Codeberg,原理完全一样,换的只是域名和界面。
远端(remote)
远端就是「一个 URL 的别名」,告诉你这个仓库的另一份拷贝在哪。
git remote -v # 看现有远端
git remote add origin [email protected]:user/repo.git
git remote set-url origin 新地址
git remote remove originorigin 只是约定俗成的名字,叫什么都行。
第一次把本地仓库推上去
在 GitHub 上建一个空仓库(别勾 README,否则会跟本地历史冲突),然后:
git remote add origin [email protected]:user/repo.git
git branch -M main # 确保分支叫 main
git push -u origin main-u 是 --set-upstream,等价于 git branch --set-upstream-to=origin/main:把本地 main 和远端 origin/main 绑定起来。绑定之后 git push / git pull 不用再写远端和分支名,git status 也会提示「领先/落后几个提交」。
push 的完整写法是 git push <远端> <本地分支>:<远端分支>,比如 git push origin main:main;:远端分支 可以省略。
克隆(clone)
git clone [email protected]:user/repo.git
git clone --depth=1 地址 # 浅克隆,只要最近一次提交clone 干的事:下载全部历史 → 自动建好 origin → 检出默认分支。浅克隆省流量,但没有历史(git log 只有一条),也不适合正常开发。
fetch 和 pull 的区别
| 命令 | 做什么 |
|---|---|
git fetch |
只下载远端的新对象和引用,不动工作区、不改当前分支 |
git pull |
fetch + merge(或 rebase,看 pull.rebase 配置) |
推荐习惯:先 git fetch,用 git log --oneline HEAD..origin/main 看看远端多了什么,再决定 git pull 还是 git rebase。直接 pull 容易在没搞清楚的情况下糊里糊涂多出一个 merge commit。
凭据:HTTPS 还是 SSH
- HTTPS:GitHub 从 2021 年起不再接受密码,要 Personal Access Token(Settings → Developer settings → Personal access tokens)。或者
gh auth login一次搞定。 - SSH:
ssh-keygen -t ed25519 -C "你的邮箱",把~/.ssh/id_ed25519.pub的内容贴进 GitHub → Settings → SSH and GPG keys。测试:ssh -T [email protected],看到Hi xxx!就通了。 - 国内直连 github.com 经常超时,可以给 git 配镜像:
git config --global url."https://gh-proxy.com/https://github.com/".insteadOf "https://github.com/"协作模型:fork + pull request
这是 GitHub 最核心的东西,也是「Git 不是 GitHub」的真正含义——PR 是 GitHub 的产品功能,不是 Git 的功能。git 本身只认仓库地址,不管你有没有"提 PR"这回事。
- 在别人的仓库点 Fork,得到自己账号下的一份
git clone自己那份,git switch -c fix-typo开个分支改git push origin fix-typo- 在 GitHub 上点 Compare & pull request,目标指向原仓库
- 维护者 review、提意见、合并
上游(原仓库)后来又有更新时,自己那份要手动同步:
git remote add upstream [email protected]:原作者/repo.git
git fetch upstream
git switch main
git merge upstream/main
git push origin main附带的几样东西
- Issue:任务和 bug 的讨论区。PR 里写
Fixes #12,合并时自动关掉对应 issue。 - Actions:CI。在
.github/workflows/*.yml里写流程,push 就自动跑测试、构建、部署——这个博客就是靠它自动发布的。 - Pages:仓库设置里开启,从某个分支或 Actions 产物发布静态站点。
- Release / Tag:
git tag -a v1.0 -m "第一个版本"+git push --tags,再在 GitHub 上把它变成一个 Release,附上源码包。 - gh CLI:
gh repo clone user/repo、gh pr create、gh pr checkout 42、gh issue list,全程不用开浏览器。
六、工具与资源
课程里 Misc 那节的几条,按我的实际体验挑着说:
- GUI 客户端:SourceTree、Fork、GitKraken 之类。看历史图形很方便,但日常还是命令行快。
- Shell 集成:让提示符直接显示当前分支和脏不脏,
zsh有zsh-git-prompt,bash有bash-git-prompt,Oh My Zsh 之类框架自带。 - 编辑器集成:VS Code 的源代码管理面板对冲突解决很友好;Vim 那边是
fugitive.vim。 - 工作流:这篇只讲了模型和命令,没讲团队规范。真实项目里 Git Flow、trunk-based 这些流派差异很大,有人夸 Git Flow,也有人骂。
想继续读:
- Pro Git(中文版也有):前五章看完就够用了
- Oh Shit, Git!?!:常见事故的急救手册
- Learn Git Branching:浏览器里玩着学分叉合并,强烈推荐
- Git for Computer Scientists:图更多的数据模型讲解
- Git from the Bottom Up:再往下钻到实现细节
七、练习
课程习题里值得动手的几条,加上我自己补的:
- 把命令和模型对上号:随便找个小仓库,每敲一条命令,先猜它动了哪些对象、挪了哪个引用,再用
git log --graph和git cat-file -p验证。 - 查案:
git log --graph看形状;git log -1 README.md找最后改它的人;git blame -L 某行找某行的来历,再git show看那次提交。 - 冲突演练(我做的就是这个):
git init一个仓库,写个recipe.txt;开salty和sweet两个分支,改同一行;切回 main 先合 salty 再合 sweet,看<<<<<<<=======>>>>>>>是什么,然后手工解决、git add、git commit,最后git log --graph --oneline看形状。我这里是拿foo.txt和bugFix分支做的,过程一样。 git stash实验:改一个已跟踪的文件后git stash,看工作区回到干净、git log --all --oneline里也没有新提交;git stash pop恢复。再想:什么时候它比"先提交再说"更合适?- 配别名和全局忽略:
git config --global alias.graph "log --all --graph --decorate --oneline";git config --global core.excludesfile ~/.gitignore_global然后自己建这个文件。 - 提一个 PR:fork 一个你常用的开源仓库(或者这个课程的仓库),修个错别字,走一遍 fork → 分支 → commit → push → PR。只提真正有用的改动。
八、几条经验
- 提交要小,一次只做一件事;信息写「为什么」而不是「改了什么」(
git diff已经说明了改了什么)。 - 别在公共分支上
push -f。真要改历史,用自己的分支 + PR。 - 大文件别进 Git。二进制、模型权重用 Git LFS,或者干脆放对象存储。
commit之前先git status,确认没有意料之外的暂存内容。- 卡住了先
git status,它几乎总是会告诉你下一步该干什么。