
Envoyé par
Gluups
J'ai utilisé plus ou moins tous les outils de contrôle de version, sans forcément approfondir autant qu'il aurait fallu.
Pour autant, c'était la première fois, cette semaine, que je perdais un projet et ne pouvais plus l'exécuter.
Dans ce cas, je te conseille tout de suite « Learn Git Branching » qui propose des exercices amusants en ligne pour se faire la main et qui, en outre, a le bon goût de représenter le graphe de l'historique de façon interactive, ce qui nous manque ici. Pas qu'on ne le fasse pas du tout, mais en dessiner un dans la boîte des commentaires devient vite fastidieux sans outil dédié.
Par ailleurs, si tu as déjà utilisé CVS ou Mercurial, qui eux sauvent à chaque fois le différentiel appliqué à chaque fichier (dans des fichier « *,v » pour RCS et CVS), Git est un système à snapshots. Ça veut dire que d'une part, chaque fois que tu vas modifier un fichier, ne serait-ce que d'un seul octet, il va le ré-enregistrer entièrement (ce qui ne pose globalement pas de problème, on verra plus loin pourquoi) mais également qu'il utilise un système d'objets immuables, chaque objet étant un fichier compressé et portant comme nom la somme SHA1 de son contenu. C'est ce qui fait qu'un objet est immuable, car on ne peut pas en modifier le contenu sans modifier sa somme. Lorsqu'on le fait quand même, Git le remplace en fait par un autre objet, reconstruit depuis zéro mais à partir des données du précédent.
Il y a exactement quatre types d'objets : les blob, les tree, les commits et les tags annotés (les tags ordinaires, eux, sont de simples étiquettes gérés de la même façon que les branches). Si on laisse les tags annotés de côté pour le moment (qui n'ont pas d'intérêt pour le sujet qui nous intéresse présentement) :
- Les blobs contiennent une séquence de données arbitraire (généralement le contenu d'un fichier) ;
- Les tree sont des arborescences : c'est à chaque fois une liste de noms de fichiers + chemin d'accès, chaque entrée étant associé à un blob (le contenu du fichier) ;
- Les commits sont un petit fichier texte qui contient toutes les informations nécessaires à décrire la révision concernée, c'est-à-dire la date, l'auteur, un certain nombre de headers supplémentaires, le message saisi dans l'éditeur, et :
— l'identifiant d'un objet tree qui référence tous les fichiers de la révision concernée et qui correspond en fait à l'état de l'index au moment du commit ;
— l'identifiant de l'objet du commit parent, c'est-à-dire celui sur lequel on se trouvait au moment où l'on a produit celui-ci.
Sur ce dernier point, la grande majorité des commits ont donc un et un seul champ « parent », mais ils peuvent en fait en avoir un nombre arbitraire : soit zéro dans le cas du commit initial (normalement un seul par dépôt, sauf si on fait exprès de créer un commit orphelin, chose dont on ne parlera pas ici), soit un seul dans les cas normaux, soit au minimum deux si on a fusionné deux branches (donc deux la plupart du temps, mais dans l'absolu, on peut fusionner n branches en même temps. On n'en parlera pas non plus).
Tu peux examiner un des tiens avec git cat-file -p cda8374, par exemple.
Ce qui est important de saisir ici : chaque commit référençant directement le précédent, ils constituent ainsi une sorte de longue chaîne qui est en fait le fameux graphe dont on parle depuis le départ. Cela veut dire que c'est bien l'ensemble des révisions qui forment l'historique, de fait. Une branche est une lignée de commits dans l'historique. Ce n'est pas un container ou un objet particulier comme cela peut être implémenté sous d'autres logiciels de versioning.
Et en conséquence, les « branches » sous Git telles que tu les vois avec git branch ne sont en fait qu'une référence vers le commit se trouvant actuellement au sommet de ces branches. C'est donc un point de départ depuis lequel il suffit ensuite de tirer le fil. Tu peux vérifier dans .git/refs/heads : tu y trouveras des fichiers qui portent exactement le nom de tes branches, fichiers qui contiennent exactement quarante caractères plus un retour à la ligne. Si tu affiches leur contenu, tu verras que c'est bien l'identifiant SHA1 du commit du sommet de la branche.
Il faut enfin évoquer le garbage collector (ou « ramasse-miettes ») : lorsque certains objets deviennent obsolètes (suppression de branche ou troncature, amendement, rebasage reconstruisant la branche…), ils ne sont pas supprimés immédiatement. Ils ne le seront que périodiquement, chaque fois que Git estimera nécessaire de lancer le garbage collector. Il fait cette estimation chaque fois que tu lances une commande et, le cas échéant, lance un processus en arrière-plan si c'est judicieux de le faire, qui continuera à fonctionner même après que la commande principale a rendu la main.
→ Ce garbage collector supprimera alors les objets uniquement si 1) l'objet concerné n'est plus référencé par rien (ni commit enfant, ni tag, ni branche, ni entrée de reflog) et 2) s'il est plus ancien qu'un certain âge (typiquement 3 mois par défaut).
C'est techniquement une très bonne approche car un tel ramassage est coûteux en ressources (long, beaucoup d'accès disques…) et que de l'autre côté, cela ne concerne à chaque fois que très peu d'objets par rapport au dépôt entier et, donc, l'espace qu'ils occupent n'est pas pénalisant. Cela signifie également que si tu détruis par accident quelque chose que tu as commité au moins une fois, tu es quasiment certain de pouvoir le récupérer d'une manière ou d'une autre. Il suffit de retrouver les identifiants des objets concernés, et il existe plusieurs outils pour cela, le principal étant le reflog.
Alors tirer à soi, d'accord, mais comme je précise ailleurs, il s'agit que je fasse attention de ne pas comprendre de travers.
Imaginons que toi et ton équipe soyez synchrones sur votre projet, puis que tu partes en vacances une semaine. À ton retour, la branche côté serveur aura pris de l'avance sur la tienne.
Toi Serveur
origin/master
↑
O Commit 6
|
O Commit 5
|
master O Commit 4
↑ |
O Commit 3 O Commit 3
| |
O Commit 2 O Commit 2
| |
O Commit 1 O Commit 1
Dans cette situation, il suffit de récupérer les commits 4, 5 et 6 et les appliquer sur ta branche pour que les deux soient de nouveau synchrones. C'est ce qui se passe avec les plupart des DVCS et avec Git en particulier, si on a bien saisi ce qui est exposé au paragraphe précédent, on comprend qu'il suffit même de récupérer les objets et de mettre à jour l'étiquette « master » en lui affectant l'identifiant du commit 6 pour que notre branche master locale soit de nouveau à jour. C'est ce que Git appelle « l'avance rapide » (fast-forward).
Si c'est ton équipe qui part en vacances et toi qui travaille, le cas est le même mais dans l'autre sens : il faut pousser tes modifications sur le serveur, mais l'opération sera la même.
Si en revanche, vous êtes tous au travail en même temps et que vous êtes synchrones au commit 3, comme dans l'exemple ci-dessus, il est probable que vous déposiez chacun votre propre commit au même niveau :
Toi Serveur
master origin/master
↑ ↑
O Commit 5 O Commit 4
| |
O Commit 3 O Commit 3
| |
O Commit 2 O Commit 2
| |
O Commit 1 O Commit 1
Ici, vos branches ont divergé puisqu'elles sont parties chacune dans une direction propre. Il faut donc les fusionner avec git merge (appelé automatiquement si tu fais git pull). À l'issue de la résolution des éventuels conflits, tu vas te retrouver avec ceci :
↑
O Commit 6 (fusion des deux branches)
|
+---+---+
| |
Commit 5 O O Commit 4
| |
+---+---+
|
O Commit 3
|
O Commit 2
|
O Commit 1
Il est important de remarquer ici que, dans cette situation, Git ne peut pas se contenter d'intégrer les commit 4 et 5 « directement dans l'ordre chronologique » en insérant, sur ta branche, le commit 4 entre le 3 et le 5 sur ta branche, comme s'il s'agissait d'une simple régularisation car ces commits, l'un comme l'autre, référencent directement le commit 3 comme étant leur père. Il est possible de le faire a posteriori mais cela implique de ré-écrire l'histoire récente (cela peut se faire notamment lors d'un rebasage) et de choisir lequel doit prendre place avant l'autre.
Cela se fait, mais pour ainsi dire jamais en production sur le serveur commun. À la place, soit on fait des « pull requests », c'est-à-dire que chaque développeur prépare une branche candidate à l'intégration puis, quand elle est prête, le mainteneur choisit de la fusionner à la branche principale (c'est ce qui se fait avec le noyau Linux, notamment), soit c'est le développeur qui résout lui-même le tout de son côté, place ses propres commits au sommet de la partie commune de la branche, prévient si possible les autres développeurs pour qu'ils suspendent leur activité un instant (le temps de l'opération) et pousse le tout une fois qu'il est certain qu'il n'y aura pas de conflit sur le serveur. À charge ensuite aux autres développeurs de faire un pull de leur côté et de faire exactement le même travail, s'il s'avère nécessaire.
[ checkout ] … Et donc, ça permet de coder à partir de ce commit, tout en le conservant dans l'historique.
Oui, mais pas tout-à-fait : git checkout sert avant tout à rappeler une révision donnée dans l'historique, et heureusement qu'on le peut sinon cela n'aurait aucun intérêt de les enregistrer. Tout comme push et pull, c'est une commande qui existe depuis les tous premiers VCS.
Lorsque tu appelles checkout, Git va se « placer dessus » (en y positionnant « HEAD ») et va développer dans le répertoire de travail les fichiers qui y correspondent. Tu retrouves donc ton projet dans l'état dans lequel il était au moment où tu avais effectué le commit, mais attention :
master
↑
O Commit 5
|
O Commit 4
|
O Commit 3 ← « vous êtes ici »
|
O Commit 2
|
O Commit 1
D'abord, même si tu t'es déplacé sur « commit 3 », le sommet de la branche master, lui, bien resté sur le « commit 5 ».
Ensuite, lorsque tu utilises checkout avec un nom de branche, Git considère que tu veux travailler avec et tu bascules dessus. Si tu lui passes autre chose (un identifiant SHA1, un tag, ou même un nom de branche mais accompagné de décorateurs comme ^ ou ~), tu passes en « état détaché », c'est-à-dire non lié à une branche. La différence est qu'en état attaché, l'étiquette de la branche est mise à jour lorsque tu commites pour qu'elle pointe toujours sur le dernier en date. Sinon, seul le commit est enregistré. Ça ne t'empêche pas d'en faire plusieurs d'affilée mais pour l'instant, ils ne seront référencés par rien et tu ne pourras pas les retrouver ensuite.
master
↑
O Commit 5
|
O Commit 4
|
|
| O Commit 8 ← On en est là.
| |
| O Commit 7
| |
| O Commit 6
| /
|/
O Commit 3
|
O Commit 2
|
O Commit 1
Dans ce dernier cas, tu peux mettre un tag sur le dernier commit ou y créer une branche, non seulement pour pouvoir y revenir mais surtout pour s'assurer qu'ils ne seront pas emportés par le garbage collector. Mais en réalité, ce que l'on fait surtout, c'est se déplacer sur ce point, créer une nouvelle branche (à cet endroit, donc), basculer sur cette branche et entamer le développement. Il existe d'ailleurs des raccourcis pour le faire en une seule fois.
Par contre :
- Lors d'un checkout, le logiciel ne modifie QUE les fichiers qu'il suit. Il n'efface pas le working directory pour le remplir ensuite avec les fichiers de la révision sélectionnée, mais se contente de remplacer le contenu de ceux-ci. Cela signifie que si tu as des fichiers en plus de ceux qui sont officiellement suivis dans ton dépôt, ils resteront intouchés. Cela peut néanmoins avoir une incidence sur le fonctionnement d'un projet, surtout s'ils sont autogénérés !
- Ces fichiers restent intouchés mais il se peut que tu bascules d'une version ou ils n'existent pas encore vers une autre où ils ont été ajoutés. Cela peut se produire dans l'autre sens aussi s'ils ont été supprimés au cours de l'histoire. Dans ce cas, tu peux avoir du mal à changer de version tant que ces fichiers sont présents. Si tu les as recréés toi-même, il faudra les enregistrer quelque part…
- De la même façon, Git ne te laissera pas changer de révision s'il y a des changement effectués sur un ou plusieurs des fichiers suivis et qui ne soient pas encore enregistrés. Il te demandera de le faire d'abord.
Et surtout
- On est bien d'accord que git checkout ne fait que te déplacer dans l'historique, les branches restant à leur place. Si tu veux faire repartir la branche master de la révision que tu as appelée avec checkout, il va falloir faire d'autres opérations.
[ reset ]Ça réécrit tellement l'histoire que je ne comprends pas du premier coup. J'essaierai en relisant.
En principe, tu dois déjà y voir plus clair à la lecture du laïus ci-dessus.
On comprend déjà qu'en se déplaçant avec checkout et en forçant la re-création d'une branche existante à cet endroit (ou en l'effaçant d'abord), on pourrait arriver à ses fins. Ça se fait, et ça va marcher sous Git, mais ce n'est pas propre et ce n'est pas la manière orthodoxe de faire (et ça pourrait poser des problèmes avec les DVCS qui fonctionnent différemment).
Si l'on peut dire que git reset est bien faite pour annuler les derniers commits en date d'une branche, retiens cependant qu'elle sert à « ramener une branche à une position donnée » (par défaut, la branche courante, sur laquelle on se trouve). Dans tous les cas, la branche proprement dite (c'est-à-dire l'étiquette qui pointe son sommet) sera bien ramenée à la révision que tu indiques, mais il existe trois manières de le faire, indiquées par une option :
- --soft : ne ramène que la branche à la position indiquée, laisse intact l'index et le working directory ;
- --mixed (option par défaut) : ramène la branche ET l'index à la position indiquée, laisse intact le working directory ;
- --hard : ramène de force la branche, l'index ET le working directory à la position indiquée. Écrase le contenu des fichiers qui s'y trouve si nécessaire.
On comprend alors que « reset --hard <version> » était probablement la commande qu'il t'aurait fallu dès le départ mais également pourquoi on ne l'évoque qu'au 19ème commentaire : c'est précisément une des commandes dont on parlait, celles vers lesquelles les débutants se précipitent et qui peut leur faire perdre des données sans espoir de les récupérer, alors que Git est censé être un des systèmes les plus résilients de ce côté-là.
[ rebase ]Et à ce que je me rappelle, ça mérite de prendre le temps de bien le comprendre, ça aussi.
Méfiance. C'est effectivement très pratique et très naturel quand on développe mais sauf (grosse) erreur de ma part, je crois que ça a justement été introduit avec Git. Cela a été porté ensuite au moins sur Mercurial et sous Bazaar, mais sous forme de plugins et la dernière fois que j'ai essayé sous Mercurial (fin 2011, ça commence à être vieux), cela avait corrompu mon dépôt.
C'est en revanche tout-à-fait sûr sous Git et c'est très simple d'usage, mais ce n'est pas forcément ce vers quoi il faut se lancer immédiatement. L'idée est de faire ceci :
De : Vers :
O Commit 10
|
O Commit 9
|
O Commit 8
/
↑ ↑/
Commit 7 O Commit 7 O
| O Commit 10 |
Commit 6 O | Commit 6 O
| O Commit 9 |
Commit 5 O | Commit 5 O
| O Commit 8 |
Commit 4 O / Commit 4 O
|/ |
Commit 3 O Commit 3 O
| |
Commit 2 O Commit 2 O
| |
Commit 1 O Commit 1 O
L'opération consiste donc à partir du commit 10, à remonter l'historique pour se rendre compte que le point de jonction est juste avant le commit 8, en déduire que la branche en question est formée par les commits 8, 9 et 10, « rejouer » (càd : réappliquer leur changeset) ces commits dans l'ordre à partir du nouveau point de départ (ici le commit 7) et replacer le nom de branche au sommet du nouveau commit 10 s'il y en avait un et qu'il y a lieu de le faire.
« rebaser » une branche sert donc bien à « changer de base », c'est-à-dire la débrancher et la rebrancher autre part.
Ça a l'air élémentaire comme ça mais en réalité, c'est assez difficile à réaliser quand le modèle général n'est pas celui de Git et de fait, ce n'était pas utilisé à large échelle avant lui. Les gens se débrouillaient avec merge.
Attention toutefois : prudence si tu rebases une branche qui contient elle-même des points de fusion (merge). Voir ce fil : publication de la FAQ Git.
Si la plupart des débutants se précipitent vers deux ou trois situations, ça m'étonne qu'elles soient rares.
Ça vaut le coup de s'y attarder un instant : un certain nombre de commandes sont dotées de l'option « -f », pour « --force », exactement comme l'est la commande rm sous Unix (remove : effacer un fichier). Cela permet de forcer l'action si c'est possible en dépit des mesures de sécurité.
Par exemple : git checkout ne te laissera pas changer de révision si des changements non enregistrés sont en cours, mais git checkout -f le fera quand même (et écrasera le contenu) ;
Autre exemple : git branch <nom de branche> ne créera pas la nouvelle branche si elle existe déjà (et heureusement). Par contre, git branch -f le fera quand même là aussi : l'ancien nom de branche sera effacé et le nouveau sera déclaré à la position courante. La commande t'indiquera quand même une dernière fois quelle était l'ancienne position avant de la remplacer. Tu as alors intérêt à bien la noter si ce n'était pas réellement ce que tu voulais faire.
Normalement, il n'y pas plus de raison d'utiliser « -f » sous Git qu'il n'y en a avec « rm ». Si on le fait, cela signifie « ok, je suis prévenu mais je sais ce que je fais ». Pourtant, reconnais que ces deux possibilités ont l'air très séduisantes présentées comme ça. Ajoute git reset --hard à cette liste, et on comprend qu'on puisse être tenté de forcer les commandes quand on se retrouve noyé sous des messages d'avertissements auxquels on ne comprend plus rien. C'est ainsi qu'une large proportion de débutants se précipitent d'emblée vers les rares commandes qu'il ne faut pas utiliser, du moins pas tout de suite.
Bref : dans le doute, commite tes changements et corrige ton historique ensuite car même si tu te perds, tu auras toujours deux semaines de délai de grâce si l'objet n'est plus référencé nulle part et trois mois s'il apparait dans un reflog. Tu pourras alors toujours retrouver tes petits, fût-ce avec l'aide d'une personne expérimentée. Si tu tapes d'emblée dans les commandes ci-dessus, tu risques de perdre les données non enregistrées.
Il n'empêche que pour le projet que j'ai voulu commiter hier, j'ai l'impression que c'est une bonne chose que j'aie une sauvegarde du disque.
Je ne vais pas entrer dans les détails tout de suite, car si je commence à donner des extraits de log pour deux projets, on va vite se mélanger les pinceaux.
C'est toujours une bonne chose d'avoir une sauvegarde. Par contre, il ne faut pas finir comme lui : https://xkcd.com/1597/
Le principe général de Git reste très simple : des commits sous forme d'objets, qui forment eux-mêmes leur propre graphe et au sein duquel on peut se promener. Donc c'est aussi bien d'apprendre tout de suite à le réparer lorsque l'on est perdu.
Ah oui ben j'ai eu ça trois ou quatre fois à me taper dans Visual Studio, et là je trouve que ce n'est pas du boulot terminé. Ouvrir le projet et en voulant exécuter voir "175 erreurs", à corriger, ça augure de s'engager dans une belle séance à ramasser les morceaux.
Et oui ! Ça arrive fréquemment, même en équipe, et c'était même pire avec les autres systèmes parce que là, il fallait vraiment faire le même dans l'historique des modifications apportées à un fichier. On ne pouvait pas se contenter de revenir à une position donnée, du moins pas facilement, et on ne pouvait pas non comparer aisément deux positions trop éloignées.
Reste à savoir ce qui a réellement provoqué ces « 175 erreurs ». Probablement pas du développement pur, parce que ça ferait beaucoup de matériel à produire d'abord.
Alors que d'autres fois j'ai vu qu'il suffisait de trois clics pour faire ça.
C'est généralement possible en une seule commande sous Git aussi, à condition d'être certain de savoir ce que l'on veut faire.

Envoyé par
Gluups
Je relis ça après une petite sieste, et ça commence à s'éclairer.
Il va falloir que je trace avec beaucoup plus de précision ce qui se passe quand je restaure une ancienne version, par checkout à ce que je me rappelle, car j'ai tendance à avoir des fichiers manquants.
Appuie-toi surtout sur l'outil qui te représente ton historique de façon graphique. VSCode doit le faire, sinon il existe beaucoup d'outils pour cela. Moi j'utilise tig sous Linux car il fonctionne en mode texte dans un terminal.
Logiquement, après un checkout, si c'est sur un commit qui était opérationnel, je m'attends à trouver un projet opérationnel.
Normalement oui, sauf s'il y a des fichiers parasites non suivis en plus.
Donc si Visual Studio me dit qu'il y a trois modifications, ou Git que certains fichiers ne sont pas intégrés au contrôle de version, il faut que je commence par savoir d'où ça vient.
Absolument, et ça rejoint ce que l'on disait plus haut.
Quand c'est un fichier de config il se peut que ça soit normal, quand c'est Form1.cs donc le fichier par où on commence à taper du code, il y a un loup.
Tout-à-fait, et c'est pour cela qu'en plus de git status, il faut également utiliser git diff pour voir ce qui est en suspens dans le working directory par rapport au commit qu'on est censé avoir ouvert, ainsi que git diff --cached pour voir ce que tu as déjà ajouté avec git add mais pas encore commité.
Autrement à force de faire des pull et des push dans tous les sens je finis par avoir un fichier, mais pas forcément un projet complet dans une version cohérente. Et du coup, ça risque de donner des effets de bord pas conformes à ce que prévoit la doc.
Ne fais pas de « pull » et « push » intempestifs et encore moins « dans tous les sens » : n'oublie pas que ces commandes impliquent « merge ». Si fais « push » sans savoir ce que tu fais et que ton dépôt est en désordre, tu vas « intégrer » cette pagaille au serveur distant utilisé par les autres développeurs (s'il te laisse le faire).
Si tu veux travailler efficacement et que tu ne te sens pas encore d'attaque pour remettre ton dépôt actuel sur ses pieds (ce que l'on peut comprendre), je te conseille ce qui suit :
- Conserve le dépôt actuel et n'y touche plus (tu le remettras au propre ultérieurement et tu en auras besoin pour comprendre a posteriori ce que tu avais fait à ce moment) ;
- Clone à nouveau le projet qui t'importe avec git clone mais dans un autre répertoire ;
- Déplace-toi directement sur le commit qui t'intéresse avec git checkout si ce n'est pas le dernier en date ;
- Crée une branche qui t'est propre, appelons-la par exemple devlocal, avec git branch devlocal. Elle sera créé sur le commit courant (donc celui qui t'intéresse) ;
- Bascule vers cette branche avec git checkout devlocal. Tu ne vas pas changer de commit car la branche est au même endroit, mais Git saura que c'est sur elle que tu travailles désormais ;
- Fais tous tes changements depuis cette branche. Même si tu fais des bêtises avec, master restera au même endroit et les commits qu'elle contient resteront intouchés, même ceux qui sont en communs avec ta branche (c'est-à-dire tous ceux qui sont antérieurs au point où tu l'as créée) ;
- Tu peux éventuellement pousser ta branche sur le serveur si tu souhaites la sauvegarder et/ou la partager. Dans ce cas, il faudra créer une branche distante et la configurer pour qu'elles se suive mais en réalité, la première fois, git push depuis ta branche te renverra un message qui t'indiquera la commande exacte qu'il faut saisir ;
- Quand tu commenceras à être à l'aise, tu pourras éventuellement commencer à intégrer dans ta branche les dernières modifs en date sur master ;
- Quand tu seras totalement à l'aise et que ta tâche actuelle sera terminée, tu pourras fusionner ta branche avec master pour qu'elles n'en fasse plus qu'une et que ton travail soit officiellement intégré au tronc commun. Tu pourras alors en ouvrir une autre (ou même reprendre celle-ci) pour démarrer une nouvelle tâche.
Allez zou. Prochain épisode : git stash.
Bon courage.
Partager