Gestion de code


h1 1/17/2011 10:55:00 PM

De lecture en lecture sur le web, je me suis récemment débloqué sur une question relative à l'organisation du développement logiciel...

Quand on élabore du code, on a des contraintes contradictoires:
  • Commiter souvent pour ne pas perdre du code qui marche, ainsi que pour communiquer et faire tester du code en cours d'élaboration.
  • Avoir une base de code propre pour que les gens puissent récupérer une version récente mais stable du code, et pour que l'historique soit propre.
On pourrait rajouter une dernière contrainte: coder au lieu de procrastiner sur des questions d'organisation de code...
Pour reformuler le second point, il s'agit d'assurer d'une part la qualité du code à un instant donné (ce qui nécessite une distinction entre code expérimental et code stable validé) et d'autre part d'assurer la tracabilité et le suivi du code dans le temps, c'est à dire la lisibilité de l'historique des modifications (principalement pour le code stable). L'objectif est que ce qui constitue moralement une seule modification ne soit pas explosé sur plusieurs commits. En particulier, on ne veut pas des corrections suivant trop souvent un commit hâtif. Avoir un historique propre simplifie la tâche quand on cherche à comprendre un bout de code (voir d'où vient telle partie du code) ou quand on liste les modifications avant de releaser une nouvelle version. Cela simplifie aussi la vie de ceux qui surveillent les modifications du code -- c'est la raison principale de mon obsession pour ce problème car je m'attache à suivre les commits effectués sur liquidsoap, en tout cas ceux qui touchent des modules critiques ou dans lesquels je suis impliqué. En fait, avec un historique propre on a des chances d'avoir plus de personnes qui suivent de près l'évolution du code et sont susceptibles de détecter des problèmes.

Bien sûr, une partie de la réponse est apportée par la notion de branche. Mais cela n'aide pas du tout pour ce qui est de la gestion de l'historique. Sur ce point, on peut essayer de concevoir un système avec plusieurs dépôts: le dépôt expérimental dont le code n'est pas testé et l'historique est fragmenté, et le dépôt stable où le code est propre et les modifications sont regroupées pour avoir un historique clair. Mais cela fonctionne très mal: pour le transport des modifications vers le dépôt stable, il faut développer un nouvel outil; pour le transport dans l'autre sens, il faut faire face à tout un tas de problèmes. En bref, cette solution est nulle car elle nous prive de l'aide des outils de gestion de code actuels.

Au fond il n'y a qu'une solution pour maintenir un historique propre: l'édition d'historique. Comme le nom l'indique, il s'agit de modifier l'historique des modifications après coup: on fait quelques commits, on attend du feedback, on trouve un bug, et quand tout a l'air propre on réduit tout ça en un seul commit bien clair.

Le gros problème c'est que l'édition d'historique peut interférer assez mal avec la gestion de code distribuée: si on a deux copies d'un dépôt, et qu'on change l'historique dans l'un, il devient difficile de suivre certaines modifications relatives à l'ancien historique dans l'autre. J'illustre ceci sur un exemple plus bas, mais l'idée générale est qu'on ne devrait modifier que des parties de l'historique dont personne ne dépendra jamais plus. Cela exclut de travailler avec une branche expérimentale dont on manipule l'historique avant le report des modifications dans la branche stable, car cela désynchroniserait les différents dépôts où l'on continue de travailler dans la branche. Même avec une branche expérimentale par développeur, le développeur ne pourrait plus continuer de travailler dans sa branche après l'édition d'historique. Il n'y a que deux situations où l'édition d'historique est utilisable: soit on parle d'une suite de modifications qui n'ont jamais été publiées, soit on parle d'une suite de modifications publiées mais qui n'auront plus de descendants.

J'étais bloqué à ce stade depuis un moment. J'avais découvert mercurial, l'édition d'historique, la gestion facilitée des branches, les commits locaux, etc. Mais je ne trouvais pas de solution à mon problème. Jusqu'à ce que je lise A Git Workflow for Agile teams qui fait pile ce que je veux. La clé est de vraiment faire PLEIN de branches: une par feature ou bug. Quand on a fini de préparer la modification, au moment d'intégrer les modifications dans la branche principale, on peut éditer l'historique et personne ne touche plus à la branche. Pour penser à ça, il faut vraiment oublier les SCM comme SVN, où gérer une branche est pénible, et être habitué à un SCM distribué, où c'est censé être tout naturel.

Je vous refais la recette, en français et en mercurial... Vous pouvez quand même lire l'article original, qui dit quelques choses en plus, notamment la correspondance entre branches et tickets/issues.

Un exemple


On a Alice et Bob qui bossent sur le même projet. Ils ont tous les deux contribué dans la branche 123-new-feature, chacun un commit. L'historique ressemble à ça:
@  changeset:   3:f3a6c5394605
|  tag:         tip
|  parent:      0:11575ff21a29
|  user:        Alice 
|  date:        Tue Jan 11 15:34:05 2011 +0100
|  summary:     Dummy
|
| o  changeset:   2:c6dc5c92d631
| |  branch:      123-new-feature
| |  user:        Bob 
| |  date:        Tue Jan 11 15:15:26 2011 +0100
| |  summary:     Plus expressif!
| |
| o  changeset:   1:fafdcd7dccf5
|/   branch:      123-new-feature
|    user:        Alice 
|    date:        Tue Jan 11 15:11:40 2011 +0100
|    summary:     Nicer.
|
o  changeset:   0:11575ff21a29
   user:        Alice 
   date:        Tue Jan 11 15:08:58 2011 +0100
   summary:     Initial commit.

Alice et Bob sont satisfaits de leur code, Alice va donc fusionner les deux commits et les passer dans default. On utilise l'option --keep pour ne pas détruire tout de suite la branche. En gros on prend le noeud 1 et ses ancetres, on fusionne, et on recolle la modification résultante sur le noeud 3.
$ hg rebase --collapse --keep --source 1 --dest 3

@  changeset:   4:2bf346072a24
|  tag:         tip
|  user:        Alice 
|  date:        Tue Jan 11 15:15:26 2011 +0100
|  summary:     Plus joli et plus expressif!
|
o  changeset:   3:f3a6c5394605
|  parent:      0:11575ff21a29
|  user:        Alice 
|  date:        Tue Jan 11 15:34:05 2011 +0100
|  summary:     Dummy
|
| o  changeset:   2:c6dc5c92d631
| |  branch:      123-new-feature
| |  user:        Bob 
| |  date:        Tue Jan 11 15:15:26 2011 +0100
| |  summary:     Plus expressif!
| |
| o  changeset:   1:fafdcd7dccf5
|/   branch:      123-new-feature
|    user:        Alice 
|    date:        Tue Jan 11 15:11:40 2011 +0100
|    summary:     Nicer.
|
o  changeset:   0:11575ff21a29
   user:        Alice 
   date:        Tue Jan 11 15:08:58 2011 +0100
   summary:     Initial commit.

A ce stade on peut même carrément virer la branche du dépôt. Les données correspondantes seront alors perdues pour toujours.
$ hg strip 1

@  changeset:   2:2bf346072a24
|  tag:         tip
|  user:        Alice 
|  date:        Tue Jan 11 15:15:26 2011 +0100
|  summary:     Plus joli et plus expressif!
|
o  changeset:   1:f3a6c5394605
|  user:        Alice 
|  date:        Tue Jan 11 15:34:05 2011 +0100
|  summary:     Dummy
|
o  changeset:   0:11575ff21a29
   user:        Alice 
   date:        Tue Jan 11 15:08:58 2011 +0100
   summary:     Initial commit.

C'est tout beau! Mais si Alice a détruit sa branche, cela ne se propagera pas chez Bob. Au contraire, si Alice se synchronise avec Bobe elle récupérerait la branche, qui apparait en fait comme étant nouvelle pour son dépôt qui en a perdu toute trace.

Si on pousse les modifications d'Alice chez Bob (éventuellement vai un dépôt central) la modification numéro 2 va être transmise. La branche 123-new-feature reste dans les autres dépôts -- en d'autres termes l'historique n'est pas lui même versionné. Bob pourrait donc faire d'autres modifications dans la branche, ce qui n'est bien sûr pas trop souhaitable...
# Bob fait une modif (#3) et la commite

o  changeset:   5:2bf346072a24
|  tag:         tip
|  user:        Alice 
|  date:        Tue Jan 11 15:15:26 2011 +0100
|  summary:     Plus joli et plus expressif!
|
o  changeset:   4:f3a6c5394605
|  parent:      0:11575ff21a29
|  user:        Alice 
|  date:        Tue Jan 11 15:34:05 2011 +0100
|  summary:     Dummy
|
| @  changeset:   3:b37a07ad9982
| |  branch:      123-new-feature
| |  user:        Alice 
| |  date:        Tue Jan 11 15:39:38 2011 +0100
| |  summary:     Never enough of that.
| |
| o  changeset:   2:c6dc5c92d631
| |  branch:      123-new-feature
| |  user:        Bob 
| |  date:        Tue Jan 11 15:15:26 2011 +0100
| |  summary:     Plus expressif!
| |
| o  changeset:   1:fafdcd7dccf5
|/   branch:      123-new-feature
|    user:        Alice 
|    date:        Tue Jan 11 15:11:40 2011 +0100
|    summary:     Nicer.
|
o  changeset:   0:11575ff21a29
   user:        Alice 
   date:        Tue Jan 11 15:08:58 2011 +0100
   summary:     Initial commit.

Si maintenant on pousse les modifications de Bob à Alice, on a la situation suivante, où les modifications individuelles de la "vieille" branche sont suivies d'une nouvelle modification...

o  changeset:   5:b37a07ad9982
|  branch:      123-new-feature
|  tag:         tip
|  user:        Alice 
|  date:        Tue Jan 11 15:39:38 2011 +0100
|  summary:     Never enough of that.
|
o  changeset:   4:c6dc5c92d631
|  branch:      123-new-feature
|  user:        Bob 
|  date:        Tue Jan 11 15:15:26 2011 +0100
|  summary:     Plus expressif!
|
o  changeset:   3:fafdcd7dccf5
|  branch:      123-new-feature
|  parent:      0:11575ff21a29
|  user:        Alice 
|  date:        Tue Jan 11 15:11:40 2011 +0100
|  summary:     Nicer.
|
| o  changeset:   2:2bf346072a24
| |  user:        Alice 
| |  date:        Tue Jan 11 15:15:26 2011 +0100
| |  summary:     Plus joli et plus expressif!
| |
| o  changeset:   1:f3a6c5394605
|/   user:        Alice 
|    date:        Tue Jan 11 15:34:05 2011 +0100
|    summary:     Dummy
|
@  changeset:   0:11575ff21a29
   user:        Alice 
   date:        Tue Jan 11 15:08:58 2011 +0100
   summary:     Initial commit.

Pour réparer cela, il faudrait reporter uniquement la modification 5 sur 2. Mais le but est que cela n'arrive pas, si les développeurs sont bien organisés. Par exemple, plutôt que de détruire la branche, il vaut mieux la garder, et utiliser la possibilité de fermer une branche dans mercurial (hg commit --close-branch). On peut imaginer que les très branches sont effacées périodiquement de tous les dépôts, quand il n'y a plus de souci possible.

Rebase sur un ancêtre


De façon pas très uniforme, le rebase de mercurial ne permet pas que la cible soit un ancetre de la source. C'est à dire que si on commence une branche alternative, et qu'on ne fait pas de modification dans la branche principale, on ne peut pas faire de rebase de la nouvelle branche dans la principale. Faire un simple merge ne nous permet pas de fusionner les différentes modifications. Il faut donc utiliser une routine un peu différente.

D'abord on se place dans la branche principale: hg update default. Ensuite, sans changer notre position dans l'historique, on applique sur nos fichiers les modifs appliquées dans l'autre branche: hg revert -r tip --all. Enfin, on commit ces modifications en une fois: hg ci -m "Combined.". Voila, 1 et 2 sont devenus 3:
@  changeset:   3:b29ddaf6c31c
|  tag:         tip
|  parent:      0:11575ff21a29
|  user:        Alice 
|  date:        Tue Jan 11 15:57:58 2011 +0100
|  summary:     Combined.
|
| o  changeset:   2:c0aa9c6feb0a
| |  branch:      123-new
| |  user:        Alice 
| |  date:        Tue Jan 11 15:47:36 2011 +0100
| |  summary:     Two.
| |
| o  changeset:   1:23e96c98b90e
|/   branch:      123-new
|    user:        Alice 
|    date:        Tue Jan 11 15:47:29 2011 +0100
|    summary:     One.
|
o  changeset:   0:11575ff21a29
   user:        Alice 
   date:        Tue Jan 11 15:08:58 2011 +0100
   summary:     Initial commit.

Comme avant on peut se débarasser de la vieille branche avec hg strip 1.

La morale


Je ne sais pas comment tout cela fonctionne dans la durée et à grande échelle. J'imagine que parfois c'est plus compliqué que sur ce petit exemple, mais cela me semble valoir le coup d'essayer. Et ça a l'air de marcher pour des gens.

La question restante est donc surtout: quand et comment se jeter à l'eau avec savonet?

Libellés :