一、问题回顾
在 feature/branchA 分支的基础上 checkout feature/branchA-bak 分支,由于需求涉及到model-A 和 model-B两个模块的开发,但是需要先发布上线 model-A的代码,暂不上线 model-B的代码,所以想在 checkout feature/branchA-bak 分支 上回退 model-B模块的代码和master分支保持一致,只发布model-A 的模块代码,但是在执行模块回退的git命令犯了一个错误:
git checkout master -- model-B
在feature/voip-add-node-bak 分支上执行 git checkout master -- model-B 命令,然后本地解决一些代码依赖等冲突后执行 commit: Revert model-B to master on bak branch 然后push了,由于需求改动的文件比较多,几十上百个改动文件,将近两万行代码,没有仔细比对commit 代码是否合理就执行了push操作,造成 model-B 模块回退的时间较早,之前很多功能和配置都被回退了,上线时直接把feature/voip-add-node-bak 分支代码合并到master分支上,造成master分支代码也回退了。
分析:
git checkout master -- model-B 这条命令的意思是: "用本地master分支中的model-B目录,覆盖当前工作区中的model-B目录", feature/branchA-bak 分支上master代码很可能不是最新的,可能落后于代码仓库上master分支几周的时间,因此 真实回退的commit-id基准不可控。正确的做法应该是:
# 查看远程master的最新commit
git log origin/master --oneline -1
# 使用具体的commit哈希
git checkout commit-id -- model-B
修复master分支命令:
# 安全地撤销错误的合并
git revert -m 1 bad123
git push origin master
# 优点:保留历史,团队友好
或者点击一键回滚,回滚代码到上次确认上线的版本。
二、日常开发高频命令
我们现在是多分支开发,经常涉及切换分支,merge 分支代码等,基于我们的使用场景,分析一些重要的git 命令 以及容易忽略错误使用的命令,深刻理解git 的原理的技巧,减少误操作,比如 git rebae、 git merge 、 git reset、 git stash、 git store等等。
一、Git本质:理解这三个核心概念
1.Git的状态与区域
工作区 (Working Directory) → 暂存区 (Staging Area) → 本地仓库 (Local Repository) → 远程仓库 (Remote Repository)
↓ ↓ ↓ ↓
你编辑的文件 git add后的文件 git commit后的提交 git push后的共享代码
五个工作区:
- 工作区:就是你看到的项目文件,可以直接编辑。
- 暂存区:一个中间层,让你决定哪些修改要进入下一个版本。
- 本地仓库:保存了项目完整历史的地方,本地远程仓库是远程分支在本地的引用,属于本地仓库的一部分。
- 远程仓库:团队共享的代码仓库。
2. Git对象模型:一切皆对象
Git底层有四种核心对象,理解它们能解开很多谜团:
| 对象类型 | 作用 | 存储内容 | 不存储内容 | 典型结构/字段 | 是否去重 | 常用查看命令 | 典型场景 |
|---|---|---|---|---|---|---|---|
| Blob(Binary Large Object) | 存文件内容本身 | 文件字节内容 | 文件名、路径、目录结构、作者、时间、提交信息 | 原始字节流 | 相同内容只存一份(SHA-1/SHA-256) | git hash-object``git cat-file -p <blob> | 文件内容入库、内容去重 |
| Tree | 存目录结构快照 | 文件名、权限(mode)、对象类型(blob/tree)、对象哈希 | 文件内容、作者、提交信息 | 100644 blob <hash> filename``040000 tree <hash> dirname | 间接去重(结构相同共享 tree) | git cat-file -p <tree>git ls-tree <tree> | 构建项目快照 |
| Commit | 存一次提交记录 | 根 tree 指针、父 commit、作者、提交者、时间、提交信息 | 文件内容、目录结构 | tree <hash>``parent <hash>``author ...``committer ... | 间接去重(共享 tree/blob) | git cat-file -p <commit> | 构建历史链 |
| Tag(标签) | 给 commit 起永久名字 | 指向对象、标签作者、时间、说明、签名 | 文件内容、目录结构 | object <hash>``type commit``tag v1.0 | 不去重 | git cat-file -p <tag> | 版本发布、里程碑标记 |
# 查看Git对象类型
git cat-file -t <hash> # 查看对象类型
git cat-file -p <hash> # 查看对象内容
# 四种对象:
# 1. blob: 存储文件内容(文件名和路径信息不在这里)
# 2. tree: 存储目录结构,指向blob和其他tree
# 3. commit: 存储提交信息,指向一个tree和父commit
# 4. tag: 指向特定commit的标签
3. git的本质:指针+快照系统
commit:
- 生成快照(tree + blob)
- 分支指针前移一格
checkout:
- HEAD 换个指针
- 工作区重建快照
reset:
- 分支指针往回挪
- 可选是否重建暂存区和工作区
# 查看分支文件
cat .git/HEAD # 当前HEAD指向
cat .git/refs/heads/main # main分支指向的commit
# 创建分支的本质
echo $(git rev-parse HEAD) > .git/refs/heads/new-branch
分支不是文件的拷贝,只是一个指向某个提交的指针。切换分支时,Git只是替换工作目录中的文件。
切换分支时,Git 会:
- 移动 HEAD 指向新的分支
- 更新 index(暂存区)
- 用目标 commit 的 tree 重建工作区文件
二、高频命令深度解析
1. git add:不仅仅是添加文件
# 常见用法
git add file.txt # 添加单个文件
git add . # 添加所有修改(慎用!)
git add -p # 交互式添加(推荐)
# 本质:将工作区的修改保存为blob对象,并更新暂存区
坑点:
git add .会添加所有修改,包括你不想提交的调试代码- 已添加到.:的文件不会被add,但已跟踪文件的修改会被add
安全做法:
# 先查看状态,再选择性添加
git status -s # 简洁状态
git diff # 查看具体修改
git add -p # 交互式添加,逐块确认
2. git commit:创建版本快照
# 常用方式
git commit -m "message" # 简单提交
git commit --amend # 修改最近提交
git commit --allow-empty # 创建空提交
# 本质:创建一个commit对象,包含tree、父提交、作者信息和提交信息
深入理解:
# 查看提交的完整信息
git show --stat # 查看提交的统计信息
git show --name-only # 查看提交修改的文件名
git show -p # 查看提交的具体差异
# 修改提交信息
git commit --amend -m "新的提交信息"
# 注意:这会创建一个新的commit对象,替换旧的
坑点:
git commit --amend会重写历史,已推送到远程的提交不要使用- 提交信息不清晰会给团队协作带来麻烦
3. git checkout vs git switch/git restore
Git 2.23引入了更明确的命令:
# 传统方式(容易混淆)
git checkout feature # 切换分支
git checkout -- file # 丢弃工作区修改
# 新命令(语义更清晰)
git switch feature # 切换分支
git restore file # 恢复文件
git restore --staged file # 从暂存区移除
本质区别:
checkout:多功能命令,可以切换分支、恢复文件等switch:专门用于切换分支restore:专门用于恢复文件
坑点:
git checkout -- .会永久丢失所有未提交的修改- 恢复前一定要先用
git status确认 - 切换分支,能用 switch,就别用 checkout
使用 checkout 的风险点:
1)意外覆盖未提交的修改:
# 假设你在修改一个文件,还没提交
echo "new changes" > important.txt
# 危险操作:checkout 到另一个分支
git checkout other-branch
# ❌ 如果 other-branch 也有 important.txt,Git 会尝试合并
# ❌ 如果合并冲突,可能造成修改丢失或混乱
# 安全操作:使用 switch
git switch other-branch
# ⚠️ 同样会检查未提交修改,但语义更清晰
2)与恢复文件操作混淆:
# 危险:想切换分支,但写错了命令
git checkout . # 本意是切换分支,实际恢复了所有文件!
# ❌ 这将丢弃所有未暂存的修改!
# 安全:分开使用不同命令
git switch main # 切换分支
git restore . # 恢复文件(如果需要)
3)意外分离 HEAD:
# 危险:检查历史提交
git checkout abc1234 # 分离 HEAD 到指定提交
# ❌ 现在处于 "detached HEAD" 状态
# ❌ 在此状态下提交会产生悬空提交(易丢失)
# 安全:明确意图
git switch --detach abc1234 # 明确表示要分离 HEAD
# 或使用更安全的查看方式
git show abc1234
git log --oneline -10
4)分支名与文件名冲突:
# 假设有一个分支叫 "docs",也有一个文件叫 "docs"
# 危险:想切换分支,但可能被误解
git checkout docs
# 这到底是切换分支还是恢复文件?Git 优先解释为分支名
# 但如果分支不存在,会被解释为恢复文件!
# 安全:明确操作
git switch docs # 明确切换分支
git restore -- docs # 明确恢复文件
4. git reset:三个级别
# 三级reset的区别
# 假设提交链:A(最早)→ B(中间)→ C(最新,HEAD)
# --soft: 只移动HEAD指针
git reset --soft B
# 结果:C的修改在暂存区,可以重新提交
# --mixed: 移动HEAD指针并重置暂存区(默认)
git reset B # 等同于 git reset --mixed B
# 结果:C的修改在工作区,需要重新add
# --hard: 彻底重置
git reset --hard B
# 结果:C的修改完全丢弃,不可恢复!
举个例子:
场景 1:只有 Commit,没有 Push
# 初始:本地 A → B → C,远程只有 A → B
# (C 只在你的电脑上)
# 执行 reset
git reset --hard B
# 结果:
# 本地:A → B (HEAD) # C 完全消失
# 远程:A → B # 不受影响(本来就没有 C)
# 结论:安全!因为没有影响别人
场景 2:已经 Push 了
# 初始:本地 A → B → C,远程也是 A → B → C
# (别人可能已经拉取了 C)
# 执行 reset
git reset --hard B
# 结果:
# 本地:A → B (HEAD) # C 消失
# 远程:A → B → C # 远程依然有 C!
# 现在本地和远程不一致!
5. git merge vs git rebase:选择策略
merge的本质:
# Merge(合并)是将两个分支的修改历史整合在一起,创建一个新的合并提交(merge commit)。
git merge feature
# 会创建一个新的commit,有两个父提交
# 查看合并历史
git log --graph --oneline
场景示例:
# 1. 找到共同祖先(Base)
# main: A ← B ← E
# feature: A ← B ← C ← D
# 共同祖先:B
# 2. 计算差异
diff(B → E) # main 分支的修改
diff(B → D) # feature 分支的修改
# 3. 合并差异
# 如果没有冲突,自动合并
# 如果有冲突,需要手动解决
# 4. 创建合并提交
git commit -m "Merge branch 'feature' into main"
# 场景:两个分支都有新提交
main: A ← B ← E
feature: ← C ← D
# 执行合并
git checkout main
git merge feature
# 结果:创建合并提交 F
main: A ← B ← E ← F
↖
feature: ← C ← D
特点:
创建新的合并提交 F
F 有两个父提交:E 和 D
保留两个分支的完整历史
rebase的本质:
# Rebase(变基)是将一个分支的提交重新应用到另一个分支上,重写历史使其变成线性。
git rebase main
# 1. 找到当前分支和main分支的最近共同祖先
# 2. 提取当前分支的提交,保存为临时文件
# 3. 将当前分支指向目标分支的最新提交
# 4. 依次应用保存的提交
场景示例:
# 1. 保存要变基的提交
git checkout feature
git log --oneline
# C: "Add feature X"
# D: "Fix bug in feature X"
# 2. 重置到目标分支
git reset --hard main
# 3. 逐个应用保存的提交
git cherry-pick C # 创建 C'
git cherry-pick D # 创建 D'
# 原来的 C、D 提交被废弃(变成悬空对象)
# 初始状态
main: A ← B ← E
feature: ← C ← D
# 执行变基
git checkout feature
git rebase main
# 过程:
# 1. 找到共同祖先 B
# 2. 保存 C、D 的修改为临时补丁
# 3. 将 feature 分支重置到 main 的最新提交 E
# 4. 按顺序应用补丁,创建新的提交 C'、D'
# 结果:
main: A ← B ← E
feature: ← C' ← D'
Merge vs Rebase:核心区别
| 特性 | Merge | Rebase |
|---|---|---|
| 历史记录 | 保留分支结构,有合并提交 | 线性历史,无合并提交 |
| 提交哈希 | 不改变现有提交的哈希 | 改变提交哈希(重写历史) |
| 冲突处理 | 一次解决所有冲突 | 可能多次解决冲突(每个提交) |
| 安全性 | 安全,不破坏历史 | 危险,重写历史可能造成混乱 |
| 适用场景 | 公共分支,团队协作 | 个人分支,整理提交 |
| 可视化 | 分支合并的网状结构 | 整洁的线性结构 |
黄金规则:
- 已推送到远程的提交不要rebase
- 个人本地分支可以用rebase保持整洁
- 公共分支用merge保留完整历史
6. git stash:临时保存
# 完整用法
git stash save "描述信息" # 保存当前工作
git stash list # 查看保存列表
git stash apply stash@{1} # 取回不删除(可重复取)
git stash pop # 应用并删除最近保存
git stash drop stash@{2} # 删除特定保存
git stash show -p stash@{0} # 查看保存的差异
本质:stash实际上创建了一个特殊的commit,保存在.git/refs/stash中。
坑点:
- 多次stash后容易混乱,记得加描述
- 默认不包含未跟踪文件,需要使用
-u参数 - stash不会跨越分支边界,在不同分支应用可能产生冲突
安全模式:
# 1. 紧急修复bug
git stash save "feature-x 开发中"
git checkout hotfix-branch
# 2. 修完bug
git add .
git commit -m "紧急修复"
git checkout feature-branch
# 3. 恢复工作
git stash pop
# 如果冲突,手动解决
7. git cherry-pick:复制特定提交到当前分支
cherry-pick的本质:
1️⃣ 它到底干了什么?
一句话:
Cherry-pick = 把某个提交的“改动”复制一份,重新生成一个新提交,贴到当前分支上
不是“搬提交”
而是:
重放这个提交的 diff
2️⃣ 用提交图看本质
原始结构:
A --- B --- C (feature)
\
X --- Y (main,当前分支)
你在 main 上执行:
git cherry-pick C
Git 干的事是:
Step 1:计算差异
算出:
diff = C - B
Step 2:应用差异
把这个 diff 应用到 Y
Step 3:生成新提交
创建新提交 C'
3️⃣ 结果结构
A --- B --- C (feature)
\
X --- Y --- C' (main)
关键点:
C' 和 C
内容一样
哈希不一样
历史关系没连起来
Git 认为它们是“两个独立提交”
4️⃣ 本质对比
命令 本质
merge 连分支历史
rebase 批量 cherry-pick
cherry-pick 复制单个提交
场景示例:
场景 1:紧急修 Bug,不想合整个分支
状态
A --- B --- C --- D (feature,新功能还没测完)
\
X --- Y (main,线上分支)
C = Bug 修复
D = 新功能(不稳定)
你只想要 C
操作
git checkout main
git cherry-pick C
结果
A --- B --- C --- D (feature)
\
X --- Y --- C' (main)
这就是 cherry-pick 的黄金场景
场景 2:跨分支同步小优化
你在 dev 分支做了个日志优化提交
测试分支也要
git checkout test
git cherry-pick <commit-hash>
场景 3:多提交批量搬运
git cherry-pick C D E
或者区间方式:
git cherry-pick B..E
表示:
拿 B 之后到 E 之间的提交
日常坑点:
1️⃣ 只 pick 了“最后一个提交”,忘了依赖提交
场景
A --- B --- C (feature)
你只 pick 了 C
结果
❌ 编译报错
❌ 配置缺失
❌ 逻辑异常
本质
C 的变更是建立在 B 之上的
你把**“上层补丁”贴到了“旧底座”上**
正确姿势
一次性 pick 依赖提交:
git cherry-pick B..C
或显式列出:
git cherry-pick B C
2️⃣ 后续 merge 分支时,代码被“合进来两次”
场景
你:
从 feature cherry-pick 到 main
之后又 merge feature
结果
❌ 冲突爆发
❌ 或代码重复
本质
Git 认为:
C ≠ C'
虽然内容相同,但提交历史不相连
规避规则
用过 cherry-pick 的分支,后续尽量不要再 merge 回来
3️⃣ 在旧分支上 pick 新提交
场景
你当前 main 不是最新版本
直接 cherry-pick 新提交
结果
❌ 冲突暴增
❌ 引入过时代码
标准流程
git checkout main
git pull
git cherry-pick C
4️⃣ 批量 pick 中途冲突,不知道 Git 在“暂停状态”
场景
git cherry-pick C D E
在 D 发生冲突
错误操作
直接再 cherry-pick
或强行切换分支
正确操作链
git status
git add .
git cherry-pick --continue
或放弃本次操作:
git cherry-pick --abort
5️⃣ 把“实验代码”pick 进主干
场景
feature 分支里:
一半是调试日志
一半是正式修复
你直接 pick 最近一次提交
结果
❌ 测试代码进了生产分支
标准习惯
先确认提交内容:
git log --oneline
git show C
# 确认没问题再 pick
三、多分支开发实战模式
1. 分支策略设计
main/master (保护分支)
↑
develop (集成分支)
↑
feature/xxx (短期功能分支,合并后删除)
↑
hotfix/xxx (紧急修复分支)
2. 日常开发工作流
# 晨间同步
git switch main
git fetch origin
git pull --rebase # 使用rebase保持线性历史
# 开始新功能
git switch -c feature/new-feature
# ... 开发 ...
git add -p
git commit -m "feat: 实现新功能"
# 同步主分支更新
git fetch origin
git rebase origin/main # 将主分支更新整合到当前分支
# 完成功能
git switch main
git merge --no-ff feature/new-feature # 保留合并记录
git branch -d feature/new-feature # 删除功能分支
3. 代码审查与合并
# 创建合并请求前
git fetch origin
git rebase origin/main # 确保基于最新代码
# 解决冲突
git mergetool # 使用可视化工具解决冲突
git rebase --continue # 继续rebase
# 推送
git push origin feature/new-feature
# 然后在GitLab/GitHub创建Merge Request
四、常见坑点与解决方案
坑点1:在错误的分支上提交
场景:在main分支开发了新功能,应该提交到feature分支。
# 错误:在main分支提交
git switch main
# ... 开发 ...
git add . && git commit -m "新功能"
# 解决方案1:创建新分支并移动提交
git switch -c feature-new # 新分支包含刚才的提交
git switch main
git reset --hard HEAD~1 # main分支回退,丢弃提交
# 解决方案2:使用cherry-pick
commit_hash=$(git log -1 --pretty=format:"%H")
git switch -c feature-new
git cherry-pick $commit_hash
git switch main
git reset --hard HEAD~1
坑点2:提交了敏感信息
场景:不小心提交了密码、密钥等敏感信息。
# 如果还没推送
git rm --cached config/secrets.yml
echo "config/secrets.yml" >> .gitignore
git commit --amend
# 如果已经推送
# 1. 立即更改密码/密钥
# 2. 使用BFG或git filter-repo清理历史
# 3. 强制推送(需要团队协调)
git push --force-with-lease
坑点3:rebase冲突地狱
场景:rebase过程中出现大量冲突,陷入困境。
# 错误处理:盲目继续
git rebase main
# 冲突...
git add . && git rebase --continue # 可能引入错误
# 正确做法:理解rebase过程
git rebase main
# 遇到冲突时:
# 1. 查看冲突文件
git status
# 2. 解决冲突
git mergetool
# 3. 标记已解决
git add resolved-file.js
# 4. 继续
git rebase --continue
# 如果想放弃
git rebase --abort
坑点4:强制推送的灾难
场景:使用git push --force覆盖了团队的工作。
# 永远不要这样做:
git push --force
# 相对安全:
git push --force-with-lease # 检查远程是否有其他人推送
# 最佳实践:避免强制推送
# 1. 使用保护分支
# 2. 通过PR/MR合并代码
# 3. 使用revert而不是reset
坑点5:stash遗忘症
场景:暂存了工作,几天后忘记了。
# 预防措施:描述性保存
git stash save "feature-login: user auth implementation $(date +%Y-%m-%d)"
# 每日检查
alias gsl='git stash list'
# 设置提醒(在.bashrc或.zshrc中)
export PROMPT_COMMAND='stash_count=$(git stash list 2>/dev/null | wc -l); if [ $stash_count -gt 0 ]; then echo "你有 $stash_count 个暂存的修改"; fi'
五、高级技巧与内部原理
1. 理解.git目录结构
.git/
├── HEAD # 当前所在分支
├── config # 仓库配置
├── index # 暂存区
├── objects/ # Git对象存储
│ ├── [0-9a-f][0-9a-f]/ # 对象文件
│ └── pack/ # 打包的对象
├── refs/
│ ├── heads/ # 分支指针
│ ├── tags/ # 标签指针
│ └── remotes/ # 远程跟踪分支
├── logs/ # 引用日志
└── hooks/ # 钩子脚本
2. 使用reflog恢复误操作
# 查看所有操作历史
git reflog --date=iso
# 输出示例:
# abc1234 HEAD@{2023-10-01 10:30:00}: commit: 添加新功能
# def5678 HEAD@{2023-10-01 09:15:00}: checkout: 从main切换到feature
# 恢复误删的分支
git branch feature-lost HEAD@{2}
# 恢复hard reset前的状态
git reset --hard HEAD@{5}
关键点:reflog记录了HEAD的所有移动,是Git的"时间机器",默认保存90天。
3. 二分查找定位问题
# 找到引入bug的提交
git bisect start
git bisect bad HEAD # 当前版本有问题
git bisect good v1.0.0 # 这个版本没问题
# Git会自动切换到中间提交,你测试后标记:
git bisect good # 这个提交没问题
# 或
git bisect bad # 这个提交有问题
# 直到找到第一个坏提交
git bisect reset # 结束二分查找
六、团队协作最佳实践
1. 分支保护策略
# GitLab示例 (.gitlab-ci.yml)
workflow:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: never # 禁止直接推送到main
# 分支保护设置:
# - 要求至少2个批准
# - 要求CI通过
# - 禁止强制推送
# - 要求线性历史
2. 提交信息规范
# 使用Conventional Commits
<type>(<scope>): <subject>
<body>
<footer>
# 类型:
# feat: 新功能
# fix: 修复bug
# docs: 文档更新
# style: 代码格式
# refactor: 重构
# test: 测试
# chore: 维护
3. 代码审查清单
## 合并前检查
### 代码质量
- [ ] 编译是否通过?
- [ ] 测试是否通过?
- [ ] 是否有明显的性能问题?
- [ ] 是否符合编码规范?
### Git相关
- [ ] 提交信息是否清晰?
- [ ] 是否包含不相关的修改?
- [ ] 是否基于最新主分支?
- [ ] 是否有合并冲突?
### 业务逻辑
- [ ] 是否处理了边界情况?
- [ ] 是否有安全风险?
- [ ] 是否有文档更新?
4. 自动化检查脚本
#!/bin/bash
# pre-commit-check.sh
echo "=== 提交前检查 ==="
# 1. 检查是否有未跟踪的敏感文件
sensitive_files=$(git status --porcelain | grep -E ".(key|pem|env|secret)" || true)
if [ -n "$sensitive_files" ]; then
echo "❌ 发现敏感文件:"
echo "$sensitive_files"
exit 1
fi
# 2. 检查提交信息格式
if [ -n "$1" ]; then
if ! echo "$1" | grep -qE "^(feat|fix|docs|style|refactor|test|chore)((.+))?: .+"; then
echo "❌ 提交信息格式不正确"
echo "格式应为: <type>(<scope>): <subject>"
echo "示例: feat(auth): 添加用户登录功能"
exit 1
fi
fi
echo "✅ 检查通过"
七、总结
在团队开发密集,多分支开发的瀑布式开发的场景中,新建分支、合并分支、解决代码冲突等属于高频操作,特别是需求涉及到文件修改较多多,commit、push和代码cr会被多个文件分散注意力,很容易把一些误操作的代码变更合并到master分支上,从而酿成生产事故。因此在日常开发中,每次comit 和push 代码时,需要明确自己提交的文件内容,特别是不要把本地debug修改的一些配置文件内容提交上去,此外代码cr时,要认真基于代码的模块逐个确认,切不可有在预发环境测试过没问题了就麻痹大意,在发布上线之前,最好把master分支最新代码合并到发布分支,然后发布预发观察验证系统的功能是否正常,错误日志是否正常等。