两个父提交共同连接到经过审阅的合并结果。

git merge 用一个新的提交把分叉的两条线接起来

合并连接两段历史,并记录项目的结果快照。普通的双分支分叉合并,会创建同时把两个原末端作为父级的新提交。这些父级关系让更早的提交仍然可达,却不能证明最终文件保留了每一项预期行为。合并既是图上的连接,也是内容上的决定;只检查其中一面,会漏掉另一面真实存在的错误,不能把“两个父提交都还在”当作功能没有丢失的证据。 父级保留历史,不把它改写成单线 如果当前末端是传入末端的祖先,快进可以直接移动当前分支引用,不需要额外合并提交。当两侧各自拥有新提交后,普通合并才产生双父级结果:第一个父级是合并前的当前末端,第二个是传入末端。图中依然包含两条历史线,并没有把它们重新写成一串线性提交。阅读历史时,应区分分支名字的变化与提交图本身的形状。 Git 通常借助合并基点协调两侧末端;更复杂的历史可能涉及多个基点。相对共同祖先比较,可以区分某一侧确实修改的内容和保持不变的上下文。但文本结果干净,只表示选定合并机制找到了一种组合文件的方法,不代表 Git 理解了应用接口、业务规则或发布依赖。这些含义仍需通过源码关系、约定和测试来判断。 记录连接之前先审阅结果树 开始前应明确并保留当前工作状态。未提交修改可能阻止合并,也可能让失败后重建原状态变得复杂。不能假定中止操作能够恢复任意在合并前或合并期间发生的编辑。还应记录真正被连接的分支末端对象,让审阅和后续检查针对稳定对象,而不是继续变化的名称;否则两次观察可能根本不是同一组输入。 如果流程要求显式审阅合并提交,可以让 Git 在记录提交之前暂停。但不提交选项无法阻止快进,所以即使线性情况也要求生成合并提交时,需要同时关闭快进。暂停后检查暂存变化,把候选结果与两个父级分别比较,并运行任意一侧所影响的测试。下面的实验正是在这个暂停点发现行为不兼容,先修复再记录合并,而不是把有问题的组合直接当成成功。 坏掉的样子 文本冲突不限于两边改动同一行。删除、重命名、二进制变化及其他不兼容操作,都可能需要人工决定。应使用工作区状态及索引中的未合并条目识别未解决路径。普通文本冲突中的标记方便查看双方内容,但没有发现这些字符串,不能证明所有冲突已经解决,更不能代替对二进制文件或路径结构的检查。 解决应遵循组合后程序的真实契约,再暂存具体路径并继续或提交。删除标记、执行暂存,只是在告诉 Git 已经提供了解法,不是在证明解法正确。生成文件应先协调权威输入,再通过生成器重建;不能把两个生成结果拼成看似合理的输出,否则下次构建还会覆盖人工拼接,甚至与输入表达的规则不一致。 更隐蔽的是文本干净却行为损坏。一条分支可能收紧函数参数形式,另一条同时增加仍按旧形式调用的代码。它们修改不同文件,各自测试都可以成功,组合后却失败。因此,集成测试必须覆盖这次组合新形成的调用关系,不能只是把两份分支测试摘要并排放在一起,就认为结果也已经通过同样验证。 策略选择还可能主动丢弃内容。偏向传入方的冲突选项,会优先采用传入方的冲突片段,同时保留其他非冲突变化;它不是自动保留双方意图的通用规则。另一种名为 ours 的策略则保留当前目录树,却仍把另一侧历史记录为已合并。因此,双父级图不能证明传入实现真的被采用。类似选择必须有明确依据并审查内容,不能只为让冲突提示消失而使用。 快速验证 使用 Python 3 和 Git 2.28 或更新版本运行下面实验。全部工作都在临时仓库,使用示例身份,没有远端,关闭钩子与签名,并排除继承的 Git 路由和个人配置。一条分支要求 Python 接口使用关键字参数,另一条分支新增位置参数调用。关闭字节码写入,是为了避免测试产生的缓存混入工作树状态,便于准确检查最终仓库是否干净。 import os import subprocess import sys 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-demo-") as directory: root = Path(directory) def git(*args): return subprocess.run( ["git", "-c", "user.name=Merge Example", "-c", "user.email=merge@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=root, env=env, text=True, capture_output=True, check=True, ) def python(*args): return subprocess.run([sys.executable, "-B", *args], cwd=root, text=True, capture_output=True) git("init", "-q", "-b", "main") (root / "api.py").write_text('def render(name):\n return "Hi " + name\n') git("add", "api.py") git("commit", "-qm", "base API") git("switch", "-qc", "topic") (root / "api.py").write_text('def render(*, name):\n return "Hi " + name\n') git("add", "api.py") git("commit", "-qm", "require a named argument") topic = git("rev-parse", "HEAD").stdout.strip() topic_test = python("-c", 'from api import render; print(render(name="Ada"))') print("topic-test-passed:", topic_test.returncode == 0 and topic_test.stdout == "Hi Ada\n") git("switch", "-q", "main") (root / "client.py").write_text('from api import render\nprint(render("Ada"))\n') git("add", "client.py") git("commit", "-qm", "add a caller") main = git("rev-parse", "HEAD").stdout.strip() main_test = python("client.py") print("main-test-passed:", main_test.returncode == 0 and main_test.stdout == "Hi Ada\n") git("merge", "--no-ff", "--no-commit", "topic") print("text-merge-clean:", not git("ls-files", "-u").stdout and git("rev-parse", "MERGE_HEAD").stdout.strip() == topic) broken = python("client.py") print("combined-behavior-failed:", broken.returncode != 0 and "TypeError" in broken.stderr) (root / "client.py").write_text('from api import render\nprint(render(name="Ada"))\n') fixed = python("client.py") assert fixed.returncode == 0 and fixed.stdout == "Hi Ada\n", fixed git("add", "client.py") git("commit", "-qm", "merge topic and adapt the caller") print("reviewed-merge-parents:", git("rev-parse", "HEAD^1").stdout.strip() == main and git("rev-parse", "HEAD^2").stdout.strip() == topic) print("fixed-and-clean:", not git("status", "--porcelain").stdout) 六个观察都应为真。两条分支各自检查成功,Git 也没有报告未合并路径,但组合后的调用抛出类型错误。把调用方适配为关键字参数后,恢复预期输出,再记录合并。最终提交拥有两个准确的父级,工作树干净。这验证了一次真实的干净合并故障及其修复,也说明历史仍然可达,与最终行为正确,是必须分别验收的两件事。 ...

