merge – Sam & Max http://sametmax.com Du code, du cul Wed, 23 Dec 2020 13:35:02 +0000 en-US hourly 1 https://wordpress.org/?v=4.9.7 32490438 Configurer un outil de merge pour git sous Windows http://sametmax.com/configurer-un-outil-de-merge-pour-git-sous-windows/ http://sametmax.com/configurer-un-outil-de-merge-pour-git-sous-windows/#comments Sat, 08 Mar 2014 23:04:12 +0000 http://sametmax.com/?p=9725 git mergetool. Le plus simple à configurer est kdiff3.]]> Si vous utilisez msysgit, il n’y a pas d’outil pratique préinstallé pour gérer ce qui suit un git mergetool. Le plus simple à configurer est kdiff3. Il suffit de le télécharger et installer, puis de rajouter ceci dans votre fichier .gitconfig :

[diff]
    tool = kdiff3
	keepBackup = false
	prompt = false
[merge]
    tool = kdiff3
	keepBackup = false
	prompt = false
[mergetool "kdiff3"]
    path = C:/Program Files (x86)/KDiff3/kdiff3.exe
    trustExitCode = false

Remplacez “C:/Program Files (x86)/KDiff3/kdiff3.exe” par le bon chemin selon votre système, bien entendu.

kdiff3 a tendance à laisser trainer des fichiers *.orig après le merge, et ça peut se régler dans Configure/Options > Directory Merge > Backup Files (*.orig).

Ainsi votre prochain merge ouvrira tranquilement kdiff3 pour chaque conflit.

]]>
http://sametmax.com/configurer-un-outil-de-merge-pour-git-sous-windows/feed/ 2 9725
Mesurer les performances d’un snippet Python http://sametmax.com/mesurer-les-performances-dun-snippet-python/ http://sametmax.com/mesurer-les-performances-dun-snippet-python/#comments Sat, 08 Jun 2013 20:20:52 +0000 http://sametmax.com/?p=6369 batbelt, et paf, quelques jours plus tard on a notre première contribution.]]> Ca fait longtemps que j’avais pas musique.

L’open source c’est fantastique. Github c’est merveilleux. On parle de batbelt, et paf, quelques jours plus tard on a notre première contribution.

Ce qui est sympa avec cette lib, c’est que ce sont des fonctions simples et courtes, axées sur des tâches très précises. Le code est donc généralement intéressant à lire, mais pas trop compliqué, et on tombe souvent sur des cas d’école.

C’est le cas de cette modification de la fonction dmerge(). Originalement, dmerge permet de créer un dictionnaire à partir de la fusion de de 2 autres dicos, et ça marchait comme ça:

def dmerge(d1, d2):
    d = {}
    d.update(d1)
    d.update(d2)
    return d

Facile à comprendre, rapide, simple, et le comportement par défaut est intuitif : si une clé est en double, la clé du deuxième dico écrase la première.

Comme le précisait Etienne, ce comportement est néanmoins un parmi de nombreux possibles. On pourrait aussi vouloir:

  • Lever une exception en cas de doublon.
  • Concaténer ou additionner les doublons.
  • Mettre les doublons dans une liste.
  • Garder la valeur originale en cas de doublon.
  • Prendre la valeur la plus grande / petite en cas de doublon.
  • Etc.

Pour cela, notre contributeur a proposé qu’on puisse passer en paramètre une fonction qui choisit quelle valeur garder en cas de doublon. Le code devient donc :

def dmerge(d1, d2, merge_func=lambda v1, v2: v2):
    d = {}
    d.update([(k, v) for k, v in d1.iteritems() if k not in d2])
    d.update([(k, v) for k, v in d2.iteritems() if k not in d1])
    d.update([(k, merge_func(v, d2[k])) for k, v in d1.iteritems() if k in d2])
    return d

Avoir une valeur par défaut nous permet de garder le comportement initial. Pour cela on utilise une lambda. C’est ce qu’on appelle l’injection de dépendance.

