# undo parks what your last shell command deleted, then hands it back

> A Linux command that reverses the filesystem damage of the single command you just ran, by parking deleted files as hardlinks instead of copying them. The interception happens in an LD_PRELOAD shim rather than in an alias for rm, which is what lets it catch a script that ran amok three processes deep.

**edaywalid/undo** — Project brief: Undo what the last shell command did to the filesystem. Hook. A shell hook (zsh, bash, fish) arms a small LD_PRELOAD library, libundo.so, around every command you run.

- Repository: https://github.com/edaywalid/undo
- Website: https://undo.edaywalid.com
- Stars: 400 · Forks: 13
- Language: Go
- License: MIT
- Published: 2026-08-08 · Updated: 2026-08-18 · Language: en
- Canonical page: https://hysenlabs.com/projects/edaywalid-undo

## The safety net is armed by LD_PRELOAD, and nothing runs between your commands

There is no snapshotting and no daemon in this design. Nothing runs between your commands, which is the part that separates it from the usual advice for this problem. The usual advice is to change your habits first, alias rm to a trash command, or set up btrfs or 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.

The mechanism is a shell hook for zsh, bash or fish that arms a small LD_PRELOAD library, libundo.so, around every command you run. While armed, the library intercepts destructive libc calls, and the ones named are unlink, rename, open with write flags, rmdir, mkdir and chmod. Before each one goes through, the affected file is saved into a per-command session and a line is appended to a journal.

Two binaries come out of that design, and only one of them is Go. The Makefile compiles build/libundo.so from shim/undo_shim.c with gcc, -shared -fPIC -O2 -Wall -Wextra, linked against -ldl -lpthread, and compiles bin/undo from the Go sources under cmd/ and internal/ with go 1.24 as declared in go.mod.

## Command granularity is the whole argument against snapshots

The design is explicitly command-granular, not time-granular. It reverts exactly what that one command touched, on any filesystem, without root. A snapshot rolls the whole tree back to a point in time and takes your good work with it. A per-command journal does not, because the unit of blast radius is the command rather than the clock.

That granularity is also what makes the tool reachable in places an alias cannot go. The library is loaded into whatever process the shell starts, so interception happens at the libc boundary instead of at the command line. A build script that creates and then removes a directory tree is covered by the same mechanism that covers you typing rm by hand.

Replay is the journal read backwards. Running undo reads the last session's journal and replays it in reverse: deleted files are relinked, renames move back, truncated files are swapped with their backups, accidental creations are removed, and directories are recreated with their original modes. Each command that changed something gets one session directory, so the granularity survives the round trip.

## Hardlinks are why parking a gigabyte rm -rf costs almost nothing

Deletions are saved by hardlink, and that one decision is what makes the storage cost tolerable. rm -rf on gigabytes copies no data and costs almost nothing, because a deleted file is not copied into the store, it is given a second name, and relinked later if you ask for a revert.

The store is plain files you can inspect yourself:

```
~/.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
    ├── budget     bytes stored so far     shared by every process in the session
    └── data/
        ├── 48211-1   = thesis/draft.md    (hardlink, no data copied)
        └── 48211-2   = thesis/refs.bib
```

Two details in that tree matter operationally. The pid and done markers exist so undo will not touch a running command, and the budget file is shared by every process in the session, which matters when one command forks children that keep writing.

## Thirty sessions and one gibibyte, with the newest one exempt from the cap

Retention is 30 sessions within a 1 GiB budget, both configurable, and undo purge wipes the store. The exemption rule is the part worth planning around: the most recent session is always kept, even if it alone exceeds the budget, since that is the one undo reverts.

So the worst case on disk is whatever your last destructive command touched, plus whatever the 29 sessions behind it already occupy. Because deletions are hardlinked rather than copied, the number that actually grows is the set of files that were created or truncated, not the set that was unlinked. A recursive delete is cheap to park; a script that writes gigabytes of new output is not.

Older sessions roll off as new ones arrive, so a mistake you walk away from for a week is gone, and nothing tells you it happened. The project's own table of contents treats retention as a first-class concern, listing storage and disk space, ignoring build noise and secure deletion as separate topics, with the last one flagged to read before using shred.

## Six install channels and three different hook directories

The short path is a single pipe:

```sh
curl -fsSL https://undo.edaywalid.com/install.sh | sh
```

That installs into ~/.local and 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. When there is no terminal to ask at, a CI run or a piped install, it never edits anything, and two environment variables answer the question for you: UNDO_MODIFY_RC=1 for yes, UNDO_NO_MODIFY_RC=1 for no.

The channels land the hook in different places. A .deb, .rpm or AUR install puts it under /usr/share/undo/, the one-liner and make install put it under ~/.local/share/undo/, and Homebrew puts it under $(brew --prefix)/share/undo/. Copying the wrong path is the first thing that fails silently, because a source line pointing at a file that is not there produces no error at all.

