diff --git a/README.md b/README.md index 4112a2c..cb5b2cd 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,10 @@ Canonical output supports local unstaged (`index` → working tree), staged (`HEAD` → index), explicit two-dot (`BASE..HEAD`), and explicit three-dot (`BASE...HEAD`) comparisons. Two-dot resolves both refs to immutable commits; three-dot resolves their merge base and compares it with the immutable head, -matching Git diff semantics. The artifact records those exact comparison OIDs -and every file's pre/post blob identities. Invalid refs and unavailable merge +matching Git diff semantics. For commit ranges, `diff_ref` records both the +user-requested base/head refs (`requested_base_ref`, `requested_head_ref`) and +the resolved immutable comparison endpoints (`base_ref`, `head_ref`), plus +`comparison_mode`; every file records its pre/post blob identities. Invalid refs and unavailable merge bases produce structured warnings instead of false changes. Put pathspecs after `--`. Pathspecs are interpreted relative to the directory diff --git a/diffgraph/schema/diffgraph-v2.schema.json b/diffgraph/schema/diffgraph-v2.schema.json index 1802327..4d89a0d 100644 --- a/diffgraph/schema/diffgraph-v2.schema.json +++ b/diffgraph/schema/diffgraph-v2.schema.json @@ -41,11 +41,24 @@ }, "base_ref": { "type": ["string", "null"], - "description": "Commit SHA or ref for the base side. null for working-tree diffs." + "description": "Resolved comparison-base commit object ID; for three_dot ranges this is the resolved merge base. null for working-tree diffs." }, "head_ref": { "type": ["string", "null"], - "description": "Commit SHA or ref for the head side. null for working-tree diffs." + "description": "Immutable commit object ID for the head side. null for working-tree diffs." + }, + "requested_base_ref": { + "type": ["string", "null"], + "description": "User-supplied base ref before immutable resolution. null for working-tree diffs." + }, + "requested_head_ref": { + "type": ["string", "null"], + "description": "User-supplied head ref before immutable resolution. null for working-tree diffs." + }, + "comparison_mode": { + "type": ["string", "null"], + "enum": ["two_dot", "three_dot", null], + "description": "Commit-range semantics. three_dot uses the resolved merge-base in base_ref. null for working-tree diffs." }, "pathspecs": { "type": "array", diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 2fef866..6b5c32f 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -922,6 +922,11 @@ def analyze_local_diff( resolution.comparison_base_oid if is_commit_range else "HEAD" if staged else None ), "head_ref": resolution.head_oid if is_commit_range else None, + "requested_base_ref": resolution.base_ref if is_commit_range else None, + "requested_head_ref": resolution.head_ref if is_commit_range else None, + "comparison_mode": ( + ("three_dot" if three_dot else "two_dot") if is_commit_range else None + ), "pathspecs": list(pathspecs or []), "repo_root": root, }, "files": files, "symbols": symbols, "relationships": relationships, diff --git a/tests/test_structural.py b/tests/test_structural.py index 676f245..9fcf591 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -123,6 +123,22 @@ def test_unstaged_uses_index_to_worktree_exact_identity_and_is_stable(tmp_path): assert first["symbols"][0]["change_kind"] == "modified" +@pytest.mark.parametrize("staged", [False, True]) +def test_worktree_diffs_do_not_record_commit_range_mode(tmp_path, staged): + root = repo(tmp_path) + write(root, "app.py", "def value():\n return 1\n") + commit(root) + write(root, "app.py", "def value():\n return 2\n") + if staged: + git(root, "add", "app.py") + + artifact = analyze_local_diff(str(root), staged=staged, three_dot=True) + + assert_valid(artifact) + assert artifact["diff_ref"]["kind"] == ("staged" if staged else "unstaged") + assert artifact["diff_ref"]["comparison_mode"] is None + + def test_untracked_python_is_an_exact_added_snapshot(tmp_path): root = repo(tmp_path) write(root, "tracked.txt", "baseline\n") @@ -696,6 +712,9 @@ def test_cli_structural_json_resolves_immutable_two_dot_range(tmp_path, monkeypa assert artifact["diff_ref"]["kind"] == "commit_range" assert artifact["diff_ref"]["base_ref"] == base_oid assert artifact["diff_ref"]["head_ref"] == head_oid + assert artifact["diff_ref"]["requested_base_ref"] == "HEAD~1" + assert artifact["diff_ref"]["requested_head_ref"] == "HEAD" + assert artifact["diff_ref"]["comparison_mode"] == "two_dot" provenance = json.loads(artifact["files"][0]["evidence"][0]["detail"]) assert provenance["old_oid"] == old_blob assert provenance["new_oid"] == new_blob @@ -734,6 +753,9 @@ def test_cli_structural_json_three_dot_uses_merge_base_and_pathspec(tmp_path, mo "kind": "commit_range", "base_ref": merge_base_oid, "head_ref": head_oid, + "requested_base_ref": "left", + "requested_head_ref": "right", + "comparison_mode": "three_dot", "pathspecs": ["included.py"], "repo_root": str(root), }