Back to 0diff
0diff

Vigilancia de archivos en tiempo real y calculo de diff en Rust

Inmersion profunda en el observador de archivos y motor de diff de 0diff: bucles de eventos sincronos, la crate similar, filtrado de espacios en blanco y reglas de vigilancia basadas en configuracion.

Thales & Claude | March 30, 2026 16 min 0diff
EN/ FR/ ES
0diffrustfile-watchingdiffnotifysimilar

La mayoria de las herramientas de desarrollo interactuan con codigo en reposo -- archivos en disco, commits en un repositorio, artefactos en un registro. 0diff interactua con codigo en movimiento. Vigila archivos a medida que cambian, calcula diffs en tiempo real, filtra el ruido, atribuye el cambio a un humano o agente de IA, y registra todo. Todo el pipeline se ejecuta en un bucle de eventos sincrono sin runtime asincrono, sin hilos en segundo plano mas alla del observador de archivos del SO, y sin servicios externos.

Este articulo es una inmersion profunda en como funciona ese pipeline. Recorreremos el modulo del observador (266 lineas), el modulo de diff (176 lineas), el modulo de filtrado (184 lineas), y el sistema de configuracion (456 lineas) que los une. Cada fragmento de codigo es del codebase real de 0diff. Cada decision de diseno tiene una razon.


El bucle de eventos: sin asincronia, sin problemas

El corazon de 0diff es un bucle de eventos sincrono en watcher.rs. Cuando ejecutas 0diff watch, esto es lo que sucede:

  1. La configuracion se carga desde .0diff.toml.
  2. Todos los directorios vigilados se escanean, y el contenido de cada archivo coincidente se almacena en cache en un HashMap<PathBuf, String>.
  3. Un observador de archivos a nivel del SO se registra usando la crate notify con notify-debouncer-mini para la coalescencia de eventos.
  4. El hilo principal entra en un bucle, recibiendo eventos del sistema de archivos a traves de un canal estandar mpsc.
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(())
}

Hay varias elecciones deliberadas aqui que vale la pena examinar.

Por que mpsc sincrono en lugar de tokio::sync::mpsc o async_channel? Porque 0diff no necesita asincronia. El bucle de eventos tiene exactamente una fuente de eventos (el observador de archivos) y un consumidor (el hilo principal). No hay E/S concurrente, ni llamadas de red, ni fan-out/fan-in. Un canal sincrono con un timeout de recepcion de 250ms nos da todo lo que necesitamos: manejo de eventos responsivo, verificaciones periodicas de apagado y cero sobrecarga en tiempo de ejecucion.

Por que recv_timeout(250ms) en lugar de recv() bloqueante? El timeout sirve dos propositos. Primero, nos permite verificar el flag de apagado AtomicBool cada 250 milisegundos, asegurando una salida responsiva cuando el usuario presiona Ctrl+C. Segundo, crea un latido natural que evita que el proceso parezca atascado para gestores de procesos o herramientas de monitoreo.

Por que AtomicBool para apagado en lugar de un canal? El manejador de la crate ctrlc se ejecuta en un contexto de senal donde las asignaciones y operaciones complejas no son seguras. Un almacenamiento atomico es una de las pocas operaciones garantizadas como seguras en un manejador de senales. El bucle principal lo lee en cada ciclo de timeout, dandonos un apagado limpio con rotacion de historial antes de salir.

Por que notify-debouncer-mini? Los eventos brutos del sistema de archivos son ruidosos. Una sola operacion de guardado de archivo en la mayoria de los editores desencadena multiples eventos: una escritura en un archivo temporal, un renombrado, una actualizacion de metadatos, a veces un borrar-y-recrear. El debouncer coalece estos en un solo evento por archivo dentro de una ventana configurable (predeterminada 500ms). Esto evita que 0diff calcule el mismo diff tres veces por una operacion de guardado.


La cache de archivos

Cuando 0diff comienza a vigilar, lee el contenido actual de cada archivo rastreado en un HashMap<PathBuf, String> en memoria. Esta cache sirve como linea base para el calculo de diff. Cuando un archivo cambia, 0diff lee el nuevo contenido del disco, lo compara con la version en cache y luego actualiza la cache.