The other channels are Homebrew on Linux with brew install edaywalid/tap/undo, a .deb from releases followed by sudo dpkg -i undo_*.deb, an .rpm followed by sudo rpm -i undo_*.rpm, yay -S undo-cli-bin or a .pkg.tar.zst on Arch, nix run github:edaywalid/undo marked experimental because it is a flake, and make install from source, which needs gcc and go and installs to ~/.local.

## Homebrew's prefix has to be expanded while you write the line

For Homebrew, let the shell resolve the prefix as you write the line, so your rc ends up with a plain path:

```sh
echo "source $(brew --prefix)/share/undo/undo.bash" >> ~/.bashrc && exec bash
```

Use double quotes there. Left unexpanded, that runs brew on every shell startup, and brew is a Ruby program, so the cost lands on every prompt you will ever see.

The plain paths, for a distro package, look like this:

```sh
echo 'source /usr/share/undo/undo.zsh' >> ~/.zshrc && exec zsh
```

```sh
echo 'source /usr/share/undo/undo.bash' >> ~/.bashrc && exec bash
```

```fish
echo 'source /usr/share/undo/undo.fish' >> ~/.config/fish/config.fish && exec fish
```

Bash needs version 5 or newer and fish needs 3.4 or newer, both stated as requirements. zsh carries no version floor in that list. The completions travel with the install too: _undo into the zsh site-functions directory, undo.bash into bash-completion, and undo.fish into the fish vendor_completions directory.

## Chaining undo with && puts it inside the session it is trying to revert

Run 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.

To check the install rather than assume it:

```console
$ undo doctor
```

doctor verifies the install and then actually deletes and restores a canary file, so the answer is observed rather than guessed. Then try it by hand:

```console
$ touch x
$ rm x
$ undo
```

For a script or a CI context with no hook, arm one command instead:

```sh
undo run -- ./sketchy-cleanup.sh
```

That needs Linux on amd64 or arm64 with glibc 2.6 or newer, described as every distribution still in use. Musl, macOS and WSL are pointed at a separate platform support section, so check that section before assuming the one-liner works on a Mac. Changed your mind after the revert, and undo redo [id] re-applies, because nothing was destroyed, only parked.

## Three patch releases in two weeks, then the last push on 2026-08-12

The release cadence at the end of the project is tight: v0.3.0 on 2026-08-10, v0.2.9 on 2026-08-02, v0.2.8 on 2026-07-29, and a last push on 2026-08-12. The repository is not archived, the licence is MIT, and go.mod declares go 1.24.

The layout tells you where the two halves of the design live. cmd/ and internal/ hold the Go binary, shim/ holds undo_shim.c, shell/ holds undo.zsh, undo.bash and undo.fish, completions/ holds the three completion files, packaging/ and .goreleaser.yaml handle distribution, install.sh is the one-liner entry point, flake.nix backs the Nix channel, and site/ backs the website.

Testing is split in the Makefile target. go test ./... runs first, then ./test/e2e.sh, then ./test/hook.sh is called once per shell, zsh, bash and fish, each of which skips itself when that shell is not installed. examples/ignore ships as the documented way to exclude build noise from the store. A prebuilt make install copies the Go binary, the shim, the three hook files and the three completion files into a prefix that defaults to $(HOME)/.local.

## Conclusion

Take undo seriously if you type destructive commands on a Linux box where you cannot afford to lose a directory, and install it the way you install everything else, through a package manager, so the hook path matches. Skip it if your work lives on macOS or an Alpine-style musl system, because the stated requirement is Linux on amd64 or arm64 with glibc 2.6 or newer, and skip it if what you need is a time machine rather than a rewind of one command. Before you rely on it, check the last push date, 2026-08-12, against your own plans, and remember that the 30 session and 1 GiB retention limits mean an old mistake may already be gone.

## FAQ

### What does the undo command do?

Run on its own line, it reads the most recent session's journal and replays it in reverse: deleted files are relinked, renames move back, truncated files are swapped with their backups, accidental creations are removed, and directories come back with their original modes. Nothing is deleted permanently, so undo redo re-applies the same session.

### Is there an undo command in Linux?

The shell itself has no undo, and the usual advice is to change your habits first by aliasing rm to a trash command or setting up btrfs or zfs snapshots. undo takes the other route: you keep typing rm, because a shell hook arms a small LD_PRELOAD library, libundo.so, around every command that intercepts destructive libc calls even three processes deep. The stated requirement is Linux on amd64 or arm64 with glibc 2.6 or newer.

### How do I undo undo?

Use undo redo, optionally with a session id. A revert never destroys anything, it parks the affected files in the session store, so a single session toggles between undone and applied as many times as you like.

## Sources

- [Official documentation](https://undo.edaywalid.com)
- [Official README](https://github.com/edaywalid/undo#readme)
- [Project repository](https://github.com/edaywalid/undo)
- [Release notes](https://github.com/edaywalid/undo/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/edaywalid-undo
