A rm replacement for macOS that moves paths to the Trash, keeps an undo
journal, and permanently purges them after a configurable retention period.
can notes.txt src/ # moved to ~/.Trash, recoverable
can -u # put them back
can -l # what's in there, and when it expiresbrew install zmscode/can/canOr from source — this needs Zig 0.17-dev, not 0.16 (see Zig version):
zig build --release=fast --prefix ~/.local
mkdir -p ~/.config/can
cp config.ghostty ~/.config/can/config.ghosttymacOS only: the default trash directory is ~/.Trash, and the cross-volume
path uses copyfile(3).
Before installing, confirm the name is free on your machine:
command -v can || echo "can is free"Note that /usr/bin/cancel (CUPS) exists, so can<TAB> won't complete
straight through.
can [options] <path>... move paths to the trash
can -u | --undo [batch] restore the most recent batch (or a given batch id)
can -l | --list [n] show recent trashed items (default 20)
can --sweep purge entries past the retention window
can --empty [-y] purge everything now (prompts; -y to skip)
can --config print the resolved configuration
Each invocation is one batch. can a.txt b.txt then can -u restores both.
rm-compatible, including clustered short flags so can -rf build/ works:
| flag | effect |
|---|---|
-f, --force |
ignore missing paths, never prompt, exit 0 |
-i |
prompt before each path |
-I |
prompt once when given more than 3 paths |
-r, -R, -d, -x, -W |
accepted, no effect (directories always work) |
-v, --verbose |
print each path as it is trashed |
-P |
accepted, but warns — nothing is overwritten |
can-specific:
| flag | effect |
|---|---|
-n, --dry-run |
show what would happen, change nothing |
-y, --yes |
skip confirmation prompts |
-F, --force-protect |
ignore the config protect list (built-in system paths still refused) |
-f and -i follow rm's last-flag-wins rule, so can -fi prompts and
can -if does not.
-P deserves a note: in rm it overwrites the file before unlinking. Here
nothing is erased at all until the retention window expires, so accepting it
silently would be a lie. It warns on stderr. Use real rm -P if you need it.
can --help is colourised when stdout is a terminal. Piping or redirecting it
turns colour off, as does NO_COLOR=1 or TERM=dumb; CLICOLOR_FORCE=1 turns
it back on for cases like can --help | less -R.
Prompts go to stderr, as rm's do, so can -i * > log still shows the
question on the terminal. With no terminal attached, a prompt counts as "no" —
which is why --empty needs -y in a script or a launchd agent.
Read from the first of:
$CAN_CONFIG
~/.config/.can/config.ghostty
~/.config/.can/config
~/.config/can/config.ghostty
~/.config/can/config
On first run, if none of these exist, can writes an empty
~/.config/can/config.ghostty and says so on stderr. The file contains only
comments: every setting has a built-in default, so an empty file behaves
exactly like no file at all — it is there to give you somewhere obvious to
type. Delete it and it comes back; that costs nothing.
If $CAN_CONFIG is set but points at a file that does not exist, can says so
and uses defaults rather than creating a file at a path a script chose.
Ghostty-style key = value lines, # for comments, protect may repeat. The
.ghostty suffix is not a different format — it is there so editors reach for
ghostty's syntax highlighting. See config.ghostty for every
key and what it means.
| key | default | meaning |
|---|---|---|
retention |
30d |
how long items stay recoverable; s/m/h/d/w suffixes, a bare number means days, never disables purging |
trash-dir |
~/.Trash |
where trashed items live |
journal-path |
~/.local/state/can/journal.tsv |
the undo journal |
confirm-threshold |
0 |
prompt before trashing this many paths at once; 0 disables |
sweep-on-run |
true |
purge expired items after every invocation |
cross-device |
copy |
copy across volumes, or skip to refuse |
verbose |
false |
print each action |
protect |
— | extra paths that must never be trashed; repeatable, covers descendants |
- Symlinks are moved, not followed. The final path component is never
resolved (
path.absNoFollow), so trashing a link moves the link and leaves its target alone, matchingrm. - Destination names are reserved atomically, and by type. Before moving,
canclaims its slot in the trash —mkdirfor a directory,O_CREAT|O_EXCLotherwise — then renames over its own placeholder. The type matters:rename(2)refuses to put a directory onto a regular file (ENOTDIR), so a one-size placeholder would make directories untrashable. Symlinks reserve as files even when they point at a directory. - The journal is written before the move. A record pointing at a file that
never arrived is harmless noise that
--undoskips; a file in the trash with no record is unrecoverable. Failed moves roll the record back. - The journal is
flocked for every read-modify-write, via a sidecarjournal.tsv.lock— locking the journal itself wouldn't survive the atomic rename that replaces it. It is created0600: it is a complete record of every path you have deleted. - Moves are
rename(2)when possible — instant, atomic, no copying. Across volumes it falls back tocopyfile(3)withCOPYFILE_ALL | RECURSIVE | NOFOLLOW, which preserves xattrs, ACLs, resource forks and timestamps (or refuses, ifcross-device = skip). - Finder "Put Back" will be greyed out. That metadata is written by
Foundation's
-[NSFileManager trashItemAtURL:resultingItemURL:error:], not by a plain move, socan -uis the undo path. If you want native Put Back too,move.moveis the single function to swap — call the Objective-C runtime againstNSFileManagerand link-framework Foundation. - Purging is lazy by default (a sweep runs after each trash operation).
For a scheduled sweep instead, set
sweep-on-run = falseand install a launchd agent — see Scheduled sweeps.
Two tiers. Refused along with everything inside them:
/System /usr /bin /sbin /dev /Library /etc /var
/private/etc /private/var
Refused only as exact paths, so their contents are fair game:
/ $HOME /Users /Volumes /Applications /opt /tmp /private /private/tmp
The /private forms matter because macOS makes /etc, /var and /tmp
symlinks into /private, and can resolves a path's parent before checking it
— can /etc/hosts arrives at the guardrail as /private/etc/hosts. /private
itself is only exact-protected, because tree-protecting it would drag
/private/tmp along and make all of /tmp untrashable.
Config protect entries cover descendants, so protect = ~/src guards
everything under it. Matching is component-aware, so ~/.Trashy is not mistaken
for ~/.Trash. -F bypasses your protect list but never the built-ins — use
rm for system paths.
Set sweep-on-run = false, then install
~/Library/LaunchAgents/local.can.sweep.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>local.can.sweep</string>
<key>ProgramArguments</key>
<array><string>/opt/homebrew/bin/can</string><string>--sweep</string></array>
<key>StartInterval</key><integer>3600</integer>
</dict></plist>launchctl load ~/Library/LaunchAgents/local.can.sweep.plistUse --sweep, not --empty: --empty destroys everything immediately and
would need -y to run unattended.
| file | responsibility |
|---|---|
src/main.zig |
argument parsing and dispatch |
src/trash.zig |
trash / list / undo / sweep |
src/Config.zig |
defaults, file discovery, parsing |
src/journal.zig |
Record, Lock, TSV escaping, read/write |
src/move.zig |
name reservation, rename/copyfile |
src/guard.zig |
the two protection tiers |
src/path.zig |
realpath, no-follow abs, containment |
src/usage.zig |
the --help text, coloured and plain |
src/style.zig |
ANSI palette, and when to use it |
src/cli.zig |
flag state, prompting |
src/duration.zig |
duration parse/format |
src/sys.zig |
libc bindings and their constants |
Requires Zig 0.17-dev. It will not build on 0.16 — build.zig checks and
says so. The 0.17 std moved all filesystem I/O behind an explicit Io instance
(std.fs.File → std.Io.File, every call taking io), replaced b.args with
Run.addPassthruArgs, gave main a std.process.Init parameter, and removed
Dir.realpathAlloc, File.seekFromEnd and std.process.getEnvVarOwned — the
last three are covered here by direct libc bindings in src/sys.zig.
zig build testcovers duration parsing, journal escaping, the component-aware path check, and the symlink no-follow behaviour.
MIT — see LICENSE.