十二月 29, 2027 · 2 分钟 · XB Field Notes
一组很轻的指针,在图里的某个提交上贴一个名字。

git 分支是一个会移动的标签

Git 分支为一条历史线的末端提供名字。新建分支通常只是增加一个指向既有提交的引用,不会再复制一份全部受跟踪文件。两个名字可以先指向同一提交,再分别前进。这让分支创建成本很低,但不意味着名字内部装着历史,更不意味着删除指向某个提交的全部引用后,内容仍会永远保留。理解分支时,需要把名称、对象和保留条件分开。 它是引用,不是固定的存储文件 本地分支属于 refs/heads/ 引用命名空间,具体表示方式是实现细节:引用可能独立存储、被打包,或使用其他受支持的后端。不要假定一定存在某个松散文件,就直接编辑 Git 内部目录来移动分支。应使用 Git 的引用命令,让它遵守仓库实际的存储结构和更新规则,而不是把一种磁盘布局当作永久接口。 提交记录目录树和父提交,沿着父级关系向前追溯,就得到分支末端可达的历史。因此,两条分支可以共享大部分祖先,却拥有不同的末端。分支名称不是提交身份的一部分,新增或重命名分支也不会改写它指向的提交。图中的对象关系解释了共享历史,名称只是人们访问这些对象的入口。 创建分支与切换分支也不同。git branch topic 在当前提交创建名称,但当前分支不会改变;git switch topic 才选中它,并按需要更新工作树。git switch -c topic 把创建与切换合在一起。记录新工作之前,应检查真正活动的分支,不能因为名称已经存在,就误以为接下来的提交一定会记在那条线上。 HEAD 决定哪个名字前进 通常的附着状态下,HEAD 通过符号引用指向当前分支。创建提交时,该分支前进到新对象,其他原本共享旧末端的分支保持不动;新提交的父级关系保留祖先。分支也可能因合并或其他明确更新引用的操作而移动,并不是只有普通提交才能改变它。因此,判断某个名字为什么变化,需要检查实际发生的操作,而不只看提交按钮是否被点击。 直接选中一个提交,可以进入分离状态。之后的新提交会推动 HEAD,但不会推动某条本地分支。提交本身是真实对象,只是切换离开后可能没有持久的分支名称指向它。离开之前可以在目标提交创建分支,或者之后凭已知对象标识重新命名。引用日志有助于恢复,却受到保留时间和垃圾回收影响,不能被当作永久保存承诺。 origin/main 这类远端跟踪引用,是本地对远端状态的观察,通常随获取操作更新;它不是实时连接,也不等同于本地 main。上游配置描述供相关命令使用的分支关系,不会在任意一方提交时自动同步两个末端。排查本地与远端差异时,应明确比较的是哪两个引用,以及远端状态上次何时被获取。 坏掉的样子 未提交改动属于工作树和索引,不是一份已经保存在分支里的快照。切换时,兼容的改动可能被一起带走;如果会覆盖当前修改,Git 也可能拒绝切换。应先检查工作区状态,再有意识地保存未完成工作。仅仅创建一个分支名字,并没有保存那些从未提交的编辑,不能据此清理或丢弃文件。 删除分支是删除引用,不是要求立即擦除它曾经到达的全部对象;其他引用可能继续保留那些提交。但如果最终没有任何保留路径,对象以后仍可能被清理。普通删除命令检查目标是否已经合入配置的上游;没有上游时,则检查是否合入当前 HEAD。这种保护检查的是图上关系,不是合并后程序是否正确,更不是对所有业务改动都已经保留的验收。 合并也不只是多加一个名字。快进会移动已有引用,而分叉历史的普通合并通常创建带结果目录树和多个父级的新提交。父级可达关系保留历史对象,却不保证每项贡献都正确地出现在合并后的文件中。审阅时既要看图,也要看内容,不能把“历史还能找到”解释成“最终行为没有丢失”。 快速验证 使用 Python 3 和 Git 2.28 或更新版本运行下面的实验。它只在私有临时仓库创建空提交,排除个人 Git 配置和继承的 Git 路由变量,关闭钩子及签名,不配置任何远端。删除尝试也只针对这个可丢弃仓库里的演示分支,不会影响当前项目的引用。实验结束后,临时目录管理器会清理整套演示数据。 ...

二月 5, 2027 · 1 分钟 · XB Field Notes