04-Git 日常与进阶操作
本章把 Git 操作分成三层讲:基础操作(每天都在用)、进阶操作(关键时刻救命)、危险操作(理解了才敢用)。每个操作都配实际命令示例和适用场景。
基础操作#
add / commit / log / diff#
这四个是 Git 的"吃喝拉撒",但很多人用了多年也没用对。Git 的命令分两层:porcelain(给人用的上层命令,如 git add git log)和 plumbing(给脚本用的底层命令,如 git cat-file git rev-parse)。日常用 porcelain 就够,写自动化脚本时才需要 plumbing。
# add:选择性 stage(比 git add . 更可控)
git add -p # 按 hunk 选择性 stage,最推荐的工作方式
git add -u # 只 stage 已跟踪文件的修改(不包含新文件)
# commit:写好 message
git commit -m "feat(auth): add OAuth2 login with Google"
# 多行 message
git commit -m "feat(auth): add OAuth2 login" -m "Body: 详细说明"
# 修改最近一次 commit(未 push 时安全)
git commit --amend
# log:定制查看历史
git log --oneline --graph --all # 图形化看全部分支
git log -p <file> # 看某个文件的完整改动历史
git log --author="zhangsan" --since="2 weeks ago"
git log -S "function_name" # 搜索增删了某字符串的 commit(pickaxe)
# diff:看清差异
git diff # 工作区 vs 暂存区
git diff --staged # 暂存区 vs HEAD(下次 commit 会写什么)
git diff main...feature # feature 分支相对 main 分叉点的改动(三点)
git diff main..feature # main 和 feature 的当前差异(两点)git diff main...feature(三点)和 git diff main..feature(两点)容易混。三点是"feature 自分叉以来的改动"——这是 PR review 时最想看的;两点是"两个分支当前的差异"——包含 main 上的新提交,会污染 diff。
commit message 写法#
写好 commit message 是 Git 工程化的第一步。推荐用 Conventional Commits 规范(详见 06 章),简单说就是:
<type>(<scope>): <description>
[optional body]
[optional footer]例:
feat(auth): add OAuth2 login with Google
Integrate Google OAuth2 as an alternative login method.
Closes #123rebase vs merge#
这是 Git 最容易引发"宗教战争"的话题。其实两者没有对错,只是不同的历史整理策略,关键是团队统一。
merge:保留完整历史#
git checkout main
git merge feature/login历史长这样:
* abc1234 Merge branch 'feature/login' into main
|\
| * def5678 feat: add login page
| * ghi9012 feat: add auth service
* | jkl3456 fix: something on main
|/
* mno7890 initial commit特点:保留分支创建和合并的完整轨迹。多人协作的分支、已 push 的分支,用 merge 安全。
rebase:线性历史#
git checkout feature/login
git rebase main # 把 feature 的提交"嫁接"到 main 最新点之后
git checkout main
git merge feature/login # 此时是 fast-forward,无合并提交历史长这样:
* def5678 feat: add login page
* ghi9012 feat: add auth service
* jkl3456 fix: something on main
* mno7890 initial commit特点:历史线性、干净。但 rebase 会重写 commit hash——原本的 def5678 ghi9012 被替换成新的 hash,老的变孤儿。
黄金法则#
永远不要 rebase 已经 push 到远端、其他人在基于此工作的分支。rebase 重写 hash 后强制 push,其他人的本地分支会和远端不一致,解决起来非常麻烦。
# 安全:rebase 自己的本地分支
git fetch origin
git rebase origin/main # 把自己的提交接在最新 main 之后
# 危险:rebase 已 push 的共享分支
git push --force-with-lease origin feature/shared
# 除非确认没人基于此分支工作,否则别这么做--force-with-lease 比 --force 安全——它会检查远端状态,如果远端分支有新提交(说明有人在用),会拒绝 push。
交互式 rebase:整理本地提交#
提交 PR 前清理"wip""fix typo"这类噪音 commit:
git rebase -i HEAD~4编辑器会出现:
pick abc1234 feat: add user model
pick def5678 wip
pick ghi9012 fix typo
pick jkl3456 feat: add user controller改成:
pick abc1234 feat: add user model
squash def5678 wip # 合入上一个,保留 message
fixup ghi9012 fix typo # 合入上一个,丢弃 message
pick jkl3456 feat: add user controller保存后历史变成两个干净的 commit。这是 PR 前整理提交的标配操作。
适用场景对比#
| 场景 | 推荐 | 理由 |
|---|---|---|
| 同步主分支最新代码到 feature 分支 | rebase | 历史线性,PR diff 干净 |
| 合并 feature 分支到 main | merge(或 squash merge) | 保留分支轨迹,不重写已 push 历史 |
| 整理本地未 push 的提交 | interactive rebase | 清理噪音 commit |
| 多人共用的长生命分支 | merge | 绝不 rebase 已 push 的共享分支 |
squash merge:第三种选择#
除了 merge 和 rebase,现代平台(GitHub/GitLab)还提供 squash merge——把 feature 分支的所有 commit 压成一个新 commit 合到 main:
# GitHub/GitLab UI 上选 "Squash and merge"
# 等价于:
git checkout main
git merge --squash feature/login
git commit -m "feat(auth): add OAuth2 login (#123)"优势:主线历史线性干净(一个 PR = 一个 commit),开发者 PR 过程中的 "wip"/"fix typo" 不进主线,只需关注 PR title 是否符合 Conventional Commits 规范。这是多数团队的最佳实践——squash merge + PR title 作为 commit message 源。
cherry-pick:精准移植#
cherry-pick 把某个 commit 的改动应用到当前分支,最典型的场景是 hotfix 同步:
# 在 main 上修了一个 bug
git log main --oneline
# abc1234 fix(api): fix null pointer in getUserById
# 需要同步到 release/v1.x 分支
git checkout release/v1.x
git cherry-pick abc1234cherry-pick 会创建一个新 commit(hash 不同),内容是同一个改动的"复制品"。
不要滥用 cherry-pick:如果你频繁需要 cherry-pick,说明分支策略有问题——通常是发版分支和主分支的同步机制没设计好。cherry-pick 不传递历史,同一个 commit 在两个分支上以不同 hash 存在,之后合并时可能制造混乱。
stash:暂存当前改动#
写到一半要切分支处理紧急 bug,但又不想提交半成品:
git stash # 把工作区改动暂存
git stash -u # 包含未跟踪文件
git stash save "WIP: 重构登录" # 加说明(旧语法)
git stash push -m "WIP: 重构登录" # 加说明(新语法)
git checkout main
# 处理紧急 bug...
git checkout feature/refactor
git stash pop # 取回最近的 stash(并删除 stash 记录)
git stash list # 列出所有 stash
git stash apply stash@{2} # 取回指定 stash(不删除记录)stash 是个栈,可以 push 多个。但不要长期堆积——stash list 看到 10 个 stash 时,大概率已经没人记得里面是什么了。stash 是临时操作,不是长期保存机制。
stash 的高级用法#
# 只 stash 某个文件
git stash push -m "only config" -- config.yaml
# stash 但保留暂存区
git stash --keep-index
# 从 stash 创建分支(stash 内容冲突无法 pop 时用)
git stash branch recover-branch stash@{0}
# 清空所有 stash(谨慎)
git stash clearstash vs commit:如果改动需要保留超过一天,直接创建临时 commit 比 stash 更安全——commit 不会因为 stash clear 误操作而丢失。
reflog:救命操作#
reflog 记录所有 HEAD 和分支的移动历史,即使 git reset --hard 也能找回。
git reflog
# abc1234 HEAD@{0}: reset: moving to HEAD~1 ← 刚才的 reset
# def5678 HEAD@{1}: commit: feat: add user model ← 被 reset 掉的 commit
# ghi9012 HEAD@{2}: checkout: moving from main to dev
# 找回被 reset 的 commit
git checkout def5678 # 临时查看
git checkout -b recover/login # 创建新分支保存reflog 默认保留 90 天(可配置 gc.reflogExpire)。只要 reflog 还在,几乎所有"误操作"都能恢复。这是 Git 比 SVN 安全得多的根本原因——SVN 撤销 commit 需要服务器管理员介入,Git 自己就能找回。
适用场景:
git reset --hard误操作git branch -D误删分支git checkout切走后忘了之前在 detached HEAD 提交的内容- rebase 中途放弃,想回到 rebase 前的状态
bisect:二分定位 bug#
线上发现一个 bug,不知道是哪次提交引入的。git bisect 用二分查找快速定位:
git bisect start
git bisect bad # 标记当前 commit 是坏的(有 bug)
git bisect good v1.2.0 # 标记 v1.2.0 是好的(无 bug)
# Git 自动 checkout 到中间的 commit,你测试后告诉 Git 状态
git bisect good # 这个 commit 是好的
# Git 继续 checkout 下一个中间 commit
git bisect bad # 这个 commit 是坏的
# ... 重复几次
git bisect reset # 结束 bisect,回到原分支如果有自动化测试脚本能判断好坏,可以自动化:
git bisect start HEAD v1.2.0
git bisect run npm test # 自动跑测试,根据退出码判断好坏log N 个 commit 中定位引入 bug 的那个,bisect 只需要 log N 步。1000 个 commit 的历史,10 步就能定位。这是 Git 给调试工作带来的最大杠杆。
bisect 实战技巧#
- 先缩小 good/bad 范围:如果能确定"上周五还是好的",用日期
git bisect good HEAD@{2 weeks ago}比找 tag 更方便 - 跳过无法构建的 commit:
git bisect skip跳过编译不过的中间 commit - 用脚本自动化:
git bisect run ./test_bug.sh让脚本判断好坏,适合"复现 bug 需要 5 分钟"的场景——自动跑 10 轮不用人工盯 - 结合单元测试:先写一个能复现 bug 的测试,bisect run 这个测试——失败就是 bad,通过就是 good
reset 三种模式#
git reset 是 Git 里最危险也最强大的命令之一。三种模式对应三种"撤回程度":
| 模式 | HEAD 移动 | 暂存区 | 工作区 | 适用场景 |
|---|---|---|---|---|
--soft | ✓ 移到指定 commit | 不变 | 不变 | 撤销 commit 但保留所有改动(重新 commit) |
--mixed(默认) | ✓ 移到指定 commit | 重置为 HEAD | 不变 | 撤销 commit + unstage(重新 add + commit) |
--hard | ✓ 移到指定 commit | 重置为 HEAD | 重置为 HEAD | 彻底丢弃改动(危险!) |
# 场景 1:刚 commit,想改 commit message 或重新组织改动
git reset --soft HEAD~1
# 此时改动还在暂存区,重新 commit 即可
# 场景 2:想撤销最近的 commit 和 stage,但保留工作区改动
git reset HEAD~1 # 等价于 --mixed
# 改动回到工作区,需要重新 git add
# 场景 3:彻底放弃最近的所有改动(谨慎!)
git reset --hard HEAD~1
# 工作区和暂存区都被重置,未 commit 的改动永久丢失
# (除非 reflog 还能找回)--hard 是 Git 里少数几个能真正"丢数据"的命令。用之前三思:工作区有没有未 commit 的改动?想清楚再用。
reset vs revert#
git revert 不是 reset 的反义词,它创建一个新 commit 来撤销指定 commit 的改动:
# 已 push 到 main 的 commit,不能用 reset(会重写历史)
# 用 revert 创建一个"反向 commit"
git revert abc1234
git push历史里会多出一个"Revert: xxx"的 commit,但原 commit 仍然在历史中。这是已 push 的共享分支上撤销改动的正确方式。
批量 revert:
# 撤销一个范围的 commit
git revert abc1234..HEAD # 创建多个 revert commit
# 或用 --no-commit 合并成一个
git revert --no-commit abc1234..HEAD
git commit -m "revert: rollback abc1234..HEAD"三种 reset 的决策树#
要撤销最近的 commit?
│
├── 改动还要不要?
│ ├── 要,重新组织后再 commit → git reset --soft HEAD~1
│ ├── 要,重新 add 再 commit → git reset --mixed HEAD~1
│ └── 不要,彻底丢弃 → git reset --hard HEAD~1(危险!)
│
└── 已经 push 到共享分支?
└── 不能用 reset,用 git revert <commit>实战要点#
- 养成
git add -p习惯。这是 Git 区别于其他 VCS 的杀手锏,能让 commit 历史变得语义清晰、可回滚、可 cherry-pick。 - rebase 整理本地,merge 合并到主干。这个组合是大多数团队的最佳实践。
--force-with-lease替代--force。前者会检查远端状态,防止覆盖别人的提交。- reflog 是后悔药。任何误操作先别慌,
git reflog看一眼,大概率能找回。 - bisect 在大仓库里是调试神器。配合自动化测试脚本,定位 bug 的效率提升一个数量级。
reset --hard前先git stash。多一层保险,没坏处。
小结#
Git 操作可以分三层理解:基础操作(add/commit/log/diff)是日常工具,进阶操作(rebase/cherry-pick/stash/reflog/bisect)是工程师的武器库,危险操作(reset --hard/force push)需要理解机制才敢用。
记住一个原则:Git 几乎不会真正丢数据,只要 reflog 还在,误操作都能找回。这是因为 Git 的对象是不可变的——即使 ref 移走了,commit 对象仍然存在于 .git/objects/ 里,直到 GC 才会被清理(默认 90 天后才 GC)。所以不要怕用 Git——多试、多练、多看 reflog,这是掌握 Git 的唯一路径。下一章讲怎么把这些操作组织成团队协作的分支策略。