Pas intéressé par les explications ? Ticket express pour le tl;pl 🚀

Les communautés open source sur plateformes collaboratives Git (e.g. Github, GitLab) demandent généralement aux contributeurs de soumettre leurs modifications au dépôt upstream depuis un fork.

À l’inverse d’un véritable fork qui bifurque de l’upstream et a une vie propre, un fork contributif est un simple conteneur pour branches contributives : son cycle de vie est étroitement lié à l’upstream. On attend des contributeurs qu’ils soumettent des modifications basées sur une version récente du dépôt upstream. Lorsque le fork est obsolète, ils doivent se synchroniser avec l’upstream et rebase leur travail.

C’est là que ça se complique. Il existe autant d’approches pour synchroniser que de contributeurs. La documentation GitHub a même une entrée dédiée à la synchronisation d’un fork.

De mon côté, je pense qu’une approche « sans synchro » est la meilleure 😄

Observation

Deux types de branches sur un fork contributif :

  • Branches miroirs : branches présentes à la fois sur l’upstream et sur le fork, qui doivent être gardées à jour avec l’upstream (généralement, la branche par défaut et les branches de versions).
  • Branches contributives : branches présentes uniquement sur le fork, qui contiennent les modifications qui seront soumises à l’upstream.

En pratique, les branches miroirs sont inutiles : elles servent uniquement de point de départ aux branches contributives. La nécessité de les maintenir à jour avec l’upstream génère de l’intendance supplémentaire au niveau de la gestion de branches.

Peut-on contribuer sans avoir à synchroniser le fork ? C’est l’idée derrière l’approche « sans synchro ».

Contexte

Mise en place de la remote upstream

Cette étape est commune aux approches habituelles avec synchronisation.

Par défaut, quand on clone un dépôt, une remote origin pointant vers le dépôt source est enregistrée dans le dépôt local. Ici, je clone un fork personnel de cilium/cilium :

$ git clone git@github.com:nbusseneau/cilium.git
Cloning into 'cilium'...
[...]
$ cd cilium
$ git remote -v
origin  git@github.com:nbusseneau/cilium.git (fetch)
origin  git@github.com:nbusseneau/cilium.git (push)

On est automatiquement placés sur une branche locale par défaut, qui traque la branche par défaut sur origin :

$ git branch -vv
* master 1070b19ab [origin/master] Add missing Demo App reference

S’agissant d’un fork, on peut basculer vers n’importe quelle branche miroir disponible :

$ git switch v1.10
Branch 'v1.10' set up to track remote branch 'v1.10' from 'origin'.
Switched to a new branch 'v1.10'
$ git branch -vv
  master 1070b19ab [origin/master] Add missing Demo App reference
* v1.10  75b4ed957 [origin/v1.10] build(deps): bump docker/setup-buildx-action

Note : dans ce billet je vais utiliser git switch plutôt que git checkout, mais les deux sont interchangeables.

Comme un dépôt Git peut interagir avec plusieurs dépôts Git distants, ajoutons le dépôt upstream comme remote upstream :

$ git remote add upstream git@github.com:cilium/cilium.git
$ git remote -v
origin  git@github.com:nbusseneau/cilium.git (fetch)
origin  git@github.com:nbusseneau/cilium.git (push)
upstream        git@github.com:cilium/cilium.git (fetch)
upstream        git@github.com:cilium/cilium.git (push)

On peut désormais récupérer les branches de l’upstream :

$ git fetch upstream
[...]
From github.com:cilium/cilium
 * [new branch]          master                                     -> upstream/master
 * [new branch]          v1.9                                       -> upstream/v1.9
 * [new branch]          v1.10                                      -> upstream/v1.10
 [...]

Approches avec synchro

La plupart des guides recommandent de garder les branches miroirs d’un fork à jour avec l’upstream en utilisant la technique pull-push :

  • Pull (ou merge, ou rebase) les branches miroirs locales (e.g. master) depuis la branche upstream (e.g. upstream/master).
  • Push vers la branche miroir du fork (e.g. origin/master) :
$ git switch master
$ git pull upstream master
$ git push

