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 😄
Deux types de branches sur un fork contributif :
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 ».
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
[...]
La plupart des guides recommandent de garder les branches miroirs d’un fork à jour avec l’upstream en utilisant la technique pull-push :
master
) depuis la branche upstream (e.g. upstream/master
).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.
Nous allons présenter trois approches sans synchro :
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
.
Pour qu’une branche existante traque la remote upstream
, on peut :
.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
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
.
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.
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
[...]
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
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.
Par rapport aux approches avec synchro, on a considérablement réduit l’intendance des branches
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 :
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
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 ! 😉
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)
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.
Technique fetch-create.
git pull
avant de créer une nouvelle branche.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.
Technique fetch-rebase.
git pull
avant de rebase.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.
Cette approche est minimaliste :
Pas non plus besoin de protection contre des push accidentels vers l’upstream.
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
.
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 ».
L’upfetch permet d’être très efficient comparé aux techniques précédentes :
git fetch upstream <REMOTE_BRANCH>
ne mettent pas à jour les branches locales.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
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.
Le meilleur des deux mondes :
Pareil que pour les branches upstream locales : nécessité d’empêcher les push accidentels vers l’upstream.
À 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 » :
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.