How UNF* works
UNF* watches your filesystem and records every text file change in real time — then lets you rewind any file to any second. This page explains how.
Design choices
UNF* is a background daemon. It runs continuously, so it needs to be invisible: <1% CPU, <100MB RAM, no GC pauses. Rust, no runtime.
Core logic is pure functions with side effects isolated at the edges.
Interface
Same verbs as git (log, diff, restore), applied to time instead of commits.
Every mutating command supports --dry-run. Restore creates a safety snapshot before overwriting anything, so restoring is itself undoable.
Use cases
10 real-world scenarios with commands: see the use case carousel on the homepage.
Architecture
Single global daemon, multi-project. One sentinel process supervises the daemon. All storage lives in ~/.unfudged/. Nothing is written to your project directory, so there's nothing to .gitignore.
Event pipeline
What happens when you save a file:
-
OS notification. The
notifycrate (v8) receives the filesystem event. FSEvents on macOS, inotify on Linux. -
Route.
route_event()finds which project owns the file by longest-prefix match against registered roots. -
Filter. 5-layer chain, checked in order. If any layer rejects, the event is dropped:
- Directory exclusions: hardcoded
.git,node_modules,target, etc. - Extension exclusions: case-insensitive
.png,.exe,.sqlite, etc. - .gitignore: parsed and matched if present
- Hidden files: skipped except
.envand.gitignore - Magic numbers: first 16 bytes checked for binary signatures (PNG, JPEG, ELF, Mach-O, etc.)
- Directory exclusions: hardcoded
-
Debounce. Events accumulate for 3 seconds of silence, coalescing multiple saves into one snapshot. On shutdown,
force_drain()flushes all pending events. Nothing lost. - Snapshot. Read the file, compute BLAKE3 hash, check CAS for dedup, compute line diff against previous version, write blob + metadata.
Content-addressable storage
Every file version is stored by its BLAKE3 hash, a 256-bit (64-character hex) digest. BLAKE3 was designed for high throughput (~1 GB/s per core), but for text files the speed is academic. We chose it for its simplicity and lack of known weaknesses.
Layout
Two-level directory: the first 2 hex characters become the directory prefix, the remaining 62 become the filename. Two hex digits give 256 possible prefixes (00 through ff), so no single directory holds more than 256 entries. readdir() stays fast regardless of total object count.
Deduplication
Before writing, we check if the object file already exists. If two files have identical contents (or a file is saved without changes), we store it once. Simple existence check — no reference counting.
Garbage collection
unf prune deletes snapshot rows from SQLite, then walks the CAS directory and deletes any blob not referenced by remaining rows. Full scan, not incremental. Simple and correct.
SQLite metadata
One database per project, running in WAL mode (Write-Ahead Logging, allows reads while writes are in progress). All timestamps are RFC 3339, always UTC.
Schema
| Column | Type | Purpose |
|---|---|---|
| id | INTEGER PK | Auto-incrementing snapshot ID |
| file_path | TEXT | Relative path within project |
| content_hash | TEXT | BLAKE3 hex → CAS lookup key |
| size_bytes | INTEGER | File size at capture time |
| timestamp | TEXT | RFC 3339 UTC timestamp |
| event_type | TEXT | create, modify, or delete |
| line_count | INTEGER | Total lines in this version |
| lines_added | INTEGER | Lines added vs. previous |
| lines_removed | INTEGER | Lines removed vs. previous |
Descending-timestamp indexes on both per-file and project-wide queries. Cursor-based pagination, so performance stays constant regardless of history depth.
Daemon supervision
Two-process model. A sentinel checks every 15 seconds whether the daemon is alive, and restarts it if it crashed. The daemon runs the watcher, manages per-project state, and writes to storage.
IPC is Unix signals. When you run unf watch, the CLI writes desired state to an intent file and signals the daemon to reload. The sentinel reconciles intent against reality on every tick. If the daemon crashes, it comes back knowing what to watch.
Storage layout
Each project is isolated by a hash of its absolute (canonical) path. This avoids collisions and keeps projects independent.
Desktop app
The CLI is the engine. The desktop app is a separate binary (Tauri v2 + Svelte 5) that reads ~/.unfudged/ directly. The daemon doesn't know the app exists, and the app doesn't need the daemon running to browse history.
Cross-project timeline with tabbed navigation, activity histogram with drag-to-zoom, word-level diff highlighting, and glob-based file filtering. Live-polls while open, pauses when you switch tabs.
What we don't do (yet)
Honest gaps. These are designed but not built.
- No compression. Blobs are stored uncompressed. Zstd is planned. Disk is cheap; shipping is better.
- No automatic retention decay. The design calls for 24h full / 7d hourly / 30d daily thinning. Today you get
unf prune --older-than 30d. Manual, explicit, predictable. - No burst detection. An "AI safety" feature (auto-snapshot before mass file changes) is designed but not built.
- No delta compression. Full blobs only. CAS dedup handles the common case (unchanged files). True deltas are a future optimization.
- No Windows support. Daemon IPC uses Unix signals. Windows would need named pipes or similar.
- No rename tracking. Projects are identified by absolute path. Renaming or moving a project directory starts a new history.