Esto significa que 0diff calcula diffs contra el ultimo estado observado, no contra el HEAD de git ni ningun otro punto de referencia. Esto es intencional. Los diffs de git te dicen que cambio desde el ultimo commit. Los diffs de 0diff te dicen que cambio desde la ultima vez que 0diff vio el archivo. En un entorno multi-agente donde los agentes hacen cambios rapidos entre commits, esta vista en tiempo real es mucho mas util.

La compensacion es el uso de memoria. La cache contiene el texto completo de cada archivo vigilado. Para un proyecto tipico con unos pocos cientos de archivos fuente, esto es insignificante -- quiza 10-50MB. Para un monorepo con millones de lineas, querrias configurar las rutas de vigilancia cuidadosamente en .0diff.toml. El sistema de configuracion soporta esto con filtros de extension, prefijos de ruta y patrones de ignorar basados en glob.


El motor de diff

El modulo de diff es 176 lineas de Rust construidas sobre la crate similar. similar implementa el algoritmo de diff de Myers, el mismo algoritmo usado por git diff. Lo elegimos sobre alternativas como diffy o imara-diff porque proporciona acceso limpio a operaciones agrupadas con lineas de contexto, que es exactamente lo que necesitamos para producir diffs legibles basados en 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,
    }
}

La llamada grouped_ops(3) es significativa. Agrupa operaciones de diff consecutivas e incluye 3 lineas de contexto circundante para cada grupo, coincidiendo con el comportamiento predeterminado de git diff. Esto significa que la salida de 0diff es inmediatamente familiar para cualquier desarrollador que haya leido un diff unificado.

La salida es una estructura FileDiff que contiene un vector de DiffHunks, cada uno con informacion precisa de rango de lineas (old_start, old_count, new_start, new_count) y un vector de entradas DiffLine. Esta representacion estructurada es lo que permite al resto del pipeline -- filtrado, visualizacion, serializacion JSON -- trabajar con diffs como datos en lugar de parsear texto.


Filtrado de espacios en blanco

Una de las fuentes mas comunes de ruido en los diffs son los cambios de espacios en blanco. Un editor reformatea la indentacion. Un linter ajusta los espacios al final. Un desarrollador cambia entre tabulaciones y espacios. Estos cambios producen diffs que oscurecen las modificaciones significativas.

El modulo de filtrado (184 lineas) aborda esto con un enfoque dirigido. En lugar de ignorar todos los espacios en blanco en el calculo del diff (lo que ocultaria cambios de formato legitimos), post-procesa los hunks del diff y elimina solo aquellos donde cada cambio es puramente de espacios en blanco:

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 logica es cuidadosa con los casos borde. Si el numero de adiciones no coincide con el numero de eliminaciones, no es un cambio de espacios en blanco -- se anadieron o eliminaron lineas, no solo se reformatearon. Si no hay adiciones o eliminaciones (solo lineas de contexto), no es un hunk de solo espacios en blanco. Solo cuando cada linea anadida tiene una linea eliminada correspondiente y difieren solo en espacios en blanco (iniciales, finales y series internas colapsadas) el filtro elimina el hunk.

La funcion normalize_whitespace recorta los espacios en blanco iniciales y finales, luego colapsa todas las series de espacios en blanco internos en un solo espacio. Esto captura los casos comunes: re-indentacion, conversion de tabulaciones a espacios, eliminacion de espacios en blanco al final y cambios de alineacion.

Este filtrado se controla con la opcion de configuracion filter.ignore_whitespace. Cuando esta habilitado (por defecto), los hunks de solo espacios en blanco se eliminan antes de que el cambio se registre. El desarrollador sigue viendo los cambios significativos claramente, mientras el ruido de formato automatizado se suprime.


El pipeline completo

Cuando un archivo cambia en disco, el modulo del observador orquesta todo el pipeline. Aqui esta el flujo, simplificado pero fiel a la implementacion real:

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(())
}

El paso 4 es la compuerta de ruido. El umbral min_lines_changed (predeterminado: 1) evita que 0diff registre cambios triviales como anadir una sola nueva linea. Combinado con el filtro de espacios en blanco, esto significa que el log de historial contiene solo modificaciones significativas.

