Historique des commits perdus

C'était déjà le soir quand le développeur m'a contacté. Un patch est absent de la branche master - un commit deadbeef.







On m'a montré la preuve: la sortie de deux commandes. Le premier est



 git show deadbeef 
      
      





- a montré les modifications apportées au fichier, appelons-le Page.php. La méthode canBeEdited et son utilisation y ont été ajoutées.



Et dans la sortie de la deuxième commande -



 git log -p Page.php 
      
      





- il n'y a pas eu de commit de deadbeef. Et dans la version actuelle du fichier Page.php, il n'y avait pas de méthode canBeEdited.



Ne trouvant pas de solution rapidement, nous avons apporté un autre patch au maître, décomposé les changements - et j'ai décidé que je reviendrais sur le problème avec un esprit neuf.



"Hors sujet"
, Git. , , .





Cela a-t-il été fait exprès? Le fichier a été renommé?



J'ai commencé à rechercher le problème en demandant de l'aide dans le chat de l'équipe d'ingénieurs de publication. Ils sont responsables de l'hébergement des référentiels et de l'automatisation des processus liés à Git, entre autres. Pour être honnête, ils auraient probablement pu supprimer le patch, mais ils l'auraient fait sans laisser de trace.





Un des ingénieurs de version a suggéré d'exécuter git log avec l'option --follow. Le fichier a peut-être été renommé et par conséquent Git n'affiche pas certaines des modifications.

--follow

Continue de lister l'historique d'un fichier au-delà des renommages (ne fonctionne que pour un seul fichier).

(Afficher l'historique du fichier après l'avoir renommé (ne fonctionne que pour les fichiers uniques))



Il y git log --follow Page.php



avait un deadbeef dans la sortie , mais aucun fichier n'a été supprimé ou renommé. Et pourtant, il n'était pas visible que la méthode canBeEdited ait été supprimée quelque part. L'option de suivi semblait jouer un rôle dans cette histoire, mais la destination des changements n'était toujours pas claire.



Malheureusement, le référentiel en question est l'un des plus importants que nous ayons. Du moment où le premier patch a été introduit jusqu'à sa disparition, il y a eu 21 000 commits. Il était également heureux que le fichier requis n'ait été édité que dans dix d'entre eux. Je les ai tous étudiés et je n'ai rien trouvé d'intéressant.



Nous recherchons des témoins! Nous avons besoin d'un ours vivant



Arrêtez! Nous cherchions juste du deadbeef? Pensons logiquement: il doit y avoir un commit, appelons-le livebear, après quoi deadbeef n'est plus affiché dans l'historique des fichiers. Peut-être que cela ne nous donnera rien, mais cela nous donnera quelques réflexions.



Il existe une commande git bisect pour rechercher l'historique Git. D'après la documentation , il vous permet de trouver le commit dans lequel le bogue est apparu pour la première fois. En pratique, il peut être utilisé pour trouver n'importe quel moment de l'histoire si vous savez comment déterminer si ce moment est arrivé. Notre bogue était le manque de changements dans le code. Je pourrais le vérifier avec une autre commande - git grep. Après tout, il me suffisait de savoir s'il existe une méthode canBeEdited dans Page.php. Un peu de débogage et de lecture de la documentation:



livebear [build]: fusionne l'origine de la branche / XXX dans build_web_yyyy.mm.dd.hh



Cela ressemble à un commit de fusion normal d'une branche de tâche avec une branche de version. Mais avec ce commit, j'ai réussi à reproduire le problème:



$ git checkout -b test livebear^1 2>/dev/null
$ grep -c canBeEdited Page.php
2
$ git merge —-no-edit -—no-stat livebear^2
Removing …
Removing …
Merge made by the ‘recursive’ strategy.

$ grep -c canBeEdited Page.php
0
$ git log -p Page.php | grep -c canBeEdited
0

      
      





Certes, je n'ai rien trouvé d'intéressant dans livebear, et son lien avec notre problème est resté flou. Après avoir un peu réfléchi, j'ai envoyé les résultats de mes recherches au développeur: nous avons convenu que, même si nous arrivons à la vérité, le schéma de reproduction sera trop compliqué et nous ne pourrons pas nous assurer contre quelque chose comme ça à l'avenir. Par conséquent, nous avons officiellement décidé d'arrêter la recherche.



Cependant, ma curiosité est restée insatisfaite.



La persévérance n'est pas un vice, mais un grand dégoûtant



Plusieurs fois, je suis revenu sur le problème, j'ai exécuté git bisect et j'ai trouvé de plus en plus de commits. Tous sont suspects, tous sont des fusions, mais cela ne m'a rien donné. Il me semble que l'un des commis m'a alors plus souvent parlé que d'autres, mais je ne suis pas sûr que ce soit lui qui soit finalement le coupable.



