Revert what the last shell command did to the filesystem.
Website: undo.edaywalid.com
Works for the classic mistakes: rm / rm -rf, mv over a file you
needed, truncating with >, an accidental chmod -R, files and
directories created by a script that ran amok. Changed your mind again?
undo redo re-applies. Undo never deletes anything permanently, only
parks it in the session store, so a session toggles between undone and
applied as many times as you like.
Contents
- Why
- How it works
- Install
- Usage
- Storage and disk space - what it keeps, for how long, and what it costs you
- Ignoring build noise
- Configuration - every environment variable
- What it cannot catch
- Secure deletion - read this before
shred - Platform support
- Upgrading and Uninstalling
- Development
The shell has never had an undo, and the usual advice is that once data
is overwritten it is gone. The tools that do exist ask you to change
your habits first: alias rm to a trash command, or set up btrfs/zfs
snapshots before the accident. undo asks for nothing. You keep typing
rm, and the safety net is already under you, even when the deletion
happens three processes deep inside a build tool where no alias could
reach.
It is command-granular, not time-granular: it reverts exactly what that one command touched, on any filesystem, without root. That is the difference from a snapshot, which rolls the whole tree back to a point in time and takes your good work with it.
There is no snapshotting and no daemon. Nothing runs between your commands.
- Hook. A shell hook (zsh, bash, fish) arms a small
LD_PRELOADlibrary,libundo.so, around every command you run. - Journal. While armed, the library intercepts destructive libc
calls (
unlink,rename,openwith write flags,rmdir,mkdir,chmod, ...). Before each one goes through, it saves the affected file into a per-command session and appends a line to a journal. Deletions are saved by hardlink, sorm -rfon gigabytes copies no data and costs almost nothing. - Replay.
undoreads the last session's journal and replays it in reverse: relink deleted files, move renames back, swap truncated files with their backups, remove accidental creations, recreate directories with their original modes.
Each command that changed something gets one session directory. The whole storage format is plain files you can inspect:
~/.local/share/undo/sessions/ (mode 0700)
└── 1784718280691946/
├── cmd rm -rf thesis/ the command line, for `undo list`
├── journal one line per change replayed in reverse
├── pid, done liveness markers so undo won't touch a running command
└── data/
├── 48211-1 = thesis/draft.md (hardlink, no data copied)
└── 48211-2 = thesis/refs.bib
The last 30 sessions are kept, within a 1 GiB budget; both are
configurable, and undo purge wipes the store. The most recent session
is always kept, even if it alone exceeds the budget, since that is the
one undo reverts.
Any distro, no root, nothing to configure:
curl -fsSL https://undo.edaywalid.com/install.sh | shThat installs into ~/.local, then asks whether to add the hook line
to your shell rc, showing you the exact line first. Say no and it prints
the instructions instead; it never edits anything when there is no
terminal to ask at (a CI run, a piped install). UNDO_MODIFY_RC=1
answers yes for scripted installs, UNDO_NO_MODIFY_RC=1 answers no.
Prefer a package manager?
| Channel | Command |
|---|---|
| Homebrew (Linux) | brew install edaywalid/tap/undo |
| Debian / Ubuntu | .deb from releases, then sudo dpkg -i undo_*.deb |
| Fedora / openSUSE | .rpm from releases, then sudo rpm -i undo_*.rpm |
| Arch | yay -S undo-cli-bin (AUR), or .pkg.tar.zst from releases |
| Nix | nix run github:edaywalid/undo (flake, experimental) |
| From source | make install (needs gcc + go, installs to ~/.local) |
If you let the installer do it, open a new terminal and skip to step 3. After a package install, or if you declined, add the line for your shell:
zsh
echo 'source /usr/share/undo/undo.zsh' >> ~/.zshrc && exec zshbash (5 or newer)
echo 'source /usr/share/undo/undo.bash' >> ~/.bashrc && exec bashfish (3.4 or newer)
echo 'source /usr/share/undo/undo.fish' >> ~/.config/fish/config.fish && exec fishInstalling from source or the one-liner puts the hooks in
~/.local/share/undo/ instead of /usr/share/undo/.
$ undo doctordoctor verifies the install and then actually deletes and restores a
canary file, so you get a real answer rather than a guess. Then try it
for yourself:
$ touch x
$ rm x
$ undoRun undo on its own line, not chained with &&. A chained line is a
single command to the shell, so undo would be running inside the very
session it is being asked to revert, before that command has finished.
No hook, or a script / CI context? Arm a single command instead, no setup required:
undo run -- ./sketchy-cleanup.shRequires Linux (amd64 or arm64) with glibc 2.6 or newer, which is every distribution still in use. See platform support for musl, macOS and WSL.
undo revert the most recent command that changed files
undo -i pick a session, cherry-pick individual entries to restore
undo redo [id] re-apply an undone session
undo diff [id] show what a session changed, with content diffs
undo run -- cmd run one command with the shim armed, no hook needed
undo list recent sessions, newest first
undo show [id] what a session changed
undo apply <id> revert a specific session
undo gc prune old, empty, and oversized sessions
undo purge delete all stored sessions and backups
undo doctor check the install and run a live capture/restore test
undo upgrade update to the latest release
undo uninstall remove undo (--purge also deletes backups)
The honest version, because it is the question everyone asks second.
Deleting costs no new disk, but it does defer freeing the old. When
you rm a file, undo makes a hardlink to it before the delete runs.
A hardlink is a second name for the same data, not a copy, which is why
rm -rf on gigabytes is instant and adds nothing. The catch: Linux only
frees a file's blocks when its last name disappears, and undo is still
holding one. So the space is not reclaimed until that backup is pruned.
If you deleted something specifically to free disk, run undo purge.
Overwriting a file in place does cost disk. A hardlink cannot
preserve content that is about to be rewritten, so for > truncation,
editors, and shred, undo copies the previous version. That copy is real
new space, capped per file by UNDO_MAX_BYTES (256 MiB default).
Nothing grows without bound. After every command undo prunes the store:
| Limit | Default | Meaning |
|---|---|---|
UNDO_KEEP |
30 | how many commands are kept, not files. One rm -rf of 10,000 files is a single session and one undo restores all of them. |
UNDO_MAX_STORE |
1 GiB | total size budget for the whole store. Oldest sessions are dropped first. |
The most recent session is never pruned, even if it alone exceeds the
budget, since that is the one plain undo reverts. Older sessions that
bust the budget are dropped almost immediately, so a very large delete
stays undoable only until the next command unless you raise the limits.
Commands that changed nothing leave no session at all. undo gc prunes
on demand, undo purge empties the store completely.
If you are deleting something because it must be gone, know that undo
works against you by default: for an in-place overwrite it copies the
original bytes into its store before shred rewrites them, so a
readable copy survives. undo purge unlinks that copy but does not
overwrite it either.
Exclude the path first, then shred:
echo '/home/you/secrets' >> ~/.config/undo/ignore
shred -u ~/secrets/tax-return.pdfsudo shred also works, since sudo strips LD_PRELOAD and undo never
loads. Note that shred is itself unreliable on journaling filesystems
and SSDs, which its own man page explains.
A command that rewrites thousands of files (a package install, a build)
would otherwise flood undo list and the store with churn you will never
revert. The shim always skips node_modules, .cache, __pycache__,
and .git, and collapses repeated writes to the same file within one
command down to a single backup. Add your own patterns in
~/.config/undo/ignore (see examples/ignore):
target # any path component named target, at any depth
dist
/home/you/scratch # an absolute path and everything under it
Set UNDO_DEFAULT_IGNORE=0 to turn the built-ins off, or UNDO_IGNORE
to a colon-separated list to override the config file.
Environment variables, set before sourcing the hook:
| Variable | Default | Meaning |
|---|---|---|
UNDO_KEEP |
30 |
how many commands are kept (see storage) |
UNDO_MAX_STORE |
1 GiB | total store size budget in bytes; oldest pruned first |
UNDO_MAX_BYTES |
256 MiB | largest file the shim will copy for an in-place overwrite; deletions are hardlinked with no size limit |
UNDO_DATA_DIR |
~/.local/share/undo |
where sessions live |
UNDO_IGNORE |
from config file | colon-separated ignore patterns, overrides ~/.config/undo/ignore |
UNDO_IGNORE_FILE |
~/.config/undo/ignore |
where the ignore list is read from |
UNDO_DEFAULT_IGNORE |
on | set to 0 to stop skipping node_modules, .cache, __pycache__, .git |
UNDO_CAPTURE_SHELL |
off | zsh only: set to 1 to re-exec once at startup with the shim preloaded, so the shell's own redirections (echo x > file) are captured too |
UNDO_LIB |
auto-detected | explicit path to libundo.so |
Installer-only variables, for install.sh:
| Variable | Meaning |
|---|---|
PREFIX |
install location, default ~/.local |
UNDO_MODIFY_RC |
set to 1 to add the hook line without being asked (for scripted installs) |
UNDO_NO_MODIFY_RC |
set to 1 to never touch your shell rc |
LD_PRELOAD only reaches dynamically linked programs that go through
libc. undo does not see:
- static binaries and programs that make raw syscalls (Go programs)
sudoand setuid programs; the loader stripsLD_PRELOAD- writes through already-open file descriptors,
ftruncate, mmap writes chownand other metadata beyond mode (chmodis covered)- anything run outside the hooked shell (use
undo runthere)
Coreutils and your shell are glibc-dynamic, which is exactly where the everyday accidents happen. This is a safety net, not a backup. Keep backups.
- Linux, amd64 and arm64. Any glibc distro (Debian, Ubuntu, Fedora, Arch, openSUSE, ...). WSL2 counts, it is Linux.
- Alpine / musl: build from source (
make install, CI-tested); the prebuilt shim in releases targets glibc. - Shells: zsh, bash 5+, fish 3.4+ for the automatic hook. Any shell
works with
undo run. The fish hook is currently untested. - macOS: not supported. SIP blocks library injection into system
binaries, so a port could not cover
rmand friends. - Windows: use it inside WSL2.
- Snap / Flatpak: never; sandboxing and an
LD_PRELOADhook are architecturally incompatible. Use a package or the installer.
$ undo upgrade # or: undo upgrade --check, to only lookThat updates a copy installed by the one-liner or make install, in
place. If undo came from a package manager it will not fight it, and
prints the right command for your system instead (brew upgrade undo,
sudo pacman -Syu undo-cli-bin, and so on). Open a new shell afterwards
so the updated hook is loaded.
$ undo uninstall # keeps your backups
$ undo uninstall --purge # deletes the session store tooIt shows what it will remove and asks first, then takes out the binary,
the shim, the hooks and the completions, and removes the lines it added
to your shell rc. Your session store is left alone unless you pass
--purge. A package-managed copy is not touched: it prints your package
manager's remove command instead.
Flags: -n dry run, -y skip the confirmation prompt, --force
overwrite files that changed again after the session.
undo asks before reverting, refuses to clobber a path that was recreated
after the session unless you pass --force, and refuses to touch a
session whose command may still be running in another terminal. undo list marks undone sessions with a leading u.
If nothing seems to happen after installing, run undo doctor: it
locates the shim, checks the store, and deletes then restores a canary
file end to end, so you get a concrete diagnosis instead of silence.
make # build bin/undo and build/libundo.so
make test # go unit tests + end-to-end suiteThe end-to-end suite (test/e2e.sh) arms the shim exactly as the hooks
do and exercises real deletions, overwrites, renames, undo, redo, ignore
rules, and gc against a temp directory. See
CONTRIBUTING.md for layout and the release process.
MIT. See LICENSE.
