CI/CD & Ops

Git avancé

Rebase, cherry-pick, bisect, stratégies de branches.

Vérifié en septembre 2026 · outils aux versions citées dans le cours · environ 16 min

Git range un graphe de commits. Chaque commit est désigné par une empreinte calculée sur son contenu, son auteur et son committer, leurs dates, son message et ses parents ; une branche n'est qu'un pointeur déplaçable vers l'un d'eux. Aucune commande ne modifie donc un commit : rebase, cherry-pick ou fixup en créent de nouveaux et déplacent le pointeur, tandis que les anciens restent dans le dépôt jusqu'à ce que le ramasse-miettes les supprime. Ces deux faits expliquent tout ce cours : pourquoi réécrire une branche partagée fait des dégâts, et pourquoi presque rien n'est perdu localement. Les sorties viennent de Git 2.49.0 pour Windows, dans des dépôts de démonstration ; la dernière version, au 26 septembre 2026, est la 2.55.0, et les écarts qui touchent le cours sont signalés.

Fusion ou rebase

panier part de main, qui a avancé depuis. Si main n'avait pas bougé, git merge se contenterait d'avancer le pointeur : c'est l'avance rapide, « Fast-forward » dans la sortie de Git. Ici les deux lignes ont divergé, et la fusion crée un commit à deux parents qui les réunit. L'historique garde ce qui s'est réellement passé, et rien n'est réécrit.

$ git log --oneline --graph --all
* 58c6d1c (HEAD -> main) Ajoute la journalisation
| * 4ea7271 (panier) Calcule le total
| * 89cab72 Ajoute le panier
|/
* 99a4cc9 Ajoute Program.cs
$ git merge --no-edit panier
Merge made by the 'ort' strategy.
 Panier.cs | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 Panier.cs
$ git log --oneline --graph
*   26d87ca (HEAD -> main) Merge branch 'panier'
|\
| * 4ea7271 (panier) Calcule le total
| * 89cab72 Ajoute le panier
* | 58c6d1c Ajoute la journalisation
|/
* 99a4cc9 Ajoute Program.cs

git rebase main fait autre chose : il reprend les modifications de chaque commit de panier absent de main et les rejoue au-dessus de main. Le parent change, donc l'empreinte aussi : 89cab72 devient 5340792, même contenu, autre commit. La fusion qui suit est une avance rapide, et l'historique reste linéaire, plus simple à lire et à parcourir avec bisect.

# Même dépôt de départ, mais panier est rejouée sur main au lieu d'être fusionnée
$ git switch panier
Switched to branch 'panier'
$ git rebase main
Successfully rebased and updated refs/heads/panier.
$ git log --oneline --graph --all
* 102a762 (HEAD -> panier) Calcule le total
* 5340792 Ajoute le panier
* 58c6d1c (main) Ajoute la journalisation
* 99a4cc9 Ajoute Program.cs
$ git switch main
Switched to branch 'main'
$ git merge panier
Updating 58c6d1c..102a762
Fast-forward
 Panier.cs | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 Panier.cs

Un conflit arrête la fusion une fois, le rebase à chaque commit rejoué qui en provoque un : on résout, on indexe avec git add, puis git rebase --continue. git rebase --abort rend la branche telle qu'avant ; --skip abandonne le commit en cours.

Ne pas réécrire ce qui est partagé

Le livre Pro Git en fait une règle : ne pas rebaser des commits qui existent hors de son dépôt et sur lesquels d'autres ont pu bâtir. Après un rebase, la branche locale ne descend plus de la branche distante, et Git refuse de pousser. La tentation est alors --force, qui remplace la branche distante par la sienne sans regarder ce qu'elle contient.

Dans l'exemple suivant, Bob a poussé un commit sur panier pendant qu'Alice rebasait : le push forcé le fait disparaître du dépôt partagé, sans erreur ni avertissement.

# Alice a rebasé panier sur main ; Bob avait poussé « Ajoute la remise » sur panier
$ git rebase main
Successfully rebased and updated refs/heads/panier.
$ git push --force
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 12 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 314 bytes | 104.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To ../origine.git
 + 1d0aa08...9e517d0 panier -> panier (forced update)
$ git log --oneline origin/panier
9e517d0 (HEAD -> panier, origin/panier) Ajoute le panier
aeade65 (origin/main, main) Ajoute la journalisation
d829b85 Ajoute Program.cs

