Git diff 双点与三点:比较分支现状,还是查看分叉后的修改

10-01 4阅读

主分支新加的文件,为什么出现在删除列表里

审核功能分支时,主分支可能也在继续前进。如果直接比较两个分支的当前内容,主分支新加而功能分支尚未拥有的文件,就会表现为删除。这不一定意味着功能开发者执行过删除操作,而是比较起点和终点的文件集合不同。要解释差异,先说明自己想回答的是哪一个问题。

双点形式比较左右两个提交的最终内容,与把两个提交分开写的形式等价。三点形式则先寻找两边的共同祖先,再从那里比较到右侧提交。前者适合检查两份现状相差什么,后者常用于查看某一侧分叉之后引入的内容变化,但仍然需要看清左右顺序。

用三个提交建立可手算的分叉

下面保存为 Python 文件运行,需要安装 Git;本文使用 Python 三点十二与 Git 二点五十二验证。程序只在自动清理的临时目录中创建仓库,配置也仅写入这个仓库。共同起点有一个文件,随后主分支新增 main.txt,功能分支从起点出发新增 topic.txt。

示例通过 Python 调用 Git 并检查每次退出状态,让三个比较结果可以直接断言。它不会访问远程仓库,也不修改现有项目。读者应先运行整段实验确认结果,再把其中的比较命令用于真实分支,并核对引用是否指向自己想查看的提交。

Git diff 双点与三点:比较分支现状,还是查看分叉后的修改

AI概念配图,非真实界面

from pathlib import Path
import os
import subprocess
import tempfile

with tempfile.TemporaryDirectory(prefix='diff-dots-demo-') as directory:
    root = Path(directory)
    env = os.environ.copy()
    for key in list(env):
        if key.startswith('GIT_'):
            del env[key]
    env['GIT_CONFIG_GLOBAL'] = os.devnull
    env['GIT_CONFIG_NOSYSTEM'] = '1'
    env['GIT_TERMINAL_PROMPT'] = '0'

    def git(*args):
        return subprocess.run(['git', *args], cwd=root, env=env,
                              text=True, capture_output=True, check=True).stdout.strip()

    git('init', '-q', '--initial-branch=main')
    git('config', 'user.name', 'Local Demo')
    git('config', 'user.email', 'demo@example.invalid')
    (root / 'base.txt').write_text('shared base\n', encoding='utf-8')
    git('add', 'base.txt')
    git('commit', '-qm', 'base')
    base = git('rev-parse', 'HEAD')
    git('branch', 'topic')
    (root / 'main.txt').write_text('main work\n', encoding='utf-8')
    git('add', 'main.txt')
    git('commit', '-qm', 'main-change')
    git('checkout', '-q', 'topic')
    (root / 'topic.txt').write_text('topic work\n', encoding='utf-8')
    git('add', 'topic.txt')
    git('commit', '-qm', 'topic-change')

    endpoints = git('diff', '--name-status', 'main..topic')
    topic_delta = git('diff', '--name-status', 'main...topic')
    main_delta = git('diff', '--name-status', 'topic...main')
    assert endpoints.splitlines() == ['D\tmain.txt', 'A\ttopic.txt']
    assert topic_delta == 'A\ttopic.txt'
    assert main_delta == 'A\tmain.txt'
    assert endpoints == git('diff', '--name-status', 'main', 'topic')
    assert git('merge-base', 'main', 'topic') == base
    assert git('diff', 'main...topic') == git('diff', base, 'topic')
    (root / 'base.txt').write_text('uncommitted edit\n', encoding='utf-8')
    assert git('diff', '--name-only') == 'base.txt'
    assert git('diff', '--name-status', 'main...topic') == topic_delta
    print('two dots:', endpoints.splitlines())
    print('three dots:', topic_delta)
    print('reversed:', main_delta)
    print('ancestor and worktree checks passed')

把输出还原成明确的起点和终点

双点输出包含删除主分支文件、添加功能分支文件,表达的是把主分支当前快照变成功能分支当前快照所需的内容差异。三点输出只包含添加功能分支文件,因为比较的起点变成了共同提交,那个起点还没有主分支后来新增的文件。

交换三点两侧后,输出变成只添加主分支文件。共同祖先没有变化,终点却换了,所以三点比较不是对称操作。若审核说明写着“本次功能改动”,命令右侧通常应是要审核的功能提交;若想研究另一侧的发展,就应明确交换终点,而不是期待同一结果。

它比较内容,不会列出一段提交历史

示例把三点结果与“显式找到共同祖先,再比较到功能分支”的补丁做相等检查,帮助读者确认简写究竟展开成什么。不要把这里的点号直接套用到 git log 的范围含义:查看提交集合与比较两个快照是两种操作,即使命令长得相似,问题也不同。

一次内容差异无法告诉你每一行由哪次提交引入,也无法证明功能已经正确合并。如果某个文件在中间新增又删除,最终快照可能完全看不出来。需要调查过程时,应再查看对应历史和补丁;需要判断合并结果时,应在适当环境实际合并并测试。

分支引用与本地修改要分别核对

代码最后故意修改工作区中的公共文件,再次比较两个明确提交,结果保持不变。这说明该形式只使用提交内容,不会把尚未提交的修改混进去。若你想检查当前工作区,则应选择相应的比较形式,不能只看分支差异就认定所有本地修改都已审核。

真实仓库里的分支名只是引用。远程跟踪分支若很久没更新,计算出来的共同祖先与差异也可能基于旧信息。运行前应核对引用对应的提交;需要更新远程信息时,再根据项目流程获取。命令执行成功本身不能证明本地引用已经反映服务器最新状态。

复杂交叉合并可能存在多个最佳共同祖先,无共同历史时则无法得到普通意义上的共同起点。本例特意只有一个清楚的祖先,不能推成所有历史形状都一样。遇到异常或不符合预期的结果,应先检查祖先关系,再解释补丁,避免仅凭增删文件列表判断是谁做错了操作。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。