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.

$ unf watch # start recording $ unf status # what's being watched $ unf log [file] # timeline of changes $ unf log --since 2h --until 1h # time-range query $ unf log --sessions # detect work sessions $ unf log --global # cross-project timeline $ unf diff --at 5m # what changed in last 5 min $ unf diff --session # what changed this session $ unf restore --at 5m # rewind 5 minutes $ unf restore --at 5m file.rs # rewind one file $ unf restore --session # rewind to session start $ unf cat file.rs --at 1h # view file from 1 hour ago $ unf recap --json # reconstruct context (for agents)
$ unf log --exclude "tests/*" # hide matching files $ unf log --group-by-file # tree view by file $ unf diff --snapshot 42 # diff a specific snapshot $ unf diff --context 10 # more context lines $ unf list -v # detailed project info $ unf prune --all-projects # prune everything at once

Restore and prune support --dry-run for safe previews. 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. Storage defaults to ~/.unfudged/. Run unf config --move-storage /path to relocate (add --force to skip confirmation). Nothing is written to your project directory, so there's nothing to .gitignore.

OS filesystem events (FSEvents / inotify)
Global Daemon (one process, all projects) route_event() → prefix match to owning project Per project: Filter → Debounce → Snapshot → Engine
SQLite (metadata) WAL mode keyset pagination
CAS (file blobs) BLAKE3 addressed 2-level directory
Supervised by: Sentinel (15s health check tick)

Event pipeline

What happens when you save a file:

  1. OS notification. The notify crate (v8) receives the filesystem event. FSEvents on macOS, inotify on Linux.
  2. Route. route_event() finds which project owns the file by longest-prefix match against registered roots.
  3. 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 .env and .gitignore
    • Magic numbers: first 16 bytes checked for binary signatures (PNG, JPEG, ELF, Mach-O, etc.)
  4. Debounce. Events accumulate for 3 seconds of silence, coalescing multiple saves into one snapshot. On shutdown, force_drain() flushes all pending events. Nothing lost.
  5. 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.

~/.unfudged/projects/<hash>/objects/ ab/ cdef0123456789abcdef0123456789abcdef0123456789abcdef012345678901 cd/ ef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd

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

ColumnTypePurpose
idINTEGER PKAuto-incrementing snapshot ID
file_pathTEXTRelative path within project
content_hashTEXTBLAKE3 hex → CAS lookup key
size_bytesINTEGERFile size at capture time
timestampTEXTRFC 3339 UTC timestamp
event_typeTEXTcreate, modify, or delete
line_countINTEGERTotal lines in this version
lines_addedINTEGERLines added vs. previous
lines_removedINTEGERLines 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.

Hardening (v0.17.16): The sentinel retains the daemon's process handle and uses try_wait() to detect zombie processes that kill(pid, 0) would miss. An flock guard on the PID file prevents multiple sentinels from accumulating after rapid restarts. A data freshness check runs every 60 seconds—if the daemon is alive but hasn't recorded snapshots for 5 minutes despite filesystem activity, it gets force-restarted.

Storage layout

~/.unfudged/ daemon.pid # daemon process ID sentinel.pid # sentinel process ID registry.json # what the daemon is watching intent.json # what should be watched projects/ <project-hash>/ # derived from absolute path metadata.db # SQLite database objects/ # content-addressed blobs ab/ cdef01... # 62-char filename

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. Watch and unwatch projects, stop and restart the daemon directly from the app dropdown. Live-polls while open, pauses when you switch tabs.

What we don't do (yet)

Honest gaps. These are designed but not built.

← Back to www.unfudged.io