Comme j’ai bien faire mumuse avec Python, je me suis demandé : “quelle est la stratégie de merge la plus rapide ?”. J’ai donc fait plusieurs tests de merge, avec plusieurs algos. Voici ce que ça donne:

 from itertools import chain
  
  
 def dmerge1(d1, d2, merge_func=None):
     """
         Mon premier essai, en virant la lambda et en utilisant itertools.
     """
     d = {}
  
     if merge_func is None:
  
         d.update(d1)
         d.update(d2)
         return d
  
     for k, v in chain(d1.iteritems(), d2.iteritems()):
         if k in d1 and k in d2:
             d[k] = merge_func(d1[k], d2[k])
         else:
             d[k] = v
  
     return d
  
  
  
 def dmerge2(d1, d2, merge_func=lambda v1, v2: v2):
     """
         Le code du PR original
     """
     d = {}
     d.update([(k, v) for k, v in d1.iteritems() if k not in d2])
     d.update([(k, v) for k, v in d2.iteritems() if k not in d1])
     d.update([(k, merge_func(v, d2[k])) for k, v in d1.iteritems() if k in d2])
     return d
  
  
 def dmerge3(d1, d2, merge_func=None):
     """
        Un mélange du code original et de mon premier essai.
     """
     d = {}
  
     d.update(d1)
  
     if merge_func is None:
         d.update(d2)
         return d
  
     for k, v in d2.iteritems():
         if k in d:
             d[k] = merge_func(d[k], v)
         else:
             d[k] = v
  
     return d
  
  
  
 def dmerge4(d1, d2, merge_func=None):
     """
         Le code original, en virant la lambda
     """
     d = {}
  
     if merge_func is None:
         d.update(d1)
         d.update(d2)
         return d
  
     d.update([(k, v) for k, v in d1.iteritems() if k not in d2])
     d.update([(k, v) for k, v in d2.iteritems() if k not in d1])
     d.update([(k, merge_func(v, d2[k])) for k, v in d1.iteritems() if k in d2])
     return d
  
  
 if __name__ == "__main__":
  
     import random
     import timeit
  
     print('Generate test dicts')
  
     # pour que le test soit juste, il faut créer plusieurs types de dicos:
     # des longs, et des courts avec plein de collisions ou moins
     d1 = {random.randint(0, 100): 'd1' for x in xrange(10000)}
     d2 = {random.randint(0, 100): 'd2' for x in xrange(10000)}
     d3 = {random.randint(0, 10000000): 'd1' for x in xrange(1000000)}
     d4 = {random.randint(0, 10000000): 'd2' for x in xrange(1000000)}
  
     merge_to_list = lambda a, b: [a, b]
  
     # ensuite il faut s'assurer que toutes ces fonctions retournent bien
     # la même chose

     print("Testing returns value all match")
  
     assert (dmerge1(d1, d2) == dmerge2(d1, d2)
             == dmerge3(d1, d2) == dmerge4(d1, d2))
     assert (dmerge1(d1, d2, merge_to_list) == dmerge2(d1, d2, merge_to_list)
            == dmerge3(d1, d2, merge_to_list) == dmerge4(d1, d2, merge_to_list))
  
     assert (dmerge1(d3, d4) == dmerge2(d3, d4)
             == dmerge3(d3, d4) == dmerge4(d3, d4))
     assert (dmerge1(d3, d4, merge_to_list) == dmerge2(d3, d4, merge_to_list)
             == dmerge3(d3, d4, merge_to_list) == dmerge4(d3, d4, merge_to_list))
  
     assert (dmerge1(d1, d4) == dmerge2(d1, d4)
             == dmerge3(d1, d4) == dmerge4(d1, d4))
     assert (dmerge1(d1, d4, merge_to_list) == dmerge2(d1, d4, merge_to_list)
             == dmerge3(d1, d4, merge_to_list) == dmerge4(d1, d4, merge_to_list))
  
  
     # enfin on lance l'évaluation du temps d'éxécution avec timeit()

     print("Start timing")
  

     # ce code est exécuté une fois par appel de timeit
     # notez l'astuce 'from __main__ import x' qui importe du code
     # du fichier en cours, ce qui sert rarement
     setup = '''from __main__ import (dmerge1, dmerge2, dmerge3, dmerge4,
                                      d1, d2, d3, d4, merge_to_list)'''
  
  

     # ensuite on fait des appels à timeit : 
     # - le premier paramètre est le code à mesurer: il faut qu'il 
     #   soit le plus simple et court possible
     # - le second et le code d'initialisation avant le test (hors mesure)
     # - le 3e est le nombre de fois que le code va être appelé. 


     # on va tester chaque fonction pour chaque type de dico, une fois
     # avec l'approche par défaut, et une fois avec une fonction de merge
     # personnalisée
     print "Lots of collisions"
  
     print "Default merge strategy"
     print "1", timeit.timeit("dmerge1(d1, d2)", setup=setup, number=1000000)
     print "2", timeit.timeit("dmerge2(d1, d2)", setup=setup, number=1000000)
     print "3", timeit.timeit("dmerge3(d1, d2)", setup=setup, number=1000000)
     print "4", timeit.timeit("dmerge4(d1, d2)", setup=setup, number=1000000)
  
     print "Custom merge strategy"
     print "1", timeit.timeit("dmerge1(d1, d2, merge_to_list)",
                               setup=setup, number=100000)
     print "2", timeit.timeit("dmerge2(d1, d2, merge_to_list)",
                               setup=setup, number=100000)
     print "3", timeit.timeit("dmerge3(d1, d2, merge_to_list)",
                               setup=setup, number=100000)
     print "4", timeit.timeit("dmerge4(d1, d2, merge_to_list)",
                               setup=setup, number=100000)
  

    # le nombre de répétitions est bien plus faible ici car sinon le test
    # est très très long

     print "Long dictionaries"
  
     print "Default merge strategy"
     print "1", timeit.timeit("dmerge1(d3, d4)", setup=setup, number=100)
     print "2", timeit.timeit("dmerge2(d3, d4)", setup=setup, number=100)
     print "3", timeit.timeit("dmerge3(d3, d4)", setup=setup, number=100)
     print "4", timeit.timeit("dmerge4(d3, d4)", setup=setup, number=100)
  
     print "Custom merge strategy"
     print "1", timeit.timeit("dmerge1(d3, d4, merge_to_list)",
                               setup=setup, number=100)
     print "2", timeit.timeit("dmerge2(d3, d4, merge_to_list)",
                               setup=setup, number=100)
     print "3", timeit.timeit("dmerge3(d3, d4, merge_to_list)",
                               setup=setup, number=100)
     print "4", timeit.timeit("dmerge4(d3, d4, merge_to_list)",
                               setup=setup, number=100)
  
     print "Mixed dictionaries"
  
     print "Default merge strategy"
     print "1", timeit.timeit("dmerge1(d1, d4)", setup=setup, number=100)
     print "2", timeit.timeit("dmerge2(d1, d4)", setup=setup, number=100)
     print "3", timeit.timeit("dmerge3(d1, d4)", setup=setup, number=100)
     print "4", timeit.timeit("dmerge4(d1, d4)", setup=setup, number=100)
  
     print "Custom merge strategy"
     print "1", timeit.timeit("dmerge1(d1, d4, merge_to_list)",
                              setup=setup, number=100)
     print "2", timeit.timeit("dmerge2(d1, d4, merge_to_list)",
                              setup=setup, number=100)
     print "3", timeit.timeit("dmerge3(d1, d4, merge_to_list)",
                              setup=setup, number=100)
     print "4", timeit.timeit("dmerge4(d1, d4, merge_to_list)",
                              setup=setup, number=100)
  

