From 24dd50968c4533758cb0cd00f660d0c86aad9696 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Mon, 24 Aug 2026 09:04:45 +0530 Subject: [PATCH 1/2] fix(git): warn on unmerged snapshot paths --- diffgraph/git_snapshot.py | 23 ++++++++++++++++++++++ tests/test_git_snapshot.py | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/diffgraph/git_snapshot.py b/diffgraph/git_snapshot.py index 631ebe0..b64d21b 100644 --- a/diffgraph/git_snapshot.py +++ b/diffgraph/git_snapshot.py @@ -265,8 +265,31 @@ def _resolve( return SnapshotResolution((), tuple(warnings)) raw_entries = _parse_raw(output, warnings) + # A conflicted index has no single pre/post pair. ``git diff --raw`` can + # emit an unmerged ``U`` record and a second record for the same path; + # hashing that second record against an arbitrary conflict stage would + # fabricate a snapshot. Omit every record touching an unmerged path until + # the user resolves it, while retaining an actionable warning. + unmerged_paths = { + path + for raw in raw_entries + if raw.status == "U" + for path in (raw.old_path, raw.new_path) + if path is not None + } + for path in sorted(unmerged_paths, key=os.fsencode): + warnings.append(ResolutionWarning( + "unmerged_index_entry", + "Cannot resolve an exact pre/post snapshot while the index is unmerged", + path, + )) + entries: List[SnapshotEntry] = [] for raw in raw_entries: + if raw.status == "U" or ( + raw.old_path in unmerged_paths or raw.new_path in unmerged_paths + ): + continue if staged: entry = _exact_staged_entry(raw, warnings) else: diff --git a/tests/test_git_snapshot.py b/tests/test_git_snapshot.py index ea08037..be4769c 100644 --- a/tests/test_git_snapshot.py +++ b/tests/test_git_snapshot.py @@ -49,6 +49,28 @@ def make_repo(tmp_path): return repo +def make_conflicted_repo(tmp_path): + """Create one unresolved merge conflict in an otherwise valid repository.""" + + repo = make_repo(tmp_path) + write(repo, "conflict.py", b"base = True\n") + commit_all(repo, "base") + + git(repo, "switch", "-c", "side") + write(repo, "conflict.py", b"side = True\n") + commit_all(repo, "side") + + git(repo, "switch", "master") + write(repo, "conflict.py", b"main = True\n") + commit_all(repo, "main") + completed = subprocess.run( + ["git", "merge", "side"], cwd=str(repo), stdout=subprocess.PIPE, + stderr=subprocess.PIPE, check=False, + ) + assert completed.returncode != 0 + return repo + + def test_staged_add_modify_delete_and_rename_have_exact_identities(tmp_path): repo = make_repo(tmp_path) write(repo, "modify.txt", b"old modify\n") @@ -216,6 +238,23 @@ def test_git_failures_are_warnings_not_changes(tmp_path, monkeypatch): assert result.warnings[0].code == "not_a_git_repository" +def test_unmerged_index_entry_is_a_warning_not_a_fabricated_snapshot(tmp_path): + repo = make_conflicted_repo(tmp_path) + + staged = resolve_staged(str(repo)) + unstaged = resolve_unstaged(str(repo)) + + assert staged.entries == () + assert unstaged.entries == () + expected_warning = ("unmerged_index_entry", "conflict.py") + assert [(warning.code, warning.path) for warning in staged.warnings] == [ + expected_warning + ] + assert [(warning.code, warning.path) for warning in unstaged.warnings] == [ + expected_warning + ] + + def test_unstaged_intent_to_add_and_rename_have_exact_identities(tmp_path): repo = make_repo(tmp_path) write(repo, "rename-old.py", b"def moved():\n return 1\n" * 20) From f05bf397c006a8689b40464cfcc5ca7b904a2f19 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Mon, 24 Aug 2026 15:02:20 +0530 Subject: [PATCH 2/2] fix(structural): preserve unmerged index warnings --- diffgraph/schema/diffgraph-v2.schema.json | 3 ++- diffgraph/structural.py | 1 + tests/test_structural.py | 20 ++++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/diffgraph/schema/diffgraph-v2.schema.json b/diffgraph/schema/diffgraph-v2.schema.json index 083e57d..d416ef4 100644 --- a/diffgraph/schema/diffgraph-v2.schema.json +++ b/diffgraph/schema/diffgraph-v2.schema.json @@ -400,7 +400,8 @@ "unsupported_worktree_entry", "worktree_read_failed", "hash_object_failed", - "malformed_hash_object_output" + "malformed_hash_object_output", + "unmerged_index_entry" ], "description": "Machine-readable warning code. Consumers can surface these to the user." }, diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 0e768f5..66c3f0b 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -320,6 +320,7 @@ def _resolution_warning(item: ResolutionWarning) -> Dict[str, str]: "worktree_read_failed", "hash_object_failed", "malformed_hash_object_output", + "unmerged_index_entry", } code = item.code if item.code in known_codes else "UNKNOWN" return _warning(code, item.path, "{}: {}".format(item.code, item.message)) diff --git a/tests/test_structural.py b/tests/test_structural.py index 265c1c8..71466bd 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -830,6 +830,26 @@ def test_resolution_warning_preserves_machine_readable_code(monkeypatch, tmp_pat ] +def test_unmerged_index_warning_preserves_machine_readable_code(monkeypatch, tmp_path): + root = repo(tmp_path) + warning = ResolutionWarning("unmerged_index_entry", "conflict remains", "app.py") + monkeypatch.setattr( + "diffgraph.structural.resolve_unstaged", + lambda repository, pathspecs: SnapshotResolution((), (warning,)), + ) + + artifact = analyze_local_diff(str(root)) + + assert_valid(artifact) + assert artifact["metadata"]["warnings"] == [ + { + "code": "unmerged_index_entry", + "file": "app.py", + "detail": "unmerged_index_entry: conflict remains", + } + ] + + def test_cli_structural_json_rejects_non_diff_command(): from click.testing import CliRunner from diffgraph.cli import main