Bien sûr, j'ai également essayé d'autres méthodes de recherche. Par exemple, j'ai parcouru à plusieurs reprises les 21 000 commits effectués au moment où le problème est survenu. Ce n'était pas très excitant, mais je suis tombé sur un modèle intéressant. J'ai exécuté la même commande:



git grep -c canBeEdited {commit} -- Page.php
      
      





Il s'est avéré que les "mauvais" commits, qui n'avaient pas le code requis, étaient dans la même branche! Et une recherche sur ce fil m'a rapidement conduit à un indice:



changekiller Fusionner la branche 'master' dans TICKET-XXX_description



C'était aussi une fusion de deux branches. Et en essayant de le répéter localement, il y avait un conflit dans le fichier requis - Page.php. A en juger par l'état du référentiel, le développeur a laissé sa version du fichier, rejetant les modifications du maître (à savoir, elles ont été perdues). Un long moment s'est écoulé et le développeur ne se souvenait pas exactement de ce qui s'était passé, mais en pratique, la situation était reproduite dans une séquence simple:



git checkout -b test changekiller^1
git merge -s ours changekiller^2

      
      





Il reste à comprendre comment une séquence d'actions légitime aurait pu conduire à un tel résultat. Ne trouvant rien à ce sujet dans la documentation, je suis entré dans le code source.



Le tueur est-il Git?





La documentation dit que git log reçoit plusieurs commits en entrée et doit montrer à l'utilisateur leurs commits parents, à l'exclusion des parents des commits soumis avec un ^ devant eux. Il s'avère que git log A ^ B devrait afficher les commits qui sont les parents de A et non les parents de B.



Le code de commande s'est avéré assez complexe. Il y a eu beaucoup d'optimisations différentes pour travailler avec la mémoire, et en général, la lecture de code C ne m'a jamais semblé une expérience très agréable. La logique de base peut être représentée avec le pseudo-code suivant:



//    ,   
commit commit;
rev_info revs;

revs = setup_revisions(revisions_range);
while (commit = get_revision(revs)) {
	log_tree_commit(commit);
}

      
      





Ici, la fonction get_revision accepte revs, un ensemble d'indicateurs de contrôle, en entrée. Chacun de ses appels devrait sembler donner le prochain commit pour le traitement dans le bon ordre (ou vide, quand nous sommes arrivés à la fin). Il existe également une fonction setup_revisions qui remplit la structure revs et log_tree_commit, qui affiche des informations à l'écran.



J'avais le sentiment de savoir où chercher le problème. J'ai passé un fichier spécifique (Page.php) à la commande, car je n'étais intéressé que par ses modifications. Cela signifie que le journal git doit avoir une sorte de logique pour filtrer les commits "supplémentaires". Les fonctions setup_revisions et get_revision ont été utilisées dans de nombreux endroits - ce n'est guère le problème avec elles. Cela a laissé log_tree_commit.



À ma joie indicible, dans cette fonction, il y avait vraiment un code qui calcule les modifications apportées à un commit particulier. Je pensais que la logique générale devrait ressembler à ceci:



void log_tree_commit(commit) {
	if (tree_has_changed(commit, commit->parents)) {
		log_tree_commit_1(commit);
}
}

      
      





Mais plus je regardais le vrai code, plus je réalisais que j'avais tort. Cette fonction imprimait uniquement les messages. Alors croyez vos sentiments après ça!



Je suis retourné aux fonctions setup_revisions et get_revision. La logique de leur travail était difficile à comprendre - le «brouillard» des fonctions auxiliaires interférait, dont certaines étaient nécessaires pour fonctionner correctement avec les pointeurs et la mémoire. Tout semblait que la logique principale était une simple traversée en largeur de l'arbre de validation, c'est-à-dire un algorithme assez standard:



rev_info setup_revisions(revisions_range, ...) {
	rev_info rev;
	commit commit;
	
	//       —   
	for (commit = get_commit_from_range(revisions_range)) {
		revs->commits = commit_list_append(commit, revs->commits)
	}
}

commit get_revision(rev_info revs) {
	commit c;
	commit l;

c = get_revision_1(revs);
	for (l = c->parents; l; l = l->next) {
		commit_list_insert(l, &revs->commits);
	}
	return c;
}

commit get_revision_1(rev_info revs) {
	return pop_commit(revs->commits);
}

      
      





Une liste est créée (revs-> commits), le premier élément (le plus haut) de l'arborescence de commit y est placé. Ensuite, les commits du début sont progressivement extraits de cette liste et leurs parents sont ajoutés à la fin.



En lisant le code, j'ai trouvé que parmi le "brouillard" des fonctions d'assistance, il y a une logique complexe pour filtrer les commits, ce que j'ai recherché. Cela se produit dans la fonction get_revision_1:



commit get_revision_1(rev_info revs) {
	commit commit;
	commit = pop_commit(revs->commits);
	try_to_sipmlify_commit(commit);
	return commit;
}

void try_to_simplify_commit(commit commit) {
	for (parent = commit->parents; parent; parent = parent->next) {
		if (rev_compare_tree(revs, parent, commit) == REV_TREE_SAME) {
			parent->next = NULL;
			commit->parents = parent;
		}
	}
}

      
      





Dans le cas où plusieurs branches sont en cours de fusion, si l'état du fichier reste le même que dans l'une d'entre elles, cela n'a aucun sens de considérer d'autres branches. Si l'état du fichier n'a changé nulle part, nous ne laisserons que la première branche.



Exemple. Notons par zéro les commits dans lesquels le fichier n'a pas changé, par un - ceux dans lesquels le fichier a changé, et X - la fusion des branches.







Dans ce cas, le code ne prendra pas en compte la branche de fonctionnalité - il n'y a pas de modifications. Si le fichier y a été modifié, alors dans X les modifications ont été «rejetées», ce qui signifie que leur historique n'est pas très pertinent: ce code n'est plus là.



Quelque chose de similaire s'est produit avec nous. Deux développeurs ont apporté des modifications dans un fichier - Page.php, un - dans la branche master, dans le commit deadbeef, le second - dans leur branche de tâches.



Lorsque le deuxième développeur a fusionné les modifications de la branche maître dans la branche tâche, un conflit s'est produit, au cours de la résolution duquel il a simplement rejeté les modifications du maître. Le temps a passé, il a fini de travailler sur la tâche et la branche de la tâche a été téléchargée vers le maître, supprimant ainsi les modifications du commit deadbeef.



Le commit lui-même est resté. Mais si vous exécutez git log avec le paramètre Page.php, vous ne verrez pas le commit deadbeef dans la sortie.



L'optimisation est un travail ingrat



Je me suis précipité pour étudier attentivement les règles de soumission des modifications et des bogues à Git lui-même. Après tout, je pensais avoir trouvé un problème vraiment sérieux: réfléchissez, certains commits disparaissent tout simplement de la sortie - et c'est le comportement par défaut! Heureusement, les règles se sont avérées volumineuses, l'heure était tardive et le lendemain matin, mon fusible a disparu.



J'ai réalisé que cette optimisation accélérait considérablement les performances de Git sur de grands référentiels comme le nôtre. Il existe également une documentation à ce sujet dans man git-rev-list , et ce comportement peut être désactivé très facilement.



Au fait, comment --follow est-il impliqué dans cette histoire?



En fait, il existe de nombreuses façons d'influencer le fonctionnement de cette logique. Plus précisément, à propos de l'indicateur de suivi dans le code Git, un commentaire a été trouvé il y a 13 ans:



Impossible d'élaguer les commits avec le changement de nom suivant: les chemins changent.

(Traduction: impossible de lancer des commits lorsque le changement de nom est en cours: les chemins peuvent changer)





PS

Je fais moi-même partie de l'équipe d'ingénierie des versions de Badoo depuis plusieurs années maintenant, et beaucoup dans l'entreprise pensent que nous comprenons Git.





(Traduction. Original: xkcd.com/1597 )



À cet égard, nous devons faire face aux problèmes qui se posent dans ce système, et certains d'entre eux me semblent assez curieux - comme, par exemple, décrits dans cet article. Très souvent, les problèmes sont résolus rapidement: nous avons déjà rencontré beaucoup de choses, quelque chose est bien décrit dans la documentation. Ce cas était une exception.



En fait, la documentation contenait en effet une section de simplification de l'historique, mais c'était uniquement pour la commande git rev-list et je n'ai pas pensé y regarder. Il y a six mois, cette section était incluse dans le manuel de la commande git log, mais notre cas s'est produit un peu plus tôt - je n'ai tout simplement pas eu le temps de terminer cet article. (*)



Et enfin, j'ai un petit bonus pour ceux qui ont lu jusqu'au bout. J'ai un tout petit référentiel où le problème est reproduit:



$ git clone https://github.com/Md-Cake/lost-changes.git
Cloning into 'lost-changes'...

$ git log --oneline test.php
edfd6a4 master: print 3 between 1 and 2
096d4cf init

$ git log --oneline --full-history test.php
afea493 (HEAD -> master, origin/master, origin/HEAD) Merge branch 'changekiller'
57041b8 (origin/changekiller) print 4 between 1 and 2
edfd6a4 master: print 3 between 1 and 2
096d4cf init

      
      





Merci pour l'attention!



(*) UPD: Il s'est avéré que la section de simplification de l'historique était dans la documentation de la commande git log depuis bien plus de six mois, et je l'ai simplement ignorée. Je vous remercie tu gèresqui a attiré l'attention sur cela!



All Articles