A small, safe, data-driven utility that keeps messy directories tidy. You
describe what goes where in a YAML file; fileorg computes a plan and (only
when you let it) carries it out. It does three things: organizes files into
folders by extension, name, age, or size; finds duplicate files by content
and quarantines the redundant copies; and takes incremental backups into
timestamped snapshots. It is built to run unattended on a schedule with a full
audit trail, and to be boring and predictable: it never overwrites, never
deletes, and shows you exactly what it will do before it does anything. (The one
exception: backup prunes its own old snapshots — never your originals.)
$ fileorg organize --config ~/.fileorg/config.yaml --dry-run
2026-06-15 21:31:29 INFO === fileorg run: mode=organize dry_run=True ===
2026-06-15 21:31:29 INFO organize: 2 move(s) planned.
2026-06-15 21:31:29 INFO DRY-RUN move ~/Downloads/book.pdf -> ~/Documents/Books/book.pdf (ext=pdf)
2026-06-15 21:31:29 INFO DRY-RUN move ~/Downloads/photo.jpg -> ~/Pictures/Downloads/photo.jpg (ext=jpg/png)
Requires Python 3.10+.
# 1. Install (editable, so the `fileorg` command tracks your checkout)
pip install -e .
# 2. Copy the example config and edit it for your machine
mkdir -p ~/.fileorg
cp config.example.yaml ~/.fileorg/config.yaml
# 3. Always look before you leap — dry-run prints the plan, touches nothing
fileorg organize --config ~/.fileorg/config.yaml --dry-run
# 4. Happy with the plan? Run it for real
fileorg organize --config ~/.fileorg/config.yaml--config defaults to ~/.fileorg/config.yaml, so once it's in place you can
just run fileorg organize.
Behavior lives entirely in the config file — changing rules never means
editing Python. See config.example.yaml for a
complete, commented example.
organize:
- source: ~/Downloads
rules:
- match: { extension: [pdf, epub] }
dest: ~/Documents/Books
- match: { name_pattern: "invoice_*" }
dest: ~/Documents/Invoices
- match: { older_than_days: 90 }
dest: ~/Downloads/Archive
dedup:
- path: ~/Pictures
action: flag # flag | report-only (default: report-only)
backup:
- source: ~/Documents/Projects
dest: ~/Backups/Projects
keep_snapshots: 7 # default: 7
logging:
file: ~/.fileorg/fileorg.log
level: INFO
max_bytes: 1048576
backup_count: 5A rule fires when every key in its match block matches (logical AND).
| Match key | Matches when… |
|---|---|
extension |
file extension is in the list (case-insensitive) |
name_pattern |
filename matches the glob (e.g. invoice_*) |
older_than_days |
file's mtime is at least N days ago |
min_size_mb |
file is at least this many MB |
max_size_mb |
file is at most this many MB |
- Rules are evaluated in order; the first match wins. Put your most specific rules first — this order-dependence is the one footgun to keep in mind.
destdirectories are created automatically if missing.- Only the top level of each
sourceis scanned; subfolders are left alone (which is also what keeps repeat runs idempotent). ~and$VARSare expanded in every path.
Invalid config fails fast with a located message, e.g.
organize[0].rules[2]: unknown match key 'flavour'.
Since all behavior lives in the config file, fileorg ships small helpers for
working with it. None of them rewrite the YAML — your comments and key order are
left intact:
fileorg config edit # open in $VISUAL/$EDITOR, then re-validate on save
fileorg config validate # load + validate; print a per-section summary (or the error)
fileorg config path # print the resolved config path (~ and $VARS expanded)config edit is the easiest way to change a destination or add a rule: it's a
plain text edit that re-checks the file the moment you save, so a typo surfaces
right then instead of at the next scheduled run. config validate does the same
check on demand (exit 0 if valid, 1 if not) — handy in a pre-flight script.
fileorg dedup walks each dedup path recursively and groups files by
content (SHA-256, with a size + first/last-chunk pre-filter so large sets stay
cheap). Within each group the oldest file is kept as the canonical original.
fileorg dedup --config ~/.fileorg/config.yaml --dry-run # look first
fileorg dedup --config ~/.fileorg/config.yaml # then actaction: report-only(default) just logs the duplicate groups.action: flagmoves the redundant copies into a_duplicates/folder at the root of that path (preserving subpaths, never overwriting). The original stays put, and nothing is ever deleted. The_duplicates/folder is skipped on later runs, so dedup is idempotent.
fileorg backup walks each backup source recursively and writes a
timestamped snapshot under its dest — a full, self-contained mirror plus a
.fileorg-manifest.json of per-file size/mtime/sha256.
fileorg backup --config ~/.fileorg/config.yaml --dry-run # look first
fileorg backup --config ~/.fileorg/config.yaml # then snapshot- Incremental reads. A file is re-read from the source only when it's new or
its
mtime/sizechanged; unchanged files are carried forward from the previous snapshot (hash reused), so nothing on the source is re-read needlessly. - Idempotent. If nothing changed, no new snapshot is written.
- Pruned. Snapshots beyond
keep_snapshots(default 7) are removed. This is the only deletionfileorgperforms, and it only ever touches its own snapshot folders — never your source files.
Trade-off: each snapshot is a full copy rather than a hardlink tree, so an unchanged file uses disk in every snapshot it survives into. We chose this for v1 because each snapshot stands alone — pruning one can never corrupt another.
This tool moves your files unattended, so it is conservative by design:
- Dry-run everywhere.
--dry-runcomputes and logs the full plan without touching a single file. It's the recommended first step every time. - Never overwrites. A move into an occupied name becomes
name (1).ext,name (2).ext, … — the existing file is never clobbered. - Never deletes your files. Organize moves, dedup at most quarantines, and
backup only reads from sources. The sole removal is backup pruning its own
snapshots beyond
keep_snapshots— nothing under a source is ever deleted. - Idempotent. Running twice in a row is safe: the second run sees nothing to do (and backup writes no duplicate snapshot).
- Observable. Every action is written to a rotating log file.
fileorg is a plain CLI, so let the OS schedule it — more robust than a
long-running Python daemon. Point the scheduler at a single fileorg run --once,
which performs organize → dedup → backup in one pass with one aggregated exit
code.
Ready-to-copy templates live in scheduling/, one per platform,
with a step-by-step guide in scheduling/README.md:
- macOS — launchd:
scheduling/macos/com.user.fileorg.plist - Linux — systemd timer:
scheduling/linux/fileorg.service+scheduling/linux/fileorg.timer - Linux — cron:
scheduling/linux/fileorg.cron—0 9 * * * fileorg run --once --config ~/.fileorg/config.yaml - Windows — Task Scheduler:
scheduling/windows/fileorg-task.xml
Each template runs run --once daily. To run organize / dedup / backup on
separate cadences, copy a template and swap the command — see the
"Different cadences" note in the guide.
pip install -e ".[dev]" # installs pytest
pytest # run the suiteTests use tmp_path fixtures and never touch real directories. The focus is
the engine — rule matching, collision handling, dry-run, and idempotency —
rather than glue code.
The backbone of the whole tool is a plan → execute split:
- Plan — a pure function inspects the filesystem and returns a list of
intended operations (
Move(src, dest, rule)), touching nothing. - Execute — the only side-effecting half carries the plan out (unless
--dry-run), logging each step through a singlesafe_movehelper.
This one decision is what makes dry-run honest, idempotency automatic, and the logic trivial to unit-test.
fileorg/
├── config.py # YAML -> typed dataclasses, strict fail-fast validation
├── organizer.py # rule matching (plan) + moves (execute)
├── dedup.py # content hashing (plan) + flag/report (execute)
├── backup.py # snapshot diffing (plan) + copy/prune (execute)
├── utils.py # safe_move / safe_copy — never overwrite
├── logsetup.py # rotating logger with a per-run header line
└── cli.py # argument parsing + command dispatch