Age-based pruning for scratch and temp directories.
Reaper tracks when it first observed each file in a target folder, then removes files that have gone untouched beyond a configurable retention period. It uses a small SQLite database per folder rather than relying on filesystem timestamps, which are unreliable as age signals on Windows.
Windows filesystem timestamps are misleading in too many common situations to be trusted as age signals:
- Same-volume move — creation and modified timestamps are preserved; the file appears as old as its origin
- Cross-volume move — creation is updated but modified still reflects the original; age is ambiguous
- Archive extraction — modified typically reflects the timestamp stored inside the archive, not when it was extracted
- LastAccess — nominally disabled by default on modern Windows (
NtfsDisableLastAccessUpdate), but not reliably so in practice; AV scans, indexing, and even routine reads have been observed to update it anyway. Reaper does not use it as a signal. - Folder LastAccess — reading a file inside a folder does not reliably update the folder's own timestamp
Reaper sidesteps all of this. The first_seen timestamp in the database is set to now when a file is first observed, and reset to now if its creation or modified timestamp advances (indicating external modification). Everything else ages from when Reaper first noticed it.
On each execute run:
- Abort if the folder has not been initialised —
reap initmust be run first - Scan all files recursively (excluding
.reaper.db,.reaper.toml, anddesktop.iniat the root level) - Remove database entries for files that no longer exist
- For each file on disk:
- Not in database → record with
first_seen = nowandrefreshed_at = now; no deletion this run - In database, filesystem timestamps advanced or file size changed → reset
refreshed_at = now(first_seenis untouched — it's a permanent record of when Reaper first saw the file); no deletion this run - In database,
refreshed_atolder than the retention threshold → flag for removal
- Not in database → record with
- Folder atomicity pass — if any file in a subtree is retained, clear removal flags on all ancestors and their other contents
- Delete flagged files; remove any directories that are now empty
A single retained file protects its entire ancestor chain. You never end up with a half-deleted project folder:
Scratch/
ProjectFoo/ ← retained (Bar/ is retained)
Bar/ ← retained (baz.txt is retained)
baz.txt ← 2 days old → kept
old.log ← 30 days old → would be removed, but Bar/ is protected
other.txt ← 30 days old → would be removed, but ProjectFoo/ is protected
StaleStuff/ ← all contents expired → deleted
Download reap.exe and reap-silent.exe from the Releases page. Both are self-contained single-file executables targeting Windows x64 — no installer, no runtime dependency.
Place both files in the same folder on your PATH (e.g. C:\Tools\). They must live together: reap-silent.exe is a thin launcher used with Task Scheduler that delegates to reap.exe in the same directory.
reap init D:\Scratch\Temp
reap execute D:\Scratch\Temp
The first execute records every file with first_seen = now and deletes nothing. Files begin expiring after the retention period (default: 7 days).
Typical targets include personal scratch and temp folders, or anywhere files accumulate without a clear expiry:
reap init %USERPROFILE%\Temp # working files, short retention
reap init %USERPROFILE%\Scratch # project scratch space
reap init %USERPROFILE%\Downloads # auto-prune old downloads
Use reap-silent.exe rather than reap.exe when scheduling. It starts reap.exe without allocating a console window, so nothing flashes on screen during scheduled runs.
schtasks (one-liner):
schtasks /create /tn "Reaper - Temp" /tr "\"C:\Tools\reap-silent.exe\" execute \"D:\Scratch\Temp\"" /sc daily /st 03:00
PowerShell:
$action = New-ScheduledTaskAction -Execute 'C:\Tools\reap-silent.exe' -Argument 'execute "D:\Scratch\Temp"'
$trigger = New-ScheduledTaskTrigger -Daily -At '3:00AM'
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 1)
Register-ScheduledTask -TaskName 'Reaper - Temp' -Action $action -Trigger $trigger -Settings $settingsExit codes are the only signal when running under Task Scheduler — any non-zero exit indicates an error.
Print version and runtime info.
Initialise a folder for tracking. Creates .reaper.db and a default .reaper.toml in the target folder. Safe to run on an already-initialised folder (no-op, exits 0).
All other commands abort with an error if the folder has not been initialised.
Show a summary of the database state: entry count, oldest recorded file, and how many files would be pruned at the current threshold.
List exactly which files would be deleted, grouped by directory. Does not update the database — files with recently changed filesystem timestamps will not have their clocks reset until execute runs.
Preview reflects the current database state. New files and recently modified files are not yet recorded —
executemay retain more folders than shown here.
List every tracked entry with first_seen, refreshed_at, size, age, computed eligibility date, and current state (retained / protected by folder atomicity / will reap next run). Also shows any Task Scheduler task detected as targeting this folder, with its next/last run time — useful for confirming Reaper is actually scheduled to run before wondering why nothing's been pruned.
Perform the prune. Reconciles the database with the filesystem, resets clocks on touched files, then deletes anything that has aged out. Pass --dry-run to get preview output without making any changes.
Reset the aging clock (refreshed_at) to now for a specific file or directory within the database at <root>. first_seen is never modified — it stays a permanent record of when Reaper first saw the path. If <target> is a directory, all entries under it are reset. <target> may be an absolute path or relative to <root>.
Useful for protecting a folder from pruning without modifying any files inside it.
All commands that take <path>:
| Flag | Default | Description |
|---|---|---|
--days N / -d N |
7 | Retention threshold in days |
--config <file> |
<path>/.reaper.toml |
Explicit config file location |
execute only:
| Flag | Default | Description |
|---|---|---|
--dry-run |
false | Preview what would be deleted without making changes |
Each tracked folder may contain a .reaper.toml alongside its database. reap init creates one with defaults:
retention_days = 7
delete_empty_dirs = true
max_deletes_per_run = 0 # 0 = unlimitedCLI flags override config file values.
| Option | Type | Default | Description |
|---|---|---|---|
retention_days |
integer | 7 |
Days a file must go unmodified before it is eligible for removal |
delete_empty_dirs |
bool | true |
Remove directories that become empty after file deletions |
max_deletes_per_run |
integer | 0 |
Cap on file deletions per run; 0 means unlimited |
First run — the first execute on a freshly initialised folder records all files with first_seen = now and refreshed_at = now, and deletes nothing. Reaper needs at least one full retention period of observation before it removes anything.
first_seen vs refreshed_at — first_seen is set once and never changes again; it's a pure audit trail. refreshed_at is the actual aging clock: it starts equal to first_seen, and resets independently whenever an external touch is detected. reap list shows both, plus the computed eligibility date, so you can see e.g. a file first seen a month ago whose clock was refreshed last week — evidence something touched it, without losing the original history.
Symlinks — never followed. A symlink is tracked as an opaque file (it ages, it can be deleted) but Reaper never traverses into a symlinked directory or resolves the target. This is a hard invariant, not a configuration option.
Locked files — Reaper does not pre-check locks before attempting deletion (pre-checks are a race condition). If a deletion fails, the file is treated as retained and folder atomicity protects its ancestors. It will be retried on the next run once the lock is released.
Nested tracked folders — if a subfolder has its own .reaper.db, the outer Reaper instance tracks the inner .reaper.db as a regular file. When the inner execute runs, it updates the database file's modified timestamp; the outer instance detects this as a recent touch and resets the clock, protecting the entire inner subtree via folder atomicity. An abandoned inner database will naturally age out along with its folder.
Protected paths — Reaper refuses to operate on system directories. Drive roots and the following paths are blocked, along with everything underneath them: %WINDIR%, %APPDATA%, %LOCALAPPDATA%, %ProgramFiles%, %ProgramFiles(x86)%, and %ProgramData%. %USERPROFILE% itself is also blocked, but its subdirectories are not — %USERPROFILE%\Temp, %USERPROFILE%\Scratch, %USERPROFILE%\Downloads, and similar are the primary intended use cases.
Replacing files with older versions — Reaper also compares file size, which catches most cases where a file is overwritten with a version whose timestamps are preserved from elsewhere (e.g. extracted from an archive) but whose byte size differs. It won't catch a same-size in-place edit with preserved timestamps — a narrow remaining gap. Full content hashing would close it but requires reading every tracked file on every run, which trades away the cheap metadata-only scan for a real, ongoing I/O cost; not worth it for a scratch-folder pruning tool.
Inspecting the database — .reaper.db is a standard unencrypted SQLite file. You can open it with DB Browser for SQLite or query it with the sqlite3 command-line tool.
Requires .NET 10 SDK.
dotnet build
dotnet test
dotnet publish Reaper -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true
dotnet publish Reaper.Silent -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true
Published binaries appear at Reaper\bin\Release\net10.0\win-x64\publish\reap.exe and Reaper.Silent\bin\Release\net10.0\win-x64\publish\reap-silent.exe.
MIT — see LICENSE.