Las eliminaciones de archivos siguen un camino paralelo. Cuando el observador detecta un evento de eliminacion, calcula el diff como una eliminacion completa (cada linea en la version en cache se convierte en una eliminacion), lo registra en el historial y elimina el archivo de la cache. Esto asegura que las eliminaciones de archivos se rastreen con la misma fidelidad que las modificaciones.


El sistema de configuracion

El modulo de configuracion es el modulo individual mas grande con 456 lineas, y por buena razon. Un observador de archivos que no se puede configurar es inutil -- cada proyecto tiene diferentes tipos de archivos, diferentes estructuras de directorios, diferentes fuentes de ruido.

0diff usa TOML para la configuracion, almacenado en .0diff.toml en la raiz del proyecto. La configuracion tiene cinco secciones:

  • [watch] -- Que directorios vigilar, que extensiones de archivo rastrear, que patrones ignorar y el intervalo de debounce.
  • [filter] -- Si ignorar cambios de espacios en blanco y el umbral minimo de cambio de lineas.
  • [git] -- Si extraer metadatos de git y como.
  • [history] -- Donde almacenar el archivo de historial, tamano maximo para rotacion y edad maxima en dias.
  • [agents] -- Patrones personalizados de deteccion de agentes mas alla de los incorporados.

La funcion should_watch() es el portero. Cada evento del sistema de archivos pasa a traves de ella antes de que ocurra cualquier calculo 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 verificacion de tres pasos esta ordenada por costo. La verificacion de extension es una comparacion de cadenas -- esencialmente gratuita. La verificacion de prefijo de ruta es ligeramente mas costosa pero sigue siendo rapida. La coincidencia de patrones glob es la operacion mas costosa y solo se alcanza para archivos que pasan las dos primeras verificaciones.

La configuracion predeterminada ignora directorios de ruido comunes (target/, node_modules/, .git/, build/, dist/) y extensiones comunes que no son de codigo fuente (imagenes, binarios, archivos lock). Un nuevo usuario puede ejecutar 0diff init y comenzar a vigilar inmediatamente sin ninguna configuracion manual. El .0diff.toml generado incluye comentarios explicando cada opcion, por lo que la personalizacion es sencilla.

El modulo de configuracion incluye 7 tests que cubren parseo TOML, valores predeterminados, filtrado de extensiones, coincidencia de prefijos de ruta y patrones de ignorar glob. Estos tests fueron escritos por agent-config durante la sesion de construccion inicial y han capturado varios casos borde en el desarrollo posterior -- particularmente alrededor del manejo de separadores de ruta en diferentes sistemas operativos.


La suite de tests

0diff tiene 44 tests a traves de todos los modulos. La distribucion refleja donde vive la complejidad:

  • config: 7 tests -- parseo TOML, evaluacion de reglas de vigilancia, valores predeterminados
  • differ: 8 tests -- diffs vacios, solo adiciones, solo eliminaciones, cambios mixtos, lineas de contexto
  • filter: 6 tests -- hunks de solo espacios en blanco, hunks mixtos, conteos de adicion/eliminacion no coincidentes, hunks vacios
  • git: 9 tests -- parseo de commits, extraccion de autor, deteccion de agente desde mensajes de commit, variables de entorno, deteccion de TTY
  • history: 8 tests -- adicion, consulta por autor, consulta por agente, rotacion por tamano, rotacion por edad, validacion de formato JSON-lines
  • watcher: 3 tests -- manejo de eventos, actualizacion de cache, apagado
  • display: 3 tests -- formato de salida de terminal, formato de salida JSON, generacion de resumen

Los tests son unitarios, no de integracion. Cada modulo se prueba de forma aislada con entradas construidas. Esta fue una decision practica para la construccion inicial -- la sesion de 45 minutos no tuvo tiempo para infraestructura de tests de integracion. Los tests unitarios cubren las rutas logicas importantes, y las interfaces limpias de los modulos (datos estructurados de entrada, datos estructurados de salida) significan que los problemas de integracion son raros.


Caracteristicas de rendimiento

0diff esta disenado para ser invisible. No deberia ralentizar tu flujo de trabajo de desarrollo ni consumir recursos del sistema notables.