# Et voici le résultat que ça nous ressort. On voit clairement 
# que la 3eme fonction donne les meilleurs perfs, du coup
# c'est celle qu'on a choisit
  
 ## Generate test dicts
 ## Testing returns value all match
 ## Start timing
 ## Lots of collisions
 ## Default merge strategy
 ## 1 19.9299340248
 ## 2 148.185166121
 ## 3 21.2276539803
 ## 4 21.2074358463
 ## Custom merge strategy
 ## 1 30.646312952
 ## 2 18.522135973
 ## 3 14.0125968456
 ## 4 18.5139119625
 ## Long dictionaries
 ## Default merge strategy
 ## 1 84.4819910526
 ## 2 383.444111109
 ## 3 80.7273669243
 ## 4 86.0287930965
 ## Custom merge strategy
 ## 1 294.41114521
 ## 2 377.38009119
 ## 3 154.505481005
 ## 4 256.771039963
 ## Mixed dictionaries
 ## Default merge strategy
 ## 1 19.9574320316
 ## 2 87.1410660744
 ## 3 19.3570361137
 ## 4 19.524998188
 ## Custom merge strategy
 ## 1 60.6157000065
 ## 2 86.3876900673
 ## 3 59.0331327915
 ## 4 87.0504939556
 ## [Finished in 2494.7s]
 

