Spruce up your files. Scan, sort, dedupe, and bulk-rename — from the command line.
███████╗██████╗ ██████╗ ██╗ ██╗ ██████╗███████╗
██╔════╝██╔══██╗██╔══██╗██║ ██║██╔════╝██╔════╝
███████╗██████╔╝██████╔╝██║ ██║██║ █████╗
╚════██║██╔═══╝ ██╔══██╗██║ ██║██║ ██╔══╝
███████║██║ ██║ ██║╚██████╔╝╚██████╗███████╗
╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝
spruce is a single, zero-dependency command-line toolkit that brings order to messy directories. It scans, sorts, dedupes, and renames files — safely.
| Command | What it does |
|---|---|
spruce scan [PATH…] |
Analyze directories: totals, sizes by category, largest files, duplicate hints. --json for machines. |
spruce tidy [PATH…] |
Sort files into folders — by category, extension, or date (2024/03/15). |
spruce dedupe [PATH…] |
Find byte-identical files (SHA-256) and trash/delete all but one copy. |
spruce rename [PATH…] |
Bulk-rename through a composable transform pipeline. |
spruce config |
Inspect or initialize the JSON config file. |
spruce completions |
Generate bash/zsh/fish tab-completion scripts. |
spruce logo |
Print the logo. Obviously. |
- Zero dependencies — pure Python standard library. Works on Python 3.9+,
Linux, macOS, and Windows.
pip install .and go. - Safe by default — every mutating command supports
--dry-run, asks before touching anything, never overwrites a file (conflicts becomename_1.ext), and sends duplicates to a local.spruce-trash/instead of straight to the void. - Never guesses — non-interactive without
--yes? It refuses rather than silently destroying anything. - Junk-aware —
.git,node_modules, caches, hidden files and other noise are skipped automatically;--alloverrides,--excludeextends. - Composable —
spruce scan --json | jq '.categories'works out of the box. - Deterministic — same input, same output, always.
git clone https://github.com/sunji-droid/Spruce.git
cd Spruce
pip install .
spruce --versionOr run straight from the checkout without installing:
PYTHONPATH=src python -m spruce --helpRun bash examples/demo.sh to watch this yourself (on a throwaway temp
folder — safe anywhere). Here's the actual output, with paths trimmed:
$ spruce scan ~/demo
Scan ~/demo
12 files · 136.8 KB
By category
Category Files Size
--------- ----- -------
code 2 20 B
data 1 7 B
documents 5 70 B
images 2 58.6 KB
other 1 5 B
videos 1 78.1 KB
Largest files (top 10)
Path Size
--------------------------------------- -------
~/demo/downloads/clip.mp4 78.1 KB
~/demo/photo_1.jpg 29.3 KB
~/demo/photo_1_copy.jpg 29.3 KB
~/demo/documents/report.txt 16 B
~/demo/backup/scan_001.pdf 14 B
~/demo/downloads/scan_001.pdf 14 B
~/demo/notes.txt 14 B
~/demo/hello world.txt 12 B
~/demo/script copy.py 10 B
~/demo/script.py 10 B
Duplicate hints (same size)
3 groups · 7 files · ~29.3 KB reclaimable — run `spruce dedupe` to confirm by content
$ spruce dedupe ~/demo --dry-run
Dedupe
[1] scan_001.pdf — 14 B each
keep ~/demo/backup/scan_001.pdf (newest)
remove ~/demo/downloads/scan_001.pdf -> ~/demo/.spruce-trash/2026-08-06_193912/scan_001.pdf
[2] photo_1.jpg — 29.3 KB each
keep ~/demo/photo_1_copy.jpg (newest)
remove ~/demo/photo_1.jpg -> ~/demo/.spruce-trash/2026-08-06_193912/photo_1.jpg
[3] script copy.py — 10 B each
keep ~/demo/script copy.py (newest)
remove ~/demo/script.py -> ~/demo/.spruce-trash/2026-08-06_193912/script.py
3 duplicates would be removed · 29.3 KB freed (dry run — nothing changed)
$ spruce tidy ~/demo --strategy category --yes
Tidy ~/demo — category layout
code/ script copy.py
data/ data.json
documents/ scan_001.pdf, hello world.txt, notes.txt
images/ photo_1_copy.jpg
other/ junk.tmp
videos/ clip.mp4
8 files moved
$ spruce rename ~/demo --recursive --lower --spaces-to-underscores --yes
Rename
script copy.py → script_copy.py
hello world.txt → hello_world.txt
2 files renamed
$ spruce scan ~/demo --json | jq '.total_files, .categories | keys'
9
["code", "data", "documents", "images", "other", "videos"]
(Output trimmed to fit; bash examples/demo.sh shows every section, including
a JSON dump and both dry-run + real passes.)
Analyze without changing anything.
spruce scan ~/Downloads
spruce scan . --json | jq '.largest[0]'
spruce scan ~/Pictures ~/Documents --top 5
--json— emit pure JSON on stdout (human output suppressed), so it pipes cleanly intojqand friends.--top N— how many largest files to list (default 10).--all— include hidden files.--exclude GLOB— skip extra globs.
Sort files into bucket folders. Subdirectories are flattened into the buckets; files already in place are left alone.
spruce tidy ~/Downloads --strategy category # images/, documents/, …
spruce tidy ~/Desktop --strategy date --dry-run # 2024/03/…, preview first
spruce tidy ~/Downloads ~/Desktop --strategy ext --target ~/Sorted
-s, --strategy {category,ext,date}— bucket layout (default:category, overridable in config). Date buckets areYYYY,YYYY/MM, orYYYY/MM/DDvia--date-level {day,month,year}.--target DIR— consolidate everything underDIRinstead of each input dir.--copy— copy instead of move.--dry-run,-y/--yes,--all,--exclude.
Find byte-identical files by content hash (only same-sized files are hashed, so it's fast on unique folders) and remove all but one copy.
spruce dedupe ~/Pictures --dry-run
spruce dedupe ~/Pictures --keep largest --yes # keep the biggest copy
spruce dedupe ~/Downloads --delete --yes # skip the trash
--keep {newest,oldest,first,largest,smallest}— which copy survives (default:newest; ties break by path, deterministically).- Duplicates move to
<root>/.spruce-trash/<timestamp>/by default.--deleteremoves them permanently instead;--trash-dir DIRtrashes them into a custom location.
Bulk-rename via a fixed, documented transform pipeline:
strip → case → separator → prefix → numbering → date prefix → suffix.
spruce rename *.jpg --lower --spaces-to-underscores
spruce rename ~/Downloads --recursive --strip 'IMG_\d{8}_'
spruce rename ~/Photos --date-prefix --numbered --dry-run
spruce rename ~/Desktop --prefix 'backup-' --suffix '.old'
--lower | --upper | --title— case transforms on the stem.--spaces-to-underscores | --spaces-to-dashes— separator cleanup.--strip REGEX— remove regex matches.--prefix TEXT,--suffix TEXT— wrap the name.--date-prefix— prefix withYYYY-MM-DD(from file mtime).--numbered [START]— prefix with001_,002_, …;--pad Nfor width.-r, --recursive— include files in subdirectories.
spruce config path # ~/.config/spruce/config.json
spruce config show # effective config (defaults + user file)
spruce config init # write a config file with defaults
Config is JSON, deep-merged over built-ins. You can override the category mapping, default strategies, keep rules, and exclusion globs:
{
"tidy": { "strategy": "date", "date_level": "month" },
"dedupe": { "keep": "largest" },
"rename": { "pad": 4 },
"exclude": { "dirs": ["secrets"], "files": ["*.tmp"] },
"categories": { "my-special-type": ["zzz"] }
}Set $SPRUCE_CONFIG or pass --config PATH to point at a custom file.
Drop a .spruceignore file in any directory to tell spruce what to skip
(works with scan, tidy, dedupe, and rename; disable with
--no-ignore):
# junk
*.log
build/
cache/
!important.logRules apply to that directory and everything below it: basename patterns
match at any depth, dir/ matches directories only, /anchored and a/b
are relative to the ignore file, ! re-includes (last match wins), and
** spans any depth. Nested .spruceignore files stack on top of their
parents.
spruce completions bash # → source this / put in /etc/bash_completion.d/
spruce completions zsh # → ~/.zshrc or a compdef-enabled location
spruce completions fish # → ~/.config/fish/completions/spruce.fishScripts are generated by introspecting the argument parser, so they always
match the installed CLI. (On macOS zsh, run compinit once for compdef.)
- Safety is structural. Mutating commands print a preview, then require a
confirmation prompt — or
--yesif non-interactive.--dry-runalways available. Files are never overwritten; name collisions get_1,_2… - Efficiency matters. Dedupe only hashes files that already share a size. Walking prunes excluded and hidden directories before descending.
- Honest output. Scan's duplicate hints are labeled as size-only hints;
only
dedupeconfirms by content. Exit codes:0success (incl. dry runs),1spruce error,2usage error. - Colors follow the terminal (disabled when piped or when
NO_COLORis set);--no-colorforces it off.
spruce/
├── .github/workflows/ci.yml # lint + test matrix (3.9–3.13, 3 OSes)
├── examples/demo.sh # self-demo: messy folder → spotless
├── src/spruce/
│ ├── cli.py # argument parsing, dispatch, presentation
│ ├── tidy.py # sort-into-buckets engine
│ ├── dedupe.py # content-hash duplicate removal
│ ├── rename.py # transform pipeline
│ ├── scanner.py # file walking + hashing
│ ├── categories.py # extension → category mapping
│ ├── config.py # defaults + JSON deep-merge
│ ├── paths.py # junk-aware walking + conflict claims
│ └── ui.py # colors, tables, human sizes
└── tests/ # 76 tests, 94% coverage
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
make test # pytest with coverage
make lint # ruff check
make format # ruff format
make demo # watch it workSee CONTRIBUTING.md for how to add commands and keep the bar high.
Want to preview renames in your browser before running them? Try Spruce Lab — a zero-dependency, offline web tool that mirrors the exact same transform pipeline and collision rules, with live before/after diffs. Live at sunji-droid.github.io/spruce-lab.
Kabo Merapelo Onamile — built with ☕ and an unreasonable love of tidy folders. You can reach them at mkonamile@gmail.com.
