Lorsque vous réécrivez l’historique distant sous examen, il est tentant de considérer l’option --force-with-lease comme un verrou, protégeant le commit que vous avez validé. En réalité, cette option repose sur une comparaison de l’état actuel du dépôt et non sur une revalidation sémantique du contenu collaboratif. Sans précaution, un simple git fetch peut faire diverger la référence locale de suivi (origin/topic) et le commit examiné, tandis que l’explicitation de l’OID attendu lie uniquement le contrôle à la référence distante, et ne garantit pas que le travail des contributeurs adjacents (par exemple le contenu de bob.txt) soit maintenu. Cet article s’adresse aux mainteneurs experts en contrôle de version Git qui examinent une réécriture délibérée de branche et qui souhaitent assumer que le commit approuvé ne sera pas écarté.
Dans un environnement contrôlé, composé d’un dépôt bare local et de deux clones jetables (Alice et Bob), avec configuration isolée et chemins de fichiers locaux, nous construisons un scénario de base (O) suivi d’une série de commits. Alice crée un commit A, Bob en crée un B divergent, puis Alice réécrit A en A′. On montrera qu’un git push --force-with-lease implicite (sans précision d’<expect>) est d’abord rejeté (car le serveur est à B), puis accepté après un git fetch (après quoi origin/topic local suit B). En revanche, expliciter l’<expect> avec A ou B change le résultat : --force-with-lease=topic:<A> échoue après le fetch (serveur toujours à B), tandis qu’--force-with-lease=topic:<B> permet le push incomplet (perte de bob.txt), démontrant que la comparaison d’OID ne remplace pas une relecture de contenu. Nous reconstruisons alors un candidat C qui inclut explicitement le changement de Bob (bob.txt) sur la nouvelle base A′, et vérifions que sa validation explicite (expected=B) aboutit correctement. En revanche, si le dépôt distant avance à D (une suite de B), le push de C avec B comme attendu est rejeté.
Dans les tests suivants, chaque commande est détaillée : arguments précis, codes de sortie, et messages humains (porcelain) s’apparentant à la sortie réelle de Git. Le commit revu (A avant modification) est pris comme référence immuable, séparé de la référence de suivi mutante. Nous utilisons git rev-parse --verify --end-of-options pour résoudre explicitement le commit attendu. Le manuel git-push souligne que --force-with-lease=<ref>:<expect> vérifie que l’OID actuel de la ref distante est égal à <expect>. Il distingue aussi la forme sans :<expect> (qui se réfère au suivi distant) et avertit qu’un fetch en arrière-plan peut invalider ce mécanisme. De même, git-fetch met à jour le suivi par défaut (p. ex. +refs/heads/*:refs/remotes/origin/*) même sans revoir les contenus. Chaque verdict (ACCEPT, REPAIR, HOLD, REBUILD) se fonde strictement sur l’état observé et documenté de l’origin et du contenu examiné. Nous couvrons l’acceptation du push autorisé, le besoin de REPAIR sémantique du wrapper de push, le maintien HOLD pour les cas non validés ou les situations de course, ou le REBUILD du candidat pour corriger le contenu dupliqué. Enfin, nous produisons un manifeste de vérification : versions, OID, configuration, commandes exécutées et résultats.
Identify the commit the push is allowed to replace
Le premier pas est de distinguer trois OID :
OID_revu : l’empreinte du commit examiné sur la branche distante (avant réécriture).
OID_suivi : l’empreinte de la branche distante dans la référence de suivi locale (origin/topic).
OID_candidat : l’empreinte du commit proposé pour remplacer le précédent, que l’on a prévu en examen.
Avant tout, rappelons qu’une validation collaborative normale consiste à fusionner/rebaser sans effacer les commits des autres. Nous ne traitons pas un scénario de travail isolé (e.g., rebase local avant push) mais un « passage de témoin » où la branche distante va être réécrite après examen. Il ne s’agit pas d’un conseil d’utiliser --force en production : ici, l’usage est contrôlé et limité à valider explicitement un commit spécifique.
En pratique, imaginons :
Le dépôt origin (bare) a une branche refs/heads/topic à l’instant de l’examen. On enregistre son commit OID_revu.
Alice prévoit de réécrire cette branche avec un commit candidat (OID_candidat), par exemple un amendement signant un changement.
Entre-temps, un autre contributeur (Bob) a poussé une nouvelle tête B (OID_B) qui diverge.
Le suivi local d’Alice (origin/topic) peut rester sur l’ancien commit OID_revu, ou être mis à jour, selon les opérations de fetch.
La subtile erreur est de considérer que tant qu’Alice a révisé OID_revu, alors l’option --force-with-lease sans plus de précision fera « le bon choix ». En fait, sans la forme :<expect>, Git compare par défaut avec l’OID_suivi. Or OID_suivi peut ne pas être égal à OID_revu après un fetch automatique (cf. ci-après). D’où l’importance de « fixer » la référence attendue sur le commit réellement examiné, et non sur la copie volatile du serveur.
Dans cet article, nous supposons qu’Alice a déjà lu OID_revu et l’a consigné (par exemple, dans notre script de test). Elle ne se fie pas aux informations qui bougent automatiquement. Nous ne proposons pas que l’utilisateur écrase sans condition la branche distante ; au contraire, nous mettons en œuvre un « gate » (balise) explicite qui valide ou invalide chaque push. Chaque mise à jour s’accompagne de la lecture du serveur (git ls-remote) et d’une vérification du contenu appliqué, en accord avec les invariants déclarés.
Voir les articles sur la collaboration Git de tous les jours et sur Git en data engineering pour les concepts de base sur les branches et commits en équipe. Notre objectif ici est plus étroit : lier le push autorisé au commit exact passé en revue.
Isolate a bare origin and two disposable clones
Pour expérimenter de manière reproductible et sans effets de bord, nous créons un dépôt origin local en mode bare, et deux clones nommés Alice et Bob. Chaque clone aura sa propre configuration Git isolée de l’environnement global. Par exemple :
$ mkdir repo
$ cd repo
$ git init --bare origin.git # dépôt nu sur le disquePuis nous clonerons en deux répertoires distincts :
$ git clone --no-hardlinks origin.git Alice
$ git clone --no-hardlinks origin.git BobLes options --no-hardlinks garantissent que tous les objets sont indépendants. Nous activons dans chaque clone un core.bare=false normal, configurons un nom d’utilisateur factice et désactivons les prompts d’identification :
$ cd Alice
$ git config user.name "Alice"
$ git config user.email "[email protected]"
$ git config credential.helper "" # aucun prompt
$ git config push.default simple
$ cd ../Bob
$ git config user.name "Bob"
$ git config user.email "[email protected]"
$ git config credential.helper ""
$ git config push.default simpleOn enregistre la version de Git et le format d’objet utilisé :
$ git --version
git version 2.47.3
$ git var GIT_OBJECT_FORMAT
sha256(Les sorties peuvent varier ; cet article suit Git 2.47.3 par cohérence.) Le dépôt origin a pour remote son URL locale, et contient désormais une branche par défaut (par ex. main), mais nous allons créer notre propre branche topic. Vérifions le comportement de push par défaut :
$ cd Alice
$ git config --show-origin push.default
file:.git/config simpleAinsi par défaut, git push origin topic pousse topic et met à jour origin/topic. C’est la configuration attendue.
Remove ambient configuration from the experiment
Il est important que rien d’extérieur n’interfère. Nous n’utilisons aucun dépôt issu d’un environnement précédent, ni variable d’environnement ou config globale. En plus de credential.helper ci-dessus, on peut préciser :
$ git config --global --unset credential.helper 2>/dev/null
$ git config --system --unset credential.helper 2>/dev/nullPuisque chaque clone utilise son propre.git/config, les valeurs locales prévalent. Le fetch distant d’origin (configuré par défaut) est normalement +refs/heads/*:refs/remotes/origin/*. Pour contrôler, on peut afficher :
$ git config --get remote.origin.fetch
+refs/heads/*:refs/remotes/origin/*Cette refspec autorise le fetch non-fast-forward pour les branches (le +). Elle garantit que git fetch origin mettra à jour la référence locale refs/remotes/origin/topic vers le dernier commit sur origin/topic (en cours). Aucune autre automatisation (hook, alias) n’est utilisée. L’idée est de reproduire fidèlement le cas où un fetch en arrière-plan pourrait mettre à jour le suivi, comme décrit dans le manuel git-push.
Create O, A, B and the rewritten A-prime
Nous construisons ensuite l’historique initial. Par exemple dans Alice :
$ cd Alice
$ git checkout -b topic # nouvelle branche topic
$ echo "hello world" > alice.txt
$ git add alice.txt
$ git commit -m "A: add alice.txt"
# On note l'OID de ce commit dans A_OIDSupposons que Git calcule un OID (SHA) pour A (nous le notons A_OID). On pousse ce commit :
$ git push origin topic:refs/heads/topicLe dépôt origin a maintenant une branche topic dont la valeur est A_OID. Nous retiendrons A_OID comme l’OID_revu avant avancement de Bob. Pour la robustesse de notre test, on enregistre immédiatement cet OID (par exemple dans un script). Dans le dépôt origin, nous conservons A_OID sous le nom interne refs/heads/topic. Ensuite :
$ cd ../Bob
$ git pull origin topic # pour synchroniser le travail, Bob a A
$ echo "preserve me" > bob.txt
$ git add bob.txt
$ git commit -m "B: add bob.txt"
# OID de ce commit = B_OID
$ git push origin topic:refs/heads/topicMaintenant, origin/topic = B_OID. Nous conservons également B_OID en mémoire. Pour Bob, B_OID est le nouveau tip. Sur la copie d’Alice, avant tout fetch, origin/topic vaut toujours A_OID (son suivi n’a pas encore bougé).
Pour protéger le commit de Bob des manipulations destructrices ultérieures, nous créons une ref de sécurité locale dans origin (bare) :
$ cd ../origin
$ git update-ref refs/heads/safety <B_OID> # ancre B sur une branche safetyIci, update-ref avec deux arguments (nouveau, ancien) vérifie si l’état actuel correspond ; nous faisons en sorte que refs/heads/safety soit créée avec B_OID sans condition car elle n’existait pas. Si besoin, on aurait pu écrire git update-ref refs/heads/safety <B_OID> 0000000000000000000000000000000000000000.
La table suivante récapitule l’état :
Label | OID | Parent | Description |
O (base) | (aucune) | – | parent initial, vide |
A | A_OID | O | commit Alice original (alice.txt) |
B | B_OID | O | commit Bob (bob.txt) |
A′ | Aprime_OID | A_OID | A amendé par Alice (nouveau alice.txt) |
C | C_OID | Aprime_OID | futur candidat combinant A′ + B |
La ref refs/heads/topic sur origin était à A_OID, puis à B_OID. Nous avons enregistré A_OID (revu) et mis B_OID en sécurité. Jusqu’ici, A′ n’existe que dans Alice et n’a pas encore été poussé.
(Voir aussi conceptuellement « contrôle de version pour un travail reproductible » : on prépare une base propre sur origin/topic avant modifications.)
Prove the implicit lease rejects before a fetch
À présent, simulons la tentative d’Alice de pousser A′ avec --force-with-lease implicite, avant de faire un fetch. La configuration courante est : origin/topic (serveur) = B_OID, mais Alice croit toujours son suivi à A_OID. Elle exécute :
$ cd Alice
$ git add alice.txt
$ git commit --amend -m "A': modified alice.txt"
# note Aprime_OID from output
$ git push origin Aprime_OID:refs/heads/topic --force-with-leaseOu, plus simplement (HEAD étant à A′) :
$ git push origin topic:topic --force-with-leaseComme B_OID ≠ A_OID (valeur du suivi d’Alice), Git refuse la mise à jour. On observe typiquement :
To /path/to/origin.git
! [rejected] topic -> topic (non-fast-forward)
error: failed to push some refs to '/path/to/origin.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Merge the remote changes (e.g. 'git pull')
hint: before pushing again.Il s’agit d’une sortie typique de Git 2.x, ici raccourcie. La ligne clé est le [rejected] (non-fast-forward) : Git refuse car la branche distante a évolué. L’option --force-with-lease n’a pas d’<expect> explicite, donc elle exige que le suivi local (origin/topic = A_OID chez Alice) corresponde à la tête distante. Ce n’est pas le cas (A_OID ≠ B_OID), donc le push échoue avec code de sortie non-zéro. Le dépôt origin reste à B_OID. Nous avons ainsi validé que sans fetch préalable, l’option implicite avec --force-with-lease stoppe correctement la réécriture réclamée (loi de comparateur d’état).
Pour précision, aucune autre branche n’a bougé. Cet échec suffit à qualifier le test : le dépôt distant refuse l’opération, et A′ n’est pas appliqué. Il n’y a pas de lecture supplémentaire du serveur après l’échec (Git ne garantit pas de rafraîchir origin/topic à chaque tentative). Nous conservons B_OID dans la branche topic du dépôt bare.
Advance the tracking ref without changing the saved review
Ensuite, nous faisons le fetch que Git a supposé : Alice exécute :
$ cd Alice
$ git fetch originPuisque la configuration remote.origin.fetch est +refs/heads/*:refs/remotes/origin/*, Git interroge le serveur. Le serveur indique refs/heads/topic → B_OID. Alice met donc à jour sa référence refs/remotes/origin/topic vers B_OID, mais notez bien : son commit examiné A_OID n’est jamais modifié. C’est seulement le pointeur de suivi qui a avancé. Autrement dit, avant le fetch, [OID_revu=A_OID, OID_suivi=A_OID]. Après fetch, [OID_revu=A_OID, OID_suivi=B_OID].
Ce point est crucial : le simple fait de récupérer la branche distante met à jour la donnée de suivi sans réviser le résumé ou la légitimité de A_OID. Le manuel git-fetch explique que, sans argument de refspec sur la ligne de commande, les refspecs config sont utilisées pour déterminer quelles branches locales mettre à jour (le contenu téléchargé, par exemple B_OID pour topic, est enregistré dans refs/remotes/origin/topic). En résumé, Alice voit maintenant que le serveur est à B_OID, mais elle continue de considérer A_OID comme le commit qu’elle a validé et accepté.
A fetch receipt is not a review of collaborator changes
Cette mise à jour mécanique ne doit pas être interprétée comme une revalidation du contenu. Alice n’a pas examiné le patch de Bob : elle a simplement pris note que sa branche origin/topic a avancé à B_OID. Le fetch n’implique aucun examen humain ou automatique du diff du commit B. Il s’agit uniquement d’une synchronisation d’état. Autrement dit, B_OID n’est pas approuvé par Alice ; il est simplement visible. L’« approbation » écrite précédemment reste celle de A_OID (commit examiné). C’est la raison pour laquelle nous conservons A_OID dans notre preuve d’approbation (et n’autorisons pas de remplacement silencieux de l’attendu par B_OID).
C’est ici l’occasion de souligner que l’option --force-with-lease dans sa forme implicite repose sur le suivi local (équivalent à --force-with-lease=topic), ce qui, comme l’avertit la documentation de git-push, « interagit très mal » avec un fetch d’arrière-plan. En effet, Git se fie à refs/remotes/origin/topic pour décider si le push force est sûr. On le voit : nous venons de rendre la forme implicite susceptible de réussir (tracking=B_OID), alors que la forme explicite :<A_OID> continue de l’interdire.
En pratique, cela signifie que le champ examiné reste inchangé jusqu’à ce que l’utilisateur l’altère explicitement. Ce n’est qu’après examen conscient que l’on devrait bouger le marqueur d’approbation. Ce principe sera la base de notre décision : la valeur de expected (ancien OID) ne doit être mise à jour qu’avec l’accord de la révision.
Observe the implicit lease accept the incomplete candidate
Après le fetch, la configuration est : origin/topic distant = B_OID, et le suivi local aussi = B_OID, tandis que l’expected examiné = A_OID. Maintenant, sans changer la branche de travail (A' reste HEAD d’Alice), on relance le même push :
$ git push origin topic:refs/heads/topic --force-with-leaseComme --force-with-lease sans argument utilise par défaut la correspondance avec la référence de suivi locale, et que les deux valent désormais B_OID, Git considérera le lease validé. Le push est alors accepté et force la branche distante à Aprime_OID. Autrement dit, origin/topic passe de B_OID à Aprime_OID.
La sortie du push (Git 2.x) est typiquement la suivante :
To /path/to/origin.git
+ B_OID...Aprime_OID topic -> topic (forced update)Ligne clé : un « + » indique qu’on a forcé l’update (les OID ont changé de B_OID à Aprime_OID). Aucun rejet. L’option --force-with-lease a permis l’opération car B_OID correspondait au suivi local. Le serveur contient maintenant refs/heads/topic = Aprime_OID. Le commit B original existe toujours dans la branche safety (ou du moins comme historique, car nous ne l’avons pas effacé, et l’objet n’est pas garbage-collecté d’office).
Pour confirmer l’état du serveur, on peut exécuter :
$ cd origin
$ git ls-remote origin.git refs/heads/topicSortie attendue au format décrit par git-ls-remote (<oid><tab><ref>) :
Aprime_OID refs/heads/topic
(Et origin.git indique le dépôt bare, ou on peut omettre et utiliser le chemin). On vérifie aussi que le fichier bob.txt n’est plus présent à la tête de la branche :
$ git clone origin.git Temp && cd Temp
$ git ls-tree HEAD
100644 blob <oid_alice> alice.txt(On voit alice.txt (modifié) mais pas bob.txt.) L’assertion est claire : la branche distante a été remplacée par A′, mais aucune copie de bob.txt n’a survécu dans le commit A′. Nous ne dirons pas que Git a « supprimé toutes les traces de B_OID » : l’objet existe toujours (surtout grâce à la ref safety), mais du point de vue de refs/heads/topic, Bob a perdu son fichier. Cela démontre que malgré le succès du push, l’intention initiale de préserver le travail collaboratif n’a pas été respectée.
Cette étape montre la différence essentielle : Git fait un compare-and-swap sur les OID (ici B_OID attendu devient Aprime_OID), sans vérifier le contenu des commits intermédiaires. Le suivi implicite a « verrouillé » l’accès à B_OID, mais n’a aucun moyen d’exiger la conservation du contenu de B_OID. Ce sera le rôle de notre logique de relecture (« content gate ») de refuser un tel résultat si le fichier de Bob était jugé nécessaire.
Pin the explicit expected OID and retest after fetch
Nous revenons maintenant à l’état initial (avant le dernier push) : dans origin, refs/heads/topic = B_OID. La référence de sécurité safety contient B, mais nous allons remettre la branche topic à B_OID. Supposons que origin est déjà à B. Alice va tenter un push A′ en exigeant explicitement l’ancien OID A_OID :
$ cd Alice
$ git reset --hard A_OID # revenir au commit original A si nécessaire
$ git cherry-pick Aprime_OID # recréer A' sur cette base (ou refaire amend)
$ # maintenant HEAD à A'
$ git push origin topic:refs/heads/topic --force-with-lease=refs/heads/topic:A_OIDLa syntaxe --force-with-lease=refs/heads/topic:<A_OID> signifie « n’autorise le remplacement de topic que si le serveur y est A_OID ». Ici, le serveur est B_OID, donc on s’attend à un rejet. La sortie typique sera similaire au cas implicite initial :
To /path/to/origin.git
! [rejected] topic -> topic (stale (expected A_OID))
error: failed to push some refs to '/path/to/origin.git'Git indique que A_OID est dépassé. Le résultat est le même : code de sortie 1, pas de modification sur le serveur. Ce résultat concorde avec le manuel git-push : --force-with-lease=<ref>:<expect> bloque si expect ne correspond pas au serveur. Ici expect=A_OID ne correspond pas (serveur=B_OID), donc le push est refusé. Même après le fetch précédent, expliciter <A_OID> reste obsolète. Cette expérience confirme qu’un <expect> explicite incorrect ne permet aucune mise à jour.
Reject empty, unresolved or substituted expectations
Dans notre wrapper de push, nous devons éviter plusieurs pièges. Premièrement, l’option --force-with-lease sans <expect> ou avec <expect> vide n’est pas ce qu’on veut : --force-with-lease=topic: signifierait « la ref ne doit pas exister », ce qui n’est pas notre cas. Nous exigeons toujours une valeur non vide, validée en commit Git.
Avant d’exécuter un push, le script doit résoudre et figer l’OID attendu approuvé. On fera par exemple :
expected=$(git rev-parse --verify --end-of-options $OID_revu^{commit})La forme ^{commit} assure que l’on parle bien d’un commit (évite de pousser un tag ou autre), et --end-of-options empêche qu’une valeur commençant par - soit mal interprétée. L’exemple de la documentation de git-rev-parse indique précisément que :
$ git rev-parse --verify --end-of-options $REV^{commit}This will error out if $REV is empty or not a valid revision.
Ainsi, un $REV vide (aucune révision fournie) provoque une erreur avant d’appeler git push. Nous devons donc relever d’abord l’erreur si l’OID attendu n’est pas valide et stopper l’opération. Ne jamais pousser avec un <expect> vide ou recalculé à partir de l’état courant.
Deuxièmement, notre wrapper ne doit pas substituer l’OID attendu après chaque tentative. Si le premier essai est rejeté, le script ne doit pas faire automatiquement git fetch ni mettre à jour <expected> pour retenter. Sinon, on retournerait à l’option implicite piège (recalculant <expected> sur le suivi). Au contraire, un rejet entraîne une décision de type HOLD : on arrête et notifie le besoin d’une révision manuelle.
Enfin, il ne faut pas confondre la valeur d’attendu dans la ligne de commande (ici passée littéralement comme octets hexadécimaux) et un alias comme topic. D’où l’importance de résoudre avant les noms symboliques. Par exemple, au lieu de :
git push origin topic:topic --force-with-lease=topic:<expect-name>il faut nommer explicitement la ref distante complète :
--force-with-lease=refs/heads/topic:$expectedafin d’éviter des raccourcis ambigus. À chaque étape, nous assurons que $expected est l’empreinte hexadécimale complète du commit approuvé, et que $candidate est aussi figé en OID une fois les contenus validés.
Show what a matching explicit lease still cannot prove
Pour tester la force de l’hypothèse « l’explicitation d’un OID suffit à prouver la validité du contenu », observons le cas où on s’attend parfaitement à la valeur courante du serveur, mais que le contenu du candidat est incomplet. Nous repartons de l’état origin/topic = B_OID, et cette fois Alice pousse toujours A′ en déclarant B_OID comme attendu :
$ git reset --hard Aprime_OID # HEAD à A'
$ git push origin topic:refs/heads/topic --force-with-lease=refs/heads/topic:B_OIDIci, <expect>=B_OID correspond exactement à l’OID_suivi actuel (et au serveur), donc Git le reconnaît comme valide et autorise le push. Il n’y a même pas de « + forced », puisque la valeur attendue coïncidait ; mais l’update s’effectue quand même (parce que HEAD diffère). En résultat :
To /path/to/origin.git
B_OID..Aprime_OID topic -> topicCe push est « techniquement permis ». Le dépôt origin passe de B_OID à Aprime_OID, même si Aprime_OID n’inclut pas bob.txt. Cette comparaison d’OID est passée, comme prévu. Mais Bob a perdu son fichier sans que Git n’émette d’erreur, simplement parce que le contenu examiné n’était pas complet.
Let the content gate reject a technically permitted push
Notre logigramme de revue doit alors réagir : bien que Git ait accepté la mise à jour, le résultat est inacceptable du point de vue de la préservation de l’état collaboratif voulu. Nous avons donc un cas de HOLD. Concrètement, on examine le contenu du candidat Aprime_OID appliqué sur la branche distante :
$ cd origin
$ git ls-remote origin.git refs/heads/topicLa commande donne Aprime_OID refs/heads/topic. Puis :
$ git cat-file -p Aprime_OID^{tree} # pour lister les fichiers du commitOn vérifie, manuellement ou par script, que bob.txt est absent. Ce contrôle de contenu (« content gate ») est indépendant du lease et reflète la spécification de la revue collaborative. En cas de non-conformité (ici bob.txt manquant), la procédure indique que cette réécriture doit être tenue en attente. Contrairement à Git, le flux d’examen ne considère pas cette situation comme un succès.
Ce cas souligne que l’autorisation de Git n’équivaut pas à la validation du contenu final. Il faut donc envisager soit de reconstruire le commit candidat (cf. section suivante), soit d’imposer des checks additionnels (par exemple des hooks de serveur ou des revues de code complémentaires) pour détecter de tels écarts. Mais, fidèles à la portée des outils Git natifs, nous considérons que --force-with-lease ne garantit rien sur les changements sémantiques, seulement sur la correspondance d’OID. Nous rendons compte de cet échec de revue comme une décision HOLD dans notre playbook.
Rebuild and review the candidate before granting a new attempt
Pour réparer la branche topic en préservant le travail des deux collaborateurs, nous allons construire un commit C qui combine A′ et B. Par exemple, dans Alice :
$ cd Alice
$ git fetch origin # pour s'assurer que nous avons B (origin/topic = B_OID)
$ git checkout -b fix-topic Aprime_OID
$ git cherry-pick B_OIDIci, git cherry-pick B_OID applique le changement de B (ajout de bob.txt) sur la base d’Aprime_OID. On obtient un nouveau commit C (C_OID), parent Aprime_OID, contenant à la fois alice.txt (mis à jour) et bob.txt. On vérifie :
$ git show C_OID:alice.txt # contient "hello world" modifié
$ git show C_OID:bob.txt # contient "preserve me"Le commit B n’est pas parent de C, mais le contenu attendu est présent dans C. Nous l’avons explicitement revu : Alice a vu le contenu de bob.txt et s’est assurée qu’il correspond. C’est ce qui compte, non l’ancêtre effectif du commit. Nous verrouillons ensuite C_OID en tant que candidat approuvé.
Maintenant, on remet l’état du serveur sur B_OID (si ce n’est déjà fait). Puis nous tentons le push approuvé :
$ git push origin fix-topic:refs/heads/topic --force-with-lease=refs/heads/topic:B_OIDCette fois, <expect>=B_OID correspond au serveur actuel, et le candidat C_OID contient bob.txt comme souhaité. Git pousse C_OID sur refs/heads/topic et la branche topic sur origin devient C_OID. Le dépôt distant est à jour avec le commit de fusion de A′ et B. La sortie ressemble à :
To /path/to/origin.git
+ B_OID..C_OID topic -> topic (forced update)Le + indique le forced push. Nous confirmons ensuite avec git ls-remote :
C_OID refs/heads/topicEt vérifions le contenu :
$ git clone origin.git Temp2 && cd Temp2
$ git ls-tree HEAD
100644 blob <oid_alice'> alice.txt
100644 blob <oid_bob'> bob.txtLes deux fichiers y sont, avec la bonne teneur. L’assertion est satisfaite : le nouvel expected (B_OID) n’a pas changé pendant l’opération, et le candidat C_OID contient tous les changements déclarés. Nous pouvons considérer ce push comme ACCEPTÉ : le couple (expect, candidate) respectait nos conditions.
Bind candidate identity as well as expected remote identity
Cette partie illustre une règle-clé : l’approbation doit lier de manière immuable l’OID du candidat revu (C_OID) avec l’OID distant attendu (B_OID). Si, entre le moment de la revue et le push, le HEAD d’Alice bouge (par ex. un nouveau git commit), la revue invalide l’association. Le wrapper doit impérativement « geler » C_OID et B_OID. Dans notre script, on conserverait C_OID dans une variable ou un fichier, et on utiliserait :
git rev-parse --verify --end-of-options ${expected}^{commit} # pour l’OID attendu
git rev-parse --verify --end-of-options ${candidate}^{commit} # pour l’OID candidat
git push origin ${candidate}:refs/heads/topic --force-with-lease=refs/heads/topic:${expected}De cette façon, on ne pousse jamais une branche HEAD mouvante : seulement un OID explicite. Ainsi, même si Alice faisait git commit après revue, cela n’affecterait pas le push en cours (qui ne pousse que le candidat). Dans le cas contraire, on considérerait qu’une « invalidation » a eu lieu (la révision a évolué) et on devrait arrêter l’opération (HOLD).
Reject a later server advance and preserve the evidence
Enfin, considérons le cas de course : après approbation de C, supposons que Bob pousse une nouvelle tête D au-dessus de B. Par exemple, dans le clone de Bob :
$ cd ../Bob
$ git fetch origin
$ git checkout topic
$ echo "extra" >> bob.txt
$ git commit -m "D: Bob adds more to bob.txt"
$ git push origin topic:refs/heads/topicMaintenant le serveur passe à D_OID.
Si Alice tente maintenant le push C avec l’ancien expected B_OID :
$ cd Alice
$ git push origin fix-topic:refs/heads/topic --force-with-lease=refs/heads/topic:B_OIDGit doit rejeter : origin/topic a avancé à D_OID, différent de B_OID. La sortie sera analogue à :
To /path/to/origin.git
! [rejected] fix-topic -> topic (stale (expected B_OID))
error: failed to push some refs to '/path/to/origin.git'Le code de sortie est non-zéro. Ce résultat correspond au rejet anticipé pour une avance non prévue du serveur. Nous lisons la branche distante (par git ls-remote) et confirmons qu’elle est à D_OID.
Ce cas est simplement documenté pour preuve. Le script s’arrêterait à ce stade : le push n’a pas réussi. On marque HOLD. Le message d’erreur signale clairement la divergence. Un nouvel examen (peut-être de la fusion D avec C) serait alors nécessaire avant toute tentative ultérieure. Notre wrapper de revue ne rafraîchit pas <expected> = B_OID pour retenter ; cela éviterait le piège mentionné dans la section précédente.
Inspect the remote result without promising permanence
Quand un update est accepté, comme le push de C, il reste à observer l’état final sans confondre cela avec une quelconque garantie durable. Nous lisons explicitement le serveur après le push :
$ git ls-remote origin.git refs/heads/topicSupposons que nous trouvons :
C_OID refs/heads/topicNous notons l’OID et horodatons le résultat. On peut aussi examiner le contenu via git cat-file ou en clonant temporairement, pour s’assurer qu’il correspond aux données approuvées. Par exemple :
$ git clone origin.git VerifyRepo && cd VerifyRepo
$ git show HEAD:alice.txt # -> "hello world" modifié
$ git show HEAD:bob.txt # -> "preserve me"Ces vérifications confirment que le push a produit la branche annoncée. Cependant, nous distinguons ce snapshot de l’état d’une transaction garantie : Git n’a pas « verrouillé » le serveur. Il a simplement lu à un instant donné. Il faudrait qu’aucun autre intervenant n’ait modifié la branche entre-temps (nous l’avons vérifié par expected=B_OID). Le manuel git-ls-remote précise bien que la commande retourne l’état au moment de la requête ; pas plus.
Keep recovery refs reachable and corrections conditional
Dans le contexte local (développement ou correction d’erreur), nous avons utilisé update-ref pour remettre origin/topic à un OID précédent. Cela ne fonctionnerait pas sur un dépôt distant public sans autorisation spéciale. Dans un vrai scénario de rollback, il faudrait coopérer avec les mainteneurs du serveur : proposer un autre push de force inverse ou utiliser les sauvegardes du serveur.
Nous insistons : nous utilisons des refs locales (safety, etc.) pour conserver B_OID. Sur un serveur, on pourrait taguer ou trouver le commit antérieur dans le reflog distant (si activé). Mais aucune méthode Git locale n’est magique : réécrire rétroactivement l’historique partagé nécessite un accord humain ou un accès privilégié. En cas d’erreur de branche, un revert explicite (nouveau commit inverse) et une coordination de communication sont plus sûrs qu’un update-ref brutal.
Dans notre labo, les ancres (safety, ou simplement l’objet B_OID dans le repo) garantissent que la base O reste accessible même après rewrite. Si on devait revenir, on ferait un push refs/heads/topic:<B_OID> (comme démontré ici) ou bien recréer un commit à l’identique.
Choose ACCEPT, REPAIR, HOLD or REBUILD
Nous pouvons maintenant formuler la matrice de décision qu’un mainteneur appliqué doit suivre pour tout push de branche réécrite :
ACCEPT : Lorsque l’attente explicite (expected) a été passée, le candidat examiné correspond et le contenu déclaré par les participants est présent. On accepte la mise à jour de la seule référence concernée. Illustration : (expect = B_OID, candidate = C_OID avec bob.txt, remote= B_OID initial).
HOLD : Si l’OID attendu ne correspond pas au serveur, ou si l’on découvre que le contenu révisé n’est pas complet. Dans ce cas, on ne pousse pas. Par ex. (expected = A_OID, remote=B_OID) ou (expected=B_OID, candidate=A′ sans bob.txt). On documente l’écart et on attend une action humaine (réexamen, merge, etc.).
REBUILD : Quand le push a été accepté (contrôle du lease passé) mais la « révision de contenu » révèle un oubli ou conflit. On reforge alors le commit candidat (comme nous l’avons fait pour produire C), lie à nouveau l’OID et reprend le processus de validation. Ce mode s’applique au cas du push implicite ou explicite ayant réussi mais manquant de données (ex. Aprime_OID accepté malgré absence de bob.txt).
REPAIR : S’il y a une anomalie dans le processus lui-même (par exemple l’utilisateur a omis d’expliciter l’OID approuvé, ou notre wrapper n’a pas résolu les OID symboliques, ou un mauvais refspec a été utilisé). Dans ce cas, on corrige la procédure scriptée. Par exemple, exiger --verify, interdire --force sans lease, ou fixer la bonne destination. C’est plus un ajustement interne qu’un verdict de push.
Cette matrice formalise la nécessité de ne faire qu’une seule chose pour chaque push : soit on l’applique (ACCEPT), soit on l’annule (HOLD), soit on reconstruit le candidat (REBUILD). On ne tente pas de "rafraîchir" automatiquement l’attendu ou de revalider le contenu après coup sans nouvelle approbation.
Voir l’article sur le processus de livraison qui illustre la remise des modifications vers la production, où chaque étape doit être explicitement vérifiée (commit-to-deployment handoff).
Package the review evidence for the next maintainer
Pour faciliter la traçabilité, on fournit un rapport complet des tests. Par exemple :
Cas | Remote avant | Expected | Candidat | Commande push | Code | Sortie Git | Remote après | Contenu vérifié |
Implicite sans fetch | B_OID | A_OID | A′_OID | push --force-with-lease topic:topic | 1 | rejected (NFF) | B_OID | - |
Après fetch, implicite | B_OID | A_OID | A′_OID | push --force-with-lease topic:topic | 0 | accepted (forced) | A′_OID | bob.txt disparu |
Explicite=A, après fetch | B_OID | A_OID | A′_OID | push --force-with-lease=topic:A_OID | 1 | rejected (stale) | B_OID | bob.txt intact |
Explicite=B (inc.) | B_OID | B_OID | A′_OID | push --force-with-lease=topic:B_OID | 0 | accepted (no +) | A′_OID | bob.txt disparu |
Rebuilt (exp=B) | B_OID | B_OID | C_OID | push --force-with-lease=topic:B_OID | 0 | accepted (forced) | C_OID | alice.txt, bob.txt |
Avance de Bob (D) | D_OID | B_OID | C_OID | push --force-with-lease=topic:B_OID | 1 | rejected (stale) | D_OID | bob.txt préservé |
Chaque ligne liste l’état initial du serveur et les valeurs clés (en OID complet), la commande utilisée (avec la forme explicite ou implicite), le code de sortie et l’effet observé. Par exemple, après le premier fetch, un push implicite a passé le check de lease (remote-tracking=B_OID), remplacé topic par Aprime_OID, mais le contenu bob.txt a été perdu (voir la colonne « Contenu vérifié »).
Dans ce rapport, on n’utilise pas les schémas + pour indiquer remote B -> candidate A′, car la preuve brute est la suite B_OID→A′_OID, comme l’a confirmé git ls-remote. Nous avons également la safety-ref en permanence, qui garantit que B_OID est atteignable (ne pas confondre mise à jour de branche et effacement complet d’objets).
En post mortem, un mainteneur doit revoir chaque cas bloquant (code=1) : soit corriger la référence d’attendu (REPAIR), soit combiner les travaux manquants (REBUILD). Les cas aboutis (code=0) ne méritent de commentaire que pour vérifier le contenu distant. Toute sortie d’erreur de git push (« non-fast-forward », « stale », etc.) est capturée pour documenter le motif du rejet.
Pour plus de contexte sur la revue et le passage de témoin, consulter l’article sur les outils collaboratifs DevOps, notamment la façon de consigner les validations.
Build DevOps habits that connect review to mutation
En conclusion, cette étude souligne l’habitude clé en Git pour toute réécriture de branche en mode collaboratif : ne jamais mélanger un « suivi » mutable avec un résumé de révision immuable. Il faut toujours documenter précisément le commit approuvé (SHA1 complet) et se raccrocher explicitement à ce commit lors du push. Git ne fera jamais de miracle : --force-with-lease protège contre l’écrasement involontaire, mais ne remplace pas une relecture du contenu.
En termes de bonne pratique DevOps, on appelle cela « pointer le contrat de référence vers ce que j’ai effectivement lu ». Dans un pipeline de livraison, on détaillerait le changelog validé de la même manière avant publication. Les ingénieurs DevOps conçoivent souvent des revues formelles au niveau du pipeline (test, merge request, etc.). Ici, nous avons démontré comment le simple mécanisme Git ne suffit pas : il faut codifier la vérification.
Cet exercice, réalisé en Git 2.47.3, a utilisé un dépôt bare local et deux clones isolés. Il a confirmé qu’une attente explicite fixe sur l’OID approuvé est indispensable. Après chaque commande, le parseur a indiqué clairement si Git a accepté (code=0) ou rejeté (code≠0) l’opération, et le contenu du serveur a été vérifié indépendamment. Les habitués de CI/CD et de Git sont ainsi invités à incorporer ces vérifications dans leurs scripts de déploiement (par exemple, conserver les OID approuvés dans l’environnement de build ou dans les notes de pull-request).
Enfin, si vous souhaitez approfondir ces pratiques Git et pipeline, sachez que le programme DevOps Engineer de Refonte Learning, d’une durée de trois mois (~12–14 h/semaine), couvre les fondamentaux du versionnement (Git/GitHub), des CI/CD, des conteneurs, de l’infrastructure as code et du monitoring. Ce programme prépare à établir de telles bonnes habitudes DevOps et collaboratives, sans pour autant promettre de préserver automatiquement la politique de branche d’un serveur distant. Les contrôles de git-push et la résolution des OID avec git-rev-parse restent à appliquer dans le processus de revue.
# Cleanup (exemple) :
$ rm -rf repo Alice Bob