Tiempo de inicio: Menos de 50ms en un proyecto tipico. El costo principal es alimentar la cache de archivos, lo que requiere leer cada archivo vigilado. Para un proyecto con 500 archivos fuente promediando 200 lineas cada uno, esto es alrededor de 10MB de E/S -- trivial en cualquier sistema moderno.

Latencia de manejo de eventos: Sub-milisegundo para el calculo de diff en cambios de archivo tipicos (menos de 1000 lineas). La implementacion de Myers de la crate similar es O(ND) donde N es el numero total de lineas y D es la distancia de edicion. Para el caso comun de pequenas ediciones en archivos de tamano medio, esto se completa en microsegundos.

Uso de memoria: Proporcional al tamano total de archivos vigilados (para la cache) mas la porcion en memoria del buffer de eventos del debouncer. Tipicamente 20-100MB para un proyecto de tamano medio.

Uso de disco: El archivo de historial JSON-lines crece a aproximadamente 200-500 bytes por cambio registrado. A 100 cambios por dia, eso es alrededor de 15KB/dia o 5MB/ano. El sistema de rotacion asegura que el archivo nunca exceda max_size_mb (predeterminado: 50MB) o max_days (predeterminado: 90 dias).

El binario de release de 2MB incluye todo -- sin dependencias de runtime mas alla de la API de notificacion del sistema de archivos a nivel del SO (inotify en Linux, FSEvents en macOS, ReadDirectoryChangesW en Windows) y la herramienta de linea de comandos git.


Lo que aprendimos

Construir un observador de archivos nos enseno varias cosas que no son obvias desde la documentacion:

Los eventos del sistema de archivos no son confiables. Diferentes sistemas operativos, diferentes sistemas de archivos y diferentes editores producen secuencias de eventos diferentes para la misma operacion logica. El debouncer maneja la mayoria de esto, pero aun tuvimos que manejar casos donde recibimos un evento de modificacion para un archivo que no existe (porque fue eliminado entre el evento y nuestra lectura) o un evento de creacion para un archivo que ya existe en nuestra cache (porque el editor hizo un borrar-y-recrear en lugar de modificar-en-su-lugar).

La invalidacion de cache es el verdadero problema. La cache de archivos debe mantenerse sincronizada con el disco. Si un archivo cambia mientras estamos procesando otro evento, podriamos calcular un diff contra datos obsoletos. El debouncer ayuda coalesciendo cambios rapidos, y el patron de actualizar-cache-despues-del-procesamiento asegura que siempre registremos la transicion del ultimo estado conocido al estado actual.

El filtrado de espacios en blanco es mas dificil de lo que parece. El enfoque ingenuo (eliminar todos los espacios en blanco y comparar) destruye demasiada informacion. Una linea que cambia de if (x) a if ( x ) es un cambio de espacios en blanco. Una linea que cambia de return 0 a return 1 no lo es. Pero una linea que cambia de return 0 a return 0 si lo es. El enfoque de comparacion por pares -- emparejar cada adicion con su eliminacion correspondiente y comparar formas normalizadas -- maneja todos estos casos correctamente.

La configuracion es una funcionalidad, no una ocurrencia tardia. El modulo de configuracion es el modulo mas grande por una razon. Un observador de archivos sin patrones de ignorar apropiados se ahogara en ruido de node_modules, artefactos de compilacion y archivos generados. Un registrador de diffs sin un umbral minimo de cambio llenara el historial con ediciones de un solo caracter. Lograr los valores predeterminados correctos y hacer la personalizacion facil es tan importante como la funcionalidad central.


Serie: Como construimos 0diff.dev

Este articulo es parte de una serie de cuatro partes sobre la construccion de 0diff:

  1. Por que construimos un rastreador de cambios de codigo para la era de los agentes de IA -- El problema, la solucion y la sesion de construccion de 45 minutos
  2. Vigilancia de archivos en tiempo real y calculo de diff en Rust -- Estas aqui
  3. Deteccion de agentes de IA en tu codebase -- El sistema de deteccion de agentes en detalle
  4. De 5 agentes a produccion: Desplegando 0diff en 20 minutos -- El flujo de trabajo con agentes en paralelo que lo construyo todo
Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles