diff --git a/diffgraph/git_snapshot.py b/diffgraph/git_snapshot.py index 745b6ee..fc21d5a 100644 --- a/diffgraph/git_snapshot.py +++ b/diffgraph/git_snapshot.py @@ -150,7 +150,7 @@ def resolve_commit_range( comparison_base_oid = base_oid if three_dot: output = _run( - ["git", "merge-base", base_oid, head_oid], root, warnings, + ["git", "merge-base", "--all", base_oid, head_oid], root, warnings, "merge_base_failed", ) if output is None: @@ -158,8 +158,8 @@ def resolve_commit_range( base_ref, head_ref, three_dot, warnings=warnings, base_oid=base_oid, head_oid=head_oid, ) - comparison_base_oid = os.fsdecode(output).strip() - if not _is_hex_oid(comparison_base_oid): + merge_bases = sorted(set(os.fsdecode(output).splitlines())) + if not merge_bases or any(not _is_hex_oid(oid) for oid in merge_bases): warnings.append(ResolutionWarning( "malformed_merge_base", "Git returned an invalid merge-base object ID", @@ -168,6 +168,18 @@ def resolve_commit_range( base_ref, head_ref, three_dot, warnings=warnings, base_oid=base_oid, head_oid=head_oid, ) + if len(merge_bases) != 1: + warnings.append(ResolutionWarning( + "ambiguous_merge_base", + "Git returned {} merge bases; a three-dot comparison requires one exact base".format( + len(merge_bases) + ), + )) + return _commit_range_result( + base_ref, head_ref, three_dot, warnings=warnings, + base_oid=base_oid, head_oid=head_oid, + ) + comparison_base_oid = merge_bases[0] command = [ "git", "diff", "--raw", "-z", "--no-abbrev", "--no-ext-diff", diff --git a/tests/test_git_snapshot.py b/tests/test_git_snapshot.py index 5fa365f..ea8b87f 100644 --- a/tests/test_git_snapshot.py +++ b/tests/test_git_snapshot.py @@ -652,6 +652,44 @@ def test_three_dot_without_merge_base_is_a_warning_not_a_change(tmp_path): assert [warning.code for warning in result.warnings] == ["merge_base_failed"] +def test_three_dot_with_multiple_merge_bases_is_a_warning_not_a_change(tmp_path): + """A criss-cross history must not silently select one merge base.""" + repo = make_repo(tmp_path) + write(repo, "shared.txt", b"common\n") + commit_all(repo, "common") + git(repo, "branch", "left-start") + + write(repo, "left.txt", b"left\n") + commit_all(repo, "left change") + left_change = oid(repo, "HEAD") + git(repo, "branch", "left-change") + + git(repo, "switch", "left-start") + write(repo, "right.txt", b"right\n") + commit_all(repo, "right change") + right_change = oid(repo, "HEAD") + git(repo, "branch", "right-change") + + git(repo, "switch", "left-change") + git(repo, "merge", "--no-ff", "right-change", "-m", "left merge") + git(repo, "branch", "left") + + git(repo, "switch", "right-change") + git(repo, "merge", "--no-ff", left_change, "-m", "right merge") + git(repo, "branch", "right") + + assert set(git(repo, "merge-base", "--all", "left", "right").splitlines()) == { + left_change.encode(), right_change.encode(), + } + result = resolve_commit_range(str(repo), "left", "right", three_dot=True) + + assert result.entries == () + assert result.comparison_base_oid is None + assert [(warning.code, warning.path) for warning in result.warnings] == [ + ("ambiguous_merge_base", None), + ] + + def test_incomplete_raw_object_ids_are_not_reported_as_exact_snapshots(): raw = git_snapshot._RawEntry( status="M",