Savoir qui modifie quoi est une information précieuse, mais rarement exploitée.
Un dépôt Git contient pourtant toutes les données nécessaires pour reconstruire la cartographie de ses contributions.
Même si je me doute que certaines personnes sont susceptibles d’utiliser ces techniques dans un but de surveillance punitive ou de les confondre avec des métriques de productivité, il est essentiel de ne pas tomber dans ce panneau.
Nous aimerions juste écouter ce que dit le code sur les dynamiques collectives pour identifier les zones à risque, améliorer la communication et renforcer la résilience des équipes.
En pratique, on combine normalement ces analyses avec d’autres ateliers comme des interviews, des sessions de pairing/mobbing, etc pour interpréter convenablement les données exploitées.
Dans cet article, nous allons apprendre à extraire ces informations, construire un graphe et générer des premières visualisations pour commencer à observer la structure cachée d’une organisation.
Nous utiliserons Python pour effectuer nos opérations. N’étant pas un expert, je ne vais pas écrire du code compliqué ni chercher à l’optimiser : le but n’est pas d’aller en production, mais d’explorer.
Plus précisément, nous utiliserons Jupyter, un outil souvent utilisé par les data scientists.
Mon code est accessible ici : n’hésitez pas à le modifier et à l’adapter à votre context.
Pour les développeurs qui ne sont pas familiers avec, un notebook Jupyter est un document interactif combinant du code exécutable, des visualisations et du texte explicatif dans une interface web. Cela permet d’enregistrer et de partager des analyses très facilement.
Récupération du log
Spring Boot sera notre exemple de dépôt. Il s’agit d’un framework de développement, très populaire au sein de la communauté Java.
Comme il s’agit d’un projet open-source, ayez à l’esprit que les dynamiques organisationnelles que nous découvrirons seront probablement très différentes de celles existant dans une entreprise.
Les techniques que nous allons voir s’appliquent à n’importe quel dépôt Git. Le langage de programmation utilisé est sans importance.
Lancez un terminal et accédez au dossier de votre dépôt, puis exécutez la commande suivante1 :
git log --pretty='format:%H|%an|%ad' --numstat --date=iso | head
Voici la signification des différents arguments utilisés avec l’option pretty
:
Le paramètre numstat
affiche le nombre de lignes ajoutées et supprimées par fichier.
Voici un exemple annoté de sortie :
C’est top, mais cette approche est cependant insuffisante, car un fichier renommé ou déplacé apparaîtra dans le log à la fois sous son nouveau nom, mais aussi ses anciens, faussant l’analyse.
Si on veut comptabiliser les fichiers renommés correctement, on doit ajouter le paramètre follow
à la commande. Cependant, celui-ci ne peut pas être utilisé pour analyser un dépôt complet, seulement un fichier.
Par exemple :
git log --pretty='format:%H|%an|%ad' --numstat --date=iso --follow -- spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java
retourne l’historique pour le fichier dans le même format que précédemment en incluant les renommages :
Dans l’image ci-dessus, extraite du log, j’ai entouré les renommages qui ont eu lieu pour le fichier en question. Git utilise une syntaxe compacte pour décrire l’ancien et le nouveau nom :
/chemin_non_modifié/{ancien/chemin => nouveau/chemin}/nomFichier.java
Notre stratégie pour récupérer les données va donc être la suivante :
récupérer dans un premier temps la liste des fichiers du dépôt
pour chaque fichier, exécuter la commande précédente pour en avoir l’historique
extraire du log du fichier les infos que l’on souhaite (SHA-1, auteur, date, nom du fichier, les métriques comme le nombre de lignes ajoutées ou supprimées)
La récupération des fichiers du dépôt se fait avec la commande git ls-files
Si on traduit cela en Python, voici ce que cela donne :
def git_log_to_csv(repo_path, output_csv, start_date = None):
# ...
files = get_repo_files(repo_path)
output_path = Path(output_csv)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL)
writer.writerow(['commit', 'author', 'date', 'file', 'added', 'removed'])
for file in files:
log = retrieve_git_log(repo_path, start_date, file)
process_log(log, writer)
On récupère les chemins des fichiers avec
get_repo_files
On crée le dossier de sortie
Pour chaque fichier du dépôt, on retrouve son log git avec
retrieve_git_log
On parse le log et on le reformate dans un fichier CSV avec
process_log
Un paramètre supplémentaire (start_date)
permet de spécifier la date à partir de laquelle commencer l’analyse.
En effet, on a rarement besoin de récupérer tout le log depuis sa création. Nous nous intéressons le plus souvent aux dynamiques les plus récentes, surtout pour les dépôts qui ont une longue histoire.
Voici le contenu de la fonction process_log
:
def process_log(log, writer):
current_commit, current_author, current_date = None, None, None
for line in log:
if is_line_empty(line):
continue
elif is_line_with_date_info(line):
current_commit, current_author, current_date = process_metadata_line(line)
elif is_line_with_file_info(line):
added, removed, file = process_file_line(line)
if added is not None:
writer.writerow([current_commit, current_author, current_date, file, added, removed])
Elle paraît complexe, mais voici ce qu’elle fait :
pour chaque ligne du log git:
si la ligne est vide, on ne fait rien
si la ligne contient les metadonnées (SHA-1, auteur, date), on les récupère
si la ligne contient les stats du fichier, on les récupère et on écrit une ligne dans le fichier CSV
La fonction git_log_to_csv
se trouve dans le fichier git.py
. Pour l’utiliser dans le notebook, exécutez simplement :
from git import git_log_to_csv
git_log_to_csv('./spring-boot', './output/spring_boot_log.csv')
Le dossier spring-boot contient le projet cloné.
L’extraction des logs prend plusieurs minutes (sur ma machine). Voici un extrait de celui obtenu pour Spring Boot :
Analyse des résultats
Pour analyser les données extraites, nous allons utiliser pandas, une bibliothèque Python très populaire pour la manipulation de données, notamment utilisée en data science.
Même si vous n'avez jamais utilisé pandas
, vous pouvez l'imaginer comme un tableur dans votre code Python : il permet de lire, filtrer, trier et transformer des données tabulaires (comme celles qu'on trouve dans un fichier CSV), de manière très efficace.
Voici comment charger un fichier CSV dans un DataFrame, la structure centrale de pandas
(elle représente une table de données en mémoire) :
import pandas as pd
df = pd.read_csv(csv_file, on_bad_lines='skip')
Une fois qu’on a le DataFrame
, on peut effectuer tout un tas d’analyses. Par exemple, afficher le top 10 des fichiers les plus révisés :
from IPython.display import HTML # permet d'afficher joliment des tableaux HTML
top_10 = (
df.groupby('file')['commit']
.nunique()
.sort_values(ascending=False)
.head(10)
.reset_index(name='revisions')
)
HTML(top_10.to_html(escape=False))
Décomposons ce code pour le rendre plus clair :
df.groupby('file')['commit']
On regroupe les lignes du tableau par nom de fichier. Pour chaque fichier, on s'intéresse uniquement à la colonne des commits..nunique()
On compte le nombre de commits uniques associés à chaque fichier — autrement dit, combien de fois chaque fichier a été modifié..sort_values(ascending=False)
On trie les fichiers du plus modifié au moins modifié..head(10)
On garde seulement les 10 premiers..reset_index(name='revisions')
On remet le tableau à plat et on nomme la colonne avec les comptes"revisions"
.
Ce bloc de code nous donne une vue d’ensemble des fichiers les plus “vivants” du projet — ceux qui ont été modifiés le plus souvent. Cela peut nous aider à repérer les zones de code les plus actives, instables ou critiques.
La sortie pour Spring Boot :
On peut également visualiser ces résultats sous forme de distribution, à l’aide de la matplotlib :
import matplotlib.pyplot as plt # outil standard pour créer des graphiques
plt.figure(figsize=(10, 6)) # les unités sont des pouces
top_10.plot(kind='bar') # choix du type du diagramme
plt.title(f"Top 10 Modified Files")
plt.xlabel("File")
plt.ylabel("Revisions")
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()
On obtient :
Notez que pour ne pas alourdir le graphe, je n’ai affiché que les positions des fichiers sur l’axe des abscisses. Si vous souhaitez afficher les noms des fichiers, vous pouvez créer le diagramme ainsi :
top_10.plot(kind='bar', x='file', y='revisions')
Interprétation
On remarque rapidement que seule une poignée de fichiers concentre la majorité des modifications, tandis que la grande majorité reste presque inchangée tout au long de l'historique du projet.
Ce type de distribution très déséquilibrée est en réalité la norme dans les projets logiciels — et non une exception. On parle parfois d'effet "80/20" ou de loi de Pareto appliquée au code : environ 20 % des fichiers concentrent 80 % de l’activité.
Cette observation est précieuse : ces fichiers fréquemment modifiés sont souvent au cœur du système, instables ou source de bugs potentiels. Ils méritent en général une attention particulière (tests, revue de code, documentation...).
Si le sujet vous intéresse, je vous recommande vivement les livres d’Adam Tornhill, qui explore ces dynamiques à l’aide de l’historique Git et d’analyses comportementales du code.
De notre côté, nous allons continuer la construction de notre graphe reliant les auteurs aux fichiers du dépôt.
Construction du graphe
Pour représenter les relations entre les auteurs et les fichiers modifiés, nous allons construire un graphe biparti à l’aide de la bibliothèque networkx, spécialisée dans la modélisation et l’analyse de graphes.
Un graphe biparti est un graphe dont les nœuds appartiennent à deux ensembles distincts, et où les connexions ne se font qu’entre ensembles.
Dans notre cas :
Un ensemble pour les auteurs
Un autre pour les fichiers
Chaque lien indique qu’un auteur a modifié un fichier.
Créer un graphe à partir d’un DataFrame
est très simple :
import networkx as nx
G = nx.Graph() # création d'un graphe vide
# Regrouper par auteur et fichier pour compter combien de fois chaque auteur a modifié chaque fichier
grouped = df.groupby(['author', 'file'])['commit'].nunique().reset_index(name='revisions')
for _, row in grouped.iterrows():
# Ajouter un nœud pour l’auteur (si non existant)
G.add_node(row['author'], type='author')
# Ajouter un nœud pour le fichier (si non existant)
G.add_node(row['file'], type='file')
# Créer un lien entre l’auteur et le fichier, pondéré par le nombre de révisions
G.add_edge(row['author'], row['file'], weight=row['revisions'])
Chaque nœud est typé (
'author'
ou'file'
), ce qui permettra par la suite de les filtrer, colorer ou analyser différemment.Le poids de chaque lien (
weight
) correspond au nombre de commits dans lesquels l’auteur a modifié ce fichier. Cela donne une indication de l’intensité de la relation.
Pour afficher des infos sur le graphe créé, on exécute simplement :
print(G)
Pour le dépôt Spring Boot, on obtient :
Graph with 4851 nodes and 7157 edges
La fonction suivante permet d’afficher visuellement le graphe biparti que nous avons construit (entre auteurs et fichiers).
Elle utilise à nouveau matplotlib
pour le rendu graphique et networkx
pour la gestion des positions et des objets du graphe.
import networkx as nx
import matplotlib.pyplot as plt
def plot_graph(G, node_size=30, edge_alpha=0.1, figsize=(12, 12)):
plt.figure(figsize=figsize)
pos = nx.kamada_kawai_layout(G)
# Sépare les auteurs des fichiers
authors = [n for n, d in G.nodes(data=True) if d.get('type') == 'author']
files = [n for n, d in G.nodes(data=True) if d.get('type') == 'file']
# Dessiner les nœuds par type afin de leur attribuer des styles différents
nx.draw_networkx_nodes(G, pos, nodelist=authors, node_color='skyblue', node_size=node_size, label='Authors')
nx.draw_networkx_nodes(G, pos, nodelist=files, node_color='lightgreen', node_size=node_size, label='Files')
# Dessine les arêtes
nx.draw_networkx_edges(G, pos, alpha=edge_alpha)
# Affiche le graphe
plt.axis('off')
plt.title("Contributions Graph")
plt.legend()
plt.tight_layout()
plt.show()
Les paramètres de la fonction plot_graph
sont :
G
: le graphe à affichernode_size
: taille des nœuds (par défaut 30)edge_alpha
: transparence des liens (0 = invisible, 1 = opaque)figsize
: taille de la figure matplotlib en pouces
La fonction kamada_kawai_layout
de networkx calcule la position des nœuds selon un algorithme de force : les nœuds liés sont rapprochés, les autres éloignés. Il existe d’autres layouts avec lesquels vous pouvez vous amuser à expérimenter.
Voici le graphe généré pour Spring Boot :
Wow !
C’est habituellement le premier effet que ça me fait lorsque je visualise un graphe.
Le second effet est de me demander comment je vais bien pouvoir analyser tout ça… Eh bien, c’est ce que l’on va découvrir lors du prochain article 😃
Quelques remarques pour conclure
Les équipes qui pratiquent le pair programming ou l’ensemble programming laissent généralement les différents contributeurs d’un commit dans un message. Si c’est votre situation, il faudra probablement améliorer le parsing du log Git.
Dans cet article, nous n’analysons qu’un seul dépôt git, mais il est tout à fait envisageable d’agréger les logs de plusieurs dépôts pour en générer le graphe.
Vous voudrez parfois faire du nettoyage dans les données. Par exemple, supprimer des auteurs qui ont quitté le projet ou des fichiers qu’il n’est pas nécessaire de tenir compte. Dans ce cas, vous avez deux options :
faire le nettoyage avant de construire le graphe
faire le nettoyage après. C’est mon option préférée : l’objet graphe propose des fonctions pour renommer ou supprimer des nœuds facilement. Il s’occupe de nettoyer les arêtes inutiles automatiquement.
J’espère que cette plongée dans l’analyse du log git vous a intéressé et que je vous retrouverai dans le prochain article.
Ressources
Travaillant sur Mac, je ne garantis pas que la syntaxe soit identique avec d’autres systèmes. Partagez vos commandes si vous utilisez Windows, Linux ou autre et qu’elles diffèrent.