--force-with-lease n'écrase la branche distante que si elle pointe encore là où la branche de suivi locale, origin/panier, dit qu'elle pointe. La documentation de git push en donne la faille : tout ce qui lance un git fetch en arrière-plan met cette branche de suivi à jour, et le bail passe alors sans que rien n'ait été intégré. Après un tel fetch, un --force-with-lease seul efface la remise de Bob exactement comme --force. --force-if-includes, apparu dans Git 2.30, ajoute une condition : la pointe distante doit figurer dans le reflog de la branche locale, ou être atteignable depuis l'une de ses entrées, c'est-à-dire avoir été récupérée dans la branche avant d'être remplacée. Sans --force-with-lease, il n'a aucun effet.

$ git rebase main
Successfully rebased and updated refs/heads/panier.
$ git push --force-with-lease --force-if-includes
To ../origine.git
 ! [rejected]        panier -> panier (stale info)
error: failed to push some refs to '../origine.git'
# Un fetch (celui de l'IDE, par exemple) met origin/panier à jour sans rien intégrer
$ git fetch
remote: Enumerating objects: 4, done.
remote: Counting objects: 100% (4/4), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
Unpacking objects: 100% (3/3), 315 bytes | 35.00 KiB/s, done.
From ../origine
   82a6673..1d0aa08  panier     -> origin/panier
$ git push --force-with-lease --force-if-includes
To ../origine.git
 ! [rejected]        panier -> panier (remote ref updated since checkout)
error: failed to push some refs to '../origine.git'
hint: Updates were rejected because the tip of the remote-tracking branch has
hint: been updated since the last checkout. If you want to integrate the
hint: remote changes, use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
# Repartir de la branche partagée, qui contient la remise de Bob, et refaire le rebase.
# Valable ici parce que tout le travail d'Alice est déjà sur origin/panier ; sinon ses
# commits locaux seraient jetés (récupérables par le reflog, mais à recueillir d'abord).
$ git reset --hard origin/panier
HEAD is now at 1d0aa08 Ajoute la remise
$ git rebase main
Successfully rebased and updated refs/heads/panier.
$ git push --force-with-lease --force-if-includes
Enumerating objects: 7, done.
Counting objects: 100% (7/7), done.
Delta compression using up to 12 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (6/6), 534 bytes | 133.00 KiB/s, done.
Total 6 (delta 1), reused 0 (delta 0), pack-reused 0 (from 0)
To ../origine.git
 + 1d0aa08...c05566a panier -> panier (forced update)
$ git log --oneline
c05566a (HEAD -> panier, origin/panier) Ajoute la remise
c1ae253 Ajoute le panier
aeade65 (origin/main, origin/HEAD, main) Ajoute la journalisation
d829b85 Ajoute Program.cs

Sur main, que tout le monde tire, même le bail ne suffit plus : un commit publié s'annule par git revert, qui ajoute un commit inverse au lieu de retirer l'ancien. Les plateformes d'hébergement permettent d'interdire le push forcé sur les branches protégées, et c'est le réglage à garder pour la branche principale.

$ git revert --no-edit aeade65
[main fb5550b] Revert "Ajoute la journalisation"
 Date: Sat Sep 26 17:05:00 2026 +0200
 1 file changed, 1 deletion(-)
 delete mode 100644 Journal.cs
$ git push
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Delta compression using up to 12 threads
Compressing objects: 100% (1/1), done.
Writing objects: 100% (2/2), 262 bytes | 131.00 KiB/s, done.
Total 2 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To ../origine.git
   aeade65..fb5550b  main -> main
$ git log --oneline
fb5550b (HEAD -> main, origin/main) Revert "Ajoute la journalisation"
aeade65 Ajoute la journalisation
d829b85 Ajoute Program.cs

Le rebase interactif

git rebase -i main ouvre dans l'éditeur la liste des commits à rejouer, du plus ancien au plus récent, à l'inverse de git log. Chaque ligne est une commande : pick garde le commit, reword change son message, edit s'y arrête pour le modifier, squash le fond dans le précédent en réunissant les messages, fixup fait de même en gardant le seul message du précédent, drop le supprime, exec lance une commande. Déplacer une ligne change l'ordre des commits ; en effacer une perd le commit.

$ git log --oneline main..panier
823917d (HEAD -> panier) Ajoute la remise
3b9f125 Corrige une faute
ee96bed WIP
3615794 Calcule le total
9892d73 Ajoute le panier
$ git rebase -i main

Voici le début du fichier qu'ouvre l'éditeur ; une aide en commentaires suit. Depuis Git 2.50, le titre de chaque commit y est précédé d'un # : pick 9892d73 # Ajoute le panier.

pick 9892d73 Ajoute le panier
pick 3615794 Calcule le total
pick ee96bed WIP
pick 3b9f125 Corrige une faute
pick 823917d Ajoute la remise

