Skip to main content

Contributing through a fork

You fixed a library you don't maintain. It takes changes only as pull requests, and may take weeks to merge yours. Your monorepo needs the fix now. So keep the fix in a fork of the library, and let the monorepo follow the fork until the library has taken it.

The fork has three kinds of branches:

  • main: a copy of the library's main. Nobody commits on it.
  • patches: your changes on top of main. The monorepo follows this branch, so it has every fix, merged upstream or not.
  • One branch per pull request: each with one change on top of main, so the library can review and take each on its own. With a single change, patches can be the pull request's branch.

The monorepo's x, y and p, which is H and B; the lane vendor/a/ split in two: origin with k, m and n, its main; fork with the same k, m and n, its main, q on top of n on fix/parser, e on top of n on docs/parser, and d on top of q, which is T, R, U(H), U(B) and patches

Here the monorepo made two changes, x and y. In the fork, each has a branch of its own, q on fix/parser and e on docs/parser, and patches has both. A scenario runs the whole workflow.

Follow the fork​

Fork the library on its host. Then add the fork to the splice's .splice as a second upstream, make it the default, and follow its patches:

git config --file vendor/a/.splice upstream.fork.url 'https://github.com/<you>/lib.git'
git config --file vendor/a/.splice upstream.fork.default-branch patches
git config --file vendor/a/.splice splice.default-upstream fork
git commit -m "follow the fork's patches" -- vendor/a/.splice
[splice]
id = …
commit = …
default-upstream = fork
[upstream "origin"]
url = https://github.com/<them>/lib.git
[upstream "fork"]
url = https://github.com/<you>/lib.git
default-branch = patches

From then on, git splice push vendor/a sends your changes to the fork's patches, and git splice pull vendor/a brings that branch in.

Open a pull request per change​

Clone the fork, with the library as a second remote:

git clone https://github.com/<you>/lib.git && cd lib
git remote add original https://github.com/<them>/lib.git

Give each change a branch that starts at main, and push it:

git switch -c fix/parser origin/main
git cherry-pick <the change's commit on patches>
git push origin fix/parser

Open the pull request against the library. One against the fork's own main runs the library's CI on it first.

When the library moves on​

In the clone of the fork, bring the fork's main up to date. It has no commits of its own, so that's a fast-forward:

git fetch original
git push origin original/main:main
git fetch origin

Then rebase patches onto it, and force-push it. The same goes for each pull request branch:

git switch -C patches origin/patches
git rebase origin/main
git push --force-with-lease origin patches

Then pull in the monorepo, as always. When the library has taken one of your pull requests, the rebase drops that change from patches, since main has it now. The monorepo already has the change, so its pull only records the new patches. Here the library took fix/parser as f. patches is now just g, the documentation change, on top of f, and the monorepo's r records it:

The monorepo&#39;s p and r, which is H and B; the lane vendor/a/ split in two: origin with k, m, n and f, its main; fork with the same k, m, n and f, its main, and g on top of f, which is T, R, U(H), U(B), patches and docs/parser

If the maintainer changed your fix on the way, the rebase conflicts in the fork. Keep the library's version, and the monorepo pulls that.

Push before the fork rebases​

Push your changes from the monorepo to patches before anyone rebases it. Then the rebased patches has them, and a pull only brings in the fork's version of what the monorepo already has (patch-branch-rebased). A change you didn't push in time needs pull --rebase.

To see where patches was before the rebase, the pull reads the fetched branch's reflog, like git pull --rebase. That reflog exists only on your machine. A colleague who fetches patches for the first time after the rebase has none, and gets a conflict where your pull went through (a clone that fetched after the rebase).

In practice​

This repository dogfoods the workflow with scrut, its test runner: scrut/ is a splice that follows a fork, roschaefer/scrut. The fork's pull requests show each change on its way back to Meta.