Le test complet a pris en tout 2494.7s, soit 41 minutes, pour tourner sur ma machine, et je l’ai lancé plusieurs fois avec différentes modifs au fil de la journée. Il faut vraiment est un geek pour aimer faire ce genre de connerie. Parce que soyons honnête, tout le monde s’en branle des perfs de cette fonction :-)

Il faut savoir aussi qu’après coup j’ai réalisé que son code utilisait des listes en intention, et non des expressions génératrices, ce qui fait qu’il faut attendre que toute la liste soit générée avec que update() fasse son travail.

Il y avait donc des cas supplémentaires à tester et j’avais mergé son code dans batbelt. Arf, l’optimisation n’a jamais de fin ^^

Bref, j’ai relancé un test avec avec des listes en intentions (et même avec des expressions ternaires ce qui donne des trucs capilotractés):

def dmerge1(d1, d2, merge_func=None):
    """
        Une version de l'ancien dmerge3 qui utilise une expression génératrice.
    """
    d = {}

    d.update(d1)

    if merge_func is None:
        d.update(d2)
        return d

    d.update((k, (v if k not in d else merge_func(d[k], v))) for k, v in d2.iteritems())

    return d



def dmerge2(d1, d2, merge_func=lambda v1, v2: v2):
    """
        La version du proposée par notre gentil contributeur, mais
        avec des expressions génératrices à la place des listes en 
        intention.
    """
    d = {}
    d.update((k, v) for k, v in d1.iteritems() if k not in d2)
    d.update((k, v) for k, v in d2.iteritems() if k not in d1)
    d.update((k, merge_func(v, d2[k])) for k, v in d1.iteritems() if k in d2)
    return d



def dmerge3(d1, d2, merge_func=None):
    """
      La version la plus rapide du test précédent.
    """
    d = {}

    d.update(d1)

    if merge_func is None:
        d.update(d2)
        return d

    for k, v in d2.iteritems():
        if k in d:
            d[k] = merge_func(d[k], v)
        else:
            d[k] = v

    return d


def dmerge4(d1, d2, merge_func=None):
    """
        La version proposée par notre contributeur, optimisée comme un connard.
    """
    d = {}

    if merge_func is None:
        d.update(d1)
        d.update(d2)
        return d

    d.update((k, v) for k, v in d1.iteritems() if k not in d2)
    d.update((k, v) for k, v in d2.iteritems() if k not in d1)
    d.update((k, merge_func(v, d2[k])) for k, v in d1.iteritems() if k in d2)
    return d


## Generate test dicts
## Testing returns value all match
## Start timing
## Lots of collisions
## Default merge strategy
## 1 12.2690660954
## 2 97.121183157
## 3 12.1084120274
## 4 12.6807589531
## Custom merge strategy
## 1 8.73903894424
## 2 12.0493769646
## 3 8.00074601173
## 4 11.4764301777
## Long dictionaries
## Default merge strategy
## 1 54.5233860016
## 2 218.695598841
## 3 70.213809967
## 4 61.8348701
## Custom merge strategy
## 1 103.258821964
## 2 217.720175982
## 3 96.5204670429
## 4 214.480421066
## Mixed dictionaries
## Default merge strategy
## 1 19.169850111
## 2 66.9599928856
## 3 19.4371211529
## 4 19.5183057785
## Custom merge strategy
## 1 64.7940750122
## 2 66.9712991714
## 3 58.5918440819
## 4 67.5281140804
## [Finished in 1619.3s]

La bonne nouvelle pour moi, c’est que l’algo 3 est toujours le plus rapide, j’ai pas à re pusher.

Mais il est intéressant de constater que globalement les 4 tests mettent 1619.3s à s’exécuter. Globalement utiliser des expressions génératrices boost bien les perfs.


Télécharger le code de l’article.

]]>
http://sametmax.com/mesurer-les-performances-dun-snippet-python/feed/ 6 6369
Être alerté par Git quand un fichier a été modifié durant le dernier merge http://sametmax.com/etre-alerte-par-git-quand-un-fichier-a-ete-modifie-durant-le-dernier-merge/ http://sametmax.com/etre-alerte-par-git-quand-un-fichier-a-ete-modifie-durant-le-dernier-merge/#comments Mon, 28 Jan 2013 12:25:50 +0000 http://sametmax.com/?p=4303 Il y a certains fichiers comme les migrations ou les fichiers de settings dont vous avez besoin de connaître l’état. Si un collègue les modifie et les push, vous voulez le savoir au moment du pull. Bien sûr vous pouvez scruter la liste des modifs à ce moment là, mais n’est-ce pas tellement mieux si votre ordinateur fait ça pour vous et affiche une grosse alerte rouge ?

