单人工作流:在 Git 的时光机里自由穿梭
# 第2章 · 单人工作流
# 一、我改了什么
上一章结尾,小李学会了 git init、add、commit。他兴奋地做了一整天的开发,提交了七八次。晚上回家前,他敲了 git log,看到一串 commit 记录,信心满满地合上了电脑。
第二天早上,他的同事小王在群里发了条消息:
"小李,昨天下午你改的那个
utils.js,把分页逻辑删掉了?现在第 2 页以后全报 404。"
小李心里一紧。他确实改过 utils.js,但不记得删过分页逻辑。更尴尬的是:
- ❌ 他不记得在哪一次 commit 里改的
- ❌ 他不记得改之前代码长什么样
- ❌ 他想回到「加分页功能那次 commit」,但不记得它的 hash 是什么
- ❌ 如果回退了,之后做的其他功能怎么办?
小李的问题不是 Git 能不能帮忙,而是他不知道用什么命令来「看」和「回」。
这就是本章要解决的问题:当你一个人写代码时,如何像翻书一样翻阅你的提交历史、精确查看每一次改动、以及随时退回到任意一个过去的版本。
# 二、看的本质
在深入命令之前,先建立两个关键认知:
# 认知1:时间地图
很多人把 git log 当成一条直线的时间线。错。在一个有很多分支的项目里,git log 输出的是一个有向无环图(DAG)——就像一张地铁线路图,每一条分支都是独立线路,有交叉、有分叉。
o──o──o feature/支付功能
/
o──o──o──o──o main
\
o──o──o hotfix/登录崩溃
2
3
4
5
理解了这一点,你就不会再对 git log 的输出感到困惑——它不是在讲故事,而是在画地图。
# 认知2:撤销目标
Git 里的「撤销」不是一个单一动作,而是根据你要撤销的「东西在哪个区域」来决定的:
| 你要撤销的 | 东西在哪 | 用什么命令 |
|---|---|---|
还没 add 的修改 | 工作区 | git restore / git checkout -- |
已经 add 但还没 commit | 暂存区 | git restore --staged / git reset HEAD |
已经 commit 了 | 版本库 | git reset --soft / --mixed / --hard |
已经 push 了 | 远程仓库 | git revert(第 5 章再讲) |
本章我们集中解决前三个——也就是「单人本地工作」中的所有撤销场景。
# 三、时间地图
# 3.1 基础用法
mkdir git-log-lab && cd git-log-lab
git init
# 创建几个提交,模拟真实开发
echo "const app = 'My Blog';" > app.js
git add . && git commit -m "init: 项目初始化"
echo "function getPosts() { return []; }" >> app.js
git add . && git commit -m "feat: 添加获取文章列表函数"
echo "function renderPosts(posts) { console.log(posts); }" >> app.js
git add . && git commit -m "feat: 实现文章渲染"
echo "function deletePost(id) { /* 删除文章 */ }" >> app.js
git add . && git commit -m "feat: 添加删除文章功能"
echo "function editPost(id, content) { /* 编辑文章 */ }" >> app.js
git add . && git commit -m "feat: 添加编辑文章功能"
# 最基础的 log
git log
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
你会看到类似这样的输出:
commit 8f3a2b1c4d... (HEAD -> master)
Author: 杨充 <yangchong@example.com>
Date: Sun Jun 6 15:30:00 2025 +0800
feat: 添加编辑文章功能
commit 7e2d1c0b9a...
Author: 杨充 <yangchong@example.com>
Date: Sun Jun 6 15:29:00 2025 +0800
feat: 添加删除文章功能
...
2
3
4
5
6
7
8
9
10
11
12
13
每次提交都有四个关键信息:谁、什么时候、为什么(message)、以及一个唯一的 hash。
但这样太啰嗦了。大部分时候你只想要一个概览:
# 一行一个 commit,简洁到极致
git log --oneline
# 输出:
# 8f3a2b1 feat: 添加编辑文章功能
# 7e2d1c0 feat: 添加删除文章功能
# 6c1b0a9 feat: 实现文章渲染
# 5b0a9f8 feat: 添加获取文章列表函数
# 4a9f8e7 init: 项目初始化
2
3
4
5
6
7
8
9
# 3.2 可视化分支图
git log --oneline --graph --all
在只有一条 branch 的时候,这看起来就是一条竖线,但当你有多个分支时(第 4 章),这条命令就是你的「导航地图」。
# 3.3 只看 N 次提交
git log --oneline -3 # 只看最近 3 次
# 3.4 看某个文件的历史
git log --oneline app.js # 只显示 app.js 相关的提交
🎯 实战技巧:
git log --oneline app.js是你最常用的排查命令之一。当同事问「这个文件是谁改的、什么时候改的」,用这条命令一秒定位。
# 3.5 搜索提交信息
git log --oneline --grep="删除" # 搜索 message 里包含"删除"的 commit
git log --oneline --grep="feat" # 搜索所有 feat 类型提交
2
# 3.6 用日期范围过滤
git log --oneline --since="2025-06-01" --until="2025-06-07"
git log --oneline --since="2 days ago"
2
# 3.7 装个可视化增强插件(可选但推荐)
git log --all --graph --oneline --decorate
# 太长了?起个别名:
git config --global alias.lg "log --all --graph --oneline --decorate"
# 以后直接:
git lg
2
3
4
5
💡 这些
git log选项不是让你死记硬背的。记三个最核心的就行:
git log --oneline:快速浏览git log --oneline <文件名>:看文件历史git log --oneline --graph --all:看全局分支图其他的用到了再查。
# 四、精确定位
git log 告诉你「什么时候改了」,但 git diff 告诉你「到底改了什么」。
# 场景 1:工作区 vs 版本库(还没 add 的修改)
# 先在 app.js 第一行下面加一个注释
# (使用 echo 追加到文件)
echo "// 博客应用入口" >> app.js
# 看工作区和最新 commit 的差异
git diff
2
3
4
5
6
输出解读:
diff --git a/app.js b/app.js
index 1a2b3c4..5d6e7f8 100644 ← Git 内部的 hash 变化
--- a/app.js
+++ b/app.js
@@ -1,4 +1,5 @@ ← -1,4 表示从第 1 行开始,显示 4 行(旧文件)
const app = 'My Blog'; ← 没有 +/- 前缀的是上下文
function getPosts() { return []; }
function renderPosts(posts) { console.log(posts); }
function deletePost(id) { /* 删除文章 */ }
+// 博客应用入口 ← + 是新增的行
2
3
4
5
6
7
8
9
10
+ 绿色的是你加进去的,- 红色的是你删掉的。 这一眼就能看出你做了什么。
# 场景 2:暂存区 vs 版本库(add 之后、commit 之前)
git add app.js
# 现在 git diff 什么都不显示了(因为工作区和暂存区一致)
git diff
# 想看暂存区和最新 commit 的区别,用 --cached(或 --staged)
git diff --cached
2
3
4
5
6
7
这正是我们在上一章学到的「三个区域」逻辑:git diff 比较「工作区 vs 暂存区」,git diff --cached 比较「暂存区 vs 版本库」。
# 场景 3:两个 commit 之间
# 比较两个 commit
git diff 4a9f8e7 8f3a2b1
# 比较当前工作区和两个版本之前的差异
git diff HEAD~2
# 只看某个文件在两个版本之间的变化
git diff HEAD~3 app.js
2
3
4
5
6
7
8
# 动手感受「commit 之间 diff」的威力
# 提交刚才的修改
git commit -m "docs: 添加注释"
# 现在比较「初始化那次」和「现在」,看看 app.js 长什么样
git diff $(git log --oneline | tail -1 | awk '{print $1}') HEAD -- app.js
2
3
4
5
你会看到从项目初始化到现在,app.js 的每一行增量变化。
💡 核心认知:
diff不难,难的是搞清楚 「跟谁比」。记住这个口诀:
- 不加参数 → 工作区 vs 暂存区(还没 add 的改动)
- 加
--cached→ 暂存区 vs 版本库(add 了但还没 commit 的)- 加 commit hash → 指定版本 vs 当前状态
# 五、撤回工作区
# 场景:改了几行,发现改错了,直接丢掉
# 在 app.js 里随便加点"垃圾代码"
echo "asdfasdfasdf" >> app.js
# 看改了什么
git diff app.js
# 输出显示多了 asdfasdfasdf
# 😱 算了,不要了!
git restore app.js # 新版命令(Git 2.23+)
# 等效旧命令:git checkout -- app.js
# 确认:没了
git diff app.js
# 什么都没输出 → 干净了
2
3
4
5
6
7
8
9
10
11
12
13
14
同时撤销所有文件:
git restore .
⚠️ 这是一条不会给你反悔机会的命令。
restore直接丢弃工作区的修改,被丢弃的改动不会出现在 reflog 里,因为从未 commit。用之前务必git diff确认一下。
# 六、撤回暂存区
# 场景:不小心 add 了一个不该提交的文件
# 创建两个文件
echo "重要功能" > feature.js
echo "password123" > secrets.txt # 不该提交的敏感信息!
# 一个没注意,全 add 了
git add .
git status
# 输出:
# Changes to be committed:
# new file: feature.js
# new file: secrets.txt ← 危险!这是密码!
# 把 secrets.txt 从暂存区撤回来
git restore --staged secrets.txt
# 等效旧命令:git reset HEAD secrets.txt
git status
# 输出:
# Changes to be committed:
# new file: feature.js ← 只有它了
# Untracked files:
# secrets.txt ← 回到工作区,没有丢失
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
撤回了暂存区,文件内容没丢——它只是回到了「未跟踪」或「已修改」状态。
# 七、回退版本
这是 Git 中最让人困惑但也最强大的命令。reset 有三个模式,区别在于回退之后,你的修改在哪里。
# 7.1 先准备实验环境
# 把 feature.js 提交一下
git add feature.js
git commit -m "feat: 添加核心功能模块"
# 看看当前历史
git log --oneline
# 假设输出:
# a1b2c3d feat: 添加核心功能模块
# 1d2e3f4 docs: 添加注释
# 8f3a2b1 feat: 添加编辑文章功能
# ...省略...
2
3
4
5
6
7
8
9
10
11
# 7.2 --soft:撤销 commit,修改留在暂存区
git reset --soft HEAD~1
git log --oneline
# a1b2c3d 消失了!HEAD 退到了 1d2e3f4
git status
# 输出:
# Changes to be committed:
# new file: feature.js ← 修改还在!在暂存区等你重新 commit
2
3
4
5
6
7
8
9
🎯 适用场景:commit message 写错了想改,或者 commit 了之后发现少了一个文件想补进去。
# 7.3 --mixed(默认):撤销 commit + 撤销暂存,修改留在工作区
# 重新 commit 一次,方便继续实验
git commit -m "feat: 添加核心功能模块"
# 这次用 mixed
git reset --mixed HEAD~1
# 或直接 git reset HEAD~1(默认就是 --mixed)
git log --oneline
# a1b2c3d 又消失了
git status
# 输出:
# Untracked files:
# feature.js ← 修改还在,但回到了未跟踪状态(工作区)
2
3
4
5
6
7
8
9
10
11
12
13
14
🎯 适用场景:commit 了之后想重新组织——把这次提交拆成多个更小的 commit,或者把这次改动合并到下一个 commit。
# 7.4 --hard:彻底删除(⚠️ 谨慎使用)
# 再次重新 commit
git commit -m "feat: 添加核心功能模块"
# 这次用 hard
git reset --hard HEAD~1
git log --oneline
# a1b2c3d 消失了
git status
# nothing to commit, working tree clean
# ← feature.js 和它的修改全都消失了!
2
3
4
5
6
7
8
9
10
11
12
你会看到 feature.js 文件都找不到了——不仅 commit 没了,文件本身也没了。
--soft --mixed --hard
commit ✗ 消失 ✗ 消失 ✗ 消失
暂存区 ✓ 保留 ✗ 回到工作区 ✗ 消失
工作区 ✓ 保留 ✓ 保留 ✗ 消失
2
3
4
⚠️ 三选一指南:
- 只想修改上次 commit message →
--soft(最安全)- 想重新规划这次改动的 commit 结构 →
--mixed(默认,安全)- 确定这次所有的修改都不要了 →
--hard(危险,但有后悔药)
# 八、终极后悔药
你可能会问:--hard 不是全删了吗?如果我 reset --hard 之后后悔了怎么办?
这就是 reflog 的用武之地——它记录了 HEAD 的每一次位置变化,不管你怎么 reset,它都帮你记着。
# 8.1 亲手验证「丢了再找回来」
# 先看看当前 reflog
git reflog
# 输出类似:
# 1d2e3f4 HEAD@{0}: reset: moving to HEAD~1
# a1b2c3d HEAD@{1}: commit: feat: 添加核心功能模块
# ↑ 看到了!那个被 --hard 删掉的 commit 还在!
# 找回它!
git reset --hard a1b2c3d
# 确认:feature.js 回来了
ls feature.js
# 输出:feature.js ← 回来了!
2
3
4
5
6
7
8
9
10
11
12
13
# 8.2 reflog 的三个必备使用场景
# 场景1:reset --hard 之后后悔
git reflog
# 找到 reset 之前的 commit hash
git reset --hard <hash>
# 场景2:误删分支
git reflog
# 找到分支在删除前指向的 commit
git checkout -b <恢复的分支名> <hash>
# 场景3:查看 30 天内的操作记录
git reflog --date=iso
# 每条记录都带精确时间戳
2
3
4
5
6
7
8
9
10
11
12
13
💡 Git 最重要的安全网:只要做过 commit,Git 默认保留 30 天。30 天内无论你删分支、reset --hard、还是任何骚操作,
reflog都能救你。
# 九、综合实战
# 场景设定
你是小李,你要为一个电商项目实现「商品管理」功能。按照最佳实践,你计划小步提交。但中途你引入了一个 bug,需要定位并撤销。
# 实战开始
# ==================== Phase 1:搭建项目基础 ====================
mkdir shop && cd shop
git init
echo 'const products = [];' > products.js
echo 'function addProduct(name, price) {
products.push({ name, price });
}' >> products.js
git add . && git commit -m "feat: 初始化商品模块"
# ==================== Phase 2:实现查询功能 ====================
echo '
function findProduct(name) {
return products.find(p => p.name === name);
}' >> products.js
git add . && git commit -m "feat: 添加商品查询功能"
# 假设 hash: b111111
# ==================== Phase 3:实现删除功能 ====================
echo '
function deleteProduct(name) {
const idx = products.findIndex(p => p.name === name);
if (idx !== -1) products.splice(idx, 1);
}' >> products.js
git add . && git commit -m "feat: 添加商品删除功能"
# hash: c222222
# ==================== Phase 4:实现统计功能(这里引入了一个 bug) ====================
echo '
function getTotalPrice() {
let total = 0;
for (let p of products) total += p.price;
return total; // ← 这里是对的
}' >> products.js
git add . && git commit -m "feat: 添加总价统计功能"
# hash: d333333
# ==================== Phase 5:越改越糟 ====================
# 同事说结果要保留两位小数,小李随手一改...
# 我们用 sed 把 "return total;" 换成 "return total.toFixed(2);"
# 但 .toFixed(2) 返回的是字符串,后续计算会出问题
sed -i '' 's/return total;/return total.toFixed(2);/' products.js
git add . && git commit -m "fix: 统计结果保留两位小数"
# hash: e444444
# ==================== Phase 6:又加了新功能 ====================
echo '
function getProductCount() {
return products.length;
}' >> products.js
git add . && git commit -m "feat: 添加商品数量统计"
# hash: f555555
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
现在小李的项目历史长这样:
f555555 feat: 添加商品数量统计 ← HEAD(当前位置)
e444444 fix: 统计结果保留两位小数 ← 🙈 引入类型 bug
d333333 feat: 添加总价统计功能 ← ✨ 这里是好的
c222222 feat: 添加商品删除功能
b111111 feat: 添加商品查询功能
a000000 feat: 初始化商品模块
2
3
4
5
6
# 🎯 复盘定位
第二天早上,小李的前端同事说 getTotalPrice() 返回的结果不能直接参与计算。小李要通过 Git 查清楚发生了什么。
Step 1:看看历史
git log --oneline
Step 2:缩小范围,只看 products.js 的变化
git log --oneline -- products.js
Step 3:找到引入 bug 的 commit
只看 message 就知道 e444444 fix: 统计结果保留两位小数 最可疑。验证一下:比较它和它之前的版本:
git diff e444444~1..e444444 -- products.js
输出:
- return total;
+ return total.toFixed(2);
2
找到了!toFixed(2) 返回字符串,破坏了类型。
# 🎯 恢复方案
方案 A:git revert(安全,适合已 push 的场景)
git revert e444444 -m "revert: 撤销保留两位小数的修改,toFixed 破坏类型"
# revert 创建一个新 commit,反向抵消 e444444 的改动
git log --oneline
# 输出:
# g666666 revert: 撤销保留两位小数的修改... ← 新增的抵消 commit
# f555555 feat: 添加商品数量统计
# e444444 fix: 统计结果保留两位小数
# ...
# e444444 的改动被抵消了,但 commit 还在历史里
2
3
4
5
6
7
8
9
10
方案 B:git rebase -i(适合未 push,想彻底删除那个 commit)
# 先用 revert 撤销回去,好继续实验
git reset --hard g666666 # 回到 revert 后的状态
# ... 这里我们重新开始另一个方案 ...
2
3
等等,让我们重新来。先回到 Phase 6 结束的状态:
git reflog
# 找到 "feat: 添加商品数量统计" 的 hash(f555555)
git reset --hard f555555
2
3
现在用交互式 rebase 删除 e444444(那个有 bug 的 commit):
git rebase -i e444444~1
会弹出一个编辑器,显示:
pick e444444 fix: 统计结果保留两位小数
pick f555555 feat: 添加商品数量统计
2
把要删除的那一行 pick 改成 drop(或直接删掉这一行):
drop e444444 fix: 统计结果保留两位小数 ← 删掉
pick f555555 feat: 添加商品数量统计
2
保存退出。看结果:
git log --oneline
# 输出:
# f555555_feat: 添加商品数量统计 ← 重新生成了,hash 变了
# d333333 feat: 添加总价统计功能 ← 干净的!
# c222222 feat: 添加商品删除功能
# b111111 feat: 添加商品查询功能
# a000000 feat: 初始化商品模块
2
3
4
5
6
7
e444444 直接消失了,就像从未存在过。
# 🎯 搞砸之后
# rebase 过程中出现问题,不想继续了
git rebase --abort
# rebase 完了发现不对,用 reflog 找回
git reflog
# 找到 rebase 之前的那个 f555555(原始的)
git reset --hard <原始hash>
2
3
4
5
6
7
# 十、本章回顾
| 你的需求 | 用什么命令 | 口诀 |
|---|---|---|
| 快速看一眼历史 | git log --oneline | 一行看全 |
| 看全局分支图 | git log --oneline --graph --all | 地图模式 |
| 看某个文件的历史 | git log --oneline -- <file> | 锁定文件 |
| 看还没 add 的改动 | git diff | 改了啥? |
| 看 add 了但没 commit 的 | git diff --cached | 要交啥? |
| 看两个版本的区别 | git diff <hash1> <hash2> | 版本间找差异 |
| 丢一个文件还没 add | git restore <file> | 不要了 |
| add 错了想撤回 | git restore --staged <file> | 暂存撤回 |
| 改 commit message | git reset --soft HEAD~1 | 软回退 |
| 重新规划 commit 结构 | git reset --mixed HEAD~1 | 混合回退 |
| 彻底丢弃这次 commit | git reset --hard HEAD~1 | 硬回退 ⚠️ |
| reset 错了想反悔 | git reflog → git reset --hard <hash> | 后悔药 |
| 已 push 了想撤销 | git revert <hash> | 反向抵消 |
🚀 核心心法(本章三句话):
git log是地图,git diff是放大镜——先定位,再细看。- 撤销不恐怖,恐怖的是你在哪个区域反应不过来——对照三区域四状态再动手。
git reflog是你的终极保险——什么都能丢,commit hash 别丢。
🏃 下一章预告:你一个人已经能自由穿梭历史了。接下来,Git 真正的灵魂登场——分支。你会学到:为什么分支只是一个 40 字节的指针、merge 和 rebase 到底怎么选、以及那个让所有新手崩溃的「冲突」到底怎么优雅地解决。
# 📎 本章涉及的命令速查
# === 时间地图(看历史) ===
git log # 完整历史
git log --oneline # 一行一个 commit
git log --oneline --graph --all # 可视化分支图
git log --oneline -3 # 最近 3 次
git log --oneline <file> # 只看某个文件的历史
git log --oneline --grep="关键词" # 按 message 搜索
git log --oneline --since="2 days ago" # 按日期过滤
# === 放大镜(看差异) ===
git diff # 工作区 vs 暂存区
git diff --cached # 暂存区 vs 版本库
git diff <hash1> <hash2> # 两个版本之间
git diff HEAD~2 # 当前 vs 两代之前
# === 撤销工作区 ===
git restore <file> # 丢弃工作区修改(⚠️ 无法恢复)
git restore . # 丢弃所有工作区修改
# === 撤销暂存区 ===
git restore --staged <file> # 从暂存区撤回(文件不丢)
# === 回退版本 ===
git reset --soft HEAD~1 # 撤销 commit,修改在暂存区
git reset --mixed HEAD~1 # 撤销 commit+暂存,修改在工作区
git reset --hard HEAD~1 # 全部丢弃(⚠️ 但 reflog 可救)
# === 后悔药 ===
git reflog # 查看 HEAD 移动记录
git reflog --date=iso # 带时间戳的后悔药
# === 安全撤销(已 push) ===
git revert <hash> # 新建一个 commit 抵消指定改动
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33