# Rebase dd071db..823917d onto dd071db (5 commands)

On fond la correction de faute dans le commit qu'elle corrige, en la plaçant juste dessous, et on jette le « WIP » :

pick 9892d73 Ajoute le panier
pick 3615794 Calcule le total
fixup 3b9f125 Corrige une faute
drop ee96bed WIP
pick 823917d Ajoute la remise
# L'éditeur fermé, Git exécute la liste de haut en bas
Successfully rebased and updated refs/heads/panier.
$ git log --oneline main..panier
f6ac498 (HEAD -> panier) Ajoute la remise
7580a6e Calcule le total
9892d73 Ajoute le panier

9892d73 garde son empreinte : son parent n'a pas changé, et Git réutilise tel quel un commit qu'il n'a pas besoin de réécrire.

Réordonner à la main n'est pas nécessaire quand on sait d'avance quel commit on corrige. git commit --fixup=<commit> crée un commit intitulé fixup! <titre>, et --autosquash le place et le fond tout seul ; depuis Git 2.44, il fonctionne aussi sans -i. git rebase -x "dotnet test" main lance la commande après chaque commit rejoué et s'arrête au premier échec : un moyen de vérifier que chaque commit de la branche compile et passe les tests, pas seulement le dernier.

# La correction de « Calcule le total » est indexée : on la rattache à ce commit
$ git commit --fixup=21cc662
[panier 840a6e0] fixup! Calcule le total
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git log --oneline main..
840a6e0 (HEAD -> panier) fixup! Calcule le total
3323cf5 Ajoute la remise
21cc662 Calcule le total
3a79bd1 Ajoute le panier
$ git rebase --autosquash main
Successfully rebased and updated refs/heads/panier.
$ git log --oneline main..
f31d95f (HEAD -> panier) Ajoute la remise
1d75516 Calcule le total
3a79bd1 Ajoute le panier

cherry-pick

git cherry-pick applique sur la branche courante la modification qu'introduit un commit, sous la forme d'un nouveau commit. Le cas typique est le report d'un correctif sur une branche de maintenance. -x ajoute au message la référence du commit d'origine : sur une branche publiée, c'est la trace qui permet de savoir d'où vient le correctif. Une plage A..B cueille les commits de B qui ne sont pas dans A, et les conflits se traitent comme au rebase, avec --continue ou --abort.

$ git log --oneline main
142bc36 (HEAD -> main) Corrige l'arrondi de la TVA
2876dc5 Ajoute l'export CSV
4a31663 (tag: v1.2.0, release/1.2) Ajoute le calcul de TVA
$ git switch release/1.2
Switched to branch 'release/1.2'
$ git cherry-pick -x 142bc36
[release/1.2 dafee7d] Corrige l'arrondi de la TVA
 Date: Sat Sep 26 13:03:00 2026 +0200
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git log -1
commit dafee7d82aee1b87b06d35ec7a815737b8853ad5 (HEAD -> release/1.2)
Author: Alice <[email protected]>
Date:   Sat Sep 26 13:03:00 2026 +0200

    Corrige l'arrondi de la TVA

    (cherry picked from commit 142bc36a213a548235f51473accf4f3b77778c2f)

La même modification vit alors dans deux commits d'empreintes différentes. Le rebase le détecte : il compare le contenu des modifications, non les empreintes, et saute celle que la branche cible contient déjà.

# panier contient « Corrige le libellé de TVA » (3fba094), déjà cueilli sur main
$ git rebase main
warning: skipped previously applied commit 3fba094
hint: use --reapply-cherry-picks to include skipped commits
hint: Disable this message with "git config set advice.skippedCherryPicks false"
Successfully rebased and updated refs/heads/panier.

Le cherry-pick sert pour un commit isolé. Pour ramener toute une branche, la fusion ou le rebase gardent l'historique cohérent, là où des cueillettes répétées multiplient les copies.

bisect

Entre un commit connu bon et un commit connu mauvais, git bisect cherche par dichotomie le premier commit fautif : chaque test divise par deux le nombre de candidats, et dix tests suffisent pour mille commits. On déclare git bisect start, puis git bisect bad et git bisect good v1.0.0 — ou les deux d'un coup, le mauvais d'abord —, et Git extrait à chaque étape un commit à juger par good, bad ou skip. git bisect reset ramène là où l'on était.

git bisect run automatise le jugement par le code de sortie d'une commande : 0 pour bon, de 1 à 127 pour mauvais, sauf 125, qui demande de sauter le commit ; tout autre code arrête la recherche. Lancer directement les tests paraît naturel, mais dotnet test rend 1 quand la compilation échoue comme quand un test échoue : un commit qui ne compile pas est jugé mauvais, et la recherche accuse le mauvais commit.

$ git bisect start HEAD v1.0.0
$ git bisect run dotnet test
# ... la sortie de chaque dotnet test, puis :
3dbeb39cc1e84a6cc40b9c627d064ae850b43d04 is the first bad commit
# « Commence le refactoring du panier » : il ne compile pas (CS1513),
# et le taux de TVA n'y a pas encore changé

Le script distingue les deux cas, et ne lance que le test du symptôme : un autre test cassé ailleurs dans l'historique détournerait la recherche vers son propre coupable.

../bisect.sh
#!/bin/sh
# Lancé par « git bisect run sh ../bisect.sh » : sans sh, un script non exécutable
# (chmod +x oublié sous Linux ou macOS) rend 126 et arrête la recherche.
# À garder hors du dépôt : un fichier suivi changerait à chaque commit testé.
# Ne compile pas : ce commit ne dit rien du bogue, 125 demande de le sauter.
dotnet build -c Release > /dev/null 2>&1 || exit 125
# Compile : seul le test du symptôme recherché décide, pas les autres.
dotnet test -c Release --no-build --filter "FullyQualifiedName~TvaTests" > /dev/null 2>&1 || exit 1
exit 0
$ git bisect start HEAD v1.0.0
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[3dbeb39cc1e84a6cc40b9c627d064ae850b43d04] Commence le refactoring du panier
$ git bisect run sh ../bisect.sh
running 'sh' '../bisect.sh'
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[a3a905aa551b6246bb24e416d25465100c29ae4a] Termine le refactoring du panier
running 'sh' '../bisect.sh'
Bisecting: 1 revision left to test after this (roughly 1 step)
[d4120bbfa937410264393e69629008566d77532b] Ajoute la journalisation
running 'sh' '../bisect.sh'
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[06e40caf48e59ddd4948f0b11e8424692168c7cb] Extrait le taux de TVA en constante
running 'sh' '../bisect.sh'
06e40caf48e59ddd4948f0b11e8424692168c7cb is the first bad commit
commit 06e40caf48e59ddd4948f0b11e8424692168c7cb
Author: Alice <[email protected]>
Date:   Sat Sep 26 14:07:00 2026 +0200

    Extrait le taux de TVA en constante

 Tva.cs | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)
bisect found first bad commit
$ git bisect reset
Previous HEAD position was 06e40ca Extrait le taux de TVA en constante
Switched to branch 'main'

Le commit qui ne compile pas a été sauté, et la recherche aboutit au changement de taux. Sauter a une limite : si le coupable jouxte des commits sautés, Git ne peut plus trancher et donne la liste des candidats possibles.

Le reflog pour récupérer

Le reflog note chaque déplacement local de HEAD et de chaque branche : commit, reset, rebase, changement de branche. git reflog affiche celui de HEAD, et HEAD@{1} désigne la position précédente. Un reset --hard de trop ou un rebase raté s'annulent en revenant à l'entrée d'avant l'erreur.

$ git reset --hard HEAD~2
HEAD is now at 219d31d Ajoute le panier
$ git reflog
219d31d (HEAD -> panier) HEAD@{0}: reset: moving to HEAD~2
856d9d5 HEAD@{1}: commit: Ajoute la remise
ac78f38 HEAD@{2}: commit: Calcule le total
219d31d (HEAD -> panier) HEAD@{3}: commit: Ajoute le panier
c34e167 (main) HEAD@{4}: checkout: moving from main to panier
c34e167 (main) HEAD@{5}: commit (initial): Ajoute Program.cs
$ git reset --hard HEAD@{1}
HEAD is now at 856d9d5 Ajoute la remise
$ git log --oneline
856d9d5 (HEAD -> panier) Ajoute la remise
ac78f38 Calcule le total
219d31d Ajoute le panier
c34e167 (main) Ajoute Program.cs

Une branche supprimée emporte son propre reflog, mais celui de HEAD garde ses commits, et git branch -D affiche lui-même l'empreinte de la pointe. Après un rebase, un reset ou une fusion, ORIG_HEAD désigne aussi la pointe d'avant.

$ git switch main
Switched to branch 'main'
$ git branch -D panier
Deleted branch panier (was 856d9d5).
$ git reflog -3
c34e167 (HEAD -> main) HEAD@{0}: checkout: moving from panier to main
856d9d5 HEAD@{1}: reset: moving to HEAD@{1}
219d31d HEAD@{2}: reset: moving to HEAD~2
$ git branch panier 856d9d5
$ git log --oneline -1 panier
856d9d5 (panier) Ajoute la remise

