Zum Inhalt

File-Synchronisation

Diese Seite dokumentiert den Mechanismus zur Synchronisation zwischen der App und Backlog.md.

Übersicht

CC-Sprint verwendet einen bidirektionalen Synchronisations-Mechanismus:

  • App → File: Benutzer-Änderungen werden in die SQLite-DB geschrieben und anschließend nach Backlog.md gerendert
  • File → App: Externe Änderungen an Backlog.md (z.B. durch Claude Code) werden erkannt und in die DB re-importiert

Kernproblem: Wie unterscheiden wir zwischen "Wir haben die Datei selbst geschrieben" und "Jemand anderes hat die Datei geändert"?

Lösung: Hash-basierte Change Detection mit SHA-256.

File Watcher

Technologie

Crate: notify-debouncer-full 0.3 (basiert auf notify 6.1)

Konfiguration:

let debouncer = new_debouncer(
    Duration::from_millis(500),  // 500ms debounce
    None,                         // No custom file ID cache
    event_handler
)?;

debouncer
    .watcher()
    .watch(&file_path, RecursiveMode::NonRecursive)?;

Implementierung: src-tauri/src/services/file_watcher.rs

Funktionsweise

Diagramm

Debouncing: Verhindert, dass schnelle Mehrfach-Schreiboperationen (z.B. Editor Auto-Save) zu vielen Events führen.

Beispiel-Szenario:

t=0ms:   File write (by editor)
t=50ms:  File write (by editor auto-save)
t=100ms: File write (by editor final)
t=600ms: → EINE Event emittiert (nach 500ms seit letzter Änderung)

Event-Handling

fn handle_events(
    rx: Receiver<DebouncedEvent>,
    app_handle: AppHandle,
    project_id: String,
    file_path: PathBuf
) {
    loop {
        match rx.recv() {
            Ok(event) => {
                let is_our_file = event.paths.iter().any(|p| p == &file_path);

                if is_our_file {
                    let change_type = match event.kind {
                        EventKind::Modify(_) => "modified",
                        EventKind::Create(_) => "created",
                        EventKind::Remove(_) => "removed",
                        _ => "other",
                    };

                    // Emit to frontend
                    app_handle.emit("backlog-file-changed", FileChangeEvent {
                        project_id,
                        file_path: file_path.to_string_lossy().to_string(),
                        change_type: change_type.to_string(),
                        has_unsaved_changes: false, // TODO: Hash comparison
                    })?;
                }
            }
            Err(e) => {
                tracing::error!("File watcher channel error: {}", e);
                break;
            }
        }
    }
}

Event-Typen: - Modify: Datei wurde geändert (häufigster Fall) - Create: Datei wurde neu erstellt - Remove: Datei wurde gelöscht

Hash-basierte Change Detection

Problem

Wenn die App selbst Backlog.md schreibt, löst das einen File-System-Event aus. Wir müssen unterscheiden:

  • Self-Write: App hat geschrieben → Ignorieren (kein Re-Import nötig)
  • External-Write: Jemand anderes hat geschrieben → Re-Import nötig

Lösung: SHA-256 Hash

Konzept: 1. Vor dem Schreiben: Berechne Hash des Inhalts, der geschrieben wird 2. Speichere Hash in DB 3. Bei File-Event: Lese Datei, berechne Hash, vergleiche mit gespeichertem Hash 4. Hashes gleich → Self-Write (ignorieren) 5. Hashes unterschiedlich → External-Write (re-importieren)

Diagramm

Implementierung

Schreiben (src-tauri/src/commands/items.rs:395-400):

pub async fn save_backlog(
    project_id: String,
    app_state: State<'_, AppState>,
) -> Result<(), String> {
    // ... render backlog to content ...

    // ⚠️ PROBLEM: Hash wird NACH write berechnet und gespeichert
    std::fs::write(&backlog_path, &content)
        .map_err(|e| format!("Failed to write backlog: {}", e))?;

    let hash = calculate_hash(&content);
    store_hash(&mut conn, &project_id, &hash)?;

    Ok(())
}

Problem: Window zwischen write() und store_hash() → Hash könnte veraltet sein.

Lesen (FileWatcher):

fn check_if_external_change(
    file_path: &Path,
    db_conn: &Connection,
    project_id: &str
) -> Result<bool, String> {
    let content = std::fs::read_to_string(file_path)?;
    let current_hash = calculate_hash(&content);

    let stored_hash: String = db_conn
        .query_row(
            "SELECT last_hash FROM projects WHERE id = ?",
            params![project_id],
            |row| row.get(0)
        )?;

    Ok(current_hash != stored_hash)
}

Hash-Berechnung

use sha2::{Sha256, Digest};

fn calculate_hash(content: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(content.as_bytes());
    let result = hasher.finalize();
    format!("{:x}", result)
}

Performance: SHA-256 für 3900 Zeilen (~160KB) dauert ~5ms → akzeptabel.

Auto-Save

Konfiguration

Hook: app/src/hooks/useAutoSave.ts

export function useAutoSave(enabled: boolean = true) {
  const [isSaving, setIsSaving] = useState(false);
  const saveTimerRef = useRef<NodeJS.Timeout | null>(null);

  const triggerSave = useCallback(() => {
    if (!enabled) return;

    // Cancel previous timer
    if (saveTimerRef.current) {
      clearTimeout(saveTimerRef.current);
    }

    // Start new 2s timer
    saveTimerRef.current = setTimeout(async () => {
      setIsSaving(true);
      try {
        await invoke('save_backlog', { projectId });
        console.log('✓ Auto-save successful');
      } catch (error) {
        console.error('Auto-save failed:', error);
      } finally {
        setIsSaving(false);
      }
    }, 2000); // 2 second debounce
  }, [enabled, projectId]);

  return { triggerSave, isSaving };
}

Trigger: - Bei jeder Änderung an einem Item (Status, Titel, Beschreibung, etc.) - Bei Dependency-Änderungen - Bei Akzeptanzkriterien-Toggling

Timing:

User tippt "F" -> Timer startet (2s)
User tippt "e" -> Timer reset (2s)
User tippt "a" -> Timer reset (2s)
User tippt "t" -> Timer reset (2s)
User tippt "u" -> Timer reset (2s)
User tippt "r" -> Timer reset (2s)
User stoppt    -> Timer läuft ab nach 2s -> SAVE

DB-First Approach

Wichtig: Alle Änderungen gehen sofort in die DB, nur das Schreiben nach Backlog.md wird debounced.

Diagramm

Vorteil: Keine Daten verloren, wenn App crashed vor Auto-Save.

Nachteil: DB und Backlog.md können kurzfristig out-of-sync sein.

Konflikt-Erkennung

Szenario: Konkurrente Änderungen

Diagramm

Konflikt-Auflösung UI

ConflictBanner (app/src/components/ConflictBanner.tsx):

interface ConflictBannerProps {
  onReload: () => void;      // Discard app changes, load from file
  onKeep: () => void;         // Keep app changes, overwrite file
  onMerge: () => void;        // Open DiffView for manual merge
  externalChanges: string[];  // List of changed items (IDs)
  localChanges: string[];     // List of modified items in app
}

Beispiel:

⚠️ Backlog.md wurde extern geändert

Externe Änderungen: F-0032, T-0015
Ihre Änderungen: F-0018 (Status), T-0020 (Beschreibung)

[ Neu laden ]  [ Meine Änderungen behalten ]  [ Zusammenführen ]

Optionen

Option Aktion Datenverlust?
Neu laden Re-import von Backlog.md Ja, App-Änderungen gehen verloren
Meine Änderungen behalten Schreibe App-Daten nach Backlog.md (mit Backup) Ja, externe Änderungen gehen verloren
Zusammenführen Öffne DiffView für manuellen Merge Nein, aber manueller Aufwand

Backup-Strategie

Wann werden Backups erstellt?

  1. Vor Overwrite: Wenn User "Meine Änderungen behalten" wählt
  2. Bei External Edit: Vor Re-Import (optional, konfigurierbar)
  3. Manuell: Via UI ("Backup erstellen" Button)

Backup-Format

Speicherort: .backlog-admin/backups/

Dateinamen: backlog-YYYY-MM-DD_HH-MM-SS.md

Beispiel:

.backlog-admin/
├── backlog.db
└── backups/
    ├── backlog-2026-02-01_10-30-45.md
    ├── backlog-2026-02-01_14-22-10.md
    └── backlog-2026-02-02_09-15-33.md

Retention Policy

Standard: Letzte 10 Backups behalten, ältere automatisch löschen.

Konfigurierbar: User kann in Settings einstellen: - Anzahl Backups (5, 10, 20, 50, Unbegrenzt) - Maximale Größe (100MB, 500MB, 1GB)

Performance-Überlegungen

Timing-Analyse

Operation Dauer Häufigkeit Optimierung
File Watch Event ~1ms Bei jeder Datei-Änderung Debounced (500ms)
Hash Berechnung ~5ms Bei jedem Event Effizient, kein Problem
Parse Backlog ~100ms Bei External Change Nur bei Hash-Mismatch
Render Backlog ~50ms Bei Auto-Save Debounced (2s)
DB Write ~10ms Bei jeder Änderung Indexed, schnell

Gesamtlatenz (External Change → UI Update):

500ms (debounce) + 5ms (hash) + 100ms (parse) + 10ms (DB) + 20ms (UI) = ~635ms

Optimierungen

Früher Abbruch:

// Wenn Hash gleich, breche früh ab (kein Re-Import nötig)
if current_hash == stored_hash {
    return Ok(()); // Nur 505ms statt 635ms
}

Incremental Parsing (Future): - Nur geänderte Items parsen, nicht komplettes File - Erfordert Line-Range Tracking - Potenzielle Beschleunigung: ~10x bei einzelner Item-Änderung

Bekannte Probleme

Siehe: - Race Conditions - FileWatcher vs AutoSave Timing-Issues - Datenverlust-Risiken - Hash-Timing, rebuild_database

Nächste Schritte