Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

can

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 expires

Install

brew install zmscode/can/can

Or 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.ghostty

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

Usage

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.

Flags

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.

Config

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

Design notes

  • 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, matching rm.
  • Destination names are reserved atomically, and by type. Before moving, can claims its slot in the trash — mkdir for a directory, O_CREAT|O_EXCL otherwise — 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 --undo skips; 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 sidecar journal.tsv.lock — locking the journal itself wouldn't survive the atomic rename that replaces it. It is created 0600: 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 to copyfile(3) with COPYFILE_ALL | RECURSIVE | NOFOLLOW, which preserves xattrs, ACLs, resource forks and timestamps (or refuses, if cross-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, so can -u is the undo path. If you want native Put Back too, move.move is the single function to swap — call the Objective-C runtime against NSFileManager and link -framework Foundation.
  • Purging is lazy by default (a sweep runs after each trash operation). For a scheduled sweep instead, set sweep-on-run = false and install a launchd agent — see Scheduled sweeps.

Guardrails

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.

Scheduled sweeps

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

Use --sweep, not --empty: --empty destroys everything immediately and would need -y to run unattended.

Source layout

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

Zig version

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.Filestd.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 test

covers duration parsing, journal escaping, the component-aware path check, and the symlink no-follow behaviour.

License

MIT — see LICENSE.

About

Move files to the macOS Trash instead of deleting them, with undo and a retention period. Written in Zig.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages