La plupart des outils de développement interagissent avec le code au repos -- des fichiers sur disque, des commits dans un dépôt, des artefacts dans un registre. 0diff interagit avec le code en mouvement. Il surveille les fichiers à mesure qu'ils changent, calcule les diffs en temps réel, filtre le bruit, attribue la modification à un humain ou un agent IA, et enregistre tout. Le pipeline entier tourne dans une boucle d'événements synchrone sans runtime async, sans threads supplémentaires au-delà du surveillant de fichiers de l'OS, et sans services externes.
Cet article est une plongée technique dans le fonctionnement de ce pipeline. Nous allons parcourir le module watcher (266 lignes), le module differ (176 lignes), le module filter (184 lignes) et le système de configuration (456 lignes) qui les relie. Chaque extrait de code provient du vrai code source de 0diff. Chaque décision de conception a une raison.
La boucle d'événements : pas d'async, pas de problème
Le coeur de 0diff est une boucle d'événements synchrone dans watcher.rs. Quand vous lancez 0diff watch, voici ce qui se passe :
- La configuration est chargée depuis
.0diff.toml. - Tous les répertoires surveillés sont scannés, et le contenu de chaque fichier correspondant est mis en cache dans un
HashMap<PathBuf, String>. - Un surveillant de fichiers au niveau OS est enregistré via la crate
notifyavecnotify-debouncer-minipour la coalescence des événements. - Le thread principal entre dans une boucle, recevant les événements du système de fichiers à travers un canal
mpscstandard.
rustpub fn run(config: Config, format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_flag = shutdown.clone();
ctrlc::set_handler(move || {
shutdown_flag.store(true, Ordering::SeqCst);
})?;
let mut file_cache: HashMap<PathBuf, String> = HashMap::new();
// ... seed cache from watched directories
let (tx, rx) = std::sync::mpsc::channel();
let mut debouncer = new_debouncer(Duration::from_millis(config.watch.debounce_ms), tx)?;
// ... register watch paths
while !shutdown.load(Ordering::SeqCst) {
match rx.recv_timeout(Duration::from_millis(250)) {
Ok(Ok(events)) => { /* handle each event */ }
Ok(Err(error)) => { /* log error */ }
Err(RecvTimeoutError::Timeout) => { /* check shutdown */ }
Err(RecvTimeoutError::Disconnected) => { break; }
}
}
let _ = history.rotate(config.history.max_size_mb, config.history.max_days);
Ok(())
}Plusieurs choix délibérés méritent d'être examinés ici.
Pourquoi mpsc synchrone au lieu de tokio::sync::mpsc ou async_channel ? Parce que 0diff n'a pas besoin d'async. La boucle d'événements a exactement une source d'événements (le surveillant de fichiers) et un consommateur (le thread principal). Il n'y a pas d'E/S concurrentes, pas d'appels réseau, pas de fan-out/fan-in. Un canal synchrone avec un timeout de réception de 250 ms nous donne tout ce dont nous avons besoin : gestion réactive des événements, vérifications périodiques d'arrêt, et zéro surcoût de runtime.
Pourquoi recv_timeout(250ms) au lieu d'un recv() bloquant ? Le timeout sert deux objectifs. Premièrement, il nous permet de vérifier le flag d'arrêt AtomicBool toutes les 250 millisecondes, assurant une sortie réactive quand l'utilisateur appuie sur Ctrl+C. Deuxièmement, il crée un battement de coeur naturel qui empêche le processus de paraître bloqué aux yeux des gestionnaires de processus ou des outils de monitoring.
Pourquoi AtomicBool pour l'arrêt au lieu d'un canal ? Le handler de la crate ctrlc s'exécute dans un contexte de signal où les allocations et les opérations complexes sont dangereuses. Un store atomique est l'une des rares opérations garanties sûres dans un handler de signal. La boucle principale le lit à chaque cycle de timeout, nous offrant un arrêt propre avec rotation de l'historique avant la sortie.
Pourquoi notify-debouncer-mini ? Les événements bruts du système de fichiers sont bruyants. Une seule sauvegarde de fichier dans la plupart des éditeurs déclenche plusieurs événements : une écriture dans un fichier temporaire, un renommage, une mise à jour de métadonnées, parfois un suppression-et-recréation. Le debouncer coalescence ces événements en un seul événement par fichier dans une fenêtre configurable (500 ms par défaut). Cela empêche 0diff de calculer le même diff trois fois pour une seule opération de sauvegarde.
Le cache de fichiers
Quand 0diff commence à surveiller, il lit le contenu actuel de chaque fichier suivi dans un HashMap<PathBuf, String> en mémoire. Ce cache sert de référence pour le calcul des diffs. Quand un fichier change, 0diff lit le nouveau contenu depuis le disque, le compare à la version en cache, puis met à jour le cache.
Cela signifie que 0diff calcule les diffs par rapport au dernier état observé, pas par rapport au HEAD git ou tout autre point de référence. C'est intentionnel. Les diffs git vous disent ce qui a changé depuis le dernier commit. Les diffs de 0diff vous disent ce qui a changé depuis la dernière fois que 0diff a vu le fichier. Dans un environnement multi-agents où les agents font des modifications rapides entre les commits, cette vue en temps réel est bien plus utile.
Le compromis est l'utilisation mémoire. Le cache contient le texte intégral de chaque fichier surveillé. Pour un projet typique avec quelques centaines de fichiers source, c'est négligeable -- peut-être 10-50 Mo. Pour un monorepo avec des millions de lignes, il faudrait configurer soigneusement les chemins de surveillance dans .0diff.toml. Le système de configuration supporte cela avec des filtres d'extensions, des préfixes de chemins et des patterns d'ignorance basés sur les globs.
Le moteur de diff
Le module differ fait 176 lignes de Rust construit sur la crate similar. similar implémente l'algorithme de diff de Myers, le même algorithme utilisé par git diff. Nous l'avons choisi plutôt que des alternatives comme diffy ou imara-diff parce qu'il fournit un accès propre aux opérations groupées avec des lignes de contexte, ce qui est exactement ce dont nous avons besoin pour produire des diffs lisibles basés sur des hunks.
rustpub fn compute_diff(old: &str, new: &str, file_path: &str) -> FileDiff {
let diff = TextDiff::from_lines(old, new);
let mut hunks = Vec::new();
let mut total_additions = 0;
let mut total_deletions = 0;
for group in diff.grouped_ops(3) {
let mut lines = Vec::new();
let mut old_start = 0;
let mut new_start = 0;
for op in &group {
for change in diff.iter_changes(op) {
let text = change.value().to_string();
match change.tag() {
ChangeTag::Equal => lines.push(DiffLine::Context(text)),
ChangeTag::Insert => {
lines.push(DiffLine::Add(text));
total_additions += 1;
}
ChangeTag::Delete => {
lines.push(DiffLine::Delete(text));
total_deletions += 1;
}
}
}
}
hunks.push(DiffHunk {
old_start,
old_count: /* computed from ops */,
new_start,
new_count: /* computed from ops */,
lines,
});
}
FileDiff {
file_path: file_path.to_string(),
hunks,
additions: total_additions,
deletions: total_deletions,
}
}L'appel grouped_ops(3) est significatif. Il regroupe les opérations de diff consécutives et inclut 3 lignes de contexte environnant pour chaque groupe, correspondant au comportement par défaut de git diff. Cela signifie que la sortie de 0diff est immédiatement familière pour tout développeur ayant lu un diff unifié.
La sortie est une structure FileDiff contenant un vecteur de DiffHunks, chacun avec des informations précises de plage de lignes (old_start, old_count, new_start, new_count) et un vecteur d'entrées DiffLine. Cette représentation structurée est ce qui permet au reste du pipeline -- filtrage, affichage, sérialisation JSON -- de travailler avec les diffs comme des données plutôt que de parser du texte.
Filtrage des espaces blancs
L'une des sources les plus courantes de bruit dans les diffs est les changements d'espaces blancs. Un éditeur reformate l'indentation. Un linter ajuste les espaces en fin de ligne. Un développeur alterne entre tabulations et espaces. Ces modifications produisent des diffs qui obscurcissent les changements significatifs.
Le module filter (184 lignes) traite cela avec une approche ciblée. Plutôt que d'ignorer tous les espaces blancs dans le calcul du diff (ce qui cacherait les changements de formatage légitimes), il post-traite les hunks de diff et supprime uniquement ceux où chaque modification est purement constituée d'espaces blancs :
rustfn is_whitespace_only_hunk(hunk: &DiffHunk) -> bool {
let adds: Vec<&str> = hunk.lines.iter()
.filter_map(|l| match l {
DiffLine::Add(s) => Some(s.as_str()),
_ => None,
})
.collect();
let dels: Vec<&str> = hunk.lines.iter()
.filter_map(|l| match l {
DiffLine::Delete(s) => Some(s.as_str()),
_ => None,
})
.collect();
// If counts don't match, it's a real structural change
if adds.len() != dels.len() {
return false;
}
// Empty hunks with only context lines are not whitespace-only
if adds.is_empty() {
return false;
}
// Every add/delete pair must differ only in whitespace
adds.iter().zip(dels.iter()).all(|(a, d)| {
normalize_whitespace(a) == normalize_whitespace(d)
})
}La logique est prudente avec les cas limites. Si le nombre d'ajouts ne correspond pas au nombre de suppressions, ce n'est pas un changement d'espaces blancs -- des lignes ont été ajoutées ou supprimées, pas simplement reformatées. S'il n'y a aucun ajout ni suppression (juste des lignes de contexte), ce n'est pas un hunk constitué uniquement d'espaces blancs. Ce n'est que lorsque chaque ligne ajoutée a une ligne supprimée correspondante et qu'elles ne diffèrent que par les espaces blancs (espaces en début, en fin, et séquences internes réduites) que le filtre supprime le hunk.
La fonction normalize_whitespace supprime les espaces blancs en début et fin de ligne, puis réduit toutes les séquences internes d'espaces blancs en un seul espace. Cela couvre les cas courants : ré-indentation, conversion tabulations vers espaces, suppression des espaces en fin de ligne, et changements d'alignement.
Ce filtrage est contrôlé par l'option de configuration filter.ignore_whitespace. Lorsqu'il est activé (par défaut), les hunks constitués uniquement d'espaces blancs sont retirés avant que la modification ne soit enregistrée. Le développeur voit toujours clairement les changements significatifs, tandis que le bruit de formatage automatisé est supprimé.
Le pipeline complet
Quand un fichier change sur le disque, le module watcher orchestre le pipeline entier. Voici le flux, simplifié mais fidèle à l'implémentation réelle :
rustfn handle_file_change(
path: &Path,
relative: &Path,
cache: &mut HashMap<PathBuf, String>,
config: &Config,
git: &GitInfo,
detector: &AgentDetector,
history: &mut HistoryStore,
format: &OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Read the new file contents
let new_contents = std::fs::read_to_string(path)?;
let old_contents = cache.get(path).cloned().unwrap_or_default();
// 2. Compute the diff against the cached version
let rel_str = relative.to_string_lossy();
let diff = differ::compute_diff(&old_contents, &new_contents, &rel_str);
// 3. Apply whitespace filtering if configured
let diff = if config.filter.ignore_whitespace {
filter::filter_whitespace_changes(diff)
} else {
diff
};
// 4. Check if the change meets the minimum threshold
if diff.hunks.is_empty()
|| (diff.additions + diff.deletions) < config.filter.min_lines_changed
{
cache.insert(path.to_path_buf(), new_contents);
return Ok(());
}
// 5. Get git metadata (author, branch)
let author = git.get_author();
let branch = git.get_branch();
// 6. Detect AI agent
let commit_info = git.get_last_commit();
let agent = detector.tag_for_entry(commit_info.as_ref());
// 7. Create and record the history entry
let entry = HistoryEntry {
timestamp: Utc::now().to_rfc3339(),
file: rel_str.to_string(),
additions: diff.additions,
deletions: diff.deletions,
author,
branch,
agent,
summary: format!("{} additions, {} deletions", diff.additions, diff.deletions),
};
history.append(&entry)?;
// 8. Display to terminal or emit JSON
display::print_change(&entry, &diff, format);
// 9. Update the cache
cache.insert(path.to_path_buf(), new_contents);
Ok(())
}L'étape 4 est le seuil de bruit. Le seuil min_lines_changed (par défaut : 1) empêche 0diff d'enregistrer des modifications triviales comme l'ajout d'un seul saut de ligne. Combiné avec le filtre d'espaces blancs, cela signifie que le journal d'historique ne contient que des modifications significatives.
Les suppressions de fichiers suivent un chemin parallèle. Quand le watcher détecte un événement de suppression, il calcule le diff comme une suppression complète (chaque ligne de la version en cache devient une suppression), l'enregistre dans l'historique et retire le fichier du cache. Cela garantit que les suppressions de fichiers sont suivies avec la même fidélité que les modifications.
Le système de configuration
Le module de configuration est le plus grand module avec 456 lignes, et pour une bonne raison. Un surveillant de fichiers qui ne peut pas être configuré est inutile -- chaque projet a des types de fichiers différents, des structures de répertoires différentes, des sources de bruit différentes.
0diff utilise TOML pour la configuration, stocké dans .0diff.toml à la racine du projet. La configuration comporte cinq sections :
[watch]-- Quels répertoires surveiller, quelles extensions de fichiers suivre, quels patterns ignorer, et l'intervalle de debounce.[filter]-- S'il faut ignorer les changements d'espaces blancs et le seuil minimum de lignes modifiées.[git]-- S'il faut extraire les métadonnées git et comment.[history]-- Où stocker le fichier d'historique, la taille maximale pour la rotation, et l'ancienneté maximale en jours.[agents]-- Patterns de détection d'agents personnalisés au-delà de ceux intégrés.
La fonction should_watch() est le gardien. Chaque événement du système de fichiers passe par elle avant tout calcul de diff :
rustpub fn should_watch(&self, path: &Path) -> bool {
// 1. Check extension is in watch.extensions
let ext = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if !self.watch.extensions.is_empty()
&& !self.watch.extensions.contains(&ext.to_string())
{
return false;
}
// 2. Check path starts with at least one watch.paths prefix
let in_watch_path = self.watch.paths.iter().any(|p| {
path.starts_with(p)
});
if !in_watch_path {
return false;
}
// 3. Check path doesn't match any watch.ignore glob pattern
for pattern in &self.watch.ignore {
if glob_match(pattern, path) {
return false;
}
}
true
}La vérification en trois étapes est ordonnée par coût. La vérification d'extension est une comparaison de chaînes -- essentiellement gratuite. La vérification de préfixe de chemin est légèrement plus coûteuse mais reste rapide. Le pattern matching glob est l'opération la plus coûteuse et n'est atteint que pour les fichiers qui passent les deux premières vérifications.
La configuration par défaut ignore les répertoires de bruit courants (target/, node_modules/, .git/, build/, dist/) et les extensions non-source courantes (images, binaires, fichiers de verrouillage). Un nouvel utilisateur peut lancer 0diff init et commencer à surveiller immédiatement sans aucune configuration manuelle. Le .0diff.toml généré inclut des commentaires expliquant chaque option, rendant la personnalisation simple.
Le module de configuration inclut 7 tests couvrant le parsing TOML, les valeurs par défaut, le filtrage par extension, le matching de préfixe de chemin et les patterns d'ignorance glob. Ces tests ont été écrits par agent-config durant la session de construction initiale et ont détecté plusieurs cas limites lors du développement ultérieur -- en particulier autour de la gestion des séparateurs de chemins sur différents systèmes d'exploitation.
La suite de tests
0diff compte 44 tests répartis sur tous les modules. La distribution reflète où la complexité réside :
- config : 7 tests -- parsing TOML, évaluation des règles de surveillance, valeurs par défaut
- differ : 8 tests -- diffs vides, ajouts seuls, suppressions seules, modifications mixtes, lignes de contexte
- filter : 6 tests -- hunks uniquement d'espaces blancs, hunks mixtes, comptages ajout/suppression non concordants, hunks vides
- git : 9 tests -- parsing de commits, extraction d'auteur, détection d'agents depuis les messages de commit, variables d'environnement, détection TTY
- history : 8 tests -- ajout, requête par auteur, requête par agent, rotation par taille, rotation par ancienneté, validation du format JSON-lines
- watcher : 3 tests -- gestion d'événements, mise à jour du cache, arrêt
- display : 3 tests -- format de sortie terminal, format de sortie JSON, génération de résumé
Les tests sont des tests unitaires, pas des tests d'intégration. Chaque module est testé isolément avec des entrées construites. C'était une décision pratique pour la construction initiale -- la session de 45 minutes n'avait pas le temps pour une infrastructure de tests d'intégration. Les tests unitaires couvrent les chemins logiques importants, et les interfaces propres des modules (données structurées en entrée, données structurées en sortie) signifient que les problèmes d'intégration sont rares.
Caractéristiques de performance
0diff est conçu pour être invisible. Il ne devrait pas ralentir votre workflow de développement ni consommer des ressources système perceptibles.
Temps de démarrage : Moins de 50 ms sur un projet typique. Le coût principal est le remplissage du cache de fichiers, qui nécessite la lecture de chaque fichier surveillé. Pour un projet avec 500 fichiers source de 200 lignes en moyenne, cela représente environ 10 Mo d'E/S -- trivial sur tout système moderne.
Latence de gestion des événements : Sous la milliseconde pour le calcul de diff sur des modifications de fichiers typiques (moins de 1 000 lignes). L'implémentation Myers de la crate similar est en O(ND) où N est le nombre total de lignes et D la distance d'édition. Pour le cas courant de petites modifications sur des fichiers de taille moyenne, cela se complète en microsecondes.
Utilisation mémoire : Proportionnelle à la taille totale des fichiers surveillés (pour le cache) plus la portion en mémoire du tampon d'événements du debouncer. Typiquement 20-100 Mo pour un projet de taille moyenne.
Utilisation disque : Le fichier d'historique JSON-lines croît à environ 200-500 octets par modification enregistrée. À 100 modifications par jour, cela fait environ 15 Ko/jour ou 5 Mo/an. Le système de rotation garantit que le fichier ne dépasse jamais max_size_mb (par défaut : 50 Mo) ou max_days (par défaut : 90 jours).
Le binaire release de 2 Mo inclut tout -- aucune dépendance runtime au-delà de l'API de notification du système de fichiers au niveau OS (inotify sur Linux, FSEvents sur macOS, ReadDirectoryChangesW sur Windows) et l'outil en ligne de commande git.
Ce que nous avons appris
Construire un surveillant de fichiers nous a enseigné plusieurs choses qui ne sont pas évidentes à la lecture de la documentation :
Les événements du système de fichiers ne sont pas fiables. Différents systèmes d'exploitation, différents systèmes de fichiers et différents éditeurs produisent des séquences d'événements différentes pour la même opération logique. Le debouncer gère la plupart de ces cas, mais nous avons quand même dû traiter les situations où nous recevons un événement de modification pour un fichier qui n'existe pas (parce qu'il a été supprimé entre l'événement et notre lecture) ou un événement de création pour un fichier qui existe déjà dans notre cache (parce que l'éditeur a fait une suppression-recréation au lieu d'une modification sur place).
L'invalidation du cache est le vrai problème. Le cache de fichiers doit rester synchronisé avec le disque. Si un fichier change pendant que nous traitons un autre événement, nous pourrions calculer un diff par rapport à des données périmées. Le debouncer aide en coalescent les changements rapides, et le pattern de mise-à-jour-du-cache-après-traitement garantit que nous enregistrons toujours la transition du dernier état connu à l'état actuel.
Le filtrage des espaces blancs est plus difficile qu'il n'y paraît. L'approche naïve (supprimer tous les espaces blancs et comparer) détruit trop d'information. Une ligne qui passe de if (x) à if ( x ) est un changement d'espaces blancs. Une ligne qui passe de return 0 à return 1 ne l'est pas. Mais une ligne qui passe de return 0 à return 0 l'est. L'approche de comparaison par paires -- faire correspondre chaque ajout avec sa suppression correspondante et comparer les formes normalisées -- gère correctement tous ces cas.
La configuration est une fonctionnalité, pas un ajout tardif. Le module de configuration est le plus grand module pour une raison. Un surveillant de fichiers sans patterns d'ignorance appropriés se noiera dans le bruit de node_modules, des artefacts de build et des fichiers générés. Un enregistreur de diffs sans seuil minimum de changement remplira l'historique de modifications d'un seul caractère. Bien choisir les valeurs par défaut et rendre la personnalisation facile est aussi important que la fonctionnalité principale.
Série : Comment nous avons construit 0diff.dev
Cet article fait partie d'une série en quatre parties sur la construction de 0diff :
- Pourquoi nous avons construit un traqueur de modifications de code pour l'ère des agents IA -- Le problème, la solution et la session de construction de 45 minutes
- Surveillance de fichiers en temps réel et calcul de diff en Rust -- Vous êtes ici
- Détecter les agents IA dans votre codebase -- Le système de détection d'agents en détail
- De 5 agents à la production : livrer 0diff en 20 minutes -- Le workflow d'agents parallèles qui a tout construit