Ce dernier point est précisément ce qu’on ne fera pas : on ne va jamais mettre à jour les branches miroirs sur le fork.

Approches sans synchro

Nous allons présenter trois approches sans synchro :

  • Branches upstream locales
  • Branches upstream fetch-only
  • Upfetch

Branches upstream locales

Cette première approche est la plus simple. On part de l’état du dépôt présenté ci-avant, avec les remotes origin et upstream. Jettons un œil au fichier .git/config :

[branch "master"]
        remote = origin
        merge = refs/heads/master
[branch "v1.10"]
        remote = origin
        merge = refs/heads/v1.10

Les branches locales master et v1.10 traquent toutes les deux des branches miroirs sur le fork (remote origin). On va contourner le fork et travailler directement avec upstream.

Traquer l’upstream sur des branches existantes

Pour qu’une branche existante traque la remote upstream, on peut :

  • Modifier manuellement .git/config et remplacer remote = origin par remote = upstream.
  • git config branch.<BRANCH_NAME>.remote upstream
  • git branch <BRANCH_NAME> -u upstream/<BRANCH_NAME>
$ git branch master -u upstream/master
Branch 'master' set up to track remote branch 'master' from 'upstream'.
$ git config branch.v1.10.remote upstream
$ git branch -vv
  master 1070b19ab [upstream/master: behind 104] Add missing Demo App reference
* v1.10  75b4ed957 [upstream/v1.10: behind 96] build(deps): bump docker/setup-buildx-action
[branch "master"]
        remote = upstream
        merge = refs/heads/master
[branch "v1.10"]
        remote = upstream
        merge = refs/heads/v1.10

Traquer l’upstream sur de nouvelles branches

Pour basculer vers une nouvelle branche en provenant du dépôt upstream et qu’elle traque directement upstream, on peut utiliser --track :

$ git switch -c v1.9 --track upstream/v1.9
Updating files: 100% (7131/7131), done.
Branch 'v1.9' set up to track remote branch 'v1.9' from 'upstream'.
Switched to a new branch 'v1.9'
$ git branch -vv
  master 1070b19ab [upstream/master: behind 104] Add missing Demo App reference
  v1.10  75b4ed957 [upstream/v1.10: behind 96] build(deps): bump docker/setup-buildx-action
* v1.9   f993696f9 [upstream/v1.9] Prepare for release v1.9.7
[branch "v1.9"]
        remote = upstream
        merge = refs/heads/v1.9

Note : --track est également disponible pour git checkout -b, si vous la préférez à git switch -c.

Gestion de branches

Maintenant qu’on a des branches upstream locales traquant directement le dépôt upstream, on peut gérer les branches exactement comme on le ferait sur un dépôt mono-remote habituel.

Mettre à jour les branches upstream locales

Pull habituel afin de récupérer les dernières modifications depuis l’upstream :

$ git switch master
Switched to branch 'master'
Your branch is behind 'upstream/master' by 104 commits, and can be fast-forwarded.
  (use "git pull" to update your local branch)
$ git pull
Updating 1070b19ab..dfc528bbe
Updating files: 100% (567/567), done.
Fast-forward
[...]

Créer de nouvelles branches contributives

Technique pull-create habituelle :

$ git switch master
Switched to branch 'master'
Your branch is behind 'upstream/master' by 104 commits, and can be fast-forwarded.
  (use "git pull" to update your local branch)
$ git pull
Updating 1070b19ab..dfc528bbe
Updating files: 100% (567/567), done.
Fast-forward
[...]
$ git switch -c pr/foo
Switched to a new branch 'pr/foo'
$ git branch -vv
  master 541214272 [upstream/master] install: Disable kube-proxy-replacement by default
* pr/foo 541214272 install: Disable kube-proxy-replacement by default

Rebase des branches contributives existantes

Technique pull-rebase habituelle :

$ git switch master
Switched to branch 'master'
Your branch is behind 'upstream/master' by 104 commits, and can be fast-forwarded.
  (use "git pull" to update your local branch)
$ git pull
Updating 1070b19ab..dfc528bbe
Updating files: 100% (567/567), done.
Fast-forward
[...]
$ git switch pr/foo
Switched to branch 'pr/foo'
$ git rebase master
Successfully rebased and updated refs/heads/pr/foo.

