Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
- name: build shim and check its glibc floor
run: |
mkdir -p build
gcc -shared -fPIC -O2 -Wall -o build/libundo.so shim/undo_shim.c -ldl
gcc -shared -fPIC -O2 -Wall -o build/libundo.so shim/undo_shim.c -ldl -lpthread
max=$(objdump -T build/libundo.so | grep -o 'GLIBC_[0-9.]*' | sort -u -V | tail -1)
echo "highest glibc symbol required: $max"
# even built on a modern runner, nothing newer than 2.34 (dlsym)
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ jobs:
mkdir -p build
docker run --rm -v "$PWD:/w" -w /w debian:11 bash -c '
apt-get update -qq && apt-get install -y -qq gcc gcc-aarch64-linux-gnu binutils
gcc -shared -fPIC -O2 -Wall -o build/libundo_amd64.so shim/undo_shim.c -ldl
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -o build/libundo_arm64.so shim/undo_shim.c -ldl
gcc -shared -fPIC -O2 -Wall -o build/libundo_amd64.so shim/undo_shim.c -ldl -lpthread
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -o build/libundo_arm64.so shim/undo_shim.c -ldl -lpthread
'
- name: assert the shims load on old distros
run: |
Expand Down
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
# Changelog

## Unreleased

- undo could fill a disk. One session grew to 147G over 23 hours and took
a 320G filesystem to zero bytes free, which broke every other program on
the machine. The size budget was enforced by `undo gc`, which the shell
hook only calls when a command returns; the command here was an agent
that ran all day and never did. Nothing was ever going to prune the one
session that was growing, and `gc` skips live sessions anyway. Two
ceilings now live in the shim, checked as it writes: `UNDO_MIN_FREE`
(2 GiB, a floor on free space) and `UNDO_MAX_SESSION` (half of
`UNDO_MAX_STORE`, so the store has room for more than one session). Whichever trips first, that session stops recording and says
so at the next prompt; `undo list` marks it `!` and `undo show`
explains the gap. The command itself is never blocked.
- `UNDO_MAX_BYTES` did not apply to deletions. A hardlink copies no data,
so the per-file cap skipped it, but the link is exactly what stops the
original blocks being freed when the file is unlinked. Deleting a 4G
file cost 4G that the cap was supposed to have refused.
- The built-in ignore list missed the caches that churn hardest.
`.turbo/cache` never matched the `.cache` pattern, because patterns
match whole path components. Added `.turbo`, `.next`, `.nuxt`, `.vite`,
`.svelte-kit`, `.parcel-cache`, `.angular`, `.nx`, `.tox`,
`.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.gradle`, `.terraform`,
`.dart_tool`, `test-results` and `playwright-report`. `dist`, `build`,
`target` and `vendor` stay opt-in in `examples/ignore`: they hold
generated output most of the time, but an accidental `rm -rf dist` is
something people want back.
- The shim leaked a journal descriptor and a mapped page per thread. Both
are thread-local, and a thread that exits takes the variable holding
them but not the descriptor or the page itself, so a program doing its
file work on short-lived threads climbed towards `EMFILE`. Released
from a TLS destructor now; the shim links pthread for it, and the
released binary keeps its glibc 2.6 floor.
- Deleting a session whose command was still running could fail partway
with `Directory not empty`, having already destroyed most of it: the
shim recreated backups as fast as the delete unlinked them. Sessions
are renamed out of the way before removal.
- The shell hook wrote its done marker without checking the session
directory still existed, so a store removed underneath it made every
later prompt print `no such file or directory`. Fixed in all three
shells.

## v0.2.9 - 2026-08-02

- The shim could stop journaling partway through a command with no sign
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ all: bin/undo build/libundo.so

build/libundo.so: shim/undo_shim.c
@mkdir -p build
$(CC) -shared -fPIC -O2 -Wall -Wextra -o $@ $< -ldl
$(CC) -shared -fPIC -O2 -Wall -Wextra -o $@ $< -ldl -lpthread

bin/undo: $(GO_SRC) go.mod
@mkdir -p bin
Expand Down
49 changes: 41 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ whole storage format is plain files you can inspect:
├── 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
Expand Down Expand Up @@ -219,10 +220,28 @@ 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).
new space, capped per file by `UNDO_MAX_BYTES` (256 MiB default). The
same cap applies to deletions: the hardlink costs no new blocks, but it
is exactly what keeps the original ones from being freed.

**Nothing grows without bound.** After every command undo prunes the
store:
**Two ceilings apply while a command runs**, enforced by the shim itself:

| Limit | Default | Meaning |
| --- | --- | --- |
| `UNDO_MIN_FREE` | 2 GiB | free space undo refuses to eat into. Checked against the store's filesystem as backups are written. |
| `UNDO_MAX_SESSION` | half of `UNDO_MAX_STORE` (512 MiB) | how much one command may record. Derived so the store has room for more than one session. |

Hit either and the session stops recording, writes down why, and says so
at your next prompt. `undo list` marks it `!` and `undo show` explains
the gap. The command itself is never blocked or slowed down; undo only
ever stops recording it.

This matters most for commands that do not return for hours: an editor,
a dev server, an agent. The store-wide limits below only get a turn
between commands, so before this they simply never ran for the session
that was actually growing.

**Two more apply between commands**, when the prompt comes back:

| Limit | Default | Meaning |
| --- | --- | --- |
Expand Down Expand Up @@ -260,9 +279,21 @@ 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
revert. The shim always skips the caches that build tools own and
recreate:

```
node_modules __pycache__ test-results playwright-report
.git .cache .turbo .next .nuxt .vite .svelte-kit .parcel-cache
.angular .nx .tox .pytest_cache .mypy_cache .ruff_cache
.gradle .terraform .dart_tool
```

It also collapses repeated writes to the same file within one command
down to a single backup. Names you might have picked yourself, like
`dist`, `build`, `target` or `vendor`, are deliberately not built in:
they hold generated output most of the time, but an accidental
`rm -rf dist` is a thing people want back. Add your own patterns in
`~/.config/undo/ignore` (see [`examples/ignore`](examples/ignore)):

```
Expand All @@ -282,11 +313,13 @@ Environment variables, set before sourcing the hook:
| --- | --- | --- |
| `UNDO_KEEP` | `30` | how many commands are kept (see [storage](#storage-and-disk-space)) |
| `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_MAX_BYTES` | 256 MiB | largest file the shim will back up, for deletions as well as overwrites |
| `UNDO_MIN_FREE` | 2 GiB | stop recording when the store's filesystem has less than this free; `0` disables |
| `UNDO_MAX_SESSION` | half of `UNDO_MAX_STORE` | stop recording when one command has stored this much; `0` disables |
| `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_DEFAULT_IGNORE` | on | set to `0` to stop skipping the built-in cache directories |
| `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` |

Expand Down
14 changes: 13 additions & 1 deletion cmd/undo/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,20 @@ func reportStore(report func(checkState, string, string), root string) {
report(pass, "store", root)
}

// builtinIgnores mirrors default_ignores and default_dot_ignores in
// shim/undo_shim.c. TestBuiltinIgnoresMatchShim parses the C source and
// fails if the two drift, which is how this list went stale last time.
var builtinIgnores = []string{
"node_modules", "__pycache__", "test-results", "playwright-report",
".git", ".cache", ".turbo", ".next", ".nuxt", ".vite", ".svelte-kit",
".parcel-cache", ".angular", ".nx", ".tox", ".pytest_cache",
".mypy_cache", ".ruff_cache", ".gradle", ".terraform", ".dart_tool",
}

func reportIgnore(report func(checkState, string, string)) {
defaults := "node_modules, .cache, __pycache__, .git (built in)"
// too many to spell out on one line; the README has the full list
defaults := fmt.Sprintf("%d tool caches built in (%s, ...)",
len(builtinIgnores), strings.Join(builtinIgnores[:4], ", "))
if extra := os.Getenv("UNDO_IGNORE"); extra != "" {
n := len(strings.Split(extra, ":"))
report(pass, "ignore", fmt.Sprintf("%d extra pattern(s) from config; %s", n, defaults))
Expand Down
42 changes: 42 additions & 0 deletions cmd/undo/doctor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"os"
"regexp"
"sort"
"testing"
)

// The built-in ignore list lives in C, and doctor keeps a copy so it can
// report it. A copy that drifts tells users the shim skips things it does
// not, so read the real list out of the shim source and compare.
func TestBuiltinIgnoresMatchShim(t *testing.T) {
src, err := os.ReadFile("../../shim/undo_shim.c")
if err != nil {
t.Fatalf("cannot read shim source: %v", err)
}
arrays := regexp.MustCompile(
`(?s)default_(?:dot_)?ignores\[\] = \{(.*?)\};`).FindAllSubmatch(src, -1)
if len(arrays) != 2 {
t.Fatalf("expected 2 default ignore arrays in the shim, found %d", len(arrays))
}
quoted := regexp.MustCompile(`"([^"]*)"`)
var fromShim []string
for _, a := range arrays {
for _, m := range quoted.FindAllSubmatch(a[1], -1) {
fromShim = append(fromShim, string(m[1]))
}
}
got := append([]string(nil), builtinIgnores...)
sort.Strings(got)
sort.Strings(fromShim)
if len(got) != len(fromShim) {
t.Fatalf("doctor lists %d built-in ignores, the shim has %d\ndoctor: %v\nshim: %v",
len(got), len(fromShim), got, fromShim)
}
for i := range got {
if got[i] != fromShim[i] {
t.Errorf("built-in ignore mismatch: doctor has %q, shim has %q", got[i], fromShim[i])
}
}
}
13 changes: 13 additions & 0 deletions cmd/undo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ func cmdList() {
if s.Undone {
mark = "u"
}
// a partial session restores fine, it just is not the whole
// command, and that is not something to find out afterwards
if s.Degraded != "" {
mark = "!"
}
cmd := s.Cmd
if len(cmd) > 60 {
cmd = cmd[:57] + "..."
Expand All @@ -153,6 +158,9 @@ func cmdShow(args []string) {
fatal(fmt.Errorf("no such session"))
}
fmt.Printf("session %s (%s)\n$ %s\n\n", shortID(s.ID), when(s.ID), s.Cmd)
if s.Degraded != "" {
fmt.Printf(" ! %s\n ! changes after that point were not recorded\n\n", s.Degraded)
}
for i, e := range s.Entries {
fmt.Printf(" %2d %s\n", i+1, e.Describe())
}
Expand Down Expand Up @@ -224,6 +232,11 @@ func cmdApply(s *session.Session, dir restore.Direction, opts restore.Options, y
}
fatal(fmt.Errorf("the command may still be running (pid %d); --force to override", s.Pid))
}
if s.Degraded != "" && dir == restore.Undo {
fmt.Fprintf(os.Stderr, "warning: %s\n"+
"warning: this session is incomplete; changes after that point"+
" cannot be reverted\n", s.Degraded)
}
if dir == restore.Undo && s.Undone {
fatal(fmt.Errorf("session was already undone (undo redo %s to re-apply)", shortID(s.ID)))
}
Expand Down
33 changes: 26 additions & 7 deletions examples/ignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,41 @@
# anything under a directory named target, at any depth)
# /abs/path matches that absolute path and everything under it
#
# The shim ALWAYS ignores these on top of your list, unless you set
# UNDO_DEFAULT_IGNORE=0: node_modules .cache __pycache__ .git
# On top of your list the shim always ignores the tool-owned caches
# below, unless you set UNDO_DEFAULT_IGNORE=0:
#
# node_modules __pycache__ test-results playwright-report
# .git .cache .turbo .next .nuxt .vite .svelte-kit
# .parcel-cache .angular .nx .tox .pytest_cache .mypy_cache
# .ruff_cache .gradle .terraform .dart_tool
#
# Everything below is opt-in. These names hold generated output most of
# the time, but people also name their own source directories this, and
# undo will not make a directory unrecoverable by default on a guess.

# Rust / Go / Java build output
# Rust / Go / Java / C build output
target
build
out

# Python
.venv
.mypy_cache
.pytest_cache
venv

# JS/TS
dist
.next
.turbo
.output

# Test and coverage output
coverage
.nyc_output

# Vendored dependencies
vendor

# A build tool with a non-standard output directory: add it by name.
# Next.js with a custom distDir, for example.
# .next-dev

# a specific directory tree, by absolute path
# /home/you/big-scratch-area
32 changes: 30 additions & 2 deletions internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Session struct {
UndoneAt time.Time // when the undo happened, zero if not undone
Done bool // the command finished (done marker present)
Pid int // shell or runner pid, 0 for pre-lock sessions
Degraded string // why the shim stopped recording, empty if it did not
Entries []journal.Entry
}

Expand Down Expand Up @@ -80,6 +81,12 @@ func load(dir string) (*Session, error) {
if b, err := os.ReadFile(filepath.Join(dir, "pid")); err == nil {
s.Pid, _ = strconv.Atoi(strings.TrimSpace(string(b)))
}
// The shim writes this when it hits the free-space floor or the
// session budget. The session is still restorable, it is just not the
// whole command, so anything that shows a session has to say so.
if b, err := os.ReadFile(filepath.Join(dir, "degraded")); err == nil {
s.Degraded = strings.TrimSpace(string(b))
}
entries, err := journal.Read(filepath.Join(dir, "journal"))
if err != nil {
if os.IsNotExist(err) {
Expand All @@ -103,7 +110,9 @@ func List() ([]*Session, error) {
}
names := make([]string, 0, len(dirs))
for _, d := range dirs {
if d.IsDir() {
// Remove renames before it deletes; a leftover means a removal
// died partway and the tree is rubbish, not a session.
if d.IsDir() && !strings.HasSuffix(d.Name(), removingSuffix) {
names = append(names, d.Name())
}
}
Expand Down Expand Up @@ -286,9 +295,28 @@ func GC(keep int, maxBytes int64) (int, error) {
return removed, nil
}

// the name a session is parked under while it is being deleted
const removingSuffix = ".removing"

// Remove deletes a session and its backups entirely.
//
// The rename comes first because a session whose writer is still running
// recreates files as fast as RemoveAll unlinks them: the shim saves each
// backup into the directory being deleted, and RemoveAll gives up with
// "directory not empty" having already destroyed most of it. Renaming
// takes the whole tree out from under the shim in one step. The writer
// carries on appending to a path nobody will ever read, and the delete
// that follows races with nothing.
func (s *Session) Remove() error {
return os.RemoveAll(s.Dir)
doomed := s.Dir + removingSuffix
if err := os.RemoveAll(doomed); err != nil {
return os.RemoveAll(s.Dir)
}
if err := os.Rename(s.Dir, doomed); err != nil {
// already gone, or a filesystem that will not have it
return os.RemoveAll(s.Dir)
}
return os.RemoveAll(doomed)
}

// MarkUndone records that a session was reverted, and when.
Expand Down
11 changes: 10 additions & 1 deletion shell/undo.bash
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,16 @@ _undo_precmd() {
elif [[ -n ${_undo_saved_preload-} ]]; then
export LD_PRELOAD=$_undo_saved_preload
fi
: >| "$_undo_session/done"
# The shim gives up when it would otherwise fill the disk. Say so:
# precmd runs once per session, so this warns exactly once, and it is
# the only chance the user gets to hear about it.
if [[ -s $_undo_session/degraded ]]; then
printf 'undo: %s\n' "$(<"$_undo_session/degraded")" >&2
fi

# the store can be removed underneath a running command, by gc or by
# hand. A hook has no business erroring at the prompt when it is.
[[ -d $_undo_session ]] && : >| "$_undo_session/done" 2>/dev/null
unset _undo_saved_preload _undo_session

if command -v undo >/dev/null 2>&1; then
Expand Down
Loading
Loading