List the commits a fork carries ahead of upstream
What does this MR do and why?
The divergence view already tells you a fork is five commits ahead of upstream. It does not tell you which five. Anyone carrying private patches — a custom auth hook, a rate limit, a compliance check — has to go and find them by hand every time upstream moves, which is the part of keeping a fork current that actually costs time.
This is the first slice of #628865: list them.
Projects::Forks::Details gains #ahead_commits, which walks the range between the upstream default branch and the fork's ref and returns the commits in the order a rebase would re-apply them, oldest first. Each commit's own description doubles as its intent summary, so no new type was needed. It is exposed as ForkDetails.aheadCommits behind the fork_patch_inventory feature flag, which is off.
Two deliberate limits:
- The list is capped at 100. A long-lived fork can be thousands of commits ahead and this is read on page load, so beyond the cap the most recent commits are returned.
- Walking the range needs the upstream commit to be present in the fork repository, and a fork far enough behind can be missing it. In that case the method returns
nullrather than guessing — the same "diverged, action required" state the divergence counts already surface.
There is no migration and no new Gitaly RPC. The two commit SHAs the range needs were already being resolved for the divergence counts, and they are now memoized so ahead, behind and the list share one pair of lookups instead of two.
The divergence view UI, per-patch conflict prediction, and the conflict-to-merge-request flow follow in their own merge requests.
Related to #628865
Differences
There is no UI in this merge request, so the difference is in the GraphQL response for a fork that is three commits ahead.
Before: the counts are available, the patches are not.
{ "forkDetails": { "ahead": 3, "behind": 118 } }After: the same query can ask which three.
{ "forkDetails": { "ahead": 3, "behind": 118, "aheadCommits": [ { "sha": "9c1e4f2a...", "title": "Add an LDAP group filter", "description": "Our directory nests groups two levels deep." }, { "sha": "b7d0a531...", "title": "Raise the API rate limit for internal tooling", "description": "" }, { "sha": "41fe98cd...", "title": "Log the acting user on project transfer", "description": "Required by our audit policy." } ] } }
## How to set up and validate locally
1. Enable the flag: `Feature.enable(:fork_patch_inventory)` in `rails console`.
1. Fork any project with a repository, then add a couple of commits to the fork's default branch so it is ahead of upstream.
1. Query it in GraphiQL at `/-/graphql-explorer`:
```graphql
{
project(fullPath: "your-namespace/your-fork") {
forkDetails(ref: "main") {
ahead
behind
aheadCommits {
sha
title
description
authorName
authoredDate
webUrl
}
}
}
}## MR acceptance checklist
- [x] I have evaluated the MR acceptance checklist for this MR.
- [x] Added unit tests for the new model method, including the cap, the missing-upstream-commit path and the feature flag, and a request spec for the GraphQL field.
- [x] Followed the [GraphQL styleguide](https://docs.gitlab.com/development/api_graphql_styleguide/): the field is marked as an experiment and its description names the feature flag and says what it returns when the flag is off.
- [x] Regenerated the GraphQL reference and introspection artifacts to match the new field.
- [x] Behind a `wip` feature flag, default off, with the flag definition pointing at both the issue and this MR.
- [x] No migration and no new database query; the field reads from Gitaly only.
- [x] Bounded the result set (100 commits) so a long-lived fork cannot return an unbounded list on page load.
- [x] Reused the existing SHA lookups rather than adding Gitaly round trips, and memoized them so both fields share one pair.
- [x] No changelog entry, per the [feature flag guidance](https://docs.gitlab.com/development/feature_flags/#changelog) for changes behind a default-off flag.
- [x] No user-facing documentation yet; it lands with the divergence view UI.