merge 与 rebase 都能整合工作,却对提交身份和祖先关系产生不同影响。合并通常保留既有提交并连接历史,变基则把选定变化移到另一基线。选择不只是图形看起来是否整齐,还影响评审引用、冲突处理、签名,以及已经依赖旧提交的协作者。应依据仓库流程与真实分支关系决定,而不是把“线性一定更好”或“有合并提交就一定安全”当成通用判断。
合并记录什么
两个末端已经分叉时,普通双分支合并创建以这两个末端为父级的新提交,并记录结果目录树;既有提交保持原身份。图保留的是祖先关系,不是所有活动的完整时间排序。提交日期与界面展示顺序也不能替代父级关系,否则可能把并行工作误读成严格前后发生的一串事件,进而错误判断某项修改究竟基于什么版本。
如果当前末端是传入末端的祖先,合并可以直接快进分支,不创建额外合并提交。要求始终创建合并提交,是通过相应选项表达的另一项流程规则。反过来,即便结果保留两个父级,也不能证明双方预期行为都正确保留。仍需审阅内容并运行集成测试,不能用提交拓扑替应用接口、数据规则或发布依赖完成验收。
变基改变什么
普通变基识别选定提交,再把它们的变化重新应用到新基线。重新创建的提交只要父级不同,对象标识就不同,即便补丁看起来相同。原作者信息通常保留,但新对象仍需自己的验证,以及流程要求的签名。指向旧对象的评审链接,也不会自动变成指向新对象的链接;后续说明需要让读者能够找到实际接受审阅的结果。
并不是每次变基都会重写所有提交:分支已经更新时可能无需重放;上游等价变化或应用后的空结果,也会影响哪些提交留下。普通变基从重放序列中省略合并提交,让选定工作趋于线性;明确启用重建合并的模式,则会尝试恢复分支结构。因此,“变基永远得到直线”同样过度概括,具体结果取决于参数和输入历史。
合并协调的是分支末端,变基通常逐个处理提交,所以冲突出现的位置与次数可能不同,同一种取舍还可能需要多次执行。完成后应比较最终目录树及重放变化,并重新运行相关测试。补丁相似,不证明新基线上行为完全相同;依赖、接口和上下文已经改变时,文本能够落地只是审查的开始。
坏掉的样子
重写其他工作已经依赖的提交,会产生协调成本。私人且未发布的分支,通常比共享集成分支更适合变基。明确协商过的共享历史重写并非不可能,但必须识别受影响引用及消费者。偏好线性历史,不等于获得替换别人已发布工作的权限;是否应该执行,需要独立于工具是否支持来判断。
变基本身并不必然要求强制推送。新的远端分支可以普通创建;远端末端仍是本地结果祖先时,也可以正常快进。需要替换已被重写的远端提交,才属于非快进情况。即便使用租约,它也只是根据预期远端值作并发检查,不代表已经批准替换,也不能证明所有协作者都已被考虑。发布前应先确定真实引用关系,而不是机械套用强推命令。
变基冲突中,合并后端所称的本方,是目前已构建的变基历史,包含上游基线;对方则是正在重放的提交。这可能与开发者按原功能分支理解的“我的版本”相反。应检查实际文件版本和目标契约,再决定如何解决;暂存具体文件后继续。跳过会丢弃当前补丁,并不是自动修好这次冲突,不能为了让过程继续就忽略它的业务含义。
决定放弃操作时,中止变基会恢复起初的分支状态。--quit 则只停止管理变基,保留当前 HEAD、索引与工作文件,并不是等价的撤销命令。需要恢复起点时,不能因为两者都会结束流程就随意替换。从干净工作区开始,有助于明确恢复结果。不要把中止当成在过程中保存任意无关编辑的通用备份,也不要用强制清理作为常规冲突处理方式。应先理解当前序列、索引与文件中有哪些有效成果,再选择与操作意图匹配的恢复路径。
快速验证
在 POSIX 系统上使用 Python 3 与 Git 2.28 或更新版本运行下面实验。它在临时仓库中使用示例身份,关闭钩子与签名,没有远端,并排除继承的 Git 路由及个人配置。两个分支从同一个主题提交起步,以不同方式整合一项独立上游变化;第三个分支有意制造新增同名文件的冲突,检查中止恢复,而不碰当前项目的实际历史。
import os
import subprocess
import tempfile
from pathlib import Path
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
with tempfile.TemporaryDirectory(prefix="git-merge-rebase-") as directory:
root = Path(directory)
def git(*args, check=True):
return subprocess.run(
["git", "-c", "user.name=History Example",
"-c", "user.email=history@example.invalid",
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args],
cwd=root, env=env, text=True, capture_output=True, check=check,
)
git("init", "-q", "-b", "main")
git("commit", "--allow-empty", "-qm", "base")
git("switch", "-qc", "topic")
(root / "topic.txt").write_text("feature\n")
git("add", "topic.txt")
git("commit", "-qm", "feature")
original = git("rev-parse", "HEAD").stdout.strip()
git("branch", "merge-case")
git("branch", "rebase-case")
git("switch", "-q", "main")
(root / "main.txt").write_text("upstream\n")
git("add", "main.txt")
git("commit", "-qm", "upstream")
upstream = git("rev-parse", "HEAD").stdout.strip()
git("switch", "-q", "merge-case")
git("merge", "--no-edit", "main")
merged = git("rev-parse", "HEAD").stdout.strip()
print("merge-keeps-two-parents:", git("rev-parse", "HEAD^1").stdout.strip() == original
and git("rev-parse", "HEAD^2").stdout.strip() == upstream)
git("switch", "-q", "rebase-case")
git("rebase", "main")
rebased = git("rev-parse", "HEAD").stdout.strip()
print("rebase-changes-commit:", rebased != original
and git("rev-parse", "HEAD^").stdout.strip() == upstream)
print("same-result-tree:", git("rev-parse", f"{merged}^{{tree}}").stdout
== git("rev-parse", f"{rebased}^{{tree}}").stdout)
print("original-topic-retained:", git("rev-parse", "topic").stdout.strip() == original
and git("merge-base", "--is-ancestor", original, rebased, check=False).returncode == 1)
git("switch", "-qc", "conflict", "topic")
(root / "main.txt").write_text("topic alternative\n")
git("add", "main.txt")
git("commit", "-qm", "alternative upstream file")
before = git("rev-parse", "HEAD").stdout.strip()
stopped = git("rebase", "main", check=False)
print("rebase-conflict-stops:", stopped.returncode != 0 and bool(git("ls-files", "-u").stdout))
git("rebase", "--abort")
print("abort-restores-branch:", git("rev-parse", "HEAD").stdout.strip() == before
and git("symbolic-ref", "--short", "HEAD").stdout.strip() == "conflict"
and (root / "main.txt").read_text() == "topic alternative\n"
and not git("status", "--porcelain").stdout)
六个判断都应为真。合并具有两个准确父级,变基则在上游末端创建新的主题提交。这个故意不冲突的样例中,两者结果目录树相同,原主题提交也仍由独立分支保留。之后新增同名文件的冲突让变基停止,中止恢复该分支准确的末端、文本和干净状态。实验没有执行或暗示任何远端更新,局部成功不能被解释成共享历史替换已经获批。
延伸阅读
来源
- Git rebase 参考,重放选择、重建合并、冲突两侧和中止行为,已核验 2026-10-15。
- Git merge 参考,父级关系、快进及结果目录树,已核验 2026-10-15。