详解 Git 的三种 Merge Request 形式
2026/3/9大约 5 分钟
详解 Git 的三种 Merge Request 形式
在现代软件工程实践中,代码审查与合并是保障代码质量与维持项目可维护性的核心环节。GitHub、GitLab 等主流代码托管平台通常提供三种截然不同的合并策略:Create a merge commit、Squash and merge 以及 Rebase and merge。
这三种策略并非简单的功能选项,它们深刻影响着项目的 Git 历史形态、版本回溯的难度以及团队协作的心智模型。选择合适的合并策略,是构建清晰、可追溯且易于维护的代码仓库的关键。
Git Merge 策略模拟器
<template>
<div class="git-merge-simulator">
<div class="header">
<h3>Git Merge 策略模拟器</h3>
<p>点击下方按钮,观察不同合并策略对 Git 历史的影响。</p>
</div>
<div class="controls">
<button class="btn reset" @click="reset">Reset</button>
<button class="btn merge" :class="{ active: mode === 'merge' }" @click="setMode('merge')">Merge Commit</button>
<button class="btn squash" :class="{ active: mode === 'squash' }" @click="setMode('squash')">Squash &
Merge</button>
<button class="btn rebase" :class="{ active: mode === 'rebase' }" @click="setMode('rebase')">Rebase &
Merge</button>
</div>
<div class="visualization">
<svg width="100%" height="300" viewBox="0 0 600 300" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#ccc" />
</marker>
</defs>
<!-- Lines -->
<g v-for="link in links" :key="link.id">
<line :x1="link.source.x" :y1="link.source.y" :x2="link.target.x" :y2="link.target.y" stroke="#ccc"
stroke-width="2" marker-end="url(#arrowhead)" />
</g>
<!-- Commits -->
<g v-for="commit in commits" :key="commit.id" class="commit-node"
:class="{ 'commit-faded': commit.faded, 'commit-new': commit.isNew }">
<circle :cx="commit.x" :cy="commit.y" r="18" :fill="getCommitColor(commit.branch)" stroke="#333"
stroke-width="2" />
<text :x="commit.x" :y="commit.y + 5" text-anchor="middle" font-size="12" fill="white" font-weight="bold">{{
commit.id }}</text>
<text :x="commit.x" :y="commit.y + 35" text-anchor="middle" font-size="11" fill="#666" class="commit-msg">{{
commit.message }}</text>
</g>
<!-- Branch Labels -->
<g v-for="branch in activeBranches" :key="branch.name">
<rect :x="branch.x - 30" :y="branch.y - 45" width="60" height="24" rx="4"
:fill="getBranchColor(branch.name)" />
<text :x="branch.x" :y="branch.y - 28" text-anchor="middle" font-size="12" fill="white" font-weight="bold">{{
branch.name }}</text>
</g>
</svg>
</div>
<div class="explanation">
<div v-if="mode === 'initial'">
<h4>初始状态</h4>
<p>Feature 分支基于 Main 分支的 C2 节点切出,并包含了两个新的提交 C3 和 C4。</p>
</div>
<div v-else-if="mode === 'merge'">
<h4>Merge (Create a merge commit)</h4>
<p>创建一个新的合并提交 <strong>C5</strong>。它有两个父节点 (C2, C4),保留了分支分叉的历史结构。</p>
<ul>
<li>保留完整历史 ✅</li>
<li>非线性历史 (Diamond Shape) ⚠️</li>
</ul>
</div>
<div v-else-if="mode === 'squash'">
<h4>Squash and merge</h4>
<p>将 Feature 分支的所有变更 (C3 + C4) 压缩成一个新的提交 <strong>S1</strong>,直接接在 Main 分支后面。</p>
<ul>
<li>历史极其简洁 ✅</li>
<li>丢失中间过程信息 ⚠️</li>
</ul>
</div>
<div v-else-if="mode === 'rebase'">
<h4>Rebase and merge</h4>
<p>将 Feature 分支的提交“重放”在 Main 分支之后,生成新的提交 <strong>C3', C4'</strong>。</p>
<ul>
<li>线性历史,且保留每个提交细节 ✅</li>
<li>提交 Hash 发生改变 ⚠️</li>
</ul>
</div>
</div>
</div>
</template>
<script>
// 基础坐标
const Y_MAIN = 200;
const Y_FEATURE = 100;
const X_START = 50;
const X_STEP = 100;
// 初始提交
const baseCommits = [
{ id: 'C1', message: 'init', branch: 'main', x: X_START, y: Y_MAIN, parents: [] },
{ id: 'C2', message: 'base', branch: 'main', x: X_START + X_STEP, y: Y_MAIN, parents: ['C1'] },
{ id: 'C3', message: 'feat 1', branch: 'feature', x: X_START + X_STEP * 1.5, y: Y_FEATURE, parents: ['C2'] },
{ id: 'C4', message: 'feat 2', branch: 'feature', x: X_START + X_STEP * 2.5, y: Y_FEATURE, parents: ['C3'] },
];
export default {
name: 'GitMergeSimulator',
data() {
return {
mode: 'initial',
commits: JSON.parse(JSON.stringify(baseCommits))
};
},
computed: {
// 连线计算
links() {
const lines = [];
this.commits.forEach(c => {
c.parents.forEach(pId => {
// 查找父节点,优先找非 faded 的,或者同分支的
const parent = this.commits.find(x => x.id === pId);
if (parent) {
lines.push({ id: `${pId}-${c.id}`, source: parent, target: c });
}
});
});
return lines;
},
// 分支标签位置计算
activeBranches() {
const result = [];
// Main Branch Head
const mainCommits = this.commits.filter(c => (c.branch === 'main' || c.branch === 'squash' || c.branch === 'rebase') && !c.faded);
if (mainCommits.length > 0) {
const head = mainCommits[mainCommits.length - 1];
result.push({ name: 'main', x: head.x, y: head.y });
}
// Feature Branch Head
// 只有在 initial 和 merge 模式下,feature 分支才是“活跃”且可见的重点
// 在 squash 和 rebase 模式下,feature 分支通常被视为“已合并并可删除”,或者是旧历史
if (this.mode === 'initial' || this.mode === 'merge') {
const featureCommits = this.commits.filter(c => c.branch === 'feature' && !c.faded);
if (featureCommits.length > 0) {
const head = featureCommits[featureCommits.length - 1];
result.push({ name: 'feature', x: head.x, y: head.y });
}
}
return result;
}
},
methods: {
getCommitColor(branch) {
switch (branch) {
case 'main': return '#3eaf7c';
case 'feature': return '#4abf8a'; // Lighter green
case 'squash': return '#a371f7'; // Purple
case 'rebase': return '#3eaf7c'; // Same as main, as they are rebased onto it
default: return '#999';
}
},
getBranchColor(name) {
return name === 'main' ? '#3eaf7c' : '#4abf8a';
},
reset() {
this.mode = 'initial';
this.commits = JSON.parse(JSON.stringify(baseCommits));
},
setMode(newMode) {
if (this.mode === newMode) return;
this.reset();
this.mode = newMode;
if (newMode === 'merge') {
// Merge: Add C5 connecting C2 and C4
const c5 = {
id: 'C5',
message: 'merge',
branch: 'main',
x: X_START + X_STEP * 3.5,
y: Y_MAIN,
parents: ['C2', 'C4'],
isNew: true
};
this.commits.push(c5);
} else if (newMode === 'squash') {
// Squash: Add S1 after C2. Fade out C3, C4
// 为了视觉清晰,我们将 C3, C4 变淡
this.commits.forEach(c => {
if (c.branch === 'feature') c.faded = true;
});
const s1 = {
id: 'S1',
message: 'squashed',
branch: 'squash',
x: X_START + X_STEP * 2,
y: Y_MAIN,
parents: ['C2'],
isNew: true
};
this.commits.push(s1);
} else if (newMode === 'rebase') {
// Rebase: Add C3', C4' after C2. Fade out original C3, C4
this.commits.forEach(c => {
if (c.branch === 'feature') c.faded = true;
});
const c3Prime = {
id: "C3'",
message: 'feat 1',
branch: 'rebase',
x: X_START + X_STEP * 2,
y: Y_MAIN,
parents: ['C2'],
isNew: true
};
const c4Prime = {
id: "C4'",
message: 'feat 2',
branch: 'rebase',
x: X_START + X_STEP * 3,
y: Y_MAIN,
parents: ["C3'"],
isNew: true
};
this.commits.push(c3Prime, c4Prime);
}
}
}
};
</script>
<style scoped>
.git-merge-simulator {
padding: 20px;
border: 1px solid #eaecef;
border-radius: 8px;
margin: 20px 0;
background: #f9f9f9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
.header {
margin-bottom: 20px;
text-align: center;
}
.header h3 {
margin: 0 0 10px 0;
color: #2c3e50;
}
.header p {
margin: 0;
color: #666;
font-size: 14px;
}
.controls {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.btn {
padding: 8px 16px;
border: 1px solid #ccc;
border-radius: 20px;
cursor: pointer;
background: white;
transition: all 0.2s ease;
font-weight: 500;
font-size: 14px;
color: #555;
}
.btn:hover {
background: #f0f0f0;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn.active {
background: #3eaf7c;
color: white;
border-color: #3eaf7c;
box-shadow: 0 2px 8px rgba(62, 175, 124, 0.3);
}
.btn.reset {
color: #d73a49;
border-color: #d73a49;
}
.btn.reset:hover {
background: #d73a49;
color: white;
}
.visualization {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin-bottom: 20px;
overflow: hidden;
}
.explanation {
background: #fff;
border-left: 4px solid #3eaf7c;
padding: 15px 20px;
border-radius: 0 4px 4px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.explanation h4 {
margin: 0 0 10px 0;
color: #2c3e50;
}
.explanation p {
margin: 0 0 10px 0;
line-height: 1.6;
color: #444;
}
.explanation ul {
margin: 0;
padding-left: 20px;
color: #555;
}
.explanation li {
margin-bottom: 4px;
}
/* Animations and States */
.commit-node circle {
transition: all 0.5s ease;
}
.commit-node text {
transition: opacity 0.5s ease;
}
.commit-faded circle {
fill: #e0e0e0;
stroke: #ccc;
}
.commit-faded text {
fill: #ccc;
}
.commit-faded .commit-msg {
opacity: 0;
}
.commit-new circle {
animation: popIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes popIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
</style>Create a merge commit (普通合并)
这是 Git 最基础且最传统的合并方式。其核心机制是创建一个全新的 合并提交,将源分支的所有变更作为一个整体合入目标分支。
技术特征
- 拓扑结构保留:该操作会在 Git 有向无环图中保留源分支的完整历史。合并后,历史记录呈现出非线性的分叉与汇合结构,通常被称为 钻石 形状。
- 显式合并节点:目标分支上会生成一个新的 Commit,该 Commit 拥有两个父节点:一个是目标分支的最新提交,另一个是源分支的最新提交。这一节点明确标识了合并发生的时间点与上下文。
- 历史完整性:源分支上的每一次细微提交都会被永久保留在主干历史中。
适用场景
- 大型特性开发:当一个功能分支涉及大量复杂的逻辑变更,且开发周期较长时,保留其完整的提交历史有助于后续的代码审计与问题追踪。
- 需要保留上下文:如果团队希望在 Git Graph 中清晰地看到某个特性的生命周期,此策略是最佳选择。
- 对应 Git 命令:
git merge --no-ff。
Squash and merge (压缩合并)
压缩合并 是一种 破坏性 但极具整洁性的合并策略。它将源分支上的所有提交 压缩 为一个全新的 原子提交,然后将其追加到目标分支的末尾。
技术特征
- 历史线性化:源分支上的中间提交会被彻底丢弃,只保留最终的一个合并结果。目标分支的历史将保持严格的线性结构,没有任何分叉。
- 无 Merge Commit:虽然名为 合并,但实际上并不会生成拥有两个父节点的 Merge Commit。新生成的提交只有一个父节点,即目标分支在合并前的最新提交。
- 原子性增强:主干上的每一个提交都代表了一个完整的、通过测试的功能单元,而非开发过程中的碎片。
适用场景
- 日常功能开发与 Bug 修复:对于大多数短期存在的特性分支,中间的提交记录往往充满了试验性代码和琐碎的修正,这些噪音对主干历史并无价值。使用 Squash 可以极大地净化主干历史。
- 代码回滚:由于一个特性对应一个提交,当需要回滚某个功能时,只需 revert 一个 commit 即可,操作风险极低。
- 不关注过程:当团队只关注 代码变成了什么样,而不关注 代码是怎么变成这样的 时。
Rebase and merge (变基合并)
变基合并 是一种追求极致线性历史的高级策略。它将源分支上的提交逐个 重放 到目标分支的最新提交之后。
技术特征
- 提交哈希重写:由于提交的父节点发生了变化,源分支上所有提交的 SHA-1 哈希值都会被重新计算。这意味着虽然内容相同,但在 Git 看来它们是全新的提交。
- 无 Merge Commit:与 Squash 类似,不会生成 Merge Commit。
- 细节保留与线性并存:它结合了前两者的部分特点——既像 Squash 一样保持了线性历史,又像 Merge Commit 一样保留了源分支的每一个提交细节。
适用场景
- 严格的线性历史要求:某些开源项目或团队规范要求 Git 历史必须是线性的,以便于使用
git bisect等工具进行二分查找。 - 提交质量极高:如果开发者的每一个提交都经过了精心设计(原子性、自包含、描述清晰),那么 Rebase 是展示这些高质量提交的最佳方式。
- 对应 Git 命令:类似于手动执行
git rebase main后再执行git merge --ff-only。
总结与决策建议
| 策略 | Git 拓扑结构 | 提交数量 | Merge Commit | 核心价值 |
|---|---|---|---|---|
| Merge | 网状/分叉 | 有 | 完整性:忠实记录真实开发历程 | |
| Squash | 线性 | 无 | 整洁性:关注最终产出,屏蔽过程噪音 | |
| Rebase | 线性 | 无 | 线性细节:既要线性历史,又要过程细节 |
最佳实践建议
- 默认推荐 Squash:对于绝大多数敏捷开发团队,Squash and merge 是最佳的默认选择。它能确保主干分支的每一行记录都是有意义的里程碑,极大地降低了代码审查和版本回溯的认知负担。
- 长期分支用 Merge:对于长期存在的
develop分支合入main,或者大型 Epic 分支的合并,使用 Merge Commit 以保留分支间的拓扑关系。 - 慎用 Rebase:除非团队全员对 Git 原理有深刻理解,并且严格遵守 不重写已推送历史 的原则,否则 Rebase 可能会导致混乱。