Le reflog a trois limites. Il est local : il ne se pousse pas, et celui d'un collègue n'est pas le vôtre. Ses entrées expirent, par défaut après 90 jours, et après 30 jours pour celles qui ne sont plus atteignables depuis la pointe de la branche (gc.reflogExpire et gc.reflogExpireUnreachable) ; le ramasse-miettes peut ensuite supprimer les commits. Enfin, il ne connaît que les commits : une modification jamais indexée qu'un reset --hard écrase est perdue, alors qu'un contenu indexé survit comme objet orphelin, que git fsck --lost-found retrouve. Dans Windows PowerShell 5.1, HEAD@{1} s'écrit entre apostrophes : sans elles, les accolades sont lues comme un bloc de script, et Git reçoit HEAD@ suivi d'arguments qu'il ne connaît pas.

Stratégies de branches

Une stratégie de branches dit combien de branches vivent longtemps et comment le travail rejoint la production. Trois modèles dominent.

ModèleBranches durablesBranches de travailConvient à
Trunk-basedle tronc seul (plus d'éventuelles branches de version)deux jours au plus, ou commit direct sur le tronclivraison continue, équipe qui intègre plusieurs fois par jour
GitHub flowla branche par défautune par changement, fusionnée par pull request puis suppriméeune seule version en production, déployée souvent
GitFlowmaster et developfeature, release-*, hotfix-*versions numérotées, plusieurs versions maintenues en parallèle

Le développement basé sur le tronc, tel que le décrit le site de Paul Hammant, fait travailler toute l'équipe sur une seule branche : une branche de travail ne doit pas vivre plus de deux jours, une fonction inachevée entre dans le tronc cachée derrière un drapeau de fonctionnalité, et une branche de version, si elle existe, est coupée au dernier moment. GitHub flow tient en six étapes dans la documentation de GitHub : créer une branche, commiter, ouvrir une pull request, traiter la revue, fusionner, supprimer la branche.

GitFlow, publié par Vincent Driessen en 2010, garde deux branches permanentes : master, toujours prête pour la production, et develop. Les fonctions partent de develop et y reviennent ; une branche de version part de develop et revient dans les deux ; un correctif part de master et revient dans les deux, ou dans la branche de version en cours plutôt que dans develop s'il en existe une ; chaque version est étiquetée sur master. Le 5 mars 2020, l'auteur a ajouté en tête de l'article une note : il n'avait pas en vue les applications web, livrées en continu, sans plusieurs versions à maintenir, et à une équipe en livraison continue il conseille un flux bien plus simple, comme GitHub flow.

# GitFlow : un correctif part de la branche de production et revient dans les deux
# (master dans l'article de 2010 ; beaucoup de dépôts l'appellent aujourd'hui main)
git switch -c hotfix-1.2.1 master
git commit -am "Corrige l'arrondi de la TVA"
git switch master
git merge --no-ff hotfix-1.2.1   # --no-ff : un commit de fusion, même si l'avance rapide suffisait ;
                                 # dans un terminal, l'éditeur s'ouvre pour son message
git tag -a v1.2.1 -m "Version 1.2.1"
git switch develop                # ou la branche de version en cours, s'il y en a une
git merge --no-ff hotfix-1.2.1
git branch -d hotfix-1.2.1

Les plateformes proposent en outre plusieurs façons de fusionner une pull request : commit de fusion, rebase, ou squash, qui réduit la branche à un seul commit. Le squash donne un commit par changement, facile à annuler par revert et à isoler par bisect, mais les commits de la branche ne sont pas dans main : Git ne la considère pas comme fusionnée, et ne la supprime qu'avec -D. Continuer la branche après un squash mène souvent aux conflits : rebasée, elle rejoue ses anciens commits sur un main qui les contient déjà ; fusionnée, elle heurte le commit de squash dès que le nouveau travail touche les mêmes lignes. Après la fusion, on repart de main. La validation de la pull request par le pipeline est vue dans les cours Azure DevOps et GitLab CI.

$ git merge --squash panier
Updating fa1e4d3..32e7518
Fast-forward
Squash commit -- not updating HEAD
 Panier.cs | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 Panier.cs
$ git commit -m "Ajoute le panier (#42)"
[main 4066011] Ajoute le panier (#42)
 1 file changed, 2 insertions(+)
 create mode 100644 Panier.cs
$ git branch -d panier
error: the branch 'panier' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D panier'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
Ce cours vous a servi ? Offrir un café Signaler une erreur