feat(backend): answer per-branch drift from one worker read [IFC-3154]
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
InfrahubRepositoryBranchDriftreturns, for each branch it lists, the remote head and the condition:IN_SYNC,BEHIND,REWRITTEN,ORPHANED,NO_REMOTE,REF_MISSINGorNOT_TRACKED. The answers for all branches come from onegit.branch_heads.getmessage, whatever the number of branches.- When no worker answers in time, every branch the graph resolved is still listed with its
branch_name,git_refandtracked_commit,remote_headis null, and the payload carriesunavailable { 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 whenfetched_atorunavailableis selected, orremote_headorconditionunderedges.node. - The
RepositoryBranchDrift.remote_headdescription now reads "Null when there is no remote counterpart or when condition is UNAVAILABLE". A row keeps its remote head underNOT_TRACKEDandORPHANED, so a consumer should decide drift fromcondition, not by comparingremote_headwithtracked_commit. - The drift part of the existing changelog fragment
+ifc-3101-repository-commits-api.added.mdnow 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.pyreuses 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 handlermessage_bus/operations/git/branch_heads.py, and the wire conversiongit/state/branch_heads_wire.py. git/base.py:get_branches_from_remote()is not used and not changed. It removes everyorigin/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
backend/infrahub/git/state/log_reader.py: the branch-heads read and the per-ref lookup.backend/infrahub/graphql/queries/repository_git_state.py: the drift resolver, its field gating and the timeout handling.- The message, handler and wire module, which mirror the commit-log ones.
- Tests, then the spec updates under
dev/specs/ifc-3101-repo-commit-visibility/, including the implementation reportopsmill-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 passedtest_branch_heads.pyandtest_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