lhi
Local history for your code — like IntelliJ’s Local History, but for any editor.
lhi watches a directory for file changes and maintains a local version history. Every save is captured automatically with content-addressed storage and a JSONL index. No server, no network, no config — just a .lhi/ directory at your project root.
Features
- Editor-agnostic — works with vim, VS Code, Helix, IntelliJ, or any editor
- Automatic capture — every file save is recorded via OS-native filesystem notifications
- Content-addressed storage — SHA-256 hashing with automatic deduplication, zstd compression
- Git branch tracking — each event is tagged with the current git branch
- Syntax-highlighted output —
cat,diff, andsearchuse bat for rich terminal output;diffalso supports delta if installed - Full-text search — search through all historical file versions
- Point-in-time restore — restore individual files or entire projects to any previous state
- Shell integration — automatic watcher activation via
cdhook (bash, zsh) - Scriptable — JSON output and plain text when piped
Project structure
src/
├── lib.rs Module root
├── util.rs Shared utilities (SHA-256, file mode, git branch)
├── core/
│ ├── event.rs Event data model (EventType, LhiEvent, etc.)
│ ├── index.rs JSONL index (read/write/query/compact)
│ └── store.rs Content-addressed blob store (zstd-compressed)
├── commands/
│ ├── activate.rs lhi activate (shell hook generation, bash + zsh)
│ ├── cat.rs lhi cat (syntax-highlighted file viewing)
│ ├── diff.rs lhi diff (delta/bat-powered diff)
│ ├── info.rs lhi info
│ ├── init.rs lhi init
│ ├── log.rs lhi log
│ ├── search.rs lhi search (highlighted context matches)
│ ├── compact.rs lhi compact
│ ├── snapshot.rs lhi snapshot
│ ├── restore.rs lhi restore
│ └── watch.rs lhi watch
├── watcher/
│ ├── mod.rs LhiWatcher, baseline snapshot
│ ├── events.rs Debounced event loop
│ └── helpers.rs Watcher-specific helpers
└── bin/lhi/
├── main.rs Entry point
└── cli.rs Clap CLI definition
Installation & Quick Start
Install
cargo install --path .
Quick start
# Initialize a project
cd ~/my-project
lhi init
# Add this to your ~/.bashrc or ~/.zshrc for automatic watching
eval "$(lhi activate)"
That’s it. lhi init creates the .lhi/ directory and adds it to .gitignore. The shell hook automatically starts a watcher whenever you cd into a project with .lhi/. Multiple projects can be watched concurrently — each gets its own watcher process. All watchers are cleaned up when the shell exits.
# Check what changed
lhi log src/main.rs # shows ~1, ~2, ~3... revision numbers
# View an old version
lhi cat src/main.rs # latest stored version
lhi cat src/main.rs ~3 # 3rd most recent
lhi cat a1b2c3d4 # by short hash prefix
# Compare versions
lhi diff src/main.rs # latest stored vs current disk
lhi diff src/main.rs ~5 # revision ~5 vs current disk
lhi diff src/main.rs ~3 ~1 # compare two revisions
# Search through stored file versions
lhi search "fn main"
lhi search "TODO" --file src/lib.rs
# Restore files
lhi restore src/main.rs ~5 # restore single file to revision
lhi restore --at a1b2c3d4 # restore project to that moment
lhi restore --at a1b2c3d4 --dry-run # preview first
# Other commands
lhi info # storage statistics
lhi snapshot --label "before refactor" # manual snapshot
lhi compact # shrink the index
Logging
lhi uses tracing for structured logging. Control verbosity with RUST_LOG:
RUST_LOG=lhi=debug lhi watch # verbose
RUST_LOG=lhi=trace lhi watch # very verbose
Default level is info (warnings and errors only).
The shell hook logs watcher stderr to ~/.lhi-watch.log for troubleshooting.
Commands
lhi init [PATH]
Initialize a .lhi/ directory for a project. Creates .lhi/blobs/ and adds .lhi/ to .gitignore if one exists. Safe to run multiple times (idempotent).
lhi activate
Prints a shell hook script to stdout. Designed to be eval’d in your shell rc file:
eval "$(lhi activate)"
The hook:
- Overrides
cd,pushd, andpopdto detect.lhi/projects - Walks up parent directories (so
cd ~/project/src/deepactivates~/project) - Starts
lhi watchin the background for each new project entered - Tracks multiple concurrent watchers (one per project root)
- Re-launches a watcher if its process dies
- Logs watcher errors to
~/.lhi-watch.logand warns on failed launches - Kills all watchers on shell exit (
EXITtrap)
Shell-specific implementations are emitted for portability:
- bash — uses a newline-delimited string to track watchers (compatible with bash 3.2 on macOS)
- zsh — uses native
typeset -Aassociative arrays with zsh syntax
To manually stop all watchers and remove the hook, run _lhi_deactivate in your shell.
Supports bash and zsh. Fish support is planned.
lhi watch [PATH]
Watch a directory for file changes. Records every create, modify, and delete to .lhi/.
Options:
-v, --verbose Print events as JSON to stdout
Runs in the foreground (blocking). Useful for troubleshooting or one-off use. The lhi activate shell hook uses this command internally.
On first run, captures a baseline snapshot of all existing files. Respects .gitignore. Debounces rapid writes (100ms window). Files over 10MB are skipped.
lhi log [FILE]
Show change history. When filtered to a single file, shows ~N revision numbers (~1 = newest).
Options:
--since <DURATION> Filter by time (e.g. 5m, 1h, 2d)
--branch <NAME> Filter by git branch
--json Output as JSON
-f, --follow Continuously watch for new entries (like tail -f)
When git branch tracking is available, each entry shows the branch it was recorded on.
With --follow, prints existing history then polls for new index entries every 500ms. Combines with --since, --branch, and file filters. Press q to stop (or Ctrl+C).
lhi cat <TARGET> [~N]
Print the content of a stored file version. Accepts a hash (or short prefix), a file path (shows latest), or a file path with ~N revision.
lhi cat a1b2c3d4 # by hash prefix
lhi cat src/main.rs # latest version
lhi cat src/main.rs ~3 # 3rd most recent
When stdout is a terminal, output is syntax-highlighted with line numbers and a grid border (powered by bat). The language is auto-detected from the filename in the index. When piped, raw content is emitted for composability.
lhi diff <ARG1> [ARG2] [ARG3]
Show a unified diff between file versions. Supports multiple forms:
lhi diff a1b2c3d4 e5f6a7b8 # two hash prefixes
lhi diff src/main.rs ~3 ~1 # file with two revisions
lhi diff src/main.rs ~5 # revision vs current file on disk
lhi diff src/main.rs # latest stored vs current disk
When stdout is a terminal, the diff is rendered with syntax highlighting. If delta is installed, it is used automatically for rich side-by-side output. Otherwise, falls back to bat’s Diff syntax highlighting. When piped, standard unified diff format is emitted.
lhi search <QUERY>
Search through stored file contents for a text pattern (case-insensitive).
Options:
--file <PATH> Search only versions of this file
Searches each unique blob once. When stdout is a terminal, matching lines are shown with syntax-highlighted context (2 lines above and below), line numbers, and highlighted match lines. When piped, plain text output is emitted.
lhi info
Show storage statistics: index entries, files tracked, blob count, blob size, and total .lhi/ disk usage.
lhi restore [FILE] [~N]
Restore files to a previous state. Supports multiple modes:
lhi restore src/main.rs ~5 # single file to revision
lhi restore src/main.rs --at a1b2 # single file to specific hash
lhi restore --at a1b2c3d4 # all files to that moment
lhi restore --before 5m # all files to 5 minutes ago
Options:
--at <HASH> Restore to the moment a specific hash was recorded
--before <TIME> Restore to before a time (5m, 1h, 14:30, ISO 8601)
--dry-run Preview without making changes
--json Output as JSON
Compares stored hashes against current disk state — only overwrites files that actually changed. Restores Unix file permissions. Deletes files that didn’t exist at the target time.
lhi snapshot [--label <LABEL>]
Capture a full project snapshot. Useful before risky changes.
lhi compact
Compact the index to keep only the latest entry per file. Reduces .lhi/index.jsonl size.
How It Works
Storage layout
.lhi/
├── index.jsonl Append-only event log (one JSON line per change)
└── blobs/ Content-addressed file storage (SHA-256, zstd-compressed)
├── a1b2c3...
└── d4e5f6...
Blob store
Files are stored by their SHA-256 hash. Identical content is automatically deduplicated. Blobs are zstd-compressed on write; old uncompressed blobs are read transparently (magic byte detection). Writes are atomic (temp file + rename). Short hash prefixes are resolved by scanning the blobs directory.
Index
JSONL format — each line records timestamp, event type, file path, content hash, size, and git branch. Append-only during normal operation; compact rewrites it.
Watcher
Uses OS-native filesystem notifications (notify crate) with 100ms debouncing. On first run, captures a baseline snapshot of all existing files. Respects .gitignore. Ignores .lhi/ directories at any nesting depth. Files over 10MB are skipped. Symlinks are ignored.
Git integration
Automatically records the current git branch with each event (captured at watcher startup and snapshot time via git rev-parse --abbrev-ref HEAD). Stored as Option<String> — None when not in a git repo.
Terminal output
cat, diff, and search use bat as a library (PrettyPrinter) for syntax-highlighted output when stdout is a terminal. When piped, they emit plain/raw output for composability.
diff additionally tries piping to delta if installed before falling back to bat. The bat dependency uses default-features = false with regex-fancy (pure Rust, no C dependencies). Filenames for syntax auto-detection are resolved from the index via hash lookup.
Architecture
bin/lhi → commands → core (index, store, event)
│ ▲
└──► watcher ──────┘
- core/ — Data layer.
Indexmanages the JSONL index,BlobStorehandles content-addressed blobs,eventdefines serializable types. Core types returnio::Result. - commands/ — One file per CLI subcommand. All return
anyhow::Result.cat.rs,diff.rs, andsearch.rsusebatfor syntax-highlighted output, withdiff.rsalso piping todeltaif available. - watcher/ — Real-time filesystem monitoring. Debounces events, respects
.gitignore, stores blobs and index entries. - util.rs — Shared
hex_sha256,get_file_mode, andcurrent_git_branch. - bin/lhi/ — Thin CLI layer using
clap.
lhi activate — Developer Guide
This guide is written for a human developer working on this project without AI assistance. It explains what the
activatemodule does, how the shell hook works, what the known issues are, and how to fix them.
Table of Contents
- What does lhi activate do?
- Project layout (relevant files)
- Module walkthrough: commands::activate
- Data flow
- Known issues & how to fix them
- Things to watch out for when making changes
What does lhi activate do?
lhi activate prints a shell script to stdout. The user puts eval "$(lhi activate)" in their .bashrc or .zshrc, and the script installs hooks on cd, pushd, and popd. Every time the user changes directories, the hook walks up the directory tree looking for a .lhi/ folder. If it finds one, it starts lhi watch in the background for that project.
Multiple projects can be watched concurrently — each gets its own background process. The hook tracks which projects are being watched and re-launches watchers if they die. When the shell exits, all watchers are killed via an EXIT trap. The user can also run _lhi_deactivate to manually stop everything and restore the original shell behavior.
The Rust side is minimal: validate the shell from $SHELL, then print the appropriate script. All the real logic lives in the emitted shell script.
Project layout (relevant files)
src/commands/
├── activate.rs — This module. Shell hook generation.
├── mod.rs — Exports activate(). Wires it into the command system.
└── watch.rs — The lhi watch command that the hook launches in background.
src/bin/lhi/
├── cli.rs — Clap CLI. Has Activate variant that calls activate().
└── main.rs — Entry point, tracing init.
Module walkthrough: commands::activate
This module has three functions and no types. It generates shell code — the Rust is just a delivery mechanism.
Functions:
-
activate()(line 8) — Entry point called by the CLI. Reads$SHELL, validates it viadetect_shell(), then prints the hook script to stdout. The return value fromdetect_shellis currently unused (_shell) because only one hook function exists. When fish support is added, this will need to dispatch to different hook generators per shell. -
detect_shell(shell_path: &str)(line 14) — Pure function that extracts the shell name from a path like/usr/local/bin/zsh. Usesrsplit('/')to get the basename, matches against"bash"and"zsh". Returns&'static str. Errors with a descriptive message on unsupported shells. Takes a&strparameter (not reading env directly) so it can be unit-tested without mutating environment variables — important becauseenv::set_varis unsafe in Rust edition 2024. -
bash_zsh_hook()(line 26) — Returns a&'static strcontaining ~80 lines of shell script. This is the heart of the module. The script defines:_LHI_PIDS— associative array mapping project root paths to watcher PIDs_lhi_find_root()— walks up from a directory looking for.lhi/_lhi_hook()— called after every cd; finds root, checks if already watching, launcheslhi watchif needed_lhi_deactivate()— kills all watchers, restores original cd/pushd/popd, removes all hook functions- cd/pushd/popd overrides — call the original or builtin, then
_lhi_hook - EXIT trap — calls
_lhi_deactivateon shell exit - Immediate
_lhi_hookcall — activates for the current directory at eval time
The script preserves existing
cdoverrides (e.g., from other tools) by copying the currentcdfunction body into_lhi_orig_cdbefore installing its own override. On deactivate, it copies the body back.
If you’re modifying this module:
- The shell script is a raw string literal — no syntax highlighting, no linting in your editor. Run
bash -nandzsh -non the output after changes, but be aware these only check syntax, not runtime behavior. detect_shelltakes&strinstead of reading env directly becauseenv::set_varis unsafe in edition 2024. Don’t change this to read env in tests.- The
declare -f cd | tail -n +2idiom extracts a function body. It works in both bash and zsh but the output format differs slightly — test in both.
Data flow
User's .bashrc / .zshrc
│
▼
eval "$(lhi activate)"
│
▼
activate() ──► detect_shell($SHELL) ──► "bash" | "zsh" | error
│
▼
bash_zsh_hook() ──► prints shell script to stdout
│
▼
Shell evals the script, installing:
├── cd() / pushd() / popd() ──► _lhi_hook()
│ │
│ ▼
│ _lhi_find_root($PWD)
│ │
│ ▼
│ Check _LHI_PIDS[$root]
│ │
│ ┌───────┴────────┐
│ │ │
│ Already watching Not watching
│ (kill -0 check) │
│ │ ▼
│ return 0 lhi watch $root &
│ _LHI_PIDS[$root]=$!
│
├── trap EXIT ──► _lhi_deactivate()
│ │
│ ▼
│ Kill all PIDs in _LHI_PIDS
│ Restore original cd
│ Unset all _lhi_* functions
│
└── _lhi_hook() (immediate, for current directory)
Known issues & how to fix them
1. Associative array syntax not portable between bash and zsh
File: src/commands/activate.rs, bash_zsh_hook() (line 26)
Severity: Critical
The problem: The script uses a single associative array syntax that doesn’t work on either target platform:
- macOS ships bash 3.2, which has no associative arrays at all.
_LHI_PIDS["/path"]=pidfails with an arithmetic evaluation error because bash 3.2 treats[...]as arithmetic context. - zsh has associative arrays but uses different syntax:
${(k)_LHI_PIDS[@]}for key iteration (not${!_LHI_PIDS[@]}), and${arr[key]+x}existence checks don’t work for associative array elements.
The fix: Emit shell-specific scripts. The _shell return value from detect_shell() is already captured — use it to dispatch between bash_hook() and zsh_hook(). Each uses the correct syntax for its shell. The shared logic (function structure, _lhi_find_root, cd overrides) can stay the same; only the associative array operations differ.
2. Unused _shell variable in activate()
File: src/commands/activate.rs, activate() (line 8)
Severity: Low (design decision, not a defect)
The problem: detect_shell() returns the shell name but activate() discards it with _shell. This is intentional — fish support is deferred and only one hook generator exists. When fish support is added, this becomes the dispatch point.
The fix: No action needed now. When adding fish support, change to match shell { "bash" | "zsh" => ..., "fish" => ..., }.
Things to watch out for when making changes
- bash 3.2 on macOS: Apple ships bash 3.2 (2007) due to GPLv3 licensing. Associative arrays,
declare -g,&>>,|&, and many other bash 4+ features are unavailable. If you target macOS bash users, stick to bash 3.2 syntax or require them to install bash 4+ via Homebrew. - zsh array syntax: zsh associative arrays use
typeset -A,${(k)arr}for keys,${(v)arr}for values, and(( ${+arr[key]} ))for existence checks. None of these work in bash. declare -foutput format: Both bash and zsh supportdeclare -f funcnameto print a function definition, but the exact output format differs. Thetail -n +2idiom to strip the first line (function name) works in both, but test after changes.- Shell script is a raw string: No editor support for the embedded shell. After any change, run:
cargo run -- activate 2>/dev/null | bash -n && echo okand the same withzsh -n. But remember these only catch syntax errors, not runtime failures. - Edition 2024 env safety:
std::env::set_varis unsafe. Tests usedetect_shell(&str)to avoid env mutation. Don’t add tests that callenv::set_varwithout an unsafe block.
Troubleshooting
(eval):2: parse error near '}' on source ~/.zshrc
This happened in older versions when the shell hook was eval’d twice (e.g. re-sourcing your rc file). The hook tried to save the existing cd function using declare -f cd | tail -n +2, which strips the opening { in zsh (where the brace is on the same line as the function signature, unlike bash). This was fixed — update lhi and open a fresh shell.
If your current session is stuck, reset it:
unset -f cd _lhi_orig_cd pushd popd _lhi_hook _lhi_find_root _lhi_deactivate 2>/dev/null
source ~/.zshrc
lhi commands are killed immediately (killed or exit code 137)
macOS Gatekeeper can SIGKILL unsigned or invalidly-signed binaries. This happens if you copy the lhi binary manually (e.g. cp) — the copy invalidates the ad-hoc code signature that cargo install creates. Fix it by re-signing:
codesign -f -s - $(which lhi)
Or reinstall with cargo install --path ., which produces a properly signed binary.
zsh: command not found: _lhi_orig_cd
The shell hook failed to load (usually due to the parse error above), leaving cd overridden to call _lhi_orig_cd which was never defined. Open a new terminal, or reset manually:
unset -f cd _lhi_orig_cd 2>/dev/null
source ~/.zshrc