feat(backend): answer per-branch drift from one worker read [IFC-3154]

Merged
OG
1mo ago
+1943-129

Why

The drift query (InfrahubRepositoryBranchDrift) was published in #10530 but answered every branch with UNAVAILABLE / NOT_IMPLEMENTED. A user could not see which branches of a repository are behind, rewritten or missing on the remote.

Goal: answer the drift column for every branch of a repository from a single worker request, read from the answering worker's existing local copy, and keep the branch rows when no worker answers in time.

Non-goals:

  • The Branches card that shows this column belongs to IFC-3104. The hand-off note for it (T084) is a follow-up to this PR.
  • Keeping the rows when the worker answers with an error other than a timeout. See "Accepted gap" below.
  • No graph writes and no schema migration.

Part of the IFC-3101 commit-visibility feature: PR 10 in tasks.md, tasks T076 to T083 and T085.

Jira: IFC-3154

What changed

Behavioral changes

  • InfrahubRepositoryBranchDrift returns, for each branch it lists, the remote head and the condition: IN_SYNC, BEHIND, REWRITTEN, ORPHANED, NO_REMOTE, REF_MISSING or NOT_TRACKED. The answers for all branches come from one git.branch_heads.get message, whatever the number of branches.
  • When no worker answers in time, every branch the graph resolved is still listed with its branch_name, git_ref and tracked_commit, remote_head is null, and the payload carries unavailable { reason: TIMEOUT }. The query raises no error.
  • A query that selects only Infrahub-side columns (branch_name, git_ref, tracked_commit) sends no worker request. The worker is called only when fetched_at or unavailable is selected, or remote_head or condition under edges.node.
  • The RepositoryBranchDrift.remote_head description now reads "Null when there is no remote counterpart or when condition is UNAVAILABLE". A row keeps its remote head under NOT_TRACKED and ORPHANED, so a consumer should decide drift from condition, not by comparing remote_head with tracked_commit.
  • The drift part of the existing changelog fragment +ifc-3101-repository-commits-api.added.md now describes this behaviour. The drift query is unreleased, so it gets no second fragment.

Implementation notes

  • The branch-heads read in git/state/log_reader.py reuses the commit log's availability and warm-up path. It resolves only the refs the request names, each once, by full ref name: refs/remotes/origin/<ref>, then, for a read-only repository only, refs/tags/<ref> and a commit hash. It never lists every remote branch or tag, and never accepts a bare name.
  • A remote ref or tag that names no commit the clone holds is read as absent, and the remote case is logged with the repository name. One damaged ref therefore cannot fail the read for every other branch.
  • New modules: the message message_bus/messages/git_branch_heads_get.py, the handler message_bus/operations/git/branch_heads.py, and the wire conversion git/state/branch_heads_wire.py.
  • git/base.py:get_branches_from_remote() is not used and not changed. It removes every origin/ in a branch name, not only the prefix. Repository sync uses it, so fixing it is a separate change.

What stayed the same

  • The commit-log query, the repository lock, sync and import are unchanged.
  • The GraphQL schema change is limited to one field description.

How to review

  1. backend/infrahub/git/state/log_reader.py: the branch-heads read and the per-ref lookup.
  2. backend/infrahub/graphql/queries/repository_git_state.py: the drift resolver, its field gating and the timeout handling.
  3. The message, handler and wire module, which mirror the commit-log ones.
  4. Tests, then the spec updates under dev/specs/ifc-3101-repo-commit-visibility/, including the implementation report opsmill-implement-report.md.

Accepted gap (raised by several reviews, including cubic): only a worker timeout keeps the drift rows; any other worker error fails the query. Accepted for now: keeping the rows on other errors needs a new RepositoryGitUnavailableReason, which changes the GraphQL schema. FR-022 asks for the rows to render whenever drift is unavailable.

Deferred, recorded in the implementation report: a git_ref of HEAD reads as having no remote in drift, while the commit log resolves origin/HEAD.

How to test

INFRAHUB_USE_TEST_CONTAINERS=true uv run pytest \
  backend/tests/component/message_bus/operations/git/test_branch_heads.py \
  backend/tests/component/graphql/queries/test_repository_git_state.py -v -n 0
uv run pytest backend/tests/unit/git/state backend/tests/unit/message_bus -v -n 0

Local runs on 2026-10-07:

  • test_repository_git_state.py: 40 passed
  • test_branch_heads.py and test_commit_log.py: 40 passed
  • the git-state unit tests: 48 passed
  • uv run invoke backend.test-unit: 3275 passed

/pre-ci passed, except betterer ci. It failed only because the frontend/packages/schema-visualizer submodule is not initialized in the local worktree, and the two files it reported are not in this diff. The frontend unit tests were not run locally; this PR changes only one generated description comment in the frontend.

Impact & rollout

  • Backward compatibility: additive. The drift query keeps its arguments and fields.
  • Performance: one worker request per drift query. The worker looks up each distinct ref once.
  • Config/env changes: none.

Checklist

  • Tests added/updated
  • Changelog entry added (the existing ifc-3101 fragment was updated)
  • External docs updated: not applicable, the generated message-bus reference was regenerated
  • Internal .md docs updated (spec set and implementation report)
  • I have reviewed AI generated content

Assisted-by: speckit-opsmill-implement unversioned
Assisted-by: speckit-implement unversioned
Assisted-by: speckit-review-run unversioned
Assisted-by: reviewing-local-changes 0.1.0
Assisted-by: pre-ci unversioned
Assisted-by: opsmill-dev-pr 0.2.0