magic undo for the command line
You just made a mistake on the command line — an edit, a botched mv, a rm that took more than you meant — and you want to undo its effects. shit watches the right kernel surfaces, captures pre-images cheaply via your filesystem's native copy-on-write, and produces an approvable undo plan.
Pre-1.0. APIs, wire formats, and on-disk layouts are unstable. The headline claim — "arbitrary command undo" — is honest only for the classes pinned by a green CI smoke today; see Coverage today and What doesn't work yet for the precise picture.
Coming soon: brew install tenseleyFlow/tap/shit, AUR (shit-bin), FreeBSD ports, and a signed curl … | sh.
git clone https://github.com/tenseleyFlow/shit
cd shit
make dev # cargo build --workspace
make test # cargo test --workspace
make lint # cargo clippy
make ci # everything CI runsshit is three binaries plus shell glue:
shit— the CLI:shit undo,shit redo,shit list,shit show,shit pin,shit doctor, …shitd— a per-user daemon that owns the snapshot store, the sqlite index, the IPC sockets, and the undo planner.shit-helper— a privileged helper that drives kernel-level capture: EndpointSecurity on macOS, fanotify-perm + eBPF-LSM on Linux, kqueue + LD_PRELOAD shim on FreeBSD (the shim is the supported default; running without it is reported byshit doctoras the degradedkqueue-onlytier). Falls back to FSEvents-degraded on un-entitled macOS installs.- shell hooks (bash, zsh, fish) that bracket each command with metadata events sent over a per-user Unix-domain-socket.
When a command writes, renames, unlinks, or otherwise mutates a file inside a tracked process subtree, the helper captures the pre-image before the kernel allows the syscall to complete — using clonefile (APFS), reflink (btrfs/XFS), zfs clone (ZFS), hardlink, or streaming copy, picking the cheapest tier the underlying filesystem supports. Blobs land in a content-addressed store (blake3 + zstd) under $XDG_STATE_HOME/shit/. When you run shit undo, the daemon's planner walks the captured events, computes an inverse-op DAG, shows you exactly what it'll do, and applies it on your y.
Honesty principle: any command class we cannot mechanically reverse goes in the refuse-list catalog — shit undo exits non-zero with a one-line reason rather than silently producing a partial undo.
Each entry below has a green CI smoke. The capture mechanism column is named at the kernel surface that fires, not the user-facing tool. "LSM" means the helper's eBPF program is attached to the named hook (bpf_lsm_<hook>); "shim" means the LD_PRELOAD shim in crates/shit-preload-shim/; "wrapper" means a packaging wrapper script in packaging/ that brackets the real binary with shit-helper <tool>-event.
A handful of smokes (daemon-boot.sh, brew-pkg.sh, sqlite3-db.sh) are tier-agnostic — they branch on the runtime platform inside the script and run unchanged on Linux, FreeBSD, and macOS. The smoke-driver auto-buckets them into every available CI runner.
Filesystem mutations (eBPF-LSM tier — requires lsm=bpf in /sys/kernel/security/lsm + cap_bpf,cap_perfmon,cap_sys_admin on the helper):
| Class | LSM hook | Smoke |
|---|---|---|
rm / unlink / unlinkat |
inode_unlink |
rm-undo-linux.sh |
rmdir / unlinkat(AT_REMOVEDIR) (carries dir mode for accurate restore) |
inode_rmdir (G03) |
git-clean-fd-undo-linux.sh |
chmod, chown, chgrp, utimes, truncate |
inode_setattr (v1/v2 BTF-dispatch for kernel ≥7.0 mnt_idmap drift) |
chmod-undo-linux.sh, chown-undo-linux.sh |
mkdir / mkdirat |
inode_mkdir |
mkdir-undo-linux.sh |
open(O_CREAT) / creat |
inode_create |
covered via edit-undo-linux.sh, touch-edit-undo-linux.sh |
rename / renameat2 (atomic-replace, vim/git/sed dance, dir-rename recursive pre-image) |
inode_rename (DR-CR-54 recursive subtree capture) |
mv-undo-linux.sh, vim-edit-undo-linux.sh, sed-i-undo-linux.sh, git-commit-undo-linux.sh, dir-rename-undo-linux.sh |
symlink / symlinkat (ln -s) |
inode_symlink |
ln-symlink-undo-linux.sh |
link / linkat (hardlink) |
inode_link |
ln-hardlink-undo-linux.sh |
open(O_TRUNC | O_WRONLY) (content overwrites) |
file_open + live-baseline pre-image |
edit-undo-linux.sh, cp-r-undo-linux.sh |
open(O_RDWR) + write/pwrite (in-place writes, no truncate) |
file_open + file_release content-diff refinement (L04.2, best-effort on kernel ≥7.0) |
write-in-place-undo-linux.sh |
mmap(MAP_SHARED) writes |
same as above; release fires on last unmap+close | mmap-write-undo-linux.sh |
cmd > existing-file (shell-pre-stash race) |
C06 redirect parser in the bash DEBUG trap → PreStashRedirects ctl |
redirect-race-undo-linux.sh, dd-of-undo-linux.sh |
Destructive git operations (G01 family — pre-image via the LSM rename/unlink path; the planner's .git/ smart renderer presents them as single user-readable lines):
| Class | Smoke |
|---|---|
git reset --hard <sha> — HEAD + worktree restore |
git-reset-hard-undo-linux.sh |
git checkout -- <file> — discard-edit restore |
git-checkout-file-undo-linux.sh |
git checkout <branch> — branch-switch restore |
git-checkout-branch-undo-linux.sh |
git stash drop — .git/refs/stash rewrite |
git-stash-drop-undo-linux.sh |
git branch -D (loose ref) |
git-branch-D-undo-linux.sh |
git branch -D (packed-refs path — git pack-refs --all collapsed shape) |
git-branch-D-packed-undo-linux.sh |
git clean -fd (files + directories at captured mode) |
git-clean-fd-undo-linux.sh |
Shell-state mutations (DEBUG-trap snapshot via crates/shit-shell/src/state.rs → PreExecShellState IPC → ShellStateRestore inverse, applied via the per-session precmd-queue mechanism — DR-CR-50):
| Class | Smoke |
|---|---|
cd <dir> — pwd restore (bash + zsh + fish parity) |
cd-undo-linux.sh, cd-undo-zsh-linux.sh, cd-undo-fish-linux.sh |
set -o errexit / set -e — shell-opts restore (bash + zsh) |
set-e-undo-linux.sh, set-e-undo-zsh-linux.sh |
alias name=val — alias-table restore |
alias-undo-linux.sh |
Build-install pre-image capture (LD_PRELOAD shim — works without LSM caps, opt-in via the install-pattern auto-detector in crates/shit/src/auto_inject.rs):
| Workload | Smoke |
|---|---|
make install PREFIX=… writing into a non-watched prefix |
make-install-undo-linux.sh |
cargo install --force overwriting an existing binary |
cargo-install-force-undo-linux.sh |
pip install --user --force-reinstall overwriting a module |
pip-install-user-undo-linux.sh (Outcome A via DR-CR-54 recursive pre-image; Outcome B graceful fallback) |
Native-undo delegation + synthesis fallback (helper hooks the package manager via apt's DPkg::Pre-Install-Pkgs / dnf's plugin / brew's --json snapshot):
| Tool | Path | Smoke |
|---|---|---|
apt-get install / remove (≥3.2 with native rollback) |
delegates to apt-get history-rollback <id> |
apt-history-rollback-linux.sh |
apt-get install / remove (<3.2) |
synthesizes inverse apt-get invocation |
refuse-without-native-delegate.sh (apt 2.8 path) |
dnf install / remove |
delegates to dnf history undo <id> |
dnf-history-undo-linux.sh |
apt-tier capture wire (DR-19) |
pkg-event IPC + journal |
apt-pkg.sh |
Container / orchestration (helper-side container-event wrapper captures docker inspect JSON pre-mutation; planner walks the JSON to reconstruct a docker run argv with ~15 flags):
| Workload | Smoke |
|---|---|
docker rm (full container restore from docker save + inspect-JSON argv synthesis) |
docker-rm-undo-linux.sh |
docker rmi (image tag restore via docker load) |
docker-rmi-undo-linux.sh |
docker volume rm (volume tar stash + restore) |
docker-volume-rm-undo-linux.sh |
docker network rm (network spec replay) |
docker-network-rm-undo-linux.sh |
docker compose down (compose project snapshot + up -d replay) |
docker-compose-down-undo-linux.sh |
podman rm (cross-runtime delegation through the same path) |
podman-rm-undo-linux.sh |
Cloud / IaC (helper-side cloud-event wrapper captures tool state pre-mutation):
| Workload | Capture | Smoke |
|---|---|---|
terraform apply |
reverse via terraform destroy |
terraform-apply-undo-linux.sh |
terraform destroy |
reverse via state-push + terraform apply |
terraform-destroy-undo-linux.sh |
kubectl delete <resource> |
reverse via captured-YAML apply -f - (context-drift gated) |
kubectl-delete-undo-linux.sh (kind cluster) |
gh release delete |
reverse via gh release create from captured metadata + asset list |
gh-release-delete-undo-linux.sh |
Services / networking / processes:
| Workload | Mechanism | Smoke |
|---|---|---|
systemctl stop / start / enable / disable |
wrapper → svc-event IPC → planner SystemdRollback |
systemctl-stop-undo-linux.sh, systemctl-svc.sh |
nft add table / chain / rule |
wrapper → net-event IPC |
nft-net.sh |
iptables -N / -A / -D |
wrapper → net-event IPC |
iptables-net-linux.sh |
kill / pkill / killall |
wrapper → proc-event IPC; informational + restart-hint (no reanimation) |
kill-proc-undo-linux.sh |
sqlite3 file.db "<sql>" |
shim → file pre-image; sqlite WAL-aware | sqlite3-db.sh |
Capture tier: kqueue (EVFILT_VNODE NOTE_WRITE/RENAME/DELETE/ATTRIB) + LD_PRELOAD shim for the cross-watch / install-overwrite race. The shim is the supported default; shit doctor reports the kqueue-only tier when the shim is missing and surfaces a multi-line WARN block telling you which classes of pre-image you lose. Capsicum sandbox is default-on (B05); set SHIT_CAPSICUM=0 to opt out.
Pre-image CoW tier on ZFS-backed paths: per-event zfs clone. The COW engine snapshots the source dataset, clones the snapshot to a hidden mountpoint (/tmp/.shit-clones/<id>), reads the pre-image bytes from the clone, then destroys the clone + snapshot — all per captured event. Falls through to hardlink + streaming copy when /sbin/zfs is missing, the path isn't on ZFS, or the dataset is mountpoint=none|legacy. Coverage validated by zfs-clone-primitives-fbsd.sh (primitives) and zfs-clone-engine-fbsd.sh (engine wire).
| Class | Smoke |
|---|---|
rm, mv (same-dir / cross-dir / dir), ln (sym + hard), cp -r, mkdir -p, mkfifo |
rm-undo-fbsd.sh, mv-{dir,across-dirs,noop}-undo-fbsd.sh, ln-{symlink,hardlink}-undo-fbsd.sh, etc. |
chmod (regular + recursive + setuid), chown (gid) |
chmod-{undo,recursive,setuid}-undo-fbsd.sh, chown-undo-fbsd.sh |
cmd > file, cmd >> file, tee, dd of=, dd conv=notrunc (partial writes), awk -i inplace, sort -o, sed -i |
shell-{redirect,append}-undo-fbsd.sh, tee-undo-fbsd.sh, dd-{large-file,notrunc}-undo-fbsd.sh, etc. |
vim :wq, git commit, atomic-rename save dances |
vim-edit-undo-fbsd.sh, git-commit-undo-fbsd.sh |
pkg install |
pkg-install-undo-fbsd.sh |
pfctl -f, pfctl -F all (firewall add + flush) |
pfctl-{add,flush}-undo-fbsd.sh |
service restart, kill (pattern resolution via sysctl kern.proc.proc) |
service-restart-undo-fbsd.sh, kill-proc-undo-fbsd.sh |
Long-tail: tar -x, cpio -i, gzip, gunzip, patch -p1, rsync (incremental), xargs rm, find -exec rm, make install (intra/cross-watch), backup-and-modify patterns |
corresponding -fbsd.sh smokes |
Capture tier: EndpointSecurity (M03 — entitled installs) with FSEvents-degraded fallback (M01 — un-entitled, no Apple Developer ID required). The helper auto-selects at boot: ES when the entitlement is granted, FSEvents otherwise. shit doctor reports the active tier.
| Class | Capture | Smoke |
|---|---|---|
rm / unlink |
ES AUTH_UNLINK |
es-unlink-undo-macos.sh |
mv / rename |
ES AUTH_RENAME |
es-rename-undo-macos.sh |
truncate / ftruncate |
ES AUTH_TRUNCATE |
es-truncate-undo-macos.sh |
content overwrites via open(O_WRONLY) |
ES AUTH_OPEN + clonefile pre-image |
es-open-write-undo-macos.sh |
chmod / chown / utimes (metadata) |
ES AUTH_SETATTRLIST |
es-chmod-metadata-macos.sh |
| xattr round-trip | ES + xattr capture (M03.x.XATTR) | es-xattr-roundtrip-macos.sh |
| FSEvents fallback (un-entitled) | post-hoc kqueue-style notifications, no pre-image | fsevents-fallback-macos.sh |
brew install / uninstall |
brew JSON snapshot wrapper | brew-pkg.sh |
sqlite3 file.db "<sql>" |
shim pre-image | sqlite3-db.sh |
M03.x follow-ups (mkfifo/mknod via AUTH_CREATE, chflags/extended-ACL via AUTH_SETFLAGS+AUTH_SETACL, hardlink via AUTH_LINK, MAP_SHARED mmap via AUTH_MMAP, AUTH_CLONE/AUTH_COPYFILE for cp -c) are tracked in .docs/sprints/macos/M03.x-followup-roadmap.md — promote to landed when a real-workload smoke surfaces the gap.
The planner short-circuits these classes at plan-build time with a one-line reason + optional remediation, surfaced under a Refused (out of scope for shit undo): block in shit undo output and exposed as arbitrary_undo_coverage.refused_classes in shit doctor --json. The list is in crates/shit-planner/src/refuse.rs.
| Class | Examples | Why |
|---|---|---|
| remote-push | git push, docker push, podman push, npm publish, cargo publish, gh release upload |
remote replication is outside our capture-tier visibility |
| history-rewrite | git rebase -i, git filter-branch, git filter-repo |
rewrites ref topology in ways the event journal cannot mechanically reverse; use git reflog |
| identity-generation | gpg --gen-key, gpg --full-gen-key, ssh-keygen |
key material may already be distributed; deleting the file cannot un-publish a public key |
| power-state | shutdown, reboot, halt, poweroff |
no inverse operation exists |
| sandbox-escape | chroot, unshare, nsenter |
namespace / chroot transitions move execution into a context our capture tier no longer observes |
| opaque-shell-mutation | source script.sh, . script.sh |
sourcing mutates the parent shell in arbitrary ways we don't introspect |
| system-identity | useradd, userdel, usermod, groupadd, groupdel, passwd |
PAM + shadow-file mutations touch state we won't trust ourselves to roll back without per-OS policy |
These are gaps where coverage is plausible but not yet shipped. Each item is tracked under .docs/sprints/ (gitignored) with implementation notes.
>>append /tee -aLinux smoke — the FileExtend wire + planner + executor all shipped in C06 (InverseOp::FileExtend { truncate_to: pre_size },FileExecutor::apply_file_extend); pipeline integration tests inshit-shell::c06_pipelineare green. The remaining gap is a pinned Linux smoke — FreeBSD hasshell-append-undo-fbsd.shcovering the round-trip; Linux needs the equivalent (proposed campaign:AR-append-smoke-linux).docker pull <floating-tag>digest journaling — DR-CR-51. Post-phase reconciliation reserved but not implemented.- multi-target
docker rmi a b cbatching — DR-CR-52. Today only the first positional is journaled. - Packaging install hooks —
apt install shit→doctor-green is the AR09 sprint goal. Today the daemon must be built + setcap'd manually. - mknod / mkfifo via privileged helper —
RecreatePathfor fifo/socket/block/char kinds returnsFailed { err: "needs helper-IPC privileged-op routing (DR-15.1)" }. The chown path is wired; mknod via helper is the remainder.
- NetBSD / OpenBSD / DragonFly — explicitly post-v1 stretch (B06). Today the helper compiles only on FreeBSD; the other BSDs have an untested kqueue port.
- Perf budgets — B07 wires a benchmark harness with regression gates. The Linux side is in place; BSD budgets land alongside the perf-bsd-14 CI gate.
- Notarized helper distribution — M04 sprint. Today the entitled-ES path requires a self-built helper with a developer-signed entitlement. Codesign self-verify infrastructure shipped in M07.C (the daemon refuses helpers whose code signature doesn't validate); the remaining M04 work is the signed-distribution + notarize CI pipeline so a
brew install shitships a ready-to-run binary. Un-entitled installs already get FSEvents-degraded capture out of the box. - DYLD interposer auto-install — DR-CR-34. The interposer itself shipped via M07.A (
shit dyld-hooks installwrites the shim to/usr/local/lib/libshit_preload_shim.dyliband the M07.B.5 smoke validates end-to-end against brew'sgchmod, a non-SIP binary). The remaining work is package-manager auto-install (sobrew install shitconfigures DYLD on first run), and the auth-event coverage for the long tail of dyld-strippable syscalls. SIP-protected system binaries are intentionally out of scope — Apple stripsDYLD_INSERT_LIBRARIESon those by design. - M03.x follow-ups — see
.docs/sprints/macos/M03.x-followup-roadmap.mdfor the precise list (AUTH_LINK / AUTH_MMAP / AUTH_SETFLAGS / AUTH_CLONE handlers); each gates on a real-workload smoke surfacing the gap.
These should work in practice once the underlying mechanism is wired (no architectural blocker discovered), but they don't have a pinned smoke today:
helm install / uninstall— same shape asdocker compose; descriptor candidate in AR08.2.ufw allow / deny,firewall-cmd --add-port— both are thin wrappers over iptables/nft; descriptor candidates.npm install -g,gem install— same LD_PRELOAD shim path aspip install --user. AR08.2.useradd(refusal) — thesystem-identityrefuse class catches it at plan-build time today (the refuse-list catalog is shipped in trunk); a pinned smoke would gate that the refusal stays loud.
| Capability | Linux (lsm=bpf) | Linux (no LSM) | FreeBSD | macOS (entitled ES) | macOS (FSEvents) |
|---|---|---|---|---|---|
| filesystem capture | ✅ eBPF-LSM (10 hooks: unlink/rmdir/setattr/mkdir/create/rename/symlink/link/file_open/file_release†) | ✅ kqueue + Capsicum sandbox | ✅ EndpointSecurity AUTH events | ||
| pre-image via CoW | ✅ reflink/btrfs/XFS/zfs | ✅ same | ✅ zfs clone / hardlink | ✅ APFS clonefile | ✅ APFS clonefile |
| LD_PRELOAD shim | ✅ install-pattern auto-inject | ✅ same | ✅ | ✅ DYLD interposer (M07.A install + M07.B.5 chmod smoke); SIP binaries excluded by Apple | ✅ same |
| package undo | ✅ apt, dnf | ✅ same | ✅ pkg | ✅ brew | ✅ brew |
| container undo | ✅ docker, podman, compose | ✅ same | |||
| cloud undo | ✅ terraform, kubectl, gh | ✅ same | |||
| services | ✅ systemctl | ✅ same | ✅ service | ||
| firewall | ✅ nft, iptables | ✅ same | ✅ pfctl | ❌ | ❌ |
| shell hook | ✅ bash, zsh, fish | ✅ same | ✅ bash |
✅ has a green CI smoke today;
† file_release is the in-place-write capture hook; on Linux ≥ 7.0 the LSM chain entry takes an extra mnt_idmap arg and the helper picks the right v1/v2 entry-point at load time via a BTF probe. Older kernels (< 7.0) use the legacy single-arg signature. A verifier-rejected attach on either fork degrades the helper to the fanotify-perm fallback rather than crashing.
shit doctor --json emits arbitrary_undo_coverage with two stable fields you can pin a CI gate to:
"arbitrary_undo_coverage": {
"covered_classes": ["container-rm", "container-rmi", "fs-content-restore", "fs-metadata", "fs-rename", "fs-tree", "kubectl-delete", "package-apt", "package-dnf", "package-brew", "preload-install", "process-note", "redirect-truncate", "service-systemctl", "tool-gh", "tool-network", "tool-terraform", "..."],
"refused_classes": ["remote-push", "history-rewrite", "identity-generation", "power-state", "sandbox-escape", "opaque-shell-mutation", "system-identity"],
"coverage_pct": 74,
"last_validated_at": ""
}The covered_classes list is hand-curated against the AR08.1 audit; the refused_classes list is enumerated from shit-planner::refuse::CATALOG at runtime so the two cannot drift.
AGPLv3. See LICENSE.