Git 核心原理与命令实战:三区模型、回退与冲突处理

我最初对 Git 的态度和很多人一样:会 add、会 commit、会 push,就觉得够用了。直到有次改错了仓库里的配置文件需要回退,我在 reset 和 revert 之间犹豫了半天,最后选了 reset 强推上去,顺手把同事当天的一条提交也干掉了。那次之后我把 Git 的底层模型认真补了一遍,就是这篇的内容。

一、Git 解决的是什么问题

Git 是分布式版本控制系统。它记录文件内容的变化,可以回退到任意历史版本、对比差异、多人协作不互相覆盖,并且追踪每次修改的作者和时间。

它和 SVN 这类集中式的核心区别在”副本”上:

对比项 集中式(SVN) 分布式(Git)
版本库位置 只在中央服务器 每个开发者都有完整副本
离线操作 只能查看,不能提交 可以提交、建分支、看历史
单点故障 服务器挂了全员停摆 任何一份克隆都能恢复
分支成本 昂贵,相当于复制目录 极低,本质只是加一个指针
速度 依赖网络 大部分操作在本地完成

工作里有个直观的体现:SVN 时代回滚要连服务器、要权限;Git 时代我在本地就能把整个历史翻个底朝天,出问题不慌。Git 存的是完整快照,不是差异——文件没变,就只保留一个指向前一版本的链接,所以它才敢”每个提交都是全量状态”。

二、三区模型:所有命令都能对上位置

学 Git 最大的分水岭是搞懂三个区。搞懂了,命令就不用背,靠推理就能想出来:

1
2
3
4
5
6
7
8
9
10
工作区(正在编辑的文件)
│ git add
▼
暂存区 Index(准备提交的快照清单)
│ git commit
▼
本地仓库 .git(永久快照历史)
│ git push
▼
远程仓库(GitLab / GitHub)
  • 工作区:我正在编辑的目录;
  • 暂存区:git add 之后、还没提交的内容,相当于”这一版要提交哪些文件”的清单;
  • 本地仓库:git commit 之后进入 .git 的完整历史。

文件对应四种状态:Untracked(未跟踪)→ git add → Staged(已暂存)→ git commit → Committed(未修改)→ 再编辑 → Modified(已修改)。

git status -s 的输出符号值得记一下,排障时全靠它:

标志 含义
?? 未跟踪的新文件
A 已暂存的新增文件
M 左列已暂存修改,右列未暂存修改
D 删除
UU 合并冲突,未解决

三、日常操作链路

1
2
3
4
5
6
7
8
9
10
11
12
# 初始化与克隆
git init # 当前目录创建仓库
git clone git@gitlab.example.com:ops/app.git # SSH 克隆
git clone -b develop git@gitlab.example.com:ops/app.git # 指定分支
git clone --depth 1 git@gitlab.example.com:ops/app.git # 浅克隆,大仓库拉取快很多

# 提交链路
git status -s # 看状态
git add index.html # 单文件;git add . 全部;git add -p 交互式挑选
git commit -m "feat(user): 增加登录校验" # 提交
git commit -am "fix: 修正 listen 端口" # 跳过 add,提交已跟踪文件
git push -u origin main # 首次推送并设上游,之后直接 git push

提交信息这件事,团队里最好一开始就定规矩:type(scope): 描述,type 用 feat / fix / docs / style / refactor / test / chore / perf / ci / revert。运维改动的仓库我一般直接用 OPS: 前缀,比如 OPS: 调整 nginx upstream 配置——以后用 git log --grep 筛运维改动时非常省事。

四、配置与 SSH 密钥

配置分三层,优先级是 本地 > 全局 > 系统:

1
2
3
4
5
6
7
8
9
10
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global init.defaultBranch main
git config --list # 查看全部生效配置

# 运维常用
git config --global core.autocrlf input # Linux/Mac 换行符处理,Windows 用 true
git config --global http.proxy http://proxy.example.com:8080 # 内网拉外网代码
git config --global core.sshCommand "ssh -i ~/.ssh/id_ed25519_gitlab" # 指定私钥
git config --global alias.lg "log --oneline --graph --all" # 别名

SSH 密钥现在推荐 ed25519,比 RSA 短、更安全:

1
2
3
ssh-keygen -t ed25519 -C "you@example.com"
cat ~/.ssh/id_ed25519.pub # 公钥贴到 GitLab/GitHub 的 SSH Keys 页面
ssh -T git@gitlab.example.com # 测连通性

两个高频报错的排查方向,记下来能省不少时间:

  • Permission denied (publickey):先 ssh -T 测密钥,多半是公钥没配或换了机器;
  • LF will be replaced by CRLF:检查 core.autocrlf,Windows 和 Linux 混着提交时最容易出。

五、历史、差异与回退

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 查看历史
git log --oneline --graph --all --decorate # 图形化看全历史
git log --since="2024-03-01" # 按时间过滤,线上出事定位"谁改了什么"
git log --author="zhangsan" --oneline
git show <commit-id> # 看某次提交的完整差异

# 查看差异
git diff # 工作区 vs 暂存区
git diff --staged # 暂存区 vs 最新提交
git diff main..develop # 两个分支比
git diff HEAD --name-only # 只看改了哪些文件

# 撤销与回退
git checkout -- app.conf # 丢弃工作区修改(不可恢复,手要稳)
git reset HEAD app.conf # 从暂存区拿出来,保留修改
git reset --soft HEAD~1 # 撤销最近一次 commit,改动留在暂存区
git reset --hard HEAD~2 # 彻底回退两个版本(危险)
git revert <commit-id> # 生成一条反向提交,安全回滚
git reflog # 所有指针移动历史,数据恢复的安全网

这里有一条生产铁律,也是我那次翻车换来的:已经推送到远程的提交,回滚一律用 revert,不用 reset。reset 会重写历史,别人仓库里对不上号,麻烦比原来的问题还大。reset 只用在本地、没推过的提交上。

另外一个值得练的习惯:回退前先 git log --oneline -5 确认自己在哪个位置,回退目标是什么。误操作大多不是命令不会敲,而是根本没看清当前位置。

六、分支管理与工作流

分支的本质是”指向某个提交的可变指针”,创建分支只是新建一个指针,所以非常轻量。

1
2
3
4
5
6
7
git branch                       # 本地分支
git branch -a # 本地 + 远程
git switch -c feature/login # 创建并切换(老写法 checkout -b)
git branch -d feature/login # 安全删除,未合并会报错
git branch -D feature/login # 强制删除
git push origin --delete feature/login # 删远程分支
git fetch --prune # 清理本地对已删除远程分支的引用

分支命名我见过两种典型风格,推荐带上用途和编号,看着就知道这条分支在干嘛:

1
2
3
4
feature/JIRA-123-user-login   # 新功能
bugfix/JIRA-456-fix-crash # Bug 修复
hotfix/urgent-security-patch # 紧急修复,从 main 直接拉
release/v2.1.0 # 发布分支

团队工作流常见三种,选型看发布节奏:

策略 分支模型 适用场景 复杂度
Git Flow main + develop + feature + release + hotfix 有明确发布周期的项目 高
GitHub Flow main + feature,合并即部署 持续部署的 Web 项目 低
Trunk-Based 所有人直接提交 main,短命分支 高频发布、CI/CD 成熟的团队 中

我经手的两个项目就是两个极端:老项目用 Git Flow,发布节奏一月一次;新项目 GitHub Flow,随时合并随时上线。选错的代价很直接——小团队硬上 Git Flow,光流程成本就够喝一壶。

七、合并与冲突处理

合并有三种模式,日常最需要记住的是 --no-ff:

1
2
3
4
git merge feature/login           # 默认 fast-forward,指针直接前移
git merge --no-ff feature/login # 强制生成合并提交,保留分支拓扑(团队推荐)
git merge --squash feature/login # 把分支压缩成一条提交,再手动 commit
git merge --abort # 放弃合并,回到合并前

fast-forward 的缺点是分支信息没了,以后看历史不知道这段代码从哪条分支来;--no-ff 会生成一条合并提交,历史里能看出”这里合并过一个功能分支”。团队协作场景我一般要求开 --no-ff。

冲突的产生条件很明确:两个分支改了同一文件的同一处,Git 无法自动裁决。冲突文件里的标记长这样:

1
2
3
4
5
<<<<<<< HEAD
当前分支(HEAD)的内容
=======
传入分支(feature/login)的内容
>>>>>>> feature/login

解决流程六步:合并出现 CONFLICT → git status 找到冲突文件(UU 标记)→ 编辑文件、删掉标记保留正确内容 → git add 标记已解决 → git commit 完成合并 → 后悔就 git merge --abort。

1
2
3
4
5
# 列出冲突文件
git diff --name-only --diff-filter=U
# 配图形化工具解冲突
git config --global merge.tool vscode
git mergetool

冲突不止 merge 一种场景,这是我踩过才整理全的:

场景 触发命令 解决方式
merge 冲突 git merge feature 改文件 → git add → git commit
rebase 冲突 git rebase main 改文件 → git add → git rebase –continue
cherry-pick 冲突 git cherry-pick xxx 改文件 → git add → git cherry-pick –continue
pull 冲突 git pull 改文件 → git add → git commit,或先 stash 再 pull
stash pop 冲突 git stash pop 改文件 → git add → git stash drop(记录不会自动删)
二进制文件冲突 两分支都改了 png/jar 无法合并内容,只能选一侧或重新生成

二进制冲突没法”改文件”,只能二选一:

1
2
3
git checkout --ours  app.jar    # 保留当前分支版本
git checkout --theirs app.jar # 保留合并进来分支的版本
git add app.jar && git commit

解决冲突后有个动作绝不能省:本地编译 / 测试跑一遍再提交。冲突标记删干净只是格式上对了,逻辑可能已经被改得前后矛盾——我见过一次合并后登录模块直接失效,就是两边各保留了一半的校验逻辑。

八、远程仓库与几个救场命令

1
2
3
4
5
6
7
8
9
10
11
12
git remote -v                                    # 看远程地址
git remote set-url origin git@gitlab.example.com:ops/app.git # 换域名后改地址

# fetch vs pull
git fetch origin # 只下载不合并,先看看再决定(安全)
git merge origin/main # 确认后再合并
git pull origin main # = fetch + merge
git pull --rebase # 用 rebase 代替 merge,历史更线性

# 推送
git push -u origin feature/login
git push --force-with-lease origin main # 安全强推:远程有新提交会拒绝

几个高频救场命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# rebase:把当前分支的提交"移植"到新基点上,历史线性但会重写 SHA
git switch feature/login && git rebase main
# 冲突时:解决 → git add → git rebase --continue;放弃:git rebase --abort

# cherry-pick:把某个提交单独摘到当前分支,hotfix 场景常用
git cherry-pick a3f5b2c
git cherry-pick -x a3f5b2c # 保留原始提交号标注,方便追溯

# stash:临时保存未完成的工作,切分支修 bug 前用
git stash push -m "WIP: 登录功能"
git stash list
git stash pop # 恢复并删除记录

# tag:版本发布标记
git tag -a v1.0.0 -m "正式版"
git push origin --tags # 标签默认不推送,必须显式推

两条经验规则:永远不要 rebase 已推送到远程的公共分支(历史重写会让所有人对不上);永远不要对公共分支用 --force,真要强推就用 --force-with-lease,它会在远程有新提交时拒绝覆盖。发布时打 tag 配合平台的 Release 功能,回滚目标一目了然。

九、图形化工具要不要用

命令行是基本功,但图形化工具在两类场景下确实好用:看分支拓扑、给不熟命令行的同事演示。SourceTree 这类工具内置了图形化历史视图和冲突对比界面,排查”谁在什么时候改了什么”比 git log --graph 直观。注意 Windows 上装完默认用 PuTTY 的 SSH,如果之前用 Git Bash 生成过密钥,要在设置里把 SSH 客户端切成 OpenSSH,否则会报 host key 相关的认证失败。

不过服务器上操作永远以命令行为主,GUI 只是本机辅助。

小结

回退类操作的选择可以浓缩成一句话:工作区改错了用 checkout --,暂存区放错了用 reset HEAD,提交错了但没推用 reset,推出去的用 revert。把这句话记住,再配合”动手前先 git status 和 git log 看一眼”,Git 的事故率会下降一大截。