Mettez le code suivant du le fichier .git/hooks/post-merge de votre repo en local :

#!/bin/bash

# On met ici tous les fichiers (ou pattern de nom) qu'on veut surveiller
# changez les pour les adapter à votre projet
files=('settings.py' 'migrations');

# on récupère tous les noms de fichiers modifiés depuis le dernier merge
modified_files=`git diff "HEAD@{1}" --name-only`

# on boucle sur chaque nom de fichier surveillé
for watched_file in "${files[@]}"; do

    # on liste tous les fichiers modifiés qui correspondent à ce nom de
    # fichier surveillé
    modified_watched_files=(`echo "$modified_files" | grep $watched_file`)

    # si le nombre de fichiers correspondant est plus grand que 0
    if [ ${#modified_watched_files[@]} ]; then

        # pour chaque fichier qui correspond, on affiche un avertissement
        # en rouge
        for modified_watched_file in "${modified_watched_files[@]}"; do

            echo -e "\e[41m "$modified_watched_file" has changed \033[0m"

        done


    fi

done

Le hook post-merge est exécute après git pull (et seulement si il n’y a pas eu de conflit). Il va afficher une bonne grosse alerte en rouge pour chaque fichier surveillé qui a été modifié entre avant et après le pull.

Notez au passage la syntaxe intuitive de bash pour utiliser des tableaux. On dirait presque que le mec qui a codé bash était un pote du gars qui a codé git.

]]>
http://sametmax.com/etre-alerte-par-git-quand-un-fichier-a-ete-modifie-durant-le-dernier-merge/feed/ 1 4303
Accepter tous les fichiers de l’autre ou garder les siens lors d’un merge Git http://sametmax.com/accepter-tous-les-fichiers-de-lautre-ou-garder-les-siens-lors-dun-merge-git/ http://sametmax.com/accepter-tous-les-fichiers-de-lautre-ou-garder-les-siens-lors-dun-merge-git/#comments Mon, 20 Aug 2012 22:50:25 +0000 http://sametmax.com/?p=1801 Parfois le merge peut être très simple. Vous avez fait de la merde et vous voulez oublier tout votre travail, et prendre celui de l’autre. Ou l’inverse.

Après git pull:

git checkout --theirs .

Pour garder toutes les modifs des autres.

OU

git checkout --ours .

Pour garder les siennes.

On peut remplacer . (le point) par un chemin de fichier pour être plus selectif.

Ces commandes vont juste mettre les fichiers voulus dans la copie de travail. Pour terminer le merge, il faut faire évidement un petit add et un commit.

]]>
http://sametmax.com/accepter-tous-les-fichiers-de-lautre-ou-garder-les-siens-lors-dun-merge-git/feed/ 2 1801
Une astuce pour ne plus avoir peur des merges avec Git http://sametmax.com/une-astuce-pour-ne-plus-avoir-peur-des-merges-avec-git/ http://sametmax.com/une-astuce-pour-ne-plus-avoir-peur-des-merges-avec-git/#comments Mon, 16 Jul 2012 16:05:13 +0000 http://sametmax.com/?p=1147 Git merge. Les dents se serrent. Les fesses aussi. Est-ce que je vais tout pêter ?]]> Pendant longtemps, Max et moi on a joué à celui qui push le premier pour que l’autre se tape le merge. Parfois on est pas en forme, le terrain est trop lourd, les sangliers ont mangé des cochoneries… Bref, on perd la course, et la goutte de sueur au bord de la tempe, on se lance dans le Git merge.

Les dents se serrent. Les fesses aussi. Est-ce que je vais tout pêter ?

Bonne nouvelle, Git ne perd rien

Quand on ne connait pas Git, on a peur de perdre son travail parcequ’on assimile le fait qu’on ne peut plus trouver son travail avec sa destruction. En fait, Git ne supprime rien (du moins, si aucune référence ne pointe sur les données – ce qui n’arrive jamais pour votre histo – vous avez 2 bonnes semaines devant vous).

