Small Rust CLI that finds files unused for N months, groups them by extension and name similarity, and optionally packs each group into a zip then removes the originals (move into archive).
Default mode is dry-run (report only). Destructive work requires --apply.
cargo install --path .
# or
cargo build --release
# binary: target/release/stale-archiveRelease builds use LTO, opt-level = "z", and strip for a small binary (typically a few MB on macOS arm64).
# Dry-run: list proposed groups and zip names
# On a TTY, stderr shows progress during scan, grouping, and apply; non-TTY/CI is silent.
stale-archive ~/Documents
# Zip under default <DIR>/.stale-archive and delete originals
stale-archive ~/Documents --apply --yes
# Custom threshold, output dir, exclusions
stale-archive ~/Downloads --months 12 --out /Volumes/Archive/stale --exclude '*.tmp' --apply --yes
# Shell completions
stale-archive --completions zsh > ~/.zfunc/_stale-archive
stale-archive --completions bash > ~/.local/share/bash-completion/completions/stale-archive| Flag | Description |
|---|---|
DIR |
Root directory to scan (tab-completes as a directory) |
--months <N> |
Stale threshold in months (default 6; month = 30 days) |
--out <DIR> |
Where to write zips (default <DIR>/.stale-archive) |
--min-group <N> |
Min files per named group; smaller clusters go to misc (default 2) |
--exclude <GLOB> |
Extra ignore globs (repeatable) |
--follow-symlinks |
Follow directory symlinks (file symlinks are always skipped) |
--max-depth <N> |
Limit recursion depth |
--apply |
Write zips and delete originals |
--yes |
Skip interactive confirm (required when stdin is not a TTY) |
--completions <shell> |
Print completion script (bash/zsh/fish/powershell/elvish) |
-v, --verbose |
More logging |
-h, --help / -V, --version |
Help / version |
Exit codes: 0 success, 1 hard error, 2 partial (some groups failed or deletes failed).
- macOS: usable Spotlight / Launch Services
kMDItemLastUsedDate(Finder “Date Last Opened”) when present; otherwise mtime. Filesystem atime is not used — Finder browsing and Spotlight scans bump access without a real open. - Other platforms: usable atime when present; otherwise mtime.
- Timestamps in the future are never treated as stale.
- Threshold:
N * 30 * 24 * 3600seconds (no calendar library).
“Usable” means seconds since Unix epoch > 0. Last-used can be unset (never opened / not indexed) — then mtime applies. --months 0 marks every file with a usable non-future timestamp as stale (handy for tests).
- Bucket by normalized extension (
jpeg→jpg,htm→html, none→_noext). - Within each extension:
- Normalize stem (lowercase, collapse
_/-/.to spaces). - Strip only trailing allowlist tokens:
final,copy,draft,wip,old,new,backup,bak,v[digits],(N),copyN— not bare years or integers. - Exact normalized-key groups with size ≥
--min-group. - Then first significant token (length ≥ 4).
- Then guarded longest-common-prefix (prefix ≥ 4, ≥ 50% of shorter, ≥ 60% of longer, boundary + versionish suffix checks — does not merge
data/databaseorreport/repost). - Remainder →
{ext}__miscwhenmin-group ≥ 2.
- Normalize stem (lowercase, collapse
Zip names: {ext}__{slug}__stale-{YYYYMMDD}.zip (UTC date).
- Dry-run by default.
--applywithout--yesprompts on a TTY; non-TTY requires--yes.- Per group: write
*.zip.partial→ finish → open-verify → rename → then delete originals. On zip failure the partial is removed and nothing is deleted for that group. - Zip entries are paths relative to the scan root with
/separators;.., absolute, and prefix components are rejected. - Always skips default denylist dirs, hidden directories,
.DS_Store/Thumbs.db, and the entire--outtree. - Symlink files are skipped; directory symlinks only with
--follow-symlinks.
.git, .hg, .svn, .stale-archive, node_modules, target, dist, build, .Trash, .trash, __pycache__, .cache, .venv, venv, .idea, .vscode, plus any other hidden directory.
cargo test
cargo build --release
ls -lh target/release/stale-archive