The Successor-Fork Teardown: Abandoned Library to One Vetted, Pinned Fork
Run your own dependency's fork list through eight filters to one pinned fork with a written decision record, or to a documented no-viable-fork outcome.
Key takeaways
- A commits-ahead count is not a maintenance score, because merges pulled in from upstream and bot version bumps both inflate ahead_by, so read the actual commits before you rank any fork.
- If you drop zero-ahead forks before expanding branches, a 1,000-fork scan needs about 1,010 API calls, which finishes in under an hour on a personal token's 5,000-requests-per-hour limit but takes about 17 hours unauthenticated.
- Star and fork counts do not separate malicious repositories from clean ones, and Apiiro's repo-confusion campaign used mass automated forking of infected clones to infect more than 100,000 repositories.
- Measured provenance attestation adoption is 2.0-6.6% across four major package registries, so a missing attestation is the norm, and a tag-to-tarball diff should be the default check.
- An override pins only your own build: pnpm reads overrides only at the project root and Go ignores replace directives outside the main module, so confirm the switch with npm ls or go mod graph.
- In Refolk's index, US profiles with Maintainer titles outnumber German ones 29.5x (1,561 to 53), but supply-chain security profiles only 15.9x, so Germany has relatively more reviewers for this kind of work.
This is the procedure for the day a production dependency goes quiet and its fork list runs to hundreds of entries. It is for engineering managers, technical founders, developer-relations leads and technical sourcers who have to choose one community fork, switch production to it and pin it. It follows one worked example from the full fork network to a single override line. At each filter you record how many forks are left, and the guide says plainly when the honest answer is "no viable fork".
Why does sorting forks by commits ahead pick the wrong one?
Sorting by commits ahead and last push measures activity, not maintenance, and most of that activity is noise. A fork that merges upstream into itself, a bot that bumps versions, a company's private feature branch and an automated malicious clone can all rank above the one fork someone actually maintains.
Three terms carry the rest of this guide:
- Fork network: the root repository, every fork of it, and every fork of those forks.
- ahead_by / behind_by: from GitHub's compare endpoint. ahead_by counts commits in the fork that are not in the base. behind_by counts commits in the base that are not in the fork.
- Second-order fork: a fork of a fork. Its ahead and behind numbers change depending on which repository you use as the base.
The existing tools are good at listing forks and stop once they have sorted them:
- useful-forks scans recursively and shows how far each fork is ahead of and behind the parent repository's default branch. It also filters out forks where no commits were ever pushed to the main branch after the fork was created.
- Fork Finder ranks forks by push date and stars.
- find_forks warns in its README: "When repo has over 6000 forks one may face a github's API limit."
None of them read the commits behind the number, and that gap matters more than it looks. Apiiro documented a repo-confusion campaign that worked like this: clone a legitimate repository and infect it, upload it under an identical name, fork it thousands of times using automation, and promote it on forums. Every one of those automated forks shows a recent push. A ranking built on freshness and stars therefore pushes up the very forks you most need to exclude. Published research also found that numbers of forks and stars are not effective in discriminating between malicious and non-malicious repositories.
| Signal | What it proves | When it lies |
|---|---|---|
| High ahead_by | The fork has commits the root lacks | The commits are merges, bumps, or one company's features |
| Recent push | Something touched the repo | The pusher is automation |
| Stars | People found it | Mirrors, and clones promoted on forums |
What does a full fork-network scan cost in API calls?
With a personal token, a network of about 1,000 forks takes roughly 1,010 API calls to list and compare, which is under an hour. Run the cheap filters first, before anything that multiplies the number of calls.
The worked example is quux, an archived npm library with about 1,000 forks. It is a composite, not one named project. I use it so the arithmetic applies cleanly and so the wrong turns are ones you will meet on your own dependency. I do not give a survival rate for each stage. No public scan of an abandoned library's fork network reports what share of forks are zero-ahead, merge-only or second-order, and an invented percentage would only set false expectations. The decision record later in the guide has a slot for each count, to be filled from your own scan.
| Auth mode | Hourly limit | Est. calls | Hours |
|---|---|---|---|
| Unauthenticated | 60 | ~1,010 | ~17 |
| GITHUB_TOKEN | 1,000 | ~1,010 | ~1 |
| Personal token | 5,000 | ~1,010 | under 1 |
The estimate assumes 10 list pages of 100 forks each plus one compare call per fork, with no branch or sub-fork expansion. The limits come from GitHub's REST documentation. GITHUB_TOKEN gets 1,000 requests per hour per repository, and GitHub App limits cannot rise above 12,500 requests per hour. There are also secondary limits on concurrency and per-endpoint request rate. GitHub says these are subject to change without notice, so check the current rate-limit documentation before you schedule a large scan.
The rate limit is what sets the order of the funnel. Some gist scripts expand every branch of every fork, which multiplies the call count and pushes a scan past one token-hour. If you drop zero-ahead forks before doing anything at branch level, the whole scan stays inside the budget above.
The successor-fork procedure
The procedure has eight stages. Each one either removes forks or records evidence about the survivors, and it ends in one pinned fork or a written "no viable fork".
From full fork list to one pinned successor
- Enumerate the full fork networkPull every fork, including forks of forks, into a CSV that records each fork's depth in the tree. Use a personal token, because an unauthenticated scan of 1,000 forks takes about 17 hours.
- Drop zero-ahead forksRemove every fork whose ahead_by against the root's default branch is 0, and record how many are left. This step is automated and takes minutes.
- Read the commits behind each ahead countOpen the compare response for each survivor and drop forks whose commits are all upstream merges or manifest version bumps. Done when every survivor has at least one substantive commit.
- Resolve second-order forks to the rootFor every fork of a fork, recompute ahead and behind against the original repository's default branch, not its immediate parent. Drop any that turn out to be far behind the root.
- Classify forks and vet the maintainerSeparate maintenance forks from divergent rewrites and private branches, then check who maintains each candidate and look for malicious-clone signals. Done when one to three candidates remain, each with a named maintainer you can check.
- Match the registry package to its sourceVerify npm provenance attestations where they exist; otherwise diff the published tarball against the matching tag. Done when provenance verifies or the diff shows nothing unexplained.
- Count downstream adoption and diff the licenseCount dependents of the fork's package using a dependency-graph source such as deps.dev, and diff the license against the root's. Done when both are recorded.
- Write the override and the decision recordWrite the exact override line, install, and confirm it applied with npm ls or go mod graph. If nothing survived, record "no viable fork" and go back to the replace-or-contain decision.
Sources disagree on when to resolve second-order forks. useful-forks does it during the scan and lets you choose 'Parent', 'Source' [WIP] or 'Heuristic' [WIP] as the base for the ahead and behind numbers. I do it after the noise filters, because by then there are fewer forks to recompute. Either order works, as long as every final number uses the root's default branch as its base.
All the durations below are practitioner estimates, not measurements:
- Enumeration: under an hour.
- Reading commits: 1 to 3 hours.
- Classification and maintainer checks: about half a day for a senior engineer or sourcer.
- Provenance check: about an hour.
- Adoption count and license diff: about an hour.
Stages 1 to 4 on quux: from the full list to real candidates
The first four stages are mechanical. They remove forks that add nothing, forks whose additions are noise, and forks whose freshness only exists relative to the wrong parent. The first wrong turn in the example happened here.
Stage 1: enumerate with depth
Every fork goes into one table: owner, parent, depth in the tree, default branch, last push, stars. When a useful-forks scan finishes, it can offer an "Export table below as CSV" button, which gives a usable starting file. Record the total network size as the first count. Keep the depth column. You need it at stage 4, and it is the first thing a reviewer asks about.
Stage 2: drop zero-ahead forks
Compare each fork with the root and drop any fork with ahead_by of 0. useful-forks uses the same default: if 'ahead' is less than or equal to its filter value of 0, the fork is removed. A zero-ahead fork may be behind, identical, or untouched, and in every case it has nothing to offer as a successor. Record the count left.
GET /repos/ROOT_OWNER/quux/compare/main...FORK_OWNER:main Read from the response: ahead_by commits in the fork, not in the root behind_by commits in the root, not in the fork status the comparison status commits[] the actual commits, in chronological order Drop the fork if: ahead_by is 0 every commit in commits[] is a merge from upstream every commit only bumps a version in the manifest
Replace ROOT_OWNER, FORK_OWNER and the branch names. Keep the root's default branch as the base for every fork, including sub-forks.
The compare endpoint works across repositories in the same network, fork branches included. Its commit list is equivalent to git log BASE...HEAD, returned in chronological order. The endpoint has no default base, so you choose it, and that is how forks end up measured against the wrong one.
Stage 3: read the commits
This is where the first wrong turn happened. Sorted by stars, the top fork of quux looked like the obvious successor. Its compare response told a different story. It was a mirror: every commit it held beyond the root was a merge that synced from upstream, and none of them were its own changes. The stars showed that people had found it. They said nothing about whether anyone was maintaining it. I dropped it.
Apply the same test to every survivor. Keep a fork only if at least one commit changes behaviour, fixes a bug, patches a vulnerability or updates the build in a way the root never did. Forks made entirely of merges and version bumps go. Record the count left.
Stage 4: resolve second-order forks
One sub-fork two levels down had a short behind count against its own parent, and that was the comparison its fork page displayed. Recomputed against the root's default branch, it was missing a long stretch of the root's history, because its parent had itself stopped syncing long before. It had looked current only against a stale parent. Recompute every fork at depth two or more against the root, drop the ones that fall behind, and record the count.
Stage 5: maintenance fork, divergent rewrite, or private branch?
A maintenance fork keeps the original API and fixes what the root stopped fixing. A divergent rewrite changes the API and amounts to a migration. A private branch exists to serve one team. Only the first can be pinned in place of the original, and only if a real, checkable person stands behind it.
Classifying the surviving forks
The second wrong turn was the fork furthest ahead. It had the most substantive commits in the network, and they were real work. They were also one company's internal branch: features specific to the company's product, no tags, no published package, and every author from the same organisation. Pinning to it would have meant taking on someone else's roadmap with no public release process. It went in the bottom-right quadrant and I dropped it.
Checking the maintainer
For each candidate, answer these questions and write down what each answer proves:
- Does the person who merges commits also publish to the registry? This shows a single chain of control. It fails when publish rights sit with an account that never appears in the history.
- Do they tag releases? Tags show there is a release process you can pin to. Tags that do not match any published version show the opposite.
- Is there a second person with merge or publish rights? A second person means the fork survives one maintainer leaving. It means little if the second account has never done anything.
- Does the account have a history beyond this fork? Other projects and contributions show a real practitioner. Account age has not been established as a published malware marker, so treat it as context, not proof.
- Is the person identifiable outside GitHub? An employer, other open source work and a professional profile show that a person you could contact stands behind the handle.
At the last question, the job stops being a GitHub problem. The handle that merges commits belongs to a person with an employer, other projects and a public footprint across LinkedIn and the open web. Refolk lets you ask for that person in plain English and returns them with that context already attached, which is faster than piecing it together from commit emails.
Checking for malicious-clone signals
Run these checks on each candidate before going further:
- Summary versus details: Apiiro observed thousands of forks appearing in a repository's summary and none in its details.
- Planted install calls: payloads spread through
os.system("pip install package")calls planted in forks of popular repos. - Identical name and forum promotion: in the confusion pattern, the infected clone shares the root's exact name.
- Release artifacts that differ from source: Fork Sentry found malware hosted in fork releases. Stage 6 covers this.
Stages 6 and 7: does the package match the source, and who else depends on it?
A fork passes stage 6 when the package you will install is shown to come from the source you reviewed. Either an attestation verifies, or a diff between the release tag and the published tarball shows nothing you cannot explain. Stage 7 then records who else depends on that package and whether the license changed.
Strength of evidence that a package matches its source
- Provenance attestationVerifies the package came from a specific commit in a specific repository
- Tag-to-tarball diffShows the published files match the tagged source, with nothing unexplained
- Repository URL in metadataClaims a source; proves nothing on its own
- NothingNo link from package to source; do not pin
Provenance when it exists
For npm, query registry.npmjs.org/-/npm/v1/attestations/<pkg>@<version> and verify that the predicate type is slsa.dev/provenance/v1. A valid attestation lets you verify that the published code came from a specific commit in a specific repository. Check that the repository is the candidate fork and the commit is one you reviewed.
The tag diff when it does not
Most forks will not have an attestation, and that is not a red flag.
| Sample | Size | With provenance | Share |
|---|---|---|---|
| Top jsDelivr packages publishing via GitHub Actions | 205 | 26 | 12.6% |
| Top 50 npm packages by weekly downloads | 50 | 6 | 12% |
| Four registries, latest artifact | ecosystem-wide | n/a | 2.0-6.6% |
Sources: an Exaforce audit of jsDelivr packages, an audit of the top 50 npm packages, and arXiv 2608.18180. The 12% share in row 2 is derived from 6 of 50. The same arXiv study found that 14.7-42.4% of packages lack a valid repository URL, so sometimes even the claimed source is missing. Nobody has published the share for community-fork packages specifically.
Provenance is evidence when it is present. When it is absent, it is simply the norm.
The default check is therefore a diff. Download the published tarball for the version you intend to pin, check out the matching tag, and compare the two file trees. Build output can legitimately differ from source. Unexpected files, install scripts that are not in the repository, or code that appears in only one of the two cannot. Fork Sentry's method compared release artifacts against known-malicious signatures and checked file similarity with ssdeep. For a single candidate, a careful manual diff does the same job.
Downstream adoption and license
Count dependents of the fork's package with a dependency-graph source. deps.dev provides source locations, dependency graphs, package metadata, licenses and releases. Dependents show that other teams made the same bet. Discount them when they turn out to be clones or near-empty repositories. No public figure exists for how often original maintainers endorse a successor fork, so an endorsement in the root's README is a bonus, not a requirement.
Then compare the fork's license file and the license field in its package metadata with the root's. If the license changed, take it to whoever owns license review before you go further. That is a legal decision, not an engineering one.
How do you write an override that actually applies?
Write the override at the root of your project in the form your package manager expects, then confirm it resolved with npm ls quux or go mod graph. An override can fail silently: the install finishes, but the old package or no package gets loaded.
How each ecosystem behaves:
- pnpm: overrides are documented for replacing a dependency with a fork, for example
"quux": "npm:@myorg/quux@^1.0.0", but they can only be set at the project root. The>selector narrows the scope:qar@1>zoooverrides only thezoodependency ofqar@1. - npm: if an override disagrees with a direct dependency, npm throws
EOVERRIDE: override conflicts with direct dependency. The workaround is the$namereference syntax, which points the override at the version you declared directly. - Go: replace directives apply only in the main module's
go.modand are ignored in other modules. A replace directive on its own does not add a module to the module graph, so the module must also be required. - pnpm strict mode: OWASP warns that a package present only because of an override pin is not hoisted in pnpm strict mode, so the import fails at runtime even though the install completed.
Yarn resolutions, Cargo [patch] and pip were not checked for this guide. Confirm their behaviour in their own documentation before you rely on them.
Dependency: quux (root: ROOT_OWNER/quux, default branch: main)
Decision: PIN FORK_SCOPE/quux@VERSION | NO VIABLE FORK
Counts left after each filter
Full network, all depths: ____
After zero-ahead drop: ____
After merge-only / bump drop: ____
After second-order resolution: ____
After maintenance-vs-rewrite split: ____
After maintainer and malware checks: ____
Wrong turns and why each was dropped
Top-starred fork: ____
Most-ahead fork: ____
Chosen fork
Maintainer(s), named, with outside evidence: ____
Second person with publish rights: ____
Tags match published versions: yes / no
Provenance: attestation verified / tag diff clean / failed
Dependents counted (source, date): ____
License same as root: yes / no (reviewed by ____)
Override line (pnpm, package.json at root)
"pnpm": { "overrides": { "quux": "npm:FORK_SCOPE/quux@^VERSION" } }
Override line (npm, direct dependency plus $name)
"dependencies": { "quux": "npm:FORK_SCOPE/quux@^VERSION" },
"overrides": { "quux": "$quux" }
Override line (Go, main module go.mod)
require ORIGINAL/MODULE/PATH vX.Y.Z
replace ORIGINAL/MODULE/PATH => FORK/MODULE/PATH vX.Y.Z
Verified after install with: npm ls quux | go mod graph
Re-check date: ____One record per abandoned dependency. Fill every count from your own scan, and keep the record next to the override in the same change.
How does the successor-fork decision go wrong?
Most bad picks come from trusting a number that measures something other than maintenance. The most common causes are counting merges as work, comparing against the wrong base, trusting popularity, skipping the artifact check, rejecting forks for a missing attestation, and writing an override that never applies.
| Failure | What it looks like | Check |
|---|---|---|
| ahead_by as a score | Top fork's commits are merges or bumps | Read commits[]; drop merge-only forks |
| Wrong base | Sub-fork looks current against its parent | Recompute against the root's default branch |
| Trusting stars and forks | Popular fork is a mirror or mass-forked clone | Summary versus details; planted install calls |
| Artifact differs from source | Release tarball holds files the tag lacks | Diff the tarball against the tag |
| No provenance read as malicious | Every candidate rejected | Tag diff as default; attestation as upgrade |
| Silent override | Install passes, old package or none loads | npm ls or go mod graph after install |
Treat the malware risk as real, not theoretical. Apiiro reported more than 100,000 infected repositories from the repo-confusion campaign, with an initial published count of 101,271. Fork Sentry ("What the Fork?" by Alan Cao and Brendan Dolan-Gavitt) showed how rare and how concentrated malicious forks can be.
Fork Sentry's scan of cryptocurrency fork networks
- 68,879Forks scanned
Across 35 popular cryptocurrency repositories
- 26Forks hosting malware
Found through release artifact analysis
- 7Takedowns
Forks removed after reporting
One pitfall comes from the people side of the work. In Refolk's index, matches for "Maintainer" titles include founders and CEOs, and those are the most common US titles. A title match tells you where to start looking. It does not show that the person maintains anything. Confirm through commits and releases.
Keeping the pin current, or walking away
A pin is a decision with an expiry date. Rerun the funnel whenever the chosen fork stops tagging releases, changes maintainers or changes license. If the funnel ends empty, the record of why goes into the replace-or-contain decision.
"No viable fork" is a legitimate outcome. Write the decision record anyway, with every count, and give the reason the last candidate failed. That record is what you hand to the replace-or-contain discussion, which decides whether to swap in a different library or wrap and freeze the dead one. It also gives you a baseline, so a later rescan shows whether a real maintenance fork has emerged.
To keep a pin honest, watch the mechanisms, not a calendar. Watch the chosen fork's tags against its published versions. Watch its publish rights for new or removed accounts. Watch its license file. Watch the root, in case it comes back to life. Before each rescan, check that the tools you use are still maintained themselves. useful-forks shows its last update on its own page.
If a stage 5 call is too close to make alone, bring in a second reviewer with supply-chain security experience. Refolk's index shows where those reviewers are, relative to maintainers:
| Query | US | Germany | US:DE multiple |
|---|---|---|---|
| Maintainer titles | 1,561 | 53 | 29.5x |
| Supply chain security keyword | 238 | 15 | 15.9x |
| Supply-chain as share of maintainer count | 15.2% | 28.3% | n/a |
The ratios are derived from four queries against Refolk's index. Supply-chain expertise is relatively denser in Germany. Chainguard is the top employer for supply-chain security profiles in both countries (7 in the US, 2 in Germany). In a sample of 25 German maintainers, 8 held the literal title "Maintainer", with employers including CNCF, The Zephyr Project and Drupal. In Refolk, a plain-English request such as "software supply chain security engineers in Germany who contribute to OWASP Dependency-Track" returns named reviewers in one step, which saves hand-building a list from conference speaker pages.
Before you merge the override
- The decision record shows a count after every filter, starting from the full network at all depths.
- Every surviving fork was measured against the root's default branch, not its immediate parent.
- The chosen fork has at least one substantive commit that is neither a merge nor a version bump.
- The maintainer is named and identifiable outside this one repository.
- The chosen fork keeps the root's API, or the switch is being handled as a migration.
- Provenance verified, or the tag-to-tarball diff shows nothing unexplained.
- Downstream dependents were counted and spot-checked for clones.
- The license matches the root, or the change was reviewed by its owner.
- The override sits at the project root, and npm ls or go mod graph shows the fork resolved.
- A re-check trigger is written down, covering tags, publish rights, license and root activity.
Questions practitioners ask
How do I find a maintained fork of an abandoned GitHub repo?
Enumerate the whole fork network, sub-forks included, and compare every fork against the root's default branch using GitHub's compare endpoint. Drop forks that are zero ahead. Then read the commits behind each remaining ahead count, because merges and version bumps inflate it. Only after that should you look at the maintainer, the published package and downstream users. Tools like useful-forks handle the enumeration step well, but none of them decide for you which fork is maintained.
Is commits ahead a good way to compare forks?
Commits ahead is a useful first filter but a bad final ranking. A count of zero reliably means the fork adds nothing. A high count can be upstream merges, automated version bumps, or one company's internal feature work. It also depends on the base you compare against: measure a fork of a fork against its own parent and it can look current while being far behind the original. Always compare against the root and read the commits themselves.
Should I reject a fork that publishes to npm without provenance?
No. Missing provenance is normal. Measured adoption is 2.0-6.6% across four major registries, and only 26 of 205 top jsDelivr packages that publish from GitHub workflows carry it. Treat an attestation as strong evidence when it is there. When it is not, download the published tarball and diff it against the matching git tag. Reject the fork only if that diff shows files or code you cannot explain.
Why did my pnpm override or Go replace directive not switch to the fork?
The override was probably declared somewhere the tool does not read it. pnpm reads overrides only at the project root. Go applies replace directives only in the main module's go.mod and ignores them in other modules, and a replace on its own does not add a module to the graph. npm throws EOVERRIDE when an override disagrees with a direct dependency. After installing, check what actually resolved with npm ls or go mod graph.
What do I do if no fork passes every filter?
Write down "no viable fork", with the count left after each filter and the reason the last candidates failed, then go back to the replace-or-contain decision. That record is still useful: it shows reviewers the fork route was checked properly. It also gives you a baseline, so you can rerun the scan in a few months and see whether a real maintenance fork has appeared since.
Try it on the search you came here for
Stop building boolean strings. Just describe the person.
Type one sentence. I plan the search, read GitHub, public LinkedIn and Crunchbase records, and the open web as it is right now, and hand back a ranked list with the reason next to every name.
01Describe them
One plain sentence. Role, city, stack, stage, whatever matters to you.
02I read the web live
GitHub, public LinkedIn and Crunchbase records, the open web. Not a database that went stale last quarter.
03You read the shortlist
Ranked, with the reasoning under every name. Open a profile, ask a follow-up, narrow it down.
- Staff backend engineers in NYC who shipped Rust in production
- Series A fintechs in SF under 50 people, growing headcount this year
- Maintainers of fast-growing Rust web frameworks on GitHub
- No boolean, no filters, no seat to buy. One box.
- Read at search time, so a profile updated yesterday counts today.
- Every step visible as it runs, every name with its reason.
500 free credits on sign-up. No card, no demo call. See real searches.