Classify a tracked ref against its remote head [IFC-3280]

Merged
GM
1mo ago
+1471-304

Why

compare_local_remote compares commits for equality, so a fast-forward and a rewritten history
are indistinguishable. This adds the classifier the reconciliation needs. Nothing is wired to it
yet.

First item of IFC-3210.

What changed

A new backend/infrahub/git/divergence/ package: the five-member classification, the detector,
and a gateway that keeps every git type out of the detector.

Three things the code found that the design had wrong:

  • Asking "is this commit here" is harder than it looks. GitPython's Repo.commit resolves lazily
    and hands back an object for a sha that is not there, and the object database reader raises the
    same ValueError for a missing object as for a reader that died. The gateway runs
    git rev-parse --verify --quiet <sha>^{commit} as a plain command instead, and comparing what
    it resolved to against the input rejects an annotated tag, which peeling would otherwise accept.
    Git returns the same status for a commit it holds but cannot read, so the gateway proves the
    object database answers for HEAD before it reports an absence. A pack it cannot read raises
    instead of reading as a rewrite.
  • Resolving a commit object goes through the shared reader, so a dead pipe escaped the gateway as
    BrokenPipeError. merge-base --is-ancestor answers the comparison in one call.
  • The gateway binds to one repository at construction. Taking a repository per call would put a
    git type in the detector's signature.

A graph commit ahead of the remote head means the remote was rewound, because no write site
records a commit the remote never had. That is a rewrite. The worktree resets onto the remote head
all the same: the merge pushes before it records, so the reset discards no commit that exists
nowhere else.

How to test

uv run pytest backend/tests/unit/git/divergence/

52 tests, no database, under two seconds. Reverting the presence check makes
test_a_broken_object_database_is_an_error_not_an_absence pass silently instead of raising, and
reverting the HEAD check makes test_an_unreadable_pack_is_an_error_not_an_absence do the same.

Checklist

  • Tests added
  • Changelog entry: not applicable, nothing user visible ships until the reconciliation lands
  • I have reviewed AI generated content