Donc, si vos données sont commitées (les fichiers modifiés non commités ne comptent pas, évidement), ils sont quelques part dans les méandre du répository.

On peut utiliser cela à son avantage

Git branch, le point de sauvegarde avant d’attaquer le boss final

Quand vous avez un truc tendu à faire (genre un merge de mamouth), la stratégie du débutant est de faire un gros copier/coller du repo. Et si ça merde, on supprime, et renomme, et hop, c’est tout neuf.

Ca marche (on l’a tous fait), mais c’est un peu con.

Il existe une stragétie beaucoup plus maline: simplement créer une branche

  1. Assurez-vous que votre copie de travail est propre (git stash au besoin). Vous devez avoir le message: “nothing to commit (working directory clean)“.
  2. git branch point_de_sauvegarde
  3. git merge mamouth

Le 1 est très important, si vous êtes débutant et que vous l’oubliez, vous êtes dead. Il faudrait faire une option dans git qui interdit un merge sur une copie de travail en cours de modification sous peine de choc électrique par le clavier (ou juste automatiquement, mais c’est moins fun).

Si le merge a marché: bingo, on peut supprimer la sauvegarde avec git branch -d point_de_sauvegarde et commiter le merge git commit -m "Je l'ai faiiiiiiiiiiit".

Sinon, on revient au point de sauvegarde: git reset --hard point_de_sauvegarde puis git branch -d point_de_sauvegarde

Et voilà, comme si rien ne s’était passé !

Heu, ok c’est magique, mais ça marche comment ?

Ca marche d’abord en agissant prudement, et en ne faisant pas de merge sur une copie de travail en cours de modification. C’est la moitié du boulot.

Ensuite, on créé une nouvelle branche. Une branche n’est qu’un pointeur, ce n’est rien d’autre qu’un panneau disant “ici c’est ‘point de sauvegarde'”. Ce n’est pas un bras d’un arbre comme dans SVN. C’est une putain d’étiquette toute simple.

Donc en créant une branche, on met juste une étiquette sur le commit que l’on veut garder. Les commits de l’historique de Git sont INALTERABLES. Même avec un rebase, contrairement à la croyance populaire, on ne peut pas les supprimer. Le seul moyen de supprimer un commit est de le laisser sans aucun accès (sans étiquette ni parent avec étiquette) pendant 2 semaines ou de forcer le nettoyage avec git gc.

En clair, vous ne pouvez pas perdre un commit. Votre collègue ne peut pas vous niquer un commit par accident. La seule chose à faire si vous tenez à un commit, c’est de mettre un moyen pour le retrouver facilement. Ici, on lui met une étiquette.

Puis finalement, si ça marche, on supprime l’étiquette.

Si ça ne marche pas, on demande un hard reset depuis le point de sauvegarde. Un hard reset remet à plat votre copie de travail (les fichiers du disque), et l’index.

Cela marche pour une seule raison: git merge ne commit pas.

Si le merge est un fast forward, Git va juste se déplacer d’une case en avant. Si le merge est plus complexe, il va vous demander de commiter.

Dans le cas un, reset vous fait juste reculer d’une case. Dans le cas deux, tant que vous ne commitez pas, le hard reset annule tout ce qui a été fait, et le nouveau commit n’est jamais créé.

En résumé, avant de faire un truc qui vous fait peur avec git

  • Verifiez que la copie de travail est propre.
  • git branch point_de_sauvegarde.
  • Si ça marche, git commit (parfois inutile, mais ça ne coûte rien).
  • Si ça marche pas, git reset --hard point_de_sauvegarde.

L’astuce pour les levels 99

Une fois que vous êtes à l’aise avec cette idée, vous réaliserez qu’en fait le point de sauvegarde n’est pas du tout obligatoire. Puisque votre merge est accessible, tous ses parents le sont. Avec git log, vous pouvez retrouver l’ID du parent et faire la même chose:

git reset --hard 1234abcd

Avec 1234abcd comme id du parent :-)

Si ça ne vous parles pas, restez avec le point de sauvegarde, c’est une bonne technique.

]]>
http://sametmax.com/une-astuce-pour-ne-plus-avoir-peur-des-merges-avec-git/feed/ 7 1147