Put a folder on a shelf instead of deleting it.
shelf compresses a directory in place, verifies the archive contains every
file, then moves the original to the Trash. One command puts it back.
You already do this. Everyone does:
cp config.yml{,.bak} # keep a copy "just in case"
mv old-project archived/ # get it out of the way
mv plugin.so{,.disabled} # turn it off without losing itEach of them rots in its own way.
.bak files carry no date and no provenance β six months on you cannot tell
which is current, whether the original still exists, or if it was ever safe to
delete. They also stay uncompressed, so a "backup" of a big tree costs you its
full size forever.
archived/ becomes a junk drawer. It grows without bound, nothing in it is ever
looked at again, and it silently doubles the size of every backup you take.
.disabled prefixes clutter the listing they were supposed to clean up, and
they only work for things whose name nobody depends on.
All three share the same real problem: nothing verified that the copy was good, so nothing is safe to remove. So you never remove any of it.
shelf closes that loop:
shelf old-project # compress β verify β original to Trash
shelf # see what's shelved here
shelf old-project # put it back, exactly as it was
shelf trash old-project # once you're sureThe archive lands beside where the folder lived, named
.shelved-2026-08-14-old-project.aar β dot-prefixed, so ls shows only live
work while shelf shows what you put away.
brew install moonexpr/tap/shelfOr from source β requires macOS and the Command Line Tools
(xcode-select --install, no full Xcode needed):
git clone https://github.com/moonexpr/shelf.git
cd shelf
make install # swift build -c release, then into ~/.local/binNo third-party dependencies. One binary, ~8 ms startup. Verify an install with
shelf --selftest.
shelf list what's shelved here, then show this help
shelf <dir|file> shelve it: compress, verify, trash the original
shelf <archive> restore it from the shelf
shelf list [-r] list shelved items (-r walks subdirectories)
shelf add <target> ... shelve explicitly
shelf restore <name> ... restore explicitly
shelf trash <name> ... send the archive itself to the Trash
--format <name> archive format for this run
--force shelve even if verification reports differences
--selftest run the built-in checks
-h, --help this help
Bare shelf <thing> reads what the argument is: an archive restores,
anything else gets shelved. There is deliberately no remove β the ambiguity
between "take it off the shelf" and "throw it away" is exactly the confusion
that loses data, so the verbs are restore and trash.
It also restores tarballs you made yourself β .tar, .tar.gz, .tar.xz,
.tgz, .tar.bz2:
shelf old-backup.tar.gzThose are extracted but left in place. shelf didn't create them, so
disposing of them isn't its call.
It verifies before it trashes. After compressing, every path in the source tree is checked against the finished archive's own listing. If anything is missing, the original is left untouched and the archive is kept for inspection. The one unrecoverable outcome β a truncated archive whose source got trashed anyway β is the thing this tool is built to prevent.
Nothing is ever rm'd. Originals and archives both go to the macOS Trash,
with Finder put-back intact. The only files removed outright are half-written
archives shelf itself just created.
Ctrl-C is safe. Interrupt a shelve and the partial archive is removed; the original is untouched. Interrupt a restore and the partial extraction is removed; the archive is untouched. A failed extraction cleans up after itself too β a damaged tarball won't leave half a tree next to it.
Your archives don't depend on this tool. /usr/bin/aa ships with macOS and
reads them directly:
aa list -i .shelved-2026-08-14-old-project.aar
aa extract -i .shelved-2026-08-14-old-project.aar -d .Six, all reading and writing symlinks, permissions, ownership and timestamps:
| name | notes |
|---|---|
aar (default) |
Apple Archive + LZMA. Fastest. macOS only |
tar.xz |
best ratio of the portable formats |
tar.gz |
most universal |
tar.bz2 |
|
tar.zst |
fast, needs a recent tar to read elsewhere |
tar |
no compression |
Pick one for a single run, for your shell, or once and for all:
shelf add old-project --format tar.xz
SHELF_FORMAT=tar.xz shelf old-project
defaults write com.moonexpr.shelf format tar.xzIn that order of precedence. Restoring ignores the setting entirely β every archive is read by whatever engine its suffix names β so changing the format never strands what you have already shelved, and a shelf can hold a mix.
Anything unrecognised is an error listing the valid names, rather than a silent fall back to a default you didn't ask for.
Measured against tar -cJf on this machine:
| corpus | .aar |
.tar.xz |
ratio | time |
|---|---|---|---|---|
| 179 MB repo (git objects, binaries) | 180.3 MB | 178.5 MB | 1.01Γ | 4.9 s vs 67 s |
| 2.1 MB of pure source text | 575 kB | 557 kB | 1.03Γ | comparable |
Essentially identical size, and roughly 14Γ faster on large trees because
Apple Archive threads and xz does not.
The trade-off: .aar is macOS-only. A .tar.xz opens anywhere for the next
thirty years; an Apple Archive needs a Mac. If cross-platform archives matter
more to you than speed, this is the wrong tool β but note that reading them
never requires shelf itself, only macOS.
Two abstractions, on two axes β and the file layout is those two axes:
Sources/ShelfKit/
ArchiveEngine.swift AppleArchiveEngine.swift TarEngine.swift β axis 1: format
EntryVisitor.swift FileTree.swift β axis 2: traversal
CmdLine.swift FileSystem.swift Archive.swift SelfTest.swift
ArchiveEngineβ a facade over the archive format. Application logic never names a format or a suffix; adding one means writing one engine and one registry row.EntryVisitorβ a visitor over entries. Two different things are traversable, an archive and a directory on disk, and both accept the same visitors. Verification is therefore literally onePathCollectorrun over each and the sets subtracted, rather than bespoke string comparison.
ShelfKit exposes exactly one public symbol, ShelfCLI.run(); a three-line
executable calls it.
Tests live in the binary (shelf --selftest) rather than in a test target,
because XCTest and swift-testing both need frameworks that ship with Xcode β
swift test cannot run on a Command Line Tools install at all. Building the
checks in keeps them runnable everywhere and makes them a genuine post-install
verification.
MIT β see LICENSE.