Avantages

Par rapport aux approches avec synchro, on a considérablement réduit l’intendance des branches

  • Plus d’opérations manuelles pour récupérer les modifications de l’upstream.
  • Plus de synchronisation des branches miroirs sur le fork.
  • Les branches upstream locales peuvent être utilisées comme des branches standards, exactement comme on le ferait sur un dépôt mono-remote habituel.

Inconvénients

Si on peut pull directement depuis l’upstream, on peut aussi push directement vers l’upstream. Si on a les droits en écritures, on pourrait accidentellement commit dessus et push directement vers l’upstream.

Heureusement, on peut facilement s’en prémunir. Deux solutions :

  • Bloquer par configuration Git.
  • Bloquer par hook Git.

Bloquer par configuration Git

git config a une variable optionnelle pushRemote pour les branches, qui a précedence sur la variable remote précédemment configurée pour les opérations de push. On peut enregistrer une pushRemote inexistant pour bloquer les opérations de push sur les branches de notre choix :

$ git config branch.master.pushRemote DISABLE_PUSH
$ git push
fatal: 'DISABLE_PUSH' does not appear to be a git repository
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.
[branch "master"]
        remote = upstream
        merge = refs/heads/master
        pushRemote = DISABLE_PUSH

Bloquer par hook Git

Le hook pre-push existe précisement pour ce cas d’usage. On peut créer un hook .git/hooks/pre-push dans le dépôt avec une liste de branches pour lesquelles bloquer les opérations de push :

#!/bin/bash

protected_branches=('master' 'v1.10' 'v1.9')

while read local_ref local_oid remote_ref remote_oid; do
  # Strip everything before last the '/'
  # e.g. refs/heads/master -> master
  current_branch=$(echo ${local_ref} | sed -e 's/.*\/\(.*\)/\1/')
  for protected_branch in "${protected_branches[@]}"; do
    if [ "${protected_branch}" = "${current_branch}" ]; then
      echo "push denied: ${protected_branch} is protected"
      exit 1
    fi
  done
done
$ git push
push denied: master is protected
error: failed to push some refs to 'github.com:nbusseneau/cilium.git'

Note : personnellement, je bloque les opérations de push avec pushRemote. Le hook pre-push en exemple ci-dessus semble fonctionner correctement, mais je l’ai écrit en quelques minutes et ne l’ai pas testé extensivement. Soyez vigilant ! 😉

Branches upstream fetch-only

Cette seconde approche est un peu plus complexe. Partant d’un dépôt vierge, on commence avec la mise en place des remotes origin et upstream comme ci-dessus :

$ git clone git@github.com:nbusseneau/cilium.git
Cloning into 'cilium'...
[...]
$ cd cilium
$ git remote add upstream git@github.com:cilium/cilium.git
$ git remote -v
origin  git@github.com:nbusseneau/cilium.git (fetch)
origin  git@github.com:nbusseneau/cilium.git (push)
upstream        git@github.com:cilium/cilium.git (fetch)
upstream        git@github.com:cilium/cilium.git (push)

Gestion de branches

On va directement utiliser les branches upstream de la remote avec git fetch upstream <REMOTE_BRANCH>, et ainsi ne jamais avoir besoin de gérer de branches upstream locales.

Créer de nouvelles branches contributives

Technique fetch-create.

  • D’abord, on fetch la branche de départ depuis l’upstream pour s’assurer qu’elle soit à jour – un peu comme un git pull avant de créer une nouvelle branche.
  • Ensuite, on crée une nouvelle branche avec upstream/<BRANCH_NAME> comme point de départ :
$ git fetch upstream master
[...]
From github.com:cilium/cilium
 * branch                master     -> FETCH_HEAD
   dfc528bbe..541214272  master     -> upstream/master
$ git switch -c pr/foo upstream/master --no-track
Switched to a new branch 'pr/foo'
$ git branch -vv
  master 1070b19ab [origin/master] Add missing Demo App reference
* pr/foo 541214272 install: Disable kube-proxy-replacement by default

Notez l’usage de --no-track lors de la création de branche : si non fourni, --track upstream/master est supposé, ce qui n’est pas ce que nous souhaitons.

Rebase des branches contributives existantes

Technique fetch-rebase.

  • D’abord, on fetch la branche sur laquelle rebase depuis l’upstream pour s’assurer qu’elle soit à jour – un peu comme un git pull avant de rebase.
  • Ensuite, on rebase sur upstream/<BRANCH_NAME> plutôt que sur une branche locale :
$ git fetch upstream master
[...]
From github.com:cilium/cilium
 * branch                master     -> FETCH_HEAD
   dfc528bbe..541214272  master     -> upstream/master
$ git rebase upstream/master
Successfully rebased and updated refs/heads/pr/foo.

Avantages

Cette approche est minimaliste :

  • On a complètement éliminé les branches upstream locales : le dépôt ne contient plus que des branches contributives.
  • Plus de switch entre branches lors des créations ou rebase.

Pas non plus besoin de protection contre des push accidentels vers l’upstream.

Inconvénients

La gestion de branches est non-standard. On voudra probablement mettre en place des alias Git, en particulier pour ne pas oublier le paramètre --no-track.

Upfetch

Cette troisième approche est un compromis émergeant des deux précédentes. Elle fonctionne comme l’approche branches upstream locales, mais on incorpore une variante des fetch de l’approche fetch-only via git fetch upstream <REMOTE_BRANCH>:<LOCAL_BRANCH> :

$ git fetch upstream master:master
From github.com:cilium/cilium
   1070b19ab..541214272  master     -> master

Cette astuce git fetch permet dans le même temps de fetch une branche remote et mettre à jour une branche locale, sans avoir à y basculer au préalable :

git branch -vv
  master 541214272 [upstream/master] install: Disable kube-proxy-replacement by default
* pr/foo 1070b19ab Add missing Demo App reference

Je l’ai surnommée « upfetch ».

Gestion de branches

L’upfetch permet d’être très efficient comparé aux techniques précédentes :

  • Les pull-create / pull-rebase habituels nécessitent des switch superflu, ce qui peut être lourd sur de gros dépôts.
  • Les fetch-create / fetch-rebase avec git fetch upstream <REMOTE_BRANCH> ne mettent pas à jour les branches locales.

Créer de nouvelles branches contributives

Technique upfetch-create :

$ git fetch upstream master:master
From github.com:cilium/cilium
   1070b19ab..541214272  master     -> master
$ git switch -c pr/foo
Switched to a new branch 'pr/foo'
$ git branch -vv
  master 541214272 [upstream/master] install: Disable kube-proxy-replacement by default
* pr/foo 541214272 install: Disable kube-proxy-replacement by default

Rebase des branches contributives existantes

Technique upfetch-rebase :

$ git fetch upstream master:master
From github.com:cilium/cilium
   1070b19ab..541214272  master     -> master
$ git rebase master
Successfully rebased and updated refs/heads/pr/foo.

Avantages

Le meilleur des deux mondes :

  • Quand on travaille avec des branches upstream : gestion de branches intuitive, comme sur un dépôt mono-remote habituel.
  • Quand on travaille avec des branches contributives : opération de branches minimalistes, pas de switch superflu.

Inconvénients

Pareil que pour les branches upstream locales : nécessité d’empêcher les push accidentels vers l’upstream.

tl;pl

À mon avis, synchroniser un fork est fondamentalement inutile. Un fork contributif est un simple conteneur pour branches contributives : les branches non-contributives ne sont que de simples miroirs de l’upstream, et les synchroniser n’est pas nécessaire vu qu’on peut directement utiliser l’upstream.

Dans ce billet, nous présentons trois approches « sans synchro » :

  • Branches upstream locales, permettant de gérer les branches exactement comme on le ferait sur un dépôt mono-remote habituel.
  • Branches upstream fetch-only, permettant une gestion de branches minimaliste – uniquement des branches contributives, rien d’autre.
  • L’upfetch, un mix des deux autres basée sur une astuce git fetch.

Dans tous les cas, on ne synchronise jamais le fork, ce qui réduit l’intendance au niveau de la gestion de branches.

Article précédent Article suivant